#!/usr/bin/env bash
# ============================================================================
# check_readme_claims.sh · OTO Enterprise OS DTP
# ----------------------------------------------------------------------------
# Gate d'INTÉGRITÉ des chiffres du README (Sprint 8 buffer · CLAUDE.md #6 —
# « Zéro invention de chiffres · toujours vérifier sources archives »).
#
# Le README.md est le POINT D'ENTRÉE du mandat. Sa section « État courant
# (sourcé) » affiche des chiffres et déclare, mot pour mot :
#   « Chaque chiffre ci-dessous est sourcé vers un artefact commité
#     (anti-invention #6) ; ce README n'introduit aucune donnée nouvelle. »
# Cette promesse n'était garantie par AUCUN gate. `check_docs.sh` ne valide que
# les liens (cible existe) — pas la VALEUR des nombres. Résultat : quand un
# module + son job CI sont ajoutés (21→22 modules, 21→22 suites) ou qu'une
# promesse change de statut (14 in_repo + 1 out_of_scope → 15 in_repo), les
# chiffres du README se PÉRIMENT en silence tout en restant « sourcés » vers un
# artefact qui, lui, dit autre chose. Un README qui se contredit avec sa propre
# source est un « vert trompeur » de la même classe que la matrice périmée
# (« demo 18→21 ») ou l'orphan_tests_dirs (INV4) — appliqué à la doc d'entrée.
#
# Ce gate RECOMPUTE chaque chiffre depuis l'artefact cité (jamais une liste à la
# main · #6) et exige l'égalité avec ce qui est ÉCRIT dans les docs d'entrée :
#   README.md :
#   • Qualité 4Big  N/M modules gated à K/100, verdict  → qa/audit_4big totals
#   • Régression    N suites gated                        → qa/regression plan
#   • Recette       N promesses (X sprint + Y métriques), Z in_repo, verdict
#                                                          → qa/acceptance matrix
#   • Agents        « 13 agents » (×2 : nav + titre)      → 03_agents/*/AGENT.md
#   03_agents/qa/AGENT.md (fiche QA · « Verdict agrégé courant ») :
#   • N suites · M tests · M passés · E échec · E erreur · verdict
#                                                          → qa/regression run
#   03_agents/erpnext_backend/AGENT.md (« source unique ») :
#   • N tests · M suites · verdict (matrice de régression du repo)
#                                                          → qa/regression run
#   05_deliverables_mvp/qa/audit_4big/README.md (« Résultat courant ») :
#   • N/M modules à K/100, verdict (l'auditeur compte les modules mais figeait
#     son PROPRE total à la main : 17→22)                    → qa/audit_4big totals
#   03_agents/*/AGENT.md (tables de livrables · colonne « Tests ») :
#   • chaque cellule « Tests » par suite gatée              → plan.suites[path]
#     (+ count_tests pour le self_module qa/regression, exclu de la matrice)
#   03_agents/{crm,erpnext_backend}/AGENT.md (agrégats EN PROSE) :
#   • « Total CRM N (a+b+c) » · « RBAC N (…) » · « e-CF N » — total ET multiset
#     des composants recomputés (une compensation entre suites laisserait la
#     table juste et la prose fausse)                        → plan.suites[path]
#   frontend/portails/README.md + 03_agents/frontend_console/AGENT.md :
#   • comptes PAR WORKSPACE (cartes / liens / rôles) transcrits À LA MAIN dans
#     DEUX docs (table du README + prose « OTO Ventes 4 cartes/11 liens/12
#     rôles ») + total « N Workspace natifs »               → portails MANIFEST
#     (tableau `workspaces`, byte-gaté par check_artifacts)
#   seo/README.md + 03_agents/seo/AGENT.md (comptes de mots-clés) :
#   • total + ventilation « 258 mots-clés (fr=87 · en=87 · es=84) » transcrits À
#     LA MAIN dans DEUX docs                                 → seo MANIFEST.counts
#     (keywords_total + keywords_per_lang, recomptés de projets_master, byte-gatés)
#   crm/workflow_vente/README.md + crm/dossier_vente/README.md (pipeline vente) :
#   • « N états · M transitions » (titre du README workflow) + « N états du
#     pipeline » (README DocType porteur, même compte dérivé du même graphe)
#     transcrits À LA MAIN dans DEUX docs                    → workflow_vente
#     MANIFEST.counts (states + transitions, recomptés du spec pipeline, byte-gatés)
#   rbac/{roleprofile_gen,apply_plan,userperm_gen}/README.md (comptes 3 volets) :
#   • table « Nb rôles » PAR profil + total bijectif        → roleprofile MANIFEST
#   • ligne agrégée « 6 étapes · 50 rôles / 116 DocPerm / 28 UP / 6 Role
#     Profile · 4 DocType custom + 5 Company + 4 rôles »     → apply_plan MANIFEST
#   • ventilation « 28 entite · 16 groupe · 2 own · 4 equipe » → userperm MANIFEST
#     (les 3 MANIFEST recalculés du contrat rbac_50_roles.json, byte-gatés)
#   mobile/app_config/README.md (ligne récap de l'app Expo) :
#   • quadruplet « 5 onglets · 44 rôles couverts · 3 langues · 13 identifiants
#     a_confirmer » transcrit À LA MAIN                       → app_config
#     MANIFEST.counts (onglets/roles_couverts/langues/identifiants_a_confirmer,
#     recomptés de rbac_50_roles + seo_spec + a_confirmer, byte-gatés)
#   legal/confotur/README.md + 03_agents/onapi_legal/AGENT.md (DocType CONFOTUR) :
#   • quintuplet « 18 champs (14 de donnée) · 4 sections · 3 rôles · 2 évènements
#     de dépôt » transcrit À LA MAIN dans DEUX docs           → confotur
#     MANIFEST.counts (fields/data_fields/sections/roles/depot_events, recomptés
#     de confotur_spec.json, byte-gatés)
#   frontend/chat_otoia/README.md + 03_agents/frontend_console/AGENT.md (montage
#   du Chat OTOIA par portail) :
#   • triplet « N `Custom Block` · M configs runtime · P portails » (tous = 5
#     aujourd'hui — piège aveugle) transcrit À LA MAIN dans DEUX docs → chat_otoia
#     MANIFEST.counts (custom_blocks/mounts/portails, recomptés du contrat RBAC,
#     byte-gatés) + cohérence croisée == |MANIFEST.portails| (bijection 1 block ⇔
#     1 mount ⇔ 1 portail)
#   qa/acceptance/README.md + 03_agents/qa/AGENT.md (module de recette) :
#   • couple « N livrables de sprint + M métriques succès MVP » transcrit À LA MAIN
#     dans DEUX docs de module (le bloc racine ne gate que le README d'entrée)
#     → recompté depuis la matrice acceptance (kind sprint_deliverable / mvp_metric)
#     + cohérence croisée == MANIFEST.counts (compteur ⇔ matrice résumée)
#   fiscal/ecf_dgii/README.md (×3) + 03_agents/erpnext_backend/AGENT.md (format e-NCF) :
#   • le CONTRAT DE FORMAT de l'e-NCF « E + tipoeCF(2) + secuencia(10) = 13 »
#     (préfixe · longueur segment tipo · longueur segment secuencia · longueur
#     totale) transcrit À LA MAIN dans QUATRE endroits de prose → recompté depuis
#     le SEUL bloc `e_ncf` d'ecf_plan.json (prefix/longueur/regex/structure, byte-
#     gatés) + cohérences croisées (1+tipo+seq == longueur · |regex digits| ==
#     tipo+seq — mordent un artefact internement incohérent)
#   05_deliverables_mvp/<suite>/README.md (comptes de tests self-reportés par MODULE) :
#   • chaque README de module se re-déclare le compte de SA suite (commentaire
#     « # N tests » de la commande unittest · bullet « → **N/N verts** » · prose
#     d'auto-score « en-repo (N tests verts + …) ») — 27 claims sur 16 modules,
#     surface que row_re (qui ne lit le README de module QUE comme cible de lien)
#     et les blocs per-suite (tous sur 03_agents/*) n'atteignent pas → recomptés
#     depuis plan.suites[path].test_methods (`auth`, même source · byte-gatée) ;
#     un « N/N » interne incohérent (25/24) mord aussi
#   03_agents/devops/AGENT.md (cellule « Tests » de la row pipeline CI) :
#   • le COMPTE de suites gated « N suites gated » — CLASSE distincte des comptes
#     de MÉTHODES ci-dessus (un compte de SUITES, pas de tests) — non atteint par
#     row_re (ancre `ci.yml`/`ci/`, pas un module de `auth` · suffixe « suites
#     gated ») et distinct de l'agrégat de la fiche QA → recompté depuis
#     regression_plan.totals.suites (`reg["suites"]`, byte-gaté) + cohérence
#     croisée == regression_run len(suites) (mord une divergence plan↔run)
# Un claim absent échoue AUSSI (la dérive de formulation qui ferait disparaître
# un chiffre est elle-même une régression de traçabilité).
#
# Dépendances : bash, git, python3 (stdlib pur · CLAUDE.md #2 — runner sans pip).
# ============================================================================
set -uo pipefail
# shellcheck source=ci/lib.sh
source "$(dirname "${BASH_SOURCE[0]}")/lib.sh" || exit 3
cd_repo_root  # racine du dépôt, ou ROUGE honnête si hors arbre git (cf. lib.sh)

echo "== Intégrité des chiffres du README (valeur == artefact cité · #6) =="

python3 - <<'PY'
import json, re, subprocess, sys, unicodedata

D = "05_deliverables_mvp"
FAIL = 0
def bad(m):
    global FAIL; FAIL = 1; print(f"  \033[31m✗\033[0m {m}")
def good(m):
    print(f"  \033[32m✓\033[0m {m}")

try:
    readme = open("README.md", encoding="utf-8").read()
except OSError as e:
    print(f"  \033[31m✗\033[0m README.md illisible : {e}"); sys.exit(1)
# Texte normalisé (espaces/newlines compressés) pour les claims multi-lignes.
norm = re.sub(r"\s+", " ", readme)

def load(p):
    with open(f"{D}/{p}", encoding="utf-8") as f:
        return json.load(f)

# --- Vérités-terrain recalculées depuis les artefacts (jamais saisies) -------
q = load("qa/audit_4big/out/quality_report.json")["totals"]
qv = load("qa/audit_4big/out/quality_report.json")["verdict"]
reg = load("qa/regression/out/regression_plan.json")["totals"]
acc = load("qa/acceptance/out/acceptance_matrix.json")
rows = acc["matrix"]
n_sprint = sum(1 for r in rows if r["kind"] == "sprint_deliverable")
n_metric = sum(1 for r in rows if r["kind"] == "mvp_metric")
n_inrepo = sum(1 for r in rows if r["status"] == "in_repo")
acc_verdict = acc["verdict"]
agents = subprocess.run(["git", "ls-files", "03_agents/*/AGENT.md"],
                        capture_output=True, text=True).stdout.split()
n_agents = len(agents)

# --- Chaque claim : (libellé, regex sur `norm`, groupes attendus) ------------
# Le n-uplet capturé (str) est comparé à l'attendu (str). Un claim non trouvé
# échoue (regex is None) — la disparition d'un chiffre est une régression.
checks = [
    ("Qualité 4Big · modules gated",
     r"\*\*(\d+)/(\d+) modules gated à (\d+)/100\*\*",
     (str(q["pass"]), str(q["modules"]), str(q["min_score"])),
     f"pass={q['pass']} total={q['modules']} min_score={q['min_score']}"),

    ("Régression · suites gated",
     r"\*\*(\d+) suites gated\*\*",
     (str(reg["suites"]),),
     f"suites={reg['suites']}"),

    ("Recette · promesses (sprint + métriques)",
     r"\*\*(\d+) promesses\*\* \((\d+) livrables sprint \+ (\d+) métriques",
     (str(len(rows)), str(n_sprint), str(n_metric)),
     f"total={len(rows)} sprint={n_sprint} metric={n_metric}"),

    ("Recette · promesses in_repo",
     r"\*\*(\d+) `in_repo`",
     (str(n_inrepo),),
     f"in_repo={n_inrepo}"),

    ("Agents · fiche nav",
     r"Fiches des \*\*(\d+) agents\*\*",
     (str(n_agents),),
     f"AGENT.md={n_agents}"),

    ("Agents · titre section",
     r"## Les (\d+) agents",
     (str(n_agents),),
     f"AGENT.md={n_agents}"),
]

for label, pat, expected, src in checks:
    m = re.search(pat, norm)
    if m is None:
        bad(f"{label} — claim INTROUVABLE dans README (attendu {src}) · motif=/{pat}/")
        continue
    got = m.groups()
    if got == expected:
        good(f"{label} — README {'/'.join(got)} == artefact ({src})")
    else:
        bad(f"{label} — README dit {'/'.join(got)} MAIS artefact dit {'/'.join(expected)} ({src}) → régénérer README")

# --- Verdicts textuels cités dans le README (PASS / true) --------------------
if re.search(r"verdict `PASS`", norm):
    if qv == "PASS":
        good(f"Qualité 4Big · verdict README `PASS` == artefact ({qv})")
    else:
        bad(f"Qualité 4Big · README dit verdict `PASS` MAIS artefact dit {qv}")
else:
    bad("Qualité 4Big · verdict `PASS` non cité dans README (attendu)")

if re.search(r"verdict `true`", norm):
    if acc_verdict is True:
        good("Recette · verdict README `true` == artefact (True)")
    else:
        bad(f"Recette · README dit verdict `true` MAIS artefact dit {acc_verdict}")
else:
    bad("Recette · verdict `true` non cité dans README (attendu)")

# ============================================================================
# Comptes d'agents dans les docs de mandat de tête (hors README) · même classe.
# ----------------------------------------------------------------------------
# Le README est déjà gaté (« 13 agents » ×2 ci-dessus, recompté depuis
# 03_agents/*/AGENT.md). Or DEUX autres docs de tête retranscrivent ce même
# compte en dur et se périmaient en silence : ils affichaient « 12 agents » alors
# que le 13e (publiciste) était livré+gaté (fiche + générateur 100/100 + suite de
# régression). Aucun gate ne les comparait à la réalité du disque. On réutilise
# le `n_agents` DÉJÀ dérivé de `git ls-files 03_agents/*/AGENT.md` (source unique,
# pas de nouvelle machinerie) et on exige que chaque doc cite exactement ce compte.
# Le GAP_ANALYSIS_SPRINT1 (livrable Sprint 1, Definition of Done) retranscrit ce
# même compte à TROIS surfaces vives non gatées : le périmètre d'audit (« audit des
# N agents DTP »), le constat global (« … des N agents partent d'un socle existant »
# — on n'ancre QUE le TOTAL, pas le « 12 » analytique des agents à socle existant),
# et surtout la coche de couverture DoD (« Couverture N agents + OTOIA core ✅ »).
# Cette coche AFFIRME une complétude courante : si un 14e agent était mandaté (comme
# le publiciste le fut, 12→13), elle mentirait en silence. Même source unique n_agents.
# ============================================================================
GAP = "05_deliverables_mvp/GAP_ANALYSIS_SPRINT1.md"
for path, pat, label in [
    ("AGENTS_EXISTING_ASSETS.md",
     r"Mapping vers les (\d+) agents DTP", "Actifs existants · titre"),
    ("02_master_prompt/MASTER_PROMPT_DTP_v2.md",
     r"Tes (\d+) agents spécialisés", "Master prompt · titre"),
    (GAP, r"audit des \*\*(\d+) agents DTP", "Gap analysis · périmètre d'audit"),
    (GAP, r"\d+ des (\d+) agents", "Gap analysis · constat global (total)"),
    (GAP, r"Couverture (\d+) agents", "Gap analysis · couverture DoD"),
]:
    try:
        doc = re.sub(r"\s+", " ", open(path, encoding="utf-8").read())
    except OSError as e:
        bad(f"{label} — {path} illisible : {e}"); continue
    m = re.search(pat, doc)
    if m is None:
        bad(f"{label} — compte d'agents INTROUVABLE dans {path} (attendu {n_agents}) · motif=/{pat}/")
    elif m.group(1) == str(n_agents):
        good(f"{label} — {path} cite {m.group(1)} == 03_agents/ ({n_agents})")
    else:
        bad(f"{label} — {path} dit {m.group(1)} MAIS 03_agents/ en compte {n_agents} → corriger le doc")

# ============================================================================
# Fiche QA (03_agents/qa/AGENT.md) · même classe de « vert trompeur » —
# ----------------------------------------------------------------------------
# Sa section « Verdict agrégé courant » affiche un compte agrégé (« N suites
# gated · M tests · M passés · 0 échec · 0 erreur → verdict PASS ») en déclarant
# « jamais compté à la main » et en citant qa/regression/out/regression_run.json.
# Ce compte se PÉRIME pourtant en silence quand une suite est ajoutée : la fiche
# affichait « 21 suites · 534 tests » alors que le run agrégé faisant autorité en
# disait déjà davantage (matrice recomptée ci-dessous, jamais figée ici). Comme pour
# le README, aucun gate ne comparait la VALEUR au run. On la RECALCULE ici depuis
# le compte agrégé faisant autorité (regression_run.json, commité · déterministe).
# ============================================================================
QA = "03_agents/qa/AGENT.md"
try:
    qa_txt = re.sub(r"\s+", " ", open(QA, encoding="utf-8").read())
except OSError as e:
    bad(f"Fiche QA · {QA} illisible : {e}"); qa_txt = ""

run = load("qa/regression/out/regression_run.json")
suites = run["suites"]
agg = (
    str(len(suites)),                        # suites
    str(sum(s["ran"] for s in suites)),      # tests exécutés
    str(sum(s["passed"] for s in suites)),   # passés
    str(sum(s["failures"] for s in suites)), # échecs
    str(sum(s["errors"] for s in suites)),   # erreurs
    run["verdict"],                          # verdict
)
qa_pat = (r"\*\*(\d+) suites gated · (\d+) tests · (\d+) passés · "
          r"(\d+) échec · (\d+) erreur → verdict `(PASS|FAIL)`")
qa_src = (f"suites={agg[0]} tests={agg[1]} passés={agg[2]} "
          f"échec={agg[3]} erreur={agg[4]} verdict={agg[5]}")
mqa = re.search(qa_pat, qa_txt)
if mqa is None:
    bad(f"Fiche QA · verdict agrégé — claim INTROUVABLE dans {QA} (attendu {qa_src})")
elif mqa.groups() == agg:
    good(f"Fiche QA · verdict agrégé — {QA} {'/'.join(mqa.groups())} == regression_run ({qa_src})")
else:
    bad(f"Fiche QA · {QA} dit {'/'.join(mqa.groups())} MAIS regression_run dit "
        f"{'/'.join(agg)} ({qa_src}) → régénérer la fiche")

# ============================================================================
# Fiche ERPNext Backend (03_agents/erpnext_backend/AGENT.md) · même classe.
# ----------------------------------------------------------------------------
# Sa ligne « source unique » situait les tests backend dans le repo par un
# compte agrégé (« repo : 560 tests au total ») saisi à la main. Ce nombre s'est
# PÉRIMÉ en silence (matrice 534 → 551 → 558) : 560 ne correspondait plus ni à la
# matrice (558) ni au repo-wide (584 méthodes test_*). Aucun gate ne le couvrait.
# On le RECALCULE depuis la même matrice faisant autorité (regression_run) et on
# exige tests + suites + verdict — comme la fiche QA.
# ============================================================================
BE = "03_agents/erpnext_backend/AGENT.md"
try:
    be_txt = re.sub(r"\s+", " ", open(BE, encoding="utf-8").read())
except OSError as e:
    bad(f"Fiche Backend · {BE} illisible : {e}"); be_txt = ""

be_exp = (
    str(sum(s["ran"] for s in suites)),   # tests exécutés (matrice)
    str(len(suites)),                     # suites
    run["verdict"],                       # verdict
)
be_pat = (r"matrice\s+de régression du repo : \*\*(\d+) tests · (\d+) suites\*\* · "
          r"verdict `(PASS|FAIL)`")
be_src = f"tests={be_exp[0]} suites={be_exp[1]} verdict={be_exp[2]}"
mbe = re.search(be_pat, be_txt)
if mbe is None:
    bad(f"Fiche Backend · matrice repo — claim INTROUVABLE dans {BE} (attendu {be_src})")
elif mbe.groups() == be_exp:
    good(f"Fiche Backend · matrice repo — {BE} {'/'.join(mbe.groups())} == regression_run ({be_src})")
else:
    bad(f"Fiche Backend · {BE} dit {'/'.join(mbe.groups())} MAIS regression_run dit "
        f"{'/'.join(be_exp)} ({be_src}) → régénérer la fiche")

# ============================================================================
# RBAC · le COMPTE CANONIQUE de rôles ANCRÉ sur CLAUDE.md §Architecture cible
# (« RBAC N rôles ») — classe claude-md-constant-anchor, restée NON ancrée.
# ----------------------------------------------------------------------------
# CLAUDE.md §Architecture cible FIXE le mandat « RBAC 50 rôles ». Ce nombre
# canonique est recopié SANS ancrage vers CLAUDE.md dans : le contrat
# rbac_50_roles.json (`cible_rbac_roles` ET la taille réelle du catalogue
# `len(roles)`), les schémas (`const: 50`) et la fiche agent erpnext_backend
# (« RBAC 50 rôles natif » · « **50 rôles** » · « RBAC **50 rôles** exactement »).
# Les gates RBAC amont ne prouvent que la COHÉRENCE INTERNE (les READMEs
# apply_plan/userperm/roleprofile == leur MANIFEST, lui-même recompté du contrat)
# — AVEUGLES à l'ancrage au MANDAT : changer « RBAC 50 rôles » dans CLAUDE.md, ou
# faire dévier le catalogue de 50 de façon INTERNE-cohérente (cible ET len alignés
# sur une autre valeur), laisserait le contrat et la fiche affirmer « 50 exactement »
# en CONTREDISANT le mandat, en silence. On RE-DÉRIVE N de CLAUDE.md §Architecture
# cible (source unique) et on exige : (a) contrat cible_rbac_roles == N, (b) taille
# réelle len(roles) == N, (c) cible == réel (le mandat est ATTEINT par le catalogue),
# (d) la fiche erpnext_backend porte l'invariant emphatique « RBAC **N rôles**
# exactement » ET aucune mention « M rôles » périmée (M != N). Même patron que les
# tokens branding #4 ou la persona OTOIA §Architecture cible ; un claim absent
# échoue AUSSI (traçabilité #6).
# ============================================================================
try:
    _cm = open("CLAUDE.md", encoding="utf-8").read()
except OSError as e:
    bad(f"RBAC compte · CLAUDE.md illisible : {e}"); _cm = ""
_arch = re.search(r"## Architecture cible\n(.*?)(?:\n## |\Z)", _cm, re.S)
m_n = re.search(r"RBAC\s+(\d+)\s+rôles", _arch.group(1)) if _arch else None
if m_n is None:
    bad("RBAC compte · « RBAC N rôles » INTROUVABLE dans CLAUDE.md §Architecture "
        "cible → l'ancre du mandat a disparu")
else:
    N = int(m_n.group(1))
    good(f"RBAC compte · ancre CLAUDE.md §Architecture cible = « RBAC {N} rôles »")
    try:
        _c = load("rbac/rbac_50_roles.json")
        cible = _c.get("cible_rbac_roles"); actual = len(_c.get("roles", []))
    except (OSError, ValueError) as e:
        bad(f"RBAC compte · contrat rbac_50_roles.json illisible : {e}")
        cible = actual = None
    if cible is not None:
        if cible == N:
            good(f"RBAC compte · (a) contrat cible_rbac_roles {cible} == CLAUDE.md ({N})")
        else:
            bad(f"RBAC compte · (a) contrat cible_rbac_roles {cible} != CLAUDE.md "
                f"§Architecture cible ({N}) → le contrat contredit le mandat")
        if actual == N:
            good(f"RBAC compte · (b) catalogue réel len(roles) {actual} == CLAUDE.md ({N})")
        else:
            bad(f"RBAC compte · (b) catalogue réel len(roles) {actual} != CLAUDE.md "
                f"§Architecture cible ({N}) → le catalogue a dévié du mandat")
        if cible == actual:
            good(f"RBAC compte · (c) cible {cible} == catalogue réel {actual} (mandat atteint)")
        else:
            bad(f"RBAC compte · (c) cible_rbac_roles {cible} != catalogue réel {actual} "
                f"→ cible déclarée non tenue par le catalogue")
    RBC = "03_agents/erpnext_backend/AGENT.md"
    try:
        rbc = re.sub(r"\s+", " ", open(RBC, encoding="utf-8").read())
    except OSError as e:
        bad(f"RBAC compte · {RBC} illisible : {e}"); rbc = ""
    if re.search(rf"RBAC \*\*{N} rôles\*\* exactement", rbc):
        good(f"RBAC compte · (d) fiche porte « RBAC **{N} rôles** exactement » == CLAUDE.md")
    else:
        bad(f"RBAC compte · (d) invariant « RBAC **{N} rôles** exactement » INTROUVABLE "
            f"dans {RBC} → claim absent = régression #6")
    role_mentions = [int(x) for x in re.findall(r"(\d+) rôles", rbc)]
    stale = sorted({m for m in role_mentions if m != N})
    if not role_mentions:
        bad(f"RBAC compte · (d) aucune mention « N rôles » dans {RBC}")
    elif stale:
        bad(f"RBAC compte · (d) mention(s) périmée(s) {stale} rôles dans {RBC} MAIS "
            f"CLAUDE.md dit {N} → resynchroniser la fiche")
    else:
        good(f"RBAC compte · (d) {len(role_mentions)} mention(s) « {N} rôles » "
             f"dans {RBC} == CLAUDE.md (aucune périmée)")

# ============================================================================
# README de l'auditeur 4Big (05_deliverables_mvp/qa/audit_4big/README.md) ·
# section « Résultat courant » — même classe de « vert trompeur ».
# ----------------------------------------------------------------------------
# Ironie parfaite : le module qui COMPTE les modules affichait son propre total
# SAISI À LA MAIN (« 17/17 modules à 100/100 »). Ce compte s'est PÉRIMÉ en silence
# quand des modules ont été ajoutés (17 → 22) — le README de l'auditeur contredisait
# son propre artefact `quality_report.json` (totals.modules = 22). Le gate racine
# ne couvrait QUE le README d'entrée, pas ce README de module. On RECALCULE ici
# depuis le même artefact faisant autorité (totals, déjà chargé dans `q`) et on
# exige l'égalité avec ce que la section « Résultat courant » ÉCRIT.
# ============================================================================
A4 = f"{D}/qa/audit_4big/README.md"
try:
    a4_txt = re.sub(r"\s+", " ", open(A4, encoding="utf-8").read())
except OSError as e:
    bad(f"README audit_4big · {A4} illisible : {e}"); a4_txt = ""

a4_exp = (qv, str(q["pass"]), str(q["modules"]), str(q["min_score"]))
a4_pat = (r"\*\*Verdict `(PASS|FAIL)`\*\* [—-] \*\*(\d+)/(\d+)\*\* "
          r"modules à \*\*(\d+)/100\*\*")
a4_src = f"verdict={a4_exp[0]} pass={a4_exp[1]} modules={a4_exp[2]} min_score={a4_exp[3]}"
ma4 = re.search(a4_pat, a4_txt)
if ma4 is None:
    bad(f"README audit_4big · « Résultat courant » — claim INTROUVABLE dans {A4} "
        f"(attendu {a4_src})")
elif ma4.groups() == a4_exp:
    good(f"README audit_4big · Résultat courant — {A4} "
         f"{'/'.join(ma4.groups())} == quality_report ({a4_src})")
else:
    bad(f"README audit_4big · {A4} dit {'/'.join(ma4.groups())} MAIS quality_report "
        f"dit {'/'.join(a4_exp)} ({a4_src}) → régénérer le README")

# ============================================================================
# README de l'auditeur 4Big · BARÈME de notation (N critères + poids par critère)
# — même classe de « vert trompeur », au grain du référentiel de scoring.
# ----------------------------------------------------------------------------
# Le barème 4Big est DATA-DERIVED : quality_report.json.criteria (byte-gaté par
# check_artifacts, RECOMPUTÉ du référentiel à chaque build) porte la liste des
# critères et leur poids. Le README les RETRANSCRIT À LA MAIN à trois endroits :
#   • la prose « Note chaque module sur N critères 4Big déterministes »,
#   • la table « Critère | Poids | Fait vérifié » (une ligne id→poids par critère),
#   • l'archétype « generator (N critères) » (generator applique TOUS les critères).
# Ajouter/retirer un critère ou changer un poids ferait dériver ces trois surfaces
# en silence pendant que l'artefact dit autre chose — aucune suite tests/ (qui teste
# des FONCTIONS, pas la prose du README) ne l'attrape. On RECOMPUTE ici chaque nombre
# depuis `criteria` (zéro duplication du barème du générateur · #6) et on exige
# l'égalité avec ce que la doc ÉCRIT. Cohérences croisées : Σ poids == 100 (base de
# renormalisation) ET tout module d'archétype `generator` porte EXACTEMENT
# len(criteria) checks — c.-à-d. « generator = tous les critères » (mord une dérive
# du modèle qui rendrait le « generator (N critères) » du README faux). Un claim
# absent échoue AUSSI (disparition = régression de traçabilité).
# Bloc (6) : la carte de RENORMALISATION PAR ARCHÉTYPE (« data_room sans HANDOFF ·
# parser sans CONTRAT ni HANDOFF · contract sans CLI ni HANDOFF ») est elle aussi
# data-derived — critères applicables d'un archétype = {check.criterion} de ses
# modules ⇒ exclus = barème − applicables. Recomputés par archétype, comparés à la
# parenthèse du README (exclusions des 3 archétypes non-generator, jamais gatées
# avant), + uniformité « 1 archétype → 1 jeu ».
# ============================================================================
try:
    qr_crit = load("qa/audit_4big/out/quality_report.json")
    criteria = qr_crit["criteria"]
except (OSError, KeyError, ValueError) as e:
    bad(f"README audit_4big · quality_report illisible : {e}"); criteria = None
if criteria is not None and a4_txt:
    n_crit = len(criteria)
    # 1) prose « N critères 4Big déterministes »
    mc = re.search(r"sur (\d+) critères 4Big déterministes", a4_txt)
    if mc is None:
        bad(f"README audit_4big · prose « N critères 4Big » — claim INTROUVABLE dans "
            f"{A4} (attendu {n_crit})")
    elif mc.group(1) == str(n_crit):
        good(f"README audit_4big · prose critères — {mc.group(1)} == criteria ({n_crit})")
    else:
        bad(f"README audit_4big · prose critères — {A4} dit {mc.group(1)} MAIS criteria "
            f"en compte {n_crit} → régénérer le README")
    # 2) table « Critère | Poids | … » — une ligne id→poids par critère
    for c in criteria:
        cid, w = c["id"], str(c["weight"])
        mw = re.search(rf"\| `{re.escape(cid)}` \| (\d+) \|", a4_txt)
        if mw is None:
            bad(f"README audit_4big · critère {cid} — ligne INTROUVABLE dans {A4} "
                f"(attendu poids={w})")
        elif mw.group(1) == w:
            good(f"README audit_4big · critère {cid} — poids {mw.group(1)} == criteria ({w})")
        else:
            bad(f"README audit_4big · critère {cid} — {A4} dit poids {mw.group(1)} MAIS "
                f"criteria dit {w} → régénérer le README")
    # 3) archétype « generator (N critères) » — generator applique TOUS les critères
    mg = re.search(r"`generator` \((\d+) critères\)", a4_txt)
    if mg is None:
        bad(f"README audit_4big · archétype « generator (N critères) » — claim "
            f"INTROUVABLE dans {A4} (attendu {n_crit})")
    elif mg.group(1) == str(n_crit):
        good(f"README audit_4big · generator — {mg.group(1)} critères == criteria ({n_crit})")
    else:
        bad(f"README audit_4big · generator — {A4} dit {mg.group(1)} MAIS l'archétype "
            f"generator applique les {n_crit} critères → régénérer le README")
    # 4) cohérence croisée : Σ poids == 100 (base de renormalisation)
    w_sum = sum(c["weight"] for c in criteria)
    if w_sum == 100:
        good(f"README audit_4big · Σ poids critères == {w_sum} (base 100 cohérente)")
    else:
        bad(f"README audit_4big · Σ poids critères == {w_sum} ≠ 100 — barème incohérent "
            f"avec la renormalisation documentée")
    # 5) cohérence croisée : tout module `generator` porte len(criteria) checks
    gen = [m for m in qr_crit["modules"] if m.get("archetype") == "generator"]
    gen_bad = [m["id"] for m in gen if len(m.get("checks", [])) != n_crit]
    if not gen:
        good("README audit_4big · aucun module d'archétype generator (rien à recouper)")
    elif gen_bad:
        bad(f"README audit_4big · modules generator avec ≠ {n_crit} checks : {gen_bad} "
            f"→ « generator = tous les critères » violé")
    else:
        good(f"README audit_4big · {len(gen)} module(s) generator portent {n_crit} "
             f"checks (« generator = tous les critères » vérifié)")
    # 6) carte « archétype → critères EXCLUS » (renormalisation) : le README énumère
    #    data_room « sans HANDOFF », parser « sans CONTRAT … ni HANDOFF », contract
    #    « sans CLI ni HANDOFF ». Cette carte est DATA-DERIVED : pour un archétype,
    #    l'ensemble des critères APPLICABLES = {check.criterion} de ses modules (les
    #    non-applicables sont ABSENTS des checks) ⇒ EXCLUS = ordre du barème − appl.
    #    Le bloc (3) ne recompute QUE generator (5 critères) ; les exclusions des
    #    trois autres archétypes n'étaient recomputées par AUCUN gate. Changer la
    #    règle de renormalisation d'un archétype (ex. data_room exclut aussi CLI)
    #    ferait dériver cette prose en silence pendant que quality_report dit autre
    #    chose. On recompute par archétype + on exige que la parenthèse du README
    #    énumère EXACTEMENT cet ensemble. Uniformité en bonus : tous les modules
    #    d'un même archétype portent le MÊME ensemble applicable (sinon la carte
    #    « 1 archétype → 1 jeu de critères » n'a pas de sens).
    crit_ids = [c["id"] for c in criteria]
    crit_set = set(crit_ids)
    by_arch, uni_bad = {}, []
    for m in qr_crit["modules"]:
        a = m.get("archetype")
        appl = frozenset(ck["criterion"] for ck in m.get("checks", []))
        if a in by_arch and by_arch[a] != appl:
            uni_bad.append(m["id"])
        else:
            by_arch.setdefault(a, appl)
    if uni_bad:
        bad(f"README audit_4big · archétypes NON uniformes (modules aux critères "
            f"applicables divergents) : {uni_bad} → renormalisation incohérente")
    else:
        good(f"README audit_4big · {len(by_arch)} archétype(s) à critères applicables "
             f"uniformes (« 1 archétype → 1 jeu » vérifié)")
    for a in sorted(by_arch):
        if a == "generator":
            continue  # traité en (3) : « generator (N critères) », zéro exclusion
        exp = [c for c in crit_ids if c not in by_arch[a]]  # ordre du barème, stable
        ma = re.search(rf"`{re.escape(a)}` \(([^)]*)\)", a4_txt)
        if ma is None:
            bad(f"README audit_4big · archétype `{a}` — énumération INTROUVABLE dans "
                f"{A4} (attendu « sans {' ni '.join(exp)} »)")
            continue
        got = set(re.findall(r"[A-Z]{2,}", ma.group(1))) & crit_set
        if got == set(exp):
            good(f"README audit_4big · archétype `{a}` — exclus {sorted(got)} == "
                 f"référentiel ({exp})")
        else:
            bad(f"README audit_4big · archétype `{a}` — {A4} exclut {sorted(got)} MAIS "
                f"le référentiel exclut {exp} → régénérer le README")

# ============================================================================
# README de l'auditeur 4Big · SEUILS STRUCTURELS de la colonne « Fait vérifié »
# (min_doc_bytes / min_test_methods) — 3e surface du MÊME README, jamais gatée.
# ----------------------------------------------------------------------------
# Le bloc BARÈME ci-dessus recompute les POIDS (2e colonne de la table
# « Critère | Poids | Fait vérifié »), mais la 3e colonne « Fait vérifié » NOMME
# les DEUX seuils structurels que le scorer applique réellement :
#   • DOC   « un *.md (README/SPEC) ≥ 400 octets »   → thresholds.min_doc_bytes
#   • TESTS « ≥ 8 méthodes def test_* dans tests/ »   → thresholds.min_test_methods
# Ces deux nombres sont DATA-DERIVED de quality_spec.json[thresholds] — l'INPUT
# du générateur, byte-gaté par check_artifacts qui prouve que quality_report.json
# se rebuild depuis lui — et TRANSCRITS À LA MAIN dans la table. AUCUN gate ne
# les liait : le bloc BARÈME ne capture que le poids (`| \`DOC\` | 20 |`),
# AVEUGLE à la 3e cellule. Élever min_test_methods (8→12 : un module de 8-11
# tests tomberait sous FAIL — la barre même que le critère pose) ou changer
# min_doc_bytes dans le spec rebuild le report SANS TOUCHER au README → prose
# « ≥ 8 méthodes » / « ≥ 400 octets » PÉRIMÉE en silence, l'auditeur documentant
# un barème qu'il n'applique plus (l'invention #6 interdit). Aucune suite tests/
# (qui teste des FONCTIONS de scoring, pas la prose) n'attrape ce « vert
# trompeur ». CROSS-CHECK d'ancrage : l'evidence TESTS de CHAQUE module du report
# porte « (seuil N) » (émise par crit_tests) — on exige N == spec.min_test_methods
# pour TOUS, prouvant que le seuil ÉCRIT dans le spec == le seuil réellement
# APPLIQUÉ à la construction (mord une divergence spec↔report). Un claim absent
# échoue AUSSI (traçabilité #6).
# ============================================================================
try:
    _a4_thr = load("qa/audit_4big/quality_spec.json")["thresholds"]
    min_doc = int(_a4_thr["min_doc_bytes"])
    min_test = int(_a4_thr["min_test_methods"])
except (OSError, KeyError, ValueError, TypeError) as e:
    bad(f"README audit_4big seuils · quality_spec.json[thresholds] illisible/inattendu : {e}")
    min_doc = min_test = None
if min_doc is not None and criteria is not None and a4_txt:
    # cross-check d'ancrage : seuil TESTS APPLIQUÉ (evidence report) == spec
    ev_seuils = set()
    for m in qr_crit["modules"]:
        for ck in m.get("checks", []):
            if ck.get("criterion") == "TESTS":
                mm = re.search(r"\(seuil (\d+)\)", ck.get("evidence", ""))
                if mm:
                    ev_seuils.add(int(mm.group(1)))
    if not ev_seuils:
        bad("README audit_4big seuils · aucune evidence TESTS « (seuil N) » dans le "
            "report — impossible d'ancrer le seuil réellement appliqué")
    elif ev_seuils != {min_test}:
        bad(f"README audit_4big seuils · seuil TESTS APPLIQUÉ (evidence report) "
            f"{sorted(ev_seuils)} ≠ spec.min_test_methods ({min_test}) → spec↔report "
            f"divergent (régénérer le report)")
    else:
        good(f"README audit_4big seuils · seuil TESTS appliqué (report) == spec "
             f"({min_test}) sur tous les modules")
    # prose · DOC « ≥ N octets » (3e cellule de la ligne DOC)
    md = re.search(r"`DOC`[^|]*\|[^|]*\|[^|]*?≥\s*(\d+)\s*octets", a4_txt)
    if md is None:
        bad(f"README audit_4big seuils · ligne DOC « ≥ N octets » INTROUVABLE dans {A4} "
            f"(attendu {min_doc})")
    elif md.group(1) == str(min_doc):
        good(f"README audit_4big seuils · DOC — « ≥ {md.group(1)} octets » == spec ({min_doc})")
    else:
        bad(f"README audit_4big seuils · DOC — {A4} dit « ≥ {md.group(1)} octets » MAIS "
            f"spec.min_doc_bytes == {min_doc} → régénérer le README")
    # prose · TESTS « ≥ N méthodes » (3e cellule de la ligne TESTS)
    mt = re.search(r"`TESTS`[^|]*\|[^|]*\|[^|]*?≥\s*(\d+)\s*méthodes", a4_txt)
    if mt is None:
        bad(f"README audit_4big seuils · ligne TESTS « ≥ N méthodes » INTROUVABLE dans {A4} "
            f"(attendu {min_test})")
    elif mt.group(1) == str(min_test):
        good(f"README audit_4big seuils · TESTS — « ≥ {mt.group(1)} méthodes » == spec ({min_test})")
    else:
        bad(f"README audit_4big seuils · TESTS — {A4} dit « ≥ {mt.group(1)} méthodes » MAIS "
            f"spec.min_test_methods == {min_test} → régénérer le README")

# ============================================================================
# Fiches agents · comptes de tests PAR SUITE (colonne « Tests » des tables) —
# même classe de « vert trompeur », au grain le PLUS FIN.
# ----------------------------------------------------------------------------
# Chaque fiche agent (03_agents/*/AGENT.md) recense ses livrables dans une table
# dont la dernière colonne « Tests » affiche le nombre de méthodes `def test_` de
# la suite. Ce nombre est SAISI À LA MAIN : il se périme dès qu'un test est
# ajouté/retiré. C'est arrivé (fiche QA : `acceptance` disait 31 alors que la
# suite en portait 37 · `audit_4big` 35→34 · `regression` 25→26). Les gates
# amont ne couvraient QUE la SOMME agrégée (« 564 tests ») : une compensation
# entre deux suites (l'une +1, l'autre −1) laisserait la somme juste et les DEUX
# lignes fausses. On RECOMPUTE ici chaque cellule depuis la source faisant
# autorité — plan.suites[path].test_methods pour les suites gated, et
# reglib.discovery.count_tests (même fonction que le plan · zéro duplication)
# pour le self_module (qa/regression, exclu de la matrice par SoD mais bien
# documenté avec son propre compte).
# ============================================================================
sys.path.insert(0, f"{D}/qa/regression")
try:
    from reglib.discovery import count_tests
except Exception as e:  # import cassé = red honnête (source de vérité absente)
    bad(f"Comptes par suite · import reglib.discovery impossible : {e}")
    count_tests = None

plan = load("qa/regression/out/regression_plan.json")
auth = {s["path"]: s["test_methods"] for s in plan["suites"]}
self_mod = plan["coverage"].get("self_module_excluded")
if count_tests is not None and self_mod:
    auth[self_mod] = count_tests(f"{D}/{self_mod}/tests")[1]

# Ligne de table Tests : lien vers 05_deliverables_mvp/<path>/<doc>.md + dernier
# champ numérique. Le <doc>.md est l'ENTRÉE du module — le plus souvent README.md,
# mais parfois une SPEC (la fiche `rbac` pointe RBAC_50_ROLES_SPEC.md, son point
# d'entrée) : on accepte donc tout `*.md` du dossier module, sinon cette cellule-là
# reste un angle mort (mutation 10→99 non mordue). On ne vérifie QUE les lignes
# pointant une suite gatée connue (auth) — toute autre ligne est ignorée (hors
# périmètre), le garde `path not in auth` protège de tout élargissement fortuit.
row_re = re.compile(
    r"^\|\s*\[`\w+/`\]\([^)]*?05_deliverables_mvp/([\w/]+?)/[\w.-]+\.md\)"
    r".*\|\s*(\d+)\s*\|\s*$")
fiches = subprocess.run(["git", "ls-files", "03_agents/*/AGENT.md"],
                        capture_output=True, text=True).stdout.split()
seen_rows = 0
for af in fiches:
    try:
        lines = open(af, encoding="utf-8").read().splitlines()
    except OSError as e:
        bad(f"Fiche {af} illisible : {e}"); continue
    for ln, line in enumerate(lines, 1):
        m = row_re.match(line)
        if not m:
            continue
        path = m.group(1)
        if path not in auth:      # ligne pointant un non-suite → hors périmètre
            continue
        claimed, exp = int(m.group(2)), auth[path]
        seen_rows += 1
        if claimed == exp:
            good(f"Fiche {af}:{ln} · {path} — Tests {claimed} == source ({exp})")
        else:
            bad(f"Fiche {af}:{ln} · {path} — Tests {claimed} MAIS source dit {exp} "
                f"(plan.suites/count_tests) → régénérer la fiche")
if seen_rows == 0:
    bad("Comptes par suite · AUCUNE ligne de table « Tests » trouvée dans les "
        "fiches — la disparition du recensement est elle-même une régression.")

# ============================================================================
# Fiches agents · comptes AGRÉGÉS EN PROSE (« Total X : N tests (a + b + c) ») —
# dernière surface de la MÊME classe, un cran au-dessus des cellules de table.
# ----------------------------------------------------------------------------
# Les cellules « Tests » des tables sont recomputées ci-dessus, mais deux fiches
# rédigent EN PROSE un agrégat multi-suites : `crm` (« Total CRM : 81 tests
# (25 + 31 + 25) ») et `erpnext_backend` (« RBAC 60 tests (10 + 11 + 12 + 11 + 16)
# + e-CF 39 tests »). Ces totaux ET leurs composants sont SAISIS À LA MAIN et
# n'étaient couverts par AUCUN gate : une compensation entre deux suites (l'une
# +1, l'autre −1) laisserait la table juste et la prose fausse — exactement le
# motif per-suite corrigé la session précédente. On RECOMPUTE ici le total ET le
# multiset des composants (ordre-indépendant) depuis `auth` (plan.suites, source
# faisant autorité déjà chargée), et on exige l'égalité avec ce que la fiche ÉCRIT.
# ============================================================================
PROSE = [
    ("03_agents/crm/AGENT.md", "Total CRM",
     r"Total CRM : \*\*(\d+) tests\*\* \(([\d + ]+)\)",
     ["crm/commissions", "crm/dossier_vente", "crm/workflow_vente"]),
    ("03_agents/erpnext_backend/AGENT.md", "RBAC backend",
     r"\*\*RBAC (\d+) tests\*\* \(([\d + ]+)\)",
     ["rbac", "rbac/apply_plan", "rbac/fixtures_gen",
      "rbac/roleprofile_gen", "rbac/userperm_gen"]),
]
for path_f, label, pat, suites_list in PROSE:
    try:
        txt = re.sub(r"\s+", " ", open(path_f, encoding="utf-8").read())
    except OSError as e:
        bad(f"Prose agrégée · {path_f} illisible : {e}"); continue
    try:
        exp_parts = sorted(auth[s] for s in suites_list)
    except KeyError as e:
        bad(f"Prose agrégée · {label} — suite {e} absente de plan.suites (câblage ?)")
        continue
    exp_total = sum(exp_parts)
    m = re.search(pat, txt)
    if m is None:
        bad(f"Prose agrégée · {path_f} · « {label} » — claim INTROUVABLE "
            f"(attendu total={exp_total} composants={exp_parts})")
        continue
    total = int(m.group(1))
    parts = sorted(int(x) for x in re.findall(r"\d+", m.group(2)))
    if total == exp_total and parts == exp_parts and total == sum(parts):
        good(f"Prose agrégée · {path_f} · {label} — "
             f"{total} ({'+'.join(map(str, parts))}) == source ({exp_total})")
    else:
        bad(f"Prose agrégée · {path_f} · {label} — dit total={total} "
            f"composants={parts} MAIS source dit total={exp_total} "
            f"composants={exp_parts} (interne_ok={total == sum(parts)}) "
            f"→ régénérer la fiche")

# e-CF : agrégat en prose à SUITE UNIQUE (pas de composants entre parenthèses).
be_ecf = re.sub(r"\s+", " ", open("03_agents/erpnext_backend/AGENT.md",
                                  encoding="utf-8").read())
ecf_exp = auth["fiscal/ecf_dgii"]
mecf = re.search(r"\*\*e-CF (\d+) tests\*\*", be_ecf)
if mecf is None:
    bad(f"Prose agrégée · e-CF — claim INTROUVABLE (attendu {ecf_exp})")
elif int(mecf.group(1)) == ecf_exp:
    good(f"Prose agrégée · e-CF — {mecf.group(1)} == source ({ecf_exp})")
else:
    bad(f"Prose agrégée · e-CF — dit {mecf.group(1)} MAIS source dit {ecf_exp} "
        f"(fiscal/ecf_dgii) → régénérer la fiche")

# ============================================================================
# Fiches agents · comptes de tests PAR SUITE écrits HORS de la colonne « Tests »
# (prose libre + cellule à suffixe) — dernière poche de la MÊME classe per-suite.
# ----------------------------------------------------------------------------
# Les deux blocs ci-dessus recomputent (a) chaque CELLULE « Tests » des tables de
# livrables via row_re — dont l'ancre de lien est `<mot>/` ET la cellule un ENTIER
# NU — et (b) les AGRÉGATS multi-suites en prose de crm/erpnext_backend. Restent
# CINQ claims de test PAR SUITE que NI l'un NI l'autre n'atteint :
#   • prose libre (hors table) : seo « **36 tests** » (suite `seo`) ·
#     frontend_console « **19 tests** » (frontend/portails) & « **31 tests** »
#     (frontend/chat_otoia) · onapi_legal « **44 tests** » (legal/confotur) ;
#   • cellule de table à SUFFIXE : devops « 29 (dont 14 injections négatives) »
#     (devops/deploy_runbook) — row_re échoue DEUX fois : l'ancre de lien est un
#     CHEMIN complet (`05_deliverables_mvp/devops/deploy_runbook/`, pas `<mot>/`)
#     ET la cellule n'est PAS un entier nu (suffixe « (dont …) »).
# Ces cinq nombres sont SAISIS À LA MAIN et se périment dès qu'un `def test_` est
# ajouté/retiré à la suite — exactement le « vert trompeur » per-suite que le bloc
# des cellules corrige, mais sur des surfaces qu'il ne balaie pas (une suite +1 et
# une autre −1 laisserait la SOMME « 564 tests » juste et ces lignes fausses).
# Aucune suite tests/ (qui teste des FONCTIONS, pas la prose des fiches) n'attrape
# ce périmé. On RECOMPUTE chaque nombre depuis `auth` (plan.suites[path].
# test_methods / count_tests · MÊME source déjà chargée · zéro duplication #6) et
# on exige que la fiche l'écrive EXACTEMENT. Chaque motif est ANCRÉ sur le nom de
# job CI (ou le lien du module) adjacent pour viser LA bonne suite sans ambiguïté.
# Un claim absent échoue AUSSI (traçabilité #6 : le recensement qui s'évapore est
# lui-même une régression).
# ============================================================================
PER_SUITE_HORS_TABLE = [
    ("03_agents/seo/AGENT.md",              "seo",
     r"\*\*(\d+) tests\*\*, gated", "seo"),
    ("03_agents/frontend_console/AGENT.md", "frontend/portails",
     r"\*\*(\d+) tests\*\* · job CI `frontend-portails-tests`", "portails"),
    ("03_agents/frontend_console/AGENT.md", "frontend/chat_otoia",
     r"\*\*(\d+) tests\*\* · job CI `chat-otoia-tests`", "chat_otoia"),
    ("03_agents/onapi_legal/AGENT.md",      "legal/confotur",
     r"\*\*(\d+) tests\*\* \(dont \d+ négatifs\) · job CI `legal-confotur-tests`",
     "confotur"),
    ("03_agents/devops/AGENT.md",           "devops/deploy_runbook",
     r"deploy_runbook/README\.md\).*?\| (\d+) \(dont \d+", "deploy_runbook"),
]
for path_f, suite_path, pat, label in PER_SUITE_HORS_TABLE:
    try:
        txt = re.sub(r"\s+", " ", open(path_f, encoding="utf-8").read())
    except OSError as e:
        bad(f"Test par suite (hors table) · {path_f} illisible : {e}"); continue
    if suite_path not in auth:
        bad(f"Test par suite (hors table) · {label} — suite `{suite_path}` absente "
            f"de plan.suites/count_tests (câblage rompu ?)"); continue
    exp = auth[suite_path]
    m = re.search(pat, txt)
    if m is None:
        bad(f"Test par suite (hors table) · {path_f} · {label} — claim « N tests » "
            f"INTROUVABLE (attendu {exp} · suite `{suite_path}`)")
    elif int(m.group(1)) == exp:
        good(f"Test par suite (hors table) · {path_f} · {label} — {m.group(1)} == "
             f"source ({exp} · `{suite_path}`)")
    else:
        bad(f"Test par suite (hors table) · {path_f} · {label} — dit {m.group(1)} "
            f"MAIS source dit {exp} (plan.suites[`{suite_path}`]) → régénérer la fiche")

# ============================================================================
# Fiche DevOps · COMPTE de suites gated (« N suites gated ») — sémantique DISTINCTE
# des comptes de tests par suite ci-dessus.
# ----------------------------------------------------------------------------
# Tous les blocs de comptes de tests précédents recomputent un compte de MÉTHODES
# `def test_` (`auth[path]`). La table de livrables de la fiche DevOps porte, elle,
# dans la cellule « Tests » de la ROW du pipeline CI (`.gitea/workflows/ci.yml` +
# `ci/`), un compte d'une CLASSE tout autre : « 24 suites gated » — le nombre de
# suites que la CI passe au crible, soit `regression_plan.totals.suites` (déjà chargé
# dans `reg`, byte-gaté par check_artifacts · recompté à chaque build par la
# découverte réelle des suites). Ce nombre est SAISI À LA MAIN dans la fiche et
# NI row_re NI aucun bloc amont ne l'atteint : (a) row_re exige un lien d'ancre
# `<mot>/…/README.md` vers une suite gatée ET une cellule ENTIER NU — or l'ancre ici
# est `ci.yml`/`ci/` (pas un module de `auth`) ET la cellule porte le suffixe
# « suites gated » ; (b) l'agrégat de la fiche QA (« 24 suites gated · 624 tests… »,
# gaté sur regression_run) est une SURFACE distincte (autre fiche, autre motif). La
# MÊME valeur 24 vit donc à DEUX endroits — un seul était gaté. Ajouter/retirer un
# module gated fait bouger `reg["suites"]` tandis que cette cellule DevOps rote en
# silence : la doc d'identité de l'agent DevOps affirmerait un périmètre de couverture
# CI faux (le « vert trompeur » que #6 interdit). Aucune suite tests/ (FONCTIONS, pas
# la prose de fiche) ne l'attrape. On RECOMPUTE depuis `reg["suites"]` (source unique
# déjà en portée · zéro duplication #6) et on exige que la cellule l'écrive EXACTEMENT.
# Motif ANCRÉ sur le label de rôle « CI/CD Gitea Actions » de CETTE row → vise la
# bonne cellule sans capter la valeur 24 de la fiche QA (autre fichier de toute façon).
# Claim absent = échec (traçabilité #6). Cohérence croisée : `reg["suites"]` ==
# regression_run (len(suites)) — la source du compte de tests de la fiche QA — de sorte
# qu'une divergence plan↔run (compte de suites) morde ici aussi.
# ============================================================================
DEVOPS_FICHE = "03_agents/devops/AGENT.md"
try:
    dv_txt = re.sub(r"\s+", " ", open(DEVOPS_FICHE, encoding="utf-8").read())
except OSError as e:
    bad(f"Fiche DevOps · {DEVOPS_FICHE} illisible : {e}"); dv_txt = ""
run_sc = load("qa/regression/out/regression_run.json")
n_suites_run = len(run_sc["suites"])
if reg["suites"] != n_suites_run:
    bad(f"Fiche DevOps · suites gated — incohérence SOURCE : regression_plan.totals."
        f"suites={reg['suites']} ≠ regression_run len(suites)={n_suites_run} "
        f"(plan↔run divergent → régénérer les artefacts qa/regression)")
dv_pat = r"CI/CD Gitea Actions.*?\| (\d+) suites gated \|"
mdv = re.search(dv_pat, dv_txt)
if not dv_txt:
    pass  # illisible déjà signalé
elif mdv is None:
    bad(f"Fiche DevOps · suites gated — cellule « N suites gated » INTROUVABLE dans "
        f"{DEVOPS_FICHE} (row CI/CD Gitea Actions · attendu {reg['suites']})")
elif int(mdv.group(1)) == reg["suites"]:
    good(f"Fiche DevOps · suites gated — {DEVOPS_FICHE} {mdv.group(1)} == source "
         f"(regression_plan.totals.suites={reg['suites']})")
else:
    bad(f"Fiche DevOps · suites gated — {DEVOPS_FICHE} dit {mdv.group(1)} MAIS source "
        f"dit {reg['suites']} (regression_plan.totals.suites) → régénérer la fiche")

# ============================================================================
# READMEs de MODULE · comptes de tests self-reportés — surface DISTINCTE des fiches.
# ----------------------------------------------------------------------------
# Tous les blocs de comptes de tests ci-dessus visent `03_agents/*/AGENT.md` (la
# cellule « Tests » via row_re, les agrégats prose crm/erpnext_backend, les cinq
# claims per-suite hors table). AUCUN ne touche le README du MODULE lui-même —
# `05_deliverables_mvp/<suite>/README.md` — qui pourtant se re-déclare, en propre,
# le compte de SA suite à plusieurs endroits : le commentaire de la commande
# `unittest` (« # N tests »), le bullet « Vérification en-repo » (« → **N/N verts** »)
# et la justification d'auto-score (« Validé … en-repo (N tests verts + … ) »). Ces
# nombres sont SAISIS À LA MAIN dans le doc d'entrée de chaque module et se périment
# dès qu'un `def test_` est ajouté/retiré : la source faisant autorité (regression_
# plan.json → `auth`, recomptée à chaque build par découverte réelle · byte-gatée)
# se met à jour, tandis que la prose du README rote EN SILENCE — « vert trompeur »
# de la MÊME classe que la cellule « Tests » des fiches, mais sur la surface que
# row_re n'atteint pas (il ne lit le README de module QUE comme cible de lien). Aucune
# suite tests/ (FONCTIONS de génération, jamais la prose du README) n'attrape ce
# périmé. Un « N/N » interne incohérent (ex. `25/24 verts`) mord aussi (les deux
# nombres doivent valoir `auth`). On RECOMPUTE chaque nombre depuis `auth` (MÊME
# source déjà chargée · zéro duplication #6) et on exige que le README l'écrive
# EXACTEMENT ; un claim listé mais absent échoue AUSSI (traçabilité #6 : le compte
# qui s'évapore est lui-même une régression). Cinq modules ne self-reportent AUCUN
# compte (frontend/chat_otoia · mobile/app_config · publiciste · qa/acceptance ·
# qa/audit_4big) — volontairement HORS table (rien à ancrer ; ajouter un futur claim
# = l'ajouter ici). Chaque motif est ANCRÉ sur un contexte distinctif (commande
# `discover`, `**N/N verts**`, « en-repo (N tests verts », nom de fichier de suite…)
# pour viser LE compte de CETTE suite sans capter un autre nombre.
# ============================================================================
MODULE_README_TESTS = [
    ("crm/commissions",       [r"discover -s tests[^#]*# (\d+) tests",
                               r"en-repo \((\d+) tests verts"]),
    ("crm/dossier_vente",     [r"discover -s tests[^#]*# (\d+) tests"]),
    ("crm/workflow_vente",    [r"\*\*(\d+)/(\d+) verts\*\*",
                               r"en-repo \((\d+) tests verts"]),
    ("demo/scenarios",        [r"(\d+) tests \(dont injections"]),
    ("devops/deploy_runbook", [r"(\d+) tests dont \d+ injections"]),
    ("faisabilite/bancable",  [r"\*\*(\d+)/(\d+) verts\*\*",
                               r"en-repo \((\d+) tests verts"]),
    ("faisabilite/generator", [r"(\d+) tests `unittest`"]),
    ("fiscal/ecf_dgii",       [r"discover -s tests[^#]*# (\d+) tests",
                               r"en-repo \((\d+) tests verts"]),
    ("frontend/portails",     [r"discover -s tests[^#]*# (\d+) tests",
                               r"exacts, (\d+) tests \(dont"]),
    ("legal/confotur",        [r"discover -s tests[^#]*# (\d+) tests \(dont",
                               r"test_confotur\.py\s*# (\d+) tests",
                               r"diffable, (\d+) tests \(dont"]),
    ("qa/audit_5d",           [r"\*\*(\d+) tests\*\* \(`unittest`"]),
    ("rbac/apply_plan",       [r"\*\*(\d+)/(\d+) verts\*\*",
                               r"en-repo \((\d+) tests verts"]),
    ("rbac/fixtures_gen",     [r"\*\*(\d+)/(\d+) verts\*\*",
                               r"en-repo \((\d+) tests verts"]),
    ("rbac/roleprofile_gen",  [r"\*\*(\d+)/(\d+) verts\*\*",
                               r"en-repo \((\d+) tests verts"]),
    ("rbac/userperm_gen",     [r"\*\*(\d+)/(\d+) verts\*\*",
                               r"en-repo \((\d+) tests verts"]),
    ("seo",                   [r"discover -s tests[^#]*# (\d+) tests"]),
]
for suite_path, pats in MODULE_README_TESTS:
    rd = f"{D}/{suite_path}/README.md"
    try:
        rtxt = re.sub(r"\s+", " ", open(rd, encoding="utf-8").read())
    except OSError as e:
        bad(f"Compte tests README module · {rd} illisible : {e}"); continue
    if suite_path not in auth:
        bad(f"Compte tests README module · suite `{suite_path}` absente de "
            f"plan.suites/count_tests (câblage rompu ?)"); continue
    exp = auth[suite_path]
    for pat in pats:
        m = re.search(pat, rtxt)
        if m is None:
            bad(f"Compte tests README module · {rd} — claim « N tests » INTROUVABLE "
                f"(attendu {exp} · suite `{suite_path}`) · motif=/{pat}/")
            continue
        got = [int(g) for g in m.groups()]
        if all(v == exp for v in got):
            good(f"Compte tests README module · {rd} — {'/'.join(map(str, got))} == "
                 f"source ({exp} · `{suite_path}`)")
        else:
            bad(f"Compte tests README module · {rd} — dit {'/'.join(map(str, got))} "
                f"MAIS source dit {exp} (plan.suites[`{suite_path}`]) → régénérer le "
                f"README (ou run-book de régression si un test a bougé)")

# ============================================================================
# Portails frontend · comptes PAR WORKSPACE (cartes / liens / rôles) —
# même classe de « vert trompeur », transcrits À LA MAIN dans DEUX docs.
# ----------------------------------------------------------------------------
# Le générateur de portails émet out/MANIFEST.json dont le tableau `workspaces`
# porte, PAR portail, {nb_cartes, nb_liens, nb_roles} recalculés depuis le
# contrat RBAC à chaque build (byte-gaté par check_artifacts). Ces triplets sont
# recopiés À LA MAIN à DEUX endroits : la table du README du module
# (frontend/portails/README.md, colonnes Cartes|Liens|Rôles) ET la prose de la
# fiche agent (03_agents/frontend_console/AGENT.md — « OTO Ventes 4 cartes/11
# liens/12 rôles · … »). Aucun gate ne les couvrait : ajouter un rôle/DocType au
# contrat ferait dériver LES DEUX docs en silence pendant que l'artefact dit
# autre chose — même piège que les cellules « Tests » et la prose agrégée. On
# RECOMPUTE ici chaque triplet depuis MANIFEST (déjà byte-gaté · zéro duplication
# du compteur du générateur) et on exige l'égalité avec les DEUX docs. Un
# workspace absent d'un doc échoue AUSSI (traçabilité).
# ============================================================================
WS_RD = f"{D}/frontend/portails/README.md"
WS_FI = "03_agents/frontend_console/AGENT.md"
try:
    ws_list = load("frontend/portails/out/MANIFEST.json")["workspaces"]
except (OSError, KeyError, ValueError) as e:
    bad(f"Portails · MANIFEST illisible/inattendu : {e}"); ws_list = []
try:
    ws_rd = re.sub(r"\s+", " ", open(WS_RD, encoding="utf-8").read())
except OSError as e:
    bad(f"Portails · {WS_RD} illisible : {e}"); ws_rd = ""
try:
    ws_fi = re.sub(r"\s+", " ", open(WS_FI, encoding="utf-8").read())
except OSError as e:
    bad(f"Portails · {WS_FI} illisible : {e}"); ws_fi = ""

for w in ws_list:
    portail, ws = w["portail"], w["workspace"]
    exp = (str(w["nb_cartes"]), str(w["nb_liens"]), str(w["nb_roles"]))
    src = f"cartes={exp[0]} liens={exp[1]} rôles={exp[2]}"
    # 1) Table du README du module : | <portail> | `<ws>` | C | L | R |
    rd_pat = (rf"\| {re.escape(portail)} \| `{re.escape(ws)}` \| "
              r"(\d+) \| (\d+) \| (\d+) \|")
    mrd = re.search(rd_pat, ws_rd)
    if mrd is None:
        bad(f"Portails README · {ws} — ligne INTROUVABLE dans {WS_RD} (attendu {src})")
    elif mrd.groups() == exp:
        good(f"Portails README · {ws} — {'/'.join(mrd.groups())} == MANIFEST ({src})")
    else:
        bad(f"Portails README · {ws} — {WS_RD} dit {'/'.join(mrd.groups())} MAIS "
            f"MANIFEST dit {'/'.join(exp)} ({src}) → régénérer le README")
    # 2) Prose de la fiche agent : `<ws>` C cartes/L liens/R rôles  (ou C/L/R)
    fi_pat = (rf"`{re.escape(ws)}` (\d+)(?: cartes)?/(\d+)(?: liens)?/"
              r"(\d+)(?: rôles)?")
    mfi = re.search(fi_pat, ws_fi)
    if mfi is None:
        bad(f"Portails fiche · {ws} — claim INTROUVABLE dans {WS_FI} (attendu {src})")
    elif mfi.groups() == exp:
        good(f"Portails fiche · {ws} — {'/'.join(mfi.groups())} == MANIFEST ({src})")
    else:
        bad(f"Portails fiche · {ws} — {WS_FI} dit {'/'.join(mfi.groups())} MAIS "
            f"MANIFEST dit {'/'.join(exp)} ({src}) → régénérer la fiche")

# Total de workspaces (« N `Workspace` natifs ») cohérent artefact ↔ fiche.
n_ws = len(ws_list)
if n_ws:
    mtot = re.search(r"\*\*(\d+) `Workspace` natifs", ws_fi)
    if mtot is None:
        bad(f"Portails fiche · total — « N `Workspace` natifs » INTROUVABLE "
            f"dans {WS_FI} (attendu {n_ws})")
    elif int(mtot.group(1)) == n_ws:
        good(f"Portails fiche · total — {mtot.group(1)} == MANIFEST ({n_ws} workspaces)")
    else:
        bad(f"Portails fiche · total — {WS_FI} dit {mtot.group(1)} MAIS "
            f"MANIFEST dit {n_ws} → régénérer la fiche")

# ============================================================================
# Portails frontend (3e surface) · IDENTITÉ de l'ENSEMBLE des portails métier +
# exclusion NOMMÉE de la console technique — même fiche, autre « vert trompeur »
# aveugle au seul COMPTE.
# ----------------------------------------------------------------------------
# Le bloc triplets ci-dessus recompute cartes/liens/rôles PAR workspace mais
# reste aveugle à QUELS portails composent l'ensemble : la fiche agent
# (03_agents/frontend_console/AGENT.md) énumère À LA MAIN, ligne 11, « 5 portails
# rôle métier (Ventes / Construction / Achat / Compta / Direction) » et affirme
# ligne 46 que « la console technique `plateforme` (6ᵉ portail RBAC) est exclue ».
# DEUX assertions data-derived, aucune gatée :
#   (a) l'ENSEMBLE ORDONNÉ des noms courts = les workspaces métier du frontend
#       (out/MANIFEST.json workspaces · byte-gaté ⇒ faisant autorité · label
#       « OTO <Nom> » sans le préfixe, comme l'énumération des onglets Mobile) ET,
#       DOUBLE ANCRAGE, leurs ids == `portails_business` du contrat RBAC (l'«
#       égalité stricte » que la fiche revendique elle-même en :47) ;
#   (b) `plateforme` EST un vrai portail RBAC (∈ portails distincts des rôles),
#       il est le 6ᵉ (total distinct == 6 = 5 métier + 1) et il est HORS métier
#       (∉ portails_business) — donc légitimement « exclu ».
# Un ÉCHANGE (Achat→Marketing), un RETRAIT, un RÉORDRE, un RENOMMAGE de la console
# technique dans le contrat (`plateforme`→`console`) ou un contrat qui PROMEUT
# `plateforme` en métier ferait dériver la fiche EN SILENCE pendant que les
# artefacts (frontend workspaces + contrat RBAC) disent autre chose — dérive
# qu'aucune suite tests/ (FONCTIONS, jamais la prose) n'attrape. Même patron que
# l'énumération des onglets Mobile ou la colonne « Entité » de la SPEC §4. Zéro
# duplication : on relit deux artefacts déjà byte-gatés, on ne fige aucune liste.
# ============================================================================
try:
    _fc = load("rbac/rbac_50_roles.json")
    _pb = _fc["portails_business"]                       # ordre du contrat
    _distinct = list(dict.fromkeys(r["portail"] for r in _fc["roles"]))
except (OSError, KeyError, ValueError, TypeError) as e:
    bad(f"Portails ensemble · contrat rbac_50_roles.json illisible/inattendu : {e}")
    _pb = None; _distinct = None

if _pb is not None and ws_list:
    # double ancrage : ids métier du frontend == portails_business du contrat
    _fe_ids = [w["portail"] for w in ws_list]
    if _fe_ids != _pb:
        bad(f"Portails ensemble · ancrage rompu : ids workspaces frontend {_fe_ids} "
            f"≠ contrat portails_business {_pb} → resynchroniser artefact/contrat")
    else:
        good(f"Portails ensemble · double ancrage : ids workspaces frontend == "
             f"portails_business ({'/'.join(_pb)})")
    # (a) noms courts ordonnés = label workspace sans « OTO » (ce que la fiche copie)
    _short = [re.sub(r"^OTO\s+", "", w["workspace"]).strip() for w in ws_list]
    ma = re.search(r"(\d+) portails rôle métier \(([^)]*)\)", ws_fi)
    if ma is None:
        bad(f"Portails ensemble · fiche — « N portails rôle métier (…) » INTROUVABLE "
            f"dans {WS_FI} (attendu {'/'.join(_short)})")
    else:
        got = [s.strip() for s in ma.group(2).split("/") if s.strip()]
        if ma.group(1) != str(len(_short)):
            bad(f"Portails ensemble · fiche — annonce « {ma.group(1)} portails métier » "
                f"MAIS les workspaces frontend en comptent {len(_short)}")
        if got == _short:
            good(f"Portails ensemble · fiche — énumération {'/'.join(got)} == "
                 f"workspaces frontend ({len(_short)})")
        else:
            miss = [x for x in _short if x not in got]
            extra = [x for x in got if x not in _short]
            detail = (f" · absents={miss}" if miss else "") + \
                     (f" · en trop={extra}" if extra else "") or " · même ensemble, ordre ≠"
            bad(f"Portails ensemble · fiche — énumère {got} MAIS workspaces disent "
                f"{_short}{detail} → régénérer la fiche")

if _pb is not None and _distinct is not None:
    # (b) console technique exclue = l'unique portail RBAC HORS métier
    _tech = [p for p in _distinct if p not in _pb]
    if len(_tech) != 1:
        bad(f"Portails ensemble · attendu exactement 1 portail technique hors métier, "
            f"trouvé {_tech} (distinct={_distinct}, métier={_pb}) → contrat incohérent")
    else:
        tech = _tech[0]
        # « console technique `<nom>` (Nᵉ portail RBAC) est **exclue** » — le \S*
        # absorbe l'exposant « ᵉ » (multi-octets) sans le coder en dur ici.
        mt = re.search(r"console technique `([^`]+)` \((\d+)\S* portail RBAC\) "
                       r"est \*\*exclue\*\*", ws_fi)
        if mt is None:
            bad(f"Portails ensemble · fiche — claim « console technique `{tech}` "
                f"(Nᵉ portail RBAC) est **exclue** » INTROUVABLE dans {WS_FI}")
        elif mt.group(1) != tech:
            bad(f"Portails ensemble · fiche — nomme la console technique `{mt.group(1)}` "
                f"MAIS le contrat dit `{tech}` (unique portail hors métier) → régénérer")
        elif mt.group(2) != str(len(_distinct)):
            bad(f"Portails ensemble · fiche — dit `{tech}` = {mt.group(2)}ᵉ portail RBAC "
                f"MAIS le contrat compte {len(_distinct)} portails distincts")
        else:
            good(f"Portails ensemble · fiche — console technique `{tech}` = "
                 f"{mt.group(2)}ᵉ portail RBAC, hors métier, exclue ✓")

# ============================================================================
# RBAC · comptes des 3 volets (roleprofile · apply_plan · userperm) —
# même classe de « vert trompeur », recopiés À LA MAIN depuis les MANIFEST.
# ----------------------------------------------------------------------------
# Les trois générateurs RBAC émettent, PAR volet, un out/MANIFEST.json recalculé
# à chaque build depuis le contrat `rbac_50_roles.json` (byte-gaté par
# check_artifacts ⇒ source faisant autorité). Trois READMEs de module rédigent à
# LA MAIN des comptes qui dérivent de ces manifestes, sans AUCUN gate :
#   1) roleprofile_gen/README.md — table « Nb rôles » PAR profil (6 lignes) +
#      total. Grain le plus fin (par item), comme la colonne « Tests » des fiches.
#   2) apply_plan/README.md — ligne AGRÉGÉE « 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 » : 8 comptes consolidés de
#      TOUS les volets en une phrase.
#   3) userperm_gen/README.md — « Répartition : 28 entite · 16 groupe · 2 own ·
#      4 equipe » : la ventilation par mécanisme d'enforcement.
# Ajouter un rôle/DocType au contrat ferait dériver LES TROIS docs en silence
# pendant que les MANIFEST (byte-gatés) disent autre chose — même piège que les
# cellules « Tests », la prose agrégée CRM/RBAC et les triplets par workspace. On
# RECOMPUTE ici chaque nombre depuis le MANIFEST du volet (zéro duplication des
# compteurs des générateurs) et on exige l'égalité avec ce que la doc ÉCRIT. Un
# claim absent échoue AUSSI (disparition = régression de traçabilité).
# ============================================================================
def read_norm(path):
    try:
        return re.sub(r"\s+", " ", open(path, encoding="utf-8").read())
    except OSError as e:
        bad(f"RBAC · {path} illisible : {e}"); return None

# 1) roleprofile_gen — table « Nb rôles » par profil + total bijectif -----------
RP_RD = f"{D}/rbac/roleprofile_gen/README.md"
try:
    rp_man = load("rbac/roleprofile_gen/out/MANIFEST.json")
except (OSError, KeyError, ValueError) as e:
    bad(f"RBAC roleprofile · MANIFEST illisible : {e}"); rp_man = None
rp_rd = read_norm(RP_RD)
if rp_man is not None and rp_rd is not None:
    for p in rp_man["profiles"]:
        name, portail, exp = p["role_profile"], p["portail"], str(p["nb_roles"])
        # métier vs technique : le flag DATA-DERIVED (booléen `metier` du MANIFEST)
        # doit être transcrit EXACTEMENT dans la colonne « Type ». Sans ce gate la
        # colonne était un WILDCARD (`[^|]*`) → un portail technique classé « métier »
        # (ou l'inverse) passait « vert » alors que l'artefact dit autre chose.
        exp_type = "métier" if p["metier"] else "technique"
        pat = (rf"\| `{re.escape(name)}` \| {re.escape(portail)} \| "
               r"([^|]+?) \| (\d+) \|")
        m = re.search(pat, rp_rd)
        if m is None:
            bad(f"RBAC roleprofile · {name} — ligne INTROUVABLE dans {RP_RD} "
                f"(attendu type={exp_type} nb_roles={exp})")
        else:
            got_type, got_n = m.group(1).strip(), m.group(2)
            if got_n == exp:
                good(f"RBAC roleprofile · {name} — Nb rôles {got_n} == MANIFEST ({exp})")
            else:
                bad(f"RBAC roleprofile · {name} — {RP_RD} dit {got_n} MAIS "
                    f"MANIFEST dit {exp} → régénérer le README")
            if got_type == exp_type:
                good(f"RBAC roleprofile · {name} — Type « {got_type} » == MANIFEST "
                     f"(metier={p['metier']})")
            else:
                bad(f"RBAC roleprofile · {name} — {RP_RD} classe « {got_type} » MAIS "
                    f"MANIFEST dit « {exp_type} » (metier={p['metier']}) → régénérer")
    exp_tot = str(rp_man["counts"]["roles_couverts"])
    mt = re.search(r"\*\*Total\*\* \| \*\*(\d+)\*\*", rp_rd)
    if mt is None:
        bad(f"RBAC roleprofile · total — ligne « Total » INTROUVABLE dans {RP_RD} "
            f"(attendu {exp_tot})")
    elif mt.group(1) == exp_tot:
        good(f"RBAC roleprofile · total — {mt.group(1)} == MANIFEST ({exp_tot} couverts)")
    else:
        bad(f"RBAC roleprofile · total — {RP_RD} dit {mt.group(1)} MAIS MANIFEST "
            f"dit {exp_tot} → régénérer le README")
    # 1bis) IDENTITÉ de l'ensemble des profils (pas seulement leurs comptes) --------
    # La boucle ci-dessus est MANIFEST→README (chaque profil du contrat a une ligne :
    # capte le MANQUANT). Le sens README→MANIFEST (une ligne FANTÔME dans la table,
    # profil inventé absent du contrat) reste aveugle → on compte les lignes de profil
    # de la table et on exige == |profiles|. Ensemble ∩ boucle = identité d'ensemble.
    exp_prof = len(rp_man["profiles"])
    rows = re.findall(r"\| `OTO Portail [^|`]+` \| [^|]+ \| (?:métier|technique) \| \d+ \|",
                      rp_rd)
    if len(rows) == exp_prof:
        good(f"RBAC roleprofile · table — {len(rows)} lignes de profil == "
             f"MANIFEST ({exp_prof}) : aucun profil FANTÔME")
    else:
        bad(f"RBAC roleprofile · table — {RP_RD} porte {len(rows)} lignes de profil "
            f"MAIS MANIFEST en compte {exp_prof} → ligne en trop/manquante ?")
    # 1ter) prose « N profils (X métier + Y technique) » == comptes MANIFEST --------
    # + cohérences croisées : les comptes portails_metier/techniques doivent RECOMPTER
    # les flags `metier` des profils, et leur somme == role_profiles == |profiles|
    # (mord un MANIFEST INTERNEMENT incohérent : compte de flag ≠ flags réels).
    c = rp_man["counts"]
    cm, ct, cp = c["portails_metier"], c["portails_techniques"], c["role_profiles"]
    real_m = sum(1 for p in rp_man["profiles"] if p["metier"])
    real_t = sum(1 for p in rp_man["profiles"] if not p["metier"])
    if cm == real_m and ct == real_t and cm + ct == cp == exp_prof:
        good(f"RBAC roleprofile · cohérence comptes — portails_metier={cm} + "
             f"techniques={ct} == role_profiles={cp} == |flags métier réels|")
    else:
        bad(f"RBAC roleprofile · cohérence comptes — MANIFEST incohérent : "
            f"portails_metier={cm} (flags réels={real_m}) · techniques={ct} "
            f"(réels={real_t}) · role_profiles={cp} · |profiles|={exp_prof}")
    mp = re.search(r"\*\*(\d+) profils\*\* \((\d+) métier \+ (\d+) technique\)", rp_rd)
    if mp is None:
        bad(f"RBAC roleprofile · split métier/technique — prose « N profils "
            f"(X métier + Y technique) » INTROUVABLE dans {RP_RD} "
            f"(attendu {cp} profils · {cm} métier + {ct} technique)")
    elif (mp.group(1), mp.group(2), mp.group(3)) == (str(cp), str(cm), str(ct)):
        good(f"RBAC roleprofile · split métier/technique — {mp.group(1)} profils "
             f"({mp.group(2)} métier + {mp.group(3)} technique) == MANIFEST")
    else:
        bad(f"RBAC roleprofile · split métier/technique — {RP_RD} dit "
            f"{mp.group(1)}/{mp.group(2)}/{mp.group(3)} MAIS MANIFEST dit "
            f"{cp}/{cm}/{ct} → régénérer le README")

# 1quater) RBAC_50_ROLES_SPEC.md §3 — table « Nb rôles » PAR portail ------------
# La table §3 « Cartographie portails ↔ familles » transcrit À LA MAIN le nombre de
# rôles de CHAQUE portail. C'est une ventilation par portail dans un doc DISTINCT
# du MANIFEST roleprofile (gaté ci-dessus) — jamais gaté, et il AVAIT dérivé en
# silence : `direction` 10 / `plateforme` 5 alors que le contrat compte 9 / 6, le
# total 50 restant préservé (dérive muette classique, #6). On RECOMPTE ici chaque
# ligne depuis rbac_50_roles.json (source de vérité, byte-gatée par check_artifacts)
# et on exige l'égalité avec ce que la SPEC écrit, total compris. Un portail manquant
# de la table échoue aussi (disparition = régression de traçabilité).
SPEC_RD = f"{D}/rbac/RBAC_50_ROLES_SPEC.md"
try:
    src_roles = load("rbac/rbac_50_roles.json")["roles"]
except (OSError, KeyError, ValueError) as e:
    bad(f"RBAC SPEC §3 · rbac_50_roles.json illisible : {e}"); src_roles = None
spec_rd = read_norm(SPEC_RD)
if src_roles is not None and spec_rd is not None:
    by_portail = {}
    for r in src_roles:
        by_portail[r.get("portail")] = by_portail.get(r.get("portail"), 0) + 1
    for portail in sorted(by_portail):
        exp = str(by_portail[portail])
        pat = rf"\| \*\*{re.escape(portail)}\*\*[^|]*\| [^|]*\| (\d+) \|"
        m = re.search(pat, spec_rd)
        if m is None:
            bad(f"RBAC SPEC §3 · portail `{portail}` — ligne INTROUVABLE dans "
                f"{SPEC_RD} (attendu Nb rôles={exp})")
        elif m.group(1) == exp:
            good(f"RBAC SPEC §3 · portail `{portail}` — Nb rôles {m.group(1)} == "
                 f"rbac_50_roles.json ({exp})")
        else:
            bad(f"RBAC SPEC §3 · portail `{portail}` — {SPEC_RD} dit {m.group(1)} "
                f"MAIS rbac_50_roles.json compte {exp} → corriger la SPEC")
    exp_tot = str(len(src_roles))
    mt = re.search(r"\*\*Total\*\* \| \*\*(\d+)\*\*", spec_rd)
    if mt is None:
        bad(f"RBAC SPEC §3 · total — ligne « Total » INTROUVABLE dans {SPEC_RD} "
            f"(attendu {exp_tot})")
    elif mt.group(1) == exp_tot:
        good(f"RBAC SPEC §3 · total — {mt.group(1)} == rbac_50_roles.json ({exp_tot})")
    else:
        bad(f"RBAC SPEC §3 · total — {SPEC_RD} dit {mt.group(1)} MAIS "
            f"rbac_50_roles.json compte {exp_tot} → corriger la SPEC")

# 1quater-bis) RBAC_50_ROLES_SPEC.md §3 — colonne « Familles rattachées » PAR portail
# La MÊME table §3 porte, à GAUCHE de la colonne « Nb rôles » (gatée ci-dessus), la
# colonne « Familles rattachées » : pour chaque portail, l'ENSEMBLE des `famille`
# distinctes de ses rôles (ex. `direction` → {direction, finance, faisabilite, legal}).
# C'est un SET data-derived du contrat `rbac_50_roles.json[roles][].famille` — mais le
# bloc §3 ci-dessus l'AVALE par `[^|]*` (il ne capture QUE le compte), donc AUCUN gate
# ne le touche. Silent-green réel : reclasser la `famille` d'un rôle À L'INTÉRIEUR d'un
# portail (ex. l'unique rôle `legal` de `direction` repassé en `finance`) change le SET
# de familles SANS changer le « Nb rôles » du portail → la cellule §3 ment en silence
# pendant que le compte-gate reste VERT ; ajouter/retirer une famille d'un portail
# (nouveau rôle d'une famille inédite) laisse la cellule PÉRIMÉE. Ni check_artifacts
# (SPEC n'est pas un out/*.json) ni le §3 count-gate ni le §5 (regroupements ÉDITORIAUX,
# volontairement NON mappés famille↔groupe) ne mordent. On RECOMPUTE le SET par portail
# et on exige, par ENSEMBLE (absent ET fantôme), l'égalité avec les jetons `famille` de
# la cellule. On ne gate QUE les jetons `famille` bruts (identifiant de tête de chaque
# item séparé par `,`), PAS les parenthèses ÉDITORIALES (« finance (CFO) », « faisabilite
# (analyste) » — paraphrase, pas contrat, #6). Un portail sans cellule échoue AUSSI
# (INTROUVABLE = régression de traçabilité #6).
if src_roles is not None and spec_rd is not None:
    fam_by_portail = {}
    for r in src_roles:
        p = r.get("portail"); f = r.get("famille")
        fam_by_portail.setdefault(p, [])
        if f not in fam_by_portail[p]:
            fam_by_portail[p].append(f)
    for portail in sorted(fam_by_portail):
        exp_fam = fam_by_portail[portail]
        # cellule « Familles rattachées » = 2ᵉ colonne (entre le portail et « Nb rôles »)
        pat = rf"\| \*\*{re.escape(portail)}\*\*[^|]*\| ([^|]*)\| \d+ \|"
        m = re.search(pat, spec_rd)
        if m is None:
            bad(f"RBAC SPEC §3 familles · portail `{portail}` — cellule INTROUVABLE "
                f"dans {SPEC_RD} (attendu familles {{{', '.join(exp_fam)}}})")
            continue
        # jeton `famille` de tête de chaque item séparé par « , » (hors parenthèses éditoriales)
        got_fam = re.findall(r"(?:^|,)\s*([a-z]+)", m.group(1).strip())
        absent  = [f for f in exp_fam if f not in got_fam]
        phantom = [f for f in got_fam if f not in exp_fam]
        if not absent and not phantom:
            good(f"RBAC SPEC §3 familles · portail `{portail}` — {{{', '.join(sorted(got_fam))}}} "
                 f"== familles distinctes du contrat")
        else:
            det = []
            if absent:  det.append(f"MANQUE {{{', '.join(absent)}}}")
            if phantom: det.append(f"FANTÔME {{{', '.join(phantom)}}}")
            bad(f"RBAC SPEC §3 familles · portail `{portail}` — la cellule diverge du "
                f"contrat : {' · '.join(det)} (attendu {{{', '.join(exp_fam)}}}) → corriger la SPEC")

# 1quinquies) RBAC_50_ROLES_SPEC.md §7 — comptes du run-book d'application --------
# La NOTE §7 (« ✅ … livré en-repo ») restitue, sur une SURFACE DISTINCTE des READMEs
# userperm_gen / roleprofile_gen (déjà gatés), TROIS comptes data-derived du run-book
# d'application : « 28 templates `Company` (portée `entite`) », « 6 profils (5 portails
# métier + console technique `plateforme`) » et « 50 rôles de façon bijective ». Ces
# nombres sont recopiés À LA MAIN dans la SPEC ; le bloc §3 ne gate QUE la ventilation
# par portail, et les gates userperm/roleprofile ne regardent QUE leurs propres READMEs
# — AVEUGLES à cette note. BUMPER une portée `entite`→`groupe` dans le contrat (le
# nombre de `User Permission` templates change), AJOUTER/RETIRER un portail (le nombre
# de `Role Profile` change) ou casser la bijection laisse la note PÉRIMÉE en silence
# pendant que le contrat dit autre chose → l'agent ERPNext Backend lirait un run-book
# qui promet un décompte faux. On RECOMPUTE les 3 comptes depuis rbac_50_roles.json
# (source de vérité, byte-gatée par check_artifacts) : 28 == |scope==entite|, 6 ==
# |portails distincts| (= 1 Role Profile par portail), 5 == portails métier (hors
# `plateforme`, la console technique), 50 == |rôles|. Un claim absent échoue AUSSI.
if src_roles is not None and spec_rd is not None:
    # La note §7 vit dans un blockquote (`> `) : les marqueurs survivent au collapse
    # de read_norm et fragmentent la prose (« 28 > templates »). On les retire pour
    # ce bloc (aucun `> ` littéral hors blockquote dans cette SPEC ; les flèches sont
    # des `→`). Le §3 (tables sans blockquote) n'en a pas besoin.
    spec7 = spec_rd.replace("> ", "")
    up_entite = sum(1 for r in src_roles if r.get("scope_donnees") == "entite")
    portails = {r.get("portail") for r in src_roles}
    metier = portails - {"plateforme"}
    if "plateforme" not in portails:
        bad("RBAC SPEC §7 · portail technique `plateforme` ABSENT du contrat "
            "rbac_50_roles.json → la note « console technique plateforme » est fausse")
    exp_up = str(up_entite)
    m_up = re.search(r"(\d+) templates `Company` \(portée `entite`\)", spec7)
    if m_up is None:
        bad(f"RBAC SPEC §7 · « N templates Company (portée entite) » INTROUVABLE "
            f"dans {SPEC_RD} (attendu {exp_up})")
    elif m_up.group(1) == exp_up:
        good(f"RBAC SPEC §7 · UP templates {m_up.group(1)} == |scope==entite| "
             f"rbac_50_roles.json ({exp_up})")
    else:
        bad(f"RBAC SPEC §7 · {SPEC_RD} dit {m_up.group(1)} templates Company MAIS "
            f"rbac_50_roles.json compte {exp_up} rôles `entite` → corriger la SPEC")
    exp_prof, exp_metier, exp_bij = str(len(portails)), str(len(metier)), str(len(src_roles))
    m_pr = re.search(
        r"\*\*(\d+) profils\*\* \((\d+) portails métier \+ console technique "
        r"`plateforme`\) couvrant les \*\*(\d+) rôles de façon bijective\*\*", spec7)
    if m_pr is None:
        bad(f"RBAC SPEC §7 · « N profils (M portails métier …) couvrant les K rôles "
            f"de façon bijective » INTROUVABLE dans {SPEC_RD} "
            f"(attendu {exp_prof}/{exp_metier}/{exp_bij})")
    elif m_pr.groups() == (exp_prof, exp_metier, exp_bij):
        good(f"RBAC SPEC §7 · Role Profiles {m_pr.group(1)} (métier {m_pr.group(2)}) "
             f"bijectifs sur {m_pr.group(3)} rôles == rbac_50_roles.json "
             f"({exp_prof}/{exp_metier}/{exp_bij})")
    else:
        bad(f"RBAC SPEC §7 · {SPEC_RD} dit {'/'.join(m_pr.groups())} MAIS "
            f"rbac_50_roles.json compte {exp_prof}/{exp_metier}/{exp_bij} "
            f"(profils=portails distincts / métier=hors plateforme / rôles) "
            f"→ corriger la SPEC")

# 1sexies) RBAC_50_ROLES_SPEC.md §4 — colonne « Entité » ancrée sur CLAUDE.md §Entités
# La table §4 « Cartographie rôles ↔ entités » liste, en colonne de tête, les entités
# porteuses (`entite_principale`) du RBAC : les 7 entités NOMMÉES du mandat (WAF · WA
# SRL · AC Arias Cuevas · Consortium ECR DR · Helios RD · Ploutos · 9060 QC) + le
# périmètre consolidé « Groupe ». Cette colonne a DEUX sources faisant autorité — (i)
# CLAUDE.md §Entités (l'orthographe + le descripteur parenthétique de chaque entité
# nommée) et (ii) rbac_50_roles.json (l'ENSEMBLE des `entite_principale` réellement
# portées par les 50 rôles) — mais AUCUN gate ne la touchait : le §3 gate les comptes
# par PORTAIL, le §7 les comptes du run-book, les blocs fiches l'`entite` d'UN rôle ;
# tous AVEUGLES à l'identité de CETTE liste. AJOUTER au contrat un rôle porté par une
# entité absente de §4, RENOMMER une entité dans CLAUDE.md §Entités (9060 QC→9061 QC)
# ou DÉRIVER un descripteur laisse la SPEC contredire le mandat/le contrat EN SILENCE
# — « vert trompeur » de la MÊME classe que les entités CONFOTUR (§Entités) ou la
# marque SEO org, qu'aucune suite tests/ (FONCTIONS RBAC, jamais la prose de la SPEC)
# n'attrape.
# GATE : (a) ENSEMBLE des entités de §4 == distinct `entite_principale` du contrat
# (set-diff : ni fantôme ni manquante) ; (b) ANCRAGE — chaque entité NOMMÉE de
# CLAUDE.md §Entités est une ligne de §4 avec son descripteur parenthétique EXACT
# (absence comprise) ; (c) chaque entité nommée du mandat est PORTÉE ≥1× par le
# contrat (cm_ents ⊆ contrat). Un claim absent échoue AUSSI (traçabilité #6).
if src_roles is not None and spec_rd is not None:
    try:
        _cm4 = open("CLAUDE.md", encoding="utf-8").read()
        _sec4 = re.search(r"^## Entités\s*\n(.*?)(?=^## )", _cm4, re.S | re.M)
        if not _sec4:
            raise ValueError("section « ## Entités » introuvable")
        # (entité → descripteur parenthétique | None), dans l'ordre du mandat
        cm4 = [(m.group(1).strip(), (m.group(2).strip() if m.group(2) else None))
               for m in re.finditer(r"\*\*([^*]+?)\*\*(?:\s*\(([^)]+)\))?", _sec4.group(1))]
        if not cm4:
            raise ValueError("aucune entité en gras dans §Entités")
        if len({e for e, _ in cm4}) != len(cm4):
            raise ValueError(f"entités non uniques : {[e for e, _ in cm4]}")
    except (OSError, ValueError) as e:
        bad(f"RBAC SPEC §4 · CLAUDE.md §Entités illisible/inattendu : {e}"); cm4 = None
    _m4 = re.search(r"## 4\..*?(?=## 5\.)", spec_rd)
    if _m4 is None:
        bad(f"RBAC SPEC §4 · section « ## 4. » INTROUVABLE dans {SPEC_RD} "
            f"(claim absent = régression #6)"); sec4 = None
    else:
        sec4 = _m4.group(0)
    if cm4 is not None and sec4 is not None:
        # Colonne de tête : « | **Entité** (desc)? | » (le header non-gras et le
        # séparateur ne matchent pas ; la colonne « Exemples de rôles » n'a pas de gras)
        spec4 = [(m.group(1).strip(), (m.group(2).strip() if m.group(2) else None))
                 for m in re.finditer(r"\| \*\*([^*|]+?)\*\*(?: \(([^)|]+)\))? \|", sec4)]
        spec4_names = {n for n, _ in spec4}
        contract_ents = {r.get("entite_principale") for r in src_roles}
        # (a) ENSEMBLE §4 == distinct entite_principale du contrat
        _miss = sorted(contract_ents - spec4_names)   # portées mais absentes de §4
        _phan = sorted(spec4_names - contract_ents)   # listées mais jamais portées
        if _miss or _phan:
            bad(f"RBAC SPEC §4 · (a) colonne Entité ≠ distinct `entite_principale` du "
                f"contrat — manquantes={_miss} · fantômes={_phan} → corriger la SPEC "
                f"(ou régénérer si le contrat a changé)")
        else:
            good(f"RBAC SPEC §4 · (a) {len(spec4_names)} entités == distinct "
                 f"`entite_principale` rbac_50_roles.json (ni fantôme ni manquante)")
        # (b) ANCRAGE — chaque entité NOMMÉE de CLAUDE.md est une ligne §4 · descripteur exact
        spec4_map = dict(spec4)
        for _e, _d in cm4:
            if _e not in spec4_map:
                bad(f"RBAC SPEC §4 · (b) entité canonique `{_e}` (CLAUDE.md §Entités) "
                    f"ABSENTE de la colonne §4 → SPEC périmée")
            elif spec4_map[_e] != _d:
                bad(f"RBAC SPEC §4 · (b) `{_e}` — descripteur §4 {spec4_map[_e]!r} ≠ "
                    f"CLAUDE.md §Entités {_d!r} → aligner sur le mandat")
            else:
                good(f"RBAC SPEC §4 · (b) `{_e}` ({_d or 'sans descripteur'}) == "
                     f"CLAUDE.md §Entités")
        # (c) chaque entité nommée du mandat est PORTÉE ≥1× par le contrat
        _unused = sorted(e for e, _ in cm4 if e not in contract_ents)
        if _unused:
            bad(f"RBAC SPEC §4 · (c) entités du mandat JAMAIS portées par un rôle : "
                f"{_unused} → entité décorative (contrat ou §Entités à revoir)")
        else:
            good(f"RBAC SPEC §4 · (c) les {len(cm4)} entités nommées du mandat sont "
                 f"portées ≥1× par le contrat")

# 1septies) RBAC_50_ROLES_SPEC.md §5 — « Les N rôles (résumé) » : compte-titre,
# comptes de regroupement éditoriaux et LIGNE d'auto-contrôle arithmétique -------
# Le §5 « résumé » restitue le total de rôles sur TROIS surfaces prose qu'aucun
# gate ne touchait — (i) le TITRE « ## 5. Les N rôles (résumé) », (ii) NEUF comptes
# parenthétiques de regroupement (« **Ventes (8)** » …) et (iii) une LIGNE
# d'auto-contrôle arithmétique « _(6+8+…+6 = 50.)_ ». Le §3 gate la ventilation par
# PORTAIL, le §7 le run-book, le §4 les entités — TOUS aveugles à ce §5. NB : les 9
# regroupements sont ÉDITORIAUX (« Direction / Gouvernance » agrège des rôles de
# familles direction+finance ; ≠ la colonne `famille` du contrat, cf. memory
# rbac-spec-portail-count-gate « §5 by-family is editorial ») → on NE mappe PAS
# groupe↔famille (ce serait une dérive fausse). On gate ce qui EST data-derived et
# vérifiable SANS ce mapping : (a) le compte-titre N == |rôles| du contrat (byte-
# gaté par check_artifacts) ; (b) les 9 comptes de regroupement == EXACTEMENT, dans
# l'ordre, les termes de la ligne arithmétique (cohérence interne prose↔somme) ;
# (c) l'arithmétique est VRAIE (Σ termes == membre droit) ; (d) le membre droit ==
# |rôles|. AJOUTER un rôle au contrat (|rôles| 50→51) laisse le titre, la somme et
# le « = 50 » PÉRIMÉS en silence ; ÉDITER « Ventes (8) »→« (7) » sans toucher la
# ligne casse (b) puis (c). Section/titre/ligne/comptes absents = échec (#6).
if src_roles is not None and spec_rd is not None:
    N5 = len(src_roles)
    _m5 = re.search(r"## 5\..*?(?=## 6\.)", spec_rd)
    if _m5 is None:
        bad(f"RBAC SPEC §5 · section « ## 5. » INTROUVABLE dans {SPEC_RD} "
            f"(claim absent = régression #6)")
    else:
        sec5 = _m5.group(0)
        # (a) compte-titre « ## 5. Les N rôles »
        mt5 = re.search(r"## 5\. Les (\d+) rôles", sec5)
        if mt5 is None:
            bad(f"RBAC SPEC §5 · titre « ## 5. Les N rôles » INTROUVABLE dans {SPEC_RD}")
        elif mt5.group(1) == str(N5):
            good(f"RBAC SPEC §5 · (a) compte-titre {mt5.group(1)} == |rôles| "
                 f"rbac_50_roles.json ({N5})")
        else:
            bad(f"RBAC SPEC §5 · (a) titre dit {mt5.group(1)} rôles MAIS le contrat "
                f"en compte {N5} → corriger la SPEC")
        # 9 comptes de regroupement (gras · « (N) » collant au **fermant)
        heads = [int(m.group(1)) for m in
                 re.finditer(r"\*\*[^*]+?\((\d+)\)\*\*", sec5)]
        # ligne d'auto-contrôle « _(a+b+… = T.)_ »
        ma5 = re.search(r"_\(((?:\d+\+)+\d+) = (\d+)\.\)_", sec5)
        if ma5 is None:
            bad(f"RBAC SPEC §5 · ligne d'auto-contrôle « _(a+b+… = T.)_ » "
                f"INTROUVABLE dans {SPEC_RD} (claim absent = régression #6)")
        elif not heads:
            bad(f"RBAC SPEC §5 · aucun compte de regroupement « **… (N)** » trouvé "
                f"dans {SPEC_RD} (claim absent = régression #6)")
        else:
            addends = [int(x) for x in ma5.group(1).split("+")]
            rhs = int(ma5.group(2))
            # (b) comptes de regroupement == termes de la ligne, EN ORDRE
            if heads == addends:
                good(f"RBAC SPEC §5 · (b) {len(heads)} comptes de regroupement == "
                     f"termes de la ligne d'auto-contrôle ({'+'.join(map(str, addends))})")
            else:
                bad(f"RBAC SPEC §5 · (b) comptes de regroupement {heads} ≠ termes de "
                    f"la ligne {addends} (ordre/valeur) → aligner titres et somme")
            # (c) arithmétique vraie
            if sum(addends) == rhs:
                good(f"RBAC SPEC §5 · (c) Σ termes {'+'.join(map(str, addends))} = "
                     f"{rhs} (arithmétique exacte)")
            else:
                bad(f"RBAC SPEC §5 · (c) la ligne écrit « = {rhs} » MAIS Σ termes = "
                    f"{sum(addends)} → corriger la somme")
            # (d) membre droit == |rôles| du contrat
            if rhs == N5:
                good(f"RBAC SPEC §5 · (d) membre droit {rhs} == |rôles| "
                     f"rbac_50_roles.json ({N5})")
            else:
                bad(f"RBAC SPEC §5 · (d) la ligne totalise {rhs} MAIS le contrat "
                    f"compte {N5} rôles → SPEC périmée")

# 1octies) RBAC_50_ROLES_SPEC.md §2 — ligne « Row-level » : ENSEMBLE des `scope_donnees`
# ancré sur l'ENUM du schéma ---------------------------------------------------
# Le §2 « Modèle RBAC » ouvre par « trois niveaux, tous couverts PAR LE SCHÉMA » puis
# sa 3ᵉ ligne de table (« **Row-level** (User Permission) | `scope_donnees` | … ») ÉNUMÈRE
# À LA MAIN l'ensemble EXHAUSTIF des portées row-level : `own` · `equipe` · `entite`
# (Company) · `groupe` (consolidé). Cet ensemble EST, par la phrase d'intro, l'`enum`
# `scope_donnees` de `rbac.schema.json` (la DÉFINITION du modèle — pas le sous-ensemble
# effectivement exercé par les 50 rôles, qui peut être plus petit). AUCUN gate ne le
# touchait : §3 compte les rôles par portail, §4 les entités, §5 le résumé, le bloc
# userperm gate la TABLE `scope → mécanisme` d'un AUTRE artefact (userperm_gen/out) —
# tous AVEUGLES à l'énumération du §2. « Vert trompeur » : le schéma AJOUTE une portée
# (`region` pour un besoin ops réel) ou en RETIRE une (`own` supprimé, schéma resserré)
# et le §2 reste PÉRIMÉ en silence — il documente un modèle qui contredit le schéma
# tout en se réclamant « couvert par le schéma ». Ni check_artifacts (la SPEC n'est pas
# un out/*.json) ni tests/ (FONCTIONS de validation, jamais l'ancre SPEC↔schéma) ne
# mordent. Même classe que §4 (colonne ancrée sur une source-de-vérité structurée).
# GATE : (a) ENSEMBLE des jetons `scope` de la ligne §2 == `enum` scope_donnees du
# schéma (set-diff absent ET fantôme) ; (b) ANCRAGE — la colonne du milieu NOMME bien
# `scope_donnees` (sinon on gate la mauvaise ligne) ; (c) sanity — toute portée
# RÉELLEMENT exercée par le contrat ∈ enum (une portée en usage jamais dans l'enum =
# schéma incohérent). On NE gate PAS les gloses parenthétiques `(Company)`/`(consolidé)`
# (éditoriales · #6). Ligne/section/enum absents = échec (traçabilité #6).
if src_roles is not None and spec_rd is not None:
    try:
        _sch = load("rbac/rbac.schema.json")
        _enum = _sch["definitions"]["role"]["properties"]["scope_donnees"]["enum"]
        scope_enum = set(_enum)
        if not scope_enum:
            raise ValueError("enum scope_donnees vide")
        if len(scope_enum) != len(_enum):
            raise ValueError(f"enum scope_donnees non unique : {_enum}")
    except (OSError, KeyError, ValueError, TypeError) as e:
        bad(f"RBAC SPEC §2 · rbac.schema.json enum scope_donnees illisible : {e}")
        scope_enum = None
    _m2 = re.search(r"## 2\..*?(?=## 3\.)", spec_rd)
    if _m2 is None:
        bad(f"RBAC SPEC §2 · section « ## 2. » INTROUVABLE dans {SPEC_RD} "
            f"(claim absent = régression #6)")
    elif scope_enum is not None:
        # Ligne « Row-level » : capte la colonne « champ » (c2) et la colonne énum (c3)
        _row = re.search(r"\| \*\*Row-level\*\*[^|]*\|([^|]*)\|([^|]*)\|", _m2.group(0))
        if _row is None:
            bad(f"RBAC SPEC §2 · ligne « **Row-level** » INTROUVABLE dans {SPEC_RD} "
                f"(claim absent = régression #6)")
        else:
            c_field, c_enum = _row.group(1), _row.group(2)
            # (b) la colonne du milieu doit nommer le champ `scope_donnees`
            if "scope_donnees" not in c_field:
                bad(f"RBAC SPEC §2 · (b) colonne « champ » de la ligne Row-level ne "
                    f"nomme pas `scope_donnees` (capté {c_field!r}) → ligne mal ancrée")
            else:
                good("RBAC SPEC §2 · (b) ligne Row-level ancrée sur le champ "
                     "`scope_donnees`")
                spec_scopes = set(re.findall(r"`([a-z_]+)`", c_enum))
                _miss = sorted(scope_enum - spec_scopes)   # dans l'enum, absents du §2
                _phan = sorted(spec_scopes - scope_enum)   # listés au §2, hors enum
                if _miss or _phan:
                    bad(f"RBAC SPEC §2 · (a) énumération `scope_donnees` ≠ enum du "
                        f"schéma — manquants={_miss} · fantômes={_phan} → aligner la "
                        f"SPEC sur rbac.schema.json (ou régénérer si le schéma a changé)")
                else:
                    good(f"RBAC SPEC §2 · (a) {len(spec_scopes)} portées row-level "
                         f"{sorted(spec_scopes)} == enum scope_donnees du schéma")
        # (c) sanity : toute portée exercée par le contrat ∈ enum
        _used = {r.get("scope_donnees") for r in src_roles}
        _illegal = sorted(_used - scope_enum)
        if _illegal:
            bad(f"RBAC SPEC §2 · (c) portées `scope_donnees` exercées par le contrat "
                f"HORS enum du schéma : {_illegal} → schéma/contrat incohérents")
        else:
            good(f"RBAC SPEC §2 · (c) les {len(_used)} portées exercées par le contrat "
                 f"sont toutes dans l'enum du schéma")

# 2) apply_plan — ligne « Génération réelle » agrégeant les 3 volets ------------
AP_RD = f"{D}/rbac/apply_plan/README.md"
try:
    ap_man = load("rbac/apply_plan/out/MANIFEST.json")
    ap_steps = len(load("rbac/apply_plan/out/apply_plan.json"))
except (OSError, KeyError, ValueError) as e:
    bad(f"RBAC apply_plan · artefact illisible : {e}"); ap_man = None
ap_rd = read_norm(AP_RD)
if ap_man is not None and ap_rd is not None:
    c, v = ap_man["counts"], ap_man["confirmations_vps"]
    ap_exp = (str(ap_steps), str(c["roles"]), str(c["custom_docperm"]),
              str(c["user_permission_templates"]), str(c["role_profiles"]),
              str(len(v["custom_doctypes"])), str(len(v["companies"])),
              str(len(v["roles_scope_equipe"])))
    ap_src = (f"étapes={ap_exp[0]} rôles={ap_exp[1]} docperm={ap_exp[2]} "
              f"up={ap_exp[3]} role_profiles={ap_exp[4]} custom_dt={ap_exp[5]} "
              f"companies={ap_exp[6]} equipe={ap_exp[7]}")
    ap_pat = (r"\*\*(\d+) étapes\*\* · (\d+) rôles / (\d+) DocPerm / "
              r"(\d+) UP templates / (\d+) Role Profile · confirmations VPS "
              r"(\d+) DocType custom \+ (\d+) Company \+ (\d+) rôles")
    ma = re.search(ap_pat, ap_rd)
    if ma is None:
        bad(f"RBAC apply_plan · « Génération réelle » — claim INTROUVABLE dans "
            f"{AP_RD} (attendu {ap_src})")
    elif ma.groups() == ap_exp:
        good(f"RBAC apply_plan · agrégat — {AP_RD} {'/'.join(ma.groups())} == "
             f"MANIFEST ({ap_src})")
    else:
        bad(f"RBAC apply_plan · {AP_RD} dit {'/'.join(ma.groups())} MAIS MANIFEST "
            f"dit {'/'.join(ap_exp)} ({ap_src}) → régénérer le README")

# 2bis) apply_plan — IDENTITÉ de la TABLE « Run-book généré » (SPEC §7) ----------
# Le bloc 2 ci-dessus ne gate que la ligne AGRÉGÉE « Génération réelle » (comptes).
# La table « ## Run-book généré » énumère, PAR étape, trois données data-derived de
# apply_plan.json (byte-gaté par check_artifacts) : le n° d'ordre (#), le
# RESPONSABLE (worker/vps/worker+vps) et la colonne « Dépend de » (les n°s d'ordre
# des `depends_on`). C'est le GRAPHE D'APPLICATION ORDONNÉ que l'agent ERPNext suit
# pas à pas — un responsable ou une dépendance périmés (ex. Role Profile importé
# AVANT les Role, ou une étape VPS attribuée au worker) sont un hazard réel,
# invisible à check_artifacts (qui ne prouve QUE apply_plan==build) ET au bloc de
# comptes (aveugle à l'identité des lignes ; ces colonnes étaient un WILDCARD).
# Même classe que la colonne « Type » de roleprofile_gen et la table demo/scenarios.
# On RECOMPUTE #/responsable/deps depuis apply_plan.json (zéro duplication) et on
# exige que la table les énonce EXACTEMENT. Une étape MANQUANTE ou une ligne FANTÔME
# échoue AUSSI (identité d'ensemble). La colonne « Étape » reste libre (paraphrase).
# Cohérences croisées en bonus (mordent un plan INTERNEMENT incohérent) : ordres
# contigus 1..N sans doublon · responsable ∈ {worker, vps, worker+vps} · toute
# dépendance pointe en ARRIÈRE (n° < n° de l'étape ⇒ pas de cycle/renvoi avant).
try:
    ap_plan = load("rbac/apply_plan/out/apply_plan.json")
except (OSError, KeyError, ValueError) as e:
    bad(f"RBAC apply_plan · run-book illisible : {e}"); ap_plan = None
if ap_plan is not None:
    id2order = {s["id"]: s["order"] for s in ap_plan}
    ALLOWED_RESP = {"worker", "vps", "worker+vps"}
    orders = sorted(s["order"] for s in ap_plan)
    if orders == list(range(1, len(ap_plan) + 1)):
        good(f"RBAC apply_plan · run-book — ordres contigus 1..{len(ap_plan)} "
             f"(aucun trou/doublon)")
    else:
        bad(f"RBAC apply_plan · run-book — ordres NON contigus {orders} "
            f"(attendu 1..{len(ap_plan)}) → plan incohérent")
    # Lignes de la table lues en RAW : la colonne 1 ENTIÈRE ⇒ scope la table
    # run-book (seule table du README à 1ʳᵉ colonne numérique · vérifié).
    try:
        ap_lines = open(AP_RD, encoding="utf-8").read().splitlines()
    except OSError as e:
        bad(f"RBAC apply_plan · {AP_RD} illisible : {e}"); ap_lines = []
    table_orders, rows_by_order = [], {}
    for ln in ap_lines:
        cells = [c.strip() for c in ln.strip().strip("|").split("|")]
        if len(cells) == 4 and re.fullmatch(r"\d+", cells[0]):
            o = int(cells[0]); table_orders.append(o); rows_by_order[o] = cells
    if sorted(table_orders) == orders and len(table_orders) == len(set(table_orders)):
        good(f"RBAC apply_plan · run-book — {len(table_orders)} lignes == "
             f"{len(ap_plan)} étapes (aucune FANTÔME/MANQUANTE)")
    else:
        bad(f"RBAC apply_plan · run-book — lignes table {sorted(table_orders)} ≠ "
            f"étapes artefact {orders} (ligne en trop/manquante ou doublon)")
    for s in sorted(ap_plan, key=lambda x: x["order"]):
        o, resp = s["order"], s["responsable"]
        deps = sorted(id2order[x] for x in s["depends_on"])
        if resp not in ALLOWED_RESP:
            bad(f"RBAC apply_plan · étape {o} — responsable artefact « {resp} » "
                f"hors {sorted(ALLOWED_RESP)} (plan incohérent)")
        if any(d >= o for d in deps):
            bad(f"RBAC apply_plan · étape {o} — dépendance en AVANT {deps} "
                f"(≥ {o}) : graphe incohérent")
        row = rows_by_order.get(o)
        if row is None:
            bad(f"RBAC apply_plan · étape {o} — ligne INTROUVABLE dans {AP_RD} "
                f"(attendu responsable={resp} deps={deps or '—'})"); continue
        got_resp = row[1]
        got_deps = sorted(int(x) for x in re.findall(r"\d+", row[3]))
        if got_resp == resp:
            good(f"RBAC apply_plan · étape {o} — responsable « {got_resp} » == artefact")
        else:
            bad(f"RBAC apply_plan · étape {o} — {AP_RD} dit responsable « {got_resp} » "
                f"MAIS artefact dit « {resp} » → régénérer le README")
        if got_deps == deps:
            good(f"RBAC apply_plan · étape {o} — Dépend de {got_deps or '—'} == artefact")
        else:
            bad(f"RBAC apply_plan · étape {o} — {AP_RD} « Dépend de » = {got_deps or '—'} "
                f"MAIS artefact dit {deps or '—'} → régénérer le README")

# 3) userperm — ventilation par mécanisme d'enforcement -------------------------
UP_RD = f"{D}/rbac/userperm_gen/README.md"
try:
    up_bm = load("rbac/userperm_gen/out/MANIFEST.json")["counts"]["by_mechanism"]
except (OSError, KeyError, ValueError) as e:
    bad(f"RBAC userperm · MANIFEST illisible : {e}"); up_bm = None
up_rd = read_norm(UP_RD)
if up_bm is not None and up_rd is not None:
    up_exp = (str(up_bm["user_permission_company"]), str(up_bm["none_consolidated"]),
              str(up_bm["docperm_if_owner"]), str(up_bm["vps_confirm_team"]))
    up_src = (f"entite={up_exp[0]} groupe={up_exp[1]} own={up_exp[2]} "
              f"equipe={up_exp[3]}")
    up_pat = (r"\*\*(\d+)\*\* `entite` · \*\*(\d+)\*\* `groupe` · "
              r"\*\*(\d+)\*\* `own` · \*\*(\d+)\*\* `equipe`")
    mu = re.search(up_pat, up_rd)
    if mu is None:
        bad(f"RBAC userperm · répartition — claim INTROUVABLE dans {UP_RD} "
            f"(attendu {up_src})")
    elif mu.groups() == up_exp:
        good(f"RBAC userperm · répartition — {UP_RD} {'/'.join(mu.groups())} == "
             f"MANIFEST ({up_src})")
    else:
        bad(f"RBAC userperm · {UP_RD} dit {'/'.join(mu.groups())} MAIS MANIFEST "
            f"dit {'/'.join(up_exp)} ({up_src}) → régénérer le README")

# ============================================================================
# SEO · comptes de mots-clés (total + ventilation par langue) —
# même classe de « vert trompeur », transcrits À LA MAIN dans DEUX docs.
# ----------------------------------------------------------------------------
# Le générateur SEO émet out/MANIFEST.json dont le bloc `counts` porte
# keywords_total et keywords_per_lang{fr,en,es}, RECOMPTÉS à chaque build depuis
# `projets_master.json` (byte-gaté par check_artifacts ⇒ source faisant autorité).
# Ce quadruplet est recopié À LA MAIN à DEUX endroits : la table « Livrables » du
# README du module (05_deliverables_mvp/seo/README.md — « **258** mots-clés
# (fr=87 · en=87 · es=84) ») ET la même ligne de la fiche agent
# (03_agents/seo/AGENT.md). Aucun gate ne les couvrait : ajouter un projet ou du
# vocabulaire éditorial ferait dériver LES DEUX docs en silence pendant que
# l'artefact dit autre chose — même piège que les triplets par workspace des
# portails. On RECOMPUTE ici (total + 3 langues) depuis MANIFEST.counts (déjà
# byte-gaté · zéro duplication du compteur du générateur) et on exige l'égalité
# avec les DEUX docs. Un claim absent échoue AUSSI (traçabilité).
# ============================================================================
SEO_RD = f"{D}/seo/README.md"
SEO_FI = "03_agents/seo/AGENT.md"
try:
    seo_c = load("seo/out/MANIFEST.json")["counts"]
    seo_exp = (str(seo_c["keywords_total"]),
               str(seo_c["keywords_per_lang"]["fr"]),
               str(seo_c["keywords_per_lang"]["en"]),
               str(seo_c["keywords_per_lang"]["es"]))
except (OSError, KeyError, ValueError) as e:
    bad(f"SEO · MANIFEST illisible/inattendu : {e}"); seo_exp = None
if seo_exp is not None:
    seo_src = (f"total={seo_exp[0]} fr={seo_exp[1]} en={seo_exp[2]} es={seo_exp[3]}")
    seo_pat = r"\*\*(\d+)\*\* mots-clés \(fr=(\d+) · en=(\d+) · es=(\d+)\)"
    for path_f in (SEO_RD, SEO_FI):
        txt = read_norm(path_f)
        if txt is None:
            continue
        m = re.search(seo_pat, txt)
        if m is None:
            bad(f"SEO · {path_f} — claim mots-clés INTROUVABLE (attendu {seo_src})")
        elif m.groups() == seo_exp:
            good(f"SEO · {path_f} — {'/'.join(m.groups())} == MANIFEST ({seo_src})")
        else:
            bad(f"SEO · {path_f} — dit {'/'.join(m.groups())} MAIS MANIFEST dit "
                f"{'/'.join(seo_exp)} ({seo_src}) → régénérer le doc")

# ============================================================================
# SEO · IDENTITÉ du COMPTE d'INVARIANTS CI — le README annonce « ## Garanties
# (N invariants) » et le générateur se SELF-REPORTE (« schéma + N invariants »)
# ----------------------------------------------------------------------------
# CLASSE DISTINCTE du bloc SEO ci-dessus (qui gate des COMPTES d'ARTEFACT —
# mots-clés total + par langue, recomptés de MANIFEST.counts). Ici la donnée
# gardée est le COMPTE D'INVARIANTS de validation, dont la source d'autorité
# n'est PAS un artefact JSON (invisible à check_artifacts qui byte-gate les
# out/*.json) mais le LEDGER NUMÉROTÉ de la fonction `_validate` du générateur :
# les marqueurs `# N ·` (1 · 2 · … · 15) qui ouvrent chaque contrôle appliqué —
# exactement l'analogue du registre `# Invariant N` de Chat OTOIA. Ce compte est
# transcrit À LA MAIN à SEPT endroits qu'AUCUN gate ne couvrait : le titre de
# section du README (« ## Garanties (N invariants) ») + son bloc de commande
# (« schéma de sortie + N invariants »), plus CINQ chaînes du .py lui-même
# (docstring module, docstring `_validate`, aide `build`, message de succès CLI
# « N invariants verts. », aide du sous-parseur `validate`). Ajouter un contrôle
# `# 16 ·` à `_validate` sans toucher les chaînes ⇒ le README ET les self-reports
# du .py mentent en silence pendant que la CI applique 16 contrôles — « vert
# trompeur » (#6) que ni les suites tests/ (FONCTIONS, jamais la prose) ni le
# byte-gate d'artefact (aveugle au .py source) n'attrapent. On RECOMPUTE ici le
# ledger depuis le SEUL registre-source (les `# N ·` de `_validate`) et on exige :
# (a) le ledger est CONTIGU 1..N (lacune/doublon dans les marqueurs = défaut réel
# du registre) ; (b) TOUTE mention « N invariants » du .py (self-reports) == |ledger|
# (un seul chiffre désynchronisé mordu) ; (c) le compte annoncé du README == |ledger| ;
# (d) TOUTE mention « N invariants » du README == |ledger|. La prose du README
# RÉSUME les contrôles (≠ énumération numérotée 1:1) : on gate le COMPTE partout
# où il apparaît, pas un set-diff de la liste. Section/compte absent = échec (la
# disparition d'un chiffre est une régression de traçabilité · #6).
# ============================================================================
SEO_GEN = f"{D}/seo/seo_gen.py"
try:
    seo_src = open(SEO_GEN, encoding="utf-8").read()
except OSError as e:
    bad(f"SEO invariants · générateur {SEO_GEN} illisible : {e}"); seo_src = None
if seo_src is not None:
    # (a) registre-source = marqueurs `# N ·` du LEDGER de `_validate` (autorité).
    _mfn = re.search(r"def _validate\b", seo_src)
    if _mfn is None:
        bad(f"SEO invariants · fonction `_validate` INTROUVABLE dans {SEO_GEN} "
            "(registre-source du ledger absent → impossible de re-dériver)")
    else:
        _bs = _mfn.end()
        _mn = re.search(r"\ndef ", seo_src[_bs:])
        _body = seo_src[_bs: _bs + _mn.start()] if _mn else seo_src[_bs:]
        seo_led = [int(n) for n in re.findall(r"#\s*(\d+)\s*·", _body)]
        if not seo_led:
            bad(f"SEO invariants · aucun marqueur `# N ·` dans `_validate` de {SEO_GEN} "
                "(ledger vide → impossible de re-dériver)")
        else:
            seo_ninv = len(seo_led)
            if sorted(seo_led) != list(range(1, seo_ninv + 1)):
                bad(f"SEO invariants · ledger `_validate` NON contigu : {seo_led} "
                    f"(attendu 1..{seo_ninv}) — lacune/doublon dans les marqueurs `# N ·`")
            else:
                good(f"SEO invariants · ledger `_validate` contigu 1..{seo_ninv} "
                     f"({seo_ninv} marqueurs `# N ·`)")
            # (b) self-reports du .py « N invariants » (docstrings + aides + succès CLI).
            seo_py_rep = sorted({int(x) for x in re.findall(r"(\d+)\s+invariants", seo_src)})
            if not seo_py_rep:
                bad(f"SEO invariants · aucune mention « N invariants » dans {SEO_GEN} "
                    f"(self-report absent → attendu {seo_ninv})")
            elif seo_py_rep == [seo_ninv]:
                good(f"SEO invariants · {SEO_GEN} — self-report(s) {seo_py_rep} "
                     f"== ledger ({seo_ninv})")
            else:
                bad(f"SEO invariants · {SEO_GEN} — self-report(s) « N invariants » "
                    f"{seo_py_rep} MAIS ledger en compte {seo_ninv} "
                    "(une chaîne du générateur désynchronisée du registre)")
            # README : titre de section + toute mention « N invariants ».
            try:
                seo_rd_raw = open(SEO_RD, encoding="utf-8").read()
            except OSError as e:
                bad(f"SEO invariants · {SEO_RD} illisible : {e}"); seo_rd_raw = None
            if seo_rd_raw is not None:
                _mh = re.search(r"##\s+Garanties\s+\((\d+)\s+invariants\)", seo_rd_raw)
                if _mh is None:
                    bad(f"SEO invariants · {SEO_RD} — section « ## Garanties (N invariants) » "
                        f"INTROUVABLE (attendu {seo_ninv})")
                else:
                    # (c) compte annoncé.
                    if int(_mh.group(1)) == seo_ninv:
                        good(f"SEO invariants · {SEO_RD} — annonce ({_mh.group(1)}) "
                             f"== ledger ({seo_ninv})")
                    else:
                        bad(f"SEO invariants · {SEO_RD} — annonce ({_mh.group(1)}) MAIS "
                            f"ledger en compte {seo_ninv} → régénérer le README")
                    # (d) toute mention « N invariants » du README == ledger.
                    seo_rd_rep = sorted({int(x) for x in re.findall(r"(\d+)\s+invariants", seo_rd_raw)})
                    if seo_rd_rep == [seo_ninv]:
                        good(f"SEO invariants · {SEO_RD} — mention(s) {seo_rd_rep} "
                             f"== ledger (aucune dérive)")
                    else:
                        bad(f"SEO invariants · {SEO_RD} — mention(s) « N invariants » "
                            f"{seo_rd_rep} MAIS ledger en compte {seo_ninv} → régénérer le README")

# ============================================================================
# Fiscal · e-CF DGII · IDENTITÉ du COMPTE D'INVARIANTS de `_validate` — même
# classe que le ledger SEO / Chat OTOIA, appliquée au générateur fiscal.
# ----------------------------------------------------------------------------
# CLASSE DISTINCTE des blocs Fiscal existants (format e-NCF · cross-cohérence
# emission_events · rôle émetteur — qui gatent le CONTENU de out/ecf_plan.json).
# Ici la donnée gardée est le COMPTE d'invariants de cross-cohérence appliqués
# par `_validate` d'ecf_dgii_gen.py, dont la source d'autorité n'est PAS un
# artefact JSON (invisible à check_artifacts qui byte-gate les out/*.json) mais
# le LEDGER NUMÉROTÉ de la fonction : les marqueurs `# N ·` qui ouvrent chaque
# contrôle — analogue du registre SEO/Chat OTOIA. Le nombre « 12 » est transcrit
# À LA MAIN à SEPT endroits qu'AUCUN gate ne couvrait : QUATRE chaînes du .py
# (docstring module, docstring `_validate`, message de succès CLI « … 12
# invariants verts. », aide du sous-parseur `validate`) + TROIS du README (bloc
# de commande « schéma + 12 invariants », titre « ## Les 12 invariants », prose
# de score « 12 invariants de cross-cohérence »). Ajouter un contrôle `# 13 ·` à
# `_validate` sans toucher les chaînes ⇒ le README ET les self-reports du .py
# mentent en silence pendant que la CI applique 13 contrôles — « vert trompeur »
# (#6) que ni les suites tests/ (FONCTIONS, jamais la prose) ni le byte-gate
# d'artefact (aveugle au .py source) n'attrapent. On RECOMPUTE ici le ledger
# depuis le SEUL registre-source (les `# N ·` de `_validate`) et on exige :
# (a) ledger CONTIGU 1..N — l'invariant 1 est la validation de SCHÉMA (ligne
#     `validate(bundle, schema)`, non marquée `# 1 ·`) ; les marqueurs couvrent
#     2..N ; l'invariant 12 est SPLITTÉ en `# 12a ·`/`# 12b ·` (deux volets d'un
#     même invariant, comptés UNE fois) — un doublon d'un numéro NON suffixé est
#     lui un vrai défaut de registre et MORD ; (b) TOUTE mention « N invariants »
#     du .py (self-reports) == |ledger| ; (c) le compte annoncé du titre README
#     == |ledger| ; (d) TOUTE mention « N invariants » du README == |ledger| ;
# (e) l'ÉNUMÉRATION numérotée du README (section « ## Les N invariants », prose
#     « 1. … 2. … N. ») est elle-même CONTIGUË 1..N (la liste ne saute/duplique
#     pas un item). Section/compte absent = échec (disparition d'un chiffre =
#     régression de traçabilité · #6).
# ============================================================================
ECF_GEN = f"{D}/fiscal/ecf_dgii/ecf_dgii_gen.py"
ECF_RD_INV = f"{D}/fiscal/ecf_dgii/README.md"
try:
    ecf_src = open(ECF_GEN, encoding="utf-8").read()
except OSError as e:
    bad(f"Fiscal invariants · générateur {ECF_GEN} illisible : {e}"); ecf_src = None
if ecf_src is not None:
    _mfn = re.search(r"def _validate\b", ecf_src)
    if _mfn is None:
        bad(f"Fiscal invariants · fonction `_validate` INTROUVABLE dans {ECF_GEN} "
            "(registre-source du ledger absent → impossible de re-dériver)")
    else:
        _bs = _mfn.end()
        _mn = re.search(r"\ndef ", ecf_src[_bs:])
        _body = ecf_src[_bs: _bs + _mn.start()] if _mn else ecf_src[_bs:]
        # marqueurs `# N ·` / `# Na ·` (suffixe lettre = volet d'un même invariant)
        _pairs = re.findall(r"#\s*(\d+)([a-z]?)\s*·", _body)
        # doublon d'un numéro NON suffixé = vrai défaut (≠ split 12a/12b légitime)
        _dup_bare = sorted({n for n in {p[0] for p in _pairs}
                            if [s for num, s in _pairs if num == n and s == ""]
                            and sum(1 for num, _ in _pairs if num == n) > 1})
        _schema = bool(re.search(r"validate\(bundle,\s*schema\)", _body))
        _top = {int(n) for n, _ in _pairs}
        if not _top:
            bad(f"Fiscal invariants · aucun marqueur `# N ·` dans `_validate` de "
                f"{ECF_GEN} (ledger vide → impossible de re-dériver)")
        elif _dup_bare:
            bad(f"Fiscal invariants · marqueur(s) `# N ·` dupliqué(s) sans suffixe "
                f"a/b dans `_validate` : {_dup_bare} — vrai doublon de registre")
        elif not _schema:
            bad(f"Fiscal invariants · l'invariant 1 (validation de schéma, "
                f"`validate(bundle, schema)`) INTROUVABLE dans `_validate` de {ECF_GEN} "
                "— le ledger ne peut pas débuter à 1")
        else:
            ecf_led = sorted(_top | {1})   # 1 = validation de schéma (non marquée)
            ecf_ninv = len(ecf_led)
            if ecf_led != list(range(1, ecf_ninv + 1)):
                bad(f"Fiscal invariants · ledger `_validate` NON contigu : {ecf_led} "
                    f"(attendu 1..{ecf_ninv}) — lacune dans les marqueurs `# N ·` "
                    "(schéma = 1, marqueurs = 2..N)")
            else:
                good(f"Fiscal invariants · ledger `_validate` contigu 1..{ecf_ninv} "
                     f"(schéma #1 + {len(_top)} marqueurs `# N ·`, 12a/12b comptés 1×)")
            # (b) self-reports du .py « N invariants ».
            ecf_py_rep = sorted({int(x) for x in re.findall(r"(\d+)\s+invariants", ecf_src)})
            if not ecf_py_rep:
                bad(f"Fiscal invariants · aucune mention « N invariants » dans {ECF_GEN} "
                    f"(self-report absent → attendu {ecf_ninv})")
            elif ecf_py_rep == [ecf_ninv]:
                good(f"Fiscal invariants · {ECF_GEN} — self-report(s) {ecf_py_rep} "
                     f"== ledger ({ecf_ninv})")
            else:
                bad(f"Fiscal invariants · {ECF_GEN} — self-report(s) « N invariants » "
                    f"{ecf_py_rep} MAIS ledger en compte {ecf_ninv} "
                    "(une chaîne du générateur désynchronisée du registre)")
            # README : titre de section + toute mention + énumération numérotée.
            try:
                ecf_rd_raw = open(ECF_RD_INV, encoding="utf-8").read()
            except OSError as e:
                bad(f"Fiscal invariants · {ECF_RD_INV} illisible : {e}"); ecf_rd_raw = None
            if ecf_rd_raw is not None:
                _mh = re.search(r"##\s+Les\s+(\d+)\s+invariants", ecf_rd_raw)
                if _mh is None:
                    bad(f"Fiscal invariants · {ECF_RD_INV} — section « ## Les N invariants » "
                        f"INTROUVABLE (attendu {ecf_ninv})")
                else:
                    # (c) compte annoncé du titre.
                    if int(_mh.group(1)) == ecf_ninv:
                        good(f"Fiscal invariants · {ECF_RD_INV} — annonce ({_mh.group(1)}) "
                             f"== ledger ({ecf_ninv})")
                    else:
                        bad(f"Fiscal invariants · {ECF_RD_INV} — annonce ({_mh.group(1)}) MAIS "
                            f"ledger en compte {ecf_ninv} → régénérer le README")
                    # (d) toute mention « N invariants » du README == ledger.
                    ecf_rd_rep = sorted({int(x) for x in re.findall(r"(\d+)\s+invariants", ecf_rd_raw)})
                    if ecf_rd_rep == [ecf_ninv]:
                        good(f"Fiscal invariants · {ECF_RD_INV} — mention(s) {ecf_rd_rep} "
                             f"== ledger (aucune dérive)")
                    else:
                        bad(f"Fiscal invariants · {ECF_RD_INV} — mention(s) « N invariants » "
                            f"{ecf_rd_rep} MAIS ledger en compte {ecf_ninv} → régénérer le README")
                    # (e) énumération numérotée de la section == contiguë 1..N.
                    _msec = re.search(r"##\s+Les\s+\d+\s+invariants.*?(?=\n##\s|\Z)",
                                      ecf_rd_raw, re.S)
                    ecf_enum = ([int(x) for x in
                                 re.findall(r"(?<![\d.])(\d+)\.\s", _msec.group(0))]
                                if _msec else [])
                    if ecf_enum == list(range(1, ecf_ninv + 1)):
                        good(f"Fiscal invariants · {ECF_RD_INV} — énumération prose "
                             f"contiguë 1..{ecf_ninv} == ledger")
                    else:
                        bad(f"Fiscal invariants · {ECF_RD_INV} — énumération prose {ecf_enum} "
                            f"≠ 1..{ecf_ninv} (item sauté/dupliqué) → régénérer le README")

# ============================================================================
# Legal · CONFOTUR · IDENTITÉ du COMPTE D'INVARIANTS de `_validate` — 4ᵉ instance
# de la classe « ledger d'invariants » (après Chat OTOIA · SEO · Fiscal e-CF).
# ----------------------------------------------------------------------------
# Le générateur confotur_application_gen.py applique un COMPTE d'invariants de
# cross-cohérence dont la source d'autorité n'est PAS un artefact JSON (invisible
# à check_artifacts qui byte-gate les out/*.json) mais le LEDGER NUMÉROTÉ de
# `_validate` : les marqueurs `# N ·` (1..14) qui ouvrent chaque contrôle appliqué.
# Le nombre « 14 » est transcrit À LA MAIN à SIX endroits qu'AUCUN gate ne
# couvrait : QUATRE chaînes du .py (docstring module, docstring `_validate`,
# message de succès CLI « … 14 invariants verts. », aide du sous-parseur
# `validate`) + DEUX du README (bloc de commande « schéma + 14 invariants »,
# commentaire d'arborescence « CLI build/validate · 14 invariants »). Ajouter un
# contrôle `# 15 ·` à `_validate` sans toucher les chaînes ⇒ le README ET les
# self-reports du .py mentent en silence pendant que la CI applique 15 contrôles —
# « vert trompeur » (#6) que ni les suites tests/ (FONCTIONS de validation, jamais
# la prose) ni le byte-gate d'artefact (aveugle au .py source) n'attrapent.
# PARTICULARITÉ vs Fiscal : ledger PROPRE 1..N (pas de schéma-#1 non marqué, pas de
# split a/b) MAIS le README porte AUSSI un compte de SOUS-ENSEMBLE éditorial
# (« 2 invariants refusent tout champ monétaire ») À NE PAS confondre avec le total
# — on l'exclut par lookahead négatif `(?!\s+refusent)`, faute de quoi le gate
# mordrait l'arbre propre (le « 2 » n'est pas le compte du ledger). On RECOMPUTE le
# ledger depuis le SEUL registre-source (les `# N ·` de `_validate`) et on exige :
# (a) ledger CONTIGU 1..N (lacune OU doublon nu = vrai défaut de registre, mordu) ;
# (b) TOUTE mention-total « N invariants » du .py == |ledger| ; (c) TOUTE
# mention-total du README (hors sous-ensemble « refusent ») == |ledger|. Mention ou
# registre absent = échec (disparition d'un chiffre = régression de traçabilité #6).
# ============================================================================
CF_GEN = f"{D}/legal/confotur/confotur_application_gen.py"
CF_RD = f"{D}/legal/confotur/README.md"
# mention-total = « N invariants » NON suivi de « refusent » (le sous-ensemble
# éditorial anti-invention du README, ≠ compte total du ledger).
_CF_RX_TOT = r"(\d+)\s+invariants(?!\s+refusent)"
try:
    cf_src = open(CF_GEN, encoding="utf-8").read()
except OSError as e:
    bad(f"CONFOTUR invariants · générateur {CF_GEN} illisible : {e}"); cf_src = None
if cf_src is not None:
    _mfn = re.search(r"def _validate\b", cf_src)
    if _mfn is None:
        bad(f"CONFOTUR invariants · fonction `_validate` INTROUVABLE dans {CF_GEN} "
            "(registre-source du ledger absent → impossible de re-dériver)")
    else:
        _bs = _mfn.end()
        _mn = re.search(r"\ndef ", cf_src[_bs:])
        _body = cf_src[_bs: _bs + _mn.start()] if _mn else cf_src[_bs:]
        # marqueurs `# N ·` (le ledger confotur est propre : pas de suffixe a/b,
        # mais on garde la détection de doublon nu comme pour Fiscal).
        _pairs = re.findall(r"#\s*(\d+)([a-z]?)\s*·", _body)
        _dup_bare = sorted({n for n in {p[0] for p in _pairs}
                            if [s for num, s in _pairs if num == n and s == ""]
                            and sum(1 for num, _ in _pairs if num == n) > 1})
        _top = sorted({int(n) for n, _ in _pairs})
        if not _top:
            bad(f"CONFOTUR invariants · aucun marqueur `# N ·` dans `_validate` de "
                f"{CF_GEN} (ledger vide → impossible de re-dériver)")
        elif _dup_bare:
            bad(f"CONFOTUR invariants · marqueur(s) `# N ·` dupliqué(s) sans suffixe "
                f"a/b dans `_validate` : {_dup_bare} — vrai doublon de registre")
        else:
            cf_ninv = len(_top)
            if _top != list(range(1, cf_ninv + 1)):
                bad(f"CONFOTUR invariants · ledger `_validate` NON contigu : {_top} "
                    f"(attendu 1..{cf_ninv}) — lacune/doublon dans les marqueurs `# N ·`")
            else:
                good(f"CONFOTUR invariants · ledger `_validate` contigu 1..{cf_ninv} "
                     f"({cf_ninv} marqueurs `# N ·`)")
            # (b) self-reports du .py « N invariants » (hors sous-ensemble « refusent »).
            cf_py_rep = sorted({int(x) for x in re.findall(_CF_RX_TOT, cf_src)})
            if not cf_py_rep:
                bad(f"CONFOTUR invariants · aucune mention-total « N invariants » dans "
                    f"{CF_GEN} (self-report absent → attendu {cf_ninv})")
            elif cf_py_rep == [cf_ninv]:
                good(f"CONFOTUR invariants · {CF_GEN} — self-report(s) {cf_py_rep} "
                     f"== ledger ({cf_ninv})")
            else:
                bad(f"CONFOTUR invariants · {CF_GEN} — self-report(s) « N invariants » "
                    f"{cf_py_rep} MAIS ledger en compte {cf_ninv} "
                    "(une chaîne du générateur désynchronisée du registre)")
            # (c) README : mentions-total (le sous-ensemble « N invariants refusent »
            #     est exclu par le lookahead ⇒ ne fausse pas le compte).
            try:
                cf_rd_raw = open(CF_RD, encoding="utf-8").read()
            except OSError as e:
                bad(f"CONFOTUR invariants · {CF_RD} illisible : {e}"); cf_rd_raw = None
            if cf_rd_raw is not None:
                cf_rd_rep = sorted({int(x) for x in re.findall(_CF_RX_TOT, cf_rd_raw)})
                if not cf_rd_rep:
                    bad(f"CONFOTUR invariants · {CF_RD} — aucune mention-total « N "
                        f"invariants » (attendu {cf_ninv} · disparition = régression #6)")
                elif cf_rd_rep == [cf_ninv]:
                    good(f"CONFOTUR invariants · {CF_RD} — mention(s)-total {cf_rd_rep} "
                         "== ledger (aucune dérive · sous-ensemble « refusent » exclu)")
                else:
                    bad(f"CONFOTUR invariants · {CF_RD} — mention(s)-total « N invariants » "
                        f"{cf_rd_rep} MAIS ledger en compte {cf_ninv} → régénérer le README")

# ============================================================================
# CRM · pipeline vente (états / transitions) — même classe de « vert trompeur »,
# transcrit À LA MAIN dans DEUX README de module.
# ----------------------------------------------------------------------------
# Le générateur workflow_vente émet out/MANIFEST.json dont `counts` porte states
# et transitions, RECOMPTÉS à chaque build depuis workflow_vente_spec.json (le
# graphe pipeline · byte-gaté par check_artifacts ⇒ source faisant autorité). Ce
# couple est recopié À LA MAIN à DEUX endroits : le titre de section du README du
# module workflow (« ## Le pipeline (9 états · 11 transitions) ») ET le README du
# DocType porteur dossier_vente (« options = les **9 états** du pipeline »), qui
# DÉRIVE le même compte d'états du même graphe (source unique · #5). Aucun gate ne
# les couvrait : ajouter un état/une transition au pipeline ferait dériver LES
# DEUX docs en silence pendant que l'artefact dit autre chose — même piège que les
# triplets par workspace des portails ou les mots-clés SEO. On RECOMPUTE ici
# (states + transitions) depuis workflow_vente MANIFEST.counts (déjà byte-gaté ·
# zéro duplication du compteur du générateur) et on exige l'égalité avec les DEUX
# docs. Un claim absent échoue AUSSI (traçabilité). Recomputer transitions EN PLUS
# de states mord une dérive du 2e nombre là où le seul titre du README workflow
# (qui n'a pas les transitions dans dossier_vente) resterait aveugle.
# ============================================================================
WF_RD = f"{D}/crm/workflow_vente/README.md"
DV_RD = f"{D}/crm/dossier_vente/README.md"
try:
    wf_c = load("crm/workflow_vente/out/MANIFEST.json")["counts"]
    wf_states, wf_trans = str(wf_c["states"]), str(wf_c["transitions"])
except (OSError, KeyError, ValueError) as e:
    bad(f"CRM pipeline · MANIFEST workflow_vente illisible/inattendu : {e}")
    wf_states = None
if wf_states is not None:
    # 1) README workflow — titre de section (états + transitions)
    wf_src = f"états={wf_states} transitions={wf_trans}"
    wf_txt = read_norm(WF_RD)
    if wf_txt is not None:
        m = re.search(r"## Le pipeline \((\d+) états · (\d+) transitions\)", wf_txt)
        if m is None:
            bad(f"CRM pipeline · {WF_RD} — titre pipeline INTROUVABLE (attendu {wf_src})")
        elif m.groups() == (wf_states, wf_trans):
            good(f"CRM pipeline · {WF_RD} — {'/'.join(m.groups())} == MANIFEST ({wf_src})")
        else:
            bad(f"CRM pipeline · {WF_RD} — dit {'/'.join(m.groups())} MAIS MANIFEST "
                f"dit {wf_states}/{wf_trans} ({wf_src}) → régénérer le README")
    # 2) README dossier_vente — même compte d'états, dérivé du même graphe
    dv_txt = read_norm(DV_RD)
    if dv_txt is not None:
        m = re.search(r"les \*\*(\d+) états\*\* du pipeline", dv_txt)
        if m is None:
            bad(f"CRM pipeline · {DV_RD} — claim « N états du pipeline » INTROUVABLE "
                f"(attendu états={wf_states})")
        elif m.group(1) == wf_states:
            good(f"CRM pipeline · {DV_RD} — états={m.group(1)} == MANIFEST (états={wf_states})")
        else:
            bad(f"CRM pipeline · {DV_RD} — dit états={m.group(1)} MAIS MANIFEST dit "
                f"états={wf_states} → régénérer le README")

# ============================================================================
# CRM · IDENTITÉ du DocType porteur — le NOM `OTO Dossier Vente`
# ----------------------------------------------------------------------------
# Le module dossier_vente émet out/doctype_oto_dossier_vente.json dont le champ
# `name` EST le nom du DocType porteur (= MANIFEST.doctype_name), byte-gaté par
# check_artifacts ⇒ artefact faisant autorité. Ce NOM est le POINT D'ATTACHE du
# pipeline : le Workflow vente s'y branche via son `document_type`
# (workflow.json[0].document_type). Le nom est RECOPIÉ À LA MAIN à plusieurs
# endroits, AUCUN gaté sur le NOM (le bloc CRM pipeline ci-dessus ne recompute
# que le COMPTE d'états, aveugle à l'identité) :
#   • dossier_vente/README.md — titre « # DocType porteur · `NOM` » ET la ligne
#     « Nom du DocType = `document_type` du workflow (`NOM`) »
#   • 03_agents/crm/AGENT.md:25 — « **DocType porteur** `NOM` »
#   • 03_agents/onapi_legal/AGENT.md:50 — « cible = `workflow.document_type` (`NOM`) »
# Piège : RENOMMER le DocType dans doctype_spec.json (`OTO Dossier Vente` →
# `OTO Dossier de Vente`) reconstruit les DEUX artefacts de façon COHÉRENTE (le
# workflow.document_type suit, les tests — qui testent des FONCTIONS — restent
# verts), MAIS les mentions en prose se PÉRIMENT en silence → l'agent ERPNext
# Backend importerait un DocType sous un nom pendant que la fiche/le README en
# nomment un autre, et le Workflow s'attacherait à un DocType FANTÔME — la
# rupture même que « sans lui le pipeline n'a rien à quoi s'attacher » (crm/
# AGENT.md:25) veut prévenir. Aucune suite tests/ (FONCTIONS de graphe, pas la
# prose) n'attrape ce « vert trompeur ». On RECOMPUTE le nom depuis l'artefact
# (zéro duplication) et on exige que chaque prose le nomme EXACTEMENT (backtické).
# Cohérences croisées bonus (mordent un artefact INTERNEMENT incohérent) :
# DocType.name == MANIFEST.doctype_name · == workflow.document_type (l'attache
# porteur↔workflow). Un claim absent échoue AUSSI (traçabilité #6).
# ============================================================================
try:
    dv_dt = load("crm/dossier_vente/out/doctype_oto_dossier_vente.json")
    dv_man = load("crm/dossier_vente/out/MANIFEST.json")
    dt_name = dv_dt["name"]
    dv_man_name = dv_man["doctype_name"]
    wf_doc_type = load("crm/workflow_vente/out/workflow.json")[0]["document_type"]
except (OSError, KeyError, ValueError, IndexError, TypeError) as e:
    bad(f"CRM DocType porteur · artefact illisible/inattendu : {e}"); dt_name = None
if dt_name:
    # bonus 1 : cohérence interne du module (DocType.name == MANIFEST.doctype_name)
    if dv_man_name != dt_name:
        bad(f"CRM DocType porteur · incohérence artefacts : DocType.name "
            f"« {dt_name} » MAIS MANIFEST.doctype_name « {dv_man_name} » → régénérer out/")
    else:
        good(f"CRM DocType porteur · DocType.name == MANIFEST.doctype_name « {dt_name} »")
    # bonus 2 : l'attache porteur↔workflow — le Workflow s'y branche par document_type
    if wf_doc_type != dt_name:
        bad(f"CRM DocType porteur · attache ROMPUE : workflow.document_type "
            f"« {wf_doc_type} » ≠ DocType porteur « {dt_name} » → le Workflow "
            f"s'attacherait à un DocType FANTÔME")
    else:
        good(f"CRM DocType porteur · attache workflow.document_type == « {dt_name} » "
             f"(le pipeline a bien où s'attacher)")
    # prose : chaque doc doit nommer le DocType EXACTEMENT (backtické)
    esc = re.escape(dt_name)
    DV_DOC = [
        (DV_RD, rf"# DocType porteur · `{esc}`", "titre dossier_vente"),
        (DV_RD, rf"Nom du DocType\*\* = `document_type` du workflow \(`{esc}`\)",
         "ligne « Nom du DocType »"),
        ("03_agents/crm/AGENT.md", rf"DocType porteur\*\* `{esc}`", "fiche CRM:25"),
        ("03_agents/onapi_legal/AGENT.md",
         rf"cible = `workflow\.document_type` \(`{esc}`\)", "fiche onapi_legal:50"),
    ]
    for path, pat, label in DV_DOC:
        txt = read_norm(path)
        if txt is None:
            continue
        if re.search(pat, txt):
            good(f"CRM DocType porteur · {label} — nomme « {dt_name} » == artefact")
        else:
            bad(f"CRM DocType porteur · {label} ({path}) — nom « {dt_name} » "
                f"INTROUVABLE/périmé → régénérer la doc")

# ============================================================================
# CRM (2e surface du MÊME README) · ÉNUMÉRATION des transitions à SÉPARATION
# DES POUVOIRS — la phrase « engagent de l'argent ou clôturent — confirmer
# réservation, signer contrat, approuver CONFOTUR, annuler/résilier ».
# ----------------------------------------------------------------------------
# Le bloc CRM ci-dessus recompute le COMPTE d'états/transitions mais PAS
# l'IDENTITÉ des transitions marquées `separation_of_duties`. Or cette liste est
# DATA-DERIVED : le générateur émet out/workflow.json (byte-gaté par
# check_artifacts ⇒ artefact faisant autorité) dont chaque transition porte
# `allow_self_approval` ; l'ensemble des transitions QUATRE-YEUX = {action |
# allow_self_approval == 0} — précisément ce que le README énumère À LA MAIN
# comme « interdisent l'auto-approbation (`allow_self_approval = 0`) ». Le bloc de
# COMPTE reste AVEUGLE au contenu : (a) DÉCLASSER une transition sensible (ex.
# « Signer contrat » repasse à allow_self_approval=1 dans le contrat) laisse le
# compte d'états/transitions à 9/11 pendant que l'artefact retire l'action de
# l'ensemble QUATRE-YEUX — la prose reste à 5 actions FAUSSES ; (b) PROMOUVOIR une
# transition (« Déposer CONFOTUR » → self=0) l'ajoute à l'ensemble sans que le
# compte bouge, et la prose l'OMET ; (c) RÉORDRE/RENOMMAGE d'action passe aussi —
# « vert trompeur » qu'aucune suite tests/ (qui teste des FONCTIONS de graphe, pas
# la prose) n'attrape — même patron que l'énumération des onglets Mobile, des open
# items de l'audit 5D ou de la carte de renormalisation 4Big. On RECOMPUTE ici
# l'ENSEMBLE des actions QUATRE-YEUX depuis workflow.json (zéro duplication du flag
# du générateur) et on exige que la phrase l'énumère EXACTEMENT (absent ET en trop
# mordus via set-diff). Normalisation : casse ignorée + suffixe « (perdu) » élidé
# (les deux transitions vers l'état Perdu — Annuler/Résilier — que la prose groupe
# « annuler/résilier »). Cohérence croisée en bonus : 0 < |quatre-yeux| < |trans|
# (l'ensemble sensible est NON VIDE — sinon la garde est vacante — et STRICT — sinon
# tout le graphe serait bloqué en quatre-yeux, ni l'un ni l'autre n'étant le
# contrat). Un claim absent échoue AUSSI (traçabilité).
# ============================================================================
try:
    wf_trans_list = load("crm/workflow_vente/out/workflow.json")[0]["transitions"]
    # ensemble QUATRE-YEUX = actions dont l'auto-approbation est interdite (self=0),
    # normalisées (casse ignorée · suffixe « (perdu) » élidé — cf. groupage prose)
    def _sod_norm(a):
        return re.sub(r"\s*\(perdu\)\s*", "", a).strip().lower()
    sod_set = {_sod_norm(t["action"]) for t in wf_trans_list
               if str(t["allow_self_approval"]) == "0"}
    n_trans_tot = len(wf_trans_list)
except (OSError, KeyError, ValueError, IndexError, TypeError) as e:
    bad(f"CRM séparation · workflow.json illisible/inattendu : {e}"); sod_set = None
if sod_set is not None:
    # bonus : l'ensemble sensible est non vide ET strictement inclus dans le graphe
    if not (0 < len(sod_set) < n_trans_tot):
        bad(f"CRM séparation · ensemble quatre-yeux dégénéré : |sod|={len(sod_set)} "
            f"vs |transitions|={n_trans_tot} (attendu 0 < sod < total) → vérifier le contrat")
    else:
        good(f"CRM séparation · ensemble quatre-yeux non vide et strict : "
             f"0 < {len(sod_set)} < {n_trans_tot}")
    # énumération de la phrase : capturer le segment entre « clôturent** — » et « — sont marquées »
    wf_txt3 = read_norm(WF_RD)
    if wf_txt3 is not None:
        ms = re.search(r"engagent de l'argent ou clôturent\*\* — (.+?) — sont marquées",
                       wf_txt3)
        if ms is None:
            bad(f"CRM séparation · {WF_RD} — énumération des transitions quatre-yeux "
                f"INTROUVABLE (attendu {sorted(sod_set)})")
        else:
            prose_set = {t.strip().lower() for t in re.split(r"[,/]", ms.group(1)) if t.strip()}
            if prose_set == sod_set:
                good(f"CRM séparation · {WF_RD} — énumération quatre-yeux "
                     f"{sorted(prose_set)} == workflow.json (allow_self_approval=0)")
            else:
                miss = sorted(sod_set - prose_set)
                extra = sorted(prose_set - sod_set)
                detail = (f" · absents={miss}" if miss else "") + \
                         (f" · en trop={extra}" if extra else "")
                bad(f"CRM séparation · {WF_RD} — énumère {sorted(prose_set)} MAIS "
                    f"workflow.json dit {sorted(sod_set)}{detail} → régénérer le README")

# ============================================================================
# Mobile · récap de l'app Expo (onglets / rôles couverts / langues / identifiants
# a_confirmer) — même classe de « vert trompeur », transcrite À LA MAIN.
# ----------------------------------------------------------------------------
# Le générateur app_config émet out/MANIFEST.json dont `counts` porte onglets,
# roles_couverts, langues et identifiants_a_confirmer, RECOMPTÉS à chaque build
# depuis rbac_50_roles.json (portails métier + rôles), seo_spec.json (langues) et
# la liste `a_confirmer` (identifiants EAS/stores jamais fabriqués · byte-gaté par
# check_artifacts ⇒ artefact faisant autorité). Ce quadruplet est recopié À LA
# MAIN dans la ligne récap du README du module (05_deliverables_mvp/mobile/
# app_config/README.md — « **5 onglets** … · **44 rôles** couverts · **3 langues**
# · **13 identifiants `a_confirmer`** »). Aucun gate ne le couvrait : ajouter un
# rôle/portail au contrat RBAC, une langue au site ou un credential store ferait
# dériver le doc en silence pendant que l'artefact dit autre chose — même piège
# que les mots-clés SEO ou les triplets par workspace des portails. On RECOMPUTE
# ici les 4 nombres depuis MANIFEST.counts (déjà byte-gaté · zéro duplication du
# compteur du générateur) et on exige l'égalité. Un claim absent échoue AUSSI
# (traçabilité). Recomputer les 4 (et pas le seul total de rôles) mord une dérive
# de N'IMPORTE lequel des volets là où un seul nombre resterait aveugle.
# ============================================================================
MOB_RD = f"{D}/mobile/app_config/README.md"
try:
    mob_c = load("mobile/app_config/out/MANIFEST.json")["counts"]
    mob_exp = (str(mob_c["onglets"]),
               str(mob_c["roles_couverts"]),
               str(mob_c["langues"]),
               str(mob_c["identifiants_a_confirmer"]))
except (OSError, KeyError, ValueError) as e:
    bad(f"Mobile · MANIFEST app_config illisible/inattendu : {e}"); mob_exp = None
if mob_exp is not None:
    mob_src = (f"onglets={mob_exp[0]} rôles={mob_exp[1]} langues={mob_exp[2]} "
               f"identifiants={mob_exp[3]}")
    mob_pat = (r"\*\*(\d+) onglets\*\* \([^)]*\) · \*\*(\d+) rôles\*\* couverts · "
               r"\*\*(\d+) langues\*\* · \*\*(\d+) identifiants")
    mob_txt = read_norm(MOB_RD)
    if mob_txt is not None:
        m = re.search(mob_pat, mob_txt)
        if m is None:
            bad(f"Mobile · {MOB_RD} — récap app INTROUVABLE (attendu {mob_src})")
        elif m.groups() == mob_exp:
            good(f"Mobile · {MOB_RD} — {'/'.join(m.groups())} == MANIFEST ({mob_src})")
        else:
            bad(f"Mobile · {MOB_RD} — dit {'/'.join(m.groups())} MAIS MANIFEST dit "
                f"{'/'.join(mob_exp)} ({mob_src}) → régénérer le README")

# ============================================================================
# Mobile (2e surface) · ÉNUMÉRATION des onglets de l'app Expo — même README, même
# ligne récap « **5 onglets** (Ventes/Construction/Achat/Compta/Direction) ».
# ----------------------------------------------------------------------------
# Le bloc mobile ci-dessus recompute le COMPTE d'onglets (« 5 ») mais son patron
# ne mord QUE le nombre : la parenthèse qui ÉNUMÈRE les 5 portails est matchée par
# `\([^)]*\)` et son CONTENU est IGNORÉ. Or cette liste est DATA-DERIVED : le
# générateur app_config émet out/role_navigation.json (byte-gaté par
# check_artifacts ⇒ artefact faisant autorité) = un onglet par portail métier du
# contrat RBAC (bijection 1 onglet ⇔ 1 portail · `plateforme` exclu), chaque
# entrée portant `portail` (id) + `label` (« OTO <Nom> »). Le nom court énuméré au
# README = le label sans le préfixe « OTO ». Un ÉCHANGE (Ventes→Marketing), un
# RETRAIT (Compta absent — l'énumération tombe à 4 tandis que « **5 onglets** »
# reste juste) ou un RÉORDRE ferait dériver la prose EN SILENCE pendant que
# l'artefact dit autre chose : « vert trompeur » aveugle au seul compte, qu'aucune
# suite tests/ (qui teste des FONCTIONS, pas la prose) n'attrape — même patron que
# l'énumération des confirmations DevOps, la carte de renormalisation par archétype
# 4Big ou la table « Verdict courant » de l'audit 5D. On RECOMPUTE ici la LISTE
# ORDONNÉE des noms courts depuis role_navigation.json (zéro duplication de la
# navigation du générateur) et on exige que la parenthèse l'énumère EXACTEMENT
# (absent ET en trop mordus, puis ordre). Cohérence croisée en bonus :
# len(role_navigation) == MANIFEST.counts.onglets == MANIFEST.counts.portails (la
# bijection du générateur) — mord un compteur du manifeste désynchronisé de sa
# propre liste de navigation.
# ============================================================================
try:
    mob_nav = load("mobile/app_config/out/role_navigation.json")
    # nom court = label « OTO <Nom> » sans le préfixe « OTO » (ce que le doc copie)
    mob_names = [re.sub(r"^OTO\s+", "", e["label"]).strip() for e in mob_nav]
    mob_names_c = load("mobile/app_config/out/MANIFEST.json")["counts"]
except (OSError, KeyError, ValueError, TypeError) as e:
    bad(f"Mobile · role_navigation/MANIFEST illisible/inattendu : {e}"); mob_names = None
if mob_names is not None:
    # bijection : la liste de navigation == compteurs onglets ET portails du manifeste
    n_nav = len(mob_nav)
    if not (n_nav == mob_names_c.get("onglets") == mob_names_c.get("portails")):
        bad(f"Mobile · bijection rompue : |role_navigation|={n_nav} MAIS "
            f"MANIFEST onglets={mob_names_c.get('onglets')} "
            f"portails={mob_names_c.get('portails')} → régénérer l'artefact")
    else:
        good(f"Mobile · bijection onglet⇔portail : {n_nav} == onglets == portails")
    # énumération de la parenthèse : capturer le contenu entre ( ) qui suit « N onglets »
    mob_enum_pat = r"\*\*\d+ onglets\*\* \(([^)]*)\)"
    mob_txt2 = read_norm(MOB_RD)
    if mob_txt2 is not None:
        me = re.search(mob_enum_pat, mob_txt2)
        if me is None:
            bad(f"Mobile · {MOB_RD} — énumération des onglets INTROUVABLE "
                f"(attendu {'/'.join(mob_names)})")
        else:
            got = [s.strip() for s in me.group(1).split("/") if s.strip()]
            if got == mob_names:
                good(f"Mobile · {MOB_RD} — énumération onglets {'/'.join(got)} "
                     f"== role_navigation")
            else:
                miss = [x for x in mob_names if x not in got]
                extra = [x for x in got if x not in mob_names]
                detail = (f" · absents={miss}" if miss else "") + \
                         (f" · en trop={extra}" if extra else "") or " · même ensemble, ordre ≠"
                bad(f"Mobile · {MOB_RD} — énumère {got} MAIS role_navigation dit "
                    f"{mob_names}{detail} → régénérer le README")

# ============================================================================
# Legal · DocType CONFOTUR Application (champs / sections / rôles / évènements de
# dépôt) — même classe de « vert trompeur », transcrit À LA MAIN dans DEUX docs.
# ----------------------------------------------------------------------------
# Le générateur confotur émet out/MANIFEST.json dont `counts` porte fields,
# data_fields, sections, roles et depot_events, RECOMPTÉS à chaque build depuis
# confotur_spec.json (byte-gaté par check_artifacts ⇒ artefact faisant autorité).
# Ce quintuplet est recopié À LA MAIN à DEUX endroits : la ligne de synthèse du
# README du module (05_deliverables_mvp/legal/confotur/README.md — « 18 champs
# (14 de donnée) · 4 sections · 3 rôles · … · 2 évènements de dépôt ») ET la
# cellule de la fiche agent onapi_legal (03_agents/onapi_legal/AGENT.md — mêmes 5
# nombres, chacun en gras). Aucun gate ne les couvrait : ajouter un champ/une
# section/un rôle au spec ferait dériver LES DEUX docs en silence pendant que
# l'artefact dit autre chose — même piège que les mots-clés SEO ou les triplets
# par workspace des portails. On RECOMPUTE ici les 5 nombres depuis
# MANIFEST.counts (déjà byte-gaté · zéro duplication du compteur du générateur)
# et on exige l'égalité avec les DEUX docs. Un claim absent échoue AUSSI
# (traçabilité). Recomputer les 5 (et pas le seul total de champs) mord une dérive
# de N'IMPORTE lequel des volets là où un seul nombre resterait aveugle.
# ============================================================================
CONF_RD = f"{D}/legal/confotur/README.md"
CONF_FI = "03_agents/onapi_legal/AGENT.md"
try:
    conf_c = load("legal/confotur/out/MANIFEST.json")["counts"]
    conf_exp = (str(conf_c["fields"]),
                str(conf_c["data_fields"]),
                str(conf_c["sections"]),
                str(conf_c["roles"]),
                str(conf_c["depot_events"]))
except (OSError, KeyError, ValueError) as e:
    bad(f"Legal confotur · MANIFEST illisible/inattendu : {e}"); conf_exp = None
if conf_exp is not None:
    conf_src = (f"champs={conf_exp[0]} données={conf_exp[1]} sections={conf_exp[2]} "
                f"rôles={conf_exp[3]} dépôts={conf_exp[4]}")
    # README du module (sans gras) et fiche agent (chaque nombre en gras) —
    # même quintuplet, deux mises en forme distinctes du même artefact.
    conf_docs = (
        (CONF_RD, r"(\d+) champs \((\d+) de donnée\) · (\d+) sections · (\d+) rôles"
                  r" · soumissible · (\d+) évènements de dépôt"),
        (CONF_FI, r"\*\*(\d+) champs\*\* \((\d+) de donnée\) · \*\*(\d+) sections\*\*"
                  r" · \*\*(\d+) rôles/permissions\*\* · \*\*(\d+) évènements de dépôt\*\*"),
    )
    for path_f, pat in conf_docs:
        txt = read_norm(path_f)
        if txt is None:
            continue
        m = re.search(pat, txt)
        if m is None:
            bad(f"Legal confotur · {path_f} — synthèse DocType INTROUVABLE "
                f"(attendu {conf_src})")
        elif m.groups() == conf_exp:
            good(f"Legal confotur · {path_f} — {'/'.join(m.groups())} == "
                 f"MANIFEST ({conf_src})")
        else:
            bad(f"Legal confotur · {path_f} — dit {'/'.join(m.groups())} MAIS "
                f"MANIFEST dit {'/'.join(conf_exp)} ({conf_src}) → régénérer le doc")

# ============================================================================
# Legal · CONFOTUR — cross-cohérence PERMISSIONS (2e surface du MÊME README) :
# l'IDENTITÉ des 3 rôles + leur jeu d'ACTIONS exact — le CŒUR sécurité du livrable.
# ----------------------------------------------------------------------------
# Le bloc CONFOTUR ci-dessus ne gate que le COMPTE (« 3 rôles »). La section
# « Cœur du livrable : cross-cohérence » du README (05_deliverables_mvp/legal/
# confotur/README.md §Permissions) énumère À LA MAIN, PAR rôle, son portail ET son
# jeu d'actions RBAC — annoncé « mot pour mot les permissions_cibles RBAC … ni
# ajout ni retrait » :
#   - `ventes-confotur`  (portail Ventes)    → read/write/create/print
#   - `legal-onapi`      (portail Direction) → read/write/create
#   - `legal-directeur`  (portail Direction) → read/write/**submit**/report
# Ces trois lignes sont DATA-DERIVED d'out/MANIFEST.json (`roles_rbac_utilises[]` :
# role_id · portail · `permissions`, recomputé du contrat rbac_50_roles.json à
# chaque build) et projetées dans out/doctype_confotur_application.json
# (`permissions[]` par nom de rôle) — les DEUX byte-gatés par check_artifacts. Le
# compte « 3 rôles » est AVEUGLE à leur identité : PROMOUVOIR `ventes-confotur` à
# `submit` (élévation de privilège — casse la séparation des pouvoirs dont
# is_submittable est déduit), RETIRER une action, RÉAFFECTER un portail, RENOMMER
# un rôle ou AJOUTER une ligne fantôme laisse la prose périmée pendant que les
# artefacts disent autre chose → l'agent ERPNext Backend câblerait le mauvais jeu
# de permissions (le risque même que la cross-cohérence veut prévenir) — « vert
# trompeur » qu'aucune suite tests/ (qui teste des FONCTIONS de mapping RBAC, pas
# la prose) n'attrape. Même patron d'IDENTITÉ que le catalogue RBAC fixtures_gen
# (séparation des pouvoirs `set_user_permissions`) ou la cross-cohérence e-CF. On
# RECOMPUTE chaque jeu d'actions + portail depuis le MANIFEST (zéro duplication du
# contrat #6) et on exige que la prose l'encode EXACTEMENT (set-diff : absent ET en
# trop · casse normalisée), + l'identité d'ensemble (README ⇔ MANIFEST, aucun rôle
# fantôme ni manquant). Cohérences croisées en bonus (mordent un artefact
# INTERNEMENT incohérent) : MANIFEST ⇄ DocType d'accord sur le jeu d'actions par
# rôle · séparation des pouvoirs : `submit` porté par EXACTEMENT un rôle et
# is_submittable=True déduit de sa présence (README:31). Un claim absent échoue
# AUSSI (traçabilité).
# ============================================================================
CONF_RD2 = f"{D}/legal/confotur/README.md"
_CONF_PERM_FLAGS = ("read", "write", "create", "submit", "cancel", "amend",
                    "delete", "report", "export", "import", "print", "email", "share")
try:
    conf_roles = load("legal/confotur/out/MANIFEST.json")["roles_rbac_utilises"]
    conf_perm_by_id = {r["role_id"]: set(r["permissions"]) for r in conf_roles}
    conf_portail_by_id = {r["role_id"]: r["portail"] for r in conf_roles}
    conf_name_by_id = {r["role_id"]: r["erpnext_role_name"] for r in conf_roles}
    conf_dt = load("legal/confotur/out/doctype_confotur_application.json")
    conf_dt_perm_by_name = {p["role"]: {f for f in _CONF_PERM_FLAGS if p.get(f)}
                            for p in conf_dt["permissions"]}
    conf_submittable = bool(conf_dt.get("is_submittable"))
except (OSError, KeyError, ValueError, TypeError) as e:
    bad(f"Legal confotur perms · artefact illisible/inattendu : {e}"); conf_roles = None
if conf_roles:
    # bonus 1 : les DEUX artefacts (MANIFEST ⇄ DocType) d'accord sur le jeu d'actions
    for rid, perms in conf_perm_by_id.items():
        nm = conf_name_by_id[rid]
        dt_perms = conf_dt_perm_by_name.get(nm)
        if dt_perms is None:
            bad(f"Legal confotur perms · rôle `{rid}` ({nm}) ABSENT des permissions du DocType")
        elif dt_perms != perms:
            bad(f"Legal confotur perms · incohérence artefacts pour `{rid}` : MANIFEST "
                f"{sorted(perms)} MAIS DocType {sorted(dt_perms)}")
        else:
            good(f"Legal confotur perms · `{rid}` — MANIFEST {sorted(perms)} == DocType (accord)")
    # bonus 2 : séparation des pouvoirs — `submit` porté par EXACTEMENT un rôle, et
    # is_submittable DÉDUIT de sa présence (README:31). Singleton sécurité (cf. RBAC Admin).
    conf_submitters = {rid for rid, p in conf_perm_by_id.items() if "submit" in p}
    if len(conf_submitters) != 1:
        bad(f"Legal confotur perms · séparation des pouvoirs : action `submit` portée par "
            f"{sorted(conf_submitters)} (attendu 1 — ni vacant, ni élévation de privilège)")
    elif conf_submittable is not True:
        bad(f"Legal confotur perms · un rôle porte `submit` MAIS is_submittable="
            f"{conf_submittable} → DocType incohérent")
    else:
        good(f"Legal confotur perms · séparation des pouvoirs — `submit` singleton "
             f"{sorted(conf_submitters)} · is_submittable=True (déduit · README:31)")
    conf_rd2 = read_norm(CONF_RD2)
    if conf_rd2 is not None:
        for rid in sorted(conf_perm_by_id):
            exp_perms = conf_perm_by_id[rid]
            exp_portail = conf_portail_by_id[rid]
            m = re.search(rf"`{re.escape(rid)}` \(portail ([^)]+)\) → ([\w/*]+)", conf_rd2)
            if m is None:
                bad(f"Legal confotur perms · {CONF_RD2} — ligne du rôle `{rid}` INTROUVABLE "
                    f"(attendu portail {exp_portail} · {sorted(exp_perms)})")
                continue
            got_portail = m.group(1).strip().lower()
            got_perms = {t.replace("*", "").strip().lower()
                         for t in m.group(2).split("/") if t.replace("*", "").strip()}
            if got_portail != exp_portail:
                bad(f"Legal confotur perms · `{rid}` — {CONF_RD2} dit portail "
                    f"« {m.group(1).strip()} » MAIS MANIFEST dit « {exp_portail} » → régénérer")
            elif got_perms != exp_perms:
                miss = sorted(exp_perms - got_perms); extra = sorted(got_perms - exp_perms)
                detail = (f" · absents={miss}" if miss else "") + \
                         (f" · en trop={extra}" if extra else "")
                bad(f"Legal confotur perms · `{rid}` — {CONF_RD2} énumère {sorted(got_perms)} "
                    f"MAIS MANIFEST {sorted(exp_perms)}{detail} → régénérer le README")
            else:
                good(f"Legal confotur perms · `{rid}` — portail {got_portail} · actions "
                     f"{sorted(got_perms)} == MANIFEST")
        # identité d'ensemble : README ⇔ MANIFEST (aucun rôle fantôme NI manquant) —
        # rd_ids = rôles réellement écrits dans la prose §Permissions (patron identique).
        rd_ids = set(re.findall(r"`([a-z][a-z-]+)` \(portail [^)]+\) → [\w/*]+", conf_rd2))
        exp_ids = set(conf_perm_by_id)
        if rd_ids == exp_ids:
            good(f"Legal confotur perms · identité d'ensemble — {sorted(rd_ids)} == "
                 f"roles_rbac_utilises (aucun rôle fantôme/manquant)")
        else:
            miss = sorted(exp_ids - rd_ids); extra = sorted(rd_ids - exp_ids)
            detail = (f" · absents={miss}" if miss else "") + \
                     (f" · en trop (fantôme)={extra}" if extra else "")
            bad(f"Legal confotur perms · {CONF_RD2} — rôles énumérés {sorted(rd_ids)} MAIS "
                f"MANIFEST {sorted(exp_ids)}{detail} → régénérer le README")

# ============================================================================
# Legal · CONFOTUR — cross-cohérence PERMISSIONS répliquée dans la FICHE AGENT
# (3e surface) : la MÊME table role→portail→actions, transcrite À LA MAIN dans
# 03_agents/onapi_legal/AGENT.md:43-45 — jamais gatée.
# ----------------------------------------------------------------------------
# Le bloc ci-dessus gate la §Permissions du README du MODULE (legal/confotur/
# README.md). Mais la FICHE de l'agent ONAPI/Legal RECOPIE la MÊME donnée
# sécurité — annoncée « **mot pour mot** les `permissions_cibles` RBAC des 3
# rôles » — sous une convention DIFFÉRENTE (` / ` espacé · `**submit**` en gras) :
#   - `ventes-confotur`  (portail Ventes)    → read / write / create / print
#   - `legal-onapi`      (portail Direction) → read / write / create
#   - `legal-directeur`  (portail Direction) → read / write / **submit** / report
# Ces trois lignes sont DATA-DERIVED du MÊME out/MANIFEST.json (`roles_rbac_
# utilises[]` : role_id · portail · `permissions`, recomputé du contrat rbac_50_
# roles.json à chaque build · byte-gaté par check_artifacts) que le README. Le
# gate README est AVEUGLE à la fiche : PROMOUVOIR `ventes-confotur` à `submit`
# ici (élévation de privilège · casse la séparation des pouvoirs), RETIRER une
# action, RÉAFFECTER un portail, RENOMMER un rôle ou AJOUTER une ligne fantôme
# laisse la FICHE périmée pendant que l'artefact (et le README, lui gaté) disent
# autre chose → l'agent ONAPI/Legal lirait sa PROPRE doc d'identité mentant sur
# le jeu de permissions qu'il porte (le risque même que « mot pour mot » promet
# d'écarter) — « vert trompeur » qu'aucune suite tests/ (FONCTIONS de mapping
# RBAC, jamais la prose de fiche) n'attrape. Même patron d'IDENTITÉ que les
# fiches Mobile/Faisabilité (attributs de rôle) ou la citation #10 (constantes),
# appliqué à la SURFACE DISTINCTE qu'est la §Permissions de cette fiche. On
# RECOMPUTE chaque jeu d'actions + portail depuis conf_perm_by_id/conf_portail_
# by_id (déjà dérivés du MANIFEST ci-dessus · ZÉRO duplication du contrat #6) et
# on exige que la fiche l'encode EXACTEMENT (set-diff : absent ET en trop · gras
# `**` et casse normalisés) + l'identité d'ensemble (fiche ⇔ MANIFEST, aucun
# rôle fantôme ni manquant). Un claim absent échoue AUSSI (traçabilité).
# ============================================================================
CONF_FICHE = "03_agents/onapi_legal/AGENT.md"
if conf_roles:
    conf_fiche = read_norm(CONF_FICHE)
    if conf_fiche is None:
        bad(f"Legal confotur fiche · {CONF_FICHE} illisible")
    else:
        # une action : mot, éventuellement encadré de `**` (gras), séparé par ` / `.
        _ACT = r"(?:\*{0,2}\w+\*{0,2}\s*/\s*)*\*{0,2}\w+\*{0,2}"
        for rid in sorted(conf_perm_by_id):
            exp_perms = conf_perm_by_id[rid]
            exp_portail = conf_portail_by_id[rid]
            pat = r"`" + re.escape(rid) + r"` \(portail ([^)]+)\) → (" + _ACT + r")"
            m = re.search(pat, conf_fiche)
            if m is None:
                bad(f"Legal confotur fiche · {CONF_FICHE} — ligne du rôle `{rid}` INTROUVABLE "
                    f"(attendu portail {exp_portail} · {sorted(exp_perms)})")
                continue
            got_portail = m.group(1).strip().lower()
            got_perms = {t.replace("*", "").strip().lower()
                         for t in m.group(2).split("/") if t.replace("*", "").strip()}
            if got_portail != exp_portail:
                bad(f"Legal confotur fiche · `{rid}` — {CONF_FICHE} dit portail "
                    f"« {m.group(1).strip()} » MAIS MANIFEST dit « {exp_portail} » → régénérer")
            elif got_perms != exp_perms:
                miss = sorted(exp_perms - got_perms); extra = sorted(got_perms - exp_perms)
                detail = (f" · absents={miss}" if miss else "") + \
                         (f" · en trop={extra}" if extra else "")
                bad(f"Legal confotur fiche · `{rid}` — {CONF_FICHE} énumère {sorted(got_perms)} "
                    f"MAIS MANIFEST {sorted(exp_perms)}{detail} → régénérer la fiche")
            else:
                good(f"Legal confotur fiche · `{rid}` — portail {got_portail} · actions "
                     f"{sorted(got_perms)} == MANIFEST")
        # identité d'ensemble : fiche ⇔ MANIFEST (aucun rôle fantôme NI manquant).
        fiche_ids = set(re.findall(r"`([a-z][a-z-]+)` \(portail [^)]+\) →", conf_fiche))
        exp_ids = set(conf_perm_by_id)
        if fiche_ids == exp_ids:
            good(f"Legal confotur fiche · identité d'ensemble — {sorted(fiche_ids)} == "
                 f"roles_rbac_utilises (aucun rôle fantôme/manquant)")
        else:
            miss = sorted(exp_ids - fiche_ids); extra = sorted(fiche_ids - exp_ids)
            detail = (f" · absents={miss}" if miss else "") + \
                     (f" · en trop (fantôme)={extra}" if extra else "")
            bad(f"Legal confotur fiche · {CONF_FICHE} — rôles énumérés {sorted(fiche_ids)} MAIS "
                f"MANIFEST {sorted(exp_ids)}{detail} → régénérer la fiche")

# ============================================================================
# Audit 5D · synthèse de l'audit de conformité (contrôles / dimensions / verdict
# pass-fail-à confirmer) — même classe de « vert trompeur », transcrite À LA MAIN
# dans DEUX docs.
# ----------------------------------------------------------------------------
# Le générateur audit_5d émet DEUX artefacts byte-gatés par check_artifacts
# (⇒ faisant autorité) : out/MANIFEST.json (controls_total, `dimensions`,
# `audited_artifacts`, `open_items`, verdict — recomptés du catalogue de contrôles
# à chaque build) ET out/audit_report.json dont `totals` porte la ventilation
# {controls, pass, fail, a_confirmer} recalculée en rejouant les 17 contrôles sur
# les hand-off amont. Deux nombres de cette synthèse sont recopiés À LA MAIN à
# DEUX endroits : la phrase d'ouverture du README du module (05_deliverables_mvp/
# qa/audit_5d/README.md — « **17 contrôles** répartis en **5 dimensions (5D)** »)
# ET la cellule de la fiche agent QA (03_agents/qa/AGENT.md — « 17 contrôles / 5
# dimensions »). La ventilation du verdict (« **13 PASS · 0 FAIL · 4 à confirmer** »)
# n'est portée QUE par le README, mais se PÉRIME dès qu'un contrôle bascule en
# A_CONFIRMER (pass 13→12, à confirmer 4→5) alors que le TOTAL de contrôles reste
# 17 — dérive AVEUGLE au seul compte de contrôles, d'où sa recomputation séparée
# depuis audit_report.totals. Aucun gate ne couvrait ces claims : ajouter un
# contrôle/une dimension au catalogue, ou faire basculer un contrôle, ferait
# dériver LES DEUX docs (et la ligne verdict) en silence pendant que les artefacts
# disent autre chose — même piège que les mots-clés SEO, les triplets par workspace
# des portails ou le quintuplet CONFOTUR. On RECOMPUTE ici (contrôles + dimensions
# depuis le MANIFEST · pass/fail/à confirmer depuis audit_report.totals · déjà
# byte-gatés · zéro duplication du compteur du générateur) et on exige l'égalité.
# Un claim absent échoue AUSSI (traçabilité). Cohérence croisée en bonus : le
# `controls_total` du MANIFEST doit égaler `totals.controls` du rapport.
# ============================================================================
A5D_RD = f"{D}/qa/audit_5d/README.md"
A5D_FI = "03_agents/qa/AGENT.md"
try:
    a5_man = load("qa/audit_5d/out/MANIFEST.json")
    a5_tot = load("qa/audit_5d/out/audit_report.json")["totals"]
    a5_ctl = str(a5_man["controls_total"])
    a5_dim = str(len(a5_man["dimensions"]))
    if a5_ctl != str(a5_tot["controls"]):
        bad(f"Audit 5D · incohérence artefacts : MANIFEST controls_total={a5_ctl} "
            f"MAIS audit_report totals.controls={a5_tot['controls']}")
    a5_pass, a5_fail = str(a5_tot["pass"]), str(a5_tot["fail"])
    a5_conf = str(a5_tot["a_confirmer"])
except (OSError, KeyError, ValueError) as e:
    bad(f"Audit 5D · artefact illisible/inattendu : {e}"); a5_ctl = None
if a5_ctl is not None:
    a5_src = f"contrôles={a5_ctl} dimensions={a5_dim}"
    # 1) README module — phrase d'ouverture (contrôles + dimensions)
    a5_rd = read_norm(A5D_RD)
    if a5_rd is not None:
        m = re.search(r"\*\*(\d+) contrôles\*\* répartis en \*\*(\d+) dimensions \(5D\)\*\*",
                      a5_rd)
        if m is None:
            bad(f"Audit 5D · {A5D_RD} — synthèse contrôles/dimensions INTROUVABLE "
                f"(attendu {a5_src})")
        elif m.groups() == (a5_ctl, a5_dim):
            good(f"Audit 5D · {A5D_RD} — {'/'.join(m.groups())} == artefact ({a5_src})")
        else:
            bad(f"Audit 5D · {A5D_RD} — dit {'/'.join(m.groups())} MAIS artefact dit "
                f"{a5_ctl}/{a5_dim} ({a5_src}) → régénérer le README")
        # 2) README module — ventilation du verdict (pass / fail / à confirmer)
        a5_vsrc = f"pass={a5_pass} fail={a5_fail} à_confirmer={a5_conf}"
        m = re.search(r"\*\*(\d+) PASS · (\d+) FAIL · (\d+) à confirmer\*\*", a5_rd)
        if m is None:
            bad(f"Audit 5D · {A5D_RD} — ventilation verdict INTROUVABLE (attendu {a5_vsrc})")
        elif m.groups() == (a5_pass, a5_fail, a5_conf):
            good(f"Audit 5D · {A5D_RD} — verdict {'/'.join(m.groups())} == "
                 f"audit_report.totals ({a5_vsrc})")
        else:
            bad(f"Audit 5D · {A5D_RD} — dit {'/'.join(m.groups())} MAIS totals disent "
                f"{a5_pass}/{a5_fail}/{a5_conf} ({a5_vsrc}) → régénérer le README")
    # 3) Fiche agent QA — même couple contrôles/dimensions, 2e transcription
    a5_fi = read_norm(A5D_FI)
    if a5_fi is not None:
        m = re.search(r"(\d+) contrôles / (\d+) dimensions", a5_fi)
        if m is None:
            bad(f"Audit 5D · {A5D_FI} — claim « N contrôles / M dimensions » INTROUVABLE "
                f"(attendu {a5_src})")
        elif m.groups() == (a5_ctl, a5_dim):
            good(f"Audit 5D · {A5D_FI} — {'/'.join(m.groups())} == artefact ({a5_src})")
        else:
            bad(f"Audit 5D · {A5D_FI} — dit {'/'.join(m.groups())} MAIS artefact dit "
                f"{a5_ctl}/{a5_dim} ({a5_src}) → régénérer la fiche")

# ----------------------------------------------------------------------------
# Audit 5D · IDENTITÉ du VERDICT GLOBAL — le LABEL de conclusion (`PASS` /
# `PASS_WITH_OPEN_ITEMS` / `FAIL`), pas seulement sa ventilation chiffrée.
# ----------------------------------------------------------------------------
# Le bloc de ventilation ci-dessus recompute le TRIPLET chiffré (pass/fail/à
# confirmer) mais reste AVEUGLE au LABEL de verdict lui-même — la HEADLINE de tout
# l'audit. Ce label est data-derived (out/audit_report.json[verdict] == out/
# MANIFEST.json[verdict], byte-gatés par check_artifacts ⇒ faisant autorité),
# recalculé par le générateur (qalib/builder.py) via la règle EXACTE : `FAIL` si
# ≥1 FAIL · sinon `PASS_WITH_OPEN_ITEMS` si ≥1 A_CONFIRMER · sinon `PASS`. Le README
# le transcrit À LA MAIN à DEUX endroits — la RÈGLE (« Verdict global : `FAIL` si …
# sinon `PASS_WITH_OPEN_ITEMS` … sinon `PASS` ») ET le VERDICT COURANT
# (« ## Verdict courant \n `PASS_WITH_OPEN_ITEMS` — **13 PASS · 0 FAIL · 4 à
# confirmer** »). AUCUN gate ne le vérifiait : le triplet peut coller à l'artefact
# pendant que le LABEL courant ment (écrire `PASS` alors que 4 open items
# subsistent = violation #6 « ne jamais forcer un PASS », le CŒUR même du livrable —
# l'audit prétend propre un dossier avec 4 params réglementaires non confirmés) ;
# ou la RÈGLE documentée dérive du codomaine réel du générateur (un label renommé
# dans builder.py, la prose périmée). check_artifacts ne prouve QUE
# audit_report==build (byte) ; aucune suite tests/ (FONCTIONS, pas la prose du
# README) n'attrape ces deux dérives. On MIROITE la règle du générateur et on
# exige : (a) HONNÊTETÉ interne — audit_report.verdict == verdict recalculé du
# triplet (l'audit ne peut afficher un verdict incohérent avec ses propres
# chiffres) ; (b) CROISÉ — MANIFEST.verdict == audit_report.verdict ; (c) le LABEL
# « Verdict courant » du README == audit_report.verdict ; (d) la RÈGLE du README
# énumère EXACTEMENT le codomaine {FAIL, PASS_WITH_OPEN_ITEMS, PASS} (un label
# renommé côté code sans mise à jour de la règle est mordu). Un claim absent échoue
# AUSSI (disparition = régression).
# ============================================================================
A5V_DOMAIN = {"FAIL", "PASS_WITH_OPEN_ITEMS", "PASS"}
def _a5_verdict_from_totals(t):
    # miroir EXACT de qalib/builder.py : FAIL prime, puis open items, sinon PASS
    if t["fail"]:
        return "FAIL"
    if t["a_confirmer"]:
        return "PASS_WITH_OPEN_ITEMS"
    return "PASS"
try:
    a5v_rep = load("qa/audit_5d/out/audit_report.json")
    a5v_verdict = a5v_rep["verdict"]
    a5v_tot = a5v_rep["totals"]
    a5v_man_verdict = load("qa/audit_5d/out/MANIFEST.json")["verdict"]
except (OSError, KeyError, ValueError, TypeError) as e:
    bad(f"Audit 5D · verdict — artefact illisible/inattendu : {e}"); a5v_verdict = None
if a5v_verdict is not None:
    # (a) honnêteté interne : le verdict de l'artefact découle de ses propres totals
    a5v_exp = _a5_verdict_from_totals(a5v_tot)
    if a5v_verdict != a5v_exp:
        bad(f"Audit 5D · verdict — audit_report dit « {a5v_verdict} » MAIS ses totals "
            f"(fail={a5v_tot['fail']} · à_confirmer={a5v_tot['a_confirmer']}) imposent "
            f"« {a5v_exp} » (verdict FORCÉ, incohérent — viole #6)")
    else:
        good(f"Audit 5D · verdict — audit_report « {a5v_verdict} » == recalcul du triplet "
             f"(fail={a5v_tot['fail']} · à_confirmer={a5v_tot['a_confirmer']})")
    # (b) croisé artefacts : MANIFEST == rapport
    if a5v_man_verdict != a5v_verdict:
        bad(f"Audit 5D · verdict — MANIFEST dit « {a5v_man_verdict} » MAIS audit_report "
            f"dit « {a5v_verdict} » (artefacts désynchronisés)")
    else:
        good(f"Audit 5D · verdict — MANIFEST « {a5v_man_verdict} » == audit_report")
    a5v_rd = read_norm(A5D_RD)
    if a5v_rd is not None:
        # (c) LABEL « Verdict courant » du README == verdict de l'artefact
        m = re.search(r"## Verdict courant\s+`([A-Z_]+)`\s*—\s*\*\*\d+ PASS", a5v_rd)
        if m is None:
            bad(f"Audit 5D · {A5D_RD} — label « Verdict courant » INTROUVABLE "
                f"(attendu « {a5v_verdict} »)")
        elif m.group(1) == a5v_verdict:
            good(f"Audit 5D · {A5D_RD} — « Verdict courant » = « {m.group(1)} » == artefact")
        else:
            bad(f"Audit 5D · {A5D_RD} — « Verdict courant » = « {m.group(1)} » MAIS "
                f"artefact dit « {a5v_verdict} » → régénérer le README")
        # (d) la RÈGLE documentée énumère EXACTEMENT le codomaine du générateur
        seg = re.search(r"Verdict global\s*:\s*(.+?)(?: > | ## )", a5v_rd)
        if seg is None:
            bad(f"Audit 5D · {A5D_RD} — règle « Verdict global : … » INTROUVABLE "
                f"(codomaine {sorted(A5V_DOMAIN)} non vérifiable)")
        else:
            labels = set(re.findall(r"`([A-Z_]+)`", seg.group(1)))
            if labels == A5V_DOMAIN:
                good(f"Audit 5D · {A5D_RD} — règle « Verdict global » énumère le codomaine "
                     f"{sorted(A5V_DOMAIN)} == générateur")
            else:
                miss = sorted(A5V_DOMAIN - labels); extra = sorted(labels - A5V_DOMAIN)
                bad(f"Audit 5D · {A5D_RD} — règle « Verdict global » énumère {sorted(labels)} "
                    f"≠ codomaine générateur {sorted(A5V_DOMAIN)} "
                    f"(manquants={miss or '—'} en_trop={extra or '—'}) → aligner la règle")

# ============================================================================
# Audit 5D (2e surface du MÊME README) · ÉNUMÉRATION des open items du « Verdict
# courant » (control → dimension → propriétaire) — même classe de « vert trompeur ».
# ----------------------------------------------------------------------------
# Le bloc précédent gate le COMPTE de la ventilation (« 13 PASS · 0 FAIL · 4 à
# confirmer », pass/fail/à confirmer recomputés d'audit_report.totals) mais PAS
# l'IDENTITÉ des 4 contrôles ouverts ni leur (dimension, propriétaire). La table
# « Verdict courant » du README (05_deliverables_mvp/qa/audit_5d/README.md) énumère
# ces 4 lignes À LA MAIN, alors que audit_report.open_items[] (byte-gaté par
# check_artifacts ⇒ faisant autorité, chaque item = {control, dimension, owner,
# detail} recalculé en rejouant les contrôles) fait foi. Un ÉCHANGE d'open item
# (ex. D2.3 → D3.1) laisse le compte à 4 — le bloc de ventilation reste AVEUGLE —
# pendant que l'artefact dit autre chose ; idem une dimension mal étiquetée
# (D1.3 rangé sous D2) ou un propriétaire réattribué. « Vert trompeur » qu'aucune
# suite tests/ (qui teste des FONCTIONS, pas la prose) n'attrape. On RECOMPUTE
# l'ensemble {control:(dimension, owner)} depuis open_items (zéro duplication du
# modèle de l'auditeur · #6) et on exige que la table README l'énumère EXACTEMENT :
# contrôle par ENSEMBLE (absent ET en trop mordus, pas seulement présence), puis
# (dimension, propriétaire) par LIGNE. Même patron que l'énumération des
# confirmations DevOps ou la carte de renormalisation par archétype 4Big. Cohérence
# croisée en bonus : l'ensemble des `control` == MANIFEST.open_items (le manifeste
# qui résume le rapport) — mord un manifeste désynchronisé de son propre rapport.
# ============================================================================
try:
    a5_oi = load("qa/audit_5d/out/audit_report.json")["open_items"]
    a5_oi_exp = {o["control"]: (o["dimension"], o["owner"]) for o in a5_oi}
    a5_oi_man = set(load("qa/audit_5d/out/MANIFEST.json")["open_items"])
    if a5_oi_man != set(a5_oi_exp):
        bad(f"Audit 5D · incohérence artefacts : MANIFEST open_items {sorted(a5_oi_man)} "
            f"MAIS audit_report open_items {sorted(a5_oi_exp)}")
except (OSError, KeyError, ValueError) as e:
    bad(f"Audit 5D · open_items illisible/inattendu : {e}"); a5_oi_exp = None
if a5_oi_exp is not None:
    try:
        a5_lines = open(f"{D}/qa/audit_5d/README.md",
                        encoding="utf-8").read().splitlines()
    except OSError as e:
        bad(f"Audit 5D · README illisible : {e}"); a5_lines = None
    if a5_lines is not None:
        # ligne de table = « | <control> · <libellé> | <dimension> | <propriétaire> | »
        a5_row_re = re.compile(
            r"^\|\s*(D\d+\.\d+)\b[^|]*\|\s*([^|]+?)\s*\|\s*([^|]+?)\s*\|\s*$")
        a5_seen = {}
        for ln in a5_lines:
            m = a5_row_re.match(ln)
            if m:
                a5_seen[m.group(1)] = (m.group(2).strip(), m.group(3).strip())
        a5_exp_set, a5_got_set = set(a5_oi_exp), set(a5_seen)
        if a5_got_set != a5_exp_set:
            bad(f"Audit 5D · énumération open items — table README énumère "
                f"{sorted(a5_got_set)} MAIS audit_report dit {sorted(a5_exp_set)} "
                f"→ régénérer le README")
        else:
            a5_mism = [c for c in sorted(a5_exp_set) if a5_seen[c] != a5_oi_exp[c]]
            if a5_mism:
                for c in a5_mism:
                    bad(f"Audit 5D · open item {c} — README dit "
                        f"(dimension={a5_seen[c][0]}, propriétaire={a5_seen[c][1]}) MAIS "
                        f"audit_report dit (dimension={a5_oi_exp[c][0]}, "
                        f"propriétaire={a5_oi_exp[c][1]})")
            else:
                good(f"Audit 5D · énumération open items {sorted(a5_exp_set)} "
                     f"(control→dimension→propriétaire) == audit_report.open_items")

# ============================================================================
# Audit 5D (3e surface du MÊME README) · IDENTITÉ des 5 DIMENSIONS — le libellé
# (`titre`) ET la NORME de référence (`reference`) de chaque dimension du cadre —
# même classe de « vert trompeur », transcrite À LA MAIN dans un tableau.
# ----------------------------------------------------------------------------
# Le 1er bloc audit_5d gate le COMPTE de dimensions (« 5 dimensions (5D) ») — un
# WILDCARD AVEUGLE à QUELLE dimension : renommer « Traçabilité & anti-invention »
# ou permuter une norme de référence (D3 fiscal citant la mauvaise Ley, D1
# probatoire citant `ISA 501` au lieu d'`ISA 500`) laisse le compte à 5 pendant que
# l'artefact dit autre chose. La table « ## Les 5 dimensions » du README
# (05_deliverables_mvp/qa/audit_5d/README.md) énumère PAR LIGNE le triplet
# (`id`, `titre`, `reference`) alors que audit_report.dimensions[] (byte-gaté par
# check_artifacts ⇒ faisant autorité, chaque dimension recomposée du catalogue de
# contrôles à chaque build) fait foi. C'est l'audit de SECOND NIVEAU que
# l'auditeur/banquier lit : une norme de référence périmée (D2 AML citant une
# mauvaise Ley 155-17, D3 fiscal la mauvaise Ley 32-23) est une AFFIRMATION de
# conformité fausse — exactement l'invention que #6 interdit, qu'aucune suite
# tests/ (qui teste des FONCTIONS de contrôle, pas la prose) n'attrape. On RECOMPUTE
# la liste ORDONNÉE {id:(titre, reference)} depuis audit_report.dimensions[] (zéro
# duplication du modèle de l'auditeur · #6) et on exige que la table README
# l'énumère EXACTEMENT : identité par ENSEMBLE d'`id` (absent ET en trop mordus),
# ORDRE préservé (D1→D5), puis (titre, reference) par LIGNE. Même patron que
# l'énumération des open items ci-dessus. Cohérence croisée en bonus : les
# dimensions du rapport == celles de audit_spec.json (l'INPUT dont le rapport
# dérive) — mord un builder qui renommerait/permuterait une dimension en aval du
# spec. Un claim absent (table disparue) échoue AUSSI (traçabilité #6).
# ============================================================================
try:
    a5d_dim = load("qa/audit_5d/out/audit_report.json")["dimensions"]
    a5d_exp = {d["id"]: (d["titre"], d["reference"]) for d in a5d_dim}
    a5d_order = [d["id"] for d in a5d_dim]
    a5d_spec = load("qa/audit_5d/audit_spec.json")["dimensions"]
    a5d_spec_map = {d["id"]: (d["titre"], d["reference"]) for d in a5d_spec}
    if a5d_spec_map != a5d_exp:
        bad(f"Audit 5D · incohérence artefacts : audit_spec dimensions {sorted(a5d_spec_map)} "
            f"divergent du rapport {sorted(a5d_exp)} (titre/reference) → le rapport doit "
            f"dériver du spec")
except (OSError, KeyError, ValueError) as e:
    bad(f"Audit 5D · dimensions illisibles/inattendues : {e}"); a5d_exp = None
if a5d_exp is not None:
    try:
        a5d_lines = open(f"{D}/qa/audit_5d/README.md",
                         encoding="utf-8").read().splitlines()
    except OSError as e:
        bad(f"Audit 5D · README illisible : {e}"); a5d_lines = None
    if a5d_lines is not None:
        # ligne de table = « | **D1** | <titre> | <reference> | » (gras + PAS de
        # sous-point ⇒ distingue des lignes open items « | D1.1 · … | »)
        a5d_row_re = re.compile(
            r"^\|\s*\*\*(D\d+)\*\*\s*\|\s*([^|]+?)\s*\|\s*([^|]+?)\s*\|\s*$")
        a5d_seen, a5d_seen_order = {}, []
        for ln in a5d_lines:
            m = a5d_row_re.match(ln)
            if m:
                a5d_seen[m.group(1)] = (m.group(2).strip(), m.group(3).strip())
                a5d_seen_order.append(m.group(1))
        a5d_exp_ids, a5d_got_ids = set(a5d_exp), set(a5d_seen)
        if a5d_got_ids != a5d_exp_ids:
            bad(f"Audit 5D · IDENTITÉ des dimensions — table README énumère "
                f"{sorted(a5d_got_ids)} MAIS audit_report dit {sorted(a5d_exp_ids)} "
                f"→ régénérer le README")
        elif a5d_seen_order != a5d_order:
            bad(f"Audit 5D · IDENTITÉ des dimensions — ORDRE README {a5d_seen_order} "
                f"MAIS audit_report dit {a5d_order} → régénérer le README")
        else:
            a5d_mism = [i for i in a5d_order if a5d_seen[i] != a5d_exp[i]]
            if a5d_mism:
                for i in a5d_mism:
                    bad(f"Audit 5D · dimension {i} — README dit "
                        f"(titre={a5d_seen[i][0]!r}, reference={a5d_seen[i][1]!r}) MAIS "
                        f"audit_report dit (titre={a5d_exp[i][0]!r}, "
                        f"reference={a5d_exp[i][1]!r})")
            else:
                good(f"Audit 5D · IDENTITÉ des 5 dimensions {a5d_order} "
                     f"(id→titre→reference) == audit_report.dimensions")

# ============================================================================
# Audit 5D (4e surface du MÊME README) · IDENTITÉ des LIVRABLES AUDITÉS — la liste
# des hand-off `out/*.json` que l'audit de second niveau LIT comme matière première,
# énumérée À LA MAIN dans la section « ## Livrables audités » — même classe de
# « vert trompeur ».
# ----------------------------------------------------------------------------
# Les 3 blocs audit_5d ci-dessus gatent la SYNTHÈSE (contrôles/dimensions/verdict),
# l'ÉNUMÉRATION des open items et l'IDENTITÉ des 5 dimensions — AUCUN ne touche la
# liste des ARTEFACTS SOURCES. Cette liste vit dans MANIFEST.audited_artifacts
# (byte-gaté par check_artifacts ⇒ faisant autorité : le générateur la recompute à
# chaque build via `sorted(ARTIFACT_PATHS.values())`, le registre des hand-off amont
# que `load_artifacts()` charge réellement). Le README (05_deliverables_mvp/qa/
# audit_5d/README.md, section « ## Livrables audités (source = leur `out/`) »)
# réénumère ces 5 chemins À LA MAIN — dans un ORDRE DIFFÉRENT du MANIFEST (workflow
# 2e ici, 3e au MANIFEST, trié) ⇒ comparaison par ENSEMBLE. Piège : brancher un
# nouveau livrable amont sur l'audit (nouvelle entrée ARTIFACT_PATHS → nouveau chemin
# dans audited_artifacts) ou en retirer un laisse la section README PÉRIMÉE en
# silence pendant que l'audit lit un autre jeu de fichiers — le lecteur/banquier
# croirait l'audit couvrir un périmètre qu'il ne couvre plus (l'invention #6
# interdit), qu'aucune suite tests/ (qui teste des FONCTIONS de contrôle, pas la
# prose) n'attrape. On RECOMPUTE l'ensemble depuis MANIFEST.audited_artifacts (zéro
# duplication du registre) et on exige que les puces README l'énumèrent EXACTEMENT
# (absent ET fantôme mordus). Cohérences croisées bonus (mordent un MANIFEST/spec
# INTERNEMENT incohérent) : (a) chaque chemin audité RÉSOUT un fichier commité sur
# disque (le `_load` de l'auditeur lève sinon — audit à vide impossible) ; (b) le
# NOMBRE de livrables audités == le nombre d'ALIAS distincts d'artefacts référencés
# par les contrôles du spec (audit_spec.json · union des `controls[].artifacts`) : un
# artefact chargé mais jamais contrôlé, ou un alias contrôlé sans chemin de
# chargement (KeyError runtime), désynchronise les deux. Section absente ⇒ ROUGE
# (traçabilité #6). Direction README/MANIFEST → registre/spec.
# ============================================================================
import os
try:
    a5a_exp = load("qa/audit_5d/out/MANIFEST.json")["audited_artifacts"]
    a5a_ctrls = load("qa/audit_5d/audit_spec.json")["controls"]
except (OSError, KeyError, ValueError) as e:
    bad(f"Audit 5D · livrables audités illisibles/inattendus : {e}"); a5a_exp = None
if a5a_exp is not None:
    a5a_exp_set = set(a5a_exp)
    # tri/dédup : audited_artifacts = sorted(dict.values()) — mord un MANIFEST malformé
    if a5a_exp != sorted(a5a_exp_set) or len(a5a_exp) != len(a5a_exp_set):
        bad(f"Audit 5D · MANIFEST.audited_artifacts non trié/dédupliqué : {a5a_exp}")
    # (a) chaque chemin audité résout un fichier commité (audit à vide impossible)
    a5a_missing = [p for p in a5a_exp if not os.path.exists(f"{D}/{p}")]
    if a5a_missing:
        bad(f"Audit 5D · livrables audités INTROUVABLES sur disque {a5a_missing} → "
            f"l'auditeur lèverait FileNotFoundError (aucun audit à vide)")
    else:
        good(f"Audit 5D · les {len(a5a_exp)} livrables audités résolvent tous un "
             f"fichier commité")
    # (b) |audited| == |alias distincts référencés par les contrôles du spec|
    a5a_alias = set()
    for ctl in a5a_ctrls:
        a5a_alias |= set(ctl.get("artifacts", []))
    if len(a5a_alias) == len(a5a_exp_set):
        good(f"Audit 5D · {len(a5a_exp_set)} livrables audités == {len(a5a_alias)} "
             f"alias d'artefacts distincts contrôlés par le spec (aucun mort/orphelin)")
    else:
        bad(f"Audit 5D · désynchro registre/spec : {len(a5a_exp_set)} livrables "
            f"audités MAIS {len(a5a_alias)} alias distincts contrôlés "
            f"({sorted(a5a_alias)}) → artefact chargé jamais contrôlé, ou alias sans "
            f"chemin de chargement")
    # README — puces de la section « ## Livrables audités »
    try:
        a5a_lines = open(f"{D}/qa/audit_5d/README.md",
                         encoding="utf-8").read().splitlines()
    except OSError as e:
        bad(f"Audit 5D · README illisible : {e}"); a5a_lines = None
    if a5a_lines is not None:
        a5a_got, a5a_in_sec = set(), False
        a5a_bullet = re.compile(r"^-\s+`([^`]+)`\s*$")
        for ln in a5a_lines:
            if re.match(r"^##\s+Livrables audités\b", ln):
                a5a_in_sec = True; continue
            if a5a_in_sec and ln.startswith("## "):
                break
            if a5a_in_sec:
                mb = a5a_bullet.match(ln)
                if mb:
                    a5a_got.add(mb.group(1))
        if not a5a_got:
            bad(f"Audit 5D · section « ## Livrables audités » INTROUVABLE/vide dans "
                f"{D}/qa/audit_5d/README.md (attendu {sorted(a5a_exp_set)})")
        elif a5a_got == a5a_exp_set:
            good(f"Audit 5D · livrables audités — les {len(a5a_got)} puces README == "
                 f"MANIFEST.audited_artifacts (aucun fantôme/manquant)")
        else:
            miss = sorted(a5a_exp_set - a5a_got); extra = sorted(a5a_got - a5a_exp_set)
            detail = (f" · absents={miss}" if miss else "") + \
                     (f" · en trop (fantôme)={extra}" if extra else "")
            bad(f"Audit 5D · livrables audités — README énumère {sorted(a5a_got)} MAIS "
                f"MANIFEST dit {sorted(a5a_exp_set)}{detail} → régénérer le README")

# ============================================================================
# DevOps · run-book de déploiement VPS (phases ordonnées / confirmations
# préalables) — même classe de « vert trompeur », transcrite À LA MAIN dans
# DEUX docs, sans AUCUN gate.
# ----------------------------------------------------------------------------
# Le générateur deploy_runbook émet out/MANIFEST.json (byte-gaté par
# check_artifacts ⇒ faisant autorité) dont `counts.phases` et
# `counts.confirmations` sont RECOMPTÉS à chaque build (phases = |graph.phase_order| ·
# confirmations = |confirmations_ouvertes|, l'ensemble consolidé des paramètres
# réglementaires non confirmés). Deux nombres de cette synthèse sont recopiés À LA
# MAIN : le compte de PHASES dans la fiche agent DevOps (03_agents/devops/AGENT.md —
# « graphe de **7 phases ordonnées** » ET, plus bas, « exécute les 7 phases sur le
# VPS ») et le compte de CONFIRMATIONS dans le README du module
# (05_deliverables_mvp/devops/deploy_runbook/README.md — « renseigner les 7
# confirmations »). Piège « aveugle » caractéristique (cf. bascule verdict audit_5d,
# compensation SEO entre langues) : phases ET confirmations valent 7 AUJOURD'HUI —
# un gate qui ne recompterait qu'UN nombre serait AVEUGLE à la dérive de l'autre
# (ajouter une phase au graphe → 8 phases pendant que les confirmations restent 7,
# ou l'inverse). On RECOMPUTE donc CHAQUE nombre depuis SON PROPRE champ du MANIFEST
# (jamais figé ici · zéro duplication du compteur du générateur #5) et on exige
# l'égalité avec les docs. Bonus anti-dérive : l'ÉNUMÉRATION des confirmations du
# README (les tokens `snake_case` de la phrase « Confirmations préalables VPS »)
# doit être l'ensemble EXACT de confirmations_ouvertes — capte l'ÉCHANGE d'un nom de
# confirmation (count aveugle à un swap 1↔1). Cohérence croisée : counts.phases ==
# |phase_order| · counts.confirmations == |confirmations_ouvertes|. Un claim absent
# échoue AUSSI (la disparition d'un chiffre est une régression · traçabilité).
# ============================================================================
DR_RD = f"{D}/devops/deploy_runbook/README.md"
DR_FI = "03_agents/devops/AGENT.md"
try:
    dr_man = load("devops/deploy_runbook/out/MANIFEST.json")
    dr_ph = str(dr_man["counts"]["phases"])
    dr_cf = str(dr_man["counts"]["confirmations"])
    dr_order = dr_man["graph"]["phase_order"]
    dr_open = dr_man["confirmations_ouvertes"]
    if dr_ph != str(len(dr_order)):
        bad(f"DevOps · incohérence artefact : counts.phases={dr_ph} MAIS "
            f"|graph.phase_order|={len(dr_order)}")
    if dr_cf != str(len(dr_open)):
        bad(f"DevOps · incohérence artefact : counts.confirmations={dr_cf} MAIS "
            f"|confirmations_ouvertes|={len(dr_open)}")
except (OSError, KeyError, ValueError) as e:
    bad(f"DevOps · artefact deploy_runbook illisible/inattendu : {e}"); dr_ph = None
if dr_ph is not None:
    # 1) Fiche agent DevOps — « graphe de N phases ordonnées »
    dr_fi = read_norm(DR_FI)
    if dr_fi is not None:
        m = re.search(r"graphe de \*\*(\d+) phases ordonnées\*\*", dr_fi)
        if m is None:
            bad(f"DevOps · {DR_FI} — claim « graphe de N phases ordonnées » INTROUVABLE "
                f"(attendu phases={dr_ph})")
        elif m.group(1) == dr_ph:
            good(f"DevOps · {DR_FI} — {m.group(1)} phases == artefact (phases={dr_ph})")
        else:
            bad(f"DevOps · {DR_FI} — dit {m.group(1)} phases MAIS artefact dit {dr_ph} "
                f"→ régénérer la fiche")
        # 2) Fiche agent DevOps — 2e transcription « exécute les N phases »
        m = re.search(r"exécute les (\d+) phases", dr_fi)
        if m is None:
            bad(f"DevOps · {DR_FI} — 2e claim « exécute les N phases » INTROUVABLE "
                f"(attendu phases={dr_ph})")
        elif m.group(1) == dr_ph:
            good(f"DevOps · {DR_FI} — « exécute les {m.group(1)} phases » == artefact "
                 f"(phases={dr_ph})")
        else:
            bad(f"DevOps · {DR_FI} — 2e claim dit {m.group(1)} phases MAIS artefact dit "
                f"{dr_ph} → régénérer la fiche")
    # 3) README module — « renseigner les N confirmations » (compte)
    dr_rd = read_norm(DR_RD)
    if dr_rd is not None:
        m = re.search(r"renseigner les (\d+) confirmations", dr_rd)
        if m is None:
            bad(f"DevOps · {DR_RD} — claim « renseigner les N confirmations » INTROUVABLE "
                f"(attendu confirmations={dr_cf})")
        elif m.group(1) == dr_cf:
            good(f"DevOps · {DR_RD} — {m.group(1)} confirmations == artefact "
                 f"(confirmations={dr_cf})")
        else:
            bad(f"DevOps · {DR_RD} — dit {m.group(1)} confirmations MAIS artefact dit "
                f"{dr_cf} → régénérer le README")
        # 4) README module — l'ÉNUMÉRATION doit être l'ensemble EXACT des confirmations
        seg = re.search(r"Confirmations préalables VPS\*\* \(consolidées, sourcées\) : "
                        r"(.+?) ## ", dr_rd)
        if seg is None:
            bad(f"DevOps · {DR_RD} — énumération « Confirmations préalables VPS » "
                f"INTROUVABLE (attendu {sorted(dr_open)})")
        else:
            listed = sorted(set(re.findall(r"`([a-z_]+)`", seg.group(1))))
            if listed == sorted(dr_open):
                good(f"DevOps · {DR_RD} — énumération ({len(listed)}) == confirmations_ouvertes")
            else:
                bad(f"DevOps · {DR_RD} — énumération {listed} ≠ confirmations_ouvertes "
                    f"{sorted(dr_open)} → régénérer le README")

# 2bis) deploy_runbook — IDENTITÉ de la TABLE « Plan de phases généré » -----------
# Le bloc ci-dessus ne gate que DEUX comptes agrégés (phases · confirmations). La
# table « ## Plan de phases généré » énumère, PAR phase, QUATRE données data-derived
# de out/deploy_runbook.json (byte-gaté par check_artifacts) : le n° d'ordre (#), le
# RESPONSABLE (worker/vps/worker+vps), l'ensemble des MODULES gated déployés dans la
# phase, et « Dépend de » (les n°s d'ordre des `depends_on`). C'est le GRAPHE DE
# PORTAGE VPS ORDONNÉ que l'agent DevOps suit phase par phase — un module OUBLIÉ de
# la colonne Modules (donc jamais déployé), un responsable ou une dépendance périmés
# (ex. Frontend avant RBAC) sont un hazard réel, invisible à check_artifacts (qui ne
# prouve QUE deploy_runbook==build) ET au bloc de comptes (aveugle à l'identité des
# lignes ; ces colonnes étaient un WILDCARD). Même classe que la table run-book de
# rbac/apply_plan et la table demo/scenarios. Preuve reproduite : la table omettait
# `mobile/app_config` (phase 5) ET `qa/acceptance` (phase 7) — 20 modules listés sur
# 22 — pendant que le MANIFEST prouvait la couverture BIJECTIVE 22/22 vs CI : vert
# trompeur, l'agent DevOps aurait sauté 2 modules. On RECOMPUTE #/responsable/
# modules/deps depuis deploy_runbook.json (zéro duplication) et on exige que la table
# les énonce EXACTEMENT. Identité d'ensemble : {ordres des lignes} == {ordres de
# l'artefact} (aucune phase FANTÔME/MANQUANTE) ET l'UNION des cellules Modules ==
# l'ensemble des modules de l'artefact (aucun module OUBLIÉ/en trop). La colonne « Phase » reste
# libre (paraphrase). Cohérences croisées en bonus (mordent un plan INTERNEMENT
# incohérent) : ordres contigus 1..N sans doublon · responsable ∈ {worker, vps,
# worker+vps} · toute dépendance pointe en ARRIÈRE (n° < n° de la phase ⇒ pas de
# cycle/renvoi avant).
try:
    dr_plan = load("devops/deploy_runbook/out/deploy_runbook.json")
except (OSError, KeyError, ValueError) as e:
    bad(f"DevOps · run-book illisible : {e}"); dr_plan = None
if dr_plan is not None:
    id2order = {p["id"]: p["order"] for p in dr_plan}
    ALLOWED_RESP = {"worker", "vps", "worker+vps"}
    orders = sorted(p["order"] for p in dr_plan)
    all_modules = sorted(x["module"] for p in dr_plan for x in p["modules"])
    if orders == list(range(1, len(dr_plan) + 1)):
        good(f"DevOps · run-book — ordres contigus 1..{len(dr_plan)} (aucun trou/doublon)")
    else:
        bad(f"DevOps · run-book — ordres NON contigus {orders} "
            f"(attendu 1..{len(dr_plan)}) → plan incohérent")
    # Lignes lues en RAW : la table « Plan de phases généré » est la SEULE à 5
    # colonnes dont la 1ʳᵉ est numérique (l'autre table est `Fichier | Rôle`).
    try:
        dr_tbl = open(DR_RD, encoding="utf-8").read().splitlines()
    except OSError as e:
        bad(f"DevOps · {DR_RD} illisible : {e}"); dr_tbl = []
    table_orders, rows_by_order = [], {}
    for ln in dr_tbl:
        cells = [c.strip() for c in ln.strip().strip("|").split("|")]
        if len(cells) == 5 and re.fullmatch(r"\d+", cells[0]):
            o = int(cells[0]); table_orders.append(o); rows_by_order[o] = cells
    if sorted(table_orders) == orders and len(table_orders) == len(set(table_orders)):
        good(f"DevOps · run-book — {len(table_orders)} lignes == {len(dr_plan)} phases "
             f"(aucune FANTÔME/MANQUANTE)")
    else:
        bad(f"DevOps · run-book — lignes table {sorted(table_orders)} ≠ phases artefact "
            f"{orders} (ligne en trop/manquante ou doublon)")
    table_modules = sorted(m for r in rows_by_order.values()
                           for m in re.findall(r"`([^`]+)`", r[3]))
    if table_modules == all_modules:
        good(f"DevOps · run-book — union des Modules ({len(table_modules)}) == "
             f"{len(all_modules)} modules de l'artefact (aucun OUBLIÉ/en trop)")
    else:
        miss = sorted(set(all_modules) - set(table_modules))
        extra = sorted(set(table_modules) - set(all_modules))
        bad(f"DevOps · run-book — colonne Modules ≠ artefact : manquants={miss or '—'} "
            f"en_trop={extra or '—'} → régénérer le README")
    for p in sorted(dr_plan, key=lambda x: x["order"]):
        o, resp = p["order"], p["responsable"]
        deps = sorted(id2order[x] for x in p["depends_on"])
        mods = sorted(x["module"] for x in p["modules"])
        if resp not in ALLOWED_RESP:
            bad(f"DevOps · phase {o} — responsable artefact « {resp} » "
                f"hors {sorted(ALLOWED_RESP)} (plan incohérent)")
        if any(d >= o for d in deps):
            bad(f"DevOps · phase {o} — dépendance en AVANT {deps} (≥ {o}) : graphe incohérent")
        row = rows_by_order.get(o)
        if row is None:
            bad(f"DevOps · phase {o} — ligne INTROUVABLE dans {DR_RD} "
                f"(attendu responsable={resp} modules={mods} deps={deps or '—'})"); continue
        got_resp = row[1]
        got_mods = sorted(re.findall(r"`([^`]+)`", row[3]))
        got_deps = sorted(int(x) for x in re.findall(r"\d+", row[4]))
        if got_resp == resp:
            good(f"DevOps · phase {o} — responsable « {got_resp} » == artefact")
        else:
            bad(f"DevOps · phase {o} — {DR_RD} dit responsable « {got_resp} » MAIS "
                f"artefact dit « {resp} » → régénérer le README")
        if got_mods == mods:
            good(f"DevOps · phase {o} — Modules ({len(got_mods)}) == artefact")
        else:
            bad(f"DevOps · phase {o} — {DR_RD} Modules {got_mods} MAIS artefact dit "
                f"{mods} → régénérer le README")
        if got_deps == deps:
            good(f"DevOps · phase {o} — Dépend de {got_deps or '—'} == artefact")
        else:
            bad(f"DevOps · phase {o} — {DR_RD} « Dépend de » = {got_deps or '—'} MAIS "
                f"artefact dit {deps or '—'} → régénérer le README")

# 2ter) deploy_runbook — IMPUTABILITÉ des CONFIRMATIONS préalables VPS ------------
# La ligne « Confirmations préalables VPS » du README annote CHAQUE confirmation
# réglementaire d'un COUPLE data-derived : `nom` (OWNER · RÉF-CONTRÔLE), ex.
# `taux_commission` (Direction · audit_5d D1.1). L'OWNER = QUI doit confirmer le
# paramètre avant déploiement ; la RÉF = le contrôle qa/audit_5d (`D#.#`) — ou la
# session — qui en assure le suivi. Source faisant autorité : les `confirmations`
# de out/deploy_runbook.json (byte-gaté) — chaque item porte `owner` + `source` —
# CROISÉES avec le rapport qa/audit_5d (byte-gaté) : le contrôle cité y porte SON
# PROPRE `owner`. Les blocs DevOps amont ne gatent que le COMPTE de confirmations
# (2) et l'ENSEMBLE de leurs noms (bloc énumération) — AVEUGLES à QUI porte chaque
# confirmation et à QUEL contrôle la trace. Piège #6 : RÉAFFECTER `taux_commission`
# de Direction à Ventes (mauvais responsable relancé → confirmation jamais obtenue),
# MISATTRIBUER le contrôle (`D1.1`→`D2.1`, pointant un contrôle qui ne trace PAS ce
# paramètre) ou citer un contrôle FANTÔME (`D9.9`, absent d'audit_5d) laisse la
# prose périmée EN SILENCE pendant que l'artefact/l'audit disent autre chose →
# l'agent DevOps escaladerait le mauvais owner / le mauvais contrôle. La table
# phases (2bis) est aveugle aux confirmations ; aucune suite tests/ (FONCTIONS de
# graphe) n'attrape la prose. Cross-anchor à 3 : owner runbook COMPATIBLE avec
# owner du contrôle audit_5d (sous-ensemble de tokens · tolère le suffixe éditorial
# « / UAF ») · owner README ⊆ owner runbook (tolère l'abréviation « Fiscaliste eCF »
# ⊂ « Compta Fiscaliste eCF ») · réf README == contrôle réel (présent dans audit_5d
# ET dans la source). Une confirmation tracée par un contrôle DOIT rester annotée
# (disparition = régression de traçabilité #6). Une annotation d'un nom FANTÔME
# échoue AUSSI.
try:
    a5_rep = load("qa/audit_5d/out/audit_report.json")
    a5_owner = {c["id"]: c["owner"] for c in a5_rep["controls"]}
except (OSError, KeyError, ValueError) as e:
    bad(f"DevOps · confirmations — rapport audit_5d illisible : {e}"); a5_owner = None
dr_rd2 = read_norm(DR_RD)
if dr_plan is not None and a5_owner is not None and dr_rd2 is not None:
    def _toks(s):
        return {t for t in re.split(r"\s+", s.strip()) if t}
    def _ctrl(s):
        m = re.search(r"D\d\.\d+", s)
        return m.group(0) if m else None
    # confirmations aplaties (id → item) + unicité inter-phases
    conf_by_id, dups = {}, []
    for p in dr_plan:
        for c in p.get("confirmations", []):
            if c["id"] in conf_by_id:
                dups.append(c["id"])
            conf_by_id[c["id"]] = c
    if dups:
        bad(f"DevOps · confirmations — id(s) DUPLIQUÉ(s) inter-phases "
            f"{sorted(set(dups))} (plan incohérent)")
    # tracked : confirmations dont la `source` cite un contrôle audit_5d `D#.#`
    tracked = {cid: _ctrl(c["source"]) for cid, c in conf_by_id.items()}
    # annotations README : `nom` (owner · réf)
    ann = {}
    seg = re.search(r"Confirmations préalables VPS\*\* \(consolidées, sourcées\) : "
                    r"(.+?) ## ", dr_rd2)
    if seg is None:
        bad(f"DevOps · {DR_RD} — ligne « Confirmations préalables VPS » INTROUVABLE "
            f"(imputabilité non vérifiable)")
    else:
        for name, body in re.findall(r"`([a-z_]+)`\s*\(([^)]+)\)", seg.group(1)):
            parts = [x.strip() for x in body.split("·")]
            if len(parts) == 2:
                ann[name] = {"owner": parts[0], "ref": parts[1]}
    # (a) cross-module : chaque confirmation tracée porte un contrôle audit_5d RÉEL
    #     dont l'owner est COMPATIBLE avec l'owner du run-book (séparation des
    #     pouvoirs cohérente entre modules).
    for cid, ctrl in sorted(tracked.items()):
        if ctrl is None:
            continue
        c = conf_by_id[cid]
        if ctrl not in a5_owner:
            bad(f"DevOps · confirmation `{cid}` — source cite le contrôle `{ctrl}` "
                f"ABSENT du rapport qa/audit_5d (contrôle FANTÔME)"); continue
        if not _toks(c["owner"]) <= _toks(a5_owner[ctrl]):
            bad(f"DevOps · confirmation `{cid}` — owner run-book « {c['owner']} » "
                f"INCOMPATIBLE avec owner du contrôle {ctrl} audit_5d "
                f"« {a5_owner[ctrl]} » (imputabilité divergente inter-modules)")
        else:
            good(f"DevOps · confirmation `{cid}` — owner « {c['owner']} » ⊆ owner "
                 f"contrôle {ctrl} audit_5d (imputabilité cohérente)")
        if cid not in ann:
            bad(f"DevOps · confirmation `{cid}` (tracée par {ctrl}) — annotation "
                f"« (owner · réf) » INTROUVABLE dans {DR_RD} → régénérer le README")
    # (b) chaque annotation README désigne une confirmation RÉELLE + owner/réf exacts
    for cid, a in sorted(ann.items()):
        c = conf_by_id.get(cid)
        if c is None:
            bad(f"DevOps · annotation README `{cid}` — confirmation FANTÔME "
                f"(absente du run-book) → régénérer le README"); continue
        if not _toks(a["owner"]) <= _toks(c["owner"]):
            bad(f"DevOps · confirmation `{cid}` — README owner « {a['owner']} » "
                f"n'est PAS un sous-ensemble de l'owner artefact « {c['owner']} » "
                f"→ mauvais responsable")
        else:
            good(f"DevOps · confirmation `{cid}` — README owner « {a['owner']} » ⊆ "
                 f"artefact « {c['owner']} »")
        ref_ctrl = _ctrl(a["ref"])
        if ref_ctrl is not None:
            # réf = contrôle audit_5d : doit == le contrôle de la source ET exister
            if ref_ctrl != tracked.get(cid):
                bad(f"DevOps · confirmation `{cid}` — README réf cite {ref_ctrl} MAIS "
                    f"la source artefact trace {tracked.get(cid) or '(aucun contrôle)'} "
                    f"→ contrôle audit_5d périmé")
            elif ref_ctrl not in a5_owner:
                bad(f"DevOps · confirmation `{cid}` — README réf cite le contrôle "
                    f"`{ref_ctrl}` ABSENT du rapport qa/audit_5d (fantôme)")
            else:
                good(f"DevOps · confirmation `{cid}` — réf README == contrôle "
                     f"{ref_ctrl} (présent audit_5d + source)")
        else:
            # réf textuelle (ex. « session 19 ») : doit apparaître dans la source
            if a["ref"] not in c["source"]:
                bad(f"DevOps · confirmation `{cid}` — README réf « {a['ref']} » ABSENTE "
                    f"de la source artefact « {c['source']} »")
            else:
                good(f"DevOps · confirmation `{cid}` — réf « {a['ref']} » == source artefact")

# ============================================================================
# Démo · run-sheet de pitch (nombre de modules cités) — même classe de « vert
# trompeur », transcrit À LA MAIN dans le diagramme d'architecture du README du
# module, sans AUCUN gate.
# ----------------------------------------------------------------------------
# Le générateur demo_scenario_gen émet out/MANIFEST.json (byte-gaté par
# check_artifacts ⇒ faisant autorité) dont `counts.modules_cites_uniques` est
# RECOMPTÉ à chaque build = |union des `modules_cites` des deux scénarios| — les
# hand-off amont que les beats citent par pointeur RFC 6901 (le module ne saisit
# AUCUNE valeur métier ; il résout depuis le disque). Ce nombre est recopié À LA
# MAIN dans le diagramme du README du module (05_deliverables_mvp/demo/scenarios/
# README.md — « out/ des modules livrés (10 modules cités) »). Aucun gate ne le
# couvrait : ajouter un beat qui cite un module encore non tissé (11) ou retirer
# la dernière citation d'un module d'un scénario (9) ferait dériver le diagramme
# en silence pendant que le MANIFEST byte-gaté dit autre chose — dérive qu'aucune
# suite tests/ (qui teste des FONCTIONS, pas la prose du README) n'attrape. Même
# piège que les mots-clés SEO, les triplets par workspace des portails, le
# quintuplet CONFOTUR ou la synthèse audit_5d. On RECOMPUTE depuis
# counts.modules_cites_uniques (jamais figé ici · zéro duplication du compteur du
# générateur #5) et on exige l'égalité. Cohérence croisée en bonus :
# counts.modules_cites_uniques == |modules_cites| (liste dédupliquée du manifeste,
# elle-même sans doublon) — mord un compteur désynchronisé de sa propre liste. Un
# claim absent échoue AUSSI (la disparition d'un chiffre est une régression).
# ============================================================================
DEMO_RD = f"{D}/demo/scenarios/README.md"
try:
    demo_man = load("demo/scenarios/out/MANIFEST.json")
    demo_n = str(demo_man["counts"]["modules_cites_uniques"])
    demo_list = demo_man["modules_cites"]
    if len(demo_list) != len(set(demo_list)):
        bad(f"Démo · incohérence artefact : modules_cites contient des doublons "
            f"({len(demo_list)} entrées, {len(set(demo_list))} uniques)")
    if demo_n != str(len(demo_list)):
        bad(f"Démo · incohérence artefact : counts.modules_cites_uniques={demo_n} "
            f"MAIS |modules_cites|={len(demo_list)}")
except (OSError, KeyError, ValueError) as e:
    bad(f"Démo · artefact scenarios illisible/inattendu : {e}"); demo_n = None
if demo_n is not None:
    demo_rd = read_norm(DEMO_RD)
    if demo_rd is not None:
        m = re.search(r"out/ des modules livrés \((\d+) modules cités\)", demo_rd)
        if m is None:
            bad(f"Démo · {DEMO_RD} — claim « (N modules cités) » INTROUVABLE "
                f"(attendu modules_cites_uniques={demo_n})")
        elif m.group(1) == demo_n:
            good(f"Démo · {DEMO_RD} — {m.group(1)} modules cités == artefact "
                 f"(modules_cites_uniques={demo_n})")
        else:
            bad(f"Démo · {DEMO_RD} — dit {m.group(1)} modules cités MAIS artefact dit "
                f"{demo_n} → régénérer le README")

# ============================================================================
# Démo · run-sheet · MINIMA D'ORCHESTRATION (≥ M modules distincts · ≥ N beats)
# — même classe de « vert trompeur », au grain des SEUILS de la garde d'invariant.
# ----------------------------------------------------------------------------
# La garantie #3 du README du module (« chaque scénario tisse ≥ 2 modules
# distincts et ≥ 3 beats ») ÉNONCE deux planchers structurels qui sont EN RÉALITÉ
# imposés par la garde `check_invariants` (bloc (c)) de demo_scenario_gen.py — la
# SEULE autorité d'exécution : `len(sc["beats"]) < N` refuse un scénario sous N
# beats, `len(mods) < M` sous M modules distincts. Ces deux nombres sont recopiés
# À LA MAIN à TROIS surfaces, aucune gatée : (1) la comparaison `< N`/`< M` (le
# plancher EFFECTIF), (2) le message d'erreur « (min N) »/« (min M · orchestration) »
# (les bornes annoncées au caller), (3) la prose « ≥ M modules distincts … ≥ N
# beats » du README. Resserrer la règle (p.ex. exiger ≥ 3 modules pour une démo
# plus riche : `< 2`→`< 3`) sans toucher la prose laisse le README PÉRIMÉ en
# silence, documentant un plancher que le code n'applique plus — dérive qu'aucune
# suite tests/ (qui exerce la FONCTION `check_invariants`, jamais la prose du
# README) n'attrape, et que `check_artifacts` ne voit pas (`.py` ≠ `out/*.json`).
# Même patron que les seuils min_doc_bytes/min_test_methods de l'auditeur 4Big.
# On RE-DÉRIVE les deux minima depuis la comparaison `<` de la garde (source
# unique · zéro duplication #5) et on exige que le message d'erreur ET la prose
# du README les répètent À L'IDENTIQUE. Une surface INTROUVABLE échoue AUSSI
# (ancre morte · anti-évaporation #6).
# ============================================================================
DEMO_GEN = f"{D}/demo/scenarios/demo_scenario_gen.py"
try:
    demo_src = open(DEMO_GEN, encoding="utf-8").read()
except OSError as e:
    bad(f"Démo minima · {DEMO_GEN} illisible : {e}"); demo_src = None
if demo_src is not None:
    # (1) plancher EFFECTIF = comparaison `<` de la garde d'orchestration (autorité)
    mb = re.search(r'len\(sc\["beats"\]\)\s*<\s*(\d+)', demo_src)
    mm = re.search(r'len\(mods\)\s*<\s*(\d+)', demo_src)
    if mb is None or mm is None:
        bad(f"Démo minima · garde `check_invariants` — comparaison `len(...)<N` "
            f"INTROUVABLE dans {DEMO_GEN} (beats={mb is not None} modules={mm is not None})")
    else:
        min_beats, min_mods = mb.group(1), mm.group(1)
        good(f"Démo minima · garde d'orchestration — ≥ {min_mods} modules · "
             f"≥ {min_beats} beats (source unique = comparaison `<` de check_invariants)")
        # (2) message d'erreur de la garde == plancher effectif
        gm_b = re.search(r"beat\(s\) \(min (\d+)\)", demo_src)
        gm_m = re.search(r"\(min (\d+) · orchestration\)", demo_src)
        if gm_b is None or gm_m is None:
            bad(f"Démo minima · message d'erreur « (min N) » INTROUVABLE dans "
                f"{DEMO_GEN} (beats={gm_b is not None} modules={gm_m is not None})")
        elif gm_b.group(1) != min_beats or gm_m.group(1) != min_mods:
            bad(f"Démo minima · message garde dit (min {gm_m.group(1)} modules · "
                f"min {gm_b.group(1)} beats) MAIS la comparaison `<` impose "
                f"(≥ {min_mods} · ≥ {min_beats}) → aligner le message sur la garde")
        else:
            good(f"Démo minima · message garde « (min {min_mods} · orchestration) » / "
                 f"« (min {min_beats}) » == comparaison `<`")
        # (3) prose #3 du README == plancher effectif
        demo_rd3 = read_norm(DEMO_RD)
        if demo_rd3 is not None:
            mr = re.search(r"≥ (\d+) modules distincts\*\* et \*\*≥ (\d+) beats", demo_rd3)
            if mr is None:
                bad(f"Démo minima · prose « ≥ M modules distincts … ≥ N beats » "
                    f"INTROUVABLE dans {DEMO_RD} (attendu ≥ {min_mods} · ≥ {min_beats})")
            elif mr.groups() == (min_mods, min_beats):
                good(f"Démo minima · README — ≥ {mr.group(1)} modules · ≥ {mr.group(2)} "
                     f"beats == garde ({min_mods} · {min_beats})")
            else:
                bad(f"Démo minima · README dit ≥ {mr.group(1)} modules · ≥ {mr.group(2)} "
                    f"beats MAIS la garde impose ≥ {min_mods} · ≥ {min_beats} → "
                    f"régénérer le README")

# ============================================================================
# Chat OTOIA · montage de l'assistant conversationnel par portail (nombre de
# Custom Block / configs runtime / portails) — même classe de « vert trompeur »,
# transcrit À LA MAIN dans DEUX docs, sans AUCUN gate.
# ----------------------------------------------------------------------------
# Le générateur chat_otoia_gen émet out/MANIFEST.json (byte-gaté par
# check_artifacts ⇒ faisant autorité) dont `counts.custom_blocks`, `counts.mounts`
# et `counts.portails` sont RECOMPTÉS à chaque build depuis le contrat RBAC (un
# `Custom Block` ⇔ une config runtime ⇔ un portail métier, `plateforme` exclu —
# invariant 2 du générateur : bijection 1 block ⇔ 1 mount ⇔ 1 portail). Ces
# nombres sont recopiés À LA MAIN : la fiche agent Frontend Console
# (03_agents/frontend_console/AGENT.md — « **N `Custom Block` + M configs
# runtime** ») ET le README du module (05_deliverables_mvp/frontend/chat_otoia/
# README.md — « Portails (N, `plateforme` exclu) », « N fixtures `Custom Block` »,
# « M configs runtime »). Piège « aveugle » caractéristique (cf. DevOps phases ⁄
# confirmations, bascule verdict audit_5d) : custom_blocks, mounts ET portails
# valent TOUS TROIS 5 AUJOURD'HUI — un gate qui n'en recompterait qu'un serait
# AVEUGLE à la dérive des autres (ajouter un portail au contrat RBAC → chat_otoia
# rebâtit 6 blocks ⁄ 6 mounts ⁄ 6 portails pendant que les docs restent à 5). On
# RECOMPUTE donc CHAQUE nombre depuis SON PROPRE champ du MANIFEST (jamais figé
# ici · zéro duplication du compteur du générateur #5) et on exige l'égalité avec
# les docs. Cohérence croisée : counts.custom_blocks == counts.mounts ==
# counts.portails == |MANIFEST.portails| (la bijection du générateur) — mord un
# compteur désynchronisé de sa propre liste. Un claim absent échoue AUSSI (la
# disparition d'un chiffre est une régression · traçabilité).
# ============================================================================
CO_RD = f"{D}/frontend/chat_otoia/README.md"
CO_FI = "03_agents/frontend_console/AGENT.md"
try:
    co_man = load("frontend/chat_otoia/out/MANIFEST.json")
    co_cb = str(co_man["counts"]["custom_blocks"])
    co_mo = str(co_man["counts"]["mounts"])
    co_po = str(co_man["counts"]["portails"])
    co_nlist = len(co_man["portails"])
    if not (co_cb == co_mo == co_po == str(co_nlist)):
        bad(f"Chat OTOIA · incohérence artefact : custom_blocks={co_cb} · "
            f"mounts={co_mo} · portails={co_po} · |portails|={co_nlist} "
            f"(bijection 1 block ⇔ 1 mount ⇔ 1 portail attendue)")
except (OSError, KeyError, ValueError) as e:
    bad(f"Chat OTOIA · artefact chat_otoia illisible/inattendu : {e}"); co_cb = None
if co_cb is not None:
    # 1) Fiche agent Frontend Console — « N `Custom Block` + M configs runtime »
    co_fi = read_norm(CO_FI)
    if co_fi is not None:
        m = re.search(r"\*\*(\d+) `Custom Block` \+ (\d+) configs runtime\*\*", co_fi)
        if m is None:
            bad(f"Chat OTOIA · {CO_FI} — claim « N `Custom Block` + M configs runtime » "
                f"INTROUVABLE (attendu {co_cb}/{co_mo})")
        elif m.groups() == (co_cb, co_mo):
            good(f"Chat OTOIA · {CO_FI} — {'/'.join(m.groups())} == artefact "
                 f"(custom_blocks={co_cb} · mounts={co_mo})")
        else:
            bad(f"Chat OTOIA · {CO_FI} — dit {'/'.join(m.groups())} MAIS artefact dit "
                f"{co_cb}/{co_mo} → régénérer la fiche")
    # 2) README module — « Portails (N, `plateforme` exclu) »
    co_rd = read_norm(CO_RD)
    if co_rd is not None:
        m = re.search(r"Portails \((\d+), `plateforme` exclu\)", co_rd)
        if m is None:
            bad(f"Chat OTOIA · {CO_RD} — claim « Portails (N, `plateforme` exclu) » "
                f"INTROUVABLE (attendu portails={co_po})")
        elif m.group(1) == co_po:
            good(f"Chat OTOIA · {CO_RD} — {m.group(1)} portails == artefact "
                 f"(portails={co_po})")
        else:
            bad(f"Chat OTOIA · {CO_RD} — dit {m.group(1)} portails MAIS artefact dit "
                f"{co_po} → régénérer le README")
        # 3) README module — « N fixtures `Custom Block` »
        m = re.search(r"(\d+) fixtures `Custom Block`", co_rd)
        if m is None:
            bad(f"Chat OTOIA · {CO_RD} — claim « N fixtures `Custom Block` » INTROUVABLE "
                f"(attendu custom_blocks={co_cb})")
        elif m.group(1) == co_cb:
            good(f"Chat OTOIA · {CO_RD} — {m.group(1)} fixtures == artefact "
                 f"(custom_blocks={co_cb})")
        else:
            bad(f"Chat OTOIA · {CO_RD} — dit {m.group(1)} fixtures MAIS artefact dit "
                f"{co_cb} → régénérer le README")
        # 4) README module — « M configs runtime » (2e transcription des mounts)
        m = re.search(r"(\d+) configs runtime", co_rd)
        if m is None:
            bad(f"Chat OTOIA · {CO_RD} — claim « M configs runtime » INTROUVABLE "
                f"(attendu mounts={co_mo})")
        elif m.group(1) == co_mo:
            good(f"Chat OTOIA · {CO_RD} — {m.group(1)} configs runtime == artefact "
                 f"(mounts={co_mo})")
        else:
            bad(f"Chat OTOIA · {CO_RD} — dit {m.group(1)} configs runtime MAIS artefact "
                f"dit {co_mo} → régénérer le README")

# ============================================================================
# Chat OTOIA · IDENTITÉ des configs runtime (persona · langues · endpoint) —
# ATTRIBUTS transcrits À LA MAIN dans la cellule « Métrique vérifiée » de la
# fiche agent, AVEUGLES au gate de COMPTE ci-dessus.
# ----------------------------------------------------------------------------
# Le bloc précédent gate le NOMBRE de blocks/mounts/portails ; il est indifférent
# à CE QUE chaque config runtime dit. Or la même cellule (03_agents/
# frontend_console/AGENT.md:29) restitue TROIS attributs data-derived du montage :
# « persona Amélie … · langues FR/EN/ES · `endpoint: null` ». Ils sortent des
# artefacts byte-gatés out/chat_mount.json (une config par portail) + out/
# MANIFEST.json (persona/langues/endpoint_statut consolidés) — jamais de la spec.
#   • persona.nom : le prénom de l'assistant (le « QC » est le qualificatif de
#     marque CLAUDE.md « Voix Amélie QC », gardé en littéral — non gaté ici).
#   • langues : la surface trilingue FR/EN/ES lue par le générateur dans
#     seo_spec.json (source unique des langues).
#   • endpoint : `null` est une AFFIRMATION D'HONNÊTETÉ #6/#8 — l'endpoint OTOIA
#     n'est PAS fabriqué (renseigné côté VPS). Si une config gagnait un endpoint
#     réel, la fiche « endpoint: null » deviendrait un MENSONGE en silence.
# Cohérence croisée (bijection du générateur) : MANIFEST.persona.nom ET
# MANIFEST.langues doivent valoir EXACTEMENT ce que porte CHAQUE mount (un mount
# désynchronisé de son propre manifeste mord ici) ; endpoint_statut « a_confirmer »
# doit correspondre à des endpoints TOUS `null` (sinon incohérence interne de
# l'artefact). Un claim absent échoue AUSSI (INTROUVABLE = régression #6). Aucune
# suite tests/ (FONCTIONS du builder, jamais la prose de la fiche) n'attrape ce
# « vert trompeur ».
# ============================================================================
try:
    ci_man = load("frontend/chat_otoia/out/MANIFEST.json")
    ci_mounts = load("frontend/chat_otoia/out/chat_mount.json")
    ci_persona = ci_man["persona"]["nom"]
    ci_langs = list(ci_man["langues"])
    ci_statut = ci_man["endpoint_statut"]
    if not ci_mounts:
        bad("Chat OTOIA identité · out/chat_mount.json vide"); ci_persona = None
except (OSError, KeyError, ValueError, TypeError) as e:
    bad(f"Chat OTOIA identité · artefact illisible/inattendu : {e}"); ci_persona = None
if ci_persona is not None:
    # Cohérence croisée MANIFEST ⇔ chaque config runtime (bijection du générateur)
    bad_persona = sorted({m["persona"]["nom"] for m in ci_mounts if m["persona"]["nom"] != ci_persona})
    bad_langs = sorted({"/".join(m["langues"]) for m in ci_mounts if list(m["langues"]) != ci_langs})
    all_null = all(m["endpoint"] is None for m in ci_mounts)
    if bad_persona:
        bad(f"Chat OTOIA identité · persona MANIFEST={ci_persona!r} MAIS mounts portent "
            f"aussi {bad_persona} (montage désynchronisé)")
    if bad_langs:
        bad(f"Chat OTOIA identité · langues MANIFEST={ci_langs} MAIS mounts portent "
            f"aussi {bad_langs} (montage désynchronisé)")
    if all_null and ci_statut != "a_confirmer":
        bad(f"Chat OTOIA identité · endpoints TOUS null MAIS endpoint_statut={ci_statut!r} "
            f"(attendu 'a_confirmer' — incohérence interne artefact)")
    if (not all_null) and ci_statut == "a_confirmer":
        bad("Chat OTOIA identité · endpoint_statut='a_confirmer' MAIS ≥1 endpoint fabriqué "
            "(incohérence interne artefact)")
    exp_langs = "/".join(l.upper() for l in ci_langs)
    exp_endpoint = "null" if all_null else "NON-null(" + ",".join(
        sorted({str(m["endpoint"]) for m in ci_mounts if m["endpoint"] is not None})) + ")"
    co_fi2 = read_norm(CO_FI)
    if co_fi2 is not None:
        m = re.search(r"persona (\S+) QC · langues ([A-Z/]+) · `endpoint: ([^`]+)`", co_fi2)
        if m is None:
            bad(f"Chat OTOIA identité · {CO_FI} — claim « persona <nom> QC · langues "
                f"<L> · `endpoint: <e>` » INTROUVABLE (attendu {ci_persona}/{exp_langs}/{exp_endpoint})")
        else:
            got = m.groups()
            want = (ci_persona, exp_langs, exp_endpoint)
            if got == want:
                good(f"Chat OTOIA identité · {CO_FI} — persona {got[0]} · langues {got[1]} · "
                     f"endpoint {got[2]} == artefact (chat_mount ×{len(ci_mounts)} ⇔ MANIFEST)")
            else:
                bad(f"Chat OTOIA identité · {CO_FI} — dit persona {got[0]}/langues {got[1]}/"
                    f"endpoint {got[2]} MAIS artefact dit {want[0]}/{want[1]}/{want[2]} "
                    f"→ régénérer la fiche")

# ============================================================================
# Chat OTOIA · IDENTITÉ de la LISTE D'INVARIANTS CI — le README du module porte
# une section « ## Invariants (N) — garantis en CI » qui ANNONCE un compte (N)
# PUIS énumère chaque invariant par son numéro entre parenthèses ((1) · (2) ·
# … · (11, 13) · (12) · (14)). Cette prose transcrit À LA MAIN le REGISTRE réel
# des invariants du générateur — les marqueurs `# Invariant N ·` de
# chat_otoia_gen.py, qui sont la source d'autorité de « ce que la CI garantit »
# (chaque marqueur = un contrôle appliqué par validate()). Classe DISTINCTE de
# tout ce qui précède : les blocs Chat OTOIA plus haut gatent des COMPTES
# d'artefact (blocks/mounts/portails) et l'IDENTITÉ persona/langues/endpoint ;
# AUCUN ne touche la liste d'invariants — surface ungatée, piège aveugle. Le
# générateur se SELF-REPORTE lui-même (« schéma + N invariants verts. » dans son
# message CLI), 3ᵉ transcription du même compte. On RECOMPUTE ici le registre
# depuis le SEUL registre-source (les marqueurs `# Invariant N` du .py) et on
# exige : (a) le registre code est un ensemble CONTIGU 1..max (une lacune/un
# doublon dans le ledger code = défaut réel) ; (b) le message CLI du générateur
# « N invariants verts » == |registre| (self-report du .py cohérent avec son
# propre ledger) ; (c) le compte annoncé « ## Invariants (N) » du README ==
# |registre| ; (d) l'ÉNUMÉRATION en prose du README == registre EXACT (numéro
# absent ET numéro fantôme mordus par set-diff). Ajouter un `# Invariant 15`
# (nouveau contrôle) sans toucher la prose ⇒ (c)(d) rouges ; retirer le contrôle
# 7 du code ⇒ (7) devient fantôme dans la prose. Aucune suite tests/ ne teste la
# PROSE ; le byte-gate d'artefact ignore le .py source ⇒ dérive silencieuse
# sinon. Section/compte absent = échec (la disparition d'un chiffre est une
# régression de traçabilité · #6).
# ============================================================================
CO_GEN = f"{D}/frontend/chat_otoia/chat_otoia_gen.py"
try:
    co_src = open(CO_GEN, encoding="utf-8").read()
except OSError as e:
    bad(f"Chat OTOIA invariants · générateur {CO_GEN} illisible : {e}"); co_src = None
if co_src is not None:
    # (a) registre-source = marqueurs `# Invariant N` du générateur (autorité).
    inv_reg = sorted({int(n) for n in re.findall(r"#\s*Invariant\s+(\d+)\b", co_src)})
    if not inv_reg:
        bad(f"Chat OTOIA invariants · aucun marqueur `# Invariant N` dans {CO_GEN} "
            "(registre-source vide → impossible de re-dériver)")
    else:
        n_inv = len(inv_reg)
        contigu = inv_reg == list(range(1, inv_reg[-1] + 1))
        if not contigu:
            bad(f"Chat OTOIA invariants · registre code NON contigu : {inv_reg} "
                f"(attendu 1..{n_inv}) — lacune/doublon dans le ledger `# Invariant N`")
        else:
            good(f"Chat OTOIA invariants · registre code contigu 1..{n_inv} "
                 f"({n_inv} marqueurs `# Invariant N`)")
        # (b) self-report CLI du générateur « N invariants verts ».
        mg = re.search(r"(\d+)\s+invariants verts", co_src)
        if mg is None:
            bad(f"Chat OTOIA invariants · message CLI « N invariants verts » "
                f"INTROUVABLE dans {CO_GEN} (attendu {n_inv})")
        elif int(mg.group(1)) == n_inv:
            good(f"Chat OTOIA invariants · générateur self-report {mg.group(1)} "
                 f"== registre ({n_inv})")
        else:
            bad(f"Chat OTOIA invariants · générateur dit « {mg.group(1)} invariants "
                f"verts » MAIS registre `# Invariant N` en compte {n_inv} "
                "(self-report désynchronisé du ledger)")
        # README : section « ## Invariants (N) — garantis en CI » + énumération.
        try:
            co_rd_raw = open(CO_RD, encoding="utf-8").read()
        except OSError as e:
            bad(f"Chat OTOIA invariants · {CO_RD} illisible : {e}"); co_rd_raw = None
        if co_rd_raw is not None:
            mh = re.search(r"##\s+Invariants\s+\((\d+)\)\s+—\s+garantis en CI", co_rd_raw)
            if mh is None:
                bad(f"Chat OTOIA invariants · {CO_RD} — section « ## Invariants (N) — "
                    f"garantis en CI » INTROUVABLE (attendu {n_inv})")
            else:
                # (c) compte annoncé.
                if int(mh.group(1)) == n_inv:
                    good(f"Chat OTOIA invariants · {CO_RD} — annonce ({mh.group(1)}) "
                         f"== registre ({n_inv})")
                else:
                    bad(f"Chat OTOIA invariants · {CO_RD} — annonce ({mh.group(1)}) MAIS "
                        f"registre en compte {n_inv} → régénérer le README")
                # (d) énumération en prose == registre EXACT (set-diff).
                nxt = re.search(r"\n##\s", co_rd_raw[mh.end():])
                prose = co_rd_raw[mh.end(): mh.end() + nxt.start()] if nxt else co_rd_raw[mh.end():]
                enum = sorted({int(x) for grp in re.findall(r"\(([\d,\s]+)\)", prose)
                               for x in re.findall(r"\d+", grp)})
                if enum == inv_reg:
                    good(f"Chat OTOIA invariants · {CO_RD} — énumération prose {enum} "
                         "== registre code (aucun absent/fantôme)")
                else:
                    absents = [x for x in inv_reg if x not in enum]
                    fantomes = [x for x in enum if x not in inv_reg]
                    bad(f"Chat OTOIA invariants · {CO_RD} — énumération prose ≠ registre : "
                        f"absent(s)={absents} · fantôme(s)={fantomes} → régénérer le README")

# ============================================================================
# Recette (module) · couple « N livrables de sprint + M métriques succès MVP »
# transcrit À LA MAIN dans DEUX docs — même classe de « vert trompeur ».
# ----------------------------------------------------------------------------
# La matrice d'acceptation (qa/acceptance/out/acceptance_matrix.json, byte-gatée
# par check_artifacts ⇒ faisant autorité) porte une ligne par promesse roadmap,
# de `kind` sprint_deliverable OU mvp_metric. Le compte de chaque genre (n_sprint,
# n_metric déjà recalculés plus haut depuis la matrice, jamais saisis) est repris
# À LA MAIN à DEUX endroits que le bloc racine ne couvre PAS : le README du module
# (05_deliverables_mvp/qa/acceptance/README.md — puces « N livrables de sprint »
# puis « M métriques succès MVP ») ET la fiche agent QA (03_agents/qa/AGENT.md —
# « (N livrables + M métriques) »). Le bloc racine plus haut ne gate que le SEUL
# README d'entrée (« N promesses (X sprint + Y métriques) ») ; ces deux docs de
# module en dérivent sans gate. Ajouter une promesse roadmap (l'artefact rebâtit
# 9/7) ferait dériver LES DEUX docs en silence pendant que la matrice dit autre
# chose — piège que ni les suites tests/ (qui testent des FONCTIONS, pas la prose)
# ni le roadmap-anchor (qui ancre les `roadmap_line` de l'ARTEFACT, pas la prose
# des docs) n'attrapent. On RECOMPUTE ici chaque nombre depuis SON PROPRE genre de
# la matrice (jamais figé · zéro duplication #5) et on exige l'égalité avec les
# DEUX docs. Recomputer les DEUX (sprint ET métriques) mord une dérive de l'un là
# où l'autre resterait aveugle. Cohérence croisée en bonus :
# MANIFEST.counts.{sprint_deliverables,mvp_metrics} == (n_sprint, n_metric) — mord
# un compteur du manifeste désynchronisé de la matrice qu'il résume. Un claim
# absent échoue AUSSI (la disparition d'un chiffre est une régression · traçabilité).
# ============================================================================
ACC_RD = f"{D}/qa/acceptance/README.md"
ACC_FI = "03_agents/qa/AGENT.md"
acc_exp = (str(n_sprint), str(n_metric))
acc_src = f"livrables_sprint={acc_exp[0]} métriques={acc_exp[1]}"
try:
    acc_mc = load("qa/acceptance/out/MANIFEST.json")["counts"]
    if (str(acc_mc["sprint_deliverables"]), str(acc_mc["mvp_metrics"])) != acc_exp:
        bad(f"Recette · incohérence artefact : MANIFEST.counts dit "
            f"{acc_mc['sprint_deliverables']}/{acc_mc['mvp_metrics']} MAIS la matrice "
            f"dit {'/'.join(acc_exp)} (compteur désynchronisé de sa liste)")
except (OSError, KeyError, ValueError) as e:
    bad(f"Recette · MANIFEST acceptance illisible/inattendu : {e}")
# 1) README du module — deux puces distinctes (livrables sprint · métriques MVP)
acc_rd = read_norm(ACC_RD)
if acc_rd is not None:
    ms = re.search(r"\*\*(\d+) livrables de sprint\*\*", acc_rd)
    mm = re.search(r"\*\*(\d+) métriques succès MVP\*\*", acc_rd)
    if ms is None or mm is None:
        bad(f"Recette · {ACC_RD} — puces « N livrables de sprint » / « M métriques "
            f"succès MVP » INTROUVABLE(s) (attendu {acc_src})")
    elif (ms.group(1), mm.group(1)) == acc_exp:
        good(f"Recette · {ACC_RD} — {ms.group(1)}/{mm.group(1)} == matrice ({acc_src})")
    else:
        bad(f"Recette · {ACC_RD} — dit {ms.group(1)}/{mm.group(1)} MAIS matrice dit "
            f"{'/'.join(acc_exp)} ({acc_src}) → régénérer le README")
# 2) Fiche agent QA — parenthèse « (N livrables + M métriques) »
acc_fi = read_norm(ACC_FI)
if acc_fi is not None:
    m = re.search(r"\((\d+) livrables \+ (\d+) métriques\)", acc_fi)
    if m is None:
        bad(f"Recette · {ACC_FI} — claim « (N livrables + M métriques) » INTROUVABLE "
            f"(attendu {acc_src})")
    elif m.groups() == acc_exp:
        good(f"Recette · {ACC_FI} — {'/'.join(m.groups())} == matrice ({acc_src})")
    else:
        bad(f"Recette · {ACC_FI} — dit {'/'.join(m.groups())} MAIS matrice dit "
            f"{'/'.join(acc_exp)} ({acc_src}) → régénérer la fiche")

# ============================================================================
# Fiscal · e-CF DGII — le CONTRAT DE FORMAT de l'e-NCF (« E + tipoeCF(2) +
# secuencia(10) » = 13) transcrit À LA MAIN dans QUATRE endroits de prose (README
# ×3 + fiche agent erpnext_backend), sans AUCUN gate d'IDENTITÉ.
# ----------------------------------------------------------------------------
# Le générateur ecf_dgii_gen émet out/ecf_plan.json (byte-gaté par check_artifacts
# ⇒ faisant autorité) dont le bloc `e_ncf` PORTE le format de l'identifiant
# électronique DGII : `prefix` (« E »), `longueur` (13), `regex`
# (« ^E[0-9]{12}$ ») et `structure` (« E + tipoeCF (2 chiffres) + secuencia
# (10 chiffres) »). Ce format — préfixe + longueur du segment tipo + longueur du
# segment secuencia + longueur totale — est le CŒUR du module (le « composeur
# traçable ») et il est recopié À LA MAIN dans QUATRE endroits de prose :
#   • README:10 « composeur d'e-NCF traçable `E + tipoeCF(2) + secuencia(10)` »
#   • README:75 (invariant 7) « E+tipo(2)+seq(10)=13 » (porte la longueur TOTALE)
#   • README:84 (composition) « e_ncf = "E" + tipo(2) + secuencia(10) »
#   • 03_agents/erpnext_backend/AGENT.md:33 « `E + tipoeCF(2) + secuencia(10)` »
# Le bloc racine de check_readme_claims ne gate QUE le COMPTE de tests e-CF
# (« 39 »). Aucun gate ne comparait la LONGUEUR des segments à l'artefact : allonger
# la secuencia (rango DGII à 12 chiffres → seq=12, longueur=15, regex 14 digits)
# OU changer le préfixe ferait dériver les QUATRE proses en silence pendant que
# l'ecf_plan byte-gaté dit autre chose — « vert trompeur » qu'aucune suite tests/
# (qui teste des FONCTIONS ncf.compose/parse, pas la prose des docs) n'attrape.
# Même patron que l'énumération des onglets Mobile, la carte 4Big ou les open items
# 5D. On RECOMPUTE (prefix, tipo_len, seq_len, total) depuis le SEUL bloc `e_ncf`
# (zéro duplication du format du générateur #6) et on exige que CHAQUE prose
# l'encode EXACTEMENT. Cohérences croisées en bonus (mordent un artefact
# INTERNEMENT incohérent) : |prefix| + tipo_len + seq_len == longueur (1+2+10==13)
# ET le nombre de chiffres du `regex` == tipo_len + seq_len (12 == 2+10). Un claim
# absent échoue AUSSI (la disparition d'un format est une régression · traçabilité).
# ============================================================================
ECF_RD = f"{D}/fiscal/ecf_dgii/README.md"
ECF_FI = "03_agents/erpnext_backend/AGENT.md"
try:
    encf = load("fiscal/ecf_dgii/out/ecf_plan.json")["e_ncf"]
    ecf_prefix = encf["prefix"]
    ecf_total = int(encf["longueur"])
    ecf_seglens = [int(x) for x in
                   re.findall(r"\((\d+)\s*chiffres?\)", encf["structure"])]
    ecf_mdig = re.search(r"\[0-9\]\{(\d+)\}", encf["regex"])
except (OSError, KeyError, ValueError) as e:
    bad(f"Fiscal · artefact ecf_plan illisible/inattendu : {e}"); encf = None
if encf is not None:
    if len(ecf_seglens) != 2:
        bad(f"Fiscal · e_ncf.structure malformée : {encf['structure']!r} "
            f"(attendu 2 segments « (N chiffres) », trouvé {ecf_seglens})")
    else:
        ecf_tipo, ecf_seq = ecf_seglens
        # Cohérences croisées : l'artefact doit être INTERNEMENT cohérent.
        if len(ecf_prefix) != 1:
            bad(f"Fiscal · incohérence artefact : prefix={ecf_prefix!r} n'est pas "
                f"1 caractère")
        if 1 + ecf_tipo + ecf_seq != ecf_total:
            bad(f"Fiscal · incohérence artefact : 1+{ecf_tipo}+{ecf_seq}="
                f"{1 + ecf_tipo + ecf_seq} MAIS longueur={ecf_total}")
        if ecf_mdig is None or int(ecf_mdig.group(1)) != ecf_tipo + ecf_seq:
            got = ecf_mdig.group(1) if ecf_mdig else "?"
            bad(f"Fiscal · incohérence artefact : regex porte {got} chiffres MAIS "
                f"tipo+seq={ecf_tipo + ecf_seq}")
        exp_ncf = f"{ecf_prefix} + tipo({ecf_tipo}) + secuencia({ecf_seq}) = {ecf_total}"
        def ecf_check(tag, path, txt, m, with_total):
            if m is None:
                bad(f"Fiscal · {path} — {tag} : format e-NCF INTROUVABLE "
                    f"(attendu {exp_ncf})")
                return
            g = m.groups()
            ok = (g[0] == ecf_prefix and int(g[1]) == ecf_tipo
                  and int(g[2]) == ecf_seq
                  and (not with_total or int(g[3]) == ecf_total))
            if ok:
                good(f"Fiscal · {path} — {tag} : {exp_ncf} == e_ncf (artefact)")
            else:
                shown = "/".join(g)
                bad(f"Fiscal · {path} — {tag} : dit {shown} MAIS e_ncf dit "
                    f"prefix={ecf_prefix} tipo={ecf_tipo} seq={ecf_seq} "
                    f"total={ecf_total} → régénérer le doc")
        # 1) README:10 — résumé « composeur d'e-NCF traçable `E + tipoeCF(2) + secuencia(10)` »
        ecf_rd = read_norm(ECF_RD)
        if ecf_rd is not None:
            ecf_check("résumé composeur", ECF_RD, ecf_rd,
                      re.search(r"`([A-Z])\s*\+\s*tipoeCF\((\d+)\)\s*\+\s*"
                                r"secuencia\((\d+)\)`", ecf_rd), False)
            # 2) README:75 — invariant 7 « E+tipo(2)+seq(10)=13 » (porte la longueur totale)
            ecf_check("invariant format", ECF_RD, ecf_rd,
                      re.search(r"\(([A-Z])\+tipo\((\d+)\)\+seq\((\d+)\)=(\d+)",
                                ecf_rd), True)
            # 3) README:84 — composition « e_ncf = "E" + tipo(2) + secuencia(10) »
            ecf_check("composition ncf.py", ECF_RD, ecf_rd,
                      re.search(r'e_ncf\s*=\s*"([A-Z])"\s*\+\s*tipo\((\d+)\)\s*\+\s*'
                                r"secuencia\((\d+)\)", ecf_rd), False)
        # 4) Fiche agent erpnext_backend — « `E + tipoeCF(2) + secuencia(10)` »
        ecf_fi = read_norm(ECF_FI)
        if ecf_fi is not None:
            ecf_check("fiche erpnext_backend", ECF_FI, ecf_fi,
                      re.search(r"`([A-Z])\s*\+\s*tipoeCF\((\d+)\)\s*\+\s*"
                                r"secuencia\((\d+)\)`", ecf_fi), False)
        # ------------------------------------------------------------------
        # 5) 6) 7) 8) 9) — le MÊME contrat de format dans le COMPOSEUR ncf.py
        #     (CODE d'exécution, PAS de la prose) + son ORACLE de test.
        # `ecflib/ncf.py` porte TROIS copies INDÉPENDANTES du format, AUCUNE
        # dérivée de l'artefact : la regex de VALIDATION `ENCF_RE` (cœur de
        # is_valid_encf/parse_encf), la chaîne `FORMULE` publiée par
        # compose_encf (donc RENVOYÉE au caller VPS/Compupar), et deux
        # proses-en-code (docstring + commentaire Ley 32-23). En prime,
        # `tests/test_ecf_dgii.py:246` HARDCODE l'oracle « FORMULE ». Le bloc
        # e-NCF ci-dessus ne gate QUE la prose (README ×3 + fiche). Or un
        # rango DGII à 12 chiffres régénère ecf_spec→ecf_plan (donc les 4
        # proses gatées ci-dessus SUIVENT), MAIS laisse ENCF_RE à `{2}{10}` :
        # compose_encf zfill à 10 et is_valid_encf REJETTE un e-NCF désormais
        # valide → BUG d'exécution du composeur fiscal, pendant que la suite
        # tests/ reste VERTE (son oracle == FORMULE périmée, self-consistante).
        # « Vert trompeur » que ni check_artifacts (ncf.py n'est pas un
        # out/*.json) ni tests/ (teste les FONCTIONS, pas leur ancrage) ne
        # mordent. Même patron que l'oracle/formateur du Publiciste. On ANCRE
        # ces surfaces CODE au SEUL bloc e_ncf byte-gaté (réutilise
        # prefix/tipo/seq/total ci-dessus · zéro re-dérivation du format #6).
        ECF_NCF = f"{D}/fiscal/ecf_dgii/ecflib/ncf.py"
        ECF_TEST = f"{D}/fiscal/ecf_dgii/tests/test_ecf_dgii.py"
        ecf_ncf = read_norm(ECF_NCF)
        if ecf_ncf is None:
            bad(f"Fiscal · composeur {ECF_NCF} illisible → format e-NCF non ancré")
        else:
            # 5) ENCF_RE — la regex de VALIDATION (prefix + 2 segments de chiffres)
            ecf_check("composeur ncf.py · ENCF_RE (validateur)", ECF_NCF, ecf_ncf,
                      re.search(r"ENCF_RE\s*=\s*re\.compile\(\s*r?[\"']\^"
                                r"([A-Z])\(\[0-9\]\{(\d+)\}\)\(\[0-9\]\{(\d+)\}\)\$",
                                ecf_ncf), False)
            # 6) FORMULE — la chaîne publiée par compose_encf (renvoyée au VPS)
            ecf_check("composeur ncf.py · FORMULE (publiée)", ECF_NCF, ecf_ncf,
                      re.search(r"FORMULE\s*=\s*[\"']([A-Z])\s*\+\s*tipoeCF\((\d+)\)"
                                r"\s*\+\s*secuencia\((\d+)\)[\"']", ecf_ncf), False)
            # 7) docstring ncf.py — « "E" + tipoeCF (2 chiffres) + secuencia (10 chiffres) → 13 caracteres »
            ecf_check("composeur ncf.py · docstring", ECF_NCF, ecf_ncf,
                      re.search(r'e-NCF\s*=\s*"([A-Z])"\s*\+\s*tipoeCF\s*\((\d+)'
                                r"\s*chiffres?\)\s*\+\s*secuencia\s*\((\d+)\s*chiffres?\)"
                                r"\s*\S+\s*(\d+)\s*caracteres?", ecf_ncf), True)
            # 8) commentaire ncf.py — « E + tipo(2) + secuencia(10) = 13 caracteres »
            ecf_check("composeur ncf.py · commentaire Ley 32-23", ECF_NCF, ecf_ncf,
                      re.search(r"Format e-NCF[^:]*:\s*([A-Z])\s*\+\s*tipo\((\d+)\)"
                                r"\s*\+\s*secuencia\((\d+)\)\s*=\s*(\d+)\s*caracteres?",
                                ecf_ncf), True)
        # 9) oracle de test — la RAISON pour laquelle la dérive resterait VERTE
        ecf_test = read_norm(ECF_TEST)
        if ecf_test is None:
            bad(f"Fiscal · oracle {ECF_TEST} illisible → FORMULE de test non ancrée")
        else:
            ecf_check("oracle test_ecf_dgii.py · formule", ECF_TEST, ecf_test,
                      re.search(r"\[[\"']formule[\"']\]\s*,\s*[\"']([A-Z])\s*\+"
                                r"\s*tipoeCF\((\d+)\)\s*\+\s*secuencia\((\d+)\)[\"']",
                                ecf_test), False)

# ============================================================================
# Fiscal · e-CF DGII (2e surface du MÊME README) · la CROSS-COHÉRENCE
# (« le cœur du livrable ») — ENSEMBLES/valeur data-derived transcrits À LA MAIN
# sans AUCUN gate d'IDENTITÉ. Le bloc e-NCF ci-dessus ne gate QUE le FORMAT de
# l'identifiant ; la section « Cross-cohérence e-CF ↔ workflow ↔ DocType ↔ RBAC »
# ÉNUMÈRE trois données dérivées de out/ecf_plan.json (byte-gaté par
# check_artifacts ⇒ faisant autorité) :
#   • README:48 « seulement réservation et contrat » — l'ensemble des états qui
#     ÉMETTENT un e-CF = {emission_events[].update_value}.
#   • README:50 « (`montant_reservation`, `montant_contrat`) » — l'ensemble des
#     champs Currency de base = {emission_events[].base_field}.
#   • README:59 « FormaPago défaut = 3 (Tarjeta) … via Cardnet (#10) » — le code +
#     libellé de forma_pago_defaut, ANCRÉ sur CLAUDE.md #10 (Cardnet = carte).
# Aucun gate ne comparait ces valeurs à l'artefact : AJOUTER un évènement
# d'émission (facturer un devis) · en RETIRER un · RENOMMER un base_field ·
# CHANGER forma_pago_defaut (Cardnet abandonné → code 2 Transfert) ferait dériver
# la prose en silence pendant que l'ecf_plan byte-gaté dit autre chose — l'agent
# ERPNext Backend câblerait le mauvais évènement / la mauvaise FormaPago (le risque
# même que la cross-cohérence veut prévenir) — « vert trompeur » qu'aucune suite
# tests/ (qui teste des FONCTIONS ncf/résolution, pas la prose) n'attrape. Même
# patron EXACT que la cross-cohérence CRM/Commissions (base_field + états soumis).
# On RECOMPUTE chaque ensemble/valeur depuis l'artefact (zéro duplication du
# contrat du générateur #6) et on exige que la prose l'encode EXACTEMENT (absent ET
# en trop via set-diff · accents/casse normalisés). Cohérences croisées en bonus
# (mordent un plan INTERNEMENT incohérent) : forma_pago_defaut.code ∈ catalogue
# formas_pago · libellé prose ⊆ libellé catalogue du code · ensembles NON VIDES.
# Un claim absent échoue AUSSI (traçabilité).
# ============================================================================
ECF_RD2 = f"{D}/fiscal/ecf_dgii/README.md"
def _ecf_acc(s):
    return "".join(c for c in unicodedata.normalize("NFKD", s)
                   if unicodedata.category(c) != "Mn").lower()
try:
    ecfp = load("fiscal/ecf_dgii/out/ecf_plan.json")
    ecf_ev = ecfp["emission_events"]
    ecf_uv = {_ecf_acc(e["update_value"]) for e in ecf_ev}
    ecf_bf = {e["base_field"] for e in ecf_ev}
    ecf_fpd = ecfp["forma_pago_defaut"]
    ecf_fp_code = str(ecf_fpd["code"])
    ecf_fp_cat = {str(f["code"]): f["label"] for f in ecfp["formas_pago"]}
except (OSError, KeyError, ValueError, TypeError) as e:
    bad(f"Fiscal cross-cohérence · artefact ecf_plan illisible/inattendu : {e}"); ecf_ev = None
if ecf_ev is not None:
    # bonus : plan INTERNEMENT cohérent (ensembles non vides · défaut ∈ catalogue)
    if not ecf_uv or not ecf_bf:
        bad("Fiscal cross-cohérence · aucun évènement d'émission (plan vide) → vérifier le plan")
    elif ecf_fp_code not in ecf_fp_cat:
        bad(f"Fiscal cross-cohérence · forma_pago_defaut.code={ecf_fp_code} ABSENT du "
            f"catalogue formas_pago {sorted(ecf_fp_cat)} → plan incohérent")
    else:
        good(f"Fiscal cross-cohérence · forma_pago_defaut {ecf_fp_code} "
             f"({ecf_fp_cat[ecf_fp_code]}) ∈ catalogue formas_pago · émission {sorted(ecf_uv)} non vide")
    ecf_rd2 = read_norm(ECF_RD2)
    if ecf_rd2 is not None:
        # 1) README:48 — états émetteurs « seulement réservation et contrat »
        me = re.search(r"seulement\s+([^.]+)\.", ecf_rd2)
        if me is None:
            bad(f"Fiscal cross-cohérence · {ECF_RD2} — énumération des états émetteurs "
                f"INTROUVABLE (attendu {sorted(ecf_uv)})")
        else:
            es = {_ecf_acc(t.strip()) for t in re.split(r"\bet\b|[,/]", me.group(1)) if t.strip()}
            if es == ecf_uv:
                good(f"Fiscal cross-cohérence · {ECF_RD2} — états émetteurs {sorted(es)} == "
                     f"ecf_plan.emission_events[].update_value")
            else:
                miss = sorted(ecf_uv - es); extra = sorted(es - ecf_uv)
                detail = (f" · absents={miss}" if miss else "") + (f" · en trop={extra}" if extra else "")
                bad(f"Fiscal cross-cohérence · {ECF_RD2} — énumère {sorted(es)} MAIS plan "
                    f"{sorted(ecf_uv)}{detail} → régénérer le README")
        # 2) README:50 — champs de base « (`montant_reservation`, `montant_contrat`) »
        mf = re.search(r"Currency réel\*\* du DocType Dossier Vente\s*\(([^)]*)\)", ecf_rd2)
        if mf is None:
            bad(f"Fiscal cross-cohérence · {ECF_RD2} — énumération des champs de base "
                f"INTROUVABLE (attendu {sorted(ecf_bf)})")
        else:
            bs = set(re.findall(r"`([a-z_]+)`", mf.group(1)))
            if bs == ecf_bf:
                good(f"Fiscal cross-cohérence · {ECF_RD2} — champs de base {sorted(bs)} == "
                     f"ecf_plan.emission_events[].base_field")
            else:
                miss = sorted(ecf_bf - bs); extra = sorted(bs - ecf_bf)
                detail = (f" · absents={miss}" if miss else "") + (f" · en trop={extra}" if extra else "")
                bad(f"Fiscal cross-cohérence · {ECF_RD2} — énumère {sorted(bs)} MAIS plan "
                    f"{sorted(ecf_bf)}{detail} → régénérer le README")
        # 3) README:59 — FormaPago défaut « = 3 (Tarjeta) … via Cardnet (#10) »
        mp = re.search(r"FormaPago défaut\s*=\s*(\d+)\s*\(([^)]*)\)", ecf_rd2)
        if mp is None:
            bad(f"Fiscal cross-cohérence · {ECF_RD2} — FormaPago défaut INTROUVABLE "
                f"(attendu code {ecf_fp_code} · {ecf_fp_cat.get(ecf_fp_code,'?')})")
        else:
            got_code = mp.group(1); got_lbl = _ecf_acc(mp.group(2).strip())
            cat_lbl = _ecf_acc(ecf_fp_cat.get(ecf_fp_code, ""))
            has_cardnet = "cardnet" in _ecf_acc(ecf_rd2[mp.start():mp.start() + 160])
            if got_code == ecf_fp_code and got_lbl and got_lbl in cat_lbl and has_cardnet:
                good(f"Fiscal cross-cohérence · {ECF_RD2} — FormaPago défaut {got_code} "
                     f"({mp.group(2).strip()}) == forma_pago_defaut · ancré Cardnet (#10)")
            elif got_code != ecf_fp_code:
                bad(f"Fiscal cross-cohérence · {ECF_RD2} — FormaPago défaut = {got_code} MAIS "
                    f"forma_pago_defaut.code = {ecf_fp_code} → régénérer le README")
            elif not (got_lbl and got_lbl in cat_lbl):
                bad(f"Fiscal cross-cohérence · {ECF_RD2} — libellé FormaPago {mp.group(2).strip()!r} "
                    f"≠ catalogue code {ecf_fp_code} ({ecf_fp_cat.get(ecf_fp_code)!r}) → régénérer")
            else:
                bad(f"Fiscal cross-cohérence · {ECF_RD2} — FormaPago défaut sans ancrage "
                    f"Cardnet (#10) à proximité → régénérer le README")

# ============================================================================
# Fiscal · e-CF DGII (3e surface du MÊME README) · l'IDENTITÉ du RÔLE émetteur —
# le seul rôle habilité à ÉMETTRE un e-CF, transcrit À LA MAIN sans gate d'IDENTITÉ.
# ----------------------------------------------------------------------------
# La section « Cross-cohérence » du README (bullet `role_id`, README:51-54) NOMME
# le rôle émetteur par TROIS attributs data-derived :
#   • README:54 « le rôle dédié `compta-fiscaliste-ecf` » — le `role_id` que TOUS
#     les emission_events d'ecf_plan.json (byte-gaté) référencent.
#   • README:54 « (_OTO Compta Fiscaliste eCF_) » — son `erpnext_role_name`.
#   • README:53 « appartenir au portail `compta` » — le `portail` de CE rôle dans
#     le contrat rbac_50_roles.json (schéma-validé · byte-gaté par les 3 MANIFEST).
# Le bloc e-NCF gate le FORMAT de l'identifiant, le bloc cross-cohérence gate les
# ÉTATS émetteurs / champs de base / FormaPago — mais AUCUN ne compare l'IDENTITÉ
# de ce rôle à ses deux sources. RÉAFFECTER l'émission à un autre rôle (dans le
# plan), le RENOMMER, ou DÉPLACER le rôle `compta-fiscaliste-ecf` du portail
# `compta` vers un autre portail (dans le contrat : l'e-CF serait alors émis HORS
# Compta — la séparation même que la cross-cohérence pose) laisse la prose périmée
# en silence pendant que l'artefact/le contrat disent autre chose ⇒ l'agent ERPNext
# Backend câblerait le mauvais rôle émetteur (le risque même que #6 interdit).
# Aucune suite tests/ (qui teste des FONCTIONS ncf/résolution, pas la prose) ne
# l'attrape — même patron d'IDENTITÉ que la Fiche Mobile ou les gardes CRM. On
# RECOMPUTE `role_id`/`erpnext_role_name` de l'artefact et `portail`/nom Frappe du
# contrat (zéro duplication) et on exige la prose EXACTE. Cohérences croisées bonus
# (mordent un plan/contrat INTERNEMENT incohérent) : tous les emission_events
# pointent UN SEUL rôle (fonction · non vide) · ce role_id existe UNE fois au
# contrat · le nom résolu dans l'artefact == le contrat (zéro-dup) · portail ==
# `compta`. Un claim absent échoue AUSSI (traçabilité #6).
# ============================================================================
ECF_RD3 = f"{D}/fiscal/ecf_dgii/README.md"
try:
    ecfp3 = load("fiscal/ecf_dgii/out/ecf_plan.json")
    ecf_ev3 = ecfp3["emission_events"]
    ecf_rids = {e["role_id"] for e in ecf_ev3}
    ecf_rnames = {e["erpnext_role_name"] for e in ecf_ev3}
    ecf_contract = load("rbac/rbac_50_roles.json")["roles"]
except (OSError, KeyError, ValueError, TypeError) as e:
    bad(f"Fiscal rôle émetteur · ecf_plan / rbac_50_roles illisible/inattendu : {e}"); ecf_ev3 = None
if ecf_ev3 is not None:
    ecf_rd3 = read_norm(ECF_RD3)
    # bonus : tous les emission_events pointent UN SEUL rôle (fonction · non vide)
    if len(ecf_rids) != 1 or len(ecf_rnames) != 1:
        bad(f"Fiscal rôle émetteur · emission_events référencent {sorted(ecf_rids)} role_id / "
            f"{sorted(ecf_rnames)} noms (attendu 1 rôle émetteur unique) → plan incohérent")
    elif ecf_rd3 is not None:
        ecf_rid = next(iter(ecf_rids)); ecf_art_name = next(iter(ecf_rnames))
        ecf_match = [r for r in ecf_contract if r.get("id") == ecf_rid]
        if len(ecf_match) != 1:
            bad(f"Fiscal rôle émetteur · le contrat porte {len(ecf_match)} rôle(s) `{ecf_rid}` "
                f"(attendu exactement 1) → rôle absent/dupliqué du contrat rbac_50_roles.json")
        else:
            rc = ecf_match[0]
            ecf_ct_name = rc.get("erpnext_role_name"); ecf_portail = rc.get("portail")
            # bonus zéro-duplication : le nom résolu dans l'artefact == le contrat
            if ecf_ct_name != ecf_art_name:
                bad(f"Fiscal rôle émetteur · ecf_plan dit erpnext_role_name={ecf_art_name!r} MAIS "
                    f"contrat dit {ecf_ct_name!r} pour `{ecf_rid}` → régénérer l'artefact")
            # bonus séparation des pouvoirs : ce rôle est bien du portail compta (README:53)
            if ecf_portail != "compta":
                bad(f"Fiscal rôle émetteur · le rôle `{ecf_rid}` est du portail {ecf_portail!r} "
                    f"≠ `compta` au contrat → l'e-CF serait émis HORS Compta (séparation cassée)")
            # prose : 3 claims data-derived recomputés (role_id · nom Frappe · portail)
            for label, pat, exp in [
                ("role_id", rf"rôle dédié `{re.escape(ecf_rid)}`", ecf_rid),
                ("nom d'affichage ERPNext", rf"\(_{re.escape(ecf_ct_name)}_\)", ecf_ct_name),
                ("portail", rf"portail `{re.escape(ecf_portail)}`", ecf_portail),
            ]:
                if re.search(pat, ecf_rd3):
                    good(f"Fiscal rôle émetteur · {label} « {exp} » == ecf_plan/contrat rbac")
                else:
                    bad(f"Fiscal rôle émetteur · {ECF_RD3} — {label} : la prose ne cite pas "
                        f"« {exp} » (recomputé de emission_events + rbac_50_roles) → régénérer")

# ============================================================================
# CRM/Commissions · barème vendeurs — la CROSS-COHÉRENCE (« cœur du livrable »)
# transcrite À LA MAIN dans le README sans AUCUN gate d'IDENTITÉ.
# ----------------------------------------------------------------------------
# Le générateur commissions_gen émet out/commission_plan.json (byte-gaté par
# check_artifacts ⇒ faisant autorité) : chaque évènement porte `update_value`
# (l'état du pipeline qui PAIE) et `base_field` (le champ Currency de base). La
# section « Cross-cohérence » du README ÉNUMÈRE À LA MAIN deux ensembles
# data-derived + une propriété — le CŒUR du livrable :
#   • README:47 « on ne commissionne pas un brouillon (lead/visite/devis/
#     abandonné) » — l'ensemble des états BROUILLON (doc_status=0 du workflow).
#   • README:50 « base_field … (`montant_reservation`, `montant_contrat`) » —
#     l'ensemble des champs de base cités par le plan.
#   • README:46 « correspondre à un état soumis (doc_status = 1) » — INVARIANT 3
#     du module : on ne commissionne QUE des états soumis, jamais un brouillon.
# Le bloc racine ne gate QUE le COMPTE de tests commissions (« 25 », via l'agrégat
# « Total CRM »). Aucun gate ne comparait ces ENSEMBLES aux artefacts : PROMOUVOIR
# un état brouillon en soumis (abandonne → doc_status 1) le retire de l'ensemble
# brouillon pendant que la prose l'énumère encore ; AJOUTER un champ de base à un
# évènement (base_field montant_confotur) l'ajoute sans que la prose bouge ;
# RENOMMER un état passe aussi — « vert trompeur » qu'aucune suite tests/ (qui teste
# des FONCTIONS finance/résolution, pas la prose) n'attrape. Même patron que
# l'énumération des onglets Mobile, des transitions quatre-yeux CRM ou du format
# e-NCF fiscal. On RECOMPUTE chaque ensemble depuis SON artefact byte-gaté (zéro
# duplication du contrat du générateur #6) et on exige que la prose l'énumère
# EXACTEMENT (absent ET en trop mordus via set-diff · accents/casse normalisés).
# Cohérence croisée en bonus (mord un plan INTERNEMENT incohérent avec le workflow) :
# tout `update_value` commissionné ∈ états soumis (doc_status=1) ET ∉ brouillons —
# l'invariant 3 du module, non vide. Un claim absent échoue AUSSI (traçabilité).
# ============================================================================
CM_RD = f"{D}/crm/commissions/README.md"
def _cm_acc(s):
    return "".join(c for c in unicodedata.normalize("NFKD", s)
                   if unicodedata.category(c) != "Mn")
try:
    cm_ev = load("crm/commissions/out/commission_plan.json")["evenements"]
    cm_states = load("crm/workflow_vente/out/workflow.json")[0]["states"]
    cm_base = {e["base_field"] for e in cm_ev}
    cm_uv = {e["update_value"] for e in cm_ev}
    cm_draft = {s["update_value"] for s in cm_states if str(s["doc_status"]) == "0"}
    cm_subm = {s["update_value"] for s in cm_states if str(s["doc_status"]) == "1"}
except (OSError, KeyError, ValueError, IndexError, TypeError) as e:
    bad(f"CRM commissions · artefact illisible/inattendu : {e}"); cm_ev = None
if cm_ev is not None:
    # bonus : invariant 3 — tout état commissionné est SOUMIS (jamais brouillon), non vide
    if not cm_uv:
        bad("CRM commissions · aucun évènement commissionné (plan vide) → vérifier le barème")
    elif not (cm_uv <= cm_subm):
        bad(f"CRM commissions · incohérence plan↔workflow : update_values commissionnés "
            f"{sorted(cm_uv - cm_subm)} PAS soumis (doc_status≠1) → invariant 3 cassé")
    elif cm_uv & cm_draft:
        bad(f"CRM commissions · incohérence plan↔workflow : {sorted(cm_uv & cm_draft)} "
            f"commissionné(s) mais BROUILLON (doc_status=0) → invariant 3 cassé")
    else:
        good(f"CRM commissions · invariant 3 — {sorted(cm_uv)} tous soumis (doc_status=1), "
             f"aucun brouillon")
    cm_rd = read_norm(CM_RD)
    if cm_rd is not None:
        # 1) README:47 — ensemble des états BROUILLON exclus (doc_status=0 du workflow)
        mb = re.search(r"brouillon \(([^)]*)\)", cm_rd)
        if mb is None:
            bad(f"CRM commissions · {CM_RD} — énumération des brouillons exclus "
                f"INTROUVABLE (attendu {sorted(cm_draft)})")
        else:
            ps = {_cm_acc(t.strip().lower()) for t in re.split(r"[/,]", mb.group(1)) if t.strip()}
            exp = {_cm_acc(x) for x in cm_draft}
            if ps == exp:
                good(f"CRM commissions · {CM_RD} — brouillons exclus {sorted(ps)} == "
                     f"workflow.json (doc_status=0)")
            else:
                miss = sorted(exp - ps); extra = sorted(ps - exp)
                detail = (f" · absents={miss}" if miss else "") + \
                         (f" · en trop={extra}" if extra else "")
                bad(f"CRM commissions · {CM_RD} — énumère {sorted(ps)} MAIS brouillons "
                    f"workflow {sorted(exp)}{detail} → régénérer le README")
        # 2) README:50 — ensemble des champs de base (base_field du plan)
        mf = re.search(r"Currency réel\*\* du DocType Dossier Vente \(([^)]*)\)", cm_rd)
        if mf is None:
            bad(f"CRM commissions · {CM_RD} — énumération des champs de base "
                f"INTROUVABLE (attendu {sorted(cm_base)})")
        else:
            bs = set(re.findall(r"`([a-z_]+)`", mf.group(1)))
            if bs == cm_base:
                good(f"CRM commissions · {CM_RD} — champs de base {sorted(bs)} == "
                     f"commission_plan.evenements[].base_field")
            else:
                miss = sorted(cm_base - bs); extra = sorted(bs - cm_base)
                detail = (f" · absents={miss}" if miss else "") + \
                         (f" · en trop={extra}" if extra else "")
                bad(f"CRM commissions · {CM_RD} — énumère {sorted(bs)} MAIS plan "
                    f"{sorted(cm_base)}{detail} → régénérer le README")

# ============================================================================
# CRM/Commissions · ANTI-INVENTION (#6) — la promesse « tous les taux sont `null` »
# (le CŒUR du livrable · sa RAISON D'ÊTRE) confrontée à l'artefact byte-gaté.
# ----------------------------------------------------------------------------
# Le module commissions n'existe QUE pour NE PAS inventer de taux (aucun taux de
# commission n'est documenté dans CLAUDE.md). Le README §« Anti-invention (#6) »
# le promet noir sur blanc (README:22-23) : « Le barème livré porte `taux_pct:
# null` + `source: null` + `a_confirmer: true` pour **chaque** évènement », et
# l'invariant CLI #7 REFUSE tout `taux_pct` fourni **sans `source`**. La source
# faisant autorité est `out/commission_plan.json` (byte-gaté par check_artifacts) :
# chaque évènement porte `taux_pct`/`source`/`a_confirmer`, et `MANIFEST.counts.
# taux_a_confirmer` recompte les taux à confirmer.
# Le bloc commissions ci-dessus ne gate QUE la CROSS-COHÉRENCE (base_field +
# brouillons exclus) ; le bloc racine ne gate QUE le COMPTE de tests (« 25 »).
# RIEN ne confrontait la promesse #6 à l'artefact. Piège #6, la dérive la plus
# grave possible ici : qu'un `taux_pct` DEVIENNE non-null (un taux INVENTÉ, ou une
# valeur remplie SANS `source`) pendant que le README continue d'affirmer « tous
# `null` pour chaque évènement » → le calcul `base × taux` émettrait une commission
# fondée sur un chiffre inventé (l'invention EXACTE que le module promet d'écarter),
# « vert trompeur » qu'aucune suite tests/ (FONCTIONS de calcul, jamais la promesse
# du README vs l'artefact commité) n'attrape. On RECOMPUTE l'état null/confirmé
# depuis l'artefact et on exige que la promesse du README le REFLÈTE — dans les DEUX
# sens (si un taux est un jour confirmé par la Direction, la promesse « tous null »
# DOIT être mise à jour, sinon ROUGE : le gate suit la vie du module sans faux
# négatif). Cohérences croisées (mordent un plan INTERNEMENT incohérent) : invariant
# #7 permanent (aucun `taux_pct` sans `source`) · `a_confirmer` ⟺ `taux_pct is null`
# (un taux confirmé n'est plus « à confirmer », et inversement) · MANIFEST.counts
# recoupe le plan (`taux_a_confirmer` == nb à confirmer · `evenements` == |plan|).
# ============================================================================
try:
    cta_ev = load("crm/commissions/out/commission_plan.json")["evenements"]
    cta_counts = load("crm/commissions/out/MANIFEST.json")["counts"]
    cta_note = load("crm/commissions/out/MANIFEST.json").get("note_taux", "")
except (OSError, KeyError, ValueError, TypeError) as e:
    bad(f"CRM commissions anti-invention · artefact illisible/inattendu : {e}")
    cta_ev = None
if cta_ev is not None:
    if not cta_ev:
        bad("CRM commissions anti-invention · plan VIDE → aucun évènement à couvrir "
            "(barème suspect)")
    else:
        # (a) invariant #7 permanent : aucun taux_pct rempli SANS source (invention)
        invente = sorted(e["update_value"] for e in cta_ev
                         if e.get("taux_pct") is not None and e.get("source") is None)
        if invente:
            bad(f"CRM commissions anti-invention · taux_pct RENSEIGNÉ sans `source` pour "
                f"{invente} → invariant #7 VIOLÉ (taux INVENTÉ · #6) → fournir la source "
                f"ou remettre à null")
        else:
            good("CRM commissions anti-invention · invariant #7 — aucun `taux_pct` sans "
                 "`source` (nul taux inventé)")
        # (b) a_confirmer ⟺ taux_pct is null (un taux confirmé n'est plus « à confirmer »)
        incoh = sorted(e["update_value"] for e in cta_ev
                       if bool(e.get("a_confirmer")) != (e.get("taux_pct") is None))
        if incoh:
            bad(f"CRM commissions anti-invention · `a_confirmer` incohérent avec "
                f"`taux_pct` pour {incoh} (confirmé mais taux null, ou taux rempli mais "
                f"encore « à confirmer ») → régénérer le plan")
        else:
            good("CRM commissions anti-invention · `a_confirmer` ⟺ `taux_pct is null` "
                 "sur les 5 évènements (cohérent)")
        # (c) MANIFEST.counts recoupe le plan
        n_conf = sum(1 for e in cta_ev if e.get("a_confirmer"))
        if cta_counts.get("taux_a_confirmer") != n_conf:
            bad(f"CRM commissions anti-invention · MANIFEST.counts.taux_a_confirmer="
                f"{cta_counts.get('taux_a_confirmer')} ≠ {n_conf} (plan) → MANIFEST périmé")
        elif cta_counts.get("evenements") != len(cta_ev):
            bad(f"CRM commissions anti-invention · MANIFEST.counts.evenements="
                f"{cta_counts.get('evenements')} ≠ {len(cta_ev)} (plan) → MANIFEST périmé")
        else:
            good(f"CRM commissions anti-invention · MANIFEST.counts recoupe le plan "
                 f"({n_conf} à confirmer / {len(cta_ev)} évènements)")
        # (d) la PROMESSE #6 du README reflète l'état RÉEL de l'artefact (bidirectionnel)
        all_null = all(e.get("taux_pct") is None and e.get("source") is None
                       and e.get("a_confirmer") is True for e in cta_ev)
        cta_rd = read_norm(CM_RD)
        if cta_rd is not None:
            promesse = re.search(
                r"`taux_pct:\s*null`\s*\+\s*`source:\s*null`\s*\+\s*"
                r"`a_confirmer:\s*true`\s*pour\s*\*\*chaque\*\*", cta_rd, re.IGNORECASE)
            if all_null:
                if promesse is None:
                    bad(f"CRM commissions anti-invention · {CM_RD} — la promesse « `taux_pct: "
                        f"null` + `source: null` + `a_confirmer: true` pour **chaque** "
                        f"évènement » INTROUVABLE alors que l'artefact est bien tout-null "
                        f"→ la raison d'être du module ne doit pas disparaître (#6)")
                else:
                    good(f"CRM commissions anti-invention · {CM_RD} — promesse « tout null "
                         f"pour chaque évènement » == artefact (les 5 évènements tout-null)")
            else:
                if promesse is not None:
                    bad(f"CRM commissions anti-invention · {CM_RD} — le README affirme "
                        f"« tout `null` pour **chaque** évènement » MAIS l'artefact porte "
                        f"des taux CONFIRMÉS → mettre à jour la §Anti-invention (promesse "
                        f"périmée · « vert trompeur »)")
                else:
                    good(f"CRM commissions anti-invention · {CM_RD} — la promesse « tout "
                         f"null » a été retirée, cohérent avec des taux désormais confirmés")
        # (e) la note d'auto-traçabilité du MANIFEST reste cohérente avec l'état
        if all_null and "null" not in cta_note.lower():
            bad(f"CRM commissions anti-invention · MANIFEST.note_taux ne mentionne plus "
                f"`null` alors que tous les taux le sont → note périmée")

# ============================================================================
# CRM/Commissions · IDENTITÉ des RÔLES PAYÉS — appartenance au portail `ventes`
# + nom Frappe RÉSOLU (invariants #5/#6 du module · le « à quel rôle » du livrable).
# ----------------------------------------------------------------------------
# Le README pose la question fondatrice « quel évènement paie, à quel RÔLE, sur
# quel montant » (README:8-9) et promet en §Cross-cohérence (README:51-53) que
# chaque `role_id` commissionné « doit être résolu depuis rbac_50_roles.json …
# ET appartenir au portail `ventes` » (invariant CLI #5), son `erpnext_role_name`
# restant « cohérent avec RBAC » (invariant #6 · le « nom Frappe résolu » du
# README:36). C'est une SÉPARATION DES POUVOIRS : on ne commissionne QUE des rôles
# du portail ventes, jamais un rôle compta/construction/direction. Les blocs
# commissions ci-dessus gatent les états soumis, les champs de base et la promesse
# anti-taux — mais RIEN ne confronte cette appartenance-portail ni la résolution du
# nom Frappe à l'artefact. Dérive #6 la plus grave : (a) commissionner un rôle d'un
# AUTRE portail (payer un fiscaliste compta → la prose « portail `ventes` » devient
# un MENSONGE, pouvoirs mélangés) ; (b) un `role_id` FANTÔME absent du contrat (rôle
# inventé, l'invention même que #6 interdit) ; (c) un `erpnext_role_name` figé en dur
# qui dérive quand le contrat renomme le rôle (nom Frappe périmé → `Has Role` importé
# sur un rôle INEXISTANT côté VPS). Aucune suite tests/ (FONCTIONS de résolution/
# calcul, jamais la prose vs l'artefact) ne l'attrape ; ce n'est ni un lien, ni un
# terme interdit, ni un `out/` → invisible à check_docs/guard_constraints/artifacts.
# On RECOMPUTE depuis les artefacts byte-gatés (plan + contrat + MANIFEST · zéro dup
# du contrat du générateur #6) : chaque role_id du plan ∈ contrat, portail == un
# SINGLETON == le portail nommé au README == un portail_business, nom Frappe ==
# contrat, et MANIFEST.roles_rbac_utilises recoupe l'ensemble distinct réellement payé.
# ============================================================================
try:
    cr_ev = load("crm/commissions/out/commission_plan.json")["evenements"]
    _cr_rbac = load("rbac/rbac_50_roles.json")
    cr_by = {r["id"]: r for r in _cr_rbac["roles"]}
    cr_pbiz = set(_cr_rbac["portails_business"])
    cr_man = load("crm/commissions/out/MANIFEST.json")
except (OSError, KeyError, ValueError, TypeError) as e:
    bad(f"CRM commissions rôles · artefact illisible/inattendu : {e}"); cr_ev = None
if cr_ev is not None:
    cr_ids = {e["role_id"] for e in cr_ev}
    if not cr_ids:
        bad("CRM commissions rôles · plan sans role_id → barème suspect")
    else:
        # (a) chaque role_id commissionné se RÉSOUT dans le contrat (jamais fantôme)
        cr_phantom = sorted(i for i in cr_ids if i not in cr_by)
        if cr_phantom:
            bad(f"CRM commissions rôles · role_id(s) {cr_phantom} ABSENT(s) de "
                f"rbac_50_roles.json → rôle INVENTÉ (invariant #5 · #6)")
        else:
            good(f"CRM commissions rôles · les {len(cr_ids)} role_id payés se résolvent "
                 f"tous dans le contrat RBAC")
            # (b) nom Frappe RÉSOLU : plan.erpnext_role_name == contrat (invariant #6)
            cr_badname = sorted(e["role_id"] for e in cr_ev
                                if e.get("erpnext_role_name")
                                != cr_by[e["role_id"]]["erpnext_role_name"])
            if cr_badname:
                bad(f"CRM commissions rôles · erpnext_role_name du plan ≠ contrat pour "
                    f"{cr_badname} → nom Frappe périmé/en dur (invariant #6) → régénérer")
            else:
                good("CRM commissions rôles · erpnext_role_name == contrat pour chaque "
                     "évènement (nom Frappe résolu · invariant #6)")
            # (c) appartenance-portail : SINGLETON == portail nommé au README == business
            cr_ports = {cr_by[i]["portail"] for i in cr_ids}
            if len(cr_ports) != 1:
                bad(f"CRM commissions rôles · rôles payés répartis sur PLUSIEURS portails "
                    f"{sorted(cr_ports)} → séparation des pouvoirs rompue (invariant #5 : "
                    f"seul le portail ventes commissionne)")
            else:
                cr_port = next(iter(cr_ports))
                if cr_port not in cr_pbiz:
                    bad(f"CRM commissions rôles · portail des rôles payés `{cr_port}` ∉ "
                        f"portails_business {sorted(cr_pbiz)} → portail fantôme")
                else:
                    cr_rd = read_norm(CM_RD)
                    mrp = re.search(r"appartenir au portail `([a-z-]+)`", cr_rd or "")
                    if mrp is None:
                        bad(f"CRM commissions rôles · {CM_RD} — claim « appartenir au portail "
                            f"`…` » INTROUVABLE (attendu `{cr_port}`)")
                    elif mrp.group(1) != cr_port:
                        bad(f"CRM commissions rôles · {CM_RD} — dit portail `{mrp.group(1)}` "
                            f"MAIS les rôles payés sont du portail `{cr_port}` → régénérer le README")
                    else:
                        good(f"CRM commissions rôles · tous les rôles payés ∈ portail "
                             f"`{cr_port}` == README == portail_business (pouvoirs séparés)")
            # (d) MANIFEST.roles_rbac_utilises recoupe l'ensemble distinct payé
            cr_mru_pairs = {(r.get("role_id"), r.get("erpnext_role_name"))
                            for r in cr_man.get("roles_rbac_utilises", [])}
            cr_ev_pairs = {(e["role_id"], cr_by[e["role_id"]]["erpnext_role_name"])
                           for e in cr_ev if e["role_id"] in cr_by}
            if cr_mru_pairs != cr_ev_pairs:
                miss = sorted(cr_ev_pairs - cr_mru_pairs)
                extra = sorted(cr_mru_pairs - cr_ev_pairs)
                bad(f"CRM commissions rôles · MANIFEST.roles_rbac_utilises ≠ rôles réellement "
                    f"payés" + (f" · absents={miss}" if miss else "")
                    + (f" · en trop={extra}" if extra else "") + " → MANIFEST périmé")
            elif cr_man.get("counts", {}).get("roles") != len(cr_ids):
                bad(f"CRM commissions rôles · MANIFEST.counts.roles="
                    f"{cr_man.get('counts', {}).get('roles')} ≠ {len(cr_ids)} rôles distincts "
                    f"payés → MANIFEST périmé")
            else:
                good(f"CRM commissions rôles · MANIFEST.roles_rbac_utilises == {len(cr_ids)} "
                     f"rôles distincts payés (counts.roles cohérent)")

# ============================================================================
# CRM/Commissions · IDENTITÉ du CHAMP DEVISE — `devise_field` = le champ `devise`
# (`Select` USD/DOP · #10) du DocType, transcrit À LA MAIN au README:54 sans gate.
# ----------------------------------------------------------------------------
# Le générateur commissions_gen émet out/commission_plan.json (byte-gaté par
# check_artifacts ⇒ faisant autorité) : le plan porte la clé RACINE `devise_field`
# — le champ du Dossier Vente qui PORTE la devise d'un montant commissionné. Son
# invariant CLI #8 exige TROIS choses simultanées : `devise_field == "devise"`, le
# DocType Dossier Vente (doctype_spec.json, l'INPUT du générateur) porte bien un
# champ `devise` de fieldtype `Select`, et ses `options` == `USD`/`DOP` — les DEUX
# devises canoniques de CLAUDE.md #10 (« **USD + DOP** devises »). La §Cross-cohérence
# RÉÉNONCE le tout EN PROSE (README:54) : « `devise_field` = le champ `devise`
# (`Select` **USD/DOP** · #10) du DocType ». Les blocs commissions ci-dessus gatent
# les états soumis, les champs de base, les rôles payés et la promesse anti-taux —
# mais AUCUN ne touche ce couple champ↔devises. Dérive #6 : RENOMMER le champ
# (`devise`→`moneda`) dans le plan/DocType, changer son fieldtype (`Select`→`Data` :
# la contrainte d'énumération PERDUE, un montant en monnaie libre), ou altérer ses
# options (`USD/DOP`→`USD/EUR` : devise HORS #10, le calcul `base × taux`
# commissionnerait dans une monnaie non prévue) laisse la prose périmée EN SILENCE
# pendant que l'artefact/DocType dit autre chose → « vert trompeur » qu'aucune suite
# tests/ (FONCTIONS finance/résolution, jamais la prose vs artefact) n'attrape ; ce
# n'est ni un lien, ni un terme interdit, ni un `out/` byte-gaté du module → invisible
# à check_docs/guard_constraints. On RECOMPUTE depuis les artefacts byte-gatés + la
# contrainte constitutionnelle : (0) ANCRAGE — les 2 devises depuis CLAUDE.md #10
# (segment gras « USD + DOP ») ; (a) CROSS-ARTEFACT — plan.devise_field == "devise"
# == fieldname du champ DocType · fieldtype == "Select" · options (ORDONNÉES) ==
# devises #10 ; (b) PROSE — README:54 nomme le champ `devise`, le dit `Select`, cite
# USD/DOP == #10 et tague #10. Un claim absent échoue AUSSI (traçabilité #6).
# Direction plan/DocType/CLAUDE.md #10 → README.
# ============================================================================
try:
    cd_plan = load("crm/commissions/out/commission_plan.json")
    cd_dt = load("crm/dossier_vente/doctype_spec.json")
    cd_cm = open("CLAUDE.md", encoding="utf-8").read()
except (OSError, KeyError, ValueError, TypeError) as e:
    bad(f"CRM commissions devise · artefact/CLAUDE.md illisible/inattendu : {e}"); cd_plan = None
if cd_plan is not None:
    # (0) ANCRAGE #10 : les 2 devises canoniques (segment gras « USD + DOP »)
    cd_cur = None
    cd_l10 = re.search(r"^10\.\s+(.+)$", cd_cm, re.M)
    if cd_l10:
        for seg in re.split(r"·", cd_l10.group(1)):
            if "devise" in seg.lower():
                mm = re.search(r"\*\*(.+?)\*\*", seg)
                if mm:
                    cd_cur = [t.strip() for t in re.split(r"[+/]", mm.group(1)) if t.strip()]
    if not cd_cur:
        bad("CRM commissions devise · devises #10 (« **USD + DOP** ») INTROUVABLES dans "
            "CLAUDE.md #10 → ancrage rompu")
    else:
        cd_field = None
        for grp in cd_dt.get("field_groups", []):
            for f in grp.get("fields", []):
                if f.get("fieldname") == "devise":
                    cd_field = f
        cd_pf = cd_plan.get("devise_field")
        cd_opts = ([ln for ln in cd_field.get("options", "").split("\n") if ln]
                   if cd_field else None)
        # (a) CROSS-ARTEFACT : plan ↔ DocType ↔ devises #10 (invariant CLI #8)
        if cd_pf != "devise":
            bad(f"CRM commissions devise · plan.devise_field={cd_pf!r} ≠ 'devise' → "
                f"invariant #8 cassé (le champ porteur de la devise a changé)")
        elif cd_field is None:
            bad("CRM commissions devise · champ `devise` ABSENT du DocType Dossier Vente "
                "→ invariant #8 (options USD/DOP invérifiables)")
        elif cd_field.get("fieldtype") != "Select":
            bad(f"CRM commissions devise · champ `devise` fieldtype={cd_field.get('fieldtype')!r} "
                f"≠ 'Select' → énumération USD/DOP PERDUE (monnaie libre)")
        elif cd_opts != cd_cur:
            bad(f"CRM commissions devise · options du champ `devise` {cd_opts} ≠ devises #10 "
                f"{cd_cur} (ordre inclus) → devise HORS CLAUDE.md #10")
        else:
            good(f"CRM commissions devise · plan.devise_field='devise' == DocType champ `devise` "
                 f"(Select {cd_opts}) == CLAUDE.md #10")
            # (b) PROSE README:54 — champ · fieldtype · devises · tag #10
            cd_rd = read_norm(CM_RD)
            mpr = re.search(
                r"`devise_field`\*\* = le champ `([a-z_]+)` \(`(\w+)` \*\*([A-Z/]+)\*\* · #10\)",
                cd_rd or "")
            if mpr is None:
                bad(f"CRM commissions devise · {CM_RD} — claim « `devise_field` = le champ "
                    f"`devise` (`Select` **USD/DOP** · #10) » INTROUVABLE/malformé (traçabilité #6)")
            else:
                p_field, p_ftype = mpr.group(1), mpr.group(2)
                p_cur = mpr.group(3).split("/")
                errs = []
                if p_field != "devise":
                    errs.append(f"champ `{p_field}` ≠ `devise`")
                if p_ftype != "Select":
                    errs.append(f"type `{p_ftype}` ≠ `Select`")
                if p_cur != cd_cur:
                    errs.append(f"devises {p_cur} ≠ #10 {cd_cur}")
                if errs:
                    bad(f"CRM commissions devise · {CM_RD} — prose périmée : "
                        + " · ".join(errs) + " → régénérer le README")
                else:
                    good(f"CRM commissions devise · {CM_RD} — prose « champ `devise` "
                         f"(`Select` {'/'.join(p_cur)}) · #10 » == artefacts + CLAUDE.md #10")

# ============================================================================
# RBAC/fixtures_gen · le CATALOGUE des DocTypes `custom` à créer sur le VPS et la
# SÉPARATION DES POUVOIRS (le rôle unique porteur de `set_user_permissions`) —
# deux faits data-derived transcrits À LA MAIN dans le README sans AUCUN gate
# d'IDENTITÉ.
# ----------------------------------------------------------------------------
# Le générateur rbac_fixtures_gen émet out/MANIFEST.json et out/custom_docperm.json
# (byte-gatés par check_artifacts ⇒ faisant autorité) :
#   • MANIFEST.custom_doctypes_a_confirmer = l'ensemble des DocTypes `custom: true`
#     du contrat rbac_50_roles.json (ceux à CRÉER avant import — le reste est natif
#     v15). Le README §« Application sur VPS » l'ÉNUMÈRE À LA MAIN, en nommant même
#     le champ : « listés dans `MANIFEST.custom_doctypes_a_confirmer` (`Faisabilité`,
#     `Publiciste Log`, `CONFOTUR Application`, `API Access`) ».
#   • custom_docperm.json porte le flag `set_user_permissions`. Le CŒUR sécurité du
#     module (« séparation des pouvoirs · défense en profondeur ») est que ce flag
#     n'est émis que pour UN SEUL rôle — le README:48 dit « n'est émis que pour le
#     rôle **RBAC Admin** ». C'est l'ensemble {role | set_user_permissions=1}.
# Le bloc racine ne gate QUE le COMPTE de tests fixtures (« 50 rôles / 116 DocPerm »
# via l'agrégat RBAC de l'apply_plan) — jamais l'IDENTITÉ de ces deux ensembles.
# Aucun gate ne les comparait à l'artefact : AJOUTER un DocType `custom` au contrat
# (le MANIFEST rebâtit 5 entrées) OU RENOMMER/ÉCHANGER l'un des 4 laisse la prose à
# ses 4 noms périmés ; PROMOUVOIR un 2ᵉ rôle porteur de `set_user_permissions` (ou
# renommer RBAC Admin) laisse la prose « un seul rôle RBAC Admin » fausse — « vert
# trompeur » qu'aucune suite tests/ (qui teste des FONCTIONS de mapping, pas la
# prose du README) n'attrape. Même patron que l'énumération des onglets Mobile, des
# brouillons CRM ou des open items de l'audit 5D. On RECOMPUTE chaque ensemble
# depuis SON artefact byte-gaté (zéro duplication du contrat du générateur #6) et on
# exige que la prose l'énumère/le nomme EXACTEMENT (absent ET en trop via set-diff ·
# accents/casse normalisés). Cohérence croisée en bonus : le catalogue est NON VIDE,
# sans doublon et TRIÉ (byte-déterminisme du générateur) ; la séparation des pouvoirs
# est un SINGLETON (ni vide — la garde serait vacante — ni multiple — élévation de
# privilège). Un claim absent échoue AUSSI (traçabilité).
# ============================================================================
FX_RD = f"{D}/rbac/fixtures_gen/README.md"
def _fx_acc(s):
    return "".join(c for c in unicodedata.normalize("NFKD", s)
                   if unicodedata.category(c) != "Mn").strip().lower()
try:
    fx_cdt = load("rbac/fixtures_gen/out/MANIFEST.json")["custom_doctypes_a_confirmer"]
    fx_perm = load("rbac/fixtures_gen/out/custom_docperm.json")
    fx_sup = sorted({p["role"] for p in fx_perm if p.get("set_user_permissions")})
except (OSError, KeyError, ValueError, TypeError) as e:
    bad(f"RBAC fixtures · artefact illisible/inattendu : {e}"); fx_cdt = None
if fx_cdt is not None:
    # bonus : le catalogue est un vrai ENSEMBLE trié non vide (byte-déterminisme)
    if not fx_cdt:
        bad("RBAC fixtures · custom_doctypes_a_confirmer VIDE → aucun DocType custom "
            "à créer ? (contrat suspect)")
    elif fx_cdt != sorted(set(fx_cdt)):
        bad(f"RBAC fixtures · custom_doctypes_a_confirmer {fx_cdt} non trié/dédupliqué "
            f"→ générateur non déterministe (attendu {sorted(set(fx_cdt))})")
    else:
        good(f"RBAC fixtures · catalogue custom {fx_cdt} — ensemble trié, sans doublon")
    # bonus : séparation des pouvoirs — un SEUL rôle porte set_user_permissions
    if len(fx_sup) != 1:
        bad(f"RBAC fixtures · set_user_permissions porté par {fx_sup} (attendu 1 seul "
            f"rôle) → séparation des pouvoirs cassée (élévation de privilège possible)")
    else:
        good(f"RBAC fixtures · set_user_permissions — singleton {fx_sup[0]!r} "
             f"(défense en profondeur intacte)")
    fx_rd = read_norm(FX_RD)
    if fx_rd is not None:
        # 1) README — énumération du catalogue custom (parenthèse après le champ nommé)
        me = re.search(r"custom_doctypes_a_confirmer`?\s*\(([^)]*)\)", fx_rd)
        if me is None:
            bad(f"RBAC fixtures · {FX_RD} — énumération du catalogue custom INTROUVABLE "
                f"(attendu {sorted(fx_cdt)})")
        else:
            got = {_fx_acc(t) for t in re.findall(r"`([^`]+)`", me.group(1))}
            exp = {_fx_acc(x) for x in fx_cdt}
            if got == exp:
                good(f"RBAC fixtures · {FX_RD} — catalogue custom {sorted(got)} == "
                     f"MANIFEST.custom_doctypes_a_confirmer")
            else:
                miss = sorted(exp - got); extra = sorted(got - exp)
                detail = (f" · absents={miss}" if miss else "") + \
                         (f" · en trop={extra}" if extra else "")
                bad(f"RBAC fixtures · {FX_RD} — énumère {sorted(got)} MAIS MANIFEST "
                    f"{sorted(exp)}{detail} → régénérer le README")
        # 2) README:48 — le rôle NOMMÉ porteur de set_user_permissions (séparation)
        if len(fx_sup) == 1:
            mr = re.search(r"émis que pour le rôle \*\*([^*]+)\*\*", fx_rd)
            if mr is None:
                bad(f"RBAC fixtures · {FX_RD} — claim « set_user_permissions n'est émis "
                    f"que pour le rôle ** ** » INTROUVABLE (attendu {fx_sup[0]!r})")
            elif _fx_acc(mr.group(1)) in _fx_acc(fx_sup[0]):
                good(f"RBAC fixtures · {FX_RD} — rôle nommé « {mr.group(1).strip()} » ⊆ "
                     f"{fx_sup[0]!r} (le seul porteur du flag)")
            else:
                bad(f"RBAC fixtures · {FX_RD} — nomme « {mr.group(1).strip()} » MAIS "
                    f"set_user_permissions porté par {fx_sup[0]!r} → régénérer le README")
        # 3) RBAC_50_ROLES_SPEC.md §6 — 2ᵉ surface prose du MÊME catalogue custom :
        #    « **DocTypes `custom: true`** (`Faisabilité`, `Publiciste Log`, … ) ». Le
        #    bloc apply_plan ne gate que le COMPTE (len custom_doctypes), le point (1)
        #    ci-dessus que le README fixtures — la SPEC §6 restait AVEUGLE : renommer/
        #    retirer un DocType `custom` du contrat (le MANIFEST suit, byte-gaté) y
        #    laisse les 4 noms périmés en silence, comme la 2ᵉ surface CONFOTUR/entités.
        if spec_rd is not None:
            m6 = re.search(r"DocTypes `custom: true`\*\* \(([^)]*)\)", spec_rd)
            if m6 is None:
                bad(f"RBAC fixtures · {SPEC_RD} §6 — énumération « DocTypes `custom: "
                    f"true` (…) » INTROUVABLE (attendu {sorted(fx_cdt)} · anti-évaporation #6)")
            else:
                got6 = {_fx_acc(t) for t in re.findall(r"`([^`]+)`", m6.group(1))}
                exp6 = {_fx_acc(x) for x in fx_cdt}
                if got6 == exp6:
                    good(f"RBAC fixtures · {SPEC_RD} §6 — catalogue custom {sorted(got6)} "
                         f"== MANIFEST.custom_doctypes_a_confirmer")
                else:
                    miss6 = sorted(exp6 - got6); extra6 = sorted(got6 - exp6)
                    detail6 = (f" · absents={miss6}" if miss6 else "") + \
                              (f" · en trop={extra6}" if extra6 else "")
                    bad(f"RBAC fixtures · {SPEC_RD} §6 — énumère {sorted(got6)} MAIS "
                        f"MANIFEST {sorted(exp6)}{detail6} → régénérer la SPEC")
    # 4) RBAC_50_ROLES_SPEC.md §2 — encadré « Défense en profondeur » : 3ᵉ surface prose
    #    du MÊME fait de sécurité. Les points (2)/(3) ancrent la prose du README fixtures
    #    (« émis que pour le rôle **NAME** ») + le catalogue §6 ; le §2 « Modèle RBAC »
    #    ferme sur « **réservé** au seul rôle `<NAME>` (`scope_donnees = groupe`,
    #    `famille = plateforme`) — séparation des pouvoirs vérifiée par test automatisé » :
    #    TROIS attributs (NOM · scope · famille) qu'AUCUN gate ne touchait. Le test
    #    `test_rbac::test_set_user_permissions_reserve_admin` impose scope/famille à TOUT
    #    détenteur mais JAMAIS le NOM du rôle ni cette prose §2 → renommer le détenteur
    #    (contrat + régénération fixtures/out + audit 4Big) laisse le §2 citer un rôle
    #    DISPARU, VERT (check_artifacts ne voit qu'un HASH de TAILLE du doc, la SPEC n'est
    #    pas un out/*.json). On RÉUTILISE fx_sup (singleton déjà byte-gaté ci-dessus · zéro
    #    re-dérivation de l'unicité #5) comme NOM-autorité et src_roles (contrat byte-gaté)
    #    pour scope/famille. La glose « (gestion des habilitations) » N'est PAS gatée.
    if spec_rd is not None and len(fx_sup) == 1:
        _su_hn = fx_sup[0]
        _su_role = next((r for r in (src_roles or [])
                         if r.get("erpnext_role_name") == _su_hn), None)
        _su_def = re.search(r"\*\*Défense en profondeur\*\*(.*?)"
                            r"vérifiée par test automatis", spec_rd)
        if _su_def is None:
            bad(f"RBAC fixtures · {SPEC_RD} §2 — encadré « **Défense en profondeur** … "
                f"vérifiée par test automatisé » INTROUVABLE (anti-évaporation #6)")
        else:
            _su_reg = _su_def.group(1)
            _su_toks = re.findall(r"`([^`]+)`", _su_reg)
            # ancrage : le pouvoir lui-même doit être nommé dans l'encadré
            if "set_user_permissions" not in _su_toks:
                bad(f"RBAC fixtures · {SPEC_RD} §2 — encadré ne nomme pas "
                    f"`set_user_permissions` → mal ancré")
            # (a) NOM du détenteur cité == le seul porteur du flag (byte-gaté)
            if _fx_acc(_su_hn) in {_fx_acc(t) for t in _su_toks}:
                good(f"RBAC fixtures · {SPEC_RD} §2 — détenteur nommé « {_su_hn} » == le "
                     f"seul porteur de set_user_permissions (custom_docperm.json)")
            else:
                _su_named = [t for t in _su_toks
                             if "=" not in t and t != "set_user_permissions"]
                bad(f"RBAC fixtures · {SPEC_RD} §2 — nomme {_su_named} MAIS "
                    f"set_user_permissions réservé à {_su_hn!r} → régénérer la SPEC "
                    f"(rôle renommé côté contrat ?)")
            # (b)/(c) scope + famille cités == attributs du détenteur (contrat byte-gaté)
            if _su_role is None:
                bad(f"RBAC fixtures · {SPEC_RD} §2 — détenteur {_su_hn!r} INTROUVABLE dans "
                    f"rbac_50_roles.json (fixtures ↔ contrat désynchronisés)")
            else:
                for _su_lbl in ("scope_donnees", "famille"):
                    _su_m = re.search(rf"{_su_lbl}\s*=\s*(\w+)", _su_reg)
                    _su_want = _su_role.get(_su_lbl)
                    if _su_m is None:
                        bad(f"RBAC fixtures · {SPEC_RD} §2 — `{_su_lbl} = …` INTROUVABLE "
                            f"dans l'encadré")
                    elif _su_m.group(1) == _su_want:
                        good(f"RBAC fixtures · {SPEC_RD} §2 — {_su_lbl} `{_su_m.group(1)}` "
                             f"== détenteur (rbac_50_roles.json)")
                    else:
                        bad(f"RBAC fixtures · {SPEC_RD} §2 — encadré dit {_su_lbl}="
                            f"`{_su_m.group(1)}` MAIS le détenteur {_su_hn!r} a "
                            f"`{_su_want}` → aligner sur le contrat")
        # 5) §6 row « Séparation des pouvoirs » cite le pouvoir réservé (ancre présente)
        _su_m6 = re.search(r"## 6\..*?(?=## 7\.)", spec_rd)
        if _su_m6 and "Séparation des pouvoirs" in _su_m6.group(0) \
                and "set_user_permissions" in _su_m6.group(0):
            good(f"RBAC fixtures · {SPEC_RD} §6 — row « Séparation des pouvoirs » cite "
                 f"bien `set_user_permissions` réservé (ancre présente)")
        else:
            bad(f"RBAC fixtures · {SPEC_RD} §6 — row « Séparation des pouvoirs » / "
                f"`set_user_permissions` réservé INTROUVABLE → ancre évaporée (#6)")

# ============================================================================
# SEO · la COMPOSITION du graphe schema.org (JSON-LD) — un nœud racine
# `Organization` + une `Residence` PAR PROJET — décrite EN PROSE dans le README
# sans AUCUN gate d'IDENTITÉ.
# ----------------------------------------------------------------------------
# Le générateur SEO émet out/seo_schema_org.json (byte-gaté par check_artifacts ⇒
# faisant autorité). Son `@graph` est l'ENSEMBLE des nœuds JSON-LD : exactement UN
# nœud racine `Organization` (@id …#organization) + un listing PAR PROJET, tous du
# même @type. Le README:16 décrit cette structure À LA MAIN : « graphe JSON-LD
# (`Organization` + une `Residence` par projet) ». Le bloc SEO existant ne gate que
# le COMPTE de mots-clés (keywords_total + ventilation par langue) — jamais les
# @type du graphe ni la bijection un-listing-par-projet. Aucun gate ne comparait
# ces TYPES à l'artefact : RENOMMER le @type des listings (Residence → Apartment,
# rango schema.org) OU AJOUTER un 3ᵉ type de nœud (Offer/Product) OU casser la
# bijection (2 nœuds pour un projet) ferait dériver la prose en silence pendant que
# le graphe byte-gaté dit autre chose — « vert trompeur » qu'aucune suite tests/
# (qui teste des FONCTIONS de génération, pas la prose du README) n'attrape. Même
# patron que l'énumération des onglets Mobile ou du catalogue custom RBAC. On
# RECOMPUTE l'ENSEMBLE des @type depuis SON artefact byte-gaté (zéro duplication du
# contrat du générateur #6) et on exige que la prose les nomme EXACTEMENT (absent ET
# en trop via set-diff). Cohérences croisées en bonus : le nœud racine Organization
# est un SINGLETON ; les listings sont uniformes (un seul @type) et en BIJECTION avec
# les projets (listings == projects == schema_org_nodes−1 du MANIFEST byte-gaté) ;
# aucune `offers` (statuts sans prix · USD #10). Un claim absent échoue AUSSI.
# ============================================================================
SEO_SG_RD = f"{D}/seo/README.md"
try:
    sg = load("seo/out/seo_schema_org.json")["@graph"]
    seo_cnt = load("seo/out/MANIFEST.json")["counts"]
    sg_org = [n for n in sg if str(n.get("@id", "")).endswith("#organization")]
    sg_list = [n for n in sg if not str(n.get("@id", "")).endswith("#organization")]
    sg_types = {n["@type"] for n in sg}                 # ensemble des @type du graphe
    org_types = {n["@type"] for n in sg_org}
    list_types = {n["@type"] for n in sg_list}
    sg_offers = sum(1 for n in sg if "offers" in n)
except (OSError, KeyError, ValueError, TypeError) as e:
    bad(f"SEO schema.org · artefact illisible/inattendu : {e}"); sg = None
if sg is not None:
    n_proj = seo_cnt.get("projects")
    # bonus 1 : racine Organization = SINGLETON, d'un seul @type
    if len(sg_org) != 1 or len(org_types) != 1:
        bad(f"SEO schema.org · nœud racine #organization non-singleton "
            f"({len(sg_org)} nœud(s), types={sorted(org_types)}) → graphe suspect")
    else:
        good(f"SEO schema.org · racine {sorted(org_types)[0]!r} — singleton (1 nœud)")
    # bonus 2 : listings uniformes + bijection 1 par projet + total du MANIFEST
    if not sg_list or len(list_types) != 1:
        bad(f"SEO schema.org · listings non uniformes (types={sorted(list_types)}) "
            f"→ un @type unique attendu par projet")
    elif not (len(sg_list) == n_proj == seo_cnt.get("listings")
              == seo_cnt.get("schema_org_nodes", 0) - 1):
        bad(f"SEO schema.org · bijection cassée : {len(sg_list)} listing(s) MAIS "
            f"projects={n_proj} listings={seo_cnt.get('listings')} "
            f"schema_org_nodes={seo_cnt.get('schema_org_nodes')} (attendu 1+{n_proj})")
    else:
        good(f"SEO schema.org · {len(sg_list)} × {sorted(list_types)[0]!r} — bijection "
             f"un listing par projet ({n_proj}) · total {seo_cnt.get('schema_org_nodes')} nœuds")
    # bonus 3 : aucune offers/prix (statuts sans prix · #10 · cohérent MANIFEST.offers)
    if sg_offers != 0 or seo_cnt.get("offers") != 0:
        bad(f"SEO schema.org · {sg_offers} nœud(s) `offers` (MANIFEST.offers="
            f"{seo_cnt.get('offers')}) → prix non attendu pour statuts sans prix (#10)")
    else:
        good("SEO schema.org · aucune `offers`/prix (statuts sans prix · USD #10)")
    seo_sg_rd = read_norm(SEO_SG_RD)
    if seo_sg_rd is not None:
        # README:16 — la parenthèse décrit la composition du graphe : ENSEMBLE des @type
        msg = re.search(r"graphe JSON-LD \(([^)]*)\)", seo_sg_rd)
        if msg is None:
            bad(f"SEO schema.org · {SEO_SG_RD} — composition « graphe JSON-LD (…) » "
                f"INTROUVABLE (attendu {sorted(sg_types)})")
        else:
            prose_types = set(re.findall(r"`([A-Za-z]+)`", msg.group(1)))
            if prose_types == sg_types:
                good(f"SEO schema.org · {SEO_SG_RD} — @type {sorted(prose_types)} == "
                     f"seo_schema_org.@graph")
            else:
                miss = sorted(sg_types - prose_types); extra = sorted(prose_types - sg_types)
                detail = (f" · absents={miss}" if miss else "") + \
                         (f" · en trop={extra}" if extra else "")
                bad(f"SEO schema.org · {SEO_SG_RD} — nomme {sorted(prose_types)} MAIS "
                    f"graphe {sorted(sg_types)}{detail} → régénérer le README")
        # README:16 — le type PAR PROJET nommé == le @type des listings (racine distincte)
        if len(list_types) == 1:
            mpp = re.search(r"une `([A-Za-z]+)` par projet", seo_sg_rd)
            lt = sorted(list_types)[0]
            if mpp is None:
                bad(f"SEO schema.org · {SEO_SG_RD} — « une `<type>` par projet » "
                    f"INTROUVABLE (attendu {lt!r})")
            elif mpp.group(1) == lt:
                good(f"SEO schema.org · {SEO_SG_RD} — « une `{lt}` par projet » == "
                     f"listings du graphe")
            else:
                bad(f"SEO schema.org · {SEO_SG_RD} — dit « une `{mpp.group(1)}` par projet » "
                    f"MAIS listings sont `{lt}` → régénérer le README")

# ============================================================================
# SEO · l'IDENTITÉ du nœud racine `Organization` du graphe schema.org (JSON-LD)
# — le nom de marque `Helios RD` (marque publique · CLAUDE.md §Entités) + son
# `url` == base_url du site — ANCRÉE sur CLAUDE.md, jamais gatée.
# ----------------------------------------------------------------------------
# out/seo_schema_org.json est byte-gaté par check_artifacts : la reproductibilité
# prouve qu'il SE RECONSTRUIT depuis seo_spec.json, PAS que son `Organization.name`
# == la marque publique nommée dans CLAUDE.md §Entités (« **Helios RD** (marque
# publique) sous WAG »). Le bloc SEO schema.org amont gate la COMPOSITION (@type +
# bijection un-listing-par-projet), jamais l'IDENTITÉ de la marque ni le rattachement
# des nœuds au site. Piège #6 : RENOMMER la marque dans
# seo_spec.json[schema_org.organization.name] (ou dans CLAUDE.md §Entités) laisse le
# JSON-LD PUBLIC — celui que Google indexe sous `vente.otov7.com` — émettre un nom de
# marque qui CONTREDIT le mandat pendant que la byte-gate reste VERTE (l'artefact se
# reconstruit fidèlement… depuis un spec dérivé) — « vert trompeur » de la classe des
# tokens branding ancrés sur CLAUDE.md #4. Aucune suite tests/ (FONCTIONS de
# génération, jamais l'ancrage à CLAUDE.md) ne l'attrape. On RE-DÉRIVE la marque de
# CLAUDE.md §Entités (zéro duplication · source unique) + le base_url du MANIFEST
# byte-gaté + le country_code du spec, puis on exige : (a) Organization.name == marque
# CLAUDE.md ; (b) Organization.url == base_url et @id sous base_url ; (c) chaque nœud
# listing rattaché à CETTE Organization (brand.@id == org.@id) et sous base_url ;
# (d) addressCountry UNIFORME == country_code du spec (géo-ciblage appliqué à
# l'identique à chaque nœud) ; (e) le spec DÉCLARE l'ancrage (organization.name ==
# marque + `source` cite CLAUDE.md). État courant : aucune valeur périmée — le défaut
# est la surface ungated.
# ============================================================================
_nfc_org = lambda s: unicodedata.normalize("NFC", str(s))
try:
    cm_ent = open("CLAUDE.md", encoding="utf-8").read()
    _mb = re.findall(r"\*\*([^*]+?)\*\*\s*\(marque publique\)", cm_ent)
    if len(_mb) != 1:
        raise ValueError(f"« (marque publique) » attendu 1× dans CLAUDE.md §Entités, "
                         f"trouvé {len(_mb)}×")
    brand = _nfc_org(_mb[0].strip())
except (OSError, ValueError) as e:
    bad(f"SEO org · marque publique CLAUDE.md §Entités illisible/inattendue : {e}"); brand = None
try:
    sog = load("seo/out/seo_schema_org.json")["@graph"]
    seo_base = load("seo/out/MANIFEST.json")["base_url"]
    with open(f"{D}/seo/seo_spec.json", encoding="utf-8") as _f:
        spec_sorg = json.load(_f)["schema_org"]
    seo_cc = spec_sorg["country_code"]
    spec_orgn = spec_sorg["organization"]
    org_n = [n for n in sog if str(n.get("@id", "")).endswith("#organization")]
    lst_n = [n for n in sog if not str(n.get("@id", "")).endswith("#organization")]
except (OSError, KeyError, ValueError, TypeError) as e:
    bad(f"SEO org · artefact/spec schema.org illisible/inattendu : {e}"); sog = None
if brand is not None and sog is not None:
    # (0) racine Organization = singleton (pré-requis à toute assertion d'identité)
    if len(org_n) != 1:
        bad(f"SEO org · nœud racine #organization non-singleton ({len(org_n)}) "
            f"→ identité de marque indéterminable")
    else:
        org = org_n[0]
        # (a) ANCRAGE : Organization.name == marque publique CLAUDE.md §Entités
        got_name = _nfc_org(org.get("name", ""))
        if got_name == brand:
            good(f"SEO org · Organization.name {got_name!r} == marque publique "
                 f"CLAUDE.md §Entités")
        else:
            bad(f"SEO org · Organization.name {got_name!r} ≠ CLAUDE.md §Entités {brand!r} "
                f"→ JSON-LD public contredit le mandat (régénérer spec/artefact ou aligner CLAUDE.md)")
        # (b) Organization.url == base_url + @id sous base_url
        if org.get("url") == seo_base and str(org.get("@id", "")) == f"{seo_base}/#organization":
            good(f"SEO org · Organization.url == base_url ({seo_base}) · @id cohérent")
        else:
            bad(f"SEO org · Organization.url={org.get('url')!r}/@id={org.get('@id')!r} "
                f"≠ base_url {seo_base!r}(+/#organization) → graphe incohérent")
        # (c) chaque listing rattaché à CETTE Organization (brand.@id), sous base_url
        bad_brand = [n.get("@id") for n in lst_n
                     if (n.get("brand") or {}).get("@id") != org.get("@id")]
        bad_url = [n.get("@id") for n in lst_n
                   if not str(n.get("url", "")).startswith(f"{seo_base}/")]
        if not lst_n:
            bad("SEO org · aucun nœud listing → graphe suspect")
        elif bad_brand or bad_url:
            d = (f" · brand.@id≠org: {bad_brand}" if bad_brand else "") + \
                (f" · url hors base_url: {bad_url}" if bad_url else "")
            bad(f"SEO org · {len(lst_n)} listing(s) mais rattachement/URL cassé{d}")
        else:
            good(f"SEO org · {len(lst_n)} listing(s) rattaché(s) à l'Organization "
                 f"(brand.@id) sous base_url")
    # (d) géo-ciblage : addressCountry UNIFORME == country_code du spec (code ISO 2)
    if not re.fullmatch(r"[A-Z]{2}", str(seo_cc)):
        bad(f"SEO org · country_code du spec {seo_cc!r} non conforme "
            f"(code pays ISO 2 lettres attendu)")
    else:
        countries = {(n.get("address") or {}).get("addressCountry") for n in lst_n}
        if countries == {seo_cc}:
            good(f"SEO org · addressCountry uniforme == country_code spec ({seo_cc}) "
                 f"sur {len(lst_n)} nœud(s)")
        else:
            bad(f"SEO org · addressCountry {sorted(map(str, countries))} ≠ {{{seo_cc}}} du spec "
                f"→ géo-ciblage non uniforme/divergent")
    # (e) le spec DÉCLARE l'ancrage : organization.name == marque + source cite CLAUDE.md
    spec_name = _nfc_org(spec_orgn.get("name", ""))
    spec_src = spec_orgn.get("source", "")
    if spec_name != brand:
        bad(f"SEO org · seo_spec.json organization.name {spec_name!r} ≠ CLAUDE.md {brand!r} "
            f"(l'input du générateur DÉRIVE de CLAUDE.md §Entités)")
    elif "CLAUDE.md" not in spec_src or brand not in spec_src:
        bad(f"SEO org · seo_spec.json organization.source ne DÉCLARE pas l'ancrage "
            f"CLAUDE.md/{brand!r} : {spec_src!r}")
    else:
        good(f"SEO org · seo_spec.json déclare l'ancrage CLAUDE.md §Entités pour {brand!r}")

# ============================================================================
# SEO · l'IDENTITÉ du CATALOGUE PROJETS (P01..P09) — les 9 projets (`code` +
# `nom`) que le bundle SEO expose à Google — ANCRÉE sur CLAUDE.md §Projets.
# ----------------------------------------------------------------------------
# La matière première du worker SEO est `seo/fixtures/projets_master.json`
# (README:22 « sortie canonique … le worker ne fabrique aucun fait de projet »).
# Chaque entrée DÉCLARE `source.fichiers: ["CLAUDE.md#Projets"]` et le README:80
# l'affirme (« 9 projets P01..P09 · noms sourcés de `CLAUDE.md §Projets` ») — MAIS
# AUCUN gate ne le VÉRIFIAIT. check_artifacts prouve QUE les out/ SE RECONSTRUISENT
# depuis la fixture (byte-for-byte), PAS que ses `code`/`nom` == CLAUDE.md §Projets ;
# les blocs SEO amont gatent les COMPTES de mots-clés + la COMPOSITION du graphe
# schema.org (bijection un-listing-par-projet) + l'IDENTITÉ de la marque racine
# (§Entités), tous AVEUGLES à l'identité de la LISTE de projets. Piège #6 :
# RENOMMER un projet dans CLAUDE.md §Projets (« Aqua Terra Las Terrenas » → « … Bay »),
# le RENOMMER/AJOUTER/RETIRER dans la fixture, puis régénérer, laisse le JSON-LD PUBLIC
# — celui que Google indexe sous `vente.otov7.com/projets/…` — lister un projet
# FANTÔME ou en OMETTRE un canonique pendant que la byte-gate reste VERTE (l'artefact
# se reconstruit fidèlement… depuis une fixture DÉRIVÉE) et que le compte reste 9 —
# « vert trompeur » de la MÊME classe que la marque org (§Entités), les entités
# CONFOTUR (§Entités) et les libellés projets du run_sheet démo (§Projets). Aucune
# suite tests/ (FONCTIONS de génération, jamais l'ancrage à CLAUDE.md) ne l'attrape.
# On RE-DÉRIVE le catalogue de CLAUDE.md §Projets (zéro duplication · source unique)
# puis on exige : (a) identité d'ensemble des codes fixture ⇔ CLAUDE.md (aucun
# fantôme/manquant · pas de doublon) ; (b) `nom` de chaque entrée == CLAUDE.md §Projets
# (accent-insensible · la fixture normalise « Xamaná »→« Xamana ») ; (c) chaque entrée
# DÉCLARE l'ancrage `CLAUDE.md#Projets` dans `source.fichiers` ; (d) les `name` des
# listings du graphe schema.org PUBLIC == l'ensemble des noms CLAUDE.md §Projets +
# bijection (1 listing/projet) + MANIFEST counts projects/listings == |§Projets| ;
# (e) le README cite l'ancrage CLAUDE.md §Projets. Un claim absent échoue AUSSI
# (disparition = régression). État courant : aucune valeur périmée.
# ============================================================================
_pnfc = lambda s: unicodedata.normalize("NFC", str(s))
_pai = lambda s: "".join(c for c in unicodedata.normalize("NFKD", str(s))
                        if unicodedata.category(c) != "Mn").lower().replace(" ", "")
try:
    cm_pj = open("CLAUDE.md", encoding="utf-8").read()
    _mpj = re.search(r"^## Projets\s*\n(.+)$", cm_pj, re.M)
    if _mpj is None:
        raise ValueError("section « ## Projets » INTROUVABLE")
    seo_pmap = {}
    for _t in _mpj.group(1).split("·"):
        _mc = re.match(r"(P\d{2})\s+(.+)$", _pnfc(_t.strip()))
        if _mc:
            seo_pmap[_mc.group(1)] = _mc.group(2).strip()
    if not seo_pmap:
        raise ValueError("aucun projet PNN parsé")
except (OSError, ValueError) as e:
    bad(f"SEO projets · CLAUDE.md §Projets illisible/inattendu : {e}"); seo_pmap = None
try:
    with open(f"{D}/seo/fixtures/projets_master.json", encoding="utf-8") as _f:
        seo_pm = json.load(_f)["projets"]
    sg_pj = load("seo/out/seo_schema_org.json")["@graph"]
    seo_cpj = load("seo/out/MANIFEST.json")["counts"]
    lst_pj = [n for n in sg_pj if not str(n.get("@id", "")).endswith("#organization")]
except (OSError, KeyError, ValueError, TypeError) as e:
    bad(f"SEO projets · fixture/artefact illisible/inattendu : {e}"); seo_pm = None
if seo_pmap is not None and seo_pm is not None:
    # (a) ANCRAGE : identité d'ensemble des codes fixture ⇔ CLAUDE.md §Projets
    fix_codes = [p.get("code") for p in seo_pm]
    if len(set(fix_codes)) != len(fix_codes) or None in fix_codes or "" in fix_codes:
        bad(f"SEO projets · projets_master : codes en doublon/vides {fix_codes}")
    fx = {c for c in fix_codes if c}
    miss = sorted(set(seo_pmap) - fx); extra = sorted(fx - set(seo_pmap))
    if miss or extra:
        bad(f"SEO projets · codes projets_master ≠ CLAUDE.md §Projets"
            f"{' · manquants=' + str(miss) if miss else ''}"
            f"{' · fantômes=' + str(extra) if extra else ''}")
    else:
        good(f"SEO projets · {len(fx)} codes P01..P09 projets_master == CLAUDE.md §Projets")
    # (b)+(c) nom ancré + déclaration de l'ancrage, par projet
    for p in seo_pm:
        code = p.get("code"); nom = _pnfc(p.get("nom", ""))
        anchor = seo_pmap.get(code)
        if anchor is None:
            continue  # code fantôme : déjà mordu par le set-diff (a)
        if _pai(nom) != _pai(anchor):
            bad(f"SEO projets · {code} nom « {nom} » (projets_master) DÉRIVE de "
                f"CLAUDE.md §Projets « {anchor} » → aligner (ou CLAUDE.md)")
        else:
            good(f"SEO projets · {code} — nom « {nom} » == CLAUDE.md §Projets")
        srcf = (p.get("source") or {}).get("fichiers") or []
        if not any("CLAUDE.md" in str(x) and "Projets" in str(x) for x in srcf):
            bad(f"SEO projets · {code} — projets_master ne DÉCLARE pas l'ancrage "
                f"`CLAUDE.md#Projets` dans source.fichiers : {srcf}")
    # (d) artefact PUBLIC : `name` des listings schema.org == noms CLAUDE.md §Projets
    if not lst_pj:
        bad("SEO projets · aucun nœud listing schema.org → graphe suspect")
    else:
        got_n = {_pai(n.get("name", "")) for n in lst_pj}
        want_n = {_pai(v) for v in seo_pmap.values()}
        gm = sorted(want_n - got_n); ge = sorted(got_n - want_n)
        if gm or ge:
            bad(f"SEO projets · `name` des listings schema.org (JSON-LD public) ≠ "
                f"CLAUDE.md §Projets{' · manquants=' + str(gm) if gm else ''}"
                f"{' · en trop=' + str(ge) if ge else ''}")
        elif len(lst_pj) != len(seo_pmap):
            bad(f"SEO projets · {len(lst_pj)} listing(s) schema.org MAIS "
                f"{len(seo_pmap)} projet(s) CLAUDE.md §Projets (bijection cassée)")
        else:
            good(f"SEO projets · {len(lst_pj)} listings schema.org (name) == CLAUDE.md "
                 f"§Projets (JSON-LD indexé fidèle au mandat)")
    if seo_cpj.get("projects") != len(seo_pmap) or seo_cpj.get("listings") != len(seo_pmap):
        bad(f"SEO projets · MANIFEST counts projects={seo_cpj.get('projects')}/"
            f"listings={seo_cpj.get('listings')} ≠ {len(seo_pmap)} (CLAUDE.md §Projets)")
    else:
        good(f"SEO projets · MANIFEST counts projects/listings == {len(seo_pmap)} (§Projets)")
    # (e) le README cite l'ancrage CLAUDE.md §Projets
    seo_p_rd = read_norm(f"{D}/seo/README.md")
    if seo_p_rd is not None:
        if re.search(r"CLAUDE\.md\s*§?\s*Projets", seo_p_rd):
            good("SEO projets · README cite l'ancrage CLAUDE.md §Projets")
        else:
            bad("SEO projets · README ne cite pas l'ancrage CLAUDE.md §Projets "
                "(attendu « noms sourcés de CLAUDE.md §Projets »)")

# ============================================================================
# SEO · la CARTE hreflang (`alternate` FR/EN/ES + `x-default` par page) — la
# 3ᵉ surface du même README SEO — décrite EN PROSE sans AUCUN gate d'IDENTITÉ.
# ----------------------------------------------------------------------------
# Le générateur SEO émet out/seo_hreflang.json (byte-gaté par check_artifacts ⇒
# faisant autorité) : une page par projet + accueil, chacune portant un ENSEMBLE
# d'`alternate` (un par langue de contenu) + un `x-default`. Le README:17 décrit
# cet ensemble À LA MAIN : « carte `alternate` FR/EN/ES + `x-default` par page ».
# Les deux blocs SEO existants gatent le COMPTE de mots-clés (keywords_total +
# ventilation par langue, artefact seo_keywords) et la composition du graphe
# schema.org (artefact seo_schema_org) — jamais le jeu de langues hreflang, qui
# vit dans un TROISIÈME artefact (seo_hreflang) au contenu distinct. Aucun gate
# ne comparait ces CODES à l'artefact : AJOUTER une langue au bundle (ex. `pt`
# → hreflang à 4 langues de contenu) OU RETIRER/ÉCHANGER `es` ferait dériver la
# prose en silence pendant que la carte byte-gatée dit autre chose — « vert
# trompeur » qu'aucune suite tests/ (qui teste des FONCTIONS de génération, pas
# la prose du README) n'attrape. Même patron que l'énumération des onglets
# Mobile, du catalogue custom RBAC ou des @type schema.org. On RECOMPUTE
# l'ENSEMBLE des langues de contenu depuis SON artefact byte-gaté (zéro
# duplication de la carte du générateur #6) et on exige que la prose les nomme
# EXACTEMENT (absent ET en trop via set-diff) + mentionne `x-default`. Cohérences
# croisées en bonus (mordent un artefact INTERNEMENT incohérent) : toutes les
# pages portent le MÊME jeu d'`alternate` (uniforme) ; `x-default` présent sur
# CHAQUE page ; la langue par défaut (`x_default_lang`) ∈ langues de contenu (ni
# vacante ni étrangère) ; pour chaque page `x-default`.href == `canonical` ==
# href de la langue par défaut (l'invariant « x-default = langue par défaut =
# canonical » du README:61). Un claim absent échoue AUSSI.
# ============================================================================
SEO_HL_RD = f"{D}/seo/README.md"
try:
    hl = load("seo/out/seo_hreflang.json")
    hl_pages = hl["pages"]
    x_default_lang = hl["x_default_lang"]
    page_sets = [frozenset(a["hreflang"] for a in p["alternates"]) for p in hl_pages]
    all_codes = set().union(*page_sets) if page_sets else set()
    content_langs = all_codes - {"x-default"}       # langues de contenu (hors x-default)
except (OSError, KeyError, ValueError, TypeError) as e:
    bad(f"SEO hreflang · artefact illisible/inattendu : {e}"); hl_pages = None
if hl_pages:
    # bonus 1 : toutes les pages portent le MÊME jeu d'alternate (uniforme)
    if len(set(page_sets)) != 1:
        bad(f"SEO hreflang · carte NON uniforme : {len(set(page_sets))} jeux distincts "
            f"d'`alternate` sur {len(hl_pages)} pages → une carte hétérogène")
    else:
        good(f"SEO hreflang · {len(hl_pages)} pages · jeu d'`alternate` uniforme "
             f"{sorted(all_codes)}")
    # bonus 2 : x-default sur chaque page + langue par défaut ∈ langues de contenu
    if "x-default" not in all_codes or any("x-default" not in s for s in page_sets):
        bad("SEO hreflang · `x-default` absent d'au moins une page → carte incomplète")
    elif x_default_lang not in content_langs:
        bad(f"SEO hreflang · x_default_lang={x_default_lang!r} ∉ langues de contenu "
            f"{sorted(content_langs)} → défaut vacant/étranger")
    else:
        good(f"SEO hreflang · `x-default` sur chaque page · défaut {x_default_lang!r} "
             f"∈ langues de contenu")
    # bonus 3 : href-cohérence — x-default == canonical == href(langue par défaut)
    incoh = []
    for p in hl_pages:
        alt = {a["hreflang"]: a["href"] for a in p["alternates"]}
        if not (alt.get("x-default") == p.get("canonical") == alt.get(x_default_lang)):
            incoh.append(p.get("page"))
    if incoh:
        bad(f"SEO hreflang · href incohérent (x-default ≠ canonical ≠ {x_default_lang}) "
            f"sur {incoh} → artefact interne incohérent")
    else:
        good(f"SEO hreflang · `x-default`.href == `canonical` == href({x_default_lang}) "
             f"(les {len(hl_pages)} pages)")
    # README:17 — la cellule « carte `alternate` <langues> + `x-default` par page »
    seo_hl_rd = read_norm(SEO_HL_RD)
    if seo_hl_rd is not None:
        m = re.search(r"carte `alternate` (.+?) par page", seo_hl_rd)
        if m is None:
            bad(f"SEO hreflang · {SEO_HL_RD} — carte « `alternate` … par page » "
                f"INTROUVABLE (attendu {sorted(content_langs)} + x-default)")
        else:
            seg = m.group(1)
            prose_langs = {t.lower() for t in re.findall(r"\b[A-Z]{2}\b", seg.split("+")[0])}
            has_xdef = "x-default" in seg
            if prose_langs == content_langs and has_xdef:
                good(f"SEO hreflang · {SEO_HL_RD} — langues {sorted(prose_langs)} + "
                     f"`x-default` == seo_hreflang")
            else:
                miss = sorted(content_langs - prose_langs)
                extra = sorted(prose_langs - content_langs)
                detail = (f" · absents={miss}" if miss else "") + \
                         (f" · en trop={extra}" if extra else "") + \
                         ("" if has_xdef else " · `x-default` non mentionné")
                bad(f"SEO hreflang · {SEO_HL_RD} — nomme {sorted(prose_langs)}"
                    f"{'+x-default' if has_xdef else ''} MAIS carte "
                    f"{sorted(content_langs)}+x-default{detail} → régénérer le README")

# ============================================================================
# SEO · FICHE AGENT (03_agents/seo/AGENT.md) · les cellules « Métrique vérifiée »
# restituent DEUX attributs data-derived du bundle SEO — gatées seulement côté
# README, JAMAIS sur la fiche.
# ----------------------------------------------------------------------------
# Le tableau « Livrable réellement produit » (fiche:24-26) transcrit À LA MAIN,
# à côté du compte de mots-clés (lui déjà gaté sur README **et** fiche via le
# bloc SEO_FI amont), deux sorties byte-gatées mais AVEUGLÉES côté fiche :
#   L25 « graphe JSON-LD : **10 nœuds** (`Organization` + 9 `Residence`) »
#   L26 « **10 pages** × FR/EN/ES + `x-default` ».
# Les blocs SEO schema.org / hreflang ci-dessus ne lisent QUE le README
# (SEO_SG_RD / SEO_HL_RD) : RENOMMER le @type des listings, AJOUTER/RETIRER un
# projet (→ N nœuds & N pages dérivent) ou ÉCHANGER une langue de contenu laisse
# ces cellules périmées pendant que les trois artefacts byte-gatés disent autre
# chose — « vert trompeur » de la classe des fiches-agent qui restituent des
# attributs data-derived gatés seulement en AGRÉGAT (mémoire
# agent-fiche-role-attrs-ungated ; même patron que la cellule chat_otoia de la
# fiche Frontend Console). Aucune suite tests/ (FONCTIONS du builder, jamais la
# prose de la fiche) ne l'attrape. On RECOMPUTE depuis les MÊMES artefacts
# byte-gatés (schema.org + hreflang) réutilisés par les blocs README ci-dessus
# (zéro duplication #6) et on exige l'IDENTITÉ EXACTE ; un claim absent échoue
# AUSSI (INTROUVABLE = régression #6). Cohérence arithmétique en bonus : total
# nœuds == 1 racine + M listings (un graphe interne incohérent est mordu).
# ============================================================================
seo_fi = read_norm(SEO_FI)              # SEO_FI défini au bloc « mots-clés » amont
if seo_fi is not None and sg is not None:
    # fiche:25 — « graphe JSON-LD : **N nœuds** (`<racine>` + M `<listing>`) »
    root_t = sorted(org_types)[0] if len(org_types) == 1 else None
    list_t = sorted(list_types)[0] if len(list_types) == 1 else None
    n_nodes = seo_cnt.get("schema_org_nodes")
    n_list = seo_cnt.get("listings")
    m = re.search(r"graphe JSON-LD : \*\*(\d+) nœuds\*\* "
                  r"\(`([A-Za-z]+)` \+ (\d+) `([A-Za-z]+)`\)", seo_fi)
    if m is None:
        bad(f"SEO fiche · {SEO_FI} — cellule « graphe JSON-LD : **N nœuds** "
            f"(`<racine>` + M `<listing>`) » INTROUVABLE (attendu {n_nodes} nœuds : "
            f"1 `{root_t}` + {n_list} `{list_t}`)")
    else:
        g_tot, g_root = int(m.group(1)), m.group(2)
        g_m, g_list = int(m.group(3)), m.group(4)
        errs = []
        if g_tot != n_nodes: errs.append(f"total {g_tot}≠{n_nodes}")
        if g_root != root_t: errs.append(f"racine `{g_root}`≠`{root_t}`")
        if g_m != n_list: errs.append(f"listings {g_m}≠{n_list}")
        if g_list != list_t: errs.append(f"type listing `{g_list}`≠`{list_t}`")
        if g_tot != 1 + g_m: errs.append(f"arith {g_tot}≠1+{g_m}")
        if errs:
            bad(f"SEO fiche · {SEO_FI}:25 — {' · '.join(errs)} → régénérer la fiche")
        else:
            good(f"SEO fiche · {SEO_FI}:25 — {g_tot} nœuds (1 `{g_root}` + {g_m} "
                 f"`{g_list}`) == seo_schema_org.@graph")
if seo_fi is not None and hl_pages:
    # fiche:26 — « **N pages** × <langues> + `x-default` »
    exp_langs = "/".join(c.upper() for c in sorted(content_langs))
    m = re.search(r"\*\*(\d+) pages\*\* × ([A-Z/]+) \+ `x-default`", seo_fi)
    if m is None:
        bad(f"SEO fiche · {SEO_FI} — cellule « **N pages** × <langues> + `x-default` » "
            f"INTROUVABLE (attendu {len(hl_pages)} pages × {exp_langs})")
    else:
        g_pages = int(m.group(1))
        g_langs = {t.lower() for t in m.group(2).split("/") if t}
        errs = []
        if g_pages != len(hl_pages): errs.append(f"pages {g_pages}≠{len(hl_pages)}")
        if g_langs != content_langs:
            miss = sorted(content_langs - g_langs); extra = sorted(g_langs - content_langs)
            errs.append(f"langues {sorted(g_langs)}≠{sorted(content_langs)}"
                        + (f" absents={miss}" if miss else "")
                        + (f" en trop={extra}" if extra else ""))
        if errs:
            bad(f"SEO fiche · {SEO_FI}:26 — {' · '.join(errs)} → régénérer la fiche")
        else:
            good(f"SEO fiche · {SEO_FI}:26 — {g_pages} pages × {exp_langs} + "
                 f"`x-default` == seo_hreflang")

# ============================================================================
# CRM/dossier_vente (2e surface) · CATALOGUE DE PROJETS « P01..P09 ancré sur
# CLAUDE.md » — le Select `projet` du DocType porteur.
# ----------------------------------------------------------------------------
# Le bloc CRM pipeline ci-dessus recompute le COMPTE d'états du même README
# (« les 9 états du pipeline »). Restait, dans le MÊME README, une surface
# data-derived distincte et jamais gatée : la liste des PROJETS. Le champ Select
# `projet` du DocType (out/doctype_oto_dossier_vente.json, byte-gaté par
# check_artifacts) porte l'ENSEMBLE ORDONNÉ des 9 projets OTO
# (« P01 Structure », « P02 Coral del Sur », …). Le README:46 le résume
# « Projet (`Select` **P01..P09** ancré sur CLAUDE.md) » et le `_comment` du
# doctype_spec.json le déclare « ancré sur CLAUDE.md … pas inventé ». Or ce
# catalogue est une TRANSCRIPTION MANUELLE : la section « ## Projets » de
# CLAUDE.md est la SOURCE FAISANT AUTORITÉ (contraintes #6/#7 · liste des
# projets), mais AUCUN gate ne compare les options du DocType à CLAUDE.md.
# check_artifacts ne prouve que DocType == spec (byte-for-byte) ; la spec
# elle-même recopie la liste — donc TOUTE la chaîne (spec → DocType) peut
# DÉRIVER de CLAUDE.md en restant byte-verte : RENOMMER un projet dans CLAUDE.md
# (« P03 Nakua » → « P03 Nakua Bay »), en AJOUTER un (P10) ou en RETIRER un
# laisse le Select périmé, contredisant silencieusement CLAUDE.md pendant que la
# prose « ancré sur CLAUDE.md » devient FAUSSE — « vert trompeur » qu'aucune
# suite tests/ (qui teste des FONCTIONS de builder, pas l'ancrage à CLAUDE.md)
# n'attrape. Même esprit que le roadmap-anchor (INV11) qui ancre les
# `roadmap_line` de l'artefact au fichier roadmap : ici on ANCRE le catalogue
# projet du DocType à CLAUDE.md. On RECOMPUTE le catalogue depuis « ## Projets »
# de CLAUDE.md (zéro duplication · source unique) et on exige que les options du
# Select l'égalent EXACTEMENT (absent ET en trop via set-diff, puis ORDRE), et
# que la prose porte la bonne plage P01..P<dernier> + le mot d'ancrage. Un claim
# absent échoue AUSSI. Cohérences croisées en bonus (mordent un catalogue
# CLAUDE.md internement incohérent) : catalogue NON VIDE, codes SANS DOUBLON et
# CONTIGUS depuis P01 (P01,P02,…,P0N — byte-déterminisme du contrat).
# ============================================================================
def _nfc(s):
    return unicodedata.normalize("NFC", s)
try:
    cm_raw = open("CLAUDE.md", encoding="utf-8").read()
    mcm = re.search(r"^## Projets\s*\n(.+)$", cm_raw, re.M)
    if mcm is None:
        raise ValueError("section « ## Projets » INTROUVABLE")
    # catalogue faisant autorité = tokens « PNN Label » séparés par « · »
    cat = [_nfc(t.strip()) for t in mcm.group(1).split("·") if t.strip()]
    cat_codes = [re.match(r"(P\d{2})\b", p).group(1) for p in cat]
except (OSError, ValueError, AttributeError) as e:
    bad(f"CRM projets · CLAUDE.md « ## Projets » illisible/inattendu : {e}"); cat = None
if cat is not None:
    # bonus : catalogue non vide · codes sans doublon · contigus depuis P01
    contig = cat_codes == [f"P{i:02d}" for i in range(1, len(cat_codes) + 1)]
    if not cat or len(set(cat_codes)) != len(cat_codes) or not contig:
        bad(f"CRM projets · catalogue CLAUDE.md dégénéré : codes={cat_codes} "
            f"(attendu P01..P{len(cat_codes):02d} contigus, sans doublon, non vide)")
    else:
        good(f"CRM projets · catalogue CLAUDE.md sain : {len(cat)} projets "
             f"P01..{cat_codes[-1]} contigus")
    # 1) ANCRAGE : options du Select `projet` du DocType == catalogue CLAUDE.md
    try:
        dv_dt = load("crm/dossier_vente/out/doctype_oto_dossier_vente.json")
        pf = next(f for f in dv_dt["fields"] if f.get("fieldname") == "projet")
        dt_opts = [_nfc(o.strip()) for o in pf["options"].split("\n") if o.strip()]
    except (OSError, KeyError, ValueError, StopIteration) as e:
        bad(f"CRM projets · DocType Select `projet` illisible/inattendu : {e}"); dt_opts = None
    if dt_opts is not None:
        if dt_opts == cat:
            good(f"CRM projets · Select `projet` du DocType == CLAUDE.md "
                 f"({len(cat)} projets, ordre inclus)")
        elif set(dt_opts) == set(cat):
            bad(f"CRM projets · Select `projet` porte le bon ENSEMBLE mais un ORDRE "
                f"≠ CLAUDE.md : DocType={dt_opts} vs {cat} → régénérer le DocType")
        else:
            miss = [p for p in cat if p not in dt_opts]
            extra = [p for p in dt_opts if p not in cat]
            detail = (f" · absents={miss}" if miss else "") + \
                     (f" · en trop={extra}" if extra else "")
            bad(f"CRM projets · Select `projet` du DocType DÉRIVE de CLAUDE.md"
                f"{detail} → régénérer le DocType (ou aligner CLAUDE.md)")
    # 2) PROSE : README résume la plage P01..P<dernier> + ancrage CLAUDE.md
    dv_rd2 = read_norm(f"{D}/crm/dossier_vente/README.md")
    if dv_rd2 is not None:
        mp = re.search(r"Projet \(`Select` \*\*(P\d{2})\.\.(P\d{2})\*\* "
                       r"ancré sur CLAUDE\.md\)", dv_rd2)
        if mp is None:
            bad(f"CRM projets · {D}/crm/dossier_vente/README.md — claim « Projet "
                f"(`Select` P01..P{cat_codes[-1][1:]} ancré sur CLAUDE.md) » INTROUVABLE")
        elif (mp.group(1), mp.group(2)) == (cat_codes[0], cat_codes[-1]):
            good(f"CRM projets · README plage {mp.group(1)}..{mp.group(2)} "
                 f"+ ancrage CLAUDE.md == catalogue")
        else:
            bad(f"CRM projets · README dit {mp.group(1)}..{mp.group(2)} MAIS catalogue "
                f"CLAUDE.md va de {cat_codes[0]} à {cat_codes[-1]} → régénérer le README")

# ============================================================================
# Portails frontend (2e surface) · CATALOGUE des DocTypes `custom` à CRÉER avant
# import — la consigne de hand-off VPS énumérée À LA MAIN, sans gate d'IDENTITÉ.
# ----------------------------------------------------------------------------
# Le bloc portails ci-dessus recompute les COMPTES par workspace (cartes/liens/
# rôles) et la table portail⇔workspace. Restait, dans le MÊME README, une surface
# data-derived DISTINCTE et jamais gatée : le §« Hand-off → agent ERPNext Backend »
# item 2 énumère « DocTypes custom à créer avant import : `CONFOTUR Application`,
# `Faisabilité`, `Publiciste Log` (… le reste est natif v15) ». Ce catalogue est
# DATA-DERIVED : c'est out/MANIFEST.json `doctypes_custom_a_creer` (byte-gaté par
# check_artifacts) = l'UNION des `doctypes_custom` par workspace, eux-mêmes lus du
# flag `custom` du contrat rbac_50_roles.json pour les seuls DocTypes visés par une
# carte/un lien du portail. C'est un ensemble PROPRE au module portails (sous-
# ensemble de celui de rbac/fixtures_gen : ici seuls comptent les DocTypes custom
# effectivement CÂBLÉS dans un workspace — API Access, custom mais non câblé, en est
# ABSENT). check_artifacts ne prouve QUE MANIFEST==build (byte-for-byte), et le
# build RECOMPUTE l'union — donc AJOUTER une carte visant un nouveau DocType custom
# au contrat (le MANIFEST rebâtit le catalogue) · en RENOMMER un · en RETIRER un du
# câblage laisse la prose de hand-off périmée pendant que l'artefact dit autre
# chose : l'agent Backend créerait le mauvais jeu de DocTypes (liens pointant dans
# le vide — le risque même que la consigne veut prévenir) — « vert trompeur »
# qu'aucune suite tests/ (qui teste des FONCTIONS de mapping, pas la prose de hand-
# off) n'attrape. Même patron que le catalogue custom de rbac/fixtures_gen. On
# RECOMPUTE le catalogue depuis MANIFEST (zéro duplication du contrat du générateur
# #6) et on exige que la prose l'énumère EXACTEMENT (absent ET en trop via set-diff ·
# accents/casse normalisés). Cohérences croisées en bonus (mordent un MANIFEST
# INTERNEMENT incohérent) : catalogue NON VIDE, sans doublon et TRIÉ (byte-
# déterminisme) · top-level == UNION des `doctypes_custom` par workspace (pas de
# DocType custom câblé mais oublié du catalogue de hand-off, ni l'inverse). Un claim
# absent échoue AUSSI (traçabilité).
# ============================================================================
PC_RD = f"{D}/frontend/portails/README.md"
def _pc_acc(s):
    return "".join(c for c in unicodedata.normalize("NFKD", s)
                   if unicodedata.category(c) != "Mn").strip().lower()
try:
    pc_man = load("frontend/portails/out/MANIFEST.json")
    pc_cat = pc_man["doctypes_custom_a_creer"]
    pc_union = sorted({d for w in pc_man["workspaces"]
                       for d in w.get("doctypes_custom", [])})
except (OSError, KeyError, ValueError, TypeError) as e:
    bad(f"Portails catalogue · MANIFEST illisible/inattendu : {e}"); pc_cat = None
if pc_cat is not None:
    # bonus : le catalogue est un vrai ENSEMBLE trié non vide (byte-déterminisme)
    if not pc_cat:
        bad("Portails catalogue · doctypes_custom_a_creer VIDE → aucun DocType custom "
            "câblé dans un workspace ? (contrat/spec suspect)")
    elif pc_cat != sorted(set(pc_cat)):
        bad(f"Portails catalogue · doctypes_custom_a_creer {pc_cat} non trié/dédupliqué "
            f"→ générateur non déterministe (attendu {sorted(set(pc_cat))})")
    else:
        good(f"Portails catalogue · custom à créer {pc_cat} — ensemble trié, sans doublon")
    # bonus : cohérence interne — catalogue == union des doctypes_custom par workspace
    if sorted(set(pc_cat)) != pc_union:
        miss = sorted(set(pc_union) - set(pc_cat)); extra = sorted(set(pc_cat) - set(pc_union))
        detail = (f" · câblés hors catalogue={miss}" if miss else "") + \
                 (f" · au catalogue mais non câblés={extra}" if extra else "")
        bad(f"Portails catalogue · doctypes_custom_a_creer {sorted(pc_cat)} ≠ UNION des "
            f"doctypes_custom par workspace {pc_union}{detail} → MANIFEST incohérent")
    else:
        good(f"Portails catalogue · catalogue == union des doctypes_custom par workspace "
             f"({pc_union})")
    pc_rd = read_norm(PC_RD)
    if pc_rd is not None:
        # README §Hand-off item 2 — énumération du catalogue custom avant « (issus »
        mp = re.search(r"custom à créer\*\* avant import[^:]*?: (.+?)\(issus", pc_rd)
        if mp is None:
            bad(f"Portails catalogue · {PC_RD} — énumération « DocTypes custom à créer … » "
                f"INTROUVABLE (attendu {sorted(pc_cat)})")
        else:
            got = {_pc_acc(t) for t in re.findall(r"`([^`]+)`", mp.group(1))}
            exp = {_pc_acc(x) for x in pc_cat}
            if got == exp:
                good(f"Portails catalogue · {PC_RD} — hand-off {sorted(pc_cat)} == "
                     f"MANIFEST.doctypes_custom_a_creer")
            else:
                miss = sorted(exp - got); extra = sorted(got - exp)
                detail = (f" · absents={miss}" if miss else "") + \
                         (f" · en trop={extra}" if extra else "")
                bad(f"Portails catalogue · {PC_RD} — hand-off énumère {sorted(got)} MAIS "
                    f"MANIFEST {sorted(exp)}{detail} → régénérer le README")

# ============================================================================
# Faisabilité/generator · PARAMÈTRES CANONIQUES « ancrés sur CLAUDE.md #9/#10 »
# — les 6 valeurs imposées à CHAQUE faisabilité (jamais issues du brief).
# ----------------------------------------------------------------------------
# Le générateur 4 volets tient sa garantie anti-invention (#6) d'un jeu de
# PARAMÈTRES CANONIQUES émis TELS QUELS pour tous les projets : frais d'édition
# 3 %, marketing 8.5 %, point d'équilibre 52 % (CLAUDE.md #9) · devises USD+DOP,
# format Letter US, paiement Cardnet (CLAUDE.md #10). Ces 6 valeurs sont la
# SOURCE FAISANT AUTORITÉ = les contraintes NON-NÉGOCIABLES #9/#10 de CLAUDE.md.
# Elles sont RECOPIÉES à deux endroits, tous deux NON gatés :
#   (a) genlib/model.py `CANONICAL` — le dict que le générateur émet dans CHAQUE
#       rendu (n'est PAS un out/*.json → invisible à check_artifacts) ;
#   (b) README:28 « 3 % édition · 8.5 % marketing · 52 % point d'équilibre ·
#       USD+DOP · Cardnet · Letter US, CLAUDE.md #9/#10 » — la prose du module.
# Le SEUL contrôle existant (tests/test_generator.py::test_canonical_params…)
# assert que le RENDU contient les marqueurs « 3 % », « 52 % »… — mais ces
# marqueurs sont HARDCODÉS DANS LE TEST, une copie de plus, JAMAIS comparée à
# CLAUDE.md. Donc si la contrainte #9 change (ex. point d'équilibre 52 %→55 %),
# model.py, le test ET le README restent « 52 % » : tous internement cohérents,
# TOUS contredisant CLAUDE.md — le générateur émettrait alors 52 % dans chaque
# faisabilité alors que le mandat dit 55 % : une invention silencieuse de la
# classe même que #6 interdit, qu'aucune suite tests/ (qui teste des FONCTIONS,
# pas l'ancrage à CLAUDE.md) n'attrape. Même esprit que le roadmap-anchor (INV11)
# et le catalogue projets dossier_vente : on ANCRE les consommateurs à CLAUDE.md.
# On RECOMPUTE les 6 valeurs depuis les lignes #9/#10 de CLAUDE.md (zéro
# duplication · source unique) et on exige que (1) model.py `CANONICAL` les
# égale EXACTEMENT (% par valeur numérique · textes accent/espace-insensibles)
# et (2) la prose README les énumère EXACTEMENT (set-diff : absent ET en trop).
# Un claim absent échoue AUSSI (traçabilité).
# ============================================================================
import ast
def _ai(s):  # accent-insensible, minuscule, sans espaces (comparaison de tokens)
    return "".join(c for c in unicodedata.normalize("NFKD", s)
                   if unicodedata.category(c) != "Mn").lower().replace(" ", "")
def _pnorm(v):  # 3.0 → "3%" · 8.5 → "8.5%" (format déterministe)
    return f"{'%g' % v}%"
def _pct(s):  # "3 %"/"52%"/… → 3.0/52.0
    mm = re.search(r"(\d+(?:\.\d+)?)\s*%", s)
    return float(mm.group(1)) if mm else None
try:
    cm_raw2 = open("CLAUDE.md", encoding="utf-8").read()
    l9 = re.search(r"^9\.\s+(.+)$", cm_raw2, re.M)
    l10 = re.search(r"^10\.\s+(.+)$", cm_raw2, re.M)
    if not l9 or not l10:
        raise ValueError("contrainte #9 ou #10 INTROUVABLE")
    # #9 : trois pourcentages, associés par mot-clé (accent-insensible)
    seg9 = re.split(r"·", l9.group(1))
    def _pct_kw(kw):
        for s in seg9:
            if kw in _ai(s):
                return _pct(s)
        return None
    exp = {
        "frais_edition_pct": _pct_kw("frais"),
        "marketing_pct": _pct_kw("marketing"),
        "point_equilibre_pct": _pct_kw("equilibre"),
    }
    # #10 : trois valeurs en gras, associées par leur rôle (devises/format/paiement)
    seg10 = re.split(r"·", l10.group(1))
    def _bold_kw(kw):
        for s in seg10:
            if kw in _ai(s):
                mm = re.search(r"\*\*(.+?)\*\*", s)
                return mm.group(1).strip() if mm else None
        return None
    exp["devises"] = _bold_kw("devise")
    exp["format_doc"] = _bold_kw("format")
    exp["paiement"] = _bold_kw("paiement")
    if None in exp.values():
        raise ValueError(f"valeurs canoniques illisibles dans #9/#10 : {exp}")
except (OSError, ValueError) as e:
    bad(f"Faisabilité canoniques · CLAUDE.md #9/#10 illisible/inattendu : {e}"); exp = None
if exp is not None:
    good(f"Faisabilité canoniques · CLAUDE.md #9/#10 sain : {_pnorm(exp['frais_edition_pct'])} "
         f"édition · {_pnorm(exp['marketing_pct'])} mkt · {_pnorm(exp['point_equilibre_pct'])} "
         f"équilibre · {exp['devises']} · {exp['format_doc']} · {exp['paiement']}")
    _num_keys = ("frais_edition_pct", "marketing_pct", "point_equilibre_pct")
    _txt_keys = ("devises", "format_doc", "paiement")
    # 1) ANCRAGE : genlib/model.py CANONICAL == CLAUDE.md #9/#10
    MP = f"{D}/faisabilite/generator/genlib/model.py"
    try:
        msrc = open(MP, encoding="utf-8").read()
        mm = re.search(r"CANONICAL\s*=\s*(\{.*?\})", msrc, re.S)
        canon = ast.literal_eval(mm.group(1))
    except (OSError, ValueError, SyntaxError, AttributeError) as e:
        bad(f"Faisabilité canoniques · {MP} CANONICAL illisible/inattendu : {e}"); canon = None
    if canon is not None:
        drift = []
        for k in _num_keys:
            g = _pct(str(canon.get(k, "")))
            if g is None or g != exp[k]:
                drift.append(f"{k}={canon.get(k)!r} (attendu {_pnorm(exp[k])})")
        for k in _txt_keys:
            if _ai(str(canon.get(k, ""))) != _ai(exp[k]):
                drift.append(f"{k}={canon.get(k)!r} (attendu {exp[k]!r})")
        if drift:
            bad(f"Faisabilité canoniques · model.py CANONICAL DÉRIVE de CLAUDE.md #9/#10 : "
                f"{' · '.join(drift)} → aligner model.py (ou CLAUDE.md)")
        else:
            good("Faisabilité canoniques · model.py CANONICAL == CLAUDE.md #9/#10 (6 valeurs)")
    # 2) PROSE : README:28 énumère les 6 valeurs EXACTEMENT (absent ET en trop)
    FG_RD = f"{D}/faisabilite/generator/README.md"
    fg_rd = read_norm(FG_RD)
    if fg_rd is not None:
        mr = re.search(r"param[eè]tres canoniques\*\* \((.+?), CLAUDE\.md", fg_rd)
        if mr is None:
            bad(f"Faisabilité canoniques · {FG_RD} — énumération « paramètres canoniques "
                f"(… CLAUDE.md #9/#10) » INTROUVABLE")
        else:
            got = set()
            for tok in re.split(r"·", mr.group(1)):
                p = _pct(tok)
                got.add(_pnorm(p) if p is not None else _ai(tok))
            want = {_pnorm(exp[k]) for k in _num_keys} | {_ai(exp[k]) for k in _txt_keys}
            if got == want:
                good(f"Faisabilité canoniques · {FG_RD} — prose énumère les 6 valeurs == "
                     f"CLAUDE.md #9/#10")
            else:
                miss = sorted(want - got); extra = sorted(got - want)
                detail = (f" · absents={miss}" if miss else "") + \
                         (f" · en trop={extra}" if extra else "")
                bad(f"Faisabilité canoniques · {FG_RD} — prose énumère {sorted(got)} MAIS "
                    f"CLAUDE.md {sorted(want)}{detail} → régénérer le README")

# ============================================================================
# Faisabilité/generator (2e surface du MÊME module) · l'ORACLE D'ENFORCEMENT
# `_CANONICAL_MARKERS` + les DEUX docstrings du scorer + l'oracle du TEST — trois
# copies DE PLUS des 6 marqueurs canoniques, ancrées sur CLAUDE.md #9/#10.
# ----------------------------------------------------------------------------
# Le bloc ci-dessus ancre model.py `CANONICAL` + le README à CLAUDE.md #9/#10.
# Restent, dans le MÊME module, TROIS surfaces data-derived qui RECOPIENT les
# mêmes 6 marqueurs, toutes NON gatées :
#   (c) genlib/renderer.py `_CANONICAL_MARKERS` — la liste que `check_canonical()`
#       PARCOURT pour PROUVER que le rendu du volet ingénierie contient les 6
#       marqueurs (l'oracle de l'« axe 4 honnêteté »). C'est l'enforcement même :
#       si cette liste DÉRIVE de CANONICAL (donc de #9/#10), le contrôle devient
#       FAUX — soit il exige un marqueur périmé (« Cardnet » alors que le rendu,
#       issu d'un CANONICAL mis à jour, porterait « Azul » → faux rouge), soit on
#       en retire un et le contrôle NE VÉRIFIE PLUS RIEN (vert vacux).
#   (d) genlib/scorer.py — DEUX docstrings (barème axe 4 en tête · fonction
#       `_score_parametres_canoniques`) énumèrent « 3 % / 8.5 % / 52 % · USD+DOP ·
#       Letter US · Cardnet ». Prose recopiée, jamais confrontée à #9/#10.
#   (e) tests/test_generator.py — la liste `for marker in [...]` : l'oracle du
#       test lui-même. Le commentaire du bloc ci-dessus (« ces marqueurs sont
#       HARDCODÉS DANS LE TEST, une copie de plus, JAMAIS comparée à CLAUDE.md »)
#       NOMMAIT ce défaut sans le gater — on ferme la boucle ici.
# La suite tests/ teste des FONCTIONS (dont `check_canonical` — AVEC son propre
# oracle hardcodé), jamais l'ancrage à CLAUDE.md. Même classe EXACTE que le tuple
# d'oracle du test publiciste/branding ancré sur #4. On RÉUTILISE `exp` (6 valeurs
# recomputées de #9/#10 ci-dessus · zéro duplication) : les LISTES (c)/(e) sont
# parsées puis comparées à l'ensemble EXACT (set-diff : absent ET en trop) ; les
# DOCSTRINGS (d) doivent porter CHAQUE valeur normalisée (drift d'un marqueur ⇒
# ROUGE). Un claim/ligne absent échoue AUSSI (traçabilité).
# ============================================================================
if exp is not None:
    _want6 = {_pnorm(exp[k]) for k in _num_keys} | {_ai(exp[k]) for k in _txt_keys}
    def _norm_marker(tok):
        p = _pct(tok)
        return _pnorm(p) if p is not None else _ai(tok)
    def _list_vs_want6(path, var_re, label):
        try:
            src = open(path, encoding="utf-8").read()
            m = re.search(var_re, src, re.S)
            items = ast.literal_eval(m.group(1))
        except (OSError, ValueError, SyntaxError, AttributeError) as e:
            bad(f"Faisabilité canoniques · {path} {label} illisible/inattendu : {e}")
            return
        got = {_norm_marker(x) for x in items}
        if len(got) != len(items):
            bad(f"Faisabilité canoniques · {path} — {label} contient un DOUBLON ({items})")
        elif got == _want6:
            good(f"Faisabilité canoniques · {path} — {label} == CLAUDE.md #9/#10 (6 marqueurs)")
        else:
            miss = sorted(_want6 - got); extra = sorted(got - _want6)
            detail = (f" · absents={miss}" if miss else "") + \
                     (f" · en trop={extra}" if extra else "")
            bad(f"Faisabilité canoniques · {path} — {label} {sorted(got)} MAIS "
                f"CLAUDE.md {sorted(_want6)}{detail} → aligner (ou CLAUDE.md)")
    # (c) l'oracle d'enforcement renderer._CANONICAL_MARKERS
    _list_vs_want6(f"{D}/faisabilite/generator/genlib/renderer.py",
                   r"_CANONICAL_MARKERS\s*=\s*(\[.*?\])",
                   "_CANONICAL_MARKERS (oracle check_canonical)")
    # (e) l'oracle du test tests/test_generator.py (for marker in [...])
    _list_vs_want6(f"{D}/faisabilite/generator/tests/test_generator.py",
                   r"for marker in (\[[^\]]*\])",
                   "oracle du test check_canonical")
    # (d) les DEUX docstrings du scorer : chaque valeur normalisée présente dans la ligne
    SC = f"{D}/faisabilite/generator/genlib/scorer.py"
    try:
        sc_lines = open(SC, encoding="utf-8").read().splitlines()
    except OSError as e:
        bad(f"Faisabilité canoniques · {SC} illisible : {e}"); sc_lines = None
    if sc_lines is not None:
        # ligne d'énumération = contient un « % » ET « USD » (barème axe 4 + fonction)
        enum = [ln for ln in sc_lines if "%" in ln and "USD" in ln]
        if len(enum) < 2:
            bad(f"Faisabilité canoniques · {SC} — attendu ≥2 énumérations des 6 marqueurs "
                f"(barème axe 4 + _score_parametres_canoniques), trouvé {len(enum)}")
        else:
            for ln in enum:
                ai = _ai(ln)  # espaces/accents retirés ⇒ « USD+DOP » == « USD + DOP »
                miss = sorted(v for v in _want6 if v not in ai)
                if miss:
                    bad(f"Faisabilité canoniques · {SC} — docstring « {ln.strip()[:56]}… » "
                        f"n'énumère plus {miss} (attendu les 6 de CLAUDE.md #9/#10)")
                else:
                    good(f"Faisabilité canoniques · {SC} — docstring énumère les 6 marqueurs "
                         f"== CLAUDE.md #9/#10")

# ============================================================================
# Fiches agents · CITATION des constantes CLAUDE.md #10 (devises/format/paiement)
# — même classe d'ANCRAGE que le bloc « paramètres canoniques » ci-dessus, mais
# sur une SURFACE distincte : la prose « Anti-invention » des fiches agents.
# ----------------------------------------------------------------------------
# Deux fiches RECOPIENT à la main les valeurs canoniques de CLAUDE.md #10
# (**USD + DOP** devises · **Letter US** format · **Cardnet** paiements, pas
# Stripe) en AFFIRMANT « cités depuis CLAUDE.md #10, jamais réinventés » :
#   • 03_agents/erpnext_backend/AGENT.md:57 — devises **USD + DOP**, format
#     **Letter US**, paiements **Cardnet** (pas Stripe)
#   • 03_agents/crm/AGENT.md:37 — devises **USD + DOP** et format **Letter US**
# La prose PRÉTEND être une CITATION de #10, mais AUCUN gate ne la liait à #10 :
# le bloc « paramètres canoniques » ci-dessus n'ancre QUE genlib/model.py
# CANONICAL + le README generator — AVEUGLE à ces deux fiches. Piège #6 : RENOMMER
# le prestataire de paiement dans CLAUDE.md #10 (`Cardnet` → `Azul`) ou changer une
# devise/format laisse ces citations PÉRIMÉES en silence, contredisant le mandat
# pendant que la prose se réclame de « CLAUDE.md #10 » — « vert trompeur » qu'aucune
# suite tests/ (FONCTIONS, jamais l'ancre à CLAUDE.md) n'attrape. On RÉUTILISE les
# valeurs `exp` déjà recomputées depuis #10 (zéro duplication) + le prestataire
# EXCLU (`(pas X)`), et on exige de CHAQUE fiche les tokens en gras qu'elle cite
# EXACTEMENT + l'ancre « CLAUDE.md #10 ». Chaque fiche cite un SOUS-ENSEMBLE
# distinct (crm omet le paiement) → gate par-fiche ce que la fiche énonce.
# ============================================================================
if exp is not None:
    _m_excl = re.search(r"\(pas\s+(.+?)\)", l10.group(1))
    exp_excl = _m_excl.group(1).strip() if _m_excl else None
    _lab10 = {"devises": "devises", "format_doc": "format", "paiement": "paiement"}
    # (fiche, [clés #10 citées], cite l'exclusion « (pas X) » ?, libellé)
    FICHE10 = [
        ("03_agents/erpnext_backend/AGENT.md",
         ["devises", "format_doc", "paiement"], True, "fiche ERPNext Backend:57"),
        ("03_agents/crm/AGENT.md",
         ["devises", "format_doc"], False, "fiche CRM:37"),
    ]
    for path, keys, cites_excl, label in FICHE10:
        txt = read_norm(path)
        if txt is None:
            continue
        # ancrage : la citation DOIT se réclamer de CLAUDE.md #10
        if not re.search(r"CLAUDE\.md #10", txt):
            bad(f"Fiches #10 · {label} ({path}) — ancre « CLAUDE.md #10 » "
                f"INTROUVABLE → la citation ne se réclame plus de la contrainte")
            continue
        drift = []
        for k in keys:
            # token en gras **<valeur>** == valeur recomputée de #10 (casse tolérée)
            if not re.search(r"\*\*\s*" + re.escape(exp[k]) + r"\s*\*\*",
                             txt, re.IGNORECASE):
                drift.append(f"{_lab10[k]} attendu **{exp[k]}**")
        if cites_excl and exp_excl is not None:
            if not re.search(r"\(pas\s+" + re.escape(exp_excl) + r"\)",
                             txt, re.IGNORECASE):
                drift.append(f"exclusion attendue « (pas {exp_excl}) »")
        if drift:
            bad(f"Fiches #10 · {label} ({path}) — citation DÉRIVE de CLAUDE.md #10 : "
                f"{' · '.join(drift)} → aligner la fiche (ou CLAUDE.md)")
        else:
            _cited = " · ".join(f"**{exp[k]}**" for k in keys) + \
                     (f" · (pas {exp_excl})" if cites_excl and exp_excl else "")
            good(f"Fiches #10 · {label} — cite {_cited} == CLAUDE.md #10")

# ============================================================================
# Faisabilité/generator · DOMAINE DES CODES PROJET (P01..P09) ancré sur §Projets
# — même classe d'ANCRAGE que le CONTRAT DE FORMAT e-NCF (`ncf.py`) : une GARDE
# DE VALIDATION en CODE qui encode un domaine constitutionnel SANS l'ancrer.
# ----------------------------------------------------------------------------
# Le générateur 4 volets (Livrable Sprint 3 · « P07 Aqua Terra <1h ») REFUSE tout
# brief dont le `projet` n'est pas un code canonique. Le DOMAINE des codes valides
# EST exactement l'ensemble des projets de CLAUDE.md §Projets (P01..P09). Il est
# recopié À LA MAIN à QUATRE surfaces, AUCUNE ancrée sur §Projets, toutes
# invisibles à `check_artifacts` (aucune n'est un `out/*.json`) :
#   (a) `faisabilite/generator/faisabilite_gen.py::_validate_brief` — la GARDE
#       runtime `code[:2] == "P0" and code[2] in "123456789"` (le domaine effectif)
#       + le message `attendu P01..P09` (les bornes) ;
#   (b) `faisabilite/version.schema.json` — `properties.projet.pattern "^P0[1-9]$"`
#       (validation JSON du `version.json` par projet) + sa `description` ;
#   (c) `faisabilite/generator/brief.schema.json` — même `pattern "^P0[1-9]$"`
#       (validation JSON du brief d'entrée) + sa `description`.
# « Vert trompeur » : si CLAUDE.md §Projets AJOUTE un 10ᵉ projet (`P10 …`, évènement
# de portefeuille réel), la GARDE et les DEUX patterns REJETTENT `P10` comme
# « invalide (attendu P01..P09) » → le générateur REFUSE de scaffolder un projet
# LÉGITIMEMENT mandaté (bug d'exécution) pendant que la suite `tests/` reste VERTE
# (ses oracles P01/P02 sont self-consistants, jamais comparés à §Projets). Ni
# `check_artifacts` (schémas/`.py` ne sont pas des `out/*.json`) ni `tests/` (teste
# la FONCTION `_validate_brief`, pas son ANCRAGE) ne mordent. Symétriquement,
# RETIRER un projet de §Projets laisse la garde trop PERMISSIVE (accepte un code
# démobilisé). On RE-DÉRIVE le domaine de §Projets et on exige que chaque surface
# accepte EXACTEMENT cet ensemble (test COMPORTEMENTAL du pattern, robuste à la
# forme de la classe de caractères), + la déclaration de l'ancrage « §Projets ».
# ----------------------------------------------------------------------------
try:
    _fcm = open("CLAUDE.md", encoding="utf-8").read()
    _fmp = re.search(r"^## Projets\s*\n(.+)$", _fcm, re.M)
    if _fmp is None:
        raise ValueError("section « ## Projets » INTROUVABLE")
    fpj_codes = sorted(set(re.findall(r"\bP\d{2,}\b", _fmp.group(1))))
    if not fpj_codes:
        raise ValueError("aucun code projet PNN parsé")
except (OSError, ValueError) as e:
    bad(f"Faisabilité domaine · CLAUDE.md §Projets illisible/inattendu : {e}")
    fpj_codes = None

if fpj_codes is not None:
    fpj_set = set(fpj_codes); fpj_mn, fpj_mx = fpj_codes[0], fpj_codes[-1]
    # (a) la garde runtime _validate_brief : préfixe + ensemble de chiffres + message
    FG = f"{D}/faisabilite/generator/faisabilite_gen.py"
    try:
        _fg = open(FG, encoding="utf-8").read()
    except OSError as e:
        _fg = None; bad(f"Faisabilité domaine · {FG} illisible : {e}")
    if _fg is not None:
        _mpre = re.search(r'code\[:2\]\s*==\s*"([^"]+)"', _fg)
        _mdig = re.search(r'code\[2\]\s*in\s*"([^"]+)"', _fg)
        if not (_mpre and _mdig):
            bad("Faisabilité domaine · _validate_brief : préfixe/ensemble-chiffres du "
                "code projet INTROUVABLE (ancre morte) → la garde a changé de forme")
        else:
            _accept = {f"{_mpre.group(1)}{d}" for d in _mdig.group(1)}
            _miss = sorted(fpj_set - _accept); _extra = sorted(_accept - fpj_set)
            if _miss or _extra:
                bad(f"Faisabilité domaine · _validate_brief accepte {sorted(_accept)} ≠ "
                    f"CLAUDE.md §Projets{' · manquants=' + str(_miss) if _miss else ''}"
                    f"{' · en trop=' + str(_extra) if _extra else ''} → aligner la garde "
                    f"(ou CLAUDE.md §Projets)")
            else:
                good(f"Faisabilité domaine · _validate_brief accepte exactement les "
                     f"{len(_accept)} codes {fpj_mn}..{fpj_mx} de CLAUDE.md §Projets")
        _mmsg = re.search(r'brief\.projet invalide \(attendu ([^)]+)\)', _fg)
        if not _mmsg:
            bad("Faisabilité domaine · message « brief.projet invalide (attendu …) » "
                "INTROUVABLE (ancre morte)")
        elif fpj_mn not in _mmsg.group(1) or fpj_mx not in _mmsg.group(1):
            bad(f"Faisabilité domaine · message « attendu {_mmsg.group(1)} » ne cite pas "
                f"les bornes {fpj_mn}..{fpj_mx} de CLAUDE.md §Projets")
        else:
            good(f"Faisabilité domaine · message d'erreur cite les bornes "
                 f"{fpj_mn}..{fpj_mx} == CLAUDE.md §Projets")
    # (b)+(c) les 2 schémas JSON : le pattern accepte EXACTEMENT §Projets (comportemental)
    _probe = fpj_set | {"P00", "P10", "P99", "P0", "PXX", "P1", "P010", "Q01"}
    for _rel in ("version.schema.json", "generator/brief.schema.json"):
        SP = f"{D}/faisabilite/{_rel}"
        try:
            _sj = json.load(open(SP, encoding="utf-8"))
        except (OSError, ValueError) as e:
            bad(f"Faisabilité domaine · {_rel} illisible/inattendu : {e}"); continue
        _prop = (_sj.get("properties") or {}).get("projet") or {}
        _pat = _prop.get("pattern")
        if not _pat:
            bad(f"Faisabilité domaine · {_rel} : properties.projet.pattern INTROUVABLE")
        else:
            try:
                _rx = re.compile(_pat)
            except re.error as e:
                bad(f"Faisabilité domaine · {_rel} pattern {_pat!r} illégal : {e}"); _rx = None
            if _rx is not None:
                _acc = {c for c in _probe if _rx.fullmatch(c)}
                _miss = sorted(fpj_set - _acc); _extra = sorted(_acc - fpj_set)
                if _miss or _extra:
                    bad(f"Faisabilité domaine · {_rel} pattern {_pat!r} accepte {sorted(_acc)}"
                        f" ≠ CLAUDE.md §Projets{' · manquants=' + str(_miss) if _miss else ''}"
                        f"{' · en trop=' + str(_extra) if _extra else ''}")
                else:
                    good(f"Faisabilité domaine · {_rel} pattern accepte exactement les "
                         f"{len(fpj_set)} codes de §Projets")
        _desc = _prop.get("description", "") or ""
        if not re.search(r"CLAUDE\.md\s*§?\s*Projets", _desc):
            bad(f"Faisabilité domaine · {_rel} : description projet ne DÉCLARE pas "
                f"l'ancrage « CLAUDE.md §Projets » : {_desc!r}")
        elif fpj_mn not in _desc or fpj_mx not in _desc:
            bad(f"Faisabilité domaine · {_rel} : description ne cite pas les bornes "
                f"{fpj_mn}..{fpj_mx} : {_desc!r}")
        else:
            good(f"Faisabilité domaine · {_rel} description ancre {fpj_mn}..{fpj_mx} "
                 f"sur CLAUDE.md §Projets")

# ============================================================================
# TEMPLATE FAISABILITÉ CANONIQUE v1.0 · PARAMÈTRES #9/#10 — même classe d'ANCRAGE
# que « paramètres canoniques » (generator) et « Fiches #10 » ci-dessus, sur une
# SURFACE encore distincte : le DOCUMENT-MODÈLE « CANONIQUE » que TOUTE faisabilité
# en production DOIT reproduire — `05_deliverables_mvp/faisabilite/
# TEMPLATE_FAISABILITE_CANONIQUE_v1.0.md` (Livrable Sprint 1 · statut « CANONIQUE :
# toute faisabilité en production DOIT s'y conformer »).
# ----------------------------------------------------------------------------
# Ce template recopie À LA MAIN les 6 valeurs de CLAUDE.md #9/#10 à CINQ surfaces
# structurées, TOUTES se réclamant de « contrainte #9 »/« #10 » :
#   (1) l.28  intro §0 « frais édition **3 %** · marketing **8.5 %** · point
#       d'équilibre **52 %** pré-vente » (les 3 pourcentages #9, associés par mot-clé) ;
#   (2) l.144-146  §5.2 — 3 lignes de table « **N %** (fixe · contrainte #9) »
#       (frais/marketing/équilibre) ;
#   (3) l.29  intro §0 « **USD + DOP** · format **Letter US** · paiements **Cardnet** »
#       (les 3 valeurs #10) ;
#   (4) l.149-151  §5.2 — 3 lignes de table « **valeur** (… contrainte #10) » (devises ·
#       format · paiement, la ligne paiement citant l'exclusion « ❌ pas-X ») ;
#   (5) l.196  §7 checklist « 3 % / 8.5 % / 52 % · USD+DOP · Letter US · Cardnet présents »
#       (l'énumération des 6 marqueurs, comme la prose README:28 du generator).
# AUCUN gate ne liait ce fichier à CLAUDE.md (grep : absent de tous les `ci/*.sh`) :
# check_artifacts est aveugle (ce n'est pas un `out/*.json`), check_docs/guard aussi.
# Piège #6 « vert trompeur » : si CLAUDE.md #9 passe 52 %→55 % (ou #10 renomme le
# prestataire) et que les modules s'alignent, ce TEMPLATE — la référence que le
# générateur 4 volets, le Publiciste et la régénération batch S4 (§8) sont censés
# suivre — reste PÉRIMÉ en silence, imposant à TOUTES les faisabilités un paramètre
# qui CONTREDIT le mandat, pendant qu'il se réclame « contrainte #9/#10 ». Aucune
# suite tests/ (FONCTIONS, jamais l'ancre à CLAUDE.md) ne l'attrape. On RÉUTILISE
# `exp` (6 valeurs recomputées de #9/#10 · zéro duplication) + `exp_excl` (prestataire
# exclu) et on exige que CHACUNE des 5 surfaces == CLAUDE.md. Une surface INTROUVABLE
# échoue AUSSI (le recensement qui s'évapore est lui-même une régression #6).
# ============================================================================
if exp is not None:
    TPL = f"{D}/faisabilite/TEMPLATE_FAISABILITE_CANONIQUE_v1.0.md"
    tpl = read_norm(TPL)
    if tpl is None:
        bad(f"Template canonique · {TPL} illisible")
    else:
        _pct_kw_map = (("frais_edition_pct", r"frais", "frais édition"),
                       ("marketing_pct", r"marketing", "marketing"),
                       ("point_equilibre_pct", r"[ée]quilibre", "point d'équilibre"))
        # (1) intro §0 (l.28) · les 3 pourcentages #9 associés par mot-clé
        m1 = re.search(r"#9 Param[^→]*→(.+?)pr[ée]-vente", tpl)
        if m1 is None:
            bad("Template canonique · (1) intro #9 (l.28) INTROUVABLE "
                "(attendu frais/marketing/équilibre en gras)")
        else:
            seg1 = m1.group(1); drift = []
            for key, kw, lab in _pct_kw_map:
                mm = re.search(kw + r"[^*]*?\*\*\s*(\d+(?:\.\d+)?)\s*%", seg1)
                got = float(mm.group(1)) if mm else None
                if got != exp[key]:
                    drift.append(f"{lab}={_pnorm(got) if got is not None else '∅'} "
                                 f"(attendu {_pnorm(exp[key])})")
            if drift:
                bad("Template canonique · (1) intro #9 (l.28) DÉRIVE de CLAUDE.md #9 : "
                    f"{' · '.join(drift)} → aligner le template (ou CLAUDE.md)")
            else:
                good("Template canonique · (1) intro #9 (l.28) — 3 pourcentages == "
                     "CLAUDE.md #9")
        # (2) §5.2 (l.144-146) · les 3 lignes de table « **N %** (fixe · contrainte #9) »
        rows9 = re.findall(r"\*\*\s*(\d+(?:\.\d+)?)\s*%\s*\*\*\s*\(fixe · contrainte #9\)",
                           tpl)
        want9 = {exp[k] for k in _num_keys}
        got9 = {float(x) for x in rows9}
        if len(rows9) != 3:
            bad(f"Template canonique · (2) §5.2 — attendu 3 lignes « **N %** (fixe · "
                f"contrainte #9) », trouvé {len(rows9)} ({rows9}) → régénérer le template")
        elif got9 == want9:
            good("Template canonique · (2) §5.2 — 3 lignes « (fixe · contrainte #9) » == "
                 "CLAUDE.md #9 (3 %/8.5 %/52 %)")
        else:
            miss = sorted(_pnorm(v) for v in want9 - got9)
            extra = sorted(_pnorm(v) for v in got9 - want9)
            bad(f"Template canonique · (2) §5.2 — lignes #9 {sorted(map(_pnorm, got9))} "
                f"MAIS CLAUDE.md {sorted(map(_pnorm, want9))}"
                + (f" · absents={miss}" if miss else "")
                + (f" · en trop={extra}" if extra else "") + " → régénérer le template")
        # (3) intro §0 (l.29) · les 3 valeurs #10 en gras
        m3 = re.search(r"#10 Devises\*\* → (.+?)\.", tpl)
        if m3 is None:
            bad("Template canonique · (3) intro #10 (l.29) INTROUVABLE "
                "(attendu devises/format/paiement en gras)")
        else:
            seg3 = m3.group(1); drift = []
            for k in _txt_keys:
                if not re.search(r"\*\*\s*" + re.escape(exp[k]) + r"\s*\*\*", seg3):
                    drift.append(f"{_lab10[k]} attendu **{exp[k]}**")
            if drift:
                bad("Template canonique · (3) intro #10 (l.29) DÉRIVE de CLAUDE.md #10 : "
                    f"{' · '.join(drift)} → aligner le template (ou CLAUDE.md)")
            else:
                good("Template canonique · (3) intro #10 (l.29) — devises/format/paiement "
                     "== CLAUDE.md #10")
        # (4) §5.2 (l.149-151) · les 3 lignes de table « **valeur** (… contrainte #10) »
        drift = []
        # devises + format : « **valeur** (contrainte #10) »
        for k in ("devises", "format_doc"):
            if not re.search(r"\*\*\s*" + re.escape(exp[k]) + r"\s*\*\*\s*\(contrainte #10\)",
                             tpl):
                drift.append(f"ligne {_lab10[k]} attendue « **{exp[k]}** (contrainte #10) »")
        # paiement : « **valeur** (❌ <exclu> · contrainte #10) » — cite l'exclusion #10
        pay_re = (r"\*\*\s*" + re.escape(exp["paiement"]) + r"\s*\*\*\s*\(❌\s*"
                  + (re.escape(exp_excl) if exp_excl else r"\S+") + r"\s*·\s*contrainte #10\)")
        if not re.search(pay_re, tpl):
            drift.append(f"ligne paiement attendue « **{exp['paiement']}** (❌ "
                         f"{exp_excl or '?'} · contrainte #10) »")
        if drift:
            bad("Template canonique · (4) §5.2 lignes #10 DÉRIVENT de CLAUDE.md #10 : "
                f"{' · '.join(drift)} → aligner le template (ou CLAUDE.md)")
        else:
            good("Template canonique · (4) §5.2 — 3 lignes « (… contrainte #10) » (devises · "
                 f"format · paiement ❌ {exp_excl}) == CLAUDE.md #10")
        # (5) §7 checklist (l.196) · énumération des 6 marqueurs canoniques
        _want6_tpl = {_pnorm(exp[k]) for k in _num_keys} | {_ai(exp[k]) for k in _txt_keys}
        m5 = re.search(r"Param[èe]tres canoniques\*\* \| (.+?) pr[ée]sents", tpl)
        if m5 is None:
            bad("Template canonique · (5) §7 checklist — énumération « 3 %/…/Cardnet "
                "présents » INTROUVABLE")
        else:
            got5 = set()
            for tok in re.split(r"[/·]", m5.group(1)):
                p = _pct(tok)
                got5.add(_pnorm(p) if p is not None else _ai(tok))
            if got5 == _want6_tpl:
                good("Template canonique · (5) §7 checklist — énumère les 6 marqueurs == "
                     "CLAUDE.md #9/#10")
            else:
                miss = sorted(_want6_tpl - got5); extra = sorted(got5 - _want6_tpl)
                bad(f"Template canonique · (5) §7 checklist — énumère {sorted(got5)} MAIS "
                    f"CLAUDE.md {sorted(_want6_tpl)}"
                    + (f" · absents={miss}" if miss else "")
                    + (f" · en trop={extra}" if extra else "") + " → régénérer le template")

# ============================================================================
# Fiscal · ecf_dgii — le JEU DE DEVISES `moneda` (`USD`/`DOP`) ANCRÉ sur CLAUDE.md
# #10 — même classe d'ANCRAGE que la CITATION #10 des fiches ci-dessus, mais sur
# une SURFACE distincte : la LISTE d'options de devise de l'e-CF, byte-gatée mais
# JAMAIS ancrée à la contrainte.
# ----------------------------------------------------------------------------
# `moneda.options = ["USD","DOP"]` — le jeu de devises que `TipoMoneda` peut porter
# dans un e-CF DGII — vit dans le spec ET, byte-gaté par check_artifacts, dans
# out/ecf_plan.json. C'est une valeur CANONIQUE de CLAUDE.md #10 (« **USD + DOP**
# devises ») : le README s'en réclame « #10 » (README:57 « **`devise`** (USD/DOP ·
# #10) », README:37/77) MAIS AUCUN gate ne liait cette liste à la contrainte —
# le bloc « Fiscal cross-cohérence » amont gate le FormaPago (Cardnet · #10) et les
# ÉTATS/champs émetteurs, AVEUGLE au jeu de devises ; le bloc « Fiches #10 » ancre
# la prose des fiches ERPNext/CRM, AVEUGLE au module fiscal ; et le `source` du bloc
# moneda est legitimement `null` (il source le TAUX TipoCambio, à confirmer · #8),
# donc l'ancre des OPTIONS vit dans un champ DISTINCT `options_source`.
# Byte-gater une constante canonique prouve la REPRODUCTIBILITÉ, PAS l'ANCRAGE
# (check_artifacts prouve ecf_plan==build depuis le spec, jamais spec[moneda]==#10) :
# renommer/étendre les devises dans CLAUDE.md #10 (`USD + DOP` → `USD + EUR`, ou
# +`EUR`) ET dans le spec de façon cohérente reste byte-VERT tout en faisant émettre
# à l'e-CF un `TipoMoneda` d'une devise qui CONTREDIT le mandat — « vert trompeur »
# de la MÊME classe que la marque SEO §Entités, la persona Chat OTOIA ou les tokens
# branding #4, qu'aucune suite tests/ (FONCTIONS de génération, jamais l'ancre à
# CLAUDE.md) n'attrape. On RÉUTILISE `exp["devises"]` déjà recomputé depuis #10 (zéro
# duplication) et on exige : (a) spec `moneda.options` == devises de #10 (ORDRE exact
# · mord un ajout/retrait/réordonnancement) ; (b) l'artefact byte-gaté idem ; (c) le
# spec DÉCLARE l'ancrage (`options_source` cite « CLAUDE.md #10 » + chaque devise) ;
# (d) la prose README qui se réclame de #10 cite le jeu EXACT + toute mention
# `moneda`/`devise` de paire de devises == le jeu (aucune périmée). Un claim absent
# échoue AUSSI (traçabilité).
# ============================================================================
if exp is not None:
    ECF_MON_RD = f"{D}/fiscal/ecf_dgii/README.md"
    want_cur = re.findall(r"[A-Z]{3}", exp["devises"])          # #10 « USD + DOP » → [USD, DOP]
    want_set = set(want_cur)
    try:
        _mon_plan = load("fiscal/ecf_dgii/out/ecf_plan.json")["moneda"]
        _mon_spec = json.load(open(f"{D}/fiscal/ecf_dgii/ecf_spec.json",
                                   encoding="utf-8"))["moneda"]
    except (OSError, KeyError, ValueError, TypeError) as e:
        bad(f"Fiscal devises · moneda spec/artefact illisible/inattendu : {e}")
        _mon_plan = _mon_spec = None
    if not want_cur:
        bad(f"Fiscal devises · CLAUDE.md #10 devises illisibles : {exp['devises']!r}")
    elif _mon_plan is not None:
        _sp_opt = [str(o).upper() for o in _mon_spec.get("options", [])]
        _pl_opt = [str(o).upper() for o in _mon_plan.get("options", [])]
        # (a) ANCRAGE — spec moneda.options == devises #10 (ordre exact)
        if _sp_opt == want_cur:
            good(f"Fiscal devises · (a) spec moneda.options {_sp_opt} == CLAUDE.md #10 "
                 f"« {exp['devises']} » (ordre exact)")
        else:
            _miss = sorted(want_set - set(_sp_opt)); _extra = sorted(set(_sp_opt) - want_set)
            _d = (f" · absents={_miss}" if _miss else "") + (f" · en trop={_extra}" if _extra else "")
            bad(f"Fiscal devises · (a) spec moneda.options {_sp_opt} ≠ CLAUDE.md #10 "
                f"{want_cur}{_d} → aligner le spec (ou CLAUDE.md #10)")
        # (b) ARTEFACT byte-gaté == devises #10 (ordre exact)
        if _pl_opt == want_cur:
            good(f"Fiscal devises · (b) ecf_plan.moneda.options {_pl_opt} == CLAUDE.md #10")
        else:
            bad(f"Fiscal devises · (b) ecf_plan.moneda.options {_pl_opt} ≠ CLAUDE.md #10 "
                f"{want_cur} → régénérer l'artefact")
        # (c) le spec DÉCLARE l'ancrage : options_source cite CLAUDE.md #10 + chaque devise
        _osrc = str(_mon_spec.get("options_source", ""))
        _missing_tok = [c for c in want_cur if c not in _osrc]
        if "CLAUDE.md #10" not in _osrc or _missing_tok:
            bad(f"Fiscal devises · (c) moneda.options_source ne DÉCLARE pas l'ancrage "
                f"CLAUDE.md #10 + {want_cur} (manquant : "
                f"{'CLAUDE.md #10' if 'CLAUDE.md #10' not in _osrc else _missing_tok}) : {_osrc!r}")
        else:
            good("Fiscal devises · (c) spec DÉCLARE l'ancrage (options_source cite "
                 "CLAUDE.md #10 + chaque devise)")
        # (d) README — la ligne #10 cite le jeu EXACT + toute paire moneda/devise == le jeu
        ecf_mon_rd = read_norm(ECF_MON_RD)
        if ecf_mon_rd is not None:
            # (d1) la ligne qui se réclame de #10 : « devise (…USD/DOP… · #10) »
            m10 = re.search(r"devise[^\n]*?\(([^)]*#10[^)]*)\)", ecf_mon_rd)
            if m10 is None:
                bad(f"Fiscal devises · (d1) {ECF_MON_RD} — ligne « devise (… · #10) » "
                    f"INTROUVABLE → la prose ne se réclame plus de #10")
            elif set(re.findall(r"[A-Z]{3}", m10.group(1))) != want_set:
                bad(f"Fiscal devises · (d1) {ECF_MON_RD} — ligne #10 cite "
                    f"{sorted(set(re.findall(r'[A-Z]{3}', m10.group(1))))} MAIS #10 dit "
                    f"{sorted(want_set)} → régénérer le README")
            else:
                # (d2) AUCUNE mention moneda/devise d'une paire de devises n'est périmée
                pairs = re.findall(r"(?:moneda|devise)[^.|\n]{0,40}?\b([A-Z]{3}\s*/\s*[A-Z]{3})\b",
                                   ecf_mon_rd)
                stale = [p for p in pairs if set(re.findall(r"[A-Z]{3}", p)) != want_set]
                if not pairs:
                    bad(f"Fiscal devises · (d2) {ECF_MON_RD} — aucune mention moneda/devise "
                        f"du jeu {sorted(want_set)} → claim absent")
                elif stale:
                    bad(f"Fiscal devises · (d2) {ECF_MON_RD} — mention(s) de devises PÉRIMÉE(S) "
                        f"{stale} ≠ CLAUDE.md #10 {sorted(want_set)} → régénérer le README")
                else:
                    good(f"Fiscal devises · (d) README cite le jeu #10 {sorted(want_set)} "
                         f"+ {len(pairs)} mention(s) moneda/devise concordante(s)")

# ============================================================================
# Démo · run-sheet de pitch (2e surface du MÊME README) · la TABLE des SCÉNARIOS
# — l'IDENTITÉ de chaque ligne (id · projet+libellé · audience), jamais gatée.
# ----------------------------------------------------------------------------
# Le bloc « Démo » ci-dessus recompute le seul COMPTE de modules cités du
# diagramme. Restait, dans le MÊME README, une surface data-derived distincte et
# NON gatée : la table « Scénario | Projet | Audience | Angle » (README:10-13).
# Ses trois colonnes d'identité sont DATA-DERIVED de out/run_sheet.json (byte-
# gaté par check_artifacts ⇒ faisant autorité) : `id` (« S-P07-BANQUIER »),
# `projet`+`projet_libelle` (« P07 » + « Aqua Terra Las Terrenas ») et `audience`
# (« banquier »). Le générateur ne saisit AUCUNE donnée métier — il résout depuis
# le disque ; les libellés projets sont d'ailleurs déclarés (README:91-92)
# « proviennent **verbatim** de CLAUDE.md · §Projets ». Or AUCUN gate ne le
# vérifiait : check_artifacts ne prouve QUE run_sheet==build (byte-for-byte) et
# le bloc Démo existant ne gate QUE `modules_cites_uniques` → RENOMMER un id dans
# la prose · CLASSER un scénario sous le mauvais projet/audience · AJOUTER une
# ligne fantôme d'un scénario inventé · laisser un libellé projet DÉRIVER de
# CLAUDE.md (« Aqua Terra » → « Aqua Terra Bay ») restait « vert trompeur »
# qu'aucune suite tests/ (qui teste des FONCTIONS de résolution, pas la prose du
# README) n'attrape → un présentateur pitcherait le mauvais projet/audience (le
# risque même que la run-sheet veut éliminer). Même patron EXACT que la colonne
# « Type » de roleprofile_gen (identité de table, fin du wildcard) et l'ancrage
# du catalogue projets dossier_vente à CLAUDE.md §Projets. On RECOMPUTE : (1)
# ANCRAGE — chaque `P{code} {libelle}` du run_sheet == l'entrée CLAUDE.md §Projets
# de ce code (verbatim, comme la prose l'affirme · zéro duplication) ; (2) TABLE —
# chaque ligne porte EXACTEMENT id + `P{code} {libelle}` + audience ; (3) IDENTITÉ
# d'ensemble — l'ensemble des ids de lignes == l'ensemble des ids du run_sheet ==
# counts.scenarios (aucune ligne FANTÔME, aucun scénario MANQUANT). Cohérences
# croisées en bonus (mordent un artefact INTERNEMENT incohérent) : run_sheet ↔
# MANIFEST d'accord sur (id, projet, audience) · id == f"S-{projet}-{AUDIENCE}" ·
# counts.scenarios == |scenarios| · ids non vides et sans doublon. Un claim absent
# échoue AUSSI (disparition = régression).
# ============================================================================
DEMO_RD2 = f"{D}/demo/scenarios/README.md"
try:
    d_rs = load("demo/scenarios/out/run_sheet.json")["scenarios"]
    d_man = load("demo/scenarios/out/MANIFEST.json")
    d_man_sc = d_man["scenarios"]
    d_n = int(d_man["counts"]["scenarios"])
except (OSError, KeyError, ValueError, TypeError) as e:
    bad(f"Démo scénarios · artefact run_sheet/MANIFEST illisible/inattendu : {e}"); d_rs = None
# ancrage libellés projets : CLAUDE.md §Projets = source faisant autorité
try:
    _cmp = open("CLAUDE.md", encoding="utf-8").read()
    _mp = re.search(r"^## Projets\s*\n(.+)$", _cmp, re.M)
    if _mp is None:
        raise ValueError("section « ## Projets » INTROUVABLE")
    _proj_map = {}
    for _t in _mp.group(1).split("·"):
        _t = _nfc(_t.strip())
        _mc = re.match(r"(P\d{2})\b", _t)
        if _mc:
            _proj_map[_mc.group(1)] = _t
    if not _proj_map:
        raise ValueError("aucun projet PNN parsé")
except (OSError, ValueError) as e:
    bad(f"Démo scénarios · CLAUDE.md §Projets illisible/inattendu : {e}"); _proj_map = None
if d_rs is not None and _proj_map is not None:
    d_rd = read_norm(DEMO_RD2)
    # cohérences croisées internes à l'artefact (avant de croire la prose)
    rs_ids = [s["id"] for s in d_rs]
    if len(set(rs_ids)) != len(rs_ids) or "" in rs_ids:
        bad(f"Démo scénarios · run_sheet incohérent : ids={rs_ids} "
            f"(doublon ou vide)")
    if d_n != len(d_rs):
        bad(f"Démo scénarios · incohérence artefact : counts.scenarios={d_n} "
            f"MAIS |scenarios|={len(d_rs)}")
    man_key = {s["id"]: (s["projet"], s["audience"]) for s in d_man_sc}
    for s in d_rs:
        sid, proj, aud = s["id"], s["projet"], s["audience"]
        # id == f"S-{projet}-{AUDIENCE}" (encode projet+audience)
        exp_id = f"S-{proj}-{aud.upper()}"
        if sid != exp_id:
            bad(f"Démo scénarios · run_sheet incohérent : id={sid!r} MAIS "
                f"projet={proj}/audience={aud} imposent {exp_id!r}")
        # run_sheet ↔ MANIFEST d'accord sur (projet, audience)
        if man_key.get(sid) != (proj, aud):
            bad(f"Démo scénarios · run_sheet ↔ MANIFEST désynchronisés pour {sid} : "
                f"run_sheet=({proj},{aud}) MANIFEST={man_key.get(sid)}")
        # ANCRAGE : libellé projet == CLAUDE.md §Projets (verbatim, cf. README:91)
        full = _nfc(f"{proj} {s['projet_libelle']}")
        anchor = _proj_map.get(proj)
        if anchor is None:
            bad(f"Démo scénarios · projet {proj} du run_sheet ABSENT de CLAUDE.md "
                f"§Projets (libellé « {full} » non ancré)")
        elif _ai(full) != _ai(anchor):
            bad(f"Démo scénarios · libellé projet « {full} » du run_sheet DÉRIVE de "
                f"CLAUDE.md §Projets « {anchor} » → aligner (ou CLAUDE.md)")
        else:
            good(f"Démo scénarios · {sid} — libellé « {full} » == CLAUDE.md §Projets")
        # TABLE : ligne README porte id + `P{code} {libelle}` + audience
        if d_rd is not None:
            pat = (rf"\| `{re.escape(sid)}` \| \*\*([^|]+?)\*\* \| "
                   r"([^|]+?) \|")
            m = re.search(pat, d_rd)
            if m is None:
                bad(f"Démo scénarios · {DEMO_RD2} — ligne `{sid}` INTROUVABLE "
                    f"(attendu projet « {full} » · audience « {aud} »)")
            else:
                got_lbl, got_aud = m.group(1).strip(), m.group(2).strip()
                if _ai(got_lbl) != _ai(full):
                    bad(f"Démo scénarios · {sid} — {DEMO_RD2} porte projet "
                        f"« {got_lbl} » MAIS run_sheet dit « {full} » → régénérer")
                elif got_aud != aud:
                    bad(f"Démo scénarios · {sid} — {DEMO_RD2} porte audience "
                        f"« {got_aud} » MAIS run_sheet dit « {aud} » → régénérer")
                else:
                    good(f"Démo scénarios · {sid} — ligne README (projet « {got_lbl} » · "
                         f"audience « {got_aud} ») == run_sheet")
    # IDENTITÉ d'ensemble : ids des lignes de la table == ids du run_sheet (== counts)
    if d_rd is not None:
        row_ids = set(re.findall(r"\| `(S-[^`]+)` \| \*\*[^|]+\*\* \| [^|]+ \|", d_rd))
        want_ids = set(rs_ids)
        if row_ids == want_ids and len(row_ids) == d_n:
            good(f"Démo scénarios · table — {len(row_ids)} lignes == run_sheet == "
                 f"counts.scenarios ({d_n}) : aucun scénario FANTÔME ni MANQUANT")
        else:
            miss = sorted(want_ids - row_ids); extra = sorted(row_ids - want_ids)
            detail = (f" · absents={miss}" if miss else "") + \
                     (f" · en trop={extra}" if extra else "")
            bad(f"Démo scénarios · table — lignes {sorted(row_ids)} MAIS run_sheet "
                f"{sorted(want_ids)} (counts.scenarios={d_n}){detail} → régénérer le README")

# ============================================================================
# CRM/workflow_vente (3e surface du MÊME README) · la TABLE « Rôles = contrat
# RBAC » — le RÔLE RBAC qui GARDE chaque transition du pipeline vente. C'est le
# CŒUR sécurité du workflow (qui a le droit d'exécuter quel pas), transcrit À LA
# MAIN sans AUCUN gate d'IDENTITÉ.
# ----------------------------------------------------------------------------
# Les deux blocs CRM/workflow existants gatent (1) le COUPLE de COMPTES du pipeline
# (« 9 états · 11 transitions ») et (2) l'ÉNUMÉRATION des transitions à SÉPARATION
# DES POUVOIRS (allow_self_approval=0). Restait, dans le MÊME README (l.55-62), une
# 3e surface data-derived : la table « Étape → Rôle qui garde la transition » qui
# nomme, PAR pas, le rôle RBAC autorisé. Sources faisant autorité (byte-gatées par
# check_artifacts) : crm/workflow_vente/out/workflow.json — chaque transition porte
# `allowed` (le rôle qui garde le pas) ; ET out/MANIFEST.json.roles_rbac_utilises[]
# — l'ensemble des rôles employés, RECOMPUTÉ du contrat rbac_50_roles.json à chaque
# build. Les deux blocs de COMPTE/séparation sont AVEUGLES à l'identité de ces rôles :
# RÉAFFECTER un pas (« Confirmer réservation » — soumission d'argent — passe de
# `OTO Ventes Réservations` à `OTO Ventes Conseiller` : élévation de privilège sur
# une étape monétaire), RENOMMER un rôle, en AJOUTER un fantôme dans la prose ou en
# OUBLIER un laisse le README périmé pendant que l'artefact dit autre chose →
# l'agent ERPNext Backend câblerait le mauvais garde (le risque même que la table
# veut prévenir). « Vert trompeur » qu'aucune suite tests/ (qui teste des FONCTIONS
# de graphe/résolution RBAC, pas la prose) n'attrape — même patron d'IDENTITÉ que la
# cross-cohérence PERMISSIONS Legal/CONFOTUR ou le catalogue RBAC fixtures_gen.
#   Bonus (mordent un artefact INTERNEMENT incohérent) : {allowed} de workflow.json
# == {erpnext_role_name} de MANIFEST.roles_rbac_utilises (aucun rôle gardant une
# transition sans être listé dans le manifeste, ni l'inverse) · ensemble NON VIDE.
#   Cross-cohérence par pas (mord la réaffectation que le set-diff seul manque quand
# un rôle sert DEUX transitions) : pour les pas monétaires/de clôture SENSIBLES et
# UNIQUES dans le graphe (recomputés `allowed`, jamais figés), la ligne README qui
# les nomme doit citer EXACTEMENT ce rôle.
# État courant : aucun garde périmé (anti-invention #6) — le défaut est la surface
# ungated. Un claim absent échoue AUSSI (traçabilité).
# ============================================================================
def _fold(s):
    s = unicodedata.normalize("NFKD", s)
    return "".join(c for c in s if not unicodedata.combining(c)).strip().casefold()
try:
    wfv_trans = load("crm/workflow_vente/out/workflow.json")[0]["transitions"]
    wfv_roles = {t["allowed"] for t in wfv_trans}          # gardes du graphe
    wfv_man = load("crm/workflow_vente/out/MANIFEST.json")["roles_rbac_utilises"]
    wfv_roles_man = {r["erpnext_role_name"] for r in wfv_man}
    # rôle par action, UNIQUEMENT quand l'action garde 1 seul rôle (déterministe)
    wfv_by_act = {}
    for t in wfv_trans:
        wfv_by_act.setdefault(t["action"], set()).add(t["allowed"])
    wfv_uniq = {a: next(iter(v)) for a, v in wfv_by_act.items() if len(v) == 1}
except (OSError, KeyError, ValueError, IndexError, TypeError) as e:
    bad(f"CRM gardes · workflow.json/MANIFEST illisible/inattendu : {e}"); wfv_roles = None
if wfv_roles is not None:
    # bonus 1 : ensemble non vide
    if not wfv_roles:
        bad("CRM gardes · aucun rôle ne garde de transition (ensemble VIDE) → contrat cassé")
    else:
        good(f"CRM gardes · {len(wfv_roles)} rôles gardent le pipeline (ensemble non vide)")
    # bonus 2 : workflow.json ⇄ MANIFEST d'accord sur l'ensemble des rôles employés
    if wfv_roles == wfv_roles_man:
        good(f"CRM gardes · {{allowed}} workflow.json == roles_rbac_utilises MANIFEST "
             f"({len(wfv_roles)} rôles · aucun fantôme/manquant entre artefacts)")
    else:
        miss = sorted(wfv_roles_man - wfv_roles); extra = sorted(wfv_roles - wfv_roles_man)
        detail = (f" · dans MANIFEST pas gardien={miss}" if miss else "") + \
                 (f" · gardien pas dans MANIFEST={extra}" if extra else "")
        bad(f"CRM gardes · incohérence artefacts : gardes workflow.json {sorted(wfv_roles)} "
            f"MAIS roles_rbac_utilises {sorted(wfv_roles_man)}{detail} → régénérer")
    wfv_txt = read_norm(WF_RD)
    if wfv_txt is not None:
        seg = re.search(r"Rôle qui garde la transition \|(.+?)## Séparation", wfv_txt)
        if seg is None:
            bad(f"CRM gardes · {WF_RD} — table « Rôle qui garde la transition » INTROUVABLE "
                f"(attendu {sorted(wfv_roles)})")
        else:
            rows = [(l, r) for l, r in re.findall(r"\| ([^|]+?) \| ([^|]+?) \|", seg.group(1))
                    if set(l.strip()) != {"-"}]
            # identité d'ensemble : colonne « Rôle » de la table == gardes du graphe
            rd_roles = set()
            for _lbl, cell in rows:
                for tok in cell.split("·"):
                    if tok.strip():
                        rd_roles.add(tok.strip())
            rd_fold = {_fold(x) for x in rd_roles}; wf_fold = {_fold(x) for x in wfv_roles}
            if rd_fold == wf_fold:
                good(f"CRM gardes · {WF_RD} — colonne Rôle {sorted(rd_roles)} == "
                     f"{{allowed}} workflow.json ({len(wfv_roles)} rôles · ni fantôme ni manquant)")
            else:
                miss = sorted(wf_fold - rd_fold); extra = sorted(rd_fold - wf_fold)
                detail = (f" · absents={miss}" if miss else "") + \
                         (f" · en trop={extra}" if extra else "")
                bad(f"CRM gardes · {WF_RD} — colonne Rôle {sorted(rd_fold)} MAIS "
                    f"{{allowed}} workflow.json {sorted(wf_fold)}{detail} → régénérer le README")
            # cross-cohérence par pas : les gardes des étapes SENSIBLES (argent/clôture),
            # uniques dans le graphe, recomputés depuis workflow.json — jamais figés.
            for act in ("Confirmer réservation", "Signer contrat", "Approuver CONFOTUR"):
                exp = wfv_uniq.get(act)
                if exp is None:
                    bad(f"CRM gardes · action sensible « {act} » absente/non-déterministe "
                        f"dans workflow.json → contrat changé de forme, vérifier")
                    continue
                row = next((c for l, c in rows if _fold(act) in _fold(l)), None)
                if row is None:
                    bad(f"CRM gardes · {WF_RD} — ligne de l'étape sensible « {act} » "
                        f"INTROUVABLE (attendu garde {exp})")
                elif _fold(exp) in {_fold(t) for t in row.split("·") if t.strip()}:
                    good(f"CRM gardes · étape sensible « {act} » gardée par « {exp} » "
                         f"== workflow.json (séparation des pouvoirs · #6)")
                else:
                    bad(f"CRM gardes · {WF_RD} — étape sensible « {act} » : README dit "
                        f"« {row.strip()} » MAIS workflow.json dit garde « {exp} » → "
                        f"réaffectation de privilège non répercutée, régénérer")

# ============================================================================
# RBAC/userperm_gen · la TABLE « Mapping scope_donnees → mécanisme » — la
# FONCTION d'enforcement ROW-LEVEL : quel mécanisme Frappe natif applique CHAQUE
# portée de données, ET si un `User Permission` template est émis. CŒUR sécurité
# du module, transcrit À LA MAIN sans AUCUN gate d'IDENTITÉ.
# ----------------------------------------------------------------------------
# Le bloc RBAC existant (« 3 volets ») ne gate QUE la VENTILATION par mécanisme
# (« 28 entite · 16 groupe · 2 own · 4 equipe » — un COMPTE, aveugle à QUEL
# mécanisme applique QUELLE portée). Restait, dans le MÊME README (l.33-38), une
# surface data-derived distincte : la table qui énonce, PAR portée, le `mechanism`
# Frappe natif ET le verdict « Template émis ? ». Source faisant autorité
# (byte-gatée par check_artifacts) : rbac/userperm_gen/out/user_permission_plan.json
# — chaque entrée porte `scope_donnees` + `mechanism` + `user_permission_template`
# (null ou objet). Le mapping est une FONCTION : chaque portée → exactement un
# mécanisme, template émis SSI `entite`/`user_permission_company`. RÉAFFECTER une
# portée à un mécanisme plus permissif (`entite` → `none_consolidated` : le
# row-level enforcement ABANDONNÉ, sur-exposition des données inter-entités — la
# restriction même que le module pose), RENOMMER un mécanisme ou basculer le
# verdict « Template émis ? » laisse le README périmé pendant que l'artefact dit
# autre chose → l'agent ERPNext câblerait le mauvais mécanisme (le risque même que
# la table veut prévenir). « Vert trompeur » qu'aucune suite tests/ (qui teste des
# FONCTIONS de mapping/résolution, pas la prose) n'attrape — même patron
# d'IDENTITÉ que la cross-cohérence PERMISSIONS Legal/CONFOTUR ou le catalogue RBAC
# fixtures_gen.
#   Bonus (mordent un plan INTERNEMENT incohérent) : chaque portée mappe UN SEUL
# mécanisme (fonction, pas relation) · « Template émis » UNIFORME sur les entrées
# d'une portée · template émis EXACTEMENT pour `user_permission_company` (l'invariant
# « template SSI entite » du README:36/61) · ensemble des portées NON VIDE.
# État courant : aucune ligne périmée (anti-invention #6) — le défaut est la surface
# ungated. Un claim absent échoue AUSSI (traçabilité).
# ============================================================================
try:
    up_plan = load("rbac/userperm_gen/out/user_permission_plan.json")
    up_scopes, up_tmpl = {}, {}      # portée -> set(mécanisme) / set(template non-null?)
    for e in up_plan:
        s = e["scope_donnees"]
        up_scopes.setdefault(s, set()).add(e["mechanism"])
        up_tmpl.setdefault(s, set()).add(e["user_permission_template"] is not None)
except (OSError, KeyError, ValueError, TypeError) as e:
    bad(f"RBAC userperm map · user_permission_plan.json illisible/inattendu : {e}")
    up_scopes = None
if up_scopes is not None:
    # bonus 1 : ensemble des portées non vide
    if not up_scopes:
        bad("RBAC userperm map · plan VIDE (aucune portée) → contrat cassé")
    else:
        good(f"RBAC userperm map · {len(up_scopes)} portées dans le plan (ensemble non vide)")
    # bonus 2 : chaque portée → UN SEUL mécanisme (fonction) + « Template émis » uniforme
    scope_mech = {}
    for s in sorted(up_scopes):
        if len(up_scopes[s]) != 1:
            bad(f"RBAC userperm map · portée « {s} » mappe {sorted(up_scopes[s])} "
                f"(attendu 1 mécanisme) → plan incohérent (relation, pas fonction)")
            continue
        scope_mech[s] = next(iter(up_scopes[s]))
        if len(up_tmpl[s]) != 1:
            bad(f"RBAC userperm map · portée « {s} » — « Template émis ? » NON uniforme "
                f"sur ses entrées {sorted(up_tmpl[s])} → plan incohérent")
    # bonus 3 : template émis EXACTEMENT pour user_permission_company (l'invariant SSI)
    for s, mech in scope_mech.items():
        emitted = up_tmpl[s] == {True}
        if emitted != (mech == "user_permission_company"):
            bad(f"RBAC userperm map · portée « {s} » (mécanisme={mech}) émet "
                f"template={emitted} MAIS l'invariant est « template SSI "
                f"user_permission_company » → plan incohérent")
    # identité + par-ligne vs README (lignes RAW : la table Mapping est la seule à 3
    # cellules dont col1 = une portée backtickée `[a-z_]+` et col3 = le verdict
    # « Template émis ? » **oui** / non (`null`)).
    UPM_RD = f"{D}/rbac/userperm_gen/README.md"
    try:
        upm_lines = open(UPM_RD, encoding="utf-8").read().splitlines()
    except OSError as e:
        bad(f"RBAC userperm map · {UPM_RD} illisible : {e}"); upm_lines = []
    map_rows = {}
    for ln in upm_lines:
        cells = [c.strip() for c in ln.strip().strip("|").split("|")]
        if len(cells) != 3:
            continue
        ms = re.fullmatch(r"`([a-z_]+)`", cells[0])
        if ms and re.search(r"\*\*oui\*\*|non \(`null`\)", cells[2]):
            map_rows[ms.group(1)] = cells
    # identité d'ensemble : portées de la table == portées de l'artefact
    tbl, art = set(map_rows), set(scope_mech)
    if tbl == art:
        good(f"RBAC userperm map · table {sorted(tbl)} == portées de l'artefact "
             f"({len(art)} · ni fantôme ni manquante)")
    else:
        miss = sorted(art - tbl); extra = sorted(tbl - art)
        detail = (f" · absents={miss}" if miss else "") + \
                 (f" · en trop={extra}" if extra else "")
        bad(f"RBAC userperm map · {UPM_RD} table {sorted(tbl)} MAIS artefact "
            f"{sorted(art)}{detail} → régénérer le README")
    # par portée : mécanisme (1er token backtické de col2) + verdict « Template émis ? »
    for s in sorted(art):
        exp_mech = scope_mech[s]
        exp_emit = up_tmpl[s] == {True}
        row = map_rows.get(s)
        if row is None:
            bad(f"RBAC userperm map · portée « {s} » — ligne INTROUVABLE dans {UPM_RD} "
                f"(attendu mécanisme={exp_mech} · template="
                f"{'oui' if exp_emit else 'non'})")
            continue
        mm = re.match(r"`([^`]+)`", row[1])
        got_mech = mm.group(1) if mm else "?"
        if got_mech == exp_mech:
            good(f"RBAC userperm map · portée « {s} » — mécanisme « {got_mech} » == artefact")
        else:
            bad(f"RBAC userperm map · portée « {s} » — {UPM_RD} dit mécanisme "
                f"« {got_mech} » MAIS artefact dit « {exp_mech} » → réaffectation "
                f"d'enforcement non répercutée, régénérer")
        got_emit = row[2].strip() == "**oui**"
        if got_emit == exp_emit:
            good(f"RBAC userperm map · portée « {s} » — Template émis="
                 f"{'oui' if got_emit else 'non'} == artefact")
        else:
            bad(f"RBAC userperm map · portée « {s} » — {UPM_RD} dit Template émis="
                f"{'oui' if got_emit else 'non'} MAIS artefact dit "
                f"{'oui' if exp_emit else 'non'} → régénérer le README")

# ============================================================================
# Faisabilité/bancable — figures « Génération réelle (fixture) » du README
# ----------------------------------------------------------------------------
# Le module `faisabilite/bancable` n'écrit son `out/` que sur disque (« out/ non
# commité ») : il est HORS de check_artifacts (aucun artefact byte-gaté) et
# AUCUN gate ne le touchait. Or son README §« Vérification en-repo » recopie
# QUATRE chiffres DATA-DERIVED que le générateur calcule sur la fixture COMMITÉE
# `fixtures/brief_bancable.json` : « 40 unités · valeur catalogue USD 8,560,000
# / DOP 505,040,000 · point d'équilibre 21 unités (⌈52 % × 40⌉) ». Ces valeurs
# sont Σ quantités · Σ(qté×prix_usd) · Σ(qté×prix_dop) · ⌈52 % × Σ unités⌉ — le
# 52 % ancré sur CLAUDE.md #9. PIÈGE : la suite tests/ teste des FONCTIONS
# (l'arithmétique de recoupement) avec des oracles HARDCODÉS ; elle ne compare
# JAMAIS la PROSE du README à la génération réelle. Éditer une typologie de la
# fixture (quantité/prix), en ajouter/retirer une, ou déplacer le point
# d'équilibre de CLAUDE.md #9, laisse les 4 chiffres du README périmés en silence
# pendant que le générateur produit autre chose → le banquier lit un dossier
# faux, l'invention même que #6 interdit. Ce bloc RECOMPUTE les 4 figures depuis
# la fixture (source de vérité), exige que la GÉNÉRATION RÉELLE (bancable_gen
# validate) les confirme (mord un générateur/fixture incohérents), puis que la
# prose README les énumère EXACTEMENT (valeur + séparateur de milliers + formule
# ⌈pct × Σ unités⌉, pct ancré CLAUDE.md #9). Un claim absent échoue AUSSI.
# ============================================================================
import math
BANC = f"{D}/faisabilite/bancable"
BANC_RD = f"{BANC}/README.md"
# 1) point d'équilibre % ANCRÉ sur CLAUDE.md #9 (source faisant autorité · #6/#9)
try:
    _cmb = open("CLAUDE.md", encoding="utf-8").read()
    _l9b = re.search(r"^9\.\s+(.+)$", _cmb, re.M)
    if not _l9b:
        raise ValueError("contrainte #9 INTROUVABLE")
    _peb = None
    for _sb in re.split(r"·", _l9b.group(1)):
        if "equilibre" in _ai(_sb):
            _mmb = re.search(r"(\d+(?:\.\d+)?)\s*%", _sb)
            _peb = float(_mmb.group(1)) if _mmb else None
    if _peb is None:
        raise ValueError("point d'équilibre % absent de #9")
except (OSError, ValueError) as e:
    bad(f"Bancable figures · CLAUDE.md #9 (point d'équilibre) illisible : {e}"); _peb = None
# 2) recompute INDÉPENDANT depuis la fixture COMMITÉE (source de vérité · #6)
try:
    _fxb = json.load(open(f"{BANC}/fixtures/brief_bancable.json", encoding="utf-8"))
    _typb = _fxb["architecture"]["typologies"]
    rec_units = sum(int(t["quantite"]) for t in _typb)
    rec_usd = sum(int(t["quantite"]) * int(t["prix_usd"]) for t in _typb)
    rec_dop = sum(int(t["quantite"]) * int(t["prix_dop"]) for t in _typb)
except (OSError, KeyError, ValueError, TypeError) as e:
    bad(f"Bancable figures · fixture brief_bancable.json illisible/inattendue : {e}"); rec_units = None
# 3) génération RÉELLE : le manifeste que le README prétend recopier
man_fig = None
try:
    _procb = subprocess.run(
        [sys.executable, "bancable_gen.py", "validate", "fixtures/brief_bancable.json"],
        cwd=BANC, capture_output=True, text=True, encoding="utf-8")
    if _procb.returncode != 0:
        raise ValueError(f"validate exit {_procb.returncode} : {_procb.stderr.strip()[:160]}")
    man_fig = {f["cle"]: f["valeur"] for f in json.loads(_procb.stdout)["figures_calculees"]}
except (ValueError, KeyError, OSError) as e:
    bad(f"Bancable figures · génération réelle (bancable_gen validate) échoue : {e}")

if _peb is not None and rec_units is not None and man_fig is not None:
    rec_be = math.ceil(_peb / 100.0 * rec_units)
    # cohérence croisée : le manifeste GÉNÉRÉ == le recompute indépendant fixture
    for cle, expv in (("total_unites", rec_units), ("valeur_catalogue_usd", rec_usd),
                      ("valeur_catalogue_dop", rec_dop), ("point_equilibre_unites", rec_be)):
        gotv = man_fig.get(cle)
        if gotv is None:
            bad(f"Bancable figures · manifeste sans figure « {cle} » (attendu {expv})")
        elif int(gotv) != int(expv):
            bad(f"Bancable figures · manifeste « {cle} »={gotv} MAIS recompute fixture "
                f"{expv} → générateur/fixture incohérents (formule cassée)")
        else:
            good(f"Bancable figures · manifeste « {cle} »={int(gotv)} == recompute fixture")
    # 4) PROSE README §« Vérification en-repo » — un seul pattern d'identité
    try:
        banc_txt = re.sub(r"\s+", " ", open(BANC_RD, encoding="utf-8").read())
    except OSError as e:
        bad(f"Bancable figures · {BANC_RD} illisible : {e}"); banc_txt = None
    if banc_txt is not None:
        pat = (r"(\d[\d,]*)\s*unités\s*·\s*valeur catalogue\s*USD\s*([\d,]+)\s*/\s*"
               r"DOP\s*([\d,]+)\s*·\s*point d'équilibre\s*(\d+)\s*unités\s*"
               r"\(⌈\s*(\d+)\s*%\s*×\s*(\d+)\s*⌉\)")
        m = re.search(pat, banc_txt)
        if m is None:
            bad(f"Bancable figures · {BANC_RD} — ligne « Génération réelle (fixture) » "
                f"INTROUVABLE (attendu {rec_units} unités · USD {rec_usd:,} / DOP "
                f"{rec_dop:,} · équilibre {rec_be} unités (⌈{_peb:g} % × {rec_units}⌉)) — "
                f"un claim absent est une régression (#6)")
        else:
            g_pct, g_tot = float(m.group(5)), int(m.group(6))
            for lbl, raw, expv in (("Σ unités", m.group(1), rec_units),
                                   ("valeur catalogue USD", m.group(2), rec_usd),
                                   ("valeur catalogue DOP", m.group(3), rec_dop),
                                   ("point d'équilibre", m.group(4), rec_be)):
                got = int(raw.replace(",", ""))
                if got != expv:
                    bad(f"Bancable figures · README {lbl}={raw} MAIS génération réelle "
                        f"={expv} → figure périmée, régénérer la fixture/le README (#6)")
                elif expv >= 1000 and "," not in raw:
                    bad(f"Bancable figures · README {lbl}={raw} sans séparateur de milliers "
                        f"(attendu « {expv:,} »)")
                else:
                    good(f"Bancable figures · README {lbl}={raw} == génération réelle")
            # formule ⌈52 % × 40⌉ : pct ancré CLAUDE.md #9 · total == Σ unités
            if g_pct != _peb:
                bad(f"Bancable figures · README formule ⌈{g_pct:g} %⌉ MAIS CLAUDE.md #9 "
                    f"point d'équilibre = {_peb:g} % → aligner (ou CLAUDE.md)")
            else:
                good(f"Bancable figures · README formule pct {g_pct:g} % == CLAUDE.md #9")
            if g_tot != rec_units:
                bad(f"Bancable figures · README formule « × {g_tot} » MAIS Σ unités "
                    f"= {rec_units} → total de la formule désaligné")
            else:
                good(f"Bancable figures · README formule « × {g_tot} » == Σ unités")

# ============================================================================
# Fiche Mobile (03_agents/mobile/AGENT.md) · la FICHE D'IDENTITÉ du rôle RBAC
# `plateforme-mobile` — le SEUL ancrage in-repo réellement vérifiable de l'agent
# mobile (les builds/stores étant hors-repo, #8), transcrit À LA MAIN sans gate.
# ----------------------------------------------------------------------------
# La ligne « Points de contact réellement commités » (AGENT.md:36) recopie, DEPUIS
# le contrat rbac_50_roles.json (source de vérité RBAC, schéma-validé · byte-gaté
# par les 3 MANIFEST recomputés), TOUS les attributs data-derived du rôle : son
# `erpnext_role_name` (« OTO Plateforme Mobile »), son `nom_fr`, sa `famille`/
# `portail`, son `entite_principale`, son `niveau`, son `scope_donnees`, ses
# `modules` et sa `permission_cible` (`API Access` custom R/W) + sa `description`.
# C'est la carte que l'agent ERPNext Backend lit pour seeder le rôle. AUCUN gate ne
# comparait ces valeurs au contrat : le README mobile/app_config est gaté sur des
# COMPTES agrégés (« 5 onglets · 44 rôles »), AVEUGLES à l'identité de CE rôle. Éditer
# le contrat — élever le `scope_donnees` (`groupe`→`entite` : sur-portée), changer
# le `niveau`, l'`entite_principale`, renommer le rôle, ajouter/retirer un module ou
# élargir la permission (R/W→+delete) — laisse la fiche périmée en silence pendant
# que le contrat dit autre chose ⇒ l'agent câblerait le mauvais rôle (l'invention
# même que #6 interdit). Aucune suite tests/ (qui teste des FONCTIONS de résolution
# RBAC, pas la prose d'une fiche) n'attrape ce « vert trompeur » — même patron
# d'IDENTITÉ que la table gardes CRM/workflow_vente ou le mapping RBAC userperm_gen.
# On RECOMPUTE chaque attribut depuis le contrat (zéro duplication) et on exige que
# la fiche les énonce EXACTEMENT. Cohérences croisées bonus (mordent un contrat
# INTERNEMENT incohérent) : `famille` == `portail` (invariant de bijection portail) ·
# la permission est bien `custom` · l'ensemble des rôles porte CE role_id UNE fois.
# Un claim absent échoue AUSSI (traçabilité). La description (data-derived) est
# recoupée verbatim, emphase markdown et point final neutralisés.
# ============================================================================
MOB = "03_agents/mobile/AGENT.md"
RID = "plateforme-mobile"
try:
    roles = load("rbac/rbac_50_roles.json")["roles"]
    mob_role = [r for r in roles if r.get("id") == RID]
except (OSError, KeyError, ValueError, TypeError) as e:
    bad(f"Fiche Mobile · rbac_50_roles.json illisible/inattendu : {e}"); mob_role = None
mob_txt = read_norm(MOB)
if mob_role is not None and mob_txt is not None:
    if len(mob_role) != 1:
        bad(f"Fiche Mobile · le contrat porte {len(mob_role)} rôle(s) `{RID}` "
            f"(attendu exactement 1) → contrat changé de forme, vérifier")
    else:
        r = mob_role[0]
        # cellule « Ce qu'il fixe » de la ligne plateforme-mobile (sans le `|` de fin)
        seg = re.search(rf"→ rôle `{re.escape(RID)}` \|(.+?)\|", mob_txt)
        if seg is None:
            bad(f"Fiche Mobile · {MOB} — ligne de contact du rôle `{RID}` INTROUVABLE "
                f"(un claim absent est une régression · #6)")
        else:
            cell = seg.group(1)
            # cohérence croisée : famille == portail (bijection portail du contrat)
            if r.get("famille") != r.get("portail"):
                bad(f"Fiche Mobile · contrat incohérent : famille={r.get('famille')} "
                    f"≠ portail={r.get('portail')} → bijection portail cassée")
            # permission_cible : doctype + flag custom + verbes → « R/W »
            perms = r.get("permissions_cibles") or []
            perm = perms[0] if len(perms) == 1 else None
            if perm is None:
                bad(f"Fiche Mobile · rôle `{RID}` — attendu 1 permission_cible, "
                    f"trouvé {len(perms)} → contrat changé, vérifier la fiche")
            elif not perm.get("custom", False):
                bad(f"Fiche Mobile · rôle `{RID}` — permission « {perm.get('doctype')} » "
                    f"non `custom` dans le contrat MAIS la fiche la dit custom → aligner")
            acts = set(perm.get("actions", [])) if perm else set()
            # verbes → sigle canonique ordonné : élargir la perm (ex. +delete) change
            # le sigle attendu ⇒ la fiche « R/W » périme et le gate mord.
            _VERB = [("create", "C"), ("read", "R"), ("write", "W"), ("delete", "D"),
                     ("submit", "S"), ("cancel", "X"), ("amend", "A")]
            rw = "/".join(sig for k, sig in _VERB if k in acts)
            if acts - {k for k, _ in _VERB}:
                bad(f"Fiche Mobile · rôle `{RID}` — verbe(s) inconnu(s) "
                    f"{sorted(acts - {k for k, _ in _VERB})} dans le contrat → étendre le gate")
            mods = "+".join(f"`{m}`" for m in (r.get("modules") or []))
            # chaque attribut data-derived recomputé du contrat → prose EXACTE exigée
            attrs = [
                ("nom d'affichage ERPNext",
                 rf"rôle ERPNext \*\*`{re.escape(r['erpnext_role_name'])}`\*\*",
                 r["erpnext_role_name"]),
                ("nom_fr", rf"« {re.escape(r['nom_fr'])} »", r["nom_fr"]),
                ("famille/portail",
                 rf"famille/portail \*\*`{re.escape(r['portail'])}`\*\*", r["portail"]),
                ("entité principale",
                 rf"entité \*\*{re.escape(r['entite_principale'])}\*\*",
                 r["entite_principale"]),
                ("niveau", rf"niveau {r['niveau']}(?!\d)", str(r["niveau"])),
                ("scope_donnees",
                 rf"scope `{re.escape(r['scope_donnees'])}`", r["scope_donnees"]),
                ("modules", rf"modules {re.escape(mods)}(?![+`])",
                 "+".join(r.get("modules") or [])),
            ]
            if perm is not None:
                attrs.append(("permission cible",
                              rf"perm `{re.escape(perm['doctype'])}` custom {re.escape(rw)}(?![\w/])",
                              f"{perm['doctype']} custom {rw}"))
            for label, pat, exp in attrs:
                if re.search(pat, cell):
                    good(f"Fiche Mobile · rôle `{RID}` — {label} « {exp} » == contrat")
                else:
                    bad(f"Fiche Mobile · {MOB} — {label} : la fiche ne cite pas "
                        f"« {exp} » du contrat rbac_50_roles.json (périmé ?) → régénérer")
            # description data-derived : recoupée verbatim (emphase + point final neutralisés)
            def _plain(s):
                return re.sub(r"\s+", " ", s.replace("*", "")).strip().rstrip(".").strip()
            md = re.search(r"description « (.+?) »\)", cell)
            exp_desc = _plain(r.get("description", ""))
            if md is None:
                bad(f"Fiche Mobile · {MOB} — la description « … » du rôle `{RID}` "
                    f"INTROUVABLE (attendu « {exp_desc} »)")
            elif _plain(md.group(1)) == exp_desc:
                good(f"Fiche Mobile · rôle `{RID}` — description == contrat "
                     f"(emphase/point neutralisés)")
            else:
                bad(f"Fiche Mobile · {MOB} — description « {_plain(md.group(1))} » "
                    f"MAIS contrat « {exp_desc} » → description périmée, régénérer")

# ============================================================================
# Fiches Faisabilité (03_agents/rendu/AGENT.md · 03_agents/ifc_speckle/AGENT.md) ·
# les FICHES D'IDENTITÉ des rôles RBAC `faisabilite-rendu-3d` et
# `faisabilite-ifc-speckle` — même classe de « vert trompeur » que la Fiche Mobile.
# ----------------------------------------------------------------------------
# La ligne « Contact in-repo (vérifié) » de chaque fiche (rendu:34 · ifc_speckle:34)
# recopie À LA MAIN, DEPUIS le contrat rbac_50_roles.json (source de vérité RBAC,
# schéma-validé · byte-gaté par les 3 MANIFEST recomputés), les attributs data-derived
# du rôle : son `erpnext_role_name`, son `portail`, son `entite_principale`, son
# `niveau`, ses `modules` (rendu), son `nom_fr` (rendu) et ses `permissions_cibles`
# (deux doctypes chacune : `File` R/W/create + `Faisabilité` R ou R/W). C'est la carte
# que l'agent ERPNext Backend lit pour seeder le rôle porteur des rendus / de l'export
# IFC→GLB. AUCUN gate ne comparait ces valeurs au contrat : le README frontend/portails
# n'est gaté que sur des COMPTES de rôles par Workspace, AVEUGLES à l'identité de CE
# rôle. Éditer le contrat — élever le `scope_donnees`, changer le `niveau`,
# l'`entite_principale`, renommer le rôle, ajouter/retirer un module ou élargir la
# permission (`Faisabilité` R→R/W : le rôle Rendu 3D gagnerait l'écriture sur le
# DocType Faisabilité qu'il ne doit que LIRE) — laisse la fiche périmée en silence
# pendant que le contrat dit autre chose ⇒ l'agent câblerait le mauvais rôle
# (l'invention même que #6 interdit). Aucune suite tests/ (qui teste des FONCTIONS de
# résolution RBAC, pas la prose d'une fiche) n'attrape ce « vert trompeur ». On
# RECOMPUTE chaque attribut RESTITUÉ par la fiche depuis le contrat (zéro duplication)
# et on exige que la fiche l'énonce EXACTEMENT. Les verbes de permission sont rendus
# dans un ORDRE CANONIQUE (read→R · write→W · create/delete/submit/… en toutes
# lettres, ordre fixe) : élargir OU rétrécir une permission change la chaîne attendue
# (`R/W` ne satisfait plus `R/W/create`, ni l'inverse) ; verbe inconnu ⇒ ROUGE
# (étendre le gate). Un claim absent échoue AUSSI (traçabilité). NB : contrairement
# à la Fiche Mobile, la DESCRIPTION de ces deux fiches est une PARAPHRASE éditoriale
# (le contrat porte « (… — CLAUDE.md) » / « les modèles » que la fiche condense) —
# elle n'est donc PAS gatée verbatim (ne rien réécrire de correct pour un gate · #6) ;
# seuls les attributs STRUCTURÉS, eux transcrits à l'exact, sont contraints.
# ============================================================================
_CANON_VERB = [("read", "R"), ("write", "W"), ("create", "create"),
               ("delete", "delete"), ("submit", "submit"), ("cancel", "cancel"),
               ("amend", "amend"), ("print", "print"), ("report", "report")]
FICHES_FAIS = [
    {"file": "03_agents/rendu/AGENT.md", "rid": "faisabilite-rendu-3d",
     "attrs": ["role", "nom_fr", "portail", "entite", "niveau", "modules", "perms"]},
    {"file": "03_agents/ifc_speckle/AGENT.md", "rid": "faisabilite-ifc-speckle",
     "attrs": ["role", "portail", "entite", "niveau", "perms"]},
]
try:
    _fais_roles = load("rbac/rbac_50_roles.json")["roles"]
except (OSError, KeyError, ValueError, TypeError) as e:
    bad(f"Fiches Faisabilité · rbac_50_roles.json illisible/inattendu : {e}")
    _fais_roles = None
if _fais_roles is not None:
    for fc in FICHES_FAIS:
        rid, tag = fc["rid"], fc["file"].split("/")[1]
        cand = [r for r in _fais_roles if r.get("id") == rid]
        ftxt = read_norm(fc["file"])
        if ftxt is None:
            continue
        if len(cand) != 1:
            bad(f"Fiche {tag} · le contrat porte {len(cand)} rôle(s) `{rid}` "
                f"(attendu exactement 1) → contrat changé de forme, vérifier")
            continue
        r = cand[0]
        seg = re.search(rf"→ rôle `{re.escape(rid)}` \|(.+?)\|", ftxt)
        if seg is None:
            bad(f"Fiche {tag} · {fc['file']} — ligne de contact du rôle `{rid}` "
                f"INTROUVABLE (un claim absent est une régression · #6)")
            continue
        cell = seg.group(1)
        # permissions_cibles → chaîne « `Doctype` R/W/create + `Autre` R », ordre canon
        _perm_parts, _unknown = [], set()
        for p in (r.get("permissions_cibles") or []):
            acts = set(p.get("actions", []))
            _unknown |= acts - {k for k, _ in _CANON_VERB}
            sig = "/".join(tok for k, tok in _CANON_VERB if k in acts)
            _perm_parts.append(f"`{p.get('doctype')}` {sig}")
        perm_str = " + ".join(_perm_parts)
        if _unknown:
            bad(f"Fiche {tag} · rôle `{rid}` — verbe(s) inconnu(s) "
                f"{sorted(_unknown)} dans le contrat → étendre le gate")
        mods = r.get("modules") or []
        mod_pat = (rf"module `{re.escape(mods[0])}`(?![+`\w])" if len(mods) == 1
                   else r"modules " + r"\+".join(f"`{re.escape(m)}`" for m in mods))
        _PATS = {
            "role": (rf"rôle ERPNext \*\*`{re.escape(r['erpnext_role_name'])}`\*\*",
                     r["erpnext_role_name"]),
            "nom_fr": (rf"« {re.escape(r['nom_fr'])} »", r["nom_fr"]),
            "portail": (rf"portail {re.escape(r['portail'])}(?![\w])", r["portail"]),
            "entite": (rf"(?<![\w]){re.escape(r['entite_principale'])}(?![\w])",
                       r["entite_principale"]),
            "niveau": (rf"niveau {r['niveau']}(?!\d)", str(r["niveau"])),
            "modules": (mod_pat, "+".join(mods)),
            "perms": (rf"perms {re.escape(perm_str)}(?![\w/])", perm_str),
        }
        for a in fc["attrs"]:
            pat, exp = _PATS[a]
            if re.search(pat, cell):
                good(f"Fiche {tag} · rôle `{rid}` — {a} « {exp} » == contrat")
            else:
                bad(f"Fiche {tag} · {fc['file']} — {a} : la fiche ne cite pas "
                    f"« {exp} » du contrat rbac_50_roles.json (périmé ?) → régénérer")

# ============================================================================
# Fiches agents · rattachement Workspace (`Has Role`) — QUELLE CONSOLE (Workspace
# ERPNext natif v15) le rôle d'une fiche peut atteindre. Surface data-derived
# DISTINCTE des attributs du contrat rbac_50_roles.json : la MEMBERSHIP `Has Role`
# ne vit PAS dans le contrat mais dans frontend/portails/out/workspace.json
# (artefact byte-gaté par check_artifacts). Trois fiches font, EN PROSE, une
# assertion d'appartenance :
#   • rendu (:35)       — rôle « OTO Faisabilité Rendu 3D » rattaché au Workspace
#                          NOMMÉ `OTO Construction` (accès console).
#   • ifc_speckle (:35) — rôle « OTO Faisabilité IFC Speckle » rattaché à UN
#                          Workspace ERPNext (générique, non nommé).
#   • mobile (:41)      — rôle « OTO Plateforme Mobile » VOLONTAIREMENT PAS
#                          rattaché au Workspace NOMMÉ `OTO Ventes` (assertion
#                          NÉGATIVE d'honnêteté · #6).
# Aucun gate ne les couvrait : le bloc « triplets par workspace » gate le COMPTE de
# rôles par Workspace (nb_roles), AVEUGLE à QUEL rôle ; les blocs Fiches Faisabilité /
# Fiche Mobile gatent le CONTRAT, pas la membership Has Role. Rattacher le rôle mobile
# à `OTO Ventes` (SUR-EXPOSITION console : le dev mobile gagnerait l'accès au portail
# Ventes — la restriction même que la note #6 pose), retirer le rôle rendu de
# `OTO Construction` (perte d'accès console du spécialiste), ou DÉPLACER le rôle vers
# un autre Workspace, laisse la fiche périmée EN SILENCE pendant que l'artefact dit
# autre chose ⇒ l'agent ERPNext Backend câblerait le mauvais accès console. Aucune
# suite tests/ (qui teste des FONCTIONS de résolution, pas la prose d'une fiche)
# n'attrape ce « vert trompeur ». On RECOMPUTE, depuis workspace.json, l'ensemble des
# Workspaces dont le `Has Role` porte l'erpnext_role_name du rôle (résolu du contrat
# par id, zéro duplication) et on confronte l'assertion de la fiche. Un claim absent
# échoue AUSSI (traçabilité · #6).
# ============================================================================
FICHES_WS = [
    {"file": "03_agents/rendu/AGENT.md", "rid": "faisabilite-rendu-3d",
     "kind": "in_named", "ws": "OTO Construction"},
    {"file": "03_agents/ifc_speckle/AGENT.md", "rid": "faisabilite-ifc-speckle",
     "kind": "in_any"},
    {"file": "03_agents/mobile/AGENT.md", "rid": "plateforme-mobile",
     "kind": "not_in_named", "ws": "OTO Ventes"},
]
_ws_json = _ws_roles = None
try:
    _ws_json = load("frontend/portails/out/workspace.json")
    _ws_roles = load("rbac/rbac_50_roles.json")["roles"]
except (OSError, KeyError, ValueError, TypeError) as e:
    bad(f"Fiches Workspace · workspace.json / rbac_50_roles.json illisible : {e}")
if _ws_json is not None and _ws_roles is not None:
    # Index recomputé : erpnext_role_name -> ensemble des titres de Workspace le portant.
    _mem, _titles = {}, set()
    for ws in _ws_json:
        title = ws.get("title")
        _titles.add(title)
        for hr in (ws.get("roles") or []):
            _mem.setdefault(hr.get("role"), set()).add(title)
    for fc in FICHES_WS:
        tag = fc["file"].split("/")[1]
        cand = [r for r in _ws_roles if r.get("id") == fc["rid"]]
        if len(cand) != 1:
            bad(f"Fiche {tag} · le contrat porte {len(cand)} rôle(s) `{fc['rid']}` "
                f"(attendu exactement 1) → contrat changé de forme, vérifier")
            continue
        rname = cand[0].get("erpnext_role_name")
        actual = _mem.get(rname, set())          # Workspaces réels du rôle (Has Role)
        ftxt = read_norm(fc["file"])
        if ftxt is None:
            continue
        named = fc.get("ws")
        # Un Workspace NOMMÉ par la fiche doit exister dans l'artefact (anti-typo :
        # sinon une assertion in_named/not_in_named « verte » viserait un fantôme).
        if named is not None and named not in _titles:
            bad(f"Fiche {tag} · le Workspace nommé `{named}` n'existe pas dans "
                f"workspace.json (titres : {sorted(_titles)}) → fiche/artefact périmé")
            continue
        if fc["kind"] == "in_named":
            if not re.search(rf"rattaché au Workspace `{re.escape(named)}`", ftxt):
                bad(f"Fiche {tag} · {fc['file']} — assertion « rattaché au Workspace "
                    f"`{named}` » INTROUVABLE (claim absent = régression · #6)")
            elif actual == {named}:
                good(f"Fiche {tag} · rôle « {rname} » ∈ Has Role de `{named}` "
                     f"(et de lui seul) == workspace.json")
            elif named in actual:
                bad(f"Fiche {tag} · rôle « {rname} » ∈ `{named}` MAIS AUSSI "
                    f"{sorted(actual - {named})} — la fiche nomme UN seul Workspace ; "
                    f"membership élargie dans workspace.json → réviser fiche/artefact")
            else:
                bad(f"Fiche {tag} · {fc['file']} nomme `{named}` MAIS workspace.json "
                    f"rattache « {rname} » à {sorted(actual) or 'aucun Workspace'} → "
                    f"fiche périmée (rôle déplacé/retiré du Has Role), régénérer")
        elif fc["kind"] == "in_any":
            if not re.search(r"rattaché à un Workspace", ftxt):
                bad(f"Fiche {tag} · {fc['file']} — assertion « rattaché à un "
                    f"Workspace » INTROUVABLE (claim absent = régression · #6)")
            elif actual:
                good(f"Fiche {tag} · rôle « {rname} » ∈ Has Role de {sorted(actual)} "
                     f"== workspace.json (≥1 Workspace, comme la fiche l'affirme)")
            else:
                bad(f"Fiche {tag} · {fc['file']} affirme un rattachement Workspace "
                    f"MAIS « {rname} » n'est dans le Has Role d'AUCUN Workspace → "
                    f"fiche périmée")
        elif fc["kind"] == "not_in_named":
            if not re.search(rf"volontairement pas\*\* rattaché au Workspace "
                             rf"`{re.escape(named)}`", ftxt):
                bad(f"Fiche {tag} · {fc['file']} — assertion négative « volontairement "
                    f"pas rattaché au Workspace `{named}` » INTROUVABLE (#6)")
            elif named not in actual:
                good(f"Fiche {tag} · rôle « {rname} » ABSENT du Has Role de `{named}` "
                     f"== workspace.json (assertion négative #6 tenue)")
            else:
                bad(f"Fiche {tag} · {fc['file']} affirme « pas rattaché à `{named}` » "
                    f"MAIS workspace.json met « {rname} » dans son Has Role → "
                    f"SUR-EXPOSITION console, assertion #6 VIOLÉE")

# ============================================================================
# Fiches agents · appartenance à `roles_allowed` du chat OTOIA — QUI peut
# INVOQUER l'assistant OTOIA embarqué dans un portail. Surface data-derived
# DISTINCTE de la membership Workspace `Has Role` (QUELLE console le rôle atteint,
# gatée juste au-dessus) ET des attributs du contrat rbac_50_roles.json (gatés par
# les blocs Fiches Faisabilité / Fiche Mobile) : la liste `roles_allowed` ne vit
# PAS dans le contrat ni dans workspace.json mais dans
# frontend/chat_otoia/out/chat_mount.json (une config runtime par portail,
# artefact byte-gaté par check_artifacts). Deux fiches font, EN PROSE, une
# assertion d'appartenance à CE contrôle d'accès :
#   • rendu (:36)  — POSITIVE : le rôle « OTO Faisabilité Rendu 3D » fait partie
#                     des `roles_allowed` du portail NOMMÉ `construction` (droit
#                     d'invoquer le chat pour déclencher les rendus assistés).
#   • mobile (:41) — NÉGATIVE d'honnêteté (#6) : le rôle « OTO Plateforme Mobile »
#                     (famille `plateforme`) n'est VOLONTAIREMENT dans AUCUNE liste
#                     `roles_allowed` — le chat ne couvre que les portails métier.
# Aucun gate ne les couvrait : le bloc Chat OTOIA amont gate l'IDENTITÉ de la
# persona/capabilities (ancrée CLAUDE.md) et la COMPOSITION (5 mounts ⇔ 5 portails),
# AVEUGLE à QUEL rôle figure dans quelle `roles_allowed` ; le bloc Fiches Workspace
# gate la membership `Has Role`, un artefact + une sémantique DIFFÉRENTS (accès
# console ≠ droit d'invocation du chat). Ajouter le rôle mobile à une `roles_allowed`
# (SUR-EXPOSITION : le dev mobile gagnerait le droit de piloter l'IA d'un portail
# métier — l'escalade même que la note #6 refuse), retirer le rôle rendu de la
# `roles_allowed` construction (perte du déclenchement assisté), ou le DÉPLACER vers
# un autre portail, laisse la fiche périmée EN SILENCE pendant que l'artefact dit
# autre chose ⇒ l'agent ERPNext Backend câblerait le mauvais contrôle d'accès chat.
# Aucune suite tests/ (qui teste des FONCTIONS de montage/résolution, pas la prose
# d'une fiche) n'attrape ce « vert trompeur ». On RECOMPUTE, depuis chat_mount.json,
# l'ensemble des portails dont la `roles_allowed` porte l'erpnext_role_name du rôle
# (résolu du contrat par id, zéro duplication) et on confronte l'assertion de la
# fiche. Triple ancrage bonus (POSITIVE) : le portail NOMMÉ par la fiche == le
# `portail` du rôle dans le contrat == l'unique portail dont la `roles_allowed`
# le porte. Un claim absent échoue AUSSI (traçabilité · #6).
# ============================================================================
FICHES_CHAT = [
    {"file": "03_agents/rendu/AGENT.md", "rid": "faisabilite-rendu-3d",
     "kind": "in_named_portail"},
    {"file": "03_agents/mobile/AGENT.md", "rid": "plateforme-mobile",
     "kind": "not_in_any"},
]
_chat_mounts = _chat_roles = None
try:
    _chat_mounts = load("frontend/chat_otoia/out/chat_mount.json")
    _chat_roles = load("rbac/rbac_50_roles.json")["roles"]
except (OSError, KeyError, ValueError, TypeError) as e:
    bad(f"Fiches Chat · chat_mount.json / rbac_50_roles.json illisible : {e}")
if _chat_mounts is not None and _chat_roles is not None:
    # Index recomputé : erpnext_role_name -> ensemble des portails dont la
    # `roles_allowed` le porte (le droit d'invoquer le chat de CE portail).
    _allow, _portails = {}, set()
    for _mnt in _chat_mounts:
        _pt = _mnt.get("portail")
        _portails.add(_pt)
        for _rn in (_mnt.get("roles_allowed") or []):
            _allow.setdefault(_rn, set()).add(_pt)
    if not _chat_mounts:
        bad("Fiches Chat · aucun mount dans chat_mount.json → artefact suspect")
    for fc in FICHES_CHAT:
        tag = fc["file"].split("/")[1]
        cand = [r for r in _chat_roles if r.get("id") == fc["rid"]]
        if len(cand) != 1:
            bad(f"Fiche {tag} · le contrat porte {len(cand)} rôle(s) `{fc['rid']}` "
                f"(attendu exactement 1) → contrat changé de forme, vérifier")
            continue
        r0 = cand[0]
        rname = r0.get("erpnext_role_name")
        actual = _allow.get(rname, set())        # portails réels autorisant ce rôle
        ftxt = read_norm(fc["file"])
        if ftxt is None:
            continue
        if fc["kind"] == "in_named_portail":
            # Le portail NOMMÉ par la fiche est data-derived : c'est le `portail` du
            # rôle dans le contrat (triple ancrage). Il DOIT exister comme mount
            # (anti-typo : sinon l'assertion « verte » viserait un portail fantôme).
            named = r0.get("portail")
            if named not in _portails:
                bad(f"Fiche {tag} · le rôle `{fc['rid']}` a portail `{named}` (contrat) "
                    f"MAIS aucun mount chat pour ce portail (mounts : {sorted(_portails)}) "
                    f"→ contrat/artefact périmé")
                continue
            # La fiche doit NOMMER ce portail dans son assertion roles_allowed + chat.
            if not re.search(rf"`roles_allowed` \(portail {re.escape(named)}\)", ftxt):
                bad(f"Fiche {tag} · {fc['file']} — assertion « `roles_allowed` (portail "
                    f"{named}) » INTROUVABLE (claim absent = régression · #6)")
            elif not re.search(r"autorisés à \*\*invoquer le chat OTOIA\*\*", ftxt):
                bad(f"Fiche {tag} · {fc['file']} — la fiche nomme le portail mais pas "
                    f"le droit « invoquer le chat OTOIA » → assertion incomplète (#6)")
            elif actual == {named}:
                good(f"Fiche {tag} · rôle « {rname} » ∈ roles_allowed du portail "
                     f"`{named}` (et de lui seul) == chat_mount.json")
            elif named in actual:
                bad(f"Fiche {tag} · rôle « {rname} » ∈ roles_allowed de `{named}` MAIS "
                    f"AUSSI {sorted(actual - {named})} — la fiche nomme UN seul portail ; "
                    f"droit d'invocation élargi dans chat_mount.json → réviser fiche/artefact")
            else:
                bad(f"Fiche {tag} · {fc['file']} nomme le portail `{named}` MAIS "
                    f"chat_mount.json autorise « {rname} » sur "
                    f"{sorted(actual) or 'aucun portail'} → fiche périmée (rôle "
                    f"retiré/déplacé de roles_allowed), régénérer")
        elif fc["kind"] == "not_in_any":
            if not re.search(r"volontairement pas\*\* rattaché.*?`roles_allowed` du chat "
                             r"OTOIA", ftxt):
                bad(f"Fiche {tag} · {fc['file']} — assertion négative « volontairement pas "
                    f"… `roles_allowed` du chat OTOIA » INTROUVABLE (#6)")
            elif not actual:
                good(f"Fiche {tag} · rôle « {rname} » ABSENT de TOUTE roles_allowed "
                     f"== chat_mount.json (assertion négative #6 tenue)")
            else:
                bad(f"Fiche {tag} · {fc['file']} affirme « pas dans roles_allowed » MAIS "
                    f"chat_mount.json autorise « {rname} » sur {sorted(actual)} → "
                    f"SUR-EXPOSITION du chat OTOIA, assertion #6 VIOLÉE")

# ============================================================================
# Publiciste/branding · TOKENS DESIGN CANONIQUES « ancrés sur CLAUDE.md #4 »
# — les 4 valeurs de la marque luxury (2 couleurs + 2 typographies), jamais gatées.
# ----------------------------------------------------------------------------
# La contrainte NON-NÉGOCIABLE #4 de CLAUDE.md fixe l'identité visuelle : fond
# dark `#0a0a12` + accent doré `#f0b429`, titres Fraunces + corps Cormorant
# Garamond. C'est la SOURCE FAISANT AUTORITÉ. Ces 4 tokens sont RECOPIÉS à
# QUATRE endroits, tous NON gatés :
#   (a) lib/branding.py CONSTANTES (COLOR_BG/COLOR_ACCENT/FONT_DISPLAY/FONT_BODY)
#       — la source unique importée par le générateur ET substituée dans le
#       gabarit ({{COLOR_BG}}…) ; ce n'est PAS un out/*.json → invisible à
#       check_artifacts ;
#   (b) le docstring de branding.py (« `#0a0a12` fond · `#f0b429` … Fraunces
#       (titres) + Cormorant Garamond ») — la prose du module ;
#   (c) README:43 (« Gabarit dark+doré (Fraunces + Cormorant Garamond) ») ;
#   (d) tests/test_publiciste.py — le tuple d'oracle HARDCODÉ
#       ("#0a0a12","#f0b429","Fraunces","Cormorant Garamond") : le SEUL contrôle
#       existant assert que le RENDU contient ces marqueurs, mais ils sont une
#       COPIE de plus, JAMAIS comparée à CLAUDE.md. Si #4 change (ex. accent
#       `#f0b429`→`#e0a420`) et qu'on aligne branding.py, l'oracle du test reste
#       périmé (rouge trompeur) ; si RIEN ne bouge, tout reste vert alors que le
#       site public émettrait la mauvaise couleur — invention silencieuse (#6) que
#       nulle suite tests/ (qui teste des FONCTIONS, pas l'ancrage à CLAUDE.md)
#       n'attrape. Même patron EXACT que les paramètres canoniques
#       faisabilite/generator (ancrage CANONICAL↔CLAUDE.md #9/#10).
# On RECOMPUTE les 4 tokens depuis la ligne #4 de CLAUDE.md (zéro duplication) et
# on exige : (a) branding.py CONSTANTES == #4 ; (b) docstring cite les 4 EXACTS ;
# (c) README cite les 2 fontes ; (d) l'oracle du test == #4. Hex insensible à la
# casse, fontes comparées à l'exact (strip). Un claim absent échoue AUSSI.
# ============================================================================
PUB = f"{D}/publiciste"
try:
    cm_raw4 = open("CLAUDE.md", encoding="utf-8").read()
    l4 = re.search(r"^4\.\s+(.+)$", cm_raw4, re.M)
    if not l4:
        raise ValueError("contrainte #4 INTROUVABLE")
    hexes = [h.lower() for h in re.findall(r"`(#[0-9a-fA-F]{6})`", l4.group(1))]
    mfp = re.search(r"\(([^)]+)\)", l4.group(1))
    fonts = [f.strip() for f in mfp.group(1).split("+")] if mfp else []
    if len(hexes) != 2 or len(fonts) != 2:
        raise ValueError(f"attendu 2 couleurs + 2 fontes, lu {hexes} / {fonts}")
    b4 = {"bg": hexes[0], "accent": hexes[1], "display": fonts[0], "body": fonts[1]}
except (OSError, ValueError) as e:
    bad(f"Branding canoniques · CLAUDE.md #4 illisible/inattendu : {e}"); b4 = None
if b4 is not None:
    good(f"Branding canoniques · CLAUDE.md #4 sain : bg={b4['bg']} accent={b4['accent']} "
         f"display={b4['display']!r} body={b4['body']!r}")
    # (a) ANCRAGE : lib/branding.py CONSTANTES == CLAUDE.md #4 -------------------
    BR = f"{PUB}/lib/branding.py"
    try:
        br_src = open(BR, encoding="utf-8").read()
    except OSError as e:
        bad(f"Branding canoniques · {BR} illisible : {e}"); br_src = None
    if br_src is not None:
        const_pat = {
            "bg":      r'COLOR_BG\s*=\s*"([^"]+)"',
            "accent":  r'COLOR_ACCENT\s*=\s*"([^"]+)"',
            "display": r'FONT_DISPLAY\s*=\s*"([^"]+)"',
            "body":    r'FONT_BODY\s*=\s*"([^"]+)"',
        }
        drift = []
        for k, pat in const_pat.items():
            m = re.search(pat, br_src)
            got = m.group(1) if m else None
            want = b4[k]
            ok = (got is not None and
                  (got.lower() == want if k in ("bg", "accent") else got.strip() == want))
            if not ok:
                drift.append(f"{k}={got!r} (attendu {want!r})")
        if drift:
            bad(f"Branding canoniques · branding.py CONSTANTES DÉRIVENT de CLAUDE.md #4 : "
                f"{' · '.join(drift)} → aligner branding.py (ou CLAUDE.md)")
        else:
            good("Branding canoniques · branding.py CONSTANTES == CLAUDE.md #4 (4 tokens)")
        # (b) PROSE docstring : les 4 tokens cités AVANT les CONSTANTES -----------
        doc = br_src.split("COLOR_BG", 1)[0]
        doc_hex = {h.lower() for h in re.findall(r"`(#[0-9a-fA-F]{6})`", doc)}
        miss = [b4[k] for k in ("bg", "accent") if b4[k] not in doc_hex]
        miss += [b4[k] for k in ("display", "body") if b4[k] not in doc]
        if miss:
            bad(f"Branding canoniques · docstring branding.py — tokens ABSENTS de la "
                f"prose : {miss} (claim absent = régression · #6)")
        else:
            good("Branding canoniques · docstring branding.py cite les 4 tokens == CLAUDE.md #4")
    # (c) README : la ligne du gabarit cite les 2 fontes -----------------------
    PBR_RD = f"{PUB}/README.md"
    pbr_rd = read_norm(PBR_RD)
    if pbr_rd is not None:
        rd_miss = [b4[k] for k in ("display", "body") if b4[k] not in pbr_rd]
        if rd_miss:
            bad(f"Branding canoniques · {PBR_RD} — fontes ABSENTES de la prose : "
                f"{rd_miss} (attendu {b4['display']} + {b4['body']} == CLAUDE.md #4)")
        else:
            good(f"Branding canoniques · {PBR_RD} cite les 2 fontes == CLAUDE.md #4")
    # (d) ORACLE du test : le tuple hardcodé == les 4 tokens de CLAUDE.md #4 ----
    TBR = f"{PUB}/tests/test_publiciste.py"
    try:
        tbr_src = open(TBR, encoding="utf-8").read()
    except OSError as e:
        bad(f"Branding canoniques · {TBR} illisible : {e}"); tbr_src = None
    if tbr_src is not None:
        mt = re.search(r"for token in \(([^)]+)\)", tbr_src)
        if mt is None:
            bad(f"Branding canoniques · {TBR} — tuple d'oracle « for token in (...) » "
                f"INTROUVABLE (le contrôle #4 du test a disparu · régression)")
        else:
            toks = [t.strip().strip('"\'') for t in mt.group(1).split(",") if t.strip()]
            toks_n = {t.lower() if t.startswith("#") else t for t in toks}
            want_n = {b4["bg"], b4["accent"], b4["display"], b4["body"]}
            if toks_n == want_n:
                good(f"Branding canoniques · {TBR} oracle == CLAUDE.md #4 (4 tokens)")
            else:
                miss = sorted(want_n - toks_n); extra = sorted(toks_n - want_n)
                bad(f"Branding canoniques · {TBR} oracle DÉRIVE de CLAUDE.md #4"
                    + (f" · absents={miss}" if miss else "")
                    + (f" · en trop={extra}" if extra else "") + " → aligner le test")

# ============================================================================
# Publiciste/generator+branding · DEVISES CANONIQUES du site public « ancrées
# sur CLAUDE.md #10 » (USD + DOP) — les libellés monétaires RÉELLEMENT ÉMIS sur
# vente.otov7.com, jamais gatés.
# ----------------------------------------------------------------------------
# Le bloc branding #4 ci-dessus ancre les 4 tokens DESIGN. Mais le MÊME module
# porte une 2ᵉ constante constitutionnelle NON gatée : les DEUX devises de
# CLAUDE.md #10 (« **USD + DOP** devises »), recopiées à QUATRE endroits data-
# derived, tous invisibles à check_artifacts (pas un out/*.json) et jamais
# confrontés à #10 :
#   (a) lib/branding.py DEVISE_PRIMAIRE/DEVISE_SECONDAIRE — constantes AUTO-
#       étiquetées « contrainte #10 · USD + DOP », sans aucun gate ;
#   (b) lib/generator.py `_fmt_usd`/`_fmt_dop` — les DEUX formateurs qui
#       IMPRIMENT le code devise sur la grille de prix du site public
#       (`f"USD {value:,.0f}"` / `f"DOP {value:,.0f}"`) ; c'est la SORTIE
#       FACE-PUBLIC : une devise HORS #10 ici = mandat trahi en silence (#6) ;
#   (c) lib/generator.py footer (émis dans le HTML) « Prix … en USD + DOP
#       (Cardnet) » — la prose du pied de page du site ;
#   (d) tests/test_publiciste.py — l'oracle HARDCODÉ « assertIn("USD 150 000",
#       …) » : une COPIE de plus de la devise primaire, JAMAIS comparée à #10.
# Aucune suite tests/ (qui teste des FONCTIONS de rendu, pas l'ancre à
# CLAUDE.md) n'attrape la dérive : renommer la devise #10 (USD→EUR) et aligner
# le module reste vert alors que le site émettrait la mauvaise devise ; ne RIEN
# bouger reste vert alors que branding se dit « #10 » en mentant. Même patron
# EXACT que le bloc branding/#4 et faisabilite/#9-#10. On RE-PARSE les 2 devises
# depuis la ligne #10 de CLAUDE.md (indépendamment de `exp` amont · aucun skip
# hérité) et on exige : (a) les 2 constantes == #10 (ordre inclus) ; (b)
# l'ensemble des codes émis par les formateurs == #10 ; (c) le footer cite les 2
# devises dans l'ordre ; (d) l'oracle du test == devise primaire. Un claim
# absent échoue AUSSI (traçabilité #6).
# ============================================================================
try:
    cm_raw10p = open("CLAUDE.md", encoding="utf-8").read()
    l10p = re.search(r"^10\.\s+(.+)$", cm_raw10p, re.M)
    if not l10p:
        raise ValueError("contrainte #10 INTROUVABLE")
    seg10p = [s for s in re.split(r"·", l10p.group(1)) if "devise" in _ai(s)]
    mdev = re.search(r"\*\*(.+?)\*\*", seg10p[0]) if seg10p else None
    if not mdev:
        raise ValueError("segment devises #10 (« **… devises** ») illisible")
    pub_cur = re.findall(r"[A-Z]{3}", mdev.group(1))
    if len(pub_cur) != 2:
        raise ValueError(f"attendu 2 devises en gras, lu {pub_cur}")
except (OSError, ValueError) as e:
    bad(f"Publiciste devises · CLAUDE.md #10 illisible/inattendu : {e}"); pub_cur = None
if pub_cur is not None:
    good(f"Publiciste devises · CLAUDE.md #10 sain : primaire={pub_cur[0]} "
         f"secondaire={pub_cur[1]}")
    PUB = f"{D}/publiciste"
    # (a) ANCRAGE : branding.py DEVISE_PRIMAIRE/SECONDAIRE == #10 (ordre inclus) ---
    BRD = f"{PUB}/lib/branding.py"
    try:
        brd_src = open(BRD, encoding="utf-8").read()
    except OSError as e:
        bad(f"Publiciste devises · {BRD} illisible : {e}"); brd_src = None
    if brd_src is not None:
        dev_pat = {
            "primaire":   (r'DEVISE_PRIMAIRE\s*=\s*"([^"]+)"',   pub_cur[0]),
            "secondaire": (r'DEVISE_SECONDAIRE\s*=\s*"([^"]+)"', pub_cur[1]),
        }
        ddrift = []
        for k, (pat, want) in dev_pat.items():
            m = re.search(pat, brd_src)
            got = m.group(1) if m else None
            if got != want:
                ddrift.append(f"{k}={got!r} (attendu {want!r})")
        if ddrift:
            bad(f"Publiciste devises · branding.py CONSTANTES DÉRIVENT de CLAUDE.md #10 : "
                f"{' · '.join(ddrift)} → aligner branding.py (ou CLAUDE.md)")
        else:
            good(f"Publiciste devises · branding.py DEVISE_PRIMAIRE/SECONDAIRE == "
                 f"CLAUDE.md #10 ({pub_cur[0]}/{pub_cur[1]})")
    # (b) SORTIE : les formateurs generator.py émettent EXACTEMENT les 2 devises #10
    GEN = f"{PUB}/lib/generator.py"
    try:
        gen_src = open(GEN, encoding="utf-8").read()
    except OSError as e:
        bad(f"Publiciste devises · {GEN} illisible : {e}"); gen_src = None
    if gen_src is not None:
        emis = re.findall(r'return f"([A-Z]{3}) \{value:[^}]+\}"', gen_src)
        if set(emis) != set(pub_cur):
            miss = sorted(set(pub_cur) - set(emis)); extra = sorted(set(emis) - set(pub_cur))
            bad(f"Publiciste devises · formateurs generator.py émettent {sorted(set(emis))} "
                f"MAIS CLAUDE.md #10 = {sorted(set(pub_cur))}"
                + (f" · absents={miss}" if miss else "")
                + (f" · HORS #10={extra}" if extra else "")
                + " → le site public émettrait une devise non conforme (#6)")
        else:
            good(f"Publiciste devises · formateurs generator.py émettent {sorted(set(emis))} "
                 f"== CLAUDE.md #10")
        # (c) FOOTER émis dans le HTML : « en <D1> + <D2> (Cardnet) » ordre inclus ---
        mft = re.search(r"en ([A-Z]{3}) \+ ([A-Z]{3}) \(Cardnet\)", gen_src)
        if mft is None:
            bad(f"Publiciste devises · {GEN} — pied de page « en <D1> + <D2> (Cardnet) » "
                f"INTROUVABLE (claim absent = régression · #6)")
        elif [mft.group(1), mft.group(2)] == pub_cur:
            good(f"Publiciste devises · footer generator.py cite {pub_cur[0]} + {pub_cur[1]} "
                 f"== CLAUDE.md #10")
        else:
            bad(f"Publiciste devises · footer generator.py cite {mft.group(1)} + {mft.group(2)} "
                f"MAIS CLAUDE.md #10 = {pub_cur[0]} + {pub_cur[1]} → aligner le footer")
    # (d) ORACLE du test : « assertIn("<D1> 150 000", self.html) » == devise primaire
    TP = f"{PUB}/tests/test_publiciste.py"
    try:
        tp_src = open(TP, encoding="utf-8").read()
    except OSError as e:
        bad(f"Publiciste devises · {TP} illisible : {e}"); tp_src = None
    if tp_src is not None:
        mto = re.search(r'assertIn\("([A-Z]{3}) [\d ]+", self\.html\)', tp_src)
        if mto is None:
            bad(f"Publiciste devises · {TP} — oracle « assertIn(\"<devise> …\", self.html) » "
                f"INTROUVABLE (le contrôle prix du test a disparu · régression)")
        elif mto.group(1) == pub_cur[0]:
            good(f"Publiciste devises · {TP} oracle prix == devise primaire #10 ({pub_cur[0]})")
        else:
            bad(f"Publiciste devises · {TP} oracle prix cite {mto.group(1)!r} MAIS devise "
                f"primaire #10 = {pub_cur[0]!r} → aligner le test")

# ============================================================================
# Fiches agents · colonne « Job CI » — chaque nom de job cité DOIT exister dans
# .gitea/workflows/ci.yml (même classe de « vert trompeur », côté câblage CI).
# ----------------------------------------------------------------------------
# Chaque table de livrables des fiches 03_agents/*/AGENT.md porte une colonne
# « Job CI » qui NOMME, mot pour mot et entre backticks, le job Gitea Actions qui
# exécute la suite du module (`rbac-tests`, `fiscal-ecf-tests`, …). Ce nom est
# SAISI À LA MAIN. La source de vérité est la section `jobs:` de ci.yml. Les
# gates existants ne couvraient PAS ce lien : `check_ci_integrity.sh` prouve que
# chaque job de ci.yml est bien CÂBLÉ dans `gate.needs` (INV-A/B), et les blocs
# amont recomputent les COMPTES de tests par suite — mais RIEN ne vérifiait que
# le nom écrit dans la fiche DÉSIGNE un job réel. Renommer un job dans ci.yml
# (ex. `rbac-tests` → `rbac-role-tests`, ou `demo-scenario-tests` mal recopié en
# `demo-scenarios-tests`) laisse la fiche pointer un job FANTÔME : le CI reste
# vert (le vrai job tourne sous son nouveau nom), la doc d'identité de l'agent
# ment en silence. On RECOMPUTE ici l'ensemble des jobs depuis la section `jobs:`
# de ci.yml (jamais une liste à la main) et on exige que chaque token `*-tests`
# cité dans une fiche y figure. Direction fiche→ci.yml (le consommateur) : ci.yml
# peut définir des jobs non cités (légitime), mais une fiche ne peut pas citer un
# job absent.
# ============================================================================
CI_YML = ".gitea/workflows/ci.yml"
try:
    ci_lines = open(CI_YML, encoding="utf-8").read().splitlines()
except OSError as e:
    bad(f"Job CI fiches · {CI_YML} illisible : {e}")
    ci_lines = None

if ci_lines is not None:
    # Ensemble des jobs = clés à 2 espaces sous la clé top-level `jobs:`
    # (la section `on:` — push/pull_request/workflow_dispatch — est AVANT `jobs:`
    #  et donc naturellement exclue). Une clé top-level `^\S` clôt la section.
    jobs = set()
    in_jobs = False
    for line in ci_lines:
        if re.match(r"^jobs:\s*$", line):
            in_jobs = True
            continue
        if in_jobs and re.match(r"^\S", line):   # nouvelle clé top-level
            break
        if in_jobs:
            mj = re.match(r"^  ([A-Za-z0-9_-]+):\s*$", line)
            if mj:
                jobs.add(mj.group(1))
    if not jobs:
        bad(f"Job CI fiches · aucun job détecté dans la section `jobs:` de "
            f"{CI_YML} — parse cassé ou fichier vidé (régression de câblage).")

    fiches_ci = subprocess.run(
        ["git", "ls-files", "03_agents/*/AGENT.md"],
        capture_output=True, text=True).stdout.split()
    tok_re = re.compile(r"`([a-z0-9]+(?:-[a-z0-9]+)*-tests)`")
    seen_jobrefs = 0
    for af in fiches_ci:
        try:
            flines = open(af, encoding="utf-8").read().splitlines()
        except OSError as e:
            bad(f"Job CI fiches · {af} illisible : {e}"); continue
        for ln, line in enumerate(flines, 1):
            for tok in tok_re.findall(line):
                seen_jobrefs += 1
                if jobs and tok in jobs:
                    good(f"Job CI · {af}:{ln} · `{tok}` == job ci.yml")
                elif jobs:
                    bad(f"Job CI · {af}:{ln} · `{tok}` ABSENT de la section "
                        f"`jobs:` de {CI_YML} → job renommé/supprimé, fiche "
                        f"pointe un job fantôme (régénérer la fiche ou ci.yml)")
    if seen_jobrefs == 0:
        bad("Job CI fiches · AUCUN token `*-tests` trouvé dans les fiches — la "
            "disparition de la colonne « Job CI » est elle-même une régression.")

# ============================================================================
# Fiches agents · colonne « Entrée CLI » — le JEU DE SOUS-COMMANDES documenté
# DOIT être l'ensemble EXACT des subparsers argparse du générateur (même classe
# de « vert trompeur », côté surface d'invocation du module).
# ----------------------------------------------------------------------------
# Chaque table de livrables des fiches 03_agents/*/AGENT.md porte une colonne
# « Entrée CLI » qui cite, entre backticks, le point d'entrée du module ET son
# jeu de verbes : `publiciste.py parse|validate|generate|run`,
# `faisabilite_gen.py score|scaffold|generate|batch`, `rbac_apply_plan.py
# build|validate`, … Cette liste est SAISIE À LA MAIN. La source de vérité est
# l'ensemble des `subparsers.add_parser("<verbe>")` du script réel. Les gates
# amont couvrent le COMPTE de tests (colonne « Tests »), le NOM du job CI
# (colonne « Job CI ») et la reproductibilité des artefacts `out/` — mais RIEN
# ne vérifiait que les VERBES écrits dans la fiche sont ceux que le CLI expose
# réellement. Renommer une sous-commande (`generate` → `render`), en AJOUTER une
# (`publish`), en RETIRER une ou les RÉORDONNER laisse la fiche documenter une
# surface d'invocation FANTÔME : le module tourne (le CI appelle le vrai verbe),
# la doc d'identité de l'agent — celle qui lui dit COMMENT lancer son propre
# livrable — ment en silence. Aucune suite tests/ (FONCTIONS du module, jamais
# la prose de fiche) ne l'attrape ; classe explicitement notée en mémoire
# (`agent-fiche-role-attrs-ungated`) : la fiche restitue une donnée data-derived
# (ici la CLI) sur une surface distincte de tout gate.
#
# On RECOMPUTE l'ensemble ordonné des sous-commandes depuis l'AST du script
# (jamais une liste à la main · zéro chiffre/verbe inventé · #6) et on exige
# l'égalité EXACTE (ensemble ET ordre — l'ordre de `add_parser` est celui de
# l'aide argparse) avec ce que la fiche ÉCRIT. Le script est résolu par le
# chemin du module de la MÊME ligne de table (lien `…/05_deliverables_mvp/<path>/
# README.md`) + le basename du CLI → `{D}/<path>/<file>.py`, ce qui lie chaque
# cellule CLI à SON module (pas de collision de basename). Direction fiche→CLI :
# la fiche ne peut pas mentir sur les verbes que le script expose.
# ============================================================================
def _cli_subcommands(pyfile):
    """Sous-commandes argparse d'un script, en ordre source (via AST)."""
    src = open(pyfile, encoding="utf-8").read()
    tree = ast.parse(src, filename=pyfile)
    hits = []
    for node in ast.walk(tree):
        if (isinstance(node, ast.Call)
                and isinstance(node.func, ast.Attribute)
                and node.func.attr == "add_parser"
                and node.args
                and isinstance(node.args[0], ast.Constant)
                and isinstance(node.args[0].value, str)):
            hits.append((node.lineno, node.col_offset, node.args[0].value))
    hits.sort()
    return [v for _, _, v in hits]

# Ligne de table portant À LA FOIS le lien module (…/<path>/README.md) et une
# cellule CLI `\`<file>.py <verbe>|<verbe>|…\`` → on scope aux vraies lignes de
# table (une mention CLI en prose, sans lien module, est ignorée). Les `|`
# séparateurs de colonnes markdown sont échappés `\|` dans la cellule.
cli_pathre = re.compile(r"05_deliverables_mvp/([\w/]+?)/README\.md")
cli_cellre = re.compile(r"`([a-z][a-z0-9_]*\.py) ([a-z][a-z|\\]+)`")
fiches_cli = subprocess.run(
    ["git", "ls-files", "03_agents/*/AGENT.md"],
    capture_output=True, text=True).stdout.split()
seen_cli = 0
for af in fiches_cli:
    try:
        flines = open(af, encoding="utf-8").read().splitlines()
    except OSError as e:
        bad(f"Entrée CLI fiches · {af} illisible : {e}"); continue
    for ln, line in enumerate(flines, 1):
        mp = cli_pathre.search(line)
        mc = cli_cellre.search(line)
        if not (mp and mc):
            continue
        path, cli_file, verbs_raw = mp.group(1), mc.group(1), mc.group(2)
        # retirer les backslashes d'échappement avant de découper les verbes
        claimed = verbs_raw.replace("\\", "").split("|")
        pyfile = f"{D}/{path}/{cli_file}"
        seen_cli += 1
        try:
            actual = _cli_subcommands(pyfile)
        except FileNotFoundError:
            bad(f"Entrée CLI · {af}:{ln} · `{cli_file}` — script INTROUVABLE à "
                f"`{pyfile}` (module déplacé/renommé ? régénérer la fiche)")
            continue
        except SyntaxError as e:
            bad(f"Entrée CLI · {af}:{ln} · `{cli_file}` — AST cassé ({e}) : "
                f"source de vérité illisible")
            continue
        if not actual:
            bad(f"Entrée CLI · {af}:{ln} · `{cli_file}` — la fiche cite les "
                f"verbes {claimed} MAIS le script n'expose AUCUN "
                f"`add_parser(...)` (CLI refondue ? régénérer la fiche)")
        elif claimed == actual:
            good(f"Entrée CLI · {af}:{ln} · `{cli_file}` — verbes "
                 f"{'|'.join(claimed)} == subparsers ({pyfile})")
        else:
            miss = [v for v in actual if v not in claimed]
            extra = [v for v in claimed if v not in actual]
            order_only = set(claimed) == set(actual)
            bad(f"Entrée CLI · {af}:{ln} · `{cli_file}` — la fiche dit "
                f"{claimed} MAIS le CLI expose {actual} "
                f"(manquants={miss} · en trop={extra} · "
                f"seul_ordre_diffère={order_only}) → régénérer la fiche")
if seen_cli == 0:
    bad("Entrée CLI fiches · AUCUNE cellule CLI `<file>.py <verbes>` liée à un "
        "module trouvée dans les fiches — la disparition de la colonne « Entrée "
        "CLI » est elle-même une régression.")

# ============================================================================
# Legal · CONFOTUR — IDENTITÉ des ENTITÉS PORTEUSES ancrée sur CLAUDE.md §Entités
# ----------------------------------------------------------------------------
# Le champ Select `entite_porteuse` du DocType CONFOTUR Application — le menu
# déroulant qui fixe QUELLE entité juridique porte chaque dossier d'incitation —
# offre pour options les 7 entités canoniques du mandat (WAF · WA SRL · AC Arias
# Cuevas · Consortium ECR DR · Helios RD · Ploutos · 9060 QC). Ces options sont
# DÉRIVÉES via `options_source` de confotur_spec.json[entites] et byte-gatées pour
# la REPRODUCTIBILITÉ par check_artifacts (l'artefact se reconstruit depuis le
# spec) — mais JAMAIS ANCRÉES à CLAUDE.md §Entités, leur source faisant autorité.
# Les blocs CONFOTUR amont gatent le COMPTE de champs/rôles et la cross-cohérence
# des PERMISSIONS, aveugles à l'identité de cette liste d'entités.
# Piège #6 : RENOMMER une entité dans CLAUDE.md §Entités (9060 QC→9061 QC) ou dans
# confotur_spec.json[entites] puis régénérer laisse le DocType offrir un menu qui
# CONTREDIT/omet une entité canonique du mandat pendant que check_artifacts reste
# VERT (l'artefact se reconstruit… depuis un spec dérivé) — « vert trompeur » de
# la MÊME classe que la marque SEO org (§Entités) et les tokens branding (#4).
# Aucune suite tests/ (FONCTIONS de génération) ne l'attrape → l'agent ERPNext
# Backend importerait un DocType dont le déroulant liste une entité fantôme ou en
# omet une (la restriction même que le champ `reqd` pose).
# GATE : (a) options de l'artefact == entités CLAUDE.md §Entités (ORDRE exact) ;
# (b) spec[entites] == CLAUDE.md (l'INPUT ancré) ; (c) le spec DÉCLARE l'ancrage
# (entites_source cite CLAUDE.md + chaque entité) ; (d) wiring intact
# (options_source == "entites" · le déroulant DÉRIVE de la liste, pas d'un dur) ;
# (e) README cite l'ancre CLAUDE.md + nomme chaque entité (nom complet OU 1er
# token — l'abréviation « AC » de « AC Arias Cuevas » est un choix éditorial
# toléré · #6). Un claim absent échoue AUSSI (traçabilité #6).
# ============================================================================
try:
    _cm_ce = open("CLAUDE.md", encoding="utf-8").read()
    _sec_ce = re.search(r"^## Entités\s*\n(.*?)(?=^## )", _cm_ce, re.S | re.M)
    if not _sec_ce:
        raise ValueError("section « ## Entités » introuvable")
    cm_ents = [_nfc(x.strip()) for x in re.findall(r"\*\*([^*]+?)\*\*", _sec_ce.group(1))]
    if not cm_ents:
        raise ValueError("aucune entité en gras dans §Entités")
    if len(cm_ents) != len(set(cm_ents)):
        raise ValueError(f"entités non uniques : {cm_ents}")
except (OSError, ValueError) as e:
    bad(f"Confotur entités · CLAUDE.md §Entités illisible/inattendu : {e}"); cm_ents = None
try:
    conf_spec_ce = load("legal/confotur/confotur_spec.json")
    conf_dt_ce = load("legal/confotur/out/doctype_confotur_application.json")
    ep_art = [f for f in conf_dt_ce.get("fields", []) if f.get("fieldname") == "entite_porteuse"]
    ep_spec = None
    for _grp in conf_spec_ce.get("field_groups", []):
        for _f in _grp.get("fields", []):
            if _f.get("fieldname") == "entite_porteuse":
                ep_spec = _f
    spec_ents = [_nfc(str(x).strip()) for x in conf_spec_ce.get("entites", [])]
    ents_src = _nfc(str(conf_spec_ce.get("entites_source", "")))
except (OSError, KeyError, ValueError, TypeError) as e:
    bad(f"Confotur entités · artefact/spec illisible/inattendu : {e}"); cm_ents = None
if cm_ents is not None:
    # (a) ANCRAGE — options du Select de l'artefact == entités CLAUDE.md (ordre exact)
    if len(ep_art) != 1:
        bad(f"Confotur entités · champ `entite_porteuse` non-singleton dans l'artefact "
            f"({len(ep_art)}) → identité du déroulant indéterminable")
    else:
        art_opts = [_nfc(o.strip()) for o in str(ep_art[0].get("options", "")).split("\n") if o.strip()]
        if art_opts == cm_ents:
            good(f"Confotur entités · (a) options DocType `entite_porteuse` "
                 f"({len(art_opts)}) == CLAUDE.md §Entités (ordre exact)")
        else:
            bad(f"Confotur entités · (a) options DocType {art_opts} ≠ CLAUDE.md §Entités "
                f"{cm_ents} → menu périmé (régénérer depuis le spec / aligner CLAUDE.md)")
    # (b) l'INPUT ancré — spec[entites] == CLAUDE.md (ordre exact)
    if spec_ents == cm_ents:
        good(f"Confotur entités · (b) spec[entites] == CLAUDE.md §Entités")
    else:
        bad(f"Confotur entités · (b) spec[entites] {spec_ents} ≠ CLAUDE.md §Entités "
            f"{cm_ents} → l'input du générateur DÉRIVE du mandat")
    # (c) le spec DÉCLARE l'ancrage : entites_source cite CLAUDE.md + chaque entité
    if "CLAUDE.md" not in ents_src:
        bad(f"Confotur entités · (c) `entites_source` NE CITE PAS CLAUDE.md "
            f"(ancrage non déclaré) : {ents_src!r}")
    else:
        _miss_src = [e for e in cm_ents if e not in ents_src]
        if _miss_src:
            bad(f"Confotur entités · (c) `entites_source` cite CLAUDE.md mais OMET "
                f"{_miss_src} → déclaration d'ancrage périmée")
        else:
            good(f"Confotur entités · (c) spec DÉCLARE l'ancrage (entites_source cite "
                 f"CLAUDE.md + les {len(cm_ents)} entités)")
    # (d) wiring intact — le champ dérive ses options de la liste `entites`, pas d'un dur
    if ep_spec is None:
        bad("Confotur entités · (d) champ `entite_porteuse` ABSENT du spec (field_groups)")
    elif ep_spec.get("options_source") != "entites":
        bad(f"Confotur entités · (d) `entite_porteuse.options_source` = "
            f"{ep_spec.get('options_source')!r} ≠ \"entites\" → déroulant DÉCROCHÉ de la "
            f"liste ancrée (options potentiellement écrites en dur)")
    else:
        good("Confotur entités · (d) wiring intact (options_source == \"entites\")")
    # (e) README cite l'ancre CLAUDE.md + nomme chaque entité (nom complet OU 1er token)
    CONF_RD3 = f"{D}/legal/confotur/README.md"
    try:
        conf_rd3 = re.sub(r"\s+", " ", open(CONF_RD3, encoding="utf-8").read())
    except OSError as e:
        bad(f"Confotur entités · {CONF_RD3} illisible : {e}"); conf_rd3 = None
    if conf_rd3 is not None:
        _m_ep = re.search(r"`entite_porteuse`.{0,240}?9060 QC", conf_rd3)
        if not _m_ep:
            bad(f"Confotur entités · (e) ligne `entite_porteuse` INTROUVABLE dans "
                f"{CONF_RD3} (claim absent = régression #6)")
        else:
            _epline = _m_ep.group(0)
            if "CLAUDE.md" not in _epline:
                bad(f"Confotur entités · (e) ligne `entite_porteuse` ne cite pas l'ancre "
                    f"« CLAUDE.md »")
            else:
                _miss_rd = []
                for _e in cm_ents:
                    _first = _e.split()[0]
                    if _e in _epline or re.search(r"(?<![\w-])" + re.escape(_first) + r"(?![\w-])", _epline):
                        continue
                    _miss_rd.append(_e)
                if _miss_rd:
                    bad(f"Confotur entités · (e) README omet {_miss_rd} de la ligne "
                        f"`entite_porteuse` (nom complet ni 1er token)")
                else:
                    good(f"Confotur entités · (e) README cite CLAUDE.md + nomme les "
                         f"{len(cm_ents)} entités")

# ============================================================================
# Fiche ONAPI/Legal (03_agents/onapi_legal/AGENT.md:54) · 2e SURFACE PROSE des
# ENTITÉS PORTEUSES du DocType CONFOTUR — la fiche ÉNUMÈRE À LA MAIN « les 7
# entités de CLAUDE.md #Entités (WAF · WA SRL · AC Arias Cuevas · Consortium ECR
# DR · Helios RD · Ploutos · 9060 QC) ».
# ----------------------------------------------------------------------------
# Le bloc « Confotur entités » ci-dessus ancre le champ Select `entite_porteuse`
# (options de l'artefact · spec[entites] · entites_source · wiring) et gate la
# ligne du README confotur (surface e, ancrée sur `{D}/legal/confotur/README.md`).
# Mais la FICHE agent onapi_legal:54 est une SECONDE surface prose du MÊME menu — un
# COMPTE littéral « les 7 entités » ET l'énumération complète — qu'AUCUN bloc
# n'atteint (le motif (e) ne vise pas 03_agents/). Piège #6 : AJOUTER / RETIRER /
# RENOMMER une entité dans CLAUDE.md §Entités (source ancre) puis régénérer propage
# le menu de l'artefact ET la ligne du README confotur, MAIS la fiche onapi_legal
# reste PÉRIMÉE en silence : elle annoncerait « 7 entités » quand il y en a 8, ou
# nommerait `Ploutos` là où le menu ne l'offre plus — l'agent ERPNext Backend qui
# seede le DocType croirait le déroulant couvrir un périmètre d'entités qu'il ne
# couvre plus (l'invention #6 interdit). Aucune suite tests/ (FONCTIONS de build du
# DocType, pas la prose d'une fiche) n'attrape ce « vert trompeur » — même patron
# d'IDENTITÉ D'UNE LISTE que la marque SEO §Entités, la §4 du RBAC SPEC ou la ligne
# README (e) ci-dessus. On RE-DÉRIVE la liste depuis CLAUDE.md §Entités (source
# ancre · zéro duplication) et on exige de la fiche : (a) le COMPTE littéral ==
# |§Entités| ; (b) l'énumération == §Entités ORDRE EXACT (absent ET fantôme mordus
# par set-diff + comparaison ordonnée) ; (c) l'ancre « CLAUDE.md #Entités » citée ;
# (d) cross-check bonus — la liste de la fiche == options `entite_porteuse` de
# l'artefact byte-gaté (la fiche décrit EXACTEMENT le menu que le DocType offrira).
# Ligne absente = échec (traçabilité #6).
# ============================================================================
LEGAL_FI = "03_agents/onapi_legal/AGENT.md"
try:
    _cm_le = open("CLAUDE.md", encoding="utf-8").read()
    _sec_le = re.search(r"^## Entités\s*\n(.*?)(?=^## )", _cm_le, re.S | re.M)
    if not _sec_le:
        raise ValueError("section « ## Entités » introuvable")
    le_ents = [_nfc(x.strip()) for x in re.findall(r"\*\*([^*]+?)\*\*", _sec_le.group(1))]
    if not le_ents:
        raise ValueError("aucune entité en gras dans §Entités")
    if len(le_ents) != len(set(le_ents)):
        raise ValueError(f"entités non uniques : {le_ents}")
except (OSError, ValueError) as e:
    bad(f"Fiche ONAPI entités · CLAUDE.md §Entités illisible/inattendu : {e}"); le_ents = None
# menu RÉEL de l'artefact byte-gaté (cross-check d)
try:
    _cf_dt_le = load("legal/confotur/out/doctype_confotur_application.json")
    _ep_le = [f for f in _cf_dt_le.get("fields", []) if f.get("fieldname") == "entite_porteuse"]
    le_art_opts = ([_nfc(o.strip()) for o in str(_ep_le[0].get("options", "")).split("\n") if o.strip()]
                   if len(_ep_le) == 1 else None)
except (OSError, KeyError, ValueError, TypeError) as e:
    bad(f"Fiche ONAPI entités · artefact confotur illisible/inattendu : {e}"); le_art_opts = None
le_txt = read_norm(LEGAL_FI)
if le_ents is not None and le_txt is not None:
    m_le = re.search(r"\*\*`entite_porteuse`\*\*.{0,60}?les (\d+) entités de (.+?)\((.+?)\)",
                     le_txt)
    if m_le is None:
        bad(f"Fiche ONAPI entités · {LEGAL_FI} — ligne `entite_porteuse` "
            f"(« les N entités de CLAUDE.md #Entités (…) ») INTROUVABLE/périmée "
            f"(claim absent = régression #6)")
    else:
        # (a) COMPTE littéral == |§Entités|
        n_claim = int(m_le.group(1))
        if n_claim == len(le_ents):
            good(f"Fiche ONAPI entités · (a) compte « {n_claim} entités » == "
                 f"CLAUDE.md §Entités")
        else:
            bad(f"Fiche ONAPI entités · (a) fiche dit « {n_claim} entités » MAIS "
                f"CLAUDE.md §Entités en porte {len(le_ents)} → fiche périmée, régénérer")
        # (b) énumération == §Entités, ORDRE EXACT (absent ET fantôme mordus)
        fi_ents = [_nfc(x.strip()) for x in m_le.group(3).split("·")]
        if fi_ents == le_ents:
            good(f"Fiche ONAPI entités · (b) énumération == CLAUDE.md §Entités "
                 f"(ordre exact · {len(fi_ents)})")
        else:
            _abs = [e for e in le_ents if e not in fi_ents]
            _pha = [e for e in fi_ents if e not in le_ents]
            _detail = []
            if _abs:
                _detail.append(f"absent(s)={_abs}")
            if _pha:
                _detail.append(f"fantôme(s)={_pha}")
            if not _detail:
                _detail.append(f"ordre : fiche {fi_ents} ≠ §Entités {le_ents}")
            bad(f"Fiche ONAPI entités · (b) énumération ≠ CLAUDE.md §Entités "
                f"({' · '.join(_detail)}) → aligner la fiche")
        # (c) l'ancre « CLAUDE.md #Entités » est bien citée sur la ligne
        if "CLAUDE.md" in m_le.group(2) and "#Entités" in m_le.group(2):
            good(f"Fiche ONAPI entités · (c) ancre « CLAUDE.md #Entités » citée")
        else:
            bad(f"Fiche ONAPI entités · (c) la ligne `entite_porteuse` ne cite pas "
                f"l'ancre « CLAUDE.md #Entités » (source={m_le.group(2)!r})")
        # (d) cross-check bonus — la liste de la fiche == menu RÉEL de l'artefact
        if le_art_opts is None:
            bad(f"Fiche ONAPI entités · (d) champ `entite_porteuse` non-singleton "
                f"dans l'artefact → menu réel indéterminable")
        elif fi_ents == le_art_opts:
            good(f"Fiche ONAPI entités · (d) énumération == options `entite_porteuse` "
                 f"de l'artefact (la fiche décrit le menu réel du DocType)")
        else:
            bad(f"Fiche ONAPI entités · (d) fiche {fi_ents} ≠ menu artefact "
                f"{le_art_opts} → la fiche décrit un menu que le DocType n'offre pas")

# ============================================================================
# Chat OTOIA · l'IDENTITÉ de la persona (`Amélie` / voix `multilingual_v2`) et
# des 4 CAPABILITIES OTOIA (`aec.py` + `knowledge.py` + `prompt_engine.py` +
# `chat.py`) — ANCRÉES sur CLAUDE.md §Architecture cible, jamais gatées.
# ----------------------------------------------------------------------------
# out/chat_mount.json (5 configs runtime, une par portail) + out/MANIFEST.json
# sont byte-gatés par check_artifacts : la reproductibilité prouve qu'ils SE
# RECONSTRUISENT depuis chat_spec.json, PAS que leur persona/capabilities ==
# ce que NOMME CLAUDE.md §Architecture cible (« Voix Amélie QC (multilingual_v2) »
# · « OTOIA capabilities : aec.py + knowledge.py + prompt_engine.py + chat.py »).
# Le bloc chat_otoia amont gate la COMPOSITION (portails métier · 1 block⇔1 mount).
# Le SEUL contrôle d'identité vit dans tests/ (test_persona_amelie_sourcee /
# test_capabilities_sourcees_sans_ajout) mais son ORACLE est HARDCODÉ
# (`"Amélie"`, `["aec.py",…]`) — une copie de plus, JAMAIS comparée à CLAUDE.md.
# Piège #6 : renommer la persona (`Amélie`→`Sophie`), changer la voix
# (`multilingual_v2`→…), ou renommer/retirer une capability dans chat_spec.json
# (ou dans CLAUDE.md §Architecture cible) laisse le chat monté dans CHAQUE portail
# annoncer une persona/voix/capabilities qui CONTREDIT le mandat pendant que la
# byte-gate ET les tests restent VERTS (l'artefact se reconstruit fidèlement…
# depuis un spec dérivé ; l'oracle du test est une copie de plus) — « vert
# trompeur » de la classe des tokens branding ancrés sur CLAUDE.md #4 et de la
# marque SEO §Entités. On RE-DÉRIVE persona+capabilities de CLAUDE.md
# §Architecture cible (source unique) et on exige : (a) chat_spec.json
# persona/capabilities == CLAUDE.md ; (b) le spec DÉCLARE l'ancrage (persona.source
# + chaque capability.source citent CLAUDE.md) ; (c) CHACUN des 5 mounts + le
# MANIFEST portent persona/capabilities == CLAUDE.md ; (d) le README cite l'ancre
# §Architecture cible + nomme persona + chaque capability ; (e) l'ORACLE du test
# == CLAUDE.md (set-diff : absent ET en trop) — sinon la copie de copie survit à
# un renommage. État courant : aucune valeur périmée — le défaut est l'ancrage
# ungated.
# ============================================================================
_nfc = lambda s: unicodedata.normalize("NFC", str(s)).strip()
CHAT = f"{D}/frontend/chat_otoia"
try:
    _cm = open("CLAUDE.md", encoding="utf-8").read()
    _mp = re.search(r"Voix\s+(.+?)\s+QC\s*\(([^)]+)\)", _cm)
    _mc = re.search(r"OTOIA capabilities\s*:\s*(.+)", _cm)
    if not _mp or not _mc:
        raise ValueError("« Voix … QC (…) » ou « OTOIA capabilities : … » "
                         "INTROUVABLE dans CLAUDE.md §Architecture cible")
    cm_persona = {"nom": _nfc(_mp.group(1)), "voix": _nfc(_mp.group(2))}
    cm_caps = [c.strip() for c in _mc.group(1).split("+") if c.strip()]
    if len(cm_caps) < 2:
        raise ValueError(f"capabilities CLAUDE.md mal formées : {cm_caps}")
except (OSError, ValueError) as e:
    bad(f"Chat OTOIA · persona/capabilities CLAUDE.md §Architecture cible "
        f"illisible/inattendu : {e}")
    cm_persona = cm_caps = None
try:
    chat_spec = json.load(open(f"{CHAT}/chat_spec.json", encoding="utf-8"))
    chat_mounts = load("frontend/chat_otoia/out/chat_mount.json")
    chat_man = load("frontend/chat_otoia/out/MANIFEST.json")
except (OSError, KeyError, ValueError) as e:
    bad(f"Chat OTOIA · spec/artefacts illisibles/inattendus : {e}")
    chat_spec = None
if cm_persona is not None and chat_spec is not None:
    # (a) ANCRAGE — chat_spec.json persona/capabilities == CLAUDE.md §Architecture cible
    sp_persona = {"nom": _nfc(chat_spec["persona"].get("nom", "")),
                  "voix": _nfc(chat_spec["persona"].get("voix", ""))}
    sp_caps = [str(c.get("module", "")).strip() for c in chat_spec.get("capabilities", [])]
    if sp_persona == cm_persona:
        good(f"Chat OTOIA · (a) spec persona {sp_persona} == CLAUDE.md §Architecture cible")
    else:
        bad(f"Chat OTOIA · (a) spec persona {sp_persona} ≠ CLAUDE.md {cm_persona} "
            f"→ l'assistant monté contredirait le mandat (aligner spec ou CLAUDE.md)")
    if sp_caps == cm_caps:
        good(f"Chat OTOIA · (a) spec capabilities {sp_caps} == CLAUDE.md (ordre exact)")
    else:
        bad(f"Chat OTOIA · (a) spec capabilities {sp_caps} ≠ CLAUDE.md {cm_caps} "
            f"→ surface de capacités inventée/périmée")
    # (b) le spec DÉCLARE l'ancrage : persona.source + chaque capability.source citent CLAUDE.md
    _psrc = str(chat_spec["persona"].get("source", ""))
    if "CLAUDE.md" not in _psrc or cm_persona["nom"] not in _nfc(_psrc):
        bad(f"Chat OTOIA · (b) chat_spec persona.source ne DÉCLARE pas l'ancrage "
            f"CLAUDE.md/{cm_persona['nom']!r} : {_psrc!r}")
    else:
        _bad_src = [c.get("module") for c in chat_spec["capabilities"]
                    if "CLAUDE.md" not in str(c.get("source", ""))]
        if _bad_src:
            bad(f"Chat OTOIA · (b) capabilities sans `source` CLAUDE.md : {_bad_src}")
        else:
            good("Chat OTOIA · (b) spec DÉCLARE l'ancrage CLAUDE.md (persona + chaque capability)")
    # (c) CHAQUE mount + le MANIFEST portent persona/capabilities == CLAUDE.md
    _bad_m = []
    for m in chat_mounts:
        pm = {"nom": _nfc((m.get("persona") or {}).get("nom", "")),
              "voix": _nfc((m.get("persona") or {}).get("voix", ""))}
        if pm != cm_persona or list(m.get("capabilities", [])) != cm_caps:
            _bad_m.append(m.get("portail"))
    man_ok = ({"nom": _nfc((chat_man.get("persona") or {}).get("nom", "")),
               "voix": _nfc((chat_man.get("persona") or {}).get("voix", ""))} == cm_persona
              and list(chat_man.get("capabilities", [])) == cm_caps)
    if not chat_mounts:
        bad("Chat OTOIA · (c) aucun mount → artefact suspect")
    elif _bad_m:
        bad(f"Chat OTOIA · (c) mount(s) {_bad_m} persona/capabilities ≠ CLAUDE.md "
            f"→ chat monté hors mandat")
    elif not man_ok:
        bad("Chat OTOIA · (c) MANIFEST persona/capabilities ≠ CLAUDE.md")
    else:
        good(f"Chat OTOIA · (c) {len(chat_mounts)} mounts + MANIFEST portent "
             f"persona/capabilities == CLAUDE.md")
    # (d) README cite l'ancre §Architecture cible + nomme persona + chaque capability
    CHAT_RD = f"{CHAT}/README.md"
    chat_rd = read_norm(CHAT_RD)
    if chat_rd is not None:
        chat_rd_n = _nfc(chat_rd)
        _mp_rd = re.search(r"Persona.*?§Architecture cible", chat_rd_n)
        _mc_rd = re.search(r"Capabilities.*?§Architecture cible", chat_rd_n)
        if not _mp_rd:
            bad(f"Chat OTOIA · (d) ligne « Persona … §Architecture cible » INTROUVABLE "
                f"dans {CHAT_RD} (claim absent = régression #6)")
        elif cm_persona["nom"] not in _mp_rd.group(0) or cm_persona["voix"] not in _mp_rd.group(0):
            bad(f"Chat OTOIA · (d) README ligne persona omet {cm_persona['nom']!r}/"
                f"{cm_persona['voix']!r}")
        elif not _mc_rd:
            bad(f"Chat OTOIA · (d) ligne « Capabilities … §Architecture cible » INTROUVABLE "
                f"dans {CHAT_RD} (claim absent = régression #6)")
        else:
            _stems = [c[:-3] if c.endswith(".py") else c for c in cm_caps]
            _miss = [s for s in _stems
                     if not re.search(r"(?<![\w.])" + re.escape(s) + r"(?![\w])", _mc_rd.group(0))]
            if _miss:
                bad(f"Chat OTOIA · (d) README ligne capabilities omet {_miss}")
            else:
                good(f"Chat OTOIA · (d) README cite §Architecture cible + persona + "
                     f"{len(cm_caps)} capabilities")
    # (e) l'ORACLE du test == CLAUDE.md (sinon la copie de copie survit à un renommage)
    CHAT_TEST = f"{CHAT}/tests/test_chat_otoia.py"
    try:
        _t = open(CHAT_TEST, encoding="utf-8").read()
    except OSError as e:
        bad(f"Chat OTOIA · (e) {CHAT_TEST} illisible : {e}"); _t = None
    if _t is not None:
        _mtp = re.search(r'spec\["persona"\]\["nom"\],\s*"([^"]+)"', _t)
        _mtv = re.search(r'spec\["persona"\]\["voix"\],\s*"([^"]+)"', _t)
        _mtc = re.search(r"caps,\s*\[([^\]]*)\]", _t)
        if not (_mtp and _mtv and _mtc):
            bad(f"Chat OTOIA · (e) oracle persona/capabilities INTROUVABLE dans {CHAT_TEST} "
                f"(claim absent = régression #6)")
        else:
            t_caps = [x.strip().strip("\"'") for x in _mtc.group(1).split(",") if x.strip()]
            if _nfc(_mtp.group(1)) != cm_persona["nom"] or _nfc(_mtv.group(1)) != cm_persona["voix"]:
                bad(f"Chat OTOIA · (e) oracle du test persona "
                    f"{{{_mtp.group(1)!r},{_mtv.group(1)!r}}} ≠ CLAUDE.md {cm_persona} "
                    f"→ copie de copie périmée")
            elif t_caps != cm_caps:
                bad(f"Chat OTOIA · (e) oracle du test capabilities {t_caps} ≠ CLAUDE.md "
                    f"{cm_caps}")
            else:
                good("Chat OTOIA · (e) oracle du test persona/capabilities == CLAUDE.md")

# ============================================================================
# Fiches agents · le PIED DE PAGE canonique « Voix Amélie QC (multilingual_v2) »
# répété dans CHAQUE fiche 03_agents/*/AGENT.md — ANCRÉ sur CLAUDE.md
# §Architecture cible, jamais gaté.
# ----------------------------------------------------------------------------
# 11 des 13 fiches agents (toutes sauf faisabilite/publiciste, qui n'orchestrent
# pas OTOIA) se terminent par la ligne « - Voix <persona> <qualif> (<voix>) pour
# toute interaction OTOIA ». Elle RECOPIE À LA MAIN les TROIS tokens de la
# contrainte CLAUDE.md §Architecture cible « Voix Amélie QC (multilingual_v2) » :
# le prénom de la persona (`Amélie`), le qualificatif de marque (`QC`) et le
# modèle de voix ElevenLabs (`multilingual_v2`). Le bloc « Chat OTOIA · IDENTITÉ
# de la persona » (ci-dessus) RE-DÉRIVE bien persona+voix de CLAUDE.md, MAIS ne
# confronte QUE le module chat_otoia (spec + out/ + son propre README + l'oracle
# du test) — AVEUGLE aux 11 pieds de page des fiches. L'autre bloc (« Chat OTOIA ·
# IDENTITÉ des configs runtime ») note même explicitement que le « QC » est
# « gardé en littéral — non gaté ici ». Aucune suite tests/ (FONCTIONS du builder,
# jamais la prose des fiches) n'attrape ce « vert trompeur ».
# Piège #6 : le jour où ElevenLabs déprécie `multilingual_v2` (→ `multilingual_v3`)
# ou qu'on renomme la persona/le qualificatif dans CLAUDE.md, ces 11 pieds de page
# annonceraient EN SILENCE une voix/persona que le mandat ne prescrit plus, chaque
# fiche affirmant orchestrer « toute interaction OTOIA » avec le mauvais modèle.
# On RE-DÉRIVE le triplet (persona · qualif · voix) de CLAUDE.md §Architecture cible
# (source unique · zéro duplication — mêmes `nom`/`voix` que le bloc persona
# ci-dessus, plus le qualificatif jusqu'ici jamais ancré) et on exige de CHAQUE
# fiche portant le pied de page que ses trois tokens == CLAUDE.md. Direction
# fiche→CLAUDE.md. La disparition TOTALE du pied de page échoue AUSSI (le
# recensement qui s'évapore est lui-même une régression #6). État courant : aucune
# valeur périmée.
# ============================================================================
if True:
    # RE-DÉRIVATION INDÉPENDANTE de CLAUDE.md (ne dépend PAS du parse persona
    # ci-dessus, dont la regex fige « QC » en dur : un renommage du qualificatif
    # doit mordre ICI, pas seulement via l'INTROUVABLE du bloc persona).
    try:
        _cm_af = open("CLAUDE.md", encoding="utf-8").read()
        # triplet canonique : « Voix <nom> <qualif> (<voix>) » (qualif jamais figé en dur)
        _maf = re.search(r"Voix\s+(\S+)\s+(\S+)\s*\(([^)]+)\)", _cm_af)
        if _maf is None:
            raise ValueError("« Voix <persona> <qualif> (<voix>) » INTROUVABLE "
                             "dans CLAUDE.md §Architecture cible")
        af_nom, af_qual, af_voix = (_nfc(_maf.group(1)), _nfc(_maf.group(2)),
                                    _nfc(_maf.group(3)))
        # cohérence avec le bloc persona quand il a pu parser (zéro duplication —
        # même source, mêmes valeurs) ; s'il a échoué (cm_persona None), on continue.
        if cm_persona is not None and (af_nom != cm_persona["nom"]
                                       or af_voix != cm_persona["voix"]):
            raise ValueError(f"triplet {af_nom!r}/{af_voix!r} incohérent avec le bloc "
                             f"persona {cm_persona} (parse divergent de CLAUDE.md)")
    except (OSError, ValueError) as e:
        bad(f"Fiches agents · pied de page voix CLAUDE.md §Architecture cible "
            f"illisible/inattendu : {e}")
        af_nom = None
    if af_nom is not None:
        _fiches = subprocess.run(["git", "ls-files", "03_agents/*/AGENT.md"],
                                 capture_output=True, text=True).stdout.split()
        _foot_re = re.compile(
            r"Voix\s+(\S+)\s+(\S+)\s*\(([^)]+)\)\s+pour toute interaction OTOIA")
        af_seen = 0
        for _fi in _fiches:
            try:
                _ftxt = open(_fi, encoding="utf-8").read()
            except OSError as e:
                bad(f"Fiches agents · {_fi} illisible : {e}"); continue
            _mfoot = _foot_re.search(_nfc(_ftxt))
            # Fiche sans OTOIA (faisabilite/publiciste) : pas de pied de page attendu.
            if _mfoot is None:
                if "interaction OTOIA" in _ftxt:
                    bad(f"Fiches agents · {_fi} — prose « interaction OTOIA » présente "
                        f"MAIS pied de page « Voix <nom> <qualif> (<voix>) » malformé/"
                        f"INTROUVABLE (attendu {af_nom} {af_qual} ({af_voix}))")
                continue
            af_seen += 1
            got = (_nfc(_mfoot.group(1)), _nfc(_mfoot.group(2)), _nfc(_mfoot.group(3)))
            want = (af_nom, af_qual, af_voix)
            if got == want:
                good(f"Fiches agents · {_fi} — pied de page « Voix {got[0]} {got[1]} "
                     f"({got[2]}) » == CLAUDE.md §Architecture cible")
            else:
                bad(f"Fiches agents · {_fi} — pied de page dit « {got[0]} {got[1]} "
                    f"({got[2]}) » MAIS CLAUDE.md §Architecture cible dit « {want[0]} "
                    f"{want[1]} ({want[2]}) » → persona/qualif/voix périmé (mettre la "
                    f"fiche à jour)")
        if af_seen == 0:
            bad("Fiches agents · AUCUNE fiche ne porte le pied de page « Voix "
                f"{af_nom} {af_qual} ({af_voix}) pour toute interaction OTOIA » — la "
                f"surface d'identité voix OTOIA s'est évaporée (régression #6)")

# ============================================================================
# Mobile · le SDK MAJEUR Expo (« Expo 54 ») — ANCRÉ sur la roadmap Sprint 5 l.56,
# transcrit À LA MAIN dans le README sans AUCUN gate d'IDENTITÉ ni d'ancrage.
# ----------------------------------------------------------------------------
# La version MAJEURE du SDK Expo que l'app compagnon cible (`expoSdkMajor`) est une
# donnée DATA-DERIVED : le générateur émet out/app_config.json (byte-gaté par
# check_artifacts ⇒ faisant autorité) portant `expo.extra.expoSdkMajor`, recopié
# dans out/MANIFEST.json (`app.expo_sdk_major`), lui-même dérivé de
# mobile_spec.json (`app.expo_sdk_major`) dont la SOURCE FAISANT AUTORITÉ est la
# roadmap Sprint 5 l.56 « **Rebuild Expo 54** » (le spec le DÉCLARE via
# `app.expo_sdk_source`). Le README:4/19/35/72 recopie ce « Expo 54 » CINQ fois À
# LA MAIN — dont la ligne de source « Expo SDK **54** | roadmap Sprint 5 l.56 ».
# Le bloc « Mobile · récap de l'app Expo » amont ne gate QUE le quadruplet
# onglets/rôles/langues/identifiants a_confirmer (comptes de MANIFEST.counts) —
# AVEUGLE au majeur du SDK. Aucun gate ne comparait cette valeur à l'artefact ni
# ne l'ancrait à la roadmap : BUMPER le SDK (Expo 55 sort · EAS build cible 55)
# dans le spec/artefact SANS toucher au README (ou l'inverse), ou dériver la
# roadmap de l'artefact, laisse la prose PÉRIMÉE en silence pendant que le graphe
# byte-gaté dit autre chose — l'agent Mobile lancerait `eas build` sur le mauvais
# SDK (le rebuild même que la ligne roadmap prescrit) — « vert trompeur » qu'aucune
# suite tests/ (qui teste des FONCTIONS de génération, jamais la prose ni l'ancre à
# la roadmap) n'attrape. Même patron que l'ancrage §Entités/CLAUDE.md (SEO org) ou
# les `roadmap_line` de la recette : on RE-DÉRIVE le majeur de SON artefact
# byte-gaté (zéro duplication du contrat #6), on ANCRE à la roadmap faisant
# autorité, et on exige que TOUTE mention prose == cette valeur. Cohérences
# croisées bonus (mordent une chaîne INTERNEMENT incohérente) : artefact ⇔ MANIFEST
# ⇔ spec d'accord ; le spec DÉCLARE l'ancre roadmap. Un claim absent échoue AUSSI.
# ============================================================================
MOB_SDK_RD = f"{D}/mobile/app_config/README.md"
try:
    sdk = int(load("mobile/app_config/out/app_config.json")["expo"]["extra"]["expoSdkMajor"])
    _mman = load("mobile/app_config/out/MANIFEST.json")
    _mspec = json.load(open(f"{D}/mobile/app_config/mobile_spec.json", encoding="utf-8"))
except (OSError, KeyError, ValueError, TypeError) as e:
    bad(f"Mobile Expo SDK · artefact/spec illisible/inattendu : {e}"); sdk = None
if sdk is not None:
    # (a) cohérence interne : artefact ⇔ MANIFEST ⇔ spec d'accord sur le majeur
    man_sdk = _mman.get("app", {}).get("expo_sdk_major")
    spec_sdk = _mspec.get("app", {}).get("expo_sdk_major")
    if man_sdk != sdk or spec_sdk != sdk:
        bad(f"Mobile Expo SDK · incohérence artefact={sdk} MANIFEST={man_sdk} "
            f"spec={spec_sdk} → chaîne de dérivation cassée")
    else:
        good(f"Mobile Expo SDK · artefact ⇔ MANIFEST ⇔ spec d'accord (Expo {sdk})")
    # (b) ANCRAGE roadmap : la roadmap faisant autorité dit « Rebuild Expo <N> »
    try:
        _rm = re.sub(r"\s+", " ",
                     open("04_roadmap/ROADMAP_8_WEEKS_OR_LESS.md", encoding="utf-8").read())
    except OSError as e:
        bad(f"Mobile Expo SDK · roadmap illisible : {e}"); _rm = None
    if _rm is not None:
        _rmm = re.search(r"Rebuild Expo (\d+)", _rm)
        if _rmm is None:
            bad(f"Mobile Expo SDK · « Rebuild Expo <N> » INTROUVABLE dans la roadmap "
                f"(attendu {sdk}) → ancre morte")
        elif int(_rmm.group(1)) == sdk:
            good(f"Mobile Expo SDK · roadmap « Rebuild Expo {sdk} » == artefact (ancre vive)")
        else:
            bad(f"Mobile Expo SDK · roadmap dit « Rebuild Expo {_rmm.group(1)} » MAIS "
                f"artefact dit {sdk} → régénérer depuis la roadmap")
    # (c) le spec DÉCLARE son ancrage roadmap (expo_sdk_source cite roadmap + Expo N)
    _ssrc = str(_mspec.get("app", {}).get("expo_sdk_source", ""))
    if "roadmap" not in _ssrc.lower() or re.search(rf"Expo {sdk}\b", _ssrc) is None:
        bad(f"Mobile Expo SDK · spec.app.expo_sdk_source n'ancre pas roadmap+Expo {sdk} "
            f"(={_ssrc!r}) → ancrage déclaré manquant")
    else:
        good("Mobile Expo SDK · spec DÉCLARE l'ancrage roadmap (expo_sdk_source)")
    # (d) README prose : TOUTE mention « Expo [SDK] N » == artefact (aucune périmée)
    mob_sdk_rd = read_norm(MOB_SDK_RD)
    if mob_sdk_rd is not None:
        nums = [int(x) for x in re.findall(r"Expo(?:\s+SDK)?\s*\*{0,2}(\d+)", mob_sdk_rd)]
        if not nums:
            bad(f"Mobile Expo SDK · {MOB_SDK_RD} — aucune mention « Expo <N> » "
                f"(attendu {sdk}) · claim absent = régression #6")
        else:
            stale = sorted({n for n in nums if n != sdk})
            if stale:
                bad(f"Mobile Expo SDK · {MOB_SDK_RD} — mention(s) périmée(s) Expo {stale} "
                    f"MAIS artefact {sdk} → régénérer le README")
            else:
                good(f"Mobile Expo SDK · {MOB_SDK_RD} — {len(nums)} mention(s) « Expo {sdk} » "
                     f"== artefact")
        # (e) la ligne de source du tableau NOMME l'ancre roadmap à côté du majeur
        if re.search(rf"Expo SDK \*\*{sdk}\*\*[^|]*\|[^|]*roadmap", mob_sdk_rd) is None:
            bad(f"Mobile Expo SDK · {MOB_SDK_RD} — la ligne « Expo SDK **{sdk}** » ne cite "
                f"pas son ancre `roadmap` (Sprint 5 l.56) → ancrage prose manquant")
        else:
            good(f"Mobile Expo SDK · {MOB_SDK_RD} — « Expo SDK **{sdk}** » cite l'ancre roadmap")

# ============================================================================
# Fiches agents + master prompt · IDENTITÉ de l'endpoint GPU RunPod ComfyUI —
# le pod `t68akvx80wv092` (Flux dev fp8) cité DOIT == l'inventaire maître
# AGENTS_EXISTING_ASSETS.md (même classe de « vert trompeur », côté infra GPU).
# ----------------------------------------------------------------------------
# Les fiches BIM/Rendu et le master prompt NOMMENT, entre backticks, l'ID du pod
# RunPod ComfyUI qui exécute le rendu Flux (`t68akvx80wv092`) + le tag du modèle
# (« Flux dev fp8 »). Ces valeurs sont SAISIES À LA MAIN ; la source de vérité
# in-repo est le bullet §1 de AGENTS_EXISTING_ASSETS.md — « le fichier maître
# en-repo » que les fiches CITENT explicitement comme leur ancre. Aucun gate ne
# couvrait ce lien : check_docs ne voit qu'un lien Markdown, guard_constraints
# qu'un terme interdit, check_artifacts qu'un out/ — un ID de pod n'est aucun des
# trois. Or un pod RunPod est RE-CRÉÉ avec un NOUVEL id (le cas le plus courant) :
# l'inventaire maître se met à jour, les 6 citations en fiches se PÉRIMENT en
# silence → l'agent Rendu/BIM câblerait le batch Flux vers un endpoint GPU MORT
# (l'invention même que #6 interdit). Aucune suite tests/ (FONCTIONS du module,
# jamais la prose de fiche) ne l'attrape ; classe notée en mémoire
# (`agent-fiche-role-attrs-ungated`) : la fiche restitue une donnée data-derived
# (ici l'infra GPU) sur une surface distincte de tout gate.
#
# On RECOMPUTE l'id + le tag depuis le bullet maître (jamais une liste à la main
# · #6). Direction fiche→inventaire (le consommateur) : toute fiche/master prompt
# qui MENTIONNE RunPod doit citer l'id EXACT (tout token backtické de forme pod —
# alphanum minuscule ≥12 avec un chiffre — == canonique) + le tag Flux EXACT. La
# disparition TOTALE de la surface (plus aucune mention RunPod) échoue AUSSI.
# ============================================================================
RUNPOD_SRC = "AGENTS_EXISTING_ASSETS.md"
try:
    _rp_src = re.sub(r"\s+", " ", open(RUNPOD_SRC, encoding="utf-8").read())
except OSError as e:
    bad(f"RunPod endpoint · {RUNPOD_SRC} illisible : {e}"); _rp_src = None

if _rp_src is not None:
    _rp_hits = re.findall(
        r"RunPod ComfyUI\*\*\s*endpoint\s*`([a-z0-9]+)`\s*\((Flux[^)]+)\)", _rp_src)
    if len(_rp_hits) != 1:
        bad(f"RunPod endpoint · bullet canonique INTROUVABLE ou en double dans "
            f"{RUNPOD_SRC} (attendu 1, trouvé {len(_rp_hits)}) → source d'ancrage "
            f"cassée, impossible de recomputer l'id du pod GPU")
    else:
        pod_id, flux_tag = _rp_hits[0]
        flux_tag = flux_tag.strip()
        flux_norm = re.sub(r"\s+", " ", flux_tag).lower()
        pod_shape = re.compile(r"`([a-z0-9]{12,})`")
        rp_files = subprocess.run(
            ["git", "ls-files", "03_agents/*/AGENT.md"],
            capture_output=True, text=True).stdout.split()
        rp_files.append("02_master_prompt/MASTER_PROMPT_DTP_v2.md")
        rp_citers = 0
        for rf in rp_files:
            try:
                rtxt = re.sub(r"\s+", " ", open(rf, encoding="utf-8").read())
            except OSError as e:
                bad(f"RunPod endpoint · {rf} illisible : {e}"); continue
            if "RunPod" not in rtxt:
                continue                       # fiche ne cite pas l'infra GPU (ok)
            rp_citers += 1
            toks = [t for t in pod_shape.findall(rtxt) if re.search(r"\d", t)]
            if not toks:
                bad(f"RunPod endpoint · {rf} mentionne RunPod mais ne cite AUCUN id "
                    f"de pod backtické (attendu `{pod_id}`) → citation incomplète")
            else:
                stale = sorted({t for t in toks if t != pod_id})
                if stale:
                    bad(f"RunPod endpoint · {rf} cite l'id périmé {stale} MAIS "
                        f"l'inventaire maître dit `{pod_id}` → pod re-créé / mal "
                        f"recopié, endpoint GPU fantôme (régénérer la fiche)")
                else:
                    good(f"RunPod endpoint · {rf} · {len(toks)} citation(s) "
                         f"`{pod_id}` == {RUNPOD_SRC}")
            if flux_norm not in rtxt.lower():
                bad(f"RunPod endpoint · {rf} cite le pod mais PAS le tag modèle "
                    f"« {flux_tag} » (== {RUNPOD_SRC}) → tag Flux périmé/absent")
            else:
                good(f"RunPod endpoint · {rf} · tag modèle « {flux_tag} » == inventaire")
        if rp_citers == 0:
            bad(f"RunPod endpoint · AUCUNE fiche/master prompt ne mentionne RunPod — "
                f"la surface d'identité de l'endpoint GPU s'est évaporée (régression #6)")

# ============================================================================
# Fiscal · e-CF DGII — IDENTITÉ des ÉVÈNEMENTS D'ÉMISSION (le « cœur du livrable »)
# ----------------------------------------------------------------------------
# La section §Cross-cohérence du README (05_deliverables_mvp/fiscal/ecf_dgii/
# README.md:40-59) — présentée comme « le cœur du livrable » — transcrit À LA MAIN
# l'IDENTITÉ des emission_events d'out/ecf_plan.json (byte-gaté par check_artifacts
# ⇒ artefact faisant autorité) :
#   • L48  l'ENSEMBLE des évènements qui émettent un e-CF (« seulement réservation
#          et contrat ») + l'ENSEMBLE exclu « on ne facture pas un brouillon
#          (lead/visite/devis) » — la RAISON D'ÊTRE du module : jamais de facture
#          sur un brouillon (états SOUMIS uniquement · doc_status=1)
#   • L50  l'ENSEMBLE des base_field (« montant_reservation, montant_contrat »)
#   • L53  le rôle UNIQUE (« compta-fiscaliste-ecf » · « OTO Compta Fiscaliste
#          eCF » · portail compta)
#   • L59  la FormaPago défaut (« = 3 (Tarjeta) » · Cardnet #10)
# Le SEUL gate fiscal existant couvre le CONTRAT DE FORMAT e-NCF + le compte de
# tests ; le bloc racine, le seul agrégat « e-CF 39 tests ». RIEN ne confronte
# cette identité à l'artefact. Dérive la plus grave, #6/#5 : AJOUTER un
# emission_event sur un état BROUILLON (ex. `devis`) — l'anti-pattern EXACT que le
# module interdit (« on ne facture pas un brouillon ») — pendant que L48 dit encore
# « seulement réservation et contrat » → l'agent ERPNext Backend câblerait une
# facturation sur brouillon (e-CF émis avant soumission/encaissement). Aucune suite
# tests/ (FONCTIONS de résolution/composition, jamais la prose vs l'artefact) ne
# l'attrape — classe « vert trompeur » notée en mémoire.
#
# On RECOMPUTE les ENSEMBLES depuis ecf_plan.json (jamais une liste à la main · #6)
# et EXIGE que le README les reflète EXACTEMENT (set-diff : ni fantôme ni manquant ;
# accents/casse normalisés). Le rôle est en outre DOUBLE-ANCRÉ sur rbac_50_roles
# (id → portail compta → nom ERPNext concordant). Cohérences croisées en bonus
# (mordent un plan INTERNEMENT incohérent) : rôle UNIQUE sur tous les évènements ·
# défaut ∈ catalogue FormaPago · ensembles NON VIDES. Un claim absent échoue AUSSI
# (traçabilité #6).
# ============================================================================
ECF_RD2 = f"{D}/fiscal/ecf_dgii/README.md"
try:
    _ecfp = load("fiscal/ecf_dgii/out/ecf_plan.json")
    _ecf_ev = _ecfp["emission_events"]
    if not _ecf_ev:
        raise ValueError("emission_events vide")
    ecf_updvals = {e["update_value"] for e in _ecf_ev}
    ecf_bases   = {e["base_field"]   for e in _ecf_ev}
    ecf_roleids = {e["role_id"]      for e in _ecf_ev}
    ecf_rolenms = {e["erpnext_role_name"] for e in _ecf_ev}
    _ecf_fpd    = _ecfp["forma_pago_defaut"]
    _ecf_fpcat  = {str(f["code"]): f["label"] for f in _ecfp["formas_pago"]}
except (OSError, KeyError, TypeError, ValueError, json.JSONDecodeError) as e:
    bad(f"Fiscal e-CF évènements · ecf_plan.json illisible/inattendu : {e}")
    _ecf_ev = None

if _ecf_ev is not None:
    def _ecf_ai(s):  # accent-insensible · minuscule · sans espaces
        return "".join(c for c in unicodedata.normalize("NFKD", str(s))
                       if unicodedata.category(c) != "Mn").lower().replace(" ", "")
    try:
        ecf_doc = re.sub(r"\s+", " ", open(ECF_RD2, encoding="utf-8").read())
    except OSError as e:
        bad(f"Fiscal e-CF évènements · {ECF_RD2} illisible : {e}"); ecf_doc = None

    # (0) cohérence interne du plan : rôle UNIQUE + défaut ∈ catalogue FormaPago
    if len(ecf_roleids) != 1 or len(ecf_rolenms) != 1:
        bad(f"Fiscal e-CF évènements · plan INCOHÉRENT : rôle non unique sur les "
            f"évènements (ids={sorted(ecf_roleids)} noms={sorted(ecf_rolenms)}) — "
            f"le README promet un « rôle dédié » unique")
    _fp_code = str(_ecf_fpd.get("code"))
    if _fp_code not in _ecf_fpcat:
        bad(f"Fiscal e-CF évènements · forma_pago_defaut.code={_fp_code!r} ABSENT "
            f"du catalogue formas_pago {sorted(_ecf_fpcat)} (plan incohérent)")

    if ecf_doc is not None:
        # (1) ENSEMBLE des évènements émetteurs — L48 « seulement X et Y »
        m_bill = re.search(r"seulement\s+([^.]+?)\.", ecf_doc)
        want = {_ecf_ai(x) for x in ecf_updvals}
        if m_bill is None:
            bad(f"Fiscal e-CF évènements · {ECF_RD2} — énumération « seulement … » "
                f"INTROUVABLE (attendu {sorted(ecf_updvals)})")
        else:
            bill = {_ecf_ai(x) for x in re.split(r"\s+et\s+|,\s*", m_bill.group(1)) if x.strip()}
            if bill == want:
                good(f"Fiscal e-CF évènements · émetteurs README == ecf_plan "
                     f"({sorted(ecf_updvals)})")
            else:
                bad(f"Fiscal e-CF évènements · émetteurs README {sorted(bill)} ≠ "
                    f"ecf_plan {sorted(want)} (fantôme={sorted(bill-want)} "
                    f"manquant={sorted(want-bill)}) → régénérer le README")
        # (2) ENSEMBLE exclu (brouillons) DISJOINT des émetteurs — anti-facture-brouillon
        m_excl = re.search(r"on ne facture pas un brouillon\s*\(([^)]+)\)", ecf_doc)
        if m_excl is None:
            bad(f"Fiscal e-CF évènements · {ECF_RD2} — clause « on ne facture pas un "
                f"brouillon (…) » INTROUVABLE (raison d'être #6 · traçabilité)")
        else:
            excl = {_ecf_ai(x) for x in re.split(r"[/,]", m_excl.group(1)) if x.strip()}
            clash = excl & want
            if clash:
                bad(f"Fiscal e-CF évènements · un état déclaré BROUILLON {sorted(clash)} "
                    f"est ÉMETTEUR dans ecf_plan → FACTURE SUR BROUILLON (l'anti-pattern "
                    f"que le module interdit · #6) OU README auto-contradictoire")
            else:
                good(f"Fiscal e-CF évènements · brouillons {sorted(excl)} ∩ émetteurs = ∅ "
                     f"(jamais de facture sur brouillon)")
        # (3) ENSEMBLE des base_field — L50 « (montant_reservation, montant_contrat) »
        m_base = re.search(r"champ \*\*Currency réel\*\*[^(]*\(([^)]+)\)", ecf_doc)
        if m_base is None:
            bad(f"Fiscal e-CF évènements · {ECF_RD2} — énumération des base_field "
                f"INTROUVABLE (attendu {sorted(ecf_bases)})")
        else:
            docbases = set(re.findall(r"`([^`]+)`", m_base.group(1)))
            if docbases == ecf_bases:
                good(f"Fiscal e-CF évènements · base_field README == ecf_plan "
                     f"({sorted(ecf_bases)})")
            else:
                bad(f"Fiscal e-CF évènements · base_field README {sorted(docbases)} ≠ "
                    f"ecf_plan {sorted(ecf_bases)} (fantôme={sorted(docbases-ecf_bases)} "
                    f"manquant={sorted(ecf_bases-docbases)})")
        # (4) rôle UNIQUE : backtické + nom ERPNext + DOUBLE-ANCRÉ portail compta (RBAC)
        _rid = next(iter(ecf_roleids)); _rnm = next(iter(ecf_rolenms))
        if f"`{_rid}`" not in ecf_doc:
            bad(f"Fiscal e-CF évènements · rôle dédié `{_rid}` (== ecf_plan) NON cité "
                f"backtické dans le README")
        elif _rnm not in ecf_doc:
            bad(f"Fiscal e-CF évènements · nom ERPNext « {_rnm} » (== ecf_plan) ABSENT "
                f"du README")
        else:
            try:
                _rbac = load("rbac/rbac_50_roles.json")["roles"]
                _rrec = next((r for r in _rbac if r.get("id") == _rid), None)
            except (OSError, KeyError, TypeError, json.JSONDecodeError) as e:
                bad(f"Fiscal e-CF évènements · rbac_50_roles illisible : {e}"); _rrec = "ERR"
            if _rrec is None:
                bad(f"Fiscal e-CF évènements · rôle `{_rid}` (ecf_plan) INTROUVABLE dans "
                    f"rbac_50_roles → rôle Compta fantôme")
            elif _rrec == "ERR":
                pass
            elif _rrec.get("portail") != "compta":
                bad(f"Fiscal e-CF évènements · rôle `{_rid}` portail={_rrec.get('portail')!r} "
                    f"≠ « compta » (le README exige le portail compta)")
            elif _rrec.get("erpnext_role_name") != _rnm:
                bad(f"Fiscal e-CF évènements · nom ERPNext discordant ecf_plan « {_rnm} » "
                    f"vs RBAC « {_rrec.get('erpnext_role_name')} »")
            else:
                good(f"Fiscal e-CF évènements · rôle unique `{_rid}` (« {_rnm} ») == "
                     f"ecf_plan == RBAC (portail compta)")
        # (5) FormaPago défaut — L59 « = 3 (Tarjeta) » · Cardnet #10
        m_fp = re.search(r"FormaPago défaut\s*=\s*(\d+)\s*\(([^)]+)\)", ecf_doc)
        _lbl = _ecf_fpcat.get(_fp_code, "")
        if m_fp is None:
            bad(f"Fiscal e-CF évènements · {ECF_RD2} — « FormaPago défaut = N (…) » "
                f"INTROUVABLE (attendu {_fp_code} / {_lbl})")
        elif m_fp.group(1) != _fp_code:
            bad(f"Fiscal e-CF évènements · FormaPago défaut README ={m_fp.group(1)} "
                f"MAIS ecf_plan dit ={_fp_code} ({_lbl}) → défaut périmé")
        elif _ecf_ai(m_fp.group(2)) not in _ecf_ai(_lbl):
            bad(f"Fiscal e-CF évènements · FormaPago défaut label README "
                f"« {m_fp.group(2)} » incohérent avec le catalogue « {_lbl} » (code {_fp_code})")
        elif "cardnet" not in _ecf_ai(_ecf_fpd.get("motif", "")):
            bad(f"Fiscal e-CF évènements · forma_pago_defaut.motif ne cite plus "
                f"« Cardnet » (#10) → justification du défaut évaporée")
        else:
            good(f"Fiscal e-CF évènements · FormaPago défaut = {_fp_code} "
                 f"({_lbl}) · Cardnet #10 == ecf_plan")

# ============================================================================
# CLAUDE.md §VPS · IDENTITÉ des IDENTIFIANTS D'INFRASTRUCTURE (IP + conteneurs
# Docker) — même classe de « vert trompeur » que l'endpoint RunPod, côté serveur.
# ----------------------------------------------------------------------------
# CLAUDE.md §VPS FIXE l'infrastructure de production du mandat :
#   • l'IP du VPS `153.75.250.214`
#   • les DEUX conteneurs Docker `erpnext-backend-1` / `erpnext-frontend-1`
# Ces identifiants sont RECOPIÉS À LA MAIN dans quantité de docs — README.md,
# le master prompt, AGENTS_EXISTING_ASSETS.md, la fiche `mobile` (backend cible de
# l'API), RBAC_50_ROLES_SPEC.md (run-book d'import), tests/README.md (base URL
# Playwright), ci/README.md (instance Gitea) — sans AUCUN ancrage vers CLAUDE.md.
# Aucun gate ne couvrait ce lien : check_docs ne voit qu'un lien Markdown,
# guard_constraints qu'un terme interdit, check_artifacts qu'un out/ — une IP ni un
# nom de conteneur n'est aucun des trois. Or une MIGRATION serveur (nouvelle IP) ou
# un RENOMMAGE du projet compose (`erpnext_*` → `oto_*`) est un évènement ops
# COURANT : CLAUDE.md §VPS se met à jour, les ~10 copies se PÉRIMENT en silence →
# un agent câblerait l'API mobile / les tests e2e / le hand-off d'import vers un
# serveur MORT ou un conteneur FANTÔME (l'invention même que #6 interdit). Aucune
# suite tests/ (FONCTIONS des modules, jamais la prose infra) ne l'attrape — classe
# notée en mémoire (`agent-fiche-role-attrs-ungated`, surface infra ~ RunPod).
#
# On RE-DÉRIVE l'IP + l'ENSEMBLE des conteneurs de CLAUDE.md §VPS (jamais une liste
# à la main · #6). Direction copie→source : sur TOUTE doc .md du mandat (hors
# CLAUDE.md = la source, hors 05_activity_log/ + daily_reports/ = journaux
# historiques figés, à ne pas réécrire), tout token IPv4 DOIT == l'IP canonique et
# tout token conteneur `erpnext-<x>-1` DOIT ∈ l'ensemble canonique. Le corpus ne
# porte QUE ces valeurs (aucune autre IPv4, aucun autre conteneur — vérifié) : un
# écart = une copie périmée. Le write-path interdit `/var/www/html/static` relève
# de guard_constraints (#interdits), non de ce gate d'identité. Garde
# anti-évaporation : l'IP ET ≥1 conteneur canonique doivent rester cités quelque
# part (sinon la surface d'identité du VPS a silencieusement disparu · #6).
# ============================================================================
VPS_SRC = "CLAUDE.md"
try:
    _vps_cm = open(VPS_SRC, encoding="utf-8").read()
except OSError as e:
    bad(f"VPS infra · {VPS_SRC} illisible : {e}"); _vps_cm = None

if _vps_cm is not None:
    _vps_sec = re.search(r"^## VPS\s*\n(.*?)(?=^## |\Z)", _vps_cm, re.S | re.M)
    if _vps_sec is None:
        bad(f"VPS infra · section « ## VPS » INTROUVABLE dans {VPS_SRC} → ancre "
            f"d'infra cassée, impossible de recomputer IP/conteneurs")
    else:
        _vps_body = _vps_sec.group(1)
        _ip_m = re.search(r"\bIP\s+(\d{1,3}(?:\.\d{1,3}){3})\b", _vps_body)
        _dk_m = re.search(r"Docker\s*\(([^)]+)\)", _vps_body)
        if _ip_m is None or _dk_m is None:
            bad(f"VPS infra · §VPS ne livre pas l'IP et/ou les conteneurs Docker "
                f"(IP={_ip_m and _ip_m.group(1)} · Docker={_dk_m and _dk_m.group(1)}) "
                f"→ format de §VPS modifié, recompute impossible")
        else:
            vps_ip = _ip_m.group(1)
            vps_containers = {c.strip() for c in _dk_m.group(1).split(",")
                              if re.fullmatch(r"[a-z0-9]+-[a-z]+-1", c.strip())}
            # Enveloppe de forme DÉRIVÉE des stems de service canoniques
            # (backend/frontend), PAS du préfixe compose littéral `erpnext-` : un
            # RENOMMAGE du projet compose (`erpnext-backend-1` → `oto-backend-1`)
            # dans une copie DOIT être vu comme fantôme — l'ancrer sur `erpnext-`
            # le rendrait invisible (le token renommé ne matcherait plus).
            _stems = sorted({m.group(1) for c in vps_containers
                             if (m := re.fullmatch(r"[a-z0-9]+-([a-z]+)-1", c))})
            if not vps_containers or not _stems:
                bad(f"VPS infra · §VPS « Docker (…) » ne contient aucun conteneur de "
                    f"forme `<projet>-<service>-1` (lu {_dk_m.group(1)!r}) → format modifié")
            else:
                _cont_shape = re.compile(r"[a-z0-9]+-(?:" + "|".join(_stems) + r")-1")
                _ip_shape = re.compile(r"\b\d{1,3}(?:\.\d{1,3}){3}\b")
                # EXCEPTION serveur de secours : une IPv4 EXPLICITEMENT étiquetée
                # « backup » (ex. « VPS backup 153.75.232.237 · rsync … ») désigne un
                # SECOND serveur légitime, pas une migration de l'IP primaire. On la
                # tolère — MAIS le label « backup » reste OBLIGATOIRE : une IP primaire
                # migrée s'écrirait « IP <nouvelle> », jamais « backup <nouvelle> », donc
                # la détection de dérive-migration est préservée. Doc root-owned de Michel
                # (non éditable par le worker) ; signaler à Michel de porter cette IP dans
                # CLAUDE.md §VPS pour rétablir la couverture SSOT complète.
                _backup_ip_shape = re.compile(
                    r"\bbackup\s+(\d{1,3}(?:\.\d{1,3}){3})\b", re.IGNORECASE)
                vps_files = [f for f in subprocess.run(
                    ["git", "ls-files", "*.md"],
                    capture_output=True, text=True).stdout.split()
                    if f != "CLAUDE.md"
                    and not f.startswith("05_activity_log/")
                    and not f.startswith(f"{D}/daily_reports/")]
                ip_seen = cont_seen = 0
                for vf in vps_files:
                    try:
                        vtxt = open(vf, encoding="utf-8").read()
                    except OSError as e:
                        bad(f"VPS infra · {vf} illisible : {e}"); continue
                    # (a) IPv4 : toute IP citée == IP canonique du §VPS
                    #     (hors IP explicitement étiquetées « backup <ip> » = serveurs de secours)
                    _backup_ips = set(_backup_ip_shape.findall(vtxt))
                    ips = set(_ip_shape.findall(vtxt))
                    if ips:
                        ip_seen += 1
                        stale_ip = sorted(ips - {vps_ip} - _backup_ips)
                        if stale_ip:
                            bad(f"VPS infra · {vf} cite l'IP périmée {stale_ip} MAIS "
                                f"CLAUDE.md §VPS dit `{vps_ip}` → serveur migré / IP "
                                f"mal recopiée (mettre la doc à jour)")
                        else:
                            good(f"VPS infra · {vf} · IP `{vps_ip}` == §VPS")
                    # (b) conteneurs Docker : tout `erpnext-<x>-1` ∈ ensemble canonique
                    conts = set(_cont_shape.findall(vtxt))
                    if conts:
                        cont_seen += 1
                        phantom = sorted(conts - vps_containers)
                        if phantom:
                            bad(f"VPS infra · {vf} cite le(s) conteneur(s) fantôme(s) "
                                f"{phantom} MAIS §VPS dit {sorted(vps_containers)} → "
                                f"projet compose renommé / nom mal recopié")
                        else:
                            good(f"VPS infra · {vf} · conteneur(s) {sorted(conts)} "
                                 f"⊆ §VPS {sorted(vps_containers)}")
                if ip_seen == 0:
                    bad(f"VPS infra · AUCUNE doc du mandat ne cite l'IP `{vps_ip}` — la "
                        f"surface d'identité du VPS s'est évaporée (régression #6)")
                if cont_seen == 0:
                    bad(f"VPS infra · AUCUNE doc ne cite de conteneur Docker "
                        f"`erpnext-<x>-1` — surface d'identité conteneur évaporée (#6)")

# ============================================================================
# Legal · CONFOTUR — IDENTITÉ des champs DÉRIVÉS du WORKFLOW (`dossier_vente` / `estado`)
# ----------------------------------------------------------------------------
# La §« Cœur du livrable : cross-cohérence » du README confotur affirme que DEUX
# champs du DocType sont DÉRIVÉS du pipeline vente, jamais réécrits en dur :
#   • `dossier_vente` (Link) : cible = `workflow.document_type` (= `OTO Dossier
#     Vente`)  — README:35-36 · NOMME la valeur en clair entre backticks ;
#   • `estado` (Select) : options = les états `confotur_*` du **workflow vente**
#     — README:33-34.
# Source faisant autorité : `crm/workflow_vente/workflow_vente_spec.json` — l'INPUT
# que le builder confotur LIT (déclaré `workflow_source` dans le MANIFEST), d'où il
# tire `document_type` et les `update_value` d'états commençant par « confotur ».
# Les blocs CONFOTUR amont gatent le COMPTE de champs/rôles, la cross-cohérence des
# PERMISSIONS et l'ancrage des ENTITÉS (`entite_porteuse`) — mais AUCUN ne touche
# `dossier_vente`/`estado`. Piège #6 : RENOMMER le DocType porteur côté workflow
# (`OTO Dossier Vente` → `OTO Dossier de Vente`) ou AJOUTER/RENOMMER un état
# CONFOTUR régénère l'artefact confotur (check_artifacts reste VERT — l'artefact se
# reconstruit depuis le spec workflow) MAIS laisse la PROSE README « (= `OTO Dossier
# Vente`) » périmée EN SILENCE → l'agent ONAPI/Legal lirait une cible de Link / un
# menu d'états que le pipeline ne porte plus. Aucune suite tests/ (FONCTIONS de
# génération) n'attrape ce « vert trompeur » de la prose. Même classe EXACTE que
# l'ancrage `entite_porteuse` du MÊME README à CLAUDE.md §Entités.
# GATE : (0) traçabilité — MANIFEST `workflow_source` cite bien le spec workflow ;
# (a) cross-artefact — `dossier_vente.options` (Link) == workflow `document_type` ;
# (b) prose — la valeur README « (= `…`) » == workflow `document_type` ; (c)
# cross-artefact — `estado.options` == update_value des états `confotur_*` du
# workflow (ORDRE du pipeline) ; (c') toute option `estado` porte le préfixe
# confotur (le README l'affirme) ; (d) prose — le bullet `estado` DÉCLARE la
# dérivation (`confotur_*` + workflow vente). Un claim absent échoue AUSSI (#6).
# ============================================================================
CONF_RD4 = f"{D}/legal/confotur/README.md"
CONF_WF_SRC = "crm/workflow_vente/workflow_vente_spec.json"
try:
    conf_dt_wd = load("legal/confotur/out/doctype_confotur_application.json")
    conf_man_wd = load("legal/confotur/out/MANIFEST.json")
    wf_spec_wd = load(CONF_WF_SRC)
    wf_doctype = _nfc(str(wf_spec_wd["document_type"]).strip())
    wf_confotur = [_nfc(str(s["update_value"]).strip())
                   for s in wf_spec_wd["states"]
                   if str(s.get("update_value", "")).startswith("confotur")]
    man_wf_src = _nfc(str(conf_man_wd.get("workflow_source", "")))
except (OSError, KeyError, ValueError, TypeError) as e:
    bad(f"Confotur champs dérivés · artefact/spec workflow illisible/inattendu : {e}")
    conf_dt_wd = None
if conf_dt_wd is not None:
    _fields = conf_dt_wd.get("fields", [])
    dv_art = [f for f in _fields if f.get("fieldname") == "dossier_vente"]
    es_art = [f for f in _fields if f.get("fieldname") == "estado"]
    conf_rd4 = read_norm(CONF_RD4)
    # (0) TRAÇABILITÉ — le MANIFEST DÉCLARE dériver du workflow spec (source de vérité)
    if CONF_WF_SRC not in man_wf_src:
        bad(f"Confotur champs dérivés · (0) MANIFEST `workflow_source` = {man_wf_src!r} "
            f"ne cite PAS {CONF_WF_SRC} → dérivation non déclarée (ancrage cassé)")
    else:
        good(f"Confotur champs dérivés · (0) MANIFEST DÉCLARE dériver de {CONF_WF_SRC}")
    if not wf_confotur:
        bad(f"Confotur champs dérivés · aucun état `confotur_*` dans {CONF_WF_SRC} → le "
            f"menu `estado` n'aurait aucune source (workflow incohérent)")
    # ---- dossier_vente (Link) ----
    if len(dv_art) != 1:
        bad(f"Confotur champs dérivés · champ `dossier_vente` non-singleton dans l'artefact "
            f"({len(dv_art)}) → cible du Link indéterminable")
    else:
        dv = dv_art[0]
        dv_opt = _nfc(str(dv.get("options", "")).strip())
        if dv.get("fieldtype") != "Link":
            bad(f"Confotur champs dérivés · `dossier_vente` fieldtype "
                f"{dv.get('fieldtype')!r} ≠ Link → le champ n'est plus un Link")
        # (a) CROSS-ARTEFACT — cible du Link == document_type du workflow
        if dv_opt == wf_doctype:
            good(f"Confotur champs dérivés · (a) `dossier_vente` Link → « {dv_opt} » == "
                 f"workflow.document_type")
        else:
            bad(f"Confotur champs dérivés · (a) `dossier_vente` Link → « {dv_opt} » ≠ "
                f"workflow.document_type « {wf_doctype} » → régénérer le DocType")
        # (b) PROSE README — la valeur « (= `…`) » == document_type du workflow
        if conf_rd4 is not None:
            m = re.search(r"`dossier_vente`.{0,120}?`workflow\.document_type`"
                          r"\s*\(=\s*`([^`]+)`\)", conf_rd4)
            if m is None:
                bad(f"Confotur champs dérivés · (b) bullet `dossier_vente` (cible = "
                    f"`workflow.document_type` (= `…`)) INTROUVABLE dans {CONF_RD4} "
                    f"(claim absent = régression #6)")
            else:
                got_dv = _nfc(m.group(1).strip())
                if got_dv == wf_doctype:
                    good(f"Confotur champs dérivés · (b) README `dossier_vente` = "
                         f"« {got_dv} » == workflow.document_type")
                else:
                    bad(f"Confotur champs dérivés · (b) README dit `dossier_vente` = "
                        f"« {got_dv} » MAIS workflow.document_type = « {wf_doctype} » "
                        f"→ prose périmée (régénérer le README)")
    # ---- estado (Select) ----
    if len(es_art) != 1:
        bad(f"Confotur champs dérivés · champ `estado` non-singleton dans l'artefact "
            f"({len(es_art)}) → menu d'états indéterminable")
    else:
        es = es_art[0]
        es_opts = [_nfc(o.strip()) for o in str(es.get("options", "")).split("\n") if o.strip()]
        if es.get("fieldtype") != "Select":
            bad(f"Confotur champs dérivés · `estado` fieldtype {es.get('fieldtype')!r} "
                f"≠ Select → le champ n'est plus un Select")
        # (c) CROSS-ARTEFACT — options == états confotur_* du workflow (ordre pipeline)
        if es_opts == wf_confotur:
            good(f"Confotur champs dérivés · (c) `estado` options {es_opts} == états "
                 f"confotur_* du workflow (ordre pipeline)")
        else:
            bad(f"Confotur champs dérivés · (c) `estado` options {es_opts} ≠ états "
                f"confotur_* du workflow {wf_confotur} → menu périmé (régénérer)")
        # (c') toute option `estado` porte le préfixe confotur (le README l'affirme)
        _bad_pref = [o for o in es_opts if not o.startswith("confotur")]
        if _bad_pref:
            bad(f"Confotur champs dérivés · (c') option(s) `estado` HORS préfixe "
                f"confotur_* {_bad_pref} → contredit « options = les états `confotur_*` »")
        elif es_opts:
            good(f"Confotur champs dérivés · (c') les {len(es_opts)} options `estado` "
                 f"portent toutes le préfixe confotur_*")
        # (d) PROSE README — le bullet `estado` DÉCLARE la dérivation (confotur_* + workflow)
        if conf_rd4 is not None:
            m = re.search(r"`estado`\*{0,2}\s*\(Select\).{0,200}?`confotur_\*`"
                          r".{0,80}?workflow vente", conf_rd4)
            if m is None:
                bad(f"Confotur champs dérivés · (d) bullet `estado` (options = les états "
                    f"`confotur_*` du workflow vente) INTROUVABLE dans {CONF_RD4} "
                    f"(claim absent = régression #6)")
            else:
                good(f"Confotur champs dérivés · (d) README `estado` DÉCLARE dériver des "
                     f"états confotur_* du workflow vente")

# ============================================================================
# CRM · Dossier Vente · IDENTITÉ du COMPTE D'INVARIANTS de `_validate` — 5ᵉ
# instance de la classe « ledger d'invariants » (après Chat OTOIA · SEO · Fiscal
# e-CF · Legal CONFOTUR), appliquée au générateur du DocType porteur.
# ----------------------------------------------------------------------------
# Le générateur doctype_dossier_vente_gen.py applique un COMPTE d'invariants de
# cross-cohérence workflow ↔ DocType dont la source d'autorité n'est PAS un
# artefact JSON (invisible à check_artifacts qui byte-gate les out/*.json) mais le
# LEDGER NUMÉROTÉ de `_validate` : les marqueurs `# N ·` (1..12) qui ouvrent chaque
# contrôle appliqué. Le nombre « 12 » est transcrit À LA MAIN à SEPT endroits
# qu'AUCUN gate ne couvrait : QUATRE chaînes du .py (docstring module, docstring
# `_validate`, message de succès CLI « … schéma + 12 invariants verts. », aide du
# sous-parseur `validate`) + TROIS du README (bloc de commande « schéma + 12
# invariants », titre de section « ## Les 12 invariants », prose de score « 12
# invariants de cross-cohérence »). Ajouter un contrôle `# 13 ·` à `_validate` sans
# toucher les chaînes ⇒ le README ET les self-reports du .py mentent en silence
# pendant que la CI applique 13 contrôles — « vert trompeur » (#6) que ni les
# suites tests/ (FONCTIONS de validation, jamais la prose) ni le byte-gate
# d'artefact (aveugle au .py source) n'attrapent.
# PARTICULARITÉ vs Fiscal : ledger PROPRE 1..N (le schéma est compté À PART —
# phrasé « schéma + N invariants » — donc PAS de schéma-#1 non marqué, pas de split
# a/b) ; on garde la détection de doublon nu comme filet. On RECOMPUTE le ledger
# depuis le SEUL registre-source (les `# N ·` de `_validate`) et on exige :
# (a) ledger CONTIGU 1..N (lacune OU doublon nu = vrai défaut de registre, mordu) ;
# (b) TOUTE mention « N invariants » du .py == |ledger| ; (c) le compte annoncé du
# titre README « ## Les N invariants » == |ledger| ; (d) TOUTE mention « N
# invariants » du README == |ledger| ; (e) l'ÉNUMÉRATION numérotée de la section
# (« 1. … 2. … N. ») elle-même contiguë 1..N (item ni sauté ni dupliqué). Mention
# ou registre absent = échec (disparition d'un chiffre = régression de traçabilité
# #6).
# ============================================================================
DVV_GEN = f"{D}/crm/dossier_vente/doctype_dossier_vente_gen.py"
DVV_RD = f"{D}/crm/dossier_vente/README.md"
try:
    dvv_src = open(DVV_GEN, encoding="utf-8").read()
except OSError as e:
    bad(f"Dossier Vente invariants · générateur {DVV_GEN} illisible : {e}"); dvv_src = None
if dvv_src is not None:
    _mfn = re.search(r"def _validate\b", dvv_src)
    if _mfn is None:
        bad(f"Dossier Vente invariants · fonction `_validate` INTROUVABLE dans {DVV_GEN} "
            "(registre-source du ledger absent → impossible de re-dériver)")
    else:
        _bs = _mfn.end()
        _mn = re.search(r"\ndef ", dvv_src[_bs:])
        _body = dvv_src[_bs: _bs + _mn.start()] if _mn else dvv_src[_bs:]
        _pairs = re.findall(r"#\s*(\d+)([a-z]?)\s*·", _body)
        _dup_bare = sorted({n for n in {p[0] for p in _pairs}
                            if [s for num, s in _pairs if num == n and s == ""]
                            and sum(1 for num, _ in _pairs if num == n) > 1})
        _top = sorted({int(n) for n, _ in _pairs})
        if not _top:
            bad(f"Dossier Vente invariants · aucun marqueur `# N ·` dans `_validate` de "
                f"{DVV_GEN} (ledger vide → impossible de re-dériver)")
        elif _dup_bare:
            bad(f"Dossier Vente invariants · marqueur(s) `# N ·` dupliqué(s) sans suffixe "
                f"a/b dans `_validate` : {_dup_bare} — vrai doublon de registre")
        else:
            dvv_ninv = len(_top)
            if _top != list(range(1, dvv_ninv + 1)):
                bad(f"Dossier Vente invariants · ledger `_validate` NON contigu : {_top} "
                    f"(attendu 1..{dvv_ninv}) — lacune/doublon dans les marqueurs `# N ·`")
            else:
                good(f"Dossier Vente invariants · ledger `_validate` contigu 1..{dvv_ninv} "
                     f"({dvv_ninv} marqueurs `# N ·` · schéma compté à part)")
            # (b) self-reports du .py « N invariants ».
            dvv_py_rep = sorted({int(x) for x in re.findall(r"(\d+)\s+invariants", dvv_src)})
            if not dvv_py_rep:
                bad(f"Dossier Vente invariants · aucune mention « N invariants » dans {DVV_GEN} "
                    f"(self-report absent → attendu {dvv_ninv})")
            elif dvv_py_rep == [dvv_ninv]:
                good(f"Dossier Vente invariants · {DVV_GEN} — self-report(s) {dvv_py_rep} "
                     f"== ledger ({dvv_ninv})")
            else:
                bad(f"Dossier Vente invariants · {DVV_GEN} — self-report(s) « N invariants » "
                    f"{dvv_py_rep} MAIS ledger en compte {dvv_ninv} "
                    "(une chaîne du générateur désynchronisée du registre)")
            # README : titre de section + toute mention + énumération numérotée.
            try:
                dvv_rd_raw = open(DVV_RD, encoding="utf-8").read()
            except OSError as e:
                bad(f"Dossier Vente invariants · {DVV_RD} illisible : {e}"); dvv_rd_raw = None
            if dvv_rd_raw is not None:
                _mh = re.search(r"##\s+Les\s+(\d+)\s+invariants", dvv_rd_raw)
                if _mh is None:
                    bad(f"Dossier Vente invariants · {DVV_RD} — section « ## Les N invariants » "
                        f"INTROUVABLE (attendu {dvv_ninv})")
                else:
                    # (c) compte annoncé du titre.
                    if int(_mh.group(1)) == dvv_ninv:
                        good(f"Dossier Vente invariants · {DVV_RD} — annonce ({_mh.group(1)}) "
                             f"== ledger ({dvv_ninv})")
                    else:
                        bad(f"Dossier Vente invariants · {DVV_RD} — annonce ({_mh.group(1)}) MAIS "
                            f"ledger en compte {dvv_ninv} → régénérer le README")
                    # (d) toute mention « N invariants » du README == ledger.
                    dvv_rd_rep = sorted({int(x) for x in re.findall(r"(\d+)\s+invariants", dvv_rd_raw)})
                    if dvv_rd_rep == [dvv_ninv]:
                        good(f"Dossier Vente invariants · {DVV_RD} — mention(s) {dvv_rd_rep} "
                             f"== ledger (aucune dérive)")
                    else:
                        bad(f"Dossier Vente invariants · {DVV_RD} — mention(s) « N invariants » "
                            f"{dvv_rd_rep} MAIS ledger en compte {dvv_ninv} → régénérer le README")
                    # (e) énumération numérotée de la section == contiguë 1..N.
                    _msec = re.search(r"##\s+Les\s+\d+\s+invariants.*?(?=\n##\s|\Z)",
                                      dvv_rd_raw, re.S)
                    dvv_enum = ([int(x) for x in
                                 re.findall(r"(?<![\d.])(\d+)\.\s", _msec.group(0))]
                                if _msec else [])
                    if dvv_enum == list(range(1, dvv_ninv + 1)):
                        good(f"Dossier Vente invariants · {DVV_RD} — énumération prose "
                             f"contiguë 1..{dvv_ninv} == ledger")
                    else:
                        bad(f"Dossier Vente invariants · {DVV_RD} — énumération prose {dvv_enum} "
                            f"≠ 1..{dvv_ninv} (item sauté/dupliqué) → régénérer le README")

# ============================================================================
# QA · Audit 5D · IDENTITÉ du COMPTE D'INVARIANTS de `_validate` — 6ᵉ instance
# de la classe « ledger d'invariants » (après Chat OTOIA · SEO · Fiscal e-CF ·
# Legal CONFOTUR · CRM Dossier Vente), appliquée au générateur de l'audit QA.
# ----------------------------------------------------------------------------
# Le générateur audit_5d_gen.py applique un COMPTE d'invariants de cohérence
# audit/contrôles dont la source d'autorité n'est PAS un artefact JSON (invisible
# à check_artifacts qui byte-gate les out/*.json) mais le LEDGER NUMÉROTÉ de
# `_validate` : les marqueurs `# N ·` (1..15) qui ouvrent chaque contrôle. Le
# nombre « 15 » est transcrit À LA MAIN à SIX endroits qu'AUCUN gate ne couvrait :
# QUATRE chaînes du .py (docstring sous-commande « valide schéma + 15 invariants »,
# docstring `_validate`, message de succès CLI « … 15 invariants — verdict … »,
# aide du sous-parseur `validate`) + DEUX du README (bloc de commande « schéma +
# 15 invariants », prose d'architecture « CLI build/validate + **15 invariants** »).
# Ajouter un contrôle `# 16 ·` à `_validate` sans toucher les chaînes ⇒ le README
# ET les self-reports du .py mentent en silence pendant que la CI applique 16
# contrôles — « vert trompeur » (#6) que ni les suites tests/ (FONCTIONS de
# validation, jamais la prose) ni le byte-gate d'artefact (aveugle au .py source)
# n'attrapent.
# PARTICULARITÉ vs Dossier Vente : ledger PROPRE 1..N compté À PART du schéma
# (phrasé « schéma + N invariants » — pas de schéma-#1 non marqué, pas de split
# a/b ; on garde la détection de doublon nu comme filet). MAIS le module porte
# un SECOND nombre distinct — « 17 contrôles » (le compte de fonctions-contrôle,
# déjà gaté par ailleurs) — présent dans le .py ET le README : un `\d+ invariants`
# ancré sur le mot « invariants » l'exclut nativement (« 17 contrôles » ≠ mot
# « invariants »), donc AUCUN faux positif sur l'arbre propre. Contrairement à
# Dossier Vente/Fiscal, ce README n'a NI titre « ## Les N invariants » NI
# énumération numérotée du ledger → on ne gate que (a)(b)(d). On RECOMPUTE le
# ledger depuis le SEUL registre-source (les `# N ·` de `_validate`) et on exige :
# (a) ledger CONTIGU 1..N (lacune OU doublon nu = vrai défaut de registre, mordu) ;
# (b) TOUTE mention « N invariants » du .py == |ledger| ; (d) TOUTE mention « N
# invariants » du README == |ledger|. Mention ou registre absent = échec
# (disparition d'un chiffre = régression de traçabilité #6).
# ============================================================================
A5I_GEN = f"{D}/qa/audit_5d/audit_5d_gen.py"
A5I_RD = f"{D}/qa/audit_5d/README.md"
try:
    a5i_src = open(A5I_GEN, encoding="utf-8").read()
except OSError as e:
    bad(f"Audit 5D invariants · générateur {A5I_GEN} illisible : {e}"); a5i_src = None
if a5i_src is not None:
    _mfn = re.search(r"def _validate\b", a5i_src)
    if _mfn is None:
        bad(f"Audit 5D invariants · fonction `_validate` INTROUVABLE dans {A5I_GEN} "
            "(registre-source du ledger absent → impossible de re-dériver)")
    else:
        _bs = _mfn.end()
        _mn = re.search(r"\ndef ", a5i_src[_bs:])
        _body = a5i_src[_bs: _bs + _mn.start()] if _mn else a5i_src[_bs:]
        _pairs = re.findall(r"#\s*(\d+)([a-z]?)\s*·", _body)
        _dup_bare = sorted({n for n in {p[0] for p in _pairs}
                            if [s for num, s in _pairs if num == n and s == ""]
                            and sum(1 for num, _ in _pairs if num == n) > 1})
        _top = sorted({int(n) for n, _ in _pairs})
        if not _top:
            bad(f"Audit 5D invariants · aucun marqueur `# N ·` dans `_validate` de "
                f"{A5I_GEN} (ledger vide → impossible de re-dériver)")
        elif _dup_bare:
            bad(f"Audit 5D invariants · marqueur(s) `# N ·` dupliqué(s) sans suffixe "
                f"a/b dans `_validate` : {_dup_bare} — vrai doublon de registre")
        else:
            a5i_ninv = len(_top)
            if _top != list(range(1, a5i_ninv + 1)):
                bad(f"Audit 5D invariants · ledger `_validate` NON contigu : {_top} "
                    f"(attendu 1..{a5i_ninv}) — lacune/doublon dans les marqueurs `# N ·`")
            else:
                good(f"Audit 5D invariants · ledger `_validate` contigu 1..{a5i_ninv} "
                     f"({a5i_ninv} marqueurs `# N ·` · schéma compté à part)")
            # (b) self-reports du .py « N invariants » (le distinct « N contrôles »
            #     est exclu nativement par l'ancre sur le mot « invariants »).
            a5i_py_rep = sorted({int(x) for x in re.findall(r"(\d+)\s+invariants", a5i_src)})
            if not a5i_py_rep:
                bad(f"Audit 5D invariants · aucune mention « N invariants » dans {A5I_GEN} "
                    f"(self-report absent → attendu {a5i_ninv})")
            elif a5i_py_rep == [a5i_ninv]:
                good(f"Audit 5D invariants · {A5I_GEN} — self-report(s) {a5i_py_rep} "
                     f"== ledger ({a5i_ninv})")
            else:
                bad(f"Audit 5D invariants · {A5I_GEN} — self-report(s) « N invariants » "
                    f"{a5i_py_rep} MAIS ledger en compte {a5i_ninv} "
                    "(une chaîne du générateur désynchronisée du registre)")
            # (d) toute mention « N invariants » du README == ledger.
            try:
                a5i_rd_raw = open(A5I_RD, encoding="utf-8").read()
            except OSError as e:
                bad(f"Audit 5D invariants · {A5I_RD} illisible : {e}"); a5i_rd_raw = None
            if a5i_rd_raw is not None:
                a5i_rd_rep = sorted({int(x) for x in re.findall(r"(\d+)\s+invariants", a5i_rd_raw)})
                if not a5i_rd_rep:
                    bad(f"Audit 5D invariants · aucune mention « N invariants » dans {A5I_RD} "
                        f"(self-report README absent → attendu {a5i_ninv})")
                elif a5i_rd_rep == [a5i_ninv]:
                    good(f"Audit 5D invariants · {A5I_RD} — mention(s) {a5i_rd_rep} "
                         f"== ledger (aucune dérive)")
                else:
                    bad(f"Audit 5D invariants · {A5I_RD} — mention(s) « N invariants » "
                        f"{a5i_rd_rep} MAIS ledger en compte {a5i_ninv} → régénérer le README")

# ============================================================================
# CRM · Commissions · IDENTITÉ du COMPTE D'INVARIANTS de `_validate` — 7ᵉ
# instance de la classe « ledger d'invariants » (après Chat OTOIA · SEO · Fiscal
# e-CF · Legal CONFOTUR · CRM Dossier Vente · QA Audit 5D), appliquée au
# générateur du barème de commissions vendeurs (roadmap ligne 51).
# ----------------------------------------------------------------------------
# Le générateur commissions_gen.py applique un COMPTE d'invariants de cross-
# cohérence barème ↔ workflow ↔ DocType ↔ RBAC (les 4 contrats) dont la source
# d'autorité n'est PAS un artefact JSON (invisible à check_artifacts qui byte-gate
# les out/*.json) mais le LEDGER NUMÉROTÉ de `_validate` : les marqueurs `# N ·`
# (1..10) qui ouvrent chaque contrôle. Le nombre « 10 » est transcrit À LA MAIN à
# CINQ endroits qu'AUCUN gate ne couvrait : DEUX chaînes du .py (message de succès
# CLI « … schéma + 10 invariants verts. », aide du sous-parseur `validate`) + TROIS
# du README (bloc de commande « schéma + 10 invariants », titre de section « ## Les
# 10 invariants », prose de score « 10 invariants de cross-cohérence »). Ajouter un
# contrôle `# 11 ·` à `_validate` sans toucher les chaînes ⇒ le README ET les
# self-reports du .py mentent en silence pendant que la CI applique 11 contrôles —
# « vert trompeur » (#6) que ni les suites tests/ (FONCTIONS de validation, jamais
# la prose) ni le byte-gate d'artefact (aveugle au .py source) n'attrapent.
# PARTICULARITÉ (≈ Dossier Vente) : ledger PROPRE 1..N compté À PART du schéma
# (phrasé « schéma + N invariants » — pas de schéma-#1 non marqué, pas de split
# a/b ; on garde la détection de doublon nu comme filet). Le module porte AUSSI un
# SECOND nombre — « 25 tests » (le compte de la suite unittest, déjà gaté par
# ailleurs), présent dans le .py absent mais dans le README : l'ancre sur le mot
# « invariants » l'exclut nativement (« 25 tests » ≠ mot « invariants »), donc
# AUCUN faux positif. Comme Dossier Vente, ce README A un titre « ## Les N
# invariants » ET une énumération numérotée du ledger → on gate (a)(b)(c)(d)(e).
# On RECOMPUTE le ledger depuis le SEUL registre-source (les `# N ·` de `_validate`)
# et on exige : (a) ledger CONTIGU 1..N (lacune OU doublon nu = vrai défaut de
# registre, mordu) ; (b) TOUTE mention « N invariants » du .py == |ledger| ; (c) le
# compte annoncé du titre README « ## Les N invariants » == |ledger| ; (d) TOUTE
# mention « N invariants » du README == |ledger| ; (e) l'ÉNUMÉRATION numérotée de
# la section (« 1. … 2. … N. ») elle-même contiguë 1..N (item ni sauté ni dupliqué).
# Mention ou registre absent = échec (disparition d'un chiffre = régression #6).
# ============================================================================
CMI_GEN = f"{D}/crm/commissions/commissions_gen.py"
CMI_RD = f"{D}/crm/commissions/README.md"
try:
    cmi_src = open(CMI_GEN, encoding="utf-8").read()
except OSError as e:
    bad(f"Commissions invariants · générateur {CMI_GEN} illisible : {e}"); cmi_src = None
if cmi_src is not None:
    _mfn = re.search(r"def _validate\b", cmi_src)
    if _mfn is None:
        bad(f"Commissions invariants · fonction `_validate` INTROUVABLE dans {CMI_GEN} "
            "(registre-source du ledger absent → impossible de re-dériver)")
    else:
        _bs = _mfn.end()
        _mn = re.search(r"\ndef ", cmi_src[_bs:])
        _body = cmi_src[_bs: _bs + _mn.start()] if _mn else cmi_src[_bs:]
        _pairs = re.findall(r"#\s*(\d+)([a-z]?)\s*·", _body)
        _dup_bare = sorted({n for n in {p[0] for p in _pairs}
                            if [s for num, s in _pairs if num == n and s == ""]
                            and sum(1 for num, _ in _pairs if num == n) > 1})
        _top = sorted({int(n) for n, _ in _pairs})
        if not _top:
            bad(f"Commissions invariants · aucun marqueur `# N ·` dans `_validate` de "
                f"{CMI_GEN} (ledger vide → impossible de re-dériver)")
        elif _dup_bare:
            bad(f"Commissions invariants · marqueur(s) `# N ·` dupliqué(s) sans suffixe "
                f"a/b dans `_validate` : {_dup_bare} — vrai doublon de registre")
        else:
            cmi_ninv = len(_top)
            if _top != list(range(1, cmi_ninv + 1)):
                bad(f"Commissions invariants · ledger `_validate` NON contigu : {_top} "
                    f"(attendu 1..{cmi_ninv}) — lacune/doublon dans les marqueurs `# N ·`")
            else:
                good(f"Commissions invariants · ledger `_validate` contigu 1..{cmi_ninv} "
                     f"({cmi_ninv} marqueurs `# N ·` · schéma compté à part)")
            # (b) self-reports du .py « N invariants » (le distinct « N tests » est
            #     exclu nativement par l'ancre sur le mot « invariants »).
            cmi_py_rep = sorted({int(x) for x in re.findall(r"(\d+)\s+invariants", cmi_src)})
            if not cmi_py_rep:
                bad(f"Commissions invariants · aucune mention « N invariants » dans {CMI_GEN} "
                    f"(self-report absent → attendu {cmi_ninv})")
            elif cmi_py_rep == [cmi_ninv]:
                good(f"Commissions invariants · {CMI_GEN} — self-report(s) {cmi_py_rep} "
                     f"== ledger ({cmi_ninv})")
            else:
                bad(f"Commissions invariants · {CMI_GEN} — self-report(s) « N invariants » "
                    f"{cmi_py_rep} MAIS ledger en compte {cmi_ninv} "
                    "(une chaîne du générateur désynchronisée du registre)")
            # README : titre de section + toute mention + énumération numérotée.
            try:
                cmi_rd_raw = open(CMI_RD, encoding="utf-8").read()
            except OSError as e:
                bad(f"Commissions invariants · {CMI_RD} illisible : {e}"); cmi_rd_raw = None
            if cmi_rd_raw is not None:
                _mh = re.search(r"##\s+Les\s+(\d+)\s+invariants", cmi_rd_raw)
                if _mh is None:
                    bad(f"Commissions invariants · {CMI_RD} — section « ## Les N invariants » "
                        f"INTROUVABLE (attendu {cmi_ninv})")
                else:
                    # (c) compte annoncé du titre.
                    if int(_mh.group(1)) == cmi_ninv:
                        good(f"Commissions invariants · {CMI_RD} — annonce ({_mh.group(1)}) "
                             f"== ledger ({cmi_ninv})")
                    else:
                        bad(f"Commissions invariants · {CMI_RD} — annonce ({_mh.group(1)}) MAIS "
                            f"ledger en compte {cmi_ninv} → régénérer le README")
                    # (d) toute mention « N invariants » du README == ledger.
                    cmi_rd_rep = sorted({int(x) for x in re.findall(r"(\d+)\s+invariants", cmi_rd_raw)})
                    if cmi_rd_rep == [cmi_ninv]:
                        good(f"Commissions invariants · {CMI_RD} — mention(s) {cmi_rd_rep} "
                             f"== ledger (aucune dérive)")
                    else:
                        bad(f"Commissions invariants · {CMI_RD} — mention(s) « N invariants » "
                            f"{cmi_rd_rep} MAIS ledger en compte {cmi_ninv} → régénérer le README")
                    # (e) énumération numérotée de la section == contiguë 1..N.
                    _msec = re.search(r"##\s+Les\s+\d+\s+invariants.*?(?=\n##\s|\Z)",
                                      cmi_rd_raw, re.S)
                    cmi_enum = ([int(x) for x in
                                 re.findall(r"(?<![\d.])(\d+)\.\s", _msec.group(0))]
                                if _msec else [])
                    if cmi_enum == list(range(1, cmi_ninv + 1)):
                        good(f"Commissions invariants · {CMI_RD} — énumération prose "
                             f"contiguë 1..{cmi_ninv} == ledger")
                    else:
                        bad(f"Commissions invariants · {CMI_RD} — énumération prose {cmi_enum} "
                            f"≠ 1..{cmi_ninv} (item sauté/dupliqué) → régénérer le README")

# ============================================================================
# Mobile · app_config · IDENTITÉ du COMPTE D'INVARIANTS de `_validate_bundle` —
# 8ᵉ instance de la classe « ledger d'invariants » (après Chat OTOIA · SEO ·
# Fiscal e-CF · Legal CONFOTUR · CRM Dossier Vente · QA Audit 5D · CRM
# Commissions), appliquée au générateur de la config app mobile (roadmap Sprint 5
# l.56-57 « Rebuild Expo 54 + submit »).
# ----------------------------------------------------------------------------
# Le générateur app_config_gen.py applique un COMPTE d'invariants de cohérence
# (onglets=portails RBAC · rôles=surface exacte · marque #4 · devises #10 · Expo
# SDK · anti-invention a_confirmer · déterminisme…) dont la source d'autorité
# n'est PAS un artefact JSON (invisible à check_artifacts qui byte-gate les
# out/*.json) mais le LEDGER NUMÉROTÉ de `_validate_bundle` : les marqueurs
# `# Invariant N ·` (1..12) qui ouvrent chaque contrôle. Le nombre « 12 » est
# transcrit À LA MAIN à QUATRE endroits qu'AUCUN gate ne couvrait : UNE chaîne du
# .py (message de succès CLI « … schéma + 12 invariants verts »), DEUX du README
# (bloc de commande « schéma + 12 invariants », prose de récap « Schéma + 12
# invariants verts » — le « 12 » et le mot séparés par un saut de ligne) + UNE du
# fichier de tests (docstring « … + les 12 invariants du CLI »). Ajouter un
# contrôle `# Invariant 13 ·` à `_validate_bundle` sans toucher ces chaînes ⇒ le
# README, le self-report CLI ET la docstring de test mentent en silence pendant
# que la CI applique 13 contrôles — « vert trompeur » (#6) que ni les suites
# tests/ (qui EXÉCUTENT la validation mais ne comptent jamais la prose) ni le
# byte-gate d'artefact (aveugle au .py source) n'attrapent.
# PARTICULARITÉS vs les 7 blocs précédents :
#  · le REGISTRE-source utilise le style `# Invariant N ·` (le mot « Invariant »
#    précède le numéro) — les blocs Chat OTOIA/SEO/Fiscal/CONFOTUR/Dossier/Audit
#    5D/Commissions ancrés sur le style nu `# N ·` NE le voient PAS ; on ancre
#    donc sur `#\s*Invariant\s+(\d+)…` ;
#  · la fonction s'appelle `_validate_bundle` (pas `_validate`) — l'ancre `def
#    _validate\b` des autres blocs ne matche pas (`\b` échoue avant `_bundle`) ;
#  · ledger PROPRE 1..N compté À PART du schéma (phrasé « schéma + N invariants »)
#    — pas de schéma-#1 non marqué, pas de split a/b ; on garde le filet doublon
#    nu ;
#  · ce README n'a NI titre « ## Les N invariants » NI énumération numérotée du
#    ledger → on gate (a)(b)(b')(d), en AJOUTANT la surface fichier-de-tests (b')
#    absente des blocs précédents (leurs tests ne portaient pas le chiffre).
# On RECOMPUTE le ledger depuis le SEUL registre-source (les `# Invariant N ·` de
# `_validate_bundle`) et on exige : (a) ledger CONTIGU 1..N (lacune OU doublon nu
# = vrai défaut de registre, mordu) ; (b) TOUTE mention « N invariants » du .py ==
# |ledger| ; (b') TOUTE mention « N invariants » du fichier de tests == |ledger| ;
# (d) TOUTE mention « N invariants » du README == |ledger|. Mention ou registre
# absent = échec (disparition d'un chiffre = régression de traçabilité #6).
# ============================================================================
MBI_GEN = f"{D}/mobile/app_config/app_config_gen.py"
MBI_RD = f"{D}/mobile/app_config/README.md"
MBI_TS = f"{D}/mobile/app_config/tests/test_mobile_app_config.py"
try:
    mbi_src = open(MBI_GEN, encoding="utf-8").read()
except OSError as e:
    bad(f"Mobile invariants · générateur {MBI_GEN} illisible : {e}"); mbi_src = None
if mbi_src is not None:
    _mfn = re.search(r"def _validate_bundle\b", mbi_src)
    if _mfn is None:
        bad(f"Mobile invariants · fonction `_validate_bundle` INTROUVABLE dans {MBI_GEN} "
            "(registre-source du ledger absent → impossible de re-dériver)")
    else:
        _bs = _mfn.end()
        _mn = re.search(r"\ndef ", mbi_src[_bs:])
        _body = mbi_src[_bs: _bs + _mn.start()] if _mn else mbi_src[_bs:]
        _pairs = re.findall(r"#\s*Invariant\s+(\d+)([a-z]?)\s*·", _body)
        _dup_bare = sorted({n for n in {p[0] for p in _pairs}
                            if [s for num, s in _pairs if num == n and s == ""]
                            and sum(1 for num, _ in _pairs if num == n) > 1})
        _top = sorted({int(n) for n, _ in _pairs})
        if not _top:
            bad(f"Mobile invariants · aucun marqueur `# Invariant N ·` dans `_validate_bundle` "
                f"de {MBI_GEN} (ledger vide → impossible de re-dériver)")
        elif _dup_bare:
            bad(f"Mobile invariants · marqueur(s) `# Invariant N ·` dupliqué(s) sans suffixe "
                f"a/b dans `_validate_bundle` : {_dup_bare} — vrai doublon de registre")
        else:
            mbi_ninv = len(_top)
            if _top != list(range(1, mbi_ninv + 1)):
                bad(f"Mobile invariants · ledger `_validate_bundle` NON contigu : {_top} "
                    f"(attendu 1..{mbi_ninv}) — lacune/doublon dans les marqueurs `# Invariant N ·`")
            else:
                good(f"Mobile invariants · ledger `_validate_bundle` contigu 1..{mbi_ninv} "
                     f"({mbi_ninv} marqueurs `# Invariant N ·` · schéma compté à part)")
            # (b) self-report(s) du .py « N invariants ».
            mbi_py_rep = sorted({int(x) for x in re.findall(r"(\d+)\s+invariants", mbi_src)})
            if not mbi_py_rep:
                bad(f"Mobile invariants · aucune mention « N invariants » dans {MBI_GEN} "
                    f"(self-report absent → attendu {mbi_ninv})")
            elif mbi_py_rep == [mbi_ninv]:
                good(f"Mobile invariants · {MBI_GEN} — self-report(s) {mbi_py_rep} "
                     f"== ledger ({mbi_ninv})")
            else:
                bad(f"Mobile invariants · {MBI_GEN} — self-report(s) « N invariants » "
                    f"{mbi_py_rep} MAIS ledger en compte {mbi_ninv} "
                    "(une chaîne du générateur désynchronisée du registre)")
            # (b') self-report du fichier de tests « N invariants » (docstring).
            try:
                mbi_ts_raw = open(MBI_TS, encoding="utf-8").read()
            except OSError as e:
                bad(f"Mobile invariants · {MBI_TS} illisible : {e}"); mbi_ts_raw = None
            if mbi_ts_raw is not None:
                mbi_ts_rep = sorted({int(x) for x in re.findall(r"(\d+)\s+invariants", mbi_ts_raw)})
                if not mbi_ts_rep:
                    bad(f"Mobile invariants · aucune mention « N invariants » dans {MBI_TS} "
                        f"(self-report tests absent → attendu {mbi_ninv})")
                elif mbi_ts_rep == [mbi_ninv]:
                    good(f"Mobile invariants · {MBI_TS} — docstring {mbi_ts_rep} "
                         f"== ledger ({mbi_ninv})")
                else:
                    bad(f"Mobile invariants · {MBI_TS} — docstring « N invariants » "
                        f"{mbi_ts_rep} MAIS ledger en compte {mbi_ninv} → régénérer la docstring")
            # (d) toute mention « N invariants » du README == ledger.
            try:
                mbi_rd_raw = open(MBI_RD, encoding="utf-8").read()
            except OSError as e:
                bad(f"Mobile invariants · {MBI_RD} illisible : {e}"); mbi_rd_raw = None
            if mbi_rd_raw is not None:
                mbi_rd_rep = sorted({int(x) for x in re.findall(r"(\d+)\s+invariants", mbi_rd_raw)})
                if not mbi_rd_rep:
                    bad(f"Mobile invariants · aucune mention « N invariants » dans {MBI_RD} "
                        f"(self-report README absent → attendu {mbi_ninv})")
                elif mbi_rd_rep == [mbi_ninv]:
                    good(f"Mobile invariants · {MBI_RD} — mention(s) {mbi_rd_rep} "
                         f"== ledger (aucune dérive)")
                else:
                    bad(f"Mobile invariants · {MBI_RD} — mention(s) « N invariants » "
                        f"{mbi_rd_rep} MAIS ledger en compte {mbi_ninv} → régénérer le README")

# ============================================================================
# CRM · workflow_vente · IDENTITÉ du COMPTE D'INVARIANTS de `_validate_bundle` —
# 9ᵉ instance de la classe « ledger d'invariants » (après Chat OTOIA · SEO ·
# Fiscal e-CF · Legal CONFOTUR · CRM Dossier Vente · QA Audit 5D · CRM
# Commissions · Mobile), appliquée au générateur du workflow de vente Frappe
# (roadmap Sprint 4 l.50 « lead → visite → devis → réservation → contrat →
# CONFOTUR »).
# ----------------------------------------------------------------------------
# Le générateur workflow_vente_gen.py applique un COMPTE d'invariants métier de
# graphe (unicité des états · transitions vers états déclarés · monotonie
# doc_status · unicité (état,action) · atteignabilité depuis Lead · terminal de
# succès · séparation des pouvoirs #6 · maîtres = états/actions · cohérence des
# comptes du manifeste) dont la source d'autorité n'est PAS un artefact JSON
# (invisible à check_artifacts qui byte-gate les out/*.json) mais le LEDGER
# NUMÉROTÉ de `_validate_bundle` : les marqueurs `# Invariant N ·` (1..9) qui
# ouvrent chaque contrôle. Le nombre « 9 » est transcrit À LA MAIN à QUATRE
# endroits qu'AUCUN gate ne couvrait : UNE chaîne du .py (message de succès CLI
# « … schéma + 9 invariants verts. ») + TROIS du README (bloc de commande
# « schéma + 9 invariants métier », prose de vérification « 9 invariants de
# graphe », prose d'auto-score « 9 invariants de graphe »). Ajouter un contrôle
# `# Invariant 10 ·` à `_validate_bundle` sans toucher ces chaînes ⇒ le README ET
# le self-report CLI mentent en silence pendant que la CI applique 10 contrôles —
# « vert trompeur » (#6) que ni les suites tests/ (FONCTIONS de validation, jamais
# la prose) ni le byte-gate d'artefact (aveugle au .py source) n'attrapent.
# Prouvé ungate par mutation avant ajout (mémoire verify-uncovered-before-gating) :
# « 9 invariants » → « 10 » dans le README laissait check_readme_claims exit 0.
# PARTICULARITÉS vs les 8 blocs précédents :
#  · le REGISTRE-source utilise le style `# Invariant N ·` + la fonction
#    `_validate_bundle` (comme Mobile — les blocs ancrés sur le style nu `# N ·` /
#    `def _validate\b` ne le voient PAS) ;
#  · ledger PROPRE 1..N compté À PART du schéma (phrasé « schéma + N invariants ») —
#    pas de schéma-#1 non marqué, pas de split a/b ; on garde le filet doublon nu ;
#  · le module porte un SECOND nombre distinct — « 25 tests » (la suite unittest,
#    déjà gatée par ailleurs) présent dans le README : l'ancre sur le mot
#    « invariants » l'exclut nativement (« 25 tests » ≠ mot « invariants ») ⇒ aucun
#    faux positif ;
#  · ce README N'A NI titre « ## Les N invariants » (son titre « ## Invariants
#    vérifiés par le CLI » ne porte AUCUN chiffre) NI énumération 1:1 avec le
#    ledger — sa liste « Invariants vérifiés par le CLI » compte le SCHÉMA en item 1
#    puis FUSIONNE les invariants 8 & 9 dans son item 9 (« Maîtres … ; comptes du
#    manifeste cohérents ») : elle N'EST PAS le ledger et la gater comme contiguë
#    == ledger serait un faux positif fragile (présentation éditoriale, #6). On
#    gate donc UNIQUEMENT (a)(b)(d), comme Audit 5D.
# On RECOMPUTE le ledger depuis le SEUL registre-source (les `# Invariant N ·` de
# `_validate_bundle`) et on exige : (a) ledger CONTIGU 1..N (lacune OU doublon nu =
# vrai défaut de registre, mordu) ; (b) TOUTE mention « N invariants » du .py ==
# |ledger| ; (d) TOUTE mention « N invariants » du README == |ledger|. Mention ou
# registre absent = échec (disparition d'un chiffre = régression de traçabilité #6).
# ============================================================================
WVI_GEN = f"{D}/crm/workflow_vente/workflow_vente_gen.py"
WVI_RD = f"{D}/crm/workflow_vente/README.md"
try:
    wvi_src = open(WVI_GEN, encoding="utf-8").read()
except OSError as e:
    bad(f"workflow_vente invariants · générateur {WVI_GEN} illisible : {e}"); wvi_src = None
if wvi_src is not None:
    _mfn = re.search(r"def _validate_bundle\b", wvi_src)
    if _mfn is None:
        bad(f"workflow_vente invariants · fonction `_validate_bundle` INTROUVABLE dans "
            f"{WVI_GEN} (registre-source du ledger absent → impossible de re-dériver)")
    else:
        _bs = _mfn.end()
        _mn = re.search(r"\ndef ", wvi_src[_bs:])
        _body = wvi_src[_bs: _bs + _mn.start()] if _mn else wvi_src[_bs:]
        _pairs = re.findall(r"#\s*Invariant\s+(\d+)([a-z]?)\s*·", _body)
        _dup_bare = sorted({n for n in {p[0] for p in _pairs}
                            if [s for num, s in _pairs if num == n and s == ""]
                            and sum(1 for num, _ in _pairs if num == n) > 1})
        _top = sorted({int(n) for n, _ in _pairs})
        if not _top:
            bad(f"workflow_vente invariants · aucun marqueur `# Invariant N ·` dans "
                f"`_validate_bundle` de {WVI_GEN} (ledger vide → impossible de re-dériver)")
        elif _dup_bare:
            bad(f"workflow_vente invariants · marqueur(s) `# Invariant N ·` dupliqué(s) sans "
                f"suffixe a/b dans `_validate_bundle` : {_dup_bare} — vrai doublon de registre")
        else:
            wvi_ninv = len(_top)
            if _top != list(range(1, wvi_ninv + 1)):
                bad(f"workflow_vente invariants · ledger `_validate_bundle` NON contigu : {_top} "
                    f"(attendu 1..{wvi_ninv}) — lacune/doublon dans les marqueurs `# Invariant N ·`")
            else:
                good(f"workflow_vente invariants · ledger `_validate_bundle` contigu 1..{wvi_ninv} "
                     f"({wvi_ninv} marqueurs `# Invariant N ·` · schéma compté à part)")
            # (b) self-report(s) du .py « N invariants » (le distinct « N tests » est
            #     exclu nativement par l'ancre sur le mot « invariants »).
            wvi_py_rep = sorted({int(x) for x in re.findall(r"(\d+)\s+invariants", wvi_src)})
            if not wvi_py_rep:
                bad(f"workflow_vente invariants · aucune mention « N invariants » dans {WVI_GEN} "
                    f"(self-report absent → attendu {wvi_ninv})")
            elif wvi_py_rep == [wvi_ninv]:
                good(f"workflow_vente invariants · {WVI_GEN} — self-report(s) {wvi_py_rep} "
                     f"== ledger ({wvi_ninv})")
            else:
                bad(f"workflow_vente invariants · {WVI_GEN} — self-report(s) « N invariants » "
                    f"{wvi_py_rep} MAIS ledger en compte {wvi_ninv} "
                    "(une chaîne du générateur désynchronisée du registre)")
            # (d) toute mention « N invariants » du README == ledger.
            try:
                wvi_rd_raw = open(WVI_RD, encoding="utf-8").read()
            except OSError as e:
                bad(f"workflow_vente invariants · {WVI_RD} illisible : {e}"); wvi_rd_raw = None
            if wvi_rd_raw is not None:
                wvi_rd_rep = sorted({int(x) for x in re.findall(r"(\d+)\s+invariants", wvi_rd_raw)})
                if not wvi_rd_rep:
                    bad(f"workflow_vente invariants · aucune mention « N invariants » dans {WVI_RD} "
                        f"(self-report README absent → attendu {wvi_ninv})")
                elif wvi_rd_rep == [wvi_ninv]:
                    good(f"workflow_vente invariants · {WVI_RD} — mention(s) {wvi_rd_rep} "
                         f"== ledger (aucune dérive)")
                else:
                    bad(f"workflow_vente invariants · {WVI_RD} — mention(s) « N invariants » "
                        f"{wvi_rd_rep} MAIS ledger en compte {wvi_ninv} → régénérer le README")

# ============================================================================
# DevOps deploy_runbook + QA acceptance · IDENTITÉ du COMPTE d'INVARIANTS de
# `validate_bundle` — DEUXIÈME famille de « ledger d'invariants », distincte des
# 8 générateurs déjà gatés (Chat OTOIA · SEO · Fiscal · CONFOTUR · Dossier Vente ·
# Audit 5D · Commissions · Mobile).
# ----------------------------------------------------------------------------
# La série « ledger » précédente a été déclarée close à 8 générateurs — MAIS sur
# le SEUL patron `# N ·` / « N invariants » / `_validate`(`_validate_bundle`).
# DEUX générateurs portent le MÊME nombre magique dans un DIALECTE différent, resté
# HORS de tout gate : marqueurs `# INVn ·` (préfixe `INV`) dans `validate_bundle`,
# et phrasé « N **familles d'**invariants ». C'est exactement le piège consigné
# (« CLOSED après N était FAUX — un style de marqueur différent avait glissé ») :
#   • devops/deploy_runbook — ledger `# INV1..INV11` dans `validate_bundle`, compte
#     « 11 familles d'invariants » recopié à TROIS surfaces (générateur l.272 · README
#     l.73 · suite tests l.4) ;
#   • qa/acceptance — ledger `# INV1..INV11` dans `validate_bundle`, compte à DEUX
#     surfaces (README l.46 « 11 invariants » · suite tests l.4 « 11 familles
#     d'invariants »). Le .py acceptance ne porte PAS de compte numéroté → non exigé.
# Ajouter un contrôle `# INV12 ·` à `validate_bundle` sans toucher ces chaînes ⇒
# README + self-reports mentent en silence pendant que la CI applique 12 contrôles —
# « vert trompeur » (#6) que ni les suites tests/ (FONCTIONS de validation, jamais
# la prose) ni le byte-gate d'artefact (aveugle au .py source) n'attrapent. Ledger
# PROPRE 1..N compté À PART du schéma (phrasé « schéma + N invariants »). Les
# mentions NON numérotées de « invariants » (docstrings « invariants de … », « Un
# invariant refuse … ») ne sont pas ancrées par un chiffre → aucun faux positif. On
# RECOMPUTE le ledger depuis le SEUL registre-source (les `# INVn ·` de
# `validate_bundle`) et on exige : (a) ledger CONTIGU 1..N (lacune/doublon = défaut
# de registre) ; (b) CHAQUE surface déclarée porte ≥1 mention « N [familles d']
# invariants » toutes == |ledger|. Surface sans mention = self-report évaporé (échec
# · régression de traçabilité #6). Les daily_reports/activity_log (snapshots datés,
# ex. acceptance « 10 » d'avant la croissance 10→11) NE sont PAS des surfaces vives
# et ne sont volontairement pas ancrés.
# ============================================================================
DRA_INV_RE = re.compile(r"(\d+)\s+(?:familles\s+d['’ʼ]\s*)?invariants")
DRA_MODS = [
    ("DevOps · deploy_runbook", f"{D}/devops/deploy_runbook/deploy_runbook_gen.py",
     [("générateur", f"{D}/devops/deploy_runbook/deploy_runbook_gen.py"),
      ("README", f"{D}/devops/deploy_runbook/README.md"),
      ("suite tests", f"{D}/devops/deploy_runbook/tests/test_deploy_runbook.py")]),
    ("QA · acceptance", f"{D}/qa/acceptance/acceptance_gen.py",
     [("README", f"{D}/qa/acceptance/README.md"),
      ("suite tests", f"{D}/qa/acceptance/tests/test_acceptance.py")]),
]
for _lbl, _gen, _surfaces in DRA_MODS:
    try:
        _src = open(_gen, encoding="utf-8").read()
    except OSError as e:
        bad(f"{_lbl} invariants · générateur {_gen} illisible : {e}"); continue
    _mfn = re.search(r"def validate_bundle\b", _src)
    if _mfn is None:
        bad(f"{_lbl} invariants · fonction `validate_bundle` INTROUVABLE dans {_gen} "
            "(registre-source du ledger absent → impossible de re-dériver)"); continue
    _bs = _mfn.end()
    _mn = re.search(r"\ndef ", _src[_bs:])
    _body = _src[_bs: _bs + _mn.start()] if _mn else _src[_bs:]
    _pairs = re.findall(r"#\s*INV\s*(\d+)([a-z]?)\s*·", _body)
    _dup_bare = sorted({n for n in {p[0] for p in _pairs}
                        if [s for num, s in _pairs if num == n and s == ""]
                        and sum(1 for num, _ in _pairs if num == n) > 1})
    _top = sorted({int(n) for n, _ in _pairs})
    if not _top:
        bad(f"{_lbl} invariants · aucun marqueur `# INVn ·` dans `validate_bundle` de "
            f"{_gen} (ledger vide → impossible de re-dériver)"); continue
    if _dup_bare:
        bad(f"{_lbl} invariants · marqueur(s) `# INVn ·` dupliqué(s) dans "
            f"`validate_bundle` : {_dup_bare} — vrai doublon de registre"); continue
    _ninv = len(_top)
    if _top != list(range(1, _ninv + 1)):
        bad(f"{_lbl} invariants · ledger `validate_bundle` NON contigu : {_top} "
            f"(attendu 1..{_ninv}) — lacune/doublon dans les marqueurs `# INVn ·`")
    else:
        good(f"{_lbl} invariants · ledger `validate_bundle` contigu 1..{_ninv} "
             f"({_ninv} marqueurs `# INVn ·` · schéma compté à part)")
    for _sname, _spath in _surfaces:
        try:
            _sraw = open(_spath, encoding="utf-8").read()
        except OSError as e:
            bad(f"{_lbl} invariants · {_sname} {_spath} illisible : {e}"); continue
        _rep = sorted({int(x) for x in DRA_INV_RE.findall(_sraw)})
        if not _rep:
            bad(f"{_lbl} invariants · aucune mention « N (familles d')invariants » dans "
                f"{_spath} ({_sname}) — self-report évaporé (attendu {_ninv} · régression #6)")
        elif _rep == [_ninv]:
            good(f"{_lbl} invariants · {_sname} — mention(s) {_rep} == ledger ({_ninv})")
        else:
            bad(f"{_lbl} invariants · {_sname} ({_spath}) — mention(s) « N invariants » "
                f"{_rep} MAIS ledger `validate_bundle` en compte {_ninv} → resynchroniser")

# ============================================================================
# Traçabilité roadmap · le `roadmap_ref` VERBATIM des artefacts — la CITATION
# (n° de ligne + « libellé bullet ») ANCRÉE sur la ligne RÉELLE de la roadmap,
# pas seulement déclarée.
# ----------------------------------------------------------------------------
# Quatre modules (demo/scenarios · frontend/chat_otoia · frontend/portails ·
# mobile/app_config) publient dans out/MANIFEST.json (byte-gaté par
# check_artifacts) un `roadmap_ref` de la forme :
#   « 04_roadmap/ROADMAP_8_WEEKS_OR_LESS.md · Sprint N l.A[-B] « <libellé bullet> » »
# Ce libellé est une TRANSCRIPTION VERBATIM du bullet de la roadmap à la ligne
# citée — mais AUCUN gate ne vérifiait que la ligne A[-B] porte RÉELLEMENT ce
# texte : check_artifacts ne prouve QUE spec==MANIFEST (byte-for-byte, la MÊME
# citation recopiée des deux côtés — l'un ne contredit jamais l'autre) ;
# l'ancrage roadmap de qa/acceptance (INV11) ne touche QUE les `roadmap_line`
# NUMÉRIQUES de SON artefact, jamais le libellé cité par les autres specs ;
# l'ancrage Expo (mobile) ne vérifie que la présence de « roadmap » + « Expo N »
# dans `expo_sdk_source`, pas la citation complète. → REORDONNER la roadmap (les
# n° de ligne glissent) ou REFORMULER un bullet laisse ces 4 citations pointer
# une ligne qui dit AUTRE CHOSE : « vert trompeur » de traçabilité (#6)
# qu'aucune suite tests/ (FONCTIONS de résolution, jamais l'ancre) n'attrape.
# Prouvé par mutation : reformuler le bullet l.63 laissait les 6 gates VERTS
# pendant que chat_otoia citait encore « … Chat OTOIA embedded … » à l.63.
# On ANCRE : pour chaque MANIFEST portant un `roadmap_ref` citant (l.A[-B] «…»),
# (1) le fichier cité == la roadmap canonique ; (2) le libellé, NORMALISÉ
# (markdown `**`/`- ` retirés, espaces compressés, NFC), est SOUS-CHAÎNE des
# lignes A..B réelles ; (3) le spec du module porte le MÊME `roadmap_ref`
# (identité spec↔artefact publié). Les 4 modules connus sont EXIGÉS présents
# (anti-évaporation #6) ; tout AUTRE MANIFEST à citation est ancré en bonus.
# ============================================================================
import glob as _glob
ROADMAP_REL = "04_roadmap/ROADMAP_8_WEEKS_OR_LESS.md"
RMREF_EXPECTED = {"demo/scenarios", "frontend/chat_otoia",
                  "frontend/portails", "mobile/app_config"}
def _rmn(s):  # normalise une ligne markdown pour comparaison de libellé
    s = unicodedata.normalize("NFC", str(s))
    s = re.sub(r"^\s*[-*+]\s+", "", s)   # marqueur de liste en tête de ligne
    s = s.replace("*", "")               # emphase **gras** / *ital*
    return re.sub(r"\s+", " ", s).strip()
try:
    _rm_lines = open(ROADMAP_REL, encoding="utf-8").read().splitlines()
except OSError as e:
    bad(f"Traçabilité roadmap · {ROADMAP_REL} illisible : {e}"); _rm_lines = None
if _rm_lines is not None:
    _man_paths = sorted(set(_glob.glob(f"{D}/*/*/out/MANIFEST.json") +
                            _glob.glob(f"{D}/*/out/MANIFEST.json")))
    _rm_seen = set()
    for _mp in _man_paths:
        _mod = _mp[len(D) + 1:].rsplit("/out/", 1)[0]   # ex. « demo/scenarios »
        try:
            _ref = json.load(open(_mp, encoding="utf-8")).get("roadmap_ref", "") or ""
        except (OSError, ValueError) as e:
            bad(f"Traçabilité roadmap · {_mp} illisible : {e}"); continue
        _mln = re.search(r"l\.(\d+)(?:-(\d+))?", _ref)
        _mq = re.search(r"«\s*(.+?)\s*»", _ref)
        if not (_mln and _mq):
            continue   # pas une citation ligne+libellé → hors périmètre de ce gate
        _rm_seen.add(_mod)
        _path = _ref.split(" · ", 1)[0].strip()
        _a = int(_mln.group(1)); _b = int(_mln.group(2) or _mln.group(1))
        _quote = _mq.group(1)
        # (1) fichier cité == roadmap canonique
        if _path != ROADMAP_REL:
            bad(f"Traçabilité roadmap · {_mod} — cite « {_path} » ≠ roadmap "
                f"canonique « {ROADMAP_REL} »"); continue
        # bornes de ligne valides (renumérotation hors-plage attrapée ici)
        if not (1 <= _a <= _b <= len(_rm_lines)):
            bad(f"Traçabilité roadmap · {_mod} — plage l.{_a}-{_b} hors roadmap "
                f"(1..{len(_rm_lines)})"); continue
        _hay = " ".join(_rmn(_rm_lines[_i - 1]) for _i in range(_a, _b + 1))
        _rng = f"l.{_a}" + ("" if _a == _b else f"-{_b}")
        # (2) libellé cité == sous-chaîne des lignes réelles A..B
        if _rmn(_quote) not in _hay:
            bad(f"Traçabilité roadmap · {_mod} — libellé cité « {_quote} » ABSENT "
                f"des lignes {_rng} de la roadmap (qui disent « {_hay} ») → "
                f"REFORMULATION/RENUMÉROTATION : aligner le roadmap_ref (ou la roadmap)")
            continue
        # (3) spec du module porte le MÊME roadmap_ref (identité spec↔artefact publié)
        _specs = _glob.glob(f"{D}/{_mod}/*_spec.json")
        _spec_ok = True
        if len(_specs) == 1:
            try:
                _sref = json.load(open(_specs[0], encoding="utf-8")).get("roadmap_ref")
            except (OSError, ValueError):
                _sref = None
            if _sref is not None and _sref != _ref:
                _spec_ok = False
                bad(f"Traçabilité roadmap · {_mod} — spec roadmap_ref « {_sref} » ≠ "
                    f"MANIFEST « {_ref} » (artefact interne incohérent)")
        if _spec_ok:
            good(f"Traçabilité roadmap · {_mod} — citation {_rng} « {_quote} » "
                 f"== roadmap")
    # anti-évaporation : les 4 citations connues doivent TOUTES subsister
    _rm_missing = sorted(RMREF_EXPECTED - _rm_seen)
    if _rm_missing:
        bad(f"Traçabilité roadmap · citation roadmap_ref ÉVAPORÉE pour {_rm_missing} "
            f"(module attendu sans (l.N «…») dans out/MANIFEST.json · #6)")
    elif _rm_seen:
        good(f"Traçabilité roadmap · {len(_rm_seen)} citation(s) ancrée(s), "
             f"4 modules attendus présents")

sys.exit(1 if FAIL else 0)
PY
rc=$?

echo
if [[ "$rc" -eq 0 ]]; then
  echo -e "\033[32m✅ Chiffres du README fidèles aux artefacts cités (aucune donnée inventée · #6).\033[0m"
else
  echo -e "\033[31m❌ Chiffre(s) du README périmé(s) vs artefacts — corriger README avant merge.\033[0m"
fi
exit "$rc"
