Files
oto-enterprise-os-dtp/ci/check_readme_claims.sh
T

5286 lines
325 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/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)
# 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)")
# ============================================================================
# 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")
# ============================================================================
# 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>/README.md + dernier
# champ numérique. On ne vérifie QUE les lignes pointant une suite gatée connue
# (auth) — toute autre ligne de table est ignorée (pas notre périmètre).
row_re = re.compile(
r"^\|\s*\[`\w+/`\]\([^)]*?05_deliverables_mvp/([\w/]+?)/README\.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")
# ============================================================================
# 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")
# 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")
# 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")
# ============================================================================
# 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 (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")
# ============================================================================
# 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 == les
# 22 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")
# ============================================================================
# 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")
# ============================================================================
# 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)
# ============================================================================
# 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")
# ============================================================================
# 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")
# ============================================================================
# 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_nodes1 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")
# ============================================================================
# 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")
# ============================================================================
# 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")
# ============================================================================
# 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")
# ============================================================================
# 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.")
# ============================================================================
# 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")
# ============================================================================
# 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")
# ============================================================================
# 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")
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"