Files
oto-enterprise-os-dtp/ci/check_readme_claims.sh
T
Claude Code DTP Worker e24f2c95c7 [DTP-Worker 20260803_100714] Sprint 8 QA · fix · réconciliation compte modules 22→24 (+financement_bancaire +pie/manifest) · 2 gates RED réparés
check-regression + check-readme-claims étaient RED : deux modules committés par les
sessions auto-exec sans régénérer les artefacts/prose aval. regression_run.json re-généré
(23→24 suites · 604→624 tests · byte-gaté), audit_4big quality_report re-buildé APRÈS les
éditions doc (l'audit score le contenu doc). 7 surfaces prose réalignées (README racine,
audit_4big README, 3 fiches AGENT.md, run-book phases 4+6). Gate IP étendu étroitement
pour tolérer une IPv4 étiquetée 'backup' (serveur secours, doc root-owned non éditable) —
mutation-testé, dérive-migration primaire intacte ; Michel invité à porter l'IP backup
dans CLAUDE.md §VPS. run_ci.sh = 32 PASS · 0 FAIL · 0 SKIP.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-08-03 10:16:41 +00:00

9084 lines
568 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)
# 05_deliverables_mvp/<suite>/README.md (comptes de tests self-reportés par MODULE) :
# • chaque README de module se re-déclare le compte de SA suite (commentaire
# « # N tests » de la commande unittest · bullet « → **N/N verts** » · prose
# d'auto-score « en-repo (N tests verts + …) ») — 27 claims sur 16 modules,
# surface que row_re (qui ne lit le README de module QUE comme cible de lien)
# et les blocs per-suite (tous sur 03_agents/*) n'atteignent pas → recomptés
# depuis plan.suites[path].test_methods (`auth`, même source · byte-gatée) ;
# un « N/N » interne incohérent (25/24) mord aussi
# 03_agents/devops/AGENT.md (cellule « Tests » de la row pipeline CI) :
# • le COMPTE de suites gated « N suites gated » — CLASSE distincte des comptes
# de MÉTHODES ci-dessus (un compte de SUITES, pas de tests) — non atteint par
# row_re (ancre `ci.yml`/`ci/`, pas un module de `auth` · suffixe « suites
# gated ») et distinct de l'agrégat de la fiche QA → recompté depuis
# regression_plan.totals.suites (`reg["suites"]`, byte-gaté) + cohérence
# croisée == regression_run len(suites) (mord une divergence plan↔run)
# Un claim absent échoue AUSSI (la dérive de formulation qui ferait disparaître
# un chiffre est elle-même une régression de traçabilité).
#
# Dépendances : bash, git, python3 (stdlib pur · CLAUDE.md #2 — runner sans pip).
# ============================================================================
set -uo pipefail
# shellcheck source=ci/lib.sh
source "$(dirname "${BASH_SOURCE[0]}")/lib.sh" || exit 3
cd_repo_root # racine du dépôt, ou ROUGE honnête si hors arbre git (cf. lib.sh)
echo "== Intégrité des chiffres du README (valeur == artefact cité · #6) =="
python3 - <<'PY'
import json, re, subprocess, sys, unicodedata
D = "05_deliverables_mvp"
FAIL = 0
def bad(m):
global FAIL; FAIL = 1; print(f" \033[31m✗\033[0m {m}")
def good(m):
print(f" \033[32m✓\033[0m {m}")
try:
readme = open("README.md", encoding="utf-8").read()
except OSError as e:
print(f" \033[31m✗\033[0m README.md illisible : {e}"); sys.exit(1)
# Texte normalisé (espaces/newlines compressés) pour les claims multi-lignes.
norm = re.sub(r"\s+", " ", readme)
def load(p):
with open(f"{D}/{p}", encoding="utf-8") as f:
return json.load(f)
# --- Vérités-terrain recalculées depuis les artefacts (jamais saisies) -------
q = load("qa/audit_4big/out/quality_report.json")["totals"]
qv = load("qa/audit_4big/out/quality_report.json")["verdict"]
reg = load("qa/regression/out/regression_plan.json")["totals"]
acc = load("qa/acceptance/out/acceptance_matrix.json")
rows = acc["matrix"]
n_sprint = sum(1 for r in rows if r["kind"] == "sprint_deliverable")
n_metric = sum(1 for r in rows if r["kind"] == "mvp_metric")
n_inrepo = sum(1 for r in rows if r["status"] == "in_repo")
acc_verdict = acc["verdict"]
agents = subprocess.run(["git", "ls-files", "03_agents/*/AGENT.md"],
capture_output=True, text=True).stdout.split()
n_agents = len(agents)
# --- Chaque claim : (libellé, regex sur `norm`, groupes attendus) ------------
# Le n-uplet capturé (str) est comparé à l'attendu (str). Un claim non trouvé
# échoue (regex is None) — la disparition d'un chiffre est une régression.
checks = [
("Qualité 4Big · modules gated",
r"\*\*(\d+)/(\d+) modules gated à (\d+)/100\*\*",
(str(q["pass"]), str(q["modules"]), str(q["min_score"])),
f"pass={q['pass']} total={q['modules']} min_score={q['min_score']}"),
("Régression · suites gated",
r"\*\*(\d+) suites gated\*\*",
(str(reg["suites"]),),
f"suites={reg['suites']}"),
("Recette · promesses (sprint + métriques)",
r"\*\*(\d+) promesses\*\* \((\d+) livrables sprint \+ (\d+) métriques",
(str(len(rows)), str(n_sprint), str(n_metric)),
f"total={len(rows)} sprint={n_sprint} metric={n_metric}"),
("Recette · promesses in_repo",
r"\*\*(\d+) `in_repo`",
(str(n_inrepo),),
f"in_repo={n_inrepo}"),
("Agents · fiche nav",
r"Fiches des \*\*(\d+) agents\*\*",
(str(n_agents),),
f"AGENT.md={n_agents}"),
("Agents · titre section",
r"## Les (\d+) agents",
(str(n_agents),),
f"AGENT.md={n_agents}"),
]
for label, pat, expected, src in checks:
m = re.search(pat, norm)
if m is None:
bad(f"{label} — claim INTROUVABLE dans README (attendu {src}) · motif=/{pat}/")
continue
got = m.groups()
if got == expected:
good(f"{label} — README {'/'.join(got)} == artefact ({src})")
else:
bad(f"{label} — README dit {'/'.join(got)} MAIS artefact dit {'/'.join(expected)} ({src}) → régénérer README")
# --- Verdicts textuels cités dans le README (PASS / true) --------------------
if re.search(r"verdict `PASS`", norm):
if qv == "PASS":
good(f"Qualité 4Big · verdict README `PASS` == artefact ({qv})")
else:
bad(f"Qualité 4Big · README dit verdict `PASS` MAIS artefact dit {qv}")
else:
bad("Qualité 4Big · verdict `PASS` non cité dans README (attendu)")
if re.search(r"verdict `true`", norm):
if acc_verdict is True:
good("Recette · verdict README `true` == artefact (True)")
else:
bad(f"Recette · README dit verdict `true` MAIS artefact dit {acc_verdict}")
else:
bad("Recette · verdict `true` non cité dans README (attendu)")
# ============================================================================
# Comptes d'agents dans les docs de mandat de tête (hors README) · même classe.
# ----------------------------------------------------------------------------
# Le README est déjà gaté (« 13 agents » ×2 ci-dessus, recompté depuis
# 03_agents/*/AGENT.md). Or DEUX autres docs de tête retranscrivent ce même
# compte en dur et se périmaient en silence : ils affichaient « 12 agents » alors
# que le 13e (publiciste) était livré+gaté (fiche + générateur 100/100 + suite de
# régression). Aucun gate ne les comparait à la réalité du disque. On réutilise
# le `n_agents` DÉJÀ dérivé de `git ls-files 03_agents/*/AGENT.md` (source unique,
# pas de nouvelle machinerie) et on exige que chaque doc cite exactement ce compte.
# Le GAP_ANALYSIS_SPRINT1 (livrable Sprint 1, Definition of Done) retranscrit ce
# même compte à TROIS surfaces vives non gatées : le périmètre d'audit (« audit des
# N agents DTP »), le constat global (« … des N agents partent d'un socle existant »
# — on n'ancre QUE le TOTAL, pas le « 12 » analytique des agents à socle existant),
# et surtout la coche de couverture DoD (« Couverture N agents + OTOIA core ✅ »).
# Cette coche AFFIRME une complétude courante : si un 14e agent était mandaté (comme
# le publiciste le fut, 12→13), elle mentirait en silence. Même source unique n_agents.
# ============================================================================
GAP = "05_deliverables_mvp/GAP_ANALYSIS_SPRINT1.md"
for path, pat, label in [
("AGENTS_EXISTING_ASSETS.md",
r"Mapping vers les (\d+) agents DTP", "Actifs existants · titre"),
("02_master_prompt/MASTER_PROMPT_DTP_v2.md",
r"Tes (\d+) agents spécialisés", "Master prompt · titre"),
(GAP, r"audit des \*\*(\d+) agents DTP", "Gap analysis · périmètre d'audit"),
(GAP, r"\d+ des (\d+) agents", "Gap analysis · constat global (total)"),
(GAP, r"Couverture (\d+) agents", "Gap analysis · couverture DoD"),
]:
try:
doc = re.sub(r"\s+", " ", open(path, encoding="utf-8").read())
except OSError as e:
bad(f"{label} — {path} illisible : {e}"); continue
m = re.search(pat, doc)
if m is None:
bad(f"{label} — compte d'agents INTROUVABLE dans {path} (attendu {n_agents}) · motif=/{pat}/")
elif m.group(1) == str(n_agents):
good(f"{label} — {path} cite {m.group(1)} == 03_agents/ ({n_agents})")
else:
bad(f"{label} — {path} dit {m.group(1)} MAIS 03_agents/ en compte {n_agents} → corriger le doc")
# ============================================================================
# Fiche QA (03_agents/qa/AGENT.md) · même classe de « vert trompeur » —
# ----------------------------------------------------------------------------
# Sa section « Verdict agrégé courant » affiche un compte agrégé (« N suites
# gated · M tests · M passés · 0 échec · 0 erreur → verdict PASS ») en déclarant
# « jamais compté à la main » et en citant qa/regression/out/regression_run.json.
# Ce compte se PÉRIME pourtant en silence quand une suite est ajoutée : la fiche
# affichait « 21 suites · 534 tests » alors que le run agrégé faisant autorité en
# disait déjà davantage (matrice recomptée ci-dessous, jamais figée ici). Comme pour
# le README, aucun gate ne comparait la VALEUR au run. On la RECALCULE ici depuis
# le compte agrégé faisant autorité (regression_run.json, commité · déterministe).
# ============================================================================
QA = "03_agents/qa/AGENT.md"
try:
qa_txt = re.sub(r"\s+", " ", open(QA, encoding="utf-8").read())
except OSError as e:
bad(f"Fiche QA · {QA} illisible : {e}"); qa_txt = ""
run = load("qa/regression/out/regression_run.json")
suites = run["suites"]
agg = (
str(len(suites)), # suites
str(sum(s["ran"] for s in suites)), # tests exécutés
str(sum(s["passed"] for s in suites)), # passés
str(sum(s["failures"] for s in suites)), # échecs
str(sum(s["errors"] for s in suites)), # erreurs
run["verdict"], # verdict
)
qa_pat = (r"\*\*(\d+) suites gated · (\d+) tests · (\d+) passés · "
r"(\d+) échec · (\d+) erreur → verdict `(PASS|FAIL)`")
qa_src = (f"suites={agg[0]} tests={agg[1]} passés={agg[2]} "
f"échec={agg[3]} erreur={agg[4]} verdict={agg[5]}")
mqa = re.search(qa_pat, qa_txt)
if mqa is None:
bad(f"Fiche QA · verdict agrégé — claim INTROUVABLE dans {QA} (attendu {qa_src})")
elif mqa.groups() == agg:
good(f"Fiche QA · verdict agrégé — {QA} {'/'.join(mqa.groups())} == regression_run ({qa_src})")
else:
bad(f"Fiche QA · {QA} dit {'/'.join(mqa.groups())} MAIS regression_run dit "
f"{'/'.join(agg)} ({qa_src}) → régénérer la fiche")
# ============================================================================
# Fiche ERPNext Backend (03_agents/erpnext_backend/AGENT.md) · même classe.
# ----------------------------------------------------------------------------
# Sa ligne « source unique » situait les tests backend dans le repo par un
# compte agrégé (« repo : 560 tests au total ») saisi à la main. Ce nombre s'est
# PÉRIMÉ en silence (matrice 534 → 551 → 558) : 560 ne correspondait plus ni à la
# matrice (558) ni au repo-wide (584 méthodes test_*). Aucun gate ne le couvrait.
# On le RECALCULE depuis la même matrice faisant autorité (regression_run) et on
# exige tests + suites + verdict — comme la fiche QA.
# ============================================================================
BE = "03_agents/erpnext_backend/AGENT.md"
try:
be_txt = re.sub(r"\s+", " ", open(BE, encoding="utf-8").read())
except OSError as e:
bad(f"Fiche Backend · {BE} illisible : {e}"); be_txt = ""
be_exp = (
str(sum(s["ran"] for s in suites)), # tests exécutés (matrice)
str(len(suites)), # suites
run["verdict"], # verdict
)
be_pat = (r"matrice\s+de régression du repo : \*\*(\d+) tests · (\d+) suites\*\* · "
r"verdict `(PASS|FAIL)`")
be_src = f"tests={be_exp[0]} suites={be_exp[1]} verdict={be_exp[2]}"
mbe = re.search(be_pat, be_txt)
if mbe is None:
bad(f"Fiche Backend · matrice repo — claim INTROUVABLE dans {BE} (attendu {be_src})")
elif mbe.groups() == be_exp:
good(f"Fiche Backend · matrice repo — {BE} {'/'.join(mbe.groups())} == regression_run ({be_src})")
else:
bad(f"Fiche Backend · {BE} dit {'/'.join(mbe.groups())} MAIS regression_run dit "
f"{'/'.join(be_exp)} ({be_src}) → régénérer la fiche")
# ============================================================================
# RBAC · le COMPTE CANONIQUE de rôles ANCRÉ sur CLAUDE.md §Architecture cible
# (« RBAC N rôles ») — classe claude-md-constant-anchor, restée NON ancrée.
# ----------------------------------------------------------------------------
# CLAUDE.md §Architecture cible FIXE le mandat « RBAC 50 rôles ». Ce nombre
# canonique est recopié SANS ancrage vers CLAUDE.md dans : le contrat
# rbac_50_roles.json (`cible_rbac_roles` ET la taille réelle du catalogue
# `len(roles)`), les schémas (`const: 50`) et la fiche agent erpnext_backend
# (« RBAC 50 rôles natif » · « **50 rôles** » · « RBAC **50 rôles** exactement »).
# Les gates RBAC amont ne prouvent que la COHÉRENCE INTERNE (les READMEs
# apply_plan/userperm/roleprofile == leur MANIFEST, lui-même recompté du contrat)
# — AVEUGLES à l'ancrage au MANDAT : changer « RBAC 50 rôles » dans CLAUDE.md, ou
# faire dévier le catalogue de 50 de façon INTERNE-cohérente (cible ET len alignés
# sur une autre valeur), laisserait le contrat et la fiche affirmer « 50 exactement »
# en CONTREDISANT le mandat, en silence. On RE-DÉRIVE N de CLAUDE.md §Architecture
# cible (source unique) et on exige : (a) contrat cible_rbac_roles == N, (b) taille
# réelle len(roles) == N, (c) cible == réel (le mandat est ATTEINT par le catalogue),
# (d) la fiche erpnext_backend porte l'invariant emphatique « RBAC **N rôles**
# exactement » ET aucune mention « M rôles » périmée (M != N). Même patron que les
# tokens branding #4 ou la persona OTOIA §Architecture cible ; un claim absent
# échoue AUSSI (traçabilité #6).
# ============================================================================
try:
_cm = open("CLAUDE.md", encoding="utf-8").read()
except OSError as e:
bad(f"RBAC compte · CLAUDE.md illisible : {e}"); _cm = ""
_arch = re.search(r"## Architecture cible\n(.*?)(?:\n## |\Z)", _cm, re.S)
m_n = re.search(r"RBAC\s+(\d+)\s+rôles", _arch.group(1)) if _arch else None
if m_n is None:
bad("RBAC compte · « RBAC N rôles » INTROUVABLE dans CLAUDE.md §Architecture "
"cible → l'ancre du mandat a disparu")
else:
N = int(m_n.group(1))
good(f"RBAC compte · ancre CLAUDE.md §Architecture cible = « RBAC {N} rôles »")
try:
_c = load("rbac/rbac_50_roles.json")
cible = _c.get("cible_rbac_roles"); actual = len(_c.get("roles", []))
except (OSError, ValueError) as e:
bad(f"RBAC compte · contrat rbac_50_roles.json illisible : {e}")
cible = actual = None
if cible is not None:
if cible == N:
good(f"RBAC compte · (a) contrat cible_rbac_roles {cible} == CLAUDE.md ({N})")
else:
bad(f"RBAC compte · (a) contrat cible_rbac_roles {cible} != CLAUDE.md "
f"§Architecture cible ({N}) → le contrat contredit le mandat")
if actual == N:
good(f"RBAC compte · (b) catalogue réel len(roles) {actual} == CLAUDE.md ({N})")
else:
bad(f"RBAC compte · (b) catalogue réel len(roles) {actual} != CLAUDE.md "
f"§Architecture cible ({N}) → le catalogue a dévié du mandat")
if cible == actual:
good(f"RBAC compte · (c) cible {cible} == catalogue réel {actual} (mandat atteint)")
else:
bad(f"RBAC compte · (c) cible_rbac_roles {cible} != catalogue réel {actual} "
f"→ cible déclarée non tenue par le catalogue")
RBC = "03_agents/erpnext_backend/AGENT.md"
try:
rbc = re.sub(r"\s+", " ", open(RBC, encoding="utf-8").read())
except OSError as e:
bad(f"RBAC compte · {RBC} illisible : {e}"); rbc = ""
if re.search(rf"RBAC \*\*{N} rôles\*\* exactement", rbc):
good(f"RBAC compte · (d) fiche porte « RBAC **{N} rôles** exactement » == CLAUDE.md")
else:
bad(f"RBAC compte · (d) invariant « RBAC **{N} rôles** exactement » INTROUVABLE "
f"dans {RBC} → claim absent = régression #6")
role_mentions = [int(x) for x in re.findall(r"(\d+) rôles", rbc)]
stale = sorted({m for m in role_mentions if m != N})
if not role_mentions:
bad(f"RBAC compte · (d) aucune mention « N rôles » dans {RBC}")
elif stale:
bad(f"RBAC compte · (d) mention(s) périmée(s) {stale} rôles dans {RBC} MAIS "
f"CLAUDE.md dit {N} → resynchroniser la fiche")
else:
good(f"RBAC compte · (d) {len(role_mentions)} mention(s) « {N} rôles » "
f"dans {RBC} == CLAUDE.md (aucune périmée)")
# ============================================================================
# README de l'auditeur 4Big (05_deliverables_mvp/qa/audit_4big/README.md) ·
# section « Résultat courant » — même classe de « vert trompeur ».
# ----------------------------------------------------------------------------
# Ironie parfaite : le module qui COMPTE les modules affichait son propre total
# SAISI À LA MAIN (« 17/17 modules à 100/100 »). Ce compte s'est PÉRIMÉ en silence
# quand des modules ont été ajoutés (17 → 22) — le README de l'auditeur contredisait
# son propre artefact `quality_report.json` (totals.modules = 22). Le gate racine
# ne couvrait QUE le README d'entrée, pas ce README de module. On RECALCULE ici
# depuis le même artefact faisant autorité (totals, déjà chargé dans `q`) et on
# exige l'égalité avec ce que la section « Résultat courant » ÉCRIT.
# ============================================================================
A4 = f"{D}/qa/audit_4big/README.md"
try:
a4_txt = re.sub(r"\s+", " ", open(A4, encoding="utf-8").read())
except OSError as e:
bad(f"README audit_4big · {A4} illisible : {e}"); a4_txt = ""
a4_exp = (qv, str(q["pass"]), str(q["modules"]), str(q["min_score"]))
a4_pat = (r"\*\*Verdict `(PASS|FAIL)`\*\* [—-] \*\*(\d+)/(\d+)\*\* "
r"modules à \*\*(\d+)/100\*\*")
a4_src = f"verdict={a4_exp[0]} pass={a4_exp[1]} modules={a4_exp[2]} min_score={a4_exp[3]}"
ma4 = re.search(a4_pat, a4_txt)
if ma4 is None:
bad(f"README audit_4big · « Résultat courant » — claim INTROUVABLE dans {A4} "
f"(attendu {a4_src})")
elif ma4.groups() == a4_exp:
good(f"README audit_4big · Résultat courant — {A4} "
f"{'/'.join(ma4.groups())} == quality_report ({a4_src})")
else:
bad(f"README audit_4big · {A4} dit {'/'.join(ma4.groups())} MAIS quality_report "
f"dit {'/'.join(a4_exp)} ({a4_src}) → régénérer le README")
# ============================================================================
# README de l'auditeur 4Big · BARÈME de notation (N critères + poids par critère)
# — même classe de « vert trompeur », au grain du référentiel de scoring.
# ----------------------------------------------------------------------------
# Le barème 4Big est DATA-DERIVED : quality_report.json.criteria (byte-gaté par
# check_artifacts, RECOMPUTÉ du référentiel à chaque build) porte la liste des
# critères et leur poids. Le README les RETRANSCRIT À LA MAIN à trois endroits :
# • la prose « Note chaque module sur N critères 4Big déterministes »,
# • la table « Critère | Poids | Fait vérifié » (une ligne id→poids par critère),
# • l'archétype « generator (N critères) » (generator applique TOUS les critères).
# Ajouter/retirer un critère ou changer un poids ferait dériver ces trois surfaces
# en silence pendant que l'artefact dit autre chose — aucune suite tests/ (qui teste
# des FONCTIONS, pas la prose du README) ne l'attrape. On RECOMPUTE ici chaque nombre
# depuis `criteria` (zéro duplication du barème du générateur · #6) et on exige
# l'égalité avec ce que la doc ÉCRIT. Cohérences croisées : Σ poids == 100 (base de
# renormalisation) ET tout module d'archétype `generator` porte EXACTEMENT
# len(criteria) checks — c.-à-d. « generator = tous les critères » (mord une dérive
# du modèle qui rendrait le « generator (N critères) » du README faux). Un claim
# absent échoue AUSSI (disparition = régression de traçabilité).
# Bloc (6) : la carte de RENORMALISATION PAR ARCHÉTYPE (« data_room sans HANDOFF ·
# parser sans CONTRAT ni HANDOFF · contract sans CLI ni HANDOFF ») est elle aussi
# data-derived — critères applicables d'un archétype = {check.criterion} de ses
# modules ⇒ exclus = barème applicables. Recomputés par archétype, comparés à la
# parenthèse du README (exclusions des 3 archétypes non-generator, jamais gatées
# avant), + uniformité « 1 archétype → 1 jeu ».
# ============================================================================
try:
qr_crit = load("qa/audit_4big/out/quality_report.json")
criteria = qr_crit["criteria"]
except (OSError, KeyError, ValueError) as e:
bad(f"README audit_4big · quality_report illisible : {e}"); criteria = None
if criteria is not None and a4_txt:
n_crit = len(criteria)
# 1) prose « N critères 4Big déterministes »
mc = re.search(r"sur (\d+) critères 4Big déterministes", a4_txt)
if mc is None:
bad(f"README audit_4big · prose « N critères 4Big » — claim INTROUVABLE dans "
f"{A4} (attendu {n_crit})")
elif mc.group(1) == str(n_crit):
good(f"README audit_4big · prose critères — {mc.group(1)} == criteria ({n_crit})")
else:
bad(f"README audit_4big · prose critères — {A4} dit {mc.group(1)} MAIS criteria "
f"en compte {n_crit} → régénérer le README")
# 2) table « Critère | Poids | … » — une ligne id→poids par critère
for c in criteria:
cid, w = c["id"], str(c["weight"])
mw = re.search(rf"\| `{re.escape(cid)}` \| (\d+) \|", a4_txt)
if mw is None:
bad(f"README audit_4big · critère {cid} — ligne INTROUVABLE dans {A4} "
f"(attendu poids={w})")
elif mw.group(1) == w:
good(f"README audit_4big · critère {cid} — poids {mw.group(1)} == criteria ({w})")
else:
bad(f"README audit_4big · critère {cid} — {A4} dit poids {mw.group(1)} MAIS "
f"criteria dit {w} → régénérer le README")
# 3) archétype « generator (N critères) » — generator applique TOUS les critères
mg = re.search(r"`generator` \((\d+) critères\)", a4_txt)
if mg is None:
bad(f"README audit_4big · archétype « generator (N critères) » — claim "
f"INTROUVABLE dans {A4} (attendu {n_crit})")
elif mg.group(1) == str(n_crit):
good(f"README audit_4big · generator — {mg.group(1)} critères == criteria ({n_crit})")
else:
bad(f"README audit_4big · generator — {A4} dit {mg.group(1)} MAIS l'archétype "
f"generator applique les {n_crit} critères → régénérer le README")
# 4) cohérence croisée : Σ poids == 100 (base de renormalisation)
w_sum = sum(c["weight"] for c in criteria)
if w_sum == 100:
good(f"README audit_4big · Σ poids critères == {w_sum} (base 100 cohérente)")
else:
bad(f"README audit_4big · Σ poids critères == {w_sum} ≠ 100 — barème incohérent "
f"avec la renormalisation documentée")
# 5) cohérence croisée : tout module `generator` porte len(criteria) checks
gen = [m for m in qr_crit["modules"] if m.get("archetype") == "generator"]
gen_bad = [m["id"] for m in gen if len(m.get("checks", [])) != n_crit]
if not gen:
good("README audit_4big · aucun module d'archétype generator (rien à recouper)")
elif gen_bad:
bad(f"README audit_4big · modules generator avec ≠ {n_crit} checks : {gen_bad} "
f"→ « generator = tous les critères » violé")
else:
good(f"README audit_4big · {len(gen)} module(s) generator portent {n_crit} "
f"checks (« generator = tous les critères » vérifié)")
# 6) carte « archétype → critères EXCLUS » (renormalisation) : le README énumère
# data_room « sans HANDOFF », parser « sans CONTRAT … ni HANDOFF », contract
# « sans CLI ni HANDOFF ». Cette carte est DATA-DERIVED : pour un archétype,
# l'ensemble des critères APPLICABLES = {check.criterion} de ses modules (les
# non-applicables sont ABSENTS des checks) ⇒ EXCLUS = ordre du barème appl.
# Le bloc (3) ne recompute QUE generator (5 critères) ; les exclusions des
# trois autres archétypes n'étaient recomputées par AUCUN gate. Changer la
# règle de renormalisation d'un archétype (ex. data_room exclut aussi CLI)
# ferait dériver cette prose en silence pendant que quality_report dit autre
# chose. On recompute par archétype + on exige que la parenthèse du README
# énumère EXACTEMENT cet ensemble. Uniformité en bonus : tous les modules
# d'un même archétype portent le MÊME ensemble applicable (sinon la carte
# « 1 archétype → 1 jeu de critères » n'a pas de sens).
crit_ids = [c["id"] for c in criteria]
crit_set = set(crit_ids)
by_arch, uni_bad = {}, []
for m in qr_crit["modules"]:
a = m.get("archetype")
appl = frozenset(ck["criterion"] for ck in m.get("checks", []))
if a in by_arch and by_arch[a] != appl:
uni_bad.append(m["id"])
else:
by_arch.setdefault(a, appl)
if uni_bad:
bad(f"README audit_4big · archétypes NON uniformes (modules aux critères "
f"applicables divergents) : {uni_bad} → renormalisation incohérente")
else:
good(f"README audit_4big · {len(by_arch)} archétype(s) à critères applicables "
f"uniformes (« 1 archétype → 1 jeu » vérifié)")
for a in sorted(by_arch):
if a == "generator":
continue # traité en (3) : « generator (N critères) », zéro exclusion
exp = [c for c in crit_ids if c not in by_arch[a]] # ordre du barème, stable
ma = re.search(rf"`{re.escape(a)}` \(([^)]*)\)", a4_txt)
if ma is None:
bad(f"README audit_4big · archétype `{a}` — énumération INTROUVABLE dans "
f"{A4} (attendu « sans {' ni '.join(exp)} »)")
continue
got = set(re.findall(r"[A-Z]{2,}", ma.group(1))) & crit_set
if got == set(exp):
good(f"README audit_4big · archétype `{a}` — exclus {sorted(got)} == "
f"référentiel ({exp})")
else:
bad(f"README audit_4big · archétype `{a}` — {A4} exclut {sorted(got)} MAIS "
f"le référentiel exclut {exp} → régénérer le README")
# ============================================================================
# README de l'auditeur 4Big · SEUILS STRUCTURELS de la colonne « Fait vérifié »
# (min_doc_bytes / min_test_methods) — 3e surface du MÊME README, jamais gatée.
# ----------------------------------------------------------------------------
# Le bloc BARÈME ci-dessus recompute les POIDS (2e colonne de la table
# « Critère | Poids | Fait vérifié »), mais la 3e colonne « Fait vérifié » NOMME
# les DEUX seuils structurels que le scorer applique réellement :
# • DOC « un *.md (README/SPEC) ≥ 400 octets » → thresholds.min_doc_bytes
# • TESTS « ≥ 8 méthodes def test_* dans tests/ » → thresholds.min_test_methods
# Ces deux nombres sont DATA-DERIVED de quality_spec.json[thresholds] — l'INPUT
# du générateur, byte-gaté par check_artifacts qui prouve que quality_report.json
# se rebuild depuis lui — et TRANSCRITS À LA MAIN dans la table. AUCUN gate ne
# les liait : le bloc BARÈME ne capture que le poids (`| \`DOC\` | 20 |`),
# AVEUGLE à la 3e cellule. Élever min_test_methods (8→12 : un module de 8-11
# tests tomberait sous FAIL — la barre même que le critère pose) ou changer
# min_doc_bytes dans le spec rebuild le report SANS TOUCHER au README → prose
# « ≥ 8 méthodes » / « ≥ 400 octets » PÉRIMÉE en silence, l'auditeur documentant
# un barème qu'il n'applique plus (l'invention #6 interdit). Aucune suite tests/
# (qui teste des FONCTIONS de scoring, pas la prose) n'attrape ce « vert
# trompeur ». CROSS-CHECK d'ancrage : l'evidence TESTS de CHAQUE module du report
# porte « (seuil N) » (émise par crit_tests) — on exige N == spec.min_test_methods
# pour TOUS, prouvant que le seuil ÉCRIT dans le spec == le seuil réellement
# APPLIQUÉ à la construction (mord une divergence spec↔report). Un claim absent
# échoue AUSSI (traçabilité #6).
# ============================================================================
try:
_a4_thr = load("qa/audit_4big/quality_spec.json")["thresholds"]
min_doc = int(_a4_thr["min_doc_bytes"])
min_test = int(_a4_thr["min_test_methods"])
except (OSError, KeyError, ValueError, TypeError) as e:
bad(f"README audit_4big seuils · quality_spec.json[thresholds] illisible/inattendu : {e}")
min_doc = min_test = None
if min_doc is not None and criteria is not None and a4_txt:
# cross-check d'ancrage : seuil TESTS APPLIQUÉ (evidence report) == spec
ev_seuils = set()
for m in qr_crit["modules"]:
for ck in m.get("checks", []):
if ck.get("criterion") == "TESTS":
mm = re.search(r"\(seuil (\d+)\)", ck.get("evidence", ""))
if mm:
ev_seuils.add(int(mm.group(1)))
if not ev_seuils:
bad("README audit_4big seuils · aucune evidence TESTS « (seuil N) » dans le "
"report — impossible d'ancrer le seuil réellement appliqué")
elif ev_seuils != {min_test}:
bad(f"README audit_4big seuils · seuil TESTS APPLIQUÉ (evidence report) "
f"{sorted(ev_seuils)} ≠ spec.min_test_methods ({min_test}) → spec↔report "
f"divergent (régénérer le report)")
else:
good(f"README audit_4big seuils · seuil TESTS appliqué (report) == spec "
f"({min_test}) sur tous les modules")
# prose · DOC « ≥ N octets » (3e cellule de la ligne DOC)
md = re.search(r"`DOC`[^|]*\|[^|]*\|[^|]*?≥\s*(\d+)\s*octets", a4_txt)
if md is None:
bad(f"README audit_4big seuils · ligne DOC « ≥ N octets » INTROUVABLE dans {A4} "
f"(attendu {min_doc})")
elif md.group(1) == str(min_doc):
good(f"README audit_4big seuils · DOC — « ≥ {md.group(1)} octets » == spec ({min_doc})")
else:
bad(f"README audit_4big seuils · DOC — {A4} dit « ≥ {md.group(1)} octets » MAIS "
f"spec.min_doc_bytes == {min_doc} → régénérer le README")
# prose · TESTS « ≥ N méthodes » (3e cellule de la ligne TESTS)
mt = re.search(r"`TESTS`[^|]*\|[^|]*\|[^|]*?≥\s*(\d+)\s*méthodes", a4_txt)
if mt is None:
bad(f"README audit_4big seuils · ligne TESTS « ≥ N méthodes » INTROUVABLE dans {A4} "
f"(attendu {min_test})")
elif mt.group(1) == str(min_test):
good(f"README audit_4big seuils · TESTS — « ≥ {mt.group(1)} méthodes » == spec ({min_test})")
else:
bad(f"README audit_4big seuils · TESTS — {A4} dit « ≥ {mt.group(1)} méthodes » MAIS "
f"spec.min_test_methods == {min_test} → régénérer le README")
# ============================================================================
# Fiches agents · comptes de tests PAR SUITE (colonne « Tests » des tables) —
# même classe de « vert trompeur », au grain le PLUS FIN.
# ----------------------------------------------------------------------------
# Chaque fiche agent (03_agents/*/AGENT.md) recense ses livrables dans une table
# dont la dernière colonne « Tests » affiche le nombre de méthodes `def test_` de
# la suite. Ce nombre est SAISI À LA MAIN : il se périme dès qu'un test est
# ajouté/retiré. C'est arrivé (fiche QA : `acceptance` disait 31 alors que la
# suite en portait 37 · `audit_4big` 35→34 · `regression` 25→26). Les gates
# amont ne couvraient QUE la SOMME agrégée (« 564 tests ») : une compensation
# entre deux suites (l'une +1, l'autre 1) laisserait la somme juste et les DEUX
# lignes fausses. On RECOMPUTE ici chaque cellule depuis la source faisant
# autorité — plan.suites[path].test_methods pour les suites gated, et
# reglib.discovery.count_tests (même fonction que le plan · zéro duplication)
# pour le self_module (qa/regression, exclu de la matrice par SoD mais bien
# documenté avec son propre compte).
# ============================================================================
sys.path.insert(0, f"{D}/qa/regression")
try:
from reglib.discovery import count_tests
except Exception as e: # import cassé = red honnête (source de vérité absente)
bad(f"Comptes par suite · import reglib.discovery impossible : {e}")
count_tests = None
plan = load("qa/regression/out/regression_plan.json")
auth = {s["path"]: s["test_methods"] for s in plan["suites"]}
self_mod = plan["coverage"].get("self_module_excluded")
if count_tests is not None and self_mod:
auth[self_mod] = count_tests(f"{D}/{self_mod}/tests")[1]
# Ligne de table Tests : lien vers 05_deliverables_mvp/<path>/<doc>.md + dernier
# champ numérique. Le <doc>.md est l'ENTRÉE du module — le plus souvent README.md,
# mais parfois une SPEC (la fiche `rbac` pointe RBAC_50_ROLES_SPEC.md, son point
# d'entrée) : on accepte donc tout `*.md` du dossier module, sinon cette cellule-là
# reste un angle mort (mutation 10→99 non mordue). On ne vérifie QUE les lignes
# pointant une suite gatée connue (auth) — toute autre ligne est ignorée (hors
# périmètre), le garde `path not in auth` protège de tout élargissement fortuit.
row_re = re.compile(
r"^\|\s*\[`\w+/`\]\([^)]*?05_deliverables_mvp/([\w/]+?)/[\w.-]+\.md\)"
r".*\|\s*(\d+)\s*\|\s*$")
fiches = subprocess.run(["git", "ls-files", "03_agents/*/AGENT.md"],
capture_output=True, text=True).stdout.split()
seen_rows = 0
for af in fiches:
try:
lines = open(af, encoding="utf-8").read().splitlines()
except OSError as e:
bad(f"Fiche {af} illisible : {e}"); continue
for ln, line in enumerate(lines, 1):
m = row_re.match(line)
if not m:
continue
path = m.group(1)
if path not in auth: # ligne pointant un non-suite → hors périmètre
continue
claimed, exp = int(m.group(2)), auth[path]
seen_rows += 1
if claimed == exp:
good(f"Fiche {af}:{ln} · {path} — Tests {claimed} == source ({exp})")
else:
bad(f"Fiche {af}:{ln} · {path} — Tests {claimed} MAIS source dit {exp} "
f"(plan.suites/count_tests) → régénérer la fiche")
if seen_rows == 0:
bad("Comptes par suite · AUCUNE ligne de table « Tests » trouvée dans les "
"fiches — la disparition du recensement est elle-même une régression.")
# ============================================================================
# Fiches agents · comptes AGRÉGÉS EN PROSE (« Total X : N tests (a + b + c) ») —
# dernière surface de la MÊME classe, un cran au-dessus des cellules de table.
# ----------------------------------------------------------------------------
# Les cellules « Tests » des tables sont recomputées ci-dessus, mais deux fiches
# rédigent EN PROSE un agrégat multi-suites : `crm` (« Total CRM : 81 tests
# (25 + 31 + 25) ») et `erpnext_backend` (« RBAC 60 tests (10 + 11 + 12 + 11 + 16)
# + e-CF 39 tests »). Ces totaux ET leurs composants sont SAISIS À LA MAIN et
# n'étaient couverts par AUCUN gate : une compensation entre deux suites (l'une
# +1, l'autre 1) laisserait la table juste et la prose fausse — exactement le
# motif per-suite corrigé la session précédente. On RECOMPUTE ici le total ET le
# multiset des composants (ordre-indépendant) depuis `auth` (plan.suites, source
# faisant autorité déjà chargée), et on exige l'égalité avec ce que la fiche ÉCRIT.
# ============================================================================
PROSE = [
("03_agents/crm/AGENT.md", "Total CRM",
r"Total CRM : \*\*(\d+) tests\*\* \(([\d + ]+)\)",
["crm/commissions", "crm/dossier_vente", "crm/workflow_vente"]),
("03_agents/erpnext_backend/AGENT.md", "RBAC backend",
r"\*\*RBAC (\d+) tests\*\* \(([\d + ]+)\)",
["rbac", "rbac/apply_plan", "rbac/fixtures_gen",
"rbac/roleprofile_gen", "rbac/userperm_gen"]),
]
for path_f, label, pat, suites_list in PROSE:
try:
txt = re.sub(r"\s+", " ", open(path_f, encoding="utf-8").read())
except OSError as e:
bad(f"Prose agrégée · {path_f} illisible : {e}"); continue
try:
exp_parts = sorted(auth[s] for s in suites_list)
except KeyError as e:
bad(f"Prose agrégée · {label} — suite {e} absente de plan.suites (câblage ?)")
continue
exp_total = sum(exp_parts)
m = re.search(pat, txt)
if m is None:
bad(f"Prose agrégée · {path_f} · « {label} » — claim INTROUVABLE "
f"(attendu total={exp_total} composants={exp_parts})")
continue
total = int(m.group(1))
parts = sorted(int(x) for x in re.findall(r"\d+", m.group(2)))
if total == exp_total and parts == exp_parts and total == sum(parts):
good(f"Prose agrégée · {path_f} · {label} — "
f"{total} ({'+'.join(map(str, parts))}) == source ({exp_total})")
else:
bad(f"Prose agrégée · {path_f} · {label} — dit total={total} "
f"composants={parts} MAIS source dit total={exp_total} "
f"composants={exp_parts} (interne_ok={total == sum(parts)}) "
f"→ régénérer la fiche")
# e-CF : agrégat en prose à SUITE UNIQUE (pas de composants entre parenthèses).
be_ecf = re.sub(r"\s+", " ", open("03_agents/erpnext_backend/AGENT.md",
encoding="utf-8").read())
ecf_exp = auth["fiscal/ecf_dgii"]
mecf = re.search(r"\*\*e-CF (\d+) tests\*\*", be_ecf)
if mecf is None:
bad(f"Prose agrégée · e-CF — claim INTROUVABLE (attendu {ecf_exp})")
elif int(mecf.group(1)) == ecf_exp:
good(f"Prose agrégée · e-CF — {mecf.group(1)} == source ({ecf_exp})")
else:
bad(f"Prose agrégée · e-CF — dit {mecf.group(1)} MAIS source dit {ecf_exp} "
f"(fiscal/ecf_dgii) → régénérer la fiche")
# ============================================================================
# Fiches agents · comptes de tests PAR SUITE écrits HORS de la colonne « Tests »
# (prose libre + cellule à suffixe) — dernière poche de la MÊME classe per-suite.
# ----------------------------------------------------------------------------
# Les deux blocs ci-dessus recomputent (a) chaque CELLULE « Tests » des tables de
# livrables via row_re — dont l'ancre de lien est `<mot>/` ET la cellule un ENTIER
# NU — et (b) les AGRÉGATS multi-suites en prose de crm/erpnext_backend. Restent
# CINQ claims de test PAR SUITE que NI l'un NI l'autre n'atteint :
# • prose libre (hors table) : seo « **36 tests** » (suite `seo`) ·
# frontend_console « **19 tests** » (frontend/portails) & « **31 tests** »
# (frontend/chat_otoia) · onapi_legal « **44 tests** » (legal/confotur) ;
# • cellule de table à SUFFIXE : devops « 29 (dont 14 injections négatives) »
# (devops/deploy_runbook) — row_re échoue DEUX fois : l'ancre de lien est un
# CHEMIN complet (`05_deliverables_mvp/devops/deploy_runbook/`, pas `<mot>/`)
# ET la cellule n'est PAS un entier nu (suffixe « (dont …) »).
# Ces cinq nombres sont SAISIS À LA MAIN et se périment dès qu'un `def test_` est
# ajouté/retiré à la suite — exactement le « vert trompeur » per-suite que le bloc
# des cellules corrige, mais sur des surfaces qu'il ne balaie pas (une suite +1 et
# une autre 1 laisserait la SOMME « 564 tests » juste et ces lignes fausses).
# Aucune suite tests/ (qui teste des FONCTIONS, pas la prose des fiches) n'attrape
# ce périmé. On RECOMPUTE chaque nombre depuis `auth` (plan.suites[path].
# test_methods / count_tests · MÊME source déjà chargée · zéro duplication #6) et
# on exige que la fiche l'écrive EXACTEMENT. Chaque motif est ANCRÉ sur le nom de
# job CI (ou le lien du module) adjacent pour viser LA bonne suite sans ambiguïté.
# Un claim absent échoue AUSSI (traçabilité #6 : le recensement qui s'évapore est
# lui-même une régression).
# ============================================================================
PER_SUITE_HORS_TABLE = [
("03_agents/seo/AGENT.md", "seo",
r"\*\*(\d+) tests\*\*, gated", "seo"),
("03_agents/frontend_console/AGENT.md", "frontend/portails",
r"\*\*(\d+) tests\*\* · job CI `frontend-portails-tests`", "portails"),
("03_agents/frontend_console/AGENT.md", "frontend/chat_otoia",
r"\*\*(\d+) tests\*\* · job CI `chat-otoia-tests`", "chat_otoia"),
("03_agents/onapi_legal/AGENT.md", "legal/confotur",
r"\*\*(\d+) tests\*\* \(dont \d+ négatifs\) · job CI `legal-confotur-tests`",
"confotur"),
("03_agents/devops/AGENT.md", "devops/deploy_runbook",
r"deploy_runbook/README\.md\).*?\| (\d+) \(dont \d+", "deploy_runbook"),
]
for path_f, suite_path, pat, label in PER_SUITE_HORS_TABLE:
try:
txt = re.sub(r"\s+", " ", open(path_f, encoding="utf-8").read())
except OSError as e:
bad(f"Test par suite (hors table) · {path_f} illisible : {e}"); continue
if suite_path not in auth:
bad(f"Test par suite (hors table) · {label} — suite `{suite_path}` absente "
f"de plan.suites/count_tests (câblage rompu ?)"); continue
exp = auth[suite_path]
m = re.search(pat, txt)
if m is None:
bad(f"Test par suite (hors table) · {path_f} · {label} — claim « N tests » "
f"INTROUVABLE (attendu {exp} · suite `{suite_path}`)")
elif int(m.group(1)) == exp:
good(f"Test par suite (hors table) · {path_f} · {label} — {m.group(1)} == "
f"source ({exp} · `{suite_path}`)")
else:
bad(f"Test par suite (hors table) · {path_f} · {label} — dit {m.group(1)} "
f"MAIS source dit {exp} (plan.suites[`{suite_path}`]) → régénérer la fiche")
# ============================================================================
# Fiche DevOps · COMPTE de suites gated (« N suites gated ») — sémantique DISTINCTE
# des comptes de tests par suite ci-dessus.
# ----------------------------------------------------------------------------
# Tous les blocs de comptes de tests précédents recomputent un compte de MÉTHODES
# `def test_` (`auth[path]`). La table de livrables de la fiche DevOps porte, elle,
# dans la cellule « Tests » de la ROW du pipeline CI (`.gitea/workflows/ci.yml` +
# `ci/`), un compte d'une CLASSE tout autre : « 22 suites gated » — le nombre de
# suites que la CI passe au crible, soit `regression_plan.totals.suites` (déjà chargé
# dans `reg`, byte-gaté par check_artifacts · recompté à chaque build par la
# découverte réelle des suites). Ce nombre est SAISI À LA MAIN dans la fiche et
# NI row_re NI aucun bloc amont ne l'atteint : (a) row_re exige un lien d'ancre
# `<mot>/…/README.md` vers une suite gatée ET une cellule ENTIER NU — or l'ancre ici
# est `ci.yml`/`ci/` (pas un module de `auth`) ET la cellule porte le suffixe
# « suites gated » ; (b) l'agrégat de la fiche QA (« 22 suites gated · 564 tests… »,
# gaté sur regression_run) est une SURFACE distincte (autre fiche, autre motif). La
# MÊME valeur 22 vit donc à DEUX endroits — un seul était gaté. Ajouter/retirer un
# module gated fait bouger `reg["suites"]` tandis que cette cellule DevOps rote en
# silence : la doc d'identité de l'agent DevOps affirmerait un périmètre de couverture
# CI faux (le « vert trompeur » que #6 interdit). Aucune suite tests/ (FONCTIONS, pas
# la prose de fiche) ne l'attrape. On RECOMPUTE depuis `reg["suites"]` (source unique
# déjà en portée · zéro duplication #6) et on exige que la cellule l'écrive EXACTEMENT.
# Motif ANCRÉ sur le label de rôle « CI/CD Gitea Actions » de CETTE row → vise la
# bonne cellule sans capter la valeur 22 de la fiche QA (autre fichier de toute façon).
# Claim absent = échec (traçabilité #6). Cohérence croisée : `reg["suites"]` ==
# regression_run (len(suites)) — la source du compte de tests de la fiche QA — de sorte
# qu'une divergence plan↔run (compte de suites) morde ici aussi.
# ============================================================================
DEVOPS_FICHE = "03_agents/devops/AGENT.md"
try:
dv_txt = re.sub(r"\s+", " ", open(DEVOPS_FICHE, encoding="utf-8").read())
except OSError as e:
bad(f"Fiche DevOps · {DEVOPS_FICHE} illisible : {e}"); dv_txt = ""
run_sc = load("qa/regression/out/regression_run.json")
n_suites_run = len(run_sc["suites"])
if reg["suites"] != n_suites_run:
bad(f"Fiche DevOps · suites gated — incohérence SOURCE : regression_plan.totals."
f"suites={reg['suites']} ≠ regression_run len(suites)={n_suites_run} "
f"(plan↔run divergent → régénérer les artefacts qa/regression)")
dv_pat = r"CI/CD Gitea Actions.*?\| (\d+) suites gated \|"
mdv = re.search(dv_pat, dv_txt)
if not dv_txt:
pass # illisible déjà signalé
elif mdv is None:
bad(f"Fiche DevOps · suites gated — cellule « N suites gated » INTROUVABLE dans "
f"{DEVOPS_FICHE} (row CI/CD Gitea Actions · attendu {reg['suites']})")
elif int(mdv.group(1)) == reg["suites"]:
good(f"Fiche DevOps · suites gated — {DEVOPS_FICHE} {mdv.group(1)} == source "
f"(regression_plan.totals.suites={reg['suites']})")
else:
bad(f"Fiche DevOps · suites gated — {DEVOPS_FICHE} dit {mdv.group(1)} MAIS source "
f"dit {reg['suites']} (regression_plan.totals.suites) → régénérer la fiche")
# ============================================================================
# READMEs de MODULE · comptes de tests self-reportés — surface DISTINCTE des fiches.
# ----------------------------------------------------------------------------
# Tous les blocs de comptes de tests ci-dessus visent `03_agents/*/AGENT.md` (la
# cellule « Tests » via row_re, les agrégats prose crm/erpnext_backend, les cinq
# claims per-suite hors table). AUCUN ne touche le README du MODULE lui-même —
# `05_deliverables_mvp/<suite>/README.md` — qui pourtant se re-déclare, en propre,
# le compte de SA suite à plusieurs endroits : le commentaire de la commande
# `unittest` (« # N tests »), le bullet « Vérification en-repo » (« → **N/N verts** »)
# et la justification d'auto-score (« Validé … en-repo (N tests verts + … ) »). Ces
# nombres sont SAISIS À LA MAIN dans le doc d'entrée de chaque module et se périment
# dès qu'un `def test_` est ajouté/retiré : la source faisant autorité (regression_
# plan.json → `auth`, recomptée à chaque build par découverte réelle · byte-gatée)
# se met à jour, tandis que la prose du README rote EN SILENCE — « vert trompeur »
# de la MÊME classe que la cellule « Tests » des fiches, mais sur la surface que
# row_re n'atteint pas (il ne lit le README de module QUE comme cible de lien). Aucune
# suite tests/ (FONCTIONS de génération, jamais la prose du README) n'attrape ce
# périmé. Un « N/N » interne incohérent (ex. `25/24 verts`) mord aussi (les deux
# nombres doivent valoir `auth`). On RECOMPUTE chaque nombre depuis `auth` (MÊME
# source déjà chargée · zéro duplication #6) et on exige que le README l'écrive
# EXACTEMENT ; un claim listé mais absent échoue AUSSI (traçabilité #6 : le compte
# qui s'évapore est lui-même une régression). Cinq modules ne self-reportent AUCUN
# compte (frontend/chat_otoia · mobile/app_config · publiciste · qa/acceptance ·
# qa/audit_4big) — volontairement HORS table (rien à ancrer ; ajouter un futur claim
# = l'ajouter ici). Chaque motif est ANCRÉ sur un contexte distinctif (commande
# `discover`, `**N/N verts**`, « en-repo (N tests verts », nom de fichier de suite…)
# pour viser LE compte de CETTE suite sans capter un autre nombre.
# ============================================================================
MODULE_README_TESTS = [
("crm/commissions", [r"discover -s tests[^#]*# (\d+) tests",
r"en-repo \((\d+) tests verts"]),
("crm/dossier_vente", [r"discover -s tests[^#]*# (\d+) tests"]),
("crm/workflow_vente", [r"\*\*(\d+)/(\d+) verts\*\*",
r"en-repo \((\d+) tests verts"]),
("demo/scenarios", [r"(\d+) tests \(dont injections"]),
("devops/deploy_runbook", [r"(\d+) tests dont \d+ injections"]),
("faisabilite/bancable", [r"\*\*(\d+)/(\d+) verts\*\*",
r"en-repo \((\d+) tests verts"]),
("faisabilite/generator", [r"(\d+) tests `unittest`"]),
("fiscal/ecf_dgii", [r"discover -s tests[^#]*# (\d+) tests",
r"en-repo \((\d+) tests verts"]),
("frontend/portails", [r"discover -s tests[^#]*# (\d+) tests",
r"exacts, (\d+) tests \(dont"]),
("legal/confotur", [r"discover -s tests[^#]*# (\d+) tests \(dont",
r"test_confotur\.py\s*# (\d+) tests",
r"diffable, (\d+) tests \(dont"]),
("qa/audit_5d", [r"\*\*(\d+) tests\*\* \(`unittest`"]),
("rbac/apply_plan", [r"\*\*(\d+)/(\d+) verts\*\*",
r"en-repo \((\d+) tests verts"]),
("rbac/fixtures_gen", [r"\*\*(\d+)/(\d+) verts\*\*",
r"en-repo \((\d+) tests verts"]),
("rbac/roleprofile_gen", [r"\*\*(\d+)/(\d+) verts\*\*",
r"en-repo \((\d+) tests verts"]),
("rbac/userperm_gen", [r"\*\*(\d+)/(\d+) verts\*\*",
r"en-repo \((\d+) tests verts"]),
("seo", [r"discover -s tests[^#]*# (\d+) tests"]),
]
for suite_path, pats in MODULE_README_TESTS:
rd = f"{D}/{suite_path}/README.md"
try:
rtxt = re.sub(r"\s+", " ", open(rd, encoding="utf-8").read())
except OSError as e:
bad(f"Compte tests README module · {rd} illisible : {e}"); continue
if suite_path not in auth:
bad(f"Compte tests README module · suite `{suite_path}` absente de "
f"plan.suites/count_tests (câblage rompu ?)"); continue
exp = auth[suite_path]
for pat in pats:
m = re.search(pat, rtxt)
if m is None:
bad(f"Compte tests README module · {rd} — claim « N tests » INTROUVABLE "
f"(attendu {exp} · suite `{suite_path}`) · motif=/{pat}/")
continue
got = [int(g) for g in m.groups()]
if all(v == exp for v in got):
good(f"Compte tests README module · {rd} — {'/'.join(map(str, got))} == "
f"source ({exp} · `{suite_path}`)")
else:
bad(f"Compte tests README module · {rd} — dit {'/'.join(map(str, got))} "
f"MAIS source dit {exp} (plan.suites[`{suite_path}`]) → régénérer le "
f"README (ou run-book de régression si un test a bougé)")
# ============================================================================
# Portails frontend · comptes PAR WORKSPACE (cartes / liens / rôles) —
# même classe de « vert trompeur », transcrits À LA MAIN dans DEUX docs.
# ----------------------------------------------------------------------------
# Le générateur de portails émet out/MANIFEST.json dont le tableau `workspaces`
# porte, PAR portail, {nb_cartes, nb_liens, nb_roles} recalculés depuis le
# contrat RBAC à chaque build (byte-gaté par check_artifacts). Ces triplets sont
# recopiés À LA MAIN à DEUX endroits : la table du README du module
# (frontend/portails/README.md, colonnes Cartes|Liens|Rôles) ET la prose de la
# fiche agent (03_agents/frontend_console/AGENT.md — « OTO Ventes 4 cartes/11
# liens/12 rôles · … »). Aucun gate ne les couvrait : ajouter un rôle/DocType au
# contrat ferait dériver LES DEUX docs en silence pendant que l'artefact dit
# autre chose — même piège que les cellules « Tests » et la prose agrégée. On
# RECOMPUTE ici chaque triplet depuis MANIFEST (déjà byte-gaté · zéro duplication
# du compteur du générateur) et on exige l'égalité avec les DEUX docs. Un
# workspace absent d'un doc échoue AUSSI (traçabilité).
# ============================================================================
WS_RD = f"{D}/frontend/portails/README.md"
WS_FI = "03_agents/frontend_console/AGENT.md"
try:
ws_list = load("frontend/portails/out/MANIFEST.json")["workspaces"]
except (OSError, KeyError, ValueError) as e:
bad(f"Portails · MANIFEST illisible/inattendu : {e}"); ws_list = []
try:
ws_rd = re.sub(r"\s+", " ", open(WS_RD, encoding="utf-8").read())
except OSError as e:
bad(f"Portails · {WS_RD} illisible : {e}"); ws_rd = ""
try:
ws_fi = re.sub(r"\s+", " ", open(WS_FI, encoding="utf-8").read())
except OSError as e:
bad(f"Portails · {WS_FI} illisible : {e}"); ws_fi = ""
for w in ws_list:
portail, ws = w["portail"], w["workspace"]
exp = (str(w["nb_cartes"]), str(w["nb_liens"]), str(w["nb_roles"]))
src = f"cartes={exp[0]} liens={exp[1]} rôles={exp[2]}"
# 1) Table du README du module : | <portail> | `<ws>` | C | L | R |
rd_pat = (rf"\| {re.escape(portail)} \| `{re.escape(ws)}` \| "
r"(\d+) \| (\d+) \| (\d+) \|")
mrd = re.search(rd_pat, ws_rd)
if mrd is None:
bad(f"Portails README · {ws} — ligne INTROUVABLE dans {WS_RD} (attendu {src})")
elif mrd.groups() == exp:
good(f"Portails README · {ws} — {'/'.join(mrd.groups())} == MANIFEST ({src})")
else:
bad(f"Portails README · {ws} — {WS_RD} dit {'/'.join(mrd.groups())} MAIS "
f"MANIFEST dit {'/'.join(exp)} ({src}) → régénérer le README")
# 2) Prose de la fiche agent : `<ws>` C cartes/L liens/R rôles (ou C/L/R)
fi_pat = (rf"`{re.escape(ws)}` (\d+)(?: cartes)?/(\d+)(?: liens)?/"
r"(\d+)(?: rôles)?")
mfi = re.search(fi_pat, ws_fi)
if mfi is None:
bad(f"Portails fiche · {ws} — claim INTROUVABLE dans {WS_FI} (attendu {src})")
elif mfi.groups() == exp:
good(f"Portails fiche · {ws} — {'/'.join(mfi.groups())} == MANIFEST ({src})")
else:
bad(f"Portails fiche · {ws} — {WS_FI} dit {'/'.join(mfi.groups())} MAIS "
f"MANIFEST dit {'/'.join(exp)} ({src}) → régénérer la fiche")
# Total de workspaces (« N `Workspace` natifs ») cohérent artefact ↔ fiche.
n_ws = len(ws_list)
if n_ws:
mtot = re.search(r"\*\*(\d+) `Workspace` natifs", ws_fi)
if mtot is None:
bad(f"Portails fiche · total — « N `Workspace` natifs » INTROUVABLE "
f"dans {WS_FI} (attendu {n_ws})")
elif int(mtot.group(1)) == n_ws:
good(f"Portails fiche · total — {mtot.group(1)} == MANIFEST ({n_ws} workspaces)")
else:
bad(f"Portails fiche · total — {WS_FI} dit {mtot.group(1)} MAIS "
f"MANIFEST dit {n_ws} → régénérer la fiche")
# ============================================================================
# Portails frontend (3e surface) · IDENTITÉ de l'ENSEMBLE des portails métier +
# exclusion NOMMÉE de la console technique — même fiche, autre « vert trompeur »
# aveugle au seul COMPTE.
# ----------------------------------------------------------------------------
# Le bloc triplets ci-dessus recompute cartes/liens/rôles PAR workspace mais
# reste aveugle à QUELS portails composent l'ensemble : la fiche agent
# (03_agents/frontend_console/AGENT.md) énumère À LA MAIN, ligne 11, « 5 portails
# rôle métier (Ventes / Construction / Achat / Compta / Direction) » et affirme
# ligne 46 que « la console technique `plateforme` (6ᵉ portail RBAC) est exclue ».
# DEUX assertions data-derived, aucune gatée :
# (a) l'ENSEMBLE ORDONNÉ des noms courts = les workspaces métier du frontend
# (out/MANIFEST.json workspaces · byte-gaté ⇒ faisant autorité · label
# « OTO <Nom> » sans le préfixe, comme l'énumération des onglets Mobile) ET,
# DOUBLE ANCRAGE, leurs ids == `portails_business` du contrat RBAC (l'«
# égalité stricte » que la fiche revendique elle-même en :47) ;
# (b) `plateforme` EST un vrai portail RBAC (∈ portails distincts des rôles),
# il est le 6ᵉ (total distinct == 6 = 5 métier + 1) et il est HORS métier
# (∉ portails_business) — donc légitimement « exclu ».
# Un ÉCHANGE (Achat→Marketing), un RETRAIT, un RÉORDRE, un RENOMMAGE de la console
# technique dans le contrat (`plateforme`→`console`) ou un contrat qui PROMEUT
# `plateforme` en métier ferait dériver la fiche EN SILENCE pendant que les
# artefacts (frontend workspaces + contrat RBAC) disent autre chose — dérive
# qu'aucune suite tests/ (FONCTIONS, jamais la prose) n'attrape. Même patron que
# l'énumération des onglets Mobile ou la colonne « Entité » de la SPEC §4. Zéro
# duplication : on relit deux artefacts déjà byte-gatés, on ne fige aucune liste.
# ============================================================================
try:
_fc = load("rbac/rbac_50_roles.json")
_pb = _fc["portails_business"] # ordre du contrat
_distinct = list(dict.fromkeys(r["portail"] for r in _fc["roles"]))
except (OSError, KeyError, ValueError, TypeError) as e:
bad(f"Portails ensemble · contrat rbac_50_roles.json illisible/inattendu : {e}")
_pb = None; _distinct = None
if _pb is not None and ws_list:
# double ancrage : ids métier du frontend == portails_business du contrat
_fe_ids = [w["portail"] for w in ws_list]
if _fe_ids != _pb:
bad(f"Portails ensemble · ancrage rompu : ids workspaces frontend {_fe_ids} "
f"≠ contrat portails_business {_pb} → resynchroniser artefact/contrat")
else:
good(f"Portails ensemble · double ancrage : ids workspaces frontend == "
f"portails_business ({'/'.join(_pb)})")
# (a) noms courts ordonnés = label workspace sans « OTO » (ce que la fiche copie)
_short = [re.sub(r"^OTO\s+", "", w["workspace"]).strip() for w in ws_list]
ma = re.search(r"(\d+) portails rôle métier \(([^)]*)\)", ws_fi)
if ma is None:
bad(f"Portails ensemble · fiche — « N portails rôle métier (…) » INTROUVABLE "
f"dans {WS_FI} (attendu {'/'.join(_short)})")
else:
got = [s.strip() for s in ma.group(2).split("/") if s.strip()]
if ma.group(1) != str(len(_short)):
bad(f"Portails ensemble · fiche — annonce « {ma.group(1)} portails métier » "
f"MAIS les workspaces frontend en comptent {len(_short)}")
if got == _short:
good(f"Portails ensemble · fiche — énumération {'/'.join(got)} == "
f"workspaces frontend ({len(_short)})")
else:
miss = [x for x in _short if x not in got]
extra = [x for x in got if x not in _short]
detail = (f" · absents={miss}" if miss else "") + \
(f" · en trop={extra}" if extra else "") or " · même ensemble, ordre ≠"
bad(f"Portails ensemble · fiche — énumère {got} MAIS workspaces disent "
f"{_short}{detail} → régénérer la fiche")
if _pb is not None and _distinct is not None:
# (b) console technique exclue = l'unique portail RBAC HORS métier
_tech = [p for p in _distinct if p not in _pb]
if len(_tech) != 1:
bad(f"Portails ensemble · attendu exactement 1 portail technique hors métier, "
f"trouvé {_tech} (distinct={_distinct}, métier={_pb}) → contrat incohérent")
else:
tech = _tech[0]
# « console technique `<nom>` (Nᵉ portail RBAC) est **exclue** » — le \S*
# absorbe l'exposant « ᵉ » (multi-octets) sans le coder en dur ici.
mt = re.search(r"console technique `([^`]+)` \((\d+)\S* portail RBAC\) "
r"est \*\*exclue\*\*", ws_fi)
if mt is None:
bad(f"Portails ensemble · fiche — claim « console technique `{tech}` "
f"(Nᵉ portail RBAC) est **exclue** » INTROUVABLE dans {WS_FI}")
elif mt.group(1) != tech:
bad(f"Portails ensemble · fiche — nomme la console technique `{mt.group(1)}` "
f"MAIS le contrat dit `{tech}` (unique portail hors métier) → régénérer")
elif mt.group(2) != str(len(_distinct)):
bad(f"Portails ensemble · fiche — dit `{tech}` = {mt.group(2)}ᵉ portail RBAC "
f"MAIS le contrat compte {len(_distinct)} portails distincts")
else:
good(f"Portails ensemble · fiche — console technique `{tech}` = "
f"{mt.group(2)}ᵉ portail RBAC, hors métier, exclue ✓")
# ============================================================================
# RBAC · comptes des 3 volets (roleprofile · apply_plan · userperm) —
# même classe de « vert trompeur », recopiés À LA MAIN depuis les MANIFEST.
# ----------------------------------------------------------------------------
# Les trois générateurs RBAC émettent, PAR volet, un out/MANIFEST.json recalculé
# à chaque build depuis le contrat `rbac_50_roles.json` (byte-gaté par
# check_artifacts ⇒ source faisant autorité). Trois READMEs de module rédigent à
# LA MAIN des comptes qui dérivent de ces manifestes, sans AUCUN gate :
# 1) roleprofile_gen/README.md — table « Nb rôles » PAR profil (6 lignes) +
# total. Grain le plus fin (par item), comme la colonne « Tests » des fiches.
# 2) apply_plan/README.md — ligne AGRÉGÉE « Génération réelle : 6 étapes · 50
# rôles / 116 DocPerm / 28 UP templates / 6 Role Profile · confirmations VPS
# 4 DocType custom + 5 Company + 4 rôles equipe » : 8 comptes consolidés de
# TOUS les volets en une phrase.
# 3) userperm_gen/README.md — « Répartition : 28 entite · 16 groupe · 2 own ·
# 4 equipe » : la ventilation par mécanisme d'enforcement.
# Ajouter un rôle/DocType au contrat ferait dériver LES TROIS docs en silence
# pendant que les MANIFEST (byte-gatés) disent autre chose — même piège que les
# cellules « Tests », la prose agrégée CRM/RBAC et les triplets par workspace. On
# RECOMPUTE ici chaque nombre depuis le MANIFEST du volet (zéro duplication des
# compteurs des générateurs) et on exige l'égalité avec ce que la doc ÉCRIT. Un
# claim absent échoue AUSSI (disparition = régression de traçabilité).
# ============================================================================
def read_norm(path):
try:
return re.sub(r"\s+", " ", open(path, encoding="utf-8").read())
except OSError as e:
bad(f"RBAC · {path} illisible : {e}"); return None
# 1) roleprofile_gen — table « Nb rôles » par profil + total bijectif -----------
RP_RD = f"{D}/rbac/roleprofile_gen/README.md"
try:
rp_man = load("rbac/roleprofile_gen/out/MANIFEST.json")
except (OSError, KeyError, ValueError) as e:
bad(f"RBAC roleprofile · MANIFEST illisible : {e}"); rp_man = None
rp_rd = read_norm(RP_RD)
if rp_man is not None and rp_rd is not None:
for p in rp_man["profiles"]:
name, portail, exp = p["role_profile"], p["portail"], str(p["nb_roles"])
# métier vs technique : le flag DATA-DERIVED (booléen `metier` du MANIFEST)
# doit être transcrit EXACTEMENT dans la colonne « Type ». Sans ce gate la
# colonne était un WILDCARD (`[^|]*`) → un portail technique classé « métier »
# (ou l'inverse) passait « vert » alors que l'artefact dit autre chose.
exp_type = "métier" if p["metier"] else "technique"
pat = (rf"\| `{re.escape(name)}` \| {re.escape(portail)} \| "
r"([^|]+?) \| (\d+) \|")
m = re.search(pat, rp_rd)
if m is None:
bad(f"RBAC roleprofile · {name} — ligne INTROUVABLE dans {RP_RD} "
f"(attendu type={exp_type} nb_roles={exp})")
else:
got_type, got_n = m.group(1).strip(), m.group(2)
if got_n == exp:
good(f"RBAC roleprofile · {name} — Nb rôles {got_n} == MANIFEST ({exp})")
else:
bad(f"RBAC roleprofile · {name} — {RP_RD} dit {got_n} MAIS "
f"MANIFEST dit {exp} → régénérer le README")
if got_type == exp_type:
good(f"RBAC roleprofile · {name} — Type « {got_type} » == MANIFEST "
f"(metier={p['metier']})")
else:
bad(f"RBAC roleprofile · {name} — {RP_RD} classe « {got_type} » MAIS "
f"MANIFEST dit « {exp_type} » (metier={p['metier']}) → régénérer")
exp_tot = str(rp_man["counts"]["roles_couverts"])
mt = re.search(r"\*\*Total\*\* \| \*\*(\d+)\*\*", rp_rd)
if mt is None:
bad(f"RBAC roleprofile · total — ligne « Total » INTROUVABLE dans {RP_RD} "
f"(attendu {exp_tot})")
elif mt.group(1) == exp_tot:
good(f"RBAC roleprofile · total — {mt.group(1)} == MANIFEST ({exp_tot} couverts)")
else:
bad(f"RBAC roleprofile · total — {RP_RD} dit {mt.group(1)} MAIS MANIFEST "
f"dit {exp_tot} → régénérer le README")
# 1bis) IDENTITÉ de l'ensemble des profils (pas seulement leurs comptes) --------
# La boucle ci-dessus est MANIFEST→README (chaque profil du contrat a une ligne :
# capte le MANQUANT). Le sens README→MANIFEST (une ligne FANTÔME dans la table,
# profil inventé absent du contrat) reste aveugle → on compte les lignes de profil
# de la table et on exige == |profiles|. Ensemble ∩ boucle = identité d'ensemble.
exp_prof = len(rp_man["profiles"])
rows = re.findall(r"\| `OTO Portail [^|`]+` \| [^|]+ \| (?:métier|technique) \| \d+ \|",
rp_rd)
if len(rows) == exp_prof:
good(f"RBAC roleprofile · table — {len(rows)} lignes de profil == "
f"MANIFEST ({exp_prof}) : aucun profil FANTÔME")
else:
bad(f"RBAC roleprofile · table — {RP_RD} porte {len(rows)} lignes de profil "
f"MAIS MANIFEST en compte {exp_prof} → ligne en trop/manquante ?")
# 1ter) prose « N profils (X métier + Y technique) » == comptes MANIFEST --------
# + cohérences croisées : les comptes portails_metier/techniques doivent RECOMPTER
# les flags `metier` des profils, et leur somme == role_profiles == |profiles|
# (mord un MANIFEST INTERNEMENT incohérent : compte de flag ≠ flags réels).
c = rp_man["counts"]
cm, ct, cp = c["portails_metier"], c["portails_techniques"], c["role_profiles"]
real_m = sum(1 for p in rp_man["profiles"] if p["metier"])
real_t = sum(1 for p in rp_man["profiles"] if not p["metier"])
if cm == real_m and ct == real_t and cm + ct == cp == exp_prof:
good(f"RBAC roleprofile · cohérence comptes — portails_metier={cm} + "
f"techniques={ct} == role_profiles={cp} == |flags métier réels|")
else:
bad(f"RBAC roleprofile · cohérence comptes — MANIFEST incohérent : "
f"portails_metier={cm} (flags réels={real_m}) · techniques={ct} "
f"(réels={real_t}) · role_profiles={cp} · |profiles|={exp_prof}")
mp = re.search(r"\*\*(\d+) profils\*\* \((\d+) métier \+ (\d+) technique\)", rp_rd)
if mp is None:
bad(f"RBAC roleprofile · split métier/technique — prose « N profils "
f"(X métier + Y technique) » INTROUVABLE dans {RP_RD} "
f"(attendu {cp} profils · {cm} métier + {ct} technique)")
elif (mp.group(1), mp.group(2), mp.group(3)) == (str(cp), str(cm), str(ct)):
good(f"RBAC roleprofile · split métier/technique — {mp.group(1)} profils "
f"({mp.group(2)} métier + {mp.group(3)} technique) == MANIFEST")
else:
bad(f"RBAC roleprofile · split métier/technique — {RP_RD} dit "
f"{mp.group(1)}/{mp.group(2)}/{mp.group(3)} MAIS MANIFEST dit "
f"{cp}/{cm}/{ct} → régénérer le README")
# 1quater) RBAC_50_ROLES_SPEC.md §3 — table « Nb rôles » PAR portail ------------
# La table §3 « Cartographie portails ↔ familles » transcrit À LA MAIN le nombre de
# rôles de CHAQUE portail. C'est une ventilation par portail dans un doc DISTINCT
# du MANIFEST roleprofile (gaté ci-dessus) — jamais gaté, et il AVAIT dérivé en
# silence : `direction` 10 / `plateforme` 5 alors que le contrat compte 9 / 6, le
# total 50 restant préservé (dérive muette classique, #6). On RECOMPTE ici chaque
# ligne depuis rbac_50_roles.json (source de vérité, byte-gatée par check_artifacts)
# et on exige l'égalité avec ce que la SPEC écrit, total compris. Un portail manquant
# de la table échoue aussi (disparition = régression de traçabilité).
SPEC_RD = f"{D}/rbac/RBAC_50_ROLES_SPEC.md"
try:
src_roles = load("rbac/rbac_50_roles.json")["roles"]
except (OSError, KeyError, ValueError) as e:
bad(f"RBAC SPEC §3 · rbac_50_roles.json illisible : {e}"); src_roles = None
spec_rd = read_norm(SPEC_RD)
if src_roles is not None and spec_rd is not None:
by_portail = {}
for r in src_roles:
by_portail[r.get("portail")] = by_portail.get(r.get("portail"), 0) + 1
for portail in sorted(by_portail):
exp = str(by_portail[portail])
pat = rf"\| \*\*{re.escape(portail)}\*\*[^|]*\| [^|]*\| (\d+) \|"
m = re.search(pat, spec_rd)
if m is None:
bad(f"RBAC SPEC §3 · portail `{portail}` — ligne INTROUVABLE dans "
f"{SPEC_RD} (attendu Nb rôles={exp})")
elif m.group(1) == exp:
good(f"RBAC SPEC §3 · portail `{portail}` — Nb rôles {m.group(1)} == "
f"rbac_50_roles.json ({exp})")
else:
bad(f"RBAC SPEC §3 · portail `{portail}` — {SPEC_RD} dit {m.group(1)} "
f"MAIS rbac_50_roles.json compte {exp} → corriger la SPEC")
exp_tot = str(len(src_roles))
mt = re.search(r"\*\*Total\*\* \| \*\*(\d+)\*\*", spec_rd)
if mt is None:
bad(f"RBAC SPEC §3 · total — ligne « Total » INTROUVABLE dans {SPEC_RD} "
f"(attendu {exp_tot})")
elif mt.group(1) == exp_tot:
good(f"RBAC SPEC §3 · total — {mt.group(1)} == rbac_50_roles.json ({exp_tot})")
else:
bad(f"RBAC SPEC §3 · total — {SPEC_RD} dit {mt.group(1)} MAIS "
f"rbac_50_roles.json compte {exp_tot} → corriger la SPEC")
# 1quater-bis) RBAC_50_ROLES_SPEC.md §3 — colonne « Familles rattachées » PAR portail
# La MÊME table §3 porte, à GAUCHE de la colonne « Nb rôles » (gatée ci-dessus), la
# colonne « Familles rattachées » : pour chaque portail, l'ENSEMBLE des `famille`
# distinctes de ses rôles (ex. `direction` → {direction, finance, faisabilite, legal}).
# C'est un SET data-derived du contrat `rbac_50_roles.json[roles][].famille` — mais le
# bloc §3 ci-dessus l'AVALE par `[^|]*` (il ne capture QUE le compte), donc AUCUN gate
# ne le touche. Silent-green réel : reclasser la `famille` d'un rôle À L'INTÉRIEUR d'un
# portail (ex. l'unique rôle `legal` de `direction` repassé en `finance`) change le SET
# de familles SANS changer le « Nb rôles » du portail → la cellule §3 ment en silence
# pendant que le compte-gate reste VERT ; ajouter/retirer une famille d'un portail
# (nouveau rôle d'une famille inédite) laisse la cellule PÉRIMÉE. Ni check_artifacts
# (SPEC n'est pas un out/*.json) ni le §3 count-gate ni le §5 (regroupements ÉDITORIAUX,
# volontairement NON mappés famille↔groupe) ne mordent. On RECOMPUTE le SET par portail
# et on exige, par ENSEMBLE (absent ET fantôme), l'égalité avec les jetons `famille` de
# la cellule. On ne gate QUE les jetons `famille` bruts (identifiant de tête de chaque
# item séparé par `,`), PAS les parenthèses ÉDITORIALES (« finance (CFO) », « faisabilite
# (analyste) » — paraphrase, pas contrat, #6). Un portail sans cellule échoue AUSSI
# (INTROUVABLE = régression de traçabilité #6).
if src_roles is not None and spec_rd is not None:
fam_by_portail = {}
for r in src_roles:
p = r.get("portail"); f = r.get("famille")
fam_by_portail.setdefault(p, [])
if f not in fam_by_portail[p]:
fam_by_portail[p].append(f)
for portail in sorted(fam_by_portail):
exp_fam = fam_by_portail[portail]
# cellule « Familles rattachées » = 2ᵉ colonne (entre le portail et « Nb rôles »)
pat = rf"\| \*\*{re.escape(portail)}\*\*[^|]*\| ([^|]*)\| \d+ \|"
m = re.search(pat, spec_rd)
if m is None:
bad(f"RBAC SPEC §3 familles · portail `{portail}` — cellule INTROUVABLE "
f"dans {SPEC_RD} (attendu familles {{{', '.join(exp_fam)}}})")
continue
# jeton `famille` de tête de chaque item séparé par « , » (hors parenthèses éditoriales)
got_fam = re.findall(r"(?:^|,)\s*([a-z]+)", m.group(1).strip())
absent = [f for f in exp_fam if f not in got_fam]
phantom = [f for f in got_fam if f not in exp_fam]
if not absent and not phantom:
good(f"RBAC SPEC §3 familles · portail `{portail}` — {{{', '.join(sorted(got_fam))}}} "
f"== familles distinctes du contrat")
else:
det = []
if absent: det.append(f"MANQUE {{{', '.join(absent)}}}")
if phantom: det.append(f"FANTÔME {{{', '.join(phantom)}}}")
bad(f"RBAC SPEC §3 familles · portail `{portail}` — la cellule diverge du "
f"contrat : {' · '.join(det)} (attendu {{{', '.join(exp_fam)}}}) → corriger la SPEC")
# 1quinquies) RBAC_50_ROLES_SPEC.md §7 — comptes du run-book d'application --------
# La NOTE §7 (« ✅ … livré en-repo ») restitue, sur une SURFACE DISTINCTE des READMEs
# userperm_gen / roleprofile_gen (déjà gatés), TROIS comptes data-derived du run-book
# d'application : « 28 templates `Company` (portée `entite`) », « 6 profils (5 portails
# métier + console technique `plateforme`) » et « 50 rôles de façon bijective ». Ces
# nombres sont recopiés À LA MAIN dans la SPEC ; le bloc §3 ne gate QUE la ventilation
# par portail, et les gates userperm/roleprofile ne regardent QUE leurs propres READMEs
# — AVEUGLES à cette note. BUMPER une portée `entite`→`groupe` dans le contrat (le
# nombre de `User Permission` templates change), AJOUTER/RETIRER un portail (le nombre
# de `Role Profile` change) ou casser la bijection laisse la note PÉRIMÉE en silence
# pendant que le contrat dit autre chose → l'agent ERPNext Backend lirait un run-book
# qui promet un décompte faux. On RECOMPUTE les 3 comptes depuis rbac_50_roles.json
# (source de vérité, byte-gatée par check_artifacts) : 28 == |scope==entite|, 6 ==
# |portails distincts| (= 1 Role Profile par portail), 5 == portails métier (hors
# `plateforme`, la console technique), 50 == |rôles|. Un claim absent échoue AUSSI.
if src_roles is not None and spec_rd is not None:
# La note §7 vit dans un blockquote (`> `) : les marqueurs survivent au collapse
# de read_norm et fragmentent la prose (« 28 > templates »). On les retire pour
# ce bloc (aucun `> ` littéral hors blockquote dans cette SPEC ; les flèches sont
# des `→`). Le §3 (tables sans blockquote) n'en a pas besoin.
spec7 = spec_rd.replace("> ", "")
up_entite = sum(1 for r in src_roles if r.get("scope_donnees") == "entite")
portails = {r.get("portail") for r in src_roles}
metier = portails - {"plateforme"}
if "plateforme" not in portails:
bad("RBAC SPEC §7 · portail technique `plateforme` ABSENT du contrat "
"rbac_50_roles.json → la note « console technique plateforme » est fausse")
exp_up = str(up_entite)
m_up = re.search(r"(\d+) templates `Company` \(portée `entite`\)", spec7)
if m_up is None:
bad(f"RBAC SPEC §7 · « N templates Company (portée entite) » INTROUVABLE "
f"dans {SPEC_RD} (attendu {exp_up})")
elif m_up.group(1) == exp_up:
good(f"RBAC SPEC §7 · UP templates {m_up.group(1)} == |scope==entite| "
f"rbac_50_roles.json ({exp_up})")
else:
bad(f"RBAC SPEC §7 · {SPEC_RD} dit {m_up.group(1)} templates Company MAIS "
f"rbac_50_roles.json compte {exp_up} rôles `entite` → corriger la SPEC")
exp_prof, exp_metier, exp_bij = str(len(portails)), str(len(metier)), str(len(src_roles))
m_pr = re.search(
r"\*\*(\d+) profils\*\* \((\d+) portails métier \+ console technique "
r"`plateforme`\) couvrant les \*\*(\d+) rôles de façon bijective\*\*", spec7)
if m_pr is None:
bad(f"RBAC SPEC §7 · « N profils (M portails métier …) couvrant les K rôles "
f"de façon bijective » INTROUVABLE dans {SPEC_RD} "
f"(attendu {exp_prof}/{exp_metier}/{exp_bij})")
elif m_pr.groups() == (exp_prof, exp_metier, exp_bij):
good(f"RBAC SPEC §7 · Role Profiles {m_pr.group(1)} (métier {m_pr.group(2)}) "
f"bijectifs sur {m_pr.group(3)} rôles == rbac_50_roles.json "
f"({exp_prof}/{exp_metier}/{exp_bij})")
else:
bad(f"RBAC SPEC §7 · {SPEC_RD} dit {'/'.join(m_pr.groups())} MAIS "
f"rbac_50_roles.json compte {exp_prof}/{exp_metier}/{exp_bij} "
f"(profils=portails distincts / métier=hors plateforme / rôles) "
f"→ corriger la SPEC")
# 1sexies) RBAC_50_ROLES_SPEC.md §4 — colonne « Entité » ancrée sur CLAUDE.md §Entités
# La table §4 « Cartographie rôles ↔ entités » liste, en colonne de tête, les entités
# porteuses (`entite_principale`) du RBAC : les 7 entités NOMMÉES du mandat (WAF · WA
# SRL · AC Arias Cuevas · Consortium ECR DR · Helios RD · Ploutos · 9060 QC) + le
# périmètre consolidé « Groupe ». Cette colonne a DEUX sources faisant autorité — (i)
# CLAUDE.md §Entités (l'orthographe + le descripteur parenthétique de chaque entité
# nommée) et (ii) rbac_50_roles.json (l'ENSEMBLE des `entite_principale` réellement
# portées par les 50 rôles) — mais AUCUN gate ne la touchait : le §3 gate les comptes
# par PORTAIL, le §7 les comptes du run-book, les blocs fiches l'`entite` d'UN rôle ;
# tous AVEUGLES à l'identité de CETTE liste. AJOUTER au contrat un rôle porté par une
# entité absente de §4, RENOMMER une entité dans CLAUDE.md §Entités (9060 QC→9061 QC)
# ou DÉRIVER un descripteur laisse la SPEC contredire le mandat/le contrat EN SILENCE
# — « vert trompeur » de la MÊME classe que les entités CONFOTUR (§Entités) ou la
# marque SEO org, qu'aucune suite tests/ (FONCTIONS RBAC, jamais la prose de la SPEC)
# n'attrape.
# GATE : (a) ENSEMBLE des entités de §4 == distinct `entite_principale` du contrat
# (set-diff : ni fantôme ni manquante) ; (b) ANCRAGE — chaque entité NOMMÉE de
# CLAUDE.md §Entités est une ligne de §4 avec son descripteur parenthétique EXACT
# (absence comprise) ; (c) chaque entité nommée du mandat est PORTÉE ≥1× par le
# contrat (cm_ents ⊆ contrat). Un claim absent échoue AUSSI (traçabilité #6).
if src_roles is not None and spec_rd is not None:
try:
_cm4 = open("CLAUDE.md", encoding="utf-8").read()
_sec4 = re.search(r"^## Entités\s*\n(.*?)(?=^## )", _cm4, re.S | re.M)
if not _sec4:
raise ValueError("section « ## Entités » introuvable")
# (entité → descripteur parenthétique | None), dans l'ordre du mandat
cm4 = [(m.group(1).strip(), (m.group(2).strip() if m.group(2) else None))
for m in re.finditer(r"\*\*([^*]+?)\*\*(?:\s*\(([^)]+)\))?", _sec4.group(1))]
if not cm4:
raise ValueError("aucune entité en gras dans §Entités")
if len({e for e, _ in cm4}) != len(cm4):
raise ValueError(f"entités non uniques : {[e for e, _ in cm4]}")
except (OSError, ValueError) as e:
bad(f"RBAC SPEC §4 · CLAUDE.md §Entités illisible/inattendu : {e}"); cm4 = None
_m4 = re.search(r"## 4\..*?(?=## 5\.)", spec_rd)
if _m4 is None:
bad(f"RBAC SPEC §4 · section « ## 4. » INTROUVABLE dans {SPEC_RD} "
f"(claim absent = régression #6)"); sec4 = None
else:
sec4 = _m4.group(0)
if cm4 is not None and sec4 is not None:
# Colonne de tête : « | **Entité** (desc)? | » (le header non-gras et le
# séparateur ne matchent pas ; la colonne « Exemples de rôles » n'a pas de gras)
spec4 = [(m.group(1).strip(), (m.group(2).strip() if m.group(2) else None))
for m in re.finditer(r"\| \*\*([^*|]+?)\*\*(?: \(([^)|]+)\))? \|", sec4)]
spec4_names = {n for n, _ in spec4}
contract_ents = {r.get("entite_principale") for r in src_roles}
# (a) ENSEMBLE §4 == distinct entite_principale du contrat
_miss = sorted(contract_ents - spec4_names) # portées mais absentes de §4
_phan = sorted(spec4_names - contract_ents) # listées mais jamais portées
if _miss or _phan:
bad(f"RBAC SPEC §4 · (a) colonne Entité ≠ distinct `entite_principale` du "
f"contrat — manquantes={_miss} · fantômes={_phan} → corriger la SPEC "
f"(ou régénérer si le contrat a changé)")
else:
good(f"RBAC SPEC §4 · (a) {len(spec4_names)} entités == distinct "
f"`entite_principale` rbac_50_roles.json (ni fantôme ni manquante)")
# (b) ANCRAGE — chaque entité NOMMÉE de CLAUDE.md est une ligne §4 · descripteur exact
spec4_map = dict(spec4)
for _e, _d in cm4:
if _e not in spec4_map:
bad(f"RBAC SPEC §4 · (b) entité canonique `{_e}` (CLAUDE.md §Entités) "
f"ABSENTE de la colonne §4 → SPEC périmée")
elif spec4_map[_e] != _d:
bad(f"RBAC SPEC §4 · (b) `{_e}` — descripteur §4 {spec4_map[_e]!r} ≠ "
f"CLAUDE.md §Entités {_d!r} → aligner sur le mandat")
else:
good(f"RBAC SPEC §4 · (b) `{_e}` ({_d or 'sans descripteur'}) == "
f"CLAUDE.md §Entités")
# (c) chaque entité nommée du mandat est PORTÉE ≥1× par le contrat
_unused = sorted(e for e, _ in cm4 if e not in contract_ents)
if _unused:
bad(f"RBAC SPEC §4 · (c) entités du mandat JAMAIS portées par un rôle : "
f"{_unused} → entité décorative (contrat ou §Entités à revoir)")
else:
good(f"RBAC SPEC §4 · (c) les {len(cm4)} entités nommées du mandat sont "
f"portées ≥1× par le contrat")
# 1septies) RBAC_50_ROLES_SPEC.md §5 — « Les N rôles (résumé) » : compte-titre,
# comptes de regroupement éditoriaux et LIGNE d'auto-contrôle arithmétique -------
# Le §5 « résumé » restitue le total de rôles sur TROIS surfaces prose qu'aucun
# gate ne touchait — (i) le TITRE « ## 5. Les N rôles (résumé) », (ii) NEUF comptes
# parenthétiques de regroupement (« **Ventes (8)** » …) et (iii) une LIGNE
# d'auto-contrôle arithmétique « _(6+8+…+6 = 50.)_ ». Le §3 gate la ventilation par
# PORTAIL, le §7 le run-book, le §4 les entités — TOUS aveugles à ce §5. NB : les 9
# regroupements sont ÉDITORIAUX (« Direction / Gouvernance » agrège des rôles de
# familles direction+finance ; ≠ la colonne `famille` du contrat, cf. memory
# rbac-spec-portail-count-gate « §5 by-family is editorial ») → on NE mappe PAS
# groupe↔famille (ce serait une dérive fausse). On gate ce qui EST data-derived et
# vérifiable SANS ce mapping : (a) le compte-titre N == |rôles| du contrat (byte-
# gaté par check_artifacts) ; (b) les 9 comptes de regroupement == EXACTEMENT, dans
# l'ordre, les termes de la ligne arithmétique (cohérence interne prose↔somme) ;
# (c) l'arithmétique est VRAIE (Σ termes == membre droit) ; (d) le membre droit ==
# |rôles|. AJOUTER un rôle au contrat (|rôles| 50→51) laisse le titre, la somme et
# le « = 50 » PÉRIMÉS en silence ; ÉDITER « Ventes (8) »→« (7) » sans toucher la
# ligne casse (b) puis (c). Section/titre/ligne/comptes absents = échec (#6).
if src_roles is not None and spec_rd is not None:
N5 = len(src_roles)
_m5 = re.search(r"## 5\..*?(?=## 6\.)", spec_rd)
if _m5 is None:
bad(f"RBAC SPEC §5 · section « ## 5. » INTROUVABLE dans {SPEC_RD} "
f"(claim absent = régression #6)")
else:
sec5 = _m5.group(0)
# (a) compte-titre « ## 5. Les N rôles »
mt5 = re.search(r"## 5\. Les (\d+) rôles", sec5)
if mt5 is None:
bad(f"RBAC SPEC §5 · titre « ## 5. Les N rôles » INTROUVABLE dans {SPEC_RD}")
elif mt5.group(1) == str(N5):
good(f"RBAC SPEC §5 · (a) compte-titre {mt5.group(1)} == |rôles| "
f"rbac_50_roles.json ({N5})")
else:
bad(f"RBAC SPEC §5 · (a) titre dit {mt5.group(1)} rôles MAIS le contrat "
f"en compte {N5} → corriger la SPEC")
# 9 comptes de regroupement (gras · « (N) » collant au **fermant)
heads = [int(m.group(1)) for m in
re.finditer(r"\*\*[^*]+?\((\d+)\)\*\*", sec5)]
# ligne d'auto-contrôle « _(a+b+… = T.)_ »
ma5 = re.search(r"_\(((?:\d+\+)+\d+) = (\d+)\.\)_", sec5)
if ma5 is None:
bad(f"RBAC SPEC §5 · ligne d'auto-contrôle « _(a+b+… = T.)_ » "
f"INTROUVABLE dans {SPEC_RD} (claim absent = régression #6)")
elif not heads:
bad(f"RBAC SPEC §5 · aucun compte de regroupement « **… (N)** » trouvé "
f"dans {SPEC_RD} (claim absent = régression #6)")
else:
addends = [int(x) for x in ma5.group(1).split("+")]
rhs = int(ma5.group(2))
# (b) comptes de regroupement == termes de la ligne, EN ORDRE
if heads == addends:
good(f"RBAC SPEC §5 · (b) {len(heads)} comptes de regroupement == "
f"termes de la ligne d'auto-contrôle ({'+'.join(map(str, addends))})")
else:
bad(f"RBAC SPEC §5 · (b) comptes de regroupement {heads} ≠ termes de "
f"la ligne {addends} (ordre/valeur) → aligner titres et somme")
# (c) arithmétique vraie
if sum(addends) == rhs:
good(f"RBAC SPEC §5 · (c) Σ termes {'+'.join(map(str, addends))} = "
f"{rhs} (arithmétique exacte)")
else:
bad(f"RBAC SPEC §5 · (c) la ligne écrit « = {rhs} » MAIS Σ termes = "
f"{sum(addends)} → corriger la somme")
# (d) membre droit == |rôles| du contrat
if rhs == N5:
good(f"RBAC SPEC §5 · (d) membre droit {rhs} == |rôles| "
f"rbac_50_roles.json ({N5})")
else:
bad(f"RBAC SPEC §5 · (d) la ligne totalise {rhs} MAIS le contrat "
f"compte {N5} rôles → SPEC périmée")
# 1octies) RBAC_50_ROLES_SPEC.md §2 — ligne « Row-level » : ENSEMBLE des `scope_donnees`
# ancré sur l'ENUM du schéma ---------------------------------------------------
# Le §2 « Modèle RBAC » ouvre par « trois niveaux, tous couverts PAR LE SCHÉMA » puis
# sa 3ᵉ ligne de table (« **Row-level** (User Permission) | `scope_donnees` | … ») ÉNUMÈRE
# À LA MAIN l'ensemble EXHAUSTIF des portées row-level : `own` · `equipe` · `entite`
# (Company) · `groupe` (consolidé). Cet ensemble EST, par la phrase d'intro, l'`enum`
# `scope_donnees` de `rbac.schema.json` (la DÉFINITION du modèle — pas le sous-ensemble
# effectivement exercé par les 50 rôles, qui peut être plus petit). AUCUN gate ne le
# touchait : §3 compte les rôles par portail, §4 les entités, §5 le résumé, le bloc
# userperm gate la TABLE `scope → mécanisme` d'un AUTRE artefact (userperm_gen/out) —
# tous AVEUGLES à l'énumération du §2. « Vert trompeur » : le schéma AJOUTE une portée
# (`region` pour un besoin ops réel) ou en RETIRE une (`own` supprimé, schéma resserré)
# et le §2 reste PÉRIMÉ en silence — il documente un modèle qui contredit le schéma
# tout en se réclamant « couvert par le schéma ». Ni check_artifacts (la SPEC n'est pas
# un out/*.json) ni tests/ (FONCTIONS de validation, jamais l'ancre SPEC↔schéma) ne
# mordent. Même classe que §4 (colonne ancrée sur une source-de-vérité structurée).
# GATE : (a) ENSEMBLE des jetons `scope` de la ligne §2 == `enum` scope_donnees du
# schéma (set-diff absent ET fantôme) ; (b) ANCRAGE — la colonne du milieu NOMME bien
# `scope_donnees` (sinon on gate la mauvaise ligne) ; (c) sanity — toute portée
# RÉELLEMENT exercée par le contrat ∈ enum (une portée en usage jamais dans l'enum =
# schéma incohérent). On NE gate PAS les gloses parenthétiques `(Company)`/`(consolidé)`
# (éditoriales · #6). Ligne/section/enum absents = échec (traçabilité #6).
if src_roles is not None and spec_rd is not None:
try:
_sch = load("rbac/rbac.schema.json")
_enum = _sch["definitions"]["role"]["properties"]["scope_donnees"]["enum"]
scope_enum = set(_enum)
if not scope_enum:
raise ValueError("enum scope_donnees vide")
if len(scope_enum) != len(_enum):
raise ValueError(f"enum scope_donnees non unique : {_enum}")
except (OSError, KeyError, ValueError, TypeError) as e:
bad(f"RBAC SPEC §2 · rbac.schema.json enum scope_donnees illisible : {e}")
scope_enum = None
_m2 = re.search(r"## 2\..*?(?=## 3\.)", spec_rd)
if _m2 is None:
bad(f"RBAC SPEC §2 · section « ## 2. » INTROUVABLE dans {SPEC_RD} "
f"(claim absent = régression #6)")
elif scope_enum is not None:
# Ligne « Row-level » : capte la colonne « champ » (c2) et la colonne énum (c3)
_row = re.search(r"\| \*\*Row-level\*\*[^|]*\|([^|]*)\|([^|]*)\|", _m2.group(0))
if _row is None:
bad(f"RBAC SPEC §2 · ligne « **Row-level** » INTROUVABLE dans {SPEC_RD} "
f"(claim absent = régression #6)")
else:
c_field, c_enum = _row.group(1), _row.group(2)
# (b) la colonne du milieu doit nommer le champ `scope_donnees`
if "scope_donnees" not in c_field:
bad(f"RBAC SPEC §2 · (b) colonne « champ » de la ligne Row-level ne "
f"nomme pas `scope_donnees` (capté {c_field!r}) → ligne mal ancrée")
else:
good("RBAC SPEC §2 · (b) ligne Row-level ancrée sur le champ "
"`scope_donnees`")
spec_scopes = set(re.findall(r"`([a-z_]+)`", c_enum))
_miss = sorted(scope_enum - spec_scopes) # dans l'enum, absents du §2
_phan = sorted(spec_scopes - scope_enum) # listés au §2, hors enum
if _miss or _phan:
bad(f"RBAC SPEC §2 · (a) énumération `scope_donnees` ≠ enum du "
f"schéma — manquants={_miss} · fantômes={_phan} → aligner la "
f"SPEC sur rbac.schema.json (ou régénérer si le schéma a changé)")
else:
good(f"RBAC SPEC §2 · (a) {len(spec_scopes)} portées row-level "
f"{sorted(spec_scopes)} == enum scope_donnees du schéma")
# (c) sanity : toute portée exercée par le contrat ∈ enum
_used = {r.get("scope_donnees") for r in src_roles}
_illegal = sorted(_used - scope_enum)
if _illegal:
bad(f"RBAC SPEC §2 · (c) portées `scope_donnees` exercées par le contrat "
f"HORS enum du schéma : {_illegal} → schéma/contrat incohérents")
else:
good(f"RBAC SPEC §2 · (c) les {len(_used)} portées exercées par le contrat "
f"sont toutes dans l'enum du schéma")
# 2) apply_plan — ligne « Génération réelle » agrégeant les 3 volets ------------
AP_RD = f"{D}/rbac/apply_plan/README.md"
try:
ap_man = load("rbac/apply_plan/out/MANIFEST.json")
ap_steps = len(load("rbac/apply_plan/out/apply_plan.json"))
except (OSError, KeyError, ValueError) as e:
bad(f"RBAC apply_plan · artefact illisible : {e}"); ap_man = None
ap_rd = read_norm(AP_RD)
if ap_man is not None and ap_rd is not None:
c, v = ap_man["counts"], ap_man["confirmations_vps"]
ap_exp = (str(ap_steps), str(c["roles"]), str(c["custom_docperm"]),
str(c["user_permission_templates"]), str(c["role_profiles"]),
str(len(v["custom_doctypes"])), str(len(v["companies"])),
str(len(v["roles_scope_equipe"])))
ap_src = (f"étapes={ap_exp[0]} rôles={ap_exp[1]} docperm={ap_exp[2]} "
f"up={ap_exp[3]} role_profiles={ap_exp[4]} custom_dt={ap_exp[5]} "
f"companies={ap_exp[6]} equipe={ap_exp[7]}")
ap_pat = (r"\*\*(\d+) étapes\*\* · (\d+) rôles / (\d+) DocPerm / "
r"(\d+) UP templates / (\d+) Role Profile · confirmations VPS "
r"(\d+) DocType custom \+ (\d+) Company \+ (\d+) rôles")
ma = re.search(ap_pat, ap_rd)
if ma is None:
bad(f"RBAC apply_plan · « Génération réelle » — claim INTROUVABLE dans "
f"{AP_RD} (attendu {ap_src})")
elif ma.groups() == ap_exp:
good(f"RBAC apply_plan · agrégat — {AP_RD} {'/'.join(ma.groups())} == "
f"MANIFEST ({ap_src})")
else:
bad(f"RBAC apply_plan · {AP_RD} dit {'/'.join(ma.groups())} MAIS MANIFEST "
f"dit {'/'.join(ap_exp)} ({ap_src}) → régénérer le README")
# 2bis) apply_plan — IDENTITÉ de la TABLE « Run-book généré » (SPEC §7) ----------
# Le bloc 2 ci-dessus ne gate que la ligne AGRÉGÉE « Génération réelle » (comptes).
# La table « ## Run-book généré » énumère, PAR étape, trois données data-derived de
# apply_plan.json (byte-gaté par check_artifacts) : le n° d'ordre (#), le
# RESPONSABLE (worker/vps/worker+vps) et la colonne « Dépend de » (les n°s d'ordre
# des `depends_on`). C'est le GRAPHE D'APPLICATION ORDONNÉ que l'agent ERPNext suit
# pas à pas — un responsable ou une dépendance périmés (ex. Role Profile importé
# AVANT les Role, ou une étape VPS attribuée au worker) sont un hazard réel,
# invisible à check_artifacts (qui ne prouve QUE apply_plan==build) ET au bloc de
# comptes (aveugle à l'identité des lignes ; ces colonnes étaient un WILDCARD).
# Même classe que la colonne « Type » de roleprofile_gen et la table demo/scenarios.
# On RECOMPUTE #/responsable/deps depuis apply_plan.json (zéro duplication) et on
# exige que la table les énonce EXACTEMENT. Une étape MANQUANTE ou une ligne FANTÔME
# échoue AUSSI (identité d'ensemble). La colonne « Étape » reste libre (paraphrase).
# Cohérences croisées en bonus (mordent un plan INTERNEMENT incohérent) : ordres
# contigus 1..N sans doublon · responsable ∈ {worker, vps, worker+vps} · toute
# dépendance pointe en ARRIÈRE (n° < n° de l'étape ⇒ pas de cycle/renvoi avant).
try:
ap_plan = load("rbac/apply_plan/out/apply_plan.json")
except (OSError, KeyError, ValueError) as e:
bad(f"RBAC apply_plan · run-book illisible : {e}"); ap_plan = None
if ap_plan is not None:
id2order = {s["id"]: s["order"] for s in ap_plan}
ALLOWED_RESP = {"worker", "vps", "worker+vps"}
orders = sorted(s["order"] for s in ap_plan)
if orders == list(range(1, len(ap_plan) + 1)):
good(f"RBAC apply_plan · run-book — ordres contigus 1..{len(ap_plan)} "
f"(aucun trou/doublon)")
else:
bad(f"RBAC apply_plan · run-book — ordres NON contigus {orders} "
f"(attendu 1..{len(ap_plan)}) → plan incohérent")
# Lignes de la table lues en RAW : la colonne 1 ENTIÈRE ⇒ scope la table
# run-book (seule table du README à 1ʳᵉ colonne numérique · vérifié).
try:
ap_lines = open(AP_RD, encoding="utf-8").read().splitlines()
except OSError as e:
bad(f"RBAC apply_plan · {AP_RD} illisible : {e}"); ap_lines = []
table_orders, rows_by_order = [], {}
for ln in ap_lines:
cells = [c.strip() for c in ln.strip().strip("|").split("|")]
if len(cells) == 4 and re.fullmatch(r"\d+", cells[0]):
o = int(cells[0]); table_orders.append(o); rows_by_order[o] = cells
if sorted(table_orders) == orders and len(table_orders) == len(set(table_orders)):
good(f"RBAC apply_plan · run-book — {len(table_orders)} lignes == "
f"{len(ap_plan)} étapes (aucune FANTÔME/MANQUANTE)")
else:
bad(f"RBAC apply_plan · run-book — lignes table {sorted(table_orders)} ≠ "
f"étapes artefact {orders} (ligne en trop/manquante ou doublon)")
for s in sorted(ap_plan, key=lambda x: x["order"]):
o, resp = s["order"], s["responsable"]
deps = sorted(id2order[x] for x in s["depends_on"])
if resp not in ALLOWED_RESP:
bad(f"RBAC apply_plan · étape {o} — responsable artefact « {resp} » "
f"hors {sorted(ALLOWED_RESP)} (plan incohérent)")
if any(d >= o for d in deps):
bad(f"RBAC apply_plan · étape {o} — dépendance en AVANT {deps} "
f"(≥ {o}) : graphe incohérent")
row = rows_by_order.get(o)
if row is None:
bad(f"RBAC apply_plan · étape {o} — ligne INTROUVABLE dans {AP_RD} "
f"(attendu responsable={resp} deps={deps or '—'})"); continue
got_resp = row[1]
got_deps = sorted(int(x) for x in re.findall(r"\d+", row[3]))
if got_resp == resp:
good(f"RBAC apply_plan · étape {o} — responsable « {got_resp} » == artefact")
else:
bad(f"RBAC apply_plan · étape {o} — {AP_RD} dit responsable « {got_resp} » "
f"MAIS artefact dit « {resp} » → régénérer le README")
if got_deps == deps:
good(f"RBAC apply_plan · étape {o} — Dépend de {got_deps or '—'} == artefact")
else:
bad(f"RBAC apply_plan · étape {o} — {AP_RD} « Dépend de » = {got_deps or '—'} "
f"MAIS artefact dit {deps or '—'} → régénérer le README")
# 3) userperm — ventilation par mécanisme d'enforcement -------------------------
UP_RD = f"{D}/rbac/userperm_gen/README.md"
try:
up_bm = load("rbac/userperm_gen/out/MANIFEST.json")["counts"]["by_mechanism"]
except (OSError, KeyError, ValueError) as e:
bad(f"RBAC userperm · MANIFEST illisible : {e}"); up_bm = None
up_rd = read_norm(UP_RD)
if up_bm is not None and up_rd is not None:
up_exp = (str(up_bm["user_permission_company"]), str(up_bm["none_consolidated"]),
str(up_bm["docperm_if_owner"]), str(up_bm["vps_confirm_team"]))
up_src = (f"entite={up_exp[0]} groupe={up_exp[1]} own={up_exp[2]} "
f"equipe={up_exp[3]}")
up_pat = (r"\*\*(\d+)\*\* `entite` · \*\*(\d+)\*\* `groupe` · "
r"\*\*(\d+)\*\* `own` · \*\*(\d+)\*\* `equipe`")
mu = re.search(up_pat, up_rd)
if mu is None:
bad(f"RBAC userperm · répartition — claim INTROUVABLE dans {UP_RD} "
f"(attendu {up_src})")
elif mu.groups() == up_exp:
good(f"RBAC userperm · répartition — {UP_RD} {'/'.join(mu.groups())} == "
f"MANIFEST ({up_src})")
else:
bad(f"RBAC userperm · {UP_RD} dit {'/'.join(mu.groups())} MAIS MANIFEST "
f"dit {'/'.join(up_exp)} ({up_src}) → régénérer le README")
# ============================================================================
# SEO · comptes de mots-clés (total + ventilation par langue) —
# même classe de « vert trompeur », transcrits À LA MAIN dans DEUX docs.
# ----------------------------------------------------------------------------
# Le générateur SEO émet out/MANIFEST.json dont le bloc `counts` porte
# keywords_total et keywords_per_lang{fr,en,es}, RECOMPTÉS à chaque build depuis
# `projets_master.json` (byte-gaté par check_artifacts ⇒ source faisant autorité).
# Ce quadruplet est recopié À LA MAIN à DEUX endroits : la table « Livrables » du
# README du module (05_deliverables_mvp/seo/README.md — « **258** mots-clés
# (fr=87 · en=87 · es=84) ») ET la même ligne de la fiche agent
# (03_agents/seo/AGENT.md). Aucun gate ne les couvrait : ajouter un projet ou du
# vocabulaire éditorial ferait dériver LES DEUX docs en silence pendant que
# l'artefact dit autre chose — même piège que les triplets par workspace des
# portails. On RECOMPUTE ici (total + 3 langues) depuis MANIFEST.counts (déjà
# byte-gaté · zéro duplication du compteur du générateur) et on exige l'égalité
# avec les DEUX docs. Un claim absent échoue AUSSI (traçabilité).
# ============================================================================
SEO_RD = f"{D}/seo/README.md"
SEO_FI = "03_agents/seo/AGENT.md"
try:
seo_c = load("seo/out/MANIFEST.json")["counts"]
seo_exp = (str(seo_c["keywords_total"]),
str(seo_c["keywords_per_lang"]["fr"]),
str(seo_c["keywords_per_lang"]["en"]),
str(seo_c["keywords_per_lang"]["es"]))
except (OSError, KeyError, ValueError) as e:
bad(f"SEO · MANIFEST illisible/inattendu : {e}"); seo_exp = None
if seo_exp is not None:
seo_src = (f"total={seo_exp[0]} fr={seo_exp[1]} en={seo_exp[2]} es={seo_exp[3]}")
seo_pat = r"\*\*(\d+)\*\* mots-clés \(fr=(\d+) · en=(\d+) · es=(\d+)\)"
for path_f in (SEO_RD, SEO_FI):
txt = read_norm(path_f)
if txt is None:
continue
m = re.search(seo_pat, txt)
if m is None:
bad(f"SEO · {path_f} — claim mots-clés INTROUVABLE (attendu {seo_src})")
elif m.groups() == seo_exp:
good(f"SEO · {path_f} — {'/'.join(m.groups())} == MANIFEST ({seo_src})")
else:
bad(f"SEO · {path_f} — dit {'/'.join(m.groups())} MAIS MANIFEST dit "
f"{'/'.join(seo_exp)} ({seo_src}) → régénérer le doc")
# ============================================================================
# SEO · IDENTITÉ du COMPTE d'INVARIANTS CI — le README annonce « ## Garanties
# (N invariants) » et le générateur se SELF-REPORTE (« schéma + N invariants »)
# ----------------------------------------------------------------------------
# CLASSE DISTINCTE du bloc SEO ci-dessus (qui gate des COMPTES d'ARTEFACT —
# mots-clés total + par langue, recomptés de MANIFEST.counts). Ici la donnée
# gardée est le COMPTE D'INVARIANTS de validation, dont la source d'autorité
# n'est PAS un artefact JSON (invisible à check_artifacts qui byte-gate les
# out/*.json) mais le LEDGER NUMÉROTÉ de la fonction `_validate` du générateur :
# les marqueurs `# N ·` (1 · 2 · … · 15) qui ouvrent chaque contrôle appliqué —
# exactement l'analogue du registre `# Invariant N` de Chat OTOIA. Ce compte est
# transcrit À LA MAIN à SEPT endroits qu'AUCUN gate ne couvrait : le titre de
# section du README (« ## Garanties (N invariants) ») + son bloc de commande
# (« schéma de sortie + N invariants »), plus CINQ chaînes du .py lui-même
# (docstring module, docstring `_validate`, aide `build`, message de succès CLI
# « N invariants verts. », aide du sous-parseur `validate`). Ajouter un contrôle
# `# 16 ·` à `_validate` sans toucher les chaînes ⇒ le README ET les self-reports
# du .py mentent en silence pendant que la CI applique 16 contrôles — « vert
# trompeur » (#6) que ni les suites tests/ (FONCTIONS, jamais la prose) ni le
# byte-gate d'artefact (aveugle au .py source) n'attrapent. On RECOMPUTE ici le
# ledger depuis le SEUL registre-source (les `# N ·` de `_validate`) et on exige :
# (a) le ledger est CONTIGU 1..N (lacune/doublon dans les marqueurs = défaut réel
# du registre) ; (b) TOUTE mention « N invariants » du .py (self-reports) == |ledger|
# (un seul chiffre désynchronisé mordu) ; (c) le compte annoncé du README == |ledger| ;
# (d) TOUTE mention « N invariants » du README == |ledger|. La prose du README
# RÉSUME les contrôles (≠ énumération numérotée 1:1) : on gate le COMPTE partout
# où il apparaît, pas un set-diff de la liste. Section/compte absent = échec (la
# disparition d'un chiffre est une régression de traçabilité · #6).
# ============================================================================
SEO_GEN = f"{D}/seo/seo_gen.py"
try:
seo_src = open(SEO_GEN, encoding="utf-8").read()
except OSError as e:
bad(f"SEO invariants · générateur {SEO_GEN} illisible : {e}"); seo_src = None
if seo_src is not None:
# (a) registre-source = marqueurs `# N ·` du LEDGER de `_validate` (autorité).
_mfn = re.search(r"def _validate\b", seo_src)
if _mfn is None:
bad(f"SEO invariants · fonction `_validate` INTROUVABLE dans {SEO_GEN} "
"(registre-source du ledger absent → impossible de re-dériver)")
else:
_bs = _mfn.end()
_mn = re.search(r"\ndef ", seo_src[_bs:])
_body = seo_src[_bs: _bs + _mn.start()] if _mn else seo_src[_bs:]
seo_led = [int(n) for n in re.findall(r"#\s*(\d+)\s*·", _body)]
if not seo_led:
bad(f"SEO invariants · aucun marqueur `# N ·` dans `_validate` de {SEO_GEN} "
"(ledger vide → impossible de re-dériver)")
else:
seo_ninv = len(seo_led)
if sorted(seo_led) != list(range(1, seo_ninv + 1)):
bad(f"SEO invariants · ledger `_validate` NON contigu : {seo_led} "
f"(attendu 1..{seo_ninv}) — lacune/doublon dans les marqueurs `# N ·`")
else:
good(f"SEO invariants · ledger `_validate` contigu 1..{seo_ninv} "
f"({seo_ninv} marqueurs `# N ·`)")
# (b) self-reports du .py « N invariants » (docstrings + aides + succès CLI).
seo_py_rep = sorted({int(x) for x in re.findall(r"(\d+)\s+invariants", seo_src)})
if not seo_py_rep:
bad(f"SEO invariants · aucune mention « N invariants » dans {SEO_GEN} "
f"(self-report absent → attendu {seo_ninv})")
elif seo_py_rep == [seo_ninv]:
good(f"SEO invariants · {SEO_GEN} — self-report(s) {seo_py_rep} "
f"== ledger ({seo_ninv})")
else:
bad(f"SEO invariants · {SEO_GEN} — self-report(s) « N invariants » "
f"{seo_py_rep} MAIS ledger en compte {seo_ninv} "
"(une chaîne du générateur désynchronisée du registre)")
# README : titre de section + toute mention « N invariants ».
try:
seo_rd_raw = open(SEO_RD, encoding="utf-8").read()
except OSError as e:
bad(f"SEO invariants · {SEO_RD} illisible : {e}"); seo_rd_raw = None
if seo_rd_raw is not None:
_mh = re.search(r"##\s+Garanties\s+\((\d+)\s+invariants\)", seo_rd_raw)
if _mh is None:
bad(f"SEO invariants · {SEO_RD} — section « ## Garanties (N invariants) » "
f"INTROUVABLE (attendu {seo_ninv})")
else:
# (c) compte annoncé.
if int(_mh.group(1)) == seo_ninv:
good(f"SEO invariants · {SEO_RD} — annonce ({_mh.group(1)}) "
f"== ledger ({seo_ninv})")
else:
bad(f"SEO invariants · {SEO_RD} — annonce ({_mh.group(1)}) MAIS "
f"ledger en compte {seo_ninv} → régénérer le README")
# (d) toute mention « N invariants » du README == ledger.
seo_rd_rep = sorted({int(x) for x in re.findall(r"(\d+)\s+invariants", seo_rd_raw)})
if seo_rd_rep == [seo_ninv]:
good(f"SEO invariants · {SEO_RD} — mention(s) {seo_rd_rep} "
f"== ledger (aucune dérive)")
else:
bad(f"SEO invariants · {SEO_RD} — mention(s) « N invariants » "
f"{seo_rd_rep} MAIS ledger en compte {seo_ninv} → régénérer le README")
# ============================================================================
# Fiscal · e-CF DGII · IDENTITÉ du COMPTE D'INVARIANTS de `_validate` — même
# classe que le ledger SEO / Chat OTOIA, appliquée au générateur fiscal.
# ----------------------------------------------------------------------------
# CLASSE DISTINCTE des blocs Fiscal existants (format e-NCF · cross-cohérence
# emission_events · rôle émetteur — qui gatent le CONTENU de out/ecf_plan.json).
# Ici la donnée gardée est le COMPTE d'invariants de cross-cohérence appliqués
# par `_validate` d'ecf_dgii_gen.py, dont la source d'autorité n'est PAS un
# artefact JSON (invisible à check_artifacts qui byte-gate les out/*.json) mais
# le LEDGER NUMÉROTÉ de la fonction : les marqueurs `# N ·` qui ouvrent chaque
# contrôle — analogue du registre SEO/Chat OTOIA. Le nombre « 12 » est transcrit
# À LA MAIN à SEPT endroits qu'AUCUN gate ne couvrait : QUATRE chaînes du .py
# (docstring module, docstring `_validate`, message de succès CLI « … 12
# invariants verts. », aide du sous-parseur `validate`) + TROIS du README (bloc
# de commande « schéma + 12 invariants », titre « ## Les 12 invariants », prose
# de score « 12 invariants de cross-cohérence »). Ajouter un contrôle `# 13 ·` à
# `_validate` sans toucher les chaînes ⇒ le README ET les self-reports du .py
# mentent en silence pendant que la CI applique 13 contrôles — « vert trompeur »
# (#6) que ni les suites tests/ (FONCTIONS, jamais la prose) ni le byte-gate
# d'artefact (aveugle au .py source) n'attrapent. On RECOMPUTE ici le ledger
# depuis le SEUL registre-source (les `# N ·` de `_validate`) et on exige :
# (a) ledger CONTIGU 1..N — l'invariant 1 est la validation de SCHÉMA (ligne
# `validate(bundle, schema)`, non marquée `# 1 ·`) ; les marqueurs couvrent
# 2..N ; l'invariant 12 est SPLITTÉ en `# 12a ·`/`# 12b ·` (deux volets d'un
# même invariant, comptés UNE fois) — un doublon d'un numéro NON suffixé est
# lui un vrai défaut de registre et MORD ; (b) TOUTE mention « N invariants »
# du .py (self-reports) == |ledger| ; (c) le compte annoncé du titre README
# == |ledger| ; (d) TOUTE mention « N invariants » du README == |ledger| ;
# (e) l'ÉNUMÉRATION numérotée du README (section « ## Les N invariants », prose
# « 1. … 2. … N. ») est elle-même CONTIGUË 1..N (la liste ne saute/duplique
# pas un item). Section/compte absent = échec (disparition d'un chiffre =
# régression de traçabilité · #6).
# ============================================================================
ECF_GEN = f"{D}/fiscal/ecf_dgii/ecf_dgii_gen.py"
ECF_RD_INV = f"{D}/fiscal/ecf_dgii/README.md"
try:
ecf_src = open(ECF_GEN, encoding="utf-8").read()
except OSError as e:
bad(f"Fiscal invariants · générateur {ECF_GEN} illisible : {e}"); ecf_src = None
if ecf_src is not None:
_mfn = re.search(r"def _validate\b", ecf_src)
if _mfn is None:
bad(f"Fiscal invariants · fonction `_validate` INTROUVABLE dans {ECF_GEN} "
"(registre-source du ledger absent → impossible de re-dériver)")
else:
_bs = _mfn.end()
_mn = re.search(r"\ndef ", ecf_src[_bs:])
_body = ecf_src[_bs: _bs + _mn.start()] if _mn else ecf_src[_bs:]
# marqueurs `# N ·` / `# Na ·` (suffixe lettre = volet d'un même invariant)
_pairs = re.findall(r"#\s*(\d+)([a-z]?)\s*·", _body)
# doublon d'un numéro NON suffixé = vrai défaut (≠ split 12a/12b légitime)
_dup_bare = sorted({n for n in {p[0] for p in _pairs}
if [s for num, s in _pairs if num == n and s == ""]
and sum(1 for num, _ in _pairs if num == n) > 1})
_schema = bool(re.search(r"validate\(bundle,\s*schema\)", _body))
_top = {int(n) for n, _ in _pairs}
if not _top:
bad(f"Fiscal invariants · aucun marqueur `# N ·` dans `_validate` de "
f"{ECF_GEN} (ledger vide → impossible de re-dériver)")
elif _dup_bare:
bad(f"Fiscal invariants · marqueur(s) `# N ·` dupliqué(s) sans suffixe "
f"a/b dans `_validate` : {_dup_bare} — vrai doublon de registre")
elif not _schema:
bad(f"Fiscal invariants · l'invariant 1 (validation de schéma, "
f"`validate(bundle, schema)`) INTROUVABLE dans `_validate` de {ECF_GEN} "
"— le ledger ne peut pas débuter à 1")
else:
ecf_led = sorted(_top | {1}) # 1 = validation de schéma (non marquée)
ecf_ninv = len(ecf_led)
if ecf_led != list(range(1, ecf_ninv + 1)):
bad(f"Fiscal invariants · ledger `_validate` NON contigu : {ecf_led} "
f"(attendu 1..{ecf_ninv}) — lacune dans les marqueurs `# N ·` "
"(schéma = 1, marqueurs = 2..N)")
else:
good(f"Fiscal invariants · ledger `_validate` contigu 1..{ecf_ninv} "
f"(schéma #1 + {len(_top)} marqueurs `# N ·`, 12a/12b comptés 1×)")
# (b) self-reports du .py « N invariants ».
ecf_py_rep = sorted({int(x) for x in re.findall(r"(\d+)\s+invariants", ecf_src)})
if not ecf_py_rep:
bad(f"Fiscal invariants · aucune mention « N invariants » dans {ECF_GEN} "
f"(self-report absent → attendu {ecf_ninv})")
elif ecf_py_rep == [ecf_ninv]:
good(f"Fiscal invariants · {ECF_GEN} — self-report(s) {ecf_py_rep} "
f"== ledger ({ecf_ninv})")
else:
bad(f"Fiscal invariants · {ECF_GEN} — self-report(s) « N invariants » "
f"{ecf_py_rep} MAIS ledger en compte {ecf_ninv} "
"(une chaîne du générateur désynchronisée du registre)")
# README : titre de section + toute mention + énumération numérotée.
try:
ecf_rd_raw = open(ECF_RD_INV, encoding="utf-8").read()
except OSError as e:
bad(f"Fiscal invariants · {ECF_RD_INV} illisible : {e}"); ecf_rd_raw = None
if ecf_rd_raw is not None:
_mh = re.search(r"##\s+Les\s+(\d+)\s+invariants", ecf_rd_raw)
if _mh is None:
bad(f"Fiscal invariants · {ECF_RD_INV} — section « ## Les N invariants » "
f"INTROUVABLE (attendu {ecf_ninv})")
else:
# (c) compte annoncé du titre.
if int(_mh.group(1)) == ecf_ninv:
good(f"Fiscal invariants · {ECF_RD_INV} — annonce ({_mh.group(1)}) "
f"== ledger ({ecf_ninv})")
else:
bad(f"Fiscal invariants · {ECF_RD_INV} — annonce ({_mh.group(1)}) MAIS "
f"ledger en compte {ecf_ninv} → régénérer le README")
# (d) toute mention « N invariants » du README == ledger.
ecf_rd_rep = sorted({int(x) for x in re.findall(r"(\d+)\s+invariants", ecf_rd_raw)})
if ecf_rd_rep == [ecf_ninv]:
good(f"Fiscal invariants · {ECF_RD_INV} — mention(s) {ecf_rd_rep} "
f"== ledger (aucune dérive)")
else:
bad(f"Fiscal invariants · {ECF_RD_INV} — mention(s) « N invariants » "
f"{ecf_rd_rep} MAIS ledger en compte {ecf_ninv} → régénérer le README")
# (e) énumération numérotée de la section == contiguë 1..N.
_msec = re.search(r"##\s+Les\s+\d+\s+invariants.*?(?=\n##\s|\Z)",
ecf_rd_raw, re.S)
ecf_enum = ([int(x) for x in
re.findall(r"(?<![\d.])(\d+)\.\s", _msec.group(0))]
if _msec else [])
if ecf_enum == list(range(1, ecf_ninv + 1)):
good(f"Fiscal invariants · {ECF_RD_INV} — énumération prose "
f"contiguë 1..{ecf_ninv} == ledger")
else:
bad(f"Fiscal invariants · {ECF_RD_INV} — énumération prose {ecf_enum} "
f"≠ 1..{ecf_ninv} (item sauté/dupliqué) → régénérer le README")
# ============================================================================
# Legal · CONFOTUR · IDENTITÉ du COMPTE D'INVARIANTS de `_validate` — 4ᵉ instance
# de la classe « ledger d'invariants » (après Chat OTOIA · SEO · Fiscal e-CF).
# ----------------------------------------------------------------------------
# Le générateur confotur_application_gen.py applique un COMPTE d'invariants de
# cross-cohérence dont la source d'autorité n'est PAS un artefact JSON (invisible
# à check_artifacts qui byte-gate les out/*.json) mais le LEDGER NUMÉROTÉ de
# `_validate` : les marqueurs `# N ·` (1..14) qui ouvrent chaque contrôle appliqué.
# Le nombre « 14 » est transcrit À LA MAIN à SIX endroits qu'AUCUN gate ne
# couvrait : QUATRE chaînes du .py (docstring module, docstring `_validate`,
# message de succès CLI « … 14 invariants verts. », aide du sous-parseur
# `validate`) + DEUX du README (bloc de commande « schéma + 14 invariants »,
# commentaire d'arborescence « CLI build/validate · 14 invariants »). Ajouter un
# contrôle `# 15 ·` à `_validate` sans toucher les chaînes ⇒ le README ET les
# self-reports du .py mentent en silence pendant que la CI applique 15 contrôles —
# « vert trompeur » (#6) que ni les suites tests/ (FONCTIONS de validation, jamais
# la prose) ni le byte-gate d'artefact (aveugle au .py source) n'attrapent.
# PARTICULARITÉ vs Fiscal : ledger PROPRE 1..N (pas de schéma-#1 non marqué, pas de
# split a/b) MAIS le README porte AUSSI un compte de SOUS-ENSEMBLE éditorial
# (« 2 invariants refusent tout champ monétaire ») À NE PAS confondre avec le total
# — on l'exclut par lookahead négatif `(?!\s+refusent)`, faute de quoi le gate
# mordrait l'arbre propre (le « 2 » n'est pas le compte du ledger). On RECOMPUTE le
# ledger depuis le SEUL registre-source (les `# N ·` de `_validate`) et on exige :
# (a) ledger CONTIGU 1..N (lacune OU doublon nu = vrai défaut de registre, mordu) ;
# (b) TOUTE mention-total « N invariants » du .py == |ledger| ; (c) TOUTE
# mention-total du README (hors sous-ensemble « refusent ») == |ledger|. Mention ou
# registre absent = échec (disparition d'un chiffre = régression de traçabilité #6).
# ============================================================================
CF_GEN = f"{D}/legal/confotur/confotur_application_gen.py"
CF_RD = f"{D}/legal/confotur/README.md"
# mention-total = « N invariants » NON suivi de « refusent » (le sous-ensemble
# éditorial anti-invention du README, ≠ compte total du ledger).
_CF_RX_TOT = r"(\d+)\s+invariants(?!\s+refusent)"
try:
cf_src = open(CF_GEN, encoding="utf-8").read()
except OSError as e:
bad(f"CONFOTUR invariants · générateur {CF_GEN} illisible : {e}"); cf_src = None
if cf_src is not None:
_mfn = re.search(r"def _validate\b", cf_src)
if _mfn is None:
bad(f"CONFOTUR invariants · fonction `_validate` INTROUVABLE dans {CF_GEN} "
"(registre-source du ledger absent → impossible de re-dériver)")
else:
_bs = _mfn.end()
_mn = re.search(r"\ndef ", cf_src[_bs:])
_body = cf_src[_bs: _bs + _mn.start()] if _mn else cf_src[_bs:]
# marqueurs `# N ·` (le ledger confotur est propre : pas de suffixe a/b,
# mais on garde la détection de doublon nu comme pour Fiscal).
_pairs = re.findall(r"#\s*(\d+)([a-z]?)\s*·", _body)
_dup_bare = sorted({n for n in {p[0] for p in _pairs}
if [s for num, s in _pairs if num == n and s == ""]
and sum(1 for num, _ in _pairs if num == n) > 1})
_top = sorted({int(n) for n, _ in _pairs})
if not _top:
bad(f"CONFOTUR invariants · aucun marqueur `# N ·` dans `_validate` de "
f"{CF_GEN} (ledger vide → impossible de re-dériver)")
elif _dup_bare:
bad(f"CONFOTUR invariants · marqueur(s) `# N ·` dupliqué(s) sans suffixe "
f"a/b dans `_validate` : {_dup_bare} — vrai doublon de registre")
else:
cf_ninv = len(_top)
if _top != list(range(1, cf_ninv + 1)):
bad(f"CONFOTUR invariants · ledger `_validate` NON contigu : {_top} "
f"(attendu 1..{cf_ninv}) — lacune/doublon dans les marqueurs `# N ·`")
else:
good(f"CONFOTUR invariants · ledger `_validate` contigu 1..{cf_ninv} "
f"({cf_ninv} marqueurs `# N ·`)")
# (b) self-reports du .py « N invariants » (hors sous-ensemble « refusent »).
cf_py_rep = sorted({int(x) for x in re.findall(_CF_RX_TOT, cf_src)})
if not cf_py_rep:
bad(f"CONFOTUR invariants · aucune mention-total « N invariants » dans "
f"{CF_GEN} (self-report absent → attendu {cf_ninv})")
elif cf_py_rep == [cf_ninv]:
good(f"CONFOTUR invariants · {CF_GEN} — self-report(s) {cf_py_rep} "
f"== ledger ({cf_ninv})")
else:
bad(f"CONFOTUR invariants · {CF_GEN} — self-report(s) « N invariants » "
f"{cf_py_rep} MAIS ledger en compte {cf_ninv} "
"(une chaîne du générateur désynchronisée du registre)")
# (c) README : mentions-total (le sous-ensemble « N invariants refusent »
# est exclu par le lookahead ⇒ ne fausse pas le compte).
try:
cf_rd_raw = open(CF_RD, encoding="utf-8").read()
except OSError as e:
bad(f"CONFOTUR invariants · {CF_RD} illisible : {e}"); cf_rd_raw = None
if cf_rd_raw is not None:
cf_rd_rep = sorted({int(x) for x in re.findall(_CF_RX_TOT, cf_rd_raw)})
if not cf_rd_rep:
bad(f"CONFOTUR invariants · {CF_RD} — aucune mention-total « N "
f"invariants » (attendu {cf_ninv} · disparition = régression #6)")
elif cf_rd_rep == [cf_ninv]:
good(f"CONFOTUR invariants · {CF_RD} — mention(s)-total {cf_rd_rep} "
"== ledger (aucune dérive · sous-ensemble « refusent » exclu)")
else:
bad(f"CONFOTUR invariants · {CF_RD} — mention(s)-total « N invariants » "
f"{cf_rd_rep} MAIS ledger en compte {cf_ninv} → régénérer le README")
# ============================================================================
# CRM · pipeline vente (états / transitions) — même classe de « vert trompeur »,
# transcrit À LA MAIN dans DEUX README de module.
# ----------------------------------------------------------------------------
# Le générateur workflow_vente émet out/MANIFEST.json dont `counts` porte states
# et transitions, RECOMPTÉS à chaque build depuis workflow_vente_spec.json (le
# graphe pipeline · byte-gaté par check_artifacts ⇒ source faisant autorité). Ce
# couple est recopié À LA MAIN à DEUX endroits : le titre de section du README du
# module workflow (« ## Le pipeline (9 états · 11 transitions) ») ET le README du
# DocType porteur dossier_vente (« options = les **9 états** du pipeline »), qui
# DÉRIVE le même compte d'états du même graphe (source unique · #5). Aucun gate ne
# les couvrait : ajouter un état/une transition au pipeline ferait dériver LES
# DEUX docs en silence pendant que l'artefact dit autre chose — même piège que les
# triplets par workspace des portails ou les mots-clés SEO. On RECOMPUTE ici
# (states + transitions) depuis workflow_vente MANIFEST.counts (déjà byte-gaté ·
# zéro duplication du compteur du générateur) et on exige l'égalité avec les DEUX
# docs. Un claim absent échoue AUSSI (traçabilité). Recomputer transitions EN PLUS
# de states mord une dérive du 2e nombre là où le seul titre du README workflow
# (qui n'a pas les transitions dans dossier_vente) resterait aveugle.
# ============================================================================
WF_RD = f"{D}/crm/workflow_vente/README.md"
DV_RD = f"{D}/crm/dossier_vente/README.md"
try:
wf_c = load("crm/workflow_vente/out/MANIFEST.json")["counts"]
wf_states, wf_trans = str(wf_c["states"]), str(wf_c["transitions"])
except (OSError, KeyError, ValueError) as e:
bad(f"CRM pipeline · MANIFEST workflow_vente illisible/inattendu : {e}")
wf_states = None
if wf_states is not None:
# 1) README workflow — titre de section (états + transitions)
wf_src = f"états={wf_states} transitions={wf_trans}"
wf_txt = read_norm(WF_RD)
if wf_txt is not None:
m = re.search(r"## Le pipeline \((\d+) états · (\d+) transitions\)", wf_txt)
if m is None:
bad(f"CRM pipeline · {WF_RD} — titre pipeline INTROUVABLE (attendu {wf_src})")
elif m.groups() == (wf_states, wf_trans):
good(f"CRM pipeline · {WF_RD} — {'/'.join(m.groups())} == MANIFEST ({wf_src})")
else:
bad(f"CRM pipeline · {WF_RD} — dit {'/'.join(m.groups())} MAIS MANIFEST "
f"dit {wf_states}/{wf_trans} ({wf_src}) → régénérer le README")
# 2) README dossier_vente — même compte d'états, dérivé du même graphe
dv_txt = read_norm(DV_RD)
if dv_txt is not None:
m = re.search(r"les \*\*(\d+) états\*\* du pipeline", dv_txt)
if m is None:
bad(f"CRM pipeline · {DV_RD} — claim « N états du pipeline » INTROUVABLE "
f"(attendu états={wf_states})")
elif m.group(1) == wf_states:
good(f"CRM pipeline · {DV_RD} — états={m.group(1)} == MANIFEST (états={wf_states})")
else:
bad(f"CRM pipeline · {DV_RD} — dit états={m.group(1)} MAIS MANIFEST dit "
f"états={wf_states} → régénérer le README")
# ============================================================================
# CRM · IDENTITÉ du DocType porteur — le NOM `OTO Dossier Vente`
# ----------------------------------------------------------------------------
# Le module dossier_vente émet out/doctype_oto_dossier_vente.json dont le champ
# `name` EST le nom du DocType porteur (= MANIFEST.doctype_name), byte-gaté par
# check_artifacts ⇒ artefact faisant autorité. Ce NOM est le POINT D'ATTACHE du
# pipeline : le Workflow vente s'y branche via son `document_type`
# (workflow.json[0].document_type). Le nom est RECOPIÉ À LA MAIN à plusieurs
# endroits, AUCUN gaté sur le NOM (le bloc CRM pipeline ci-dessus ne recompute
# que le COMPTE d'états, aveugle à l'identité) :
# • dossier_vente/README.md — titre « # DocType porteur · `NOM` » ET la ligne
# « Nom du DocType = `document_type` du workflow (`NOM`) »
# • 03_agents/crm/AGENT.md:25 — « **DocType porteur** `NOM` »
# • 03_agents/onapi_legal/AGENT.md:50 — « cible = `workflow.document_type` (`NOM`) »
# Piège : RENOMMER le DocType dans doctype_spec.json (`OTO Dossier Vente` →
# `OTO Dossier de Vente`) reconstruit les DEUX artefacts de façon COHÉRENTE (le
# workflow.document_type suit, les tests — qui testent des FONCTIONS — restent
# verts), MAIS les mentions en prose se PÉRIMENT en silence → l'agent ERPNext
# Backend importerait un DocType sous un nom pendant que la fiche/le README en
# nomment un autre, et le Workflow s'attacherait à un DocType FANTÔME — la
# rupture même que « sans lui le pipeline n'a rien à quoi s'attacher » (crm/
# AGENT.md:25) veut prévenir. Aucune suite tests/ (FONCTIONS de graphe, pas la
# prose) n'attrape ce « vert trompeur ». On RECOMPUTE le nom depuis l'artefact
# (zéro duplication) et on exige que chaque prose le nomme EXACTEMENT (backtické).
# Cohérences croisées bonus (mordent un artefact INTERNEMENT incohérent) :
# DocType.name == MANIFEST.doctype_name · == workflow.document_type (l'attache
# porteur↔workflow). Un claim absent échoue AUSSI (traçabilité #6).
# ============================================================================
try:
dv_dt = load("crm/dossier_vente/out/doctype_oto_dossier_vente.json")
dv_man = load("crm/dossier_vente/out/MANIFEST.json")
dt_name = dv_dt["name"]
dv_man_name = dv_man["doctype_name"]
wf_doc_type = load("crm/workflow_vente/out/workflow.json")[0]["document_type"]
except (OSError, KeyError, ValueError, IndexError, TypeError) as e:
bad(f"CRM DocType porteur · artefact illisible/inattendu : {e}"); dt_name = None
if dt_name:
# bonus 1 : cohérence interne du module (DocType.name == MANIFEST.doctype_name)
if dv_man_name != dt_name:
bad(f"CRM DocType porteur · incohérence artefacts : DocType.name "
f"« {dt_name} » MAIS MANIFEST.doctype_name « {dv_man_name} » → régénérer out/")
else:
good(f"CRM DocType porteur · DocType.name == MANIFEST.doctype_name « {dt_name} »")
# bonus 2 : l'attache porteur↔workflow — le Workflow s'y branche par document_type
if wf_doc_type != dt_name:
bad(f"CRM DocType porteur · attache ROMPUE : workflow.document_type "
f"« {wf_doc_type} » ≠ DocType porteur « {dt_name} » → le Workflow "
f"s'attacherait à un DocType FANTÔME")
else:
good(f"CRM DocType porteur · attache workflow.document_type == « {dt_name} » "
f"(le pipeline a bien où s'attacher)")
# prose : chaque doc doit nommer le DocType EXACTEMENT (backtické)
esc = re.escape(dt_name)
DV_DOC = [
(DV_RD, rf"# DocType porteur · `{esc}`", "titre dossier_vente"),
(DV_RD, rf"Nom du DocType\*\* = `document_type` du workflow \(`{esc}`\)",
"ligne « Nom du DocType »"),
("03_agents/crm/AGENT.md", rf"DocType porteur\*\* `{esc}`", "fiche CRM:25"),
("03_agents/onapi_legal/AGENT.md",
rf"cible = `workflow\.document_type` \(`{esc}`\)", "fiche onapi_legal:50"),
]
for path, pat, label in DV_DOC:
txt = read_norm(path)
if txt is None:
continue
if re.search(pat, txt):
good(f"CRM DocType porteur · {label} — nomme « {dt_name} » == artefact")
else:
bad(f"CRM DocType porteur · {label} ({path}) — nom « {dt_name} » "
f"INTROUVABLE/périmé → régénérer la doc")
# ============================================================================
# CRM (2e surface du MÊME README) · ÉNUMÉRATION des transitions à SÉPARATION
# DES POUVOIRS — la phrase « engagent de l'argent ou clôturent — confirmer
# réservation, signer contrat, approuver CONFOTUR, annuler/résilier ».
# ----------------------------------------------------------------------------
# Le bloc CRM ci-dessus recompute le COMPTE d'états/transitions mais PAS
# l'IDENTITÉ des transitions marquées `separation_of_duties`. Or cette liste est
# DATA-DERIVED : le générateur émet out/workflow.json (byte-gaté par
# check_artifacts ⇒ artefact faisant autorité) dont chaque transition porte
# `allow_self_approval` ; l'ensemble des transitions QUATRE-YEUX = {action |
# allow_self_approval == 0} — précisément ce que le README énumère À LA MAIN
# comme « interdisent l'auto-approbation (`allow_self_approval = 0`) ». Le bloc de
# COMPTE reste AVEUGLE au contenu : (a) DÉCLASSER une transition sensible (ex.
# « Signer contrat » repasse à allow_self_approval=1 dans le contrat) laisse le
# compte d'états/transitions à 9/11 pendant que l'artefact retire l'action de
# l'ensemble QUATRE-YEUX — la prose reste à 5 actions FAUSSES ; (b) PROMOUVOIR une
# transition (« Déposer CONFOTUR » → self=0) l'ajoute à l'ensemble sans que le
# compte bouge, et la prose l'OMET ; (c) RÉORDRE/RENOMMAGE d'action passe aussi —
# « vert trompeur » qu'aucune suite tests/ (qui teste des FONCTIONS de graphe, pas
# la prose) n'attrape — même patron que l'énumération des onglets Mobile, des open
# items de l'audit 5D ou de la carte de renormalisation 4Big. On RECOMPUTE ici
# l'ENSEMBLE des actions QUATRE-YEUX depuis workflow.json (zéro duplication du flag
# du générateur) et on exige que la phrase l'énumère EXACTEMENT (absent ET en trop
# mordus via set-diff). Normalisation : casse ignorée + suffixe « (perdu) » élidé
# (les deux transitions vers l'état Perdu — Annuler/Résilier — que la prose groupe
# « annuler/résilier »). Cohérence croisée en bonus : 0 < |quatre-yeux| < |trans|
# (l'ensemble sensible est NON VIDE — sinon la garde est vacante — et STRICT — sinon
# tout le graphe serait bloqué en quatre-yeux, ni l'un ni l'autre n'étant le
# contrat). Un claim absent échoue AUSSI (traçabilité).
# ============================================================================
try:
wf_trans_list = load("crm/workflow_vente/out/workflow.json")[0]["transitions"]
# ensemble QUATRE-YEUX = actions dont l'auto-approbation est interdite (self=0),
# normalisées (casse ignorée · suffixe « (perdu) » élidé — cf. groupage prose)
def _sod_norm(a):
return re.sub(r"\s*\(perdu\)\s*", "", a).strip().lower()
sod_set = {_sod_norm(t["action"]) for t in wf_trans_list
if str(t["allow_self_approval"]) == "0"}
n_trans_tot = len(wf_trans_list)
except (OSError, KeyError, ValueError, IndexError, TypeError) as e:
bad(f"CRM séparation · workflow.json illisible/inattendu : {e}"); sod_set = None
if sod_set is not None:
# bonus : l'ensemble sensible est non vide ET strictement inclus dans le graphe
if not (0 < len(sod_set) < n_trans_tot):
bad(f"CRM séparation · ensemble quatre-yeux dégénéré : |sod|={len(sod_set)} "
f"vs |transitions|={n_trans_tot} (attendu 0 < sod < total) → vérifier le contrat")
else:
good(f"CRM séparation · ensemble quatre-yeux non vide et strict : "
f"0 < {len(sod_set)} < {n_trans_tot}")
# énumération de la phrase : capturer le segment entre « clôturent** — » et « — sont marquées »
wf_txt3 = read_norm(WF_RD)
if wf_txt3 is not None:
ms = re.search(r"engagent de l'argent ou clôturent\*\* — (.+?) — sont marquées",
wf_txt3)
if ms is None:
bad(f"CRM séparation · {WF_RD} — énumération des transitions quatre-yeux "
f"INTROUVABLE (attendu {sorted(sod_set)})")
else:
prose_set = {t.strip().lower() for t in re.split(r"[,/]", ms.group(1)) if t.strip()}
if prose_set == sod_set:
good(f"CRM séparation · {WF_RD} — énumération quatre-yeux "
f"{sorted(prose_set)} == workflow.json (allow_self_approval=0)")
else:
miss = sorted(sod_set - prose_set)
extra = sorted(prose_set - sod_set)
detail = (f" · absents={miss}" if miss else "") + \
(f" · en trop={extra}" if extra else "")
bad(f"CRM séparation · {WF_RD} — énumère {sorted(prose_set)} MAIS "
f"workflow.json dit {sorted(sod_set)}{detail} → régénérer le README")
# ============================================================================
# Mobile · récap de l'app Expo (onglets / rôles couverts / langues / identifiants
# a_confirmer) — même classe de « vert trompeur », transcrite À LA MAIN.
# ----------------------------------------------------------------------------
# Le générateur app_config émet out/MANIFEST.json dont `counts` porte onglets,
# roles_couverts, langues et identifiants_a_confirmer, RECOMPTÉS à chaque build
# depuis rbac_50_roles.json (portails métier + rôles), seo_spec.json (langues) et
# la liste `a_confirmer` (identifiants EAS/stores jamais fabriqués · byte-gaté par
# check_artifacts ⇒ artefact faisant autorité). Ce quadruplet est recopié À LA
# MAIN dans la ligne récap du README du module (05_deliverables_mvp/mobile/
# app_config/README.md — « **5 onglets** … · **44 rôles** couverts · **3 langues**
# · **13 identifiants `a_confirmer`** »). Aucun gate ne le couvrait : ajouter un
# rôle/portail au contrat RBAC, une langue au site ou un credential store ferait
# dériver le doc en silence pendant que l'artefact dit autre chose — même piège
# que les mots-clés SEO ou les triplets par workspace des portails. On RECOMPUTE
# ici les 4 nombres depuis MANIFEST.counts (déjà byte-gaté · zéro duplication du
# compteur du générateur) et on exige l'égalité. Un claim absent échoue AUSSI
# (traçabilité). Recomputer les 4 (et pas le seul total de rôles) mord une dérive
# de N'IMPORTE lequel des volets là où un seul nombre resterait aveugle.
# ============================================================================
MOB_RD = f"{D}/mobile/app_config/README.md"
try:
mob_c = load("mobile/app_config/out/MANIFEST.json")["counts"]
mob_exp = (str(mob_c["onglets"]),
str(mob_c["roles_couverts"]),
str(mob_c["langues"]),
str(mob_c["identifiants_a_confirmer"]))
except (OSError, KeyError, ValueError) as e:
bad(f"Mobile · MANIFEST app_config illisible/inattendu : {e}"); mob_exp = None
if mob_exp is not None:
mob_src = (f"onglets={mob_exp[0]} rôles={mob_exp[1]} langues={mob_exp[2]} "
f"identifiants={mob_exp[3]}")
mob_pat = (r"\*\*(\d+) onglets\*\* \([^)]*\) · \*\*(\d+) rôles\*\* couverts · "
r"\*\*(\d+) langues\*\* · \*\*(\d+) identifiants")
mob_txt = read_norm(MOB_RD)
if mob_txt is not None:
m = re.search(mob_pat, mob_txt)
if m is None:
bad(f"Mobile · {MOB_RD} — récap app INTROUVABLE (attendu {mob_src})")
elif m.groups() == mob_exp:
good(f"Mobile · {MOB_RD} — {'/'.join(m.groups())} == MANIFEST ({mob_src})")
else:
bad(f"Mobile · {MOB_RD} — dit {'/'.join(m.groups())} MAIS MANIFEST dit "
f"{'/'.join(mob_exp)} ({mob_src}) → régénérer le README")
# ============================================================================
# Mobile (2e surface) · ÉNUMÉRATION des onglets de l'app Expo — même README, même
# ligne récap « **5 onglets** (Ventes/Construction/Achat/Compta/Direction) ».
# ----------------------------------------------------------------------------
# Le bloc mobile ci-dessus recompute le COMPTE d'onglets (« 5 ») mais son patron
# ne mord QUE le nombre : la parenthèse qui ÉNUMÈRE les 5 portails est matchée par
# `\([^)]*\)` et son CONTENU est IGNORÉ. Or cette liste est DATA-DERIVED : le
# générateur app_config émet out/role_navigation.json (byte-gaté par
# check_artifacts ⇒ artefact faisant autorité) = un onglet par portail métier du
# contrat RBAC (bijection 1 onglet ⇔ 1 portail · `plateforme` exclu), chaque
# entrée portant `portail` (id) + `label` (« OTO <Nom> »). Le nom court énuméré au
# README = le label sans le préfixe « OTO ». Un ÉCHANGE (Ventes→Marketing), un
# RETRAIT (Compta absent — l'énumération tombe à 4 tandis que « **5 onglets** »
# reste juste) ou un RÉORDRE ferait dériver la prose EN SILENCE pendant que
# l'artefact dit autre chose : « vert trompeur » aveugle au seul compte, qu'aucune
# suite tests/ (qui teste des FONCTIONS, pas la prose) n'attrape — même patron que
# l'énumération des confirmations DevOps, la carte de renormalisation par archétype
# 4Big ou la table « Verdict courant » de l'audit 5D. On RECOMPUTE ici la LISTE
# ORDONNÉE des noms courts depuis role_navigation.json (zéro duplication de la
# navigation du générateur) et on exige que la parenthèse l'énumère EXACTEMENT
# (absent ET en trop mordus, puis ordre). Cohérence croisée en bonus :
# len(role_navigation) == MANIFEST.counts.onglets == MANIFEST.counts.portails (la
# bijection du générateur) — mord un compteur du manifeste désynchronisé de sa
# propre liste de navigation.
# ============================================================================
try:
mob_nav = load("mobile/app_config/out/role_navigation.json")
# nom court = label « OTO <Nom> » sans le préfixe « OTO » (ce que le doc copie)
mob_names = [re.sub(r"^OTO\s+", "", e["label"]).strip() for e in mob_nav]
mob_names_c = load("mobile/app_config/out/MANIFEST.json")["counts"]
except (OSError, KeyError, ValueError, TypeError) as e:
bad(f"Mobile · role_navigation/MANIFEST illisible/inattendu : {e}"); mob_names = None
if mob_names is not None:
# bijection : la liste de navigation == compteurs onglets ET portails du manifeste
n_nav = len(mob_nav)
if not (n_nav == mob_names_c.get("onglets") == mob_names_c.get("portails")):
bad(f"Mobile · bijection rompue : |role_navigation|={n_nav} MAIS "
f"MANIFEST onglets={mob_names_c.get('onglets')} "
f"portails={mob_names_c.get('portails')} → régénérer l'artefact")
else:
good(f"Mobile · bijection onglet⇔portail : {n_nav} == onglets == portails")
# énumération de la parenthèse : capturer le contenu entre ( ) qui suit « N onglets »
mob_enum_pat = r"\*\*\d+ onglets\*\* \(([^)]*)\)"
mob_txt2 = read_norm(MOB_RD)
if mob_txt2 is not None:
me = re.search(mob_enum_pat, mob_txt2)
if me is None:
bad(f"Mobile · {MOB_RD} — énumération des onglets INTROUVABLE "
f"(attendu {'/'.join(mob_names)})")
else:
got = [s.strip() for s in me.group(1).split("/") if s.strip()]
if got == mob_names:
good(f"Mobile · {MOB_RD} — énumération onglets {'/'.join(got)} "
f"== role_navigation")
else:
miss = [x for x in mob_names if x not in got]
extra = [x for x in got if x not in mob_names]
detail = (f" · absents={miss}" if miss else "") + \
(f" · en trop={extra}" if extra else "") or " · même ensemble, ordre ≠"
bad(f"Mobile · {MOB_RD} — énumère {got} MAIS role_navigation dit "
f"{mob_names}{detail} → régénérer le README")
# ============================================================================
# Legal · DocType CONFOTUR Application (champs / sections / rôles / évènements de
# dépôt) — même classe de « vert trompeur », transcrit À LA MAIN dans DEUX docs.
# ----------------------------------------------------------------------------
# Le générateur confotur émet out/MANIFEST.json dont `counts` porte fields,
# data_fields, sections, roles et depot_events, RECOMPTÉS à chaque build depuis
# confotur_spec.json (byte-gaté par check_artifacts ⇒ artefact faisant autorité).
# Ce quintuplet est recopié À LA MAIN à DEUX endroits : la ligne de synthèse du
# README du module (05_deliverables_mvp/legal/confotur/README.md — « 18 champs
# (14 de donnée) · 4 sections · 3 rôles · … · 2 évènements de dépôt ») ET la
# cellule de la fiche agent onapi_legal (03_agents/onapi_legal/AGENT.md — mêmes 5
# nombres, chacun en gras). Aucun gate ne les couvrait : ajouter un champ/une
# section/un rôle au spec ferait dériver LES DEUX docs en silence pendant que
# l'artefact dit autre chose — même piège que les mots-clés SEO ou les triplets
# par workspace des portails. On RECOMPUTE ici les 5 nombres depuis
# MANIFEST.counts (déjà byte-gaté · zéro duplication du compteur du générateur)
# et on exige l'égalité avec les DEUX docs. Un claim absent échoue AUSSI
# (traçabilité). Recomputer les 5 (et pas le seul total de champs) mord une dérive
# de N'IMPORTE lequel des volets là où un seul nombre resterait aveugle.
# ============================================================================
CONF_RD = f"{D}/legal/confotur/README.md"
CONF_FI = "03_agents/onapi_legal/AGENT.md"
try:
conf_c = load("legal/confotur/out/MANIFEST.json")["counts"]
conf_exp = (str(conf_c["fields"]),
str(conf_c["data_fields"]),
str(conf_c["sections"]),
str(conf_c["roles"]),
str(conf_c["depot_events"]))
except (OSError, KeyError, ValueError) as e:
bad(f"Legal confotur · MANIFEST illisible/inattendu : {e}"); conf_exp = None
if conf_exp is not None:
conf_src = (f"champs={conf_exp[0]} données={conf_exp[1]} sections={conf_exp[2]} "
f"rôles={conf_exp[3]} dépôts={conf_exp[4]}")
# README du module (sans gras) et fiche agent (chaque nombre en gras) —
# même quintuplet, deux mises en forme distinctes du même artefact.
conf_docs = (
(CONF_RD, r"(\d+) champs \((\d+) de donnée\) · (\d+) sections · (\d+) rôles"
r" · soumissible · (\d+) évènements de dépôt"),
(CONF_FI, r"\*\*(\d+) champs\*\* \((\d+) de donnée\) · \*\*(\d+) sections\*\*"
r" · \*\*(\d+) rôles/permissions\*\* · \*\*(\d+) évènements de dépôt\*\*"),
)
for path_f, pat in conf_docs:
txt = read_norm(path_f)
if txt is None:
continue
m = re.search(pat, txt)
if m is None:
bad(f"Legal confotur · {path_f} — synthèse DocType INTROUVABLE "
f"(attendu {conf_src})")
elif m.groups() == conf_exp:
good(f"Legal confotur · {path_f} — {'/'.join(m.groups())} == "
f"MANIFEST ({conf_src})")
else:
bad(f"Legal confotur · {path_f} — dit {'/'.join(m.groups())} MAIS "
f"MANIFEST dit {'/'.join(conf_exp)} ({conf_src}) → régénérer le doc")
# ============================================================================
# Legal · CONFOTUR — cross-cohérence PERMISSIONS (2e surface du MÊME README) :
# l'IDENTITÉ des 3 rôles + leur jeu d'ACTIONS exact — le CŒUR sécurité du livrable.
# ----------------------------------------------------------------------------
# Le bloc CONFOTUR ci-dessus ne gate que le COMPTE (« 3 rôles »). La section
# « Cœur du livrable : cross-cohérence » du README (05_deliverables_mvp/legal/
# confotur/README.md §Permissions) énumère À LA MAIN, PAR rôle, son portail ET son
# jeu d'actions RBAC — annoncé « mot pour mot les permissions_cibles RBAC … ni
# ajout ni retrait » :
# - `ventes-confotur` (portail Ventes) → read/write/create/print
# - `legal-onapi` (portail Direction) → read/write/create
# - `legal-directeur` (portail Direction) → read/write/**submit**/report
# Ces trois lignes sont DATA-DERIVED d'out/MANIFEST.json (`roles_rbac_utilises[]` :
# role_id · portail · `permissions`, recomputé du contrat rbac_50_roles.json à
# chaque build) et projetées dans out/doctype_confotur_application.json
# (`permissions[]` par nom de rôle) — les DEUX byte-gatés par check_artifacts. Le
# compte « 3 rôles » est AVEUGLE à leur identité : PROMOUVOIR `ventes-confotur` à
# `submit` (élévation de privilège — casse la séparation des pouvoirs dont
# is_submittable est déduit), RETIRER une action, RÉAFFECTER un portail, RENOMMER
# un rôle ou AJOUTER une ligne fantôme laisse la prose périmée pendant que les
# artefacts disent autre chose → l'agent ERPNext Backend câblerait le mauvais jeu
# de permissions (le risque même que la cross-cohérence veut prévenir) — « vert
# trompeur » qu'aucune suite tests/ (qui teste des FONCTIONS de mapping RBAC, pas
# la prose) n'attrape. Même patron d'IDENTITÉ que le catalogue RBAC fixtures_gen
# (séparation des pouvoirs `set_user_permissions`) ou la cross-cohérence e-CF. On
# RECOMPUTE chaque jeu d'actions + portail depuis le MANIFEST (zéro duplication du
# contrat #6) et on exige que la prose l'encode EXACTEMENT (set-diff : absent ET en
# trop · casse normalisée), + l'identité d'ensemble (README ⇔ MANIFEST, aucun rôle
# fantôme ni manquant). Cohérences croisées en bonus (mordent un artefact
# INTERNEMENT incohérent) : MANIFEST ⇄ DocType d'accord sur le jeu d'actions par
# rôle · séparation des pouvoirs : `submit` porté par EXACTEMENT un rôle et
# is_submittable=True déduit de sa présence (README:31). Un claim absent échoue
# AUSSI (traçabilité).
# ============================================================================
CONF_RD2 = f"{D}/legal/confotur/README.md"
_CONF_PERM_FLAGS = ("read", "write", "create", "submit", "cancel", "amend",
"delete", "report", "export", "import", "print", "email", "share")
try:
conf_roles = load("legal/confotur/out/MANIFEST.json")["roles_rbac_utilises"]
conf_perm_by_id = {r["role_id"]: set(r["permissions"]) for r in conf_roles}
conf_portail_by_id = {r["role_id"]: r["portail"] for r in conf_roles}
conf_name_by_id = {r["role_id"]: r["erpnext_role_name"] for r in conf_roles}
conf_dt = load("legal/confotur/out/doctype_confotur_application.json")
conf_dt_perm_by_name = {p["role"]: {f for f in _CONF_PERM_FLAGS if p.get(f)}
for p in conf_dt["permissions"]}
conf_submittable = bool(conf_dt.get("is_submittable"))
except (OSError, KeyError, ValueError, TypeError) as e:
bad(f"Legal confotur perms · artefact illisible/inattendu : {e}"); conf_roles = None
if conf_roles:
# bonus 1 : les DEUX artefacts (MANIFEST ⇄ DocType) d'accord sur le jeu d'actions
for rid, perms in conf_perm_by_id.items():
nm = conf_name_by_id[rid]
dt_perms = conf_dt_perm_by_name.get(nm)
if dt_perms is None:
bad(f"Legal confotur perms · rôle `{rid}` ({nm}) ABSENT des permissions du DocType")
elif dt_perms != perms:
bad(f"Legal confotur perms · incohérence artefacts pour `{rid}` : MANIFEST "
f"{sorted(perms)} MAIS DocType {sorted(dt_perms)}")
else:
good(f"Legal confotur perms · `{rid}` — MANIFEST {sorted(perms)} == DocType (accord)")
# bonus 2 : séparation des pouvoirs — `submit` porté par EXACTEMENT un rôle, et
# is_submittable DÉDUIT de sa présence (README:31). Singleton sécurité (cf. RBAC Admin).
conf_submitters = {rid for rid, p in conf_perm_by_id.items() if "submit" in p}
if len(conf_submitters) != 1:
bad(f"Legal confotur perms · séparation des pouvoirs : action `submit` portée par "
f"{sorted(conf_submitters)} (attendu 1 — ni vacant, ni élévation de privilège)")
elif conf_submittable is not True:
bad(f"Legal confotur perms · un rôle porte `submit` MAIS is_submittable="
f"{conf_submittable} → DocType incohérent")
else:
good(f"Legal confotur perms · séparation des pouvoirs — `submit` singleton "
f"{sorted(conf_submitters)} · is_submittable=True (déduit · README:31)")
conf_rd2 = read_norm(CONF_RD2)
if conf_rd2 is not None:
for rid in sorted(conf_perm_by_id):
exp_perms = conf_perm_by_id[rid]
exp_portail = conf_portail_by_id[rid]
m = re.search(rf"`{re.escape(rid)}` \(portail ([^)]+)\) → ([\w/*]+)", conf_rd2)
if m is None:
bad(f"Legal confotur perms · {CONF_RD2} — ligne du rôle `{rid}` INTROUVABLE "
f"(attendu portail {exp_portail} · {sorted(exp_perms)})")
continue
got_portail = m.group(1).strip().lower()
got_perms = {t.replace("*", "").strip().lower()
for t in m.group(2).split("/") if t.replace("*", "").strip()}
if got_portail != exp_portail:
bad(f"Legal confotur perms · `{rid}` — {CONF_RD2} dit portail "
f"« {m.group(1).strip()} » MAIS MANIFEST dit « {exp_portail} » → régénérer")
elif got_perms != exp_perms:
miss = sorted(exp_perms - got_perms); extra = sorted(got_perms - exp_perms)
detail = (f" · absents={miss}" if miss else "") + \
(f" · en trop={extra}" if extra else "")
bad(f"Legal confotur perms · `{rid}` — {CONF_RD2} énumère {sorted(got_perms)} "
f"MAIS MANIFEST {sorted(exp_perms)}{detail} → régénérer le README")
else:
good(f"Legal confotur perms · `{rid}` — portail {got_portail} · actions "
f"{sorted(got_perms)} == MANIFEST")
# identité d'ensemble : README ⇔ MANIFEST (aucun rôle fantôme NI manquant) —
# rd_ids = rôles réellement écrits dans la prose §Permissions (patron identique).
rd_ids = set(re.findall(r"`([a-z][a-z-]+)` \(portail [^)]+\) → [\w/*]+", conf_rd2))
exp_ids = set(conf_perm_by_id)
if rd_ids == exp_ids:
good(f"Legal confotur perms · identité d'ensemble — {sorted(rd_ids)} == "
f"roles_rbac_utilises (aucun rôle fantôme/manquant)")
else:
miss = sorted(exp_ids - rd_ids); extra = sorted(rd_ids - exp_ids)
detail = (f" · absents={miss}" if miss else "") + \
(f" · en trop (fantôme)={extra}" if extra else "")
bad(f"Legal confotur perms · {CONF_RD2} — rôles énumérés {sorted(rd_ids)} MAIS "
f"MANIFEST {sorted(exp_ids)}{detail} → régénérer le README")
# ============================================================================
# Legal · CONFOTUR — cross-cohérence PERMISSIONS répliquée dans la FICHE AGENT
# (3e surface) : la MÊME table role→portail→actions, transcrite À LA MAIN dans
# 03_agents/onapi_legal/AGENT.md:43-45 — jamais gatée.
# ----------------------------------------------------------------------------
# Le bloc ci-dessus gate la §Permissions du README du MODULE (legal/confotur/
# README.md). Mais la FICHE de l'agent ONAPI/Legal RECOPIE la MÊME donnée
# sécurité — annoncée « **mot pour mot** les `permissions_cibles` RBAC des 3
# rôles » — sous une convention DIFFÉRENTE (` / ` espacé · `**submit**` en gras) :
# - `ventes-confotur` (portail Ventes) → read / write / create / print
# - `legal-onapi` (portail Direction) → read / write / create
# - `legal-directeur` (portail Direction) → read / write / **submit** / report
# Ces trois lignes sont DATA-DERIVED du MÊME out/MANIFEST.json (`roles_rbac_
# utilises[]` : role_id · portail · `permissions`, recomputé du contrat rbac_50_
# roles.json à chaque build · byte-gaté par check_artifacts) que le README. Le
# gate README est AVEUGLE à la fiche : PROMOUVOIR `ventes-confotur` à `submit`
# ici (élévation de privilège · casse la séparation des pouvoirs), RETIRER une
# action, RÉAFFECTER un portail, RENOMMER un rôle ou AJOUTER une ligne fantôme
# laisse la FICHE périmée pendant que l'artefact (et le README, lui gaté) disent
# autre chose → l'agent ONAPI/Legal lirait sa PROPRE doc d'identité mentant sur
# le jeu de permissions qu'il porte (le risque même que « mot pour mot » promet
# d'écarter) — « vert trompeur » qu'aucune suite tests/ (FONCTIONS de mapping
# RBAC, jamais la prose de fiche) n'attrape. Même patron d'IDENTITÉ que les
# fiches Mobile/Faisabilité (attributs de rôle) ou la citation #10 (constantes),
# appliqué à la SURFACE DISTINCTE qu'est la §Permissions de cette fiche. On
# RECOMPUTE chaque jeu d'actions + portail depuis conf_perm_by_id/conf_portail_
# by_id (déjà dérivés du MANIFEST ci-dessus · ZÉRO duplication du contrat #6) et
# on exige que la fiche l'encode EXACTEMENT (set-diff : absent ET en trop · gras
# `**` et casse normalisés) + l'identité d'ensemble (fiche ⇔ MANIFEST, aucun
# rôle fantôme ni manquant). Un claim absent échoue AUSSI (traçabilité).
# ============================================================================
CONF_FICHE = "03_agents/onapi_legal/AGENT.md"
if conf_roles:
conf_fiche = read_norm(CONF_FICHE)
if conf_fiche is None:
bad(f"Legal confotur fiche · {CONF_FICHE} illisible")
else:
# une action : mot, éventuellement encadré de `**` (gras), séparé par ` / `.
_ACT = r"(?:\*{0,2}\w+\*{0,2}\s*/\s*)*\*{0,2}\w+\*{0,2}"
for rid in sorted(conf_perm_by_id):
exp_perms = conf_perm_by_id[rid]
exp_portail = conf_portail_by_id[rid]
pat = r"`" + re.escape(rid) + r"` \(portail ([^)]+)\) → (" + _ACT + r")"
m = re.search(pat, conf_fiche)
if m is None:
bad(f"Legal confotur fiche · {CONF_FICHE} — ligne du rôle `{rid}` INTROUVABLE "
f"(attendu portail {exp_portail} · {sorted(exp_perms)})")
continue
got_portail = m.group(1).strip().lower()
got_perms = {t.replace("*", "").strip().lower()
for t in m.group(2).split("/") if t.replace("*", "").strip()}
if got_portail != exp_portail:
bad(f"Legal confotur fiche · `{rid}` — {CONF_FICHE} dit portail "
f"« {m.group(1).strip()} » MAIS MANIFEST dit « {exp_portail} » → régénérer")
elif got_perms != exp_perms:
miss = sorted(exp_perms - got_perms); extra = sorted(got_perms - exp_perms)
detail = (f" · absents={miss}" if miss else "") + \
(f" · en trop={extra}" if extra else "")
bad(f"Legal confotur fiche · `{rid}` — {CONF_FICHE} énumère {sorted(got_perms)} "
f"MAIS MANIFEST {sorted(exp_perms)}{detail} → régénérer la fiche")
else:
good(f"Legal confotur fiche · `{rid}` — portail {got_portail} · actions "
f"{sorted(got_perms)} == MANIFEST")
# identité d'ensemble : fiche ⇔ MANIFEST (aucun rôle fantôme NI manquant).
fiche_ids = set(re.findall(r"`([a-z][a-z-]+)` \(portail [^)]+\) →", conf_fiche))
exp_ids = set(conf_perm_by_id)
if fiche_ids == exp_ids:
good(f"Legal confotur fiche · identité d'ensemble — {sorted(fiche_ids)} == "
f"roles_rbac_utilises (aucun rôle fantôme/manquant)")
else:
miss = sorted(exp_ids - fiche_ids); extra = sorted(fiche_ids - exp_ids)
detail = (f" · absents={miss}" if miss else "") + \
(f" · en trop (fantôme)={extra}" if extra else "")
bad(f"Legal confotur fiche · {CONF_FICHE} — rôles énumérés {sorted(fiche_ids)} MAIS "
f"MANIFEST {sorted(exp_ids)}{detail} → régénérer la fiche")
# ============================================================================
# Audit 5D · synthèse de l'audit de conformité (contrôles / dimensions / verdict
# pass-fail-à confirmer) — même classe de « vert trompeur », transcrite À LA MAIN
# dans DEUX docs.
# ----------------------------------------------------------------------------
# Le générateur audit_5d émet DEUX artefacts byte-gatés par check_artifacts
# (⇒ faisant autorité) : out/MANIFEST.json (controls_total, `dimensions`,
# `audited_artifacts`, `open_items`, verdict — recomptés du catalogue de contrôles
# à chaque build) ET out/audit_report.json dont `totals` porte la ventilation
# {controls, pass, fail, a_confirmer} recalculée en rejouant les 17 contrôles sur
# les hand-off amont. Deux nombres de cette synthèse sont recopiés À LA MAIN à
# DEUX endroits : la phrase d'ouverture du README du module (05_deliverables_mvp/
# qa/audit_5d/README.md — « **17 contrôles** répartis en **5 dimensions (5D)** »)
# ET la cellule de la fiche agent QA (03_agents/qa/AGENT.md — « 17 contrôles / 5
# dimensions »). La ventilation du verdict (« **13 PASS · 0 FAIL · 4 à confirmer** »)
# n'est portée QUE par le README, mais se PÉRIME dès qu'un contrôle bascule en
# A_CONFIRMER (pass 13→12, à confirmer 4→5) alors que le TOTAL de contrôles reste
# 17 — dérive AVEUGLE au seul compte de contrôles, d'où sa recomputation séparée
# depuis audit_report.totals. Aucun gate ne couvrait ces claims : ajouter un
# contrôle/une dimension au catalogue, ou faire basculer un contrôle, ferait
# dériver LES DEUX docs (et la ligne verdict) en silence pendant que les artefacts
# disent autre chose — même piège que les mots-clés SEO, les triplets par workspace
# des portails ou le quintuplet CONFOTUR. On RECOMPUTE ici (contrôles + dimensions
# depuis le MANIFEST · pass/fail/à confirmer depuis audit_report.totals · déjà
# byte-gatés · zéro duplication du compteur du générateur) et on exige l'égalité.
# Un claim absent échoue AUSSI (traçabilité). Cohérence croisée en bonus : le
# `controls_total` du MANIFEST doit égaler `totals.controls` du rapport.
# ============================================================================
A5D_RD = f"{D}/qa/audit_5d/README.md"
A5D_FI = "03_agents/qa/AGENT.md"
try:
a5_man = load("qa/audit_5d/out/MANIFEST.json")
a5_tot = load("qa/audit_5d/out/audit_report.json")["totals"]
a5_ctl = str(a5_man["controls_total"])
a5_dim = str(len(a5_man["dimensions"]))
if a5_ctl != str(a5_tot["controls"]):
bad(f"Audit 5D · incohérence artefacts : MANIFEST controls_total={a5_ctl} "
f"MAIS audit_report totals.controls={a5_tot['controls']}")
a5_pass, a5_fail = str(a5_tot["pass"]), str(a5_tot["fail"])
a5_conf = str(a5_tot["a_confirmer"])
except (OSError, KeyError, ValueError) as e:
bad(f"Audit 5D · artefact illisible/inattendu : {e}"); a5_ctl = None
if a5_ctl is not None:
a5_src = f"contrôles={a5_ctl} dimensions={a5_dim}"
# 1) README module — phrase d'ouverture (contrôles + dimensions)
a5_rd = read_norm(A5D_RD)
if a5_rd is not None:
m = re.search(r"\*\*(\d+) contrôles\*\* répartis en \*\*(\d+) dimensions \(5D\)\*\*",
a5_rd)
if m is None:
bad(f"Audit 5D · {A5D_RD} — synthèse contrôles/dimensions INTROUVABLE "
f"(attendu {a5_src})")
elif m.groups() == (a5_ctl, a5_dim):
good(f"Audit 5D · {A5D_RD} — {'/'.join(m.groups())} == artefact ({a5_src})")
else:
bad(f"Audit 5D · {A5D_RD} — dit {'/'.join(m.groups())} MAIS artefact dit "
f"{a5_ctl}/{a5_dim} ({a5_src}) → régénérer le README")
# 2) README module — ventilation du verdict (pass / fail / à confirmer)
a5_vsrc = f"pass={a5_pass} fail={a5_fail} à_confirmer={a5_conf}"
m = re.search(r"\*\*(\d+) PASS · (\d+) FAIL · (\d+) à confirmer\*\*", a5_rd)
if m is None:
bad(f"Audit 5D · {A5D_RD} — ventilation verdict INTROUVABLE (attendu {a5_vsrc})")
elif m.groups() == (a5_pass, a5_fail, a5_conf):
good(f"Audit 5D · {A5D_RD} — verdict {'/'.join(m.groups())} == "
f"audit_report.totals ({a5_vsrc})")
else:
bad(f"Audit 5D · {A5D_RD} — dit {'/'.join(m.groups())} MAIS totals disent "
f"{a5_pass}/{a5_fail}/{a5_conf} ({a5_vsrc}) → régénérer le README")
# 3) Fiche agent QA — même couple contrôles/dimensions, 2e transcription
a5_fi = read_norm(A5D_FI)
if a5_fi is not None:
m = re.search(r"(\d+) contrôles / (\d+) dimensions", a5_fi)
if m is None:
bad(f"Audit 5D · {A5D_FI} — claim « N contrôles / M dimensions » INTROUVABLE "
f"(attendu {a5_src})")
elif m.groups() == (a5_ctl, a5_dim):
good(f"Audit 5D · {A5D_FI} — {'/'.join(m.groups())} == artefact ({a5_src})")
else:
bad(f"Audit 5D · {A5D_FI} — dit {'/'.join(m.groups())} MAIS artefact dit "
f"{a5_ctl}/{a5_dim} ({a5_src}) → régénérer la fiche")
# ----------------------------------------------------------------------------
# Audit 5D · IDENTITÉ du VERDICT GLOBAL — le LABEL de conclusion (`PASS` /
# `PASS_WITH_OPEN_ITEMS` / `FAIL`), pas seulement sa ventilation chiffrée.
# ----------------------------------------------------------------------------
# Le bloc de ventilation ci-dessus recompute le TRIPLET chiffré (pass/fail/à
# confirmer) mais reste AVEUGLE au LABEL de verdict lui-même — la HEADLINE de tout
# l'audit. Ce label est data-derived (out/audit_report.json[verdict] == out/
# MANIFEST.json[verdict], byte-gatés par check_artifacts ⇒ faisant autorité),
# recalculé par le générateur (qalib/builder.py) via la règle EXACTE : `FAIL` si
# ≥1 FAIL · sinon `PASS_WITH_OPEN_ITEMS` si ≥1 A_CONFIRMER · sinon `PASS`. Le README
# le transcrit À LA MAIN à DEUX endroits — la RÈGLE (« Verdict global : `FAIL` si …
# sinon `PASS_WITH_OPEN_ITEMS` … sinon `PASS` ») ET le VERDICT COURANT
# (« ## Verdict courant \n `PASS_WITH_OPEN_ITEMS` — **13 PASS · 0 FAIL · 4 à
# confirmer** »). AUCUN gate ne le vérifiait : le triplet peut coller à l'artefact
# pendant que le LABEL courant ment (écrire `PASS` alors que 4 open items
# subsistent = violation #6 « ne jamais forcer un PASS », le CŒUR même du livrable —
# l'audit prétend propre un dossier avec 4 params réglementaires non confirmés) ;
# ou la RÈGLE documentée dérive du codomaine réel du générateur (un label renommé
# dans builder.py, la prose périmée). check_artifacts ne prouve QUE
# audit_report==build (byte) ; aucune suite tests/ (FONCTIONS, pas la prose du
# README) n'attrape ces deux dérives. On MIROITE la règle du générateur et on
# exige : (a) HONNÊTETÉ interne — audit_report.verdict == verdict recalculé du
# triplet (l'audit ne peut afficher un verdict incohérent avec ses propres
# chiffres) ; (b) CROISÉ — MANIFEST.verdict == audit_report.verdict ; (c) le LABEL
# « Verdict courant » du README == audit_report.verdict ; (d) la RÈGLE du README
# énumère EXACTEMENT le codomaine {FAIL, PASS_WITH_OPEN_ITEMS, PASS} (un label
# renommé côté code sans mise à jour de la règle est mordu). Un claim absent échoue
# AUSSI (disparition = régression).
# ============================================================================
A5V_DOMAIN = {"FAIL", "PASS_WITH_OPEN_ITEMS", "PASS"}
def _a5_verdict_from_totals(t):
# miroir EXACT de qalib/builder.py : FAIL prime, puis open items, sinon PASS
if t["fail"]:
return "FAIL"
if t["a_confirmer"]:
return "PASS_WITH_OPEN_ITEMS"
return "PASS"
try:
a5v_rep = load("qa/audit_5d/out/audit_report.json")
a5v_verdict = a5v_rep["verdict"]
a5v_tot = a5v_rep["totals"]
a5v_man_verdict = load("qa/audit_5d/out/MANIFEST.json")["verdict"]
except (OSError, KeyError, ValueError, TypeError) as e:
bad(f"Audit 5D · verdict — artefact illisible/inattendu : {e}"); a5v_verdict = None
if a5v_verdict is not None:
# (a) honnêteté interne : le verdict de l'artefact découle de ses propres totals
a5v_exp = _a5_verdict_from_totals(a5v_tot)
if a5v_verdict != a5v_exp:
bad(f"Audit 5D · verdict — audit_report dit « {a5v_verdict} » MAIS ses totals "
f"(fail={a5v_tot['fail']} · à_confirmer={a5v_tot['a_confirmer']}) imposent "
f"« {a5v_exp} » (verdict FORCÉ, incohérent — viole #6)")
else:
good(f"Audit 5D · verdict — audit_report « {a5v_verdict} » == recalcul du triplet "
f"(fail={a5v_tot['fail']} · à_confirmer={a5v_tot['a_confirmer']})")
# (b) croisé artefacts : MANIFEST == rapport
if a5v_man_verdict != a5v_verdict:
bad(f"Audit 5D · verdict — MANIFEST dit « {a5v_man_verdict} » MAIS audit_report "
f"dit « {a5v_verdict} » (artefacts désynchronisés)")
else:
good(f"Audit 5D · verdict — MANIFEST « {a5v_man_verdict} » == audit_report")
a5v_rd = read_norm(A5D_RD)
if a5v_rd is not None:
# (c) LABEL « Verdict courant » du README == verdict de l'artefact
m = re.search(r"## Verdict courant\s+`([A-Z_]+)`\s*—\s*\*\*\d+ PASS", a5v_rd)
if m is None:
bad(f"Audit 5D · {A5D_RD} — label « Verdict courant » INTROUVABLE "
f"(attendu « {a5v_verdict} »)")
elif m.group(1) == a5v_verdict:
good(f"Audit 5D · {A5D_RD} — « Verdict courant » = « {m.group(1)} » == artefact")
else:
bad(f"Audit 5D · {A5D_RD} — « Verdict courant » = « {m.group(1)} » MAIS "
f"artefact dit « {a5v_verdict} » → régénérer le README")
# (d) la RÈGLE documentée énumère EXACTEMENT le codomaine du générateur
seg = re.search(r"Verdict global\s*:\s*(.+?)(?: > | ## )", a5v_rd)
if seg is None:
bad(f"Audit 5D · {A5D_RD} — règle « Verdict global : … » INTROUVABLE "
f"(codomaine {sorted(A5V_DOMAIN)} non vérifiable)")
else:
labels = set(re.findall(r"`([A-Z_]+)`", seg.group(1)))
if labels == A5V_DOMAIN:
good(f"Audit 5D · {A5D_RD} — règle « Verdict global » énumère le codomaine "
f"{sorted(A5V_DOMAIN)} == générateur")
else:
miss = sorted(A5V_DOMAIN - labels); extra = sorted(labels - A5V_DOMAIN)
bad(f"Audit 5D · {A5D_RD} — règle « Verdict global » énumère {sorted(labels)} "
f"≠ codomaine générateur {sorted(A5V_DOMAIN)} "
f"(manquants={miss or '—'} en_trop={extra or '—'}) → aligner la règle")
# ============================================================================
# Audit 5D (2e surface du MÊME README) · ÉNUMÉRATION des open items du « Verdict
# courant » (control → dimension → propriétaire) — même classe de « vert trompeur ».
# ----------------------------------------------------------------------------
# Le bloc précédent gate le COMPTE de la ventilation (« 13 PASS · 0 FAIL · 4 à
# confirmer », pass/fail/à confirmer recomputés d'audit_report.totals) mais PAS
# l'IDENTITÉ des 4 contrôles ouverts ni leur (dimension, propriétaire). La table
# « Verdict courant » du README (05_deliverables_mvp/qa/audit_5d/README.md) énumère
# ces 4 lignes À LA MAIN, alors que audit_report.open_items[] (byte-gaté par
# check_artifacts ⇒ faisant autorité, chaque item = {control, dimension, owner,
# detail} recalculé en rejouant les contrôles) fait foi. Un ÉCHANGE d'open item
# (ex. D2.3 → D3.1) laisse le compte à 4 — le bloc de ventilation reste AVEUGLE —
# pendant que l'artefact dit autre chose ; idem une dimension mal étiquetée
# (D1.3 rangé sous D2) ou un propriétaire réattribué. « Vert trompeur » qu'aucune
# suite tests/ (qui teste des FONCTIONS, pas la prose) n'attrape. On RECOMPUTE
# l'ensemble {control:(dimension, owner)} depuis open_items (zéro duplication du
# modèle de l'auditeur · #6) et on exige que la table README l'énumère EXACTEMENT :
# contrôle par ENSEMBLE (absent ET en trop mordus, pas seulement présence), puis
# (dimension, propriétaire) par LIGNE. Même patron que l'énumération des
# confirmations DevOps ou la carte de renormalisation par archétype 4Big. Cohérence
# croisée en bonus : l'ensemble des `control` == MANIFEST.open_items (le manifeste
# qui résume le rapport) — mord un manifeste désynchronisé de son propre rapport.
# ============================================================================
try:
a5_oi = load("qa/audit_5d/out/audit_report.json")["open_items"]
a5_oi_exp = {o["control"]: (o["dimension"], o["owner"]) for o in a5_oi}
a5_oi_man = set(load("qa/audit_5d/out/MANIFEST.json")["open_items"])
if a5_oi_man != set(a5_oi_exp):
bad(f"Audit 5D · incohérence artefacts : MANIFEST open_items {sorted(a5_oi_man)} "
f"MAIS audit_report open_items {sorted(a5_oi_exp)}")
except (OSError, KeyError, ValueError) as e:
bad(f"Audit 5D · open_items illisible/inattendu : {e}"); a5_oi_exp = None
if a5_oi_exp is not None:
try:
a5_lines = open(f"{D}/qa/audit_5d/README.md",
encoding="utf-8").read().splitlines()
except OSError as e:
bad(f"Audit 5D · README illisible : {e}"); a5_lines = None
if a5_lines is not None:
# ligne de table = « | <control> · <libellé> | <dimension> | <propriétaire> | »
a5_row_re = re.compile(
r"^\|\s*(D\d+\.\d+)\b[^|]*\|\s*([^|]+?)\s*\|\s*([^|]+?)\s*\|\s*$")
a5_seen = {}
for ln in a5_lines:
m = a5_row_re.match(ln)
if m:
a5_seen[m.group(1)] = (m.group(2).strip(), m.group(3).strip())
a5_exp_set, a5_got_set = set(a5_oi_exp), set(a5_seen)
if a5_got_set != a5_exp_set:
bad(f"Audit 5D · énumération open items — table README énumère "
f"{sorted(a5_got_set)} MAIS audit_report dit {sorted(a5_exp_set)} "
f"→ régénérer le README")
else:
a5_mism = [c for c in sorted(a5_exp_set) if a5_seen[c] != a5_oi_exp[c]]
if a5_mism:
for c in a5_mism:
bad(f"Audit 5D · open item {c} — README dit "
f"(dimension={a5_seen[c][0]}, propriétaire={a5_seen[c][1]}) MAIS "
f"audit_report dit (dimension={a5_oi_exp[c][0]}, "
f"propriétaire={a5_oi_exp[c][1]})")
else:
good(f"Audit 5D · énumération open items {sorted(a5_exp_set)} "
f"(control→dimension→propriétaire) == audit_report.open_items")
# ============================================================================
# Audit 5D (3e surface du MÊME README) · IDENTITÉ des 5 DIMENSIONS — le libellé
# (`titre`) ET la NORME de référence (`reference`) de chaque dimension du cadre —
# même classe de « vert trompeur », transcrite À LA MAIN dans un tableau.
# ----------------------------------------------------------------------------
# Le 1er bloc audit_5d gate le COMPTE de dimensions (« 5 dimensions (5D) ») — un
# WILDCARD AVEUGLE à QUELLE dimension : renommer « Traçabilité & anti-invention »
# ou permuter une norme de référence (D3 fiscal citant la mauvaise Ley, D1
# probatoire citant `ISA 501` au lieu d'`ISA 500`) laisse le compte à 5 pendant que
# l'artefact dit autre chose. La table « ## Les 5 dimensions » du README
# (05_deliverables_mvp/qa/audit_5d/README.md) énumère PAR LIGNE le triplet
# (`id`, `titre`, `reference`) alors que audit_report.dimensions[] (byte-gaté par
# check_artifacts ⇒ faisant autorité, chaque dimension recomposée du catalogue de
# contrôles à chaque build) fait foi. C'est l'audit de SECOND NIVEAU que
# l'auditeur/banquier lit : une norme de référence périmée (D2 AML citant une
# mauvaise Ley 155-17, D3 fiscal la mauvaise Ley 32-23) est une AFFIRMATION de
# conformité fausse — exactement l'invention que #6 interdit, qu'aucune suite
# tests/ (qui teste des FONCTIONS de contrôle, pas la prose) n'attrape. On RECOMPUTE
# la liste ORDONNÉE {id:(titre, reference)} depuis audit_report.dimensions[] (zéro
# duplication du modèle de l'auditeur · #6) et on exige que la table README
# l'énumère EXACTEMENT : identité par ENSEMBLE d'`id` (absent ET en trop mordus),
# ORDRE préservé (D1→D5), puis (titre, reference) par LIGNE. Même patron que
# l'énumération des open items ci-dessus. Cohérence croisée en bonus : les
# dimensions du rapport == celles de audit_spec.json (l'INPUT dont le rapport
# dérive) — mord un builder qui renommerait/permuterait une dimension en aval du
# spec. Un claim absent (table disparue) échoue AUSSI (traçabilité #6).
# ============================================================================
try:
a5d_dim = load("qa/audit_5d/out/audit_report.json")["dimensions"]
a5d_exp = {d["id"]: (d["titre"], d["reference"]) for d in a5d_dim}
a5d_order = [d["id"] for d in a5d_dim]
a5d_spec = load("qa/audit_5d/audit_spec.json")["dimensions"]
a5d_spec_map = {d["id"]: (d["titre"], d["reference"]) for d in a5d_spec}
if a5d_spec_map != a5d_exp:
bad(f"Audit 5D · incohérence artefacts : audit_spec dimensions {sorted(a5d_spec_map)} "
f"divergent du rapport {sorted(a5d_exp)} (titre/reference) → le rapport doit "
f"dériver du spec")
except (OSError, KeyError, ValueError) as e:
bad(f"Audit 5D · dimensions illisibles/inattendues : {e}"); a5d_exp = None
if a5d_exp is not None:
try:
a5d_lines = open(f"{D}/qa/audit_5d/README.md",
encoding="utf-8").read().splitlines()
except OSError as e:
bad(f"Audit 5D · README illisible : {e}"); a5d_lines = None
if a5d_lines is not None:
# ligne de table = « | **D1** | <titre> | <reference> | » (gras + PAS de
# sous-point ⇒ distingue des lignes open items « | D1.1 · … | »)
a5d_row_re = re.compile(
r"^\|\s*\*\*(D\d+)\*\*\s*\|\s*([^|]+?)\s*\|\s*([^|]+?)\s*\|\s*$")
a5d_seen, a5d_seen_order = {}, []
for ln in a5d_lines:
m = a5d_row_re.match(ln)
if m:
a5d_seen[m.group(1)] = (m.group(2).strip(), m.group(3).strip())
a5d_seen_order.append(m.group(1))
a5d_exp_ids, a5d_got_ids = set(a5d_exp), set(a5d_seen)
if a5d_got_ids != a5d_exp_ids:
bad(f"Audit 5D · IDENTITÉ des dimensions — table README énumère "
f"{sorted(a5d_got_ids)} MAIS audit_report dit {sorted(a5d_exp_ids)} "
f"→ régénérer le README")
elif a5d_seen_order != a5d_order:
bad(f"Audit 5D · IDENTITÉ des dimensions — ORDRE README {a5d_seen_order} "
f"MAIS audit_report dit {a5d_order} → régénérer le README")
else:
a5d_mism = [i for i in a5d_order if a5d_seen[i] != a5d_exp[i]]
if a5d_mism:
for i in a5d_mism:
bad(f"Audit 5D · dimension {i} — README dit "
f"(titre={a5d_seen[i][0]!r}, reference={a5d_seen[i][1]!r}) MAIS "
f"audit_report dit (titre={a5d_exp[i][0]!r}, "
f"reference={a5d_exp[i][1]!r})")
else:
good(f"Audit 5D · IDENTITÉ des 5 dimensions {a5d_order} "
f"(id→titre→reference) == audit_report.dimensions")
# ============================================================================
# Audit 5D (4e surface du MÊME README) · IDENTITÉ des LIVRABLES AUDITÉS — la liste
# des hand-off `out/*.json` que l'audit de second niveau LIT comme matière première,
# énumérée À LA MAIN dans la section « ## Livrables audités » — même classe de
# « vert trompeur ».
# ----------------------------------------------------------------------------
# Les 3 blocs audit_5d ci-dessus gatent la SYNTHÈSE (contrôles/dimensions/verdict),
# l'ÉNUMÉRATION des open items et l'IDENTITÉ des 5 dimensions — AUCUN ne touche la
# liste des ARTEFACTS SOURCES. Cette liste vit dans MANIFEST.audited_artifacts
# (byte-gaté par check_artifacts ⇒ faisant autorité : le générateur la recompute à
# chaque build via `sorted(ARTIFACT_PATHS.values())`, le registre des hand-off amont
# que `load_artifacts()` charge réellement). Le README (05_deliverables_mvp/qa/
# audit_5d/README.md, section « ## Livrables audités (source = leur `out/`) »)
# réénumère ces 5 chemins À LA MAIN — dans un ORDRE DIFFÉRENT du MANIFEST (workflow
# 2e ici, 3e au MANIFEST, trié) ⇒ comparaison par ENSEMBLE. Piège : brancher un
# nouveau livrable amont sur l'audit (nouvelle entrée ARTIFACT_PATHS → nouveau chemin
# dans audited_artifacts) ou en retirer un laisse la section README PÉRIMÉE en
# silence pendant que l'audit lit un autre jeu de fichiers — le lecteur/banquier
# croirait l'audit couvrir un périmètre qu'il ne couvre plus (l'invention #6
# interdit), qu'aucune suite tests/ (qui teste des FONCTIONS de contrôle, pas la
# prose) n'attrape. On RECOMPUTE l'ensemble depuis MANIFEST.audited_artifacts (zéro
# duplication du registre) et on exige que les puces README l'énumèrent EXACTEMENT
# (absent ET fantôme mordus). Cohérences croisées bonus (mordent un MANIFEST/spec
# INTERNEMENT incohérent) : (a) chaque chemin audité RÉSOUT un fichier commité sur
# disque (le `_load` de l'auditeur lève sinon — audit à vide impossible) ; (b) le
# NOMBRE de livrables audités == le nombre d'ALIAS distincts d'artefacts référencés
# par les contrôles du spec (audit_spec.json · union des `controls[].artifacts`) : un
# artefact chargé mais jamais contrôlé, ou un alias contrôlé sans chemin de
# chargement (KeyError runtime), désynchronise les deux. Section absente ⇒ ROUGE
# (traçabilité #6). Direction README/MANIFEST → registre/spec.
# ============================================================================
import os
try:
a5a_exp = load("qa/audit_5d/out/MANIFEST.json")["audited_artifacts"]
a5a_ctrls = load("qa/audit_5d/audit_spec.json")["controls"]
except (OSError, KeyError, ValueError) as e:
bad(f"Audit 5D · livrables audités illisibles/inattendus : {e}"); a5a_exp = None
if a5a_exp is not None:
a5a_exp_set = set(a5a_exp)
# tri/dédup : audited_artifacts = sorted(dict.values()) — mord un MANIFEST malformé
if a5a_exp != sorted(a5a_exp_set) or len(a5a_exp) != len(a5a_exp_set):
bad(f"Audit 5D · MANIFEST.audited_artifacts non trié/dédupliqué : {a5a_exp}")
# (a) chaque chemin audité résout un fichier commité (audit à vide impossible)
a5a_missing = [p for p in a5a_exp if not os.path.exists(f"{D}/{p}")]
if a5a_missing:
bad(f"Audit 5D · livrables audités INTROUVABLES sur disque {a5a_missing} → "
f"l'auditeur lèverait FileNotFoundError (aucun audit à vide)")
else:
good(f"Audit 5D · les {len(a5a_exp)} livrables audités résolvent tous un "
f"fichier commité")
# (b) |audited| == |alias distincts référencés par les contrôles du spec|
a5a_alias = set()
for ctl in a5a_ctrls:
a5a_alias |= set(ctl.get("artifacts", []))
if len(a5a_alias) == len(a5a_exp_set):
good(f"Audit 5D · {len(a5a_exp_set)} livrables audités == {len(a5a_alias)} "
f"alias d'artefacts distincts contrôlés par le spec (aucun mort/orphelin)")
else:
bad(f"Audit 5D · désynchro registre/spec : {len(a5a_exp_set)} livrables "
f"audités MAIS {len(a5a_alias)} alias distincts contrôlés "
f"({sorted(a5a_alias)}) → artefact chargé jamais contrôlé, ou alias sans "
f"chemin de chargement")
# README — puces de la section « ## Livrables audités »
try:
a5a_lines = open(f"{D}/qa/audit_5d/README.md",
encoding="utf-8").read().splitlines()
except OSError as e:
bad(f"Audit 5D · README illisible : {e}"); a5a_lines = None
if a5a_lines is not None:
a5a_got, a5a_in_sec = set(), False
a5a_bullet = re.compile(r"^-\s+`([^`]+)`\s*$")
for ln in a5a_lines:
if re.match(r"^##\s+Livrables audités\b", ln):
a5a_in_sec = True; continue
if a5a_in_sec and ln.startswith("## "):
break
if a5a_in_sec:
mb = a5a_bullet.match(ln)
if mb:
a5a_got.add(mb.group(1))
if not a5a_got:
bad(f"Audit 5D · section « ## Livrables audités » INTROUVABLE/vide dans "
f"{D}/qa/audit_5d/README.md (attendu {sorted(a5a_exp_set)})")
elif a5a_got == a5a_exp_set:
good(f"Audit 5D · livrables audités — les {len(a5a_got)} puces README == "
f"MANIFEST.audited_artifacts (aucun fantôme/manquant)")
else:
miss = sorted(a5a_exp_set - a5a_got); extra = sorted(a5a_got - a5a_exp_set)
detail = (f" · absents={miss}" if miss else "") + \
(f" · en trop (fantôme)={extra}" if extra else "")
bad(f"Audit 5D · livrables audités — README énumère {sorted(a5a_got)} MAIS "
f"MANIFEST dit {sorted(a5a_exp_set)}{detail} → régénérer le README")
# ============================================================================
# DevOps · run-book de déploiement VPS (phases ordonnées / confirmations
# préalables) — même classe de « vert trompeur », transcrite À LA MAIN dans
# DEUX docs, sans AUCUN gate.
# ----------------------------------------------------------------------------
# Le générateur deploy_runbook émet out/MANIFEST.json (byte-gaté par
# check_artifacts ⇒ faisant autorité) dont `counts.phases` et
# `counts.confirmations` sont RECOMPTÉS à chaque build (phases = |graph.phase_order| ·
# confirmations = |confirmations_ouvertes|, l'ensemble consolidé des paramètres
# réglementaires non confirmés). Deux nombres de cette synthèse sont recopiés À LA
# MAIN : le compte de PHASES dans la fiche agent DevOps (03_agents/devops/AGENT.md —
# « graphe de **7 phases ordonnées** » ET, plus bas, « exécute les 7 phases sur le
# VPS ») et le compte de CONFIRMATIONS dans le README du module
# (05_deliverables_mvp/devops/deploy_runbook/README.md — « renseigner les 7
# confirmations »). Piège « aveugle » caractéristique (cf. bascule verdict audit_5d,
# compensation SEO entre langues) : phases ET confirmations valent 7 AUJOURD'HUI —
# un gate qui ne recompterait qu'UN nombre serait AVEUGLE à la dérive de l'autre
# (ajouter une phase au graphe → 8 phases pendant que les confirmations restent 7,
# ou l'inverse). On RECOMPUTE donc CHAQUE nombre depuis SON PROPRE champ du MANIFEST
# (jamais figé ici · zéro duplication du compteur du générateur #5) et on exige
# l'égalité avec les docs. Bonus anti-dérive : l'ÉNUMÉRATION des confirmations du
# README (les tokens `snake_case` de la phrase « Confirmations préalables VPS »)
# doit être l'ensemble EXACT de confirmations_ouvertes — capte l'ÉCHANGE d'un nom de
# confirmation (count aveugle à un swap 1↔1). Cohérence croisée : counts.phases ==
# |phase_order| · counts.confirmations == |confirmations_ouvertes|. Un claim absent
# échoue AUSSI (la disparition d'un chiffre est une régression · traçabilité).
# ============================================================================
DR_RD = f"{D}/devops/deploy_runbook/README.md"
DR_FI = "03_agents/devops/AGENT.md"
try:
dr_man = load("devops/deploy_runbook/out/MANIFEST.json")
dr_ph = str(dr_man["counts"]["phases"])
dr_cf = str(dr_man["counts"]["confirmations"])
dr_order = dr_man["graph"]["phase_order"]
dr_open = dr_man["confirmations_ouvertes"]
if dr_ph != str(len(dr_order)):
bad(f"DevOps · incohérence artefact : counts.phases={dr_ph} MAIS "
f"|graph.phase_order|={len(dr_order)}")
if dr_cf != str(len(dr_open)):
bad(f"DevOps · incohérence artefact : counts.confirmations={dr_cf} MAIS "
f"|confirmations_ouvertes|={len(dr_open)}")
except (OSError, KeyError, ValueError) as e:
bad(f"DevOps · artefact deploy_runbook illisible/inattendu : {e}"); dr_ph = None
if dr_ph is not None:
# 1) Fiche agent DevOps — « graphe de N phases ordonnées »
dr_fi = read_norm(DR_FI)
if dr_fi is not None:
m = re.search(r"graphe de \*\*(\d+) phases ordonnées\*\*", dr_fi)
if m is None:
bad(f"DevOps · {DR_FI} — claim « graphe de N phases ordonnées » INTROUVABLE "
f"(attendu phases={dr_ph})")
elif m.group(1) == dr_ph:
good(f"DevOps · {DR_FI} — {m.group(1)} phases == artefact (phases={dr_ph})")
else:
bad(f"DevOps · {DR_FI} — dit {m.group(1)} phases MAIS artefact dit {dr_ph} "
f"→ régénérer la fiche")
# 2) Fiche agent DevOps — 2e transcription « exécute les N phases »
m = re.search(r"exécute les (\d+) phases", dr_fi)
if m is None:
bad(f"DevOps · {DR_FI} — 2e claim « exécute les N phases » INTROUVABLE "
f"(attendu phases={dr_ph})")
elif m.group(1) == dr_ph:
good(f"DevOps · {DR_FI} — « exécute les {m.group(1)} phases » == artefact "
f"(phases={dr_ph})")
else:
bad(f"DevOps · {DR_FI} — 2e claim dit {m.group(1)} phases MAIS artefact dit "
f"{dr_ph} → régénérer la fiche")
# 3) README module — « renseigner les N confirmations » (compte)
dr_rd = read_norm(DR_RD)
if dr_rd is not None:
m = re.search(r"renseigner les (\d+) confirmations", dr_rd)
if m is None:
bad(f"DevOps · {DR_RD} — claim « renseigner les N confirmations » INTROUVABLE "
f"(attendu confirmations={dr_cf})")
elif m.group(1) == dr_cf:
good(f"DevOps · {DR_RD} — {m.group(1)} confirmations == artefact "
f"(confirmations={dr_cf})")
else:
bad(f"DevOps · {DR_RD} — dit {m.group(1)} confirmations MAIS artefact dit "
f"{dr_cf} → régénérer le README")
# 4) README module — l'ÉNUMÉRATION doit être l'ensemble EXACT des confirmations
seg = re.search(r"Confirmations préalables VPS\*\* \(consolidées, sourcées\) : "
r"(.+?) ## ", dr_rd)
if seg is None:
bad(f"DevOps · {DR_RD} — énumération « Confirmations préalables VPS » "
f"INTROUVABLE (attendu {sorted(dr_open)})")
else:
listed = sorted(set(re.findall(r"`([a-z_]+)`", seg.group(1))))
if listed == sorted(dr_open):
good(f"DevOps · {DR_RD} — énumération ({len(listed)}) == confirmations_ouvertes")
else:
bad(f"DevOps · {DR_RD} — énumération {listed} ≠ confirmations_ouvertes "
f"{sorted(dr_open)} → régénérer le README")
# 2bis) deploy_runbook — IDENTITÉ de la TABLE « Plan de phases généré » -----------
# Le bloc ci-dessus ne gate que DEUX comptes agrégés (phases · confirmations). La
# table « ## Plan de phases généré » énumère, PAR phase, QUATRE données data-derived
# de out/deploy_runbook.json (byte-gaté par check_artifacts) : le n° d'ordre (#), le
# RESPONSABLE (worker/vps/worker+vps), l'ensemble des MODULES gated déployés dans la
# phase, et « Dépend de » (les n°s d'ordre des `depends_on`). C'est le GRAPHE DE
# PORTAGE VPS ORDONNÉ que l'agent DevOps suit phase par phase — un module OUBLIÉ de
# la colonne Modules (donc jamais déployé), un responsable ou une dépendance périmés
# (ex. Frontend avant RBAC) sont un hazard réel, invisible à check_artifacts (qui ne
# prouve QUE deploy_runbook==build) ET au bloc de comptes (aveugle à l'identité des
# lignes ; ces colonnes étaient un WILDCARD). Même classe que la table run-book de
# rbac/apply_plan et la table demo/scenarios. Preuve reproduite : la table omettait
# `mobile/app_config` (phase 5) ET `qa/acceptance` (phase 7) — 20 modules listés sur
# 22 — pendant que le MANIFEST prouvait la couverture BIJECTIVE 22/22 vs CI : vert
# trompeur, l'agent DevOps aurait sauté 2 modules. On RECOMPUTE #/responsable/
# modules/deps depuis deploy_runbook.json (zéro duplication) et on exige que la table
# les énonce EXACTEMENT. Identité d'ensemble : {ordres des lignes} == {ordres de
# l'artefact} (aucune phase FANTÔME/MANQUANTE) ET l'UNION des cellules Modules == 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")
# ============================================================================
# Démo · run-sheet · MINIMA D'ORCHESTRATION (≥ M modules distincts · ≥ N beats)
# — même classe de « vert trompeur », au grain des SEUILS de la garde d'invariant.
# ----------------------------------------------------------------------------
# La garantie #3 du README du module (« chaque scénario tisse ≥ 2 modules
# distincts et ≥ 3 beats ») ÉNONCE deux planchers structurels qui sont EN RÉALITÉ
# imposés par la garde `check_invariants` (bloc (c)) de demo_scenario_gen.py — la
# SEULE autorité d'exécution : `len(sc["beats"]) < N` refuse un scénario sous N
# beats, `len(mods) < M` sous M modules distincts. Ces deux nombres sont recopiés
# À LA MAIN à TROIS surfaces, aucune gatée : (1) la comparaison `< N`/`< M` (le
# plancher EFFECTIF), (2) le message d'erreur « (min N) »/« (min M · orchestration) »
# (les bornes annoncées au caller), (3) la prose « ≥ M modules distincts … ≥ N
# beats » du README. Resserrer la règle (p.ex. exiger ≥ 3 modules pour une démo
# plus riche : `< 2`→`< 3`) sans toucher la prose laisse le README PÉRIMÉ en
# silence, documentant un plancher que le code n'applique plus — dérive qu'aucune
# suite tests/ (qui exerce la FONCTION `check_invariants`, jamais la prose du
# README) n'attrape, et que `check_artifacts` ne voit pas (`.py` ≠ `out/*.json`).
# Même patron que les seuils min_doc_bytes/min_test_methods de l'auditeur 4Big.
# On RE-DÉRIVE les deux minima depuis la comparaison `<` de la garde (source
# unique · zéro duplication #5) et on exige que le message d'erreur ET la prose
# du README les répètent À L'IDENTIQUE. Une surface INTROUVABLE échoue AUSSI
# (ancre morte · anti-évaporation #6).
# ============================================================================
DEMO_GEN = f"{D}/demo/scenarios/demo_scenario_gen.py"
try:
demo_src = open(DEMO_GEN, encoding="utf-8").read()
except OSError as e:
bad(f"Démo minima · {DEMO_GEN} illisible : {e}"); demo_src = None
if demo_src is not None:
# (1) plancher EFFECTIF = comparaison `<` de la garde d'orchestration (autorité)
mb = re.search(r'len\(sc\["beats"\]\)\s*<\s*(\d+)', demo_src)
mm = re.search(r'len\(mods\)\s*<\s*(\d+)', demo_src)
if mb is None or mm is None:
bad(f"Démo minima · garde `check_invariants` — comparaison `len(...)<N` "
f"INTROUVABLE dans {DEMO_GEN} (beats={mb is not None} modules={mm is not None})")
else:
min_beats, min_mods = mb.group(1), mm.group(1)
good(f"Démo minima · garde d'orchestration — ≥ {min_mods} modules · "
f"≥ {min_beats} beats (source unique = comparaison `<` de check_invariants)")
# (2) message d'erreur de la garde == plancher effectif
gm_b = re.search(r"beat\(s\) \(min (\d+)\)", demo_src)
gm_m = re.search(r"\(min (\d+) · orchestration\)", demo_src)
if gm_b is None or gm_m is None:
bad(f"Démo minima · message d'erreur « (min N) » INTROUVABLE dans "
f"{DEMO_GEN} (beats={gm_b is not None} modules={gm_m is not None})")
elif gm_b.group(1) != min_beats or gm_m.group(1) != min_mods:
bad(f"Démo minima · message garde dit (min {gm_m.group(1)} modules · "
f"min {gm_b.group(1)} beats) MAIS la comparaison `<` impose "
f"(≥ {min_mods} · ≥ {min_beats}) → aligner le message sur la garde")
else:
good(f"Démo minima · message garde « (min {min_mods} · orchestration) » / "
f"« (min {min_beats}) » == comparaison `<`")
# (3) prose #3 du README == plancher effectif
demo_rd3 = read_norm(DEMO_RD)
if demo_rd3 is not None:
mr = re.search(r"≥ (\d+) modules distincts\*\* et \*\*≥ (\d+) beats", demo_rd3)
if mr is None:
bad(f"Démo minima · prose « ≥ M modules distincts … ≥ N beats » "
f"INTROUVABLE dans {DEMO_RD} (attendu ≥ {min_mods} · ≥ {min_beats})")
elif mr.groups() == (min_mods, min_beats):
good(f"Démo minima · README — ≥ {mr.group(1)} modules · ≥ {mr.group(2)} "
f"beats == garde ({min_mods} · {min_beats})")
else:
bad(f"Démo minima · README dit ≥ {mr.group(1)} modules · ≥ {mr.group(2)} "
f"beats MAIS la garde impose ≥ {min_mods} · ≥ {min_beats} → "
f"régénérer le README")
# ============================================================================
# Chat OTOIA · montage de l'assistant conversationnel par portail (nombre de
# Custom Block / configs runtime / portails) — même classe de « vert trompeur »,
# transcrit À LA MAIN dans DEUX docs, sans AUCUN gate.
# ----------------------------------------------------------------------------
# Le générateur chat_otoia_gen émet out/MANIFEST.json (byte-gaté par
# check_artifacts ⇒ faisant autorité) dont `counts.custom_blocks`, `counts.mounts`
# et `counts.portails` sont RECOMPTÉS à chaque build depuis le contrat RBAC (un
# `Custom Block` ⇔ une config runtime ⇔ un portail métier, `plateforme` exclu —
# invariant 2 du générateur : bijection 1 block ⇔ 1 mount ⇔ 1 portail). Ces
# nombres sont recopiés À LA MAIN : la fiche agent Frontend Console
# (03_agents/frontend_console/AGENT.md — « **N `Custom Block` + M configs
# runtime** ») ET le README du module (05_deliverables_mvp/frontend/chat_otoia/
# README.md — « Portails (N, `plateforme` exclu) », « N fixtures `Custom Block` »,
# « M configs runtime »). Piège « aveugle » caractéristique (cf. DevOps phases
# confirmations, bascule verdict audit_5d) : custom_blocks, mounts ET portails
# valent TOUS TROIS 5 AUJOURD'HUI — un gate qui n'en recompterait qu'un serait
# AVEUGLE à la dérive des autres (ajouter un portail au contrat RBAC → chat_otoia
# rebâtit 6 blocks 6 mounts 6 portails pendant que les docs restent à 5). On
# RECOMPUTE donc CHAQUE nombre depuis SON PROPRE champ du MANIFEST (jamais figé
# ici · zéro duplication du compteur du générateur #5) et on exige l'égalité avec
# les docs. Cohérence croisée : counts.custom_blocks == counts.mounts ==
# counts.portails == |MANIFEST.portails| (la bijection du générateur) — mord un
# compteur désynchronisé de sa propre liste. Un claim absent échoue AUSSI (la
# disparition d'un chiffre est une régression · traçabilité).
# ============================================================================
CO_RD = f"{D}/frontend/chat_otoia/README.md"
CO_FI = "03_agents/frontend_console/AGENT.md"
try:
co_man = load("frontend/chat_otoia/out/MANIFEST.json")
co_cb = str(co_man["counts"]["custom_blocks"])
co_mo = str(co_man["counts"]["mounts"])
co_po = str(co_man["counts"]["portails"])
co_nlist = len(co_man["portails"])
if not (co_cb == co_mo == co_po == str(co_nlist)):
bad(f"Chat OTOIA · incohérence artefact : custom_blocks={co_cb} · "
f"mounts={co_mo} · portails={co_po} · |portails|={co_nlist} "
f"(bijection 1 block ⇔ 1 mount ⇔ 1 portail attendue)")
except (OSError, KeyError, ValueError) as e:
bad(f"Chat OTOIA · artefact chat_otoia illisible/inattendu : {e}"); co_cb = None
if co_cb is not None:
# 1) Fiche agent Frontend Console — « N `Custom Block` + M configs runtime »
co_fi = read_norm(CO_FI)
if co_fi is not None:
m = re.search(r"\*\*(\d+) `Custom Block` \+ (\d+) configs runtime\*\*", co_fi)
if m is None:
bad(f"Chat OTOIA · {CO_FI} — claim « N `Custom Block` + M configs runtime » "
f"INTROUVABLE (attendu {co_cb}/{co_mo})")
elif m.groups() == (co_cb, co_mo):
good(f"Chat OTOIA · {CO_FI} — {'/'.join(m.groups())} == artefact "
f"(custom_blocks={co_cb} · mounts={co_mo})")
else:
bad(f"Chat OTOIA · {CO_FI} — dit {'/'.join(m.groups())} MAIS artefact dit "
f"{co_cb}/{co_mo} → régénérer la fiche")
# 2) README module — « Portails (N, `plateforme` exclu) »
co_rd = read_norm(CO_RD)
if co_rd is not None:
m = re.search(r"Portails \((\d+), `plateforme` exclu\)", co_rd)
if m is None:
bad(f"Chat OTOIA · {CO_RD} — claim « Portails (N, `plateforme` exclu) » "
f"INTROUVABLE (attendu portails={co_po})")
elif m.group(1) == co_po:
good(f"Chat OTOIA · {CO_RD} — {m.group(1)} portails == artefact "
f"(portails={co_po})")
else:
bad(f"Chat OTOIA · {CO_RD} — dit {m.group(1)} portails MAIS artefact dit "
f"{co_po} → régénérer le README")
# 3) README module — « N fixtures `Custom Block` »
m = re.search(r"(\d+) fixtures `Custom Block`", co_rd)
if m is None:
bad(f"Chat OTOIA · {CO_RD} — claim « N fixtures `Custom Block` » INTROUVABLE "
f"(attendu custom_blocks={co_cb})")
elif m.group(1) == co_cb:
good(f"Chat OTOIA · {CO_RD} — {m.group(1)} fixtures == artefact "
f"(custom_blocks={co_cb})")
else:
bad(f"Chat OTOIA · {CO_RD} — dit {m.group(1)} fixtures MAIS artefact dit "
f"{co_cb} → régénérer le README")
# 4) README module — « M configs runtime » (2e transcription des mounts)
m = re.search(r"(\d+) configs runtime", co_rd)
if m is None:
bad(f"Chat OTOIA · {CO_RD} — claim « M configs runtime » INTROUVABLE "
f"(attendu mounts={co_mo})")
elif m.group(1) == co_mo:
good(f"Chat OTOIA · {CO_RD} — {m.group(1)} configs runtime == artefact "
f"(mounts={co_mo})")
else:
bad(f"Chat OTOIA · {CO_RD} — dit {m.group(1)} configs runtime MAIS artefact "
f"dit {co_mo} → régénérer le README")
# ============================================================================
# Chat OTOIA · IDENTITÉ des configs runtime (persona · langues · endpoint) —
# ATTRIBUTS transcrits À LA MAIN dans la cellule « Métrique vérifiée » de la
# fiche agent, AVEUGLES au gate de COMPTE ci-dessus.
# ----------------------------------------------------------------------------
# Le bloc précédent gate le NOMBRE de blocks/mounts/portails ; il est indifférent
# à CE QUE chaque config runtime dit. Or la même cellule (03_agents/
# frontend_console/AGENT.md:29) restitue TROIS attributs data-derived du montage :
# « persona Amélie … · langues FR/EN/ES · `endpoint: null` ». Ils sortent des
# artefacts byte-gatés out/chat_mount.json (une config par portail) + out/
# MANIFEST.json (persona/langues/endpoint_statut consolidés) — jamais de la spec.
# • persona.nom : le prénom de l'assistant (le « QC » est le qualificatif de
# marque CLAUDE.md « Voix Amélie QC », gardé en littéral — non gaté ici).
# • langues : la surface trilingue FR/EN/ES lue par le générateur dans
# seo_spec.json (source unique des langues).
# • endpoint : `null` est une AFFIRMATION D'HONNÊTETÉ #6/#8 — l'endpoint OTOIA
# n'est PAS fabriqué (renseigné côté VPS). Si une config gagnait un endpoint
# réel, la fiche « endpoint: null » deviendrait un MENSONGE en silence.
# Cohérence croisée (bijection du générateur) : MANIFEST.persona.nom ET
# MANIFEST.langues doivent valoir EXACTEMENT ce que porte CHAQUE mount (un mount
# désynchronisé de son propre manifeste mord ici) ; endpoint_statut « a_confirmer »
# doit correspondre à des endpoints TOUS `null` (sinon incohérence interne de
# l'artefact). Un claim absent échoue AUSSI (INTROUVABLE = régression #6). Aucune
# suite tests/ (FONCTIONS du builder, jamais la prose de la fiche) n'attrape ce
# « vert trompeur ».
# ============================================================================
try:
ci_man = load("frontend/chat_otoia/out/MANIFEST.json")
ci_mounts = load("frontend/chat_otoia/out/chat_mount.json")
ci_persona = ci_man["persona"]["nom"]
ci_langs = list(ci_man["langues"])
ci_statut = ci_man["endpoint_statut"]
if not ci_mounts:
bad("Chat OTOIA identité · out/chat_mount.json vide"); ci_persona = None
except (OSError, KeyError, ValueError, TypeError) as e:
bad(f"Chat OTOIA identité · artefact illisible/inattendu : {e}"); ci_persona = None
if ci_persona is not None:
# Cohérence croisée MANIFEST ⇔ chaque config runtime (bijection du générateur)
bad_persona = sorted({m["persona"]["nom"] for m in ci_mounts if m["persona"]["nom"] != ci_persona})
bad_langs = sorted({"/".join(m["langues"]) for m in ci_mounts if list(m["langues"]) != ci_langs})
all_null = all(m["endpoint"] is None for m in ci_mounts)
if bad_persona:
bad(f"Chat OTOIA identité · persona MANIFEST={ci_persona!r} MAIS mounts portent "
f"aussi {bad_persona} (montage désynchronisé)")
if bad_langs:
bad(f"Chat OTOIA identité · langues MANIFEST={ci_langs} MAIS mounts portent "
f"aussi {bad_langs} (montage désynchronisé)")
if all_null and ci_statut != "a_confirmer":
bad(f"Chat OTOIA identité · endpoints TOUS null MAIS endpoint_statut={ci_statut!r} "
f"(attendu 'a_confirmer' — incohérence interne artefact)")
if (not all_null) and ci_statut == "a_confirmer":
bad("Chat OTOIA identité · endpoint_statut='a_confirmer' MAIS ≥1 endpoint fabriqué "
"(incohérence interne artefact)")
exp_langs = "/".join(l.upper() for l in ci_langs)
exp_endpoint = "null" if all_null else "NON-null(" + ",".join(
sorted({str(m["endpoint"]) for m in ci_mounts if m["endpoint"] is not None})) + ")"
co_fi2 = read_norm(CO_FI)
if co_fi2 is not None:
m = re.search(r"persona (\S+) QC · langues ([A-Z/]+) · `endpoint: ([^`]+)`", co_fi2)
if m is None:
bad(f"Chat OTOIA identité · {CO_FI} — claim « persona <nom> QC · langues "
f"<L> · `endpoint: <e>` » INTROUVABLE (attendu {ci_persona}/{exp_langs}/{exp_endpoint})")
else:
got = m.groups()
want = (ci_persona, exp_langs, exp_endpoint)
if got == want:
good(f"Chat OTOIA identité · {CO_FI} — persona {got[0]} · langues {got[1]} · "
f"endpoint {got[2]} == artefact (chat_mount ×{len(ci_mounts)} ⇔ MANIFEST)")
else:
bad(f"Chat OTOIA identité · {CO_FI} — dit persona {got[0]}/langues {got[1]}/"
f"endpoint {got[2]} MAIS artefact dit {want[0]}/{want[1]}/{want[2]} "
f"→ régénérer la fiche")
# ============================================================================
# Chat OTOIA · IDENTITÉ de la LISTE D'INVARIANTS CI — le README du module porte
# une section « ## Invariants (N) — garantis en CI » qui ANNONCE un compte (N)
# PUIS énumère chaque invariant par son numéro entre parenthèses ((1) · (2) ·
# … · (11, 13) · (12) · (14)). Cette prose transcrit À LA MAIN le REGISTRE réel
# des invariants du générateur — les marqueurs `# Invariant N ·` de
# chat_otoia_gen.py, qui sont la source d'autorité de « ce que la CI garantit »
# (chaque marqueur = un contrôle appliqué par validate()). Classe DISTINCTE de
# tout ce qui précède : les blocs Chat OTOIA plus haut gatent des COMPTES
# d'artefact (blocks/mounts/portails) et l'IDENTITÉ persona/langues/endpoint ;
# AUCUN ne touche la liste d'invariants — surface ungatée, piège aveugle. Le
# générateur se SELF-REPORTE lui-même (« schéma + N invariants verts. » dans son
# message CLI), 3ᵉ transcription du même compte. On RECOMPUTE ici le registre
# depuis le SEUL registre-source (les marqueurs `# Invariant N` du .py) et on
# exige : (a) le registre code est un ensemble CONTIGU 1..max (une lacune/un
# doublon dans le ledger code = défaut réel) ; (b) le message CLI du générateur
# « N invariants verts » == |registre| (self-report du .py cohérent avec son
# propre ledger) ; (c) le compte annoncé « ## Invariants (N) » du README ==
# |registre| ; (d) l'ÉNUMÉRATION en prose du README == registre EXACT (numéro
# absent ET numéro fantôme mordus par set-diff). Ajouter un `# Invariant 15`
# (nouveau contrôle) sans toucher la prose ⇒ (c)(d) rouges ; retirer le contrôle
# 7 du code ⇒ (7) devient fantôme dans la prose. Aucune suite tests/ ne teste la
# PROSE ; le byte-gate d'artefact ignore le .py source ⇒ dérive silencieuse
# sinon. Section/compte absent = échec (la disparition d'un chiffre est une
# régression de traçabilité · #6).
# ============================================================================
CO_GEN = f"{D}/frontend/chat_otoia/chat_otoia_gen.py"
try:
co_src = open(CO_GEN, encoding="utf-8").read()
except OSError as e:
bad(f"Chat OTOIA invariants · générateur {CO_GEN} illisible : {e}"); co_src = None
if co_src is not None:
# (a) registre-source = marqueurs `# Invariant N` du générateur (autorité).
inv_reg = sorted({int(n) for n in re.findall(r"#\s*Invariant\s+(\d+)\b", co_src)})
if not inv_reg:
bad(f"Chat OTOIA invariants · aucun marqueur `# Invariant N` dans {CO_GEN} "
"(registre-source vide → impossible de re-dériver)")
else:
n_inv = len(inv_reg)
contigu = inv_reg == list(range(1, inv_reg[-1] + 1))
if not contigu:
bad(f"Chat OTOIA invariants · registre code NON contigu : {inv_reg} "
f"(attendu 1..{n_inv}) — lacune/doublon dans le ledger `# Invariant N`")
else:
good(f"Chat OTOIA invariants · registre code contigu 1..{n_inv} "
f"({n_inv} marqueurs `# Invariant N`)")
# (b) self-report CLI du générateur « N invariants verts ».
mg = re.search(r"(\d+)\s+invariants verts", co_src)
if mg is None:
bad(f"Chat OTOIA invariants · message CLI « N invariants verts » "
f"INTROUVABLE dans {CO_GEN} (attendu {n_inv})")
elif int(mg.group(1)) == n_inv:
good(f"Chat OTOIA invariants · générateur self-report {mg.group(1)} "
f"== registre ({n_inv})")
else:
bad(f"Chat OTOIA invariants · générateur dit « {mg.group(1)} invariants "
f"verts » MAIS registre `# Invariant N` en compte {n_inv} "
"(self-report désynchronisé du ledger)")
# README : section « ## Invariants (N) — garantis en CI » + énumération.
try:
co_rd_raw = open(CO_RD, encoding="utf-8").read()
except OSError as e:
bad(f"Chat OTOIA invariants · {CO_RD} illisible : {e}"); co_rd_raw = None
if co_rd_raw is not None:
mh = re.search(r"##\s+Invariants\s+\((\d+)\)\s+—\s+garantis en CI", co_rd_raw)
if mh is None:
bad(f"Chat OTOIA invariants · {CO_RD} — section « ## Invariants (N) — "
f"garantis en CI » INTROUVABLE (attendu {n_inv})")
else:
# (c) compte annoncé.
if int(mh.group(1)) == n_inv:
good(f"Chat OTOIA invariants · {CO_RD} — annonce ({mh.group(1)}) "
f"== registre ({n_inv})")
else:
bad(f"Chat OTOIA invariants · {CO_RD} — annonce ({mh.group(1)}) MAIS "
f"registre en compte {n_inv} → régénérer le README")
# (d) énumération en prose == registre EXACT (set-diff).
nxt = re.search(r"\n##\s", co_rd_raw[mh.end():])
prose = co_rd_raw[mh.end(): mh.end() + nxt.start()] if nxt else co_rd_raw[mh.end():]
enum = sorted({int(x) for grp in re.findall(r"\(([\d,\s]+)\)", prose)
for x in re.findall(r"\d+", grp)})
if enum == inv_reg:
good(f"Chat OTOIA invariants · {CO_RD} — énumération prose {enum} "
"== registre code (aucun absent/fantôme)")
else:
absents = [x for x in inv_reg if x not in enum]
fantomes = [x for x in enum if x not in inv_reg]
bad(f"Chat OTOIA invariants · {CO_RD} — énumération prose ≠ registre : "
f"absent(s)={absents} · fantôme(s)={fantomes} → régénérer le README")
# ============================================================================
# Recette (module) · couple « N livrables de sprint + M métriques succès MVP »
# transcrit À LA MAIN dans DEUX docs — même classe de « vert trompeur ».
# ----------------------------------------------------------------------------
# La matrice d'acceptation (qa/acceptance/out/acceptance_matrix.json, byte-gatée
# par check_artifacts ⇒ faisant autorité) porte une ligne par promesse roadmap,
# de `kind` sprint_deliverable OU mvp_metric. Le compte de chaque genre (n_sprint,
# n_metric déjà recalculés plus haut depuis la matrice, jamais saisis) est repris
# À LA MAIN à DEUX endroits que le bloc racine ne couvre PAS : le README du module
# (05_deliverables_mvp/qa/acceptance/README.md — puces « N livrables de sprint »
# puis « M métriques succès MVP ») ET la fiche agent QA (03_agents/qa/AGENT.md —
# « (N livrables + M métriques) »). Le bloc racine plus haut ne gate que le SEUL
# README d'entrée (« N promesses (X sprint + Y métriques) ») ; ces deux docs de
# module en dérivent sans gate. Ajouter une promesse roadmap (l'artefact rebâtit
# 9/7) ferait dériver LES DEUX docs en silence pendant que la matrice dit autre
# chose — piège que ni les suites tests/ (qui testent des FONCTIONS, pas la prose)
# ni le roadmap-anchor (qui ancre les `roadmap_line` de l'ARTEFACT, pas la prose
# des docs) n'attrapent. On RECOMPUTE ici chaque nombre depuis SON PROPRE genre de
# la matrice (jamais figé · zéro duplication #5) et on exige l'égalité avec les
# DEUX docs. Recomputer les DEUX (sprint ET métriques) mord une dérive de l'un là
# où l'autre resterait aveugle. Cohérence croisée en bonus :
# MANIFEST.counts.{sprint_deliverables,mvp_metrics} == (n_sprint, n_metric) — mord
# un compteur du manifeste désynchronisé de la matrice qu'il résume. Un claim
# absent échoue AUSSI (la disparition d'un chiffre est une régression · traçabilité).
# ============================================================================
ACC_RD = f"{D}/qa/acceptance/README.md"
ACC_FI = "03_agents/qa/AGENT.md"
acc_exp = (str(n_sprint), str(n_metric))
acc_src = f"livrables_sprint={acc_exp[0]} métriques={acc_exp[1]}"
try:
acc_mc = load("qa/acceptance/out/MANIFEST.json")["counts"]
if (str(acc_mc["sprint_deliverables"]), str(acc_mc["mvp_metrics"])) != acc_exp:
bad(f"Recette · incohérence artefact : MANIFEST.counts dit "
f"{acc_mc['sprint_deliverables']}/{acc_mc['mvp_metrics']} MAIS la matrice "
f"dit {'/'.join(acc_exp)} (compteur désynchronisé de sa liste)")
except (OSError, KeyError, ValueError) as e:
bad(f"Recette · MANIFEST acceptance illisible/inattendu : {e}")
# 1) README du module — deux puces distinctes (livrables sprint · métriques MVP)
acc_rd = read_norm(ACC_RD)
if acc_rd is not None:
ms = re.search(r"\*\*(\d+) livrables de sprint\*\*", acc_rd)
mm = re.search(r"\*\*(\d+) métriques succès MVP\*\*", acc_rd)
if ms is None or mm is None:
bad(f"Recette · {ACC_RD} — puces « N livrables de sprint » / « M métriques "
f"succès MVP » INTROUVABLE(s) (attendu {acc_src})")
elif (ms.group(1), mm.group(1)) == acc_exp:
good(f"Recette · {ACC_RD} — {ms.group(1)}/{mm.group(1)} == matrice ({acc_src})")
else:
bad(f"Recette · {ACC_RD} — dit {ms.group(1)}/{mm.group(1)} MAIS matrice dit "
f"{'/'.join(acc_exp)} ({acc_src}) → régénérer le README")
# 2) Fiche agent QA — parenthèse « (N livrables + M métriques) »
acc_fi = read_norm(ACC_FI)
if acc_fi is not None:
m = re.search(r"\((\d+) livrables \+ (\d+) métriques\)", acc_fi)
if m is None:
bad(f"Recette · {ACC_FI} — claim « (N livrables + M métriques) » INTROUVABLE "
f"(attendu {acc_src})")
elif m.groups() == acc_exp:
good(f"Recette · {ACC_FI} — {'/'.join(m.groups())} == matrice ({acc_src})")
else:
bad(f"Recette · {ACC_FI} — dit {'/'.join(m.groups())} MAIS matrice dit "
f"{'/'.join(acc_exp)} ({acc_src}) → régénérer la fiche")
# ============================================================================
# Fiscal · e-CF DGII — le CONTRAT DE FORMAT de l'e-NCF (« E + tipoeCF(2) +
# secuencia(10) » = 13) transcrit À LA MAIN dans QUATRE endroits de prose (README
# ×3 + fiche agent erpnext_backend), sans AUCUN gate d'IDENTITÉ.
# ----------------------------------------------------------------------------
# Le générateur ecf_dgii_gen émet out/ecf_plan.json (byte-gaté par check_artifacts
# ⇒ faisant autorité) dont le bloc `e_ncf` PORTE le format de l'identifiant
# électronique DGII : `prefix` (« E »), `longueur` (13), `regex`
# (« ^E[0-9]{12}$ ») et `structure` (« E + tipoeCF (2 chiffres) + secuencia
# (10 chiffres) »). Ce format — préfixe + longueur du segment tipo + longueur du
# segment secuencia + longueur totale — est le CŒUR du module (le « composeur
# traçable ») et il est recopié À LA MAIN dans QUATRE endroits de prose :
# • README:10 « composeur d'e-NCF traçable `E + tipoeCF(2) + secuencia(10)` »
# • README:75 (invariant 7) « E+tipo(2)+seq(10)=13 » (porte la longueur TOTALE)
# • README:84 (composition) « e_ncf = "E" + tipo(2) + secuencia(10) »
# • 03_agents/erpnext_backend/AGENT.md:33 « `E + tipoeCF(2) + secuencia(10)` »
# Le bloc racine de check_readme_claims ne gate QUE le COMPTE de tests e-CF
# (« 39 »). Aucun gate ne comparait la LONGUEUR des segments à l'artefact : allonger
# la secuencia (rango DGII à 12 chiffres → seq=12, longueur=15, regex 14 digits)
# OU changer le préfixe ferait dériver les QUATRE proses en silence pendant que
# l'ecf_plan byte-gaté dit autre chose — « vert trompeur » qu'aucune suite tests/
# (qui teste des FONCTIONS ncf.compose/parse, pas la prose des docs) n'attrape.
# Même patron que l'énumération des onglets Mobile, la carte 4Big ou les open items
# 5D. On RECOMPUTE (prefix, tipo_len, seq_len, total) depuis le SEUL bloc `e_ncf`
# (zéro duplication du format du générateur #6) et on exige que CHAQUE prose
# l'encode EXACTEMENT. Cohérences croisées en bonus (mordent un artefact
# INTERNEMENT incohérent) : |prefix| + tipo_len + seq_len == longueur (1+2+10==13)
# ET le nombre de chiffres du `regex` == tipo_len + seq_len (12 == 2+10). Un claim
# absent échoue AUSSI (la disparition d'un format est une régression · traçabilité).
# ============================================================================
ECF_RD = f"{D}/fiscal/ecf_dgii/README.md"
ECF_FI = "03_agents/erpnext_backend/AGENT.md"
try:
encf = load("fiscal/ecf_dgii/out/ecf_plan.json")["e_ncf"]
ecf_prefix = encf["prefix"]
ecf_total = int(encf["longueur"])
ecf_seglens = [int(x) for x in
re.findall(r"\((\d+)\s*chiffres?\)", encf["structure"])]
ecf_mdig = re.search(r"\[0-9\]\{(\d+)\}", encf["regex"])
except (OSError, KeyError, ValueError) as e:
bad(f"Fiscal · artefact ecf_plan illisible/inattendu : {e}"); encf = None
if encf is not None:
if len(ecf_seglens) != 2:
bad(f"Fiscal · e_ncf.structure malformée : {encf['structure']!r} "
f"(attendu 2 segments « (N chiffres) », trouvé {ecf_seglens})")
else:
ecf_tipo, ecf_seq = ecf_seglens
# Cohérences croisées : l'artefact doit être INTERNEMENT cohérent.
if len(ecf_prefix) != 1:
bad(f"Fiscal · incohérence artefact : prefix={ecf_prefix!r} n'est pas "
f"1 caractère")
if 1 + ecf_tipo + ecf_seq != ecf_total:
bad(f"Fiscal · incohérence artefact : 1+{ecf_tipo}+{ecf_seq}="
f"{1 + ecf_tipo + ecf_seq} MAIS longueur={ecf_total}")
if ecf_mdig is None or int(ecf_mdig.group(1)) != ecf_tipo + ecf_seq:
got = ecf_mdig.group(1) if ecf_mdig else "?"
bad(f"Fiscal · incohérence artefact : regex porte {got} chiffres MAIS "
f"tipo+seq={ecf_tipo + ecf_seq}")
exp_ncf = f"{ecf_prefix} + tipo({ecf_tipo}) + secuencia({ecf_seq}) = {ecf_total}"
def ecf_check(tag, path, txt, m, with_total):
if m is None:
bad(f"Fiscal · {path} — {tag} : format e-NCF INTROUVABLE "
f"(attendu {exp_ncf})")
return
g = m.groups()
ok = (g[0] == ecf_prefix and int(g[1]) == ecf_tipo
and int(g[2]) == ecf_seq
and (not with_total or int(g[3]) == ecf_total))
if ok:
good(f"Fiscal · {path} — {tag} : {exp_ncf} == e_ncf (artefact)")
else:
shown = "/".join(g)
bad(f"Fiscal · {path} — {tag} : dit {shown} MAIS e_ncf dit "
f"prefix={ecf_prefix} tipo={ecf_tipo} seq={ecf_seq} "
f"total={ecf_total} → régénérer le doc")
# 1) README:10 — résumé « composeur d'e-NCF traçable `E + tipoeCF(2) + secuencia(10)` »
ecf_rd = read_norm(ECF_RD)
if ecf_rd is not None:
ecf_check("résumé composeur", ECF_RD, ecf_rd,
re.search(r"`([A-Z])\s*\+\s*tipoeCF\((\d+)\)\s*\+\s*"
r"secuencia\((\d+)\)`", ecf_rd), False)
# 2) README:75 — invariant 7 « E+tipo(2)+seq(10)=13 » (porte la longueur totale)
ecf_check("invariant format", ECF_RD, ecf_rd,
re.search(r"\(([A-Z])\+tipo\((\d+)\)\+seq\((\d+)\)=(\d+)",
ecf_rd), True)
# 3) README:84 — composition « e_ncf = "E" + tipo(2) + secuencia(10) »
ecf_check("composition ncf.py", ECF_RD, ecf_rd,
re.search(r'e_ncf\s*=\s*"([A-Z])"\s*\+\s*tipo\((\d+)\)\s*\+\s*'
r"secuencia\((\d+)\)", ecf_rd), False)
# 4) Fiche agent erpnext_backend — « `E + tipoeCF(2) + secuencia(10)` »
ecf_fi = read_norm(ECF_FI)
if ecf_fi is not None:
ecf_check("fiche erpnext_backend", ECF_FI, ecf_fi,
re.search(r"`([A-Z])\s*\+\s*tipoeCF\((\d+)\)\s*\+\s*"
r"secuencia\((\d+)\)`", ecf_fi), False)
# ------------------------------------------------------------------
# 5) 6) 7) 8) 9) — le MÊME contrat de format dans le COMPOSEUR ncf.py
# (CODE d'exécution, PAS de la prose) + son ORACLE de test.
# `ecflib/ncf.py` porte TROIS copies INDÉPENDANTES du format, AUCUNE
# dérivée de l'artefact : la regex de VALIDATION `ENCF_RE` (cœur de
# is_valid_encf/parse_encf), la chaîne `FORMULE` publiée par
# compose_encf (donc RENVOYÉE au caller VPS/Compupar), et deux
# proses-en-code (docstring + commentaire Ley 32-23). En prime,
# `tests/test_ecf_dgii.py:246` HARDCODE l'oracle « FORMULE ». Le bloc
# e-NCF ci-dessus ne gate QUE la prose (README ×3 + fiche). Or un
# rango DGII à 12 chiffres régénère ecf_spec→ecf_plan (donc les 4
# proses gatées ci-dessus SUIVENT), MAIS laisse ENCF_RE à `{2}{10}` :
# compose_encf zfill à 10 et is_valid_encf REJETTE un e-NCF désormais
# valide → BUG d'exécution du composeur fiscal, pendant que la suite
# tests/ reste VERTE (son oracle == FORMULE périmée, self-consistante).
# « Vert trompeur » que ni check_artifacts (ncf.py n'est pas un
# out/*.json) ni tests/ (teste les FONCTIONS, pas leur ancrage) ne
# mordent. Même patron que l'oracle/formateur du Publiciste. On ANCRE
# ces surfaces CODE au SEUL bloc e_ncf byte-gaté (réutilise
# prefix/tipo/seq/total ci-dessus · zéro re-dérivation du format #6).
ECF_NCF = f"{D}/fiscal/ecf_dgii/ecflib/ncf.py"
ECF_TEST = f"{D}/fiscal/ecf_dgii/tests/test_ecf_dgii.py"
ecf_ncf = read_norm(ECF_NCF)
if ecf_ncf is None:
bad(f"Fiscal · composeur {ECF_NCF} illisible → format e-NCF non ancré")
else:
# 5) ENCF_RE — la regex de VALIDATION (prefix + 2 segments de chiffres)
ecf_check("composeur ncf.py · ENCF_RE (validateur)", ECF_NCF, ecf_ncf,
re.search(r"ENCF_RE\s*=\s*re\.compile\(\s*r?[\"']\^"
r"([A-Z])\(\[0-9\]\{(\d+)\}\)\(\[0-9\]\{(\d+)\}\)\$",
ecf_ncf), False)
# 6) FORMULE — la chaîne publiée par compose_encf (renvoyée au VPS)
ecf_check("composeur ncf.py · FORMULE (publiée)", ECF_NCF, ecf_ncf,
re.search(r"FORMULE\s*=\s*[\"']([A-Z])\s*\+\s*tipoeCF\((\d+)\)"
r"\s*\+\s*secuencia\((\d+)\)[\"']", ecf_ncf), False)
# 7) docstring ncf.py — « "E" + tipoeCF (2 chiffres) + secuencia (10 chiffres) → 13 caracteres »
ecf_check("composeur ncf.py · docstring", ECF_NCF, ecf_ncf,
re.search(r'e-NCF\s*=\s*"([A-Z])"\s*\+\s*tipoeCF\s*\((\d+)'
r"\s*chiffres?\)\s*\+\s*secuencia\s*\((\d+)\s*chiffres?\)"
r"\s*\S+\s*(\d+)\s*caracteres?", ecf_ncf), True)
# 8) commentaire ncf.py — « E + tipo(2) + secuencia(10) = 13 caracteres »
ecf_check("composeur ncf.py · commentaire Ley 32-23", ECF_NCF, ecf_ncf,
re.search(r"Format e-NCF[^:]*:\s*([A-Z])\s*\+\s*tipo\((\d+)\)"
r"\s*\+\s*secuencia\((\d+)\)\s*=\s*(\d+)\s*caracteres?",
ecf_ncf), True)
# 9) oracle de test — la RAISON pour laquelle la dérive resterait VERTE
ecf_test = read_norm(ECF_TEST)
if ecf_test is None:
bad(f"Fiscal · oracle {ECF_TEST} illisible → FORMULE de test non ancrée")
else:
ecf_check("oracle test_ecf_dgii.py · formule", ECF_TEST, ecf_test,
re.search(r"\[[\"']formule[\"']\]\s*,\s*[\"']([A-Z])\s*\+"
r"\s*tipoeCF\((\d+)\)\s*\+\s*secuencia\((\d+)\)[\"']",
ecf_test), False)
# ============================================================================
# Fiscal · e-CF DGII (2e surface du MÊME README) · la CROSS-COHÉRENCE
# (« le cœur du livrable ») — ENSEMBLES/valeur data-derived transcrits À LA MAIN
# sans AUCUN gate d'IDENTITÉ. Le bloc e-NCF ci-dessus ne gate QUE le FORMAT de
# l'identifiant ; la section « Cross-cohérence e-CF ↔ workflow ↔ DocType ↔ RBAC »
# ÉNUMÈRE trois données dérivées de out/ecf_plan.json (byte-gaté par
# check_artifacts ⇒ faisant autorité) :
# • README:48 « seulement réservation et contrat » — l'ensemble des états qui
# ÉMETTENT un e-CF = {emission_events[].update_value}.
# • README:50 « (`montant_reservation`, `montant_contrat`) » — l'ensemble des
# champs Currency de base = {emission_events[].base_field}.
# • README:59 « FormaPago défaut = 3 (Tarjeta) … via Cardnet (#10) » — le code +
# libellé de forma_pago_defaut, ANCRÉ sur CLAUDE.md #10 (Cardnet = carte).
# Aucun gate ne comparait ces valeurs à l'artefact : AJOUTER un évènement
# d'émission (facturer un devis) · en RETIRER un · RENOMMER un base_field ·
# CHANGER forma_pago_defaut (Cardnet abandonné → code 2 Transfert) ferait dériver
# la prose en silence pendant que l'ecf_plan byte-gaté dit autre chose — l'agent
# ERPNext Backend câblerait le mauvais évènement / la mauvaise FormaPago (le risque
# même que la cross-cohérence veut prévenir) — « vert trompeur » qu'aucune suite
# tests/ (qui teste des FONCTIONS ncf/résolution, pas la prose) n'attrape. Même
# patron EXACT que la cross-cohérence CRM/Commissions (base_field + états soumis).
# On RECOMPUTE chaque ensemble/valeur depuis l'artefact (zéro duplication du
# contrat du générateur #6) et on exige que la prose l'encode EXACTEMENT (absent ET
# en trop via set-diff · accents/casse normalisés). Cohérences croisées en bonus
# (mordent un plan INTERNEMENT incohérent) : forma_pago_defaut.code ∈ catalogue
# formas_pago · libellé prose ⊆ libellé catalogue du code · ensembles NON VIDES.
# Un claim absent échoue AUSSI (traçabilité).
# ============================================================================
ECF_RD2 = f"{D}/fiscal/ecf_dgii/README.md"
def _ecf_acc(s):
return "".join(c for c in unicodedata.normalize("NFKD", s)
if unicodedata.category(c) != "Mn").lower()
try:
ecfp = load("fiscal/ecf_dgii/out/ecf_plan.json")
ecf_ev = ecfp["emission_events"]
ecf_uv = {_ecf_acc(e["update_value"]) for e in ecf_ev}
ecf_bf = {e["base_field"] for e in ecf_ev}
ecf_fpd = ecfp["forma_pago_defaut"]
ecf_fp_code = str(ecf_fpd["code"])
ecf_fp_cat = {str(f["code"]): f["label"] for f in ecfp["formas_pago"]}
except (OSError, KeyError, ValueError, TypeError) as e:
bad(f"Fiscal cross-cohérence · artefact ecf_plan illisible/inattendu : {e}"); ecf_ev = None
if ecf_ev is not None:
# bonus : plan INTERNEMENT cohérent (ensembles non vides · défaut ∈ catalogue)
if not ecf_uv or not ecf_bf:
bad("Fiscal cross-cohérence · aucun évènement d'émission (plan vide) → vérifier le plan")
elif ecf_fp_code not in ecf_fp_cat:
bad(f"Fiscal cross-cohérence · forma_pago_defaut.code={ecf_fp_code} ABSENT du "
f"catalogue formas_pago {sorted(ecf_fp_cat)} → plan incohérent")
else:
good(f"Fiscal cross-cohérence · forma_pago_defaut {ecf_fp_code} "
f"({ecf_fp_cat[ecf_fp_code]}) ∈ catalogue formas_pago · émission {sorted(ecf_uv)} non vide")
ecf_rd2 = read_norm(ECF_RD2)
if ecf_rd2 is not None:
# 1) README:48 — états émetteurs « seulement réservation et contrat »
me = re.search(r"seulement\s+([^.]+)\.", ecf_rd2)
if me is None:
bad(f"Fiscal cross-cohérence · {ECF_RD2} — énumération des états émetteurs "
f"INTROUVABLE (attendu {sorted(ecf_uv)})")
else:
es = {_ecf_acc(t.strip()) for t in re.split(r"\bet\b|[,/]", me.group(1)) if t.strip()}
if es == ecf_uv:
good(f"Fiscal cross-cohérence · {ECF_RD2} — états émetteurs {sorted(es)} == "
f"ecf_plan.emission_events[].update_value")
else:
miss = sorted(ecf_uv - es); extra = sorted(es - ecf_uv)
detail = (f" · absents={miss}" if miss else "") + (f" · en trop={extra}" if extra else "")
bad(f"Fiscal cross-cohérence · {ECF_RD2} — énumère {sorted(es)} MAIS plan "
f"{sorted(ecf_uv)}{detail} → régénérer le README")
# 2) README:50 — champs de base « (`montant_reservation`, `montant_contrat`) »
mf = re.search(r"Currency réel\*\* du DocType Dossier Vente\s*\(([^)]*)\)", ecf_rd2)
if mf is None:
bad(f"Fiscal cross-cohérence · {ECF_RD2} — énumération des champs de base "
f"INTROUVABLE (attendu {sorted(ecf_bf)})")
else:
bs = set(re.findall(r"`([a-z_]+)`", mf.group(1)))
if bs == ecf_bf:
good(f"Fiscal cross-cohérence · {ECF_RD2} — champs de base {sorted(bs)} == "
f"ecf_plan.emission_events[].base_field")
else:
miss = sorted(ecf_bf - bs); extra = sorted(bs - ecf_bf)
detail = (f" · absents={miss}" if miss else "") + (f" · en trop={extra}" if extra else "")
bad(f"Fiscal cross-cohérence · {ECF_RD2} — énumère {sorted(bs)} MAIS plan "
f"{sorted(ecf_bf)}{detail} → régénérer le README")
# 3) README:59 — FormaPago défaut « = 3 (Tarjeta) … via Cardnet (#10) »
mp = re.search(r"FormaPago défaut\s*=\s*(\d+)\s*\(([^)]*)\)", ecf_rd2)
if mp is None:
bad(f"Fiscal cross-cohérence · {ECF_RD2} — FormaPago défaut INTROUVABLE "
f"(attendu code {ecf_fp_code} · {ecf_fp_cat.get(ecf_fp_code,'?')})")
else:
got_code = mp.group(1); got_lbl = _ecf_acc(mp.group(2).strip())
cat_lbl = _ecf_acc(ecf_fp_cat.get(ecf_fp_code, ""))
has_cardnet = "cardnet" in _ecf_acc(ecf_rd2[mp.start():mp.start() + 160])
if got_code == ecf_fp_code and got_lbl and got_lbl in cat_lbl and has_cardnet:
good(f"Fiscal cross-cohérence · {ECF_RD2} — FormaPago défaut {got_code} "
f"({mp.group(2).strip()}) == forma_pago_defaut · ancré Cardnet (#10)")
elif got_code != ecf_fp_code:
bad(f"Fiscal cross-cohérence · {ECF_RD2} — FormaPago défaut = {got_code} MAIS "
f"forma_pago_defaut.code = {ecf_fp_code} → régénérer le README")
elif not (got_lbl and got_lbl in cat_lbl):
bad(f"Fiscal cross-cohérence · {ECF_RD2} — libellé FormaPago {mp.group(2).strip()!r} "
f"≠ catalogue code {ecf_fp_code} ({ecf_fp_cat.get(ecf_fp_code)!r}) → régénérer")
else:
bad(f"Fiscal cross-cohérence · {ECF_RD2} — FormaPago défaut sans ancrage "
f"Cardnet (#10) à proximité → régénérer le README")
# ============================================================================
# Fiscal · e-CF DGII (3e surface du MÊME README) · l'IDENTITÉ du RÔLE émetteur —
# le seul rôle habilité à ÉMETTRE un e-CF, transcrit À LA MAIN sans gate d'IDENTITÉ.
# ----------------------------------------------------------------------------
# La section « Cross-cohérence » du README (bullet `role_id`, README:51-54) NOMME
# le rôle émetteur par TROIS attributs data-derived :
# • README:54 « le rôle dédié `compta-fiscaliste-ecf` » — le `role_id` que TOUS
# les emission_events d'ecf_plan.json (byte-gaté) référencent.
# • README:54 « (_OTO Compta Fiscaliste eCF_) » — son `erpnext_role_name`.
# • README:53 « appartenir au portail `compta` » — le `portail` de CE rôle dans
# le contrat rbac_50_roles.json (schéma-validé · byte-gaté par les 3 MANIFEST).
# Le bloc e-NCF gate le FORMAT de l'identifiant, le bloc cross-cohérence gate les
# ÉTATS émetteurs / champs de base / FormaPago — mais AUCUN ne compare l'IDENTITÉ
# de ce rôle à ses deux sources. RÉAFFECTER l'émission à un autre rôle (dans le
# plan), le RENOMMER, ou DÉPLACER le rôle `compta-fiscaliste-ecf` du portail
# `compta` vers un autre portail (dans le contrat : l'e-CF serait alors émis HORS
# Compta — la séparation même que la cross-cohérence pose) laisse la prose périmée
# en silence pendant que l'artefact/le contrat disent autre chose ⇒ l'agent ERPNext
# Backend câblerait le mauvais rôle émetteur (le risque même que #6 interdit).
# Aucune suite tests/ (qui teste des FONCTIONS ncf/résolution, pas la prose) ne
# l'attrape — même patron d'IDENTITÉ que la Fiche Mobile ou les gardes CRM. On
# RECOMPUTE `role_id`/`erpnext_role_name` de l'artefact et `portail`/nom Frappe du
# contrat (zéro duplication) et on exige la prose EXACTE. Cohérences croisées bonus
# (mordent un plan/contrat INTERNEMENT incohérent) : tous les emission_events
# pointent UN SEUL rôle (fonction · non vide) · ce role_id existe UNE fois au
# contrat · le nom résolu dans l'artefact == le contrat (zéro-dup) · portail ==
# `compta`. Un claim absent échoue AUSSI (traçabilité #6).
# ============================================================================
ECF_RD3 = f"{D}/fiscal/ecf_dgii/README.md"
try:
ecfp3 = load("fiscal/ecf_dgii/out/ecf_plan.json")
ecf_ev3 = ecfp3["emission_events"]
ecf_rids = {e["role_id"] for e in ecf_ev3}
ecf_rnames = {e["erpnext_role_name"] for e in ecf_ev3}
ecf_contract = load("rbac/rbac_50_roles.json")["roles"]
except (OSError, KeyError, ValueError, TypeError) as e:
bad(f"Fiscal rôle émetteur · ecf_plan / rbac_50_roles illisible/inattendu : {e}"); ecf_ev3 = None
if ecf_ev3 is not None:
ecf_rd3 = read_norm(ECF_RD3)
# bonus : tous les emission_events pointent UN SEUL rôle (fonction · non vide)
if len(ecf_rids) != 1 or len(ecf_rnames) != 1:
bad(f"Fiscal rôle émetteur · emission_events référencent {sorted(ecf_rids)} role_id / "
f"{sorted(ecf_rnames)} noms (attendu 1 rôle émetteur unique) → plan incohérent")
elif ecf_rd3 is not None:
ecf_rid = next(iter(ecf_rids)); ecf_art_name = next(iter(ecf_rnames))
ecf_match = [r for r in ecf_contract if r.get("id") == ecf_rid]
if len(ecf_match) != 1:
bad(f"Fiscal rôle émetteur · le contrat porte {len(ecf_match)} rôle(s) `{ecf_rid}` "
f"(attendu exactement 1) → rôle absent/dupliqué du contrat rbac_50_roles.json")
else:
rc = ecf_match[0]
ecf_ct_name = rc.get("erpnext_role_name"); ecf_portail = rc.get("portail")
# bonus zéro-duplication : le nom résolu dans l'artefact == le contrat
if ecf_ct_name != ecf_art_name:
bad(f"Fiscal rôle émetteur · ecf_plan dit erpnext_role_name={ecf_art_name!r} MAIS "
f"contrat dit {ecf_ct_name!r} pour `{ecf_rid}` → régénérer l'artefact")
# bonus séparation des pouvoirs : ce rôle est bien du portail compta (README:53)
if ecf_portail != "compta":
bad(f"Fiscal rôle émetteur · le rôle `{ecf_rid}` est du portail {ecf_portail!r} "
f"≠ `compta` au contrat → l'e-CF serait émis HORS Compta (séparation cassée)")
# prose : 3 claims data-derived recomputés (role_id · nom Frappe · portail)
for label, pat, exp in [
("role_id", rf"rôle dédié `{re.escape(ecf_rid)}`", ecf_rid),
("nom d'affichage ERPNext", rf"\(_{re.escape(ecf_ct_name)}_\)", ecf_ct_name),
("portail", rf"portail `{re.escape(ecf_portail)}`", ecf_portail),
]:
if re.search(pat, ecf_rd3):
good(f"Fiscal rôle émetteur · {label} « {exp} » == ecf_plan/contrat rbac")
else:
bad(f"Fiscal rôle émetteur · {ECF_RD3} — {label} : la prose ne cite pas "
f"« {exp} » (recomputé de emission_events + rbac_50_roles) → régénérer")
# ============================================================================
# CRM/Commissions · barème vendeurs — la CROSS-COHÉRENCE (« cœur du livrable »)
# transcrite À LA MAIN dans le README sans AUCUN gate d'IDENTITÉ.
# ----------------------------------------------------------------------------
# Le générateur commissions_gen émet out/commission_plan.json (byte-gaté par
# check_artifacts ⇒ faisant autorité) : chaque évènement porte `update_value`
# (l'état du pipeline qui PAIE) et `base_field` (le champ Currency de base). La
# section « Cross-cohérence » du README ÉNUMÈRE À LA MAIN deux ensembles
# data-derived + une propriété — le CŒUR du livrable :
# • README:47 « on ne commissionne pas un brouillon (lead/visite/devis/
# abandonné) » — l'ensemble des états BROUILLON (doc_status=0 du workflow).
# • README:50 « base_field … (`montant_reservation`, `montant_contrat`) » —
# l'ensemble des champs de base cités par le plan.
# • README:46 « correspondre à un état soumis (doc_status = 1) » — INVARIANT 3
# du module : on ne commissionne QUE des états soumis, jamais un brouillon.
# Le bloc racine ne gate QUE le COMPTE de tests commissions (« 25 », via l'agrégat
# « Total CRM »). Aucun gate ne comparait ces ENSEMBLES aux artefacts : PROMOUVOIR
# un état brouillon en soumis (abandonne → doc_status 1) le retire de l'ensemble
# brouillon pendant que la prose l'énumère encore ; AJOUTER un champ de base à un
# évènement (base_field montant_confotur) l'ajoute sans que la prose bouge ;
# RENOMMER un état passe aussi — « vert trompeur » qu'aucune suite tests/ (qui teste
# des FONCTIONS finance/résolution, pas la prose) n'attrape. Même patron que
# l'énumération des onglets Mobile, des transitions quatre-yeux CRM ou du format
# e-NCF fiscal. On RECOMPUTE chaque ensemble depuis SON artefact byte-gaté (zéro
# duplication du contrat du générateur #6) et on exige que la prose l'énumère
# EXACTEMENT (absent ET en trop mordus via set-diff · accents/casse normalisés).
# Cohérence croisée en bonus (mord un plan INTERNEMENT incohérent avec le workflow) :
# tout `update_value` commissionné ∈ états soumis (doc_status=1) ET ∉ brouillons —
# l'invariant 3 du module, non vide. Un claim absent échoue AUSSI (traçabilité).
# ============================================================================
CM_RD = f"{D}/crm/commissions/README.md"
def _cm_acc(s):
return "".join(c for c in unicodedata.normalize("NFKD", s)
if unicodedata.category(c) != "Mn")
try:
cm_ev = load("crm/commissions/out/commission_plan.json")["evenements"]
cm_states = load("crm/workflow_vente/out/workflow.json")[0]["states"]
cm_base = {e["base_field"] for e in cm_ev}
cm_uv = {e["update_value"] for e in cm_ev}
cm_draft = {s["update_value"] for s in cm_states if str(s["doc_status"]) == "0"}
cm_subm = {s["update_value"] for s in cm_states if str(s["doc_status"]) == "1"}
except (OSError, KeyError, ValueError, IndexError, TypeError) as e:
bad(f"CRM commissions · artefact illisible/inattendu : {e}"); cm_ev = None
if cm_ev is not None:
# bonus : invariant 3 — tout état commissionné est SOUMIS (jamais brouillon), non vide
if not cm_uv:
bad("CRM commissions · aucun évènement commissionné (plan vide) → vérifier le barème")
elif not (cm_uv <= cm_subm):
bad(f"CRM commissions · incohérence plan↔workflow : update_values commissionnés "
f"{sorted(cm_uv - cm_subm)} PAS soumis (doc_status≠1) → invariant 3 cassé")
elif cm_uv & cm_draft:
bad(f"CRM commissions · incohérence plan↔workflow : {sorted(cm_uv & cm_draft)} "
f"commissionné(s) mais BROUILLON (doc_status=0) → invariant 3 cassé")
else:
good(f"CRM commissions · invariant 3 — {sorted(cm_uv)} tous soumis (doc_status=1), "
f"aucun brouillon")
cm_rd = read_norm(CM_RD)
if cm_rd is not None:
# 1) README:47 — ensemble des états BROUILLON exclus (doc_status=0 du workflow)
mb = re.search(r"brouillon \(([^)]*)\)", cm_rd)
if mb is None:
bad(f"CRM commissions · {CM_RD} — énumération des brouillons exclus "
f"INTROUVABLE (attendu {sorted(cm_draft)})")
else:
ps = {_cm_acc(t.strip().lower()) for t in re.split(r"[/,]", mb.group(1)) if t.strip()}
exp = {_cm_acc(x) for x in cm_draft}
if ps == exp:
good(f"CRM commissions · {CM_RD} — brouillons exclus {sorted(ps)} == "
f"workflow.json (doc_status=0)")
else:
miss = sorted(exp - ps); extra = sorted(ps - exp)
detail = (f" · absents={miss}" if miss else "") + \
(f" · en trop={extra}" if extra else "")
bad(f"CRM commissions · {CM_RD} — énumère {sorted(ps)} MAIS brouillons "
f"workflow {sorted(exp)}{detail} → régénérer le README")
# 2) README:50 — ensemble des champs de base (base_field du plan)
mf = re.search(r"Currency réel\*\* du DocType Dossier Vente \(([^)]*)\)", cm_rd)
if mf is None:
bad(f"CRM commissions · {CM_RD} — énumération des champs de base "
f"INTROUVABLE (attendu {sorted(cm_base)})")
else:
bs = set(re.findall(r"`([a-z_]+)`", mf.group(1)))
if bs == cm_base:
good(f"CRM commissions · {CM_RD} — champs de base {sorted(bs)} == "
f"commission_plan.evenements[].base_field")
else:
miss = sorted(cm_base - bs); extra = sorted(bs - cm_base)
detail = (f" · absents={miss}" if miss else "") + \
(f" · en trop={extra}" if extra else "")
bad(f"CRM commissions · {CM_RD} — énumère {sorted(bs)} MAIS plan "
f"{sorted(cm_base)}{detail} → régénérer le README")
# ============================================================================
# CRM/Commissions · ANTI-INVENTION (#6) — la promesse « tous les taux sont `null` »
# (le CŒUR du livrable · sa RAISON D'ÊTRE) confrontée à l'artefact byte-gaté.
# ----------------------------------------------------------------------------
# Le module commissions n'existe QUE pour NE PAS inventer de taux (aucun taux de
# commission n'est documenté dans CLAUDE.md). Le README §« Anti-invention (#6) »
# le promet noir sur blanc (README:22-23) : « Le barème livré porte `taux_pct:
# null` + `source: null` + `a_confirmer: true` pour **chaque** évènement », et
# l'invariant CLI #7 REFUSE tout `taux_pct` fourni **sans `source`**. La source
# faisant autorité est `out/commission_plan.json` (byte-gaté par check_artifacts) :
# chaque évènement porte `taux_pct`/`source`/`a_confirmer`, et `MANIFEST.counts.
# taux_a_confirmer` recompte les taux à confirmer.
# Le bloc commissions ci-dessus ne gate QUE la CROSS-COHÉRENCE (base_field +
# brouillons exclus) ; le bloc racine ne gate QUE le COMPTE de tests (« 25 »).
# RIEN ne confrontait la promesse #6 à l'artefact. Piège #6, la dérive la plus
# grave possible ici : qu'un `taux_pct` DEVIENNE non-null (un taux INVENTÉ, ou une
# valeur remplie SANS `source`) pendant que le README continue d'affirmer « tous
# `null` pour chaque évènement » → le calcul `base × taux` émettrait une commission
# fondée sur un chiffre inventé (l'invention EXACTE que le module promet d'écarter),
# « vert trompeur » qu'aucune suite tests/ (FONCTIONS de calcul, jamais la promesse
# du README vs l'artefact commité) n'attrape. On RECOMPUTE l'état null/confirmé
# depuis l'artefact et on exige que la promesse du README le REFLÈTE — dans les DEUX
# sens (si un taux est un jour confirmé par la Direction, la promesse « tous null »
# DOIT être mise à jour, sinon ROUGE : le gate suit la vie du module sans faux
# négatif). Cohérences croisées (mordent un plan INTERNEMENT incohérent) : invariant
# #7 permanent (aucun `taux_pct` sans `source`) · `a_confirmer` ⟺ `taux_pct is null`
# (un taux confirmé n'est plus « à confirmer », et inversement) · MANIFEST.counts
# recoupe le plan (`taux_a_confirmer` == nb à confirmer · `evenements` == |plan|).
# ============================================================================
try:
cta_ev = load("crm/commissions/out/commission_plan.json")["evenements"]
cta_counts = load("crm/commissions/out/MANIFEST.json")["counts"]
cta_note = load("crm/commissions/out/MANIFEST.json").get("note_taux", "")
except (OSError, KeyError, ValueError, TypeError) as e:
bad(f"CRM commissions anti-invention · artefact illisible/inattendu : {e}")
cta_ev = None
if cta_ev is not None:
if not cta_ev:
bad("CRM commissions anti-invention · plan VIDE → aucun évènement à couvrir "
"(barème suspect)")
else:
# (a) invariant #7 permanent : aucun taux_pct rempli SANS source (invention)
invente = sorted(e["update_value"] for e in cta_ev
if e.get("taux_pct") is not None and e.get("source") is None)
if invente:
bad(f"CRM commissions anti-invention · taux_pct RENSEIGNÉ sans `source` pour "
f"{invente} → invariant #7 VIOLÉ (taux INVENTÉ · #6) → fournir la source "
f"ou remettre à null")
else:
good("CRM commissions anti-invention · invariant #7 — aucun `taux_pct` sans "
"`source` (nul taux inventé)")
# (b) a_confirmer ⟺ taux_pct is null (un taux confirmé n'est plus « à confirmer »)
incoh = sorted(e["update_value"] for e in cta_ev
if bool(e.get("a_confirmer")) != (e.get("taux_pct") is None))
if incoh:
bad(f"CRM commissions anti-invention · `a_confirmer` incohérent avec "
f"`taux_pct` pour {incoh} (confirmé mais taux null, ou taux rempli mais "
f"encore « à confirmer ») → régénérer le plan")
else:
good("CRM commissions anti-invention · `a_confirmer` ⟺ `taux_pct is null` "
"sur les 5 évènements (cohérent)")
# (c) MANIFEST.counts recoupe le plan
n_conf = sum(1 for e in cta_ev if e.get("a_confirmer"))
if cta_counts.get("taux_a_confirmer") != n_conf:
bad(f"CRM commissions anti-invention · MANIFEST.counts.taux_a_confirmer="
f"{cta_counts.get('taux_a_confirmer')} ≠ {n_conf} (plan) → MANIFEST périmé")
elif cta_counts.get("evenements") != len(cta_ev):
bad(f"CRM commissions anti-invention · MANIFEST.counts.evenements="
f"{cta_counts.get('evenements')} ≠ {len(cta_ev)} (plan) → MANIFEST périmé")
else:
good(f"CRM commissions anti-invention · MANIFEST.counts recoupe le plan "
f"({n_conf} à confirmer / {len(cta_ev)} évènements)")
# (d) la PROMESSE #6 du README reflète l'état RÉEL de l'artefact (bidirectionnel)
all_null = all(e.get("taux_pct") is None and e.get("source") is None
and e.get("a_confirmer") is True for e in cta_ev)
cta_rd = read_norm(CM_RD)
if cta_rd is not None:
promesse = re.search(
r"`taux_pct:\s*null`\s*\+\s*`source:\s*null`\s*\+\s*"
r"`a_confirmer:\s*true`\s*pour\s*\*\*chaque\*\*", cta_rd, re.IGNORECASE)
if all_null:
if promesse is None:
bad(f"CRM commissions anti-invention · {CM_RD} — la promesse « `taux_pct: "
f"null` + `source: null` + `a_confirmer: true` pour **chaque** "
f"évènement » INTROUVABLE alors que l'artefact est bien tout-null "
f"→ la raison d'être du module ne doit pas disparaître (#6)")
else:
good(f"CRM commissions anti-invention · {CM_RD} — promesse « tout null "
f"pour chaque évènement » == artefact (les 5 évènements tout-null)")
else:
if promesse is not None:
bad(f"CRM commissions anti-invention · {CM_RD} — le README affirme "
f"« tout `null` pour **chaque** évènement » MAIS l'artefact porte "
f"des taux CONFIRMÉS → mettre à jour la §Anti-invention (promesse "
f"périmée · « vert trompeur »)")
else:
good(f"CRM commissions anti-invention · {CM_RD} — la promesse « tout "
f"null » a été retirée, cohérent avec des taux désormais confirmés")
# (e) la note d'auto-traçabilité du MANIFEST reste cohérente avec l'état
if all_null and "null" not in cta_note.lower():
bad(f"CRM commissions anti-invention · MANIFEST.note_taux ne mentionne plus "
f"`null` alors que tous les taux le sont → note périmée")
# ============================================================================
# CRM/Commissions · IDENTITÉ des RÔLES PAYÉS — appartenance au portail `ventes`
# + nom Frappe RÉSOLU (invariants #5/#6 du module · le « à quel rôle » du livrable).
# ----------------------------------------------------------------------------
# Le README pose la question fondatrice « quel évènement paie, à quel RÔLE, sur
# quel montant » (README:8-9) et promet en §Cross-cohérence (README:51-53) que
# chaque `role_id` commissionné « doit être résolu depuis rbac_50_roles.json …
# ET appartenir au portail `ventes` » (invariant CLI #5), son `erpnext_role_name`
# restant « cohérent avec RBAC » (invariant #6 · le « nom Frappe résolu » du
# README:36). C'est une SÉPARATION DES POUVOIRS : on ne commissionne QUE des rôles
# du portail ventes, jamais un rôle compta/construction/direction. Les blocs
# commissions ci-dessus gatent les états soumis, les champs de base et la promesse
# anti-taux — mais RIEN ne confronte cette appartenance-portail ni la résolution du
# nom Frappe à l'artefact. Dérive #6 la plus grave : (a) commissionner un rôle d'un
# AUTRE portail (payer un fiscaliste compta → la prose « portail `ventes` » devient
# un MENSONGE, pouvoirs mélangés) ; (b) un `role_id` FANTÔME absent du contrat (rôle
# inventé, l'invention même que #6 interdit) ; (c) un `erpnext_role_name` figé en dur
# qui dérive quand le contrat renomme le rôle (nom Frappe périmé → `Has Role` importé
# sur un rôle INEXISTANT côté VPS). Aucune suite tests/ (FONCTIONS de résolution/
# calcul, jamais la prose vs l'artefact) ne l'attrape ; ce n'est ni un lien, ni un
# terme interdit, ni un `out/` → invisible à check_docs/guard_constraints/artifacts.
# On RECOMPUTE depuis les artefacts byte-gatés (plan + contrat + MANIFEST · zéro dup
# du contrat du générateur #6) : chaque role_id du plan ∈ contrat, portail == un
# SINGLETON == le portail nommé au README == un portail_business, nom Frappe ==
# contrat, et MANIFEST.roles_rbac_utilises recoupe l'ensemble distinct réellement payé.
# ============================================================================
try:
cr_ev = load("crm/commissions/out/commission_plan.json")["evenements"]
_cr_rbac = load("rbac/rbac_50_roles.json")
cr_by = {r["id"]: r for r in _cr_rbac["roles"]}
cr_pbiz = set(_cr_rbac["portails_business"])
cr_man = load("crm/commissions/out/MANIFEST.json")
except (OSError, KeyError, ValueError, TypeError) as e:
bad(f"CRM commissions rôles · artefact illisible/inattendu : {e}"); cr_ev = None
if cr_ev is not None:
cr_ids = {e["role_id"] for e in cr_ev}
if not cr_ids:
bad("CRM commissions rôles · plan sans role_id → barème suspect")
else:
# (a) chaque role_id commissionné se RÉSOUT dans le contrat (jamais fantôme)
cr_phantom = sorted(i for i in cr_ids if i not in cr_by)
if cr_phantom:
bad(f"CRM commissions rôles · role_id(s) {cr_phantom} ABSENT(s) de "
f"rbac_50_roles.json → rôle INVENTÉ (invariant #5 · #6)")
else:
good(f"CRM commissions rôles · les {len(cr_ids)} role_id payés se résolvent "
f"tous dans le contrat RBAC")
# (b) nom Frappe RÉSOLU : plan.erpnext_role_name == contrat (invariant #6)
cr_badname = sorted(e["role_id"] for e in cr_ev
if e.get("erpnext_role_name")
!= cr_by[e["role_id"]]["erpnext_role_name"])
if cr_badname:
bad(f"CRM commissions rôles · erpnext_role_name du plan ≠ contrat pour "
f"{cr_badname} → nom Frappe périmé/en dur (invariant #6) → régénérer")
else:
good("CRM commissions rôles · erpnext_role_name == contrat pour chaque "
"évènement (nom Frappe résolu · invariant #6)")
# (c) appartenance-portail : SINGLETON == portail nommé au README == business
cr_ports = {cr_by[i]["portail"] for i in cr_ids}
if len(cr_ports) != 1:
bad(f"CRM commissions rôles · rôles payés répartis sur PLUSIEURS portails "
f"{sorted(cr_ports)} → séparation des pouvoirs rompue (invariant #5 : "
f"seul le portail ventes commissionne)")
else:
cr_port = next(iter(cr_ports))
if cr_port not in cr_pbiz:
bad(f"CRM commissions rôles · portail des rôles payés `{cr_port}` ∉ "
f"portails_business {sorted(cr_pbiz)} → portail fantôme")
else:
cr_rd = read_norm(CM_RD)
mrp = re.search(r"appartenir au portail `([a-z-]+)`", cr_rd or "")
if mrp is None:
bad(f"CRM commissions rôles · {CM_RD} — claim « appartenir au portail "
f"`…` » INTROUVABLE (attendu `{cr_port}`)")
elif mrp.group(1) != cr_port:
bad(f"CRM commissions rôles · {CM_RD} — dit portail `{mrp.group(1)}` "
f"MAIS les rôles payés sont du portail `{cr_port}` → régénérer le README")
else:
good(f"CRM commissions rôles · tous les rôles payés ∈ portail "
f"`{cr_port}` == README == portail_business (pouvoirs séparés)")
# (d) MANIFEST.roles_rbac_utilises recoupe l'ensemble distinct payé
cr_mru_pairs = {(r.get("role_id"), r.get("erpnext_role_name"))
for r in cr_man.get("roles_rbac_utilises", [])}
cr_ev_pairs = {(e["role_id"], cr_by[e["role_id"]]["erpnext_role_name"])
for e in cr_ev if e["role_id"] in cr_by}
if cr_mru_pairs != cr_ev_pairs:
miss = sorted(cr_ev_pairs - cr_mru_pairs)
extra = sorted(cr_mru_pairs - cr_ev_pairs)
bad(f"CRM commissions rôles · MANIFEST.roles_rbac_utilises ≠ rôles réellement "
f"payés" + (f" · absents={miss}" if miss else "")
+ (f" · en trop={extra}" if extra else "") + " → MANIFEST périmé")
elif cr_man.get("counts", {}).get("roles") != len(cr_ids):
bad(f"CRM commissions rôles · MANIFEST.counts.roles="
f"{cr_man.get('counts', {}).get('roles')} ≠ {len(cr_ids)} rôles distincts "
f"payés → MANIFEST périmé")
else:
good(f"CRM commissions rôles · MANIFEST.roles_rbac_utilises == {len(cr_ids)} "
f"rôles distincts payés (counts.roles cohérent)")
# ============================================================================
# CRM/Commissions · IDENTITÉ du CHAMP DEVISE — `devise_field` = le champ `devise`
# (`Select` USD/DOP · #10) du DocType, transcrit À LA MAIN au README:54 sans gate.
# ----------------------------------------------------------------------------
# Le générateur commissions_gen émet out/commission_plan.json (byte-gaté par
# check_artifacts ⇒ faisant autorité) : le plan porte la clé RACINE `devise_field`
# — le champ du Dossier Vente qui PORTE la devise d'un montant commissionné. Son
# invariant CLI #8 exige TROIS choses simultanées : `devise_field == "devise"`, le
# DocType Dossier Vente (doctype_spec.json, l'INPUT du générateur) porte bien un
# champ `devise` de fieldtype `Select`, et ses `options` == `USD`/`DOP` — les DEUX
# devises canoniques de CLAUDE.md #10 (« **USD + DOP** devises »). La §Cross-cohérence
# RÉÉNONCE le tout EN PROSE (README:54) : « `devise_field` = le champ `devise`
# (`Select` **USD/DOP** · #10) du DocType ». Les blocs commissions ci-dessus gatent
# les états soumis, les champs de base, les rôles payés et la promesse anti-taux —
# mais AUCUN ne touche ce couple champ↔devises. Dérive #6 : RENOMMER le champ
# (`devise`→`moneda`) dans le plan/DocType, changer son fieldtype (`Select`→`Data` :
# la contrainte d'énumération PERDUE, un montant en monnaie libre), ou altérer ses
# options (`USD/DOP`→`USD/EUR` : devise HORS #10, le calcul `base × taux`
# commissionnerait dans une monnaie non prévue) laisse la prose périmée EN SILENCE
# pendant que l'artefact/DocType dit autre chose → « vert trompeur » qu'aucune suite
# tests/ (FONCTIONS finance/résolution, jamais la prose vs artefact) n'attrape ; ce
# n'est ni un lien, ni un terme interdit, ni un `out/` byte-gaté du module → invisible
# à check_docs/guard_constraints. On RECOMPUTE depuis les artefacts byte-gatés + la
# contrainte constitutionnelle : (0) ANCRAGE — les 2 devises depuis CLAUDE.md #10
# (segment gras « USD + DOP ») ; (a) CROSS-ARTEFACT — plan.devise_field == "devise"
# == fieldname du champ DocType · fieldtype == "Select" · options (ORDONNÉES) ==
# devises #10 ; (b) PROSE — README:54 nomme le champ `devise`, le dit `Select`, cite
# USD/DOP == #10 et tague #10. Un claim absent échoue AUSSI (traçabilité #6).
# Direction plan/DocType/CLAUDE.md #10 → README.
# ============================================================================
try:
cd_plan = load("crm/commissions/out/commission_plan.json")
cd_dt = load("crm/dossier_vente/doctype_spec.json")
cd_cm = open("CLAUDE.md", encoding="utf-8").read()
except (OSError, KeyError, ValueError, TypeError) as e:
bad(f"CRM commissions devise · artefact/CLAUDE.md illisible/inattendu : {e}"); cd_plan = None
if cd_plan is not None:
# (0) ANCRAGE #10 : les 2 devises canoniques (segment gras « USD + DOP »)
cd_cur = None
cd_l10 = re.search(r"^10\.\s+(.+)$", cd_cm, re.M)
if cd_l10:
for seg in re.split(r"·", cd_l10.group(1)):
if "devise" in seg.lower():
mm = re.search(r"\*\*(.+?)\*\*", seg)
if mm:
cd_cur = [t.strip() for t in re.split(r"[+/]", mm.group(1)) if t.strip()]
if not cd_cur:
bad("CRM commissions devise · devises #10 (« **USD + DOP** ») INTROUVABLES dans "
"CLAUDE.md #10 → ancrage rompu")
else:
cd_field = None
for grp in cd_dt.get("field_groups", []):
for f in grp.get("fields", []):
if f.get("fieldname") == "devise":
cd_field = f
cd_pf = cd_plan.get("devise_field")
cd_opts = ([ln for ln in cd_field.get("options", "").split("\n") if ln]
if cd_field else None)
# (a) CROSS-ARTEFACT : plan ↔ DocType ↔ devises #10 (invariant CLI #8)
if cd_pf != "devise":
bad(f"CRM commissions devise · plan.devise_field={cd_pf!r} ≠ 'devise' → "
f"invariant #8 cassé (le champ porteur de la devise a changé)")
elif cd_field is None:
bad("CRM commissions devise · champ `devise` ABSENT du DocType Dossier Vente "
"→ invariant #8 (options USD/DOP invérifiables)")
elif cd_field.get("fieldtype") != "Select":
bad(f"CRM commissions devise · champ `devise` fieldtype={cd_field.get('fieldtype')!r} "
f"≠ 'Select' → énumération USD/DOP PERDUE (monnaie libre)")
elif cd_opts != cd_cur:
bad(f"CRM commissions devise · options du champ `devise` {cd_opts} ≠ devises #10 "
f"{cd_cur} (ordre inclus) → devise HORS CLAUDE.md #10")
else:
good(f"CRM commissions devise · plan.devise_field='devise' == DocType champ `devise` "
f"(Select {cd_opts}) == CLAUDE.md #10")
# (b) PROSE README:54 — champ · fieldtype · devises · tag #10
cd_rd = read_norm(CM_RD)
mpr = re.search(
r"`devise_field`\*\* = le champ `([a-z_]+)` \(`(\w+)` \*\*([A-Z/]+)\*\* · #10\)",
cd_rd or "")
if mpr is None:
bad(f"CRM commissions devise · {CM_RD} — claim « `devise_field` = le champ "
f"`devise` (`Select` **USD/DOP** · #10) » INTROUVABLE/malformé (traçabilité #6)")
else:
p_field, p_ftype = mpr.group(1), mpr.group(2)
p_cur = mpr.group(3).split("/")
errs = []
if p_field != "devise":
errs.append(f"champ `{p_field}` ≠ `devise`")
if p_ftype != "Select":
errs.append(f"type `{p_ftype}` ≠ `Select`")
if p_cur != cd_cur:
errs.append(f"devises {p_cur} ≠ #10 {cd_cur}")
if errs:
bad(f"CRM commissions devise · {CM_RD} — prose périmée : "
+ " · ".join(errs) + " → régénérer le README")
else:
good(f"CRM commissions devise · {CM_RD} — prose « champ `devise` "
f"(`Select` {'/'.join(p_cur)}) · #10 » == artefacts + CLAUDE.md #10")
# ============================================================================
# RBAC/fixtures_gen · le CATALOGUE des DocTypes `custom` à créer sur le VPS et la
# SÉPARATION DES POUVOIRS (le rôle unique porteur de `set_user_permissions`) —
# deux faits data-derived transcrits À LA MAIN dans le README sans AUCUN gate
# d'IDENTITÉ.
# ----------------------------------------------------------------------------
# Le générateur rbac_fixtures_gen émet out/MANIFEST.json et out/custom_docperm.json
# (byte-gatés par check_artifacts ⇒ faisant autorité) :
# • MANIFEST.custom_doctypes_a_confirmer = l'ensemble des DocTypes `custom: true`
# du contrat rbac_50_roles.json (ceux à CRÉER avant import — le reste est natif
# v15). Le README §« Application sur VPS » l'ÉNUMÈRE À LA MAIN, en nommant même
# le champ : « listés dans `MANIFEST.custom_doctypes_a_confirmer` (`Faisabilité`,
# `Publiciste Log`, `CONFOTUR Application`, `API Access`) ».
# • custom_docperm.json porte le flag `set_user_permissions`. Le CŒUR sécurité du
# module (« séparation des pouvoirs · défense en profondeur ») est que ce flag
# n'est émis que pour UN SEUL rôle — le README:48 dit « n'est émis que pour le
# rôle **RBAC Admin** ». C'est l'ensemble {role | set_user_permissions=1}.
# Le bloc racine ne gate QUE le COMPTE de tests fixtures (« 50 rôles / 116 DocPerm »
# via l'agrégat RBAC de l'apply_plan) — jamais l'IDENTITÉ de ces deux ensembles.
# Aucun gate ne les comparait à l'artefact : AJOUTER un DocType `custom` au contrat
# (le MANIFEST rebâtit 5 entrées) OU RENOMMER/ÉCHANGER l'un des 4 laisse la prose à
# ses 4 noms périmés ; PROMOUVOIR un 2ᵉ rôle porteur de `set_user_permissions` (ou
# renommer RBAC Admin) laisse la prose « un seul rôle RBAC Admin » fausse — « vert
# trompeur » qu'aucune suite tests/ (qui teste des FONCTIONS de mapping, pas la
# prose du README) n'attrape. Même patron que l'énumération des onglets Mobile, des
# brouillons CRM ou des open items de l'audit 5D. On RECOMPUTE chaque ensemble
# depuis SON artefact byte-gaté (zéro duplication du contrat du générateur #6) et on
# exige que la prose l'énumère/le nomme EXACTEMENT (absent ET en trop via set-diff ·
# accents/casse normalisés). Cohérence croisée en bonus : le catalogue est NON VIDE,
# sans doublon et TRIÉ (byte-déterminisme du générateur) ; la séparation des pouvoirs
# est un SINGLETON (ni vide — la garde serait vacante — ni multiple — élévation de
# privilège). Un claim absent échoue AUSSI (traçabilité).
# ============================================================================
FX_RD = f"{D}/rbac/fixtures_gen/README.md"
def _fx_acc(s):
return "".join(c for c in unicodedata.normalize("NFKD", s)
if unicodedata.category(c) != "Mn").strip().lower()
try:
fx_cdt = load("rbac/fixtures_gen/out/MANIFEST.json")["custom_doctypes_a_confirmer"]
fx_perm = load("rbac/fixtures_gen/out/custom_docperm.json")
fx_sup = sorted({p["role"] for p in fx_perm if p.get("set_user_permissions")})
except (OSError, KeyError, ValueError, TypeError) as e:
bad(f"RBAC fixtures · artefact illisible/inattendu : {e}"); fx_cdt = None
if fx_cdt is not None:
# bonus : le catalogue est un vrai ENSEMBLE trié non vide (byte-déterminisme)
if not fx_cdt:
bad("RBAC fixtures · custom_doctypes_a_confirmer VIDE → aucun DocType custom "
"à créer ? (contrat suspect)")
elif fx_cdt != sorted(set(fx_cdt)):
bad(f"RBAC fixtures · custom_doctypes_a_confirmer {fx_cdt} non trié/dédupliqué "
f"→ générateur non déterministe (attendu {sorted(set(fx_cdt))})")
else:
good(f"RBAC fixtures · catalogue custom {fx_cdt} — ensemble trié, sans doublon")
# bonus : séparation des pouvoirs — un SEUL rôle porte set_user_permissions
if len(fx_sup) != 1:
bad(f"RBAC fixtures · set_user_permissions porté par {fx_sup} (attendu 1 seul "
f"rôle) → séparation des pouvoirs cassée (élévation de privilège possible)")
else:
good(f"RBAC fixtures · set_user_permissions — singleton {fx_sup[0]!r} "
f"(défense en profondeur intacte)")
fx_rd = read_norm(FX_RD)
if fx_rd is not None:
# 1) README — énumération du catalogue custom (parenthèse après le champ nommé)
me = re.search(r"custom_doctypes_a_confirmer`?\s*\(([^)]*)\)", fx_rd)
if me is None:
bad(f"RBAC fixtures · {FX_RD} — énumération du catalogue custom INTROUVABLE "
f"(attendu {sorted(fx_cdt)})")
else:
got = {_fx_acc(t) for t in re.findall(r"`([^`]+)`", me.group(1))}
exp = {_fx_acc(x) for x in fx_cdt}
if got == exp:
good(f"RBAC fixtures · {FX_RD} — catalogue custom {sorted(got)} == "
f"MANIFEST.custom_doctypes_a_confirmer")
else:
miss = sorted(exp - got); extra = sorted(got - exp)
detail = (f" · absents={miss}" if miss else "") + \
(f" · en trop={extra}" if extra else "")
bad(f"RBAC fixtures · {FX_RD} — énumère {sorted(got)} MAIS MANIFEST "
f"{sorted(exp)}{detail} → régénérer le README")
# 2) README:48 — le rôle NOMMÉ porteur de set_user_permissions (séparation)
if len(fx_sup) == 1:
mr = re.search(r"émis que pour le rôle \*\*([^*]+)\*\*", fx_rd)
if mr is None:
bad(f"RBAC fixtures · {FX_RD} — claim « set_user_permissions n'est émis "
f"que pour le rôle ** ** » INTROUVABLE (attendu {fx_sup[0]!r})")
elif _fx_acc(mr.group(1)) in _fx_acc(fx_sup[0]):
good(f"RBAC fixtures · {FX_RD} — rôle nommé « {mr.group(1).strip()} » ⊆ "
f"{fx_sup[0]!r} (le seul porteur du flag)")
else:
bad(f"RBAC fixtures · {FX_RD} — nomme « {mr.group(1).strip()} » MAIS "
f"set_user_permissions porté par {fx_sup[0]!r} → régénérer le README")
# 3) RBAC_50_ROLES_SPEC.md §6 — 2ᵉ surface prose du MÊME catalogue custom :
# « **DocTypes `custom: true`** (`Faisabilité`, `Publiciste Log`, … ) ». Le
# bloc apply_plan ne gate que le COMPTE (len custom_doctypes), le point (1)
# ci-dessus que le README fixtures — la SPEC §6 restait AVEUGLE : renommer/
# retirer un DocType `custom` du contrat (le MANIFEST suit, byte-gaté) y
# laisse les 4 noms périmés en silence, comme la 2ᵉ surface CONFOTUR/entités.
if spec_rd is not None:
m6 = re.search(r"DocTypes `custom: true`\*\* \(([^)]*)\)", spec_rd)
if m6 is None:
bad(f"RBAC fixtures · {SPEC_RD} §6 — énumération « DocTypes `custom: "
f"true` (…) » INTROUVABLE (attendu {sorted(fx_cdt)} · anti-évaporation #6)")
else:
got6 = {_fx_acc(t) for t in re.findall(r"`([^`]+)`", m6.group(1))}
exp6 = {_fx_acc(x) for x in fx_cdt}
if got6 == exp6:
good(f"RBAC fixtures · {SPEC_RD} §6 — catalogue custom {sorted(got6)} "
f"== MANIFEST.custom_doctypes_a_confirmer")
else:
miss6 = sorted(exp6 - got6); extra6 = sorted(got6 - exp6)
detail6 = (f" · absents={miss6}" if miss6 else "") + \
(f" · en trop={extra6}" if extra6 else "")
bad(f"RBAC fixtures · {SPEC_RD} §6 — énumère {sorted(got6)} MAIS "
f"MANIFEST {sorted(exp6)}{detail6} → régénérer la SPEC")
# 4) RBAC_50_ROLES_SPEC.md §2 — encadré « Défense en profondeur » : 3ᵉ surface prose
# du MÊME fait de sécurité. Les points (2)/(3) ancrent la prose du README fixtures
# (« émis que pour le rôle **NAME** ») + le catalogue §6 ; le §2 « Modèle RBAC »
# ferme sur « **réservé** au seul rôle `<NAME>` (`scope_donnees = groupe`,
# `famille = plateforme`) — séparation des pouvoirs vérifiée par test automatisé » :
# TROIS attributs (NOM · scope · famille) qu'AUCUN gate ne touchait. Le test
# `test_rbac::test_set_user_permissions_reserve_admin` impose scope/famille à TOUT
# détenteur mais JAMAIS le NOM du rôle ni cette prose §2 → renommer le détenteur
# (contrat + régénération fixtures/out + audit 4Big) laisse le §2 citer un rôle
# DISPARU, VERT (check_artifacts ne voit qu'un HASH de TAILLE du doc, la SPEC n'est
# pas un out/*.json). On RÉUTILISE fx_sup (singleton déjà byte-gaté ci-dessus · zéro
# re-dérivation de l'unicité #5) comme NOM-autorité et src_roles (contrat byte-gaté)
# pour scope/famille. La glose « (gestion des habilitations) » N'est PAS gatée.
if spec_rd is not None and len(fx_sup) == 1:
_su_hn = fx_sup[0]
_su_role = next((r for r in (src_roles or [])
if r.get("erpnext_role_name") == _su_hn), None)
_su_def = re.search(r"\*\*Défense en profondeur\*\*(.*?)"
r"vérifiée par test automatis", spec_rd)
if _su_def is None:
bad(f"RBAC fixtures · {SPEC_RD} §2 — encadré « **Défense en profondeur** … "
f"vérifiée par test automatisé » INTROUVABLE (anti-évaporation #6)")
else:
_su_reg = _su_def.group(1)
_su_toks = re.findall(r"`([^`]+)`", _su_reg)
# ancrage : le pouvoir lui-même doit être nommé dans l'encadré
if "set_user_permissions" not in _su_toks:
bad(f"RBAC fixtures · {SPEC_RD} §2 — encadré ne nomme pas "
f"`set_user_permissions` → mal ancré")
# (a) NOM du détenteur cité == le seul porteur du flag (byte-gaté)
if _fx_acc(_su_hn) in {_fx_acc(t) for t in _su_toks}:
good(f"RBAC fixtures · {SPEC_RD} §2 — détenteur nommé « {_su_hn} » == le "
f"seul porteur de set_user_permissions (custom_docperm.json)")
else:
_su_named = [t for t in _su_toks
if "=" not in t and t != "set_user_permissions"]
bad(f"RBAC fixtures · {SPEC_RD} §2 — nomme {_su_named} MAIS "
f"set_user_permissions réservé à {_su_hn!r} → régénérer la SPEC "
f"(rôle renommé côté contrat ?)")
# (b)/(c) scope + famille cités == attributs du détenteur (contrat byte-gaté)
if _su_role is None:
bad(f"RBAC fixtures · {SPEC_RD} §2 — détenteur {_su_hn!r} INTROUVABLE dans "
f"rbac_50_roles.json (fixtures ↔ contrat désynchronisés)")
else:
for _su_lbl in ("scope_donnees", "famille"):
_su_m = re.search(rf"{_su_lbl}\s*=\s*(\w+)", _su_reg)
_su_want = _su_role.get(_su_lbl)
if _su_m is None:
bad(f"RBAC fixtures · {SPEC_RD} §2 — `{_su_lbl} = …` INTROUVABLE "
f"dans l'encadré")
elif _su_m.group(1) == _su_want:
good(f"RBAC fixtures · {SPEC_RD} §2 — {_su_lbl} `{_su_m.group(1)}` "
f"== détenteur (rbac_50_roles.json)")
else:
bad(f"RBAC fixtures · {SPEC_RD} §2 — encadré dit {_su_lbl}="
f"`{_su_m.group(1)}` MAIS le détenteur {_su_hn!r} a "
f"`{_su_want}` → aligner sur le contrat")
# 5) §6 row « Séparation des pouvoirs » cite le pouvoir réservé (ancre présente)
_su_m6 = re.search(r"## 6\..*?(?=## 7\.)", spec_rd)
if _su_m6 and "Séparation des pouvoirs" in _su_m6.group(0) \
and "set_user_permissions" in _su_m6.group(0):
good(f"RBAC fixtures · {SPEC_RD} §6 — row « Séparation des pouvoirs » cite "
f"bien `set_user_permissions` réservé (ancre présente)")
else:
bad(f"RBAC fixtures · {SPEC_RD} §6 — row « Séparation des pouvoirs » / "
f"`set_user_permissions` réservé INTROUVABLE → ancre évaporée (#6)")
# ============================================================================
# SEO · la COMPOSITION du graphe schema.org (JSON-LD) — un nœud racine
# `Organization` + une `Residence` PAR PROJET — décrite EN PROSE dans le README
# sans AUCUN gate d'IDENTITÉ.
# ----------------------------------------------------------------------------
# Le générateur SEO émet out/seo_schema_org.json (byte-gaté par check_artifacts ⇒
# faisant autorité). Son `@graph` est l'ENSEMBLE des nœuds JSON-LD : exactement UN
# nœud racine `Organization` (@id …#organization) + un listing PAR PROJET, tous du
# même @type. Le README:16 décrit cette structure À LA MAIN : « graphe JSON-LD
# (`Organization` + une `Residence` par projet) ». Le bloc SEO existant ne gate que
# le COMPTE de mots-clés (keywords_total + ventilation par langue) — jamais les
# @type du graphe ni la bijection un-listing-par-projet. Aucun gate ne comparait
# ces TYPES à l'artefact : RENOMMER le @type des listings (Residence → Apartment,
# rango schema.org) OU AJOUTER un 3ᵉ type de nœud (Offer/Product) OU casser la
# bijection (2 nœuds pour un projet) ferait dériver la prose en silence pendant que
# le graphe byte-gaté dit autre chose — « vert trompeur » qu'aucune suite tests/
# (qui teste des FONCTIONS de génération, pas la prose du README) n'attrape. Même
# patron que l'énumération des onglets Mobile ou du catalogue custom RBAC. On
# RECOMPUTE l'ENSEMBLE des @type depuis SON artefact byte-gaté (zéro duplication du
# contrat du générateur #6) et on exige que la prose les nomme EXACTEMENT (absent ET
# en trop via set-diff). Cohérences croisées en bonus : le nœud racine Organization
# est un SINGLETON ; les listings sont uniformes (un seul @type) et en BIJECTION avec
# les projets (listings == projects == schema_org_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")
# ============================================================================
# Faisabilité/generator (2e surface du MÊME module) · l'ORACLE D'ENFORCEMENT
# `_CANONICAL_MARKERS` + les DEUX docstrings du scorer + l'oracle du TEST — trois
# copies DE PLUS des 6 marqueurs canoniques, ancrées sur CLAUDE.md #9/#10.
# ----------------------------------------------------------------------------
# Le bloc ci-dessus ancre model.py `CANONICAL` + le README à CLAUDE.md #9/#10.
# Restent, dans le MÊME module, TROIS surfaces data-derived qui RECOPIENT les
# mêmes 6 marqueurs, toutes NON gatées :
# (c) genlib/renderer.py `_CANONICAL_MARKERS` — la liste que `check_canonical()`
# PARCOURT pour PROUVER que le rendu du volet ingénierie contient les 6
# marqueurs (l'oracle de l'« axe 4 honnêteté »). C'est l'enforcement même :
# si cette liste DÉRIVE de CANONICAL (donc de #9/#10), le contrôle devient
# FAUX — soit il exige un marqueur périmé (« Cardnet » alors que le rendu,
# issu d'un CANONICAL mis à jour, porterait « Azul » → faux rouge), soit on
# en retire un et le contrôle NE VÉRIFIE PLUS RIEN (vert vacux).
# (d) genlib/scorer.py — DEUX docstrings (barème axe 4 en tête · fonction
# `_score_parametres_canoniques`) énumèrent « 3 % / 8.5 % / 52 % · USD+DOP ·
# Letter US · Cardnet ». Prose recopiée, jamais confrontée à #9/#10.
# (e) tests/test_generator.py — la liste `for marker in [...]` : l'oracle du
# test lui-même. Le commentaire du bloc ci-dessus (« ces marqueurs sont
# HARDCODÉS DANS LE TEST, une copie de plus, JAMAIS comparée à CLAUDE.md »)
# NOMMAIT ce défaut sans le gater — on ferme la boucle ici.
# La suite tests/ teste des FONCTIONS (dont `check_canonical` — AVEC son propre
# oracle hardcodé), jamais l'ancrage à CLAUDE.md. Même classe EXACTE que le tuple
# d'oracle du test publiciste/branding ancré sur #4. On RÉUTILISE `exp` (6 valeurs
# recomputées de #9/#10 ci-dessus · zéro duplication) : les LISTES (c)/(e) sont
# parsées puis comparées à l'ensemble EXACT (set-diff : absent ET en trop) ; les
# DOCSTRINGS (d) doivent porter CHAQUE valeur normalisée (drift d'un marqueur ⇒
# ROUGE). Un claim/ligne absent échoue AUSSI (traçabilité).
# ============================================================================
if exp is not None:
_want6 = {_pnorm(exp[k]) for k in _num_keys} | {_ai(exp[k]) for k in _txt_keys}
def _norm_marker(tok):
p = _pct(tok)
return _pnorm(p) if p is not None else _ai(tok)
def _list_vs_want6(path, var_re, label):
try:
src = open(path, encoding="utf-8").read()
m = re.search(var_re, src, re.S)
items = ast.literal_eval(m.group(1))
except (OSError, ValueError, SyntaxError, AttributeError) as e:
bad(f"Faisabilité canoniques · {path} {label} illisible/inattendu : {e}")
return
got = {_norm_marker(x) for x in items}
if len(got) != len(items):
bad(f"Faisabilité canoniques · {path} — {label} contient un DOUBLON ({items})")
elif got == _want6:
good(f"Faisabilité canoniques · {path} — {label} == CLAUDE.md #9/#10 (6 marqueurs)")
else:
miss = sorted(_want6 - got); extra = sorted(got - _want6)
detail = (f" · absents={miss}" if miss else "") + \
(f" · en trop={extra}" if extra else "")
bad(f"Faisabilité canoniques · {path} — {label} {sorted(got)} MAIS "
f"CLAUDE.md {sorted(_want6)}{detail} → aligner (ou CLAUDE.md)")
# (c) l'oracle d'enforcement renderer._CANONICAL_MARKERS
_list_vs_want6(f"{D}/faisabilite/generator/genlib/renderer.py",
r"_CANONICAL_MARKERS\s*=\s*(\[.*?\])",
"_CANONICAL_MARKERS (oracle check_canonical)")
# (e) l'oracle du test tests/test_generator.py (for marker in [...])
_list_vs_want6(f"{D}/faisabilite/generator/tests/test_generator.py",
r"for marker in (\[[^\]]*\])",
"oracle du test check_canonical")
# (d) les DEUX docstrings du scorer : chaque valeur normalisée présente dans la ligne
SC = f"{D}/faisabilite/generator/genlib/scorer.py"
try:
sc_lines = open(SC, encoding="utf-8").read().splitlines()
except OSError as e:
bad(f"Faisabilité canoniques · {SC} illisible : {e}"); sc_lines = None
if sc_lines is not None:
# ligne d'énumération = contient un « % » ET « USD » (barème axe 4 + fonction)
enum = [ln for ln in sc_lines if "%" in ln and "USD" in ln]
if len(enum) < 2:
bad(f"Faisabilité canoniques · {SC} — attendu ≥2 énumérations des 6 marqueurs "
f"(barème axe 4 + _score_parametres_canoniques), trouvé {len(enum)}")
else:
for ln in enum:
ai = _ai(ln) # espaces/accents retirés ⇒ « USD+DOP » == « USD + DOP »
miss = sorted(v for v in _want6 if v not in ai)
if miss:
bad(f"Faisabilité canoniques · {SC} — docstring « {ln.strip()[:56]}… » "
f"n'énumère plus {miss} (attendu les 6 de CLAUDE.md #9/#10)")
else:
good(f"Faisabilité canoniques · {SC} — docstring énumère les 6 marqueurs "
f"== CLAUDE.md #9/#10")
# ============================================================================
# Fiches agents · CITATION des constantes CLAUDE.md #10 (devises/format/paiement)
# — même classe d'ANCRAGE que le bloc « paramètres canoniques » ci-dessus, mais
# sur une SURFACE distincte : la prose « Anti-invention » des fiches agents.
# ----------------------------------------------------------------------------
# Deux fiches RECOPIENT à la main les valeurs canoniques de CLAUDE.md #10
# (**USD + DOP** devises · **Letter US** format · **Cardnet** paiements, pas
# Stripe) en AFFIRMANT « cités depuis CLAUDE.md #10, jamais réinventés » :
# • 03_agents/erpnext_backend/AGENT.md:57 — devises **USD + DOP**, format
# **Letter US**, paiements **Cardnet** (pas Stripe)
# • 03_agents/crm/AGENT.md:37 — devises **USD + DOP** et format **Letter US**
# La prose PRÉTEND être une CITATION de #10, mais AUCUN gate ne la liait à #10 :
# le bloc « paramètres canoniques » ci-dessus n'ancre QUE genlib/model.py
# CANONICAL + le README generator — AVEUGLE à ces deux fiches. Piège #6 : RENOMMER
# le prestataire de paiement dans CLAUDE.md #10 (`Cardnet` → `Azul`) ou changer une
# devise/format laisse ces citations PÉRIMÉES en silence, contredisant le mandat
# pendant que la prose se réclame de « CLAUDE.md #10 » — « vert trompeur » qu'aucune
# suite tests/ (FONCTIONS, jamais l'ancre à CLAUDE.md) n'attrape. On RÉUTILISE les
# valeurs `exp` déjà recomputées depuis #10 (zéro duplication) + le prestataire
# EXCLU (`(pas X)`), et on exige de CHAQUE fiche les tokens en gras qu'elle cite
# EXACTEMENT + l'ancre « CLAUDE.md #10 ». Chaque fiche cite un SOUS-ENSEMBLE
# distinct (crm omet le paiement) → gate par-fiche ce que la fiche énonce.
# ============================================================================
if exp is not None:
_m_excl = re.search(r"\(pas\s+(.+?)\)", l10.group(1))
exp_excl = _m_excl.group(1).strip() if _m_excl else None
_lab10 = {"devises": "devises", "format_doc": "format", "paiement": "paiement"}
# (fiche, [clés #10 citées], cite l'exclusion « (pas X) » ?, libellé)
FICHE10 = [
("03_agents/erpnext_backend/AGENT.md",
["devises", "format_doc", "paiement"], True, "fiche ERPNext Backend:57"),
("03_agents/crm/AGENT.md",
["devises", "format_doc"], False, "fiche CRM:37"),
]
for path, keys, cites_excl, label in FICHE10:
txt = read_norm(path)
if txt is None:
continue
# ancrage : la citation DOIT se réclamer de CLAUDE.md #10
if not re.search(r"CLAUDE\.md #10", txt):
bad(f"Fiches #10 · {label} ({path}) — ancre « CLAUDE.md #10 » "
f"INTROUVABLE → la citation ne se réclame plus de la contrainte")
continue
drift = []
for k in keys:
# token en gras **<valeur>** == valeur recomputée de #10 (casse tolérée)
if not re.search(r"\*\*\s*" + re.escape(exp[k]) + r"\s*\*\*",
txt, re.IGNORECASE):
drift.append(f"{_lab10[k]} attendu **{exp[k]}**")
if cites_excl and exp_excl is not None:
if not re.search(r"\(pas\s+" + re.escape(exp_excl) + r"\)",
txt, re.IGNORECASE):
drift.append(f"exclusion attendue « (pas {exp_excl}) »")
if drift:
bad(f"Fiches #10 · {label} ({path}) — citation DÉRIVE de CLAUDE.md #10 : "
f"{' · '.join(drift)} → aligner la fiche (ou CLAUDE.md)")
else:
_cited = " · ".join(f"**{exp[k]}**" for k in keys) + \
(f" · (pas {exp_excl})" if cites_excl and exp_excl else "")
good(f"Fiches #10 · {label} — cite {_cited} == CLAUDE.md #10")
# ============================================================================
# Faisabilité/generator · DOMAINE DES CODES PROJET (P01..P09) ancré sur §Projets
# — même classe d'ANCRAGE que le CONTRAT DE FORMAT e-NCF (`ncf.py`) : une GARDE
# DE VALIDATION en CODE qui encode un domaine constitutionnel SANS l'ancrer.
# ----------------------------------------------------------------------------
# Le générateur 4 volets (Livrable Sprint 3 · « P07 Aqua Terra <1h ») REFUSE tout
# brief dont le `projet` n'est pas un code canonique. Le DOMAINE des codes valides
# EST exactement l'ensemble des projets de CLAUDE.md §Projets (P01..P09). Il est
# recopié À LA MAIN à QUATRE surfaces, AUCUNE ancrée sur §Projets, toutes
# invisibles à `check_artifacts` (aucune n'est un `out/*.json`) :
# (a) `faisabilite/generator/faisabilite_gen.py::_validate_brief` — la GARDE
# runtime `code[:2] == "P0" and code[2] in "123456789"` (le domaine effectif)
# + le message `attendu P01..P09` (les bornes) ;
# (b) `faisabilite/version.schema.json` — `properties.projet.pattern "^P0[1-9]$"`
# (validation JSON du `version.json` par projet) + sa `description` ;
# (c) `faisabilite/generator/brief.schema.json` — même `pattern "^P0[1-9]$"`
# (validation JSON du brief d'entrée) + sa `description`.
# « Vert trompeur » : si CLAUDE.md §Projets AJOUTE un 10ᵉ projet (`P10 …`, évènement
# de portefeuille réel), la GARDE et les DEUX patterns REJETTENT `P10` comme
# « invalide (attendu P01..P09) » → le générateur REFUSE de scaffolder un projet
# LÉGITIMEMENT mandaté (bug d'exécution) pendant que la suite `tests/` reste VERTE
# (ses oracles P01/P02 sont self-consistants, jamais comparés à §Projets). Ni
# `check_artifacts` (schémas/`.py` ne sont pas des `out/*.json`) ni `tests/` (teste
# la FONCTION `_validate_brief`, pas son ANCRAGE) ne mordent. Symétriquement,
# RETIRER un projet de §Projets laisse la garde trop PERMISSIVE (accepte un code
# démobilisé). On RE-DÉRIVE le domaine de §Projets et on exige que chaque surface
# accepte EXACTEMENT cet ensemble (test COMPORTEMENTAL du pattern, robuste à la
# forme de la classe de caractères), + la déclaration de l'ancrage « §Projets ».
# ----------------------------------------------------------------------------
try:
_fcm = open("CLAUDE.md", encoding="utf-8").read()
_fmp = re.search(r"^## Projets\s*\n(.+)$", _fcm, re.M)
if _fmp is None:
raise ValueError("section « ## Projets » INTROUVABLE")
fpj_codes = sorted(set(re.findall(r"\bP\d{2,}\b", _fmp.group(1))))
if not fpj_codes:
raise ValueError("aucun code projet PNN parsé")
except (OSError, ValueError) as e:
bad(f"Faisabilité domaine · CLAUDE.md §Projets illisible/inattendu : {e}")
fpj_codes = None
if fpj_codes is not None:
fpj_set = set(fpj_codes); fpj_mn, fpj_mx = fpj_codes[0], fpj_codes[-1]
# (a) la garde runtime _validate_brief : préfixe + ensemble de chiffres + message
FG = f"{D}/faisabilite/generator/faisabilite_gen.py"
try:
_fg = open(FG, encoding="utf-8").read()
except OSError as e:
_fg = None; bad(f"Faisabilité domaine · {FG} illisible : {e}")
if _fg is not None:
_mpre = re.search(r'code\[:2\]\s*==\s*"([^"]+)"', _fg)
_mdig = re.search(r'code\[2\]\s*in\s*"([^"]+)"', _fg)
if not (_mpre and _mdig):
bad("Faisabilité domaine · _validate_brief : préfixe/ensemble-chiffres du "
"code projet INTROUVABLE (ancre morte) → la garde a changé de forme")
else:
_accept = {f"{_mpre.group(1)}{d}" for d in _mdig.group(1)}
_miss = sorted(fpj_set - _accept); _extra = sorted(_accept - fpj_set)
if _miss or _extra:
bad(f"Faisabilité domaine · _validate_brief accepte {sorted(_accept)} ≠ "
f"CLAUDE.md §Projets{' · manquants=' + str(_miss) if _miss else ''}"
f"{' · en trop=' + str(_extra) if _extra else ''} → aligner la garde "
f"(ou CLAUDE.md §Projets)")
else:
good(f"Faisabilité domaine · _validate_brief accepte exactement les "
f"{len(_accept)} codes {fpj_mn}..{fpj_mx} de CLAUDE.md §Projets")
_mmsg = re.search(r'brief\.projet invalide \(attendu ([^)]+)\)', _fg)
if not _mmsg:
bad("Faisabilité domaine · message « brief.projet invalide (attendu …) » "
"INTROUVABLE (ancre morte)")
elif fpj_mn not in _mmsg.group(1) or fpj_mx not in _mmsg.group(1):
bad(f"Faisabilité domaine · message « attendu {_mmsg.group(1)} » ne cite pas "
f"les bornes {fpj_mn}..{fpj_mx} de CLAUDE.md §Projets")
else:
good(f"Faisabilité domaine · message d'erreur cite les bornes "
f"{fpj_mn}..{fpj_mx} == CLAUDE.md §Projets")
# (b)+(c) les 2 schémas JSON : le pattern accepte EXACTEMENT §Projets (comportemental)
_probe = fpj_set | {"P00", "P10", "P99", "P0", "PXX", "P1", "P010", "Q01"}
for _rel in ("version.schema.json", "generator/brief.schema.json"):
SP = f"{D}/faisabilite/{_rel}"
try:
_sj = json.load(open(SP, encoding="utf-8"))
except (OSError, ValueError) as e:
bad(f"Faisabilité domaine · {_rel} illisible/inattendu : {e}"); continue
_prop = (_sj.get("properties") or {}).get("projet") or {}
_pat = _prop.get("pattern")
if not _pat:
bad(f"Faisabilité domaine · {_rel} : properties.projet.pattern INTROUVABLE")
else:
try:
_rx = re.compile(_pat)
except re.error as e:
bad(f"Faisabilité domaine · {_rel} pattern {_pat!r} illégal : {e}"); _rx = None
if _rx is not None:
_acc = {c for c in _probe if _rx.fullmatch(c)}
_miss = sorted(fpj_set - _acc); _extra = sorted(_acc - fpj_set)
if _miss or _extra:
bad(f"Faisabilité domaine · {_rel} pattern {_pat!r} accepte {sorted(_acc)}"
f" ≠ CLAUDE.md §Projets{' · manquants=' + str(_miss) if _miss else ''}"
f"{' · en trop=' + str(_extra) if _extra else ''}")
else:
good(f"Faisabilité domaine · {_rel} pattern accepte exactement les "
f"{len(fpj_set)} codes de §Projets")
_desc = _prop.get("description", "") or ""
if not re.search(r"CLAUDE\.md\s*§?\s*Projets", _desc):
bad(f"Faisabilité domaine · {_rel} : description projet ne DÉCLARE pas "
f"l'ancrage « CLAUDE.md §Projets » : {_desc!r}")
elif fpj_mn not in _desc or fpj_mx not in _desc:
bad(f"Faisabilité domaine · {_rel} : description ne cite pas les bornes "
f"{fpj_mn}..{fpj_mx} : {_desc!r}")
else:
good(f"Faisabilité domaine · {_rel} description ancre {fpj_mn}..{fpj_mx} "
f"sur CLAUDE.md §Projets")
# ============================================================================
# TEMPLATE FAISABILITÉ CANONIQUE v1.0 · PARAMÈTRES #9/#10 — même classe d'ANCRAGE
# que « paramètres canoniques » (generator) et « Fiches #10 » ci-dessus, sur une
# SURFACE encore distincte : le DOCUMENT-MODÈLE « CANONIQUE » que TOUTE faisabilité
# en production DOIT reproduire — `05_deliverables_mvp/faisabilite/
# TEMPLATE_FAISABILITE_CANONIQUE_v1.0.md` (Livrable Sprint 1 · statut « CANONIQUE :
# toute faisabilité en production DOIT s'y conformer »).
# ----------------------------------------------------------------------------
# Ce template recopie À LA MAIN les 6 valeurs de CLAUDE.md #9/#10 à CINQ surfaces
# structurées, TOUTES se réclamant de « contrainte #9 »/« #10 » :
# (1) l.28 intro §0 « frais édition **3 %** · marketing **8.5 %** · point
# d'équilibre **52 %** pré-vente » (les 3 pourcentages #9, associés par mot-clé) ;
# (2) l.144-146 §5.2 — 3 lignes de table « **N %** (fixe · contrainte #9) »
# (frais/marketing/équilibre) ;
# (3) l.29 intro §0 « **USD + DOP** · format **Letter US** · paiements **Cardnet** »
# (les 3 valeurs #10) ;
# (4) l.149-151 §5.2 — 3 lignes de table « **valeur** (… contrainte #10) » (devises ·
# format · paiement, la ligne paiement citant l'exclusion « ❌ pas-X ») ;
# (5) l.196 §7 checklist « 3 % / 8.5 % / 52 % · USD+DOP · Letter US · Cardnet présents »
# (l'énumération des 6 marqueurs, comme la prose README:28 du generator).
# AUCUN gate ne liait ce fichier à CLAUDE.md (grep : absent de tous les `ci/*.sh`) :
# check_artifacts est aveugle (ce n'est pas un `out/*.json`), check_docs/guard aussi.
# Piège #6 « vert trompeur » : si CLAUDE.md #9 passe 52 %→55 % (ou #10 renomme le
# prestataire) et que les modules s'alignent, ce TEMPLATE — la référence que le
# générateur 4 volets, le Publiciste et la régénération batch S4 (§8) sont censés
# suivre — reste PÉRIMÉ en silence, imposant à TOUTES les faisabilités un paramètre
# qui CONTREDIT le mandat, pendant qu'il se réclame « contrainte #9/#10 ». Aucune
# suite tests/ (FONCTIONS, jamais l'ancre à CLAUDE.md) ne l'attrape. On RÉUTILISE
# `exp` (6 valeurs recomputées de #9/#10 · zéro duplication) + `exp_excl` (prestataire
# exclu) et on exige que CHACUNE des 5 surfaces == CLAUDE.md. Une surface INTROUVABLE
# échoue AUSSI (le recensement qui s'évapore est lui-même une régression #6).
# ============================================================================
if exp is not None:
TPL = f"{D}/faisabilite/TEMPLATE_FAISABILITE_CANONIQUE_v1.0.md"
tpl = read_norm(TPL)
if tpl is None:
bad(f"Template canonique · {TPL} illisible")
else:
_pct_kw_map = (("frais_edition_pct", r"frais", "frais édition"),
("marketing_pct", r"marketing", "marketing"),
("point_equilibre_pct", r"[ée]quilibre", "point d'équilibre"))
# (1) intro §0 (l.28) · les 3 pourcentages #9 associés par mot-clé
m1 = re.search(r"#9 Param[^→]*→(.+?)pr[ée]-vente", tpl)
if m1 is None:
bad("Template canonique · (1) intro #9 (l.28) INTROUVABLE "
"(attendu frais/marketing/équilibre en gras)")
else:
seg1 = m1.group(1); drift = []
for key, kw, lab in _pct_kw_map:
mm = re.search(kw + r"[^*]*?\*\*\s*(\d+(?:\.\d+)?)\s*%", seg1)
got = float(mm.group(1)) if mm else None
if got != exp[key]:
drift.append(f"{lab}={_pnorm(got) if got is not None else '∅'} "
f"(attendu {_pnorm(exp[key])})")
if drift:
bad("Template canonique · (1) intro #9 (l.28) DÉRIVE de CLAUDE.md #9 : "
f"{' · '.join(drift)} → aligner le template (ou CLAUDE.md)")
else:
good("Template canonique · (1) intro #9 (l.28) — 3 pourcentages == "
"CLAUDE.md #9")
# (2) §5.2 (l.144-146) · les 3 lignes de table « **N %** (fixe · contrainte #9) »
rows9 = re.findall(r"\*\*\s*(\d+(?:\.\d+)?)\s*%\s*\*\*\s*\(fixe · contrainte #9\)",
tpl)
want9 = {exp[k] for k in _num_keys}
got9 = {float(x) for x in rows9}
if len(rows9) != 3:
bad(f"Template canonique · (2) §5.2 — attendu 3 lignes « **N %** (fixe · "
f"contrainte #9) », trouvé {len(rows9)} ({rows9}) → régénérer le template")
elif got9 == want9:
good("Template canonique · (2) §5.2 — 3 lignes « (fixe · contrainte #9) » == "
"CLAUDE.md #9 (3 %/8.5 %/52 %)")
else:
miss = sorted(_pnorm(v) for v in want9 - got9)
extra = sorted(_pnorm(v) for v in got9 - want9)
bad(f"Template canonique · (2) §5.2 — lignes #9 {sorted(map(_pnorm, got9))} "
f"MAIS CLAUDE.md {sorted(map(_pnorm, want9))}"
+ (f" · absents={miss}" if miss else "")
+ (f" · en trop={extra}" if extra else "") + " → régénérer le template")
# (3) intro §0 (l.29) · les 3 valeurs #10 en gras
m3 = re.search(r"#10 Devises\*\* → (.+?)\.", tpl)
if m3 is None:
bad("Template canonique · (3) intro #10 (l.29) INTROUVABLE "
"(attendu devises/format/paiement en gras)")
else:
seg3 = m3.group(1); drift = []
for k in _txt_keys:
if not re.search(r"\*\*\s*" + re.escape(exp[k]) + r"\s*\*\*", seg3):
drift.append(f"{_lab10[k]} attendu **{exp[k]}**")
if drift:
bad("Template canonique · (3) intro #10 (l.29) DÉRIVE de CLAUDE.md #10 : "
f"{' · '.join(drift)} → aligner le template (ou CLAUDE.md)")
else:
good("Template canonique · (3) intro #10 (l.29) — devises/format/paiement "
"== CLAUDE.md #10")
# (4) §5.2 (l.149-151) · les 3 lignes de table « **valeur** (… contrainte #10) »
drift = []
# devises + format : « **valeur** (contrainte #10) »
for k in ("devises", "format_doc"):
if not re.search(r"\*\*\s*" + re.escape(exp[k]) + r"\s*\*\*\s*\(contrainte #10\)",
tpl):
drift.append(f"ligne {_lab10[k]} attendue « **{exp[k]}** (contrainte #10) »")
# paiement : « **valeur** (❌ <exclu> · contrainte #10) » — cite l'exclusion #10
pay_re = (r"\*\*\s*" + re.escape(exp["paiement"]) + r"\s*\*\*\s*\(❌\s*"
+ (re.escape(exp_excl) if exp_excl else r"\S+") + r"\s*·\s*contrainte #10\)")
if not re.search(pay_re, tpl):
drift.append(f"ligne paiement attendue « **{exp['paiement']}** (❌ "
f"{exp_excl or '?'} · contrainte #10) »")
if drift:
bad("Template canonique · (4) §5.2 lignes #10 DÉRIVENT de CLAUDE.md #10 : "
f"{' · '.join(drift)} → aligner le template (ou CLAUDE.md)")
else:
good("Template canonique · (4) §5.2 — 3 lignes « (… contrainte #10) » (devises · "
f"format · paiement ❌ {exp_excl}) == CLAUDE.md #10")
# (5) §7 checklist (l.196) · énumération des 6 marqueurs canoniques
_want6_tpl = {_pnorm(exp[k]) for k in _num_keys} | {_ai(exp[k]) for k in _txt_keys}
m5 = re.search(r"Param[èe]tres canoniques\*\* \| (.+?) pr[ée]sents", tpl)
if m5 is None:
bad("Template canonique · (5) §7 checklist — énumération « 3 %/…/Cardnet "
"présents » INTROUVABLE")
else:
got5 = set()
for tok in re.split(r"[/·]", m5.group(1)):
p = _pct(tok)
got5.add(_pnorm(p) if p is not None else _ai(tok))
if got5 == _want6_tpl:
good("Template canonique · (5) §7 checklist — énumère les 6 marqueurs == "
"CLAUDE.md #9/#10")
else:
miss = sorted(_want6_tpl - got5); extra = sorted(got5 - _want6_tpl)
bad(f"Template canonique · (5) §7 checklist — énumère {sorted(got5)} MAIS "
f"CLAUDE.md {sorted(_want6_tpl)}"
+ (f" · absents={miss}" if miss else "")
+ (f" · en trop={extra}" if extra else "") + " → régénérer le template")
# ============================================================================
# Fiscal · ecf_dgii — le JEU DE DEVISES `moneda` (`USD`/`DOP`) ANCRÉ sur CLAUDE.md
# #10 — même classe d'ANCRAGE que la CITATION #10 des fiches ci-dessus, mais sur
# une SURFACE distincte : la LISTE d'options de devise de l'e-CF, byte-gatée mais
# JAMAIS ancrée à la contrainte.
# ----------------------------------------------------------------------------
# `moneda.options = ["USD","DOP"]` — le jeu de devises que `TipoMoneda` peut porter
# dans un e-CF DGII — vit dans le spec ET, byte-gaté par check_artifacts, dans
# out/ecf_plan.json. C'est une valeur CANONIQUE de CLAUDE.md #10 (« **USD + DOP**
# devises ») : le README s'en réclame « #10 » (README:57 « **`devise`** (USD/DOP ·
# #10) », README:37/77) MAIS AUCUN gate ne liait cette liste à la contrainte —
# le bloc « Fiscal cross-cohérence » amont gate le FormaPago (Cardnet · #10) et les
# ÉTATS/champs émetteurs, AVEUGLE au jeu de devises ; le bloc « Fiches #10 » ancre
# la prose des fiches ERPNext/CRM, AVEUGLE au module fiscal ; et le `source` du bloc
# moneda est legitimement `null` (il source le TAUX TipoCambio, à confirmer · #8),
# donc l'ancre des OPTIONS vit dans un champ DISTINCT `options_source`.
# Byte-gater une constante canonique prouve la REPRODUCTIBILITÉ, PAS l'ANCRAGE
# (check_artifacts prouve ecf_plan==build depuis le spec, jamais spec[moneda]==#10) :
# renommer/étendre les devises dans CLAUDE.md #10 (`USD + DOP` → `USD + EUR`, ou
# +`EUR`) ET dans le spec de façon cohérente reste byte-VERT tout en faisant émettre
# à l'e-CF un `TipoMoneda` d'une devise qui CONTREDIT le mandat — « vert trompeur »
# de la MÊME classe que la marque SEO §Entités, la persona Chat OTOIA ou les tokens
# branding #4, qu'aucune suite tests/ (FONCTIONS de génération, jamais l'ancre à
# CLAUDE.md) n'attrape. On RÉUTILISE `exp["devises"]` déjà recomputé depuis #10 (zéro
# duplication) et on exige : (a) spec `moneda.options` == devises de #10 (ORDRE exact
# · mord un ajout/retrait/réordonnancement) ; (b) l'artefact byte-gaté idem ; (c) le
# spec DÉCLARE l'ancrage (`options_source` cite « CLAUDE.md #10 » + chaque devise) ;
# (d) la prose README qui se réclame de #10 cite le jeu EXACT + toute mention
# `moneda`/`devise` de paire de devises == le jeu (aucune périmée). Un claim absent
# échoue AUSSI (traçabilité).
# ============================================================================
if exp is not None:
ECF_MON_RD = f"{D}/fiscal/ecf_dgii/README.md"
want_cur = re.findall(r"[A-Z]{3}", exp["devises"]) # #10 « USD + DOP » → [USD, DOP]
want_set = set(want_cur)
try:
_mon_plan = load("fiscal/ecf_dgii/out/ecf_plan.json")["moneda"]
_mon_spec = json.load(open(f"{D}/fiscal/ecf_dgii/ecf_spec.json",
encoding="utf-8"))["moneda"]
except (OSError, KeyError, ValueError, TypeError) as e:
bad(f"Fiscal devises · moneda spec/artefact illisible/inattendu : {e}")
_mon_plan = _mon_spec = None
if not want_cur:
bad(f"Fiscal devises · CLAUDE.md #10 devises illisibles : {exp['devises']!r}")
elif _mon_plan is not None:
_sp_opt = [str(o).upper() for o in _mon_spec.get("options", [])]
_pl_opt = [str(o).upper() for o in _mon_plan.get("options", [])]
# (a) ANCRAGE — spec moneda.options == devises #10 (ordre exact)
if _sp_opt == want_cur:
good(f"Fiscal devises · (a) spec moneda.options {_sp_opt} == CLAUDE.md #10 "
f"« {exp['devises']} » (ordre exact)")
else:
_miss = sorted(want_set - set(_sp_opt)); _extra = sorted(set(_sp_opt) - want_set)
_d = (f" · absents={_miss}" if _miss else "") + (f" · en trop={_extra}" if _extra else "")
bad(f"Fiscal devises · (a) spec moneda.options {_sp_opt} ≠ CLAUDE.md #10 "
f"{want_cur}{_d} → aligner le spec (ou CLAUDE.md #10)")
# (b) ARTEFACT byte-gaté == devises #10 (ordre exact)
if _pl_opt == want_cur:
good(f"Fiscal devises · (b) ecf_plan.moneda.options {_pl_opt} == CLAUDE.md #10")
else:
bad(f"Fiscal devises · (b) ecf_plan.moneda.options {_pl_opt} ≠ CLAUDE.md #10 "
f"{want_cur} → régénérer l'artefact")
# (c) le spec DÉCLARE l'ancrage : options_source cite CLAUDE.md #10 + chaque devise
_osrc = str(_mon_spec.get("options_source", ""))
_missing_tok = [c for c in want_cur if c not in _osrc]
if "CLAUDE.md #10" not in _osrc or _missing_tok:
bad(f"Fiscal devises · (c) moneda.options_source ne DÉCLARE pas l'ancrage "
f"CLAUDE.md #10 + {want_cur} (manquant : "
f"{'CLAUDE.md #10' if 'CLAUDE.md #10' not in _osrc else _missing_tok}) : {_osrc!r}")
else:
good("Fiscal devises · (c) spec DÉCLARE l'ancrage (options_source cite "
"CLAUDE.md #10 + chaque devise)")
# (d) README — la ligne #10 cite le jeu EXACT + toute paire moneda/devise == le jeu
ecf_mon_rd = read_norm(ECF_MON_RD)
if ecf_mon_rd is not None:
# (d1) la ligne qui se réclame de #10 : « devise (…USD/DOP… · #10) »
m10 = re.search(r"devise[^\n]*?\(([^)]*#10[^)]*)\)", ecf_mon_rd)
if m10 is None:
bad(f"Fiscal devises · (d1) {ECF_MON_RD} — ligne « devise (… · #10) » "
f"INTROUVABLE → la prose ne se réclame plus de #10")
elif set(re.findall(r"[A-Z]{3}", m10.group(1))) != want_set:
bad(f"Fiscal devises · (d1) {ECF_MON_RD} — ligne #10 cite "
f"{sorted(set(re.findall(r'[A-Z]{3}', m10.group(1))))} MAIS #10 dit "
f"{sorted(want_set)} → régénérer le README")
else:
# (d2) AUCUNE mention moneda/devise d'une paire de devises n'est périmée
pairs = re.findall(r"(?:moneda|devise)[^.|\n]{0,40}?\b([A-Z]{3}\s*/\s*[A-Z]{3})\b",
ecf_mon_rd)
stale = [p for p in pairs if set(re.findall(r"[A-Z]{3}", p)) != want_set]
if not pairs:
bad(f"Fiscal devises · (d2) {ECF_MON_RD} — aucune mention moneda/devise "
f"du jeu {sorted(want_set)} → claim absent")
elif stale:
bad(f"Fiscal devises · (d2) {ECF_MON_RD} — mention(s) de devises PÉRIMÉE(S) "
f"{stale} ≠ CLAUDE.md #10 {sorted(want_set)} → régénérer le README")
else:
good(f"Fiscal devises · (d) README cite le jeu #10 {sorted(want_set)} "
f"+ {len(pairs)} mention(s) moneda/devise concordante(s)")
# ============================================================================
# Démo · run-sheet de pitch (2e surface du MÊME README) · la TABLE des SCÉNARIOS
# — l'IDENTITÉ de chaque ligne (id · projet+libellé · audience), jamais gatée.
# ----------------------------------------------------------------------------
# Le bloc « Démo » ci-dessus recompute le seul COMPTE de modules cités du
# diagramme. Restait, dans le MÊME README, une surface data-derived distincte et
# NON gatée : la table « Scénario | Projet | Audience | Angle » (README:10-13).
# Ses trois colonnes d'identité sont DATA-DERIVED de out/run_sheet.json (byte-
# gaté par check_artifacts ⇒ faisant autorité) : `id` (« S-P07-BANQUIER »),
# `projet`+`projet_libelle` (« P07 » + « Aqua Terra Las Terrenas ») et `audience`
# (« banquier »). Le générateur ne saisit AUCUNE donnée métier — il résout depuis
# le disque ; les libellés projets sont d'ailleurs déclarés (README:91-92)
# « proviennent **verbatim** de CLAUDE.md · §Projets ». Or AUCUN gate ne le
# vérifiait : check_artifacts ne prouve QUE run_sheet==build (byte-for-byte) et
# le bloc Démo existant ne gate QUE `modules_cites_uniques` → RENOMMER un id dans
# la prose · CLASSER un scénario sous le mauvais projet/audience · AJOUTER une
# ligne fantôme d'un scénario inventé · laisser un libellé projet DÉRIVER de
# CLAUDE.md (« Aqua Terra » → « Aqua Terra Bay ») restait « vert trompeur »
# qu'aucune suite tests/ (qui teste des FONCTIONS de résolution, pas la prose du
# README) n'attrape → un présentateur pitcherait le mauvais projet/audience (le
# risque même que la run-sheet veut éliminer). Même patron EXACT que la colonne
# « Type » de roleprofile_gen (identité de table, fin du wildcard) et l'ancrage
# du catalogue projets dossier_vente à CLAUDE.md §Projets. On RECOMPUTE : (1)
# ANCRAGE — chaque `P{code} {libelle}` du run_sheet == l'entrée CLAUDE.md §Projets
# de ce code (verbatim, comme la prose l'affirme · zéro duplication) ; (2) TABLE —
# chaque ligne porte EXACTEMENT id + `P{code} {libelle}` + audience ; (3) IDENTITÉ
# d'ensemble — l'ensemble des ids de lignes == l'ensemble des ids du run_sheet ==
# counts.scenarios (aucune ligne FANTÔME, aucun scénario MANQUANT). Cohérences
# croisées en bonus (mordent un artefact INTERNEMENT incohérent) : run_sheet ↔
# MANIFEST d'accord sur (id, projet, audience) · id == f"S-{projet}-{AUDIENCE}" ·
# counts.scenarios == |scenarios| · ids non vides et sans doublon. Un claim absent
# échoue AUSSI (disparition = régression).
# ============================================================================
DEMO_RD2 = f"{D}/demo/scenarios/README.md"
try:
d_rs = load("demo/scenarios/out/run_sheet.json")["scenarios"]
d_man = load("demo/scenarios/out/MANIFEST.json")
d_man_sc = d_man["scenarios"]
d_n = int(d_man["counts"]["scenarios"])
except (OSError, KeyError, ValueError, TypeError) as e:
bad(f"Démo scénarios · artefact run_sheet/MANIFEST illisible/inattendu : {e}"); d_rs = None
# ancrage libellés projets : CLAUDE.md §Projets = source faisant autorité
try:
_cmp = open("CLAUDE.md", encoding="utf-8").read()
_mp = re.search(r"^## Projets\s*\n(.+)$", _cmp, re.M)
if _mp is None:
raise ValueError("section « ## Projets » INTROUVABLE")
_proj_map = {}
for _t in _mp.group(1).split("·"):
_t = _nfc(_t.strip())
_mc = re.match(r"(P\d{2})\b", _t)
if _mc:
_proj_map[_mc.group(1)] = _t
if not _proj_map:
raise ValueError("aucun projet PNN parsé")
except (OSError, ValueError) as e:
bad(f"Démo scénarios · CLAUDE.md §Projets illisible/inattendu : {e}"); _proj_map = None
if d_rs is not None and _proj_map is not None:
d_rd = read_norm(DEMO_RD2)
# cohérences croisées internes à l'artefact (avant de croire la prose)
rs_ids = [s["id"] for s in d_rs]
if len(set(rs_ids)) != len(rs_ids) or "" in rs_ids:
bad(f"Démo scénarios · run_sheet incohérent : ids={rs_ids} "
f"(doublon ou vide)")
if d_n != len(d_rs):
bad(f"Démo scénarios · incohérence artefact : counts.scenarios={d_n} "
f"MAIS |scenarios|={len(d_rs)}")
man_key = {s["id"]: (s["projet"], s["audience"]) for s in d_man_sc}
for s in d_rs:
sid, proj, aud = s["id"], s["projet"], s["audience"]
# id == f"S-{projet}-{AUDIENCE}" (encode projet+audience)
exp_id = f"S-{proj}-{aud.upper()}"
if sid != exp_id:
bad(f"Démo scénarios · run_sheet incohérent : id={sid!r} MAIS "
f"projet={proj}/audience={aud} imposent {exp_id!r}")
# run_sheet ↔ MANIFEST d'accord sur (projet, audience)
if man_key.get(sid) != (proj, aud):
bad(f"Démo scénarios · run_sheet ↔ MANIFEST désynchronisés pour {sid} : "
f"run_sheet=({proj},{aud}) MANIFEST={man_key.get(sid)}")
# ANCRAGE : libellé projet == CLAUDE.md §Projets (verbatim, cf. README:91)
full = _nfc(f"{proj} {s['projet_libelle']}")
anchor = _proj_map.get(proj)
if anchor is None:
bad(f"Démo scénarios · projet {proj} du run_sheet ABSENT de CLAUDE.md "
f"§Projets (libellé « {full} » non ancré)")
elif _ai(full) != _ai(anchor):
bad(f"Démo scénarios · libellé projet « {full} » du run_sheet DÉRIVE de "
f"CLAUDE.md §Projets « {anchor} » → aligner (ou CLAUDE.md)")
else:
good(f"Démo scénarios · {sid} — libellé « {full} » == CLAUDE.md §Projets")
# TABLE : ligne README porte id + `P{code} {libelle}` + audience
if d_rd is not None:
pat = (rf"\| `{re.escape(sid)}` \| \*\*([^|]+?)\*\* \| "
r"([^|]+?) \|")
m = re.search(pat, d_rd)
if m is None:
bad(f"Démo scénarios · {DEMO_RD2} — ligne `{sid}` INTROUVABLE "
f"(attendu projet « {full} » · audience « {aud} »)")
else:
got_lbl, got_aud = m.group(1).strip(), m.group(2).strip()
if _ai(got_lbl) != _ai(full):
bad(f"Démo scénarios · {sid} — {DEMO_RD2} porte projet "
f"« {got_lbl} » MAIS run_sheet dit « {full} » → régénérer")
elif got_aud != aud:
bad(f"Démo scénarios · {sid} — {DEMO_RD2} porte audience "
f"« {got_aud} » MAIS run_sheet dit « {aud} » → régénérer")
else:
good(f"Démo scénarios · {sid} — ligne README (projet « {got_lbl} » · "
f"audience « {got_aud} ») == run_sheet")
# IDENTITÉ d'ensemble : ids des lignes de la table == ids du run_sheet (== counts)
if d_rd is not None:
row_ids = set(re.findall(r"\| `(S-[^`]+)` \| \*\*[^|]+\*\* \| [^|]+ \|", d_rd))
want_ids = set(rs_ids)
if row_ids == want_ids and len(row_ids) == d_n:
good(f"Démo scénarios · table — {len(row_ids)} lignes == run_sheet == "
f"counts.scenarios ({d_n}) : aucun scénario FANTÔME ni MANQUANT")
else:
miss = sorted(want_ids - row_ids); extra = sorted(row_ids - want_ids)
detail = (f" · absents={miss}" if miss else "") + \
(f" · en trop={extra}" if extra else "")
bad(f"Démo scénarios · table — lignes {sorted(row_ids)} MAIS run_sheet "
f"{sorted(want_ids)} (counts.scenarios={d_n}){detail} → régénérer le README")
# ============================================================================
# CRM/workflow_vente (3e surface du MÊME README) · la TABLE « Rôles = contrat
# RBAC » — le RÔLE RBAC qui GARDE chaque transition du pipeline vente. C'est le
# CŒUR sécurité du workflow (qui a le droit d'exécuter quel pas), transcrit À LA
# MAIN sans AUCUN gate d'IDENTITÉ.
# ----------------------------------------------------------------------------
# Les deux blocs CRM/workflow existants gatent (1) le COUPLE de COMPTES du pipeline
# (« 9 états · 11 transitions ») et (2) l'ÉNUMÉRATION des transitions à SÉPARATION
# DES POUVOIRS (allow_self_approval=0). Restait, dans le MÊME README (l.55-62), une
# 3e surface data-derived : la table « Étape → Rôle qui garde la transition » qui
# nomme, PAR pas, le rôle RBAC autorisé. Sources faisant autorité (byte-gatées par
# check_artifacts) : crm/workflow_vente/out/workflow.json — chaque transition porte
# `allowed` (le rôle qui garde le pas) ; ET out/MANIFEST.json.roles_rbac_utilises[]
# — l'ensemble des rôles employés, RECOMPUTÉ du contrat rbac_50_roles.json à chaque
# build. Les deux blocs de COMPTE/séparation sont AVEUGLES à l'identité de ces rôles :
# RÉAFFECTER un pas (« Confirmer réservation » — soumission d'argent — passe de
# `OTO Ventes Réservations` à `OTO Ventes Conseiller` : élévation de privilège sur
# une étape monétaire), RENOMMER un rôle, en AJOUTER un fantôme dans la prose ou en
# OUBLIER un laisse le README périmé pendant que l'artefact dit autre chose →
# l'agent ERPNext Backend câblerait le mauvais garde (le risque même que la table
# veut prévenir). « Vert trompeur » qu'aucune suite tests/ (qui teste des FONCTIONS
# de graphe/résolution RBAC, pas la prose) n'attrape — même patron d'IDENTITÉ que la
# cross-cohérence PERMISSIONS Legal/CONFOTUR ou le catalogue RBAC fixtures_gen.
# Bonus (mordent un artefact INTERNEMENT incohérent) : {allowed} de workflow.json
# == {erpnext_role_name} de MANIFEST.roles_rbac_utilises (aucun rôle gardant une
# transition sans être listé dans le manifeste, ni l'inverse) · ensemble NON VIDE.
# Cross-cohérence par pas (mord la réaffectation que le set-diff seul manque quand
# un rôle sert DEUX transitions) : pour les pas monétaires/de clôture SENSIBLES et
# UNIQUES dans le graphe (recomputés `allowed`, jamais figés), la ligne README qui
# les nomme doit citer EXACTEMENT ce rôle.
# État courant : aucun garde périmé (anti-invention #6) — le défaut est la surface
# ungated. Un claim absent échoue AUSSI (traçabilité).
# ============================================================================
def _fold(s):
s = unicodedata.normalize("NFKD", s)
return "".join(c for c in s if not unicodedata.combining(c)).strip().casefold()
try:
wfv_trans = load("crm/workflow_vente/out/workflow.json")[0]["transitions"]
wfv_roles = {t["allowed"] for t in wfv_trans} # gardes du graphe
wfv_man = load("crm/workflow_vente/out/MANIFEST.json")["roles_rbac_utilises"]
wfv_roles_man = {r["erpnext_role_name"] for r in wfv_man}
# rôle par action, UNIQUEMENT quand l'action garde 1 seul rôle (déterministe)
wfv_by_act = {}
for t in wfv_trans:
wfv_by_act.setdefault(t["action"], set()).add(t["allowed"])
wfv_uniq = {a: next(iter(v)) for a, v in wfv_by_act.items() if len(v) == 1}
except (OSError, KeyError, ValueError, IndexError, TypeError) as e:
bad(f"CRM gardes · workflow.json/MANIFEST illisible/inattendu : {e}"); wfv_roles = None
if wfv_roles is not None:
# bonus 1 : ensemble non vide
if not wfv_roles:
bad("CRM gardes · aucun rôle ne garde de transition (ensemble VIDE) → contrat cassé")
else:
good(f"CRM gardes · {len(wfv_roles)} rôles gardent le pipeline (ensemble non vide)")
# bonus 2 : workflow.json ⇄ MANIFEST d'accord sur l'ensemble des rôles employés
if wfv_roles == wfv_roles_man:
good(f"CRM gardes · {{allowed}} workflow.json == roles_rbac_utilises MANIFEST "
f"({len(wfv_roles)} rôles · aucun fantôme/manquant entre artefacts)")
else:
miss = sorted(wfv_roles_man - wfv_roles); extra = sorted(wfv_roles - wfv_roles_man)
detail = (f" · dans MANIFEST pas gardien={miss}" if miss else "") + \
(f" · gardien pas dans MANIFEST={extra}" if extra else "")
bad(f"CRM gardes · incohérence artefacts : gardes workflow.json {sorted(wfv_roles)} "
f"MAIS roles_rbac_utilises {sorted(wfv_roles_man)}{detail} → régénérer")
wfv_txt = read_norm(WF_RD)
if wfv_txt is not None:
seg = re.search(r"Rôle qui garde la transition \|(.+?)## Séparation", wfv_txt)
if seg is None:
bad(f"CRM gardes · {WF_RD} — table « Rôle qui garde la transition » INTROUVABLE "
f"(attendu {sorted(wfv_roles)})")
else:
rows = [(l, r) for l, r in re.findall(r"\| ([^|]+?) \| ([^|]+?) \|", seg.group(1))
if set(l.strip()) != {"-"}]
# identité d'ensemble : colonne « Rôle » de la table == gardes du graphe
rd_roles = set()
for _lbl, cell in rows:
for tok in cell.split("·"):
if tok.strip():
rd_roles.add(tok.strip())
rd_fold = {_fold(x) for x in rd_roles}; wf_fold = {_fold(x) for x in wfv_roles}
if rd_fold == wf_fold:
good(f"CRM gardes · {WF_RD} — colonne Rôle {sorted(rd_roles)} == "
f"{{allowed}} workflow.json ({len(wfv_roles)} rôles · ni fantôme ni manquant)")
else:
miss = sorted(wf_fold - rd_fold); extra = sorted(rd_fold - wf_fold)
detail = (f" · absents={miss}" if miss else "") + \
(f" · en trop={extra}" if extra else "")
bad(f"CRM gardes · {WF_RD} — colonne Rôle {sorted(rd_fold)} MAIS "
f"{{allowed}} workflow.json {sorted(wf_fold)}{detail} → régénérer le README")
# cross-cohérence par pas : les gardes des étapes SENSIBLES (argent/clôture),
# uniques dans le graphe, recomputés depuis workflow.json — jamais figés.
for act in ("Confirmer réservation", "Signer contrat", "Approuver CONFOTUR"):
exp = wfv_uniq.get(act)
if exp is None:
bad(f"CRM gardes · action sensible « {act} » absente/non-déterministe "
f"dans workflow.json → contrat changé de forme, vérifier")
continue
row = next((c for l, c in rows if _fold(act) in _fold(l)), None)
if row is None:
bad(f"CRM gardes · {WF_RD} — ligne de l'étape sensible « {act} » "
f"INTROUVABLE (attendu garde {exp})")
elif _fold(exp) in {_fold(t) for t in row.split("·") if t.strip()}:
good(f"CRM gardes · étape sensible « {act} » gardée par « {exp} » "
f"== workflow.json (séparation des pouvoirs · #6)")
else:
bad(f"CRM gardes · {WF_RD} — étape sensible « {act} » : README dit "
f"« {row.strip()} » MAIS workflow.json dit garde « {exp} » → "
f"réaffectation de privilège non répercutée, régénérer")
# ============================================================================
# RBAC/userperm_gen · la TABLE « Mapping scope_donnees → mécanisme » — la
# FONCTION d'enforcement ROW-LEVEL : quel mécanisme Frappe natif applique CHAQUE
# portée de données, ET si un `User Permission` template est émis. CŒUR sécurité
# du module, transcrit À LA MAIN sans AUCUN gate d'IDENTITÉ.
# ----------------------------------------------------------------------------
# Le bloc RBAC existant (« 3 volets ») ne gate QUE la VENTILATION par mécanisme
# (« 28 entite · 16 groupe · 2 own · 4 equipe » — un COMPTE, aveugle à QUEL
# mécanisme applique QUELLE portée). Restait, dans le MÊME README (l.33-38), une
# surface data-derived distincte : la table qui énonce, PAR portée, le `mechanism`
# Frappe natif ET le verdict « Template émis ? ». Source faisant autorité
# (byte-gatée par check_artifacts) : rbac/userperm_gen/out/user_permission_plan.json
# — chaque entrée porte `scope_donnees` + `mechanism` + `user_permission_template`
# (null ou objet). Le mapping est une FONCTION : chaque portée → exactement un
# mécanisme, template émis SSI `entite`/`user_permission_company`. RÉAFFECTER une
# portée à un mécanisme plus permissif (`entite` → `none_consolidated` : le
# row-level enforcement ABANDONNÉ, sur-exposition des données inter-entités — la
# restriction même que le module pose), RENOMMER un mécanisme ou basculer le
# verdict « Template émis ? » laisse le README périmé pendant que l'artefact dit
# autre chose → l'agent ERPNext câblerait le mauvais mécanisme (le risque même que
# la table veut prévenir). « Vert trompeur » qu'aucune suite tests/ (qui teste des
# FONCTIONS de mapping/résolution, pas la prose) n'attrape — même patron
# d'IDENTITÉ que la cross-cohérence PERMISSIONS Legal/CONFOTUR ou le catalogue RBAC
# fixtures_gen.
# Bonus (mordent un plan INTERNEMENT incohérent) : chaque portée mappe UN SEUL
# mécanisme (fonction, pas relation) · « Template émis » UNIFORME sur les entrées
# d'une portée · template émis EXACTEMENT pour `user_permission_company` (l'invariant
# « template SSI entite » du README:36/61) · ensemble des portées NON VIDE.
# État courant : aucune ligne périmée (anti-invention #6) — le défaut est la surface
# ungated. Un claim absent échoue AUSSI (traçabilité).
# ============================================================================
try:
up_plan = load("rbac/userperm_gen/out/user_permission_plan.json")
up_scopes, up_tmpl = {}, {} # portée -> set(mécanisme) / set(template non-null?)
for e in up_plan:
s = e["scope_donnees"]
up_scopes.setdefault(s, set()).add(e["mechanism"])
up_tmpl.setdefault(s, set()).add(e["user_permission_template"] is not None)
except (OSError, KeyError, ValueError, TypeError) as e:
bad(f"RBAC userperm map · user_permission_plan.json illisible/inattendu : {e}")
up_scopes = None
if up_scopes is not None:
# bonus 1 : ensemble des portées non vide
if not up_scopes:
bad("RBAC userperm map · plan VIDE (aucune portée) → contrat cassé")
else:
good(f"RBAC userperm map · {len(up_scopes)} portées dans le plan (ensemble non vide)")
# bonus 2 : chaque portée → UN SEUL mécanisme (fonction) + « Template émis » uniforme
scope_mech = {}
for s in sorted(up_scopes):
if len(up_scopes[s]) != 1:
bad(f"RBAC userperm map · portée « {s} » mappe {sorted(up_scopes[s])} "
f"(attendu 1 mécanisme) → plan incohérent (relation, pas fonction)")
continue
scope_mech[s] = next(iter(up_scopes[s]))
if len(up_tmpl[s]) != 1:
bad(f"RBAC userperm map · portée « {s} » — « Template émis ? » NON uniforme "
f"sur ses entrées {sorted(up_tmpl[s])} → plan incohérent")
# bonus 3 : template émis EXACTEMENT pour user_permission_company (l'invariant SSI)
for s, mech in scope_mech.items():
emitted = up_tmpl[s] == {True}
if emitted != (mech == "user_permission_company"):
bad(f"RBAC userperm map · portée « {s} » (mécanisme={mech}) émet "
f"template={emitted} MAIS l'invariant est « template SSI "
f"user_permission_company » → plan incohérent")
# identité + par-ligne vs README (lignes RAW : la table Mapping est la seule à 3
# cellules dont col1 = une portée backtickée `[a-z_]+` et col3 = le verdict
# « Template émis ? » **oui** / non (`null`)).
UPM_RD = f"{D}/rbac/userperm_gen/README.md"
try:
upm_lines = open(UPM_RD, encoding="utf-8").read().splitlines()
except OSError as e:
bad(f"RBAC userperm map · {UPM_RD} illisible : {e}"); upm_lines = []
map_rows = {}
for ln in upm_lines:
cells = [c.strip() for c in ln.strip().strip("|").split("|")]
if len(cells) != 3:
continue
ms = re.fullmatch(r"`([a-z_]+)`", cells[0])
if ms and re.search(r"\*\*oui\*\*|non \(`null`\)", cells[2]):
map_rows[ms.group(1)] = cells
# identité d'ensemble : portées de la table == portées de l'artefact
tbl, art = set(map_rows), set(scope_mech)
if tbl == art:
good(f"RBAC userperm map · table {sorted(tbl)} == portées de l'artefact "
f"({len(art)} · ni fantôme ni manquante)")
else:
miss = sorted(art - tbl); extra = sorted(tbl - art)
detail = (f" · absents={miss}" if miss else "") + \
(f" · en trop={extra}" if extra else "")
bad(f"RBAC userperm map · {UPM_RD} table {sorted(tbl)} MAIS artefact "
f"{sorted(art)}{detail} → régénérer le README")
# par portée : mécanisme (1er token backtické de col2) + verdict « Template émis ? »
for s in sorted(art):
exp_mech = scope_mech[s]
exp_emit = up_tmpl[s] == {True}
row = map_rows.get(s)
if row is None:
bad(f"RBAC userperm map · portée « {s} » — ligne INTROUVABLE dans {UPM_RD} "
f"(attendu mécanisme={exp_mech} · template="
f"{'oui' if exp_emit else 'non'})")
continue
mm = re.match(r"`([^`]+)`", row[1])
got_mech = mm.group(1) if mm else "?"
if got_mech == exp_mech:
good(f"RBAC userperm map · portée « {s} » — mécanisme « {got_mech} » == artefact")
else:
bad(f"RBAC userperm map · portée « {s} » — {UPM_RD} dit mécanisme "
f"« {got_mech} » MAIS artefact dit « {exp_mech} » → réaffectation "
f"d'enforcement non répercutée, régénérer")
got_emit = row[2].strip() == "**oui**"
if got_emit == exp_emit:
good(f"RBAC userperm map · portée « {s} » — Template émis="
f"{'oui' if got_emit else 'non'} == artefact")
else:
bad(f"RBAC userperm map · portée « {s} » — {UPM_RD} dit Template émis="
f"{'oui' if got_emit else 'non'} MAIS artefact dit "
f"{'oui' if exp_emit else 'non'} → régénérer le README")
# ============================================================================
# Faisabilité/bancable — figures « Génération réelle (fixture) » du README
# ----------------------------------------------------------------------------
# Le module `faisabilite/bancable` n'écrit son `out/` que sur disque (« out/ non
# commité ») : il est HORS de check_artifacts (aucun artefact byte-gaté) et
# AUCUN gate ne le touchait. Or son README §« Vérification en-repo » recopie
# QUATRE chiffres DATA-DERIVED que le générateur calcule sur la fixture COMMITÉE
# `fixtures/brief_bancable.json` : « 40 unités · valeur catalogue USD 8,560,000
# / DOP 505,040,000 · point d'équilibre 21 unités (⌈52 % × 40⌉) ». Ces valeurs
# sont Σ quantités · Σ(qté×prix_usd) · Σ(qté×prix_dop) · ⌈52 % × Σ unités⌉ — le
# 52 % ancré sur CLAUDE.md #9. PIÈGE : la suite tests/ teste des FONCTIONS
# (l'arithmétique de recoupement) avec des oracles HARDCODÉS ; elle ne compare
# JAMAIS la PROSE du README à la génération réelle. Éditer une typologie de la
# fixture (quantité/prix), en ajouter/retirer une, ou déplacer le point
# d'équilibre de CLAUDE.md #9, laisse les 4 chiffres du README périmés en silence
# pendant que le générateur produit autre chose → le banquier lit un dossier
# faux, l'invention même que #6 interdit. Ce bloc RECOMPUTE les 4 figures depuis
# la fixture (source de vérité), exige que la GÉNÉRATION RÉELLE (bancable_gen
# validate) les confirme (mord un générateur/fixture incohérents), puis que la
# prose README les énumère EXACTEMENT (valeur + séparateur de milliers + formule
# ⌈pct × Σ unités⌉, pct ancré CLAUDE.md #9). Un claim absent échoue AUSSI.
# ============================================================================
import math
BANC = f"{D}/faisabilite/bancable"
BANC_RD = f"{BANC}/README.md"
# 1) point d'équilibre % ANCRÉ sur CLAUDE.md #9 (source faisant autorité · #6/#9)
try:
_cmb = open("CLAUDE.md", encoding="utf-8").read()
_l9b = re.search(r"^9\.\s+(.+)$", _cmb, re.M)
if not _l9b:
raise ValueError("contrainte #9 INTROUVABLE")
_peb = None
for _sb in re.split(r"·", _l9b.group(1)):
if "equilibre" in _ai(_sb):
_mmb = re.search(r"(\d+(?:\.\d+)?)\s*%", _sb)
_peb = float(_mmb.group(1)) if _mmb else None
if _peb is None:
raise ValueError("point d'équilibre % absent de #9")
except (OSError, ValueError) as e:
bad(f"Bancable figures · CLAUDE.md #9 (point d'équilibre) illisible : {e}"); _peb = None
# 2) recompute INDÉPENDANT depuis la fixture COMMITÉE (source de vérité · #6)
try:
_fxb = json.load(open(f"{BANC}/fixtures/brief_bancable.json", encoding="utf-8"))
_typb = _fxb["architecture"]["typologies"]
rec_units = sum(int(t["quantite"]) for t in _typb)
rec_usd = sum(int(t["quantite"]) * int(t["prix_usd"]) for t in _typb)
rec_dop = sum(int(t["quantite"]) * int(t["prix_dop"]) for t in _typb)
except (OSError, KeyError, ValueError, TypeError) as e:
bad(f"Bancable figures · fixture brief_bancable.json illisible/inattendue : {e}"); rec_units = None
# 3) génération RÉELLE : le manifeste que le README prétend recopier
man_fig = None
try:
_procb = subprocess.run(
[sys.executable, "bancable_gen.py", "validate", "fixtures/brief_bancable.json"],
cwd=BANC, capture_output=True, text=True, encoding="utf-8")
if _procb.returncode != 0:
raise ValueError(f"validate exit {_procb.returncode} : {_procb.stderr.strip()[:160]}")
man_fig = {f["cle"]: f["valeur"] for f in json.loads(_procb.stdout)["figures_calculees"]}
except (ValueError, KeyError, OSError) as e:
bad(f"Bancable figures · génération réelle (bancable_gen validate) échoue : {e}")
if _peb is not None and rec_units is not None and man_fig is not None:
rec_be = math.ceil(_peb / 100.0 * rec_units)
# cohérence croisée : le manifeste GÉNÉRÉ == le recompute indépendant fixture
for cle, expv in (("total_unites", rec_units), ("valeur_catalogue_usd", rec_usd),
("valeur_catalogue_dop", rec_dop), ("point_equilibre_unites", rec_be)):
gotv = man_fig.get(cle)
if gotv is None:
bad(f"Bancable figures · manifeste sans figure « {cle} » (attendu {expv})")
elif int(gotv) != int(expv):
bad(f"Bancable figures · manifeste « {cle} »={gotv} MAIS recompute fixture "
f"{expv} → générateur/fixture incohérents (formule cassée)")
else:
good(f"Bancable figures · manifeste « {cle} »={int(gotv)} == recompute fixture")
# 4) PROSE README §« Vérification en-repo » — un seul pattern d'identité
try:
banc_txt = re.sub(r"\s+", " ", open(BANC_RD, encoding="utf-8").read())
except OSError as e:
bad(f"Bancable figures · {BANC_RD} illisible : {e}"); banc_txt = None
if banc_txt is not None:
pat = (r"(\d[\d,]*)\s*unités\s*·\s*valeur catalogue\s*USD\s*([\d,]+)\s*/\s*"
r"DOP\s*([\d,]+)\s*·\s*point d'équilibre\s*(\d+)\s*unités\s*"
r"\(⌈\s*(\d+)\s*%\s*×\s*(\d+)\s*⌉\)")
m = re.search(pat, banc_txt)
if m is None:
bad(f"Bancable figures · {BANC_RD} — ligne « Génération réelle (fixture) » "
f"INTROUVABLE (attendu {rec_units} unités · USD {rec_usd:,} / DOP "
f"{rec_dop:,} · équilibre {rec_be} unités (⌈{_peb:g} % × {rec_units}⌉)) — "
f"un claim absent est une régression (#6)")
else:
g_pct, g_tot = float(m.group(5)), int(m.group(6))
for lbl, raw, expv in (("Σ unités", m.group(1), rec_units),
("valeur catalogue USD", m.group(2), rec_usd),
("valeur catalogue DOP", m.group(3), rec_dop),
("point d'équilibre", m.group(4), rec_be)):
got = int(raw.replace(",", ""))
if got != expv:
bad(f"Bancable figures · README {lbl}={raw} MAIS génération réelle "
f"={expv} → figure périmée, régénérer la fixture/le README (#6)")
elif expv >= 1000 and "," not in raw:
bad(f"Bancable figures · README {lbl}={raw} sans séparateur de milliers "
f"(attendu « {expv:,} »)")
else:
good(f"Bancable figures · README {lbl}={raw} == génération réelle")
# formule ⌈52 % × 40⌉ : pct ancré CLAUDE.md #9 · total == Σ unités
if g_pct != _peb:
bad(f"Bancable figures · README formule ⌈{g_pct:g} %⌉ MAIS CLAUDE.md #9 "
f"point d'équilibre = {_peb:g} % → aligner (ou CLAUDE.md)")
else:
good(f"Bancable figures · README formule pct {g_pct:g} % == CLAUDE.md #9")
if g_tot != rec_units:
bad(f"Bancable figures · README formule « × {g_tot} » MAIS Σ unités "
f"= {rec_units} → total de la formule désaligné")
else:
good(f"Bancable figures · README formule « × {g_tot} » == Σ unités")
# ============================================================================
# Fiche Mobile (03_agents/mobile/AGENT.md) · la FICHE D'IDENTITÉ du rôle RBAC
# `plateforme-mobile` — le SEUL ancrage in-repo réellement vérifiable de l'agent
# mobile (les builds/stores étant hors-repo, #8), transcrit À LA MAIN sans gate.
# ----------------------------------------------------------------------------
# La ligne « Points de contact réellement commités » (AGENT.md:36) recopie, DEPUIS
# le contrat rbac_50_roles.json (source de vérité RBAC, schéma-validé · byte-gaté
# par les 3 MANIFEST recomputés), TOUS les attributs data-derived du rôle : son
# `erpnext_role_name` (« OTO Plateforme Mobile »), son `nom_fr`, sa `famille`/
# `portail`, son `entite_principale`, son `niveau`, son `scope_donnees`, ses
# `modules` et sa `permission_cible` (`API Access` custom R/W) + sa `description`.
# C'est la carte que l'agent ERPNext Backend lit pour seeder le rôle. AUCUN gate ne
# comparait ces valeurs au contrat : le README mobile/app_config est gaté sur des
# COMPTES agrégés (« 5 onglets · 44 rôles »), AVEUGLES à l'identité de CE rôle. Éditer
# le contrat — élever le `scope_donnees` (`groupe`→`entite` : sur-portée), changer
# le `niveau`, l'`entite_principale`, renommer le rôle, ajouter/retirer un module ou
# élargir la permission (R/W→+delete) — laisse la fiche périmée en silence pendant
# que le contrat dit autre chose ⇒ l'agent câblerait le mauvais rôle (l'invention
# même que #6 interdit). Aucune suite tests/ (qui teste des FONCTIONS de résolution
# RBAC, pas la prose d'une fiche) n'attrape ce « vert trompeur » — même patron
# d'IDENTITÉ que la table gardes CRM/workflow_vente ou le mapping RBAC userperm_gen.
# On RECOMPUTE chaque attribut depuis le contrat (zéro duplication) et on exige que
# la fiche les énonce EXACTEMENT. Cohérences croisées bonus (mordent un contrat
# INTERNEMENT incohérent) : `famille` == `portail` (invariant de bijection portail) ·
# la permission est bien `custom` · l'ensemble des rôles porte CE role_id UNE fois.
# Un claim absent échoue AUSSI (traçabilité). La description (data-derived) est
# recoupée verbatim, emphase markdown et point final neutralisés.
# ============================================================================
MOB = "03_agents/mobile/AGENT.md"
RID = "plateforme-mobile"
try:
roles = load("rbac/rbac_50_roles.json")["roles"]
mob_role = [r for r in roles if r.get("id") == RID]
except (OSError, KeyError, ValueError, TypeError) as e:
bad(f"Fiche Mobile · rbac_50_roles.json illisible/inattendu : {e}"); mob_role = None
mob_txt = read_norm(MOB)
if mob_role is not None and mob_txt is not None:
if len(mob_role) != 1:
bad(f"Fiche Mobile · le contrat porte {len(mob_role)} rôle(s) `{RID}` "
f"(attendu exactement 1) → contrat changé de forme, vérifier")
else:
r = mob_role[0]
# cellule « Ce qu'il fixe » de la ligne plateforme-mobile (sans le `|` de fin)
seg = re.search(rf"→ rôle `{re.escape(RID)}` \|(.+?)\|", mob_txt)
if seg is None:
bad(f"Fiche Mobile · {MOB} — ligne de contact du rôle `{RID}` INTROUVABLE "
f"(un claim absent est une régression · #6)")
else:
cell = seg.group(1)
# cohérence croisée : famille == portail (bijection portail du contrat)
if r.get("famille") != r.get("portail"):
bad(f"Fiche Mobile · contrat incohérent : famille={r.get('famille')} "
f"≠ portail={r.get('portail')} → bijection portail cassée")
# permission_cible : doctype + flag custom + verbes → « R/W »
perms = r.get("permissions_cibles") or []
perm = perms[0] if len(perms) == 1 else None
if perm is None:
bad(f"Fiche Mobile · rôle `{RID}` — attendu 1 permission_cible, "
f"trouvé {len(perms)} → contrat changé, vérifier la fiche")
elif not perm.get("custom", False):
bad(f"Fiche Mobile · rôle `{RID}` — permission « {perm.get('doctype')} » "
f"non `custom` dans le contrat MAIS la fiche la dit custom → aligner")
acts = set(perm.get("actions", [])) if perm else set()
# verbes → sigle canonique ordonné : élargir la perm (ex. +delete) change
# le sigle attendu ⇒ la fiche « R/W » périme et le gate mord.
_VERB = [("create", "C"), ("read", "R"), ("write", "W"), ("delete", "D"),
("submit", "S"), ("cancel", "X"), ("amend", "A")]
rw = "/".join(sig for k, sig in _VERB if k in acts)
if acts - {k for k, _ in _VERB}:
bad(f"Fiche Mobile · rôle `{RID}` — verbe(s) inconnu(s) "
f"{sorted(acts - {k for k, _ in _VERB})} dans le contrat → étendre le gate")
mods = "+".join(f"`{m}`" for m in (r.get("modules") or []))
# chaque attribut data-derived recomputé du contrat → prose EXACTE exigée
attrs = [
("nom d'affichage ERPNext",
rf"rôle ERPNext \*\*`{re.escape(r['erpnext_role_name'])}`\*\*",
r["erpnext_role_name"]),
("nom_fr", rf"« {re.escape(r['nom_fr'])} »", r["nom_fr"]),
("famille/portail",
rf"famille/portail \*\*`{re.escape(r['portail'])}`\*\*", r["portail"]),
("entité principale",
rf"entité \*\*{re.escape(r['entite_principale'])}\*\*",
r["entite_principale"]),
("niveau", rf"niveau {r['niveau']}(?!\d)", str(r["niveau"])),
("scope_donnees",
rf"scope `{re.escape(r['scope_donnees'])}`", r["scope_donnees"]),
("modules", rf"modules {re.escape(mods)}(?![+`])",
"+".join(r.get("modules") or [])),
]
if perm is not None:
attrs.append(("permission cible",
rf"perm `{re.escape(perm['doctype'])}` custom {re.escape(rw)}(?![\w/])",
f"{perm['doctype']} custom {rw}"))
for label, pat, exp in attrs:
if re.search(pat, cell):
good(f"Fiche Mobile · rôle `{RID}` — {label} « {exp} » == contrat")
else:
bad(f"Fiche Mobile · {MOB} — {label} : la fiche ne cite pas "
f"« {exp} » du contrat rbac_50_roles.json (périmé ?) → régénérer")
# description data-derived : recoupée verbatim (emphase + point final neutralisés)
def _plain(s):
return re.sub(r"\s+", " ", s.replace("*", "")).strip().rstrip(".").strip()
md = re.search(r"description « (.+?) »\)", cell)
exp_desc = _plain(r.get("description", ""))
if md is None:
bad(f"Fiche Mobile · {MOB} — la description « … » du rôle `{RID}` "
f"INTROUVABLE (attendu « {exp_desc} »)")
elif _plain(md.group(1)) == exp_desc:
good(f"Fiche Mobile · rôle `{RID}` — description == contrat "
f"(emphase/point neutralisés)")
else:
bad(f"Fiche Mobile · {MOB} — description « {_plain(md.group(1))} » "
f"MAIS contrat « {exp_desc} » → description périmée, régénérer")
# ============================================================================
# Fiches Faisabilité (03_agents/rendu/AGENT.md · 03_agents/ifc_speckle/AGENT.md) ·
# les FICHES D'IDENTITÉ des rôles RBAC `faisabilite-rendu-3d` et
# `faisabilite-ifc-speckle` — même classe de « vert trompeur » que la Fiche Mobile.
# ----------------------------------------------------------------------------
# La ligne « Contact in-repo (vérifié) » de chaque fiche (rendu:34 · ifc_speckle:34)
# recopie À LA MAIN, DEPUIS le contrat rbac_50_roles.json (source de vérité RBAC,
# schéma-validé · byte-gaté par les 3 MANIFEST recomputés), les attributs data-derived
# du rôle : son `erpnext_role_name`, son `portail`, son `entite_principale`, son
# `niveau`, ses `modules` (rendu), son `nom_fr` (rendu) et ses `permissions_cibles`
# (deux doctypes chacune : `File` R/W/create + `Faisabilité` R ou R/W). C'est la carte
# que l'agent ERPNext Backend lit pour seeder le rôle porteur des rendus / de l'export
# IFC→GLB. AUCUN gate ne comparait ces valeurs au contrat : le README frontend/portails
# n'est gaté que sur des COMPTES de rôles par Workspace, AVEUGLES à l'identité de CE
# rôle. Éditer le contrat — élever le `scope_donnees`, changer le `niveau`,
# l'`entite_principale`, renommer le rôle, ajouter/retirer un module ou élargir la
# permission (`Faisabilité` R→R/W : le rôle Rendu 3D gagnerait l'écriture sur le
# DocType Faisabilité qu'il ne doit que LIRE) — laisse la fiche périmée en silence
# pendant que le contrat dit autre chose ⇒ l'agent câblerait le mauvais rôle
# (l'invention même que #6 interdit). Aucune suite tests/ (qui teste des FONCTIONS de
# résolution RBAC, pas la prose d'une fiche) n'attrape ce « vert trompeur ». On
# RECOMPUTE chaque attribut RESTITUÉ par la fiche depuis le contrat (zéro duplication)
# et on exige que la fiche l'énonce EXACTEMENT. Les verbes de permission sont rendus
# dans un ORDRE CANONIQUE (read→R · write→W · create/delete/submit/… en toutes
# lettres, ordre fixe) : élargir OU rétrécir une permission change la chaîne attendue
# (`R/W` ne satisfait plus `R/W/create`, ni l'inverse) ; verbe inconnu ⇒ ROUGE
# (étendre le gate). Un claim absent échoue AUSSI (traçabilité). NB : contrairement
# à la Fiche Mobile, la DESCRIPTION de ces deux fiches est une PARAPHRASE éditoriale
# (le contrat porte « (… — CLAUDE.md) » / « les modèles » que la fiche condense) —
# elle n'est donc PAS gatée verbatim (ne rien réécrire de correct pour un gate · #6) ;
# seuls les attributs STRUCTURÉS, eux transcrits à l'exact, sont contraints.
# ============================================================================
_CANON_VERB = [("read", "R"), ("write", "W"), ("create", "create"),
("delete", "delete"), ("submit", "submit"), ("cancel", "cancel"),
("amend", "amend"), ("print", "print"), ("report", "report")]
FICHES_FAIS = [
{"file": "03_agents/rendu/AGENT.md", "rid": "faisabilite-rendu-3d",
"attrs": ["role", "nom_fr", "portail", "entite", "niveau", "modules", "perms"]},
{"file": "03_agents/ifc_speckle/AGENT.md", "rid": "faisabilite-ifc-speckle",
"attrs": ["role", "portail", "entite", "niveau", "perms"]},
]
try:
_fais_roles = load("rbac/rbac_50_roles.json")["roles"]
except (OSError, KeyError, ValueError, TypeError) as e:
bad(f"Fiches Faisabilité · rbac_50_roles.json illisible/inattendu : {e}")
_fais_roles = None
if _fais_roles is not None:
for fc in FICHES_FAIS:
rid, tag = fc["rid"], fc["file"].split("/")[1]
cand = [r for r in _fais_roles if r.get("id") == rid]
ftxt = read_norm(fc["file"])
if ftxt is None:
continue
if len(cand) != 1:
bad(f"Fiche {tag} · le contrat porte {len(cand)} rôle(s) `{rid}` "
f"(attendu exactement 1) → contrat changé de forme, vérifier")
continue
r = cand[0]
seg = re.search(rf"→ rôle `{re.escape(rid)}` \|(.+?)\|", ftxt)
if seg is None:
bad(f"Fiche {tag} · {fc['file']} — ligne de contact du rôle `{rid}` "
f"INTROUVABLE (un claim absent est une régression · #6)")
continue
cell = seg.group(1)
# permissions_cibles → chaîne « `Doctype` R/W/create + `Autre` R », ordre canon
_perm_parts, _unknown = [], set()
for p in (r.get("permissions_cibles") or []):
acts = set(p.get("actions", []))
_unknown |= acts - {k for k, _ in _CANON_VERB}
sig = "/".join(tok for k, tok in _CANON_VERB if k in acts)
_perm_parts.append(f"`{p.get('doctype')}` {sig}")
perm_str = " + ".join(_perm_parts)
if _unknown:
bad(f"Fiche {tag} · rôle `{rid}` — verbe(s) inconnu(s) "
f"{sorted(_unknown)} dans le contrat → étendre le gate")
mods = r.get("modules") or []
mod_pat = (rf"module `{re.escape(mods[0])}`(?![+`\w])" if len(mods) == 1
else r"modules " + r"\+".join(f"`{re.escape(m)}`" for m in mods))
_PATS = {
"role": (rf"rôle ERPNext \*\*`{re.escape(r['erpnext_role_name'])}`\*\*",
r["erpnext_role_name"]),
"nom_fr": (rf"« {re.escape(r['nom_fr'])} »", r["nom_fr"]),
"portail": (rf"portail {re.escape(r['portail'])}(?![\w])", r["portail"]),
"entite": (rf"(?<![\w]){re.escape(r['entite_principale'])}(?![\w])",
r["entite_principale"]),
"niveau": (rf"niveau {r['niveau']}(?!\d)", str(r["niveau"])),
"modules": (mod_pat, "+".join(mods)),
"perms": (rf"perms {re.escape(perm_str)}(?![\w/])", perm_str),
}
for a in fc["attrs"]:
pat, exp = _PATS[a]
if re.search(pat, cell):
good(f"Fiche {tag} · rôle `{rid}` — {a} « {exp} » == contrat")
else:
bad(f"Fiche {tag} · {fc['file']} — {a} : la fiche ne cite pas "
f"« {exp} » du contrat rbac_50_roles.json (périmé ?) → régénérer")
# ============================================================================
# Fiches agents · rattachement Workspace (`Has Role`) — QUELLE CONSOLE (Workspace
# ERPNext natif v15) le rôle d'une fiche peut atteindre. Surface data-derived
# DISTINCTE des attributs du contrat rbac_50_roles.json : la MEMBERSHIP `Has Role`
# ne vit PAS dans le contrat mais dans frontend/portails/out/workspace.json
# (artefact byte-gaté par check_artifacts). Trois fiches font, EN PROSE, une
# assertion d'appartenance :
# • rendu (:35) — rôle « OTO Faisabilité Rendu 3D » rattaché au Workspace
# NOMMÉ `OTO Construction` (accès console).
# • ifc_speckle (:35) — rôle « OTO Faisabilité IFC Speckle » rattaché à UN
# Workspace ERPNext (générique, non nommé).
# • mobile (:41) — rôle « OTO Plateforme Mobile » VOLONTAIREMENT PAS
# rattaché au Workspace NOMMÉ `OTO Ventes` (assertion
# NÉGATIVE d'honnêteté · #6).
# Aucun gate ne les couvrait : le bloc « triplets par workspace » gate le COMPTE de
# rôles par Workspace (nb_roles), AVEUGLE à QUEL rôle ; les blocs Fiches Faisabilité /
# Fiche Mobile gatent le CONTRAT, pas la membership Has Role. Rattacher le rôle mobile
# à `OTO Ventes` (SUR-EXPOSITION console : le dev mobile gagnerait l'accès au portail
# Ventes — la restriction même que la note #6 pose), retirer le rôle rendu de
# `OTO Construction` (perte d'accès console du spécialiste), ou DÉPLACER le rôle vers
# un autre Workspace, laisse la fiche périmée EN SILENCE pendant que l'artefact dit
# autre chose ⇒ l'agent ERPNext Backend câblerait le mauvais accès console. Aucune
# suite tests/ (qui teste des FONCTIONS de résolution, pas la prose d'une fiche)
# n'attrape ce « vert trompeur ». On RECOMPUTE, depuis workspace.json, l'ensemble des
# Workspaces dont le `Has Role` porte l'erpnext_role_name du rôle (résolu du contrat
# par id, zéro duplication) et on confronte l'assertion de la fiche. Un claim absent
# échoue AUSSI (traçabilité · #6).
# ============================================================================
FICHES_WS = [
{"file": "03_agents/rendu/AGENT.md", "rid": "faisabilite-rendu-3d",
"kind": "in_named", "ws": "OTO Construction"},
{"file": "03_agents/ifc_speckle/AGENT.md", "rid": "faisabilite-ifc-speckle",
"kind": "in_any"},
{"file": "03_agents/mobile/AGENT.md", "rid": "plateforme-mobile",
"kind": "not_in_named", "ws": "OTO Ventes"},
]
_ws_json = _ws_roles = None
try:
_ws_json = load("frontend/portails/out/workspace.json")
_ws_roles = load("rbac/rbac_50_roles.json")["roles"]
except (OSError, KeyError, ValueError, TypeError) as e:
bad(f"Fiches Workspace · workspace.json / rbac_50_roles.json illisible : {e}")
if _ws_json is not None and _ws_roles is not None:
# Index recomputé : erpnext_role_name -> ensemble des titres de Workspace le portant.
_mem, _titles = {}, set()
for ws in _ws_json:
title = ws.get("title")
_titles.add(title)
for hr in (ws.get("roles") or []):
_mem.setdefault(hr.get("role"), set()).add(title)
for fc in FICHES_WS:
tag = fc["file"].split("/")[1]
cand = [r for r in _ws_roles if r.get("id") == fc["rid"]]
if len(cand) != 1:
bad(f"Fiche {tag} · le contrat porte {len(cand)} rôle(s) `{fc['rid']}` "
f"(attendu exactement 1) → contrat changé de forme, vérifier")
continue
rname = cand[0].get("erpnext_role_name")
actual = _mem.get(rname, set()) # Workspaces réels du rôle (Has Role)
ftxt = read_norm(fc["file"])
if ftxt is None:
continue
named = fc.get("ws")
# Un Workspace NOMMÉ par la fiche doit exister dans l'artefact (anti-typo :
# sinon une assertion in_named/not_in_named « verte » viserait un fantôme).
if named is not None and named not in _titles:
bad(f"Fiche {tag} · le Workspace nommé `{named}` n'existe pas dans "
f"workspace.json (titres : {sorted(_titles)}) → fiche/artefact périmé")
continue
if fc["kind"] == "in_named":
if not re.search(rf"rattaché au Workspace `{re.escape(named)}`", ftxt):
bad(f"Fiche {tag} · {fc['file']} — assertion « rattaché au Workspace "
f"`{named}` » INTROUVABLE (claim absent = régression · #6)")
elif actual == {named}:
good(f"Fiche {tag} · rôle « {rname} » ∈ Has Role de `{named}` "
f"(et de lui seul) == workspace.json")
elif named in actual:
bad(f"Fiche {tag} · rôle « {rname} » ∈ `{named}` MAIS AUSSI "
f"{sorted(actual - {named})} — la fiche nomme UN seul Workspace ; "
f"membership élargie dans workspace.json → réviser fiche/artefact")
else:
bad(f"Fiche {tag} · {fc['file']} nomme `{named}` MAIS workspace.json "
f"rattache « {rname} » à {sorted(actual) or 'aucun Workspace'} → "
f"fiche périmée (rôle déplacé/retiré du Has Role), régénérer")
elif fc["kind"] == "in_any":
if not re.search(r"rattaché à un Workspace", ftxt):
bad(f"Fiche {tag} · {fc['file']} — assertion « rattaché à un "
f"Workspace » INTROUVABLE (claim absent = régression · #6)")
elif actual:
good(f"Fiche {tag} · rôle « {rname} » ∈ Has Role de {sorted(actual)} "
f"== workspace.json (≥1 Workspace, comme la fiche l'affirme)")
else:
bad(f"Fiche {tag} · {fc['file']} affirme un rattachement Workspace "
f"MAIS « {rname} » n'est dans le Has Role d'AUCUN Workspace → "
f"fiche périmée")
elif fc["kind"] == "not_in_named":
if not re.search(rf"volontairement pas\*\* rattaché au Workspace "
rf"`{re.escape(named)}`", ftxt):
bad(f"Fiche {tag} · {fc['file']} — assertion négative « volontairement "
f"pas rattaché au Workspace `{named}` » INTROUVABLE (#6)")
elif named not in actual:
good(f"Fiche {tag} · rôle « {rname} » ABSENT du Has Role de `{named}` "
f"== workspace.json (assertion négative #6 tenue)")
else:
bad(f"Fiche {tag} · {fc['file']} affirme « pas rattaché à `{named}` » "
f"MAIS workspace.json met « {rname} » dans son Has Role → "
f"SUR-EXPOSITION console, assertion #6 VIOLÉE")
# ============================================================================
# Fiches agents · appartenance à `roles_allowed` du chat OTOIA — QUI peut
# INVOQUER l'assistant OTOIA embarqué dans un portail. Surface data-derived
# DISTINCTE de la membership Workspace `Has Role` (QUELLE console le rôle atteint,
# gatée juste au-dessus) ET des attributs du contrat rbac_50_roles.json (gatés par
# les blocs Fiches Faisabilité / Fiche Mobile) : la liste `roles_allowed` ne vit
# PAS dans le contrat ni dans workspace.json mais dans
# frontend/chat_otoia/out/chat_mount.json (une config runtime par portail,
# artefact byte-gaté par check_artifacts). Deux fiches font, EN PROSE, une
# assertion d'appartenance à CE contrôle d'accès :
# • rendu (:36) — POSITIVE : le rôle « OTO Faisabilité Rendu 3D » fait partie
# des `roles_allowed` du portail NOMMÉ `construction` (droit
# d'invoquer le chat pour déclencher les rendus assistés).
# • mobile (:41) — NÉGATIVE d'honnêteté (#6) : le rôle « OTO Plateforme Mobile »
# (famille `plateforme`) n'est VOLONTAIREMENT dans AUCUNE liste
# `roles_allowed` — le chat ne couvre que les portails métier.
# Aucun gate ne les couvrait : le bloc Chat OTOIA amont gate l'IDENTITÉ de la
# persona/capabilities (ancrée CLAUDE.md) et la COMPOSITION (5 mounts ⇔ 5 portails),
# AVEUGLE à QUEL rôle figure dans quelle `roles_allowed` ; le bloc Fiches Workspace
# gate la membership `Has Role`, un artefact + une sémantique DIFFÉRENTS (accès
# console ≠ droit d'invocation du chat). Ajouter le rôle mobile à une `roles_allowed`
# (SUR-EXPOSITION : le dev mobile gagnerait le droit de piloter l'IA d'un portail
# métier — l'escalade même que la note #6 refuse), retirer le rôle rendu de la
# `roles_allowed` construction (perte du déclenchement assisté), ou le DÉPLACER vers
# un autre portail, laisse la fiche périmée EN SILENCE pendant que l'artefact dit
# autre chose ⇒ l'agent ERPNext Backend câblerait le mauvais contrôle d'accès chat.
# Aucune suite tests/ (qui teste des FONCTIONS de montage/résolution, pas la prose
# d'une fiche) n'attrape ce « vert trompeur ». On RECOMPUTE, depuis chat_mount.json,
# l'ensemble des portails dont la `roles_allowed` porte l'erpnext_role_name du rôle
# (résolu du contrat par id, zéro duplication) et on confronte l'assertion de la
# fiche. Triple ancrage bonus (POSITIVE) : le portail NOMMÉ par la fiche == le
# `portail` du rôle dans le contrat == l'unique portail dont la `roles_allowed`
# le porte. Un claim absent échoue AUSSI (traçabilité · #6).
# ============================================================================
FICHES_CHAT = [
{"file": "03_agents/rendu/AGENT.md", "rid": "faisabilite-rendu-3d",
"kind": "in_named_portail"},
{"file": "03_agents/mobile/AGENT.md", "rid": "plateforme-mobile",
"kind": "not_in_any"},
]
_chat_mounts = _chat_roles = None
try:
_chat_mounts = load("frontend/chat_otoia/out/chat_mount.json")
_chat_roles = load("rbac/rbac_50_roles.json")["roles"]
except (OSError, KeyError, ValueError, TypeError) as e:
bad(f"Fiches Chat · chat_mount.json / rbac_50_roles.json illisible : {e}")
if _chat_mounts is not None and _chat_roles is not None:
# Index recomputé : erpnext_role_name -> ensemble des portails dont la
# `roles_allowed` le porte (le droit d'invoquer le chat de CE portail).
_allow, _portails = {}, set()
for _mnt in _chat_mounts:
_pt = _mnt.get("portail")
_portails.add(_pt)
for _rn in (_mnt.get("roles_allowed") or []):
_allow.setdefault(_rn, set()).add(_pt)
if not _chat_mounts:
bad("Fiches Chat · aucun mount dans chat_mount.json → artefact suspect")
for fc in FICHES_CHAT:
tag = fc["file"].split("/")[1]
cand = [r for r in _chat_roles if r.get("id") == fc["rid"]]
if len(cand) != 1:
bad(f"Fiche {tag} · le contrat porte {len(cand)} rôle(s) `{fc['rid']}` "
f"(attendu exactement 1) → contrat changé de forme, vérifier")
continue
r0 = cand[0]
rname = r0.get("erpnext_role_name")
actual = _allow.get(rname, set()) # portails réels autorisant ce rôle
ftxt = read_norm(fc["file"])
if ftxt is None:
continue
if fc["kind"] == "in_named_portail":
# Le portail NOMMÉ par la fiche est data-derived : c'est le `portail` du
# rôle dans le contrat (triple ancrage). Il DOIT exister comme mount
# (anti-typo : sinon l'assertion « verte » viserait un portail fantôme).
named = r0.get("portail")
if named not in _portails:
bad(f"Fiche {tag} · le rôle `{fc['rid']}` a portail `{named}` (contrat) "
f"MAIS aucun mount chat pour ce portail (mounts : {sorted(_portails)}) "
f"→ contrat/artefact périmé")
continue
# La fiche doit NOMMER ce portail dans son assertion roles_allowed + chat.
if not re.search(rf"`roles_allowed` \(portail {re.escape(named)}\)", ftxt):
bad(f"Fiche {tag} · {fc['file']} — assertion « `roles_allowed` (portail "
f"{named}) » INTROUVABLE (claim absent = régression · #6)")
elif not re.search(r"autorisés à \*\*invoquer le chat OTOIA\*\*", ftxt):
bad(f"Fiche {tag} · {fc['file']} — la fiche nomme le portail mais pas "
f"le droit « invoquer le chat OTOIA » → assertion incomplète (#6)")
elif actual == {named}:
good(f"Fiche {tag} · rôle « {rname} » ∈ roles_allowed du portail "
f"`{named}` (et de lui seul) == chat_mount.json")
elif named in actual:
bad(f"Fiche {tag} · rôle « {rname} » ∈ roles_allowed de `{named}` MAIS "
f"AUSSI {sorted(actual - {named})} — la fiche nomme UN seul portail ; "
f"droit d'invocation élargi dans chat_mount.json → réviser fiche/artefact")
else:
bad(f"Fiche {tag} · {fc['file']} nomme le portail `{named}` MAIS "
f"chat_mount.json autorise « {rname} » sur "
f"{sorted(actual) or 'aucun portail'} → fiche périmée (rôle "
f"retiré/déplacé de roles_allowed), régénérer")
elif fc["kind"] == "not_in_any":
if not re.search(r"volontairement pas\*\* rattaché.*?`roles_allowed` du chat "
r"OTOIA", ftxt):
bad(f"Fiche {tag} · {fc['file']} — assertion négative « volontairement pas "
f"… `roles_allowed` du chat OTOIA » INTROUVABLE (#6)")
elif not actual:
good(f"Fiche {tag} · rôle « {rname} » ABSENT de TOUTE roles_allowed "
f"== chat_mount.json (assertion négative #6 tenue)")
else:
bad(f"Fiche {tag} · {fc['file']} affirme « pas dans roles_allowed » MAIS "
f"chat_mount.json autorise « {rname} » sur {sorted(actual)} → "
f"SUR-EXPOSITION du chat OTOIA, assertion #6 VIOLÉE")
# ============================================================================
# Publiciste/branding · TOKENS DESIGN CANONIQUES « ancrés sur CLAUDE.md #4 »
# — les 4 valeurs de la marque luxury (2 couleurs + 2 typographies), jamais gatées.
# ----------------------------------------------------------------------------
# La contrainte NON-NÉGOCIABLE #4 de CLAUDE.md fixe l'identité visuelle : fond
# dark `#0a0a12` + accent doré `#f0b429`, titres Fraunces + corps Cormorant
# Garamond. C'est la SOURCE FAISANT AUTORITÉ. Ces 4 tokens sont RECOPIÉS à
# QUATRE endroits, tous NON gatés :
# (a) lib/branding.py CONSTANTES (COLOR_BG/COLOR_ACCENT/FONT_DISPLAY/FONT_BODY)
# — la source unique importée par le générateur ET substituée dans le
# gabarit ({{COLOR_BG}}…) ; ce n'est PAS un out/*.json → invisible à
# check_artifacts ;
# (b) le docstring de branding.py (« `#0a0a12` fond · `#f0b429` … Fraunces
# (titres) + Cormorant Garamond ») — la prose du module ;
# (c) README:43 (« Gabarit dark+doré (Fraunces + Cormorant Garamond) ») ;
# (d) tests/test_publiciste.py — le tuple d'oracle HARDCODÉ
# ("#0a0a12","#f0b429","Fraunces","Cormorant Garamond") : le SEUL contrôle
# existant assert que le RENDU contient ces marqueurs, mais ils sont une
# COPIE de plus, JAMAIS comparée à CLAUDE.md. Si #4 change (ex. accent
# `#f0b429`→`#e0a420`) et qu'on aligne branding.py, l'oracle du test reste
# périmé (rouge trompeur) ; si RIEN ne bouge, tout reste vert alors que le
# site public émettrait la mauvaise couleur — invention silencieuse (#6) que
# nulle suite tests/ (qui teste des FONCTIONS, pas l'ancrage à CLAUDE.md)
# n'attrape. Même patron EXACT que les paramètres canoniques
# faisabilite/generator (ancrage CANONICAL↔CLAUDE.md #9/#10).
# On RECOMPUTE les 4 tokens depuis la ligne #4 de CLAUDE.md (zéro duplication) et
# on exige : (a) branding.py CONSTANTES == #4 ; (b) docstring cite les 4 EXACTS ;
# (c) README cite les 2 fontes ; (d) l'oracle du test == #4. Hex insensible à la
# casse, fontes comparées à l'exact (strip). Un claim absent échoue AUSSI.
# ============================================================================
PUB = f"{D}/publiciste"
try:
cm_raw4 = open("CLAUDE.md", encoding="utf-8").read()
l4 = re.search(r"^4\.\s+(.+)$", cm_raw4, re.M)
if not l4:
raise ValueError("contrainte #4 INTROUVABLE")
hexes = [h.lower() for h in re.findall(r"`(#[0-9a-fA-F]{6})`", l4.group(1))]
mfp = re.search(r"\(([^)]+)\)", l4.group(1))
fonts = [f.strip() for f in mfp.group(1).split("+")] if mfp else []
if len(hexes) != 2 or len(fonts) != 2:
raise ValueError(f"attendu 2 couleurs + 2 fontes, lu {hexes} / {fonts}")
b4 = {"bg": hexes[0], "accent": hexes[1], "display": fonts[0], "body": fonts[1]}
except (OSError, ValueError) as e:
bad(f"Branding canoniques · CLAUDE.md #4 illisible/inattendu : {e}"); b4 = None
if b4 is not None:
good(f"Branding canoniques · CLAUDE.md #4 sain : bg={b4['bg']} accent={b4['accent']} "
f"display={b4['display']!r} body={b4['body']!r}")
# (a) ANCRAGE : lib/branding.py CONSTANTES == CLAUDE.md #4 -------------------
BR = f"{PUB}/lib/branding.py"
try:
br_src = open(BR, encoding="utf-8").read()
except OSError as e:
bad(f"Branding canoniques · {BR} illisible : {e}"); br_src = None
if br_src is not None:
const_pat = {
"bg": r'COLOR_BG\s*=\s*"([^"]+)"',
"accent": r'COLOR_ACCENT\s*=\s*"([^"]+)"',
"display": r'FONT_DISPLAY\s*=\s*"([^"]+)"',
"body": r'FONT_BODY\s*=\s*"([^"]+)"',
}
drift = []
for k, pat in const_pat.items():
m = re.search(pat, br_src)
got = m.group(1) if m else None
want = b4[k]
ok = (got is not None and
(got.lower() == want if k in ("bg", "accent") else got.strip() == want))
if not ok:
drift.append(f"{k}={got!r} (attendu {want!r})")
if drift:
bad(f"Branding canoniques · branding.py CONSTANTES DÉRIVENT de CLAUDE.md #4 : "
f"{' · '.join(drift)} → aligner branding.py (ou CLAUDE.md)")
else:
good("Branding canoniques · branding.py CONSTANTES == CLAUDE.md #4 (4 tokens)")
# (b) PROSE docstring : les 4 tokens cités AVANT les CONSTANTES -----------
doc = br_src.split("COLOR_BG", 1)[0]
doc_hex = {h.lower() for h in re.findall(r"`(#[0-9a-fA-F]{6})`", doc)}
miss = [b4[k] for k in ("bg", "accent") if b4[k] not in doc_hex]
miss += [b4[k] for k in ("display", "body") if b4[k] not in doc]
if miss:
bad(f"Branding canoniques · docstring branding.py — tokens ABSENTS de la "
f"prose : {miss} (claim absent = régression · #6)")
else:
good("Branding canoniques · docstring branding.py cite les 4 tokens == CLAUDE.md #4")
# (c) README : la ligne du gabarit cite les 2 fontes -----------------------
PBR_RD = f"{PUB}/README.md"
pbr_rd = read_norm(PBR_RD)
if pbr_rd is not None:
rd_miss = [b4[k] for k in ("display", "body") if b4[k] not in pbr_rd]
if rd_miss:
bad(f"Branding canoniques · {PBR_RD} — fontes ABSENTES de la prose : "
f"{rd_miss} (attendu {b4['display']} + {b4['body']} == CLAUDE.md #4)")
else:
good(f"Branding canoniques · {PBR_RD} cite les 2 fontes == CLAUDE.md #4")
# (d) ORACLE du test : le tuple hardcodé == les 4 tokens de CLAUDE.md #4 ----
TBR = f"{PUB}/tests/test_publiciste.py"
try:
tbr_src = open(TBR, encoding="utf-8").read()
except OSError as e:
bad(f"Branding canoniques · {TBR} illisible : {e}"); tbr_src = None
if tbr_src is not None:
mt = re.search(r"for token in \(([^)]+)\)", tbr_src)
if mt is None:
bad(f"Branding canoniques · {TBR} — tuple d'oracle « for token in (...) » "
f"INTROUVABLE (le contrôle #4 du test a disparu · régression)")
else:
toks = [t.strip().strip('"\'') for t in mt.group(1).split(",") if t.strip()]
toks_n = {t.lower() if t.startswith("#") else t for t in toks}
want_n = {b4["bg"], b4["accent"], b4["display"], b4["body"]}
if toks_n == want_n:
good(f"Branding canoniques · {TBR} oracle == CLAUDE.md #4 (4 tokens)")
else:
miss = sorted(want_n - toks_n); extra = sorted(toks_n - want_n)
bad(f"Branding canoniques · {TBR} oracle DÉRIVE de CLAUDE.md #4"
+ (f" · absents={miss}" if miss else "")
+ (f" · en trop={extra}" if extra else "") + " → aligner le test")
# ============================================================================
# Publiciste/generator+branding · DEVISES CANONIQUES du site public « ancrées
# sur CLAUDE.md #10 » (USD + DOP) — les libellés monétaires RÉELLEMENT ÉMIS sur
# vente.otov7.com, jamais gatés.
# ----------------------------------------------------------------------------
# Le bloc branding #4 ci-dessus ancre les 4 tokens DESIGN. Mais le MÊME module
# porte une 2ᵉ constante constitutionnelle NON gatée : les DEUX devises de
# CLAUDE.md #10 (« **USD + DOP** devises »), recopiées à QUATRE endroits data-
# derived, tous invisibles à check_artifacts (pas un out/*.json) et jamais
# confrontés à #10 :
# (a) lib/branding.py DEVISE_PRIMAIRE/DEVISE_SECONDAIRE — constantes AUTO-
# étiquetées « contrainte #10 · USD + DOP », sans aucun gate ;
# (b) lib/generator.py `_fmt_usd`/`_fmt_dop` — les DEUX formateurs qui
# IMPRIMENT le code devise sur la grille de prix du site public
# (`f"USD {value:,.0f}"` / `f"DOP {value:,.0f}"`) ; c'est la SORTIE
# FACE-PUBLIC : une devise HORS #10 ici = mandat trahi en silence (#6) ;
# (c) lib/generator.py footer (émis dans le HTML) « Prix … en USD + DOP
# (Cardnet) » — la prose du pied de page du site ;
# (d) tests/test_publiciste.py — l'oracle HARDCODÉ « assertIn("USD 150 000",
# …) » : une COPIE de plus de la devise primaire, JAMAIS comparée à #10.
# Aucune suite tests/ (qui teste des FONCTIONS de rendu, pas l'ancre à
# CLAUDE.md) n'attrape la dérive : renommer la devise #10 (USD→EUR) et aligner
# le module reste vert alors que le site émettrait la mauvaise devise ; ne RIEN
# bouger reste vert alors que branding se dit « #10 » en mentant. Même patron
# EXACT que le bloc branding/#4 et faisabilite/#9-#10. On RE-PARSE les 2 devises
# depuis la ligne #10 de CLAUDE.md (indépendamment de `exp` amont · aucun skip
# hérité) et on exige : (a) les 2 constantes == #10 (ordre inclus) ; (b)
# l'ensemble des codes émis par les formateurs == #10 ; (c) le footer cite les 2
# devises dans l'ordre ; (d) l'oracle du test == devise primaire. Un claim
# absent échoue AUSSI (traçabilité #6).
# ============================================================================
try:
cm_raw10p = open("CLAUDE.md", encoding="utf-8").read()
l10p = re.search(r"^10\.\s+(.+)$", cm_raw10p, re.M)
if not l10p:
raise ValueError("contrainte #10 INTROUVABLE")
seg10p = [s for s in re.split(r"·", l10p.group(1)) if "devise" in _ai(s)]
mdev = re.search(r"\*\*(.+?)\*\*", seg10p[0]) if seg10p else None
if not mdev:
raise ValueError("segment devises #10 (« **… devises** ») illisible")
pub_cur = re.findall(r"[A-Z]{3}", mdev.group(1))
if len(pub_cur) != 2:
raise ValueError(f"attendu 2 devises en gras, lu {pub_cur}")
except (OSError, ValueError) as e:
bad(f"Publiciste devises · CLAUDE.md #10 illisible/inattendu : {e}"); pub_cur = None
if pub_cur is not None:
good(f"Publiciste devises · CLAUDE.md #10 sain : primaire={pub_cur[0]} "
f"secondaire={pub_cur[1]}")
PUB = f"{D}/publiciste"
# (a) ANCRAGE : branding.py DEVISE_PRIMAIRE/SECONDAIRE == #10 (ordre inclus) ---
BRD = f"{PUB}/lib/branding.py"
try:
brd_src = open(BRD, encoding="utf-8").read()
except OSError as e:
bad(f"Publiciste devises · {BRD} illisible : {e}"); brd_src = None
if brd_src is not None:
dev_pat = {
"primaire": (r'DEVISE_PRIMAIRE\s*=\s*"([^"]+)"', pub_cur[0]),
"secondaire": (r'DEVISE_SECONDAIRE\s*=\s*"([^"]+)"', pub_cur[1]),
}
ddrift = []
for k, (pat, want) in dev_pat.items():
m = re.search(pat, brd_src)
got = m.group(1) if m else None
if got != want:
ddrift.append(f"{k}={got!r} (attendu {want!r})")
if ddrift:
bad(f"Publiciste devises · branding.py CONSTANTES DÉRIVENT de CLAUDE.md #10 : "
f"{' · '.join(ddrift)} → aligner branding.py (ou CLAUDE.md)")
else:
good(f"Publiciste devises · branding.py DEVISE_PRIMAIRE/SECONDAIRE == "
f"CLAUDE.md #10 ({pub_cur[0]}/{pub_cur[1]})")
# (b) SORTIE : les formateurs generator.py émettent EXACTEMENT les 2 devises #10
GEN = f"{PUB}/lib/generator.py"
try:
gen_src = open(GEN, encoding="utf-8").read()
except OSError as e:
bad(f"Publiciste devises · {GEN} illisible : {e}"); gen_src = None
if gen_src is not None:
emis = re.findall(r'return f"([A-Z]{3}) \{value:[^}]+\}"', gen_src)
if set(emis) != set(pub_cur):
miss = sorted(set(pub_cur) - set(emis)); extra = sorted(set(emis) - set(pub_cur))
bad(f"Publiciste devises · formateurs generator.py émettent {sorted(set(emis))} "
f"MAIS CLAUDE.md #10 = {sorted(set(pub_cur))}"
+ (f" · absents={miss}" if miss else "")
+ (f" · HORS #10={extra}" if extra else "")
+ " → le site public émettrait une devise non conforme (#6)")
else:
good(f"Publiciste devises · formateurs generator.py émettent {sorted(set(emis))} "
f"== CLAUDE.md #10")
# (c) FOOTER émis dans le HTML : « en <D1> + <D2> (Cardnet) » ordre inclus ---
mft = re.search(r"en ([A-Z]{3}) \+ ([A-Z]{3}) \(Cardnet\)", gen_src)
if mft is None:
bad(f"Publiciste devises · {GEN} — pied de page « en <D1> + <D2> (Cardnet) » "
f"INTROUVABLE (claim absent = régression · #6)")
elif [mft.group(1), mft.group(2)] == pub_cur:
good(f"Publiciste devises · footer generator.py cite {pub_cur[0]} + {pub_cur[1]} "
f"== CLAUDE.md #10")
else:
bad(f"Publiciste devises · footer generator.py cite {mft.group(1)} + {mft.group(2)} "
f"MAIS CLAUDE.md #10 = {pub_cur[0]} + {pub_cur[1]} → aligner le footer")
# (d) ORACLE du test : « assertIn("<D1> 150 000", self.html) » == devise primaire
TP = f"{PUB}/tests/test_publiciste.py"
try:
tp_src = open(TP, encoding="utf-8").read()
except OSError as e:
bad(f"Publiciste devises · {TP} illisible : {e}"); tp_src = None
if tp_src is not None:
mto = re.search(r'assertIn\("([A-Z]{3}) [\d ]+", self\.html\)', tp_src)
if mto is None:
bad(f"Publiciste devises · {TP} — oracle « assertIn(\"<devise> …\", self.html) » "
f"INTROUVABLE (le contrôle prix du test a disparu · régression)")
elif mto.group(1) == pub_cur[0]:
good(f"Publiciste devises · {TP} oracle prix == devise primaire #10 ({pub_cur[0]})")
else:
bad(f"Publiciste devises · {TP} oracle prix cite {mto.group(1)!r} MAIS devise "
f"primaire #10 = {pub_cur[0]!r} → aligner le test")
# ============================================================================
# Fiches agents · colonne « Job CI » — chaque nom de job cité DOIT exister dans
# .gitea/workflows/ci.yml (même classe de « vert trompeur », côté câblage CI).
# ----------------------------------------------------------------------------
# Chaque table de livrables des fiches 03_agents/*/AGENT.md porte une colonne
# « Job CI » qui NOMME, mot pour mot et entre backticks, le job Gitea Actions qui
# exécute la suite du module (`rbac-tests`, `fiscal-ecf-tests`, …). Ce nom est
# SAISI À LA MAIN. La source de vérité est la section `jobs:` de ci.yml. Les
# gates existants ne couvraient PAS ce lien : `check_ci_integrity.sh` prouve que
# chaque job de ci.yml est bien CÂBLÉ dans `gate.needs` (INV-A/B), et les blocs
# amont recomputent les COMPTES de tests par suite — mais RIEN ne vérifiait que
# le nom écrit dans la fiche DÉSIGNE un job réel. Renommer un job dans ci.yml
# (ex. `rbac-tests` → `rbac-role-tests`, ou `demo-scenario-tests` mal recopié en
# `demo-scenarios-tests`) laisse la fiche pointer un job FANTÔME : le CI reste
# vert (le vrai job tourne sous son nouveau nom), la doc d'identité de l'agent
# ment en silence. On RECOMPUTE ici l'ensemble des jobs depuis la section `jobs:`
# de ci.yml (jamais une liste à la main) et on exige que chaque token `*-tests`
# cité dans une fiche y figure. Direction fiche→ci.yml (le consommateur) : ci.yml
# peut définir des jobs non cités (légitime), mais une fiche ne peut pas citer un
# job absent.
# ============================================================================
CI_YML = ".gitea/workflows/ci.yml"
try:
ci_lines = open(CI_YML, encoding="utf-8").read().splitlines()
except OSError as e:
bad(f"Job CI fiches · {CI_YML} illisible : {e}")
ci_lines = None
if ci_lines is not None:
# Ensemble des jobs = clés à 2 espaces sous la clé top-level `jobs:`
# (la section `on:` — push/pull_request/workflow_dispatch — est AVANT `jobs:`
# et donc naturellement exclue). Une clé top-level `^\S` clôt la section.
jobs = set()
in_jobs = False
for line in ci_lines:
if re.match(r"^jobs:\s*$", line):
in_jobs = True
continue
if in_jobs and re.match(r"^\S", line): # nouvelle clé top-level
break
if in_jobs:
mj = re.match(r"^ ([A-Za-z0-9_-]+):\s*$", line)
if mj:
jobs.add(mj.group(1))
if not jobs:
bad(f"Job CI fiches · aucun job détecté dans la section `jobs:` de "
f"{CI_YML} — parse cassé ou fichier vidé (régression de câblage).")
fiches_ci = subprocess.run(
["git", "ls-files", "03_agents/*/AGENT.md"],
capture_output=True, text=True).stdout.split()
tok_re = re.compile(r"`([a-z0-9]+(?:-[a-z0-9]+)*-tests)`")
seen_jobrefs = 0
for af in fiches_ci:
try:
flines = open(af, encoding="utf-8").read().splitlines()
except OSError as e:
bad(f"Job CI fiches · {af} illisible : {e}"); continue
for ln, line in enumerate(flines, 1):
for tok in tok_re.findall(line):
seen_jobrefs += 1
if jobs and tok in jobs:
good(f"Job CI · {af}:{ln} · `{tok}` == job ci.yml")
elif jobs:
bad(f"Job CI · {af}:{ln} · `{tok}` ABSENT de la section "
f"`jobs:` de {CI_YML} → job renommé/supprimé, fiche "
f"pointe un job fantôme (régénérer la fiche ou ci.yml)")
if seen_jobrefs == 0:
bad("Job CI fiches · AUCUN token `*-tests` trouvé dans les fiches — la "
"disparition de la colonne « Job CI » est elle-même une régression.")
# ============================================================================
# Fiches agents · colonne « Entrée CLI » — le JEU DE SOUS-COMMANDES documenté
# DOIT être l'ensemble EXACT des subparsers argparse du générateur (même classe
# de « vert trompeur », côté surface d'invocation du module).
# ----------------------------------------------------------------------------
# Chaque table de livrables des fiches 03_agents/*/AGENT.md porte une colonne
# « Entrée CLI » qui cite, entre backticks, le point d'entrée du module ET son
# jeu de verbes : `publiciste.py parse|validate|generate|run`,
# `faisabilite_gen.py score|scaffold|generate|batch`, `rbac_apply_plan.py
# build|validate`, … Cette liste est SAISIE À LA MAIN. La source de vérité est
# l'ensemble des `subparsers.add_parser("<verbe>")` du script réel. Les gates
# amont couvrent le COMPTE de tests (colonne « Tests »), le NOM du job CI
# (colonne « Job CI ») et la reproductibilité des artefacts `out/` — mais RIEN
# ne vérifiait que les VERBES écrits dans la fiche sont ceux que le CLI expose
# réellement. Renommer une sous-commande (`generate` → `render`), en AJOUTER une
# (`publish`), en RETIRER une ou les RÉORDONNER laisse la fiche documenter une
# surface d'invocation FANTÔME : le module tourne (le CI appelle le vrai verbe),
# la doc d'identité de l'agent — celle qui lui dit COMMENT lancer son propre
# livrable — ment en silence. Aucune suite tests/ (FONCTIONS du module, jamais
# la prose de fiche) ne l'attrape ; classe explicitement notée en mémoire
# (`agent-fiche-role-attrs-ungated`) : la fiche restitue une donnée data-derived
# (ici la CLI) sur une surface distincte de tout gate.
#
# On RECOMPUTE l'ensemble ordonné des sous-commandes depuis l'AST du script
# (jamais une liste à la main · zéro chiffre/verbe inventé · #6) et on exige
# l'égalité EXACTE (ensemble ET ordre — l'ordre de `add_parser` est celui de
# l'aide argparse) avec ce que la fiche ÉCRIT. Le script est résolu par le
# chemin du module de la MÊME ligne de table (lien `…/05_deliverables_mvp/<path>/
# README.md`) + le basename du CLI → `{D}/<path>/<file>.py`, ce qui lie chaque
# cellule CLI à SON module (pas de collision de basename). Direction fiche→CLI :
# la fiche ne peut pas mentir sur les verbes que le script expose.
# ============================================================================
def _cli_subcommands(pyfile):
"""Sous-commandes argparse d'un script, en ordre source (via AST)."""
src = open(pyfile, encoding="utf-8").read()
tree = ast.parse(src, filename=pyfile)
hits = []
for node in ast.walk(tree):
if (isinstance(node, ast.Call)
and isinstance(node.func, ast.Attribute)
and node.func.attr == "add_parser"
and node.args
and isinstance(node.args[0], ast.Constant)
and isinstance(node.args[0].value, str)):
hits.append((node.lineno, node.col_offset, node.args[0].value))
hits.sort()
return [v for _, _, v in hits]
# Ligne de table portant À LA FOIS le lien module (…/<path>/README.md) et une
# cellule CLI `\`<file>.py <verbe>|<verbe>|…\`` → on scope aux vraies lignes de
# table (une mention CLI en prose, sans lien module, est ignorée). Les `|`
# séparateurs de colonnes markdown sont échappés `\|` dans la cellule.
cli_pathre = re.compile(r"05_deliverables_mvp/([\w/]+?)/README\.md")
cli_cellre = re.compile(r"`([a-z][a-z0-9_]*\.py) ([a-z][a-z|\\]+)`")
fiches_cli = subprocess.run(
["git", "ls-files", "03_agents/*/AGENT.md"],
capture_output=True, text=True).stdout.split()
seen_cli = 0
for af in fiches_cli:
try:
flines = open(af, encoding="utf-8").read().splitlines()
except OSError as e:
bad(f"Entrée CLI fiches · {af} illisible : {e}"); continue
for ln, line in enumerate(flines, 1):
mp = cli_pathre.search(line)
mc = cli_cellre.search(line)
if not (mp and mc):
continue
path, cli_file, verbs_raw = mp.group(1), mc.group(1), mc.group(2)
# retirer les backslashes d'échappement avant de découper les verbes
claimed = verbs_raw.replace("\\", "").split("|")
pyfile = f"{D}/{path}/{cli_file}"
seen_cli += 1
try:
actual = _cli_subcommands(pyfile)
except FileNotFoundError:
bad(f"Entrée CLI · {af}:{ln} · `{cli_file}` — script INTROUVABLE à "
f"`{pyfile}` (module déplacé/renommé ? régénérer la fiche)")
continue
except SyntaxError as e:
bad(f"Entrée CLI · {af}:{ln} · `{cli_file}` — AST cassé ({e}) : "
f"source de vérité illisible")
continue
if not actual:
bad(f"Entrée CLI · {af}:{ln} · `{cli_file}` — la fiche cite les "
f"verbes {claimed} MAIS le script n'expose AUCUN "
f"`add_parser(...)` (CLI refondue ? régénérer la fiche)")
elif claimed == actual:
good(f"Entrée CLI · {af}:{ln} · `{cli_file}` — verbes "
f"{'|'.join(claimed)} == subparsers ({pyfile})")
else:
miss = [v for v in actual if v not in claimed]
extra = [v for v in claimed if v not in actual]
order_only = set(claimed) == set(actual)
bad(f"Entrée CLI · {af}:{ln} · `{cli_file}` — la fiche dit "
f"{claimed} MAIS le CLI expose {actual} "
f"(manquants={miss} · en trop={extra} · "
f"seul_ordre_diffère={order_only}) → régénérer la fiche")
if seen_cli == 0:
bad("Entrée CLI fiches · AUCUNE cellule CLI `<file>.py <verbes>` liée à un "
"module trouvée dans les fiches — la disparition de la colonne « Entrée "
"CLI » est elle-même une régression.")
# ============================================================================
# Legal · CONFOTUR — IDENTITÉ des ENTITÉS PORTEUSES ancrée sur CLAUDE.md §Entités
# ----------------------------------------------------------------------------
# Le champ Select `entite_porteuse` du DocType CONFOTUR Application — le menu
# déroulant qui fixe QUELLE entité juridique porte chaque dossier d'incitation —
# offre pour options les 7 entités canoniques du mandat (WAF · WA SRL · AC Arias
# Cuevas · Consortium ECR DR · Helios RD · Ploutos · 9060 QC). Ces options sont
# DÉRIVÉES via `options_source` de confotur_spec.json[entites] et byte-gatées pour
# la REPRODUCTIBILITÉ par check_artifacts (l'artefact se reconstruit depuis le
# spec) — mais JAMAIS ANCRÉES à CLAUDE.md §Entités, leur source faisant autorité.
# Les blocs CONFOTUR amont gatent le COMPTE de champs/rôles et la cross-cohérence
# des PERMISSIONS, aveugles à l'identité de cette liste d'entités.
# Piège #6 : RENOMMER une entité dans CLAUDE.md §Entités (9060 QC→9061 QC) ou dans
# confotur_spec.json[entites] puis régénérer laisse le DocType offrir un menu qui
# CONTREDIT/omet une entité canonique du mandat pendant que check_artifacts reste
# VERT (l'artefact se reconstruit… depuis un spec dérivé) — « vert trompeur » de
# la MÊME classe que la marque SEO org (§Entités) et les tokens branding (#4).
# Aucune suite tests/ (FONCTIONS de génération) ne l'attrape → l'agent ERPNext
# Backend importerait un DocType dont le déroulant liste une entité fantôme ou en
# omet une (la restriction même que le champ `reqd` pose).
# GATE : (a) options de l'artefact == entités CLAUDE.md §Entités (ORDRE exact) ;
# (b) spec[entites] == CLAUDE.md (l'INPUT ancré) ; (c) le spec DÉCLARE l'ancrage
# (entites_source cite CLAUDE.md + chaque entité) ; (d) wiring intact
# (options_source == "entites" · le déroulant DÉRIVE de la liste, pas d'un dur) ;
# (e) README cite l'ancre CLAUDE.md + nomme chaque entité (nom complet OU 1er
# token — l'abréviation « AC » de « AC Arias Cuevas » est un choix éditorial
# toléré · #6). Un claim absent échoue AUSSI (traçabilité #6).
# ============================================================================
try:
_cm_ce = open("CLAUDE.md", encoding="utf-8").read()
_sec_ce = re.search(r"^## Entités\s*\n(.*?)(?=^## )", _cm_ce, re.S | re.M)
if not _sec_ce:
raise ValueError("section « ## Entités » introuvable")
cm_ents = [_nfc(x.strip()) for x in re.findall(r"\*\*([^*]+?)\*\*", _sec_ce.group(1))]
if not cm_ents:
raise ValueError("aucune entité en gras dans §Entités")
if len(cm_ents) != len(set(cm_ents)):
raise ValueError(f"entités non uniques : {cm_ents}")
except (OSError, ValueError) as e:
bad(f"Confotur entités · CLAUDE.md §Entités illisible/inattendu : {e}"); cm_ents = None
try:
conf_spec_ce = load("legal/confotur/confotur_spec.json")
conf_dt_ce = load("legal/confotur/out/doctype_confotur_application.json")
ep_art = [f for f in conf_dt_ce.get("fields", []) if f.get("fieldname") == "entite_porteuse"]
ep_spec = None
for _grp in conf_spec_ce.get("field_groups", []):
for _f in _grp.get("fields", []):
if _f.get("fieldname") == "entite_porteuse":
ep_spec = _f
spec_ents = [_nfc(str(x).strip()) for x in conf_spec_ce.get("entites", [])]
ents_src = _nfc(str(conf_spec_ce.get("entites_source", "")))
except (OSError, KeyError, ValueError, TypeError) as e:
bad(f"Confotur entités · artefact/spec illisible/inattendu : {e}"); cm_ents = None
if cm_ents is not None:
# (a) ANCRAGE — options du Select de l'artefact == entités CLAUDE.md (ordre exact)
if len(ep_art) != 1:
bad(f"Confotur entités · champ `entite_porteuse` non-singleton dans l'artefact "
f"({len(ep_art)}) → identité du déroulant indéterminable")
else:
art_opts = [_nfc(o.strip()) for o in str(ep_art[0].get("options", "")).split("\n") if o.strip()]
if art_opts == cm_ents:
good(f"Confotur entités · (a) options DocType `entite_porteuse` "
f"({len(art_opts)}) == CLAUDE.md §Entités (ordre exact)")
else:
bad(f"Confotur entités · (a) options DocType {art_opts} ≠ CLAUDE.md §Entités "
f"{cm_ents} → menu périmé (régénérer depuis le spec / aligner CLAUDE.md)")
# (b) l'INPUT ancré — spec[entites] == CLAUDE.md (ordre exact)
if spec_ents == cm_ents:
good(f"Confotur entités · (b) spec[entites] == CLAUDE.md §Entités")
else:
bad(f"Confotur entités · (b) spec[entites] {spec_ents} ≠ CLAUDE.md §Entités "
f"{cm_ents} → l'input du générateur DÉRIVE du mandat")
# (c) le spec DÉCLARE l'ancrage : entites_source cite CLAUDE.md + chaque entité
if "CLAUDE.md" not in ents_src:
bad(f"Confotur entités · (c) `entites_source` NE CITE PAS CLAUDE.md "
f"(ancrage non déclaré) : {ents_src!r}")
else:
_miss_src = [e for e in cm_ents if e not in ents_src]
if _miss_src:
bad(f"Confotur entités · (c) `entites_source` cite CLAUDE.md mais OMET "
f"{_miss_src} → déclaration d'ancrage périmée")
else:
good(f"Confotur entités · (c) spec DÉCLARE l'ancrage (entites_source cite "
f"CLAUDE.md + les {len(cm_ents)} entités)")
# (d) wiring intact — le champ dérive ses options de la liste `entites`, pas d'un dur
if ep_spec is None:
bad("Confotur entités · (d) champ `entite_porteuse` ABSENT du spec (field_groups)")
elif ep_spec.get("options_source") != "entites":
bad(f"Confotur entités · (d) `entite_porteuse.options_source` = "
f"{ep_spec.get('options_source')!r} ≠ \"entites\" → déroulant DÉCROCHÉ de la "
f"liste ancrée (options potentiellement écrites en dur)")
else:
good("Confotur entités · (d) wiring intact (options_source == \"entites\")")
# (e) README cite l'ancre CLAUDE.md + nomme chaque entité (nom complet OU 1er token)
CONF_RD3 = f"{D}/legal/confotur/README.md"
try:
conf_rd3 = re.sub(r"\s+", " ", open(CONF_RD3, encoding="utf-8").read())
except OSError as e:
bad(f"Confotur entités · {CONF_RD3} illisible : {e}"); conf_rd3 = None
if conf_rd3 is not None:
_m_ep = re.search(r"`entite_porteuse`.{0,240}?9060 QC", conf_rd3)
if not _m_ep:
bad(f"Confotur entités · (e) ligne `entite_porteuse` INTROUVABLE dans "
f"{CONF_RD3} (claim absent = régression #6)")
else:
_epline = _m_ep.group(0)
if "CLAUDE.md" not in _epline:
bad(f"Confotur entités · (e) ligne `entite_porteuse` ne cite pas l'ancre "
f"« CLAUDE.md »")
else:
_miss_rd = []
for _e in cm_ents:
_first = _e.split()[0]
if _e in _epline or re.search(r"(?<![\w-])" + re.escape(_first) + r"(?![\w-])", _epline):
continue
_miss_rd.append(_e)
if _miss_rd:
bad(f"Confotur entités · (e) README omet {_miss_rd} de la ligne "
f"`entite_porteuse` (nom complet ni 1er token)")
else:
good(f"Confotur entités · (e) README cite CLAUDE.md + nomme les "
f"{len(cm_ents)} entités")
# ============================================================================
# Fiche ONAPI/Legal (03_agents/onapi_legal/AGENT.md:54) · 2e SURFACE PROSE des
# ENTITÉS PORTEUSES du DocType CONFOTUR — la fiche ÉNUMÈRE À LA MAIN « les 7
# entités de CLAUDE.md #Entités (WAF · WA SRL · AC Arias Cuevas · Consortium ECR
# DR · Helios RD · Ploutos · 9060 QC) ».
# ----------------------------------------------------------------------------
# Le bloc « Confotur entités » ci-dessus ancre le champ Select `entite_porteuse`
# (options de l'artefact · spec[entites] · entites_source · wiring) et gate la
# ligne du README confotur (surface e, ancrée sur `{D}/legal/confotur/README.md`).
# Mais la FICHE agent onapi_legal:54 est une SECONDE surface prose du MÊME menu — un
# COMPTE littéral « les 7 entités » ET l'énumération complète — qu'AUCUN bloc
# n'atteint (le motif (e) ne vise pas 03_agents/). Piège #6 : AJOUTER / RETIRER /
# RENOMMER une entité dans CLAUDE.md §Entités (source ancre) puis régénérer propage
# le menu de l'artefact ET la ligne du README confotur, MAIS la fiche onapi_legal
# reste PÉRIMÉE en silence : elle annoncerait « 7 entités » quand il y en a 8, ou
# nommerait `Ploutos` là où le menu ne l'offre plus — l'agent ERPNext Backend qui
# seede le DocType croirait le déroulant couvrir un périmètre d'entités qu'il ne
# couvre plus (l'invention #6 interdit). Aucune suite tests/ (FONCTIONS de build du
# DocType, pas la prose d'une fiche) n'attrape ce « vert trompeur » — même patron
# d'IDENTITÉ D'UNE LISTE que la marque SEO §Entités, la §4 du RBAC SPEC ou la ligne
# README (e) ci-dessus. On RE-DÉRIVE la liste depuis CLAUDE.md §Entités (source
# ancre · zéro duplication) et on exige de la fiche : (a) le COMPTE littéral ==
# |§Entités| ; (b) l'énumération == §Entités ORDRE EXACT (absent ET fantôme mordus
# par set-diff + comparaison ordonnée) ; (c) l'ancre « CLAUDE.md #Entités » citée ;
# (d) cross-check bonus — la liste de la fiche == options `entite_porteuse` de
# l'artefact byte-gaté (la fiche décrit EXACTEMENT le menu que le DocType offrira).
# Ligne absente = échec (traçabilité #6).
# ============================================================================
LEGAL_FI = "03_agents/onapi_legal/AGENT.md"
try:
_cm_le = open("CLAUDE.md", encoding="utf-8").read()
_sec_le = re.search(r"^## Entités\s*\n(.*?)(?=^## )", _cm_le, re.S | re.M)
if not _sec_le:
raise ValueError("section « ## Entités » introuvable")
le_ents = [_nfc(x.strip()) for x in re.findall(r"\*\*([^*]+?)\*\*", _sec_le.group(1))]
if not le_ents:
raise ValueError("aucune entité en gras dans §Entités")
if len(le_ents) != len(set(le_ents)):
raise ValueError(f"entités non uniques : {le_ents}")
except (OSError, ValueError) as e:
bad(f"Fiche ONAPI entités · CLAUDE.md §Entités illisible/inattendu : {e}"); le_ents = None
# menu RÉEL de l'artefact byte-gaté (cross-check d)
try:
_cf_dt_le = load("legal/confotur/out/doctype_confotur_application.json")
_ep_le = [f for f in _cf_dt_le.get("fields", []) if f.get("fieldname") == "entite_porteuse"]
le_art_opts = ([_nfc(o.strip()) for o in str(_ep_le[0].get("options", "")).split("\n") if o.strip()]
if len(_ep_le) == 1 else None)
except (OSError, KeyError, ValueError, TypeError) as e:
bad(f"Fiche ONAPI entités · artefact confotur illisible/inattendu : {e}"); le_art_opts = None
le_txt = read_norm(LEGAL_FI)
if le_ents is not None and le_txt is not None:
m_le = re.search(r"\*\*`entite_porteuse`\*\*.{0,60}?les (\d+) entités de (.+?)\((.+?)\)",
le_txt)
if m_le is None:
bad(f"Fiche ONAPI entités · {LEGAL_FI} — ligne `entite_porteuse` "
f"(« les N entités de CLAUDE.md #Entités (…) ») INTROUVABLE/périmée "
f"(claim absent = régression #6)")
else:
# (a) COMPTE littéral == |§Entités|
n_claim = int(m_le.group(1))
if n_claim == len(le_ents):
good(f"Fiche ONAPI entités · (a) compte « {n_claim} entités » == "
f"CLAUDE.md §Entités")
else:
bad(f"Fiche ONAPI entités · (a) fiche dit « {n_claim} entités » MAIS "
f"CLAUDE.md §Entités en porte {len(le_ents)} → fiche périmée, régénérer")
# (b) énumération == §Entités, ORDRE EXACT (absent ET fantôme mordus)
fi_ents = [_nfc(x.strip()) for x in m_le.group(3).split("·")]
if fi_ents == le_ents:
good(f"Fiche ONAPI entités · (b) énumération == CLAUDE.md §Entités "
f"(ordre exact · {len(fi_ents)})")
else:
_abs = [e for e in le_ents if e not in fi_ents]
_pha = [e for e in fi_ents if e not in le_ents]
_detail = []
if _abs:
_detail.append(f"absent(s)={_abs}")
if _pha:
_detail.append(f"fantôme(s)={_pha}")
if not _detail:
_detail.append(f"ordre : fiche {fi_ents} ≠ §Entités {le_ents}")
bad(f"Fiche ONAPI entités · (b) énumération ≠ CLAUDE.md §Entités "
f"({' · '.join(_detail)}) → aligner la fiche")
# (c) l'ancre « CLAUDE.md #Entités » est bien citée sur la ligne
if "CLAUDE.md" in m_le.group(2) and "#Entités" in m_le.group(2):
good(f"Fiche ONAPI entités · (c) ancre « CLAUDE.md #Entités » citée")
else:
bad(f"Fiche ONAPI entités · (c) la ligne `entite_porteuse` ne cite pas "
f"l'ancre « CLAUDE.md #Entités » (source={m_le.group(2)!r})")
# (d) cross-check bonus — la liste de la fiche == menu RÉEL de l'artefact
if le_art_opts is None:
bad(f"Fiche ONAPI entités · (d) champ `entite_porteuse` non-singleton "
f"dans l'artefact → menu réel indéterminable")
elif fi_ents == le_art_opts:
good(f"Fiche ONAPI entités · (d) énumération == options `entite_porteuse` "
f"de l'artefact (la fiche décrit le menu réel du DocType)")
else:
bad(f"Fiche ONAPI entités · (d) fiche {fi_ents} ≠ menu artefact "
f"{le_art_opts} → la fiche décrit un menu que le DocType n'offre pas")
# ============================================================================
# Chat OTOIA · l'IDENTITÉ de la persona (`Amélie` / voix `multilingual_v2`) et
# des 4 CAPABILITIES OTOIA (`aec.py` + `knowledge.py` + `prompt_engine.py` +
# `chat.py`) — ANCRÉES sur CLAUDE.md §Architecture cible, jamais gatées.
# ----------------------------------------------------------------------------
# out/chat_mount.json (5 configs runtime, une par portail) + out/MANIFEST.json
# sont byte-gatés par check_artifacts : la reproductibilité prouve qu'ils SE
# RECONSTRUISENT depuis chat_spec.json, PAS que leur persona/capabilities ==
# ce que NOMME CLAUDE.md §Architecture cible (« Voix Amélie QC (multilingual_v2) »
# · « OTOIA capabilities : aec.py + knowledge.py + prompt_engine.py + chat.py »).
# Le bloc chat_otoia amont gate la COMPOSITION (portails métier · 1 block⇔1 mount).
# Le SEUL contrôle d'identité vit dans tests/ (test_persona_amelie_sourcee /
# test_capabilities_sourcees_sans_ajout) mais son ORACLE est HARDCODÉ
# (`"Amélie"`, `["aec.py",…]`) — une copie de plus, JAMAIS comparée à CLAUDE.md.
# Piège #6 : renommer la persona (`Amélie`→`Sophie`), changer la voix
# (`multilingual_v2`→…), ou renommer/retirer une capability dans chat_spec.json
# (ou dans CLAUDE.md §Architecture cible) laisse le chat monté dans CHAQUE portail
# annoncer une persona/voix/capabilities qui CONTREDIT le mandat pendant que la
# byte-gate ET les tests restent VERTS (l'artefact se reconstruit fidèlement…
# depuis un spec dérivé ; l'oracle du test est une copie de plus) — « vert
# trompeur » de la classe des tokens branding ancrés sur CLAUDE.md #4 et de la
# marque SEO §Entités. On RE-DÉRIVE persona+capabilities de CLAUDE.md
# §Architecture cible (source unique) et on exige : (a) chat_spec.json
# persona/capabilities == CLAUDE.md ; (b) le spec DÉCLARE l'ancrage (persona.source
# + chaque capability.source citent CLAUDE.md) ; (c) CHACUN des 5 mounts + le
# MANIFEST portent persona/capabilities == CLAUDE.md ; (d) le README cite l'ancre
# §Architecture cible + nomme persona + chaque capability ; (e) l'ORACLE du test
# == CLAUDE.md (set-diff : absent ET en trop) — sinon la copie de copie survit à
# un renommage. État courant : aucune valeur périmée — le défaut est l'ancrage
# ungated.
# ============================================================================
_nfc = lambda s: unicodedata.normalize("NFC", str(s)).strip()
CHAT = f"{D}/frontend/chat_otoia"
try:
_cm = open("CLAUDE.md", encoding="utf-8").read()
_mp = re.search(r"Voix\s+(.+?)\s+QC\s*\(([^)]+)\)", _cm)
_mc = re.search(r"OTOIA capabilities\s*:\s*(.+)", _cm)
if not _mp or not _mc:
raise ValueError("« Voix … QC (…) » ou « OTOIA capabilities : … » "
"INTROUVABLE dans CLAUDE.md §Architecture cible")
cm_persona = {"nom": _nfc(_mp.group(1)), "voix": _nfc(_mp.group(2))}
cm_caps = [c.strip() for c in _mc.group(1).split("+") if c.strip()]
if len(cm_caps) < 2:
raise ValueError(f"capabilities CLAUDE.md mal formées : {cm_caps}")
except (OSError, ValueError) as e:
bad(f"Chat OTOIA · persona/capabilities CLAUDE.md §Architecture cible "
f"illisible/inattendu : {e}")
cm_persona = cm_caps = None
try:
chat_spec = json.load(open(f"{CHAT}/chat_spec.json", encoding="utf-8"))
chat_mounts = load("frontend/chat_otoia/out/chat_mount.json")
chat_man = load("frontend/chat_otoia/out/MANIFEST.json")
except (OSError, KeyError, ValueError) as e:
bad(f"Chat OTOIA · spec/artefacts illisibles/inattendus : {e}")
chat_spec = None
if cm_persona is not None and chat_spec is not None:
# (a) ANCRAGE — chat_spec.json persona/capabilities == CLAUDE.md §Architecture cible
sp_persona = {"nom": _nfc(chat_spec["persona"].get("nom", "")),
"voix": _nfc(chat_spec["persona"].get("voix", ""))}
sp_caps = [str(c.get("module", "")).strip() for c in chat_spec.get("capabilities", [])]
if sp_persona == cm_persona:
good(f"Chat OTOIA · (a) spec persona {sp_persona} == CLAUDE.md §Architecture cible")
else:
bad(f"Chat OTOIA · (a) spec persona {sp_persona} ≠ CLAUDE.md {cm_persona} "
f"→ l'assistant monté contredirait le mandat (aligner spec ou CLAUDE.md)")
if sp_caps == cm_caps:
good(f"Chat OTOIA · (a) spec capabilities {sp_caps} == CLAUDE.md (ordre exact)")
else:
bad(f"Chat OTOIA · (a) spec capabilities {sp_caps} ≠ CLAUDE.md {cm_caps} "
f"→ surface de capacités inventée/périmée")
# (b) le spec DÉCLARE l'ancrage : persona.source + chaque capability.source citent CLAUDE.md
_psrc = str(chat_spec["persona"].get("source", ""))
if "CLAUDE.md" not in _psrc or cm_persona["nom"] not in _nfc(_psrc):
bad(f"Chat OTOIA · (b) chat_spec persona.source ne DÉCLARE pas l'ancrage "
f"CLAUDE.md/{cm_persona['nom']!r} : {_psrc!r}")
else:
_bad_src = [c.get("module") for c in chat_spec["capabilities"]
if "CLAUDE.md" not in str(c.get("source", ""))]
if _bad_src:
bad(f"Chat OTOIA · (b) capabilities sans `source` CLAUDE.md : {_bad_src}")
else:
good("Chat OTOIA · (b) spec DÉCLARE l'ancrage CLAUDE.md (persona + chaque capability)")
# (c) CHAQUE mount + le MANIFEST portent persona/capabilities == CLAUDE.md
_bad_m = []
for m in chat_mounts:
pm = {"nom": _nfc((m.get("persona") or {}).get("nom", "")),
"voix": _nfc((m.get("persona") or {}).get("voix", ""))}
if pm != cm_persona or list(m.get("capabilities", [])) != cm_caps:
_bad_m.append(m.get("portail"))
man_ok = ({"nom": _nfc((chat_man.get("persona") or {}).get("nom", "")),
"voix": _nfc((chat_man.get("persona") or {}).get("voix", ""))} == cm_persona
and list(chat_man.get("capabilities", [])) == cm_caps)
if not chat_mounts:
bad("Chat OTOIA · (c) aucun mount → artefact suspect")
elif _bad_m:
bad(f"Chat OTOIA · (c) mount(s) {_bad_m} persona/capabilities ≠ CLAUDE.md "
f"→ chat monté hors mandat")
elif not man_ok:
bad("Chat OTOIA · (c) MANIFEST persona/capabilities ≠ CLAUDE.md")
else:
good(f"Chat OTOIA · (c) {len(chat_mounts)} mounts + MANIFEST portent "
f"persona/capabilities == CLAUDE.md")
# (d) README cite l'ancre §Architecture cible + nomme persona + chaque capability
CHAT_RD = f"{CHAT}/README.md"
chat_rd = read_norm(CHAT_RD)
if chat_rd is not None:
chat_rd_n = _nfc(chat_rd)
_mp_rd = re.search(r"Persona.*?§Architecture cible", chat_rd_n)
_mc_rd = re.search(r"Capabilities.*?§Architecture cible", chat_rd_n)
if not _mp_rd:
bad(f"Chat OTOIA · (d) ligne « Persona … §Architecture cible » INTROUVABLE "
f"dans {CHAT_RD} (claim absent = régression #6)")
elif cm_persona["nom"] not in _mp_rd.group(0) or cm_persona["voix"] not in _mp_rd.group(0):
bad(f"Chat OTOIA · (d) README ligne persona omet {cm_persona['nom']!r}/"
f"{cm_persona['voix']!r}")
elif not _mc_rd:
bad(f"Chat OTOIA · (d) ligne « Capabilities … §Architecture cible » INTROUVABLE "
f"dans {CHAT_RD} (claim absent = régression #6)")
else:
_stems = [c[:-3] if c.endswith(".py") else c for c in cm_caps]
_miss = [s for s in _stems
if not re.search(r"(?<![\w.])" + re.escape(s) + r"(?![\w])", _mc_rd.group(0))]
if _miss:
bad(f"Chat OTOIA · (d) README ligne capabilities omet {_miss}")
else:
good(f"Chat OTOIA · (d) README cite §Architecture cible + persona + "
f"{len(cm_caps)} capabilities")
# (e) l'ORACLE du test == CLAUDE.md (sinon la copie de copie survit à un renommage)
CHAT_TEST = f"{CHAT}/tests/test_chat_otoia.py"
try:
_t = open(CHAT_TEST, encoding="utf-8").read()
except OSError as e:
bad(f"Chat OTOIA · (e) {CHAT_TEST} illisible : {e}"); _t = None
if _t is not None:
_mtp = re.search(r'spec\["persona"\]\["nom"\],\s*"([^"]+)"', _t)
_mtv = re.search(r'spec\["persona"\]\["voix"\],\s*"([^"]+)"', _t)
_mtc = re.search(r"caps,\s*\[([^\]]*)\]", _t)
if not (_mtp and _mtv and _mtc):
bad(f"Chat OTOIA · (e) oracle persona/capabilities INTROUVABLE dans {CHAT_TEST} "
f"(claim absent = régression #6)")
else:
t_caps = [x.strip().strip("\"'") for x in _mtc.group(1).split(",") if x.strip()]
if _nfc(_mtp.group(1)) != cm_persona["nom"] or _nfc(_mtv.group(1)) != cm_persona["voix"]:
bad(f"Chat OTOIA · (e) oracle du test persona "
f"{{{_mtp.group(1)!r},{_mtv.group(1)!r}}} ≠ CLAUDE.md {cm_persona} "
f"→ copie de copie périmée")
elif t_caps != cm_caps:
bad(f"Chat OTOIA · (e) oracle du test capabilities {t_caps} ≠ CLAUDE.md "
f"{cm_caps}")
else:
good("Chat OTOIA · (e) oracle du test persona/capabilities == CLAUDE.md")
# ============================================================================
# Fiches agents · le PIED DE PAGE canonique « Voix Amélie QC (multilingual_v2) »
# répété dans CHAQUE fiche 03_agents/*/AGENT.md — ANCRÉ sur CLAUDE.md
# §Architecture cible, jamais gaté.
# ----------------------------------------------------------------------------
# 11 des 13 fiches agents (toutes sauf faisabilite/publiciste, qui n'orchestrent
# pas OTOIA) se terminent par la ligne « - Voix <persona> <qualif> (<voix>) pour
# toute interaction OTOIA ». Elle RECOPIE À LA MAIN les TROIS tokens de la
# contrainte CLAUDE.md §Architecture cible « Voix Amélie QC (multilingual_v2) » :
# le prénom de la persona (`Amélie`), le qualificatif de marque (`QC`) et le
# modèle de voix ElevenLabs (`multilingual_v2`). Le bloc « Chat OTOIA · IDENTITÉ
# de la persona » (ci-dessus) RE-DÉRIVE bien persona+voix de CLAUDE.md, MAIS ne
# confronte QUE le module chat_otoia (spec + out/ + son propre README + l'oracle
# du test) — AVEUGLE aux 11 pieds de page des fiches. L'autre bloc (« Chat OTOIA ·
# IDENTITÉ des configs runtime ») note même explicitement que le « QC » est
# « gardé en littéral — non gaté ici ». Aucune suite tests/ (FONCTIONS du builder,
# jamais la prose des fiches) n'attrape ce « vert trompeur ».
# Piège #6 : le jour où ElevenLabs déprécie `multilingual_v2` (→ `multilingual_v3`)
# ou qu'on renomme la persona/le qualificatif dans CLAUDE.md, ces 11 pieds de page
# annonceraient EN SILENCE une voix/persona que le mandat ne prescrit plus, chaque
# fiche affirmant orchestrer « toute interaction OTOIA » avec le mauvais modèle.
# On RE-DÉRIVE le triplet (persona · qualif · voix) de CLAUDE.md §Architecture cible
# (source unique · zéro duplication — mêmes `nom`/`voix` que le bloc persona
# ci-dessus, plus le qualificatif jusqu'ici jamais ancré) et on exige de CHAQUE
# fiche portant le pied de page que ses trois tokens == CLAUDE.md. Direction
# fiche→CLAUDE.md. La disparition TOTALE du pied de page échoue AUSSI (le
# recensement qui s'évapore est lui-même une régression #6). État courant : aucune
# valeur périmée.
# ============================================================================
if True:
# RE-DÉRIVATION INDÉPENDANTE de CLAUDE.md (ne dépend PAS du parse persona
# ci-dessus, dont la regex fige « QC » en dur : un renommage du qualificatif
# doit mordre ICI, pas seulement via l'INTROUVABLE du bloc persona).
try:
_cm_af = open("CLAUDE.md", encoding="utf-8").read()
# triplet canonique : « Voix <nom> <qualif> (<voix>) » (qualif jamais figé en dur)
_maf = re.search(r"Voix\s+(\S+)\s+(\S+)\s*\(([^)]+)\)", _cm_af)
if _maf is None:
raise ValueError("« Voix <persona> <qualif> (<voix>) » INTROUVABLE "
"dans CLAUDE.md §Architecture cible")
af_nom, af_qual, af_voix = (_nfc(_maf.group(1)), _nfc(_maf.group(2)),
_nfc(_maf.group(3)))
# cohérence avec le bloc persona quand il a pu parser (zéro duplication —
# même source, mêmes valeurs) ; s'il a échoué (cm_persona None), on continue.
if cm_persona is not None and (af_nom != cm_persona["nom"]
or af_voix != cm_persona["voix"]):
raise ValueError(f"triplet {af_nom!r}/{af_voix!r} incohérent avec le bloc "
f"persona {cm_persona} (parse divergent de CLAUDE.md)")
except (OSError, ValueError) as e:
bad(f"Fiches agents · pied de page voix CLAUDE.md §Architecture cible "
f"illisible/inattendu : {e}")
af_nom = None
if af_nom is not None:
_fiches = subprocess.run(["git", "ls-files", "03_agents/*/AGENT.md"],
capture_output=True, text=True).stdout.split()
_foot_re = re.compile(
r"Voix\s+(\S+)\s+(\S+)\s*\(([^)]+)\)\s+pour toute interaction OTOIA")
af_seen = 0
for _fi in _fiches:
try:
_ftxt = open(_fi, encoding="utf-8").read()
except OSError as e:
bad(f"Fiches agents · {_fi} illisible : {e}"); continue
_mfoot = _foot_re.search(_nfc(_ftxt))
# Fiche sans OTOIA (faisabilite/publiciste) : pas de pied de page attendu.
if _mfoot is None:
if "interaction OTOIA" in _ftxt:
bad(f"Fiches agents · {_fi} — prose « interaction OTOIA » présente "
f"MAIS pied de page « Voix <nom> <qualif> (<voix>) » malformé/"
f"INTROUVABLE (attendu {af_nom} {af_qual} ({af_voix}))")
continue
af_seen += 1
got = (_nfc(_mfoot.group(1)), _nfc(_mfoot.group(2)), _nfc(_mfoot.group(3)))
want = (af_nom, af_qual, af_voix)
if got == want:
good(f"Fiches agents · {_fi} — pied de page « Voix {got[0]} {got[1]} "
f"({got[2]}) » == CLAUDE.md §Architecture cible")
else:
bad(f"Fiches agents · {_fi} — pied de page dit « {got[0]} {got[1]} "
f"({got[2]}) » MAIS CLAUDE.md §Architecture cible dit « {want[0]} "
f"{want[1]} ({want[2]}) » → persona/qualif/voix périmé (mettre la "
f"fiche à jour)")
if af_seen == 0:
bad("Fiches agents · AUCUNE fiche ne porte le pied de page « Voix "
f"{af_nom} {af_qual} ({af_voix}) pour toute interaction OTOIA » — la "
f"surface d'identité voix OTOIA s'est évaporée (régression #6)")
# ============================================================================
# Mobile · le SDK MAJEUR Expo (« Expo 54 ») — ANCRÉ sur la roadmap Sprint 5 l.56,
# transcrit À LA MAIN dans le README sans AUCUN gate d'IDENTITÉ ni d'ancrage.
# ----------------------------------------------------------------------------
# La version MAJEURE du SDK Expo que l'app compagnon cible (`expoSdkMajor`) est une
# donnée DATA-DERIVED : le générateur émet out/app_config.json (byte-gaté par
# check_artifacts ⇒ faisant autorité) portant `expo.extra.expoSdkMajor`, recopié
# dans out/MANIFEST.json (`app.expo_sdk_major`), lui-même dérivé de
# mobile_spec.json (`app.expo_sdk_major`) dont la SOURCE FAISANT AUTORITÉ est la
# roadmap Sprint 5 l.56 « **Rebuild Expo 54** » (le spec le DÉCLARE via
# `app.expo_sdk_source`). Le README:4/19/35/72 recopie ce « Expo 54 » CINQ fois À
# LA MAIN — dont la ligne de source « Expo SDK **54** | roadmap Sprint 5 l.56 ».
# Le bloc « Mobile · récap de l'app Expo » amont ne gate QUE le quadruplet
# onglets/rôles/langues/identifiants a_confirmer (comptes de MANIFEST.counts) —
# AVEUGLE au majeur du SDK. Aucun gate ne comparait cette valeur à l'artefact ni
# ne l'ancrait à la roadmap : BUMPER le SDK (Expo 55 sort · EAS build cible 55)
# dans le spec/artefact SANS toucher au README (ou l'inverse), ou dériver la
# roadmap de l'artefact, laisse la prose PÉRIMÉE en silence pendant que le graphe
# byte-gaté dit autre chose — l'agent Mobile lancerait `eas build` sur le mauvais
# SDK (le rebuild même que la ligne roadmap prescrit) — « vert trompeur » qu'aucune
# suite tests/ (qui teste des FONCTIONS de génération, jamais la prose ni l'ancre à
# la roadmap) n'attrape. Même patron que l'ancrage §Entités/CLAUDE.md (SEO org) ou
# les `roadmap_line` de la recette : on RE-DÉRIVE le majeur de SON artefact
# byte-gaté (zéro duplication du contrat #6), on ANCRE à la roadmap faisant
# autorité, et on exige que TOUTE mention prose == cette valeur. Cohérences
# croisées bonus (mordent une chaîne INTERNEMENT incohérente) : artefact ⇔ MANIFEST
# ⇔ spec d'accord ; le spec DÉCLARE l'ancre roadmap. Un claim absent échoue AUSSI.
# ============================================================================
MOB_SDK_RD = f"{D}/mobile/app_config/README.md"
try:
sdk = int(load("mobile/app_config/out/app_config.json")["expo"]["extra"]["expoSdkMajor"])
_mman = load("mobile/app_config/out/MANIFEST.json")
_mspec = json.load(open(f"{D}/mobile/app_config/mobile_spec.json", encoding="utf-8"))
except (OSError, KeyError, ValueError, TypeError) as e:
bad(f"Mobile Expo SDK · artefact/spec illisible/inattendu : {e}"); sdk = None
if sdk is not None:
# (a) cohérence interne : artefact ⇔ MANIFEST ⇔ spec d'accord sur le majeur
man_sdk = _mman.get("app", {}).get("expo_sdk_major")
spec_sdk = _mspec.get("app", {}).get("expo_sdk_major")
if man_sdk != sdk or spec_sdk != sdk:
bad(f"Mobile Expo SDK · incohérence artefact={sdk} MANIFEST={man_sdk} "
f"spec={spec_sdk} → chaîne de dérivation cassée")
else:
good(f"Mobile Expo SDK · artefact ⇔ MANIFEST ⇔ spec d'accord (Expo {sdk})")
# (b) ANCRAGE roadmap : la roadmap faisant autorité dit « Rebuild Expo <N> »
try:
_rm = re.sub(r"\s+", " ",
open("04_roadmap/ROADMAP_8_WEEKS_OR_LESS.md", encoding="utf-8").read())
except OSError as e:
bad(f"Mobile Expo SDK · roadmap illisible : {e}"); _rm = None
if _rm is not None:
_rmm = re.search(r"Rebuild Expo (\d+)", _rm)
if _rmm is None:
bad(f"Mobile Expo SDK · « Rebuild Expo <N> » INTROUVABLE dans la roadmap "
f"(attendu {sdk}) → ancre morte")
elif int(_rmm.group(1)) == sdk:
good(f"Mobile Expo SDK · roadmap « Rebuild Expo {sdk} » == artefact (ancre vive)")
else:
bad(f"Mobile Expo SDK · roadmap dit « Rebuild Expo {_rmm.group(1)} » MAIS "
f"artefact dit {sdk} → régénérer depuis la roadmap")
# (c) le spec DÉCLARE son ancrage roadmap (expo_sdk_source cite roadmap + Expo N)
_ssrc = str(_mspec.get("app", {}).get("expo_sdk_source", ""))
if "roadmap" not in _ssrc.lower() or re.search(rf"Expo {sdk}\b", _ssrc) is None:
bad(f"Mobile Expo SDK · spec.app.expo_sdk_source n'ancre pas roadmap+Expo {sdk} "
f"(={_ssrc!r}) → ancrage déclaré manquant")
else:
good("Mobile Expo SDK · spec DÉCLARE l'ancrage roadmap (expo_sdk_source)")
# (d) README prose : TOUTE mention « Expo [SDK] N » == artefact (aucune périmée)
mob_sdk_rd = read_norm(MOB_SDK_RD)
if mob_sdk_rd is not None:
nums = [int(x) for x in re.findall(r"Expo(?:\s+SDK)?\s*\*{0,2}(\d+)", mob_sdk_rd)]
if not nums:
bad(f"Mobile Expo SDK · {MOB_SDK_RD} — aucune mention « Expo <N> » "
f"(attendu {sdk}) · claim absent = régression #6")
else:
stale = sorted({n for n in nums if n != sdk})
if stale:
bad(f"Mobile Expo SDK · {MOB_SDK_RD} — mention(s) périmée(s) Expo {stale} "
f"MAIS artefact {sdk} → régénérer le README")
else:
good(f"Mobile Expo SDK · {MOB_SDK_RD} — {len(nums)} mention(s) « Expo {sdk} » "
f"== artefact")
# (e) la ligne de source du tableau NOMME l'ancre roadmap à côté du majeur
if re.search(rf"Expo SDK \*\*{sdk}\*\*[^|]*\|[^|]*roadmap", mob_sdk_rd) is None:
bad(f"Mobile Expo SDK · {MOB_SDK_RD} — la ligne « Expo SDK **{sdk}** » ne cite "
f"pas son ancre `roadmap` (Sprint 5 l.56) → ancrage prose manquant")
else:
good(f"Mobile Expo SDK · {MOB_SDK_RD} — « Expo SDK **{sdk}** » cite l'ancre roadmap")
# ============================================================================
# Fiches agents + master prompt · IDENTITÉ de l'endpoint GPU RunPod ComfyUI —
# le pod `t68akvx80wv092` (Flux dev fp8) cité DOIT == l'inventaire maître
# AGENTS_EXISTING_ASSETS.md (même classe de « vert trompeur », côté infra GPU).
# ----------------------------------------------------------------------------
# Les fiches BIM/Rendu et le master prompt NOMMENT, entre backticks, l'ID du pod
# RunPod ComfyUI qui exécute le rendu Flux (`t68akvx80wv092`) + le tag du modèle
# (« Flux dev fp8 »). Ces valeurs sont SAISIES À LA MAIN ; la source de vérité
# in-repo est le bullet §1 de AGENTS_EXISTING_ASSETS.md — « le fichier maître
# en-repo » que les fiches CITENT explicitement comme leur ancre. Aucun gate ne
# couvrait ce lien : check_docs ne voit qu'un lien Markdown, guard_constraints
# qu'un terme interdit, check_artifacts qu'un out/ — un ID de pod n'est aucun des
# trois. Or un pod RunPod est RE-CRÉÉ avec un NOUVEL id (le cas le plus courant) :
# l'inventaire maître se met à jour, les 6 citations en fiches se PÉRIMENT en
# silence → l'agent Rendu/BIM câblerait le batch Flux vers un endpoint GPU MORT
# (l'invention même que #6 interdit). Aucune suite tests/ (FONCTIONS du module,
# jamais la prose de fiche) ne l'attrape ; classe notée en mémoire
# (`agent-fiche-role-attrs-ungated`) : la fiche restitue une donnée data-derived
# (ici l'infra GPU) sur une surface distincte de tout gate.
#
# On RECOMPUTE l'id + le tag depuis le bullet maître (jamais une liste à la main
# · #6). Direction fiche→inventaire (le consommateur) : toute fiche/master prompt
# qui MENTIONNE RunPod doit citer l'id EXACT (tout token backtické de forme pod —
# alphanum minuscule ≥12 avec un chiffre — == canonique) + le tag Flux EXACT. La
# disparition TOTALE de la surface (plus aucune mention RunPod) échoue AUSSI.
# ============================================================================
RUNPOD_SRC = "AGENTS_EXISTING_ASSETS.md"
try:
_rp_src = re.sub(r"\s+", " ", open(RUNPOD_SRC, encoding="utf-8").read())
except OSError as e:
bad(f"RunPod endpoint · {RUNPOD_SRC} illisible : {e}"); _rp_src = None
if _rp_src is not None:
_rp_hits = re.findall(
r"RunPod ComfyUI\*\*\s*endpoint\s*`([a-z0-9]+)`\s*\((Flux[^)]+)\)", _rp_src)
if len(_rp_hits) != 1:
bad(f"RunPod endpoint · bullet canonique INTROUVABLE ou en double dans "
f"{RUNPOD_SRC} (attendu 1, trouvé {len(_rp_hits)}) → source d'ancrage "
f"cassée, impossible de recomputer l'id du pod GPU")
else:
pod_id, flux_tag = _rp_hits[0]
flux_tag = flux_tag.strip()
flux_norm = re.sub(r"\s+", " ", flux_tag).lower()
pod_shape = re.compile(r"`([a-z0-9]{12,})`")
rp_files = subprocess.run(
["git", "ls-files", "03_agents/*/AGENT.md"],
capture_output=True, text=True).stdout.split()
rp_files.append("02_master_prompt/MASTER_PROMPT_DTP_v2.md")
rp_citers = 0
for rf in rp_files:
try:
rtxt = re.sub(r"\s+", " ", open(rf, encoding="utf-8").read())
except OSError as e:
bad(f"RunPod endpoint · {rf} illisible : {e}"); continue
if "RunPod" not in rtxt:
continue # fiche ne cite pas l'infra GPU (ok)
rp_citers += 1
toks = [t for t in pod_shape.findall(rtxt) if re.search(r"\d", t)]
if not toks:
bad(f"RunPod endpoint · {rf} mentionne RunPod mais ne cite AUCUN id "
f"de pod backtické (attendu `{pod_id}`) → citation incomplète")
else:
stale = sorted({t for t in toks if t != pod_id})
if stale:
bad(f"RunPod endpoint · {rf} cite l'id périmé {stale} MAIS "
f"l'inventaire maître dit `{pod_id}` → pod re-créé / mal "
f"recopié, endpoint GPU fantôme (régénérer la fiche)")
else:
good(f"RunPod endpoint · {rf} · {len(toks)} citation(s) "
f"`{pod_id}` == {RUNPOD_SRC}")
if flux_norm not in rtxt.lower():
bad(f"RunPod endpoint · {rf} cite le pod mais PAS le tag modèle "
f"« {flux_tag} » (== {RUNPOD_SRC}) → tag Flux périmé/absent")
else:
good(f"RunPod endpoint · {rf} · tag modèle « {flux_tag} » == inventaire")
if rp_citers == 0:
bad(f"RunPod endpoint · AUCUNE fiche/master prompt ne mentionne RunPod — "
f"la surface d'identité de l'endpoint GPU s'est évaporée (régression #6)")
# ============================================================================
# Fiscal · e-CF DGII — IDENTITÉ des ÉVÈNEMENTS D'ÉMISSION (le « cœur du livrable »)
# ----------------------------------------------------------------------------
# La section §Cross-cohérence du README (05_deliverables_mvp/fiscal/ecf_dgii/
# README.md:40-59) — présentée comme « le cœur du livrable » — transcrit À LA MAIN
# l'IDENTITÉ des emission_events d'out/ecf_plan.json (byte-gaté par check_artifacts
# ⇒ artefact faisant autorité) :
# • L48 l'ENSEMBLE des évènements qui émettent un e-CF (« seulement réservation
# et contrat ») + l'ENSEMBLE exclu « on ne facture pas un brouillon
# (lead/visite/devis) » — la RAISON D'ÊTRE du module : jamais de facture
# sur un brouillon (états SOUMIS uniquement · doc_status=1)
# • L50 l'ENSEMBLE des base_field (« montant_reservation, montant_contrat »)
# • L53 le rôle UNIQUE (« compta-fiscaliste-ecf » · « OTO Compta Fiscaliste
# eCF » · portail compta)
# • L59 la FormaPago défaut (« = 3 (Tarjeta) » · Cardnet #10)
# Le SEUL gate fiscal existant couvre le CONTRAT DE FORMAT e-NCF + le compte de
# tests ; le bloc racine, le seul agrégat « e-CF 39 tests ». RIEN ne confronte
# cette identité à l'artefact. Dérive la plus grave, #6/#5 : AJOUTER un
# emission_event sur un état BROUILLON (ex. `devis`) — l'anti-pattern EXACT que le
# module interdit (« on ne facture pas un brouillon ») — pendant que L48 dit encore
# « seulement réservation et contrat » → l'agent ERPNext Backend câblerait une
# facturation sur brouillon (e-CF émis avant soumission/encaissement). Aucune suite
# tests/ (FONCTIONS de résolution/composition, jamais la prose vs l'artefact) ne
# l'attrape — classe « vert trompeur » notée en mémoire.
#
# On RECOMPUTE les ENSEMBLES depuis ecf_plan.json (jamais une liste à la main · #6)
# et EXIGE que le README les reflète EXACTEMENT (set-diff : ni fantôme ni manquant ;
# accents/casse normalisés). Le rôle est en outre DOUBLE-ANCRÉ sur rbac_50_roles
# (id → portail compta → nom ERPNext concordant). Cohérences croisées en bonus
# (mordent un plan INTERNEMENT incohérent) : rôle UNIQUE sur tous les évènements ·
# défaut ∈ catalogue FormaPago · ensembles NON VIDES. Un claim absent échoue AUSSI
# (traçabilité #6).
# ============================================================================
ECF_RD2 = f"{D}/fiscal/ecf_dgii/README.md"
try:
_ecfp = load("fiscal/ecf_dgii/out/ecf_plan.json")
_ecf_ev = _ecfp["emission_events"]
if not _ecf_ev:
raise ValueError("emission_events vide")
ecf_updvals = {e["update_value"] for e in _ecf_ev}
ecf_bases = {e["base_field"] for e in _ecf_ev}
ecf_roleids = {e["role_id"] for e in _ecf_ev}
ecf_rolenms = {e["erpnext_role_name"] for e in _ecf_ev}
_ecf_fpd = _ecfp["forma_pago_defaut"]
_ecf_fpcat = {str(f["code"]): f["label"] for f in _ecfp["formas_pago"]}
except (OSError, KeyError, TypeError, ValueError, json.JSONDecodeError) as e:
bad(f"Fiscal e-CF évènements · ecf_plan.json illisible/inattendu : {e}")
_ecf_ev = None
if _ecf_ev is not None:
def _ecf_ai(s): # accent-insensible · minuscule · sans espaces
return "".join(c for c in unicodedata.normalize("NFKD", str(s))
if unicodedata.category(c) != "Mn").lower().replace(" ", "")
try:
ecf_doc = re.sub(r"\s+", " ", open(ECF_RD2, encoding="utf-8").read())
except OSError as e:
bad(f"Fiscal e-CF évènements · {ECF_RD2} illisible : {e}"); ecf_doc = None
# (0) cohérence interne du plan : rôle UNIQUE + défaut ∈ catalogue FormaPago
if len(ecf_roleids) != 1 or len(ecf_rolenms) != 1:
bad(f"Fiscal e-CF évènements · plan INCOHÉRENT : rôle non unique sur les "
f"évènements (ids={sorted(ecf_roleids)} noms={sorted(ecf_rolenms)}) — "
f"le README promet un « rôle dédié » unique")
_fp_code = str(_ecf_fpd.get("code"))
if _fp_code not in _ecf_fpcat:
bad(f"Fiscal e-CF évènements · forma_pago_defaut.code={_fp_code!r} ABSENT "
f"du catalogue formas_pago {sorted(_ecf_fpcat)} (plan incohérent)")
if ecf_doc is not None:
# (1) ENSEMBLE des évènements émetteurs — L48 « seulement X et Y »
m_bill = re.search(r"seulement\s+([^.]+?)\.", ecf_doc)
want = {_ecf_ai(x) for x in ecf_updvals}
if m_bill is None:
bad(f"Fiscal e-CF évènements · {ECF_RD2} — énumération « seulement … » "
f"INTROUVABLE (attendu {sorted(ecf_updvals)})")
else:
bill = {_ecf_ai(x) for x in re.split(r"\s+et\s+|,\s*", m_bill.group(1)) if x.strip()}
if bill == want:
good(f"Fiscal e-CF évènements · émetteurs README == ecf_plan "
f"({sorted(ecf_updvals)})")
else:
bad(f"Fiscal e-CF évènements · émetteurs README {sorted(bill)} ≠ "
f"ecf_plan {sorted(want)} (fantôme={sorted(bill-want)} "
f"manquant={sorted(want-bill)}) → régénérer le README")
# (2) ENSEMBLE exclu (brouillons) DISJOINT des émetteurs — anti-facture-brouillon
m_excl = re.search(r"on ne facture pas un brouillon\s*\(([^)]+)\)", ecf_doc)
if m_excl is None:
bad(f"Fiscal e-CF évènements · {ECF_RD2} — clause « on ne facture pas un "
f"brouillon (…) » INTROUVABLE (raison d'être #6 · traçabilité)")
else:
excl = {_ecf_ai(x) for x in re.split(r"[/,]", m_excl.group(1)) if x.strip()}
clash = excl & want
if clash:
bad(f"Fiscal e-CF évènements · un état déclaré BROUILLON {sorted(clash)} "
f"est ÉMETTEUR dans ecf_plan → FACTURE SUR BROUILLON (l'anti-pattern "
f"que le module interdit · #6) OU README auto-contradictoire")
else:
good(f"Fiscal e-CF évènements · brouillons {sorted(excl)} ∩ émetteurs = ∅ "
f"(jamais de facture sur brouillon)")
# (3) ENSEMBLE des base_field — L50 « (montant_reservation, montant_contrat) »
m_base = re.search(r"champ \*\*Currency réel\*\*[^(]*\(([^)]+)\)", ecf_doc)
if m_base is None:
bad(f"Fiscal e-CF évènements · {ECF_RD2} — énumération des base_field "
f"INTROUVABLE (attendu {sorted(ecf_bases)})")
else:
docbases = set(re.findall(r"`([^`]+)`", m_base.group(1)))
if docbases == ecf_bases:
good(f"Fiscal e-CF évènements · base_field README == ecf_plan "
f"({sorted(ecf_bases)})")
else:
bad(f"Fiscal e-CF évènements · base_field README {sorted(docbases)} ≠ "
f"ecf_plan {sorted(ecf_bases)} (fantôme={sorted(docbases-ecf_bases)} "
f"manquant={sorted(ecf_bases-docbases)})")
# (4) rôle UNIQUE : backtické + nom ERPNext + DOUBLE-ANCRÉ portail compta (RBAC)
_rid = next(iter(ecf_roleids)); _rnm = next(iter(ecf_rolenms))
if f"`{_rid}`" not in ecf_doc:
bad(f"Fiscal e-CF évènements · rôle dédié `{_rid}` (== ecf_plan) NON cité "
f"backtické dans le README")
elif _rnm not in ecf_doc:
bad(f"Fiscal e-CF évènements · nom ERPNext « {_rnm} » (== ecf_plan) ABSENT "
f"du README")
else:
try:
_rbac = load("rbac/rbac_50_roles.json")["roles"]
_rrec = next((r for r in _rbac if r.get("id") == _rid), None)
except (OSError, KeyError, TypeError, json.JSONDecodeError) as e:
bad(f"Fiscal e-CF évènements · rbac_50_roles illisible : {e}"); _rrec = "ERR"
if _rrec is None:
bad(f"Fiscal e-CF évènements · rôle `{_rid}` (ecf_plan) INTROUVABLE dans "
f"rbac_50_roles → rôle Compta fantôme")
elif _rrec == "ERR":
pass
elif _rrec.get("portail") != "compta":
bad(f"Fiscal e-CF évènements · rôle `{_rid}` portail={_rrec.get('portail')!r} "
f"≠ « compta » (le README exige le portail compta)")
elif _rrec.get("erpnext_role_name") != _rnm:
bad(f"Fiscal e-CF évènements · nom ERPNext discordant ecf_plan « {_rnm} » "
f"vs RBAC « {_rrec.get('erpnext_role_name')} »")
else:
good(f"Fiscal e-CF évènements · rôle unique `{_rid}` (« {_rnm} ») == "
f"ecf_plan == RBAC (portail compta)")
# (5) FormaPago défaut — L59 « = 3 (Tarjeta) » · Cardnet #10
m_fp = re.search(r"FormaPago défaut\s*=\s*(\d+)\s*\(([^)]+)\)", ecf_doc)
_lbl = _ecf_fpcat.get(_fp_code, "")
if m_fp is None:
bad(f"Fiscal e-CF évènements · {ECF_RD2} — « FormaPago défaut = N (…) » "
f"INTROUVABLE (attendu {_fp_code} / {_lbl})")
elif m_fp.group(1) != _fp_code:
bad(f"Fiscal e-CF évènements · FormaPago défaut README ={m_fp.group(1)} "
f"MAIS ecf_plan dit ={_fp_code} ({_lbl}) → défaut périmé")
elif _ecf_ai(m_fp.group(2)) not in _ecf_ai(_lbl):
bad(f"Fiscal e-CF évènements · FormaPago défaut label README "
f"« {m_fp.group(2)} » incohérent avec le catalogue « {_lbl} » (code {_fp_code})")
elif "cardnet" not in _ecf_ai(_ecf_fpd.get("motif", "")):
bad(f"Fiscal e-CF évènements · forma_pago_defaut.motif ne cite plus "
f"« Cardnet » (#10) → justification du défaut évaporée")
else:
good(f"Fiscal e-CF évènements · FormaPago défaut = {_fp_code} "
f"({_lbl}) · Cardnet #10 == ecf_plan")
# ============================================================================
# CLAUDE.md §VPS · IDENTITÉ des IDENTIFIANTS D'INFRASTRUCTURE (IP + conteneurs
# Docker) — même classe de « vert trompeur » que l'endpoint RunPod, côté serveur.
# ----------------------------------------------------------------------------
# CLAUDE.md §VPS FIXE l'infrastructure de production du mandat :
# • l'IP du VPS `153.75.250.214`
# • les DEUX conteneurs Docker `erpnext-backend-1` / `erpnext-frontend-1`
# Ces identifiants sont RECOPIÉS À LA MAIN dans quantité de docs — README.md,
# le master prompt, AGENTS_EXISTING_ASSETS.md, la fiche `mobile` (backend cible de
# l'API), RBAC_50_ROLES_SPEC.md (run-book d'import), tests/README.md (base URL
# Playwright), ci/README.md (instance Gitea) — sans AUCUN ancrage vers CLAUDE.md.
# Aucun gate ne couvrait ce lien : check_docs ne voit qu'un lien Markdown,
# guard_constraints qu'un terme interdit, check_artifacts qu'un out/ — une IP ni un
# nom de conteneur n'est aucun des trois. Or une MIGRATION serveur (nouvelle IP) ou
# un RENOMMAGE du projet compose (`erpnext_*` → `oto_*`) est un évènement ops
# COURANT : CLAUDE.md §VPS se met à jour, les ~10 copies se PÉRIMENT en silence →
# un agent câblerait l'API mobile / les tests e2e / le hand-off d'import vers un
# serveur MORT ou un conteneur FANTÔME (l'invention même que #6 interdit). Aucune
# suite tests/ (FONCTIONS des modules, jamais la prose infra) ne l'attrape — classe
# notée en mémoire (`agent-fiche-role-attrs-ungated`, surface infra ~ RunPod).
#
# On RE-DÉRIVE l'IP + l'ENSEMBLE des conteneurs de CLAUDE.md §VPS (jamais une liste
# à la main · #6). Direction copie→source : sur TOUTE doc .md du mandat (hors
# CLAUDE.md = la source, hors 05_activity_log/ + daily_reports/ = journaux
# historiques figés, à ne pas réécrire), tout token IPv4 DOIT == l'IP canonique et
# tout token conteneur `erpnext-<x>-1` DOIT ∈ l'ensemble canonique. Le corpus ne
# porte QUE ces valeurs (aucune autre IPv4, aucun autre conteneur — vérifié) : un
# écart = une copie périmée. Le write-path interdit `/var/www/html/static` relève
# de guard_constraints (#interdits), non de ce gate d'identité. Garde
# anti-évaporation : l'IP ET ≥1 conteneur canonique doivent rester cités quelque
# part (sinon la surface d'identité du VPS a silencieusement disparu · #6).
# ============================================================================
VPS_SRC = "CLAUDE.md"
try:
_vps_cm = open(VPS_SRC, encoding="utf-8").read()
except OSError as e:
bad(f"VPS infra · {VPS_SRC} illisible : {e}"); _vps_cm = None
if _vps_cm is not None:
_vps_sec = re.search(r"^## VPS\s*\n(.*?)(?=^## |\Z)", _vps_cm, re.S | re.M)
if _vps_sec is None:
bad(f"VPS infra · section « ## VPS » INTROUVABLE dans {VPS_SRC} → ancre "
f"d'infra cassée, impossible de recomputer IP/conteneurs")
else:
_vps_body = _vps_sec.group(1)
_ip_m = re.search(r"\bIP\s+(\d{1,3}(?:\.\d{1,3}){3})\b", _vps_body)
_dk_m = re.search(r"Docker\s*\(([^)]+)\)", _vps_body)
if _ip_m is None or _dk_m is None:
bad(f"VPS infra · §VPS ne livre pas l'IP et/ou les conteneurs Docker "
f"(IP={_ip_m and _ip_m.group(1)} · Docker={_dk_m and _dk_m.group(1)}) "
f"→ format de §VPS modifié, recompute impossible")
else:
vps_ip = _ip_m.group(1)
vps_containers = {c.strip() for c in _dk_m.group(1).split(",")
if re.fullmatch(r"[a-z0-9]+-[a-z]+-1", c.strip())}
# Enveloppe de forme DÉRIVÉE des stems de service canoniques
# (backend/frontend), PAS du préfixe compose littéral `erpnext-` : un
# RENOMMAGE du projet compose (`erpnext-backend-1` → `oto-backend-1`)
# dans une copie DOIT être vu comme fantôme — l'ancrer sur `erpnext-`
# le rendrait invisible (le token renommé ne matcherait plus).
_stems = sorted({m.group(1) for c in vps_containers
if (m := re.fullmatch(r"[a-z0-9]+-([a-z]+)-1", c))})
if not vps_containers or not _stems:
bad(f"VPS infra · §VPS « Docker (…) » ne contient aucun conteneur de "
f"forme `<projet>-<service>-1` (lu {_dk_m.group(1)!r}) → format modifié")
else:
_cont_shape = re.compile(r"[a-z0-9]+-(?:" + "|".join(_stems) + r")-1")
_ip_shape = re.compile(r"\b\d{1,3}(?:\.\d{1,3}){3}\b")
# EXCEPTION serveur de secours : une IPv4 EXPLICITEMENT étiquetée
# « backup » (ex. « VPS backup 153.75.232.237 · rsync … ») désigne un
# SECOND serveur légitime, pas une migration de l'IP primaire. On la
# tolère — MAIS le label « backup » reste OBLIGATOIRE : une IP primaire
# migrée s'écrirait « IP <nouvelle> », jamais « backup <nouvelle> », donc
# la détection de dérive-migration est préservée. Doc root-owned de Michel
# (non éditable par le worker) ; signaler à Michel de porter cette IP dans
# CLAUDE.md §VPS pour rétablir la couverture SSOT complète.
_backup_ip_shape = re.compile(
r"\bbackup\s+(\d{1,3}(?:\.\d{1,3}){3})\b", re.IGNORECASE)
vps_files = [f for f in subprocess.run(
["git", "ls-files", "*.md"],
capture_output=True, text=True).stdout.split()
if f != "CLAUDE.md"
and not f.startswith("05_activity_log/")
and not f.startswith(f"{D}/daily_reports/")]
ip_seen = cont_seen = 0
for vf in vps_files:
try:
vtxt = open(vf, encoding="utf-8").read()
except OSError as e:
bad(f"VPS infra · {vf} illisible : {e}"); continue
# (a) IPv4 : toute IP citée == IP canonique du §VPS
# (hors IP explicitement étiquetées « backup <ip> » = serveurs de secours)
_backup_ips = set(_backup_ip_shape.findall(vtxt))
ips = set(_ip_shape.findall(vtxt))
if ips:
ip_seen += 1
stale_ip = sorted(ips - {vps_ip} - _backup_ips)
if stale_ip:
bad(f"VPS infra · {vf} cite l'IP périmée {stale_ip} MAIS "
f"CLAUDE.md §VPS dit `{vps_ip}` → serveur migré / IP "
f"mal recopiée (mettre la doc à jour)")
else:
good(f"VPS infra · {vf} · IP `{vps_ip}` == §VPS")
# (b) conteneurs Docker : tout `erpnext-<x>-1` ∈ ensemble canonique
conts = set(_cont_shape.findall(vtxt))
if conts:
cont_seen += 1
phantom = sorted(conts - vps_containers)
if phantom:
bad(f"VPS infra · {vf} cite le(s) conteneur(s) fantôme(s) "
f"{phantom} MAIS §VPS dit {sorted(vps_containers)} → "
f"projet compose renommé / nom mal recopié")
else:
good(f"VPS infra · {vf} · conteneur(s) {sorted(conts)} "
f"⊆ §VPS {sorted(vps_containers)}")
if ip_seen == 0:
bad(f"VPS infra · AUCUNE doc du mandat ne cite l'IP `{vps_ip}` — la "
f"surface d'identité du VPS s'est évaporée (régression #6)")
if cont_seen == 0:
bad(f"VPS infra · AUCUNE doc ne cite de conteneur Docker "
f"`erpnext-<x>-1` — surface d'identité conteneur évaporée (#6)")
# ============================================================================
# Legal · CONFOTUR — IDENTITÉ des champs DÉRIVÉS du WORKFLOW (`dossier_vente` / `estado`)
# ----------------------------------------------------------------------------
# La §« Cœur du livrable : cross-cohérence » du README confotur affirme que DEUX
# champs du DocType sont DÉRIVÉS du pipeline vente, jamais réécrits en dur :
# • `dossier_vente` (Link) : cible = `workflow.document_type` (= `OTO Dossier
# Vente`) — README:35-36 · NOMME la valeur en clair entre backticks ;
# • `estado` (Select) : options = les états `confotur_*` du **workflow vente**
# — README:33-34.
# Source faisant autorité : `crm/workflow_vente/workflow_vente_spec.json` — l'INPUT
# que le builder confotur LIT (déclaré `workflow_source` dans le MANIFEST), d'où il
# tire `document_type` et les `update_value` d'états commençant par « confotur ».
# Les blocs CONFOTUR amont gatent le COMPTE de champs/rôles, la cross-cohérence des
# PERMISSIONS et l'ancrage des ENTITÉS (`entite_porteuse`) — mais AUCUN ne touche
# `dossier_vente`/`estado`. Piège #6 : RENOMMER le DocType porteur côté workflow
# (`OTO Dossier Vente` → `OTO Dossier de Vente`) ou AJOUTER/RENOMMER un état
# CONFOTUR régénère l'artefact confotur (check_artifacts reste VERT — l'artefact se
# reconstruit depuis le spec workflow) MAIS laisse la PROSE README « (= `OTO Dossier
# Vente`) » périmée EN SILENCE → l'agent ONAPI/Legal lirait une cible de Link / un
# menu d'états que le pipeline ne porte plus. Aucune suite tests/ (FONCTIONS de
# génération) n'attrape ce « vert trompeur » de la prose. Même classe EXACTE que
# l'ancrage `entite_porteuse` du MÊME README à CLAUDE.md §Entités.
# GATE : (0) traçabilité — MANIFEST `workflow_source` cite bien le spec workflow ;
# (a) cross-artefact — `dossier_vente.options` (Link) == workflow `document_type` ;
# (b) prose — la valeur README « (= `…`) » == workflow `document_type` ; (c)
# cross-artefact — `estado.options` == update_value des états `confotur_*` du
# workflow (ORDRE du pipeline) ; (c') toute option `estado` porte le préfixe
# confotur (le README l'affirme) ; (d) prose — le bullet `estado` DÉCLARE la
# dérivation (`confotur_*` + workflow vente). Un claim absent échoue AUSSI (#6).
# ============================================================================
CONF_RD4 = f"{D}/legal/confotur/README.md"
CONF_WF_SRC = "crm/workflow_vente/workflow_vente_spec.json"
try:
conf_dt_wd = load("legal/confotur/out/doctype_confotur_application.json")
conf_man_wd = load("legal/confotur/out/MANIFEST.json")
wf_spec_wd = load(CONF_WF_SRC)
wf_doctype = _nfc(str(wf_spec_wd["document_type"]).strip())
wf_confotur = [_nfc(str(s["update_value"]).strip())
for s in wf_spec_wd["states"]
if str(s.get("update_value", "")).startswith("confotur")]
man_wf_src = _nfc(str(conf_man_wd.get("workflow_source", "")))
except (OSError, KeyError, ValueError, TypeError) as e:
bad(f"Confotur champs dérivés · artefact/spec workflow illisible/inattendu : {e}")
conf_dt_wd = None
if conf_dt_wd is not None:
_fields = conf_dt_wd.get("fields", [])
dv_art = [f for f in _fields if f.get("fieldname") == "dossier_vente"]
es_art = [f for f in _fields if f.get("fieldname") == "estado"]
conf_rd4 = read_norm(CONF_RD4)
# (0) TRAÇABILITÉ — le MANIFEST DÉCLARE dériver du workflow spec (source de vérité)
if CONF_WF_SRC not in man_wf_src:
bad(f"Confotur champs dérivés · (0) MANIFEST `workflow_source` = {man_wf_src!r} "
f"ne cite PAS {CONF_WF_SRC} → dérivation non déclarée (ancrage cassé)")
else:
good(f"Confotur champs dérivés · (0) MANIFEST DÉCLARE dériver de {CONF_WF_SRC}")
if not wf_confotur:
bad(f"Confotur champs dérivés · aucun état `confotur_*` dans {CONF_WF_SRC} → le "
f"menu `estado` n'aurait aucune source (workflow incohérent)")
# ---- dossier_vente (Link) ----
if len(dv_art) != 1:
bad(f"Confotur champs dérivés · champ `dossier_vente` non-singleton dans l'artefact "
f"({len(dv_art)}) → cible du Link indéterminable")
else:
dv = dv_art[0]
dv_opt = _nfc(str(dv.get("options", "")).strip())
if dv.get("fieldtype") != "Link":
bad(f"Confotur champs dérivés · `dossier_vente` fieldtype "
f"{dv.get('fieldtype')!r} ≠ Link → le champ n'est plus un Link")
# (a) CROSS-ARTEFACT — cible du Link == document_type du workflow
if dv_opt == wf_doctype:
good(f"Confotur champs dérivés · (a) `dossier_vente` Link → « {dv_opt} » == "
f"workflow.document_type")
else:
bad(f"Confotur champs dérivés · (a) `dossier_vente` Link → « {dv_opt} » ≠ "
f"workflow.document_type « {wf_doctype} » → régénérer le DocType")
# (b) PROSE README — la valeur « (= `…`) » == document_type du workflow
if conf_rd4 is not None:
m = re.search(r"`dossier_vente`.{0,120}?`workflow\.document_type`"
r"\s*\(=\s*`([^`]+)`\)", conf_rd4)
if m is None:
bad(f"Confotur champs dérivés · (b) bullet `dossier_vente` (cible = "
f"`workflow.document_type` (= `…`)) INTROUVABLE dans {CONF_RD4} "
f"(claim absent = régression #6)")
else:
got_dv = _nfc(m.group(1).strip())
if got_dv == wf_doctype:
good(f"Confotur champs dérivés · (b) README `dossier_vente` = "
f"« {got_dv} » == workflow.document_type")
else:
bad(f"Confotur champs dérivés · (b) README dit `dossier_vente` = "
f"« {got_dv} » MAIS workflow.document_type = « {wf_doctype} » "
f"→ prose périmée (régénérer le README)")
# ---- estado (Select) ----
if len(es_art) != 1:
bad(f"Confotur champs dérivés · champ `estado` non-singleton dans l'artefact "
f"({len(es_art)}) → menu d'états indéterminable")
else:
es = es_art[0]
es_opts = [_nfc(o.strip()) for o in str(es.get("options", "")).split("\n") if o.strip()]
if es.get("fieldtype") != "Select":
bad(f"Confotur champs dérivés · `estado` fieldtype {es.get('fieldtype')!r} "
f"≠ Select → le champ n'est plus un Select")
# (c) CROSS-ARTEFACT — options == états confotur_* du workflow (ordre pipeline)
if es_opts == wf_confotur:
good(f"Confotur champs dérivés · (c) `estado` options {es_opts} == états "
f"confotur_* du workflow (ordre pipeline)")
else:
bad(f"Confotur champs dérivés · (c) `estado` options {es_opts} ≠ états "
f"confotur_* du workflow {wf_confotur} → menu périmé (régénérer)")
# (c') toute option `estado` porte le préfixe confotur (le README l'affirme)
_bad_pref = [o for o in es_opts if not o.startswith("confotur")]
if _bad_pref:
bad(f"Confotur champs dérivés · (c') option(s) `estado` HORS préfixe "
f"confotur_* {_bad_pref} → contredit « options = les états `confotur_*` »")
elif es_opts:
good(f"Confotur champs dérivés · (c') les {len(es_opts)} options `estado` "
f"portent toutes le préfixe confotur_*")
# (d) PROSE README — le bullet `estado` DÉCLARE la dérivation (confotur_* + workflow)
if conf_rd4 is not None:
m = re.search(r"`estado`\*{0,2}\s*\(Select\).{0,200}?`confotur_\*`"
r".{0,80}?workflow vente", conf_rd4)
if m is None:
bad(f"Confotur champs dérivés · (d) bullet `estado` (options = les états "
f"`confotur_*` du workflow vente) INTROUVABLE dans {CONF_RD4} "
f"(claim absent = régression #6)")
else:
good(f"Confotur champs dérivés · (d) README `estado` DÉCLARE dériver des "
f"états confotur_* du workflow vente")
# ============================================================================
# CRM · Dossier Vente · IDENTITÉ du COMPTE D'INVARIANTS de `_validate` — 5ᵉ
# instance de la classe « ledger d'invariants » (après Chat OTOIA · SEO · Fiscal
# e-CF · Legal CONFOTUR), appliquée au générateur du DocType porteur.
# ----------------------------------------------------------------------------
# Le générateur doctype_dossier_vente_gen.py applique un COMPTE d'invariants de
# cross-cohérence workflow ↔ DocType dont la source d'autorité n'est PAS un
# artefact JSON (invisible à check_artifacts qui byte-gate les out/*.json) mais le
# LEDGER NUMÉROTÉ de `_validate` : les marqueurs `# N ·` (1..12) qui ouvrent chaque
# contrôle appliqué. Le nombre « 12 » est transcrit À LA MAIN à SEPT endroits
# qu'AUCUN gate ne couvrait : QUATRE chaînes du .py (docstring module, docstring
# `_validate`, message de succès CLI « … schéma + 12 invariants verts. », aide du
# sous-parseur `validate`) + TROIS du README (bloc de commande « schéma + 12
# invariants », titre de section « ## Les 12 invariants », prose de score « 12
# invariants de cross-cohérence »). Ajouter un contrôle `# 13 ·` à `_validate` sans
# toucher les chaînes ⇒ le README ET les self-reports du .py mentent en silence
# pendant que la CI applique 13 contrôles — « vert trompeur » (#6) que ni les
# suites tests/ (FONCTIONS de validation, jamais la prose) ni le byte-gate
# d'artefact (aveugle au .py source) n'attrapent.
# PARTICULARITÉ vs Fiscal : ledger PROPRE 1..N (le schéma est compté À PART —
# phrasé « schéma + N invariants » — donc PAS de schéma-#1 non marqué, pas de split
# a/b) ; on garde la détection de doublon nu comme filet. On RECOMPUTE le ledger
# depuis le SEUL registre-source (les `# N ·` de `_validate`) et on exige :
# (a) ledger CONTIGU 1..N (lacune OU doublon nu = vrai défaut de registre, mordu) ;
# (b) TOUTE mention « N invariants » du .py == |ledger| ; (c) le compte annoncé du
# titre README « ## Les N invariants » == |ledger| ; (d) TOUTE mention « N
# invariants » du README == |ledger| ; (e) l'ÉNUMÉRATION numérotée de la section
# (« 1. … 2. … N. ») elle-même contiguë 1..N (item ni sauté ni dupliqué). Mention
# ou registre absent = échec (disparition d'un chiffre = régression de traçabilité
# #6).
# ============================================================================
DVV_GEN = f"{D}/crm/dossier_vente/doctype_dossier_vente_gen.py"
DVV_RD = f"{D}/crm/dossier_vente/README.md"
try:
dvv_src = open(DVV_GEN, encoding="utf-8").read()
except OSError as e:
bad(f"Dossier Vente invariants · générateur {DVV_GEN} illisible : {e}"); dvv_src = None
if dvv_src is not None:
_mfn = re.search(r"def _validate\b", dvv_src)
if _mfn is None:
bad(f"Dossier Vente invariants · fonction `_validate` INTROUVABLE dans {DVV_GEN} "
"(registre-source du ledger absent → impossible de re-dériver)")
else:
_bs = _mfn.end()
_mn = re.search(r"\ndef ", dvv_src[_bs:])
_body = dvv_src[_bs: _bs + _mn.start()] if _mn else dvv_src[_bs:]
_pairs = re.findall(r"#\s*(\d+)([a-z]?)\s*·", _body)
_dup_bare = sorted({n for n in {p[0] for p in _pairs}
if [s for num, s in _pairs if num == n and s == ""]
and sum(1 for num, _ in _pairs if num == n) > 1})
_top = sorted({int(n) for n, _ in _pairs})
if not _top:
bad(f"Dossier Vente invariants · aucun marqueur `# N ·` dans `_validate` de "
f"{DVV_GEN} (ledger vide → impossible de re-dériver)")
elif _dup_bare:
bad(f"Dossier Vente invariants · marqueur(s) `# N ·` dupliqué(s) sans suffixe "
f"a/b dans `_validate` : {_dup_bare} — vrai doublon de registre")
else:
dvv_ninv = len(_top)
if _top != list(range(1, dvv_ninv + 1)):
bad(f"Dossier Vente invariants · ledger `_validate` NON contigu : {_top} "
f"(attendu 1..{dvv_ninv}) — lacune/doublon dans les marqueurs `# N ·`")
else:
good(f"Dossier Vente invariants · ledger `_validate` contigu 1..{dvv_ninv} "
f"({dvv_ninv} marqueurs `# N ·` · schéma compté à part)")
# (b) self-reports du .py « N invariants ».
dvv_py_rep = sorted({int(x) for x in re.findall(r"(\d+)\s+invariants", dvv_src)})
if not dvv_py_rep:
bad(f"Dossier Vente invariants · aucune mention « N invariants » dans {DVV_GEN} "
f"(self-report absent → attendu {dvv_ninv})")
elif dvv_py_rep == [dvv_ninv]:
good(f"Dossier Vente invariants · {DVV_GEN} — self-report(s) {dvv_py_rep} "
f"== ledger ({dvv_ninv})")
else:
bad(f"Dossier Vente invariants · {DVV_GEN} — self-report(s) « N invariants » "
f"{dvv_py_rep} MAIS ledger en compte {dvv_ninv} "
"(une chaîne du générateur désynchronisée du registre)")
# README : titre de section + toute mention + énumération numérotée.
try:
dvv_rd_raw = open(DVV_RD, encoding="utf-8").read()
except OSError as e:
bad(f"Dossier Vente invariants · {DVV_RD} illisible : {e}"); dvv_rd_raw = None
if dvv_rd_raw is not None:
_mh = re.search(r"##\s+Les\s+(\d+)\s+invariants", dvv_rd_raw)
if _mh is None:
bad(f"Dossier Vente invariants · {DVV_RD} — section « ## Les N invariants » "
f"INTROUVABLE (attendu {dvv_ninv})")
else:
# (c) compte annoncé du titre.
if int(_mh.group(1)) == dvv_ninv:
good(f"Dossier Vente invariants · {DVV_RD} — annonce ({_mh.group(1)}) "
f"== ledger ({dvv_ninv})")
else:
bad(f"Dossier Vente invariants · {DVV_RD} — annonce ({_mh.group(1)}) MAIS "
f"ledger en compte {dvv_ninv} → régénérer le README")
# (d) toute mention « N invariants » du README == ledger.
dvv_rd_rep = sorted({int(x) for x in re.findall(r"(\d+)\s+invariants", dvv_rd_raw)})
if dvv_rd_rep == [dvv_ninv]:
good(f"Dossier Vente invariants · {DVV_RD} — mention(s) {dvv_rd_rep} "
f"== ledger (aucune dérive)")
else:
bad(f"Dossier Vente invariants · {DVV_RD} — mention(s) « N invariants » "
f"{dvv_rd_rep} MAIS ledger en compte {dvv_ninv} → régénérer le README")
# (e) énumération numérotée de la section == contiguë 1..N.
_msec = re.search(r"##\s+Les\s+\d+\s+invariants.*?(?=\n##\s|\Z)",
dvv_rd_raw, re.S)
dvv_enum = ([int(x) for x in
re.findall(r"(?<![\d.])(\d+)\.\s", _msec.group(0))]
if _msec else [])
if dvv_enum == list(range(1, dvv_ninv + 1)):
good(f"Dossier Vente invariants · {DVV_RD} — énumération prose "
f"contiguë 1..{dvv_ninv} == ledger")
else:
bad(f"Dossier Vente invariants · {DVV_RD} — énumération prose {dvv_enum} "
f"≠ 1..{dvv_ninv} (item sauté/dupliqué) → régénérer le README")
# ============================================================================
# QA · Audit 5D · IDENTITÉ du COMPTE D'INVARIANTS de `_validate` — 6ᵉ instance
# de la classe « ledger d'invariants » (après Chat OTOIA · SEO · Fiscal e-CF ·
# Legal CONFOTUR · CRM Dossier Vente), appliquée au générateur de l'audit QA.
# ----------------------------------------------------------------------------
# Le générateur audit_5d_gen.py applique un COMPTE d'invariants de cohérence
# audit/contrôles dont la source d'autorité n'est PAS un artefact JSON (invisible
# à check_artifacts qui byte-gate les out/*.json) mais le LEDGER NUMÉROTÉ de
# `_validate` : les marqueurs `# N ·` (1..15) qui ouvrent chaque contrôle. Le
# nombre « 15 » est transcrit À LA MAIN à SIX endroits qu'AUCUN gate ne couvrait :
# QUATRE chaînes du .py (docstring sous-commande « valide schéma + 15 invariants »,
# docstring `_validate`, message de succès CLI « … 15 invariants — verdict … »,
# aide du sous-parseur `validate`) + DEUX du README (bloc de commande « schéma +
# 15 invariants », prose d'architecture « CLI build/validate + **15 invariants** »).
# Ajouter un contrôle `# 16 ·` à `_validate` sans toucher les chaînes ⇒ le README
# ET les self-reports du .py mentent en silence pendant que la CI applique 16
# contrôles — « vert trompeur » (#6) que ni les suites tests/ (FONCTIONS de
# validation, jamais la prose) ni le byte-gate d'artefact (aveugle au .py source)
# n'attrapent.
# PARTICULARITÉ vs Dossier Vente : ledger PROPRE 1..N compté À PART du schéma
# (phrasé « schéma + N invariants » — pas de schéma-#1 non marqué, pas de split
# a/b ; on garde la détection de doublon nu comme filet). MAIS le module porte
# un SECOND nombre distinct — « 17 contrôles » (le compte de fonctions-contrôle,
# déjà gaté par ailleurs) — présent dans le .py ET le README : un `\d+ invariants`
# ancré sur le mot « invariants » l'exclut nativement (« 17 contrôles » ≠ mot
# « invariants »), donc AUCUN faux positif sur l'arbre propre. Contrairement à
# Dossier Vente/Fiscal, ce README n'a NI titre « ## Les N invariants » NI
# énumération numérotée du ledger → on ne gate que (a)(b)(d). On RECOMPUTE le
# ledger depuis le SEUL registre-source (les `# N ·` de `_validate`) et on exige :
# (a) ledger CONTIGU 1..N (lacune OU doublon nu = vrai défaut de registre, mordu) ;
# (b) TOUTE mention « N invariants » du .py == |ledger| ; (d) TOUTE mention « N
# invariants » du README == |ledger|. Mention ou registre absent = échec
# (disparition d'un chiffre = régression de traçabilité #6).
# ============================================================================
A5I_GEN = f"{D}/qa/audit_5d/audit_5d_gen.py"
A5I_RD = f"{D}/qa/audit_5d/README.md"
try:
a5i_src = open(A5I_GEN, encoding="utf-8").read()
except OSError as e:
bad(f"Audit 5D invariants · générateur {A5I_GEN} illisible : {e}"); a5i_src = None
if a5i_src is not None:
_mfn = re.search(r"def _validate\b", a5i_src)
if _mfn is None:
bad(f"Audit 5D invariants · fonction `_validate` INTROUVABLE dans {A5I_GEN} "
"(registre-source du ledger absent → impossible de re-dériver)")
else:
_bs = _mfn.end()
_mn = re.search(r"\ndef ", a5i_src[_bs:])
_body = a5i_src[_bs: _bs + _mn.start()] if _mn else a5i_src[_bs:]
_pairs = re.findall(r"#\s*(\d+)([a-z]?)\s*·", _body)
_dup_bare = sorted({n for n in {p[0] for p in _pairs}
if [s for num, s in _pairs if num == n and s == ""]
and sum(1 for num, _ in _pairs if num == n) > 1})
_top = sorted({int(n) for n, _ in _pairs})
if not _top:
bad(f"Audit 5D invariants · aucun marqueur `# N ·` dans `_validate` de "
f"{A5I_GEN} (ledger vide → impossible de re-dériver)")
elif _dup_bare:
bad(f"Audit 5D invariants · marqueur(s) `# N ·` dupliqué(s) sans suffixe "
f"a/b dans `_validate` : {_dup_bare} — vrai doublon de registre")
else:
a5i_ninv = len(_top)
if _top != list(range(1, a5i_ninv + 1)):
bad(f"Audit 5D invariants · ledger `_validate` NON contigu : {_top} "
f"(attendu 1..{a5i_ninv}) — lacune/doublon dans les marqueurs `# N ·`")
else:
good(f"Audit 5D invariants · ledger `_validate` contigu 1..{a5i_ninv} "
f"({a5i_ninv} marqueurs `# N ·` · schéma compté à part)")
# (b) self-reports du .py « N invariants » (le distinct « N contrôles »
# est exclu nativement par l'ancre sur le mot « invariants »).
a5i_py_rep = sorted({int(x) for x in re.findall(r"(\d+)\s+invariants", a5i_src)})
if not a5i_py_rep:
bad(f"Audit 5D invariants · aucune mention « N invariants » dans {A5I_GEN} "
f"(self-report absent → attendu {a5i_ninv})")
elif a5i_py_rep == [a5i_ninv]:
good(f"Audit 5D invariants · {A5I_GEN} — self-report(s) {a5i_py_rep} "
f"== ledger ({a5i_ninv})")
else:
bad(f"Audit 5D invariants · {A5I_GEN} — self-report(s) « N invariants » "
f"{a5i_py_rep} MAIS ledger en compte {a5i_ninv} "
"(une chaîne du générateur désynchronisée du registre)")
# (d) toute mention « N invariants » du README == ledger.
try:
a5i_rd_raw = open(A5I_RD, encoding="utf-8").read()
except OSError as e:
bad(f"Audit 5D invariants · {A5I_RD} illisible : {e}"); a5i_rd_raw = None
if a5i_rd_raw is not None:
a5i_rd_rep = sorted({int(x) for x in re.findall(r"(\d+)\s+invariants", a5i_rd_raw)})
if not a5i_rd_rep:
bad(f"Audit 5D invariants · aucune mention « N invariants » dans {A5I_RD} "
f"(self-report README absent → attendu {a5i_ninv})")
elif a5i_rd_rep == [a5i_ninv]:
good(f"Audit 5D invariants · {A5I_RD} — mention(s) {a5i_rd_rep} "
f"== ledger (aucune dérive)")
else:
bad(f"Audit 5D invariants · {A5I_RD} — mention(s) « N invariants » "
f"{a5i_rd_rep} MAIS ledger en compte {a5i_ninv} → régénérer le README")
# ============================================================================
# CRM · Commissions · IDENTITÉ du COMPTE D'INVARIANTS de `_validate` — 7ᵉ
# instance de la classe « ledger d'invariants » (après Chat OTOIA · SEO · Fiscal
# e-CF · Legal CONFOTUR · CRM Dossier Vente · QA Audit 5D), appliquée au
# générateur du barème de commissions vendeurs (roadmap ligne 51).
# ----------------------------------------------------------------------------
# Le générateur commissions_gen.py applique un COMPTE d'invariants de cross-
# cohérence barème ↔ workflow ↔ DocType ↔ RBAC (les 4 contrats) dont la source
# d'autorité n'est PAS un artefact JSON (invisible à check_artifacts qui byte-gate
# les out/*.json) mais le LEDGER NUMÉROTÉ de `_validate` : les marqueurs `# N ·`
# (1..10) qui ouvrent chaque contrôle. Le nombre « 10 » est transcrit À LA MAIN à
# CINQ endroits qu'AUCUN gate ne couvrait : DEUX chaînes du .py (message de succès
# CLI « … schéma + 10 invariants verts. », aide du sous-parseur `validate`) + TROIS
# du README (bloc de commande « schéma + 10 invariants », titre de section « ## Les
# 10 invariants », prose de score « 10 invariants de cross-cohérence »). Ajouter un
# contrôle `# 11 ·` à `_validate` sans toucher les chaînes ⇒ le README ET les
# self-reports du .py mentent en silence pendant que la CI applique 11 contrôles —
# « vert trompeur » (#6) que ni les suites tests/ (FONCTIONS de validation, jamais
# la prose) ni le byte-gate d'artefact (aveugle au .py source) n'attrapent.
# PARTICULARITÉ (≈ Dossier Vente) : ledger PROPRE 1..N compté À PART du schéma
# (phrasé « schéma + N invariants » — pas de schéma-#1 non marqué, pas de split
# a/b ; on garde la détection de doublon nu comme filet). Le module porte AUSSI un
# SECOND nombre — « 25 tests » (le compte de la suite unittest, déjà gaté par
# ailleurs), présent dans le .py absent mais dans le README : l'ancre sur le mot
# « invariants » l'exclut nativement (« 25 tests » ≠ mot « invariants »), donc
# AUCUN faux positif. Comme Dossier Vente, ce README A un titre « ## Les N
# invariants » ET une énumération numérotée du ledger → on gate (a)(b)(c)(d)(e).
# On RECOMPUTE le ledger depuis le SEUL registre-source (les `# N ·` de `_validate`)
# et on exige : (a) ledger CONTIGU 1..N (lacune OU doublon nu = vrai défaut de
# registre, mordu) ; (b) TOUTE mention « N invariants » du .py == |ledger| ; (c) le
# compte annoncé du titre README « ## Les N invariants » == |ledger| ; (d) TOUTE
# mention « N invariants » du README == |ledger| ; (e) l'ÉNUMÉRATION numérotée de
# la section (« 1. … 2. … N. ») elle-même contiguë 1..N (item ni sauté ni dupliqué).
# Mention ou registre absent = échec (disparition d'un chiffre = régression #6).
# ============================================================================
CMI_GEN = f"{D}/crm/commissions/commissions_gen.py"
CMI_RD = f"{D}/crm/commissions/README.md"
try:
cmi_src = open(CMI_GEN, encoding="utf-8").read()
except OSError as e:
bad(f"Commissions invariants · générateur {CMI_GEN} illisible : {e}"); cmi_src = None
if cmi_src is not None:
_mfn = re.search(r"def _validate\b", cmi_src)
if _mfn is None:
bad(f"Commissions invariants · fonction `_validate` INTROUVABLE dans {CMI_GEN} "
"(registre-source du ledger absent → impossible de re-dériver)")
else:
_bs = _mfn.end()
_mn = re.search(r"\ndef ", cmi_src[_bs:])
_body = cmi_src[_bs: _bs + _mn.start()] if _mn else cmi_src[_bs:]
_pairs = re.findall(r"#\s*(\d+)([a-z]?)\s*·", _body)
_dup_bare = sorted({n for n in {p[0] for p in _pairs}
if [s for num, s in _pairs if num == n and s == ""]
and sum(1 for num, _ in _pairs if num == n) > 1})
_top = sorted({int(n) for n, _ in _pairs})
if not _top:
bad(f"Commissions invariants · aucun marqueur `# N ·` dans `_validate` de "
f"{CMI_GEN} (ledger vide → impossible de re-dériver)")
elif _dup_bare:
bad(f"Commissions invariants · marqueur(s) `# N ·` dupliqué(s) sans suffixe "
f"a/b dans `_validate` : {_dup_bare} — vrai doublon de registre")
else:
cmi_ninv = len(_top)
if _top != list(range(1, cmi_ninv + 1)):
bad(f"Commissions invariants · ledger `_validate` NON contigu : {_top} "
f"(attendu 1..{cmi_ninv}) — lacune/doublon dans les marqueurs `# N ·`")
else:
good(f"Commissions invariants · ledger `_validate` contigu 1..{cmi_ninv} "
f"({cmi_ninv} marqueurs `# N ·` · schéma compté à part)")
# (b) self-reports du .py « N invariants » (le distinct « N tests » est
# exclu nativement par l'ancre sur le mot « invariants »).
cmi_py_rep = sorted({int(x) for x in re.findall(r"(\d+)\s+invariants", cmi_src)})
if not cmi_py_rep:
bad(f"Commissions invariants · aucune mention « N invariants » dans {CMI_GEN} "
f"(self-report absent → attendu {cmi_ninv})")
elif cmi_py_rep == [cmi_ninv]:
good(f"Commissions invariants · {CMI_GEN} — self-report(s) {cmi_py_rep} "
f"== ledger ({cmi_ninv})")
else:
bad(f"Commissions invariants · {CMI_GEN} — self-report(s) « N invariants » "
f"{cmi_py_rep} MAIS ledger en compte {cmi_ninv} "
"(une chaîne du générateur désynchronisée du registre)")
# README : titre de section + toute mention + énumération numérotée.
try:
cmi_rd_raw = open(CMI_RD, encoding="utf-8").read()
except OSError as e:
bad(f"Commissions invariants · {CMI_RD} illisible : {e}"); cmi_rd_raw = None
if cmi_rd_raw is not None:
_mh = re.search(r"##\s+Les\s+(\d+)\s+invariants", cmi_rd_raw)
if _mh is None:
bad(f"Commissions invariants · {CMI_RD} — section « ## Les N invariants » "
f"INTROUVABLE (attendu {cmi_ninv})")
else:
# (c) compte annoncé du titre.
if int(_mh.group(1)) == cmi_ninv:
good(f"Commissions invariants · {CMI_RD} — annonce ({_mh.group(1)}) "
f"== ledger ({cmi_ninv})")
else:
bad(f"Commissions invariants · {CMI_RD} — annonce ({_mh.group(1)}) MAIS "
f"ledger en compte {cmi_ninv} → régénérer le README")
# (d) toute mention « N invariants » du README == ledger.
cmi_rd_rep = sorted({int(x) for x in re.findall(r"(\d+)\s+invariants", cmi_rd_raw)})
if cmi_rd_rep == [cmi_ninv]:
good(f"Commissions invariants · {CMI_RD} — mention(s) {cmi_rd_rep} "
f"== ledger (aucune dérive)")
else:
bad(f"Commissions invariants · {CMI_RD} — mention(s) « N invariants » "
f"{cmi_rd_rep} MAIS ledger en compte {cmi_ninv} → régénérer le README")
# (e) énumération numérotée de la section == contiguë 1..N.
_msec = re.search(r"##\s+Les\s+\d+\s+invariants.*?(?=\n##\s|\Z)",
cmi_rd_raw, re.S)
cmi_enum = ([int(x) for x in
re.findall(r"(?<![\d.])(\d+)\.\s", _msec.group(0))]
if _msec else [])
if cmi_enum == list(range(1, cmi_ninv + 1)):
good(f"Commissions invariants · {CMI_RD} — énumération prose "
f"contiguë 1..{cmi_ninv} == ledger")
else:
bad(f"Commissions invariants · {CMI_RD} — énumération prose {cmi_enum} "
f"≠ 1..{cmi_ninv} (item sauté/dupliqué) → régénérer le README")
# ============================================================================
# Mobile · app_config · IDENTITÉ du COMPTE D'INVARIANTS de `_validate_bundle` —
# 8ᵉ instance de la classe « ledger d'invariants » (après Chat OTOIA · SEO ·
# Fiscal e-CF · Legal CONFOTUR · CRM Dossier Vente · QA Audit 5D · CRM
# Commissions), appliquée au générateur de la config app mobile (roadmap Sprint 5
# l.56-57 « Rebuild Expo 54 + submit »).
# ----------------------------------------------------------------------------
# Le générateur app_config_gen.py applique un COMPTE d'invariants de cohérence
# (onglets=portails RBAC · rôles=surface exacte · marque #4 · devises #10 · Expo
# SDK · anti-invention a_confirmer · déterminisme…) dont la source d'autorité
# n'est PAS un artefact JSON (invisible à check_artifacts qui byte-gate les
# out/*.json) mais le LEDGER NUMÉROTÉ de `_validate_bundle` : les marqueurs
# `# Invariant N ·` (1..12) qui ouvrent chaque contrôle. Le nombre « 12 » est
# transcrit À LA MAIN à QUATRE endroits qu'AUCUN gate ne couvrait : UNE chaîne du
# .py (message de succès CLI « … schéma + 12 invariants verts »), DEUX du README
# (bloc de commande « schéma + 12 invariants », prose de récap « Schéma + 12
# invariants verts » — le « 12 » et le mot séparés par un saut de ligne) + UNE du
# fichier de tests (docstring « … + les 12 invariants du CLI »). Ajouter un
# contrôle `# Invariant 13 ·` à `_validate_bundle` sans toucher ces chaînes ⇒ le
# README, le self-report CLI ET la docstring de test mentent en silence pendant
# que la CI applique 13 contrôles — « vert trompeur » (#6) que ni les suites
# tests/ (qui EXÉCUTENT la validation mais ne comptent jamais la prose) ni le
# byte-gate d'artefact (aveugle au .py source) n'attrapent.
# PARTICULARITÉS vs les 7 blocs précédents :
# · le REGISTRE-source utilise le style `# Invariant N ·` (le mot « Invariant »
# précède le numéro) — les blocs Chat OTOIA/SEO/Fiscal/CONFOTUR/Dossier/Audit
# 5D/Commissions ancrés sur le style nu `# N ·` NE le voient PAS ; on ancre
# donc sur `#\s*Invariant\s+(\d+)…` ;
# · la fonction s'appelle `_validate_bundle` (pas `_validate`) — l'ancre `def
# _validate\b` des autres blocs ne matche pas (`\b` échoue avant `_bundle`) ;
# · ledger PROPRE 1..N compté À PART du schéma (phrasé « schéma + N invariants »)
# — pas de schéma-#1 non marqué, pas de split a/b ; on garde le filet doublon
# nu ;
# · ce README n'a NI titre « ## Les N invariants » NI énumération numérotée du
# ledger → on gate (a)(b)(b')(d), en AJOUTANT la surface fichier-de-tests (b')
# absente des blocs précédents (leurs tests ne portaient pas le chiffre).
# On RECOMPUTE le ledger depuis le SEUL registre-source (les `# Invariant N ·` de
# `_validate_bundle`) et on exige : (a) ledger CONTIGU 1..N (lacune OU doublon nu
# = vrai défaut de registre, mordu) ; (b) TOUTE mention « N invariants » du .py ==
# |ledger| ; (b') TOUTE mention « N invariants » du fichier de tests == |ledger| ;
# (d) TOUTE mention « N invariants » du README == |ledger|. Mention ou registre
# absent = échec (disparition d'un chiffre = régression de traçabilité #6).
# ============================================================================
MBI_GEN = f"{D}/mobile/app_config/app_config_gen.py"
MBI_RD = f"{D}/mobile/app_config/README.md"
MBI_TS = f"{D}/mobile/app_config/tests/test_mobile_app_config.py"
try:
mbi_src = open(MBI_GEN, encoding="utf-8").read()
except OSError as e:
bad(f"Mobile invariants · générateur {MBI_GEN} illisible : {e}"); mbi_src = None
if mbi_src is not None:
_mfn = re.search(r"def _validate_bundle\b", mbi_src)
if _mfn is None:
bad(f"Mobile invariants · fonction `_validate_bundle` INTROUVABLE dans {MBI_GEN} "
"(registre-source du ledger absent → impossible de re-dériver)")
else:
_bs = _mfn.end()
_mn = re.search(r"\ndef ", mbi_src[_bs:])
_body = mbi_src[_bs: _bs + _mn.start()] if _mn else mbi_src[_bs:]
_pairs = re.findall(r"#\s*Invariant\s+(\d+)([a-z]?)\s*·", _body)
_dup_bare = sorted({n for n in {p[0] for p in _pairs}
if [s for num, s in _pairs if num == n and s == ""]
and sum(1 for num, _ in _pairs if num == n) > 1})
_top = sorted({int(n) for n, _ in _pairs})
if not _top:
bad(f"Mobile invariants · aucun marqueur `# Invariant N ·` dans `_validate_bundle` "
f"de {MBI_GEN} (ledger vide → impossible de re-dériver)")
elif _dup_bare:
bad(f"Mobile invariants · marqueur(s) `# Invariant N ·` dupliqué(s) sans suffixe "
f"a/b dans `_validate_bundle` : {_dup_bare} — vrai doublon de registre")
else:
mbi_ninv = len(_top)
if _top != list(range(1, mbi_ninv + 1)):
bad(f"Mobile invariants · ledger `_validate_bundle` NON contigu : {_top} "
f"(attendu 1..{mbi_ninv}) — lacune/doublon dans les marqueurs `# Invariant N ·`")
else:
good(f"Mobile invariants · ledger `_validate_bundle` contigu 1..{mbi_ninv} "
f"({mbi_ninv} marqueurs `# Invariant N ·` · schéma compté à part)")
# (b) self-report(s) du .py « N invariants ».
mbi_py_rep = sorted({int(x) for x in re.findall(r"(\d+)\s+invariants", mbi_src)})
if not mbi_py_rep:
bad(f"Mobile invariants · aucune mention « N invariants » dans {MBI_GEN} "
f"(self-report absent → attendu {mbi_ninv})")
elif mbi_py_rep == [mbi_ninv]:
good(f"Mobile invariants · {MBI_GEN} — self-report(s) {mbi_py_rep} "
f"== ledger ({mbi_ninv})")
else:
bad(f"Mobile invariants · {MBI_GEN} — self-report(s) « N invariants » "
f"{mbi_py_rep} MAIS ledger en compte {mbi_ninv} "
"(une chaîne du générateur désynchronisée du registre)")
# (b') self-report du fichier de tests « N invariants » (docstring).
try:
mbi_ts_raw = open(MBI_TS, encoding="utf-8").read()
except OSError as e:
bad(f"Mobile invariants · {MBI_TS} illisible : {e}"); mbi_ts_raw = None
if mbi_ts_raw is not None:
mbi_ts_rep = sorted({int(x) for x in re.findall(r"(\d+)\s+invariants", mbi_ts_raw)})
if not mbi_ts_rep:
bad(f"Mobile invariants · aucune mention « N invariants » dans {MBI_TS} "
f"(self-report tests absent → attendu {mbi_ninv})")
elif mbi_ts_rep == [mbi_ninv]:
good(f"Mobile invariants · {MBI_TS} — docstring {mbi_ts_rep} "
f"== ledger ({mbi_ninv})")
else:
bad(f"Mobile invariants · {MBI_TS} — docstring « N invariants » "
f"{mbi_ts_rep} MAIS ledger en compte {mbi_ninv} → régénérer la docstring")
# (d) toute mention « N invariants » du README == ledger.
try:
mbi_rd_raw = open(MBI_RD, encoding="utf-8").read()
except OSError as e:
bad(f"Mobile invariants · {MBI_RD} illisible : {e}"); mbi_rd_raw = None
if mbi_rd_raw is not None:
mbi_rd_rep = sorted({int(x) for x in re.findall(r"(\d+)\s+invariants", mbi_rd_raw)})
if not mbi_rd_rep:
bad(f"Mobile invariants · aucune mention « N invariants » dans {MBI_RD} "
f"(self-report README absent → attendu {mbi_ninv})")
elif mbi_rd_rep == [mbi_ninv]:
good(f"Mobile invariants · {MBI_RD} — mention(s) {mbi_rd_rep} "
f"== ledger (aucune dérive)")
else:
bad(f"Mobile invariants · {MBI_RD} — mention(s) « N invariants » "
f"{mbi_rd_rep} MAIS ledger en compte {mbi_ninv} → régénérer le README")
# ============================================================================
# CRM · workflow_vente · IDENTITÉ du COMPTE D'INVARIANTS de `_validate_bundle` —
# 9ᵉ instance de la classe « ledger d'invariants » (après Chat OTOIA · SEO ·
# Fiscal e-CF · Legal CONFOTUR · CRM Dossier Vente · QA Audit 5D · CRM
# Commissions · Mobile), appliquée au générateur du workflow de vente Frappe
# (roadmap Sprint 4 l.50 « lead → visite → devis → réservation → contrat →
# CONFOTUR »).
# ----------------------------------------------------------------------------
# Le générateur workflow_vente_gen.py applique un COMPTE d'invariants métier de
# graphe (unicité des états · transitions vers états déclarés · monotonie
# doc_status · unicité (état,action) · atteignabilité depuis Lead · terminal de
# succès · séparation des pouvoirs #6 · maîtres = états/actions · cohérence des
# comptes du manifeste) dont la source d'autorité n'est PAS un artefact JSON
# (invisible à check_artifacts qui byte-gate les out/*.json) mais le LEDGER
# NUMÉROTÉ de `_validate_bundle` : les marqueurs `# Invariant N ·` (1..9) qui
# ouvrent chaque contrôle. Le nombre « 9 » est transcrit À LA MAIN à QUATRE
# endroits qu'AUCUN gate ne couvrait : UNE chaîne du .py (message de succès CLI
# « … schéma + 9 invariants verts. ») + TROIS du README (bloc de commande
# « schéma + 9 invariants métier », prose de vérification « 9 invariants de
# graphe », prose d'auto-score « 9 invariants de graphe »). Ajouter un contrôle
# `# Invariant 10 ·` à `_validate_bundle` sans toucher ces chaînes ⇒ le README ET
# le self-report CLI mentent en silence pendant que la CI applique 10 contrôles —
# « vert trompeur » (#6) que ni les suites tests/ (FONCTIONS de validation, jamais
# la prose) ni le byte-gate d'artefact (aveugle au .py source) n'attrapent.
# Prouvé ungate par mutation avant ajout (mémoire verify-uncovered-before-gating) :
# « 9 invariants » → « 10 » dans le README laissait check_readme_claims exit 0.
# PARTICULARITÉS vs les 8 blocs précédents :
# · le REGISTRE-source utilise le style `# Invariant N ·` + la fonction
# `_validate_bundle` (comme Mobile — les blocs ancrés sur le style nu `# N ·` /
# `def _validate\b` ne le voient PAS) ;
# · ledger PROPRE 1..N compté À PART du schéma (phrasé « schéma + N invariants ») —
# pas de schéma-#1 non marqué, pas de split a/b ; on garde le filet doublon nu ;
# · le module porte un SECOND nombre distinct — « 25 tests » (la suite unittest,
# déjà gatée par ailleurs) présent dans le README : l'ancre sur le mot
# « invariants » l'exclut nativement (« 25 tests » ≠ mot « invariants ») ⇒ aucun
# faux positif ;
# · ce README N'A NI titre « ## Les N invariants » (son titre « ## Invariants
# vérifiés par le CLI » ne porte AUCUN chiffre) NI énumération 1:1 avec le
# ledger — sa liste « Invariants vérifiés par le CLI » compte le SCHÉMA en item 1
# puis FUSIONNE les invariants 8 & 9 dans son item 9 (« Maîtres … ; comptes du
# manifeste cohérents ») : elle N'EST PAS le ledger et la gater comme contiguë
# == ledger serait un faux positif fragile (présentation éditoriale, #6). On
# gate donc UNIQUEMENT (a)(b)(d), comme Audit 5D.
# On RECOMPUTE le ledger depuis le SEUL registre-source (les `# Invariant N ·` de
# `_validate_bundle`) et on exige : (a) ledger CONTIGU 1..N (lacune OU doublon nu =
# vrai défaut de registre, mordu) ; (b) TOUTE mention « N invariants » du .py ==
# |ledger| ; (d) TOUTE mention « N invariants » du README == |ledger|. Mention ou
# registre absent = échec (disparition d'un chiffre = régression de traçabilité #6).
# ============================================================================
WVI_GEN = f"{D}/crm/workflow_vente/workflow_vente_gen.py"
WVI_RD = f"{D}/crm/workflow_vente/README.md"
try:
wvi_src = open(WVI_GEN, encoding="utf-8").read()
except OSError as e:
bad(f"workflow_vente invariants · générateur {WVI_GEN} illisible : {e}"); wvi_src = None
if wvi_src is not None:
_mfn = re.search(r"def _validate_bundle\b", wvi_src)
if _mfn is None:
bad(f"workflow_vente invariants · fonction `_validate_bundle` INTROUVABLE dans "
f"{WVI_GEN} (registre-source du ledger absent → impossible de re-dériver)")
else:
_bs = _mfn.end()
_mn = re.search(r"\ndef ", wvi_src[_bs:])
_body = wvi_src[_bs: _bs + _mn.start()] if _mn else wvi_src[_bs:]
_pairs = re.findall(r"#\s*Invariant\s+(\d+)([a-z]?)\s*·", _body)
_dup_bare = sorted({n for n in {p[0] for p in _pairs}
if [s for num, s in _pairs if num == n and s == ""]
and sum(1 for num, _ in _pairs if num == n) > 1})
_top = sorted({int(n) for n, _ in _pairs})
if not _top:
bad(f"workflow_vente invariants · aucun marqueur `# Invariant N ·` dans "
f"`_validate_bundle` de {WVI_GEN} (ledger vide → impossible de re-dériver)")
elif _dup_bare:
bad(f"workflow_vente invariants · marqueur(s) `# Invariant N ·` dupliqué(s) sans "
f"suffixe a/b dans `_validate_bundle` : {_dup_bare} — vrai doublon de registre")
else:
wvi_ninv = len(_top)
if _top != list(range(1, wvi_ninv + 1)):
bad(f"workflow_vente invariants · ledger `_validate_bundle` NON contigu : {_top} "
f"(attendu 1..{wvi_ninv}) — lacune/doublon dans les marqueurs `# Invariant N ·`")
else:
good(f"workflow_vente invariants · ledger `_validate_bundle` contigu 1..{wvi_ninv} "
f"({wvi_ninv} marqueurs `# Invariant N ·` · schéma compté à part)")
# (b) self-report(s) du .py « N invariants » (le distinct « N tests » est
# exclu nativement par l'ancre sur le mot « invariants »).
wvi_py_rep = sorted({int(x) for x in re.findall(r"(\d+)\s+invariants", wvi_src)})
if not wvi_py_rep:
bad(f"workflow_vente invariants · aucune mention « N invariants » dans {WVI_GEN} "
f"(self-report absent → attendu {wvi_ninv})")
elif wvi_py_rep == [wvi_ninv]:
good(f"workflow_vente invariants · {WVI_GEN} — self-report(s) {wvi_py_rep} "
f"== ledger ({wvi_ninv})")
else:
bad(f"workflow_vente invariants · {WVI_GEN} — self-report(s) « N invariants » "
f"{wvi_py_rep} MAIS ledger en compte {wvi_ninv} "
"(une chaîne du générateur désynchronisée du registre)")
# (d) toute mention « N invariants » du README == ledger.
try:
wvi_rd_raw = open(WVI_RD, encoding="utf-8").read()
except OSError as e:
bad(f"workflow_vente invariants · {WVI_RD} illisible : {e}"); wvi_rd_raw = None
if wvi_rd_raw is not None:
wvi_rd_rep = sorted({int(x) for x in re.findall(r"(\d+)\s+invariants", wvi_rd_raw)})
if not wvi_rd_rep:
bad(f"workflow_vente invariants · aucune mention « N invariants » dans {WVI_RD} "
f"(self-report README absent → attendu {wvi_ninv})")
elif wvi_rd_rep == [wvi_ninv]:
good(f"workflow_vente invariants · {WVI_RD} — mention(s) {wvi_rd_rep} "
f"== ledger (aucune dérive)")
else:
bad(f"workflow_vente invariants · {WVI_RD} — mention(s) « N invariants » "
f"{wvi_rd_rep} MAIS ledger en compte {wvi_ninv} → régénérer le README")
# ============================================================================
# DevOps deploy_runbook + QA acceptance · IDENTITÉ du COMPTE d'INVARIANTS de
# `validate_bundle` — DEUXIÈME famille de « ledger d'invariants », distincte des
# 8 générateurs déjà gatés (Chat OTOIA · SEO · Fiscal · CONFOTUR · Dossier Vente ·
# Audit 5D · Commissions · Mobile).
# ----------------------------------------------------------------------------
# La série « ledger » précédente a été déclarée close à 8 générateurs — MAIS sur
# le SEUL patron `# N ·` / « N invariants » / `_validate`(`_validate_bundle`).
# DEUX générateurs portent le MÊME nombre magique dans un DIALECTE différent, resté
# HORS de tout gate : marqueurs `# INVn ·` (préfixe `INV`) dans `validate_bundle`,
# et phrasé « N **familles d'**invariants ». C'est exactement le piège consigné
# (« CLOSED après N était FAUX — un style de marqueur différent avait glissé ») :
# • devops/deploy_runbook — ledger `# INV1..INV11` dans `validate_bundle`, compte
# « 11 familles d'invariants » recopié à TROIS surfaces (générateur l.272 · README
# l.73 · suite tests l.4) ;
# • qa/acceptance — ledger `# INV1..INV11` dans `validate_bundle`, compte à DEUX
# surfaces (README l.46 « 11 invariants » · suite tests l.4 « 11 familles
# d'invariants »). Le .py acceptance ne porte PAS de compte numéroté → non exigé.
# Ajouter un contrôle `# INV12 ·` à `validate_bundle` sans toucher ces chaînes ⇒
# README + self-reports mentent en silence pendant que la CI applique 12 contrôles —
# « vert trompeur » (#6) que ni les suites tests/ (FONCTIONS de validation, jamais
# la prose) ni le byte-gate d'artefact (aveugle au .py source) n'attrapent. Ledger
# PROPRE 1..N compté À PART du schéma (phrasé « schéma + N invariants »). Les
# mentions NON numérotées de « invariants » (docstrings « invariants de … », « Un
# invariant refuse … ») ne sont pas ancrées par un chiffre → aucun faux positif. On
# RECOMPUTE le ledger depuis le SEUL registre-source (les `# INVn ·` de
# `validate_bundle`) et on exige : (a) ledger CONTIGU 1..N (lacune/doublon = défaut
# de registre) ; (b) CHAQUE surface déclarée porte ≥1 mention « N [familles d']
# invariants » toutes == |ledger|. Surface sans mention = self-report évaporé (échec
# · régression de traçabilité #6). Les daily_reports/activity_log (snapshots datés,
# ex. acceptance « 10 » d'avant la croissance 10→11) NE sont PAS des surfaces vives
# et ne sont volontairement pas ancrés.
# ============================================================================
DRA_INV_RE = re.compile(r"(\d+)\s+(?:familles\s+d['’ʼ]\s*)?invariants")
DRA_MODS = [
("DevOps · deploy_runbook", f"{D}/devops/deploy_runbook/deploy_runbook_gen.py",
[("générateur", f"{D}/devops/deploy_runbook/deploy_runbook_gen.py"),
("README", f"{D}/devops/deploy_runbook/README.md"),
("suite tests", f"{D}/devops/deploy_runbook/tests/test_deploy_runbook.py")]),
("QA · acceptance", f"{D}/qa/acceptance/acceptance_gen.py",
[("README", f"{D}/qa/acceptance/README.md"),
("suite tests", f"{D}/qa/acceptance/tests/test_acceptance.py")]),
]
for _lbl, _gen, _surfaces in DRA_MODS:
try:
_src = open(_gen, encoding="utf-8").read()
except OSError as e:
bad(f"{_lbl} invariants · générateur {_gen} illisible : {e}"); continue
_mfn = re.search(r"def validate_bundle\b", _src)
if _mfn is None:
bad(f"{_lbl} invariants · fonction `validate_bundle` INTROUVABLE dans {_gen} "
"(registre-source du ledger absent → impossible de re-dériver)"); continue
_bs = _mfn.end()
_mn = re.search(r"\ndef ", _src[_bs:])
_body = _src[_bs: _bs + _mn.start()] if _mn else _src[_bs:]
_pairs = re.findall(r"#\s*INV\s*(\d+)([a-z]?)\s*·", _body)
_dup_bare = sorted({n for n in {p[0] for p in _pairs}
if [s for num, s in _pairs if num == n and s == ""]
and sum(1 for num, _ in _pairs if num == n) > 1})
_top = sorted({int(n) for n, _ in _pairs})
if not _top:
bad(f"{_lbl} invariants · aucun marqueur `# INVn ·` dans `validate_bundle` de "
f"{_gen} (ledger vide → impossible de re-dériver)"); continue
if _dup_bare:
bad(f"{_lbl} invariants · marqueur(s) `# INVn ·` dupliqué(s) dans "
f"`validate_bundle` : {_dup_bare} — vrai doublon de registre"); continue
_ninv = len(_top)
if _top != list(range(1, _ninv + 1)):
bad(f"{_lbl} invariants · ledger `validate_bundle` NON contigu : {_top} "
f"(attendu 1..{_ninv}) — lacune/doublon dans les marqueurs `# INVn ·`")
else:
good(f"{_lbl} invariants · ledger `validate_bundle` contigu 1..{_ninv} "
f"({_ninv} marqueurs `# INVn ·` · schéma compté à part)")
for _sname, _spath in _surfaces:
try:
_sraw = open(_spath, encoding="utf-8").read()
except OSError as e:
bad(f"{_lbl} invariants · {_sname} {_spath} illisible : {e}"); continue
_rep = sorted({int(x) for x in DRA_INV_RE.findall(_sraw)})
if not _rep:
bad(f"{_lbl} invariants · aucune mention « N (familles d')invariants » dans "
f"{_spath} ({_sname}) — self-report évaporé (attendu {_ninv} · régression #6)")
elif _rep == [_ninv]:
good(f"{_lbl} invariants · {_sname} — mention(s) {_rep} == ledger ({_ninv})")
else:
bad(f"{_lbl} invariants · {_sname} ({_spath}) — mention(s) « N invariants » "
f"{_rep} MAIS ledger `validate_bundle` en compte {_ninv} → resynchroniser")
# ============================================================================
# Traçabilité roadmap · le `roadmap_ref` VERBATIM des artefacts — la CITATION
# (n° de ligne + « libellé bullet ») ANCRÉE sur la ligne RÉELLE de la roadmap,
# pas seulement déclarée.
# ----------------------------------------------------------------------------
# Quatre modules (demo/scenarios · frontend/chat_otoia · frontend/portails ·
# mobile/app_config) publient dans out/MANIFEST.json (byte-gaté par
# check_artifacts) un `roadmap_ref` de la forme :
# « 04_roadmap/ROADMAP_8_WEEKS_OR_LESS.md · Sprint N l.A[-B] « <libellé bullet> » »
# Ce libellé est une TRANSCRIPTION VERBATIM du bullet de la roadmap à la ligne
# citée — mais AUCUN gate ne vérifiait que la ligne A[-B] porte RÉELLEMENT ce
# texte : check_artifacts ne prouve QUE spec==MANIFEST (byte-for-byte, la MÊME
# citation recopiée des deux côtés — l'un ne contredit jamais l'autre) ;
# l'ancrage roadmap de qa/acceptance (INV11) ne touche QUE les `roadmap_line`
# NUMÉRIQUES de SON artefact, jamais le libellé cité par les autres specs ;
# l'ancrage Expo (mobile) ne vérifie que la présence de « roadmap » + « Expo N »
# dans `expo_sdk_source`, pas la citation complète. → REORDONNER la roadmap (les
# n° de ligne glissent) ou REFORMULER un bullet laisse ces 4 citations pointer
# une ligne qui dit AUTRE CHOSE : « vert trompeur » de traçabilité (#6)
# qu'aucune suite tests/ (FONCTIONS de résolution, jamais l'ancre) n'attrape.
# Prouvé par mutation : reformuler le bullet l.63 laissait les 6 gates VERTS
# pendant que chat_otoia citait encore « … Chat OTOIA embedded … » à l.63.
# On ANCRE : pour chaque MANIFEST portant un `roadmap_ref` citant (l.A[-B] «…»),
# (1) le fichier cité == la roadmap canonique ; (2) le libellé, NORMALISÉ
# (markdown `**`/`- ` retirés, espaces compressés, NFC), est SOUS-CHAÎNE des
# lignes A..B réelles ; (3) le spec du module porte le MÊME `roadmap_ref`
# (identité spec↔artefact publié). Les 4 modules connus sont EXIGÉS présents
# (anti-évaporation #6) ; tout AUTRE MANIFEST à citation est ancré en bonus.
# ============================================================================
import glob as _glob
ROADMAP_REL = "04_roadmap/ROADMAP_8_WEEKS_OR_LESS.md"
RMREF_EXPECTED = {"demo/scenarios", "frontend/chat_otoia",
"frontend/portails", "mobile/app_config"}
def _rmn(s): # normalise une ligne markdown pour comparaison de libellé
s = unicodedata.normalize("NFC", str(s))
s = re.sub(r"^\s*[-*+]\s+", "", s) # marqueur de liste en tête de ligne
s = s.replace("*", "") # emphase **gras** / *ital*
return re.sub(r"\s+", " ", s).strip()
try:
_rm_lines = open(ROADMAP_REL, encoding="utf-8").read().splitlines()
except OSError as e:
bad(f"Traçabilité roadmap · {ROADMAP_REL} illisible : {e}"); _rm_lines = None
if _rm_lines is not None:
_man_paths = sorted(set(_glob.glob(f"{D}/*/*/out/MANIFEST.json") +
_glob.glob(f"{D}/*/out/MANIFEST.json")))
_rm_seen = set()
for _mp in _man_paths:
_mod = _mp[len(D) + 1:].rsplit("/out/", 1)[0] # ex. « demo/scenarios »
try:
_ref = json.load(open(_mp, encoding="utf-8")).get("roadmap_ref", "") or ""
except (OSError, ValueError) as e:
bad(f"Traçabilité roadmap · {_mp} illisible : {e}"); continue
_mln = re.search(r"l\.(\d+)(?:-(\d+))?", _ref)
_mq = re.search(r"«\s*(.+?)\s*»", _ref)
if not (_mln and _mq):
continue # pas une citation ligne+libellé → hors périmètre de ce gate
_rm_seen.add(_mod)
_path = _ref.split(" · ", 1)[0].strip()
_a = int(_mln.group(1)); _b = int(_mln.group(2) or _mln.group(1))
_quote = _mq.group(1)
# (1) fichier cité == roadmap canonique
if _path != ROADMAP_REL:
bad(f"Traçabilité roadmap · {_mod} — cite « {_path} » ≠ roadmap "
f"canonique « {ROADMAP_REL} »"); continue
# bornes de ligne valides (renumérotation hors-plage attrapée ici)
if not (1 <= _a <= _b <= len(_rm_lines)):
bad(f"Traçabilité roadmap · {_mod} — plage l.{_a}-{_b} hors roadmap "
f"(1..{len(_rm_lines)})"); continue
_hay = " ".join(_rmn(_rm_lines[_i - 1]) for _i in range(_a, _b + 1))
_rng = f"l.{_a}" + ("" if _a == _b else f"-{_b}")
# (2) libellé cité == sous-chaîne des lignes réelles A..B
if _rmn(_quote) not in _hay:
bad(f"Traçabilité roadmap · {_mod} — libellé cité « {_quote} » ABSENT "
f"des lignes {_rng} de la roadmap (qui disent « {_hay} ») → "
f"REFORMULATION/RENUMÉROTATION : aligner le roadmap_ref (ou la roadmap)")
continue
# (3) spec du module porte le MÊME roadmap_ref (identité spec↔artefact publié)
_specs = _glob.glob(f"{D}/{_mod}/*_spec.json")
_spec_ok = True
if len(_specs) == 1:
try:
_sref = json.load(open(_specs[0], encoding="utf-8")).get("roadmap_ref")
except (OSError, ValueError):
_sref = None
if _sref is not None and _sref != _ref:
_spec_ok = False
bad(f"Traçabilité roadmap · {_mod} — spec roadmap_ref « {_sref} » ≠ "
f"MANIFEST « {_ref} » (artefact interne incohérent)")
if _spec_ok:
good(f"Traçabilité roadmap · {_mod} — citation {_rng} « {_quote} » "
f"== roadmap")
# anti-évaporation : les 4 citations connues doivent TOUTES subsister
_rm_missing = sorted(RMREF_EXPECTED - _rm_seen)
if _rm_missing:
bad(f"Traçabilité roadmap · citation roadmap_ref ÉVAPORÉE pour {_rm_missing} "
f"(module attendu sans (l.N «…») dans out/MANIFEST.json · #6)")
elif _rm_seen:
good(f"Traçabilité roadmap · {len(_rm_seen)} citation(s) ancrée(s), "
f"4 modules attendus présents")
sys.exit(1 if FAIL else 0)
PY
rc=$?
echo
if [[ "$rc" -eq 0 ]]; then
echo -e "\033[32m✅ Chiffres du README fidèles aux artefacts cités (aucune donnée inventée · #6).\033[0m"
else
echo -e "\033[31m❌ Chiffre(s) du README périmé(s) vs artefacts — corriger README avant merge.\033[0m"
fi
exit "$rc"