Files
oto-enterprise-os-dtp/ci/check_readme_claims.sh
T
Claude Code DTP Worker 082b057639 [DTP-Worker] Sprint 8 · buffer · CRM/workflow_vente (3e surface) : la TABLE « Rôles = contrat RBAC » — le RÔLE RBAC qui GARDE chaque transition du pipeline vente, CŒUR sécurité du workflow — était transcrite EN PROSE (README:55-62) SANS AUCUN gate d'IDENTITÉ.
Les deux blocs CRM/workflow existants gatent le COMPTE (« 9 états · 11 transitions ») ET l'énumération des transitions à séparation des pouvoirs (allow_self_approval=0), jamais l'identité des GARDES. Sources faisant autorité (byte-gatées par check_artifacts) : out/workflow.json (chaque transition porte `allowed` = le rôle gardien) + out/MANIFEST.json.roles_rbac_utilises[].erpnext_role_name (recomputé du contrat rbac_50_roles.json à chaque build). PIÈGE : les blocs COMPTE/séparation sont AVEUGLES à l'identité des gardes → RÉAFFECTER un pas monétaire (« Confirmer réservation » Réservations→Conseiller = élévation de privilège) · RENOMMER un rôle · AJOUTER un fantôme · en OUBLIER un laissait 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 déjà gatée.

Nouveau bloc « CRM gardes » dans ci/check_readme_claims.sh (après le bloc Démo scénarios) : (1) identité d'ensemble — colonne « Rôle » de la table == {allowed} de workflow.json (set-diff · NFKD/casefold) ; (2) cohérence artefacts — {allowed} == roles_rbac_utilises du MANIFEST (aucun garde hors manifeste ni l'inverse) · ensemble NON VIDE ; (3) cross-cohérence par pas — les gardes des étapes SENSIBLES et uniques Confirmer réservation/Signer contrat/Approuver CONFOTUR (recomputés `allowed`, jamais figés) nommés EXACTEMENT dans leur ligne. Le (3) mord la RÉAFFECTATION vers un rôle DÉJÀ présent (rôle servant deux transitions) que le set-diff seul manquerait. Un claim absent échoue AUSSI.

