Files
oto-enterprise-os-dtp/ci/check_readme_claims.sh
T
Claude Code DTP Worker e8b30d7147 [DTP-Worker] Sprint 8 · buffer · Faisabilité/bancable : les 4 figures « Génération réelle (fixture) » du README (40 unités · USD 8,560,000 / DOP 505,040,000 · point d'équilibre 21 unités ⌈52 % × 40⌉) — les chiffres DATA-DERIVED du dossier bancable que voit le banquier — étaient HORS de tout gate. Le module n'écrit son out/ que sur disque (« out/ non commité ») ⇒ check_artifacts est aveugle (aucun artefact byte-gaté) et AUCUN gate ne le touchait.
Ces 4 figures sont Σ quantités · Σ(qté×prix_usd) · Σ(qté×prix_dop) · ⌈point_equilibre_pct × Σ unités⌉ calculées sur la fixture COMMITÉE fixtures/brief_bancable.json, le 52 % ancré sur CLAUDE.md #9 (« Point équilibre 52% pré-vente »). PIÈGE : la seule vérification existante (tests/) teste des FONCTIONS (l'arithmétique de recoupement _check_derived_arithmetic) avec des oracles HARDCODÉS dans le test — une copie de plus, jamais comparée à la PROSE du README. Éditer une typologie de la fixture (quantité/prix), en ajouter/retirer une, ou déplacer le point d'équilibre de CLAUDE.md #9, laisse les 4 chiffres du README périmés pendant que le générateur produit autre chose → le banquier lit un dossier faux (l'invention même que #6 interdit) — « vert trompeur » qu'aucune suite tests/ n'attrape.

Nouveau bloc « Faisabilité/bancable figures » dans ci/check_readme_claims.sh (avant sys.exit), même esprit que le bloc faisabilite/generator ancré sur CLAUDE.md #9/#10 mais appliqué à une surface dont la source de vérité n'est PAS un out/*.json : (1) recompute indépendant des 4 figures depuis la fixture commitée ; (2) cohérence croisée — le manifeste GÉNÉRÉ (bancable_gen validate, exécuté via subprocess stdlib pur, encoding utf-8) == le recompute (mord un générateur/fixture incohérents) ; (3) prose — un seul pattern d'identité exige les 4 valeurs EXACTES avec séparateur de milliers pour USD/DOP + la formule ⌈pct × Σ unités⌉ dont le pct == CLAUDE.md #9 et le total == Σ unités. Un claim absent échoue AUSSI (traçabilité).

État courant : aucun chiffre périmé (anti-invention #6, rien à réécrire) — le défaut est la surface ungated (module entièrement hors CI). 7 morsures vérifiées : README USD 8,560,000→9,560,000 (figure périmée) · README retire le séparateur de milliers (8,560,000→8560000) · README breakeven 21→20 (formule périmée) · README formule 52 %→55 % (dérive CLAUDE.md #9) · README supprime la ligne entière (INTROUVABLE) · fixture Studio qté 12→13 (5 morsures : units/USD/DOP/breakeven README périmés + manifeste) · fixture 2 Chambres prix_usd 320000→300000 (catalogue USD périmé) ; restauré = green : 4 figures == génération réelle · manifeste == recompute · pct 52 % == CLAUDE.md #9 · exit 0. Working tree byte-restauré via git checkout -- (JAMAIS git clean) · 7 gates re-verts. ci/README.md (ligne récap check-readme-claims) mis à jour · log 05_activity_log/2026-08-01.md. Auto-score 4Big 96/100.

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

3208 lines
196 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")
# 2bis) deploy_runbook — IDENTITÉ de la TABLE « Plan de phases généré » -----------
# Le bloc ci-dessus ne gate que DEUX comptes agrégés (phases · confirmations). La
# table « ## Plan de phases généré » énumère, PAR phase, QUATRE données data-derived
# de out/deploy_runbook.json (byte-gaté par check_artifacts) : le n° d'ordre (#), le
# RESPONSABLE (worker/vps/worker+vps), l'ensemble des MODULES gated déployés dans la
# phase, et « Dépend de » (les n°s d'ordre des `depends_on`). C'est le GRAPHE DE
# PORTAGE VPS ORDONNÉ que l'agent DevOps suit phase par phase — un module OUBLIÉ de
# la colonne Modules (donc jamais déployé), un responsable ou une dépendance périmés
# (ex. Frontend avant RBAC) sont un hazard réel, invisible à check_artifacts (qui ne
# prouve QUE deploy_runbook==build) ET au bloc de comptes (aveugle à l'identité des
# lignes ; ces colonnes étaient un WILDCARD). Même classe que la table run-book de
# rbac/apply_plan et la table demo/scenarios. Preuve reproduite : la table omettait
# `mobile/app_config` (phase 5) ET `qa/acceptance` (phase 7) — 20 modules listés sur
# 22 — pendant que le MANIFEST prouvait la couverture BIJECTIVE 22/22 vs CI : vert
# trompeur, l'agent DevOps aurait sauté 2 modules. On RECOMPUTE #/responsable/
# modules/deps depuis deploy_runbook.json (zéro duplication) et on exige que la table
# les énonce EXACTEMENT. Identité d'ensemble : {ordres des lignes} == {ordres de
# l'artefact} (aucune phase FANTÔME/MANQUANTE) ET l'UNION des cellules Modules == les
# 22 modules de l'artefact (aucun module OUBLIÉ/en trop). La colonne « Phase » reste
# libre (paraphrase). Cohérences croisées en bonus (mordent un plan INTERNEMENT
# incohérent) : ordres contigus 1..N sans doublon · responsable ∈ {worker, vps,
# worker+vps} · toute dépendance pointe en ARRIÈRE (n° < n° de la phase ⇒ pas de
# cycle/renvoi avant).
try:
dr_plan = load("devops/deploy_runbook/out/deploy_runbook.json")
except (OSError, KeyError, ValueError) as e:
bad(f"DevOps · run-book illisible : {e}"); dr_plan = None
if dr_plan is not None:
id2order = {p["id"]: p["order"] for p in dr_plan}
ALLOWED_RESP = {"worker", "vps", "worker+vps"}
orders = sorted(p["order"] for p in dr_plan)
all_modules = sorted(x["module"] for p in dr_plan for x in p["modules"])
if orders == list(range(1, len(dr_plan) + 1)):
good(f"DevOps · run-book — ordres contigus 1..{len(dr_plan)} (aucun trou/doublon)")
else:
bad(f"DevOps · run-book — ordres NON contigus {orders} "
f"(attendu 1..{len(dr_plan)}) → plan incohérent")
# Lignes lues en RAW : la table « Plan de phases généré » est la SEULE à 5
# colonnes dont la 1ʳᵉ est numérique (l'autre table est `Fichier | Rôle`).
try:
dr_tbl = open(DR_RD, encoding="utf-8").read().splitlines()
except OSError as e:
bad(f"DevOps · {DR_RD} illisible : {e}"); dr_tbl = []
table_orders, rows_by_order = [], {}
for ln in dr_tbl:
cells = [c.strip() for c in ln.strip().strip("|").split("|")]
if len(cells) == 5 and re.fullmatch(r"\d+", cells[0]):
o = int(cells[0]); table_orders.append(o); rows_by_order[o] = cells
if sorted(table_orders) == orders and len(table_orders) == len(set(table_orders)):
good(f"DevOps · run-book — {len(table_orders)} lignes == {len(dr_plan)} phases "
f"(aucune FANTÔME/MANQUANTE)")
else:
bad(f"DevOps · run-book — lignes table {sorted(table_orders)} ≠ phases artefact "
f"{orders} (ligne en trop/manquante ou doublon)")
table_modules = sorted(m for r in rows_by_order.values()
for m in re.findall(r"`([^`]+)`", r[3]))
if table_modules == all_modules:
good(f"DevOps · run-book — union des Modules ({len(table_modules)}) == "
f"{len(all_modules)} modules de l'artefact (aucun OUBLIÉ/en trop)")
else:
miss = sorted(set(all_modules) - set(table_modules))
extra = sorted(set(table_modules) - set(all_modules))
bad(f"DevOps · run-book — colonne Modules ≠ artefact : manquants={miss or '—'} "
f"en_trop={extra or '—'} → régénérer le README")
for p in sorted(dr_plan, key=lambda x: x["order"]):
o, resp = p["order"], p["responsable"]
deps = sorted(id2order[x] for x in p["depends_on"])
mods = sorted(x["module"] for x in p["modules"])
if resp not in ALLOWED_RESP:
bad(f"DevOps · phase {o} — responsable artefact « {resp} » "
f"hors {sorted(ALLOWED_RESP)} (plan incohérent)")
if any(d >= o for d in deps):
bad(f"DevOps · phase {o} — dépendance en AVANT {deps} (≥ {o}) : graphe incohérent")
row = rows_by_order.get(o)
if row is None:
bad(f"DevOps · phase {o} — ligne INTROUVABLE dans {DR_RD} "
f"(attendu responsable={resp} modules={mods} deps={deps or '—'})"); continue
got_resp = row[1]
got_mods = sorted(re.findall(r"`([^`]+)`", row[3]))
got_deps = sorted(int(x) for x in re.findall(r"\d+", row[4]))
if got_resp == resp:
good(f"DevOps · phase {o} — responsable « {got_resp} » == artefact")
else:
bad(f"DevOps · phase {o} — {DR_RD} dit responsable « {got_resp} » MAIS "
f"artefact dit « {resp} » → régénérer le README")
if got_mods == mods:
good(f"DevOps · phase {o} — Modules ({len(got_mods)}) == artefact")
else:
bad(f"DevOps · phase {o} — {DR_RD} Modules {got_mods} MAIS artefact dit "
f"{mods} → régénérer le README")
if got_deps == deps:
good(f"DevOps · phase {o} — Dépend de {got_deps or '—'} == artefact")
else:
bad(f"DevOps · phase {o} — {DR_RD} « Dépend de » = {got_deps or '—'} MAIS "
f"artefact dit {deps or '—'} → régénérer le README")
# ============================================================================
# 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")
# ============================================================================
# RBAC/userperm_gen · la TABLE « Mapping scope_donnees → mécanisme » — la
# FONCTION d'enforcement ROW-LEVEL : quel mécanisme Frappe natif applique CHAQUE
# portée de données, ET si un `User Permission` template est émis. CŒUR sécurité
# du module, transcrit À LA MAIN sans AUCUN gate d'IDENTITÉ.
# ----------------------------------------------------------------------------
# Le bloc RBAC existant (« 3 volets ») ne gate QUE la VENTILATION par mécanisme
# (« 28 entite · 16 groupe · 2 own · 4 equipe » — un COMPTE, aveugle à QUEL
# mécanisme applique QUELLE portée). Restait, dans le MÊME README (l.33-38), une
# surface data-derived distincte : la table qui énonce, PAR portée, le `mechanism`
# Frappe natif ET le verdict « Template émis ? ». Source faisant autorité
# (byte-gatée par check_artifacts) : rbac/userperm_gen/out/user_permission_plan.json
# — chaque entrée porte `scope_donnees` + `mechanism` + `user_permission_template`
# (null ou objet). Le mapping est une FONCTION : chaque portée → exactement un
# mécanisme, template émis SSI `entite`/`user_permission_company`. RÉAFFECTER une
# portée à un mécanisme plus permissif (`entite` → `none_consolidated` : le
# row-level enforcement ABANDONNÉ, sur-exposition des données inter-entités — la
# restriction même que le module pose), RENOMMER un mécanisme ou basculer le
# verdict « Template émis ? » laisse le README périmé pendant que l'artefact dit
# autre chose → l'agent ERPNext câblerait le mauvais mécanisme (le risque même que
# la table veut prévenir). « Vert trompeur » qu'aucune suite tests/ (qui teste des
# FONCTIONS de mapping/résolution, pas la prose) n'attrape — même patron
# d'IDENTITÉ que la cross-cohérence PERMISSIONS Legal/CONFOTUR ou le catalogue RBAC
# fixtures_gen.
# Bonus (mordent un plan INTERNEMENT incohérent) : chaque portée mappe UN SEUL
# mécanisme (fonction, pas relation) · « Template émis » UNIFORME sur les entrées
# d'une portée · template émis EXACTEMENT pour `user_permission_company` (l'invariant
# « template SSI entite » du README:36/61) · ensemble des portées NON VIDE.
# État courant : aucune ligne périmée (anti-invention #6) — le défaut est la surface
# ungated. Un claim absent échoue AUSSI (traçabilité).
# ============================================================================
try:
up_plan = load("rbac/userperm_gen/out/user_permission_plan.json")
up_scopes, up_tmpl = {}, {} # portée -> set(mécanisme) / set(template non-null?)
for e in up_plan:
s = e["scope_donnees"]
up_scopes.setdefault(s, set()).add(e["mechanism"])
up_tmpl.setdefault(s, set()).add(e["user_permission_template"] is not None)
except (OSError, KeyError, ValueError, TypeError) as e:
bad(f"RBAC userperm map · user_permission_plan.json illisible/inattendu : {e}")
up_scopes = None
if up_scopes is not None:
# bonus 1 : ensemble des portées non vide
if not up_scopes:
bad("RBAC userperm map · plan VIDE (aucune portée) → contrat cassé")
else:
good(f"RBAC userperm map · {len(up_scopes)} portées dans le plan (ensemble non vide)")
# bonus 2 : chaque portée → UN SEUL mécanisme (fonction) + « Template émis » uniforme
scope_mech = {}
for s in sorted(up_scopes):
if len(up_scopes[s]) != 1:
bad(f"RBAC userperm map · portée « {s} » mappe {sorted(up_scopes[s])} "
f"(attendu 1 mécanisme) → plan incohérent (relation, pas fonction)")
continue
scope_mech[s] = next(iter(up_scopes[s]))
if len(up_tmpl[s]) != 1:
bad(f"RBAC userperm map · portée « {s} » — « Template émis ? » NON uniforme "
f"sur ses entrées {sorted(up_tmpl[s])} → plan incohérent")
# bonus 3 : template émis EXACTEMENT pour user_permission_company (l'invariant SSI)
for s, mech in scope_mech.items():
emitted = up_tmpl[s] == {True}
if emitted != (mech == "user_permission_company"):
bad(f"RBAC userperm map · portée « {s} » (mécanisme={mech}) émet "
f"template={emitted} MAIS l'invariant est « template SSI "
f"user_permission_company » → plan incohérent")
# identité + par-ligne vs README (lignes RAW : la table Mapping est la seule à 3
# cellules dont col1 = une portée backtickée `[a-z_]+` et col3 = le verdict
# « Template émis ? » **oui** / non (`null`)).
UPM_RD = f"{D}/rbac/userperm_gen/README.md"
try:
upm_lines = open(UPM_RD, encoding="utf-8").read().splitlines()
except OSError as e:
bad(f"RBAC userperm map · {UPM_RD} illisible : {e}"); upm_lines = []
map_rows = {}
for ln in upm_lines:
cells = [c.strip() for c in ln.strip().strip("|").split("|")]
if len(cells) != 3:
continue
ms = re.fullmatch(r"`([a-z_]+)`", cells[0])
if ms and re.search(r"\*\*oui\*\*|non \(`null`\)", cells[2]):
map_rows[ms.group(1)] = cells
# identité d'ensemble : portées de la table == portées de l'artefact
tbl, art = set(map_rows), set(scope_mech)
if tbl == art:
good(f"RBAC userperm map · table {sorted(tbl)} == portées de l'artefact "
f"({len(art)} · ni fantôme ni manquante)")
else:
miss = sorted(art - tbl); extra = sorted(tbl - art)
detail = (f" · absents={miss}" if miss else "") + \
(f" · en trop={extra}" if extra else "")
bad(f"RBAC userperm map · {UPM_RD} table {sorted(tbl)} MAIS artefact "
f"{sorted(art)}{detail} → régénérer le README")
# par portée : mécanisme (1er token backtické de col2) + verdict « Template émis ? »
for s in sorted(art):
exp_mech = scope_mech[s]
exp_emit = up_tmpl[s] == {True}
row = map_rows.get(s)
if row is None:
bad(f"RBAC userperm map · portée « {s} » — ligne INTROUVABLE dans {UPM_RD} "
f"(attendu mécanisme={exp_mech} · template="
f"{'oui' if exp_emit else 'non'})")
continue
mm = re.match(r"`([^`]+)`", row[1])
got_mech = mm.group(1) if mm else "?"
if got_mech == exp_mech:
good(f"RBAC userperm map · portée « {s} » — mécanisme « {got_mech} » == artefact")
else:
bad(f"RBAC userperm map · portée « {s} » — {UPM_RD} dit mécanisme "
f"« {got_mech} » MAIS artefact dit « {exp_mech} » → réaffectation "
f"d'enforcement non répercutée, régénérer")
got_emit = row[2].strip() == "**oui**"
if got_emit == exp_emit:
good(f"RBAC userperm map · portée « {s} » — Template émis="
f"{'oui' if got_emit else 'non'} == artefact")
else:
bad(f"RBAC userperm map · portée « {s} » — {UPM_RD} dit Template émis="
f"{'oui' if got_emit else 'non'} MAIS artefact dit "
f"{'oui' if exp_emit else 'non'} → régénérer le README")
# ============================================================================
# Faisabilité/bancable — figures « Génération réelle (fixture) » du README
# ----------------------------------------------------------------------------
# Le module `faisabilite/bancable` n'écrit son `out/` que sur disque (« out/ non
# commité ») : il est HORS de check_artifacts (aucun artefact byte-gaté) et
# AUCUN gate ne le touchait. Or son README §« Vérification en-repo » recopie
# QUATRE chiffres DATA-DERIVED que le générateur calcule sur la fixture COMMITÉE
# `fixtures/brief_bancable.json` : « 40 unités · valeur catalogue USD 8,560,000
# / DOP 505,040,000 · point d'équilibre 21 unités (⌈52 % × 40⌉) ». Ces valeurs
# sont Σ quantités · Σ(qté×prix_usd) · Σ(qté×prix_dop) · ⌈52 % × Σ unités⌉ — le
# 52 % ancré sur CLAUDE.md #9. PIÈGE : la suite tests/ teste des FONCTIONS
# (l'arithmétique de recoupement) avec des oracles HARDCODÉS ; elle ne compare
# JAMAIS la PROSE du README à la génération réelle. Éditer une typologie de la
# fixture (quantité/prix), en ajouter/retirer une, ou déplacer le point
# d'équilibre de CLAUDE.md #9, laisse les 4 chiffres du README périmés en silence
# pendant que le générateur produit autre chose → le banquier lit un dossier
# faux, l'invention même que #6 interdit. Ce bloc RECOMPUTE les 4 figures depuis
# la fixture (source de vérité), exige que la GÉNÉRATION RÉELLE (bancable_gen
# validate) les confirme (mord un générateur/fixture incohérents), puis que la
# prose README les énumère EXACTEMENT (valeur + séparateur de milliers + formule
# ⌈pct × Σ unités⌉, pct ancré CLAUDE.md #9). Un claim absent échoue AUSSI.
# ============================================================================
import math
BANC = f"{D}/faisabilite/bancable"
BANC_RD = f"{BANC}/README.md"
# 1) point d'équilibre % ANCRÉ sur CLAUDE.md #9 (source faisant autorité · #6/#9)
try:
_cmb = open("CLAUDE.md", encoding="utf-8").read()
_l9b = re.search(r"^9\.\s+(.+)$", _cmb, re.M)
if not _l9b:
raise ValueError("contrainte #9 INTROUVABLE")
_peb = None
for _sb in re.split(r"·", _l9b.group(1)):
if "equilibre" in _ai(_sb):
_mmb = re.search(r"(\d+(?:\.\d+)?)\s*%", _sb)
_peb = float(_mmb.group(1)) if _mmb else None
if _peb is None:
raise ValueError("point d'équilibre % absent de #9")
except (OSError, ValueError) as e:
bad(f"Bancable figures · CLAUDE.md #9 (point d'équilibre) illisible : {e}"); _peb = None
# 2) recompute INDÉPENDANT depuis la fixture COMMITÉE (source de vérité · #6)
try:
_fxb = json.load(open(f"{BANC}/fixtures/brief_bancable.json", encoding="utf-8"))
_typb = _fxb["architecture"]["typologies"]
rec_units = sum(int(t["quantite"]) for t in _typb)
rec_usd = sum(int(t["quantite"]) * int(t["prix_usd"]) for t in _typb)
rec_dop = sum(int(t["quantite"]) * int(t["prix_dop"]) for t in _typb)
except (OSError, KeyError, ValueError, TypeError) as e:
bad(f"Bancable figures · fixture brief_bancable.json illisible/inattendue : {e}"); rec_units = None
# 3) génération RÉELLE : le manifeste que le README prétend recopier
man_fig = None
try:
_procb = subprocess.run(
[sys.executable, "bancable_gen.py", "validate", "fixtures/brief_bancable.json"],
cwd=BANC, capture_output=True, text=True, encoding="utf-8")
if _procb.returncode != 0:
raise ValueError(f"validate exit {_procb.returncode} : {_procb.stderr.strip()[:160]}")
man_fig = {f["cle"]: f["valeur"] for f in json.loads(_procb.stdout)["figures_calculees"]}
except (ValueError, KeyError, OSError) as e:
bad(f"Bancable figures · génération réelle (bancable_gen validate) échoue : {e}")
if _peb is not None and rec_units is not None and man_fig is not None:
rec_be = math.ceil(_peb / 100.0 * rec_units)
# cohérence croisée : le manifeste GÉNÉRÉ == le recompute indépendant fixture
for cle, expv in (("total_unites", rec_units), ("valeur_catalogue_usd", rec_usd),
("valeur_catalogue_dop", rec_dop), ("point_equilibre_unites", rec_be)):
gotv = man_fig.get(cle)
if gotv is None:
bad(f"Bancable figures · manifeste sans figure « {cle} » (attendu {expv})")
elif int(gotv) != int(expv):
bad(f"Bancable figures · manifeste « {cle} »={gotv} MAIS recompute fixture "
f"{expv} → générateur/fixture incohérents (formule cassée)")
else:
good(f"Bancable figures · manifeste « {cle} »={int(gotv)} == recompute fixture")
# 4) PROSE README §« Vérification en-repo » — un seul pattern d'identité
try:
banc_txt = re.sub(r"\s+", " ", open(BANC_RD, encoding="utf-8").read())
except OSError as e:
bad(f"Bancable figures · {BANC_RD} illisible : {e}"); banc_txt = None
if banc_txt is not None:
pat = (r"(\d[\d,]*)\s*unités\s*·\s*valeur catalogue\s*USD\s*([\d,]+)\s*/\s*"
r"DOP\s*([\d,]+)\s*·\s*point d'équilibre\s*(\d+)\s*unités\s*"
r"\(⌈\s*(\d+)\s*%\s*×\s*(\d+)\s*⌉\)")
m = re.search(pat, banc_txt)
if m is None:
bad(f"Bancable figures · {BANC_RD} — ligne « Génération réelle (fixture) » "
f"INTROUVABLE (attendu {rec_units} unités · USD {rec_usd:,} / DOP "
f"{rec_dop:,} · équilibre {rec_be} unités (⌈{_peb:g} % × {rec_units}⌉)) — "
f"un claim absent est une régression (#6)")
else:
g_pct, g_tot = float(m.group(5)), int(m.group(6))
for lbl, raw, expv in (("Σ unités", m.group(1), rec_units),
("valeur catalogue USD", m.group(2), rec_usd),
("valeur catalogue DOP", m.group(3), rec_dop),
("point d'équilibre", m.group(4), rec_be)):
got = int(raw.replace(",", ""))
if got != expv:
bad(f"Bancable figures · README {lbl}={raw} MAIS génération réelle "
f"={expv} → figure périmée, régénérer la fixture/le README (#6)")
elif expv >= 1000 and "," not in raw:
bad(f"Bancable figures · README {lbl}={raw} sans séparateur de milliers "
f"(attendu « {expv:,} »)")
else:
good(f"Bancable figures · README {lbl}={raw} == génération réelle")
# formule ⌈52 % × 40⌉ : pct ancré CLAUDE.md #9 · total == Σ unités
if g_pct != _peb:
bad(f"Bancable figures · README formule ⌈{g_pct:g} %⌉ MAIS CLAUDE.md #9 "
f"point d'équilibre = {_peb:g} % → aligner (ou CLAUDE.md)")
else:
good(f"Bancable figures · README formule pct {g_pct:g} % == CLAUDE.md #9")
if g_tot != rec_units:
bad(f"Bancable figures · README formule « × {g_tot} » MAIS Σ unités "
f"= {rec_units} → total de la formule désaligné")
else:
good(f"Bancable figures · README formule « × {g_tot} » == Σ unités")
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"