64238f6833
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
5286 lines
325 KiB
Bash
Executable File
5286 lines
325 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# ============================================================================
|
||
# check_readme_claims.sh · OTO Enterprise OS DTP
|
||
# ----------------------------------------------------------------------------
|
||
# Gate d'INTÉGRITÉ des chiffres du README (Sprint 8 buffer · CLAUDE.md #6 —
|
||
# « Zéro invention de chiffres · toujours vérifier sources archives »).
|
||
#
|
||
# Le README.md est le POINT D'ENTRÉE du mandat. Sa section « État courant
|
||
# (sourcé) » affiche des chiffres et déclare, mot pour mot :
|
||
# « Chaque chiffre ci-dessous est sourcé vers un artefact commité
|
||
# (anti-invention #6) ; ce README n'introduit aucune donnée nouvelle. »
|
||
# Cette promesse n'était garantie par AUCUN gate. `check_docs.sh` ne valide que
|
||
# les liens (cible existe) — pas la VALEUR des nombres. Résultat : quand un
|
||
# module + son job CI sont ajoutés (21→22 modules, 21→22 suites) ou qu'une
|
||
# promesse change de statut (14 in_repo + 1 out_of_scope → 15 in_repo), les
|
||
# chiffres du README se PÉRIMENT en silence tout en restant « sourcés » vers un
|
||
# artefact qui, lui, dit autre chose. Un README qui se contredit avec sa propre
|
||
# source est un « vert trompeur » de la même classe que la matrice périmée
|
||
# (« demo 18→21 ») ou l'orphan_tests_dirs (INV4) — appliqué à la doc d'entrée.
|
||
#
|
||
# Ce gate RECOMPUTE chaque chiffre depuis l'artefact cité (jamais une liste à la
|
||
# main · #6) et exige l'égalité avec ce qui est ÉCRIT dans les docs d'entrée :
|
||
# README.md :
|
||
# • Qualité 4Big N/M modules gated à K/100, verdict → qa/audit_4big totals
|
||
# • Régression N suites gated → qa/regression plan
|
||
# • Recette N promesses (X sprint + Y métriques), Z in_repo, verdict
|
||
# → qa/acceptance matrix
|
||
# • Agents « 13 agents » (×2 : nav + titre) → 03_agents/*/AGENT.md
|
||
# 03_agents/qa/AGENT.md (fiche QA · « Verdict agrégé courant ») :
|
||
# • N suites · M tests · M passés · E échec · E erreur · verdict
|
||
# → qa/regression run
|
||
# 03_agents/erpnext_backend/AGENT.md (« source unique ») :
|
||
# • N tests · M suites · verdict (matrice de régression du repo)
|
||
# → qa/regression run
|
||
# 05_deliverables_mvp/qa/audit_4big/README.md (« Résultat courant ») :
|
||
# • N/M modules à K/100, verdict (l'auditeur compte les modules mais figeait
|
||
# son PROPRE total à la main : 17→22) → qa/audit_4big totals
|
||
# 03_agents/*/AGENT.md (tables de livrables · colonne « Tests ») :
|
||
# • chaque cellule « Tests » par suite gatée → plan.suites[path]
|
||
# (+ count_tests pour le self_module qa/regression, exclu de la matrice)
|
||
# 03_agents/{crm,erpnext_backend}/AGENT.md (agrégats EN PROSE) :
|
||
# • « Total CRM N (a+b+c) » · « RBAC N (…) » · « e-CF N » — total ET multiset
|
||
# des composants recomputés (une compensation entre suites laisserait la
|
||
# table juste et la prose fausse) → plan.suites[path]
|
||
# frontend/portails/README.md + 03_agents/frontend_console/AGENT.md :
|
||
# • comptes PAR WORKSPACE (cartes / liens / rôles) transcrits À LA MAIN dans
|
||
# DEUX docs (table du README + prose « OTO Ventes 4 cartes/11 liens/12
|
||
# rôles ») + total « N Workspace natifs » → portails MANIFEST
|
||
# (tableau `workspaces`, byte-gaté par check_artifacts)
|
||
# seo/README.md + 03_agents/seo/AGENT.md (comptes de mots-clés) :
|
||
# • total + ventilation « 258 mots-clés (fr=87 · en=87 · es=84) » transcrits À
|
||
# LA MAIN dans DEUX docs → seo MANIFEST.counts
|
||
# (keywords_total + keywords_per_lang, recomptés de projets_master, byte-gatés)
|
||
# crm/workflow_vente/README.md + crm/dossier_vente/README.md (pipeline vente) :
|
||
# • « N états · M transitions » (titre du README workflow) + « N états du
|
||
# pipeline » (README DocType porteur, même compte dérivé du même graphe)
|
||
# transcrits À LA MAIN dans DEUX docs → workflow_vente
|
||
# MANIFEST.counts (states + transitions, recomptés du spec pipeline, byte-gatés)
|
||
# rbac/{roleprofile_gen,apply_plan,userperm_gen}/README.md (comptes 3 volets) :
|
||
# • table « Nb rôles » PAR profil + total bijectif → roleprofile MANIFEST
|
||
# • ligne agrégée « 6 étapes · 50 rôles / 116 DocPerm / 28 UP / 6 Role
|
||
# Profile · 4 DocType custom + 5 Company + 4 rôles » → apply_plan MANIFEST
|
||
# • ventilation « 28 entite · 16 groupe · 2 own · 4 equipe » → userperm MANIFEST
|
||
# (les 3 MANIFEST recalculés du contrat rbac_50_roles.json, byte-gatés)
|
||
# mobile/app_config/README.md (ligne récap de l'app Expo) :
|
||
# • quadruplet « 5 onglets · 44 rôles couverts · 3 langues · 13 identifiants
|
||
# a_confirmer » transcrit À LA MAIN → app_config
|
||
# MANIFEST.counts (onglets/roles_couverts/langues/identifiants_a_confirmer,
|
||
# recomptés de rbac_50_roles + seo_spec + a_confirmer, byte-gatés)
|
||
# legal/confotur/README.md + 03_agents/onapi_legal/AGENT.md (DocType CONFOTUR) :
|
||
# • quintuplet « 18 champs (14 de donnée) · 4 sections · 3 rôles · 2 évènements
|
||
# de dépôt » transcrit À LA MAIN dans DEUX docs → confotur
|
||
# MANIFEST.counts (fields/data_fields/sections/roles/depot_events, recomptés
|
||
# de confotur_spec.json, byte-gatés)
|
||
# frontend/chat_otoia/README.md + 03_agents/frontend_console/AGENT.md (montage
|
||
# du Chat OTOIA par portail) :
|
||
# • triplet « N `Custom Block` · M configs runtime · P portails » (tous = 5
|
||
# aujourd'hui — piège aveugle) transcrit À LA MAIN dans DEUX docs → chat_otoia
|
||
# MANIFEST.counts (custom_blocks/mounts/portails, recomptés du contrat RBAC,
|
||
# byte-gatés) + cohérence croisée == |MANIFEST.portails| (bijection 1 block ⇔
|
||
# 1 mount ⇔ 1 portail)
|
||
# qa/acceptance/README.md + 03_agents/qa/AGENT.md (module de recette) :
|
||
# • couple « N livrables de sprint + M métriques succès MVP » transcrit À LA MAIN
|
||
# dans DEUX docs de module (le bloc racine ne gate que le README d'entrée)
|
||
# → recompté depuis la matrice acceptance (kind sprint_deliverable / mvp_metric)
|
||
# + cohérence croisée == MANIFEST.counts (compteur ⇔ matrice résumée)
|
||
# fiscal/ecf_dgii/README.md (×3) + 03_agents/erpnext_backend/AGENT.md (format e-NCF) :
|
||
# • le CONTRAT DE FORMAT de l'e-NCF « E + tipoeCF(2) + secuencia(10) = 13 »
|
||
# (préfixe · longueur segment tipo · longueur segment secuencia · longueur
|
||
# totale) transcrit À LA MAIN dans QUATRE endroits de prose → recompté depuis
|
||
# le SEUL bloc `e_ncf` d'ecf_plan.json (prefix/longueur/regex/structure, byte-
|
||
# gatés) + cohérences croisées (1+tipo+seq == longueur · |regex digits| ==
|
||
# tipo+seq — mordent un artefact internement incohérent)
|
||
# Un claim absent échoue AUSSI (la dérive de formulation qui ferait disparaître
|
||
# un chiffre est elle-même une régression de traçabilité).
|
||
#
|
||
# Dépendances : bash, git, python3 (stdlib pur · CLAUDE.md #2 — runner sans pip).
|
||
# ============================================================================
|
||
set -uo pipefail
|
||
# shellcheck source=ci/lib.sh
|
||
source "$(dirname "${BASH_SOURCE[0]}")/lib.sh" || exit 3
|
||
cd_repo_root # racine du dépôt, ou ROUGE honnête si hors arbre git (cf. lib.sh)
|
||
|
||
echo "== Intégrité des chiffres du README (valeur == artefact cité · #6) =="
|
||
|
||
python3 - <<'PY'
|
||
import json, re, subprocess, sys, unicodedata
|
||
|
||
D = "05_deliverables_mvp"
|
||
FAIL = 0
|
||
def bad(m):
|
||
global FAIL; FAIL = 1; print(f" \033[31m✗\033[0m {m}")
|
||
def good(m):
|
||
print(f" \033[32m✓\033[0m {m}")
|
||
|
||
try:
|
||
readme = open("README.md", encoding="utf-8").read()
|
||
except OSError as e:
|
||
print(f" \033[31m✗\033[0m README.md illisible : {e}"); sys.exit(1)
|
||
# Texte normalisé (espaces/newlines compressés) pour les claims multi-lignes.
|
||
norm = re.sub(r"\s+", " ", readme)
|
||
|
||
def load(p):
|
||
with open(f"{D}/{p}", encoding="utf-8") as f:
|
||
return json.load(f)
|
||
|
||
# --- Vérités-terrain recalculées depuis les artefacts (jamais saisies) -------
|
||
q = load("qa/audit_4big/out/quality_report.json")["totals"]
|
||
qv = load("qa/audit_4big/out/quality_report.json")["verdict"]
|
||
reg = load("qa/regression/out/regression_plan.json")["totals"]
|
||
acc = load("qa/acceptance/out/acceptance_matrix.json")
|
||
rows = acc["matrix"]
|
||
n_sprint = sum(1 for r in rows if r["kind"] == "sprint_deliverable")
|
||
n_metric = sum(1 for r in rows if r["kind"] == "mvp_metric")
|
||
n_inrepo = sum(1 for r in rows if r["status"] == "in_repo")
|
||
acc_verdict = acc["verdict"]
|
||
agents = subprocess.run(["git", "ls-files", "03_agents/*/AGENT.md"],
|
||
capture_output=True, text=True).stdout.split()
|
||
n_agents = len(agents)
|
||
|
||
# --- Chaque claim : (libellé, regex sur `norm`, groupes attendus) ------------
|
||
# Le n-uplet capturé (str) est comparé à l'attendu (str). Un claim non trouvé
|
||
# échoue (regex is None) — la disparition d'un chiffre est une régression.
|
||
checks = [
|
||
("Qualité 4Big · modules gated",
|
||
r"\*\*(\d+)/(\d+) modules gated à (\d+)/100\*\*",
|
||
(str(q["pass"]), str(q["modules"]), str(q["min_score"])),
|
||
f"pass={q['pass']} total={q['modules']} min_score={q['min_score']}"),
|
||
|
||
("Régression · suites gated",
|
||
r"\*\*(\d+) suites gated\*\*",
|
||
(str(reg["suites"]),),
|
||
f"suites={reg['suites']}"),
|
||
|
||
("Recette · promesses (sprint + métriques)",
|
||
r"\*\*(\d+) promesses\*\* \((\d+) livrables sprint \+ (\d+) métriques",
|
||
(str(len(rows)), str(n_sprint), str(n_metric)),
|
||
f"total={len(rows)} sprint={n_sprint} metric={n_metric}"),
|
||
|
||
("Recette · promesses in_repo",
|
||
r"\*\*(\d+) `in_repo`",
|
||
(str(n_inrepo),),
|
||
f"in_repo={n_inrepo}"),
|
||
|
||
("Agents · fiche nav",
|
||
r"Fiches des \*\*(\d+) agents\*\*",
|
||
(str(n_agents),),
|
||
f"AGENT.md={n_agents}"),
|
||
|
||
("Agents · titre section",
|
||
r"## Les (\d+) agents",
|
||
(str(n_agents),),
|
||
f"AGENT.md={n_agents}"),
|
||
]
|
||
|
||
for label, pat, expected, src in checks:
|
||
m = re.search(pat, norm)
|
||
if m is None:
|
||
bad(f"{label} — claim INTROUVABLE dans README (attendu {src}) · motif=/{pat}/")
|
||
continue
|
||
got = m.groups()
|
||
if got == expected:
|
||
good(f"{label} — README {'/'.join(got)} == artefact ({src})")
|
||
else:
|
||
bad(f"{label} — README dit {'/'.join(got)} MAIS artefact dit {'/'.join(expected)} ({src}) → régénérer README")
|
||
|
||
# --- Verdicts textuels cités dans le README (PASS / true) --------------------
|
||
if re.search(r"verdict `PASS`", norm):
|
||
if qv == "PASS":
|
||
good(f"Qualité 4Big · verdict README `PASS` == artefact ({qv})")
|
||
else:
|
||
bad(f"Qualité 4Big · README dit verdict `PASS` MAIS artefact dit {qv}")
|
||
else:
|
||
bad("Qualité 4Big · verdict `PASS` non cité dans README (attendu)")
|
||
|
||
if re.search(r"verdict `true`", norm):
|
||
if acc_verdict is True:
|
||
good("Recette · verdict README `true` == artefact (True)")
|
||
else:
|
||
bad(f"Recette · README dit verdict `true` MAIS artefact dit {acc_verdict}")
|
||
else:
|
||
bad("Recette · verdict `true` non cité dans README (attendu)")
|
||
|
||
# ============================================================================
|
||
# Fiche QA (03_agents/qa/AGENT.md) · même classe de « vert trompeur » —
|
||
# ----------------------------------------------------------------------------
|
||
# Sa section « Verdict agrégé courant » affiche un compte agrégé (« N suites
|
||
# gated · M tests · M passés · 0 échec · 0 erreur → verdict PASS ») en déclarant
|
||
# « jamais compté à la main » et en citant qa/regression/out/regression_run.json.
|
||
# Ce compte se PÉRIME pourtant en silence quand une suite est ajoutée : la fiche
|
||
# affichait « 21 suites · 534 tests » alors que le run agrégé faisant autorité en
|
||
# disait déjà davantage (matrice recomptée ci-dessous, jamais figée ici). Comme pour
|
||
# le README, aucun gate ne comparait la VALEUR au run. On la RECALCULE ici depuis
|
||
# le compte agrégé faisant autorité (regression_run.json, commité · déterministe).
|
||
# ============================================================================
|
||
QA = "03_agents/qa/AGENT.md"
|
||
try:
|
||
qa_txt = re.sub(r"\s+", " ", open(QA, encoding="utf-8").read())
|
||
except OSError as e:
|
||
bad(f"Fiche QA · {QA} illisible : {e}"); qa_txt = ""
|
||
|
||
run = load("qa/regression/out/regression_run.json")
|
||
suites = run["suites"]
|
||
agg = (
|
||
str(len(suites)), # suites
|
||
str(sum(s["ran"] for s in suites)), # tests exécutés
|
||
str(sum(s["passed"] for s in suites)), # passés
|
||
str(sum(s["failures"] for s in suites)), # échecs
|
||
str(sum(s["errors"] for s in suites)), # erreurs
|
||
run["verdict"], # verdict
|
||
)
|
||
qa_pat = (r"\*\*(\d+) suites gated · (\d+) tests · (\d+) passés · "
|
||
r"(\d+) échec · (\d+) erreur → verdict `(PASS|FAIL)`")
|
||
qa_src = (f"suites={agg[0]} tests={agg[1]} passés={agg[2]} "
|
||
f"échec={agg[3]} erreur={agg[4]} verdict={agg[5]}")
|
||
mqa = re.search(qa_pat, qa_txt)
|
||
if mqa is None:
|
||
bad(f"Fiche QA · verdict agrégé — claim INTROUVABLE dans {QA} (attendu {qa_src})")
|
||
elif mqa.groups() == agg:
|
||
good(f"Fiche QA · verdict agrégé — {QA} {'/'.join(mqa.groups())} == regression_run ({qa_src})")
|
||
else:
|
||
bad(f"Fiche QA · {QA} dit {'/'.join(mqa.groups())} MAIS regression_run dit "
|
||
f"{'/'.join(agg)} ({qa_src}) → régénérer la fiche")
|
||
|
||
# ============================================================================
|
||
# Fiche ERPNext Backend (03_agents/erpnext_backend/AGENT.md) · même classe.
|
||
# ----------------------------------------------------------------------------
|
||
# Sa ligne « source unique » situait les tests backend dans le repo par un
|
||
# compte agrégé (« repo : 560 tests au total ») saisi à la main. Ce nombre s'est
|
||
# PÉRIMÉ en silence (matrice 534 → 551 → 558) : 560 ne correspondait plus ni à la
|
||
# matrice (558) ni au repo-wide (584 méthodes test_*). Aucun gate ne le couvrait.
|
||
# On le RECALCULE depuis la même matrice faisant autorité (regression_run) et on
|
||
# exige tests + suites + verdict — comme la fiche QA.
|
||
# ============================================================================
|
||
BE = "03_agents/erpnext_backend/AGENT.md"
|
||
try:
|
||
be_txt = re.sub(r"\s+", " ", open(BE, encoding="utf-8").read())
|
||
except OSError as e:
|
||
bad(f"Fiche Backend · {BE} illisible : {e}"); be_txt = ""
|
||
|
||
be_exp = (
|
||
str(sum(s["ran"] for s in suites)), # tests exécutés (matrice)
|
||
str(len(suites)), # suites
|
||
run["verdict"], # verdict
|
||
)
|
||
be_pat = (r"matrice\s+de régression du repo : \*\*(\d+) tests · (\d+) suites\*\* · "
|
||
r"verdict `(PASS|FAIL)`")
|
||
be_src = f"tests={be_exp[0]} suites={be_exp[1]} verdict={be_exp[2]}"
|
||
mbe = re.search(be_pat, be_txt)
|
||
if mbe is None:
|
||
bad(f"Fiche Backend · matrice repo — claim INTROUVABLE dans {BE} (attendu {be_src})")
|
||
elif mbe.groups() == be_exp:
|
||
good(f"Fiche Backend · matrice repo — {BE} {'/'.join(mbe.groups())} == regression_run ({be_src})")
|
||
else:
|
||
bad(f"Fiche Backend · {BE} dit {'/'.join(mbe.groups())} MAIS regression_run dit "
|
||
f"{'/'.join(be_exp)} ({be_src}) → régénérer la fiche")
|
||
|
||
# ============================================================================
|
||
# RBAC · le COMPTE CANONIQUE de rôles ANCRÉ sur CLAUDE.md §Architecture cible
|
||
# (« RBAC N rôles ») — classe claude-md-constant-anchor, restée NON ancrée.
|
||
# ----------------------------------------------------------------------------
|
||
# CLAUDE.md §Architecture cible FIXE le mandat « RBAC 50 rôles ». Ce nombre
|
||
# canonique est recopié SANS ancrage vers CLAUDE.md dans : le contrat
|
||
# rbac_50_roles.json (`cible_rbac_roles` ET la taille réelle du catalogue
|
||
# `len(roles)`), les schémas (`const: 50`) et la fiche agent erpnext_backend
|
||
# (« RBAC 50 rôles natif » · « **50 rôles** » · « RBAC **50 rôles** exactement »).
|
||
# Les gates RBAC amont ne prouvent que la COHÉRENCE INTERNE (les READMEs
|
||
# apply_plan/userperm/roleprofile == leur MANIFEST, lui-même recompté du contrat)
|
||
# — AVEUGLES à l'ancrage au MANDAT : changer « RBAC 50 rôles » dans CLAUDE.md, ou
|
||
# faire dévier le catalogue de 50 de façon INTERNE-cohérente (cible ET len alignés
|
||
# sur une autre valeur), laisserait le contrat et la fiche affirmer « 50 exactement »
|
||
# en CONTREDISANT le mandat, en silence. On RE-DÉRIVE N de CLAUDE.md §Architecture
|
||
# cible (source unique) et on exige : (a) contrat cible_rbac_roles == N, (b) taille
|
||
# réelle len(roles) == N, (c) cible == réel (le mandat est ATTEINT par le catalogue),
|
||
# (d) la fiche erpnext_backend porte l'invariant emphatique « RBAC **N rôles**
|
||
# exactement » ET aucune mention « M rôles » périmée (M != N). Même patron que les
|
||
# tokens branding #4 ou la persona OTOIA §Architecture cible ; un claim absent
|
||
# échoue AUSSI (traçabilité #6).
|
||
# ============================================================================
|
||
try:
|
||
_cm = open("CLAUDE.md", encoding="utf-8").read()
|
||
except OSError as e:
|
||
bad(f"RBAC compte · CLAUDE.md illisible : {e}"); _cm = ""
|
||
_arch = re.search(r"## Architecture cible\n(.*?)(?:\n## |\Z)", _cm, re.S)
|
||
m_n = re.search(r"RBAC\s+(\d+)\s+rôles", _arch.group(1)) if _arch else None
|
||
if m_n is None:
|
||
bad("RBAC compte · « RBAC N rôles » INTROUVABLE dans CLAUDE.md §Architecture "
|
||
"cible → l'ancre du mandat a disparu")
|
||
else:
|
||
N = int(m_n.group(1))
|
||
good(f"RBAC compte · ancre CLAUDE.md §Architecture cible = « RBAC {N} rôles »")
|
||
try:
|
||
_c = load("rbac/rbac_50_roles.json")
|
||
cible = _c.get("cible_rbac_roles"); actual = len(_c.get("roles", []))
|
||
except (OSError, ValueError) as e:
|
||
bad(f"RBAC compte · contrat rbac_50_roles.json illisible : {e}")
|
||
cible = actual = None
|
||
if cible is not None:
|
||
if cible == N:
|
||
good(f"RBAC compte · (a) contrat cible_rbac_roles {cible} == CLAUDE.md ({N})")
|
||
else:
|
||
bad(f"RBAC compte · (a) contrat cible_rbac_roles {cible} != CLAUDE.md "
|
||
f"§Architecture cible ({N}) → le contrat contredit le mandat")
|
||
if actual == N:
|
||
good(f"RBAC compte · (b) catalogue réel len(roles) {actual} == CLAUDE.md ({N})")
|
||
else:
|
||
bad(f"RBAC compte · (b) catalogue réel len(roles) {actual} != CLAUDE.md "
|
||
f"§Architecture cible ({N}) → le catalogue a dévié du mandat")
|
||
if cible == actual:
|
||
good(f"RBAC compte · (c) cible {cible} == catalogue réel {actual} (mandat atteint)")
|
||
else:
|
||
bad(f"RBAC compte · (c) cible_rbac_roles {cible} != catalogue réel {actual} "
|
||
f"→ cible déclarée non tenue par le catalogue")
|
||
RBC = "03_agents/erpnext_backend/AGENT.md"
|
||
try:
|
||
rbc = re.sub(r"\s+", " ", open(RBC, encoding="utf-8").read())
|
||
except OSError as e:
|
||
bad(f"RBAC compte · {RBC} illisible : {e}"); rbc = ""
|
||
if re.search(rf"RBAC \*\*{N} rôles\*\* exactement", rbc):
|
||
good(f"RBAC compte · (d) fiche porte « RBAC **{N} rôles** exactement » == CLAUDE.md")
|
||
else:
|
||
bad(f"RBAC compte · (d) invariant « RBAC **{N} rôles** exactement » INTROUVABLE "
|
||
f"dans {RBC} → claim absent = régression #6")
|
||
role_mentions = [int(x) for x in re.findall(r"(\d+) rôles", rbc)]
|
||
stale = sorted({m for m in role_mentions if m != N})
|
||
if not role_mentions:
|
||
bad(f"RBAC compte · (d) aucune mention « N rôles » dans {RBC}")
|
||
elif stale:
|
||
bad(f"RBAC compte · (d) mention(s) périmée(s) {stale} rôles dans {RBC} MAIS "
|
||
f"CLAUDE.md dit {N} → resynchroniser la fiche")
|
||
else:
|
||
good(f"RBAC compte · (d) {len(role_mentions)} mention(s) « {N} rôles » "
|
||
f"dans {RBC} == CLAUDE.md (aucune périmée)")
|
||
|
||
# ============================================================================
|
||
# README de l'auditeur 4Big (05_deliverables_mvp/qa/audit_4big/README.md) ·
|
||
# section « Résultat courant » — même classe de « vert trompeur ».
|
||
# ----------------------------------------------------------------------------
|
||
# Ironie parfaite : le module qui COMPTE les modules affichait son propre total
|
||
# SAISI À LA MAIN (« 17/17 modules à 100/100 »). Ce compte s'est PÉRIMÉ en silence
|
||
# quand des modules ont été ajoutés (17 → 22) — le README de l'auditeur contredisait
|
||
# son propre artefact `quality_report.json` (totals.modules = 22). Le gate racine
|
||
# ne couvrait QUE le README d'entrée, pas ce README de module. On RECALCULE ici
|
||
# depuis le même artefact faisant autorité (totals, déjà chargé dans `q`) et on
|
||
# exige l'égalité avec ce que la section « Résultat courant » ÉCRIT.
|
||
# ============================================================================
|
||
A4 = f"{D}/qa/audit_4big/README.md"
|
||
try:
|
||
a4_txt = re.sub(r"\s+", " ", open(A4, encoding="utf-8").read())
|
||
except OSError as e:
|
||
bad(f"README audit_4big · {A4} illisible : {e}"); a4_txt = ""
|
||
|
||
a4_exp = (qv, str(q["pass"]), str(q["modules"]), str(q["min_score"]))
|
||
a4_pat = (r"\*\*Verdict `(PASS|FAIL)`\*\* [—-] \*\*(\d+)/(\d+)\*\* "
|
||
r"modules à \*\*(\d+)/100\*\*")
|
||
a4_src = f"verdict={a4_exp[0]} pass={a4_exp[1]} modules={a4_exp[2]} min_score={a4_exp[3]}"
|
||
ma4 = re.search(a4_pat, a4_txt)
|
||
if ma4 is None:
|
||
bad(f"README audit_4big · « Résultat courant » — claim INTROUVABLE dans {A4} "
|
||
f"(attendu {a4_src})")
|
||
elif ma4.groups() == a4_exp:
|
||
good(f"README audit_4big · Résultat courant — {A4} "
|
||
f"{'/'.join(ma4.groups())} == quality_report ({a4_src})")
|
||
else:
|
||
bad(f"README audit_4big · {A4} dit {'/'.join(ma4.groups())} MAIS quality_report "
|
||
f"dit {'/'.join(a4_exp)} ({a4_src}) → régénérer le README")
|
||
|
||
# ============================================================================
|
||
# README de l'auditeur 4Big · BARÈME de notation (N critères + poids par critère)
|
||
# — même classe de « vert trompeur », au grain du référentiel de scoring.
|
||
# ----------------------------------------------------------------------------
|
||
# Le barème 4Big est DATA-DERIVED : quality_report.json.criteria (byte-gaté par
|
||
# check_artifacts, RECOMPUTÉ du référentiel à chaque build) porte la liste des
|
||
# critères et leur poids. Le README les RETRANSCRIT À LA MAIN à trois endroits :
|
||
# • la prose « Note chaque module sur N critères 4Big déterministes »,
|
||
# • la table « Critère | Poids | Fait vérifié » (une ligne id→poids par critère),
|
||
# • l'archétype « generator (N critères) » (generator applique TOUS les critères).
|
||
# Ajouter/retirer un critère ou changer un poids ferait dériver ces trois surfaces
|
||
# en silence pendant que l'artefact dit autre chose — aucune suite tests/ (qui teste
|
||
# des FONCTIONS, pas la prose du README) ne l'attrape. On RECOMPUTE ici chaque nombre
|
||
# depuis `criteria` (zéro duplication du barème du générateur · #6) et on exige
|
||
# l'égalité avec ce que la doc ÉCRIT. Cohérences croisées : Σ poids == 100 (base de
|
||
# renormalisation) ET tout module d'archétype `generator` porte EXACTEMENT
|
||
# len(criteria) checks — c.-à-d. « generator = tous les critères » (mord une dérive
|
||
# du modèle qui rendrait le « generator (N critères) » du README faux). Un claim
|
||
# absent échoue AUSSI (disparition = régression de traçabilité).
|
||
# Bloc (6) : la carte de RENORMALISATION PAR ARCHÉTYPE (« data_room sans HANDOFF ·
|
||
# parser sans CONTRAT ni HANDOFF · contract sans CLI ni HANDOFF ») est elle aussi
|
||
# data-derived — critères applicables d'un archétype = {check.criterion} de ses
|
||
# modules ⇒ exclus = barème − applicables. Recomputés par archétype, comparés à la
|
||
# parenthèse du README (exclusions des 3 archétypes non-generator, jamais gatées
|
||
# avant), + uniformité « 1 archétype → 1 jeu ».
|
||
# ============================================================================
|
||
try:
|
||
qr_crit = load("qa/audit_4big/out/quality_report.json")
|
||
criteria = qr_crit["criteria"]
|
||
except (OSError, KeyError, ValueError) as e:
|
||
bad(f"README audit_4big · quality_report illisible : {e}"); criteria = None
|
||
if criteria is not None and a4_txt:
|
||
n_crit = len(criteria)
|
||
# 1) prose « N critères 4Big déterministes »
|
||
mc = re.search(r"sur (\d+) critères 4Big déterministes", a4_txt)
|
||
if mc is None:
|
||
bad(f"README audit_4big · prose « N critères 4Big » — claim INTROUVABLE dans "
|
||
f"{A4} (attendu {n_crit})")
|
||
elif mc.group(1) == str(n_crit):
|
||
good(f"README audit_4big · prose critères — {mc.group(1)} == criteria ({n_crit})")
|
||
else:
|
||
bad(f"README audit_4big · prose critères — {A4} dit {mc.group(1)} MAIS criteria "
|
||
f"en compte {n_crit} → régénérer le README")
|
||
# 2) table « Critère | Poids | … » — une ligne id→poids par critère
|
||
for c in criteria:
|
||
cid, w = c["id"], str(c["weight"])
|
||
mw = re.search(rf"\| `{re.escape(cid)}` \| (\d+) \|", a4_txt)
|
||
if mw is None:
|
||
bad(f"README audit_4big · critère {cid} — ligne INTROUVABLE dans {A4} "
|
||
f"(attendu poids={w})")
|
||
elif mw.group(1) == w:
|
||
good(f"README audit_4big · critère {cid} — poids {mw.group(1)} == criteria ({w})")
|
||
else:
|
||
bad(f"README audit_4big · critère {cid} — {A4} dit poids {mw.group(1)} MAIS "
|
||
f"criteria dit {w} → régénérer le README")
|
||
# 3) archétype « generator (N critères) » — generator applique TOUS les critères
|
||
mg = re.search(r"`generator` \((\d+) critères\)", a4_txt)
|
||
if mg is None:
|
||
bad(f"README audit_4big · archétype « generator (N critères) » — claim "
|
||
f"INTROUVABLE dans {A4} (attendu {n_crit})")
|
||
elif mg.group(1) == str(n_crit):
|
||
good(f"README audit_4big · generator — {mg.group(1)} critères == criteria ({n_crit})")
|
||
else:
|
||
bad(f"README audit_4big · generator — {A4} dit {mg.group(1)} MAIS l'archétype "
|
||
f"generator applique les {n_crit} critères → régénérer le README")
|
||
# 4) cohérence croisée : Σ poids == 100 (base de renormalisation)
|
||
w_sum = sum(c["weight"] for c in criteria)
|
||
if w_sum == 100:
|
||
good(f"README audit_4big · Σ poids critères == {w_sum} (base 100 cohérente)")
|
||
else:
|
||
bad(f"README audit_4big · Σ poids critères == {w_sum} ≠ 100 — barème incohérent "
|
||
f"avec la renormalisation documentée")
|
||
# 5) cohérence croisée : tout module `generator` porte len(criteria) checks
|
||
gen = [m for m in qr_crit["modules"] if m.get("archetype") == "generator"]
|
||
gen_bad = [m["id"] for m in gen if len(m.get("checks", [])) != n_crit]
|
||
if not gen:
|
||
good("README audit_4big · aucun module d'archétype generator (rien à recouper)")
|
||
elif gen_bad:
|
||
bad(f"README audit_4big · modules generator avec ≠ {n_crit} checks : {gen_bad} "
|
||
f"→ « generator = tous les critères » violé")
|
||
else:
|
||
good(f"README audit_4big · {len(gen)} module(s) generator portent {n_crit} "
|
||
f"checks (« generator = tous les critères » vérifié)")
|
||
# 6) carte « archétype → critères EXCLUS » (renormalisation) : le README énumère
|
||
# data_room « sans HANDOFF », parser « sans CONTRAT … ni HANDOFF », contract
|
||
# « sans CLI ni HANDOFF ». Cette carte est DATA-DERIVED : pour un archétype,
|
||
# l'ensemble des critères APPLICABLES = {check.criterion} de ses modules (les
|
||
# non-applicables sont ABSENTS des checks) ⇒ EXCLUS = ordre du barème − appl.
|
||
# Le bloc (3) ne recompute QUE generator (5 critères) ; les exclusions des
|
||
# trois autres archétypes n'étaient recomputées par AUCUN gate. Changer la
|
||
# règle de renormalisation d'un archétype (ex. data_room exclut aussi CLI)
|
||
# ferait dériver cette prose en silence pendant que quality_report dit autre
|
||
# chose. On recompute par archétype + on exige que la parenthèse du README
|
||
# énumère EXACTEMENT cet ensemble. Uniformité en bonus : tous les modules
|
||
# d'un même archétype portent le MÊME ensemble applicable (sinon la carte
|
||
# « 1 archétype → 1 jeu de critères » n'a pas de sens).
|
||
crit_ids = [c["id"] for c in criteria]
|
||
crit_set = set(crit_ids)
|
||
by_arch, uni_bad = {}, []
|
||
for m in qr_crit["modules"]:
|
||
a = m.get("archetype")
|
||
appl = frozenset(ck["criterion"] for ck in m.get("checks", []))
|
||
if a in by_arch and by_arch[a] != appl:
|
||
uni_bad.append(m["id"])
|
||
else:
|
||
by_arch.setdefault(a, appl)
|
||
if uni_bad:
|
||
bad(f"README audit_4big · archétypes NON uniformes (modules aux critères "
|
||
f"applicables divergents) : {uni_bad} → renormalisation incohérente")
|
||
else:
|
||
good(f"README audit_4big · {len(by_arch)} archétype(s) à critères applicables "
|
||
f"uniformes (« 1 archétype → 1 jeu » vérifié)")
|
||
for a in sorted(by_arch):
|
||
if a == "generator":
|
||
continue # traité en (3) : « generator (N critères) », zéro exclusion
|
||
exp = [c for c in crit_ids if c not in by_arch[a]] # ordre du barème, stable
|
||
ma = re.search(rf"`{re.escape(a)}` \(([^)]*)\)", a4_txt)
|
||
if ma is None:
|
||
bad(f"README audit_4big · archétype `{a}` — énumération INTROUVABLE dans "
|
||
f"{A4} (attendu « sans {' ni '.join(exp)} »)")
|
||
continue
|
||
got = set(re.findall(r"[A-Z]{2,}", ma.group(1))) & crit_set
|
||
if got == set(exp):
|
||
good(f"README audit_4big · archétype `{a}` — exclus {sorted(got)} == "
|
||
f"référentiel ({exp})")
|
||
else:
|
||
bad(f"README audit_4big · archétype `{a}` — {A4} exclut {sorted(got)} MAIS "
|
||
f"le référentiel exclut {exp} → régénérer le README")
|
||
|
||
# ============================================================================
|
||
# Fiches agents · comptes de tests PAR SUITE (colonne « Tests » des tables) —
|
||
# même classe de « vert trompeur », au grain le PLUS FIN.
|
||
# ----------------------------------------------------------------------------
|
||
# Chaque fiche agent (03_agents/*/AGENT.md) recense ses livrables dans une table
|
||
# dont la dernière colonne « Tests » affiche le nombre de méthodes `def test_` de
|
||
# la suite. Ce nombre est SAISI À LA MAIN : il se périme dès qu'un test est
|
||
# ajouté/retiré. C'est arrivé (fiche QA : `acceptance` disait 31 alors que la
|
||
# suite en portait 37 · `audit_4big` 35→34 · `regression` 25→26). Les gates
|
||
# amont ne couvraient QUE la SOMME agrégée (« 564 tests ») : une compensation
|
||
# entre deux suites (l'une +1, l'autre −1) laisserait la somme juste et les DEUX
|
||
# lignes fausses. On RECOMPUTE ici chaque cellule depuis la source faisant
|
||
# autorité — plan.suites[path].test_methods pour les suites gated, et
|
||
# reglib.discovery.count_tests (même fonction que le plan · zéro duplication)
|
||
# pour le self_module (qa/regression, exclu de la matrice par SoD mais bien
|
||
# documenté avec son propre compte).
|
||
# ============================================================================
|
||
sys.path.insert(0, f"{D}/qa/regression")
|
||
try:
|
||
from reglib.discovery import count_tests
|
||
except Exception as e: # import cassé = red honnête (source de vérité absente)
|
||
bad(f"Comptes par suite · import reglib.discovery impossible : {e}")
|
||
count_tests = None
|
||
|
||
plan = load("qa/regression/out/regression_plan.json")
|
||
auth = {s["path"]: s["test_methods"] for s in plan["suites"]}
|
||
self_mod = plan["coverage"].get("self_module_excluded")
|
||
if count_tests is not None and self_mod:
|
||
auth[self_mod] = count_tests(f"{D}/{self_mod}/tests")[1]
|
||
|
||
# Ligne de table Tests : lien vers 05_deliverables_mvp/<path>/README.md + dernier
|
||
# champ numérique. On ne vérifie QUE les lignes pointant une suite gatée connue
|
||
# (auth) — toute autre ligne de table est ignorée (pas notre périmètre).
|
||
row_re = re.compile(
|
||
r"^\|\s*\[`\w+/`\]\([^)]*?05_deliverables_mvp/([\w/]+?)/README\.md\)"
|
||
r".*\|\s*(\d+)\s*\|\s*$")
|
||
fiches = subprocess.run(["git", "ls-files", "03_agents/*/AGENT.md"],
|
||
capture_output=True, text=True).stdout.split()
|
||
seen_rows = 0
|
||
for af in fiches:
|
||
try:
|
||
lines = open(af, encoding="utf-8").read().splitlines()
|
||
except OSError as e:
|
||
bad(f"Fiche {af} illisible : {e}"); continue
|
||
for ln, line in enumerate(lines, 1):
|
||
m = row_re.match(line)
|
||
if not m:
|
||
continue
|
||
path = m.group(1)
|
||
if path not in auth: # ligne pointant un non-suite → hors périmètre
|
||
continue
|
||
claimed, exp = int(m.group(2)), auth[path]
|
||
seen_rows += 1
|
||
if claimed == exp:
|
||
good(f"Fiche {af}:{ln} · {path} — Tests {claimed} == source ({exp})")
|
||
else:
|
||
bad(f"Fiche {af}:{ln} · {path} — Tests {claimed} MAIS source dit {exp} "
|
||
f"(plan.suites/count_tests) → régénérer la fiche")
|
||
if seen_rows == 0:
|
||
bad("Comptes par suite · AUCUNE ligne de table « Tests » trouvée dans les "
|
||
"fiches — la disparition du recensement est elle-même une régression.")
|
||
|
||
# ============================================================================
|
||
# Fiches agents · comptes AGRÉGÉS EN PROSE (« Total X : N tests (a + b + c) ») —
|
||
# dernière surface de la MÊME classe, un cran au-dessus des cellules de table.
|
||
# ----------------------------------------------------------------------------
|
||
# Les cellules « Tests » des tables sont recomputées ci-dessus, mais deux fiches
|
||
# rédigent EN PROSE un agrégat multi-suites : `crm` (« Total CRM : 81 tests
|
||
# (25 + 31 + 25) ») et `erpnext_backend` (« RBAC 60 tests (10 + 11 + 12 + 11 + 16)
|
||
# + e-CF 39 tests »). Ces totaux ET leurs composants sont SAISIS À LA MAIN et
|
||
# n'étaient couverts par AUCUN gate : une compensation entre deux suites (l'une
|
||
# +1, l'autre −1) laisserait la table juste et la prose fausse — exactement le
|
||
# motif per-suite corrigé la session précédente. On RECOMPUTE ici le total ET le
|
||
# multiset des composants (ordre-indépendant) depuis `auth` (plan.suites, source
|
||
# faisant autorité déjà chargée), et on exige l'égalité avec ce que la fiche ÉCRIT.
|
||
# ============================================================================
|
||
PROSE = [
|
||
("03_agents/crm/AGENT.md", "Total CRM",
|
||
r"Total CRM : \*\*(\d+) tests\*\* \(([\d + ]+)\)",
|
||
["crm/commissions", "crm/dossier_vente", "crm/workflow_vente"]),
|
||
("03_agents/erpnext_backend/AGENT.md", "RBAC backend",
|
||
r"\*\*RBAC (\d+) tests\*\* \(([\d + ]+)\)",
|
||
["rbac", "rbac/apply_plan", "rbac/fixtures_gen",
|
||
"rbac/roleprofile_gen", "rbac/userperm_gen"]),
|
||
]
|
||
for path_f, label, pat, suites_list in PROSE:
|
||
try:
|
||
txt = re.sub(r"\s+", " ", open(path_f, encoding="utf-8").read())
|
||
except OSError as e:
|
||
bad(f"Prose agrégée · {path_f} illisible : {e}"); continue
|
||
try:
|
||
exp_parts = sorted(auth[s] for s in suites_list)
|
||
except KeyError as e:
|
||
bad(f"Prose agrégée · {label} — suite {e} absente de plan.suites (câblage ?)")
|
||
continue
|
||
exp_total = sum(exp_parts)
|
||
m = re.search(pat, txt)
|
||
if m is None:
|
||
bad(f"Prose agrégée · {path_f} · « {label} » — claim INTROUVABLE "
|
||
f"(attendu total={exp_total} composants={exp_parts})")
|
||
continue
|
||
total = int(m.group(1))
|
||
parts = sorted(int(x) for x in re.findall(r"\d+", m.group(2)))
|
||
if total == exp_total and parts == exp_parts and total == sum(parts):
|
||
good(f"Prose agrégée · {path_f} · {label} — "
|
||
f"{total} ({'+'.join(map(str, parts))}) == source ({exp_total})")
|
||
else:
|
||
bad(f"Prose agrégée · {path_f} · {label} — dit total={total} "
|
||
f"composants={parts} MAIS source dit total={exp_total} "
|
||
f"composants={exp_parts} (interne_ok={total == sum(parts)}) "
|
||
f"→ régénérer la fiche")
|
||
|
||
# e-CF : agrégat en prose à SUITE UNIQUE (pas de composants entre parenthèses).
|
||
be_ecf = re.sub(r"\s+", " ", open("03_agents/erpnext_backend/AGENT.md",
|
||
encoding="utf-8").read())
|
||
ecf_exp = auth["fiscal/ecf_dgii"]
|
||
mecf = re.search(r"\*\*e-CF (\d+) tests\*\*", be_ecf)
|
||
if mecf is None:
|
||
bad(f"Prose agrégée · e-CF — claim INTROUVABLE (attendu {ecf_exp})")
|
||
elif int(mecf.group(1)) == ecf_exp:
|
||
good(f"Prose agrégée · e-CF — {mecf.group(1)} == source ({ecf_exp})")
|
||
else:
|
||
bad(f"Prose agrégée · e-CF — dit {mecf.group(1)} MAIS source dit {ecf_exp} "
|
||
f"(fiscal/ecf_dgii) → régénérer la fiche")
|
||
|
||
# ============================================================================
|
||
# Portails frontend · comptes PAR WORKSPACE (cartes / liens / rôles) —
|
||
# même classe de « vert trompeur », transcrits À LA MAIN dans DEUX docs.
|
||
# ----------------------------------------------------------------------------
|
||
# Le générateur de portails émet out/MANIFEST.json dont le tableau `workspaces`
|
||
# porte, PAR portail, {nb_cartes, nb_liens, nb_roles} recalculés depuis le
|
||
# contrat RBAC à chaque build (byte-gaté par check_artifacts). Ces triplets sont
|
||
# recopiés À LA MAIN à DEUX endroits : la table du README du module
|
||
# (frontend/portails/README.md, colonnes Cartes|Liens|Rôles) ET la prose de la
|
||
# fiche agent (03_agents/frontend_console/AGENT.md — « OTO Ventes 4 cartes/11
|
||
# liens/12 rôles · … »). Aucun gate ne les couvrait : ajouter un rôle/DocType au
|
||
# contrat ferait dériver LES DEUX docs en silence pendant que l'artefact dit
|
||
# autre chose — même piège que les cellules « Tests » et la prose agrégée. On
|
||
# RECOMPUTE ici chaque triplet depuis MANIFEST (déjà byte-gaté · zéro duplication
|
||
# du compteur du générateur) et on exige l'égalité avec les DEUX docs. Un
|
||
# workspace absent d'un doc échoue AUSSI (traçabilité).
|
||
# ============================================================================
|
||
WS_RD = f"{D}/frontend/portails/README.md"
|
||
WS_FI = "03_agents/frontend_console/AGENT.md"
|
||
try:
|
||
ws_list = load("frontend/portails/out/MANIFEST.json")["workspaces"]
|
||
except (OSError, KeyError, ValueError) as e:
|
||
bad(f"Portails · MANIFEST illisible/inattendu : {e}"); ws_list = []
|
||
try:
|
||
ws_rd = re.sub(r"\s+", " ", open(WS_RD, encoding="utf-8").read())
|
||
except OSError as e:
|
||
bad(f"Portails · {WS_RD} illisible : {e}"); ws_rd = ""
|
||
try:
|
||
ws_fi = re.sub(r"\s+", " ", open(WS_FI, encoding="utf-8").read())
|
||
except OSError as e:
|
||
bad(f"Portails · {WS_FI} illisible : {e}"); ws_fi = ""
|
||
|
||
for w in ws_list:
|
||
portail, ws = w["portail"], w["workspace"]
|
||
exp = (str(w["nb_cartes"]), str(w["nb_liens"]), str(w["nb_roles"]))
|
||
src = f"cartes={exp[0]} liens={exp[1]} rôles={exp[2]}"
|
||
# 1) Table du README du module : | <portail> | `<ws>` | C | L | R |
|
||
rd_pat = (rf"\| {re.escape(portail)} \| `{re.escape(ws)}` \| "
|
||
r"(\d+) \| (\d+) \| (\d+) \|")
|
||
mrd = re.search(rd_pat, ws_rd)
|
||
if mrd is None:
|
||
bad(f"Portails README · {ws} — ligne INTROUVABLE dans {WS_RD} (attendu {src})")
|
||
elif mrd.groups() == exp:
|
||
good(f"Portails README · {ws} — {'/'.join(mrd.groups())} == MANIFEST ({src})")
|
||
else:
|
||
bad(f"Portails README · {ws} — {WS_RD} dit {'/'.join(mrd.groups())} MAIS "
|
||
f"MANIFEST dit {'/'.join(exp)} ({src}) → régénérer le README")
|
||
# 2) Prose de la fiche agent : `<ws>` C cartes/L liens/R rôles (ou C/L/R)
|
||
fi_pat = (rf"`{re.escape(ws)}` (\d+)(?: cartes)?/(\d+)(?: liens)?/"
|
||
r"(\d+)(?: rôles)?")
|
||
mfi = re.search(fi_pat, ws_fi)
|
||
if mfi is None:
|
||
bad(f"Portails fiche · {ws} — claim INTROUVABLE dans {WS_FI} (attendu {src})")
|
||
elif mfi.groups() == exp:
|
||
good(f"Portails fiche · {ws} — {'/'.join(mfi.groups())} == MANIFEST ({src})")
|
||
else:
|
||
bad(f"Portails fiche · {ws} — {WS_FI} dit {'/'.join(mfi.groups())} MAIS "
|
||
f"MANIFEST dit {'/'.join(exp)} ({src}) → régénérer la fiche")
|
||
|
||
# Total de workspaces (« N `Workspace` natifs ») cohérent artefact ↔ fiche.
|
||
n_ws = len(ws_list)
|
||
if n_ws:
|
||
mtot = re.search(r"\*\*(\d+) `Workspace` natifs", ws_fi)
|
||
if mtot is None:
|
||
bad(f"Portails fiche · total — « N `Workspace` natifs » INTROUVABLE "
|
||
f"dans {WS_FI} (attendu {n_ws})")
|
||
elif int(mtot.group(1)) == n_ws:
|
||
good(f"Portails fiche · total — {mtot.group(1)} == MANIFEST ({n_ws} workspaces)")
|
||
else:
|
||
bad(f"Portails fiche · total — {WS_FI} dit {mtot.group(1)} MAIS "
|
||
f"MANIFEST dit {n_ws} → régénérer la fiche")
|
||
|
||
# ============================================================================
|
||
# Portails frontend (3e surface) · IDENTITÉ de l'ENSEMBLE des portails métier +
|
||
# exclusion NOMMÉE de la console technique — même fiche, autre « vert trompeur »
|
||
# aveugle au seul COMPTE.
|
||
# ----------------------------------------------------------------------------
|
||
# Le bloc triplets ci-dessus recompute cartes/liens/rôles PAR workspace mais
|
||
# reste aveugle à QUELS portails composent l'ensemble : la fiche agent
|
||
# (03_agents/frontend_console/AGENT.md) énumère À LA MAIN, ligne 11, « 5 portails
|
||
# rôle métier (Ventes / Construction / Achat / Compta / Direction) » et affirme
|
||
# ligne 46 que « la console technique `plateforme` (6ᵉ portail RBAC) est exclue ».
|
||
# DEUX assertions data-derived, aucune gatée :
|
||
# (a) l'ENSEMBLE ORDONNÉ des noms courts = les workspaces métier du frontend
|
||
# (out/MANIFEST.json workspaces · byte-gaté ⇒ faisant autorité · label
|
||
# « OTO <Nom> » sans le préfixe, comme l'énumération des onglets Mobile) ET,
|
||
# DOUBLE ANCRAGE, leurs ids == `portails_business` du contrat RBAC (l'«
|
||
# égalité stricte » que la fiche revendique elle-même en :47) ;
|
||
# (b) `plateforme` EST un vrai portail RBAC (∈ portails distincts des rôles),
|
||
# il est le 6ᵉ (total distinct == 6 = 5 métier + 1) et il est HORS métier
|
||
# (∉ portails_business) — donc légitimement « exclu ».
|
||
# Un ÉCHANGE (Achat→Marketing), un RETRAIT, un RÉORDRE, un RENOMMAGE de la console
|
||
# technique dans le contrat (`plateforme`→`console`) ou un contrat qui PROMEUT
|
||
# `plateforme` en métier ferait dériver la fiche EN SILENCE pendant que les
|
||
# artefacts (frontend workspaces + contrat RBAC) disent autre chose — dérive
|
||
# qu'aucune suite tests/ (FONCTIONS, jamais la prose) n'attrape. Même patron que
|
||
# l'énumération des onglets Mobile ou la colonne « Entité » de la SPEC §4. Zéro
|
||
# duplication : on relit deux artefacts déjà byte-gatés, on ne fige aucune liste.
|
||
# ============================================================================
|
||
try:
|
||
_fc = load("rbac/rbac_50_roles.json")
|
||
_pb = _fc["portails_business"] # ordre du contrat
|
||
_distinct = list(dict.fromkeys(r["portail"] for r in _fc["roles"]))
|
||
except (OSError, KeyError, ValueError, TypeError) as e:
|
||
bad(f"Portails ensemble · contrat rbac_50_roles.json illisible/inattendu : {e}")
|
||
_pb = None; _distinct = None
|
||
|
||
if _pb is not None and ws_list:
|
||
# double ancrage : ids métier du frontend == portails_business du contrat
|
||
_fe_ids = [w["portail"] for w in ws_list]
|
||
if _fe_ids != _pb:
|
||
bad(f"Portails ensemble · ancrage rompu : ids workspaces frontend {_fe_ids} "
|
||
f"≠ contrat portails_business {_pb} → resynchroniser artefact/contrat")
|
||
else:
|
||
good(f"Portails ensemble · double ancrage : ids workspaces frontend == "
|
||
f"portails_business ({'/'.join(_pb)})")
|
||
# (a) noms courts ordonnés = label workspace sans « OTO » (ce que la fiche copie)
|
||
_short = [re.sub(r"^OTO\s+", "", w["workspace"]).strip() for w in ws_list]
|
||
ma = re.search(r"(\d+) portails rôle métier \(([^)]*)\)", ws_fi)
|
||
if ma is None:
|
||
bad(f"Portails ensemble · fiche — « N portails rôle métier (…) » INTROUVABLE "
|
||
f"dans {WS_FI} (attendu {'/'.join(_short)})")
|
||
else:
|
||
got = [s.strip() for s in ma.group(2).split("/") if s.strip()]
|
||
if ma.group(1) != str(len(_short)):
|
||
bad(f"Portails ensemble · fiche — annonce « {ma.group(1)} portails métier » "
|
||
f"MAIS les workspaces frontend en comptent {len(_short)}")
|
||
if got == _short:
|
||
good(f"Portails ensemble · fiche — énumération {'/'.join(got)} == "
|
||
f"workspaces frontend ({len(_short)})")
|
||
else:
|
||
miss = [x for x in _short if x not in got]
|
||
extra = [x for x in got if x not in _short]
|
||
detail = (f" · absents={miss}" if miss else "") + \
|
||
(f" · en trop={extra}" if extra else "") or " · même ensemble, ordre ≠"
|
||
bad(f"Portails ensemble · fiche — énumère {got} MAIS workspaces disent "
|
||
f"{_short}{detail} → régénérer la fiche")
|
||
|
||
if _pb is not None and _distinct is not None:
|
||
# (b) console technique exclue = l'unique portail RBAC HORS métier
|
||
_tech = [p for p in _distinct if p not in _pb]
|
||
if len(_tech) != 1:
|
||
bad(f"Portails ensemble · attendu exactement 1 portail technique hors métier, "
|
||
f"trouvé {_tech} (distinct={_distinct}, métier={_pb}) → contrat incohérent")
|
||
else:
|
||
tech = _tech[0]
|
||
# « console technique `<nom>` (Nᵉ portail RBAC) est **exclue** » — le \S*
|
||
# absorbe l'exposant « ᵉ » (multi-octets) sans le coder en dur ici.
|
||
mt = re.search(r"console technique `([^`]+)` \((\d+)\S* portail RBAC\) "
|
||
r"est \*\*exclue\*\*", ws_fi)
|
||
if mt is None:
|
||
bad(f"Portails ensemble · fiche — claim « console technique `{tech}` "
|
||
f"(Nᵉ portail RBAC) est **exclue** » INTROUVABLE dans {WS_FI}")
|
||
elif mt.group(1) != tech:
|
||
bad(f"Portails ensemble · fiche — nomme la console technique `{mt.group(1)}` "
|
||
f"MAIS le contrat dit `{tech}` (unique portail hors métier) → régénérer")
|
||
elif mt.group(2) != str(len(_distinct)):
|
||
bad(f"Portails ensemble · fiche — dit `{tech}` = {mt.group(2)}ᵉ portail RBAC "
|
||
f"MAIS le contrat compte {len(_distinct)} portails distincts")
|
||
else:
|
||
good(f"Portails ensemble · fiche — console technique `{tech}` = "
|
||
f"{mt.group(2)}ᵉ portail RBAC, hors métier, exclue ✓")
|
||
|
||
# ============================================================================
|
||
# RBAC · comptes des 3 volets (roleprofile · apply_plan · userperm) —
|
||
# même classe de « vert trompeur », recopiés À LA MAIN depuis les MANIFEST.
|
||
# ----------------------------------------------------------------------------
|
||
# Les trois générateurs RBAC émettent, PAR volet, un out/MANIFEST.json recalculé
|
||
# à chaque build depuis le contrat `rbac_50_roles.json` (byte-gaté par
|
||
# check_artifacts ⇒ source faisant autorité). Trois READMEs de module rédigent à
|
||
# LA MAIN des comptes qui dérivent de ces manifestes, sans AUCUN gate :
|
||
# 1) roleprofile_gen/README.md — table « Nb rôles » PAR profil (6 lignes) +
|
||
# total. Grain le plus fin (par item), comme la colonne « Tests » des fiches.
|
||
# 2) apply_plan/README.md — ligne AGRÉGÉE « Génération réelle : 6 étapes · 50
|
||
# rôles / 116 DocPerm / 28 UP templates / 6 Role Profile · confirmations VPS
|
||
# 4 DocType custom + 5 Company + 4 rôles equipe » : 8 comptes consolidés de
|
||
# TOUS les volets en une phrase.
|
||
# 3) userperm_gen/README.md — « Répartition : 28 entite · 16 groupe · 2 own ·
|
||
# 4 equipe » : la ventilation par mécanisme d'enforcement.
|
||
# Ajouter un rôle/DocType au contrat ferait dériver LES TROIS docs en silence
|
||
# pendant que les MANIFEST (byte-gatés) disent autre chose — même piège que les
|
||
# cellules « Tests », la prose agrégée CRM/RBAC et les triplets par workspace. On
|
||
# RECOMPUTE ici chaque nombre depuis le MANIFEST du volet (zéro duplication des
|
||
# compteurs des générateurs) et on exige l'égalité avec ce que la doc ÉCRIT. Un
|
||
# claim absent échoue AUSSI (disparition = régression de traçabilité).
|
||
# ============================================================================
|
||
def read_norm(path):
|
||
try:
|
||
return re.sub(r"\s+", " ", open(path, encoding="utf-8").read())
|
||
except OSError as e:
|
||
bad(f"RBAC · {path} illisible : {e}"); return None
|
||
|
||
# 1) roleprofile_gen — table « Nb rôles » par profil + total bijectif -----------
|
||
RP_RD = f"{D}/rbac/roleprofile_gen/README.md"
|
||
try:
|
||
rp_man = load("rbac/roleprofile_gen/out/MANIFEST.json")
|
||
except (OSError, KeyError, ValueError) as e:
|
||
bad(f"RBAC roleprofile · MANIFEST illisible : {e}"); rp_man = None
|
||
rp_rd = read_norm(RP_RD)
|
||
if rp_man is not None and rp_rd is not None:
|
||
for p in rp_man["profiles"]:
|
||
name, portail, exp = p["role_profile"], p["portail"], str(p["nb_roles"])
|
||
# métier vs technique : le flag DATA-DERIVED (booléen `metier` du MANIFEST)
|
||
# doit être transcrit EXACTEMENT dans la colonne « Type ». Sans ce gate la
|
||
# colonne était un WILDCARD (`[^|]*`) → un portail technique classé « métier »
|
||
# (ou l'inverse) passait « vert » alors que l'artefact dit autre chose.
|
||
exp_type = "métier" if p["metier"] else "technique"
|
||
pat = (rf"\| `{re.escape(name)}` \| {re.escape(portail)} \| "
|
||
r"([^|]+?) \| (\d+) \|")
|
||
m = re.search(pat, rp_rd)
|
||
if m is None:
|
||
bad(f"RBAC roleprofile · {name} — ligne INTROUVABLE dans {RP_RD} "
|
||
f"(attendu type={exp_type} nb_roles={exp})")
|
||
else:
|
||
got_type, got_n = m.group(1).strip(), m.group(2)
|
||
if got_n == exp:
|
||
good(f"RBAC roleprofile · {name} — Nb rôles {got_n} == MANIFEST ({exp})")
|
||
else:
|
||
bad(f"RBAC roleprofile · {name} — {RP_RD} dit {got_n} MAIS "
|
||
f"MANIFEST dit {exp} → régénérer le README")
|
||
if got_type == exp_type:
|
||
good(f"RBAC roleprofile · {name} — Type « {got_type} » == MANIFEST "
|
||
f"(metier={p['metier']})")
|
||
else:
|
||
bad(f"RBAC roleprofile · {name} — {RP_RD} classe « {got_type} » MAIS "
|
||
f"MANIFEST dit « {exp_type} » (metier={p['metier']}) → régénérer")
|
||
exp_tot = str(rp_man["counts"]["roles_couverts"])
|
||
mt = re.search(r"\*\*Total\*\* \| \*\*(\d+)\*\*", rp_rd)
|
||
if mt is None:
|
||
bad(f"RBAC roleprofile · total — ligne « Total » INTROUVABLE dans {RP_RD} "
|
||
f"(attendu {exp_tot})")
|
||
elif mt.group(1) == exp_tot:
|
||
good(f"RBAC roleprofile · total — {mt.group(1)} == MANIFEST ({exp_tot} couverts)")
|
||
else:
|
||
bad(f"RBAC roleprofile · total — {RP_RD} dit {mt.group(1)} MAIS MANIFEST "
|
||
f"dit {exp_tot} → régénérer le README")
|
||
# 1bis) IDENTITÉ de l'ensemble des profils (pas seulement leurs comptes) --------
|
||
# La boucle ci-dessus est MANIFEST→README (chaque profil du contrat a une ligne :
|
||
# capte le MANQUANT). Le sens README→MANIFEST (une ligne FANTÔME dans la table,
|
||
# profil inventé absent du contrat) reste aveugle → on compte les lignes de profil
|
||
# de la table et on exige == |profiles|. Ensemble ∩ boucle = identité d'ensemble.
|
||
exp_prof = len(rp_man["profiles"])
|
||
rows = re.findall(r"\| `OTO Portail [^|`]+` \| [^|]+ \| (?:métier|technique) \| \d+ \|",
|
||
rp_rd)
|
||
if len(rows) == exp_prof:
|
||
good(f"RBAC roleprofile · table — {len(rows)} lignes de profil == "
|
||
f"MANIFEST ({exp_prof}) : aucun profil FANTÔME")
|
||
else:
|
||
bad(f"RBAC roleprofile · table — {RP_RD} porte {len(rows)} lignes de profil "
|
||
f"MAIS MANIFEST en compte {exp_prof} → ligne en trop/manquante ?")
|
||
# 1ter) prose « N profils (X métier + Y technique) » == comptes MANIFEST --------
|
||
# + cohérences croisées : les comptes portails_metier/techniques doivent RECOMPTER
|
||
# les flags `metier` des profils, et leur somme == role_profiles == |profiles|
|
||
# (mord un MANIFEST INTERNEMENT incohérent : compte de flag ≠ flags réels).
|
||
c = rp_man["counts"]
|
||
cm, ct, cp = c["portails_metier"], c["portails_techniques"], c["role_profiles"]
|
||
real_m = sum(1 for p in rp_man["profiles"] if p["metier"])
|
||
real_t = sum(1 for p in rp_man["profiles"] if not p["metier"])
|
||
if cm == real_m and ct == real_t and cm + ct == cp == exp_prof:
|
||
good(f"RBAC roleprofile · cohérence comptes — portails_metier={cm} + "
|
||
f"techniques={ct} == role_profiles={cp} == |flags métier réels|")
|
||
else:
|
||
bad(f"RBAC roleprofile · cohérence comptes — MANIFEST incohérent : "
|
||
f"portails_metier={cm} (flags réels={real_m}) · techniques={ct} "
|
||
f"(réels={real_t}) · role_profiles={cp} · |profiles|={exp_prof}")
|
||
mp = re.search(r"\*\*(\d+) profils\*\* \((\d+) métier \+ (\d+) technique\)", rp_rd)
|
||
if mp is None:
|
||
bad(f"RBAC roleprofile · split métier/technique — prose « N profils "
|
||
f"(X métier + Y technique) » INTROUVABLE dans {RP_RD} "
|
||
f"(attendu {cp} profils · {cm} métier + {ct} technique)")
|
||
elif (mp.group(1), mp.group(2), mp.group(3)) == (str(cp), str(cm), str(ct)):
|
||
good(f"RBAC roleprofile · split métier/technique — {mp.group(1)} profils "
|
||
f"({mp.group(2)} métier + {mp.group(3)} technique) == MANIFEST")
|
||
else:
|
||
bad(f"RBAC roleprofile · split métier/technique — {RP_RD} dit "
|
||
f"{mp.group(1)}/{mp.group(2)}/{mp.group(3)} MAIS MANIFEST dit "
|
||
f"{cp}/{cm}/{ct} → régénérer le README")
|
||
|
||
# 1quater) RBAC_50_ROLES_SPEC.md §3 — table « Nb rôles » PAR portail ------------
|
||
# La table §3 « Cartographie portails ↔ familles » transcrit À LA MAIN le nombre de
|
||
# rôles de CHAQUE portail. C'est une ventilation par portail dans un doc DISTINCT
|
||
# du MANIFEST roleprofile (gaté ci-dessus) — jamais gaté, et il AVAIT dérivé en
|
||
# silence : `direction` 10 / `plateforme` 5 alors que le contrat compte 9 / 6, le
|
||
# total 50 restant préservé (dérive muette classique, #6). On RECOMPTE ici chaque
|
||
# ligne depuis rbac_50_roles.json (source de vérité, byte-gatée par check_artifacts)
|
||
# et on exige l'égalité avec ce que la SPEC écrit, total compris. Un portail manquant
|
||
# de la table échoue aussi (disparition = régression de traçabilité).
|
||
SPEC_RD = f"{D}/rbac/RBAC_50_ROLES_SPEC.md"
|
||
try:
|
||
src_roles = load("rbac/rbac_50_roles.json")["roles"]
|
||
except (OSError, KeyError, ValueError) as e:
|
||
bad(f"RBAC SPEC §3 · rbac_50_roles.json illisible : {e}"); src_roles = None
|
||
spec_rd = read_norm(SPEC_RD)
|
||
if src_roles is not None and spec_rd is not None:
|
||
by_portail = {}
|
||
for r in src_roles:
|
||
by_portail[r.get("portail")] = by_portail.get(r.get("portail"), 0) + 1
|
||
for portail in sorted(by_portail):
|
||
exp = str(by_portail[portail])
|
||
pat = rf"\| \*\*{re.escape(portail)}\*\*[^|]*\| [^|]*\| (\d+) \|"
|
||
m = re.search(pat, spec_rd)
|
||
if m is None:
|
||
bad(f"RBAC SPEC §3 · portail `{portail}` — ligne INTROUVABLE dans "
|
||
f"{SPEC_RD} (attendu Nb rôles={exp})")
|
||
elif m.group(1) == exp:
|
||
good(f"RBAC SPEC §3 · portail `{portail}` — Nb rôles {m.group(1)} == "
|
||
f"rbac_50_roles.json ({exp})")
|
||
else:
|
||
bad(f"RBAC SPEC §3 · portail `{portail}` — {SPEC_RD} dit {m.group(1)} "
|
||
f"MAIS rbac_50_roles.json compte {exp} → corriger la SPEC")
|
||
exp_tot = str(len(src_roles))
|
||
mt = re.search(r"\*\*Total\*\* \| \*\*(\d+)\*\*", spec_rd)
|
||
if mt is None:
|
||
bad(f"RBAC SPEC §3 · total — ligne « Total » INTROUVABLE dans {SPEC_RD} "
|
||
f"(attendu {exp_tot})")
|
||
elif mt.group(1) == exp_tot:
|
||
good(f"RBAC SPEC §3 · total — {mt.group(1)} == rbac_50_roles.json ({exp_tot})")
|
||
else:
|
||
bad(f"RBAC SPEC §3 · total — {SPEC_RD} dit {mt.group(1)} MAIS "
|
||
f"rbac_50_roles.json compte {exp_tot} → corriger la SPEC")
|
||
|
||
# 1quinquies) RBAC_50_ROLES_SPEC.md §7 — comptes du run-book d'application --------
|
||
# La NOTE §7 (« ✅ … livré en-repo ») restitue, sur une SURFACE DISTINCTE des READMEs
|
||
# userperm_gen / roleprofile_gen (déjà gatés), TROIS comptes data-derived du run-book
|
||
# d'application : « 28 templates `Company` (portée `entite`) », « 6 profils (5 portails
|
||
# métier + console technique `plateforme`) » et « 50 rôles de façon bijective ». Ces
|
||
# nombres sont recopiés À LA MAIN dans la SPEC ; le bloc §3 ne gate QUE la ventilation
|
||
# par portail, et les gates userperm/roleprofile ne regardent QUE leurs propres READMEs
|
||
# — AVEUGLES à cette note. BUMPER une portée `entite`→`groupe` dans le contrat (le
|
||
# nombre de `User Permission` templates change), AJOUTER/RETIRER un portail (le nombre
|
||
# de `Role Profile` change) ou casser la bijection laisse la note PÉRIMÉE en silence
|
||
# pendant que le contrat dit autre chose → l'agent ERPNext Backend lirait un run-book
|
||
# qui promet un décompte faux. On RECOMPUTE les 3 comptes depuis rbac_50_roles.json
|
||
# (source de vérité, byte-gatée par check_artifacts) : 28 == |scope==entite|, 6 ==
|
||
# |portails distincts| (= 1 Role Profile par portail), 5 == portails métier (hors
|
||
# `plateforme`, la console technique), 50 == |rôles|. Un claim absent échoue AUSSI.
|
||
if src_roles is not None and spec_rd is not None:
|
||
# La note §7 vit dans un blockquote (`> `) : les marqueurs survivent au collapse
|
||
# de read_norm et fragmentent la prose (« 28 > templates »). On les retire pour
|
||
# ce bloc (aucun `> ` littéral hors blockquote dans cette SPEC ; les flèches sont
|
||
# des `→`). Le §3 (tables sans blockquote) n'en a pas besoin.
|
||
spec7 = spec_rd.replace("> ", "")
|
||
up_entite = sum(1 for r in src_roles if r.get("scope_donnees") == "entite")
|
||
portails = {r.get("portail") for r in src_roles}
|
||
metier = portails - {"plateforme"}
|
||
if "plateforme" not in portails:
|
||
bad("RBAC SPEC §7 · portail technique `plateforme` ABSENT du contrat "
|
||
"rbac_50_roles.json → la note « console technique plateforme » est fausse")
|
||
exp_up = str(up_entite)
|
||
m_up = re.search(r"(\d+) templates `Company` \(portée `entite`\)", spec7)
|
||
if m_up is None:
|
||
bad(f"RBAC SPEC §7 · « N templates Company (portée entite) » INTROUVABLE "
|
||
f"dans {SPEC_RD} (attendu {exp_up})")
|
||
elif m_up.group(1) == exp_up:
|
||
good(f"RBAC SPEC §7 · UP templates {m_up.group(1)} == |scope==entite| "
|
||
f"rbac_50_roles.json ({exp_up})")
|
||
else:
|
||
bad(f"RBAC SPEC §7 · {SPEC_RD} dit {m_up.group(1)} templates Company MAIS "
|
||
f"rbac_50_roles.json compte {exp_up} rôles `entite` → corriger la SPEC")
|
||
exp_prof, exp_metier, exp_bij = str(len(portails)), str(len(metier)), str(len(src_roles))
|
||
m_pr = re.search(
|
||
r"\*\*(\d+) profils\*\* \((\d+) portails métier \+ console technique "
|
||
r"`plateforme`\) couvrant les \*\*(\d+) rôles de façon bijective\*\*", spec7)
|
||
if m_pr is None:
|
||
bad(f"RBAC SPEC §7 · « N profils (M portails métier …) couvrant les K rôles "
|
||
f"de façon bijective » INTROUVABLE dans {SPEC_RD} "
|
||
f"(attendu {exp_prof}/{exp_metier}/{exp_bij})")
|
||
elif m_pr.groups() == (exp_prof, exp_metier, exp_bij):
|
||
good(f"RBAC SPEC §7 · Role Profiles {m_pr.group(1)} (métier {m_pr.group(2)}) "
|
||
f"bijectifs sur {m_pr.group(3)} rôles == rbac_50_roles.json "
|
||
f"({exp_prof}/{exp_metier}/{exp_bij})")
|
||
else:
|
||
bad(f"RBAC SPEC §7 · {SPEC_RD} dit {'/'.join(m_pr.groups())} MAIS "
|
||
f"rbac_50_roles.json compte {exp_prof}/{exp_metier}/{exp_bij} "
|
||
f"(profils=portails distincts / métier=hors plateforme / rôles) "
|
||
f"→ corriger la SPEC")
|
||
|
||
# 1sexies) RBAC_50_ROLES_SPEC.md §4 — colonne « Entité » ancrée sur CLAUDE.md §Entités
|
||
# La table §4 « Cartographie rôles ↔ entités » liste, en colonne de tête, les entités
|
||
# porteuses (`entite_principale`) du RBAC : les 7 entités NOMMÉES du mandat (WAF · WA
|
||
# SRL · AC Arias Cuevas · Consortium ECR DR · Helios RD · Ploutos · 9060 QC) + le
|
||
# périmètre consolidé « Groupe ». Cette colonne a DEUX sources faisant autorité — (i)
|
||
# CLAUDE.md §Entités (l'orthographe + le descripteur parenthétique de chaque entité
|
||
# nommée) et (ii) rbac_50_roles.json (l'ENSEMBLE des `entite_principale` réellement
|
||
# portées par les 50 rôles) — mais AUCUN gate ne la touchait : le §3 gate les comptes
|
||
# par PORTAIL, le §7 les comptes du run-book, les blocs fiches l'`entite` d'UN rôle ;
|
||
# tous AVEUGLES à l'identité de CETTE liste. AJOUTER au contrat un rôle porté par une
|
||
# entité absente de §4, RENOMMER une entité dans CLAUDE.md §Entités (9060 QC→9061 QC)
|
||
# ou DÉRIVER un descripteur laisse la SPEC contredire le mandat/le contrat EN SILENCE
|
||
# — « vert trompeur » de la MÊME classe que les entités CONFOTUR (§Entités) ou la
|
||
# marque SEO org, qu'aucune suite tests/ (FONCTIONS RBAC, jamais la prose de la SPEC)
|
||
# n'attrape.
|
||
# GATE : (a) ENSEMBLE des entités de §4 == distinct `entite_principale` du contrat
|
||
# (set-diff : ni fantôme ni manquante) ; (b) ANCRAGE — chaque entité NOMMÉE de
|
||
# CLAUDE.md §Entités est une ligne de §4 avec son descripteur parenthétique EXACT
|
||
# (absence comprise) ; (c) chaque entité nommée du mandat est PORTÉE ≥1× par le
|
||
# contrat (cm_ents ⊆ contrat). Un claim absent échoue AUSSI (traçabilité #6).
|
||
if src_roles is not None and spec_rd is not None:
|
||
try:
|
||
_cm4 = open("CLAUDE.md", encoding="utf-8").read()
|
||
_sec4 = re.search(r"^## Entités\s*\n(.*?)(?=^## )", _cm4, re.S | re.M)
|
||
if not _sec4:
|
||
raise ValueError("section « ## Entités » introuvable")
|
||
# (entité → descripteur parenthétique | None), dans l'ordre du mandat
|
||
cm4 = [(m.group(1).strip(), (m.group(2).strip() if m.group(2) else None))
|
||
for m in re.finditer(r"\*\*([^*]+?)\*\*(?:\s*\(([^)]+)\))?", _sec4.group(1))]
|
||
if not cm4:
|
||
raise ValueError("aucune entité en gras dans §Entités")
|
||
if len({e for e, _ in cm4}) != len(cm4):
|
||
raise ValueError(f"entités non uniques : {[e for e, _ in cm4]}")
|
||
except (OSError, ValueError) as e:
|
||
bad(f"RBAC SPEC §4 · CLAUDE.md §Entités illisible/inattendu : {e}"); cm4 = None
|
||
_m4 = re.search(r"## 4\..*?(?=## 5\.)", spec_rd)
|
||
if _m4 is None:
|
||
bad(f"RBAC SPEC §4 · section « ## 4. » INTROUVABLE dans {SPEC_RD} "
|
||
f"(claim absent = régression #6)"); sec4 = None
|
||
else:
|
||
sec4 = _m4.group(0)
|
||
if cm4 is not None and sec4 is not None:
|
||
# Colonne de tête : « | **Entité** (desc)? | » (le header non-gras et le
|
||
# séparateur ne matchent pas ; la colonne « Exemples de rôles » n'a pas de gras)
|
||
spec4 = [(m.group(1).strip(), (m.group(2).strip() if m.group(2) else None))
|
||
for m in re.finditer(r"\| \*\*([^*|]+?)\*\*(?: \(([^)|]+)\))? \|", sec4)]
|
||
spec4_names = {n for n, _ in spec4}
|
||
contract_ents = {r.get("entite_principale") for r in src_roles}
|
||
# (a) ENSEMBLE §4 == distinct entite_principale du contrat
|
||
_miss = sorted(contract_ents - spec4_names) # portées mais absentes de §4
|
||
_phan = sorted(spec4_names - contract_ents) # listées mais jamais portées
|
||
if _miss or _phan:
|
||
bad(f"RBAC SPEC §4 · (a) colonne Entité ≠ distinct `entite_principale` du "
|
||
f"contrat — manquantes={_miss} · fantômes={_phan} → corriger la SPEC "
|
||
f"(ou régénérer si le contrat a changé)")
|
||
else:
|
||
good(f"RBAC SPEC §4 · (a) {len(spec4_names)} entités == distinct "
|
||
f"`entite_principale` rbac_50_roles.json (ni fantôme ni manquante)")
|
||
# (b) ANCRAGE — chaque entité NOMMÉE de CLAUDE.md est une ligne §4 · descripteur exact
|
||
spec4_map = dict(spec4)
|
||
for _e, _d in cm4:
|
||
if _e not in spec4_map:
|
||
bad(f"RBAC SPEC §4 · (b) entité canonique `{_e}` (CLAUDE.md §Entités) "
|
||
f"ABSENTE de la colonne §4 → SPEC périmée")
|
||
elif spec4_map[_e] != _d:
|
||
bad(f"RBAC SPEC §4 · (b) `{_e}` — descripteur §4 {spec4_map[_e]!r} ≠ "
|
||
f"CLAUDE.md §Entités {_d!r} → aligner sur le mandat")
|
||
else:
|
||
good(f"RBAC SPEC §4 · (b) `{_e}` ({_d or 'sans descripteur'}) == "
|
||
f"CLAUDE.md §Entités")
|
||
# (c) chaque entité nommée du mandat est PORTÉE ≥1× par le contrat
|
||
_unused = sorted(e for e, _ in cm4 if e not in contract_ents)
|
||
if _unused:
|
||
bad(f"RBAC SPEC §4 · (c) entités du mandat JAMAIS portées par un rôle : "
|
||
f"{_unused} → entité décorative (contrat ou §Entités à revoir)")
|
||
else:
|
||
good(f"RBAC SPEC §4 · (c) les {len(cm4)} entités nommées du mandat sont "
|
||
f"portées ≥1× par le contrat")
|
||
|
||
# 2) apply_plan — ligne « Génération réelle » agrégeant les 3 volets ------------
|
||
AP_RD = f"{D}/rbac/apply_plan/README.md"
|
||
try:
|
||
ap_man = load("rbac/apply_plan/out/MANIFEST.json")
|
||
ap_steps = len(load("rbac/apply_plan/out/apply_plan.json"))
|
||
except (OSError, KeyError, ValueError) as e:
|
||
bad(f"RBAC apply_plan · artefact illisible : {e}"); ap_man = None
|
||
ap_rd = read_norm(AP_RD)
|
||
if ap_man is not None and ap_rd is not None:
|
||
c, v = ap_man["counts"], ap_man["confirmations_vps"]
|
||
ap_exp = (str(ap_steps), str(c["roles"]), str(c["custom_docperm"]),
|
||
str(c["user_permission_templates"]), str(c["role_profiles"]),
|
||
str(len(v["custom_doctypes"])), str(len(v["companies"])),
|
||
str(len(v["roles_scope_equipe"])))
|
||
ap_src = (f"étapes={ap_exp[0]} rôles={ap_exp[1]} docperm={ap_exp[2]} "
|
||
f"up={ap_exp[3]} role_profiles={ap_exp[4]} custom_dt={ap_exp[5]} "
|
||
f"companies={ap_exp[6]} equipe={ap_exp[7]}")
|
||
ap_pat = (r"\*\*(\d+) étapes\*\* · (\d+) rôles / (\d+) DocPerm / "
|
||
r"(\d+) UP templates / (\d+) Role Profile · confirmations VPS "
|
||
r"(\d+) DocType custom \+ (\d+) Company \+ (\d+) rôles")
|
||
ma = re.search(ap_pat, ap_rd)
|
||
if ma is None:
|
||
bad(f"RBAC apply_plan · « Génération réelle » — claim INTROUVABLE dans "
|
||
f"{AP_RD} (attendu {ap_src})")
|
||
elif ma.groups() == ap_exp:
|
||
good(f"RBAC apply_plan · agrégat — {AP_RD} {'/'.join(ma.groups())} == "
|
||
f"MANIFEST ({ap_src})")
|
||
else:
|
||
bad(f"RBAC apply_plan · {AP_RD} dit {'/'.join(ma.groups())} MAIS MANIFEST "
|
||
f"dit {'/'.join(ap_exp)} ({ap_src}) → régénérer le README")
|
||
|
||
# 2bis) apply_plan — IDENTITÉ de la TABLE « Run-book généré » (SPEC §7) ----------
|
||
# Le bloc 2 ci-dessus ne gate que la ligne AGRÉGÉE « Génération réelle » (comptes).
|
||
# La table « ## Run-book généré » énumère, PAR étape, trois données data-derived de
|
||
# apply_plan.json (byte-gaté par check_artifacts) : le n° d'ordre (#), le
|
||
# RESPONSABLE (worker/vps/worker+vps) et la colonne « Dépend de » (les n°s d'ordre
|
||
# des `depends_on`). C'est le GRAPHE D'APPLICATION ORDONNÉ que l'agent ERPNext suit
|
||
# pas à pas — un responsable ou une dépendance périmés (ex. Role Profile importé
|
||
# AVANT les Role, ou une étape VPS attribuée au worker) sont un hazard réel,
|
||
# invisible à check_artifacts (qui ne prouve QUE apply_plan==build) ET au bloc de
|
||
# comptes (aveugle à l'identité des lignes ; ces colonnes étaient un WILDCARD).
|
||
# Même classe que la colonne « Type » de roleprofile_gen et la table demo/scenarios.
|
||
# On RECOMPUTE #/responsable/deps depuis apply_plan.json (zéro duplication) et on
|
||
# exige que la table les énonce EXACTEMENT. Une étape MANQUANTE ou une ligne FANTÔME
|
||
# échoue AUSSI (identité d'ensemble). La colonne « Étape » reste libre (paraphrase).
|
||
# Cohérences croisées en bonus (mordent un plan INTERNEMENT incohérent) : ordres
|
||
# contigus 1..N sans doublon · responsable ∈ {worker, vps, worker+vps} · toute
|
||
# dépendance pointe en ARRIÈRE (n° < n° de l'étape ⇒ pas de cycle/renvoi avant).
|
||
try:
|
||
ap_plan = load("rbac/apply_plan/out/apply_plan.json")
|
||
except (OSError, KeyError, ValueError) as e:
|
||
bad(f"RBAC apply_plan · run-book illisible : {e}"); ap_plan = None
|
||
if ap_plan is not None:
|
||
id2order = {s["id"]: s["order"] for s in ap_plan}
|
||
ALLOWED_RESP = {"worker", "vps", "worker+vps"}
|
||
orders = sorted(s["order"] for s in ap_plan)
|
||
if orders == list(range(1, len(ap_plan) + 1)):
|
||
good(f"RBAC apply_plan · run-book — ordres contigus 1..{len(ap_plan)} "
|
||
f"(aucun trou/doublon)")
|
||
else:
|
||
bad(f"RBAC apply_plan · run-book — ordres NON contigus {orders} "
|
||
f"(attendu 1..{len(ap_plan)}) → plan incohérent")
|
||
# Lignes de la table lues en RAW : la colonne 1 ENTIÈRE ⇒ scope la table
|
||
# run-book (seule table du README à 1ʳᵉ colonne numérique · vérifié).
|
||
try:
|
||
ap_lines = open(AP_RD, encoding="utf-8").read().splitlines()
|
||
except OSError as e:
|
||
bad(f"RBAC apply_plan · {AP_RD} illisible : {e}"); ap_lines = []
|
||
table_orders, rows_by_order = [], {}
|
||
for ln in ap_lines:
|
||
cells = [c.strip() for c in ln.strip().strip("|").split("|")]
|
||
if len(cells) == 4 and re.fullmatch(r"\d+", cells[0]):
|
||
o = int(cells[0]); table_orders.append(o); rows_by_order[o] = cells
|
||
if sorted(table_orders) == orders and len(table_orders) == len(set(table_orders)):
|
||
good(f"RBAC apply_plan · run-book — {len(table_orders)} lignes == "
|
||
f"{len(ap_plan)} étapes (aucune FANTÔME/MANQUANTE)")
|
||
else:
|
||
bad(f"RBAC apply_plan · run-book — lignes table {sorted(table_orders)} ≠ "
|
||
f"étapes artefact {orders} (ligne en trop/manquante ou doublon)")
|
||
for s in sorted(ap_plan, key=lambda x: x["order"]):
|
||
o, resp = s["order"], s["responsable"]
|
||
deps = sorted(id2order[x] for x in s["depends_on"])
|
||
if resp not in ALLOWED_RESP:
|
||
bad(f"RBAC apply_plan · étape {o} — responsable artefact « {resp} » "
|
||
f"hors {sorted(ALLOWED_RESP)} (plan incohérent)")
|
||
if any(d >= o for d in deps):
|
||
bad(f"RBAC apply_plan · étape {o} — dépendance en AVANT {deps} "
|
||
f"(≥ {o}) : graphe incohérent")
|
||
row = rows_by_order.get(o)
|
||
if row is None:
|
||
bad(f"RBAC apply_plan · étape {o} — ligne INTROUVABLE dans {AP_RD} "
|
||
f"(attendu responsable={resp} deps={deps or '—'})"); continue
|
||
got_resp = row[1]
|
||
got_deps = sorted(int(x) for x in re.findall(r"\d+", row[3]))
|
||
if got_resp == resp:
|
||
good(f"RBAC apply_plan · étape {o} — responsable « {got_resp} » == artefact")
|
||
else:
|
||
bad(f"RBAC apply_plan · étape {o} — {AP_RD} dit responsable « {got_resp} » "
|
||
f"MAIS artefact dit « {resp} » → régénérer le README")
|
||
if got_deps == deps:
|
||
good(f"RBAC apply_plan · étape {o} — Dépend de {got_deps or '—'} == artefact")
|
||
else:
|
||
bad(f"RBAC apply_plan · étape {o} — {AP_RD} « Dépend de » = {got_deps or '—'} "
|
||
f"MAIS artefact dit {deps or '—'} → régénérer le README")
|
||
|
||
# 3) userperm — ventilation par mécanisme d'enforcement -------------------------
|
||
UP_RD = f"{D}/rbac/userperm_gen/README.md"
|
||
try:
|
||
up_bm = load("rbac/userperm_gen/out/MANIFEST.json")["counts"]["by_mechanism"]
|
||
except (OSError, KeyError, ValueError) as e:
|
||
bad(f"RBAC userperm · MANIFEST illisible : {e}"); up_bm = None
|
||
up_rd = read_norm(UP_RD)
|
||
if up_bm is not None and up_rd is not None:
|
||
up_exp = (str(up_bm["user_permission_company"]), str(up_bm["none_consolidated"]),
|
||
str(up_bm["docperm_if_owner"]), str(up_bm["vps_confirm_team"]))
|
||
up_src = (f"entite={up_exp[0]} groupe={up_exp[1]} own={up_exp[2]} "
|
||
f"equipe={up_exp[3]}")
|
||
up_pat = (r"\*\*(\d+)\*\* `entite` · \*\*(\d+)\*\* `groupe` · "
|
||
r"\*\*(\d+)\*\* `own` · \*\*(\d+)\*\* `equipe`")
|
||
mu = re.search(up_pat, up_rd)
|
||
if mu is None:
|
||
bad(f"RBAC userperm · répartition — claim INTROUVABLE dans {UP_RD} "
|
||
f"(attendu {up_src})")
|
||
elif mu.groups() == up_exp:
|
||
good(f"RBAC userperm · répartition — {UP_RD} {'/'.join(mu.groups())} == "
|
||
f"MANIFEST ({up_src})")
|
||
else:
|
||
bad(f"RBAC userperm · {UP_RD} dit {'/'.join(mu.groups())} MAIS MANIFEST "
|
||
f"dit {'/'.join(up_exp)} ({up_src}) → régénérer le README")
|
||
|
||
# ============================================================================
|
||
# SEO · comptes de mots-clés (total + ventilation par langue) —
|
||
# même classe de « vert trompeur », transcrits À LA MAIN dans DEUX docs.
|
||
# ----------------------------------------------------------------------------
|
||
# Le générateur SEO émet out/MANIFEST.json dont le bloc `counts` porte
|
||
# keywords_total et keywords_per_lang{fr,en,es}, RECOMPTÉS à chaque build depuis
|
||
# `projets_master.json` (byte-gaté par check_artifacts ⇒ source faisant autorité).
|
||
# Ce quadruplet est recopié À LA MAIN à DEUX endroits : la table « Livrables » du
|
||
# README du module (05_deliverables_mvp/seo/README.md — « **258** mots-clés
|
||
# (fr=87 · en=87 · es=84) ») ET la même ligne de la fiche agent
|
||
# (03_agents/seo/AGENT.md). Aucun gate ne les couvrait : ajouter un projet ou du
|
||
# vocabulaire éditorial ferait dériver LES DEUX docs en silence pendant que
|
||
# l'artefact dit autre chose — même piège que les triplets par workspace des
|
||
# portails. On RECOMPUTE ici (total + 3 langues) depuis MANIFEST.counts (déjà
|
||
# byte-gaté · zéro duplication du compteur du générateur) et on exige l'égalité
|
||
# avec les DEUX docs. Un claim absent échoue AUSSI (traçabilité).
|
||
# ============================================================================
|
||
SEO_RD = f"{D}/seo/README.md"
|
||
SEO_FI = "03_agents/seo/AGENT.md"
|
||
try:
|
||
seo_c = load("seo/out/MANIFEST.json")["counts"]
|
||
seo_exp = (str(seo_c["keywords_total"]),
|
||
str(seo_c["keywords_per_lang"]["fr"]),
|
||
str(seo_c["keywords_per_lang"]["en"]),
|
||
str(seo_c["keywords_per_lang"]["es"]))
|
||
except (OSError, KeyError, ValueError) as e:
|
||
bad(f"SEO · MANIFEST illisible/inattendu : {e}"); seo_exp = None
|
||
if seo_exp is not None:
|
||
seo_src = (f"total={seo_exp[0]} fr={seo_exp[1]} en={seo_exp[2]} es={seo_exp[3]}")
|
||
seo_pat = r"\*\*(\d+)\*\* mots-clés \(fr=(\d+) · en=(\d+) · es=(\d+)\)"
|
||
for path_f in (SEO_RD, SEO_FI):
|
||
txt = read_norm(path_f)
|
||
if txt is None:
|
||
continue
|
||
m = re.search(seo_pat, txt)
|
||
if m is None:
|
||
bad(f"SEO · {path_f} — claim mots-clés INTROUVABLE (attendu {seo_src})")
|
||
elif m.groups() == seo_exp:
|
||
good(f"SEO · {path_f} — {'/'.join(m.groups())} == MANIFEST ({seo_src})")
|
||
else:
|
||
bad(f"SEO · {path_f} — dit {'/'.join(m.groups())} MAIS MANIFEST dit "
|
||
f"{'/'.join(seo_exp)} ({seo_src}) → régénérer le doc")
|
||
|
||
# ============================================================================
|
||
# CRM · pipeline vente (états / transitions) — même classe de « vert trompeur »,
|
||
# transcrit À LA MAIN dans DEUX README de module.
|
||
# ----------------------------------------------------------------------------
|
||
# Le générateur workflow_vente émet out/MANIFEST.json dont `counts` porte states
|
||
# et transitions, RECOMPTÉS à chaque build depuis workflow_vente_spec.json (le
|
||
# graphe pipeline · byte-gaté par check_artifacts ⇒ source faisant autorité). Ce
|
||
# couple est recopié À LA MAIN à DEUX endroits : le titre de section du README du
|
||
# module workflow (« ## Le pipeline (9 états · 11 transitions) ») ET le README du
|
||
# DocType porteur dossier_vente (« options = les **9 états** du pipeline »), qui
|
||
# DÉRIVE le même compte d'états du même graphe (source unique · #5). Aucun gate ne
|
||
# les couvrait : ajouter un état/une transition au pipeline ferait dériver LES
|
||
# DEUX docs en silence pendant que l'artefact dit autre chose — même piège que les
|
||
# triplets par workspace des portails ou les mots-clés SEO. On RECOMPUTE ici
|
||
# (states + transitions) depuis workflow_vente MANIFEST.counts (déjà byte-gaté ·
|
||
# zéro duplication du compteur du générateur) et on exige l'égalité avec les DEUX
|
||
# docs. Un claim absent échoue AUSSI (traçabilité). Recomputer transitions EN PLUS
|
||
# de states mord une dérive du 2e nombre là où le seul titre du README workflow
|
||
# (qui n'a pas les transitions dans dossier_vente) resterait aveugle.
|
||
# ============================================================================
|
||
WF_RD = f"{D}/crm/workflow_vente/README.md"
|
||
DV_RD = f"{D}/crm/dossier_vente/README.md"
|
||
try:
|
||
wf_c = load("crm/workflow_vente/out/MANIFEST.json")["counts"]
|
||
wf_states, wf_trans = str(wf_c["states"]), str(wf_c["transitions"])
|
||
except (OSError, KeyError, ValueError) as e:
|
||
bad(f"CRM pipeline · MANIFEST workflow_vente illisible/inattendu : {e}")
|
||
wf_states = None
|
||
if wf_states is not None:
|
||
# 1) README workflow — titre de section (états + transitions)
|
||
wf_src = f"états={wf_states} transitions={wf_trans}"
|
||
wf_txt = read_norm(WF_RD)
|
||
if wf_txt is not None:
|
||
m = re.search(r"## Le pipeline \((\d+) états · (\d+) transitions\)", wf_txt)
|
||
if m is None:
|
||
bad(f"CRM pipeline · {WF_RD} — titre pipeline INTROUVABLE (attendu {wf_src})")
|
||
elif m.groups() == (wf_states, wf_trans):
|
||
good(f"CRM pipeline · {WF_RD} — {'/'.join(m.groups())} == MANIFEST ({wf_src})")
|
||
else:
|
||
bad(f"CRM pipeline · {WF_RD} — dit {'/'.join(m.groups())} MAIS MANIFEST "
|
||
f"dit {wf_states}/{wf_trans} ({wf_src}) → régénérer le README")
|
||
# 2) README dossier_vente — même compte d'états, dérivé du même graphe
|
||
dv_txt = read_norm(DV_RD)
|
||
if dv_txt is not None:
|
||
m = re.search(r"les \*\*(\d+) états\*\* du pipeline", dv_txt)
|
||
if m is None:
|
||
bad(f"CRM pipeline · {DV_RD} — claim « N états du pipeline » INTROUVABLE "
|
||
f"(attendu états={wf_states})")
|
||
elif m.group(1) == wf_states:
|
||
good(f"CRM pipeline · {DV_RD} — états={m.group(1)} == MANIFEST (états={wf_states})")
|
||
else:
|
||
bad(f"CRM pipeline · {DV_RD} — dit états={m.group(1)} MAIS MANIFEST dit "
|
||
f"états={wf_states} → régénérer le README")
|
||
|
||
# ============================================================================
|
||
# CRM · IDENTITÉ du DocType porteur — le NOM `OTO Dossier Vente`
|
||
# ----------------------------------------------------------------------------
|
||
# Le module dossier_vente émet out/doctype_oto_dossier_vente.json dont le champ
|
||
# `name` EST le nom du DocType porteur (= MANIFEST.doctype_name), byte-gaté par
|
||
# check_artifacts ⇒ artefact faisant autorité. Ce NOM est le POINT D'ATTACHE du
|
||
# pipeline : le Workflow vente s'y branche via son `document_type`
|
||
# (workflow.json[0].document_type). Le nom est RECOPIÉ À LA MAIN à plusieurs
|
||
# endroits, AUCUN gaté sur le NOM (le bloc CRM pipeline ci-dessus ne recompute
|
||
# que le COMPTE d'états, aveugle à l'identité) :
|
||
# • dossier_vente/README.md — titre « # DocType porteur · `NOM` » ET la ligne
|
||
# « Nom du DocType = `document_type` du workflow (`NOM`) »
|
||
# • 03_agents/crm/AGENT.md:25 — « **DocType porteur** `NOM` »
|
||
# • 03_agents/onapi_legal/AGENT.md:50 — « cible = `workflow.document_type` (`NOM`) »
|
||
# Piège : RENOMMER le DocType dans doctype_spec.json (`OTO Dossier Vente` →
|
||
# `OTO Dossier de Vente`) reconstruit les DEUX artefacts de façon COHÉRENTE (le
|
||
# workflow.document_type suit, les tests — qui testent des FONCTIONS — restent
|
||
# verts), MAIS les mentions en prose se PÉRIMENT en silence → l'agent ERPNext
|
||
# Backend importerait un DocType sous un nom pendant que la fiche/le README en
|
||
# nomment un autre, et le Workflow s'attacherait à un DocType FANTÔME — la
|
||
# rupture même que « sans lui le pipeline n'a rien à quoi s'attacher » (crm/
|
||
# AGENT.md:25) veut prévenir. Aucune suite tests/ (FONCTIONS de graphe, pas la
|
||
# prose) n'attrape ce « vert trompeur ». On RECOMPUTE le nom depuis l'artefact
|
||
# (zéro duplication) et on exige que chaque prose le nomme EXACTEMENT (backtické).
|
||
# Cohérences croisées bonus (mordent un artefact INTERNEMENT incohérent) :
|
||
# DocType.name == MANIFEST.doctype_name · == workflow.document_type (l'attache
|
||
# porteur↔workflow). Un claim absent échoue AUSSI (traçabilité #6).
|
||
# ============================================================================
|
||
try:
|
||
dv_dt = load("crm/dossier_vente/out/doctype_oto_dossier_vente.json")
|
||
dv_man = load("crm/dossier_vente/out/MANIFEST.json")
|
||
dt_name = dv_dt["name"]
|
||
dv_man_name = dv_man["doctype_name"]
|
||
wf_doc_type = load("crm/workflow_vente/out/workflow.json")[0]["document_type"]
|
||
except (OSError, KeyError, ValueError, IndexError, TypeError) as e:
|
||
bad(f"CRM DocType porteur · artefact illisible/inattendu : {e}"); dt_name = None
|
||
if dt_name:
|
||
# bonus 1 : cohérence interne du module (DocType.name == MANIFEST.doctype_name)
|
||
if dv_man_name != dt_name:
|
||
bad(f"CRM DocType porteur · incohérence artefacts : DocType.name "
|
||
f"« {dt_name} » MAIS MANIFEST.doctype_name « {dv_man_name} » → régénérer out/")
|
||
else:
|
||
good(f"CRM DocType porteur · DocType.name == MANIFEST.doctype_name « {dt_name} »")
|
||
# bonus 2 : l'attache porteur↔workflow — le Workflow s'y branche par document_type
|
||
if wf_doc_type != dt_name:
|
||
bad(f"CRM DocType porteur · attache ROMPUE : workflow.document_type "
|
||
f"« {wf_doc_type} » ≠ DocType porteur « {dt_name} » → le Workflow "
|
||
f"s'attacherait à un DocType FANTÔME")
|
||
else:
|
||
good(f"CRM DocType porteur · attache workflow.document_type == « {dt_name} » "
|
||
f"(le pipeline a bien où s'attacher)")
|
||
# prose : chaque doc doit nommer le DocType EXACTEMENT (backtické)
|
||
esc = re.escape(dt_name)
|
||
DV_DOC = [
|
||
(DV_RD, rf"# DocType porteur · `{esc}`", "titre dossier_vente"),
|
||
(DV_RD, rf"Nom du DocType\*\* = `document_type` du workflow \(`{esc}`\)",
|
||
"ligne « Nom du DocType »"),
|
||
("03_agents/crm/AGENT.md", rf"DocType porteur\*\* `{esc}`", "fiche CRM:25"),
|
||
("03_agents/onapi_legal/AGENT.md",
|
||
rf"cible = `workflow\.document_type` \(`{esc}`\)", "fiche onapi_legal:50"),
|
||
]
|
||
for path, pat, label in DV_DOC:
|
||
txt = read_norm(path)
|
||
if txt is None:
|
||
continue
|
||
if re.search(pat, txt):
|
||
good(f"CRM DocType porteur · {label} — nomme « {dt_name} » == artefact")
|
||
else:
|
||
bad(f"CRM DocType porteur · {label} ({path}) — nom « {dt_name} » "
|
||
f"INTROUVABLE/périmé → régénérer la doc")
|
||
|
||
# ============================================================================
|
||
# CRM (2e surface du MÊME README) · ÉNUMÉRATION des transitions à SÉPARATION
|
||
# DES POUVOIRS — la phrase « engagent de l'argent ou clôturent — confirmer
|
||
# réservation, signer contrat, approuver CONFOTUR, annuler/résilier ».
|
||
# ----------------------------------------------------------------------------
|
||
# Le bloc CRM ci-dessus recompute le COMPTE d'états/transitions mais PAS
|
||
# l'IDENTITÉ des transitions marquées `separation_of_duties`. Or cette liste est
|
||
# DATA-DERIVED : le générateur émet out/workflow.json (byte-gaté par
|
||
# check_artifacts ⇒ artefact faisant autorité) dont chaque transition porte
|
||
# `allow_self_approval` ; l'ensemble des transitions QUATRE-YEUX = {action |
|
||
# allow_self_approval == 0} — précisément ce que le README énumère À LA MAIN
|
||
# comme « interdisent l'auto-approbation (`allow_self_approval = 0`) ». Le bloc de
|
||
# COMPTE reste AVEUGLE au contenu : (a) DÉCLASSER une transition sensible (ex.
|
||
# « Signer contrat » repasse à allow_self_approval=1 dans le contrat) laisse le
|
||
# compte d'états/transitions à 9/11 pendant que l'artefact retire l'action de
|
||
# l'ensemble QUATRE-YEUX — la prose reste à 5 actions FAUSSES ; (b) PROMOUVOIR une
|
||
# transition (« Déposer CONFOTUR » → self=0) l'ajoute à l'ensemble sans que le
|
||
# compte bouge, et la prose l'OMET ; (c) RÉORDRE/RENOMMAGE d'action passe aussi —
|
||
# « vert trompeur » qu'aucune suite tests/ (qui teste des FONCTIONS de graphe, pas
|
||
# la prose) n'attrape — même patron que l'énumération des onglets Mobile, des open
|
||
# items de l'audit 5D ou de la carte de renormalisation 4Big. On RECOMPUTE ici
|
||
# l'ENSEMBLE des actions QUATRE-YEUX depuis workflow.json (zéro duplication du flag
|
||
# du générateur) et on exige que la phrase l'énumère EXACTEMENT (absent ET en trop
|
||
# mordus via set-diff). Normalisation : casse ignorée + suffixe « (perdu) » élidé
|
||
# (les deux transitions vers l'état Perdu — Annuler/Résilier — que la prose groupe
|
||
# « annuler/résilier »). Cohérence croisée en bonus : 0 < |quatre-yeux| < |trans|
|
||
# (l'ensemble sensible est NON VIDE — sinon la garde est vacante — et STRICT — sinon
|
||
# tout le graphe serait bloqué en quatre-yeux, ni l'un ni l'autre n'étant le
|
||
# contrat). Un claim absent échoue AUSSI (traçabilité).
|
||
# ============================================================================
|
||
try:
|
||
wf_trans_list = load("crm/workflow_vente/out/workflow.json")[0]["transitions"]
|
||
# ensemble QUATRE-YEUX = actions dont l'auto-approbation est interdite (self=0),
|
||
# normalisées (casse ignorée · suffixe « (perdu) » élidé — cf. groupage prose)
|
||
def _sod_norm(a):
|
||
return re.sub(r"\s*\(perdu\)\s*", "", a).strip().lower()
|
||
sod_set = {_sod_norm(t["action"]) for t in wf_trans_list
|
||
if str(t["allow_self_approval"]) == "0"}
|
||
n_trans_tot = len(wf_trans_list)
|
||
except (OSError, KeyError, ValueError, IndexError, TypeError) as e:
|
||
bad(f"CRM séparation · workflow.json illisible/inattendu : {e}"); sod_set = None
|
||
if sod_set is not None:
|
||
# bonus : l'ensemble sensible est non vide ET strictement inclus dans le graphe
|
||
if not (0 < len(sod_set) < n_trans_tot):
|
||
bad(f"CRM séparation · ensemble quatre-yeux dégénéré : |sod|={len(sod_set)} "
|
||
f"vs |transitions|={n_trans_tot} (attendu 0 < sod < total) → vérifier le contrat")
|
||
else:
|
||
good(f"CRM séparation · ensemble quatre-yeux non vide et strict : "
|
||
f"0 < {len(sod_set)} < {n_trans_tot}")
|
||
# énumération de la phrase : capturer le segment entre « clôturent** — » et « — sont marquées »
|
||
wf_txt3 = read_norm(WF_RD)
|
||
if wf_txt3 is not None:
|
||
ms = re.search(r"engagent de l'argent ou clôturent\*\* — (.+?) — sont marquées",
|
||
wf_txt3)
|
||
if ms is None:
|
||
bad(f"CRM séparation · {WF_RD} — énumération des transitions quatre-yeux "
|
||
f"INTROUVABLE (attendu {sorted(sod_set)})")
|
||
else:
|
||
prose_set = {t.strip().lower() for t in re.split(r"[,/]", ms.group(1)) if t.strip()}
|
||
if prose_set == sod_set:
|
||
good(f"CRM séparation · {WF_RD} — énumération quatre-yeux "
|
||
f"{sorted(prose_set)} == workflow.json (allow_self_approval=0)")
|
||
else:
|
||
miss = sorted(sod_set - prose_set)
|
||
extra = sorted(prose_set - sod_set)
|
||
detail = (f" · absents={miss}" if miss else "") + \
|
||
(f" · en trop={extra}" if extra else "")
|
||
bad(f"CRM séparation · {WF_RD} — énumère {sorted(prose_set)} MAIS "
|
||
f"workflow.json dit {sorted(sod_set)}{detail} → régénérer le README")
|
||
|
||
# ============================================================================
|
||
# Mobile · récap de l'app Expo (onglets / rôles couverts / langues / identifiants
|
||
# a_confirmer) — même classe de « vert trompeur », transcrite À LA MAIN.
|
||
# ----------------------------------------------------------------------------
|
||
# Le générateur app_config émet out/MANIFEST.json dont `counts` porte onglets,
|
||
# roles_couverts, langues et identifiants_a_confirmer, RECOMPTÉS à chaque build
|
||
# depuis rbac_50_roles.json (portails métier + rôles), seo_spec.json (langues) et
|
||
# la liste `a_confirmer` (identifiants EAS/stores jamais fabriqués · byte-gaté par
|
||
# check_artifacts ⇒ artefact faisant autorité). Ce quadruplet est recopié À LA
|
||
# MAIN dans la ligne récap du README du module (05_deliverables_mvp/mobile/
|
||
# app_config/README.md — « **5 onglets** … · **44 rôles** couverts · **3 langues**
|
||
# · **13 identifiants `a_confirmer`** »). Aucun gate ne le couvrait : ajouter un
|
||
# rôle/portail au contrat RBAC, une langue au site ou un credential store ferait
|
||
# dériver le doc en silence pendant que l'artefact dit autre chose — même piège
|
||
# que les mots-clés SEO ou les triplets par workspace des portails. On RECOMPUTE
|
||
# ici les 4 nombres depuis MANIFEST.counts (déjà byte-gaté · zéro duplication du
|
||
# compteur du générateur) et on exige l'égalité. Un claim absent échoue AUSSI
|
||
# (traçabilité). Recomputer les 4 (et pas le seul total de rôles) mord une dérive
|
||
# de N'IMPORTE lequel des volets là où un seul nombre resterait aveugle.
|
||
# ============================================================================
|
||
MOB_RD = f"{D}/mobile/app_config/README.md"
|
||
try:
|
||
mob_c = load("mobile/app_config/out/MANIFEST.json")["counts"]
|
||
mob_exp = (str(mob_c["onglets"]),
|
||
str(mob_c["roles_couverts"]),
|
||
str(mob_c["langues"]),
|
||
str(mob_c["identifiants_a_confirmer"]))
|
||
except (OSError, KeyError, ValueError) as e:
|
||
bad(f"Mobile · MANIFEST app_config illisible/inattendu : {e}"); mob_exp = None
|
||
if mob_exp is not None:
|
||
mob_src = (f"onglets={mob_exp[0]} rôles={mob_exp[1]} langues={mob_exp[2]} "
|
||
f"identifiants={mob_exp[3]}")
|
||
mob_pat = (r"\*\*(\d+) onglets\*\* \([^)]*\) · \*\*(\d+) rôles\*\* couverts · "
|
||
r"\*\*(\d+) langues\*\* · \*\*(\d+) identifiants")
|
||
mob_txt = read_norm(MOB_RD)
|
||
if mob_txt is not None:
|
||
m = re.search(mob_pat, mob_txt)
|
||
if m is None:
|
||
bad(f"Mobile · {MOB_RD} — récap app INTROUVABLE (attendu {mob_src})")
|
||
elif m.groups() == mob_exp:
|
||
good(f"Mobile · {MOB_RD} — {'/'.join(m.groups())} == MANIFEST ({mob_src})")
|
||
else:
|
||
bad(f"Mobile · {MOB_RD} — dit {'/'.join(m.groups())} MAIS MANIFEST dit "
|
||
f"{'/'.join(mob_exp)} ({mob_src}) → régénérer le README")
|
||
|
||
# ============================================================================
|
||
# Mobile (2e surface) · ÉNUMÉRATION des onglets de l'app Expo — même README, même
|
||
# ligne récap « **5 onglets** (Ventes/Construction/Achat/Compta/Direction) ».
|
||
# ----------------------------------------------------------------------------
|
||
# Le bloc mobile ci-dessus recompute le COMPTE d'onglets (« 5 ») mais son patron
|
||
# ne mord QUE le nombre : la parenthèse qui ÉNUMÈRE les 5 portails est matchée par
|
||
# `\([^)]*\)` et son CONTENU est IGNORÉ. Or cette liste est DATA-DERIVED : le
|
||
# générateur app_config émet out/role_navigation.json (byte-gaté par
|
||
# check_artifacts ⇒ artefact faisant autorité) = un onglet par portail métier du
|
||
# contrat RBAC (bijection 1 onglet ⇔ 1 portail · `plateforme` exclu), chaque
|
||
# entrée portant `portail` (id) + `label` (« OTO <Nom> »). Le nom court énuméré au
|
||
# README = le label sans le préfixe « OTO ». Un ÉCHANGE (Ventes→Marketing), un
|
||
# RETRAIT (Compta absent — l'énumération tombe à 4 tandis que « **5 onglets** »
|
||
# reste juste) ou un RÉORDRE ferait dériver la prose EN SILENCE pendant que
|
||
# l'artefact dit autre chose : « vert trompeur » aveugle au seul compte, qu'aucune
|
||
# suite tests/ (qui teste des FONCTIONS, pas la prose) n'attrape — même patron que
|
||
# l'énumération des confirmations DevOps, la carte de renormalisation par archétype
|
||
# 4Big ou la table « Verdict courant » de l'audit 5D. On RECOMPUTE ici la LISTE
|
||
# ORDONNÉE des noms courts depuis role_navigation.json (zéro duplication de la
|
||
# navigation du générateur) et on exige que la parenthèse l'énumère EXACTEMENT
|
||
# (absent ET en trop mordus, puis ordre). Cohérence croisée en bonus :
|
||
# len(role_navigation) == MANIFEST.counts.onglets == MANIFEST.counts.portails (la
|
||
# bijection du générateur) — mord un compteur du manifeste désynchronisé de sa
|
||
# propre liste de navigation.
|
||
# ============================================================================
|
||
try:
|
||
mob_nav = load("mobile/app_config/out/role_navigation.json")
|
||
# nom court = label « OTO <Nom> » sans le préfixe « OTO » (ce que le doc copie)
|
||
mob_names = [re.sub(r"^OTO\s+", "", e["label"]).strip() for e in mob_nav]
|
||
mob_names_c = load("mobile/app_config/out/MANIFEST.json")["counts"]
|
||
except (OSError, KeyError, ValueError, TypeError) as e:
|
||
bad(f"Mobile · role_navigation/MANIFEST illisible/inattendu : {e}"); mob_names = None
|
||
if mob_names is not None:
|
||
# bijection : la liste de navigation == compteurs onglets ET portails du manifeste
|
||
n_nav = len(mob_nav)
|
||
if not (n_nav == mob_names_c.get("onglets") == mob_names_c.get("portails")):
|
||
bad(f"Mobile · bijection rompue : |role_navigation|={n_nav} MAIS "
|
||
f"MANIFEST onglets={mob_names_c.get('onglets')} "
|
||
f"portails={mob_names_c.get('portails')} → régénérer l'artefact")
|
||
else:
|
||
good(f"Mobile · bijection onglet⇔portail : {n_nav} == onglets == portails")
|
||
# énumération de la parenthèse : capturer le contenu entre ( ) qui suit « N onglets »
|
||
mob_enum_pat = r"\*\*\d+ onglets\*\* \(([^)]*)\)"
|
||
mob_txt2 = read_norm(MOB_RD)
|
||
if mob_txt2 is not None:
|
||
me = re.search(mob_enum_pat, mob_txt2)
|
||
if me is None:
|
||
bad(f"Mobile · {MOB_RD} — énumération des onglets INTROUVABLE "
|
||
f"(attendu {'/'.join(mob_names)})")
|
||
else:
|
||
got = [s.strip() for s in me.group(1).split("/") if s.strip()]
|
||
if got == mob_names:
|
||
good(f"Mobile · {MOB_RD} — énumération onglets {'/'.join(got)} "
|
||
f"== role_navigation")
|
||
else:
|
||
miss = [x for x in mob_names if x not in got]
|
||
extra = [x for x in got if x not in mob_names]
|
||
detail = (f" · absents={miss}" if miss else "") + \
|
||
(f" · en trop={extra}" if extra else "") or " · même ensemble, ordre ≠"
|
||
bad(f"Mobile · {MOB_RD} — énumère {got} MAIS role_navigation dit "
|
||
f"{mob_names}{detail} → régénérer le README")
|
||
|
||
# ============================================================================
|
||
# Legal · DocType CONFOTUR Application (champs / sections / rôles / évènements de
|
||
# dépôt) — même classe de « vert trompeur », transcrit À LA MAIN dans DEUX docs.
|
||
# ----------------------------------------------------------------------------
|
||
# Le générateur confotur émet out/MANIFEST.json dont `counts` porte fields,
|
||
# data_fields, sections, roles et depot_events, RECOMPTÉS à chaque build depuis
|
||
# confotur_spec.json (byte-gaté par check_artifacts ⇒ artefact faisant autorité).
|
||
# Ce quintuplet est recopié À LA MAIN à DEUX endroits : la ligne de synthèse du
|
||
# README du module (05_deliverables_mvp/legal/confotur/README.md — « 18 champs
|
||
# (14 de donnée) · 4 sections · 3 rôles · … · 2 évènements de dépôt ») ET la
|
||
# cellule de la fiche agent onapi_legal (03_agents/onapi_legal/AGENT.md — mêmes 5
|
||
# nombres, chacun en gras). Aucun gate ne les couvrait : ajouter un champ/une
|
||
# section/un rôle au spec ferait dériver LES DEUX docs en silence pendant que
|
||
# l'artefact dit autre chose — même piège que les mots-clés SEO ou les triplets
|
||
# par workspace des portails. On RECOMPUTE ici les 5 nombres depuis
|
||
# MANIFEST.counts (déjà byte-gaté · zéro duplication du compteur du générateur)
|
||
# et on exige l'égalité avec les DEUX docs. Un claim absent échoue AUSSI
|
||
# (traçabilité). Recomputer les 5 (et pas le seul total de champs) mord une dérive
|
||
# de N'IMPORTE lequel des volets là où un seul nombre resterait aveugle.
|
||
# ============================================================================
|
||
CONF_RD = f"{D}/legal/confotur/README.md"
|
||
CONF_FI = "03_agents/onapi_legal/AGENT.md"
|
||
try:
|
||
conf_c = load("legal/confotur/out/MANIFEST.json")["counts"]
|
||
conf_exp = (str(conf_c["fields"]),
|
||
str(conf_c["data_fields"]),
|
||
str(conf_c["sections"]),
|
||
str(conf_c["roles"]),
|
||
str(conf_c["depot_events"]))
|
||
except (OSError, KeyError, ValueError) as e:
|
||
bad(f"Legal confotur · MANIFEST illisible/inattendu : {e}"); conf_exp = None
|
||
if conf_exp is not None:
|
||
conf_src = (f"champs={conf_exp[0]} données={conf_exp[1]} sections={conf_exp[2]} "
|
||
f"rôles={conf_exp[3]} dépôts={conf_exp[4]}")
|
||
# README du module (sans gras) et fiche agent (chaque nombre en gras) —
|
||
# même quintuplet, deux mises en forme distinctes du même artefact.
|
||
conf_docs = (
|
||
(CONF_RD, r"(\d+) champs \((\d+) de donnée\) · (\d+) sections · (\d+) rôles"
|
||
r" · soumissible · (\d+) évènements de dépôt"),
|
||
(CONF_FI, r"\*\*(\d+) champs\*\* \((\d+) de donnée\) · \*\*(\d+) sections\*\*"
|
||
r" · \*\*(\d+) rôles/permissions\*\* · \*\*(\d+) évènements de dépôt\*\*"),
|
||
)
|
||
for path_f, pat in conf_docs:
|
||
txt = read_norm(path_f)
|
||
if txt is None:
|
||
continue
|
||
m = re.search(pat, txt)
|
||
if m is None:
|
||
bad(f"Legal confotur · {path_f} — synthèse DocType INTROUVABLE "
|
||
f"(attendu {conf_src})")
|
||
elif m.groups() == conf_exp:
|
||
good(f"Legal confotur · {path_f} — {'/'.join(m.groups())} == "
|
||
f"MANIFEST ({conf_src})")
|
||
else:
|
||
bad(f"Legal confotur · {path_f} — dit {'/'.join(m.groups())} MAIS "
|
||
f"MANIFEST dit {'/'.join(conf_exp)} ({conf_src}) → régénérer le doc")
|
||
|
||
# ============================================================================
|
||
# Legal · CONFOTUR — cross-cohérence PERMISSIONS (2e surface du MÊME README) :
|
||
# l'IDENTITÉ des 3 rôles + leur jeu d'ACTIONS exact — le CŒUR sécurité du livrable.
|
||
# ----------------------------------------------------------------------------
|
||
# Le bloc CONFOTUR ci-dessus ne gate que le COMPTE (« 3 rôles »). La section
|
||
# « Cœur du livrable : cross-cohérence » du README (05_deliverables_mvp/legal/
|
||
# confotur/README.md §Permissions) énumère À LA MAIN, PAR rôle, son portail ET son
|
||
# jeu d'actions RBAC — annoncé « mot pour mot les permissions_cibles RBAC … ni
|
||
# ajout ni retrait » :
|
||
# - `ventes-confotur` (portail Ventes) → read/write/create/print
|
||
# - `legal-onapi` (portail Direction) → read/write/create
|
||
# - `legal-directeur` (portail Direction) → read/write/**submit**/report
|
||
# Ces trois lignes sont DATA-DERIVED d'out/MANIFEST.json (`roles_rbac_utilises[]` :
|
||
# role_id · portail · `permissions`, recomputé du contrat rbac_50_roles.json à
|
||
# chaque build) et projetées dans out/doctype_confotur_application.json
|
||
# (`permissions[]` par nom de rôle) — les DEUX byte-gatés par check_artifacts. Le
|
||
# compte « 3 rôles » est AVEUGLE à leur identité : PROMOUVOIR `ventes-confotur` à
|
||
# `submit` (élévation de privilège — casse la séparation des pouvoirs dont
|
||
# is_submittable est déduit), RETIRER une action, RÉAFFECTER un portail, RENOMMER
|
||
# un rôle ou AJOUTER une ligne fantôme laisse la prose périmée pendant que les
|
||
# artefacts disent autre chose → l'agent ERPNext Backend câblerait le mauvais jeu
|
||
# de permissions (le risque même que la cross-cohérence veut prévenir) — « vert
|
||
# trompeur » qu'aucune suite tests/ (qui teste des FONCTIONS de mapping RBAC, pas
|
||
# la prose) n'attrape. Même patron d'IDENTITÉ que le catalogue RBAC fixtures_gen
|
||
# (séparation des pouvoirs `set_user_permissions`) ou la cross-cohérence e-CF. On
|
||
# RECOMPUTE chaque jeu d'actions + portail depuis le MANIFEST (zéro duplication du
|
||
# contrat #6) et on exige que la prose l'encode EXACTEMENT (set-diff : absent ET en
|
||
# trop · casse normalisée), + l'identité d'ensemble (README ⇔ MANIFEST, aucun rôle
|
||
# fantôme ni manquant). Cohérences croisées en bonus (mordent un artefact
|
||
# INTERNEMENT incohérent) : MANIFEST ⇄ DocType d'accord sur le jeu d'actions par
|
||
# rôle · séparation des pouvoirs : `submit` porté par EXACTEMENT un rôle et
|
||
# is_submittable=True déduit de sa présence (README:31). Un claim absent échoue
|
||
# AUSSI (traçabilité).
|
||
# ============================================================================
|
||
CONF_RD2 = f"{D}/legal/confotur/README.md"
|
||
_CONF_PERM_FLAGS = ("read", "write", "create", "submit", "cancel", "amend",
|
||
"delete", "report", "export", "import", "print", "email", "share")
|
||
try:
|
||
conf_roles = load("legal/confotur/out/MANIFEST.json")["roles_rbac_utilises"]
|
||
conf_perm_by_id = {r["role_id"]: set(r["permissions"]) for r in conf_roles}
|
||
conf_portail_by_id = {r["role_id"]: r["portail"] for r in conf_roles}
|
||
conf_name_by_id = {r["role_id"]: r["erpnext_role_name"] for r in conf_roles}
|
||
conf_dt = load("legal/confotur/out/doctype_confotur_application.json")
|
||
conf_dt_perm_by_name = {p["role"]: {f for f in _CONF_PERM_FLAGS if p.get(f)}
|
||
for p in conf_dt["permissions"]}
|
||
conf_submittable = bool(conf_dt.get("is_submittable"))
|
||
except (OSError, KeyError, ValueError, TypeError) as e:
|
||
bad(f"Legal confotur perms · artefact illisible/inattendu : {e}"); conf_roles = None
|
||
if conf_roles:
|
||
# bonus 1 : les DEUX artefacts (MANIFEST ⇄ DocType) d'accord sur le jeu d'actions
|
||
for rid, perms in conf_perm_by_id.items():
|
||
nm = conf_name_by_id[rid]
|
||
dt_perms = conf_dt_perm_by_name.get(nm)
|
||
if dt_perms is None:
|
||
bad(f"Legal confotur perms · rôle `{rid}` ({nm}) ABSENT des permissions du DocType")
|
||
elif dt_perms != perms:
|
||
bad(f"Legal confotur perms · incohérence artefacts pour `{rid}` : MANIFEST "
|
||
f"{sorted(perms)} MAIS DocType {sorted(dt_perms)}")
|
||
else:
|
||
good(f"Legal confotur perms · `{rid}` — MANIFEST {sorted(perms)} == DocType (accord)")
|
||
# bonus 2 : séparation des pouvoirs — `submit` porté par EXACTEMENT un rôle, et
|
||
# is_submittable DÉDUIT de sa présence (README:31). Singleton sécurité (cf. RBAC Admin).
|
||
conf_submitters = {rid for rid, p in conf_perm_by_id.items() if "submit" in p}
|
||
if len(conf_submitters) != 1:
|
||
bad(f"Legal confotur perms · séparation des pouvoirs : action `submit` portée par "
|
||
f"{sorted(conf_submitters)} (attendu 1 — ni vacant, ni élévation de privilège)")
|
||
elif conf_submittable is not True:
|
||
bad(f"Legal confotur perms · un rôle porte `submit` MAIS is_submittable="
|
||
f"{conf_submittable} → DocType incohérent")
|
||
else:
|
||
good(f"Legal confotur perms · séparation des pouvoirs — `submit` singleton "
|
||
f"{sorted(conf_submitters)} · is_submittable=True (déduit · README:31)")
|
||
conf_rd2 = read_norm(CONF_RD2)
|
||
if conf_rd2 is not None:
|
||
for rid in sorted(conf_perm_by_id):
|
||
exp_perms = conf_perm_by_id[rid]
|
||
exp_portail = conf_portail_by_id[rid]
|
||
m = re.search(rf"`{re.escape(rid)}` \(portail ([^)]+)\) → ([\w/*]+)", conf_rd2)
|
||
if m is None:
|
||
bad(f"Legal confotur perms · {CONF_RD2} — ligne du rôle `{rid}` INTROUVABLE "
|
||
f"(attendu portail {exp_portail} · {sorted(exp_perms)})")
|
||
continue
|
||
got_portail = m.group(1).strip().lower()
|
||
got_perms = {t.replace("*", "").strip().lower()
|
||
for t in m.group(2).split("/") if t.replace("*", "").strip()}
|
||
if got_portail != exp_portail:
|
||
bad(f"Legal confotur perms · `{rid}` — {CONF_RD2} dit portail "
|
||
f"« {m.group(1).strip()} » MAIS MANIFEST dit « {exp_portail} » → régénérer")
|
||
elif got_perms != exp_perms:
|
||
miss = sorted(exp_perms - got_perms); extra = sorted(got_perms - exp_perms)
|
||
detail = (f" · absents={miss}" if miss else "") + \
|
||
(f" · en trop={extra}" if extra else "")
|
||
bad(f"Legal confotur perms · `{rid}` — {CONF_RD2} énumère {sorted(got_perms)} "
|
||
f"MAIS MANIFEST {sorted(exp_perms)}{detail} → régénérer le README")
|
||
else:
|
||
good(f"Legal confotur perms · `{rid}` — portail {got_portail} · actions "
|
||
f"{sorted(got_perms)} == MANIFEST")
|
||
# identité d'ensemble : README ⇔ MANIFEST (aucun rôle fantôme NI manquant) —
|
||
# rd_ids = rôles réellement écrits dans la prose §Permissions (patron identique).
|
||
rd_ids = set(re.findall(r"`([a-z][a-z-]+)` \(portail [^)]+\) → [\w/*]+", conf_rd2))
|
||
exp_ids = set(conf_perm_by_id)
|
||
if rd_ids == exp_ids:
|
||
good(f"Legal confotur perms · identité d'ensemble — {sorted(rd_ids)} == "
|
||
f"roles_rbac_utilises (aucun rôle fantôme/manquant)")
|
||
else:
|
||
miss = sorted(exp_ids - rd_ids); extra = sorted(rd_ids - exp_ids)
|
||
detail = (f" · absents={miss}" if miss else "") + \
|
||
(f" · en trop (fantôme)={extra}" if extra else "")
|
||
bad(f"Legal confotur perms · {CONF_RD2} — rôles énumérés {sorted(rd_ids)} MAIS "
|
||
f"MANIFEST {sorted(exp_ids)}{detail} → régénérer le README")
|
||
|
||
# ============================================================================
|
||
# Legal · CONFOTUR — cross-cohérence PERMISSIONS répliquée dans la FICHE AGENT
|
||
# (3e surface) : la MÊME table role→portail→actions, transcrite À LA MAIN dans
|
||
# 03_agents/onapi_legal/AGENT.md:43-45 — jamais gatée.
|
||
# ----------------------------------------------------------------------------
|
||
# Le bloc ci-dessus gate la §Permissions du README du MODULE (legal/confotur/
|
||
# README.md). Mais la FICHE de l'agent ONAPI/Legal RECOPIE la MÊME donnée
|
||
# sécurité — annoncée « **mot pour mot** les `permissions_cibles` RBAC des 3
|
||
# rôles » — sous une convention DIFFÉRENTE (` / ` espacé · `**submit**` en gras) :
|
||
# - `ventes-confotur` (portail Ventes) → read / write / create / print
|
||
# - `legal-onapi` (portail Direction) → read / write / create
|
||
# - `legal-directeur` (portail Direction) → read / write / **submit** / report
|
||
# Ces trois lignes sont DATA-DERIVED du MÊME out/MANIFEST.json (`roles_rbac_
|
||
# utilises[]` : role_id · portail · `permissions`, recomputé du contrat rbac_50_
|
||
# roles.json à chaque build · byte-gaté par check_artifacts) que le README. Le
|
||
# gate README est AVEUGLE à la fiche : PROMOUVOIR `ventes-confotur` à `submit`
|
||
# ici (élévation de privilège · casse la séparation des pouvoirs), RETIRER une
|
||
# action, RÉAFFECTER un portail, RENOMMER un rôle ou AJOUTER une ligne fantôme
|
||
# laisse la FICHE périmée pendant que l'artefact (et le README, lui gaté) disent
|
||
# autre chose → l'agent ONAPI/Legal lirait sa PROPRE doc d'identité mentant sur
|
||
# le jeu de permissions qu'il porte (le risque même que « mot pour mot » promet
|
||
# d'écarter) — « vert trompeur » qu'aucune suite tests/ (FONCTIONS de mapping
|
||
# RBAC, jamais la prose de fiche) n'attrape. Même patron d'IDENTITÉ que les
|
||
# fiches Mobile/Faisabilité (attributs de rôle) ou la citation #10 (constantes),
|
||
# appliqué à la SURFACE DISTINCTE qu'est la §Permissions de cette fiche. On
|
||
# RECOMPUTE chaque jeu d'actions + portail depuis conf_perm_by_id/conf_portail_
|
||
# by_id (déjà dérivés du MANIFEST ci-dessus · ZÉRO duplication du contrat #6) et
|
||
# on exige que la fiche l'encode EXACTEMENT (set-diff : absent ET en trop · gras
|
||
# `**` et casse normalisés) + l'identité d'ensemble (fiche ⇔ MANIFEST, aucun
|
||
# rôle fantôme ni manquant). Un claim absent échoue AUSSI (traçabilité).
|
||
# ============================================================================
|
||
CONF_FICHE = "03_agents/onapi_legal/AGENT.md"
|
||
if conf_roles:
|
||
conf_fiche = read_norm(CONF_FICHE)
|
||
if conf_fiche is None:
|
||
bad(f"Legal confotur fiche · {CONF_FICHE} illisible")
|
||
else:
|
||
# une action : mot, éventuellement encadré de `**` (gras), séparé par ` / `.
|
||
_ACT = r"(?:\*{0,2}\w+\*{0,2}\s*/\s*)*\*{0,2}\w+\*{0,2}"
|
||
for rid in sorted(conf_perm_by_id):
|
||
exp_perms = conf_perm_by_id[rid]
|
||
exp_portail = conf_portail_by_id[rid]
|
||
pat = r"`" + re.escape(rid) + r"` \(portail ([^)]+)\) → (" + _ACT + r")"
|
||
m = re.search(pat, conf_fiche)
|
||
if m is None:
|
||
bad(f"Legal confotur fiche · {CONF_FICHE} — ligne du rôle `{rid}` INTROUVABLE "
|
||
f"(attendu portail {exp_portail} · {sorted(exp_perms)})")
|
||
continue
|
||
got_portail = m.group(1).strip().lower()
|
||
got_perms = {t.replace("*", "").strip().lower()
|
||
for t in m.group(2).split("/") if t.replace("*", "").strip()}
|
||
if got_portail != exp_portail:
|
||
bad(f"Legal confotur fiche · `{rid}` — {CONF_FICHE} dit portail "
|
||
f"« {m.group(1).strip()} » MAIS MANIFEST dit « {exp_portail} » → régénérer")
|
||
elif got_perms != exp_perms:
|
||
miss = sorted(exp_perms - got_perms); extra = sorted(got_perms - exp_perms)
|
||
detail = (f" · absents={miss}" if miss else "") + \
|
||
(f" · en trop={extra}" if extra else "")
|
||
bad(f"Legal confotur fiche · `{rid}` — {CONF_FICHE} énumère {sorted(got_perms)} "
|
||
f"MAIS MANIFEST {sorted(exp_perms)}{detail} → régénérer la fiche")
|
||
else:
|
||
good(f"Legal confotur fiche · `{rid}` — portail {got_portail} · actions "
|
||
f"{sorted(got_perms)} == MANIFEST")
|
||
# identité d'ensemble : fiche ⇔ MANIFEST (aucun rôle fantôme NI manquant).
|
||
fiche_ids = set(re.findall(r"`([a-z][a-z-]+)` \(portail [^)]+\) →", conf_fiche))
|
||
exp_ids = set(conf_perm_by_id)
|
||
if fiche_ids == exp_ids:
|
||
good(f"Legal confotur fiche · identité d'ensemble — {sorted(fiche_ids)} == "
|
||
f"roles_rbac_utilises (aucun rôle fantôme/manquant)")
|
||
else:
|
||
miss = sorted(exp_ids - fiche_ids); extra = sorted(fiche_ids - exp_ids)
|
||
detail = (f" · absents={miss}" if miss else "") + \
|
||
(f" · en trop (fantôme)={extra}" if extra else "")
|
||
bad(f"Legal confotur fiche · {CONF_FICHE} — rôles énumérés {sorted(fiche_ids)} MAIS "
|
||
f"MANIFEST {sorted(exp_ids)}{detail} → régénérer la fiche")
|
||
|
||
# ============================================================================
|
||
# Audit 5D · synthèse de l'audit de conformité (contrôles / dimensions / verdict
|
||
# pass-fail-à confirmer) — même classe de « vert trompeur », transcrite À LA MAIN
|
||
# dans DEUX docs.
|
||
# ----------------------------------------------------------------------------
|
||
# Le générateur audit_5d émet DEUX artefacts byte-gatés par check_artifacts
|
||
# (⇒ faisant autorité) : out/MANIFEST.json (controls_total, `dimensions`,
|
||
# `audited_artifacts`, `open_items`, verdict — recomptés du catalogue de contrôles
|
||
# à chaque build) ET out/audit_report.json dont `totals` porte la ventilation
|
||
# {controls, pass, fail, a_confirmer} recalculée en rejouant les 17 contrôles sur
|
||
# les hand-off amont. Deux nombres de cette synthèse sont recopiés À LA MAIN à
|
||
# DEUX endroits : la phrase d'ouverture du README du module (05_deliverables_mvp/
|
||
# qa/audit_5d/README.md — « **17 contrôles** répartis en **5 dimensions (5D)** »)
|
||
# ET la cellule de la fiche agent QA (03_agents/qa/AGENT.md — « 17 contrôles / 5
|
||
# dimensions »). La ventilation du verdict (« **13 PASS · 0 FAIL · 4 à confirmer** »)
|
||
# n'est portée QUE par le README, mais se PÉRIME dès qu'un contrôle bascule en
|
||
# A_CONFIRMER (pass 13→12, à confirmer 4→5) alors que le TOTAL de contrôles reste
|
||
# 17 — dérive AVEUGLE au seul compte de contrôles, d'où sa recomputation séparée
|
||
# depuis audit_report.totals. Aucun gate ne couvrait ces claims : ajouter un
|
||
# contrôle/une dimension au catalogue, ou faire basculer un contrôle, ferait
|
||
# dériver LES DEUX docs (et la ligne verdict) en silence pendant que les artefacts
|
||
# disent autre chose — même piège que les mots-clés SEO, les triplets par workspace
|
||
# des portails ou le quintuplet CONFOTUR. On RECOMPUTE ici (contrôles + dimensions
|
||
# depuis le MANIFEST · pass/fail/à confirmer depuis audit_report.totals · déjà
|
||
# byte-gatés · zéro duplication du compteur du générateur) et on exige l'égalité.
|
||
# Un claim absent échoue AUSSI (traçabilité). Cohérence croisée en bonus : le
|
||
# `controls_total` du MANIFEST doit égaler `totals.controls` du rapport.
|
||
# ============================================================================
|
||
A5D_RD = f"{D}/qa/audit_5d/README.md"
|
||
A5D_FI = "03_agents/qa/AGENT.md"
|
||
try:
|
||
a5_man = load("qa/audit_5d/out/MANIFEST.json")
|
||
a5_tot = load("qa/audit_5d/out/audit_report.json")["totals"]
|
||
a5_ctl = str(a5_man["controls_total"])
|
||
a5_dim = str(len(a5_man["dimensions"]))
|
||
if a5_ctl != str(a5_tot["controls"]):
|
||
bad(f"Audit 5D · incohérence artefacts : MANIFEST controls_total={a5_ctl} "
|
||
f"MAIS audit_report totals.controls={a5_tot['controls']}")
|
||
a5_pass, a5_fail = str(a5_tot["pass"]), str(a5_tot["fail"])
|
||
a5_conf = str(a5_tot["a_confirmer"])
|
||
except (OSError, KeyError, ValueError) as e:
|
||
bad(f"Audit 5D · artefact illisible/inattendu : {e}"); a5_ctl = None
|
||
if a5_ctl is not None:
|
||
a5_src = f"contrôles={a5_ctl} dimensions={a5_dim}"
|
||
# 1) README module — phrase d'ouverture (contrôles + dimensions)
|
||
a5_rd = read_norm(A5D_RD)
|
||
if a5_rd is not None:
|
||
m = re.search(r"\*\*(\d+) contrôles\*\* répartis en \*\*(\d+) dimensions \(5D\)\*\*",
|
||
a5_rd)
|
||
if m is None:
|
||
bad(f"Audit 5D · {A5D_RD} — synthèse contrôles/dimensions INTROUVABLE "
|
||
f"(attendu {a5_src})")
|
||
elif m.groups() == (a5_ctl, a5_dim):
|
||
good(f"Audit 5D · {A5D_RD} — {'/'.join(m.groups())} == artefact ({a5_src})")
|
||
else:
|
||
bad(f"Audit 5D · {A5D_RD} — dit {'/'.join(m.groups())} MAIS artefact dit "
|
||
f"{a5_ctl}/{a5_dim} ({a5_src}) → régénérer le README")
|
||
# 2) README module — ventilation du verdict (pass / fail / à confirmer)
|
||
a5_vsrc = f"pass={a5_pass} fail={a5_fail} à_confirmer={a5_conf}"
|
||
m = re.search(r"\*\*(\d+) PASS · (\d+) FAIL · (\d+) à confirmer\*\*", a5_rd)
|
||
if m is None:
|
||
bad(f"Audit 5D · {A5D_RD} — ventilation verdict INTROUVABLE (attendu {a5_vsrc})")
|
||
elif m.groups() == (a5_pass, a5_fail, a5_conf):
|
||
good(f"Audit 5D · {A5D_RD} — verdict {'/'.join(m.groups())} == "
|
||
f"audit_report.totals ({a5_vsrc})")
|
||
else:
|
||
bad(f"Audit 5D · {A5D_RD} — dit {'/'.join(m.groups())} MAIS totals disent "
|
||
f"{a5_pass}/{a5_fail}/{a5_conf} ({a5_vsrc}) → régénérer le README")
|
||
# 3) Fiche agent QA — même couple contrôles/dimensions, 2e transcription
|
||
a5_fi = read_norm(A5D_FI)
|
||
if a5_fi is not None:
|
||
m = re.search(r"(\d+) contrôles / (\d+) dimensions", a5_fi)
|
||
if m is None:
|
||
bad(f"Audit 5D · {A5D_FI} — claim « N contrôles / M dimensions » INTROUVABLE "
|
||
f"(attendu {a5_src})")
|
||
elif m.groups() == (a5_ctl, a5_dim):
|
||
good(f"Audit 5D · {A5D_FI} — {'/'.join(m.groups())} == artefact ({a5_src})")
|
||
else:
|
||
bad(f"Audit 5D · {A5D_FI} — dit {'/'.join(m.groups())} MAIS artefact dit "
|
||
f"{a5_ctl}/{a5_dim} ({a5_src}) → régénérer la fiche")
|
||
|
||
# ============================================================================
|
||
# Audit 5D (2e surface du MÊME README) · ÉNUMÉRATION des open items du « Verdict
|
||
# courant » (control → dimension → propriétaire) — même classe de « vert trompeur ».
|
||
# ----------------------------------------------------------------------------
|
||
# Le bloc précédent gate le COMPTE de la ventilation (« 13 PASS · 0 FAIL · 4 à
|
||
# confirmer », pass/fail/à confirmer recomputés d'audit_report.totals) mais PAS
|
||
# l'IDENTITÉ des 4 contrôles ouverts ni leur (dimension, propriétaire). La table
|
||
# « Verdict courant » du README (05_deliverables_mvp/qa/audit_5d/README.md) énumère
|
||
# ces 4 lignes À LA MAIN, alors que audit_report.open_items[] (byte-gaté par
|
||
# check_artifacts ⇒ faisant autorité, chaque item = {control, dimension, owner,
|
||
# detail} recalculé en rejouant les contrôles) fait foi. Un ÉCHANGE d'open item
|
||
# (ex. D2.3 → D3.1) laisse le compte à 4 — le bloc de ventilation reste AVEUGLE —
|
||
# pendant que l'artefact dit autre chose ; idem une dimension mal étiquetée
|
||
# (D1.3 rangé sous D2) ou un propriétaire réattribué. « Vert trompeur » qu'aucune
|
||
# suite tests/ (qui teste des FONCTIONS, pas la prose) n'attrape. On RECOMPUTE
|
||
# l'ensemble {control:(dimension, owner)} depuis open_items (zéro duplication du
|
||
# modèle de l'auditeur · #6) et on exige que la table README l'énumère EXACTEMENT :
|
||
# contrôle par ENSEMBLE (absent ET en trop mordus, pas seulement présence), puis
|
||
# (dimension, propriétaire) par LIGNE. Même patron que l'énumération des
|
||
# confirmations DevOps ou la carte de renormalisation par archétype 4Big. Cohérence
|
||
# croisée en bonus : l'ensemble des `control` == MANIFEST.open_items (le manifeste
|
||
# qui résume le rapport) — mord un manifeste désynchronisé de son propre rapport.
|
||
# ============================================================================
|
||
try:
|
||
a5_oi = load("qa/audit_5d/out/audit_report.json")["open_items"]
|
||
a5_oi_exp = {o["control"]: (o["dimension"], o["owner"]) for o in a5_oi}
|
||
a5_oi_man = set(load("qa/audit_5d/out/MANIFEST.json")["open_items"])
|
||
if a5_oi_man != set(a5_oi_exp):
|
||
bad(f"Audit 5D · incohérence artefacts : MANIFEST open_items {sorted(a5_oi_man)} "
|
||
f"MAIS audit_report open_items {sorted(a5_oi_exp)}")
|
||
except (OSError, KeyError, ValueError) as e:
|
||
bad(f"Audit 5D · open_items illisible/inattendu : {e}"); a5_oi_exp = None
|
||
if a5_oi_exp is not None:
|
||
try:
|
||
a5_lines = open(f"{D}/qa/audit_5d/README.md",
|
||
encoding="utf-8").read().splitlines()
|
||
except OSError as e:
|
||
bad(f"Audit 5D · README illisible : {e}"); a5_lines = None
|
||
if a5_lines is not None:
|
||
# ligne de table = « | <control> · <libellé> | <dimension> | <propriétaire> | »
|
||
a5_row_re = re.compile(
|
||
r"^\|\s*(D\d+\.\d+)\b[^|]*\|\s*([^|]+?)\s*\|\s*([^|]+?)\s*\|\s*$")
|
||
a5_seen = {}
|
||
for ln in a5_lines:
|
||
m = a5_row_re.match(ln)
|
||
if m:
|
||
a5_seen[m.group(1)] = (m.group(2).strip(), m.group(3).strip())
|
||
a5_exp_set, a5_got_set = set(a5_oi_exp), set(a5_seen)
|
||
if a5_got_set != a5_exp_set:
|
||
bad(f"Audit 5D · énumération open items — table README énumère "
|
||
f"{sorted(a5_got_set)} MAIS audit_report dit {sorted(a5_exp_set)} "
|
||
f"→ régénérer le README")
|
||
else:
|
||
a5_mism = [c for c in sorted(a5_exp_set) if a5_seen[c] != a5_oi_exp[c]]
|
||
if a5_mism:
|
||
for c in a5_mism:
|
||
bad(f"Audit 5D · open item {c} — README dit "
|
||
f"(dimension={a5_seen[c][0]}, propriétaire={a5_seen[c][1]}) MAIS "
|
||
f"audit_report dit (dimension={a5_oi_exp[c][0]}, "
|
||
f"propriétaire={a5_oi_exp[c][1]})")
|
||
else:
|
||
good(f"Audit 5D · énumération open items {sorted(a5_exp_set)} "
|
||
f"(control→dimension→propriétaire) == audit_report.open_items")
|
||
|
||
# ============================================================================
|
||
# DevOps · run-book de déploiement VPS (phases ordonnées / confirmations
|
||
# préalables) — même classe de « vert trompeur », transcrite À LA MAIN dans
|
||
# DEUX docs, sans AUCUN gate.
|
||
# ----------------------------------------------------------------------------
|
||
# Le générateur deploy_runbook émet out/MANIFEST.json (byte-gaté par
|
||
# check_artifacts ⇒ faisant autorité) dont `counts.phases` et
|
||
# `counts.confirmations` sont RECOMPTÉS à chaque build (phases = |graph.phase_order| ·
|
||
# confirmations = |confirmations_ouvertes|, l'ensemble consolidé des paramètres
|
||
# réglementaires non confirmés). Deux nombres de cette synthèse sont recopiés À LA
|
||
# MAIN : le compte de PHASES dans la fiche agent DevOps (03_agents/devops/AGENT.md —
|
||
# « graphe de **7 phases ordonnées** » ET, plus bas, « exécute les 7 phases sur le
|
||
# VPS ») et le compte de CONFIRMATIONS dans le README du module
|
||
# (05_deliverables_mvp/devops/deploy_runbook/README.md — « renseigner les 7
|
||
# confirmations »). Piège « aveugle » caractéristique (cf. bascule verdict audit_5d,
|
||
# compensation SEO entre langues) : phases ET confirmations valent 7 AUJOURD'HUI —
|
||
# un gate qui ne recompterait qu'UN nombre serait AVEUGLE à la dérive de l'autre
|
||
# (ajouter une phase au graphe → 8 phases pendant que les confirmations restent 7,
|
||
# ou l'inverse). On RECOMPUTE donc CHAQUE nombre depuis SON PROPRE champ du MANIFEST
|
||
# (jamais figé ici · zéro duplication du compteur du générateur #5) et on exige
|
||
# l'égalité avec les docs. Bonus anti-dérive : l'ÉNUMÉRATION des confirmations du
|
||
# README (les tokens `snake_case` de la phrase « Confirmations préalables VPS »)
|
||
# doit être l'ensemble EXACT de confirmations_ouvertes — capte l'ÉCHANGE d'un nom de
|
||
# confirmation (count aveugle à un swap 1↔1). Cohérence croisée : counts.phases ==
|
||
# |phase_order| · counts.confirmations == |confirmations_ouvertes|. Un claim absent
|
||
# échoue AUSSI (la disparition d'un chiffre est une régression · traçabilité).
|
||
# ============================================================================
|
||
DR_RD = f"{D}/devops/deploy_runbook/README.md"
|
||
DR_FI = "03_agents/devops/AGENT.md"
|
||
try:
|
||
dr_man = load("devops/deploy_runbook/out/MANIFEST.json")
|
||
dr_ph = str(dr_man["counts"]["phases"])
|
||
dr_cf = str(dr_man["counts"]["confirmations"])
|
||
dr_order = dr_man["graph"]["phase_order"]
|
||
dr_open = dr_man["confirmations_ouvertes"]
|
||
if dr_ph != str(len(dr_order)):
|
||
bad(f"DevOps · incohérence artefact : counts.phases={dr_ph} MAIS "
|
||
f"|graph.phase_order|={len(dr_order)}")
|
||
if dr_cf != str(len(dr_open)):
|
||
bad(f"DevOps · incohérence artefact : counts.confirmations={dr_cf} MAIS "
|
||
f"|confirmations_ouvertes|={len(dr_open)}")
|
||
except (OSError, KeyError, ValueError) as e:
|
||
bad(f"DevOps · artefact deploy_runbook illisible/inattendu : {e}"); dr_ph = None
|
||
if dr_ph is not None:
|
||
# 1) Fiche agent DevOps — « graphe de N phases ordonnées »
|
||
dr_fi = read_norm(DR_FI)
|
||
if dr_fi is not None:
|
||
m = re.search(r"graphe de \*\*(\d+) phases ordonnées\*\*", dr_fi)
|
||
if m is None:
|
||
bad(f"DevOps · {DR_FI} — claim « graphe de N phases ordonnées » INTROUVABLE "
|
||
f"(attendu phases={dr_ph})")
|
||
elif m.group(1) == dr_ph:
|
||
good(f"DevOps · {DR_FI} — {m.group(1)} phases == artefact (phases={dr_ph})")
|
||
else:
|
||
bad(f"DevOps · {DR_FI} — dit {m.group(1)} phases MAIS artefact dit {dr_ph} "
|
||
f"→ régénérer la fiche")
|
||
# 2) Fiche agent DevOps — 2e transcription « exécute les N phases »
|
||
m = re.search(r"exécute les (\d+) phases", dr_fi)
|
||
if m is None:
|
||
bad(f"DevOps · {DR_FI} — 2e claim « exécute les N phases » INTROUVABLE "
|
||
f"(attendu phases={dr_ph})")
|
||
elif m.group(1) == dr_ph:
|
||
good(f"DevOps · {DR_FI} — « exécute les {m.group(1)} phases » == artefact "
|
||
f"(phases={dr_ph})")
|
||
else:
|
||
bad(f"DevOps · {DR_FI} — 2e claim dit {m.group(1)} phases MAIS artefact dit "
|
||
f"{dr_ph} → régénérer la fiche")
|
||
# 3) README module — « renseigner les N confirmations » (compte)
|
||
dr_rd = read_norm(DR_RD)
|
||
if dr_rd is not None:
|
||
m = re.search(r"renseigner les (\d+) confirmations", dr_rd)
|
||
if m is None:
|
||
bad(f"DevOps · {DR_RD} — claim « renseigner les N confirmations » INTROUVABLE "
|
||
f"(attendu confirmations={dr_cf})")
|
||
elif m.group(1) == dr_cf:
|
||
good(f"DevOps · {DR_RD} — {m.group(1)} confirmations == artefact "
|
||
f"(confirmations={dr_cf})")
|
||
else:
|
||
bad(f"DevOps · {DR_RD} — dit {m.group(1)} confirmations MAIS artefact dit "
|
||
f"{dr_cf} → régénérer le README")
|
||
# 4) README module — l'ÉNUMÉRATION doit être l'ensemble EXACT des confirmations
|
||
seg = re.search(r"Confirmations préalables VPS\*\* \(consolidées, sourcées\) : "
|
||
r"(.+?) ## ", dr_rd)
|
||
if seg is None:
|
||
bad(f"DevOps · {DR_RD} — énumération « Confirmations préalables VPS » "
|
||
f"INTROUVABLE (attendu {sorted(dr_open)})")
|
||
else:
|
||
listed = sorted(set(re.findall(r"`([a-z_]+)`", seg.group(1))))
|
||
if listed == sorted(dr_open):
|
||
good(f"DevOps · {DR_RD} — énumération ({len(listed)}) == confirmations_ouvertes")
|
||
else:
|
||
bad(f"DevOps · {DR_RD} — énumération {listed} ≠ confirmations_ouvertes "
|
||
f"{sorted(dr_open)} → régénérer le README")
|
||
|
||
# 2bis) deploy_runbook — IDENTITÉ de la TABLE « Plan de phases généré » -----------
|
||
# Le bloc ci-dessus ne gate que DEUX comptes agrégés (phases · confirmations). La
|
||
# table « ## Plan de phases généré » énumère, PAR phase, QUATRE données data-derived
|
||
# de out/deploy_runbook.json (byte-gaté par check_artifacts) : le n° d'ordre (#), le
|
||
# RESPONSABLE (worker/vps/worker+vps), l'ensemble des MODULES gated déployés dans la
|
||
# phase, et « Dépend de » (les n°s d'ordre des `depends_on`). C'est le GRAPHE DE
|
||
# PORTAGE VPS ORDONNÉ que l'agent DevOps suit phase par phase — un module OUBLIÉ de
|
||
# la colonne Modules (donc jamais déployé), un responsable ou une dépendance périmés
|
||
# (ex. Frontend avant RBAC) sont un hazard réel, invisible à check_artifacts (qui ne
|
||
# prouve QUE deploy_runbook==build) ET au bloc de comptes (aveugle à l'identité des
|
||
# lignes ; ces colonnes étaient un WILDCARD). Même classe que la table run-book de
|
||
# rbac/apply_plan et la table demo/scenarios. Preuve reproduite : la table omettait
|
||
# `mobile/app_config` (phase 5) ET `qa/acceptance` (phase 7) — 20 modules listés sur
|
||
# 22 — pendant que le MANIFEST prouvait la couverture BIJECTIVE 22/22 vs CI : vert
|
||
# trompeur, l'agent DevOps aurait sauté 2 modules. On RECOMPUTE #/responsable/
|
||
# modules/deps depuis deploy_runbook.json (zéro duplication) et on exige que la table
|
||
# les énonce EXACTEMENT. Identité d'ensemble : {ordres des lignes} == {ordres de
|
||
# l'artefact} (aucune phase FANTÔME/MANQUANTE) ET l'UNION des cellules Modules == les
|
||
# 22 modules de l'artefact (aucun module OUBLIÉ/en trop). La colonne « Phase » reste
|
||
# libre (paraphrase). Cohérences croisées en bonus (mordent un plan INTERNEMENT
|
||
# incohérent) : ordres contigus 1..N sans doublon · responsable ∈ {worker, vps,
|
||
# worker+vps} · toute dépendance pointe en ARRIÈRE (n° < n° de la phase ⇒ pas de
|
||
# cycle/renvoi avant).
|
||
try:
|
||
dr_plan = load("devops/deploy_runbook/out/deploy_runbook.json")
|
||
except (OSError, KeyError, ValueError) as e:
|
||
bad(f"DevOps · run-book illisible : {e}"); dr_plan = None
|
||
if dr_plan is not None:
|
||
id2order = {p["id"]: p["order"] for p in dr_plan}
|
||
ALLOWED_RESP = {"worker", "vps", "worker+vps"}
|
||
orders = sorted(p["order"] for p in dr_plan)
|
||
all_modules = sorted(x["module"] for p in dr_plan for x in p["modules"])
|
||
if orders == list(range(1, len(dr_plan) + 1)):
|
||
good(f"DevOps · run-book — ordres contigus 1..{len(dr_plan)} (aucun trou/doublon)")
|
||
else:
|
||
bad(f"DevOps · run-book — ordres NON contigus {orders} "
|
||
f"(attendu 1..{len(dr_plan)}) → plan incohérent")
|
||
# Lignes lues en RAW : la table « Plan de phases généré » est la SEULE à 5
|
||
# colonnes dont la 1ʳᵉ est numérique (l'autre table est `Fichier | Rôle`).
|
||
try:
|
||
dr_tbl = open(DR_RD, encoding="utf-8").read().splitlines()
|
||
except OSError as e:
|
||
bad(f"DevOps · {DR_RD} illisible : {e}"); dr_tbl = []
|
||
table_orders, rows_by_order = [], {}
|
||
for ln in dr_tbl:
|
||
cells = [c.strip() for c in ln.strip().strip("|").split("|")]
|
||
if len(cells) == 5 and re.fullmatch(r"\d+", cells[0]):
|
||
o = int(cells[0]); table_orders.append(o); rows_by_order[o] = cells
|
||
if sorted(table_orders) == orders and len(table_orders) == len(set(table_orders)):
|
||
good(f"DevOps · run-book — {len(table_orders)} lignes == {len(dr_plan)} phases "
|
||
f"(aucune FANTÔME/MANQUANTE)")
|
||
else:
|
||
bad(f"DevOps · run-book — lignes table {sorted(table_orders)} ≠ phases artefact "
|
||
f"{orders} (ligne en trop/manquante ou doublon)")
|
||
table_modules = sorted(m for r in rows_by_order.values()
|
||
for m in re.findall(r"`([^`]+)`", r[3]))
|
||
if table_modules == all_modules:
|
||
good(f"DevOps · run-book — union des Modules ({len(table_modules)}) == "
|
||
f"{len(all_modules)} modules de l'artefact (aucun OUBLIÉ/en trop)")
|
||
else:
|
||
miss = sorted(set(all_modules) - set(table_modules))
|
||
extra = sorted(set(table_modules) - set(all_modules))
|
||
bad(f"DevOps · run-book — colonne Modules ≠ artefact : manquants={miss or '—'} "
|
||
f"en_trop={extra or '—'} → régénérer le README")
|
||
for p in sorted(dr_plan, key=lambda x: x["order"]):
|
||
o, resp = p["order"], p["responsable"]
|
||
deps = sorted(id2order[x] for x in p["depends_on"])
|
||
mods = sorted(x["module"] for x in p["modules"])
|
||
if resp not in ALLOWED_RESP:
|
||
bad(f"DevOps · phase {o} — responsable artefact « {resp} » "
|
||
f"hors {sorted(ALLOWED_RESP)} (plan incohérent)")
|
||
if any(d >= o for d in deps):
|
||
bad(f"DevOps · phase {o} — dépendance en AVANT {deps} (≥ {o}) : graphe incohérent")
|
||
row = rows_by_order.get(o)
|
||
if row is None:
|
||
bad(f"DevOps · phase {o} — ligne INTROUVABLE dans {DR_RD} "
|
||
f"(attendu responsable={resp} modules={mods} deps={deps or '—'})"); continue
|
||
got_resp = row[1]
|
||
got_mods = sorted(re.findall(r"`([^`]+)`", row[3]))
|
||
got_deps = sorted(int(x) for x in re.findall(r"\d+", row[4]))
|
||
if got_resp == resp:
|
||
good(f"DevOps · phase {o} — responsable « {got_resp} » == artefact")
|
||
else:
|
||
bad(f"DevOps · phase {o} — {DR_RD} dit responsable « {got_resp} » MAIS "
|
||
f"artefact dit « {resp} » → régénérer le README")
|
||
if got_mods == mods:
|
||
good(f"DevOps · phase {o} — Modules ({len(got_mods)}) == artefact")
|
||
else:
|
||
bad(f"DevOps · phase {o} — {DR_RD} Modules {got_mods} MAIS artefact dit "
|
||
f"{mods} → régénérer le README")
|
||
if got_deps == deps:
|
||
good(f"DevOps · phase {o} — Dépend de {got_deps or '—'} == artefact")
|
||
else:
|
||
bad(f"DevOps · phase {o} — {DR_RD} « Dépend de » = {got_deps or '—'} MAIS "
|
||
f"artefact dit {deps or '—'} → régénérer le README")
|
||
|
||
# 2ter) deploy_runbook — IMPUTABILITÉ des CONFIRMATIONS préalables VPS ------------
|
||
# La ligne « Confirmations préalables VPS » du README annote CHAQUE confirmation
|
||
# réglementaire d'un COUPLE data-derived : `nom` (OWNER · RÉF-CONTRÔLE), ex.
|
||
# `taux_commission` (Direction · audit_5d D1.1). L'OWNER = QUI doit confirmer le
|
||
# paramètre avant déploiement ; la RÉF = le contrôle qa/audit_5d (`D#.#`) — ou la
|
||
# session — qui en assure le suivi. Source faisant autorité : les `confirmations`
|
||
# de out/deploy_runbook.json (byte-gaté) — chaque item porte `owner` + `source` —
|
||
# CROISÉES avec le rapport qa/audit_5d (byte-gaté) : le contrôle cité y porte SON
|
||
# PROPRE `owner`. Les blocs DevOps amont ne gatent que le COMPTE de confirmations
|
||
# (2) et l'ENSEMBLE de leurs noms (bloc énumération) — AVEUGLES à QUI porte chaque
|
||
# confirmation et à QUEL contrôle la trace. Piège #6 : RÉAFFECTER `taux_commission`
|
||
# de Direction à Ventes (mauvais responsable relancé → confirmation jamais obtenue),
|
||
# MISATTRIBUER le contrôle (`D1.1`→`D2.1`, pointant un contrôle qui ne trace PAS ce
|
||
# paramètre) ou citer un contrôle FANTÔME (`D9.9`, absent d'audit_5d) laisse la
|
||
# prose périmée EN SILENCE pendant que l'artefact/l'audit disent autre chose →
|
||
# l'agent DevOps escaladerait le mauvais owner / le mauvais contrôle. La table
|
||
# phases (2bis) est aveugle aux confirmations ; aucune suite tests/ (FONCTIONS de
|
||
# graphe) n'attrape la prose. Cross-anchor à 3 : owner runbook COMPATIBLE avec
|
||
# owner du contrôle audit_5d (sous-ensemble de tokens · tolère le suffixe éditorial
|
||
# « / UAF ») · owner README ⊆ owner runbook (tolère l'abréviation « Fiscaliste eCF »
|
||
# ⊂ « Compta Fiscaliste eCF ») · réf README == contrôle réel (présent dans audit_5d
|
||
# ET dans la source). Une confirmation tracée par un contrôle DOIT rester annotée
|
||
# (disparition = régression de traçabilité #6). Une annotation d'un nom FANTÔME
|
||
# échoue AUSSI.
|
||
try:
|
||
a5_rep = load("qa/audit_5d/out/audit_report.json")
|
||
a5_owner = {c["id"]: c["owner"] for c in a5_rep["controls"]}
|
||
except (OSError, KeyError, ValueError) as e:
|
||
bad(f"DevOps · confirmations — rapport audit_5d illisible : {e}"); a5_owner = None
|
||
dr_rd2 = read_norm(DR_RD)
|
||
if dr_plan is not None and a5_owner is not None and dr_rd2 is not None:
|
||
def _toks(s):
|
||
return {t for t in re.split(r"\s+", s.strip()) if t}
|
||
def _ctrl(s):
|
||
m = re.search(r"D\d\.\d+", s)
|
||
return m.group(0) if m else None
|
||
# confirmations aplaties (id → item) + unicité inter-phases
|
||
conf_by_id, dups = {}, []
|
||
for p in dr_plan:
|
||
for c in p.get("confirmations", []):
|
||
if c["id"] in conf_by_id:
|
||
dups.append(c["id"])
|
||
conf_by_id[c["id"]] = c
|
||
if dups:
|
||
bad(f"DevOps · confirmations — id(s) DUPLIQUÉ(s) inter-phases "
|
||
f"{sorted(set(dups))} (plan incohérent)")
|
||
# tracked : confirmations dont la `source` cite un contrôle audit_5d `D#.#`
|
||
tracked = {cid: _ctrl(c["source"]) for cid, c in conf_by_id.items()}
|
||
# annotations README : `nom` (owner · réf)
|
||
ann = {}
|
||
seg = re.search(r"Confirmations préalables VPS\*\* \(consolidées, sourcées\) : "
|
||
r"(.+?) ## ", dr_rd2)
|
||
if seg is None:
|
||
bad(f"DevOps · {DR_RD} — ligne « Confirmations préalables VPS » INTROUVABLE "
|
||
f"(imputabilité non vérifiable)")
|
||
else:
|
||
for name, body in re.findall(r"`([a-z_]+)`\s*\(([^)]+)\)", seg.group(1)):
|
||
parts = [x.strip() for x in body.split("·")]
|
||
if len(parts) == 2:
|
||
ann[name] = {"owner": parts[0], "ref": parts[1]}
|
||
# (a) cross-module : chaque confirmation tracée porte un contrôle audit_5d RÉEL
|
||
# dont l'owner est COMPATIBLE avec l'owner du run-book (séparation des
|
||
# pouvoirs cohérente entre modules).
|
||
for cid, ctrl in sorted(tracked.items()):
|
||
if ctrl is None:
|
||
continue
|
||
c = conf_by_id[cid]
|
||
if ctrl not in a5_owner:
|
||
bad(f"DevOps · confirmation `{cid}` — source cite le contrôle `{ctrl}` "
|
||
f"ABSENT du rapport qa/audit_5d (contrôle FANTÔME)"); continue
|
||
if not _toks(c["owner"]) <= _toks(a5_owner[ctrl]):
|
||
bad(f"DevOps · confirmation `{cid}` — owner run-book « {c['owner']} » "
|
||
f"INCOMPATIBLE avec owner du contrôle {ctrl} audit_5d "
|
||
f"« {a5_owner[ctrl]} » (imputabilité divergente inter-modules)")
|
||
else:
|
||
good(f"DevOps · confirmation `{cid}` — owner « {c['owner']} » ⊆ owner "
|
||
f"contrôle {ctrl} audit_5d (imputabilité cohérente)")
|
||
if cid not in ann:
|
||
bad(f"DevOps · confirmation `{cid}` (tracée par {ctrl}) — annotation "
|
||
f"« (owner · réf) » INTROUVABLE dans {DR_RD} → régénérer le README")
|
||
# (b) chaque annotation README désigne une confirmation RÉELLE + owner/réf exacts
|
||
for cid, a in sorted(ann.items()):
|
||
c = conf_by_id.get(cid)
|
||
if c is None:
|
||
bad(f"DevOps · annotation README `{cid}` — confirmation FANTÔME "
|
||
f"(absente du run-book) → régénérer le README"); continue
|
||
if not _toks(a["owner"]) <= _toks(c["owner"]):
|
||
bad(f"DevOps · confirmation `{cid}` — README owner « {a['owner']} » "
|
||
f"n'est PAS un sous-ensemble de l'owner artefact « {c['owner']} » "
|
||
f"→ mauvais responsable")
|
||
else:
|
||
good(f"DevOps · confirmation `{cid}` — README owner « {a['owner']} » ⊆ "
|
||
f"artefact « {c['owner']} »")
|
||
ref_ctrl = _ctrl(a["ref"])
|
||
if ref_ctrl is not None:
|
||
# réf = contrôle audit_5d : doit == le contrôle de la source ET exister
|
||
if ref_ctrl != tracked.get(cid):
|
||
bad(f"DevOps · confirmation `{cid}` — README réf cite {ref_ctrl} MAIS "
|
||
f"la source artefact trace {tracked.get(cid) or '(aucun contrôle)'} "
|
||
f"→ contrôle audit_5d périmé")
|
||
elif ref_ctrl not in a5_owner:
|
||
bad(f"DevOps · confirmation `{cid}` — README réf cite le contrôle "
|
||
f"`{ref_ctrl}` ABSENT du rapport qa/audit_5d (fantôme)")
|
||
else:
|
||
good(f"DevOps · confirmation `{cid}` — réf README == contrôle "
|
||
f"{ref_ctrl} (présent audit_5d + source)")
|
||
else:
|
||
# réf textuelle (ex. « session 19 ») : doit apparaître dans la source
|
||
if a["ref"] not in c["source"]:
|
||
bad(f"DevOps · confirmation `{cid}` — README réf « {a['ref']} » ABSENTE "
|
||
f"de la source artefact « {c['source']} »")
|
||
else:
|
||
good(f"DevOps · confirmation `{cid}` — réf « {a['ref']} » == source artefact")
|
||
|
||
# ============================================================================
|
||
# Démo · run-sheet de pitch (nombre de modules cités) — même classe de « vert
|
||
# trompeur », transcrit À LA MAIN dans le diagramme d'architecture du README du
|
||
# module, sans AUCUN gate.
|
||
# ----------------------------------------------------------------------------
|
||
# Le générateur demo_scenario_gen émet out/MANIFEST.json (byte-gaté par
|
||
# check_artifacts ⇒ faisant autorité) dont `counts.modules_cites_uniques` est
|
||
# RECOMPTÉ à chaque build = |union des `modules_cites` des deux scénarios| — les
|
||
# hand-off amont que les beats citent par pointeur RFC 6901 (le module ne saisit
|
||
# AUCUNE valeur métier ; il résout depuis le disque). Ce nombre est recopié À LA
|
||
# MAIN dans le diagramme du README du module (05_deliverables_mvp/demo/scenarios/
|
||
# README.md — « out/ des modules livrés (10 modules cités) »). Aucun gate ne le
|
||
# couvrait : ajouter un beat qui cite un module encore non tissé (11) ou retirer
|
||
# la dernière citation d'un module d'un scénario (9) ferait dériver le diagramme
|
||
# en silence pendant que le MANIFEST byte-gaté dit autre chose — dérive qu'aucune
|
||
# suite tests/ (qui teste des FONCTIONS, pas la prose du README) n'attrape. Même
|
||
# piège que les mots-clés SEO, les triplets par workspace des portails, le
|
||
# quintuplet CONFOTUR ou la synthèse audit_5d. On RECOMPUTE depuis
|
||
# counts.modules_cites_uniques (jamais figé ici · zéro duplication du compteur du
|
||
# générateur #5) et on exige l'égalité. Cohérence croisée en bonus :
|
||
# counts.modules_cites_uniques == |modules_cites| (liste dédupliquée du manifeste,
|
||
# elle-même sans doublon) — mord un compteur désynchronisé de sa propre liste. Un
|
||
# claim absent échoue AUSSI (la disparition d'un chiffre est une régression).
|
||
# ============================================================================
|
||
DEMO_RD = f"{D}/demo/scenarios/README.md"
|
||
try:
|
||
demo_man = load("demo/scenarios/out/MANIFEST.json")
|
||
demo_n = str(demo_man["counts"]["modules_cites_uniques"])
|
||
demo_list = demo_man["modules_cites"]
|
||
if len(demo_list) != len(set(demo_list)):
|
||
bad(f"Démo · incohérence artefact : modules_cites contient des doublons "
|
||
f"({len(demo_list)} entrées, {len(set(demo_list))} uniques)")
|
||
if demo_n != str(len(demo_list)):
|
||
bad(f"Démo · incohérence artefact : counts.modules_cites_uniques={demo_n} "
|
||
f"MAIS |modules_cites|={len(demo_list)}")
|
||
except (OSError, KeyError, ValueError) as e:
|
||
bad(f"Démo · artefact scenarios illisible/inattendu : {e}"); demo_n = None
|
||
if demo_n is not None:
|
||
demo_rd = read_norm(DEMO_RD)
|
||
if demo_rd is not None:
|
||
m = re.search(r"out/ des modules livrés \((\d+) modules cités\)", demo_rd)
|
||
if m is None:
|
||
bad(f"Démo · {DEMO_RD} — claim « (N modules cités) » INTROUVABLE "
|
||
f"(attendu modules_cites_uniques={demo_n})")
|
||
elif m.group(1) == demo_n:
|
||
good(f"Démo · {DEMO_RD} — {m.group(1)} modules cités == artefact "
|
||
f"(modules_cites_uniques={demo_n})")
|
||
else:
|
||
bad(f"Démo · {DEMO_RD} — dit {m.group(1)} modules cités MAIS artefact dit "
|
||
f"{demo_n} → régénérer le README")
|
||
|
||
# ============================================================================
|
||
# Chat OTOIA · montage de l'assistant conversationnel par portail (nombre de
|
||
# Custom Block / configs runtime / portails) — même classe de « vert trompeur »,
|
||
# transcrit À LA MAIN dans DEUX docs, sans AUCUN gate.
|
||
# ----------------------------------------------------------------------------
|
||
# Le générateur chat_otoia_gen émet out/MANIFEST.json (byte-gaté par
|
||
# check_artifacts ⇒ faisant autorité) dont `counts.custom_blocks`, `counts.mounts`
|
||
# et `counts.portails` sont RECOMPTÉS à chaque build depuis le contrat RBAC (un
|
||
# `Custom Block` ⇔ une config runtime ⇔ un portail métier, `plateforme` exclu —
|
||
# invariant 2 du générateur : bijection 1 block ⇔ 1 mount ⇔ 1 portail). Ces
|
||
# nombres sont recopiés À LA MAIN : la fiche agent Frontend Console
|
||
# (03_agents/frontend_console/AGENT.md — « **N `Custom Block` + M configs
|
||
# runtime** ») ET le README du module (05_deliverables_mvp/frontend/chat_otoia/
|
||
# README.md — « Portails (N, `plateforme` exclu) », « N fixtures `Custom Block` »,
|
||
# « M configs runtime »). Piège « aveugle » caractéristique (cf. DevOps phases ⁄
|
||
# confirmations, bascule verdict audit_5d) : custom_blocks, mounts ET portails
|
||
# valent TOUS TROIS 5 AUJOURD'HUI — un gate qui n'en recompterait qu'un serait
|
||
# AVEUGLE à la dérive des autres (ajouter un portail au contrat RBAC → chat_otoia
|
||
# rebâtit 6 blocks ⁄ 6 mounts ⁄ 6 portails pendant que les docs restent à 5). On
|
||
# RECOMPUTE donc CHAQUE nombre depuis SON PROPRE champ du MANIFEST (jamais figé
|
||
# ici · zéro duplication du compteur du générateur #5) et on exige l'égalité avec
|
||
# les docs. Cohérence croisée : counts.custom_blocks == counts.mounts ==
|
||
# counts.portails == |MANIFEST.portails| (la bijection du générateur) — mord un
|
||
# compteur désynchronisé de sa propre liste. Un claim absent échoue AUSSI (la
|
||
# disparition d'un chiffre est une régression · traçabilité).
|
||
# ============================================================================
|
||
CO_RD = f"{D}/frontend/chat_otoia/README.md"
|
||
CO_FI = "03_agents/frontend_console/AGENT.md"
|
||
try:
|
||
co_man = load("frontend/chat_otoia/out/MANIFEST.json")
|
||
co_cb = str(co_man["counts"]["custom_blocks"])
|
||
co_mo = str(co_man["counts"]["mounts"])
|
||
co_po = str(co_man["counts"]["portails"])
|
||
co_nlist = len(co_man["portails"])
|
||
if not (co_cb == co_mo == co_po == str(co_nlist)):
|
||
bad(f"Chat OTOIA · incohérence artefact : custom_blocks={co_cb} · "
|
||
f"mounts={co_mo} · portails={co_po} · |portails|={co_nlist} "
|
||
f"(bijection 1 block ⇔ 1 mount ⇔ 1 portail attendue)")
|
||
except (OSError, KeyError, ValueError) as e:
|
||
bad(f"Chat OTOIA · artefact chat_otoia illisible/inattendu : {e}"); co_cb = None
|
||
if co_cb is not None:
|
||
# 1) Fiche agent Frontend Console — « N `Custom Block` + M configs runtime »
|
||
co_fi = read_norm(CO_FI)
|
||
if co_fi is not None:
|
||
m = re.search(r"\*\*(\d+) `Custom Block` \+ (\d+) configs runtime\*\*", co_fi)
|
||
if m is None:
|
||
bad(f"Chat OTOIA · {CO_FI} — claim « N `Custom Block` + M configs runtime » "
|
||
f"INTROUVABLE (attendu {co_cb}/{co_mo})")
|
||
elif m.groups() == (co_cb, co_mo):
|
||
good(f"Chat OTOIA · {CO_FI} — {'/'.join(m.groups())} == artefact "
|
||
f"(custom_blocks={co_cb} · mounts={co_mo})")
|
||
else:
|
||
bad(f"Chat OTOIA · {CO_FI} — dit {'/'.join(m.groups())} MAIS artefact dit "
|
||
f"{co_cb}/{co_mo} → régénérer la fiche")
|
||
# 2) README module — « Portails (N, `plateforme` exclu) »
|
||
co_rd = read_norm(CO_RD)
|
||
if co_rd is not None:
|
||
m = re.search(r"Portails \((\d+), `plateforme` exclu\)", co_rd)
|
||
if m is None:
|
||
bad(f"Chat OTOIA · {CO_RD} — claim « Portails (N, `plateforme` exclu) » "
|
||
f"INTROUVABLE (attendu portails={co_po})")
|
||
elif m.group(1) == co_po:
|
||
good(f"Chat OTOIA · {CO_RD} — {m.group(1)} portails == artefact "
|
||
f"(portails={co_po})")
|
||
else:
|
||
bad(f"Chat OTOIA · {CO_RD} — dit {m.group(1)} portails MAIS artefact dit "
|
||
f"{co_po} → régénérer le README")
|
||
# 3) README module — « N fixtures `Custom Block` »
|
||
m = re.search(r"(\d+) fixtures `Custom Block`", co_rd)
|
||
if m is None:
|
||
bad(f"Chat OTOIA · {CO_RD} — claim « N fixtures `Custom Block` » INTROUVABLE "
|
||
f"(attendu custom_blocks={co_cb})")
|
||
elif m.group(1) == co_cb:
|
||
good(f"Chat OTOIA · {CO_RD} — {m.group(1)} fixtures == artefact "
|
||
f"(custom_blocks={co_cb})")
|
||
else:
|
||
bad(f"Chat OTOIA · {CO_RD} — dit {m.group(1)} fixtures MAIS artefact dit "
|
||
f"{co_cb} → régénérer le README")
|
||
# 4) README module — « M configs runtime » (2e transcription des mounts)
|
||
m = re.search(r"(\d+) configs runtime", co_rd)
|
||
if m is None:
|
||
bad(f"Chat OTOIA · {CO_RD} — claim « M configs runtime » INTROUVABLE "
|
||
f"(attendu mounts={co_mo})")
|
||
elif m.group(1) == co_mo:
|
||
good(f"Chat OTOIA · {CO_RD} — {m.group(1)} configs runtime == artefact "
|
||
f"(mounts={co_mo})")
|
||
else:
|
||
bad(f"Chat OTOIA · {CO_RD} — dit {m.group(1)} configs runtime MAIS artefact "
|
||
f"dit {co_mo} → régénérer le README")
|
||
|
||
# ============================================================================
|
||
# Chat OTOIA · IDENTITÉ des configs runtime (persona · langues · endpoint) —
|
||
# ATTRIBUTS transcrits À LA MAIN dans la cellule « Métrique vérifiée » de la
|
||
# fiche agent, AVEUGLES au gate de COMPTE ci-dessus.
|
||
# ----------------------------------------------------------------------------
|
||
# Le bloc précédent gate le NOMBRE de blocks/mounts/portails ; il est indifférent
|
||
# à CE QUE chaque config runtime dit. Or la même cellule (03_agents/
|
||
# frontend_console/AGENT.md:29) restitue TROIS attributs data-derived du montage :
|
||
# « persona Amélie … · langues FR/EN/ES · `endpoint: null` ». Ils sortent des
|
||
# artefacts byte-gatés out/chat_mount.json (une config par portail) + out/
|
||
# MANIFEST.json (persona/langues/endpoint_statut consolidés) — jamais de la spec.
|
||
# • persona.nom : le prénom de l'assistant (le « QC » est le qualificatif de
|
||
# marque CLAUDE.md « Voix Amélie QC », gardé en littéral — non gaté ici).
|
||
# • langues : la surface trilingue FR/EN/ES lue par le générateur dans
|
||
# seo_spec.json (source unique des langues).
|
||
# • endpoint : `null` est une AFFIRMATION D'HONNÊTETÉ #6/#8 — l'endpoint OTOIA
|
||
# n'est PAS fabriqué (renseigné côté VPS). Si une config gagnait un endpoint
|
||
# réel, la fiche « endpoint: null » deviendrait un MENSONGE en silence.
|
||
# Cohérence croisée (bijection du générateur) : MANIFEST.persona.nom ET
|
||
# MANIFEST.langues doivent valoir EXACTEMENT ce que porte CHAQUE mount (un mount
|
||
# désynchronisé de son propre manifeste mord ici) ; endpoint_statut « a_confirmer »
|
||
# doit correspondre à des endpoints TOUS `null` (sinon incohérence interne de
|
||
# l'artefact). Un claim absent échoue AUSSI (INTROUVABLE = régression #6). Aucune
|
||
# suite tests/ (FONCTIONS du builder, jamais la prose de la fiche) n'attrape ce
|
||
# « vert trompeur ».
|
||
# ============================================================================
|
||
try:
|
||
ci_man = load("frontend/chat_otoia/out/MANIFEST.json")
|
||
ci_mounts = load("frontend/chat_otoia/out/chat_mount.json")
|
||
ci_persona = ci_man["persona"]["nom"]
|
||
ci_langs = list(ci_man["langues"])
|
||
ci_statut = ci_man["endpoint_statut"]
|
||
if not ci_mounts:
|
||
bad("Chat OTOIA identité · out/chat_mount.json vide"); ci_persona = None
|
||
except (OSError, KeyError, ValueError, TypeError) as e:
|
||
bad(f"Chat OTOIA identité · artefact illisible/inattendu : {e}"); ci_persona = None
|
||
if ci_persona is not None:
|
||
# Cohérence croisée MANIFEST ⇔ chaque config runtime (bijection du générateur)
|
||
bad_persona = sorted({m["persona"]["nom"] for m in ci_mounts if m["persona"]["nom"] != ci_persona})
|
||
bad_langs = sorted({"/".join(m["langues"]) for m in ci_mounts if list(m["langues"]) != ci_langs})
|
||
all_null = all(m["endpoint"] is None for m in ci_mounts)
|
||
if bad_persona:
|
||
bad(f"Chat OTOIA identité · persona MANIFEST={ci_persona!r} MAIS mounts portent "
|
||
f"aussi {bad_persona} (montage désynchronisé)")
|
||
if bad_langs:
|
||
bad(f"Chat OTOIA identité · langues MANIFEST={ci_langs} MAIS mounts portent "
|
||
f"aussi {bad_langs} (montage désynchronisé)")
|
||
if all_null and ci_statut != "a_confirmer":
|
||
bad(f"Chat OTOIA identité · endpoints TOUS null MAIS endpoint_statut={ci_statut!r} "
|
||
f"(attendu 'a_confirmer' — incohérence interne artefact)")
|
||
if (not all_null) and ci_statut == "a_confirmer":
|
||
bad("Chat OTOIA identité · endpoint_statut='a_confirmer' MAIS ≥1 endpoint fabriqué "
|
||
"(incohérence interne artefact)")
|
||
exp_langs = "/".join(l.upper() for l in ci_langs)
|
||
exp_endpoint = "null" if all_null else "NON-null(" + ",".join(
|
||
sorted({str(m["endpoint"]) for m in ci_mounts if m["endpoint"] is not None})) + ")"
|
||
co_fi2 = read_norm(CO_FI)
|
||
if co_fi2 is not None:
|
||
m = re.search(r"persona (\S+) QC · langues ([A-Z/]+) · `endpoint: ([^`]+)`", co_fi2)
|
||
if m is None:
|
||
bad(f"Chat OTOIA identité · {CO_FI} — claim « persona <nom> QC · langues "
|
||
f"<L> · `endpoint: <e>` » INTROUVABLE (attendu {ci_persona}/{exp_langs}/{exp_endpoint})")
|
||
else:
|
||
got = m.groups()
|
||
want = (ci_persona, exp_langs, exp_endpoint)
|
||
if got == want:
|
||
good(f"Chat OTOIA identité · {CO_FI} — persona {got[0]} · langues {got[1]} · "
|
||
f"endpoint {got[2]} == artefact (chat_mount ×{len(ci_mounts)} ⇔ MANIFEST)")
|
||
else:
|
||
bad(f"Chat OTOIA identité · {CO_FI} — dit persona {got[0]}/langues {got[1]}/"
|
||
f"endpoint {got[2]} MAIS artefact dit {want[0]}/{want[1]}/{want[2]} "
|
||
f"→ régénérer la fiche")
|
||
|
||
# ============================================================================
|
||
# Recette (module) · couple « N livrables de sprint + M métriques succès MVP »
|
||
# transcrit À LA MAIN dans DEUX docs — même classe de « vert trompeur ».
|
||
# ----------------------------------------------------------------------------
|
||
# La matrice d'acceptation (qa/acceptance/out/acceptance_matrix.json, byte-gatée
|
||
# par check_artifacts ⇒ faisant autorité) porte une ligne par promesse roadmap,
|
||
# de `kind` sprint_deliverable OU mvp_metric. Le compte de chaque genre (n_sprint,
|
||
# n_metric déjà recalculés plus haut depuis la matrice, jamais saisis) est repris
|
||
# À LA MAIN à DEUX endroits que le bloc racine ne couvre PAS : le README du module
|
||
# (05_deliverables_mvp/qa/acceptance/README.md — puces « N livrables de sprint »
|
||
# puis « M métriques succès MVP ») ET la fiche agent QA (03_agents/qa/AGENT.md —
|
||
# « (N livrables + M métriques) »). Le bloc racine plus haut ne gate que le SEUL
|
||
# README d'entrée (« N promesses (X sprint + Y métriques) ») ; ces deux docs de
|
||
# module en dérivent sans gate. Ajouter une promesse roadmap (l'artefact rebâtit
|
||
# 9/7) ferait dériver LES DEUX docs en silence pendant que la matrice dit autre
|
||
# chose — piège que ni les suites tests/ (qui testent des FONCTIONS, pas la prose)
|
||
# ni le roadmap-anchor (qui ancre les `roadmap_line` de l'ARTEFACT, pas la prose
|
||
# des docs) n'attrapent. On RECOMPUTE ici chaque nombre depuis SON PROPRE genre de
|
||
# la matrice (jamais figé · zéro duplication #5) et on exige l'égalité avec les
|
||
# DEUX docs. Recomputer les DEUX (sprint ET métriques) mord une dérive de l'un là
|
||
# où l'autre resterait aveugle. Cohérence croisée en bonus :
|
||
# MANIFEST.counts.{sprint_deliverables,mvp_metrics} == (n_sprint, n_metric) — mord
|
||
# un compteur du manifeste désynchronisé de la matrice qu'il résume. Un claim
|
||
# absent échoue AUSSI (la disparition d'un chiffre est une régression · traçabilité).
|
||
# ============================================================================
|
||
ACC_RD = f"{D}/qa/acceptance/README.md"
|
||
ACC_FI = "03_agents/qa/AGENT.md"
|
||
acc_exp = (str(n_sprint), str(n_metric))
|
||
acc_src = f"livrables_sprint={acc_exp[0]} métriques={acc_exp[1]}"
|
||
try:
|
||
acc_mc = load("qa/acceptance/out/MANIFEST.json")["counts"]
|
||
if (str(acc_mc["sprint_deliverables"]), str(acc_mc["mvp_metrics"])) != acc_exp:
|
||
bad(f"Recette · incohérence artefact : MANIFEST.counts dit "
|
||
f"{acc_mc['sprint_deliverables']}/{acc_mc['mvp_metrics']} MAIS la matrice "
|
||
f"dit {'/'.join(acc_exp)} (compteur désynchronisé de sa liste)")
|
||
except (OSError, KeyError, ValueError) as e:
|
||
bad(f"Recette · MANIFEST acceptance illisible/inattendu : {e}")
|
||
# 1) README du module — deux puces distinctes (livrables sprint · métriques MVP)
|
||
acc_rd = read_norm(ACC_RD)
|
||
if acc_rd is not None:
|
||
ms = re.search(r"\*\*(\d+) livrables de sprint\*\*", acc_rd)
|
||
mm = re.search(r"\*\*(\d+) métriques succès MVP\*\*", acc_rd)
|
||
if ms is None or mm is None:
|
||
bad(f"Recette · {ACC_RD} — puces « N livrables de sprint » / « M métriques "
|
||
f"succès MVP » INTROUVABLE(s) (attendu {acc_src})")
|
||
elif (ms.group(1), mm.group(1)) == acc_exp:
|
||
good(f"Recette · {ACC_RD} — {ms.group(1)}/{mm.group(1)} == matrice ({acc_src})")
|
||
else:
|
||
bad(f"Recette · {ACC_RD} — dit {ms.group(1)}/{mm.group(1)} MAIS matrice dit "
|
||
f"{'/'.join(acc_exp)} ({acc_src}) → régénérer le README")
|
||
# 2) Fiche agent QA — parenthèse « (N livrables + M métriques) »
|
||
acc_fi = read_norm(ACC_FI)
|
||
if acc_fi is not None:
|
||
m = re.search(r"\((\d+) livrables \+ (\d+) métriques\)", acc_fi)
|
||
if m is None:
|
||
bad(f"Recette · {ACC_FI} — claim « (N livrables + M métriques) » INTROUVABLE "
|
||
f"(attendu {acc_src})")
|
||
elif m.groups() == acc_exp:
|
||
good(f"Recette · {ACC_FI} — {'/'.join(m.groups())} == matrice ({acc_src})")
|
||
else:
|
||
bad(f"Recette · {ACC_FI} — dit {'/'.join(m.groups())} MAIS matrice dit "
|
||
f"{'/'.join(acc_exp)} ({acc_src}) → régénérer la fiche")
|
||
|
||
# ============================================================================
|
||
# Fiscal · e-CF DGII — le CONTRAT DE FORMAT de l'e-NCF (« E + tipoeCF(2) +
|
||
# secuencia(10) » = 13) transcrit À LA MAIN dans QUATRE endroits de prose (README
|
||
# ×3 + fiche agent erpnext_backend), sans AUCUN gate d'IDENTITÉ.
|
||
# ----------------------------------------------------------------------------
|
||
# Le générateur ecf_dgii_gen émet out/ecf_plan.json (byte-gaté par check_artifacts
|
||
# ⇒ faisant autorité) dont le bloc `e_ncf` PORTE le format de l'identifiant
|
||
# électronique DGII : `prefix` (« E »), `longueur` (13), `regex`
|
||
# (« ^E[0-9]{12}$ ») et `structure` (« E + tipoeCF (2 chiffres) + secuencia
|
||
# (10 chiffres) »). Ce format — préfixe + longueur du segment tipo + longueur du
|
||
# segment secuencia + longueur totale — est le CŒUR du module (le « composeur
|
||
# traçable ») et il est recopié À LA MAIN dans QUATRE endroits de prose :
|
||
# • README:10 « composeur d'e-NCF traçable `E + tipoeCF(2) + secuencia(10)` »
|
||
# • README:75 (invariant 7) « E+tipo(2)+seq(10)=13 » (porte la longueur TOTALE)
|
||
# • README:84 (composition) « e_ncf = "E" + tipo(2) + secuencia(10) »
|
||
# • 03_agents/erpnext_backend/AGENT.md:33 « `E + tipoeCF(2) + secuencia(10)` »
|
||
# Le bloc racine de check_readme_claims ne gate QUE le COMPTE de tests e-CF
|
||
# (« 39 »). Aucun gate ne comparait la LONGUEUR des segments à l'artefact : allonger
|
||
# la secuencia (rango DGII à 12 chiffres → seq=12, longueur=15, regex 14 digits)
|
||
# OU changer le préfixe ferait dériver les QUATRE proses en silence pendant que
|
||
# l'ecf_plan byte-gaté dit autre chose — « vert trompeur » qu'aucune suite tests/
|
||
# (qui teste des FONCTIONS ncf.compose/parse, pas la prose des docs) n'attrape.
|
||
# Même patron que l'énumération des onglets Mobile, la carte 4Big ou les open items
|
||
# 5D. On RECOMPUTE (prefix, tipo_len, seq_len, total) depuis le SEUL bloc `e_ncf`
|
||
# (zéro duplication du format du générateur #6) et on exige que CHAQUE prose
|
||
# l'encode EXACTEMENT. Cohérences croisées en bonus (mordent un artefact
|
||
# INTERNEMENT incohérent) : |prefix| + tipo_len + seq_len == longueur (1+2+10==13)
|
||
# ET le nombre de chiffres du `regex` == tipo_len + seq_len (12 == 2+10). Un claim
|
||
# absent échoue AUSSI (la disparition d'un format est une régression · traçabilité).
|
||
# ============================================================================
|
||
ECF_RD = f"{D}/fiscal/ecf_dgii/README.md"
|
||
ECF_FI = "03_agents/erpnext_backend/AGENT.md"
|
||
try:
|
||
encf = load("fiscal/ecf_dgii/out/ecf_plan.json")["e_ncf"]
|
||
ecf_prefix = encf["prefix"]
|
||
ecf_total = int(encf["longueur"])
|
||
ecf_seglens = [int(x) for x in
|
||
re.findall(r"\((\d+)\s*chiffres?\)", encf["structure"])]
|
||
ecf_mdig = re.search(r"\[0-9\]\{(\d+)\}", encf["regex"])
|
||
except (OSError, KeyError, ValueError) as e:
|
||
bad(f"Fiscal · artefact ecf_plan illisible/inattendu : {e}"); encf = None
|
||
if encf is not None:
|
||
if len(ecf_seglens) != 2:
|
||
bad(f"Fiscal · e_ncf.structure malformée : {encf['structure']!r} "
|
||
f"(attendu 2 segments « (N chiffres) », trouvé {ecf_seglens})")
|
||
else:
|
||
ecf_tipo, ecf_seq = ecf_seglens
|
||
# Cohérences croisées : l'artefact doit être INTERNEMENT cohérent.
|
||
if len(ecf_prefix) != 1:
|
||
bad(f"Fiscal · incohérence artefact : prefix={ecf_prefix!r} n'est pas "
|
||
f"1 caractère")
|
||
if 1 + ecf_tipo + ecf_seq != ecf_total:
|
||
bad(f"Fiscal · incohérence artefact : 1+{ecf_tipo}+{ecf_seq}="
|
||
f"{1 + ecf_tipo + ecf_seq} MAIS longueur={ecf_total}")
|
||
if ecf_mdig is None or int(ecf_mdig.group(1)) != ecf_tipo + ecf_seq:
|
||
got = ecf_mdig.group(1) if ecf_mdig else "?"
|
||
bad(f"Fiscal · incohérence artefact : regex porte {got} chiffres MAIS "
|
||
f"tipo+seq={ecf_tipo + ecf_seq}")
|
||
exp_ncf = f"{ecf_prefix} + tipo({ecf_tipo}) + secuencia({ecf_seq}) = {ecf_total}"
|
||
def ecf_check(tag, path, txt, m, with_total):
|
||
if m is None:
|
||
bad(f"Fiscal · {path} — {tag} : format e-NCF INTROUVABLE "
|
||
f"(attendu {exp_ncf})")
|
||
return
|
||
g = m.groups()
|
||
ok = (g[0] == ecf_prefix and int(g[1]) == ecf_tipo
|
||
and int(g[2]) == ecf_seq
|
||
and (not with_total or int(g[3]) == ecf_total))
|
||
if ok:
|
||
good(f"Fiscal · {path} — {tag} : {exp_ncf} == e_ncf (artefact)")
|
||
else:
|
||
shown = "/".join(g)
|
||
bad(f"Fiscal · {path} — {tag} : dit {shown} MAIS e_ncf dit "
|
||
f"prefix={ecf_prefix} tipo={ecf_tipo} seq={ecf_seq} "
|
||
f"total={ecf_total} → régénérer le doc")
|
||
# 1) README:10 — résumé « composeur d'e-NCF traçable `E + tipoeCF(2) + secuencia(10)` »
|
||
ecf_rd = read_norm(ECF_RD)
|
||
if ecf_rd is not None:
|
||
ecf_check("résumé composeur", ECF_RD, ecf_rd,
|
||
re.search(r"`([A-Z])\s*\+\s*tipoeCF\((\d+)\)\s*\+\s*"
|
||
r"secuencia\((\d+)\)`", ecf_rd), False)
|
||
# 2) README:75 — invariant 7 « E+tipo(2)+seq(10)=13 » (porte la longueur totale)
|
||
ecf_check("invariant format", ECF_RD, ecf_rd,
|
||
re.search(r"\(([A-Z])\+tipo\((\d+)\)\+seq\((\d+)\)=(\d+)",
|
||
ecf_rd), True)
|
||
# 3) README:84 — composition « e_ncf = "E" + tipo(2) + secuencia(10) »
|
||
ecf_check("composition ncf.py", ECF_RD, ecf_rd,
|
||
re.search(r'e_ncf\s*=\s*"([A-Z])"\s*\+\s*tipo\((\d+)\)\s*\+\s*'
|
||
r"secuencia\((\d+)\)", ecf_rd), False)
|
||
# 4) Fiche agent erpnext_backend — « `E + tipoeCF(2) + secuencia(10)` »
|
||
ecf_fi = read_norm(ECF_FI)
|
||
if ecf_fi is not None:
|
||
ecf_check("fiche erpnext_backend", ECF_FI, ecf_fi,
|
||
re.search(r"`([A-Z])\s*\+\s*tipoeCF\((\d+)\)\s*\+\s*"
|
||
r"secuencia\((\d+)\)`", ecf_fi), False)
|
||
|
||
# ============================================================================
|
||
# Fiscal · e-CF DGII (2e surface du MÊME README) · la CROSS-COHÉRENCE
|
||
# (« le cœur du livrable ») — ENSEMBLES/valeur data-derived transcrits À LA MAIN
|
||
# sans AUCUN gate d'IDENTITÉ. Le bloc e-NCF ci-dessus ne gate QUE le FORMAT de
|
||
# l'identifiant ; la section « Cross-cohérence e-CF ↔ workflow ↔ DocType ↔ RBAC »
|
||
# ÉNUMÈRE trois données dérivées de out/ecf_plan.json (byte-gaté par
|
||
# check_artifacts ⇒ faisant autorité) :
|
||
# • README:48 « seulement réservation et contrat » — l'ensemble des états qui
|
||
# ÉMETTENT un e-CF = {emission_events[].update_value}.
|
||
# • README:50 « (`montant_reservation`, `montant_contrat`) » — l'ensemble des
|
||
# champs Currency de base = {emission_events[].base_field}.
|
||
# • README:59 « FormaPago défaut = 3 (Tarjeta) … via Cardnet (#10) » — le code +
|
||
# libellé de forma_pago_defaut, ANCRÉ sur CLAUDE.md #10 (Cardnet = carte).
|
||
# Aucun gate ne comparait ces valeurs à l'artefact : AJOUTER un évènement
|
||
# d'émission (facturer un devis) · en RETIRER un · RENOMMER un base_field ·
|
||
# CHANGER forma_pago_defaut (Cardnet abandonné → code 2 Transfert) ferait dériver
|
||
# la prose en silence pendant que l'ecf_plan byte-gaté dit autre chose — l'agent
|
||
# ERPNext Backend câblerait le mauvais évènement / la mauvaise FormaPago (le risque
|
||
# même que la cross-cohérence veut prévenir) — « vert trompeur » qu'aucune suite
|
||
# tests/ (qui teste des FONCTIONS ncf/résolution, pas la prose) n'attrape. Même
|
||
# patron EXACT que la cross-cohérence CRM/Commissions (base_field + états soumis).
|
||
# On RECOMPUTE chaque ensemble/valeur depuis l'artefact (zéro duplication du
|
||
# contrat du générateur #6) et on exige que la prose l'encode EXACTEMENT (absent ET
|
||
# en trop via set-diff · accents/casse normalisés). Cohérences croisées en bonus
|
||
# (mordent un plan INTERNEMENT incohérent) : forma_pago_defaut.code ∈ catalogue
|
||
# formas_pago · libellé prose ⊆ libellé catalogue du code · ensembles NON VIDES.
|
||
# Un claim absent échoue AUSSI (traçabilité).
|
||
# ============================================================================
|
||
ECF_RD2 = f"{D}/fiscal/ecf_dgii/README.md"
|
||
def _ecf_acc(s):
|
||
return "".join(c for c in unicodedata.normalize("NFKD", s)
|
||
if unicodedata.category(c) != "Mn").lower()
|
||
try:
|
||
ecfp = load("fiscal/ecf_dgii/out/ecf_plan.json")
|
||
ecf_ev = ecfp["emission_events"]
|
||
ecf_uv = {_ecf_acc(e["update_value"]) for e in ecf_ev}
|
||
ecf_bf = {e["base_field"] for e in ecf_ev}
|
||
ecf_fpd = ecfp["forma_pago_defaut"]
|
||
ecf_fp_code = str(ecf_fpd["code"])
|
||
ecf_fp_cat = {str(f["code"]): f["label"] for f in ecfp["formas_pago"]}
|
||
except (OSError, KeyError, ValueError, TypeError) as e:
|
||
bad(f"Fiscal cross-cohérence · artefact ecf_plan illisible/inattendu : {e}"); ecf_ev = None
|
||
if ecf_ev is not None:
|
||
# bonus : plan INTERNEMENT cohérent (ensembles non vides · défaut ∈ catalogue)
|
||
if not ecf_uv or not ecf_bf:
|
||
bad("Fiscal cross-cohérence · aucun évènement d'émission (plan vide) → vérifier le plan")
|
||
elif ecf_fp_code not in ecf_fp_cat:
|
||
bad(f"Fiscal cross-cohérence · forma_pago_defaut.code={ecf_fp_code} ABSENT du "
|
||
f"catalogue formas_pago {sorted(ecf_fp_cat)} → plan incohérent")
|
||
else:
|
||
good(f"Fiscal cross-cohérence · forma_pago_defaut {ecf_fp_code} "
|
||
f"({ecf_fp_cat[ecf_fp_code]}) ∈ catalogue formas_pago · émission {sorted(ecf_uv)} non vide")
|
||
ecf_rd2 = read_norm(ECF_RD2)
|
||
if ecf_rd2 is not None:
|
||
# 1) README:48 — états émetteurs « seulement réservation et contrat »
|
||
me = re.search(r"seulement\s+([^.]+)\.", ecf_rd2)
|
||
if me is None:
|
||
bad(f"Fiscal cross-cohérence · {ECF_RD2} — énumération des états émetteurs "
|
||
f"INTROUVABLE (attendu {sorted(ecf_uv)})")
|
||
else:
|
||
es = {_ecf_acc(t.strip()) for t in re.split(r"\bet\b|[,/]", me.group(1)) if t.strip()}
|
||
if es == ecf_uv:
|
||
good(f"Fiscal cross-cohérence · {ECF_RD2} — états émetteurs {sorted(es)} == "
|
||
f"ecf_plan.emission_events[].update_value")
|
||
else:
|
||
miss = sorted(ecf_uv - es); extra = sorted(es - ecf_uv)
|
||
detail = (f" · absents={miss}" if miss else "") + (f" · en trop={extra}" if extra else "")
|
||
bad(f"Fiscal cross-cohérence · {ECF_RD2} — énumère {sorted(es)} MAIS plan "
|
||
f"{sorted(ecf_uv)}{detail} → régénérer le README")
|
||
# 2) README:50 — champs de base « (`montant_reservation`, `montant_contrat`) »
|
||
mf = re.search(r"Currency réel\*\* du DocType Dossier Vente\s*\(([^)]*)\)", ecf_rd2)
|
||
if mf is None:
|
||
bad(f"Fiscal cross-cohérence · {ECF_RD2} — énumération des champs de base "
|
||
f"INTROUVABLE (attendu {sorted(ecf_bf)})")
|
||
else:
|
||
bs = set(re.findall(r"`([a-z_]+)`", mf.group(1)))
|
||
if bs == ecf_bf:
|
||
good(f"Fiscal cross-cohérence · {ECF_RD2} — champs de base {sorted(bs)} == "
|
||
f"ecf_plan.emission_events[].base_field")
|
||
else:
|
||
miss = sorted(ecf_bf - bs); extra = sorted(bs - ecf_bf)
|
||
detail = (f" · absents={miss}" if miss else "") + (f" · en trop={extra}" if extra else "")
|
||
bad(f"Fiscal cross-cohérence · {ECF_RD2} — énumère {sorted(bs)} MAIS plan "
|
||
f"{sorted(ecf_bf)}{detail} → régénérer le README")
|
||
# 3) README:59 — FormaPago défaut « = 3 (Tarjeta) … via Cardnet (#10) »
|
||
mp = re.search(r"FormaPago défaut\s*=\s*(\d+)\s*\(([^)]*)\)", ecf_rd2)
|
||
if mp is None:
|
||
bad(f"Fiscal cross-cohérence · {ECF_RD2} — FormaPago défaut INTROUVABLE "
|
||
f"(attendu code {ecf_fp_code} · {ecf_fp_cat.get(ecf_fp_code,'?')})")
|
||
else:
|
||
got_code = mp.group(1); got_lbl = _ecf_acc(mp.group(2).strip())
|
||
cat_lbl = _ecf_acc(ecf_fp_cat.get(ecf_fp_code, ""))
|
||
has_cardnet = "cardnet" in _ecf_acc(ecf_rd2[mp.start():mp.start() + 160])
|
||
if got_code == ecf_fp_code and got_lbl and got_lbl in cat_lbl and has_cardnet:
|
||
good(f"Fiscal cross-cohérence · {ECF_RD2} — FormaPago défaut {got_code} "
|
||
f"({mp.group(2).strip()}) == forma_pago_defaut · ancré Cardnet (#10)")
|
||
elif got_code != ecf_fp_code:
|
||
bad(f"Fiscal cross-cohérence · {ECF_RD2} — FormaPago défaut = {got_code} MAIS "
|
||
f"forma_pago_defaut.code = {ecf_fp_code} → régénérer le README")
|
||
elif not (got_lbl and got_lbl in cat_lbl):
|
||
bad(f"Fiscal cross-cohérence · {ECF_RD2} — libellé FormaPago {mp.group(2).strip()!r} "
|
||
f"≠ catalogue code {ecf_fp_code} ({ecf_fp_cat.get(ecf_fp_code)!r}) → régénérer")
|
||
else:
|
||
bad(f"Fiscal cross-cohérence · {ECF_RD2} — FormaPago défaut sans ancrage "
|
||
f"Cardnet (#10) à proximité → régénérer le README")
|
||
|
||
# ============================================================================
|
||
# Fiscal · e-CF DGII (3e surface du MÊME README) · l'IDENTITÉ du RÔLE émetteur —
|
||
# le seul rôle habilité à ÉMETTRE un e-CF, transcrit À LA MAIN sans gate d'IDENTITÉ.
|
||
# ----------------------------------------------------------------------------
|
||
# La section « Cross-cohérence » du README (bullet `role_id`, README:51-54) NOMME
|
||
# le rôle émetteur par TROIS attributs data-derived :
|
||
# • README:54 « le rôle dédié `compta-fiscaliste-ecf` » — le `role_id` que TOUS
|
||
# les emission_events d'ecf_plan.json (byte-gaté) référencent.
|
||
# • README:54 « (_OTO Compta Fiscaliste eCF_) » — son `erpnext_role_name`.
|
||
# • README:53 « appartenir au portail `compta` » — le `portail` de CE rôle dans
|
||
# le contrat rbac_50_roles.json (schéma-validé · byte-gaté par les 3 MANIFEST).
|
||
# Le bloc e-NCF gate le FORMAT de l'identifiant, le bloc cross-cohérence gate les
|
||
# ÉTATS émetteurs / champs de base / FormaPago — mais AUCUN ne compare l'IDENTITÉ
|
||
# de ce rôle à ses deux sources. RÉAFFECTER l'émission à un autre rôle (dans le
|
||
# plan), le RENOMMER, ou DÉPLACER le rôle `compta-fiscaliste-ecf` du portail
|
||
# `compta` vers un autre portail (dans le contrat : l'e-CF serait alors émis HORS
|
||
# Compta — la séparation même que la cross-cohérence pose) laisse la prose périmée
|
||
# en silence pendant que l'artefact/le contrat disent autre chose ⇒ l'agent ERPNext
|
||
# Backend câblerait le mauvais rôle émetteur (le risque même que #6 interdit).
|
||
# Aucune suite tests/ (qui teste des FONCTIONS ncf/résolution, pas la prose) ne
|
||
# l'attrape — même patron d'IDENTITÉ que la Fiche Mobile ou les gardes CRM. On
|
||
# RECOMPUTE `role_id`/`erpnext_role_name` de l'artefact et `portail`/nom Frappe du
|
||
# contrat (zéro duplication) et on exige la prose EXACTE. Cohérences croisées bonus
|
||
# (mordent un plan/contrat INTERNEMENT incohérent) : tous les emission_events
|
||
# pointent UN SEUL rôle (fonction · non vide) · ce role_id existe UNE fois au
|
||
# contrat · le nom résolu dans l'artefact == le contrat (zéro-dup) · portail ==
|
||
# `compta`. Un claim absent échoue AUSSI (traçabilité #6).
|
||
# ============================================================================
|
||
ECF_RD3 = f"{D}/fiscal/ecf_dgii/README.md"
|
||
try:
|
||
ecfp3 = load("fiscal/ecf_dgii/out/ecf_plan.json")
|
||
ecf_ev3 = ecfp3["emission_events"]
|
||
ecf_rids = {e["role_id"] for e in ecf_ev3}
|
||
ecf_rnames = {e["erpnext_role_name"] for e in ecf_ev3}
|
||
ecf_contract = load("rbac/rbac_50_roles.json")["roles"]
|
||
except (OSError, KeyError, ValueError, TypeError) as e:
|
||
bad(f"Fiscal rôle émetteur · ecf_plan / rbac_50_roles illisible/inattendu : {e}"); ecf_ev3 = None
|
||
if ecf_ev3 is not None:
|
||
ecf_rd3 = read_norm(ECF_RD3)
|
||
# bonus : tous les emission_events pointent UN SEUL rôle (fonction · non vide)
|
||
if len(ecf_rids) != 1 or len(ecf_rnames) != 1:
|
||
bad(f"Fiscal rôle émetteur · emission_events référencent {sorted(ecf_rids)} role_id / "
|
||
f"{sorted(ecf_rnames)} noms (attendu 1 rôle émetteur unique) → plan incohérent")
|
||
elif ecf_rd3 is not None:
|
||
ecf_rid = next(iter(ecf_rids)); ecf_art_name = next(iter(ecf_rnames))
|
||
ecf_match = [r for r in ecf_contract if r.get("id") == ecf_rid]
|
||
if len(ecf_match) != 1:
|
||
bad(f"Fiscal rôle émetteur · le contrat porte {len(ecf_match)} rôle(s) `{ecf_rid}` "
|
||
f"(attendu exactement 1) → rôle absent/dupliqué du contrat rbac_50_roles.json")
|
||
else:
|
||
rc = ecf_match[0]
|
||
ecf_ct_name = rc.get("erpnext_role_name"); ecf_portail = rc.get("portail")
|
||
# bonus zéro-duplication : le nom résolu dans l'artefact == le contrat
|
||
if ecf_ct_name != ecf_art_name:
|
||
bad(f"Fiscal rôle émetteur · ecf_plan dit erpnext_role_name={ecf_art_name!r} MAIS "
|
||
f"contrat dit {ecf_ct_name!r} pour `{ecf_rid}` → régénérer l'artefact")
|
||
# bonus séparation des pouvoirs : ce rôle est bien du portail compta (README:53)
|
||
if ecf_portail != "compta":
|
||
bad(f"Fiscal rôle émetteur · le rôle `{ecf_rid}` est du portail {ecf_portail!r} "
|
||
f"≠ `compta` au contrat → l'e-CF serait émis HORS Compta (séparation cassée)")
|
||
# prose : 3 claims data-derived recomputés (role_id · nom Frappe · portail)
|
||
for label, pat, exp in [
|
||
("role_id", rf"rôle dédié `{re.escape(ecf_rid)}`", ecf_rid),
|
||
("nom d'affichage ERPNext", rf"\(_{re.escape(ecf_ct_name)}_\)", ecf_ct_name),
|
||
("portail", rf"portail `{re.escape(ecf_portail)}`", ecf_portail),
|
||
]:
|
||
if re.search(pat, ecf_rd3):
|
||
good(f"Fiscal rôle émetteur · {label} « {exp} » == ecf_plan/contrat rbac")
|
||
else:
|
||
bad(f"Fiscal rôle émetteur · {ECF_RD3} — {label} : la prose ne cite pas "
|
||
f"« {exp} » (recomputé de emission_events + rbac_50_roles) → régénérer")
|
||
|
||
# ============================================================================
|
||
# CRM/Commissions · barème vendeurs — la CROSS-COHÉRENCE (« cœur du livrable »)
|
||
# transcrite À LA MAIN dans le README sans AUCUN gate d'IDENTITÉ.
|
||
# ----------------------------------------------------------------------------
|
||
# Le générateur commissions_gen émet out/commission_plan.json (byte-gaté par
|
||
# check_artifacts ⇒ faisant autorité) : chaque évènement porte `update_value`
|
||
# (l'état du pipeline qui PAIE) et `base_field` (le champ Currency de base). La
|
||
# section « Cross-cohérence » du README ÉNUMÈRE À LA MAIN deux ensembles
|
||
# data-derived + une propriété — le CŒUR du livrable :
|
||
# • README:47 « on ne commissionne pas un brouillon (lead/visite/devis/
|
||
# abandonné) » — l'ensemble des états BROUILLON (doc_status=0 du workflow).
|
||
# • README:50 « base_field … (`montant_reservation`, `montant_contrat`) » —
|
||
# l'ensemble des champs de base cités par le plan.
|
||
# • README:46 « correspondre à un état soumis (doc_status = 1) » — INVARIANT 3
|
||
# du module : on ne commissionne QUE des états soumis, jamais un brouillon.
|
||
# Le bloc racine ne gate QUE le COMPTE de tests commissions (« 25 », via l'agrégat
|
||
# « Total CRM »). Aucun gate ne comparait ces ENSEMBLES aux artefacts : PROMOUVOIR
|
||
# un état brouillon en soumis (abandonne → doc_status 1) le retire de l'ensemble
|
||
# brouillon pendant que la prose l'énumère encore ; AJOUTER un champ de base à un
|
||
# évènement (base_field montant_confotur) l'ajoute sans que la prose bouge ;
|
||
# RENOMMER un état passe aussi — « vert trompeur » qu'aucune suite tests/ (qui teste
|
||
# des FONCTIONS finance/résolution, pas la prose) n'attrape. Même patron que
|
||
# l'énumération des onglets Mobile, des transitions quatre-yeux CRM ou du format
|
||
# e-NCF fiscal. On RECOMPUTE chaque ensemble depuis SON artefact byte-gaté (zéro
|
||
# duplication du contrat du générateur #6) et on exige que la prose l'énumère
|
||
# EXACTEMENT (absent ET en trop mordus via set-diff · accents/casse normalisés).
|
||
# Cohérence croisée en bonus (mord un plan INTERNEMENT incohérent avec le workflow) :
|
||
# tout `update_value` commissionné ∈ états soumis (doc_status=1) ET ∉ brouillons —
|
||
# l'invariant 3 du module, non vide. Un claim absent échoue AUSSI (traçabilité).
|
||
# ============================================================================
|
||
CM_RD = f"{D}/crm/commissions/README.md"
|
||
def _cm_acc(s):
|
||
return "".join(c for c in unicodedata.normalize("NFKD", s)
|
||
if unicodedata.category(c) != "Mn")
|
||
try:
|
||
cm_ev = load("crm/commissions/out/commission_plan.json")["evenements"]
|
||
cm_states = load("crm/workflow_vente/out/workflow.json")[0]["states"]
|
||
cm_base = {e["base_field"] for e in cm_ev}
|
||
cm_uv = {e["update_value"] for e in cm_ev}
|
||
cm_draft = {s["update_value"] for s in cm_states if str(s["doc_status"]) == "0"}
|
||
cm_subm = {s["update_value"] for s in cm_states if str(s["doc_status"]) == "1"}
|
||
except (OSError, KeyError, ValueError, IndexError, TypeError) as e:
|
||
bad(f"CRM commissions · artefact illisible/inattendu : {e}"); cm_ev = None
|
||
if cm_ev is not None:
|
||
# bonus : invariant 3 — tout état commissionné est SOUMIS (jamais brouillon), non vide
|
||
if not cm_uv:
|
||
bad("CRM commissions · aucun évènement commissionné (plan vide) → vérifier le barème")
|
||
elif not (cm_uv <= cm_subm):
|
||
bad(f"CRM commissions · incohérence plan↔workflow : update_values commissionnés "
|
||
f"{sorted(cm_uv - cm_subm)} PAS soumis (doc_status≠1) → invariant 3 cassé")
|
||
elif cm_uv & cm_draft:
|
||
bad(f"CRM commissions · incohérence plan↔workflow : {sorted(cm_uv & cm_draft)} "
|
||
f"commissionné(s) mais BROUILLON (doc_status=0) → invariant 3 cassé")
|
||
else:
|
||
good(f"CRM commissions · invariant 3 — {sorted(cm_uv)} tous soumis (doc_status=1), "
|
||
f"aucun brouillon")
|
||
cm_rd = read_norm(CM_RD)
|
||
if cm_rd is not None:
|
||
# 1) README:47 — ensemble des états BROUILLON exclus (doc_status=0 du workflow)
|
||
mb = re.search(r"brouillon \(([^)]*)\)", cm_rd)
|
||
if mb is None:
|
||
bad(f"CRM commissions · {CM_RD} — énumération des brouillons exclus "
|
||
f"INTROUVABLE (attendu {sorted(cm_draft)})")
|
||
else:
|
||
ps = {_cm_acc(t.strip().lower()) for t in re.split(r"[/,]", mb.group(1)) if t.strip()}
|
||
exp = {_cm_acc(x) for x in cm_draft}
|
||
if ps == exp:
|
||
good(f"CRM commissions · {CM_RD} — brouillons exclus {sorted(ps)} == "
|
||
f"workflow.json (doc_status=0)")
|
||
else:
|
||
miss = sorted(exp - ps); extra = sorted(ps - exp)
|
||
detail = (f" · absents={miss}" if miss else "") + \
|
||
(f" · en trop={extra}" if extra else "")
|
||
bad(f"CRM commissions · {CM_RD} — énumère {sorted(ps)} MAIS brouillons "
|
||
f"workflow {sorted(exp)}{detail} → régénérer le README")
|
||
# 2) README:50 — ensemble des champs de base (base_field du plan)
|
||
mf = re.search(r"Currency réel\*\* du DocType Dossier Vente \(([^)]*)\)", cm_rd)
|
||
if mf is None:
|
||
bad(f"CRM commissions · {CM_RD} — énumération des champs de base "
|
||
f"INTROUVABLE (attendu {sorted(cm_base)})")
|
||
else:
|
||
bs = set(re.findall(r"`([a-z_]+)`", mf.group(1)))
|
||
if bs == cm_base:
|
||
good(f"CRM commissions · {CM_RD} — champs de base {sorted(bs)} == "
|
||
f"commission_plan.evenements[].base_field")
|
||
else:
|
||
miss = sorted(cm_base - bs); extra = sorted(bs - cm_base)
|
||
detail = (f" · absents={miss}" if miss else "") + \
|
||
(f" · en trop={extra}" if extra else "")
|
||
bad(f"CRM commissions · {CM_RD} — énumère {sorted(bs)} MAIS plan "
|
||
f"{sorted(cm_base)}{detail} → régénérer le README")
|
||
|
||
# ============================================================================
|
||
# RBAC/fixtures_gen · le CATALOGUE des DocTypes `custom` à créer sur le VPS et la
|
||
# SÉPARATION DES POUVOIRS (le rôle unique porteur de `set_user_permissions`) —
|
||
# deux faits data-derived transcrits À LA MAIN dans le README sans AUCUN gate
|
||
# d'IDENTITÉ.
|
||
# ----------------------------------------------------------------------------
|
||
# Le générateur rbac_fixtures_gen émet out/MANIFEST.json et out/custom_docperm.json
|
||
# (byte-gatés par check_artifacts ⇒ faisant autorité) :
|
||
# • MANIFEST.custom_doctypes_a_confirmer = l'ensemble des DocTypes `custom: true`
|
||
# du contrat rbac_50_roles.json (ceux à CRÉER avant import — le reste est natif
|
||
# v15). Le README §« Application sur VPS » l'ÉNUMÈRE À LA MAIN, en nommant même
|
||
# le champ : « listés dans `MANIFEST.custom_doctypes_a_confirmer` (`Faisabilité`,
|
||
# `Publiciste Log`, `CONFOTUR Application`, `API Access`) ».
|
||
# • custom_docperm.json porte le flag `set_user_permissions`. Le CŒUR sécurité du
|
||
# module (« séparation des pouvoirs · défense en profondeur ») est que ce flag
|
||
# n'est émis que pour UN SEUL rôle — le README:48 dit « n'est émis que pour le
|
||
# rôle **RBAC Admin** ». C'est l'ensemble {role | set_user_permissions=1}.
|
||
# Le bloc racine ne gate QUE le COMPTE de tests fixtures (« 50 rôles / 116 DocPerm »
|
||
# via l'agrégat RBAC de l'apply_plan) — jamais l'IDENTITÉ de ces deux ensembles.
|
||
# Aucun gate ne les comparait à l'artefact : AJOUTER un DocType `custom` au contrat
|
||
# (le MANIFEST rebâtit 5 entrées) OU RENOMMER/ÉCHANGER l'un des 4 laisse la prose à
|
||
# ses 4 noms périmés ; PROMOUVOIR un 2ᵉ rôle porteur de `set_user_permissions` (ou
|
||
# renommer RBAC Admin) laisse la prose « un seul rôle RBAC Admin » fausse — « vert
|
||
# trompeur » qu'aucune suite tests/ (qui teste des FONCTIONS de mapping, pas la
|
||
# prose du README) n'attrape. Même patron que l'énumération des onglets Mobile, des
|
||
# brouillons CRM ou des open items de l'audit 5D. On RECOMPUTE chaque ensemble
|
||
# depuis SON artefact byte-gaté (zéro duplication du contrat du générateur #6) et on
|
||
# exige que la prose l'énumère/le nomme EXACTEMENT (absent ET en trop via set-diff ·
|
||
# accents/casse normalisés). Cohérence croisée en bonus : le catalogue est NON VIDE,
|
||
# sans doublon et TRIÉ (byte-déterminisme du générateur) ; la séparation des pouvoirs
|
||
# est un SINGLETON (ni vide — la garde serait vacante — ni multiple — élévation de
|
||
# privilège). Un claim absent échoue AUSSI (traçabilité).
|
||
# ============================================================================
|
||
FX_RD = f"{D}/rbac/fixtures_gen/README.md"
|
||
def _fx_acc(s):
|
||
return "".join(c for c in unicodedata.normalize("NFKD", s)
|
||
if unicodedata.category(c) != "Mn").strip().lower()
|
||
try:
|
||
fx_cdt = load("rbac/fixtures_gen/out/MANIFEST.json")["custom_doctypes_a_confirmer"]
|
||
fx_perm = load("rbac/fixtures_gen/out/custom_docperm.json")
|
||
fx_sup = sorted({p["role"] for p in fx_perm if p.get("set_user_permissions")})
|
||
except (OSError, KeyError, ValueError, TypeError) as e:
|
||
bad(f"RBAC fixtures · artefact illisible/inattendu : {e}"); fx_cdt = None
|
||
if fx_cdt is not None:
|
||
# bonus : le catalogue est un vrai ENSEMBLE trié non vide (byte-déterminisme)
|
||
if not fx_cdt:
|
||
bad("RBAC fixtures · custom_doctypes_a_confirmer VIDE → aucun DocType custom "
|
||
"à créer ? (contrat suspect)")
|
||
elif fx_cdt != sorted(set(fx_cdt)):
|
||
bad(f"RBAC fixtures · custom_doctypes_a_confirmer {fx_cdt} non trié/dédupliqué "
|
||
f"→ générateur non déterministe (attendu {sorted(set(fx_cdt))})")
|
||
else:
|
||
good(f"RBAC fixtures · catalogue custom {fx_cdt} — ensemble trié, sans doublon")
|
||
# bonus : séparation des pouvoirs — un SEUL rôle porte set_user_permissions
|
||
if len(fx_sup) != 1:
|
||
bad(f"RBAC fixtures · set_user_permissions porté par {fx_sup} (attendu 1 seul "
|
||
f"rôle) → séparation des pouvoirs cassée (élévation de privilège possible)")
|
||
else:
|
||
good(f"RBAC fixtures · set_user_permissions — singleton {fx_sup[0]!r} "
|
||
f"(défense en profondeur intacte)")
|
||
fx_rd = read_norm(FX_RD)
|
||
if fx_rd is not None:
|
||
# 1) README — énumération du catalogue custom (parenthèse après le champ nommé)
|
||
me = re.search(r"custom_doctypes_a_confirmer`?\s*\(([^)]*)\)", fx_rd)
|
||
if me is None:
|
||
bad(f"RBAC fixtures · {FX_RD} — énumération du catalogue custom INTROUVABLE "
|
||
f"(attendu {sorted(fx_cdt)})")
|
||
else:
|
||
got = {_fx_acc(t) for t in re.findall(r"`([^`]+)`", me.group(1))}
|
||
exp = {_fx_acc(x) for x in fx_cdt}
|
||
if got == exp:
|
||
good(f"RBAC fixtures · {FX_RD} — catalogue custom {sorted(got)} == "
|
||
f"MANIFEST.custom_doctypes_a_confirmer")
|
||
else:
|
||
miss = sorted(exp - got); extra = sorted(got - exp)
|
||
detail = (f" · absents={miss}" if miss else "") + \
|
||
(f" · en trop={extra}" if extra else "")
|
||
bad(f"RBAC fixtures · {FX_RD} — énumère {sorted(got)} MAIS MANIFEST "
|
||
f"{sorted(exp)}{detail} → régénérer le README")
|
||
# 2) README:48 — le rôle NOMMÉ porteur de set_user_permissions (séparation)
|
||
if len(fx_sup) == 1:
|
||
mr = re.search(r"émis que pour le rôle \*\*([^*]+)\*\*", fx_rd)
|
||
if mr is None:
|
||
bad(f"RBAC fixtures · {FX_RD} — claim « set_user_permissions n'est émis "
|
||
f"que pour le rôle ** ** » INTROUVABLE (attendu {fx_sup[0]!r})")
|
||
elif _fx_acc(mr.group(1)) in _fx_acc(fx_sup[0]):
|
||
good(f"RBAC fixtures · {FX_RD} — rôle nommé « {mr.group(1).strip()} » ⊆ "
|
||
f"{fx_sup[0]!r} (le seul porteur du flag)")
|
||
else:
|
||
bad(f"RBAC fixtures · {FX_RD} — nomme « {mr.group(1).strip()} » MAIS "
|
||
f"set_user_permissions porté par {fx_sup[0]!r} → régénérer le README")
|
||
|
||
# ============================================================================
|
||
# SEO · la COMPOSITION du graphe schema.org (JSON-LD) — un nœud racine
|
||
# `Organization` + une `Residence` PAR PROJET — décrite EN PROSE dans le README
|
||
# sans AUCUN gate d'IDENTITÉ.
|
||
# ----------------------------------------------------------------------------
|
||
# Le générateur SEO émet out/seo_schema_org.json (byte-gaté par check_artifacts ⇒
|
||
# faisant autorité). Son `@graph` est l'ENSEMBLE des nœuds JSON-LD : exactement UN
|
||
# nœud racine `Organization` (@id …#organization) + un listing PAR PROJET, tous du
|
||
# même @type. Le README:16 décrit cette structure À LA MAIN : « graphe JSON-LD
|
||
# (`Organization` + une `Residence` par projet) ». Le bloc SEO existant ne gate que
|
||
# le COMPTE de mots-clés (keywords_total + ventilation par langue) — jamais les
|
||
# @type du graphe ni la bijection un-listing-par-projet. Aucun gate ne comparait
|
||
# ces TYPES à l'artefact : RENOMMER le @type des listings (Residence → Apartment,
|
||
# rango schema.org) OU AJOUTER un 3ᵉ type de nœud (Offer/Product) OU casser la
|
||
# bijection (2 nœuds pour un projet) ferait dériver la prose en silence pendant que
|
||
# le graphe byte-gaté dit autre chose — « vert trompeur » qu'aucune suite tests/
|
||
# (qui teste des FONCTIONS de génération, pas la prose du README) n'attrape. Même
|
||
# patron que l'énumération des onglets Mobile ou du catalogue custom RBAC. On
|
||
# RECOMPUTE l'ENSEMBLE des @type depuis SON artefact byte-gaté (zéro duplication du
|
||
# contrat du générateur #6) et on exige que la prose les nomme EXACTEMENT (absent ET
|
||
# en trop via set-diff). Cohérences croisées en bonus : le nœud racine Organization
|
||
# est un SINGLETON ; les listings sont uniformes (un seul @type) et en BIJECTION avec
|
||
# les projets (listings == projects == schema_org_nodes−1 du MANIFEST byte-gaté) ;
|
||
# aucune `offers` (statuts sans prix · USD #10). Un claim absent échoue AUSSI.
|
||
# ============================================================================
|
||
SEO_SG_RD = f"{D}/seo/README.md"
|
||
try:
|
||
sg = load("seo/out/seo_schema_org.json")["@graph"]
|
||
seo_cnt = load("seo/out/MANIFEST.json")["counts"]
|
||
sg_org = [n for n in sg if str(n.get("@id", "")).endswith("#organization")]
|
||
sg_list = [n for n in sg if not str(n.get("@id", "")).endswith("#organization")]
|
||
sg_types = {n["@type"] for n in sg} # ensemble des @type du graphe
|
||
org_types = {n["@type"] for n in sg_org}
|
||
list_types = {n["@type"] for n in sg_list}
|
||
sg_offers = sum(1 for n in sg if "offers" in n)
|
||
except (OSError, KeyError, ValueError, TypeError) as e:
|
||
bad(f"SEO schema.org · artefact illisible/inattendu : {e}"); sg = None
|
||
if sg is not None:
|
||
n_proj = seo_cnt.get("projects")
|
||
# bonus 1 : racine Organization = SINGLETON, d'un seul @type
|
||
if len(sg_org) != 1 or len(org_types) != 1:
|
||
bad(f"SEO schema.org · nœud racine #organization non-singleton "
|
||
f"({len(sg_org)} nœud(s), types={sorted(org_types)}) → graphe suspect")
|
||
else:
|
||
good(f"SEO schema.org · racine {sorted(org_types)[0]!r} — singleton (1 nœud)")
|
||
# bonus 2 : listings uniformes + bijection 1 par projet + total du MANIFEST
|
||
if not sg_list or len(list_types) != 1:
|
||
bad(f"SEO schema.org · listings non uniformes (types={sorted(list_types)}) "
|
||
f"→ un @type unique attendu par projet")
|
||
elif not (len(sg_list) == n_proj == seo_cnt.get("listings")
|
||
== seo_cnt.get("schema_org_nodes", 0) - 1):
|
||
bad(f"SEO schema.org · bijection cassée : {len(sg_list)} listing(s) MAIS "
|
||
f"projects={n_proj} listings={seo_cnt.get('listings')} "
|
||
f"schema_org_nodes={seo_cnt.get('schema_org_nodes')} (attendu 1+{n_proj})")
|
||
else:
|
||
good(f"SEO schema.org · {len(sg_list)} × {sorted(list_types)[0]!r} — bijection "
|
||
f"un listing par projet ({n_proj}) · total {seo_cnt.get('schema_org_nodes')} nœuds")
|
||
# bonus 3 : aucune offers/prix (statuts sans prix · #10 · cohérent MANIFEST.offers)
|
||
if sg_offers != 0 or seo_cnt.get("offers") != 0:
|
||
bad(f"SEO schema.org · {sg_offers} nœud(s) `offers` (MANIFEST.offers="
|
||
f"{seo_cnt.get('offers')}) → prix non attendu pour statuts sans prix (#10)")
|
||
else:
|
||
good("SEO schema.org · aucune `offers`/prix (statuts sans prix · USD #10)")
|
||
seo_sg_rd = read_norm(SEO_SG_RD)
|
||
if seo_sg_rd is not None:
|
||
# README:16 — la parenthèse décrit la composition du graphe : ENSEMBLE des @type
|
||
msg = re.search(r"graphe JSON-LD \(([^)]*)\)", seo_sg_rd)
|
||
if msg is None:
|
||
bad(f"SEO schema.org · {SEO_SG_RD} — composition « graphe JSON-LD (…) » "
|
||
f"INTROUVABLE (attendu {sorted(sg_types)})")
|
||
else:
|
||
prose_types = set(re.findall(r"`([A-Za-z]+)`", msg.group(1)))
|
||
if prose_types == sg_types:
|
||
good(f"SEO schema.org · {SEO_SG_RD} — @type {sorted(prose_types)} == "
|
||
f"seo_schema_org.@graph")
|
||
else:
|
||
miss = sorted(sg_types - prose_types); extra = sorted(prose_types - sg_types)
|
||
detail = (f" · absents={miss}" if miss else "") + \
|
||
(f" · en trop={extra}" if extra else "")
|
||
bad(f"SEO schema.org · {SEO_SG_RD} — nomme {sorted(prose_types)} MAIS "
|
||
f"graphe {sorted(sg_types)}{detail} → régénérer le README")
|
||
# README:16 — le type PAR PROJET nommé == le @type des listings (racine distincte)
|
||
if len(list_types) == 1:
|
||
mpp = re.search(r"une `([A-Za-z]+)` par projet", seo_sg_rd)
|
||
lt = sorted(list_types)[0]
|
||
if mpp is None:
|
||
bad(f"SEO schema.org · {SEO_SG_RD} — « une `<type>` par projet » "
|
||
f"INTROUVABLE (attendu {lt!r})")
|
||
elif mpp.group(1) == lt:
|
||
good(f"SEO schema.org · {SEO_SG_RD} — « une `{lt}` par projet » == "
|
||
f"listings du graphe")
|
||
else:
|
||
bad(f"SEO schema.org · {SEO_SG_RD} — dit « une `{mpp.group(1)}` par projet » "
|
||
f"MAIS listings sont `{lt}` → régénérer le README")
|
||
|
||
# ============================================================================
|
||
# SEO · l'IDENTITÉ du nœud racine `Organization` du graphe schema.org (JSON-LD)
|
||
# — le nom de marque `Helios RD` (marque publique · CLAUDE.md §Entités) + son
|
||
# `url` == base_url du site — ANCRÉE sur CLAUDE.md, jamais gatée.
|
||
# ----------------------------------------------------------------------------
|
||
# out/seo_schema_org.json est byte-gaté par check_artifacts : la reproductibilité
|
||
# prouve qu'il SE RECONSTRUIT depuis seo_spec.json, PAS que son `Organization.name`
|
||
# == la marque publique nommée dans CLAUDE.md §Entités (« **Helios RD** (marque
|
||
# publique) sous WAG »). Le bloc SEO schema.org amont gate la COMPOSITION (@type +
|
||
# bijection un-listing-par-projet), jamais l'IDENTITÉ de la marque ni le rattachement
|
||
# des nœuds au site. Piège #6 : RENOMMER la marque dans
|
||
# seo_spec.json[schema_org.organization.name] (ou dans CLAUDE.md §Entités) laisse le
|
||
# JSON-LD PUBLIC — celui que Google indexe sous `vente.otov7.com` — émettre un nom de
|
||
# marque qui CONTREDIT le mandat pendant que la byte-gate reste VERTE (l'artefact se
|
||
# reconstruit fidèlement… depuis un spec dérivé) — « vert trompeur » de la classe des
|
||
# tokens branding ancrés sur CLAUDE.md #4. Aucune suite tests/ (FONCTIONS de
|
||
# génération, jamais l'ancrage à CLAUDE.md) ne l'attrape. On RE-DÉRIVE la marque de
|
||
# CLAUDE.md §Entités (zéro duplication · source unique) + le base_url du MANIFEST
|
||
# byte-gaté + le country_code du spec, puis on exige : (a) Organization.name == marque
|
||
# CLAUDE.md ; (b) Organization.url == base_url et @id sous base_url ; (c) chaque nœud
|
||
# listing rattaché à CETTE Organization (brand.@id == org.@id) et sous base_url ;
|
||
# (d) addressCountry UNIFORME == country_code du spec (géo-ciblage appliqué à
|
||
# l'identique à chaque nœud) ; (e) le spec DÉCLARE l'ancrage (organization.name ==
|
||
# marque + `source` cite CLAUDE.md). État courant : aucune valeur périmée — le défaut
|
||
# est la surface ungated.
|
||
# ============================================================================
|
||
_nfc_org = lambda s: unicodedata.normalize("NFC", str(s))
|
||
try:
|
||
cm_ent = open("CLAUDE.md", encoding="utf-8").read()
|
||
_mb = re.findall(r"\*\*([^*]+?)\*\*\s*\(marque publique\)", cm_ent)
|
||
if len(_mb) != 1:
|
||
raise ValueError(f"« (marque publique) » attendu 1× dans CLAUDE.md §Entités, "
|
||
f"trouvé {len(_mb)}×")
|
||
brand = _nfc_org(_mb[0].strip())
|
||
except (OSError, ValueError) as e:
|
||
bad(f"SEO org · marque publique CLAUDE.md §Entités illisible/inattendue : {e}"); brand = None
|
||
try:
|
||
sog = load("seo/out/seo_schema_org.json")["@graph"]
|
||
seo_base = load("seo/out/MANIFEST.json")["base_url"]
|
||
with open(f"{D}/seo/seo_spec.json", encoding="utf-8") as _f:
|
||
spec_sorg = json.load(_f)["schema_org"]
|
||
seo_cc = spec_sorg["country_code"]
|
||
spec_orgn = spec_sorg["organization"]
|
||
org_n = [n for n in sog if str(n.get("@id", "")).endswith("#organization")]
|
||
lst_n = [n for n in sog if not str(n.get("@id", "")).endswith("#organization")]
|
||
except (OSError, KeyError, ValueError, TypeError) as e:
|
||
bad(f"SEO org · artefact/spec schema.org illisible/inattendu : {e}"); sog = None
|
||
if brand is not None and sog is not None:
|
||
# (0) racine Organization = singleton (pré-requis à toute assertion d'identité)
|
||
if len(org_n) != 1:
|
||
bad(f"SEO org · nœud racine #organization non-singleton ({len(org_n)}) "
|
||
f"→ identité de marque indéterminable")
|
||
else:
|
||
org = org_n[0]
|
||
# (a) ANCRAGE : Organization.name == marque publique CLAUDE.md §Entités
|
||
got_name = _nfc_org(org.get("name", ""))
|
||
if got_name == brand:
|
||
good(f"SEO org · Organization.name {got_name!r} == marque publique "
|
||
f"CLAUDE.md §Entités")
|
||
else:
|
||
bad(f"SEO org · Organization.name {got_name!r} ≠ CLAUDE.md §Entités {brand!r} "
|
||
f"→ JSON-LD public contredit le mandat (régénérer spec/artefact ou aligner CLAUDE.md)")
|
||
# (b) Organization.url == base_url + @id sous base_url
|
||
if org.get("url") == seo_base and str(org.get("@id", "")) == f"{seo_base}/#organization":
|
||
good(f"SEO org · Organization.url == base_url ({seo_base}) · @id cohérent")
|
||
else:
|
||
bad(f"SEO org · Organization.url={org.get('url')!r}/@id={org.get('@id')!r} "
|
||
f"≠ base_url {seo_base!r}(+/#organization) → graphe incohérent")
|
||
# (c) chaque listing rattaché à CETTE Organization (brand.@id), sous base_url
|
||
bad_brand = [n.get("@id") for n in lst_n
|
||
if (n.get("brand") or {}).get("@id") != org.get("@id")]
|
||
bad_url = [n.get("@id") for n in lst_n
|
||
if not str(n.get("url", "")).startswith(f"{seo_base}/")]
|
||
if not lst_n:
|
||
bad("SEO org · aucun nœud listing → graphe suspect")
|
||
elif bad_brand or bad_url:
|
||
d = (f" · brand.@id≠org: {bad_brand}" if bad_brand else "") + \
|
||
(f" · url hors base_url: {bad_url}" if bad_url else "")
|
||
bad(f"SEO org · {len(lst_n)} listing(s) mais rattachement/URL cassé{d}")
|
||
else:
|
||
good(f"SEO org · {len(lst_n)} listing(s) rattaché(s) à l'Organization "
|
||
f"(brand.@id) sous base_url")
|
||
# (d) géo-ciblage : addressCountry UNIFORME == country_code du spec (code ISO 2)
|
||
if not re.fullmatch(r"[A-Z]{2}", str(seo_cc)):
|
||
bad(f"SEO org · country_code du spec {seo_cc!r} non conforme "
|
||
f"(code pays ISO 2 lettres attendu)")
|
||
else:
|
||
countries = {(n.get("address") or {}).get("addressCountry") for n in lst_n}
|
||
if countries == {seo_cc}:
|
||
good(f"SEO org · addressCountry uniforme == country_code spec ({seo_cc}) "
|
||
f"sur {len(lst_n)} nœud(s)")
|
||
else:
|
||
bad(f"SEO org · addressCountry {sorted(map(str, countries))} ≠ {{{seo_cc}}} du spec "
|
||
f"→ géo-ciblage non uniforme/divergent")
|
||
# (e) le spec DÉCLARE l'ancrage : organization.name == marque + source cite CLAUDE.md
|
||
spec_name = _nfc_org(spec_orgn.get("name", ""))
|
||
spec_src = spec_orgn.get("source", "")
|
||
if spec_name != brand:
|
||
bad(f"SEO org · seo_spec.json organization.name {spec_name!r} ≠ CLAUDE.md {brand!r} "
|
||
f"(l'input du générateur DÉRIVE de CLAUDE.md §Entités)")
|
||
elif "CLAUDE.md" not in spec_src or brand not in spec_src:
|
||
bad(f"SEO org · seo_spec.json organization.source ne DÉCLARE pas l'ancrage "
|
||
f"CLAUDE.md/{brand!r} : {spec_src!r}")
|
||
else:
|
||
good(f"SEO org · seo_spec.json déclare l'ancrage CLAUDE.md §Entités pour {brand!r}")
|
||
|
||
# ============================================================================
|
||
# SEO · l'IDENTITÉ du CATALOGUE PROJETS (P01..P09) — les 9 projets (`code` +
|
||
# `nom`) que le bundle SEO expose à Google — ANCRÉE sur CLAUDE.md §Projets.
|
||
# ----------------------------------------------------------------------------
|
||
# La matière première du worker SEO est `seo/fixtures/projets_master.json`
|
||
# (README:22 « sortie canonique … le worker ne fabrique aucun fait de projet »).
|
||
# Chaque entrée DÉCLARE `source.fichiers: ["CLAUDE.md#Projets"]` et le README:80
|
||
# l'affirme (« 9 projets P01..P09 · noms sourcés de `CLAUDE.md §Projets` ») — MAIS
|
||
# AUCUN gate ne le VÉRIFIAIT. check_artifacts prouve QUE les out/ SE RECONSTRUISENT
|
||
# depuis la fixture (byte-for-byte), PAS que ses `code`/`nom` == CLAUDE.md §Projets ;
|
||
# les blocs SEO amont gatent les COMPTES de mots-clés + la COMPOSITION du graphe
|
||
# schema.org (bijection un-listing-par-projet) + l'IDENTITÉ de la marque racine
|
||
# (§Entités), tous AVEUGLES à l'identité de la LISTE de projets. Piège #6 :
|
||
# RENOMMER un projet dans CLAUDE.md §Projets (« Aqua Terra Las Terrenas » → « … Bay »),
|
||
# le RENOMMER/AJOUTER/RETIRER dans la fixture, puis régénérer, laisse le JSON-LD PUBLIC
|
||
# — celui que Google indexe sous `vente.otov7.com/projets/…` — lister un projet
|
||
# FANTÔME ou en OMETTRE un canonique pendant que la byte-gate reste VERTE (l'artefact
|
||
# se reconstruit fidèlement… depuis une fixture DÉRIVÉE) et que le compte reste 9 —
|
||
# « vert trompeur » de la MÊME classe que la marque org (§Entités), les entités
|
||
# CONFOTUR (§Entités) et les libellés projets du run_sheet démo (§Projets). Aucune
|
||
# suite tests/ (FONCTIONS de génération, jamais l'ancrage à CLAUDE.md) ne l'attrape.
|
||
# On RE-DÉRIVE le catalogue de CLAUDE.md §Projets (zéro duplication · source unique)
|
||
# puis on exige : (a) identité d'ensemble des codes fixture ⇔ CLAUDE.md (aucun
|
||
# fantôme/manquant · pas de doublon) ; (b) `nom` de chaque entrée == CLAUDE.md §Projets
|
||
# (accent-insensible · la fixture normalise « Xamaná »→« Xamana ») ; (c) chaque entrée
|
||
# DÉCLARE l'ancrage `CLAUDE.md#Projets` dans `source.fichiers` ; (d) les `name` des
|
||
# listings du graphe schema.org PUBLIC == l'ensemble des noms CLAUDE.md §Projets +
|
||
# bijection (1 listing/projet) + MANIFEST counts projects/listings == |§Projets| ;
|
||
# (e) le README cite l'ancrage CLAUDE.md §Projets. Un claim absent échoue AUSSI
|
||
# (disparition = régression). État courant : aucune valeur périmée.
|
||
# ============================================================================
|
||
_pnfc = lambda s: unicodedata.normalize("NFC", str(s))
|
||
_pai = lambda s: "".join(c for c in unicodedata.normalize("NFKD", str(s))
|
||
if unicodedata.category(c) != "Mn").lower().replace(" ", "")
|
||
try:
|
||
cm_pj = open("CLAUDE.md", encoding="utf-8").read()
|
||
_mpj = re.search(r"^## Projets\s*\n(.+)$", cm_pj, re.M)
|
||
if _mpj is None:
|
||
raise ValueError("section « ## Projets » INTROUVABLE")
|
||
seo_pmap = {}
|
||
for _t in _mpj.group(1).split("·"):
|
||
_mc = re.match(r"(P\d{2})\s+(.+)$", _pnfc(_t.strip()))
|
||
if _mc:
|
||
seo_pmap[_mc.group(1)] = _mc.group(2).strip()
|
||
if not seo_pmap:
|
||
raise ValueError("aucun projet PNN parsé")
|
||
except (OSError, ValueError) as e:
|
||
bad(f"SEO projets · CLAUDE.md §Projets illisible/inattendu : {e}"); seo_pmap = None
|
||
try:
|
||
with open(f"{D}/seo/fixtures/projets_master.json", encoding="utf-8") as _f:
|
||
seo_pm = json.load(_f)["projets"]
|
||
sg_pj = load("seo/out/seo_schema_org.json")["@graph"]
|
||
seo_cpj = load("seo/out/MANIFEST.json")["counts"]
|
||
lst_pj = [n for n in sg_pj if not str(n.get("@id", "")).endswith("#organization")]
|
||
except (OSError, KeyError, ValueError, TypeError) as e:
|
||
bad(f"SEO projets · fixture/artefact illisible/inattendu : {e}"); seo_pm = None
|
||
if seo_pmap is not None and seo_pm is not None:
|
||
# (a) ANCRAGE : identité d'ensemble des codes fixture ⇔ CLAUDE.md §Projets
|
||
fix_codes = [p.get("code") for p in seo_pm]
|
||
if len(set(fix_codes)) != len(fix_codes) or None in fix_codes or "" in fix_codes:
|
||
bad(f"SEO projets · projets_master : codes en doublon/vides {fix_codes}")
|
||
fx = {c for c in fix_codes if c}
|
||
miss = sorted(set(seo_pmap) - fx); extra = sorted(fx - set(seo_pmap))
|
||
if miss or extra:
|
||
bad(f"SEO projets · codes projets_master ≠ CLAUDE.md §Projets"
|
||
f"{' · manquants=' + str(miss) if miss else ''}"
|
||
f"{' · fantômes=' + str(extra) if extra else ''}")
|
||
else:
|
||
good(f"SEO projets · {len(fx)} codes P01..P09 projets_master == CLAUDE.md §Projets")
|
||
# (b)+(c) nom ancré + déclaration de l'ancrage, par projet
|
||
for p in seo_pm:
|
||
code = p.get("code"); nom = _pnfc(p.get("nom", ""))
|
||
anchor = seo_pmap.get(code)
|
||
if anchor is None:
|
||
continue # code fantôme : déjà mordu par le set-diff (a)
|
||
if _pai(nom) != _pai(anchor):
|
||
bad(f"SEO projets · {code} nom « {nom} » (projets_master) DÉRIVE de "
|
||
f"CLAUDE.md §Projets « {anchor} » → aligner (ou CLAUDE.md)")
|
||
else:
|
||
good(f"SEO projets · {code} — nom « {nom} » == CLAUDE.md §Projets")
|
||
srcf = (p.get("source") or {}).get("fichiers") or []
|
||
if not any("CLAUDE.md" in str(x) and "Projets" in str(x) for x in srcf):
|
||
bad(f"SEO projets · {code} — projets_master ne DÉCLARE pas l'ancrage "
|
||
f"`CLAUDE.md#Projets` dans source.fichiers : {srcf}")
|
||
# (d) artefact PUBLIC : `name` des listings schema.org == noms CLAUDE.md §Projets
|
||
if not lst_pj:
|
||
bad("SEO projets · aucun nœud listing schema.org → graphe suspect")
|
||
else:
|
||
got_n = {_pai(n.get("name", "")) for n in lst_pj}
|
||
want_n = {_pai(v) for v in seo_pmap.values()}
|
||
gm = sorted(want_n - got_n); ge = sorted(got_n - want_n)
|
||
if gm or ge:
|
||
bad(f"SEO projets · `name` des listings schema.org (JSON-LD public) ≠ "
|
||
f"CLAUDE.md §Projets{' · manquants=' + str(gm) if gm else ''}"
|
||
f"{' · en trop=' + str(ge) if ge else ''}")
|
||
elif len(lst_pj) != len(seo_pmap):
|
||
bad(f"SEO projets · {len(lst_pj)} listing(s) schema.org MAIS "
|
||
f"{len(seo_pmap)} projet(s) CLAUDE.md §Projets (bijection cassée)")
|
||
else:
|
||
good(f"SEO projets · {len(lst_pj)} listings schema.org (name) == CLAUDE.md "
|
||
f"§Projets (JSON-LD indexé fidèle au mandat)")
|
||
if seo_cpj.get("projects") != len(seo_pmap) or seo_cpj.get("listings") != len(seo_pmap):
|
||
bad(f"SEO projets · MANIFEST counts projects={seo_cpj.get('projects')}/"
|
||
f"listings={seo_cpj.get('listings')} ≠ {len(seo_pmap)} (CLAUDE.md §Projets)")
|
||
else:
|
||
good(f"SEO projets · MANIFEST counts projects/listings == {len(seo_pmap)} (§Projets)")
|
||
# (e) le README cite l'ancrage CLAUDE.md §Projets
|
||
seo_p_rd = read_norm(f"{D}/seo/README.md")
|
||
if seo_p_rd is not None:
|
||
if re.search(r"CLAUDE\.md\s*§?\s*Projets", seo_p_rd):
|
||
good("SEO projets · README cite l'ancrage CLAUDE.md §Projets")
|
||
else:
|
||
bad("SEO projets · README ne cite pas l'ancrage CLAUDE.md §Projets "
|
||
"(attendu « noms sourcés de CLAUDE.md §Projets »)")
|
||
|
||
# ============================================================================
|
||
# SEO · la CARTE hreflang (`alternate` FR/EN/ES + `x-default` par page) — la
|
||
# 3ᵉ surface du même README SEO — décrite EN PROSE sans AUCUN gate d'IDENTITÉ.
|
||
# ----------------------------------------------------------------------------
|
||
# Le générateur SEO émet out/seo_hreflang.json (byte-gaté par check_artifacts ⇒
|
||
# faisant autorité) : une page par projet + accueil, chacune portant un ENSEMBLE
|
||
# d'`alternate` (un par langue de contenu) + un `x-default`. Le README:17 décrit
|
||
# cet ensemble À LA MAIN : « carte `alternate` FR/EN/ES + `x-default` par page ».
|
||
# Les deux blocs SEO existants gatent le COMPTE de mots-clés (keywords_total +
|
||
# ventilation par langue, artefact seo_keywords) et la composition du graphe
|
||
# schema.org (artefact seo_schema_org) — jamais le jeu de langues hreflang, qui
|
||
# vit dans un TROISIÈME artefact (seo_hreflang) au contenu distinct. Aucun gate
|
||
# ne comparait ces CODES à l'artefact : AJOUTER une langue au bundle (ex. `pt`
|
||
# → hreflang à 4 langues de contenu) OU RETIRER/ÉCHANGER `es` ferait dériver la
|
||
# prose en silence pendant que la carte byte-gatée dit autre chose — « vert
|
||
# trompeur » qu'aucune suite tests/ (qui teste des FONCTIONS de génération, pas
|
||
# la prose du README) n'attrape. Même patron que l'énumération des onglets
|
||
# Mobile, du catalogue custom RBAC ou des @type schema.org. On RECOMPUTE
|
||
# l'ENSEMBLE des langues de contenu depuis SON artefact byte-gaté (zéro
|
||
# duplication de la carte du générateur #6) et on exige que la prose les nomme
|
||
# EXACTEMENT (absent ET en trop via set-diff) + mentionne `x-default`. Cohérences
|
||
# croisées en bonus (mordent un artefact INTERNEMENT incohérent) : toutes les
|
||
# pages portent le MÊME jeu d'`alternate` (uniforme) ; `x-default` présent sur
|
||
# CHAQUE page ; la langue par défaut (`x_default_lang`) ∈ langues de contenu (ni
|
||
# vacante ni étrangère) ; pour chaque page `x-default`.href == `canonical` ==
|
||
# href de la langue par défaut (l'invariant « x-default = langue par défaut =
|
||
# canonical » du README:61). Un claim absent échoue AUSSI.
|
||
# ============================================================================
|
||
SEO_HL_RD = f"{D}/seo/README.md"
|
||
try:
|
||
hl = load("seo/out/seo_hreflang.json")
|
||
hl_pages = hl["pages"]
|
||
x_default_lang = hl["x_default_lang"]
|
||
page_sets = [frozenset(a["hreflang"] for a in p["alternates"]) for p in hl_pages]
|
||
all_codes = set().union(*page_sets) if page_sets else set()
|
||
content_langs = all_codes - {"x-default"} # langues de contenu (hors x-default)
|
||
except (OSError, KeyError, ValueError, TypeError) as e:
|
||
bad(f"SEO hreflang · artefact illisible/inattendu : {e}"); hl_pages = None
|
||
if hl_pages:
|
||
# bonus 1 : toutes les pages portent le MÊME jeu d'alternate (uniforme)
|
||
if len(set(page_sets)) != 1:
|
||
bad(f"SEO hreflang · carte NON uniforme : {len(set(page_sets))} jeux distincts "
|
||
f"d'`alternate` sur {len(hl_pages)} pages → une carte hétérogène")
|
||
else:
|
||
good(f"SEO hreflang · {len(hl_pages)} pages · jeu d'`alternate` uniforme "
|
||
f"{sorted(all_codes)}")
|
||
# bonus 2 : x-default sur chaque page + langue par défaut ∈ langues de contenu
|
||
if "x-default" not in all_codes or any("x-default" not in s for s in page_sets):
|
||
bad("SEO hreflang · `x-default` absent d'au moins une page → carte incomplète")
|
||
elif x_default_lang not in content_langs:
|
||
bad(f"SEO hreflang · x_default_lang={x_default_lang!r} ∉ langues de contenu "
|
||
f"{sorted(content_langs)} → défaut vacant/étranger")
|
||
else:
|
||
good(f"SEO hreflang · `x-default` sur chaque page · défaut {x_default_lang!r} "
|
||
f"∈ langues de contenu")
|
||
# bonus 3 : href-cohérence — x-default == canonical == href(langue par défaut)
|
||
incoh = []
|
||
for p in hl_pages:
|
||
alt = {a["hreflang"]: a["href"] for a in p["alternates"]}
|
||
if not (alt.get("x-default") == p.get("canonical") == alt.get(x_default_lang)):
|
||
incoh.append(p.get("page"))
|
||
if incoh:
|
||
bad(f"SEO hreflang · href incohérent (x-default ≠ canonical ≠ {x_default_lang}) "
|
||
f"sur {incoh} → artefact interne incohérent")
|
||
else:
|
||
good(f"SEO hreflang · `x-default`.href == `canonical` == href({x_default_lang}) "
|
||
f"(les {len(hl_pages)} pages)")
|
||
# README:17 — la cellule « carte `alternate` <langues> + `x-default` par page »
|
||
seo_hl_rd = read_norm(SEO_HL_RD)
|
||
if seo_hl_rd is not None:
|
||
m = re.search(r"carte `alternate` (.+?) par page", seo_hl_rd)
|
||
if m is None:
|
||
bad(f"SEO hreflang · {SEO_HL_RD} — carte « `alternate` … par page » "
|
||
f"INTROUVABLE (attendu {sorted(content_langs)} + x-default)")
|
||
else:
|
||
seg = m.group(1)
|
||
prose_langs = {t.lower() for t in re.findall(r"\b[A-Z]{2}\b", seg.split("+")[0])}
|
||
has_xdef = "x-default" in seg
|
||
if prose_langs == content_langs and has_xdef:
|
||
good(f"SEO hreflang · {SEO_HL_RD} — langues {sorted(prose_langs)} + "
|
||
f"`x-default` == seo_hreflang")
|
||
else:
|
||
miss = sorted(content_langs - prose_langs)
|
||
extra = sorted(prose_langs - content_langs)
|
||
detail = (f" · absents={miss}" if miss else "") + \
|
||
(f" · en trop={extra}" if extra else "") + \
|
||
("" if has_xdef else " · `x-default` non mentionné")
|
||
bad(f"SEO hreflang · {SEO_HL_RD} — nomme {sorted(prose_langs)}"
|
||
f"{'+x-default' if has_xdef else ''} MAIS carte "
|
||
f"{sorted(content_langs)}+x-default{detail} → régénérer le README")
|
||
|
||
# ============================================================================
|
||
# SEO · FICHE AGENT (03_agents/seo/AGENT.md) · les cellules « Métrique vérifiée »
|
||
# restituent DEUX attributs data-derived du bundle SEO — gatées seulement côté
|
||
# README, JAMAIS sur la fiche.
|
||
# ----------------------------------------------------------------------------
|
||
# Le tableau « Livrable réellement produit » (fiche:24-26) transcrit À LA MAIN,
|
||
# à côté du compte de mots-clés (lui déjà gaté sur README **et** fiche via le
|
||
# bloc SEO_FI amont), deux sorties byte-gatées mais AVEUGLÉES côté fiche :
|
||
# L25 « graphe JSON-LD : **10 nœuds** (`Organization` + 9 `Residence`) »
|
||
# L26 « **10 pages** × FR/EN/ES + `x-default` ».
|
||
# Les blocs SEO schema.org / hreflang ci-dessus ne lisent QUE le README
|
||
# (SEO_SG_RD / SEO_HL_RD) : RENOMMER le @type des listings, AJOUTER/RETIRER un
|
||
# projet (→ N nœuds & N pages dérivent) ou ÉCHANGER une langue de contenu laisse
|
||
# ces cellules périmées pendant que les trois artefacts byte-gatés disent autre
|
||
# chose — « vert trompeur » de la classe des fiches-agent qui restituent des
|
||
# attributs data-derived gatés seulement en AGRÉGAT (mémoire
|
||
# agent-fiche-role-attrs-ungated ; même patron que la cellule chat_otoia de la
|
||
# fiche Frontend Console). Aucune suite tests/ (FONCTIONS du builder, jamais la
|
||
# prose de la fiche) ne l'attrape. On RECOMPUTE depuis les MÊMES artefacts
|
||
# byte-gatés (schema.org + hreflang) réutilisés par les blocs README ci-dessus
|
||
# (zéro duplication #6) et on exige l'IDENTITÉ EXACTE ; un claim absent échoue
|
||
# AUSSI (INTROUVABLE = régression #6). Cohérence arithmétique en bonus : total
|
||
# nœuds == 1 racine + M listings (un graphe interne incohérent est mordu).
|
||
# ============================================================================
|
||
seo_fi = read_norm(SEO_FI) # SEO_FI défini au bloc « mots-clés » amont
|
||
if seo_fi is not None and sg is not None:
|
||
# fiche:25 — « graphe JSON-LD : **N nœuds** (`<racine>` + M `<listing>`) »
|
||
root_t = sorted(org_types)[0] if len(org_types) == 1 else None
|
||
list_t = sorted(list_types)[0] if len(list_types) == 1 else None
|
||
n_nodes = seo_cnt.get("schema_org_nodes")
|
||
n_list = seo_cnt.get("listings")
|
||
m = re.search(r"graphe JSON-LD : \*\*(\d+) nœuds\*\* "
|
||
r"\(`([A-Za-z]+)` \+ (\d+) `([A-Za-z]+)`\)", seo_fi)
|
||
if m is None:
|
||
bad(f"SEO fiche · {SEO_FI} — cellule « graphe JSON-LD : **N nœuds** "
|
||
f"(`<racine>` + M `<listing>`) » INTROUVABLE (attendu {n_nodes} nœuds : "
|
||
f"1 `{root_t}` + {n_list} `{list_t}`)")
|
||
else:
|
||
g_tot, g_root = int(m.group(1)), m.group(2)
|
||
g_m, g_list = int(m.group(3)), m.group(4)
|
||
errs = []
|
||
if g_tot != n_nodes: errs.append(f"total {g_tot}≠{n_nodes}")
|
||
if g_root != root_t: errs.append(f"racine `{g_root}`≠`{root_t}`")
|
||
if g_m != n_list: errs.append(f"listings {g_m}≠{n_list}")
|
||
if g_list != list_t: errs.append(f"type listing `{g_list}`≠`{list_t}`")
|
||
if g_tot != 1 + g_m: errs.append(f"arith {g_tot}≠1+{g_m}")
|
||
if errs:
|
||
bad(f"SEO fiche · {SEO_FI}:25 — {' · '.join(errs)} → régénérer la fiche")
|
||
else:
|
||
good(f"SEO fiche · {SEO_FI}:25 — {g_tot} nœuds (1 `{g_root}` + {g_m} "
|
||
f"`{g_list}`) == seo_schema_org.@graph")
|
||
if seo_fi is not None and hl_pages:
|
||
# fiche:26 — « **N pages** × <langues> + `x-default` »
|
||
exp_langs = "/".join(c.upper() for c in sorted(content_langs))
|
||
m = re.search(r"\*\*(\d+) pages\*\* × ([A-Z/]+) \+ `x-default`", seo_fi)
|
||
if m is None:
|
||
bad(f"SEO fiche · {SEO_FI} — cellule « **N pages** × <langues> + `x-default` » "
|
||
f"INTROUVABLE (attendu {len(hl_pages)} pages × {exp_langs})")
|
||
else:
|
||
g_pages = int(m.group(1))
|
||
g_langs = {t.lower() for t in m.group(2).split("/") if t}
|
||
errs = []
|
||
if g_pages != len(hl_pages): errs.append(f"pages {g_pages}≠{len(hl_pages)}")
|
||
if g_langs != content_langs:
|
||
miss = sorted(content_langs - g_langs); extra = sorted(g_langs - content_langs)
|
||
errs.append(f"langues {sorted(g_langs)}≠{sorted(content_langs)}"
|
||
+ (f" absents={miss}" if miss else "")
|
||
+ (f" en trop={extra}" if extra else ""))
|
||
if errs:
|
||
bad(f"SEO fiche · {SEO_FI}:26 — {' · '.join(errs)} → régénérer la fiche")
|
||
else:
|
||
good(f"SEO fiche · {SEO_FI}:26 — {g_pages} pages × {exp_langs} + "
|
||
f"`x-default` == seo_hreflang")
|
||
|
||
# ============================================================================
|
||
# CRM/dossier_vente (2e surface) · CATALOGUE DE PROJETS « P01..P09 ancré sur
|
||
# CLAUDE.md » — le Select `projet` du DocType porteur.
|
||
# ----------------------------------------------------------------------------
|
||
# Le bloc CRM pipeline ci-dessus recompute le COMPTE d'états du même README
|
||
# (« les 9 états du pipeline »). Restait, dans le MÊME README, une surface
|
||
# data-derived distincte et jamais gatée : la liste des PROJETS. Le champ Select
|
||
# `projet` du DocType (out/doctype_oto_dossier_vente.json, byte-gaté par
|
||
# check_artifacts) porte l'ENSEMBLE ORDONNÉ des 9 projets OTO
|
||
# (« P01 Structure », « P02 Coral del Sur », …). Le README:46 le résume
|
||
# « Projet (`Select` **P01..P09** ancré sur CLAUDE.md) » et le `_comment` du
|
||
# doctype_spec.json le déclare « ancré sur CLAUDE.md … pas inventé ». Or ce
|
||
# catalogue est une TRANSCRIPTION MANUELLE : la section « ## Projets » de
|
||
# CLAUDE.md est la SOURCE FAISANT AUTORITÉ (contraintes #6/#7 · liste des
|
||
# projets), mais AUCUN gate ne compare les options du DocType à CLAUDE.md.
|
||
# check_artifacts ne prouve que DocType == spec (byte-for-byte) ; la spec
|
||
# elle-même recopie la liste — donc TOUTE la chaîne (spec → DocType) peut
|
||
# DÉRIVER de CLAUDE.md en restant byte-verte : RENOMMER un projet dans CLAUDE.md
|
||
# (« P03 Nakua » → « P03 Nakua Bay »), en AJOUTER un (P10) ou en RETIRER un
|
||
# laisse le Select périmé, contredisant silencieusement CLAUDE.md pendant que la
|
||
# prose « ancré sur CLAUDE.md » devient FAUSSE — « vert trompeur » qu'aucune
|
||
# suite tests/ (qui teste des FONCTIONS de builder, pas l'ancrage à CLAUDE.md)
|
||
# n'attrape. Même esprit que le roadmap-anchor (INV11) qui ancre les
|
||
# `roadmap_line` de l'artefact au fichier roadmap : ici on ANCRE le catalogue
|
||
# projet du DocType à CLAUDE.md. On RECOMPUTE le catalogue depuis « ## Projets »
|
||
# de CLAUDE.md (zéro duplication · source unique) et on exige que les options du
|
||
# Select l'égalent EXACTEMENT (absent ET en trop via set-diff, puis ORDRE), et
|
||
# que la prose porte la bonne plage P01..P<dernier> + le mot d'ancrage. Un claim
|
||
# absent échoue AUSSI. Cohérences croisées en bonus (mordent un catalogue
|
||
# CLAUDE.md internement incohérent) : catalogue NON VIDE, codes SANS DOUBLON et
|
||
# CONTIGUS depuis P01 (P01,P02,…,P0N — byte-déterminisme du contrat).
|
||
# ============================================================================
|
||
def _nfc(s):
|
||
return unicodedata.normalize("NFC", s)
|
||
try:
|
||
cm_raw = open("CLAUDE.md", encoding="utf-8").read()
|
||
mcm = re.search(r"^## Projets\s*\n(.+)$", cm_raw, re.M)
|
||
if mcm is None:
|
||
raise ValueError("section « ## Projets » INTROUVABLE")
|
||
# catalogue faisant autorité = tokens « PNN Label » séparés par « · »
|
||
cat = [_nfc(t.strip()) for t in mcm.group(1).split("·") if t.strip()]
|
||
cat_codes = [re.match(r"(P\d{2})\b", p).group(1) for p in cat]
|
||
except (OSError, ValueError, AttributeError) as e:
|
||
bad(f"CRM projets · CLAUDE.md « ## Projets » illisible/inattendu : {e}"); cat = None
|
||
if cat is not None:
|
||
# bonus : catalogue non vide · codes sans doublon · contigus depuis P01
|
||
contig = cat_codes == [f"P{i:02d}" for i in range(1, len(cat_codes) + 1)]
|
||
if not cat or len(set(cat_codes)) != len(cat_codes) or not contig:
|
||
bad(f"CRM projets · catalogue CLAUDE.md dégénéré : codes={cat_codes} "
|
||
f"(attendu P01..P{len(cat_codes):02d} contigus, sans doublon, non vide)")
|
||
else:
|
||
good(f"CRM projets · catalogue CLAUDE.md sain : {len(cat)} projets "
|
||
f"P01..{cat_codes[-1]} contigus")
|
||
# 1) ANCRAGE : options du Select `projet` du DocType == catalogue CLAUDE.md
|
||
try:
|
||
dv_dt = load("crm/dossier_vente/out/doctype_oto_dossier_vente.json")
|
||
pf = next(f for f in dv_dt["fields"] if f.get("fieldname") == "projet")
|
||
dt_opts = [_nfc(o.strip()) for o in pf["options"].split("\n") if o.strip()]
|
||
except (OSError, KeyError, ValueError, StopIteration) as e:
|
||
bad(f"CRM projets · DocType Select `projet` illisible/inattendu : {e}"); dt_opts = None
|
||
if dt_opts is not None:
|
||
if dt_opts == cat:
|
||
good(f"CRM projets · Select `projet` du DocType == CLAUDE.md "
|
||
f"({len(cat)} projets, ordre inclus)")
|
||
elif set(dt_opts) == set(cat):
|
||
bad(f"CRM projets · Select `projet` porte le bon ENSEMBLE mais un ORDRE "
|
||
f"≠ CLAUDE.md : DocType={dt_opts} vs {cat} → régénérer le DocType")
|
||
else:
|
||
miss = [p for p in cat if p not in dt_opts]
|
||
extra = [p for p in dt_opts if p not in cat]
|
||
detail = (f" · absents={miss}" if miss else "") + \
|
||
(f" · en trop={extra}" if extra else "")
|
||
bad(f"CRM projets · Select `projet` du DocType DÉRIVE de CLAUDE.md"
|
||
f"{detail} → régénérer le DocType (ou aligner CLAUDE.md)")
|
||
# 2) PROSE : README résume la plage P01..P<dernier> + ancrage CLAUDE.md
|
||
dv_rd2 = read_norm(f"{D}/crm/dossier_vente/README.md")
|
||
if dv_rd2 is not None:
|
||
mp = re.search(r"Projet \(`Select` \*\*(P\d{2})\.\.(P\d{2})\*\* "
|
||
r"ancré sur CLAUDE\.md\)", dv_rd2)
|
||
if mp is None:
|
||
bad(f"CRM projets · {D}/crm/dossier_vente/README.md — claim « Projet "
|
||
f"(`Select` P01..P{cat_codes[-1][1:]} ancré sur CLAUDE.md) » INTROUVABLE")
|
||
elif (mp.group(1), mp.group(2)) == (cat_codes[0], cat_codes[-1]):
|
||
good(f"CRM projets · README plage {mp.group(1)}..{mp.group(2)} "
|
||
f"+ ancrage CLAUDE.md == catalogue")
|
||
else:
|
||
bad(f"CRM projets · README dit {mp.group(1)}..{mp.group(2)} MAIS catalogue "
|
||
f"CLAUDE.md va de {cat_codes[0]} à {cat_codes[-1]} → régénérer le README")
|
||
|
||
# ============================================================================
|
||
# Portails frontend (2e surface) · CATALOGUE des DocTypes `custom` à CRÉER avant
|
||
# import — la consigne de hand-off VPS énumérée À LA MAIN, sans gate d'IDENTITÉ.
|
||
# ----------------------------------------------------------------------------
|
||
# Le bloc portails ci-dessus recompute les COMPTES par workspace (cartes/liens/
|
||
# rôles) et la table portail⇔workspace. Restait, dans le MÊME README, une surface
|
||
# data-derived DISTINCTE et jamais gatée : le §« Hand-off → agent ERPNext Backend »
|
||
# item 2 énumère « DocTypes custom à créer avant import : `CONFOTUR Application`,
|
||
# `Faisabilité`, `Publiciste Log` (… le reste est natif v15) ». Ce catalogue est
|
||
# DATA-DERIVED : c'est out/MANIFEST.json `doctypes_custom_a_creer` (byte-gaté par
|
||
# check_artifacts) = l'UNION des `doctypes_custom` par workspace, eux-mêmes lus du
|
||
# flag `custom` du contrat rbac_50_roles.json pour les seuls DocTypes visés par une
|
||
# carte/un lien du portail. C'est un ensemble PROPRE au module portails (sous-
|
||
# ensemble de celui de rbac/fixtures_gen : ici seuls comptent les DocTypes custom
|
||
# effectivement CÂBLÉS dans un workspace — API Access, custom mais non câblé, en est
|
||
# ABSENT). check_artifacts ne prouve QUE MANIFEST==build (byte-for-byte), et le
|
||
# build RECOMPUTE l'union — donc AJOUTER une carte visant un nouveau DocType custom
|
||
# au contrat (le MANIFEST rebâtit le catalogue) · en RENOMMER un · en RETIRER un du
|
||
# câblage laisse la prose de hand-off périmée pendant que l'artefact dit autre
|
||
# chose : l'agent Backend créerait le mauvais jeu de DocTypes (liens pointant dans
|
||
# le vide — le risque même que la consigne veut prévenir) — « vert trompeur »
|
||
# qu'aucune suite tests/ (qui teste des FONCTIONS de mapping, pas la prose de hand-
|
||
# off) n'attrape. Même patron que le catalogue custom de rbac/fixtures_gen. On
|
||
# RECOMPUTE le catalogue depuis MANIFEST (zéro duplication du contrat du générateur
|
||
# #6) et on exige que la prose l'énumère EXACTEMENT (absent ET en trop via set-diff ·
|
||
# accents/casse normalisés). Cohérences croisées en bonus (mordent un MANIFEST
|
||
# INTERNEMENT incohérent) : catalogue NON VIDE, sans doublon et TRIÉ (byte-
|
||
# déterminisme) · top-level == UNION des `doctypes_custom` par workspace (pas de
|
||
# DocType custom câblé mais oublié du catalogue de hand-off, ni l'inverse). Un claim
|
||
# absent échoue AUSSI (traçabilité).
|
||
# ============================================================================
|
||
PC_RD = f"{D}/frontend/portails/README.md"
|
||
def _pc_acc(s):
|
||
return "".join(c for c in unicodedata.normalize("NFKD", s)
|
||
if unicodedata.category(c) != "Mn").strip().lower()
|
||
try:
|
||
pc_man = load("frontend/portails/out/MANIFEST.json")
|
||
pc_cat = pc_man["doctypes_custom_a_creer"]
|
||
pc_union = sorted({d for w in pc_man["workspaces"]
|
||
for d in w.get("doctypes_custom", [])})
|
||
except (OSError, KeyError, ValueError, TypeError) as e:
|
||
bad(f"Portails catalogue · MANIFEST illisible/inattendu : {e}"); pc_cat = None
|
||
if pc_cat is not None:
|
||
# bonus : le catalogue est un vrai ENSEMBLE trié non vide (byte-déterminisme)
|
||
if not pc_cat:
|
||
bad("Portails catalogue · doctypes_custom_a_creer VIDE → aucun DocType custom "
|
||
"câblé dans un workspace ? (contrat/spec suspect)")
|
||
elif pc_cat != sorted(set(pc_cat)):
|
||
bad(f"Portails catalogue · doctypes_custom_a_creer {pc_cat} non trié/dédupliqué "
|
||
f"→ générateur non déterministe (attendu {sorted(set(pc_cat))})")
|
||
else:
|
||
good(f"Portails catalogue · custom à créer {pc_cat} — ensemble trié, sans doublon")
|
||
# bonus : cohérence interne — catalogue == union des doctypes_custom par workspace
|
||
if sorted(set(pc_cat)) != pc_union:
|
||
miss = sorted(set(pc_union) - set(pc_cat)); extra = sorted(set(pc_cat) - set(pc_union))
|
||
detail = (f" · câblés hors catalogue={miss}" if miss else "") + \
|
||
(f" · au catalogue mais non câblés={extra}" if extra else "")
|
||
bad(f"Portails catalogue · doctypes_custom_a_creer {sorted(pc_cat)} ≠ UNION des "
|
||
f"doctypes_custom par workspace {pc_union}{detail} → MANIFEST incohérent")
|
||
else:
|
||
good(f"Portails catalogue · catalogue == union des doctypes_custom par workspace "
|
||
f"({pc_union})")
|
||
pc_rd = read_norm(PC_RD)
|
||
if pc_rd is not None:
|
||
# README §Hand-off item 2 — énumération du catalogue custom avant « (issus »
|
||
mp = re.search(r"custom à créer\*\* avant import[^:]*?: (.+?)\(issus", pc_rd)
|
||
if mp is None:
|
||
bad(f"Portails catalogue · {PC_RD} — énumération « DocTypes custom à créer … » "
|
||
f"INTROUVABLE (attendu {sorted(pc_cat)})")
|
||
else:
|
||
got = {_pc_acc(t) for t in re.findall(r"`([^`]+)`", mp.group(1))}
|
||
exp = {_pc_acc(x) for x in pc_cat}
|
||
if got == exp:
|
||
good(f"Portails catalogue · {PC_RD} — hand-off {sorted(pc_cat)} == "
|
||
f"MANIFEST.doctypes_custom_a_creer")
|
||
else:
|
||
miss = sorted(exp - got); extra = sorted(got - exp)
|
||
detail = (f" · absents={miss}" if miss else "") + \
|
||
(f" · en trop={extra}" if extra else "")
|
||
bad(f"Portails catalogue · {PC_RD} — hand-off énumère {sorted(got)} MAIS "
|
||
f"MANIFEST {sorted(exp)}{detail} → régénérer le README")
|
||
|
||
# ============================================================================
|
||
# Faisabilité/generator · PARAMÈTRES CANONIQUES « ancrés sur CLAUDE.md #9/#10 »
|
||
# — les 6 valeurs imposées à CHAQUE faisabilité (jamais issues du brief).
|
||
# ----------------------------------------------------------------------------
|
||
# Le générateur 4 volets tient sa garantie anti-invention (#6) d'un jeu de
|
||
# PARAMÈTRES CANONIQUES émis TELS QUELS pour tous les projets : frais d'édition
|
||
# 3 %, marketing 8.5 %, point d'équilibre 52 % (CLAUDE.md #9) · devises USD+DOP,
|
||
# format Letter US, paiement Cardnet (CLAUDE.md #10). Ces 6 valeurs sont la
|
||
# SOURCE FAISANT AUTORITÉ = les contraintes NON-NÉGOCIABLES #9/#10 de CLAUDE.md.
|
||
# Elles sont RECOPIÉES à deux endroits, tous deux NON gatés :
|
||
# (a) genlib/model.py `CANONICAL` — le dict que le générateur émet dans CHAQUE
|
||
# rendu (n'est PAS un out/*.json → invisible à check_artifacts) ;
|
||
# (b) README:28 « 3 % édition · 8.5 % marketing · 52 % point d'équilibre ·
|
||
# USD+DOP · Cardnet · Letter US, CLAUDE.md #9/#10 » — la prose du module.
|
||
# Le SEUL contrôle existant (tests/test_generator.py::test_canonical_params…)
|
||
# assert que le RENDU contient les marqueurs « 3 % », « 52 % »… — mais ces
|
||
# marqueurs sont HARDCODÉS DANS LE TEST, une copie de plus, JAMAIS comparée à
|
||
# CLAUDE.md. Donc si la contrainte #9 change (ex. point d'équilibre 52 %→55 %),
|
||
# model.py, le test ET le README restent « 52 % » : tous internement cohérents,
|
||
# TOUS contredisant CLAUDE.md — le générateur émettrait alors 52 % dans chaque
|
||
# faisabilité alors que le mandat dit 55 % : une invention silencieuse de la
|
||
# classe même que #6 interdit, qu'aucune suite tests/ (qui teste des FONCTIONS,
|
||
# pas l'ancrage à CLAUDE.md) n'attrape. Même esprit que le roadmap-anchor (INV11)
|
||
# et le catalogue projets dossier_vente : on ANCRE les consommateurs à CLAUDE.md.
|
||
# On RECOMPUTE les 6 valeurs depuis les lignes #9/#10 de CLAUDE.md (zéro
|
||
# duplication · source unique) et on exige que (1) model.py `CANONICAL` les
|
||
# égale EXACTEMENT (% par valeur numérique · textes accent/espace-insensibles)
|
||
# et (2) la prose README les énumère EXACTEMENT (set-diff : absent ET en trop).
|
||
# Un claim absent échoue AUSSI (traçabilité).
|
||
# ============================================================================
|
||
import ast
|
||
def _ai(s): # accent-insensible, minuscule, sans espaces (comparaison de tokens)
|
||
return "".join(c for c in unicodedata.normalize("NFKD", s)
|
||
if unicodedata.category(c) != "Mn").lower().replace(" ", "")
|
||
def _pnorm(v): # 3.0 → "3%" · 8.5 → "8.5%" (format déterministe)
|
||
return f"{'%g' % v}%"
|
||
def _pct(s): # "3 %"/"52%"/… → 3.0/52.0
|
||
mm = re.search(r"(\d+(?:\.\d+)?)\s*%", s)
|
||
return float(mm.group(1)) if mm else None
|
||
try:
|
||
cm_raw2 = open("CLAUDE.md", encoding="utf-8").read()
|
||
l9 = re.search(r"^9\.\s+(.+)$", cm_raw2, re.M)
|
||
l10 = re.search(r"^10\.\s+(.+)$", cm_raw2, re.M)
|
||
if not l9 or not l10:
|
||
raise ValueError("contrainte #9 ou #10 INTROUVABLE")
|
||
# #9 : trois pourcentages, associés par mot-clé (accent-insensible)
|
||
seg9 = re.split(r"·", l9.group(1))
|
||
def _pct_kw(kw):
|
||
for s in seg9:
|
||
if kw in _ai(s):
|
||
return _pct(s)
|
||
return None
|
||
exp = {
|
||
"frais_edition_pct": _pct_kw("frais"),
|
||
"marketing_pct": _pct_kw("marketing"),
|
||
"point_equilibre_pct": _pct_kw("equilibre"),
|
||
}
|
||
# #10 : trois valeurs en gras, associées par leur rôle (devises/format/paiement)
|
||
seg10 = re.split(r"·", l10.group(1))
|
||
def _bold_kw(kw):
|
||
for s in seg10:
|
||
if kw in _ai(s):
|
||
mm = re.search(r"\*\*(.+?)\*\*", s)
|
||
return mm.group(1).strip() if mm else None
|
||
return None
|
||
exp["devises"] = _bold_kw("devise")
|
||
exp["format_doc"] = _bold_kw("format")
|
||
exp["paiement"] = _bold_kw("paiement")
|
||
if None in exp.values():
|
||
raise ValueError(f"valeurs canoniques illisibles dans #9/#10 : {exp}")
|
||
except (OSError, ValueError) as e:
|
||
bad(f"Faisabilité canoniques · CLAUDE.md #9/#10 illisible/inattendu : {e}"); exp = None
|
||
if exp is not None:
|
||
good(f"Faisabilité canoniques · CLAUDE.md #9/#10 sain : {_pnorm(exp['frais_edition_pct'])} "
|
||
f"édition · {_pnorm(exp['marketing_pct'])} mkt · {_pnorm(exp['point_equilibre_pct'])} "
|
||
f"équilibre · {exp['devises']} · {exp['format_doc']} · {exp['paiement']}")
|
||
_num_keys = ("frais_edition_pct", "marketing_pct", "point_equilibre_pct")
|
||
_txt_keys = ("devises", "format_doc", "paiement")
|
||
# 1) ANCRAGE : genlib/model.py CANONICAL == CLAUDE.md #9/#10
|
||
MP = f"{D}/faisabilite/generator/genlib/model.py"
|
||
try:
|
||
msrc = open(MP, encoding="utf-8").read()
|
||
mm = re.search(r"CANONICAL\s*=\s*(\{.*?\})", msrc, re.S)
|
||
canon = ast.literal_eval(mm.group(1))
|
||
except (OSError, ValueError, SyntaxError, AttributeError) as e:
|
||
bad(f"Faisabilité canoniques · {MP} CANONICAL illisible/inattendu : {e}"); canon = None
|
||
if canon is not None:
|
||
drift = []
|
||
for k in _num_keys:
|
||
g = _pct(str(canon.get(k, "")))
|
||
if g is None or g != exp[k]:
|
||
drift.append(f"{k}={canon.get(k)!r} (attendu {_pnorm(exp[k])})")
|
||
for k in _txt_keys:
|
||
if _ai(str(canon.get(k, ""))) != _ai(exp[k]):
|
||
drift.append(f"{k}={canon.get(k)!r} (attendu {exp[k]!r})")
|
||
if drift:
|
||
bad(f"Faisabilité canoniques · model.py CANONICAL DÉRIVE de CLAUDE.md #9/#10 : "
|
||
f"{' · '.join(drift)} → aligner model.py (ou CLAUDE.md)")
|
||
else:
|
||
good("Faisabilité canoniques · model.py CANONICAL == CLAUDE.md #9/#10 (6 valeurs)")
|
||
# 2) PROSE : README:28 énumère les 6 valeurs EXACTEMENT (absent ET en trop)
|
||
FG_RD = f"{D}/faisabilite/generator/README.md"
|
||
fg_rd = read_norm(FG_RD)
|
||
if fg_rd is not None:
|
||
mr = re.search(r"param[eè]tres canoniques\*\* \((.+?), CLAUDE\.md", fg_rd)
|
||
if mr is None:
|
||
bad(f"Faisabilité canoniques · {FG_RD} — énumération « paramètres canoniques "
|
||
f"(… CLAUDE.md #9/#10) » INTROUVABLE")
|
||
else:
|
||
got = set()
|
||
for tok in re.split(r"·", mr.group(1)):
|
||
p = _pct(tok)
|
||
got.add(_pnorm(p) if p is not None else _ai(tok))
|
||
want = {_pnorm(exp[k]) for k in _num_keys} | {_ai(exp[k]) for k in _txt_keys}
|
||
if got == want:
|
||
good(f"Faisabilité canoniques · {FG_RD} — prose énumère les 6 valeurs == "
|
||
f"CLAUDE.md #9/#10")
|
||
else:
|
||
miss = sorted(want - got); extra = sorted(got - want)
|
||
detail = (f" · absents={miss}" if miss else "") + \
|
||
(f" · en trop={extra}" if extra else "")
|
||
bad(f"Faisabilité canoniques · {FG_RD} — prose énumère {sorted(got)} MAIS "
|
||
f"CLAUDE.md {sorted(want)}{detail} → régénérer le README")
|
||
|
||
# ============================================================================
|
||
# Fiches agents · CITATION des constantes CLAUDE.md #10 (devises/format/paiement)
|
||
# — même classe d'ANCRAGE que le bloc « paramètres canoniques » ci-dessus, mais
|
||
# sur une SURFACE distincte : la prose « Anti-invention » des fiches agents.
|
||
# ----------------------------------------------------------------------------
|
||
# Deux fiches RECOPIENT à la main les valeurs canoniques de CLAUDE.md #10
|
||
# (**USD + DOP** devises · **Letter US** format · **Cardnet** paiements, pas
|
||
# Stripe) en AFFIRMANT « cités depuis CLAUDE.md #10, jamais réinventés » :
|
||
# • 03_agents/erpnext_backend/AGENT.md:57 — devises **USD + DOP**, format
|
||
# **Letter US**, paiements **Cardnet** (pas Stripe)
|
||
# • 03_agents/crm/AGENT.md:37 — devises **USD + DOP** et format **Letter US**
|
||
# La prose PRÉTEND être une CITATION de #10, mais AUCUN gate ne la liait à #10 :
|
||
# le bloc « paramètres canoniques » ci-dessus n'ancre QUE genlib/model.py
|
||
# CANONICAL + le README generator — AVEUGLE à ces deux fiches. Piège #6 : RENOMMER
|
||
# le prestataire de paiement dans CLAUDE.md #10 (`Cardnet` → `Azul`) ou changer une
|
||
# devise/format laisse ces citations PÉRIMÉES en silence, contredisant le mandat
|
||
# pendant que la prose se réclame de « CLAUDE.md #10 » — « vert trompeur » qu'aucune
|
||
# suite tests/ (FONCTIONS, jamais l'ancre à CLAUDE.md) n'attrape. On RÉUTILISE les
|
||
# valeurs `exp` déjà recomputées depuis #10 (zéro duplication) + le prestataire
|
||
# EXCLU (`(pas X)`), et on exige de CHAQUE fiche les tokens en gras qu'elle cite
|
||
# EXACTEMENT + l'ancre « CLAUDE.md #10 ». Chaque fiche cite un SOUS-ENSEMBLE
|
||
# distinct (crm omet le paiement) → gate par-fiche ce que la fiche énonce.
|
||
# ============================================================================
|
||
if exp is not None:
|
||
_m_excl = re.search(r"\(pas\s+(.+?)\)", l10.group(1))
|
||
exp_excl = _m_excl.group(1).strip() if _m_excl else None
|
||
_lab10 = {"devises": "devises", "format_doc": "format", "paiement": "paiement"}
|
||
# (fiche, [clés #10 citées], cite l'exclusion « (pas X) » ?, libellé)
|
||
FICHE10 = [
|
||
("03_agents/erpnext_backend/AGENT.md",
|
||
["devises", "format_doc", "paiement"], True, "fiche ERPNext Backend:57"),
|
||
("03_agents/crm/AGENT.md",
|
||
["devises", "format_doc"], False, "fiche CRM:37"),
|
||
]
|
||
for path, keys, cites_excl, label in FICHE10:
|
||
txt = read_norm(path)
|
||
if txt is None:
|
||
continue
|
||
# ancrage : la citation DOIT se réclamer de CLAUDE.md #10
|
||
if not re.search(r"CLAUDE\.md #10", txt):
|
||
bad(f"Fiches #10 · {label} ({path}) — ancre « CLAUDE.md #10 » "
|
||
f"INTROUVABLE → la citation ne se réclame plus de la contrainte")
|
||
continue
|
||
drift = []
|
||
for k in keys:
|
||
# token en gras **<valeur>** == valeur recomputée de #10 (casse tolérée)
|
||
if not re.search(r"\*\*\s*" + re.escape(exp[k]) + r"\s*\*\*",
|
||
txt, re.IGNORECASE):
|
||
drift.append(f"{_lab10[k]} attendu **{exp[k]}**")
|
||
if cites_excl and exp_excl is not None:
|
||
if not re.search(r"\(pas\s+" + re.escape(exp_excl) + r"\)",
|
||
txt, re.IGNORECASE):
|
||
drift.append(f"exclusion attendue « (pas {exp_excl}) »")
|
||
if drift:
|
||
bad(f"Fiches #10 · {label} ({path}) — citation DÉRIVE de CLAUDE.md #10 : "
|
||
f"{' · '.join(drift)} → aligner la fiche (ou CLAUDE.md)")
|
||
else:
|
||
_cited = " · ".join(f"**{exp[k]}**" for k in keys) + \
|
||
(f" · (pas {exp_excl})" if cites_excl and exp_excl else "")
|
||
good(f"Fiches #10 · {label} — cite {_cited} == CLAUDE.md #10")
|
||
|
||
# ============================================================================
|
||
# Fiscal · ecf_dgii — le JEU DE DEVISES `moneda` (`USD`/`DOP`) ANCRÉ sur CLAUDE.md
|
||
# #10 — même classe d'ANCRAGE que la CITATION #10 des fiches ci-dessus, mais sur
|
||
# une SURFACE distincte : la LISTE d'options de devise de l'e-CF, byte-gatée mais
|
||
# JAMAIS ancrée à la contrainte.
|
||
# ----------------------------------------------------------------------------
|
||
# `moneda.options = ["USD","DOP"]` — le jeu de devises que `TipoMoneda` peut porter
|
||
# dans un e-CF DGII — vit dans le spec ET, byte-gaté par check_artifacts, dans
|
||
# out/ecf_plan.json. C'est une valeur CANONIQUE de CLAUDE.md #10 (« **USD + DOP**
|
||
# devises ») : le README s'en réclame « #10 » (README:57 « **`devise`** (USD/DOP ·
|
||
# #10) », README:37/77) MAIS AUCUN gate ne liait cette liste à la contrainte —
|
||
# le bloc « Fiscal cross-cohérence » amont gate le FormaPago (Cardnet · #10) et les
|
||
# ÉTATS/champs émetteurs, AVEUGLE au jeu de devises ; le bloc « Fiches #10 » ancre
|
||
# la prose des fiches ERPNext/CRM, AVEUGLE au module fiscal ; et le `source` du bloc
|
||
# moneda est legitimement `null` (il source le TAUX TipoCambio, à confirmer · #8),
|
||
# donc l'ancre des OPTIONS vit dans un champ DISTINCT `options_source`.
|
||
# Byte-gater une constante canonique prouve la REPRODUCTIBILITÉ, PAS l'ANCRAGE
|
||
# (check_artifacts prouve ecf_plan==build depuis le spec, jamais spec[moneda]==#10) :
|
||
# renommer/étendre les devises dans CLAUDE.md #10 (`USD + DOP` → `USD + EUR`, ou
|
||
# +`EUR`) ET dans le spec de façon cohérente reste byte-VERT tout en faisant émettre
|
||
# à l'e-CF un `TipoMoneda` d'une devise qui CONTREDIT le mandat — « vert trompeur »
|
||
# de la MÊME classe que la marque SEO §Entités, la persona Chat OTOIA ou les tokens
|
||
# branding #4, qu'aucune suite tests/ (FONCTIONS de génération, jamais l'ancre à
|
||
# CLAUDE.md) n'attrape. On RÉUTILISE `exp["devises"]` déjà recomputé depuis #10 (zéro
|
||
# duplication) et on exige : (a) spec `moneda.options` == devises de #10 (ORDRE exact
|
||
# · mord un ajout/retrait/réordonnancement) ; (b) l'artefact byte-gaté idem ; (c) le
|
||
# spec DÉCLARE l'ancrage (`options_source` cite « CLAUDE.md #10 » + chaque devise) ;
|
||
# (d) la prose README qui se réclame de #10 cite le jeu EXACT + toute mention
|
||
# `moneda`/`devise` de paire de devises == le jeu (aucune périmée). Un claim absent
|
||
# échoue AUSSI (traçabilité).
|
||
# ============================================================================
|
||
if exp is not None:
|
||
ECF_MON_RD = f"{D}/fiscal/ecf_dgii/README.md"
|
||
want_cur = re.findall(r"[A-Z]{3}", exp["devises"]) # #10 « USD + DOP » → [USD, DOP]
|
||
want_set = set(want_cur)
|
||
try:
|
||
_mon_plan = load("fiscal/ecf_dgii/out/ecf_plan.json")["moneda"]
|
||
_mon_spec = json.load(open(f"{D}/fiscal/ecf_dgii/ecf_spec.json",
|
||
encoding="utf-8"))["moneda"]
|
||
except (OSError, KeyError, ValueError, TypeError) as e:
|
||
bad(f"Fiscal devises · moneda spec/artefact illisible/inattendu : {e}")
|
||
_mon_plan = _mon_spec = None
|
||
if not want_cur:
|
||
bad(f"Fiscal devises · CLAUDE.md #10 devises illisibles : {exp['devises']!r}")
|
||
elif _mon_plan is not None:
|
||
_sp_opt = [str(o).upper() for o in _mon_spec.get("options", [])]
|
||
_pl_opt = [str(o).upper() for o in _mon_plan.get("options", [])]
|
||
# (a) ANCRAGE — spec moneda.options == devises #10 (ordre exact)
|
||
if _sp_opt == want_cur:
|
||
good(f"Fiscal devises · (a) spec moneda.options {_sp_opt} == CLAUDE.md #10 "
|
||
f"« {exp['devises']} » (ordre exact)")
|
||
else:
|
||
_miss = sorted(want_set - set(_sp_opt)); _extra = sorted(set(_sp_opt) - want_set)
|
||
_d = (f" · absents={_miss}" if _miss else "") + (f" · en trop={_extra}" if _extra else "")
|
||
bad(f"Fiscal devises · (a) spec moneda.options {_sp_opt} ≠ CLAUDE.md #10 "
|
||
f"{want_cur}{_d} → aligner le spec (ou CLAUDE.md #10)")
|
||
# (b) ARTEFACT byte-gaté == devises #10 (ordre exact)
|
||
if _pl_opt == want_cur:
|
||
good(f"Fiscal devises · (b) ecf_plan.moneda.options {_pl_opt} == CLAUDE.md #10")
|
||
else:
|
||
bad(f"Fiscal devises · (b) ecf_plan.moneda.options {_pl_opt} ≠ CLAUDE.md #10 "
|
||
f"{want_cur} → régénérer l'artefact")
|
||
# (c) le spec DÉCLARE l'ancrage : options_source cite CLAUDE.md #10 + chaque devise
|
||
_osrc = str(_mon_spec.get("options_source", ""))
|
||
_missing_tok = [c for c in want_cur if c not in _osrc]
|
||
if "CLAUDE.md #10" not in _osrc or _missing_tok:
|
||
bad(f"Fiscal devises · (c) moneda.options_source ne DÉCLARE pas l'ancrage "
|
||
f"CLAUDE.md #10 + {want_cur} (manquant : "
|
||
f"{'CLAUDE.md #10' if 'CLAUDE.md #10' not in _osrc else _missing_tok}) : {_osrc!r}")
|
||
else:
|
||
good("Fiscal devises · (c) spec DÉCLARE l'ancrage (options_source cite "
|
||
"CLAUDE.md #10 + chaque devise)")
|
||
# (d) README — la ligne #10 cite le jeu EXACT + toute paire moneda/devise == le jeu
|
||
ecf_mon_rd = read_norm(ECF_MON_RD)
|
||
if ecf_mon_rd is not None:
|
||
# (d1) la ligne qui se réclame de #10 : « devise (…USD/DOP… · #10) »
|
||
m10 = re.search(r"devise[^\n]*?\(([^)]*#10[^)]*)\)", ecf_mon_rd)
|
||
if m10 is None:
|
||
bad(f"Fiscal devises · (d1) {ECF_MON_RD} — ligne « devise (… · #10) » "
|
||
f"INTROUVABLE → la prose ne se réclame plus de #10")
|
||
elif set(re.findall(r"[A-Z]{3}", m10.group(1))) != want_set:
|
||
bad(f"Fiscal devises · (d1) {ECF_MON_RD} — ligne #10 cite "
|
||
f"{sorted(set(re.findall(r'[A-Z]{3}', m10.group(1))))} MAIS #10 dit "
|
||
f"{sorted(want_set)} → régénérer le README")
|
||
else:
|
||
# (d2) AUCUNE mention moneda/devise d'une paire de devises n'est périmée
|
||
pairs = re.findall(r"(?:moneda|devise)[^.|\n]{0,40}?\b([A-Z]{3}\s*/\s*[A-Z]{3})\b",
|
||
ecf_mon_rd)
|
||
stale = [p for p in pairs if set(re.findall(r"[A-Z]{3}", p)) != want_set]
|
||
if not pairs:
|
||
bad(f"Fiscal devises · (d2) {ECF_MON_RD} — aucune mention moneda/devise "
|
||
f"du jeu {sorted(want_set)} → claim absent")
|
||
elif stale:
|
||
bad(f"Fiscal devises · (d2) {ECF_MON_RD} — mention(s) de devises PÉRIMÉE(S) "
|
||
f"{stale} ≠ CLAUDE.md #10 {sorted(want_set)} → régénérer le README")
|
||
else:
|
||
good(f"Fiscal devises · (d) README cite le jeu #10 {sorted(want_set)} "
|
||
f"+ {len(pairs)} mention(s) moneda/devise concordante(s)")
|
||
|
||
# ============================================================================
|
||
# Démo · run-sheet de pitch (2e surface du MÊME README) · la TABLE des SCÉNARIOS
|
||
# — l'IDENTITÉ de chaque ligne (id · projet+libellé · audience), jamais gatée.
|
||
# ----------------------------------------------------------------------------
|
||
# Le bloc « Démo » ci-dessus recompute le seul COMPTE de modules cités du
|
||
# diagramme. Restait, dans le MÊME README, une surface data-derived distincte et
|
||
# NON gatée : la table « Scénario | Projet | Audience | Angle » (README:10-13).
|
||
# Ses trois colonnes d'identité sont DATA-DERIVED de out/run_sheet.json (byte-
|
||
# gaté par check_artifacts ⇒ faisant autorité) : `id` (« S-P07-BANQUIER »),
|
||
# `projet`+`projet_libelle` (« P07 » + « Aqua Terra Las Terrenas ») et `audience`
|
||
# (« banquier »). Le générateur ne saisit AUCUNE donnée métier — il résout depuis
|
||
# le disque ; les libellés projets sont d'ailleurs déclarés (README:91-92)
|
||
# « proviennent **verbatim** de CLAUDE.md · §Projets ». Or AUCUN gate ne le
|
||
# vérifiait : check_artifacts ne prouve QUE run_sheet==build (byte-for-byte) et
|
||
# le bloc Démo existant ne gate QUE `modules_cites_uniques` → RENOMMER un id dans
|
||
# la prose · CLASSER un scénario sous le mauvais projet/audience · AJOUTER une
|
||
# ligne fantôme d'un scénario inventé · laisser un libellé projet DÉRIVER de
|
||
# CLAUDE.md (« Aqua Terra » → « Aqua Terra Bay ») restait « vert trompeur »
|
||
# qu'aucune suite tests/ (qui teste des FONCTIONS de résolution, pas la prose du
|
||
# README) n'attrape → un présentateur pitcherait le mauvais projet/audience (le
|
||
# risque même que la run-sheet veut éliminer). Même patron EXACT que la colonne
|
||
# « Type » de roleprofile_gen (identité de table, fin du wildcard) et l'ancrage
|
||
# du catalogue projets dossier_vente à CLAUDE.md §Projets. On RECOMPUTE : (1)
|
||
# ANCRAGE — chaque `P{code} {libelle}` du run_sheet == l'entrée CLAUDE.md §Projets
|
||
# de ce code (verbatim, comme la prose l'affirme · zéro duplication) ; (2) TABLE —
|
||
# chaque ligne porte EXACTEMENT id + `P{code} {libelle}` + audience ; (3) IDENTITÉ
|
||
# d'ensemble — l'ensemble des ids de lignes == l'ensemble des ids du run_sheet ==
|
||
# counts.scenarios (aucune ligne FANTÔME, aucun scénario MANQUANT). Cohérences
|
||
# croisées en bonus (mordent un artefact INTERNEMENT incohérent) : run_sheet ↔
|
||
# MANIFEST d'accord sur (id, projet, audience) · id == f"S-{projet}-{AUDIENCE}" ·
|
||
# counts.scenarios == |scenarios| · ids non vides et sans doublon. Un claim absent
|
||
# échoue AUSSI (disparition = régression).
|
||
# ============================================================================
|
||
DEMO_RD2 = f"{D}/demo/scenarios/README.md"
|
||
try:
|
||
d_rs = load("demo/scenarios/out/run_sheet.json")["scenarios"]
|
||
d_man = load("demo/scenarios/out/MANIFEST.json")
|
||
d_man_sc = d_man["scenarios"]
|
||
d_n = int(d_man["counts"]["scenarios"])
|
||
except (OSError, KeyError, ValueError, TypeError) as e:
|
||
bad(f"Démo scénarios · artefact run_sheet/MANIFEST illisible/inattendu : {e}"); d_rs = None
|
||
# ancrage libellés projets : CLAUDE.md §Projets = source faisant autorité
|
||
try:
|
||
_cmp = open("CLAUDE.md", encoding="utf-8").read()
|
||
_mp = re.search(r"^## Projets\s*\n(.+)$", _cmp, re.M)
|
||
if _mp is None:
|
||
raise ValueError("section « ## Projets » INTROUVABLE")
|
||
_proj_map = {}
|
||
for _t in _mp.group(1).split("·"):
|
||
_t = _nfc(_t.strip())
|
||
_mc = re.match(r"(P\d{2})\b", _t)
|
||
if _mc:
|
||
_proj_map[_mc.group(1)] = _t
|
||
if not _proj_map:
|
||
raise ValueError("aucun projet PNN parsé")
|
||
except (OSError, ValueError) as e:
|
||
bad(f"Démo scénarios · CLAUDE.md §Projets illisible/inattendu : {e}"); _proj_map = None
|
||
if d_rs is not None and _proj_map is not None:
|
||
d_rd = read_norm(DEMO_RD2)
|
||
# cohérences croisées internes à l'artefact (avant de croire la prose)
|
||
rs_ids = [s["id"] for s in d_rs]
|
||
if len(set(rs_ids)) != len(rs_ids) or "" in rs_ids:
|
||
bad(f"Démo scénarios · run_sheet incohérent : ids={rs_ids} "
|
||
f"(doublon ou vide)")
|
||
if d_n != len(d_rs):
|
||
bad(f"Démo scénarios · incohérence artefact : counts.scenarios={d_n} "
|
||
f"MAIS |scenarios|={len(d_rs)}")
|
||
man_key = {s["id"]: (s["projet"], s["audience"]) for s in d_man_sc}
|
||
for s in d_rs:
|
||
sid, proj, aud = s["id"], s["projet"], s["audience"]
|
||
# id == f"S-{projet}-{AUDIENCE}" (encode projet+audience)
|
||
exp_id = f"S-{proj}-{aud.upper()}"
|
||
if sid != exp_id:
|
||
bad(f"Démo scénarios · run_sheet incohérent : id={sid!r} MAIS "
|
||
f"projet={proj}/audience={aud} imposent {exp_id!r}")
|
||
# run_sheet ↔ MANIFEST d'accord sur (projet, audience)
|
||
if man_key.get(sid) != (proj, aud):
|
||
bad(f"Démo scénarios · run_sheet ↔ MANIFEST désynchronisés pour {sid} : "
|
||
f"run_sheet=({proj},{aud}) MANIFEST={man_key.get(sid)}")
|
||
# ANCRAGE : libellé projet == CLAUDE.md §Projets (verbatim, cf. README:91)
|
||
full = _nfc(f"{proj} {s['projet_libelle']}")
|
||
anchor = _proj_map.get(proj)
|
||
if anchor is None:
|
||
bad(f"Démo scénarios · projet {proj} du run_sheet ABSENT de CLAUDE.md "
|
||
f"§Projets (libellé « {full} » non ancré)")
|
||
elif _ai(full) != _ai(anchor):
|
||
bad(f"Démo scénarios · libellé projet « {full} » du run_sheet DÉRIVE de "
|
||
f"CLAUDE.md §Projets « {anchor} » → aligner (ou CLAUDE.md)")
|
||
else:
|
||
good(f"Démo scénarios · {sid} — libellé « {full} » == CLAUDE.md §Projets")
|
||
# TABLE : ligne README porte id + `P{code} {libelle}` + audience
|
||
if d_rd is not None:
|
||
pat = (rf"\| `{re.escape(sid)}` \| \*\*([^|]+?)\*\* \| "
|
||
r"([^|]+?) \|")
|
||
m = re.search(pat, d_rd)
|
||
if m is None:
|
||
bad(f"Démo scénarios · {DEMO_RD2} — ligne `{sid}` INTROUVABLE "
|
||
f"(attendu projet « {full} » · audience « {aud} »)")
|
||
else:
|
||
got_lbl, got_aud = m.group(1).strip(), m.group(2).strip()
|
||
if _ai(got_lbl) != _ai(full):
|
||
bad(f"Démo scénarios · {sid} — {DEMO_RD2} porte projet "
|
||
f"« {got_lbl} » MAIS run_sheet dit « {full} » → régénérer")
|
||
elif got_aud != aud:
|
||
bad(f"Démo scénarios · {sid} — {DEMO_RD2} porte audience "
|
||
f"« {got_aud} » MAIS run_sheet dit « {aud} » → régénérer")
|
||
else:
|
||
good(f"Démo scénarios · {sid} — ligne README (projet « {got_lbl} » · "
|
||
f"audience « {got_aud} ») == run_sheet")
|
||
# IDENTITÉ d'ensemble : ids des lignes de la table == ids du run_sheet (== counts)
|
||
if d_rd is not None:
|
||
row_ids = set(re.findall(r"\| `(S-[^`]+)` \| \*\*[^|]+\*\* \| [^|]+ \|", d_rd))
|
||
want_ids = set(rs_ids)
|
||
if row_ids == want_ids and len(row_ids) == d_n:
|
||
good(f"Démo scénarios · table — {len(row_ids)} lignes == run_sheet == "
|
||
f"counts.scenarios ({d_n}) : aucun scénario FANTÔME ni MANQUANT")
|
||
else:
|
||
miss = sorted(want_ids - row_ids); extra = sorted(row_ids - want_ids)
|
||
detail = (f" · absents={miss}" if miss else "") + \
|
||
(f" · en trop={extra}" if extra else "")
|
||
bad(f"Démo scénarios · table — lignes {sorted(row_ids)} MAIS run_sheet "
|
||
f"{sorted(want_ids)} (counts.scenarios={d_n}){detail} → régénérer le README")
|
||
|
||
# ============================================================================
|
||
# CRM/workflow_vente (3e surface du MÊME README) · la TABLE « Rôles = contrat
|
||
# RBAC » — le RÔLE RBAC qui GARDE chaque transition du pipeline vente. C'est le
|
||
# CŒUR sécurité du workflow (qui a le droit d'exécuter quel pas), transcrit À LA
|
||
# MAIN sans AUCUN gate d'IDENTITÉ.
|
||
# ----------------------------------------------------------------------------
|
||
# Les deux blocs CRM/workflow existants gatent (1) le COUPLE de COMPTES du pipeline
|
||
# (« 9 états · 11 transitions ») et (2) l'ÉNUMÉRATION des transitions à SÉPARATION
|
||
# DES POUVOIRS (allow_self_approval=0). Restait, dans le MÊME README (l.55-62), une
|
||
# 3e surface data-derived : la table « Étape → Rôle qui garde la transition » qui
|
||
# nomme, PAR pas, le rôle RBAC autorisé. Sources faisant autorité (byte-gatées par
|
||
# check_artifacts) : crm/workflow_vente/out/workflow.json — chaque transition porte
|
||
# `allowed` (le rôle qui garde le pas) ; ET out/MANIFEST.json.roles_rbac_utilises[]
|
||
# — l'ensemble des rôles employés, RECOMPUTÉ du contrat rbac_50_roles.json à chaque
|
||
# build. Les deux blocs de COMPTE/séparation sont AVEUGLES à l'identité de ces rôles :
|
||
# RÉAFFECTER un pas (« Confirmer réservation » — soumission d'argent — passe de
|
||
# `OTO Ventes Réservations` à `OTO Ventes Conseiller` : élévation de privilège sur
|
||
# une étape monétaire), RENOMMER un rôle, en AJOUTER un fantôme dans la prose ou en
|
||
# OUBLIER un laisse le README périmé pendant que l'artefact dit autre chose →
|
||
# l'agent ERPNext Backend câblerait le mauvais garde (le risque même que la table
|
||
# veut prévenir). « Vert trompeur » qu'aucune suite tests/ (qui teste des FONCTIONS
|
||
# de graphe/résolution RBAC, pas la prose) n'attrape — même patron d'IDENTITÉ que la
|
||
# cross-cohérence PERMISSIONS Legal/CONFOTUR ou le catalogue RBAC fixtures_gen.
|
||
# Bonus (mordent un artefact INTERNEMENT incohérent) : {allowed} de workflow.json
|
||
# == {erpnext_role_name} de MANIFEST.roles_rbac_utilises (aucun rôle gardant une
|
||
# transition sans être listé dans le manifeste, ni l'inverse) · ensemble NON VIDE.
|
||
# Cross-cohérence par pas (mord la réaffectation que le set-diff seul manque quand
|
||
# un rôle sert DEUX transitions) : pour les pas monétaires/de clôture SENSIBLES et
|
||
# UNIQUES dans le graphe (recomputés `allowed`, jamais figés), la ligne README qui
|
||
# les nomme doit citer EXACTEMENT ce rôle.
|
||
# État courant : aucun garde périmé (anti-invention #6) — le défaut est la surface
|
||
# ungated. Un claim absent échoue AUSSI (traçabilité).
|
||
# ============================================================================
|
||
def _fold(s):
|
||
s = unicodedata.normalize("NFKD", s)
|
||
return "".join(c for c in s if not unicodedata.combining(c)).strip().casefold()
|
||
try:
|
||
wfv_trans = load("crm/workflow_vente/out/workflow.json")[0]["transitions"]
|
||
wfv_roles = {t["allowed"] for t in wfv_trans} # gardes du graphe
|
||
wfv_man = load("crm/workflow_vente/out/MANIFEST.json")["roles_rbac_utilises"]
|
||
wfv_roles_man = {r["erpnext_role_name"] for r in wfv_man}
|
||
# rôle par action, UNIQUEMENT quand l'action garde 1 seul rôle (déterministe)
|
||
wfv_by_act = {}
|
||
for t in wfv_trans:
|
||
wfv_by_act.setdefault(t["action"], set()).add(t["allowed"])
|
||
wfv_uniq = {a: next(iter(v)) for a, v in wfv_by_act.items() if len(v) == 1}
|
||
except (OSError, KeyError, ValueError, IndexError, TypeError) as e:
|
||
bad(f"CRM gardes · workflow.json/MANIFEST illisible/inattendu : {e}"); wfv_roles = None
|
||
if wfv_roles is not None:
|
||
# bonus 1 : ensemble non vide
|
||
if not wfv_roles:
|
||
bad("CRM gardes · aucun rôle ne garde de transition (ensemble VIDE) → contrat cassé")
|
||
else:
|
||
good(f"CRM gardes · {len(wfv_roles)} rôles gardent le pipeline (ensemble non vide)")
|
||
# bonus 2 : workflow.json ⇄ MANIFEST d'accord sur l'ensemble des rôles employés
|
||
if wfv_roles == wfv_roles_man:
|
||
good(f"CRM gardes · {{allowed}} workflow.json == roles_rbac_utilises MANIFEST "
|
||
f"({len(wfv_roles)} rôles · aucun fantôme/manquant entre artefacts)")
|
||
else:
|
||
miss = sorted(wfv_roles_man - wfv_roles); extra = sorted(wfv_roles - wfv_roles_man)
|
||
detail = (f" · dans MANIFEST pas gardien={miss}" if miss else "") + \
|
||
(f" · gardien pas dans MANIFEST={extra}" if extra else "")
|
||
bad(f"CRM gardes · incohérence artefacts : gardes workflow.json {sorted(wfv_roles)} "
|
||
f"MAIS roles_rbac_utilises {sorted(wfv_roles_man)}{detail} → régénérer")
|
||
wfv_txt = read_norm(WF_RD)
|
||
if wfv_txt is not None:
|
||
seg = re.search(r"Rôle qui garde la transition \|(.+?)## Séparation", wfv_txt)
|
||
if seg is None:
|
||
bad(f"CRM gardes · {WF_RD} — table « Rôle qui garde la transition » INTROUVABLE "
|
||
f"(attendu {sorted(wfv_roles)})")
|
||
else:
|
||
rows = [(l, r) for l, r in re.findall(r"\| ([^|]+?) \| ([^|]+?) \|", seg.group(1))
|
||
if set(l.strip()) != {"-"}]
|
||
# identité d'ensemble : colonne « Rôle » de la table == gardes du graphe
|
||
rd_roles = set()
|
||
for _lbl, cell in rows:
|
||
for tok in cell.split("·"):
|
||
if tok.strip():
|
||
rd_roles.add(tok.strip())
|
||
rd_fold = {_fold(x) for x in rd_roles}; wf_fold = {_fold(x) for x in wfv_roles}
|
||
if rd_fold == wf_fold:
|
||
good(f"CRM gardes · {WF_RD} — colonne Rôle {sorted(rd_roles)} == "
|
||
f"{{allowed}} workflow.json ({len(wfv_roles)} rôles · ni fantôme ni manquant)")
|
||
else:
|
||
miss = sorted(wf_fold - rd_fold); extra = sorted(rd_fold - wf_fold)
|
||
detail = (f" · absents={miss}" if miss else "") + \
|
||
(f" · en trop={extra}" if extra else "")
|
||
bad(f"CRM gardes · {WF_RD} — colonne Rôle {sorted(rd_fold)} MAIS "
|
||
f"{{allowed}} workflow.json {sorted(wf_fold)}{detail} → régénérer le README")
|
||
# cross-cohérence par pas : les gardes des étapes SENSIBLES (argent/clôture),
|
||
# uniques dans le graphe, recomputés depuis workflow.json — jamais figés.
|
||
for act in ("Confirmer réservation", "Signer contrat", "Approuver CONFOTUR"):
|
||
exp = wfv_uniq.get(act)
|
||
if exp is None:
|
||
bad(f"CRM gardes · action sensible « {act} » absente/non-déterministe "
|
||
f"dans workflow.json → contrat changé de forme, vérifier")
|
||
continue
|
||
row = next((c for l, c in rows if _fold(act) in _fold(l)), None)
|
||
if row is None:
|
||
bad(f"CRM gardes · {WF_RD} — ligne de l'étape sensible « {act} » "
|
||
f"INTROUVABLE (attendu garde {exp})")
|
||
elif _fold(exp) in {_fold(t) for t in row.split("·") if t.strip()}:
|
||
good(f"CRM gardes · étape sensible « {act} » gardée par « {exp} » "
|
||
f"== workflow.json (séparation des pouvoirs · #6)")
|
||
else:
|
||
bad(f"CRM gardes · {WF_RD} — étape sensible « {act} » : README dit "
|
||
f"« {row.strip()} » MAIS workflow.json dit garde « {exp} » → "
|
||
f"réaffectation de privilège non répercutée, régénérer")
|
||
|
||
# ============================================================================
|
||
# RBAC/userperm_gen · la TABLE « Mapping scope_donnees → mécanisme » — la
|
||
# FONCTION d'enforcement ROW-LEVEL : quel mécanisme Frappe natif applique CHAQUE
|
||
# portée de données, ET si un `User Permission` template est émis. CŒUR sécurité
|
||
# du module, transcrit À LA MAIN sans AUCUN gate d'IDENTITÉ.
|
||
# ----------------------------------------------------------------------------
|
||
# Le bloc RBAC existant (« 3 volets ») ne gate QUE la VENTILATION par mécanisme
|
||
# (« 28 entite · 16 groupe · 2 own · 4 equipe » — un COMPTE, aveugle à QUEL
|
||
# mécanisme applique QUELLE portée). Restait, dans le MÊME README (l.33-38), une
|
||
# surface data-derived distincte : la table qui énonce, PAR portée, le `mechanism`
|
||
# Frappe natif ET le verdict « Template émis ? ». Source faisant autorité
|
||
# (byte-gatée par check_artifacts) : rbac/userperm_gen/out/user_permission_plan.json
|
||
# — chaque entrée porte `scope_donnees` + `mechanism` + `user_permission_template`
|
||
# (null ou objet). Le mapping est une FONCTION : chaque portée → exactement un
|
||
# mécanisme, template émis SSI `entite`/`user_permission_company`. RÉAFFECTER une
|
||
# portée à un mécanisme plus permissif (`entite` → `none_consolidated` : le
|
||
# row-level enforcement ABANDONNÉ, sur-exposition des données inter-entités — la
|
||
# restriction même que le module pose), RENOMMER un mécanisme ou basculer le
|
||
# verdict « Template émis ? » laisse le README périmé pendant que l'artefact dit
|
||
# autre chose → l'agent ERPNext câblerait le mauvais mécanisme (le risque même que
|
||
# la table veut prévenir). « Vert trompeur » qu'aucune suite tests/ (qui teste des
|
||
# FONCTIONS de mapping/résolution, pas la prose) n'attrape — même patron
|
||
# d'IDENTITÉ que la cross-cohérence PERMISSIONS Legal/CONFOTUR ou le catalogue RBAC
|
||
# fixtures_gen.
|
||
# Bonus (mordent un plan INTERNEMENT incohérent) : chaque portée mappe UN SEUL
|
||
# mécanisme (fonction, pas relation) · « Template émis » UNIFORME sur les entrées
|
||
# d'une portée · template émis EXACTEMENT pour `user_permission_company` (l'invariant
|
||
# « template SSI entite » du README:36/61) · ensemble des portées NON VIDE.
|
||
# État courant : aucune ligne périmée (anti-invention #6) — le défaut est la surface
|
||
# ungated. Un claim absent échoue AUSSI (traçabilité).
|
||
# ============================================================================
|
||
try:
|
||
up_plan = load("rbac/userperm_gen/out/user_permission_plan.json")
|
||
up_scopes, up_tmpl = {}, {} # portée -> set(mécanisme) / set(template non-null?)
|
||
for e in up_plan:
|
||
s = e["scope_donnees"]
|
||
up_scopes.setdefault(s, set()).add(e["mechanism"])
|
||
up_tmpl.setdefault(s, set()).add(e["user_permission_template"] is not None)
|
||
except (OSError, KeyError, ValueError, TypeError) as e:
|
||
bad(f"RBAC userperm map · user_permission_plan.json illisible/inattendu : {e}")
|
||
up_scopes = None
|
||
if up_scopes is not None:
|
||
# bonus 1 : ensemble des portées non vide
|
||
if not up_scopes:
|
||
bad("RBAC userperm map · plan VIDE (aucune portée) → contrat cassé")
|
||
else:
|
||
good(f"RBAC userperm map · {len(up_scopes)} portées dans le plan (ensemble non vide)")
|
||
# bonus 2 : chaque portée → UN SEUL mécanisme (fonction) + « Template émis » uniforme
|
||
scope_mech = {}
|
||
for s in sorted(up_scopes):
|
||
if len(up_scopes[s]) != 1:
|
||
bad(f"RBAC userperm map · portée « {s} » mappe {sorted(up_scopes[s])} "
|
||
f"(attendu 1 mécanisme) → plan incohérent (relation, pas fonction)")
|
||
continue
|
||
scope_mech[s] = next(iter(up_scopes[s]))
|
||
if len(up_tmpl[s]) != 1:
|
||
bad(f"RBAC userperm map · portée « {s} » — « Template émis ? » NON uniforme "
|
||
f"sur ses entrées {sorted(up_tmpl[s])} → plan incohérent")
|
||
# bonus 3 : template émis EXACTEMENT pour user_permission_company (l'invariant SSI)
|
||
for s, mech in scope_mech.items():
|
||
emitted = up_tmpl[s] == {True}
|
||
if emitted != (mech == "user_permission_company"):
|
||
bad(f"RBAC userperm map · portée « {s} » (mécanisme={mech}) émet "
|
||
f"template={emitted} MAIS l'invariant est « template SSI "
|
||
f"user_permission_company » → plan incohérent")
|
||
# identité + par-ligne vs README (lignes RAW : la table Mapping est la seule à 3
|
||
# cellules dont col1 = une portée backtickée `[a-z_]+` et col3 = le verdict
|
||
# « Template émis ? » **oui** / non (`null`)).
|
||
UPM_RD = f"{D}/rbac/userperm_gen/README.md"
|
||
try:
|
||
upm_lines = open(UPM_RD, encoding="utf-8").read().splitlines()
|
||
except OSError as e:
|
||
bad(f"RBAC userperm map · {UPM_RD} illisible : {e}"); upm_lines = []
|
||
map_rows = {}
|
||
for ln in upm_lines:
|
||
cells = [c.strip() for c in ln.strip().strip("|").split("|")]
|
||
if len(cells) != 3:
|
||
continue
|
||
ms = re.fullmatch(r"`([a-z_]+)`", cells[0])
|
||
if ms and re.search(r"\*\*oui\*\*|non \(`null`\)", cells[2]):
|
||
map_rows[ms.group(1)] = cells
|
||
# identité d'ensemble : portées de la table == portées de l'artefact
|
||
tbl, art = set(map_rows), set(scope_mech)
|
||
if tbl == art:
|
||
good(f"RBAC userperm map · table {sorted(tbl)} == portées de l'artefact "
|
||
f"({len(art)} · ni fantôme ni manquante)")
|
||
else:
|
||
miss = sorted(art - tbl); extra = sorted(tbl - art)
|
||
detail = (f" · absents={miss}" if miss else "") + \
|
||
(f" · en trop={extra}" if extra else "")
|
||
bad(f"RBAC userperm map · {UPM_RD} table {sorted(tbl)} MAIS artefact "
|
||
f"{sorted(art)}{detail} → régénérer le README")
|
||
# par portée : mécanisme (1er token backtické de col2) + verdict « Template émis ? »
|
||
for s in sorted(art):
|
||
exp_mech = scope_mech[s]
|
||
exp_emit = up_tmpl[s] == {True}
|
||
row = map_rows.get(s)
|
||
if row is None:
|
||
bad(f"RBAC userperm map · portée « {s} » — ligne INTROUVABLE dans {UPM_RD} "
|
||
f"(attendu mécanisme={exp_mech} · template="
|
||
f"{'oui' if exp_emit else 'non'})")
|
||
continue
|
||
mm = re.match(r"`([^`]+)`", row[1])
|
||
got_mech = mm.group(1) if mm else "?"
|
||
if got_mech == exp_mech:
|
||
good(f"RBAC userperm map · portée « {s} » — mécanisme « {got_mech} » == artefact")
|
||
else:
|
||
bad(f"RBAC userperm map · portée « {s} » — {UPM_RD} dit mécanisme "
|
||
f"« {got_mech} » MAIS artefact dit « {exp_mech} » → réaffectation "
|
||
f"d'enforcement non répercutée, régénérer")
|
||
got_emit = row[2].strip() == "**oui**"
|
||
if got_emit == exp_emit:
|
||
good(f"RBAC userperm map · portée « {s} » — Template émis="
|
||
f"{'oui' if got_emit else 'non'} == artefact")
|
||
else:
|
||
bad(f"RBAC userperm map · portée « {s} » — {UPM_RD} dit Template émis="
|
||
f"{'oui' if got_emit else 'non'} MAIS artefact dit "
|
||
f"{'oui' if exp_emit else 'non'} → régénérer le README")
|
||
|
||
# ============================================================================
|
||
# Faisabilité/bancable — figures « Génération réelle (fixture) » du README
|
||
# ----------------------------------------------------------------------------
|
||
# Le module `faisabilite/bancable` n'écrit son `out/` que sur disque (« out/ non
|
||
# commité ») : il est HORS de check_artifacts (aucun artefact byte-gaté) et
|
||
# AUCUN gate ne le touchait. Or son README §« Vérification en-repo » recopie
|
||
# QUATRE chiffres DATA-DERIVED que le générateur calcule sur la fixture COMMITÉE
|
||
# `fixtures/brief_bancable.json` : « 40 unités · valeur catalogue USD 8,560,000
|
||
# / DOP 505,040,000 · point d'équilibre 21 unités (⌈52 % × 40⌉) ». Ces valeurs
|
||
# sont Σ quantités · Σ(qté×prix_usd) · Σ(qté×prix_dop) · ⌈52 % × Σ unités⌉ — le
|
||
# 52 % ancré sur CLAUDE.md #9. PIÈGE : la suite tests/ teste des FONCTIONS
|
||
# (l'arithmétique de recoupement) avec des oracles HARDCODÉS ; elle ne compare
|
||
# JAMAIS la PROSE du README à la génération réelle. Éditer une typologie de la
|
||
# fixture (quantité/prix), en ajouter/retirer une, ou déplacer le point
|
||
# d'équilibre de CLAUDE.md #9, laisse les 4 chiffres du README périmés en silence
|
||
# pendant que le générateur produit autre chose → le banquier lit un dossier
|
||
# faux, l'invention même que #6 interdit. Ce bloc RECOMPUTE les 4 figures depuis
|
||
# la fixture (source de vérité), exige que la GÉNÉRATION RÉELLE (bancable_gen
|
||
# validate) les confirme (mord un générateur/fixture incohérents), puis que la
|
||
# prose README les énumère EXACTEMENT (valeur + séparateur de milliers + formule
|
||
# ⌈pct × Σ unités⌉, pct ancré CLAUDE.md #9). Un claim absent échoue AUSSI.
|
||
# ============================================================================
|
||
import math
|
||
BANC = f"{D}/faisabilite/bancable"
|
||
BANC_RD = f"{BANC}/README.md"
|
||
# 1) point d'équilibre % ANCRÉ sur CLAUDE.md #9 (source faisant autorité · #6/#9)
|
||
try:
|
||
_cmb = open("CLAUDE.md", encoding="utf-8").read()
|
||
_l9b = re.search(r"^9\.\s+(.+)$", _cmb, re.M)
|
||
if not _l9b:
|
||
raise ValueError("contrainte #9 INTROUVABLE")
|
||
_peb = None
|
||
for _sb in re.split(r"·", _l9b.group(1)):
|
||
if "equilibre" in _ai(_sb):
|
||
_mmb = re.search(r"(\d+(?:\.\d+)?)\s*%", _sb)
|
||
_peb = float(_mmb.group(1)) if _mmb else None
|
||
if _peb is None:
|
||
raise ValueError("point d'équilibre % absent de #9")
|
||
except (OSError, ValueError) as e:
|
||
bad(f"Bancable figures · CLAUDE.md #9 (point d'équilibre) illisible : {e}"); _peb = None
|
||
# 2) recompute INDÉPENDANT depuis la fixture COMMITÉE (source de vérité · #6)
|
||
try:
|
||
_fxb = json.load(open(f"{BANC}/fixtures/brief_bancable.json", encoding="utf-8"))
|
||
_typb = _fxb["architecture"]["typologies"]
|
||
rec_units = sum(int(t["quantite"]) for t in _typb)
|
||
rec_usd = sum(int(t["quantite"]) * int(t["prix_usd"]) for t in _typb)
|
||
rec_dop = sum(int(t["quantite"]) * int(t["prix_dop"]) for t in _typb)
|
||
except (OSError, KeyError, ValueError, TypeError) as e:
|
||
bad(f"Bancable figures · fixture brief_bancable.json illisible/inattendue : {e}"); rec_units = None
|
||
# 3) génération RÉELLE : le manifeste que le README prétend recopier
|
||
man_fig = None
|
||
try:
|
||
_procb = subprocess.run(
|
||
[sys.executable, "bancable_gen.py", "validate", "fixtures/brief_bancable.json"],
|
||
cwd=BANC, capture_output=True, text=True, encoding="utf-8")
|
||
if _procb.returncode != 0:
|
||
raise ValueError(f"validate exit {_procb.returncode} : {_procb.stderr.strip()[:160]}")
|
||
man_fig = {f["cle"]: f["valeur"] for f in json.loads(_procb.stdout)["figures_calculees"]}
|
||
except (ValueError, KeyError, OSError) as e:
|
||
bad(f"Bancable figures · génération réelle (bancable_gen validate) échoue : {e}")
|
||
|
||
if _peb is not None and rec_units is not None and man_fig is not None:
|
||
rec_be = math.ceil(_peb / 100.0 * rec_units)
|
||
# cohérence croisée : le manifeste GÉNÉRÉ == le recompute indépendant fixture
|
||
for cle, expv in (("total_unites", rec_units), ("valeur_catalogue_usd", rec_usd),
|
||
("valeur_catalogue_dop", rec_dop), ("point_equilibre_unites", rec_be)):
|
||
gotv = man_fig.get(cle)
|
||
if gotv is None:
|
||
bad(f"Bancable figures · manifeste sans figure « {cle} » (attendu {expv})")
|
||
elif int(gotv) != int(expv):
|
||
bad(f"Bancable figures · manifeste « {cle} »={gotv} MAIS recompute fixture "
|
||
f"{expv} → générateur/fixture incohérents (formule cassée)")
|
||
else:
|
||
good(f"Bancable figures · manifeste « {cle} »={int(gotv)} == recompute fixture")
|
||
# 4) PROSE README §« Vérification en-repo » — un seul pattern d'identité
|
||
try:
|
||
banc_txt = re.sub(r"\s+", " ", open(BANC_RD, encoding="utf-8").read())
|
||
except OSError as e:
|
||
bad(f"Bancable figures · {BANC_RD} illisible : {e}"); banc_txt = None
|
||
if banc_txt is not None:
|
||
pat = (r"(\d[\d,]*)\s*unités\s*·\s*valeur catalogue\s*USD\s*([\d,]+)\s*/\s*"
|
||
r"DOP\s*([\d,]+)\s*·\s*point d'équilibre\s*(\d+)\s*unités\s*"
|
||
r"\(⌈\s*(\d+)\s*%\s*×\s*(\d+)\s*⌉\)")
|
||
m = re.search(pat, banc_txt)
|
||
if m is None:
|
||
bad(f"Bancable figures · {BANC_RD} — ligne « Génération réelle (fixture) » "
|
||
f"INTROUVABLE (attendu {rec_units} unités · USD {rec_usd:,} / DOP "
|
||
f"{rec_dop:,} · équilibre {rec_be} unités (⌈{_peb:g} % × {rec_units}⌉)) — "
|
||
f"un claim absent est une régression (#6)")
|
||
else:
|
||
g_pct, g_tot = float(m.group(5)), int(m.group(6))
|
||
for lbl, raw, expv in (("Σ unités", m.group(1), rec_units),
|
||
("valeur catalogue USD", m.group(2), rec_usd),
|
||
("valeur catalogue DOP", m.group(3), rec_dop),
|
||
("point d'équilibre", m.group(4), rec_be)):
|
||
got = int(raw.replace(",", ""))
|
||
if got != expv:
|
||
bad(f"Bancable figures · README {lbl}={raw} MAIS génération réelle "
|
||
f"={expv} → figure périmée, régénérer la fixture/le README (#6)")
|
||
elif expv >= 1000 and "," not in raw:
|
||
bad(f"Bancable figures · README {lbl}={raw} sans séparateur de milliers "
|
||
f"(attendu « {expv:,} »)")
|
||
else:
|
||
good(f"Bancable figures · README {lbl}={raw} == génération réelle")
|
||
# formule ⌈52 % × 40⌉ : pct ancré CLAUDE.md #9 · total == Σ unités
|
||
if g_pct != _peb:
|
||
bad(f"Bancable figures · README formule ⌈{g_pct:g} %⌉ MAIS CLAUDE.md #9 "
|
||
f"point d'équilibre = {_peb:g} % → aligner (ou CLAUDE.md)")
|
||
else:
|
||
good(f"Bancable figures · README formule pct {g_pct:g} % == CLAUDE.md #9")
|
||
if g_tot != rec_units:
|
||
bad(f"Bancable figures · README formule « × {g_tot} » MAIS Σ unités "
|
||
f"= {rec_units} → total de la formule désaligné")
|
||
else:
|
||
good(f"Bancable figures · README formule « × {g_tot} » == Σ unités")
|
||
|
||
# ============================================================================
|
||
# Fiche Mobile (03_agents/mobile/AGENT.md) · la FICHE D'IDENTITÉ du rôle RBAC
|
||
# `plateforme-mobile` — le SEUL ancrage in-repo réellement vérifiable de l'agent
|
||
# mobile (les builds/stores étant hors-repo, #8), transcrit À LA MAIN sans gate.
|
||
# ----------------------------------------------------------------------------
|
||
# La ligne « Points de contact réellement commités » (AGENT.md:36) recopie, DEPUIS
|
||
# le contrat rbac_50_roles.json (source de vérité RBAC, schéma-validé · byte-gaté
|
||
# par les 3 MANIFEST recomputés), TOUS les attributs data-derived du rôle : son
|
||
# `erpnext_role_name` (« OTO Plateforme Mobile »), son `nom_fr`, sa `famille`/
|
||
# `portail`, son `entite_principale`, son `niveau`, son `scope_donnees`, ses
|
||
# `modules` et sa `permission_cible` (`API Access` custom R/W) + sa `description`.
|
||
# C'est la carte que l'agent ERPNext Backend lit pour seeder le rôle. AUCUN gate ne
|
||
# comparait ces valeurs au contrat : le README mobile/app_config est gaté sur des
|
||
# COMPTES agrégés (« 5 onglets · 44 rôles »), AVEUGLES à l'identité de CE rôle. Éditer
|
||
# le contrat — élever le `scope_donnees` (`groupe`→`entite` : sur-portée), changer
|
||
# le `niveau`, l'`entite_principale`, renommer le rôle, ajouter/retirer un module ou
|
||
# élargir la permission (R/W→+delete) — laisse la fiche périmée en silence pendant
|
||
# que le contrat dit autre chose ⇒ l'agent câblerait le mauvais rôle (l'invention
|
||
# même que #6 interdit). Aucune suite tests/ (qui teste des FONCTIONS de résolution
|
||
# RBAC, pas la prose d'une fiche) n'attrape ce « vert trompeur » — même patron
|
||
# d'IDENTITÉ que la table gardes CRM/workflow_vente ou le mapping RBAC userperm_gen.
|
||
# On RECOMPUTE chaque attribut depuis le contrat (zéro duplication) et on exige que
|
||
# la fiche les énonce EXACTEMENT. Cohérences croisées bonus (mordent un contrat
|
||
# INTERNEMENT incohérent) : `famille` == `portail` (invariant de bijection portail) ·
|
||
# la permission est bien `custom` · l'ensemble des rôles porte CE role_id UNE fois.
|
||
# Un claim absent échoue AUSSI (traçabilité). La description (data-derived) est
|
||
# recoupée verbatim, emphase markdown et point final neutralisés.
|
||
# ============================================================================
|
||
MOB = "03_agents/mobile/AGENT.md"
|
||
RID = "plateforme-mobile"
|
||
try:
|
||
roles = load("rbac/rbac_50_roles.json")["roles"]
|
||
mob_role = [r for r in roles if r.get("id") == RID]
|
||
except (OSError, KeyError, ValueError, TypeError) as e:
|
||
bad(f"Fiche Mobile · rbac_50_roles.json illisible/inattendu : {e}"); mob_role = None
|
||
mob_txt = read_norm(MOB)
|
||
if mob_role is not None and mob_txt is not None:
|
||
if len(mob_role) != 1:
|
||
bad(f"Fiche Mobile · le contrat porte {len(mob_role)} rôle(s) `{RID}` "
|
||
f"(attendu exactement 1) → contrat changé de forme, vérifier")
|
||
else:
|
||
r = mob_role[0]
|
||
# cellule « Ce qu'il fixe » de la ligne plateforme-mobile (sans le `|` de fin)
|
||
seg = re.search(rf"→ rôle `{re.escape(RID)}` \|(.+?)\|", mob_txt)
|
||
if seg is None:
|
||
bad(f"Fiche Mobile · {MOB} — ligne de contact du rôle `{RID}` INTROUVABLE "
|
||
f"(un claim absent est une régression · #6)")
|
||
else:
|
||
cell = seg.group(1)
|
||
# cohérence croisée : famille == portail (bijection portail du contrat)
|
||
if r.get("famille") != r.get("portail"):
|
||
bad(f"Fiche Mobile · contrat incohérent : famille={r.get('famille')} "
|
||
f"≠ portail={r.get('portail')} → bijection portail cassée")
|
||
# permission_cible : doctype + flag custom + verbes → « R/W »
|
||
perms = r.get("permissions_cibles") or []
|
||
perm = perms[0] if len(perms) == 1 else None
|
||
if perm is None:
|
||
bad(f"Fiche Mobile · rôle `{RID}` — attendu 1 permission_cible, "
|
||
f"trouvé {len(perms)} → contrat changé, vérifier la fiche")
|
||
elif not perm.get("custom", False):
|
||
bad(f"Fiche Mobile · rôle `{RID}` — permission « {perm.get('doctype')} » "
|
||
f"non `custom` dans le contrat MAIS la fiche la dit custom → aligner")
|
||
acts = set(perm.get("actions", [])) if perm else set()
|
||
# verbes → sigle canonique ordonné : élargir la perm (ex. +delete) change
|
||
# le sigle attendu ⇒ la fiche « R/W » périme et le gate mord.
|
||
_VERB = [("create", "C"), ("read", "R"), ("write", "W"), ("delete", "D"),
|
||
("submit", "S"), ("cancel", "X"), ("amend", "A")]
|
||
rw = "/".join(sig for k, sig in _VERB if k in acts)
|
||
if acts - {k for k, _ in _VERB}:
|
||
bad(f"Fiche Mobile · rôle `{RID}` — verbe(s) inconnu(s) "
|
||
f"{sorted(acts - {k for k, _ in _VERB})} dans le contrat → étendre le gate")
|
||
mods = "+".join(f"`{m}`" for m in (r.get("modules") or []))
|
||
# chaque attribut data-derived recomputé du contrat → prose EXACTE exigée
|
||
attrs = [
|
||
("nom d'affichage ERPNext",
|
||
rf"rôle ERPNext \*\*`{re.escape(r['erpnext_role_name'])}`\*\*",
|
||
r["erpnext_role_name"]),
|
||
("nom_fr", rf"« {re.escape(r['nom_fr'])} »", r["nom_fr"]),
|
||
("famille/portail",
|
||
rf"famille/portail \*\*`{re.escape(r['portail'])}`\*\*", r["portail"]),
|
||
("entité principale",
|
||
rf"entité \*\*{re.escape(r['entite_principale'])}\*\*",
|
||
r["entite_principale"]),
|
||
("niveau", rf"niveau {r['niveau']}(?!\d)", str(r["niveau"])),
|
||
("scope_donnees",
|
||
rf"scope `{re.escape(r['scope_donnees'])}`", r["scope_donnees"]),
|
||
("modules", rf"modules {re.escape(mods)}(?![+`])",
|
||
"+".join(r.get("modules") or [])),
|
||
]
|
||
if perm is not None:
|
||
attrs.append(("permission cible",
|
||
rf"perm `{re.escape(perm['doctype'])}` custom {re.escape(rw)}(?![\w/])",
|
||
f"{perm['doctype']} custom {rw}"))
|
||
for label, pat, exp in attrs:
|
||
if re.search(pat, cell):
|
||
good(f"Fiche Mobile · rôle `{RID}` — {label} « {exp} » == contrat")
|
||
else:
|
||
bad(f"Fiche Mobile · {MOB} — {label} : la fiche ne cite pas "
|
||
f"« {exp} » du contrat rbac_50_roles.json (périmé ?) → régénérer")
|
||
# description data-derived : recoupée verbatim (emphase + point final neutralisés)
|
||
def _plain(s):
|
||
return re.sub(r"\s+", " ", s.replace("*", "")).strip().rstrip(".").strip()
|
||
md = re.search(r"description « (.+?) »\)", cell)
|
||
exp_desc = _plain(r.get("description", ""))
|
||
if md is None:
|
||
bad(f"Fiche Mobile · {MOB} — la description « … » du rôle `{RID}` "
|
||
f"INTROUVABLE (attendu « {exp_desc} »)")
|
||
elif _plain(md.group(1)) == exp_desc:
|
||
good(f"Fiche Mobile · rôle `{RID}` — description == contrat "
|
||
f"(emphase/point neutralisés)")
|
||
else:
|
||
bad(f"Fiche Mobile · {MOB} — description « {_plain(md.group(1))} » "
|
||
f"MAIS contrat « {exp_desc} » → description périmée, régénérer")
|
||
|
||
# ============================================================================
|
||
# Fiches Faisabilité (03_agents/rendu/AGENT.md · 03_agents/ifc_speckle/AGENT.md) ·
|
||
# les FICHES D'IDENTITÉ des rôles RBAC `faisabilite-rendu-3d` et
|
||
# `faisabilite-ifc-speckle` — même classe de « vert trompeur » que la Fiche Mobile.
|
||
# ----------------------------------------------------------------------------
|
||
# La ligne « Contact in-repo (vérifié) » de chaque fiche (rendu:34 · ifc_speckle:34)
|
||
# recopie À LA MAIN, DEPUIS le contrat rbac_50_roles.json (source de vérité RBAC,
|
||
# schéma-validé · byte-gaté par les 3 MANIFEST recomputés), les attributs data-derived
|
||
# du rôle : son `erpnext_role_name`, son `portail`, son `entite_principale`, son
|
||
# `niveau`, ses `modules` (rendu), son `nom_fr` (rendu) et ses `permissions_cibles`
|
||
# (deux doctypes chacune : `File` R/W/create + `Faisabilité` R ou R/W). C'est la carte
|
||
# que l'agent ERPNext Backend lit pour seeder le rôle porteur des rendus / de l'export
|
||
# IFC→GLB. AUCUN gate ne comparait ces valeurs au contrat : le README frontend/portails
|
||
# n'est gaté que sur des COMPTES de rôles par Workspace, AVEUGLES à l'identité de CE
|
||
# rôle. Éditer le contrat — élever le `scope_donnees`, changer le `niveau`,
|
||
# l'`entite_principale`, renommer le rôle, ajouter/retirer un module ou élargir la
|
||
# permission (`Faisabilité` R→R/W : le rôle Rendu 3D gagnerait l'écriture sur le
|
||
# DocType Faisabilité qu'il ne doit que LIRE) — laisse la fiche périmée en silence
|
||
# pendant que le contrat dit autre chose ⇒ l'agent câblerait le mauvais rôle
|
||
# (l'invention même que #6 interdit). Aucune suite tests/ (qui teste des FONCTIONS de
|
||
# résolution RBAC, pas la prose d'une fiche) n'attrape ce « vert trompeur ». On
|
||
# RECOMPUTE chaque attribut RESTITUÉ par la fiche depuis le contrat (zéro duplication)
|
||
# et on exige que la fiche l'énonce EXACTEMENT. Les verbes de permission sont rendus
|
||
# dans un ORDRE CANONIQUE (read→R · write→W · create/delete/submit/… en toutes
|
||
# lettres, ordre fixe) : élargir OU rétrécir une permission change la chaîne attendue
|
||
# (`R/W` ne satisfait plus `R/W/create`, ni l'inverse) ; verbe inconnu ⇒ ROUGE
|
||
# (étendre le gate). Un claim absent échoue AUSSI (traçabilité). NB : contrairement
|
||
# à la Fiche Mobile, la DESCRIPTION de ces deux fiches est une PARAPHRASE éditoriale
|
||
# (le contrat porte « (… — CLAUDE.md) » / « les modèles » que la fiche condense) —
|
||
# elle n'est donc PAS gatée verbatim (ne rien réécrire de correct pour un gate · #6) ;
|
||
# seuls les attributs STRUCTURÉS, eux transcrits à l'exact, sont contraints.
|
||
# ============================================================================
|
||
_CANON_VERB = [("read", "R"), ("write", "W"), ("create", "create"),
|
||
("delete", "delete"), ("submit", "submit"), ("cancel", "cancel"),
|
||
("amend", "amend"), ("print", "print"), ("report", "report")]
|
||
FICHES_FAIS = [
|
||
{"file": "03_agents/rendu/AGENT.md", "rid": "faisabilite-rendu-3d",
|
||
"attrs": ["role", "nom_fr", "portail", "entite", "niveau", "modules", "perms"]},
|
||
{"file": "03_agents/ifc_speckle/AGENT.md", "rid": "faisabilite-ifc-speckle",
|
||
"attrs": ["role", "portail", "entite", "niveau", "perms"]},
|
||
]
|
||
try:
|
||
_fais_roles = load("rbac/rbac_50_roles.json")["roles"]
|
||
except (OSError, KeyError, ValueError, TypeError) as e:
|
||
bad(f"Fiches Faisabilité · rbac_50_roles.json illisible/inattendu : {e}")
|
||
_fais_roles = None
|
||
if _fais_roles is not None:
|
||
for fc in FICHES_FAIS:
|
||
rid, tag = fc["rid"], fc["file"].split("/")[1]
|
||
cand = [r for r in _fais_roles if r.get("id") == rid]
|
||
ftxt = read_norm(fc["file"])
|
||
if ftxt is None:
|
||
continue
|
||
if len(cand) != 1:
|
||
bad(f"Fiche {tag} · le contrat porte {len(cand)} rôle(s) `{rid}` "
|
||
f"(attendu exactement 1) → contrat changé de forme, vérifier")
|
||
continue
|
||
r = cand[0]
|
||
seg = re.search(rf"→ rôle `{re.escape(rid)}` \|(.+?)\|", ftxt)
|
||
if seg is None:
|
||
bad(f"Fiche {tag} · {fc['file']} — ligne de contact du rôle `{rid}` "
|
||
f"INTROUVABLE (un claim absent est une régression · #6)")
|
||
continue
|
||
cell = seg.group(1)
|
||
# permissions_cibles → chaîne « `Doctype` R/W/create + `Autre` R », ordre canon
|
||
_perm_parts, _unknown = [], set()
|
||
for p in (r.get("permissions_cibles") or []):
|
||
acts = set(p.get("actions", []))
|
||
_unknown |= acts - {k for k, _ in _CANON_VERB}
|
||
sig = "/".join(tok for k, tok in _CANON_VERB if k in acts)
|
||
_perm_parts.append(f"`{p.get('doctype')}` {sig}")
|
||
perm_str = " + ".join(_perm_parts)
|
||
if _unknown:
|
||
bad(f"Fiche {tag} · rôle `{rid}` — verbe(s) inconnu(s) "
|
||
f"{sorted(_unknown)} dans le contrat → étendre le gate")
|
||
mods = r.get("modules") or []
|
||
mod_pat = (rf"module `{re.escape(mods[0])}`(?![+`\w])" if len(mods) == 1
|
||
else r"modules " + r"\+".join(f"`{re.escape(m)}`" for m in mods))
|
||
_PATS = {
|
||
"role": (rf"rôle ERPNext \*\*`{re.escape(r['erpnext_role_name'])}`\*\*",
|
||
r["erpnext_role_name"]),
|
||
"nom_fr": (rf"« {re.escape(r['nom_fr'])} »", r["nom_fr"]),
|
||
"portail": (rf"portail {re.escape(r['portail'])}(?![\w])", r["portail"]),
|
||
"entite": (rf"(?<![\w]){re.escape(r['entite_principale'])}(?![\w])",
|
||
r["entite_principale"]),
|
||
"niveau": (rf"niveau {r['niveau']}(?!\d)", str(r["niveau"])),
|
||
"modules": (mod_pat, "+".join(mods)),
|
||
"perms": (rf"perms {re.escape(perm_str)}(?![\w/])", perm_str),
|
||
}
|
||
for a in fc["attrs"]:
|
||
pat, exp = _PATS[a]
|
||
if re.search(pat, cell):
|
||
good(f"Fiche {tag} · rôle `{rid}` — {a} « {exp} » == contrat")
|
||
else:
|
||
bad(f"Fiche {tag} · {fc['file']} — {a} : la fiche ne cite pas "
|
||
f"« {exp} » du contrat rbac_50_roles.json (périmé ?) → régénérer")
|
||
|
||
# ============================================================================
|
||
# Fiches agents · rattachement Workspace (`Has Role`) — QUELLE CONSOLE (Workspace
|
||
# ERPNext natif v15) le rôle d'une fiche peut atteindre. Surface data-derived
|
||
# DISTINCTE des attributs du contrat rbac_50_roles.json : la MEMBERSHIP `Has Role`
|
||
# ne vit PAS dans le contrat mais dans frontend/portails/out/workspace.json
|
||
# (artefact byte-gaté par check_artifacts). Trois fiches font, EN PROSE, une
|
||
# assertion d'appartenance :
|
||
# • rendu (:35) — rôle « OTO Faisabilité Rendu 3D » rattaché au Workspace
|
||
# NOMMÉ `OTO Construction` (accès console).
|
||
# • ifc_speckle (:35) — rôle « OTO Faisabilité IFC Speckle » rattaché à UN
|
||
# Workspace ERPNext (générique, non nommé).
|
||
# • mobile (:41) — rôle « OTO Plateforme Mobile » VOLONTAIREMENT PAS
|
||
# rattaché au Workspace NOMMÉ `OTO Ventes` (assertion
|
||
# NÉGATIVE d'honnêteté · #6).
|
||
# Aucun gate ne les couvrait : le bloc « triplets par workspace » gate le COMPTE de
|
||
# rôles par Workspace (nb_roles), AVEUGLE à QUEL rôle ; les blocs Fiches Faisabilité /
|
||
# Fiche Mobile gatent le CONTRAT, pas la membership Has Role. Rattacher le rôle mobile
|
||
# à `OTO Ventes` (SUR-EXPOSITION console : le dev mobile gagnerait l'accès au portail
|
||
# Ventes — la restriction même que la note #6 pose), retirer le rôle rendu de
|
||
# `OTO Construction` (perte d'accès console du spécialiste), ou DÉPLACER le rôle vers
|
||
# un autre Workspace, laisse la fiche périmée EN SILENCE pendant que l'artefact dit
|
||
# autre chose ⇒ l'agent ERPNext Backend câblerait le mauvais accès console. Aucune
|
||
# suite tests/ (qui teste des FONCTIONS de résolution, pas la prose d'une fiche)
|
||
# n'attrape ce « vert trompeur ». On RECOMPUTE, depuis workspace.json, l'ensemble des
|
||
# Workspaces dont le `Has Role` porte l'erpnext_role_name du rôle (résolu du contrat
|
||
# par id, zéro duplication) et on confronte l'assertion de la fiche. Un claim absent
|
||
# échoue AUSSI (traçabilité · #6).
|
||
# ============================================================================
|
||
FICHES_WS = [
|
||
{"file": "03_agents/rendu/AGENT.md", "rid": "faisabilite-rendu-3d",
|
||
"kind": "in_named", "ws": "OTO Construction"},
|
||
{"file": "03_agents/ifc_speckle/AGENT.md", "rid": "faisabilite-ifc-speckle",
|
||
"kind": "in_any"},
|
||
{"file": "03_agents/mobile/AGENT.md", "rid": "plateforme-mobile",
|
||
"kind": "not_in_named", "ws": "OTO Ventes"},
|
||
]
|
||
_ws_json = _ws_roles = None
|
||
try:
|
||
_ws_json = load("frontend/portails/out/workspace.json")
|
||
_ws_roles = load("rbac/rbac_50_roles.json")["roles"]
|
||
except (OSError, KeyError, ValueError, TypeError) as e:
|
||
bad(f"Fiches Workspace · workspace.json / rbac_50_roles.json illisible : {e}")
|
||
if _ws_json is not None and _ws_roles is not None:
|
||
# Index recomputé : erpnext_role_name -> ensemble des titres de Workspace le portant.
|
||
_mem, _titles = {}, set()
|
||
for ws in _ws_json:
|
||
title = ws.get("title")
|
||
_titles.add(title)
|
||
for hr in (ws.get("roles") or []):
|
||
_mem.setdefault(hr.get("role"), set()).add(title)
|
||
for fc in FICHES_WS:
|
||
tag = fc["file"].split("/")[1]
|
||
cand = [r for r in _ws_roles if r.get("id") == fc["rid"]]
|
||
if len(cand) != 1:
|
||
bad(f"Fiche {tag} · le contrat porte {len(cand)} rôle(s) `{fc['rid']}` "
|
||
f"(attendu exactement 1) → contrat changé de forme, vérifier")
|
||
continue
|
||
rname = cand[0].get("erpnext_role_name")
|
||
actual = _mem.get(rname, set()) # Workspaces réels du rôle (Has Role)
|
||
ftxt = read_norm(fc["file"])
|
||
if ftxt is None:
|
||
continue
|
||
named = fc.get("ws")
|
||
# Un Workspace NOMMÉ par la fiche doit exister dans l'artefact (anti-typo :
|
||
# sinon une assertion in_named/not_in_named « verte » viserait un fantôme).
|
||
if named is not None and named not in _titles:
|
||
bad(f"Fiche {tag} · le Workspace nommé `{named}` n'existe pas dans "
|
||
f"workspace.json (titres : {sorted(_titles)}) → fiche/artefact périmé")
|
||
continue
|
||
if fc["kind"] == "in_named":
|
||
if not re.search(rf"rattaché au Workspace `{re.escape(named)}`", ftxt):
|
||
bad(f"Fiche {tag} · {fc['file']} — assertion « rattaché au Workspace "
|
||
f"`{named}` » INTROUVABLE (claim absent = régression · #6)")
|
||
elif actual == {named}:
|
||
good(f"Fiche {tag} · rôle « {rname} » ∈ Has Role de `{named}` "
|
||
f"(et de lui seul) == workspace.json")
|
||
elif named in actual:
|
||
bad(f"Fiche {tag} · rôle « {rname} » ∈ `{named}` MAIS AUSSI "
|
||
f"{sorted(actual - {named})} — la fiche nomme UN seul Workspace ; "
|
||
f"membership élargie dans workspace.json → réviser fiche/artefact")
|
||
else:
|
||
bad(f"Fiche {tag} · {fc['file']} nomme `{named}` MAIS workspace.json "
|
||
f"rattache « {rname} » à {sorted(actual) or 'aucun Workspace'} → "
|
||
f"fiche périmée (rôle déplacé/retiré du Has Role), régénérer")
|
||
elif fc["kind"] == "in_any":
|
||
if not re.search(r"rattaché à un Workspace", ftxt):
|
||
bad(f"Fiche {tag} · {fc['file']} — assertion « rattaché à un "
|
||
f"Workspace » INTROUVABLE (claim absent = régression · #6)")
|
||
elif actual:
|
||
good(f"Fiche {tag} · rôle « {rname} » ∈ Has Role de {sorted(actual)} "
|
||
f"== workspace.json (≥1 Workspace, comme la fiche l'affirme)")
|
||
else:
|
||
bad(f"Fiche {tag} · {fc['file']} affirme un rattachement Workspace "
|
||
f"MAIS « {rname} » n'est dans le Has Role d'AUCUN Workspace → "
|
||
f"fiche périmée")
|
||
elif fc["kind"] == "not_in_named":
|
||
if not re.search(rf"volontairement pas\*\* rattaché au Workspace "
|
||
rf"`{re.escape(named)}`", ftxt):
|
||
bad(f"Fiche {tag} · {fc['file']} — assertion négative « volontairement "
|
||
f"pas rattaché au Workspace `{named}` » INTROUVABLE (#6)")
|
||
elif named not in actual:
|
||
good(f"Fiche {tag} · rôle « {rname} » ABSENT du Has Role de `{named}` "
|
||
f"== workspace.json (assertion négative #6 tenue)")
|
||
else:
|
||
bad(f"Fiche {tag} · {fc['file']} affirme « pas rattaché à `{named}` » "
|
||
f"MAIS workspace.json met « {rname} » dans son Has Role → "
|
||
f"SUR-EXPOSITION console, assertion #6 VIOLÉE")
|
||
|
||
# ============================================================================
|
||
# Publiciste/branding · TOKENS DESIGN CANONIQUES « ancrés sur CLAUDE.md #4 »
|
||
# — les 4 valeurs de la marque luxury (2 couleurs + 2 typographies), jamais gatées.
|
||
# ----------------------------------------------------------------------------
|
||
# La contrainte NON-NÉGOCIABLE #4 de CLAUDE.md fixe l'identité visuelle : fond
|
||
# dark `#0a0a12` + accent doré `#f0b429`, titres Fraunces + corps Cormorant
|
||
# Garamond. C'est la SOURCE FAISANT AUTORITÉ. Ces 4 tokens sont RECOPIÉS à
|
||
# QUATRE endroits, tous NON gatés :
|
||
# (a) lib/branding.py CONSTANTES (COLOR_BG/COLOR_ACCENT/FONT_DISPLAY/FONT_BODY)
|
||
# — la source unique importée par le générateur ET substituée dans le
|
||
# gabarit ({{COLOR_BG}}…) ; ce n'est PAS un out/*.json → invisible à
|
||
# check_artifacts ;
|
||
# (b) le docstring de branding.py (« `#0a0a12` fond · `#f0b429` … Fraunces
|
||
# (titres) + Cormorant Garamond ») — la prose du module ;
|
||
# (c) README:43 (« Gabarit dark+doré (Fraunces + Cormorant Garamond) ») ;
|
||
# (d) tests/test_publiciste.py — le tuple d'oracle HARDCODÉ
|
||
# ("#0a0a12","#f0b429","Fraunces","Cormorant Garamond") : le SEUL contrôle
|
||
# existant assert que le RENDU contient ces marqueurs, mais ils sont une
|
||
# COPIE de plus, JAMAIS comparée à CLAUDE.md. Si #4 change (ex. accent
|
||
# `#f0b429`→`#e0a420`) et qu'on aligne branding.py, l'oracle du test reste
|
||
# périmé (rouge trompeur) ; si RIEN ne bouge, tout reste vert alors que le
|
||
# site public émettrait la mauvaise couleur — invention silencieuse (#6) que
|
||
# nulle suite tests/ (qui teste des FONCTIONS, pas l'ancrage à CLAUDE.md)
|
||
# n'attrape. Même patron EXACT que les paramètres canoniques
|
||
# faisabilite/generator (ancrage CANONICAL↔CLAUDE.md #9/#10).
|
||
# On RECOMPUTE les 4 tokens depuis la ligne #4 de CLAUDE.md (zéro duplication) et
|
||
# on exige : (a) branding.py CONSTANTES == #4 ; (b) docstring cite les 4 EXACTS ;
|
||
# (c) README cite les 2 fontes ; (d) l'oracle du test == #4. Hex insensible à la
|
||
# casse, fontes comparées à l'exact (strip). Un claim absent échoue AUSSI.
|
||
# ============================================================================
|
||
PUB = f"{D}/publiciste"
|
||
try:
|
||
cm_raw4 = open("CLAUDE.md", encoding="utf-8").read()
|
||
l4 = re.search(r"^4\.\s+(.+)$", cm_raw4, re.M)
|
||
if not l4:
|
||
raise ValueError("contrainte #4 INTROUVABLE")
|
||
hexes = [h.lower() for h in re.findall(r"`(#[0-9a-fA-F]{6})`", l4.group(1))]
|
||
mfp = re.search(r"\(([^)]+)\)", l4.group(1))
|
||
fonts = [f.strip() for f in mfp.group(1).split("+")] if mfp else []
|
||
if len(hexes) != 2 or len(fonts) != 2:
|
||
raise ValueError(f"attendu 2 couleurs + 2 fontes, lu {hexes} / {fonts}")
|
||
b4 = {"bg": hexes[0], "accent": hexes[1], "display": fonts[0], "body": fonts[1]}
|
||
except (OSError, ValueError) as e:
|
||
bad(f"Branding canoniques · CLAUDE.md #4 illisible/inattendu : {e}"); b4 = None
|
||
if b4 is not None:
|
||
good(f"Branding canoniques · CLAUDE.md #4 sain : bg={b4['bg']} accent={b4['accent']} "
|
||
f"display={b4['display']!r} body={b4['body']!r}")
|
||
# (a) ANCRAGE : lib/branding.py CONSTANTES == CLAUDE.md #4 -------------------
|
||
BR = f"{PUB}/lib/branding.py"
|
||
try:
|
||
br_src = open(BR, encoding="utf-8").read()
|
||
except OSError as e:
|
||
bad(f"Branding canoniques · {BR} illisible : {e}"); br_src = None
|
||
if br_src is not None:
|
||
const_pat = {
|
||
"bg": r'COLOR_BG\s*=\s*"([^"]+)"',
|
||
"accent": r'COLOR_ACCENT\s*=\s*"([^"]+)"',
|
||
"display": r'FONT_DISPLAY\s*=\s*"([^"]+)"',
|
||
"body": r'FONT_BODY\s*=\s*"([^"]+)"',
|
||
}
|
||
drift = []
|
||
for k, pat in const_pat.items():
|
||
m = re.search(pat, br_src)
|
||
got = m.group(1) if m else None
|
||
want = b4[k]
|
||
ok = (got is not None and
|
||
(got.lower() == want if k in ("bg", "accent") else got.strip() == want))
|
||
if not ok:
|
||
drift.append(f"{k}={got!r} (attendu {want!r})")
|
||
if drift:
|
||
bad(f"Branding canoniques · branding.py CONSTANTES DÉRIVENT de CLAUDE.md #4 : "
|
||
f"{' · '.join(drift)} → aligner branding.py (ou CLAUDE.md)")
|
||
else:
|
||
good("Branding canoniques · branding.py CONSTANTES == CLAUDE.md #4 (4 tokens)")
|
||
# (b) PROSE docstring : les 4 tokens cités AVANT les CONSTANTES -----------
|
||
doc = br_src.split("COLOR_BG", 1)[0]
|
||
doc_hex = {h.lower() for h in re.findall(r"`(#[0-9a-fA-F]{6})`", doc)}
|
||
miss = [b4[k] for k in ("bg", "accent") if b4[k] not in doc_hex]
|
||
miss += [b4[k] for k in ("display", "body") if b4[k] not in doc]
|
||
if miss:
|
||
bad(f"Branding canoniques · docstring branding.py — tokens ABSENTS de la "
|
||
f"prose : {miss} (claim absent = régression · #6)")
|
||
else:
|
||
good("Branding canoniques · docstring branding.py cite les 4 tokens == CLAUDE.md #4")
|
||
# (c) README : la ligne du gabarit cite les 2 fontes -----------------------
|
||
PBR_RD = f"{PUB}/README.md"
|
||
pbr_rd = read_norm(PBR_RD)
|
||
if pbr_rd is not None:
|
||
rd_miss = [b4[k] for k in ("display", "body") if b4[k] not in pbr_rd]
|
||
if rd_miss:
|
||
bad(f"Branding canoniques · {PBR_RD} — fontes ABSENTES de la prose : "
|
||
f"{rd_miss} (attendu {b4['display']} + {b4['body']} == CLAUDE.md #4)")
|
||
else:
|
||
good(f"Branding canoniques · {PBR_RD} cite les 2 fontes == CLAUDE.md #4")
|
||
# (d) ORACLE du test : le tuple hardcodé == les 4 tokens de CLAUDE.md #4 ----
|
||
TBR = f"{PUB}/tests/test_publiciste.py"
|
||
try:
|
||
tbr_src = open(TBR, encoding="utf-8").read()
|
||
except OSError as e:
|
||
bad(f"Branding canoniques · {TBR} illisible : {e}"); tbr_src = None
|
||
if tbr_src is not None:
|
||
mt = re.search(r"for token in \(([^)]+)\)", tbr_src)
|
||
if mt is None:
|
||
bad(f"Branding canoniques · {TBR} — tuple d'oracle « for token in (...) » "
|
||
f"INTROUVABLE (le contrôle #4 du test a disparu · régression)")
|
||
else:
|
||
toks = [t.strip().strip('"\'') for t in mt.group(1).split(",") if t.strip()]
|
||
toks_n = {t.lower() if t.startswith("#") else t for t in toks}
|
||
want_n = {b4["bg"], b4["accent"], b4["display"], b4["body"]}
|
||
if toks_n == want_n:
|
||
good(f"Branding canoniques · {TBR} oracle == CLAUDE.md #4 (4 tokens)")
|
||
else:
|
||
miss = sorted(want_n - toks_n); extra = sorted(toks_n - want_n)
|
||
bad(f"Branding canoniques · {TBR} oracle DÉRIVE de CLAUDE.md #4"
|
||
+ (f" · absents={miss}" if miss else "")
|
||
+ (f" · en trop={extra}" if extra else "") + " → aligner le test")
|
||
|
||
# ============================================================================
|
||
# Fiches agents · colonne « Job CI » — chaque nom de job cité DOIT exister dans
|
||
# .gitea/workflows/ci.yml (même classe de « vert trompeur », côté câblage CI).
|
||
# ----------------------------------------------------------------------------
|
||
# Chaque table de livrables des fiches 03_agents/*/AGENT.md porte une colonne
|
||
# « Job CI » qui NOMME, mot pour mot et entre backticks, le job Gitea Actions qui
|
||
# exécute la suite du module (`rbac-tests`, `fiscal-ecf-tests`, …). Ce nom est
|
||
# SAISI À LA MAIN. La source de vérité est la section `jobs:` de ci.yml. Les
|
||
# gates existants ne couvraient PAS ce lien : `check_ci_integrity.sh` prouve que
|
||
# chaque job de ci.yml est bien CÂBLÉ dans `gate.needs` (INV-A/B), et les blocs
|
||
# amont recomputent les COMPTES de tests par suite — mais RIEN ne vérifiait que
|
||
# le nom écrit dans la fiche DÉSIGNE un job réel. Renommer un job dans ci.yml
|
||
# (ex. `rbac-tests` → `rbac-role-tests`, ou `demo-scenario-tests` mal recopié en
|
||
# `demo-scenarios-tests`) laisse la fiche pointer un job FANTÔME : le CI reste
|
||
# vert (le vrai job tourne sous son nouveau nom), la doc d'identité de l'agent
|
||
# ment en silence. On RECOMPUTE ici l'ensemble des jobs depuis la section `jobs:`
|
||
# de ci.yml (jamais une liste à la main) et on exige que chaque token `*-tests`
|
||
# cité dans une fiche y figure. Direction fiche→ci.yml (le consommateur) : ci.yml
|
||
# peut définir des jobs non cités (légitime), mais une fiche ne peut pas citer un
|
||
# job absent.
|
||
# ============================================================================
|
||
CI_YML = ".gitea/workflows/ci.yml"
|
||
try:
|
||
ci_lines = open(CI_YML, encoding="utf-8").read().splitlines()
|
||
except OSError as e:
|
||
bad(f"Job CI fiches · {CI_YML} illisible : {e}")
|
||
ci_lines = None
|
||
|
||
if ci_lines is not None:
|
||
# Ensemble des jobs = clés à 2 espaces sous la clé top-level `jobs:`
|
||
# (la section `on:` — push/pull_request/workflow_dispatch — est AVANT `jobs:`
|
||
# et donc naturellement exclue). Une clé top-level `^\S` clôt la section.
|
||
jobs = set()
|
||
in_jobs = False
|
||
for line in ci_lines:
|
||
if re.match(r"^jobs:\s*$", line):
|
||
in_jobs = True
|
||
continue
|
||
if in_jobs and re.match(r"^\S", line): # nouvelle clé top-level
|
||
break
|
||
if in_jobs:
|
||
mj = re.match(r"^ ([A-Za-z0-9_-]+):\s*$", line)
|
||
if mj:
|
||
jobs.add(mj.group(1))
|
||
if not jobs:
|
||
bad(f"Job CI fiches · aucun job détecté dans la section `jobs:` de "
|
||
f"{CI_YML} — parse cassé ou fichier vidé (régression de câblage).")
|
||
|
||
fiches_ci = subprocess.run(
|
||
["git", "ls-files", "03_agents/*/AGENT.md"],
|
||
capture_output=True, text=True).stdout.split()
|
||
tok_re = re.compile(r"`([a-z0-9]+(?:-[a-z0-9]+)*-tests)`")
|
||
seen_jobrefs = 0
|
||
for af in fiches_ci:
|
||
try:
|
||
flines = open(af, encoding="utf-8").read().splitlines()
|
||
except OSError as e:
|
||
bad(f"Job CI fiches · {af} illisible : {e}"); continue
|
||
for ln, line in enumerate(flines, 1):
|
||
for tok in tok_re.findall(line):
|
||
seen_jobrefs += 1
|
||
if jobs and tok in jobs:
|
||
good(f"Job CI · {af}:{ln} · `{tok}` == job ci.yml")
|
||
elif jobs:
|
||
bad(f"Job CI · {af}:{ln} · `{tok}` ABSENT de la section "
|
||
f"`jobs:` de {CI_YML} → job renommé/supprimé, fiche "
|
||
f"pointe un job fantôme (régénérer la fiche ou ci.yml)")
|
||
if seen_jobrefs == 0:
|
||
bad("Job CI fiches · AUCUN token `*-tests` trouvé dans les fiches — la "
|
||
"disparition de la colonne « Job CI » est elle-même une régression.")
|
||
|
||
# ============================================================================
|
||
# Legal · CONFOTUR — IDENTITÉ des ENTITÉS PORTEUSES ancrée sur CLAUDE.md §Entités
|
||
# ----------------------------------------------------------------------------
|
||
# Le champ Select `entite_porteuse` du DocType CONFOTUR Application — le menu
|
||
# déroulant qui fixe QUELLE entité juridique porte chaque dossier d'incitation —
|
||
# offre pour options les 7 entités canoniques du mandat (WAF · WA SRL · AC Arias
|
||
# Cuevas · Consortium ECR DR · Helios RD · Ploutos · 9060 QC). Ces options sont
|
||
# DÉRIVÉES via `options_source` de confotur_spec.json[entites] et byte-gatées pour
|
||
# la REPRODUCTIBILITÉ par check_artifacts (l'artefact se reconstruit depuis le
|
||
# spec) — mais JAMAIS ANCRÉES à CLAUDE.md §Entités, leur source faisant autorité.
|
||
# Les blocs CONFOTUR amont gatent le COMPTE de champs/rôles et la cross-cohérence
|
||
# des PERMISSIONS, aveugles à l'identité de cette liste d'entités.
|
||
# Piège #6 : RENOMMER une entité dans CLAUDE.md §Entités (9060 QC→9061 QC) ou dans
|
||
# confotur_spec.json[entites] puis régénérer laisse le DocType offrir un menu qui
|
||
# CONTREDIT/omet une entité canonique du mandat pendant que check_artifacts reste
|
||
# VERT (l'artefact se reconstruit… depuis un spec dérivé) — « vert trompeur » de
|
||
# la MÊME classe que la marque SEO org (§Entités) et les tokens branding (#4).
|
||
# Aucune suite tests/ (FONCTIONS de génération) ne l'attrape → l'agent ERPNext
|
||
# Backend importerait un DocType dont le déroulant liste une entité fantôme ou en
|
||
# omet une (la restriction même que le champ `reqd` pose).
|
||
# GATE : (a) options de l'artefact == entités CLAUDE.md §Entités (ORDRE exact) ;
|
||
# (b) spec[entites] == CLAUDE.md (l'INPUT ancré) ; (c) le spec DÉCLARE l'ancrage
|
||
# (entites_source cite CLAUDE.md + chaque entité) ; (d) wiring intact
|
||
# (options_source == "entites" · le déroulant DÉRIVE de la liste, pas d'un dur) ;
|
||
# (e) README cite l'ancre CLAUDE.md + nomme chaque entité (nom complet OU 1er
|
||
# token — l'abréviation « AC » de « AC Arias Cuevas » est un choix éditorial
|
||
# toléré · #6). Un claim absent échoue AUSSI (traçabilité #6).
|
||
# ============================================================================
|
||
try:
|
||
_cm_ce = open("CLAUDE.md", encoding="utf-8").read()
|
||
_sec_ce = re.search(r"^## Entités\s*\n(.*?)(?=^## )", _cm_ce, re.S | re.M)
|
||
if not _sec_ce:
|
||
raise ValueError("section « ## Entités » introuvable")
|
||
cm_ents = [_nfc(x.strip()) for x in re.findall(r"\*\*([^*]+?)\*\*", _sec_ce.group(1))]
|
||
if not cm_ents:
|
||
raise ValueError("aucune entité en gras dans §Entités")
|
||
if len(cm_ents) != len(set(cm_ents)):
|
||
raise ValueError(f"entités non uniques : {cm_ents}")
|
||
except (OSError, ValueError) as e:
|
||
bad(f"Confotur entités · CLAUDE.md §Entités illisible/inattendu : {e}"); cm_ents = None
|
||
try:
|
||
conf_spec_ce = load("legal/confotur/confotur_spec.json")
|
||
conf_dt_ce = load("legal/confotur/out/doctype_confotur_application.json")
|
||
ep_art = [f for f in conf_dt_ce.get("fields", []) if f.get("fieldname") == "entite_porteuse"]
|
||
ep_spec = None
|
||
for _grp in conf_spec_ce.get("field_groups", []):
|
||
for _f in _grp.get("fields", []):
|
||
if _f.get("fieldname") == "entite_porteuse":
|
||
ep_spec = _f
|
||
spec_ents = [_nfc(str(x).strip()) for x in conf_spec_ce.get("entites", [])]
|
||
ents_src = _nfc(str(conf_spec_ce.get("entites_source", "")))
|
||
except (OSError, KeyError, ValueError, TypeError) as e:
|
||
bad(f"Confotur entités · artefact/spec illisible/inattendu : {e}"); cm_ents = None
|
||
if cm_ents is not None:
|
||
# (a) ANCRAGE — options du Select de l'artefact == entités CLAUDE.md (ordre exact)
|
||
if len(ep_art) != 1:
|
||
bad(f"Confotur entités · champ `entite_porteuse` non-singleton dans l'artefact "
|
||
f"({len(ep_art)}) → identité du déroulant indéterminable")
|
||
else:
|
||
art_opts = [_nfc(o.strip()) for o in str(ep_art[0].get("options", "")).split("\n") if o.strip()]
|
||
if art_opts == cm_ents:
|
||
good(f"Confotur entités · (a) options DocType `entite_porteuse` "
|
||
f"({len(art_opts)}) == CLAUDE.md §Entités (ordre exact)")
|
||
else:
|
||
bad(f"Confotur entités · (a) options DocType {art_opts} ≠ CLAUDE.md §Entités "
|
||
f"{cm_ents} → menu périmé (régénérer depuis le spec / aligner CLAUDE.md)")
|
||
# (b) l'INPUT ancré — spec[entites] == CLAUDE.md (ordre exact)
|
||
if spec_ents == cm_ents:
|
||
good(f"Confotur entités · (b) spec[entites] == CLAUDE.md §Entités")
|
||
else:
|
||
bad(f"Confotur entités · (b) spec[entites] {spec_ents} ≠ CLAUDE.md §Entités "
|
||
f"{cm_ents} → l'input du générateur DÉRIVE du mandat")
|
||
# (c) le spec DÉCLARE l'ancrage : entites_source cite CLAUDE.md + chaque entité
|
||
if "CLAUDE.md" not in ents_src:
|
||
bad(f"Confotur entités · (c) `entites_source` NE CITE PAS CLAUDE.md "
|
||
f"(ancrage non déclaré) : {ents_src!r}")
|
||
else:
|
||
_miss_src = [e for e in cm_ents if e not in ents_src]
|
||
if _miss_src:
|
||
bad(f"Confotur entités · (c) `entites_source` cite CLAUDE.md mais OMET "
|
||
f"{_miss_src} → déclaration d'ancrage périmée")
|
||
else:
|
||
good(f"Confotur entités · (c) spec DÉCLARE l'ancrage (entites_source cite "
|
||
f"CLAUDE.md + les {len(cm_ents)} entités)")
|
||
# (d) wiring intact — le champ dérive ses options de la liste `entites`, pas d'un dur
|
||
if ep_spec is None:
|
||
bad("Confotur entités · (d) champ `entite_porteuse` ABSENT du spec (field_groups)")
|
||
elif ep_spec.get("options_source") != "entites":
|
||
bad(f"Confotur entités · (d) `entite_porteuse.options_source` = "
|
||
f"{ep_spec.get('options_source')!r} ≠ \"entites\" → déroulant DÉCROCHÉ de la "
|
||
f"liste ancrée (options potentiellement écrites en dur)")
|
||
else:
|
||
good("Confotur entités · (d) wiring intact (options_source == \"entites\")")
|
||
# (e) README cite l'ancre CLAUDE.md + nomme chaque entité (nom complet OU 1er token)
|
||
CONF_RD3 = f"{D}/legal/confotur/README.md"
|
||
try:
|
||
conf_rd3 = re.sub(r"\s+", " ", open(CONF_RD3, encoding="utf-8").read())
|
||
except OSError as e:
|
||
bad(f"Confotur entités · {CONF_RD3} illisible : {e}"); conf_rd3 = None
|
||
if conf_rd3 is not None:
|
||
_m_ep = re.search(r"`entite_porteuse`.{0,240}?9060 QC", conf_rd3)
|
||
if not _m_ep:
|
||
bad(f"Confotur entités · (e) ligne `entite_porteuse` INTROUVABLE dans "
|
||
f"{CONF_RD3} (claim absent = régression #6)")
|
||
else:
|
||
_epline = _m_ep.group(0)
|
||
if "CLAUDE.md" not in _epline:
|
||
bad(f"Confotur entités · (e) ligne `entite_porteuse` ne cite pas l'ancre "
|
||
f"« CLAUDE.md »")
|
||
else:
|
||
_miss_rd = []
|
||
for _e in cm_ents:
|
||
_first = _e.split()[0]
|
||
if _e in _epline or re.search(r"(?<![\w-])" + re.escape(_first) + r"(?![\w-])", _epline):
|
||
continue
|
||
_miss_rd.append(_e)
|
||
if _miss_rd:
|
||
bad(f"Confotur entités · (e) README omet {_miss_rd} de la ligne "
|
||
f"`entite_porteuse` (nom complet ni 1er token)")
|
||
else:
|
||
good(f"Confotur entités · (e) README cite CLAUDE.md + nomme les "
|
||
f"{len(cm_ents)} entités")
|
||
|
||
# ============================================================================
|
||
# Chat OTOIA · l'IDENTITÉ de la persona (`Amélie` / voix `multilingual_v2`) et
|
||
# des 4 CAPABILITIES OTOIA (`aec.py` + `knowledge.py` + `prompt_engine.py` +
|
||
# `chat.py`) — ANCRÉES sur CLAUDE.md §Architecture cible, jamais gatées.
|
||
# ----------------------------------------------------------------------------
|
||
# out/chat_mount.json (5 configs runtime, une par portail) + out/MANIFEST.json
|
||
# sont byte-gatés par check_artifacts : la reproductibilité prouve qu'ils SE
|
||
# RECONSTRUISENT depuis chat_spec.json, PAS que leur persona/capabilities ==
|
||
# ce que NOMME CLAUDE.md §Architecture cible (« Voix Amélie QC (multilingual_v2) »
|
||
# · « OTOIA capabilities : aec.py + knowledge.py + prompt_engine.py + chat.py »).
|
||
# Le bloc chat_otoia amont gate la COMPOSITION (portails métier · 1 block⇔1 mount).
|
||
# Le SEUL contrôle d'identité vit dans tests/ (test_persona_amelie_sourcee /
|
||
# test_capabilities_sourcees_sans_ajout) mais son ORACLE est HARDCODÉ
|
||
# (`"Amélie"`, `["aec.py",…]`) — une copie de plus, JAMAIS comparée à CLAUDE.md.
|
||
# Piège #6 : renommer la persona (`Amélie`→`Sophie`), changer la voix
|
||
# (`multilingual_v2`→…), ou renommer/retirer une capability dans chat_spec.json
|
||
# (ou dans CLAUDE.md §Architecture cible) laisse le chat monté dans CHAQUE portail
|
||
# annoncer une persona/voix/capabilities qui CONTREDIT le mandat pendant que la
|
||
# byte-gate ET les tests restent VERTS (l'artefact se reconstruit fidèlement…
|
||
# depuis un spec dérivé ; l'oracle du test est une copie de plus) — « vert
|
||
# trompeur » de la classe des tokens branding ancrés sur CLAUDE.md #4 et de la
|
||
# marque SEO §Entités. On RE-DÉRIVE persona+capabilities de CLAUDE.md
|
||
# §Architecture cible (source unique) et on exige : (a) chat_spec.json
|
||
# persona/capabilities == CLAUDE.md ; (b) le spec DÉCLARE l'ancrage (persona.source
|
||
# + chaque capability.source citent CLAUDE.md) ; (c) CHACUN des 5 mounts + le
|
||
# MANIFEST portent persona/capabilities == CLAUDE.md ; (d) le README cite l'ancre
|
||
# §Architecture cible + nomme persona + chaque capability ; (e) l'ORACLE du test
|
||
# == CLAUDE.md (set-diff : absent ET en trop) — sinon la copie de copie survit à
|
||
# un renommage. État courant : aucune valeur périmée — le défaut est l'ancrage
|
||
# ungated.
|
||
# ============================================================================
|
||
_nfc = lambda s: unicodedata.normalize("NFC", str(s)).strip()
|
||
CHAT = f"{D}/frontend/chat_otoia"
|
||
try:
|
||
_cm = open("CLAUDE.md", encoding="utf-8").read()
|
||
_mp = re.search(r"Voix\s+(.+?)\s+QC\s*\(([^)]+)\)", _cm)
|
||
_mc = re.search(r"OTOIA capabilities\s*:\s*(.+)", _cm)
|
||
if not _mp or not _mc:
|
||
raise ValueError("« Voix … QC (…) » ou « OTOIA capabilities : … » "
|
||
"INTROUVABLE dans CLAUDE.md §Architecture cible")
|
||
cm_persona = {"nom": _nfc(_mp.group(1)), "voix": _nfc(_mp.group(2))}
|
||
cm_caps = [c.strip() for c in _mc.group(1).split("+") if c.strip()]
|
||
if len(cm_caps) < 2:
|
||
raise ValueError(f"capabilities CLAUDE.md mal formées : {cm_caps}")
|
||
except (OSError, ValueError) as e:
|
||
bad(f"Chat OTOIA · persona/capabilities CLAUDE.md §Architecture cible "
|
||
f"illisible/inattendu : {e}")
|
||
cm_persona = cm_caps = None
|
||
try:
|
||
chat_spec = json.load(open(f"{CHAT}/chat_spec.json", encoding="utf-8"))
|
||
chat_mounts = load("frontend/chat_otoia/out/chat_mount.json")
|
||
chat_man = load("frontend/chat_otoia/out/MANIFEST.json")
|
||
except (OSError, KeyError, ValueError) as e:
|
||
bad(f"Chat OTOIA · spec/artefacts illisibles/inattendus : {e}")
|
||
chat_spec = None
|
||
if cm_persona is not None and chat_spec is not None:
|
||
# (a) ANCRAGE — chat_spec.json persona/capabilities == CLAUDE.md §Architecture cible
|
||
sp_persona = {"nom": _nfc(chat_spec["persona"].get("nom", "")),
|
||
"voix": _nfc(chat_spec["persona"].get("voix", ""))}
|
||
sp_caps = [str(c.get("module", "")).strip() for c in chat_spec.get("capabilities", [])]
|
||
if sp_persona == cm_persona:
|
||
good(f"Chat OTOIA · (a) spec persona {sp_persona} == CLAUDE.md §Architecture cible")
|
||
else:
|
||
bad(f"Chat OTOIA · (a) spec persona {sp_persona} ≠ CLAUDE.md {cm_persona} "
|
||
f"→ l'assistant monté contredirait le mandat (aligner spec ou CLAUDE.md)")
|
||
if sp_caps == cm_caps:
|
||
good(f"Chat OTOIA · (a) spec capabilities {sp_caps} == CLAUDE.md (ordre exact)")
|
||
else:
|
||
bad(f"Chat OTOIA · (a) spec capabilities {sp_caps} ≠ CLAUDE.md {cm_caps} "
|
||
f"→ surface de capacités inventée/périmée")
|
||
# (b) le spec DÉCLARE l'ancrage : persona.source + chaque capability.source citent CLAUDE.md
|
||
_psrc = str(chat_spec["persona"].get("source", ""))
|
||
if "CLAUDE.md" not in _psrc or cm_persona["nom"] not in _nfc(_psrc):
|
||
bad(f"Chat OTOIA · (b) chat_spec persona.source ne DÉCLARE pas l'ancrage "
|
||
f"CLAUDE.md/{cm_persona['nom']!r} : {_psrc!r}")
|
||
else:
|
||
_bad_src = [c.get("module") for c in chat_spec["capabilities"]
|
||
if "CLAUDE.md" not in str(c.get("source", ""))]
|
||
if _bad_src:
|
||
bad(f"Chat OTOIA · (b) capabilities sans `source` CLAUDE.md : {_bad_src}")
|
||
else:
|
||
good("Chat OTOIA · (b) spec DÉCLARE l'ancrage CLAUDE.md (persona + chaque capability)")
|
||
# (c) CHAQUE mount + le MANIFEST portent persona/capabilities == CLAUDE.md
|
||
_bad_m = []
|
||
for m in chat_mounts:
|
||
pm = {"nom": _nfc((m.get("persona") or {}).get("nom", "")),
|
||
"voix": _nfc((m.get("persona") or {}).get("voix", ""))}
|
||
if pm != cm_persona or list(m.get("capabilities", [])) != cm_caps:
|
||
_bad_m.append(m.get("portail"))
|
||
man_ok = ({"nom": _nfc((chat_man.get("persona") or {}).get("nom", "")),
|
||
"voix": _nfc((chat_man.get("persona") or {}).get("voix", ""))} == cm_persona
|
||
and list(chat_man.get("capabilities", [])) == cm_caps)
|
||
if not chat_mounts:
|
||
bad("Chat OTOIA · (c) aucun mount → artefact suspect")
|
||
elif _bad_m:
|
||
bad(f"Chat OTOIA · (c) mount(s) {_bad_m} persona/capabilities ≠ CLAUDE.md "
|
||
f"→ chat monté hors mandat")
|
||
elif not man_ok:
|
||
bad("Chat OTOIA · (c) MANIFEST persona/capabilities ≠ CLAUDE.md")
|
||
else:
|
||
good(f"Chat OTOIA · (c) {len(chat_mounts)} mounts + MANIFEST portent "
|
||
f"persona/capabilities == CLAUDE.md")
|
||
# (d) README cite l'ancre §Architecture cible + nomme persona + chaque capability
|
||
CHAT_RD = f"{CHAT}/README.md"
|
||
chat_rd = read_norm(CHAT_RD)
|
||
if chat_rd is not None:
|
||
chat_rd_n = _nfc(chat_rd)
|
||
_mp_rd = re.search(r"Persona.*?§Architecture cible", chat_rd_n)
|
||
_mc_rd = re.search(r"Capabilities.*?§Architecture cible", chat_rd_n)
|
||
if not _mp_rd:
|
||
bad(f"Chat OTOIA · (d) ligne « Persona … §Architecture cible » INTROUVABLE "
|
||
f"dans {CHAT_RD} (claim absent = régression #6)")
|
||
elif cm_persona["nom"] not in _mp_rd.group(0) or cm_persona["voix"] not in _mp_rd.group(0):
|
||
bad(f"Chat OTOIA · (d) README ligne persona omet {cm_persona['nom']!r}/"
|
||
f"{cm_persona['voix']!r}")
|
||
elif not _mc_rd:
|
||
bad(f"Chat OTOIA · (d) ligne « Capabilities … §Architecture cible » INTROUVABLE "
|
||
f"dans {CHAT_RD} (claim absent = régression #6)")
|
||
else:
|
||
_stems = [c[:-3] if c.endswith(".py") else c for c in cm_caps]
|
||
_miss = [s for s in _stems
|
||
if not re.search(r"(?<![\w.])" + re.escape(s) + r"(?![\w])", _mc_rd.group(0))]
|
||
if _miss:
|
||
bad(f"Chat OTOIA · (d) README ligne capabilities omet {_miss}")
|
||
else:
|
||
good(f"Chat OTOIA · (d) README cite §Architecture cible + persona + "
|
||
f"{len(cm_caps)} capabilities")
|
||
# (e) l'ORACLE du test == CLAUDE.md (sinon la copie de copie survit à un renommage)
|
||
CHAT_TEST = f"{CHAT}/tests/test_chat_otoia.py"
|
||
try:
|
||
_t = open(CHAT_TEST, encoding="utf-8").read()
|
||
except OSError as e:
|
||
bad(f"Chat OTOIA · (e) {CHAT_TEST} illisible : {e}"); _t = None
|
||
if _t is not None:
|
||
_mtp = re.search(r'spec\["persona"\]\["nom"\],\s*"([^"]+)"', _t)
|
||
_mtv = re.search(r'spec\["persona"\]\["voix"\],\s*"([^"]+)"', _t)
|
||
_mtc = re.search(r"caps,\s*\[([^\]]*)\]", _t)
|
||
if not (_mtp and _mtv and _mtc):
|
||
bad(f"Chat OTOIA · (e) oracle persona/capabilities INTROUVABLE dans {CHAT_TEST} "
|
||
f"(claim absent = régression #6)")
|
||
else:
|
||
t_caps = [x.strip().strip("\"'") for x in _mtc.group(1).split(",") if x.strip()]
|
||
if _nfc(_mtp.group(1)) != cm_persona["nom"] or _nfc(_mtv.group(1)) != cm_persona["voix"]:
|
||
bad(f"Chat OTOIA · (e) oracle du test persona "
|
||
f"{{{_mtp.group(1)!r},{_mtv.group(1)!r}}} ≠ CLAUDE.md {cm_persona} "
|
||
f"→ copie de copie périmée")
|
||
elif t_caps != cm_caps:
|
||
bad(f"Chat OTOIA · (e) oracle du test capabilities {t_caps} ≠ CLAUDE.md "
|
||
f"{cm_caps}")
|
||
else:
|
||
good("Chat OTOIA · (e) oracle du test persona/capabilities == CLAUDE.md")
|
||
|
||
# ============================================================================
|
||
# Mobile · le SDK MAJEUR Expo (« Expo 54 ») — ANCRÉ sur la roadmap Sprint 5 l.56,
|
||
# transcrit À LA MAIN dans le README sans AUCUN gate d'IDENTITÉ ni d'ancrage.
|
||
# ----------------------------------------------------------------------------
|
||
# La version MAJEURE du SDK Expo que l'app compagnon cible (`expoSdkMajor`) est une
|
||
# donnée DATA-DERIVED : le générateur émet out/app_config.json (byte-gaté par
|
||
# check_artifacts ⇒ faisant autorité) portant `expo.extra.expoSdkMajor`, recopié
|
||
# dans out/MANIFEST.json (`app.expo_sdk_major`), lui-même dérivé de
|
||
# mobile_spec.json (`app.expo_sdk_major`) dont la SOURCE FAISANT AUTORITÉ est la
|
||
# roadmap Sprint 5 l.56 « **Rebuild Expo 54** » (le spec le DÉCLARE via
|
||
# `app.expo_sdk_source`). Le README:4/19/35/72 recopie ce « Expo 54 » CINQ fois À
|
||
# LA MAIN — dont la ligne de source « Expo SDK **54** | roadmap Sprint 5 l.56 ».
|
||
# Le bloc « Mobile · récap de l'app Expo » amont ne gate QUE le quadruplet
|
||
# onglets/rôles/langues/identifiants a_confirmer (comptes de MANIFEST.counts) —
|
||
# AVEUGLE au majeur du SDK. Aucun gate ne comparait cette valeur à l'artefact ni
|
||
# ne l'ancrait à la roadmap : BUMPER le SDK (Expo 55 sort · EAS build cible 55)
|
||
# dans le spec/artefact SANS toucher au README (ou l'inverse), ou dériver la
|
||
# roadmap de l'artefact, laisse la prose PÉRIMÉE en silence pendant que le graphe
|
||
# byte-gaté dit autre chose — l'agent Mobile lancerait `eas build` sur le mauvais
|
||
# SDK (le rebuild même que la ligne roadmap prescrit) — « vert trompeur » qu'aucune
|
||
# suite tests/ (qui teste des FONCTIONS de génération, jamais la prose ni l'ancre à
|
||
# la roadmap) n'attrape. Même patron que l'ancrage §Entités/CLAUDE.md (SEO org) ou
|
||
# les `roadmap_line` de la recette : on RE-DÉRIVE le majeur de SON artefact
|
||
# byte-gaté (zéro duplication du contrat #6), on ANCRE à la roadmap faisant
|
||
# autorité, et on exige que TOUTE mention prose == cette valeur. Cohérences
|
||
# croisées bonus (mordent une chaîne INTERNEMENT incohérente) : artefact ⇔ MANIFEST
|
||
# ⇔ spec d'accord ; le spec DÉCLARE l'ancre roadmap. Un claim absent échoue AUSSI.
|
||
# ============================================================================
|
||
MOB_SDK_RD = f"{D}/mobile/app_config/README.md"
|
||
try:
|
||
sdk = int(load("mobile/app_config/out/app_config.json")["expo"]["extra"]["expoSdkMajor"])
|
||
_mman = load("mobile/app_config/out/MANIFEST.json")
|
||
_mspec = json.load(open(f"{D}/mobile/app_config/mobile_spec.json", encoding="utf-8"))
|
||
except (OSError, KeyError, ValueError, TypeError) as e:
|
||
bad(f"Mobile Expo SDK · artefact/spec illisible/inattendu : {e}"); sdk = None
|
||
if sdk is not None:
|
||
# (a) cohérence interne : artefact ⇔ MANIFEST ⇔ spec d'accord sur le majeur
|
||
man_sdk = _mman.get("app", {}).get("expo_sdk_major")
|
||
spec_sdk = _mspec.get("app", {}).get("expo_sdk_major")
|
||
if man_sdk != sdk or spec_sdk != sdk:
|
||
bad(f"Mobile Expo SDK · incohérence artefact={sdk} MANIFEST={man_sdk} "
|
||
f"spec={spec_sdk} → chaîne de dérivation cassée")
|
||
else:
|
||
good(f"Mobile Expo SDK · artefact ⇔ MANIFEST ⇔ spec d'accord (Expo {sdk})")
|
||
# (b) ANCRAGE roadmap : la roadmap faisant autorité dit « Rebuild Expo <N> »
|
||
try:
|
||
_rm = re.sub(r"\s+", " ",
|
||
open("04_roadmap/ROADMAP_8_WEEKS_OR_LESS.md", encoding="utf-8").read())
|
||
except OSError as e:
|
||
bad(f"Mobile Expo SDK · roadmap illisible : {e}"); _rm = None
|
||
if _rm is not None:
|
||
_rmm = re.search(r"Rebuild Expo (\d+)", _rm)
|
||
if _rmm is None:
|
||
bad(f"Mobile Expo SDK · « Rebuild Expo <N> » INTROUVABLE dans la roadmap "
|
||
f"(attendu {sdk}) → ancre morte")
|
||
elif int(_rmm.group(1)) == sdk:
|
||
good(f"Mobile Expo SDK · roadmap « Rebuild Expo {sdk} » == artefact (ancre vive)")
|
||
else:
|
||
bad(f"Mobile Expo SDK · roadmap dit « Rebuild Expo {_rmm.group(1)} » MAIS "
|
||
f"artefact dit {sdk} → régénérer depuis la roadmap")
|
||
# (c) le spec DÉCLARE son ancrage roadmap (expo_sdk_source cite roadmap + Expo N)
|
||
_ssrc = str(_mspec.get("app", {}).get("expo_sdk_source", ""))
|
||
if "roadmap" not in _ssrc.lower() or re.search(rf"Expo {sdk}\b", _ssrc) is None:
|
||
bad(f"Mobile Expo SDK · spec.app.expo_sdk_source n'ancre pas roadmap+Expo {sdk} "
|
||
f"(={_ssrc!r}) → ancrage déclaré manquant")
|
||
else:
|
||
good("Mobile Expo SDK · spec DÉCLARE l'ancrage roadmap (expo_sdk_source)")
|
||
# (d) README prose : TOUTE mention « Expo [SDK] N » == artefact (aucune périmée)
|
||
mob_sdk_rd = read_norm(MOB_SDK_RD)
|
||
if mob_sdk_rd is not None:
|
||
nums = [int(x) for x in re.findall(r"Expo(?:\s+SDK)?\s*\*{0,2}(\d+)", mob_sdk_rd)]
|
||
if not nums:
|
||
bad(f"Mobile Expo SDK · {MOB_SDK_RD} — aucune mention « Expo <N> » "
|
||
f"(attendu {sdk}) · claim absent = régression #6")
|
||
else:
|
||
stale = sorted({n for n in nums if n != sdk})
|
||
if stale:
|
||
bad(f"Mobile Expo SDK · {MOB_SDK_RD} — mention(s) périmée(s) Expo {stale} "
|
||
f"MAIS artefact {sdk} → régénérer le README")
|
||
else:
|
||
good(f"Mobile Expo SDK · {MOB_SDK_RD} — {len(nums)} mention(s) « Expo {sdk} » "
|
||
f"== artefact")
|
||
# (e) la ligne de source du tableau NOMME l'ancre roadmap à côté du majeur
|
||
if re.search(rf"Expo SDK \*\*{sdk}\*\*[^|]*\|[^|]*roadmap", mob_sdk_rd) is None:
|
||
bad(f"Mobile Expo SDK · {MOB_SDK_RD} — la ligne « Expo SDK **{sdk}** » ne cite "
|
||
f"pas son ancre `roadmap` (Sprint 5 l.56) → ancrage prose manquant")
|
||
else:
|
||
good(f"Mobile Expo SDK · {MOB_SDK_RD} — « Expo SDK **{sdk}** » cite l'ancre roadmap")
|
||
|
||
sys.exit(1 if FAIL else 0)
|
||
PY
|
||
rc=$?
|
||
|
||
echo
|
||
if [[ "$rc" -eq 0 ]]; then
|
||
echo -e "\033[32m✅ Chiffres du README fidèles aux artefacts cités (aucune donnée inventée · #6).\033[0m"
|
||
else
|
||
echo -e "\033[31m❌ Chiffre(s) du README périmé(s) vs artefacts — corriger README avant merge.\033[0m"
|
||
fi
|
||
exit "$rc"
|