7 morsures vérifiées : README réaffecte Confirmer réservation→Conseiller (absents=[réservations] + pas mordu) · artefact réaffecte Signer contrat→Conseiller (en trop=[contrats] + incohérence MANIFEST + pas mordu) · README rôle fantôme (en trop=[oto fantome]) · README retire un rôle (absents=[direction commerciale]) · artefact MANIFEST perd un garde (garde pas dans MANIFEST=[réservations]) · table supprimée (INTROUVABLE) · artefact réaffecte Approuver CONFOTUR→Directeur (rôle déjà présent, set INCHANGÉ, captée UNIQUEMENT par la cross-cohérence par pas) ; restauré = green : 7 rôles · colonne == {allowed} == roles_rbac_utilises · 3 étapes sensibles pinnées · exit 0. État courant : aucun garde périmé (anti-invention #6, rien à réécrire) — le défaut était la surface ungated. ci/README.md (table + détail « 3e surface workflow_vente ») mis à jour · working tree byte-restauré (git checkout --, JAMAIS git clean) · 7 gates re-verts.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-31 23:11:18 +00:00

2877 lines
176 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# ============================================================================
# check_readme_claims.sh · OTO Enterprise OS DTP
# ----------------------------------------------------------------------------
# Gate d'INTÉGRITÉ des chiffres du README (Sprint 8 buffer · CLAUDE.md #6 —
# « Zéro invention de chiffres · toujours vérifier sources archives »).
#
# Le README.md est le POINT D'ENTRÉE du mandat. Sa section « État courant
# (sourcé) » affiche des chiffres et déclare, mot pour mot :
# « Chaque chiffre ci-dessous est sourcé vers un artefact commité
# (anti-invention #6) ; ce README n'introduit aucune donnée nouvelle. »
# Cette promesse n'était garantie par AUCUN gate. `check_docs.sh` ne valide que
# les liens (cible existe) — pas la VALEUR des nombres. Résultat : quand un
# module + son job CI sont ajoutés (21→22 modules, 21→22 suites) ou qu'une
# promesse change de statut (14 in_repo + 1 out_of_scope → 15 in_repo), les
# chiffres du README se PÉRIMENT en silence tout en restant « sourcés » vers un
# artefact qui, lui, dit autre chose. Un README qui se contredit avec sa propre
# source est un « vert trompeur » de la même classe que la matrice périmée
# (« demo 18→21 ») ou l'orphan_tests_dirs (INV4) — appliqué à la doc d'entrée.
#
# Ce gate RECOMPUTE chaque chiffre depuis l'artefact cité (jamais une liste à la
# main · #6) et exige l'égalité avec ce qui est ÉCRIT dans les docs d'entrée :
# README.md :
# • Qualité 4Big N/M modules gated à K/100, verdict → qa/audit_4big totals
# • Régression N suites gated → qa/regression plan
# • Recette N promesses (X sprint + Y métriques), Z in_repo, verdict
# → qa/acceptance matrix
# • Agents « 13 agents » (×2 : nav + titre) → 03_agents/*/AGENT.md
# 03_agents/qa/AGENT.md (fiche QA · « Verdict agrégé courant ») :
# • N suites · M tests · M passés · E échec · E erreur · verdict
# → qa/regression run
# 03_agents/erpnext_backend/AGENT.md (« source unique ») :
# • N tests · M suites · verdict (matrice de régression du repo)
# → qa/regression run
# 05_deliverables_mvp/qa/audit_4big/README.md (« Résultat courant ») :
# • N/M modules à K/100, verdict (l'auditeur compte les modules mais figeait
# son PROPRE total à la main : 17→22) → qa/audit_4big totals
# 03_agents/*/AGENT.md (tables de livrables · colonne « Tests ») :
# • chaque cellule « Tests » par suite gatée → plan.suites[path]
# (+ count_tests pour le self_module qa/regression, exclu de la matrice)
# 03_agents/{crm,erpnext_backend}/AGENT.md (agrégats EN PROSE) :
# • « Total CRM N (a+b+c) » · « RBAC N (…) » · « e-CF N » — total ET multiset
# des composants recomputés (une compensation entre suites laisserait la
# table juste et la prose fausse) → plan.suites[path]
# frontend/portails/README.md + 03_agents/frontend_console/AGENT.md :
# • comptes PAR WORKSPACE (cartes / liens / rôles) transcrits À LA MAIN dans
# DEUX docs (table du README + prose « OTO Ventes 4 cartes/11 liens/12
# rôles ») + total « N Workspace natifs » → portails MANIFEST
# (tableau `workspaces`, byte-gaté par check_artifacts)
# seo/README.md + 03_agents/seo/AGENT.md (comptes de mots-clés) :
# • total + ventilation « 258 mots-clés (fr=87 · en=87 · es=84) » transcrits À
# LA MAIN dans DEUX docs → seo MANIFEST.counts
# (keywords_total + keywords_per_lang, recomptés de projets_master, byte-gatés)
# crm/workflow_vente/README.md + crm/dossier_vente/README.md (pipeline vente) :
# • « N états · M transitions » (titre du README workflow) + « N états du
# pipeline » (README DocType porteur, même compte dérivé du même graphe)
# transcrits À LA MAIN dans DEUX docs → workflow_vente
# MANIFEST.counts (states + transitions, recomptés du spec pipeline, byte-gatés)
# rbac/{roleprofile_gen,apply_plan,userperm_gen}/README.md (comptes 3 volets) :
# • table « Nb rôles » PAR profil + total bijectif → roleprofile MANIFEST
# • ligne agrégée « 6 étapes · 50 rôles / 116 DocPerm / 28 UP / 6 Role
# Profile · 4 DocType custom + 5 Company + 4 rôles » → apply_plan MANIFEST
# • ventilation « 28 entite · 16 groupe · 2 own · 4 equipe » → userperm MANIFEST
# (les 3 MANIFEST recalculés du contrat rbac_50_roles.json, byte-gatés)
# mobile/app_config/README.md (ligne récap de l'app Expo) :
# • quadruplet « 5 onglets · 44 rôles couverts · 3 langues · 13 identifiants
# a_confirmer » transcrit À LA MAIN → app_config
# MANIFEST.counts (onglets/roles_couverts/langues/identifiants_a_confirmer,
# recomptés de rbac_50_roles + seo_spec + a_confirmer, byte-gatés)
# legal/confotur/README.md + 03_agents/onapi_legal/AGENT.md (DocType CONFOTUR) :
# • quintuplet « 18 champs (14 de donnée) · 4 sections · 3 rôles · 2 évènements
# de dépôt » transcrit À LA MAIN dans DEUX docs → confotur
# MANIFEST.counts (fields/data_fields/sections/roles/depot_events, recomptés
# de confotur_spec.json, byte-gatés)
# frontend/chat_otoia/README.md + 03_agents/frontend_console/AGENT.md (montage
# du Chat OTOIA par portail) :
# • triplet « N `Custom Block` · M configs runtime · P portails » (tous = 5
# aujourd'hui — piège aveugle) transcrit À LA MAIN dans DEUX docs → chat_otoia
# MANIFEST.counts (custom_blocks/mounts/portails, recomptés du contrat RBAC,
# byte-gatés) + cohérence croisée == |MANIFEST.portails| (bijection 1 block ⇔
# 1 mount ⇔ 1 portail)
# qa/acceptance/README.md + 03_agents/qa/AGENT.md (module de recette) :
# • couple « N livrables de sprint + M métriques succès MVP » transcrit À LA MAIN
# dans DEUX docs de module (le bloc racine ne gate que le README d'entrée)
# → recompté depuis la matrice acceptance (kind sprint_deliverable / mvp_metric)
# + cohérence croisée == MANIFEST.counts (compteur ⇔ matrice résumée)
# fiscal/ecf_dgii/README.md (×3) + 03_agents/erpnext_backend/AGENT.md (format e-NCF) :
# • le CONTRAT DE FORMAT de l'e-NCF « E + tipoeCF(2) + secuencia(10) = 13 »
# (préfixe · longueur segment tipo · longueur segment secuencia · longueur
# totale) transcrit À LA MAIN dans QUATRE endroits de prose → recompté depuis
# le SEUL bloc `e_ncf` d'ecf_plan.json (prefix/longueur/regex/structure, byte-
# gatés) + cohérences croisées (1+tipo+seq == longueur · |regex digits| ==
# tipo+seq — mordent un artefact internement incohérent)
# Un claim absent échoue AUSSI (la dérive de formulation qui ferait disparaître
# un chiffre est elle-même une régression de traçabilité).
#
# Dépendances : bash, git, python3 (stdlib pur · CLAUDE.md #2 — runner sans pip).
# ============================================================================
set -uo pipefail
# shellcheck source=ci/lib.sh
source "$(dirname "${BASH_SOURCE[0]}")/lib.sh" || exit 3
cd_repo_root # racine du dépôt, ou ROUGE honnête si hors arbre git (cf. lib.sh)
echo "== Intégrité des chiffres du README (valeur == artefact cité · #6) =="
python3 - <<'PY'
import json, re, subprocess, sys, unicodedata
D = "05_deliverables_mvp"
FAIL = 0
def bad(m):
global FAIL; FAIL = 1; print(f" \033[31m✗\033[0m {m}")
def good(m):
print(f" \033[32m✓\033[0m {m}")
try:
readme = open("README.md", encoding="utf-8").read()
except OSError as e:
print(f" \033[31m✗\033[0m README.md illisible : {e}"); sys.exit(1)
# Texte normalisé (espaces/newlines compressés) pour les claims multi-lignes.
norm = re.sub(r"\s+", " ", readme)
def load(p):
with open(f"{D}/{p}", encoding="utf-8") as f:
return json.load(f)
# --- Vérités-terrain recalculées depuis les artefacts (jamais saisies) -------
q = load("qa/audit_4big/out/quality_report.json")["totals"]
qv = load("qa/audit_4big/out/quality_report.json")["verdict"]
reg = load("qa/regression/out/regression_plan.json")["totals"]
acc = load("qa/acceptance/out/acceptance_matrix.json")
rows = acc["matrix"]
n_sprint = sum(1 for r in rows if r["kind"] == "sprint_deliverable")
n_metric = sum(1 for r in rows if r["kind"] == "mvp_metric")
n_inrepo = sum(1 for r in rows if r["status"] == "in_repo")
acc_verdict = acc["verdict"]
agents = subprocess.run(["git", "ls-files", "03_agents/*/AGENT.md"],
capture_output=True, text=True).stdout.split()
n_agents = len(agents)
# --- Chaque claim : (libellé, regex sur `norm`, groupes attendus) ------------
# Le n-uplet capturé (str) est comparé à l'attendu (str). Un claim non trouvé
# échoue (regex is None) — la disparition d'un chiffre est une régression.
checks = [
("Qualité 4Big · modules gated",
r"\*\*(\d+)/(\d+) modules gated à (\d+)/100\*\*",
(str(q["pass"]), str(q["modules"]), str(q["min_score"])),
f"pass={q['pass']} total={q['modules']} min_score={q['min_score']}"),
("Régression · suites gated",
r"\*\*(\d+) suites gated\*\*",
(str(reg["suites"]),),
f"suites={reg['suites']}"),
("Recette · promesses (sprint + métriques)",
r"\*\*(\d+) promesses\*\* \((\d+) livrables sprint \+ (\d+) métriques",
(str(len(rows)), str(n_sprint), str(n_metric)),
f"total={len(rows)} sprint={n_sprint} metric={n_metric}"),
("Recette · promesses in_repo",
r"\*\*(\d+) `in_repo`",
(str(n_inrepo),),
f"in_repo={n_inrepo}"),
("Agents · fiche nav",
r"Fiches des \*\*(\d+) agents\*\*",
(str(n_agents),),
f"AGENT.md={n_agents}"),
("Agents · titre section",
r"## Les (\d+) agents",
(str(n_agents),),
f"AGENT.md={n_agents}"),
]
for label, pat, expected, src in checks:
m = re.search(pat, norm)
if m is None:
bad(f"{label} — claim INTROUVABLE dans README (attendu {src}) · motif=/{pat}/")
continue
got = m.groups()
if got == expected:
good(f"{label} — README {'/'.join(got)} == artefact ({src})")
else:
bad(f"{label} — README dit {'/'.join(got)} MAIS artefact dit {'/'.join(expected)} ({src}) → régénérer README")
# --- Verdicts textuels cités dans le README (PASS / true) --------------------
if re.search(r"verdict `PASS`", norm):
if qv == "PASS":
good(f"Qualité 4Big · verdict README `PASS` == artefact ({qv})")
else:
bad(f"Qualité 4Big · README dit verdict `PASS` MAIS artefact dit {qv}")
else:
bad("Qualité 4Big · verdict `PASS` non cité dans README (attendu)")
if re.search(r"verdict `true`", norm):
if acc_verdict is True:
good("Recette · verdict README `true` == artefact (True)")
else:
bad(f"Recette · README dit verdict `true` MAIS artefact dit {acc_verdict}")
else:
bad("Recette · verdict `true` non cité dans README (attendu)")
# ============================================================================
# Fiche QA (03_agents/qa/AGENT.md) · même classe de « vert trompeur » —
# ----------------------------------------------------------------------------
# Sa section « Verdict agrégé courant » affiche un compte agrégé (« N suites
# gated · M tests · M passés · 0 échec · 0 erreur → verdict PASS ») en déclarant
# « jamais compté à la main » et en citant qa/regression/out/regression_run.json.
# Ce compte se PÉRIME pourtant en silence quand une suite est ajoutée : la fiche
# affichait « 21 suites · 534 tests » alors que le run agrégé faisant autorité en
# disait déjà davantage (matrice recomptée ci-dessous, jamais figée ici). Comme pour
# le README, aucun gate ne comparait la VALEUR au run. On la RECALCULE ici depuis
# le compte agrégé faisant autorité (regression_run.json, commité · déterministe).
# ============================================================================
QA = "03_agents/qa/AGENT.md"
try:
qa_txt = re.sub(r"\s+", " ", open(QA, encoding="utf-8").read())
except OSError as e:
bad(f"Fiche QA · {QA} illisible : {e}"); qa_txt = ""
run = load("qa/regression/out/regression_run.json")
suites = run["suites"]
agg = (
str(len(suites)), # suites
str(sum(s["ran"] for s in suites)), # tests exécutés
str(sum(s["passed"] for s in suites)), # passés
str(sum(s["failures"] for s in suites)), # échecs
str(sum(s["errors"] for s in suites)), # erreurs
run["verdict"], # verdict
)
qa_pat = (r"\*\*(\d+) suites gated · (\d+) tests · (\d+) passés · "
r"(\d+) échec · (\d+) erreur → verdict `(PASS|FAIL)`")
qa_src = (f"suites={agg[0]} tests={agg[1]} passés={agg[2]} "
f"échec={agg[3]} erreur={agg[4]} verdict={agg[5]}")
mqa = re.search(qa_pat, qa_txt)
if mqa is None:
bad(f"Fiche QA · verdict agrégé — claim INTROUVABLE dans {QA} (attendu {qa_src})")
elif mqa.groups() == agg:
good(f"Fiche QA · verdict agrégé — {QA} {'/'.join(mqa.groups())} == regression_run ({qa_src})")
else:
bad(f"Fiche QA · {QA} dit {'/'.join(mqa.groups())} MAIS regression_run dit "
f"{'/'.join(agg)} ({qa_src}) → régénérer la fiche")
# ============================================================================
# Fiche ERPNext Backend (03_agents/erpnext_backend/AGENT.md) · même classe.
# ----------------------------------------------------------------------------
# Sa ligne « source unique » situait les tests backend dans le repo par un
# compte agrégé (« repo : 560 tests au total ») saisi à la main. Ce nombre s'est
# PÉRIMÉ en silence (matrice 534 → 551 → 558) : 560 ne correspondait plus ni à la
# matrice (558) ni au repo-wide (584 méthodes test_*). Aucun gate ne le couvrait.
# On le RECALCULE depuis la même matrice faisant autorité (regression_run) et on
# exige tests + suites + verdict — comme la fiche QA.
# ============================================================================
BE = "03_agents/erpnext_backend/AGENT.md"
try:
be_txt = re.sub(r"\s+", " ", open(BE, encoding="utf-8").read())
except OSError as e:
bad(f"Fiche Backend · {BE} illisible : {e}"); be_txt = ""
be_exp = (
str(sum(s["ran"] for s in suites)), # tests exécutés (matrice)
str(len(suites)), # suites
run["verdict"], # verdict
)
be_pat = (r"matrice\s+de régression du repo : \*\*(\d+) tests · (\d+) suites\*\* · "
r"verdict `(PASS|FAIL)`")
be_src = f"tests={be_exp[0]} suites={be_exp[1]} verdict={be_exp[2]}"
mbe = re.search(be_pat, be_txt)
if mbe is None:
bad(f"Fiche Backend · matrice repo — claim INTROUVABLE dans {BE} (attendu {be_src})")
elif mbe.groups() == be_exp:
good(f"Fiche Backend · matrice repo — {BE} {'/'.join(mbe.groups())} == regression_run ({be_src})")
else:
bad(f"Fiche Backend · {BE} dit {'/'.join(mbe.groups())} MAIS regression_run dit "
f"{'/'.join(be_exp)} ({be_src}) → régénérer la fiche")
# ============================================================================
# README de l'auditeur 4Big (05_deliverables_mvp/qa/audit_4big/README.md) ·
# section « Résultat courant » — même classe de « vert trompeur ».
# ----------------------------------------------------------------------------
# Ironie parfaite : le module qui COMPTE les modules affichait son propre total
# SAISI À LA MAIN (« 17/17 modules à 100/100 »). Ce compte s'est PÉRIMÉ en silence
# quand des modules ont été ajoutés (17 → 22) — le README de l'auditeur contredisait
# son propre artefact `quality_report.json` (totals.modules = 22). Le gate racine
# ne couvrait QUE le README d'entrée, pas ce README de module. On RECALCULE ici
# depuis le même artefact faisant autorité (totals, déjà chargé dans `q`) et on
# exige l'égalité avec ce que la section « Résultat courant » ÉCRIT.
# ============================================================================
A4 = f"{D}/qa/audit_4big/README.md"
try:
a4_txt = re.sub(r"\s+", " ", open(A4, encoding="utf-8").read())
except OSError as e:
bad(f"README audit_4big · {A4} illisible : {e}"); a4_txt = ""
a4_exp = (qv, str(q["pass"]), str(q["modules"]), str(q["min_score"]))
a4_pat = (r"\*\*Verdict `(PASS|FAIL)`\*\* [—-] \*\*(\d+)/(\d+)\*\* "
r"modules à \*\*(\d+)/100\*\*")
a4_src = f"verdict={a4_exp[0]} pass={a4_exp[1]} modules={a4_exp[2]} min_score={a4_exp[3]}"
ma4 = re.search(a4_pat, a4_txt)
if ma4 is None:
bad(f"README audit_4big · « Résultat courant » — claim INTROUVABLE dans {A4} "
f"(attendu {a4_src})")
elif ma4.groups() == a4_exp:
good(f"README audit_4big · Résultat courant — {A4} "
f"{'/'.join(ma4.groups())} == quality_report ({a4_src})")
else:
bad(f"README audit_4big · {A4} dit {'/'.join(ma4.groups())} MAIS quality_report "
f"dit {'/'.join(a4_exp)} ({a4_src}) → régénérer le README")
# ============================================================================
# README de l'auditeur 4Big · BARÈME de notation (N critères + poids par critère)
# — même classe de « vert trompeur », au grain du référentiel de scoring.
# ----------------------------------------------------------------------------
# Le barème 4Big est DATA-DERIVED : quality_report.json.criteria (byte-gaté par
# check_artifacts, RECOMPUTÉ du référentiel à chaque build) porte la liste des
# critères et leur poids. Le README les RETRANSCRIT À LA MAIN à trois endroits :
# • la prose « Note chaque module sur N critères 4Big déterministes »,
# • la table « Critère | Poids | Fait vérifié » (une ligne id→poids par critère),
# • l'archétype « generator (N critères) » (generator applique TOUS les critères).
# Ajouter/retirer un critère ou changer un poids ferait dériver ces trois surfaces
# en silence pendant que l'artefact dit autre chose — aucune suite tests/ (qui teste
# des FONCTIONS, pas la prose du README) ne l'attrape. On RECOMPUTE ici chaque nombre
# depuis `criteria` (zéro duplication du barème du générateur · #6) et on exige
# l'égalité avec ce que la doc ÉCRIT. Cohérences croisées : Σ poids == 100 (base de
# renormalisation) ET tout module d'archétype `generator` porte EXACTEMENT
# len(criteria) checks — c.-à-d. « generator = tous les critères » (mord une dérive
# du modèle qui rendrait le « generator (N critères) » du README faux). Un claim
# absent échoue AUSSI (disparition = régression de traçabilité).
# Bloc (6) : la carte de RENORMALISATION PAR ARCHÉTYPE (« data_room sans HANDOFF ·
# parser sans CONTRAT ni HANDOFF · contract sans CLI ni HANDOFF ») est elle aussi
# data-derived — critères applicables d'un archétype = {check.criterion} de ses
# modules ⇒ exclus = barème applicables. Recomputés par archétype, comparés à la
# parenthèse du README (exclusions des 3 archétypes non-generator, jamais gatées
# avant), + uniformité « 1 archétype → 1 jeu ».
# ============================================================================
try:
qr_crit = load("qa/audit_4big/out/quality_report.json")
criteria = qr_crit["criteria"]
except (OSError, KeyError, ValueError) as e:
bad(f"README audit_4big · quality_report illisible : {e}"); criteria = None
if criteria is not None and a4_txt:
n_crit = len(criteria)
# 1) prose « N critères 4Big déterministes »
mc = re.search(r"sur (\d+) critères 4Big déterministes", a4_txt)
if mc is None:
bad(f"README audit_4big · prose « N critères 4Big » — claim INTROUVABLE dans "
f"{A4} (attendu {n_crit})")
elif mc.group(1) == str(n_crit):
good(f"README audit_4big · prose critères — {mc.group(1)} == criteria ({n_crit})")
else:
bad(f"README audit_4big · prose critères — {A4} dit {mc.group(1)} MAIS criteria "
f"en compte {n_crit} → régénérer le README")
# 2) table « Critère | Poids | … » — une ligne id→poids par critère
for c in criteria:
cid, w = c["id"], str(c["weight"])
mw = re.search(rf"\| `{re.escape(cid)}` \| (\d+) \|", a4_txt)
if mw is None:
bad(f"README audit_4big · critère {cid} — ligne INTROUVABLE dans {A4} "
f"(attendu poids={w})")
elif mw.group(1) == w:
good(f"README audit_4big · critère {cid} — poids {mw.group(1)} == criteria ({w})")
else:
bad(f"README audit_4big · critère {cid} — {A4} dit poids {mw.group(1)} MAIS "
f"criteria dit {w} → régénérer le README")
# 3) archétype « generator (N critères) » — generator applique TOUS les critères
mg = re.search(r"`generator` \((\d+) critères\)", a4_txt)
if mg is None:
bad(f"README audit_4big · archétype « generator (N critères) » — claim "
f"INTROUVABLE dans {A4} (attendu {n_crit})")
elif mg.group(1) == str(n_crit):
good(f"README audit_4big · generator — {mg.group(1)} critères == criteria ({n_crit})")
else:
bad(f"README audit_4big · generator — {A4} dit {mg.group(1)} MAIS l'archétype "
f"generator applique les {n_crit} critères → régénérer le README")
# 4) cohérence croisée : Σ poids == 100 (base de renormalisation)
w_sum = sum(c["weight"] for c in criteria)
if w_sum == 100:
good(f"README audit_4big · Σ poids critères == {w_sum} (base 100 cohérente)")
else:
bad(f"README audit_4big · Σ poids critères == {w_sum} ≠ 100 — barème incohérent "
f"avec la renormalisation documentée")
# 5) cohérence croisée : tout module `generator` porte len(criteria) checks
gen = [m for m in qr_crit["modules"] if m.get("archetype") == "generator"]
gen_bad = [m["id"] for m in gen if len(m.get("checks", [])) != n_crit]
if not gen:
good("README audit_4big · aucun module d'archétype generator (rien à recouper)")
elif gen_bad:
bad(f"README audit_4big · modules generator avec ≠ {n_crit} checks : {gen_bad} "
f"→ « generator = tous les critères » violé")
else:
good(f"README audit_4big · {len(gen)} module(s) generator portent {n_crit} "
f"checks (« generator = tous les critères » vérifié)")
# 6) carte « archétype → critères EXCLUS » (renormalisation) : le README énumère
# data_room « sans HANDOFF », parser « sans CONTRAT … ni HANDOFF », contract
# « sans CLI ni HANDOFF ». Cette carte est DATA-DERIVED : pour un archétype,
# l'ensemble des critères APPLICABLES = {check.criterion} de ses modules (les
# non-applicables sont ABSENTS des checks) ⇒ EXCLUS = ordre du barème appl.
# Le bloc (3) ne recompute QUE generator (5 critères) ; les exclusions des
# trois autres archétypes n'étaient recomputées par AUCUN gate. Changer la
# règle de renormalisation d'un archétype (ex. data_room exclut aussi CLI)
# ferait dériver cette prose en silence pendant que quality_report dit autre
# chose. On recompute par archétype + on exige que la parenthèse du README
# énumère EXACTEMENT cet ensemble. Uniformité en bonus : tous les modules
# d'un même archétype portent le MÊME ensemble applicable (sinon la carte
# « 1 archétype → 1 jeu de critères » n'a pas de sens).
crit_ids = [c["id"] for c in criteria]
crit_set = set(crit_ids)
by_arch, uni_bad = {}, []
for m in qr_crit["modules"]:
a = m.get("archetype")
appl = frozenset(ck["criterion"] for ck in m.get("checks", []))
if a in by_arch and by_arch[a] != appl:
uni_bad.append(m["id"])
else:
by_arch.setdefault(a, appl)
if uni_bad:
bad(f"README audit_4big · archétypes NON uniformes (modules aux critères "
f"applicables divergents) : {uni_bad} → renormalisation incohérente")
else:
good(f"README audit_4big · {len(by_arch)} archétype(s) à critères applicables "
f"uniformes (« 1 archétype → 1 jeu » vérifié)")
for a in sorted(by_arch):
if a == "generator":
continue # traité en (3) : « generator (N critères) », zéro exclusion
exp = [c for c in crit_ids if c not in by_arch[a]] # ordre du barème, stable
ma = re.search(rf"`{re.escape(a)}` \(([^)]*)\)", a4_txt)
if ma is None:
bad(f"README audit_4big · archétype `{a}` — énumération INTROUVABLE dans "
f"{A4} (attendu « sans {' ni '.join(exp)} »)")
continue
got = set(re.findall(r"[A-Z]{2,}", ma.group(1))) & crit_set
if got == set(exp):
good(f"README audit_4big · archétype `{a}` — exclus {sorted(got)} == "
f"référentiel ({exp})")
else:
bad(f"README audit_4big · archétype `{a}` — {A4} exclut {sorted(got)} MAIS "
f"le référentiel exclut {exp} → régénérer le README")
# ============================================================================
# Fiches agents · comptes de tests PAR SUITE (colonne « Tests » des tables) —
# même classe de « vert trompeur », au grain le PLUS FIN.
# ----------------------------------------------------------------------------
# Chaque fiche agent (03_agents/*/AGENT.md) recense ses livrables dans une table
# dont la dernière colonne « Tests » affiche le nombre de méthodes `def test_` de
# la suite. Ce nombre est SAISI À LA MAIN : il se périme dès qu'un test est
# ajouté/retiré. C'est arrivé (fiche QA : `acceptance` disait 31 alors que la
# suite en portait 37 · `audit_4big` 35→34 · `regression` 25→26). Les gates
# amont ne couvraient QUE la SOMME agrégée (« 564 tests ») : une compensation
# entre deux suites (l'une +1, l'autre 1) laisserait la somme juste et les DEUX
# lignes fausses. On RECOMPUTE ici chaque cellule depuis la source faisant
# autorité — plan.suites[path].test_methods pour les suites gated, et
# reglib.discovery.count_tests (même fonction que le plan · zéro duplication)
# pour le self_module (qa/regression, exclu de la matrice par SoD mais bien
# documenté avec son propre compte).
# ============================================================================
sys.path.insert(0, f"{D}/qa/regression")
try:
from reglib.discovery import count_tests
except Exception as e: # import cassé = red honnête (source de vérité absente)
bad(f"Comptes par suite · import reglib.discovery impossible : {e}")
count_tests = None
plan = load("qa/regression/out/regression_plan.json")
auth = {s["path"]: s["test_methods"] for s in plan["suites"]}
self_mod = plan["coverage"].get("self_module_excluded")
if count_tests is not None and self_mod:
auth[self_mod] = count_tests(f"{D}/{self_mod}/tests")[1]
# Ligne de table Tests : lien vers 05_deliverables_mvp/<path>/README.md + dernier
# champ numérique. On ne vérifie QUE les lignes pointant une suite gatée connue
# (auth) — toute autre ligne de table est ignorée (pas notre périmètre).
row_re = re.compile(
r"^\|\s*\[`\w+/`\]\([^)]*?05_deliverables_mvp/([\w/]+?)/README\.md\)"
r".*\|\s*(\d+)\s*\|\s*$")
fiches = subprocess.run(["git", "ls-files", "03_agents/*/AGENT.md"],
capture_output=True, text=True).stdout.split()
seen_rows = 0
for af in fiches:
try:
lines = open(af, encoding="utf-8").read().splitlines()
except OSError as e:
bad(f"Fiche {af} illisible : {e}"); continue
for ln, line in enumerate(lines, 1):
m = row_re.match(line)
if not m:
continue
path = m.group(1)
if path not in auth: # ligne pointant un non-suite → hors périmètre
continue
claimed, exp = int(m.group(2)), auth[path]
seen_rows += 1
if claimed == exp:
good(f"Fiche {af}:{ln} · {path} — Tests {claimed} == source ({exp})")
else:
bad(f"Fiche {af}:{ln} · {path} — Tests {claimed} MAIS source dit {exp} "
f"(plan.suites/count_tests) → régénérer la fiche")
if seen_rows == 0:
bad("Comptes par suite · AUCUNE ligne de table « Tests » trouvée dans les "
"fiches — la disparition du recensement est elle-même une régression.")
# ============================================================================
# Fiches agents · comptes AGRÉGÉS EN PROSE (« Total X : N tests (a + b + c) ») —
# dernière surface de la MÊME classe, un cran au-dessus des cellules de table.
# ----------------------------------------------------------------------------
# Les cellules « Tests » des tables sont recomputées ci-dessus, mais deux fiches
# rédigent EN PROSE un agrégat multi-suites : `crm` (« Total CRM : 81 tests
# (25 + 31 + 25) ») et `erpnext_backend` (« RBAC 60 tests (10 + 11 + 12 + 11 + 16)
# + e-CF 39 tests »). Ces totaux ET leurs composants sont SAISIS À LA MAIN et
# n'étaient couverts par AUCUN gate : une compensation entre deux suites (l'une
# +1, l'autre 1) laisserait la table juste et la prose fausse — exactement le
# motif per-suite corrigé la session précédente. On RECOMPUTE ici le total ET le
# multiset des composants (ordre-indépendant) depuis `auth` (plan.suites, source
# faisant autorité déjà chargée), et on exige l'égalité avec ce que la fiche ÉCRIT.
# ============================================================================
PROSE = [
("03_agents/crm/AGENT.md", "Total CRM",
r"Total CRM : \*\*(\d+) tests\*\* \(([\d + ]+)\)",
["crm/commissions", "crm/dossier_vente", "crm/workflow_vente"]),
("03_agents/erpnext_backend/AGENT.md", "RBAC backend",
r"\*\*RBAC (\d+) tests\*\* \(([\d + ]+)\)",
["rbac", "rbac/apply_plan", "rbac/fixtures_gen",
"rbac/roleprofile_gen", "rbac/userperm_gen"]),
]
for path_f, label, pat, suites_list in PROSE:
try:
txt = re.sub(r"\s+", " ", open(path_f, encoding="utf-8").read())
except OSError as e:
bad(f"Prose agrégée · {path_f} illisible : {e}"); continue
try:
exp_parts = sorted(auth[s] for s in suites_list)
except KeyError as e:
bad(f"Prose agrégée · {label} — suite {e} absente de plan.suites (câblage ?)")
continue
exp_total = sum(exp_parts)
m = re.search(pat, txt)
if m is None:
bad(f"Prose agrégée · {path_f} · « {label} » — claim INTROUVABLE "
f"(attendu total={exp_total} composants={exp_parts})")
continue
total = int(m.group(1))
parts = sorted(int(x) for x in re.findall(r"\d+", m.group(2)))
if total == exp_total and parts == exp_parts and total == sum(parts):
good(f"Prose agrégée · {path_f} · {label} — "
f"{total} ({'+'.join(map(str, parts))}) == source ({exp_total})")
else:
bad(f"Prose agrégée · {path_f} · {label} — dit total={total} "
f"composants={parts} MAIS source dit total={exp_total} "
f"composants={exp_parts} (interne_ok={total == sum(parts)}) "
f"→ régénérer la fiche")
# e-CF : agrégat en prose à SUITE UNIQUE (pas de composants entre parenthèses).
be_ecf = re.sub(r"\s+", " ", open("03_agents/erpnext_backend/AGENT.md",
encoding="utf-8").read())
ecf_exp = auth["fiscal/ecf_dgii"]
mecf = re.search(r"\*\*e-CF (\d+) tests\*\*", be_ecf)
if mecf is None:
bad(f"Prose agrégée · e-CF — claim INTROUVABLE (attendu {ecf_exp})")
elif int(mecf.group(1)) == ecf_exp:
good(f"Prose agrégée · e-CF — {mecf.group(1)} == source ({ecf_exp})")
else:
bad(f"Prose agrégée · e-CF — dit {mecf.group(1)} MAIS source dit {ecf_exp} "
f"(fiscal/ecf_dgii) → régénérer la fiche")
# ============================================================================
# Portails frontend · comptes PAR WORKSPACE (cartes / liens / rôles) —
# même classe de « vert trompeur », transcrits À LA MAIN dans DEUX docs.
# ----------------------------------------------------------------------------
# Le générateur de portails émet out/MANIFEST.json dont le tableau `workspaces`
# porte, PAR portail, {nb_cartes, nb_liens, nb_roles} recalculés depuis le
# contrat RBAC à chaque build (byte-gaté par check_artifacts). Ces triplets sont
# recopiés À LA MAIN à DEUX endroits : la table du README du module
# (frontend/portails/README.md, colonnes Cartes|Liens|Rôles) ET la prose de la
# fiche agent (03_agents/frontend_console/AGENT.md — « OTO Ventes 4 cartes/11
# liens/12 rôles · … »). Aucun gate ne les couvrait : ajouter un rôle/DocType au
# contrat ferait dériver LES DEUX docs en silence pendant que l'artefact dit
# autre chose — même piège que les cellules « Tests » et la prose agrégée. On
# RECOMPUTE ici chaque triplet depuis MANIFEST (déjà byte-gaté · zéro duplication
# du compteur du générateur) et on exige l'égalité avec les DEUX docs. Un
# workspace absent d'un doc échoue AUSSI (traçabilité).
# ============================================================================
WS_RD = f"{D}/frontend/portails/README.md"
WS_FI = "03_agents/frontend_console/AGENT.md"
try:
ws_list = load("frontend/portails/out/MANIFEST.json")["workspaces"]
except (OSError, KeyError, ValueError) as e:
bad(f"Portails · MANIFEST illisible/inattendu : {e}"); ws_list = []
try:
ws_rd = re.sub(r"\s+", " ", open(WS_RD, encoding="utf-8").read())
except OSError as e:
bad(f"Portails · {WS_RD} illisible : {e}"); ws_rd = ""
try:
ws_fi = re.sub(r"\s+", " ", open(WS_FI, encoding="utf-8").read())
except OSError as e:
bad(f"Portails · {WS_FI} illisible : {e}"); ws_fi = ""
for w in ws_list:
portail, ws = w["portail"], w["workspace"]
exp = (str(w["nb_cartes"]), str(w["nb_liens"]), str(w["nb_roles"]))
src = f"cartes={exp[0]} liens={exp[1]} rôles={exp[2]}"
# 1) Table du README du module : | <portail> | `<ws>` | C | L | R |
rd_pat = (rf"\| {re.escape(portail)} \| `{re.escape(ws)}` \| "
r"(\d+) \| (\d+) \| (\d+) \|")
mrd = re.search(rd_pat, ws_rd)
if mrd is None:
bad(f"Portails README · {ws} — ligne INTROUVABLE dans {WS_RD} (attendu {src})")
elif mrd.groups() == exp:
good(f"Portails README · {ws} — {'/'.join(mrd.groups())} == MANIFEST ({src})")
else:
bad(f"Portails README · {ws} — {WS_RD} dit {'/'.join(mrd.groups())} MAIS "
f"MANIFEST dit {'/'.join(exp)} ({src}) → régénérer le README")
# 2) Prose de la fiche agent : `<ws>` C cartes/L liens/R rôles (ou C/L/R)
fi_pat = (rf"`{re.escape(ws)}` (\d+)(?: cartes)?/(\d+)(?: liens)?/"
r"(\d+)(?: rôles)?")
mfi = re.search(fi_pat, ws_fi)
if mfi is None:
bad(f"Portails fiche · {ws} — claim INTROUVABLE dans {WS_FI} (attendu {src})")
elif mfi.groups() == exp:
good(f"Portails fiche · {ws} — {'/'.join(mfi.groups())} == MANIFEST ({src})")
else:
bad(f"Portails fiche · {ws} — {WS_FI} dit {'/'.join(mfi.groups())} MAIS "
f"MANIFEST dit {'/'.join(exp)} ({src}) → régénérer la fiche")
# Total de workspaces (« N `Workspace` natifs ») cohérent artefact ↔ fiche.
n_ws = len(ws_list)
if n_ws:
mtot = re.search(r"\*\*(\d+) `Workspace` natifs", ws_fi)
if mtot is None:
bad(f"Portails fiche · total — « N `Workspace` natifs » INTROUVABLE "
f"dans {WS_FI} (attendu {n_ws})")
elif int(mtot.group(1)) == n_ws:
good(f"Portails fiche · total — {mtot.group(1)} == MANIFEST ({n_ws} workspaces)")
else:
bad(f"Portails fiche · total — {WS_FI} dit {mtot.group(1)} MAIS "
f"MANIFEST dit {n_ws} → régénérer la fiche")
# ============================================================================
# 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")
# 2) apply_plan — ligne « Génération réelle » agrégeant les 3 volets ------------
AP_RD = f"{D}/rbac/apply_plan/README.md"
try:
ap_man = load("rbac/apply_plan/out/MANIFEST.json")
ap_steps = len(load("rbac/apply_plan/out/apply_plan.json"))
except (OSError, KeyError, ValueError) as e:
bad(f"RBAC apply_plan · artefact illisible : {e}"); ap_man = None
ap_rd = read_norm(AP_RD)
if ap_man is not None and ap_rd is not None:
c, v = ap_man["counts"], ap_man["confirmations_vps"]
ap_exp = (str(ap_steps), str(c["roles"]), str(c["custom_docperm"]),
str(c["user_permission_templates"]), str(c["role_profiles"]),
str(len(v["custom_doctypes"])), str(len(v["companies"])),
str(len(v["roles_scope_equipe"])))
ap_src = (f"étapes={ap_exp[0]} rôles={ap_exp[1]} docperm={ap_exp[2]} "
f"up={ap_exp[3]} role_profiles={ap_exp[4]} custom_dt={ap_exp[5]} "
f"companies={ap_exp[6]} equipe={ap_exp[7]}")
ap_pat = (r"\*\*(\d+) étapes\*\* · (\d+) rôles / (\d+) DocPerm / "
r"(\d+) UP templates / (\d+) Role Profile · confirmations VPS "
r"(\d+) DocType custom \+ (\d+) Company \+ (\d+) rôles")
ma = re.search(ap_pat, ap_rd)
if ma is None:
bad(f"RBAC apply_plan · « Génération réelle » — claim INTROUVABLE dans "
f"{AP_RD} (attendu {ap_src})")
elif ma.groups() == ap_exp:
good(f"RBAC apply_plan · agrégat — {AP_RD} {'/'.join(ma.groups())} == "
f"MANIFEST ({ap_src})")
else:
bad(f"RBAC apply_plan · {AP_RD} dit {'/'.join(ma.groups())} MAIS MANIFEST "
f"dit {'/'.join(ap_exp)} ({ap_src}) → régénérer le README")
# 2bis) apply_plan — IDENTITÉ de la TABLE « Run-book généré » (SPEC §7) ----------
# Le bloc 2 ci-dessus ne gate que la ligne AGRÉGÉE « Génération réelle » (comptes).
# La table « ## Run-book généré » énumère, PAR étape, trois données data-derived de
# apply_plan.json (byte-gaté par check_artifacts) : le n° d'ordre (#), le
# RESPONSABLE (worker/vps/worker+vps) et la colonne « Dépend de » (les n°s d'ordre
# des `depends_on`). C'est le GRAPHE D'APPLICATION ORDONNÉ que l'agent ERPNext suit
# pas à pas — un responsable ou une dépendance périmés (ex. Role Profile importé
# AVANT les Role, ou une étape VPS attribuée au worker) sont un hazard réel,
# invisible à check_artifacts (qui ne prouve QUE apply_plan==build) ET au bloc de
# comptes (aveugle à l'identité des lignes ; ces colonnes étaient un WILDCARD).
# Même classe que la colonne « Type » de roleprofile_gen et la table demo/scenarios.
# On RECOMPUTE #/responsable/deps depuis apply_plan.json (zéro duplication) et on
# exige que la table les énonce EXACTEMENT. Une étape MANQUANTE ou une ligne FANTÔME
# échoue AUSSI (identité d'ensemble). La colonne « Étape » reste libre (paraphrase).
# Cohérences croisées en bonus (mordent un plan INTERNEMENT incohérent) : ordres
# contigus 1..N sans doublon · responsable ∈ {worker, vps, worker+vps} · toute
# dépendance pointe en ARRIÈRE (n° < n° de l'étape ⇒ pas de cycle/renvoi avant).
try:
ap_plan = load("rbac/apply_plan/out/apply_plan.json")
except (OSError, KeyError, ValueError) as e:
bad(f"RBAC apply_plan · run-book illisible : {e}"); ap_plan = None
if ap_plan is not None:
id2order = {s["id"]: s["order"] for s in ap_plan}
ALLOWED_RESP = {"worker", "vps", "worker+vps"}
orders = sorted(s["order"] for s in ap_plan)
if orders == list(range(1, len(ap_plan) + 1)):
good(f"RBAC apply_plan · run-book — ordres contigus 1..{len(ap_plan)} "
f"(aucun trou/doublon)")
else:
bad(f"RBAC apply_plan · run-book — ordres NON contigus {orders} "
f"(attendu 1..{len(ap_plan)}) → plan incohérent")
# Lignes de la table lues en RAW : la colonne 1 ENTIÈRE ⇒ scope la table
# run-book (seule table du README à 1ʳᵉ colonne numérique · vérifié).
try:
ap_lines = open(AP_RD, encoding="utf-8").read().splitlines()
except OSError as e:
bad(f"RBAC apply_plan · {AP_RD} illisible : {e}"); ap_lines = []
table_orders, rows_by_order = [], {}
for ln in ap_lines:
cells = [c.strip() for c in ln.strip().strip("|").split("|")]
if len(cells) == 4 and re.fullmatch(r"\d+", cells[0]):
o = int(cells[0]); table_orders.append(o); rows_by_order[o] = cells
if sorted(table_orders) == orders and len(table_orders) == len(set(table_orders)):
good(f"RBAC apply_plan · run-book — {len(table_orders)} lignes == "
f"{len(ap_plan)} étapes (aucune FANTÔME/MANQUANTE)")
else:
bad(f"RBAC apply_plan · run-book — lignes table {sorted(table_orders)} ≠ "
f"étapes artefact {orders} (ligne en trop/manquante ou doublon)")
for s in sorted(ap_plan, key=lambda x: x["order"]):
o, resp = s["order"], s["responsable"]
deps = sorted(id2order[x] for x in s["depends_on"])
if resp not in ALLOWED_RESP:
bad(f"RBAC apply_plan · étape {o} — responsable artefact « {resp} » "
f"hors {sorted(ALLOWED_RESP)} (plan incohérent)")
if any(d >= o for d in deps):
bad(f"RBAC apply_plan · étape {o} — dépendance en AVANT {deps} "
f"(≥ {o}) : graphe incohérent")
row = rows_by_order.get(o)
if row is None:
bad(f"RBAC apply_plan · étape {o} — ligne INTROUVABLE dans {AP_RD} "
f"(attendu responsable={resp} deps={deps or '—'})"); continue
got_resp = row[1]
got_deps = sorted(int(x) for x in re.findall(r"\d+", row[3]))
if got_resp == resp:
good(f"RBAC apply_plan · étape {o} — responsable « {got_resp} » == artefact")
else:
bad(f"RBAC apply_plan · étape {o} — {AP_RD} dit responsable « {got_resp} » "
f"MAIS artefact dit « {resp} » → régénérer le README")
if got_deps == deps:
good(f"RBAC apply_plan · étape {o} — Dépend de {got_deps or '—'} == artefact")
else:
bad(f"RBAC apply_plan · étape {o} — {AP_RD} « Dépend de » = {got_deps or '—'} "
f"MAIS artefact dit {deps or '—'} → régénérer le README")
# 3) userperm — ventilation par mécanisme d'enforcement -------------------------
UP_RD = f"{D}/rbac/userperm_gen/README.md"
try:
up_bm = load("rbac/userperm_gen/out/MANIFEST.json")["counts"]["by_mechanism"]
except (OSError, KeyError, ValueError) as e:
bad(f"RBAC userperm · MANIFEST illisible : {e}"); up_bm = None
up_rd = read_norm(UP_RD)
if up_bm is not None and up_rd is not None:
up_exp = (str(up_bm["user_permission_company"]), str(up_bm["none_consolidated"]),
str(up_bm["docperm_if_owner"]), str(up_bm["vps_confirm_team"]))
up_src = (f"entite={up_exp[0]} groupe={up_exp[1]} own={up_exp[2]} "
f"equipe={up_exp[3]}")
up_pat = (r"\*\*(\d+)\*\* `entite` · \*\*(\d+)\*\* `groupe` · "
r"\*\*(\d+)\*\* `own` · \*\*(\d+)\*\* `equipe`")
mu = re.search(up_pat, up_rd)
if mu is None:
bad(f"RBAC userperm · répartition — claim INTROUVABLE dans {UP_RD} "
f"(attendu {up_src})")
elif mu.groups() == up_exp:
good(f"RBAC userperm · répartition — {UP_RD} {'/'.join(mu.groups())} == "
f"MANIFEST ({up_src})")
else:
bad(f"RBAC userperm · {UP_RD} dit {'/'.join(mu.groups())} MAIS MANIFEST "
f"dit {'/'.join(up_exp)} ({up_src}) → régénérer le README")
# ============================================================================
# SEO · comptes de mots-clés (total + ventilation par langue) —
# même classe de « vert trompeur », transcrits À LA MAIN dans DEUX docs.
# ----------------------------------------------------------------------------
# Le générateur SEO émet out/MANIFEST.json dont le bloc `counts` porte
# keywords_total et keywords_per_lang{fr,en,es}, RECOMPTÉS à chaque build depuis
# `projets_master.json` (byte-gaté par check_artifacts ⇒ source faisant autorité).
# Ce quadruplet est recopié À LA MAIN à DEUX endroits : la table « Livrables » du
# README du module (05_deliverables_mvp/seo/README.md — « **258** mots-clés
# (fr=87 · en=87 · es=84) ») ET la même ligne de la fiche agent
# (03_agents/seo/AGENT.md). Aucun gate ne les couvrait : ajouter un projet ou du
# vocabulaire éditorial ferait dériver LES DEUX docs en silence pendant que
# l'artefact dit autre chose — même piège que les triplets par workspace des
# portails. On RECOMPUTE ici (total + 3 langues) depuis MANIFEST.counts (déjà
# byte-gaté · zéro duplication du compteur du générateur) et on exige l'égalité
# avec les DEUX docs. Un claim absent échoue AUSSI (traçabilité).
# ============================================================================
SEO_RD = f"{D}/seo/README.md"
SEO_FI = "03_agents/seo/AGENT.md"
try:
seo_c = load("seo/out/MANIFEST.json")["counts"]
seo_exp = (str(seo_c["keywords_total"]),
str(seo_c["keywords_per_lang"]["fr"]),
str(seo_c["keywords_per_lang"]["en"]),
str(seo_c["keywords_per_lang"]["es"]))
except (OSError, KeyError, ValueError) as e:
bad(f"SEO · MANIFEST illisible/inattendu : {e}"); seo_exp = None
if seo_exp is not None:
seo_src = (f"total={seo_exp[0]} fr={seo_exp[1]} en={seo_exp[2]} es={seo_exp[3]}")
seo_pat = r"\*\*(\d+)\*\* mots-clés \(fr=(\d+) · en=(\d+) · es=(\d+)\)"
for path_f in (SEO_RD, SEO_FI):
txt = read_norm(path_f)
if txt is None:
continue
m = re.search(seo_pat, txt)
if m is None:
bad(f"SEO · {path_f} — claim mots-clés INTROUVABLE (attendu {seo_src})")
elif m.groups() == seo_exp:
good(f"SEO · {path_f} — {'/'.join(m.groups())} == MANIFEST ({seo_src})")
else:
bad(f"SEO · {path_f} — dit {'/'.join(m.groups())} MAIS MANIFEST dit "
f"{'/'.join(seo_exp)} ({seo_src}) → régénérer le doc")
# ============================================================================
# CRM · pipeline vente (états / transitions) — même classe de « vert trompeur »,
# transcrit À LA MAIN dans DEUX README de module.
# ----------------------------------------------------------------------------
# Le générateur workflow_vente émet out/MANIFEST.json dont `counts` porte states
# et transitions, RECOMPTÉS à chaque build depuis workflow_vente_spec.json (le
# graphe pipeline · byte-gaté par check_artifacts ⇒ source faisant autorité). Ce
# couple est recopié À LA MAIN à DEUX endroits : le titre de section du README du
# module workflow (« ## Le pipeline (9 états · 11 transitions) ») ET le README du
# DocType porteur dossier_vente (« options = les **9 états** du pipeline »), qui
# DÉRIVE le même compte d'états du même graphe (source unique · #5). Aucun gate ne
# les couvrait : ajouter un état/une transition au pipeline ferait dériver LES
# DEUX docs en silence pendant que l'artefact dit autre chose — même piège que les
# triplets par workspace des portails ou les mots-clés SEO. On RECOMPUTE ici
# (states + transitions) depuis workflow_vente MANIFEST.counts (déjà byte-gaté ·
# zéro duplication du compteur du générateur) et on exige l'égalité avec les DEUX
# docs. Un claim absent échoue AUSSI (traçabilité). Recomputer transitions EN PLUS
# de states mord une dérive du 2e nombre là où le seul titre du README workflow
# (qui n'a pas les transitions dans dossier_vente) resterait aveugle.
# ============================================================================
WF_RD = f"{D}/crm/workflow_vente/README.md"
DV_RD = f"{D}/crm/dossier_vente/README.md"
try:
wf_c = load("crm/workflow_vente/out/MANIFEST.json")["counts"]
wf_states, wf_trans = str(wf_c["states"]), str(wf_c["transitions"])
except (OSError, KeyError, ValueError) as e:
bad(f"CRM pipeline · MANIFEST workflow_vente illisible/inattendu : {e}")
wf_states = None
if wf_states is not None:
# 1) README workflow — titre de section (états + transitions)
wf_src = f"états={wf_states} transitions={wf_trans}"
wf_txt = read_norm(WF_RD)
if wf_txt is not None:
m = re.search(r"## Le pipeline \((\d+) états · (\d+) transitions\)", wf_txt)
if m is None:
bad(f"CRM pipeline · {WF_RD} — titre pipeline INTROUVABLE (attendu {wf_src})")
elif m.groups() == (wf_states, wf_trans):
good(f"CRM pipeline · {WF_RD} — {'/'.join(m.groups())} == MANIFEST ({wf_src})")
else:
bad(f"CRM pipeline · {WF_RD} — dit {'/'.join(m.groups())} MAIS MANIFEST "
f"dit {wf_states}/{wf_trans} ({wf_src}) → régénérer le README")
# 2) README dossier_vente — même compte d'états, dérivé du même graphe
dv_txt = read_norm(DV_RD)
if dv_txt is not None:
m = re.search(r"les \*\*(\d+) états\*\* du pipeline", dv_txt)
if m is None:
bad(f"CRM pipeline · {DV_RD} — claim « N états du pipeline » INTROUVABLE "
f"(attendu états={wf_states})")
elif m.group(1) == wf_states:
good(f"CRM pipeline · {DV_RD} — états={m.group(1)} == MANIFEST (états={wf_states})")
else:
bad(f"CRM pipeline · {DV_RD} — dit états={m.group(1)} MAIS MANIFEST dit "
f"états={wf_states} → régénérer le README")
# ============================================================================
# CRM (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")
# ============================================================================
# Audit 5D · synthèse de l'audit de conformité (contrôles / dimensions / verdict
# pass-fail-à confirmer) — même classe de « vert trompeur », transcrite À LA MAIN
# dans DEUX docs.
# ----------------------------------------------------------------------------
# Le générateur audit_5d émet DEUX artefacts byte-gatés par check_artifacts
# (⇒ faisant autorité) : out/MANIFEST.json (controls_total, `dimensions`,
# `audited_artifacts`, `open_items`, verdict — recomptés du catalogue de contrôles
# à chaque build) ET out/audit_report.json dont `totals` porte la ventilation
# {controls, pass, fail, a_confirmer} recalculée en rejouant les 17 contrôles sur
# les hand-off amont. Deux nombres de cette synthèse sont recopiés À LA MAIN à
# DEUX endroits : la phrase d'ouverture du README du module (05_deliverables_mvp/
# qa/audit_5d/README.md — « **17 contrôles** répartis en **5 dimensions (5D)** »)
# ET la cellule de la fiche agent QA (03_agents/qa/AGENT.md — « 17 contrôles / 5
# dimensions »). La ventilation du verdict (« **13 PASS · 0 FAIL · 4 à confirmer** »)
# n'est portée QUE par le README, mais se PÉRIME dès qu'un contrôle bascule en
# A_CONFIRMER (pass 13→12, à confirmer 4→5) alors que le TOTAL de contrôles reste
# 17 — dérive AVEUGLE au seul compte de contrôles, d'où sa recomputation séparée
# depuis audit_report.totals. Aucun gate ne couvrait ces claims : ajouter un
# contrôle/une dimension au catalogue, ou faire basculer un contrôle, ferait
# dériver LES DEUX docs (et la ligne verdict) en silence pendant que les artefacts
# disent autre chose — même piège que les mots-clés SEO, les triplets par workspace
# des portails ou le quintuplet CONFOTUR. On RECOMPUTE ici (contrôles + dimensions
# depuis le MANIFEST · pass/fail/à confirmer depuis audit_report.totals · déjà
# byte-gatés · zéro duplication du compteur du générateur) et on exige l'égalité.
# Un claim absent échoue AUSSI (traçabilité). Cohérence croisée en bonus : le
# `controls_total` du MANIFEST doit égaler `totals.controls` du rapport.
# ============================================================================
A5D_RD = f"{D}/qa/audit_5d/README.md"
A5D_FI = "03_agents/qa/AGENT.md"
try:
a5_man = load("qa/audit_5d/out/MANIFEST.json")
a5_tot = load("qa/audit_5d/out/audit_report.json")["totals"]
a5_ctl = str(a5_man["controls_total"])
a5_dim = str(len(a5_man["dimensions"]))
if a5_ctl != str(a5_tot["controls"]):
bad(f"Audit 5D · incohérence artefacts : MANIFEST controls_total={a5_ctl} "
f"MAIS audit_report totals.controls={a5_tot['controls']}")
a5_pass, a5_fail = str(a5_tot["pass"]), str(a5_tot["fail"])
a5_conf = str(a5_tot["a_confirmer"])
except (OSError, KeyError, ValueError) as e:
bad(f"Audit 5D · artefact illisible/inattendu : {e}"); a5_ctl = None
if a5_ctl is not None:
a5_src = f"contrôles={a5_ctl} dimensions={a5_dim}"
# 1) README module — phrase d'ouverture (contrôles + dimensions)
a5_rd = read_norm(A5D_RD)
if a5_rd is not None:
m = re.search(r"\*\*(\d+) contrôles\*\* répartis en \*\*(\d+) dimensions \(5D\)\*\*",
a5_rd)
if m is None:
bad(f"Audit 5D · {A5D_RD} — synthèse contrôles/dimensions INTROUVABLE "
f"(attendu {a5_src})")
elif m.groups() == (a5_ctl, a5_dim):
good(f"Audit 5D · {A5D_RD} — {'/'.join(m.groups())} == artefact ({a5_src})")
else:
bad(f"Audit 5D · {A5D_RD} — dit {'/'.join(m.groups())} MAIS artefact dit "
f"{a5_ctl}/{a5_dim} ({a5_src}) → régénérer le README")
# 2) README module — ventilation du verdict (pass / fail / à confirmer)
a5_vsrc = f"pass={a5_pass} fail={a5_fail} à_confirmer={a5_conf}"
m = re.search(r"\*\*(\d+) PASS · (\d+) FAIL · (\d+) à confirmer\*\*", a5_rd)
if m is None:
bad(f"Audit 5D · {A5D_RD} — ventilation verdict INTROUVABLE (attendu {a5_vsrc})")
elif m.groups() == (a5_pass, a5_fail, a5_conf):
good(f"Audit 5D · {A5D_RD} — verdict {'/'.join(m.groups())} == "
f"audit_report.totals ({a5_vsrc})")
else:
bad(f"Audit 5D · {A5D_RD} — dit {'/'.join(m.groups())} MAIS totals disent "
f"{a5_pass}/{a5_fail}/{a5_conf} ({a5_vsrc}) → régénérer le README")
# 3) Fiche agent QA — même couple contrôles/dimensions, 2e transcription
a5_fi = read_norm(A5D_FI)
if a5_fi is not None:
m = re.search(r"(\d+) contrôles / (\d+) dimensions", a5_fi)
if m is None:
bad(f"Audit 5D · {A5D_FI} — claim « N contrôles / M dimensions » INTROUVABLE "
f"(attendu {a5_src})")
elif m.groups() == (a5_ctl, a5_dim):
good(f"Audit 5D · {A5D_FI} — {'/'.join(m.groups())} == artefact ({a5_src})")
else:
bad(f"Audit 5D · {A5D_FI} — dit {'/'.join(m.groups())} MAIS artefact dit "
f"{a5_ctl}/{a5_dim} ({a5_src}) → régénérer la fiche")
# ============================================================================
# Audit 5D (2e surface du MÊME README) · ÉNUMÉRATION des open items du « Verdict
# courant » (control → dimension → propriétaire) — même classe de « vert trompeur ».
# ----------------------------------------------------------------------------
# Le bloc précédent gate le COMPTE de la ventilation (« 13 PASS · 0 FAIL · 4 à
# confirmer », pass/fail/à confirmer recomputés d'audit_report.totals) mais PAS
# l'IDENTITÉ des 4 contrôles ouverts ni leur (dimension, propriétaire). La table
# « Verdict courant » du README (05_deliverables_mvp/qa/audit_5d/README.md) énumère
# ces 4 lignes À LA MAIN, alors que audit_report.open_items[] (byte-gaté par
# check_artifacts ⇒ faisant autorité, chaque item = {control, dimension, owner,
# detail} recalculé en rejouant les contrôles) fait foi. Un ÉCHANGE d'open item
# (ex. D2.3 → D3.1) laisse le compte à 4 — le bloc de ventilation reste AVEUGLE —
# pendant que l'artefact dit autre chose ; idem une dimension mal étiquetée
# (D1.3 rangé sous D2) ou un propriétaire réattribué. « Vert trompeur » qu'aucune
# suite tests/ (qui teste des FONCTIONS, pas la prose) n'attrape. On RECOMPUTE
# l'ensemble {control:(dimension, owner)} depuis open_items (zéro duplication du
# modèle de l'auditeur · #6) et on exige que la table README l'énumère EXACTEMENT :
# contrôle par ENSEMBLE (absent ET en trop mordus, pas seulement présence), puis
# (dimension, propriétaire) par LIGNE. Même patron que l'énumération des
# confirmations DevOps ou la carte de renormalisation par archétype 4Big. Cohérence
# croisée en bonus : l'ensemble des `control` == MANIFEST.open_items (le manifeste
# qui résume le rapport) — mord un manifeste désynchronisé de son propre rapport.
# ============================================================================
try:
a5_oi = load("qa/audit_5d/out/audit_report.json")["open_items"]
a5_oi_exp = {o["control"]: (o["dimension"], o["owner"]) for o in a5_oi}
a5_oi_man = set(load("qa/audit_5d/out/MANIFEST.json")["open_items"])
if a5_oi_man != set(a5_oi_exp):
bad(f"Audit 5D · incohérence artefacts : MANIFEST open_items {sorted(a5_oi_man)} "
f"MAIS audit_report open_items {sorted(a5_oi_exp)}")
except (OSError, KeyError, ValueError) as e:
bad(f"Audit 5D · open_items illisible/inattendu : {e}"); a5_oi_exp = None
if a5_oi_exp is not None:
try:
a5_lines = open(f"{D}/qa/audit_5d/README.md",
encoding="utf-8").read().splitlines()
except OSError as e:
bad(f"Audit 5D · README illisible : {e}"); a5_lines = None
if a5_lines is not None:
# ligne de table = « | <control> · <libellé> | <dimension> | <propriétaire> | »
a5_row_re = re.compile(
r"^\|\s*(D\d+\.\d+)\b[^|]*\|\s*([^|]+?)\s*\|\s*([^|]+?)\s*\|\s*$")
a5_seen = {}
for ln in a5_lines:
m = a5_row_re.match(ln)
if m:
a5_seen[m.group(1)] = (m.group(2).strip(), m.group(3).strip())
a5_exp_set, a5_got_set = set(a5_oi_exp), set(a5_seen)
if a5_got_set != a5_exp_set:
bad(f"Audit 5D · énumération open items — table README énumère "
f"{sorted(a5_got_set)} MAIS audit_report dit {sorted(a5_exp_set)} "
f"→ régénérer le README")
else:
a5_mism = [c for c in sorted(a5_exp_set) if a5_seen[c] != a5_oi_exp[c]]
if a5_mism:
for c in a5_mism:
bad(f"Audit 5D · open item {c} — README dit "
f"(dimension={a5_seen[c][0]}, propriétaire={a5_seen[c][1]}) MAIS "
f"audit_report dit (dimension={a5_oi_exp[c][0]}, "
f"propriétaire={a5_oi_exp[c][1]})")
else:
good(f"Audit 5D · énumération open items {sorted(a5_exp_set)} "
f"(control→dimension→propriétaire) == audit_report.open_items")
# ============================================================================
# DevOps · run-book de déploiement VPS (phases ordonnées / confirmations
# préalables) — même classe de « vert trompeur », transcrite À LA MAIN dans
# DEUX docs, sans AUCUN gate.
# ----------------------------------------------------------------------------
# Le générateur deploy_runbook émet out/MANIFEST.json (byte-gaté par
# check_artifacts ⇒ faisant autorité) dont `counts.phases` et
# `counts.confirmations` sont RECOMPTÉS à chaque build (phases = |graph.phase_order| ·
# confirmations = |confirmations_ouvertes|, l'ensemble consolidé des paramètres
# réglementaires non confirmés). Deux nombres de cette synthèse sont recopiés À LA
# MAIN : le compte de PHASES dans la fiche agent DevOps (03_agents/devops/AGENT.md —
# « graphe de **7 phases ordonnées** » ET, plus bas, « exécute les 7 phases sur le
# VPS ») et le compte de CONFIRMATIONS dans le README du module
# (05_deliverables_mvp/devops/deploy_runbook/README.md — « renseigner les 7
# confirmations »). Piège « aveugle » caractéristique (cf. bascule verdict audit_5d,
# compensation SEO entre langues) : phases ET confirmations valent 7 AUJOURD'HUI —
# un gate qui ne recompterait qu'UN nombre serait AVEUGLE à la dérive de l'autre
# (ajouter une phase au graphe → 8 phases pendant que les confirmations restent 7,
# ou l'inverse). On RECOMPUTE donc CHAQUE nombre depuis SON PROPRE champ du MANIFEST
# (jamais figé ici · zéro duplication du compteur du générateur #5) et on exige
# l'égalité avec les docs. Bonus anti-dérive : l'ÉNUMÉRATION des confirmations du
# README (les tokens `snake_case` de la phrase « Confirmations préalables VPS »)
# doit être l'ensemble EXACT de confirmations_ouvertes — capte l'ÉCHANGE d'un nom de
# confirmation (count aveugle à un swap 1↔1). Cohérence croisée : counts.phases ==
# |phase_order| · counts.confirmations == |confirmations_ouvertes|. Un claim absent
# échoue AUSSI (la disparition d'un chiffre est une régression · traçabilité).
# ============================================================================
DR_RD = f"{D}/devops/deploy_runbook/README.md"
DR_FI = "03_agents/devops/AGENT.md"
try:
dr_man = load("devops/deploy_runbook/out/MANIFEST.json")
dr_ph = str(dr_man["counts"]["phases"])
dr_cf = str(dr_man["counts"]["confirmations"])
dr_order = dr_man["graph"]["phase_order"]
dr_open = dr_man["confirmations_ouvertes"]
if dr_ph != str(len(dr_order)):
bad(f"DevOps · incohérence artefact : counts.phases={dr_ph} MAIS "
f"|graph.phase_order|={len(dr_order)}")
if dr_cf != str(len(dr_open)):
bad(f"DevOps · incohérence artefact : counts.confirmations={dr_cf} MAIS "
f"|confirmations_ouvertes|={len(dr_open)}")
except (OSError, KeyError, ValueError) as e:
bad(f"DevOps · artefact deploy_runbook illisible/inattendu : {e}"); dr_ph = None
if dr_ph is not None:
# 1) Fiche agent DevOps — « graphe de N phases ordonnées »
dr_fi = read_norm(DR_FI)
if dr_fi is not None:
m = re.search(r"graphe de \*\*(\d+) phases ordonnées\*\*", dr_fi)
if m is None:
bad(f"DevOps · {DR_FI} — claim « graphe de N phases ordonnées » INTROUVABLE "
f"(attendu phases={dr_ph})")
elif m.group(1) == dr_ph:
good(f"DevOps · {DR_FI} — {m.group(1)} phases == artefact (phases={dr_ph})")
else:
bad(f"DevOps · {DR_FI} — dit {m.group(1)} phases MAIS artefact dit {dr_ph} "
f"→ régénérer la fiche")
# 2) Fiche agent DevOps — 2e transcription « exécute les N phases »
m = re.search(r"exécute les (\d+) phases", dr_fi)
if m is None:
bad(f"DevOps · {DR_FI} — 2e claim « exécute les N phases » INTROUVABLE "
f"(attendu phases={dr_ph})")
elif m.group(1) == dr_ph:
good(f"DevOps · {DR_FI} — « exécute les {m.group(1)} phases » == artefact "
f"(phases={dr_ph})")
else:
bad(f"DevOps · {DR_FI} — 2e claim dit {m.group(1)} phases MAIS artefact dit "
f"{dr_ph} → régénérer la fiche")
# 3) README module — « renseigner les N confirmations » (compte)
dr_rd = read_norm(DR_RD)
if dr_rd is not None:
m = re.search(r"renseigner les (\d+) confirmations", dr_rd)
if m is None:
bad(f"DevOps · {DR_RD} — claim « renseigner les N confirmations » INTROUVABLE "
f"(attendu confirmations={dr_cf})")
elif m.group(1) == dr_cf:
good(f"DevOps · {DR_RD} — {m.group(1)} confirmations == artefact "
f"(confirmations={dr_cf})")
else:
bad(f"DevOps · {DR_RD} — dit {m.group(1)} confirmations MAIS artefact dit "
f"{dr_cf} → régénérer le README")
# 4) README module — l'ÉNUMÉRATION doit être l'ensemble EXACT des confirmations
seg = re.search(r"Confirmations préalables VPS\*\* \(consolidées, sourcées\) : "
r"(.+?) ## ", dr_rd)
if seg is None:
bad(f"DevOps · {DR_RD} — énumération « Confirmations préalables VPS » "
f"INTROUVABLE (attendu {sorted(dr_open)})")
else:
listed = sorted(set(re.findall(r"`([a-z_]+)`", seg.group(1))))
if listed == sorted(dr_open):
good(f"DevOps · {DR_RD} — énumération ({len(listed)}) == confirmations_ouvertes")
else:
bad(f"DevOps · {DR_RD} — énumération {listed} ≠ confirmations_ouvertes "
f"{sorted(dr_open)} → régénérer le README")
# ============================================================================
# Démo · run-sheet de pitch (nombre de modules cités) — même classe de « vert
# trompeur », transcrit À LA MAIN dans le diagramme d'architecture du README du
# module, sans AUCUN gate.
# ----------------------------------------------------------------------------
# Le générateur demo_scenario_gen émet out/MANIFEST.json (byte-gaté par
# check_artifacts ⇒ faisant autorité) dont `counts.modules_cites_uniques` est
# RECOMPTÉ à chaque build = |union des `modules_cites` des deux scénarios| — les
# hand-off amont que les beats citent par pointeur RFC 6901 (le module ne saisit
# AUCUNE valeur métier ; il résout depuis le disque). Ce nombre est recopié À LA
# MAIN dans le diagramme du README du module (05_deliverables_mvp/demo/scenarios/
# README.md — « out/ des modules livrés (10 modules cités) »). Aucun gate ne le
# couvrait : ajouter un beat qui cite un module encore non tissé (11) ou retirer
# la dernière citation d'un module d'un scénario (9) ferait dériver le diagramme
# en silence pendant que le MANIFEST byte-gaté dit autre chose — dérive qu'aucune
# suite tests/ (qui teste des FONCTIONS, pas la prose du README) n'attrape. Même
# piège que les mots-clés SEO, les triplets par workspace des portails, le
# quintuplet CONFOTUR ou la synthèse audit_5d. On RECOMPUTE depuis
# counts.modules_cites_uniques (jamais figé ici · zéro duplication du compteur du
# générateur #5) et on exige l'égalité. Cohérence croisée en bonus :
# counts.modules_cites_uniques == |modules_cites| (liste dédupliquée du manifeste,
# elle-même sans doublon) — mord un compteur désynchronisé de sa propre liste. Un
# claim absent échoue AUSSI (la disparition d'un chiffre est une régression).
# ============================================================================
DEMO_RD = f"{D}/demo/scenarios/README.md"
try:
demo_man = load("demo/scenarios/out/MANIFEST.json")
demo_n = str(demo_man["counts"]["modules_cites_uniques"])
demo_list = demo_man["modules_cites"]
if len(demo_list) != len(set(demo_list)):
bad(f"Démo · incohérence artefact : modules_cites contient des doublons "
f"({len(demo_list)} entrées, {len(set(demo_list))} uniques)")
if demo_n != str(len(demo_list)):
bad(f"Démo · incohérence artefact : counts.modules_cites_uniques={demo_n} "
f"MAIS |modules_cites|={len(demo_list)}")
except (OSError, KeyError, ValueError) as e:
bad(f"Démo · artefact scenarios illisible/inattendu : {e}"); demo_n = None
if demo_n is not None:
demo_rd = read_norm(DEMO_RD)
if demo_rd is not None:
m = re.search(r"out/ des modules livrés \((\d+) modules cités\)", demo_rd)
if m is None:
bad(f"Démo · {DEMO_RD} — claim « (N modules cités) » INTROUVABLE "
f"(attendu modules_cites_uniques={demo_n})")
elif m.group(1) == demo_n:
good(f"Démo · {DEMO_RD} — {m.group(1)} modules cités == artefact "
f"(modules_cites_uniques={demo_n})")
else:
bad(f"Démo · {DEMO_RD} — dit {m.group(1)} modules cités MAIS artefact dit "
f"{demo_n} → régénérer le README")
# ============================================================================
# Chat OTOIA · montage de l'assistant conversationnel par portail (nombre de
# Custom Block / configs runtime / portails) — même classe de « vert trompeur »,
# transcrit À LA MAIN dans DEUX docs, sans AUCUN gate.
# ----------------------------------------------------------------------------
# Le générateur chat_otoia_gen émet out/MANIFEST.json (byte-gaté par
# check_artifacts ⇒ faisant autorité) dont `counts.custom_blocks`, `counts.mounts`
# et `counts.portails` sont RECOMPTÉS à chaque build depuis le contrat RBAC (un
# `Custom Block` ⇔ une config runtime ⇔ un portail métier, `plateforme` exclu —
# invariant 2 du générateur : bijection 1 block ⇔ 1 mount ⇔ 1 portail). Ces
# nombres sont recopiés À LA MAIN : la fiche agent Frontend Console
# (03_agents/frontend_console/AGENT.md — « **N `Custom Block` + M configs
# runtime** ») ET le README du module (05_deliverables_mvp/frontend/chat_otoia/
# README.md — « Portails (N, `plateforme` exclu) », « N fixtures `Custom Block` »,
# « M configs runtime »). Piège « aveugle » caractéristique (cf. DevOps phases
# confirmations, bascule verdict audit_5d) : custom_blocks, mounts ET portails
# valent TOUS TROIS 5 AUJOURD'HUI — un gate qui n'en recompterait qu'un serait
# AVEUGLE à la dérive des autres (ajouter un portail au contrat RBAC → chat_otoia
# rebâtit 6 blocks 6 mounts 6 portails pendant que les docs restent à 5). On
# RECOMPUTE donc CHAQUE nombre depuis SON PROPRE champ du MANIFEST (jamais figé
# ici · zéro duplication du compteur du générateur #5) et on exige l'égalité avec
# les docs. Cohérence croisée : counts.custom_blocks == counts.mounts ==
# counts.portails == |MANIFEST.portails| (la bijection du générateur) — mord un
# compteur désynchronisé de sa propre liste. Un claim absent échoue AUSSI (la
# disparition d'un chiffre est une régression · traçabilité).
# ============================================================================
CO_RD = f"{D}/frontend/chat_otoia/README.md"
CO_FI = "03_agents/frontend_console/AGENT.md"
try:
co_man = load("frontend/chat_otoia/out/MANIFEST.json")
co_cb = str(co_man["counts"]["custom_blocks"])
co_mo = str(co_man["counts"]["mounts"])
co_po = str(co_man["counts"]["portails"])
co_nlist = len(co_man["portails"])
if not (co_cb == co_mo == co_po == str(co_nlist)):
bad(f"Chat OTOIA · incohérence artefact : custom_blocks={co_cb} · "
f"mounts={co_mo} · portails={co_po} · |portails|={co_nlist} "
f"(bijection 1 block ⇔ 1 mount ⇔ 1 portail attendue)")
except (OSError, KeyError, ValueError) as e:
bad(f"Chat OTOIA · artefact chat_otoia illisible/inattendu : {e}"); co_cb = None
if co_cb is not None:
# 1) Fiche agent Frontend Console — « N `Custom Block` + M configs runtime »
co_fi = read_norm(CO_FI)
if co_fi is not None:
m = re.search(r"\*\*(\d+) `Custom Block` \+ (\d+) configs runtime\*\*", co_fi)
if m is None:
bad(f"Chat OTOIA · {CO_FI} — claim « N `Custom Block` + M configs runtime » "
f"INTROUVABLE (attendu {co_cb}/{co_mo})")
elif m.groups() == (co_cb, co_mo):
good(f"Chat OTOIA · {CO_FI} — {'/'.join(m.groups())} == artefact "
f"(custom_blocks={co_cb} · mounts={co_mo})")
else:
bad(f"Chat OTOIA · {CO_FI} — dit {'/'.join(m.groups())} MAIS artefact dit "
f"{co_cb}/{co_mo} → régénérer la fiche")
# 2) README module — « Portails (N, `plateforme` exclu) »
co_rd = read_norm(CO_RD)
if co_rd is not None:
m = re.search(r"Portails \((\d+), `plateforme` exclu\)", co_rd)
if m is None:
bad(f"Chat OTOIA · {CO_RD} — claim « Portails (N, `plateforme` exclu) » "
f"INTROUVABLE (attendu portails={co_po})")
elif m.group(1) == co_po:
good(f"Chat OTOIA · {CO_RD} — {m.group(1)} portails == artefact "
f"(portails={co_po})")
else:
bad(f"Chat OTOIA · {CO_RD} — dit {m.group(1)} portails MAIS artefact dit "
f"{co_po} → régénérer le README")
# 3) README module — « N fixtures `Custom Block` »
m = re.search(r"(\d+) fixtures `Custom Block`", co_rd)
if m is None:
bad(f"Chat OTOIA · {CO_RD} — claim « N fixtures `Custom Block` » INTROUVABLE "
f"(attendu custom_blocks={co_cb})")
elif m.group(1) == co_cb:
good(f"Chat OTOIA · {CO_RD} — {m.group(1)} fixtures == artefact "
f"(custom_blocks={co_cb})")
else:
bad(f"Chat OTOIA · {CO_RD} — dit {m.group(1)} fixtures MAIS artefact dit "
f"{co_cb} → régénérer le README")
# 4) README module — « M configs runtime » (2e transcription des mounts)
m = re.search(r"(\d+) configs runtime", co_rd)
if m is None:
bad(f"Chat OTOIA · {CO_RD} — claim « M configs runtime » INTROUVABLE "
f"(attendu mounts={co_mo})")
elif m.group(1) == co_mo:
good(f"Chat OTOIA · {CO_RD} — {m.group(1)} configs runtime == artefact "
f"(mounts={co_mo})")
else:
bad(f"Chat OTOIA · {CO_RD} — dit {m.group(1)} configs runtime MAIS artefact "
f"dit {co_mo} → régénérer le README")
# ============================================================================
# Recette (module) · couple « N livrables de sprint + M métriques succès MVP »
# transcrit À LA MAIN dans DEUX docs — même classe de « vert trompeur ».
# ----------------------------------------------------------------------------
# La matrice d'acceptation (qa/acceptance/out/acceptance_matrix.json, byte-gatée
# par check_artifacts ⇒ faisant autorité) porte une ligne par promesse roadmap,
# de `kind` sprint_deliverable OU mvp_metric. Le compte de chaque genre (n_sprint,
# n_metric déjà recalculés plus haut depuis la matrice, jamais saisis) est repris
# À LA MAIN à DEUX endroits que le bloc racine ne couvre PAS : le README du module
# (05_deliverables_mvp/qa/acceptance/README.md — puces « N livrables de sprint »
# puis « M métriques succès MVP ») ET la fiche agent QA (03_agents/qa/AGENT.md —
# « (N livrables + M métriques) »). Le bloc racine plus haut ne gate que le SEUL
# README d'entrée (« N promesses (X sprint + Y métriques) ») ; ces deux docs de
# module en dérivent sans gate. Ajouter une promesse roadmap (l'artefact rebâtit
# 9/7) ferait dériver LES DEUX docs en silence pendant que la matrice dit autre
# chose — piège que ni les suites tests/ (qui testent des FONCTIONS, pas la prose)
# ni le roadmap-anchor (qui ancre les `roadmap_line` de l'ARTEFACT, pas la prose
# des docs) n'attrapent. On RECOMPUTE ici chaque nombre depuis SON PROPRE genre de
# la matrice (jamais figé · zéro duplication #5) et on exige l'égalité avec les
# DEUX docs. Recomputer les DEUX (sprint ET métriques) mord une dérive de l'un là
# où l'autre resterait aveugle. Cohérence croisée en bonus :
# MANIFEST.counts.{sprint_deliverables,mvp_metrics} == (n_sprint, n_metric) — mord
# un compteur du manifeste désynchronisé de la matrice qu'il résume. Un claim
# absent échoue AUSSI (la disparition d'un chiffre est une régression · traçabilité).
# ============================================================================
ACC_RD = f"{D}/qa/acceptance/README.md"
ACC_FI = "03_agents/qa/AGENT.md"
acc_exp = (str(n_sprint), str(n_metric))
acc_src = f"livrables_sprint={acc_exp[0]} métriques={acc_exp[1]}"
try:
acc_mc = load("qa/acceptance/out/MANIFEST.json")["counts"]
if (str(acc_mc["sprint_deliverables"]), str(acc_mc["mvp_metrics"])) != acc_exp:
bad(f"Recette · incohérence artefact : MANIFEST.counts dit "
f"{acc_mc['sprint_deliverables']}/{acc_mc['mvp_metrics']} MAIS la matrice "
f"dit {'/'.join(acc_exp)} (compteur désynchronisé de sa liste)")
except (OSError, KeyError, ValueError) as e:
bad(f"Recette · MANIFEST acceptance illisible/inattendu : {e}")
# 1) README du module — deux puces distinctes (livrables sprint · métriques MVP)
acc_rd = read_norm(ACC_RD)
if acc_rd is not None:
ms = re.search(r"\*\*(\d+) livrables de sprint\*\*", acc_rd)
mm = re.search(r"\*\*(\d+) métriques succès MVP\*\*", acc_rd)
if ms is None or mm is None:
bad(f"Recette · {ACC_RD} — puces « N livrables de sprint » / « M métriques "
f"succès MVP » INTROUVABLE(s) (attendu {acc_src})")
elif (ms.group(1), mm.group(1)) == acc_exp:
good(f"Recette · {ACC_RD} — {ms.group(1)}/{mm.group(1)} == matrice ({acc_src})")
else:
bad(f"Recette · {ACC_RD} — dit {ms.group(1)}/{mm.group(1)} MAIS matrice dit "
f"{'/'.join(acc_exp)} ({acc_src}) → régénérer le README")
# 2) Fiche agent QA — parenthèse « (N livrables + M métriques) »
acc_fi = read_norm(ACC_FI)
if acc_fi is not None:
m = re.search(r"\((\d+) livrables \+ (\d+) métriques\)", acc_fi)
if m is None:
bad(f"Recette · {ACC_FI} — claim « (N livrables + M métriques) » INTROUVABLE "
f"(attendu {acc_src})")
elif m.groups() == acc_exp:
good(f"Recette · {ACC_FI} — {'/'.join(m.groups())} == matrice ({acc_src})")
else:
bad(f"Recette · {ACC_FI} — dit {'/'.join(m.groups())} MAIS matrice dit "
f"{'/'.join(acc_exp)} ({acc_src}) → régénérer la fiche")
# ============================================================================
# Fiscal · e-CF DGII — le CONTRAT DE FORMAT de l'e-NCF (« E + tipoeCF(2) +
# secuencia(10) » = 13) transcrit À LA MAIN dans QUATRE endroits de prose (README
# ×3 + fiche agent erpnext_backend), sans AUCUN gate d'IDENTITÉ.
# ----------------------------------------------------------------------------
# Le générateur ecf_dgii_gen émet out/ecf_plan.json (byte-gaté par check_artifacts
# ⇒ faisant autorité) dont le bloc `e_ncf` PORTE le format de l'identifiant
# électronique DGII : `prefix` (« E »), `longueur` (13), `regex`
# (« ^E[0-9]{12}$ ») et `structure` (« E + tipoeCF (2 chiffres) + secuencia
# (10 chiffres) »). Ce format — préfixe + longueur du segment tipo + longueur du
# segment secuencia + longueur totale — est le CŒUR du module (le « composeur
# traçable ») et il est recopié À LA MAIN dans QUATRE endroits de prose :
# • README:10 « composeur d'e-NCF traçable `E + tipoeCF(2) + secuencia(10)` »
# • README:75 (invariant 7) « E+tipo(2)+seq(10)=13 » (porte la longueur TOTALE)
# • README:84 (composition) « e_ncf = "E" + tipo(2) + secuencia(10) »
# • 03_agents/erpnext_backend/AGENT.md:33 « `E + tipoeCF(2) + secuencia(10)` »
# Le bloc racine de check_readme_claims ne gate QUE le COMPTE de tests e-CF
# (« 39 »). Aucun gate ne comparait la LONGUEUR des segments à l'artefact : allonger
# la secuencia (rango DGII à 12 chiffres → seq=12, longueur=15, regex 14 digits)
# OU changer le préfixe ferait dériver les QUATRE proses en silence pendant que
# l'ecf_plan byte-gaté dit autre chose — « vert trompeur » qu'aucune suite tests/
# (qui teste des FONCTIONS ncf.compose/parse, pas la prose des docs) n'attrape.
# Même patron que l'énumération des onglets Mobile, la carte 4Big ou les open items
# 5D. On RECOMPUTE (prefix, tipo_len, seq_len, total) depuis le SEUL bloc `e_ncf`
# (zéro duplication du format du générateur #6) et on exige que CHAQUE prose
# l'encode EXACTEMENT. Cohérences croisées en bonus (mordent un artefact
# INTERNEMENT incohérent) : |prefix| + tipo_len + seq_len == longueur (1+2+10==13)
# ET le nombre de chiffres du `regex` == tipo_len + seq_len (12 == 2+10). Un claim
# absent échoue AUSSI (la disparition d'un format est une régression · traçabilité).
# ============================================================================
ECF_RD = f"{D}/fiscal/ecf_dgii/README.md"
ECF_FI = "03_agents/erpnext_backend/AGENT.md"
try:
encf = load("fiscal/ecf_dgii/out/ecf_plan.json")["e_ncf"]
ecf_prefix = encf["prefix"]
ecf_total = int(encf["longueur"])
ecf_seglens = [int(x) for x in
re.findall(r"\((\d+)\s*chiffres?\)", encf["structure"])]
ecf_mdig = re.search(r"\[0-9\]\{(\d+)\}", encf["regex"])
except (OSError, KeyError, ValueError) as e:
bad(f"Fiscal · artefact ecf_plan illisible/inattendu : {e}"); encf = None
if encf is not None:
if len(ecf_seglens) != 2:
bad(f"Fiscal · e_ncf.structure malformée : {encf['structure']!r} "
f"(attendu 2 segments « (N chiffres) », trouvé {ecf_seglens})")
else:
ecf_tipo, ecf_seq = ecf_seglens
# Cohérences croisées : l'artefact doit être INTERNEMENT cohérent.
if len(ecf_prefix) != 1:
bad(f"Fiscal · incohérence artefact : prefix={ecf_prefix!r} n'est pas "
f"1 caractère")
if 1 + ecf_tipo + ecf_seq != ecf_total:
bad(f"Fiscal · incohérence artefact : 1+{ecf_tipo}+{ecf_seq}="
f"{1 + ecf_tipo + ecf_seq} MAIS longueur={ecf_total}")
if ecf_mdig is None or int(ecf_mdig.group(1)) != ecf_tipo + ecf_seq:
got = ecf_mdig.group(1) if ecf_mdig else "?"
bad(f"Fiscal · incohérence artefact : regex porte {got} chiffres MAIS "
f"tipo+seq={ecf_tipo + ecf_seq}")
exp_ncf = f"{ecf_prefix} + tipo({ecf_tipo}) + secuencia({ecf_seq}) = {ecf_total}"
def ecf_check(tag, path, txt, m, with_total):
if m is None:
bad(f"Fiscal · {path} — {tag} : format e-NCF INTROUVABLE "
f"(attendu {exp_ncf})")
return
g = m.groups()
ok = (g[0] == ecf_prefix and int(g[1]) == ecf_tipo
and int(g[2]) == ecf_seq
and (not with_total or int(g[3]) == ecf_total))
if ok:
good(f"Fiscal · {path} — {tag} : {exp_ncf} == e_ncf (artefact)")
else:
shown = "/".join(g)
bad(f"Fiscal · {path} — {tag} : dit {shown} MAIS e_ncf dit "
f"prefix={ecf_prefix} tipo={ecf_tipo} seq={ecf_seq} "
f"total={ecf_total} → régénérer le doc")
# 1) README:10 — résumé « composeur d'e-NCF traçable `E + tipoeCF(2) + secuencia(10)` »
ecf_rd = read_norm(ECF_RD)
if ecf_rd is not None:
ecf_check("résumé composeur", ECF_RD, ecf_rd,
re.search(r"`([A-Z])\s*\+\s*tipoeCF\((\d+)\)\s*\+\s*"
r"secuencia\((\d+)\)`", ecf_rd), False)
# 2) README:75 — invariant 7 « E+tipo(2)+seq(10)=13 » (porte la longueur totale)
ecf_check("invariant format", ECF_RD, ecf_rd,
re.search(r"\(([A-Z])\+tipo\((\d+)\)\+seq\((\d+)\)=(\d+)",
ecf_rd), True)
# 3) README:84 — composition « e_ncf = "E" + tipo(2) + secuencia(10) »
ecf_check("composition ncf.py", ECF_RD, ecf_rd,
re.search(r'e_ncf\s*=\s*"([A-Z])"\s*\+\s*tipo\((\d+)\)\s*\+\s*'
r"secuencia\((\d+)\)", ecf_rd), False)
# 4) Fiche agent erpnext_backend — « `E + tipoeCF(2) + secuencia(10)` »
ecf_fi = read_norm(ECF_FI)
if ecf_fi is not None:
ecf_check("fiche erpnext_backend", ECF_FI, ecf_fi,
re.search(r"`([A-Z])\s*\+\s*tipoeCF\((\d+)\)\s*\+\s*"
r"secuencia\((\d+)\)`", ecf_fi), False)
# ============================================================================
# Fiscal · e-CF DGII (2e surface du MÊME README) · la CROSS-COHÉRENCE
# (« le cœur du livrable ») — ENSEMBLES/valeur data-derived transcrits À LA MAIN
# sans AUCUN gate d'IDENTITÉ. Le bloc e-NCF ci-dessus ne gate QUE le FORMAT de
# l'identifiant ; la section « Cross-cohérence e-CF ↔ workflow ↔ DocType ↔ RBAC »
# ÉNUMÈRE trois données dérivées de out/ecf_plan.json (byte-gaté par
# check_artifacts ⇒ faisant autorité) :
# • README:48 « seulement réservation et contrat » — l'ensemble des états qui
# ÉMETTENT un e-CF = {emission_events[].update_value}.
# • README:50 « (`montant_reservation`, `montant_contrat`) » — l'ensemble des
# champs Currency de base = {emission_events[].base_field}.
# • README:59 « FormaPago défaut = 3 (Tarjeta) … via Cardnet (#10) » — le code +
# libellé de forma_pago_defaut, ANCRÉ sur CLAUDE.md #10 (Cardnet = carte).
# Aucun gate ne comparait ces valeurs à l'artefact : AJOUTER un évènement
# d'émission (facturer un devis) · en RETIRER un · RENOMMER un base_field ·
# CHANGER forma_pago_defaut (Cardnet abandonné → code 2 Transfert) ferait dériver
# la prose en silence pendant que l'ecf_plan byte-gaté dit autre chose — l'agent
# ERPNext Backend câblerait le mauvais évènement / la mauvaise FormaPago (le risque
# même que la cross-cohérence veut prévenir) — « vert trompeur » qu'aucune suite
# tests/ (qui teste des FONCTIONS ncf/résolution, pas la prose) n'attrape. Même
# patron EXACT que la cross-cohérence CRM/Commissions (base_field + états soumis).
# On RECOMPUTE chaque ensemble/valeur depuis l'artefact (zéro duplication du
# contrat du générateur #6) et on exige que la prose l'encode EXACTEMENT (absent ET
# en trop via set-diff · accents/casse normalisés). Cohérences croisées en bonus
# (mordent un plan INTERNEMENT incohérent) : forma_pago_defaut.code ∈ catalogue
# formas_pago · libellé prose ⊆ libellé catalogue du code · ensembles NON VIDES.
# Un claim absent échoue AUSSI (traçabilité).
# ============================================================================
ECF_RD2 = f"{D}/fiscal/ecf_dgii/README.md"
def _ecf_acc(s):
return "".join(c for c in unicodedata.normalize("NFKD", s)
if unicodedata.category(c) != "Mn").lower()
try:
ecfp = load("fiscal/ecf_dgii/out/ecf_plan.json")
ecf_ev = ecfp["emission_events"]
ecf_uv = {_ecf_acc(e["update_value"]) for e in ecf_ev}
ecf_bf = {e["base_field"] for e in ecf_ev}
ecf_fpd = ecfp["forma_pago_defaut"]
ecf_fp_code = str(ecf_fpd["code"])
ecf_fp_cat = {str(f["code"]): f["label"] for f in ecfp["formas_pago"]}
except (OSError, KeyError, ValueError, TypeError) as e:
bad(f"Fiscal cross-cohérence · artefact ecf_plan illisible/inattendu : {e}"); ecf_ev = None
if ecf_ev is not None:
# bonus : plan INTERNEMENT cohérent (ensembles non vides · défaut ∈ catalogue)
if not ecf_uv or not ecf_bf:
bad("Fiscal cross-cohérence · aucun évènement d'émission (plan vide) → vérifier le plan")
elif ecf_fp_code not in ecf_fp_cat:
bad(f"Fiscal cross-cohérence · forma_pago_defaut.code={ecf_fp_code} ABSENT du "
f"catalogue formas_pago {sorted(ecf_fp_cat)} → plan incohérent")
else:
good(f"Fiscal cross-cohérence · forma_pago_defaut {ecf_fp_code} "
f"({ecf_fp_cat[ecf_fp_code]}) ∈ catalogue formas_pago · émission {sorted(ecf_uv)} non vide")
ecf_rd2 = read_norm(ECF_RD2)
if ecf_rd2 is not None:
# 1) README:48 — états émetteurs « seulement réservation et contrat »
me = re.search(r"seulement\s+([^.]+)\.", ecf_rd2)
if me is None:
bad(f"Fiscal cross-cohérence · {ECF_RD2} — énumération des états émetteurs "
f"INTROUVABLE (attendu {sorted(ecf_uv)})")
else:
es = {_ecf_acc(t.strip()) for t in re.split(r"\bet\b|[,/]", me.group(1)) if t.strip()}
if es == ecf_uv:
good(f"Fiscal cross-cohérence · {ECF_RD2} — états émetteurs {sorted(es)} == "
f"ecf_plan.emission_events[].update_value")
else:
miss = sorted(ecf_uv - es); extra = sorted(es - ecf_uv)
detail = (f" · absents={miss}" if miss else "") + (f" · en trop={extra}" if extra else "")
bad(f"Fiscal cross-cohérence · {ECF_RD2} — énumère {sorted(es)} MAIS plan "
f"{sorted(ecf_uv)}{detail} → régénérer le README")
# 2) README:50 — champs de base « (`montant_reservation`, `montant_contrat`) »
mf = re.search(r"Currency réel\*\* du DocType Dossier Vente\s*\(([^)]*)\)", ecf_rd2)
if mf is None:
bad(f"Fiscal cross-cohérence · {ECF_RD2} — énumération des champs de base "
f"INTROUVABLE (attendu {sorted(ecf_bf)})")
else:
bs = set(re.findall(r"`([a-z_]+)`", mf.group(1)))
if bs == ecf_bf:
good(f"Fiscal cross-cohérence · {ECF_RD2} — champs de base {sorted(bs)} == "
f"ecf_plan.emission_events[].base_field")
else:
miss = sorted(ecf_bf - bs); extra = sorted(bs - ecf_bf)
detail = (f" · absents={miss}" if miss else "") + (f" · en trop={extra}" if extra else "")
bad(f"Fiscal cross-cohérence · {ECF_RD2} — énumère {sorted(bs)} MAIS plan "
f"{sorted(ecf_bf)}{detail} → régénérer le README")
# 3) README:59 — FormaPago défaut « = 3 (Tarjeta) … via Cardnet (#10) »
mp = re.search(r"FormaPago défaut\s*=\s*(\d+)\s*\(([^)]*)\)", ecf_rd2)
if mp is None:
bad(f"Fiscal cross-cohérence · {ECF_RD2} — FormaPago défaut INTROUVABLE "
f"(attendu code {ecf_fp_code} · {ecf_fp_cat.get(ecf_fp_code,'?')})")
else:
got_code = mp.group(1); got_lbl = _ecf_acc(mp.group(2).strip())
cat_lbl = _ecf_acc(ecf_fp_cat.get(ecf_fp_code, ""))
has_cardnet = "cardnet" in _ecf_acc(ecf_rd2[mp.start():mp.start() + 160])
if got_code == ecf_fp_code and got_lbl and got_lbl in cat_lbl and has_cardnet:
good(f"Fiscal cross-cohérence · {ECF_RD2} — FormaPago défaut {got_code} "
f"({mp.group(2).strip()}) == forma_pago_defaut · ancré Cardnet (#10)")
elif got_code != ecf_fp_code:
bad(f"Fiscal cross-cohérence · {ECF_RD2} — FormaPago défaut = {got_code} MAIS "
f"forma_pago_defaut.code = {ecf_fp_code} → régénérer le README")
elif not (got_lbl and got_lbl in cat_lbl):
bad(f"Fiscal cross-cohérence · {ECF_RD2} — libellé FormaPago {mp.group(2).strip()!r} "
f"≠ catalogue code {ecf_fp_code} ({ecf_fp_cat.get(ecf_fp_code)!r}) → régénérer")
else:
bad(f"Fiscal cross-cohérence · {ECF_RD2} — FormaPago défaut sans ancrage "
f"Cardnet (#10) à proximité → régénérer le README")
# ============================================================================
# CRM/Commissions · barème vendeurs — la CROSS-COHÉRENCE (« cœur du livrable »)
# transcrite À LA MAIN dans le README sans AUCUN gate d'IDENTITÉ.
# ----------------------------------------------------------------------------
# Le générateur commissions_gen émet out/commission_plan.json (byte-gaté par
# check_artifacts ⇒ faisant autorité) : chaque évènement porte `update_value`
# (l'état du pipeline qui PAIE) et `base_field` (le champ Currency de base). La
# section « Cross-cohérence » du README ÉNUMÈRE À LA MAIN deux ensembles
# data-derived + une propriété — le CŒUR du livrable :
# • README:47 « on ne commissionne pas un brouillon (lead/visite/devis/
# abandonné) » — l'ensemble des états BROUILLON (doc_status=0 du workflow).
# • README:50 « base_field … (`montant_reservation`, `montant_contrat`) » —
# l'ensemble des champs de base cités par le plan.
# • README:46 « correspondre à un état soumis (doc_status = 1) » — INVARIANT 3
# du module : on ne commissionne QUE des états soumis, jamais un brouillon.
# Le bloc racine ne gate QUE le COMPTE de tests commissions (« 25 », via l'agrégat
# « Total CRM »). Aucun gate ne comparait ces ENSEMBLES aux artefacts : PROMOUVOIR
# un état brouillon en soumis (abandonne → doc_status 1) le retire de l'ensemble
# brouillon pendant que la prose l'énumère encore ; AJOUTER un champ de base à un
# évènement (base_field montant_confotur) l'ajoute sans que la prose bouge ;
# RENOMMER un état passe aussi — « vert trompeur » qu'aucune suite tests/ (qui teste
# des FONCTIONS finance/résolution, pas la prose) n'attrape. Même patron que
# l'énumération des onglets Mobile, des transitions quatre-yeux CRM ou du format
# e-NCF fiscal. On RECOMPUTE chaque ensemble depuis SON artefact byte-gaté (zéro
# duplication du contrat du générateur #6) et on exige que la prose l'énumère
# EXACTEMENT (absent ET en trop mordus via set-diff · accents/casse normalisés).
# Cohérence croisée en bonus (mord un plan INTERNEMENT incohérent avec le workflow) :
# tout `update_value` commissionné ∈ états soumis (doc_status=1) ET ∉ brouillons —
# l'invariant 3 du module, non vide. Un claim absent échoue AUSSI (traçabilité).
# ============================================================================
CM_RD = f"{D}/crm/commissions/README.md"
def _cm_acc(s):
return "".join(c for c in unicodedata.normalize("NFKD", s)
if unicodedata.category(c) != "Mn")
try:
cm_ev = load("crm/commissions/out/commission_plan.json")["evenements"]
cm_states = load("crm/workflow_vente/out/workflow.json")[0]["states"]
cm_base = {e["base_field"] for e in cm_ev}
cm_uv = {e["update_value"] for e in cm_ev}
cm_draft = {s["update_value"] for s in cm_states if str(s["doc_status"]) == "0"}
cm_subm = {s["update_value"] for s in cm_states if str(s["doc_status"]) == "1"}
except (OSError, KeyError, ValueError, IndexError, TypeError) as e:
bad(f"CRM commissions · artefact illisible/inattendu : {e}"); cm_ev = None
if cm_ev is not None:
# bonus : invariant 3 — tout état commissionné est SOUMIS (jamais brouillon), non vide
if not cm_uv:
bad("CRM commissions · aucun évènement commissionné (plan vide) → vérifier le barème")
elif not (cm_uv <= cm_subm):
bad(f"CRM commissions · incohérence plan↔workflow : update_values commissionnés "
f"{sorted(cm_uv - cm_subm)} PAS soumis (doc_status≠1) → invariant 3 cassé")
elif cm_uv & cm_draft:
bad(f"CRM commissions · incohérence plan↔workflow : {sorted(cm_uv & cm_draft)} "
f"commissionné(s) mais BROUILLON (doc_status=0) → invariant 3 cassé")
else:
good(f"CRM commissions · invariant 3 — {sorted(cm_uv)} tous soumis (doc_status=1), "
f"aucun brouillon")
cm_rd = read_norm(CM_RD)
if cm_rd is not None:
# 1) README:47 — ensemble des états BROUILLON exclus (doc_status=0 du workflow)
mb = re.search(r"brouillon \(([^)]*)\)", cm_rd)
if mb is None:
bad(f"CRM commissions · {CM_RD} — énumération des brouillons exclus "
f"INTROUVABLE (attendu {sorted(cm_draft)})")
else:
ps = {_cm_acc(t.strip().lower()) for t in re.split(r"[/,]", mb.group(1)) if t.strip()}
exp = {_cm_acc(x) for x in cm_draft}
if ps == exp:
good(f"CRM commissions · {CM_RD} — brouillons exclus {sorted(ps)} == "
f"workflow.json (doc_status=0)")
else:
miss = sorted(exp - ps); extra = sorted(ps - exp)
detail = (f" · absents={miss}" if miss else "") + \
(f" · en trop={extra}" if extra else "")
bad(f"CRM commissions · {CM_RD} — énumère {sorted(ps)} MAIS brouillons "
f"workflow {sorted(exp)}{detail} → régénérer le README")
# 2) README:50 — ensemble des champs de base (base_field du plan)
mf = re.search(r"Currency réel\*\* du DocType Dossier Vente \(([^)]*)\)", cm_rd)
if mf is None:
bad(f"CRM commissions · {CM_RD} — énumération des champs de base "
f"INTROUVABLE (attendu {sorted(cm_base)})")
else:
bs = set(re.findall(r"`([a-z_]+)`", mf.group(1)))
if bs == cm_base:
good(f"CRM commissions · {CM_RD} — champs de base {sorted(bs)} == "
f"commission_plan.evenements[].base_field")
else:
miss = sorted(cm_base - bs); extra = sorted(bs - cm_base)
detail = (f" · absents={miss}" if miss else "") + \
(f" · en trop={extra}" if extra else "")
bad(f"CRM commissions · {CM_RD} — énumère {sorted(bs)} MAIS plan "
f"{sorted(cm_base)}{detail} → régénérer le README")
# ============================================================================
# RBAC/fixtures_gen · le CATALOGUE des DocTypes `custom` à créer sur le VPS et la
# SÉPARATION DES POUVOIRS (le rôle unique porteur de `set_user_permissions`) —
# deux faits data-derived transcrits À LA MAIN dans le README sans AUCUN gate
# d'IDENTITÉ.
# ----------------------------------------------------------------------------
# Le générateur rbac_fixtures_gen émet out/MANIFEST.json et out/custom_docperm.json
# (byte-gatés par check_artifacts ⇒ faisant autorité) :
# • MANIFEST.custom_doctypes_a_confirmer = l'ensemble des DocTypes `custom: true`
# du contrat rbac_50_roles.json (ceux à CRÉER avant import — le reste est natif
# v15). Le README §« Application sur VPS » l'ÉNUMÈRE À LA MAIN, en nommant même
# le champ : « listés dans `MANIFEST.custom_doctypes_a_confirmer` (`Faisabilité`,
# `Publiciste Log`, `CONFOTUR Application`, `API Access`) ».
# • custom_docperm.json porte le flag `set_user_permissions`. Le CŒUR sécurité du
# module (« séparation des pouvoirs · défense en profondeur ») est que ce flag
# n'est émis que pour UN SEUL rôle — le README:48 dit « n'est émis que pour le
# rôle **RBAC Admin** ». C'est l'ensemble {role | set_user_permissions=1}.
# Le bloc racine ne gate QUE le COMPTE de tests fixtures (« 50 rôles / 116 DocPerm »
# via l'agrégat RBAC de l'apply_plan) — jamais l'IDENTITÉ de ces deux ensembles.
# Aucun gate ne les comparait à l'artefact : AJOUTER un DocType `custom` au contrat
# (le MANIFEST rebâtit 5 entrées) OU RENOMMER/ÉCHANGER l'un des 4 laisse la prose à
# ses 4 noms périmés ; PROMOUVOIR un 2ᵉ rôle porteur de `set_user_permissions` (ou
# renommer RBAC Admin) laisse la prose « un seul rôle RBAC Admin » fausse — « vert
# trompeur » qu'aucune suite tests/ (qui teste des FONCTIONS de mapping, pas la
# prose du README) n'attrape. Même patron que l'énumération des onglets Mobile, des
# brouillons CRM ou des open items de l'audit 5D. On RECOMPUTE chaque ensemble
# depuis SON artefact byte-gaté (zéro duplication du contrat du générateur #6) et on
# exige que la prose l'énumère/le nomme EXACTEMENT (absent ET en trop via set-diff ·
# accents/casse normalisés). Cohérence croisée en bonus : le catalogue est NON VIDE,
# sans doublon et TRIÉ (byte-déterminisme du générateur) ; la séparation des pouvoirs
# est un SINGLETON (ni vide — la garde serait vacante — ni multiple — élévation de
# privilège). Un claim absent échoue AUSSI (traçabilité).
# ============================================================================
FX_RD = f"{D}/rbac/fixtures_gen/README.md"
def _fx_acc(s):
return "".join(c for c in unicodedata.normalize("NFKD", s)
if unicodedata.category(c) != "Mn").strip().lower()
try:
fx_cdt = load("rbac/fixtures_gen/out/MANIFEST.json")["custom_doctypes_a_confirmer"]
fx_perm = load("rbac/fixtures_gen/out/custom_docperm.json")
fx_sup = sorted({p["role"] for p in fx_perm if p.get("set_user_permissions")})
except (OSError, KeyError, ValueError, TypeError) as e:
bad(f"RBAC fixtures · artefact illisible/inattendu : {e}"); fx_cdt = None
if fx_cdt is not None:
# bonus : le catalogue est un vrai ENSEMBLE trié non vide (byte-déterminisme)
if not fx_cdt:
bad("RBAC fixtures · custom_doctypes_a_confirmer VIDE → aucun DocType custom "
"à créer ? (contrat suspect)")
elif fx_cdt != sorted(set(fx_cdt)):
bad(f"RBAC fixtures · custom_doctypes_a_confirmer {fx_cdt} non trié/dédupliqué "
f"→ générateur non déterministe (attendu {sorted(set(fx_cdt))})")
else:
good(f"RBAC fixtures · catalogue custom {fx_cdt} — ensemble trié, sans doublon")
# bonus : séparation des pouvoirs — un SEUL rôle porte set_user_permissions
if len(fx_sup) != 1:
bad(f"RBAC fixtures · set_user_permissions porté par {fx_sup} (attendu 1 seul "
f"rôle) → séparation des pouvoirs cassée (élévation de privilège possible)")
else:
good(f"RBAC fixtures · set_user_permissions — singleton {fx_sup[0]!r} "
f"(défense en profondeur intacte)")
fx_rd = read_norm(FX_RD)
if fx_rd is not None:
# 1) README — énumération du catalogue custom (parenthèse après le champ nommé)
me = re.search(r"custom_doctypes_a_confirmer`?\s*\(([^)]*)\)", fx_rd)
if me is None:
bad(f"RBAC fixtures · {FX_RD} — énumération du catalogue custom INTROUVABLE "
f"(attendu {sorted(fx_cdt)})")
else:
got = {_fx_acc(t) for t in re.findall(r"`([^`]+)`", me.group(1))}
exp = {_fx_acc(x) for x in fx_cdt}
if got == exp:
good(f"RBAC fixtures · {FX_RD} — catalogue custom {sorted(got)} == "
f"MANIFEST.custom_doctypes_a_confirmer")
else:
miss = sorted(exp - got); extra = sorted(got - exp)
detail = (f" · absents={miss}" if miss else "") + \
(f" · en trop={extra}" if extra else "")
bad(f"RBAC fixtures · {FX_RD} — énumère {sorted(got)} MAIS MANIFEST "
f"{sorted(exp)}{detail} → régénérer le README")
# 2) README:48 — le rôle NOMMÉ porteur de set_user_permissions (séparation)
if len(fx_sup) == 1:
mr = re.search(r"émis que pour le rôle \*\*([^*]+)\*\*", fx_rd)
if mr is None:
bad(f"RBAC fixtures · {FX_RD} — claim « set_user_permissions n'est émis "
f"que pour le rôle ** ** » INTROUVABLE (attendu {fx_sup[0]!r})")
elif _fx_acc(mr.group(1)) in _fx_acc(fx_sup[0]):
good(f"RBAC fixtures · {FX_RD} — rôle nommé « {mr.group(1).strip()} » ⊆ "
f"{fx_sup[0]!r} (le seul porteur du flag)")
else:
bad(f"RBAC fixtures · {FX_RD} — nomme « {mr.group(1).strip()} » MAIS "
f"set_user_permissions porté par {fx_sup[0]!r} → régénérer le README")
# ============================================================================
# SEO · la COMPOSITION du graphe schema.org (JSON-LD) — un nœud racine
# `Organization` + une `Residence` PAR PROJET — décrite EN PROSE dans le README
# sans AUCUN gate d'IDENTITÉ.
# ----------------------------------------------------------------------------
# Le générateur SEO émet out/seo_schema_org.json (byte-gaté par check_artifacts ⇒
# faisant autorité). Son `@graph` est l'ENSEMBLE des nœuds JSON-LD : exactement UN
# nœud racine `Organization` (@id …#organization) + un listing PAR PROJET, tous du
# même @type. Le README:16 décrit cette structure À LA MAIN : « graphe JSON-LD
# (`Organization` + une `Residence` par projet) ». Le bloc SEO existant ne gate que
# le COMPTE de mots-clés (keywords_total + ventilation par langue) — jamais les
# @type du graphe ni la bijection un-listing-par-projet. Aucun gate ne comparait
# ces TYPES à l'artefact : RENOMMER le @type des listings (Residence → Apartment,
# rango schema.org) OU AJOUTER un 3ᵉ type de nœud (Offer/Product) OU casser la
# bijection (2 nœuds pour un projet) ferait dériver la prose en silence pendant que
# le graphe byte-gaté dit autre chose — « vert trompeur » qu'aucune suite tests/
# (qui teste des FONCTIONS de génération, pas la prose du README) n'attrape. Même
# patron que l'énumération des onglets Mobile ou du catalogue custom RBAC. On
# RECOMPUTE l'ENSEMBLE des @type depuis SON artefact byte-gaté (zéro duplication du
# contrat du générateur #6) et on exige que la prose les nomme EXACTEMENT (absent ET
# en trop via set-diff). Cohérences croisées en bonus : le nœud racine Organization
# est un SINGLETON ; les listings sont uniformes (un seul @type) et en BIJECTION avec
# les projets (listings == projects == schema_org_nodes1 du MANIFEST byte-gaté) ;
# aucune `offers` (statuts sans prix · USD #10). Un claim absent échoue AUSSI.
# ============================================================================
SEO_SG_RD = f"{D}/seo/README.md"
try:
sg = load("seo/out/seo_schema_org.json")["@graph"]
seo_cnt = load("seo/out/MANIFEST.json")["counts"]
sg_org = [n for n in sg if str(n.get("@id", "")).endswith("#organization")]
sg_list = [n for n in sg if not str(n.get("@id", "")).endswith("#organization")]
sg_types = {n["@type"] for n in sg} # ensemble des @type du graphe
org_types = {n["@type"] for n in sg_org}
list_types = {n["@type"] for n in sg_list}
sg_offers = sum(1 for n in sg if "offers" in n)
except (OSError, KeyError, ValueError, TypeError) as e:
bad(f"SEO schema.org · artefact illisible/inattendu : {e}"); sg = None
if sg is not None:
n_proj = seo_cnt.get("projects")
# bonus 1 : racine Organization = SINGLETON, d'un seul @type
if len(sg_org) != 1 or len(org_types) != 1:
bad(f"SEO schema.org · nœud racine #organization non-singleton "
f"({len(sg_org)} nœud(s), types={sorted(org_types)}) → graphe suspect")
else:
good(f"SEO schema.org · racine {sorted(org_types)[0]!r} — singleton (1 nœud)")
# bonus 2 : listings uniformes + bijection 1 par projet + total du MANIFEST
if not sg_list or len(list_types) != 1:
bad(f"SEO schema.org · listings non uniformes (types={sorted(list_types)}) "
f"→ un @type unique attendu par projet")
elif not (len(sg_list) == n_proj == seo_cnt.get("listings")
== seo_cnt.get("schema_org_nodes", 0) - 1):
bad(f"SEO schema.org · bijection cassée : {len(sg_list)} listing(s) MAIS "
f"projects={n_proj} listings={seo_cnt.get('listings')} "
f"schema_org_nodes={seo_cnt.get('schema_org_nodes')} (attendu 1+{n_proj})")
else:
good(f"SEO schema.org · {len(sg_list)} × {sorted(list_types)[0]!r} — bijection "
f"un listing par projet ({n_proj}) · total {seo_cnt.get('schema_org_nodes')} nœuds")
# bonus 3 : aucune offers/prix (statuts sans prix · #10 · cohérent MANIFEST.offers)
if sg_offers != 0 or seo_cnt.get("offers") != 0:
bad(f"SEO schema.org · {sg_offers} nœud(s) `offers` (MANIFEST.offers="
f"{seo_cnt.get('offers')}) → prix non attendu pour statuts sans prix (#10)")
else:
good("SEO schema.org · aucune `offers`/prix (statuts sans prix · USD #10)")
seo_sg_rd = read_norm(SEO_SG_RD)
if seo_sg_rd is not None:
# README:16 — la parenthèse décrit la composition du graphe : ENSEMBLE des @type
msg = re.search(r"graphe JSON-LD \(([^)]*)\)", seo_sg_rd)
if msg is None:
bad(f"SEO schema.org · {SEO_SG_RD} — composition « graphe JSON-LD (…) » "
f"INTROUVABLE (attendu {sorted(sg_types)})")
else:
prose_types = set(re.findall(r"`([A-Za-z]+)`", msg.group(1)))
if prose_types == sg_types:
good(f"SEO schema.org · {SEO_SG_RD} — @type {sorted(prose_types)} == "
f"seo_schema_org.@graph")
else:
miss = sorted(sg_types - prose_types); extra = sorted(prose_types - sg_types)
detail = (f" · absents={miss}" if miss else "") + \
(f" · en trop={extra}" if extra else "")
bad(f"SEO schema.org · {SEO_SG_RD} — nomme {sorted(prose_types)} MAIS "
f"graphe {sorted(sg_types)}{detail} → régénérer le README")
# README:16 — le type PAR PROJET nommé == le @type des listings (racine distincte)
if len(list_types) == 1:
mpp = re.search(r"une `([A-Za-z]+)` par projet", seo_sg_rd)
lt = sorted(list_types)[0]
if mpp is None:
bad(f"SEO schema.org · {SEO_SG_RD} — « une `<type>` par projet » "
f"INTROUVABLE (attendu {lt!r})")
elif mpp.group(1) == lt:
good(f"SEO schema.org · {SEO_SG_RD} — « une `{lt}` par projet » == "
f"listings du graphe")
else:
bad(f"SEO schema.org · {SEO_SG_RD} — dit « une `{mpp.group(1)}` par projet » "
f"MAIS listings sont `{lt}` → régénérer le README")
# ============================================================================
# SEO · 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")
# ============================================================================
# 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")
# ============================================================================
# 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")
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"