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

645 lines
34 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# ============================================================================
# check_readme_claims.sh · OTO Enterprise OS DTP
# ----------------------------------------------------------------------------
# Gate d'INTÉGRITÉ des chiffres du README (Sprint 8 buffer · CLAUDE.md #6 —
# « Zéro invention de chiffres · toujours vérifier sources archives »).
#
# Le README.md est le POINT D'ENTRÉE du mandat. Sa section « État courant
# (sourcé) » affiche des chiffres et déclare, mot pour mot :
# « Chaque chiffre ci-dessous est sourcé vers un artefact commité
# (anti-invention #6) ; ce README n'introduit aucune donnée nouvelle. »
# Cette promesse n'était garantie par AUCUN gate. `check_docs.sh` ne valide que
# les liens (cible existe) — pas la VALEUR des nombres. Résultat : quand un
# module + son job CI sont ajoutés (21→22 modules, 21→22 suites) ou qu'une
# promesse change de statut (14 in_repo + 1 out_of_scope → 15 in_repo), les
# chiffres du README se PÉRIMENT en silence tout en restant « sourcés » vers un
# artefact qui, lui, dit autre chose. Un README qui se contredit avec sa propre
# source est un « vert trompeur » de la même classe que la matrice périmée
# (« demo 18→21 ») ou l'orphan_tests_dirs (INV4) — appliqué à la doc d'entrée.
#
# Ce gate RECOMPUTE chaque chiffre depuis l'artefact cité (jamais une liste à la
# main · #6) et exige l'égalité avec ce qui est ÉCRIT dans les docs d'entrée :
# README.md :
# • Qualité 4Big N/M modules gated à K/100, verdict → qa/audit_4big totals
# • Régression N suites gated → qa/regression plan
# • Recette N promesses (X sprint + Y métriques), Z in_repo, verdict
# → qa/acceptance matrix
# • Agents « 13 agents » (×2 : nav + titre) → 03_agents/*/AGENT.md
# 03_agents/qa/AGENT.md (fiche QA · « Verdict agrégé courant ») :
# • N suites · M tests · M passés · E échec · E erreur · verdict
# → qa/regression run
# 03_agents/erpnext_backend/AGENT.md (« source unique ») :
# • N tests · M suites · verdict (matrice de régression du repo)
# → qa/regression run
# 05_deliverables_mvp/qa/audit_4big/README.md (« Résultat courant ») :
# • N/M modules à K/100, verdict (l'auditeur compte les modules mais figeait
# son PROPRE total à la main : 17→22) → qa/audit_4big totals
# 03_agents/*/AGENT.md (tables de livrables · colonne « Tests ») :
# • chaque cellule « Tests » par suite gatée → plan.suites[path]
# (+ count_tests pour le self_module qa/regression, exclu de la matrice)
# 03_agents/{crm,erpnext_backend}/AGENT.md (agrégats EN PROSE) :
# • « Total CRM N (a+b+c) » · « RBAC N (…) » · « e-CF N » — total ET multiset
# des composants recomputés (une compensation entre suites laisserait la
# table juste et la prose fausse) → plan.suites[path]
# frontend/portails/README.md + 03_agents/frontend_console/AGENT.md :
# • comptes PAR WORKSPACE (cartes / liens / rôles) transcrits À LA MAIN dans
# DEUX docs (table du README + prose « OTO Ventes 4 cartes/11 liens/12
# rôles ») + total « N Workspace natifs » → portails MANIFEST
# (tableau `workspaces`, byte-gaté par check_artifacts)
# seo/README.md + 03_agents/seo/AGENT.md (comptes de mots-clés) :
# • total + ventilation « 258 mots-clés (fr=87 · en=87 · es=84) » transcrits À
# LA MAIN dans DEUX docs → seo MANIFEST.counts
# (keywords_total + keywords_per_lang, recomptés de projets_master, byte-gatés)
# 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)
# 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
D = "05_deliverables_mvp"
FAIL = 0
def bad(m):
global FAIL; FAIL = 1; print(f" \033[31m✗\033[0m {m}")
def good(m):
print(f" \033[32m✓\033[0m {m}")
try:
readme = open("README.md", encoding="utf-8").read()
except OSError as e:
print(f" \033[31m✗\033[0m README.md illisible : {e}"); sys.exit(1)
# Texte normalisé (espaces/newlines compressés) pour les claims multi-lignes.
norm = re.sub(r"\s+", " ", readme)
def load(p):
with open(f"{D}/{p}", encoding="utf-8") as f:
return json.load(f)
# --- Vérités-terrain recalculées depuis les artefacts (jamais saisies) -------
q = load("qa/audit_4big/out/quality_report.json")["totals"]
qv = load("qa/audit_4big/out/quality_report.json")["verdict"]
reg = load("qa/regression/out/regression_plan.json")["totals"]
acc = load("qa/acceptance/out/acceptance_matrix.json")
rows = acc["matrix"]
n_sprint = sum(1 for r in rows if r["kind"] == "sprint_deliverable")
n_metric = sum(1 for r in rows if r["kind"] == "mvp_metric")
n_inrepo = sum(1 for r in rows if r["status"] == "in_repo")
acc_verdict = acc["verdict"]
agents = subprocess.run(["git", "ls-files", "03_agents/*/AGENT.md"],
capture_output=True, text=True).stdout.split()
n_agents = len(agents)
# --- Chaque claim : (libellé, regex sur `norm`, groupes attendus) ------------
# Le n-uplet capturé (str) est comparé à l'attendu (str). Un claim non trouvé
# échoue (regex is None) — la disparition d'un chiffre est une régression.
checks = [
("Qualité 4Big · modules gated",
r"\*\*(\d+)/(\d+) modules gated à (\d+)/100\*\*",
(str(q["pass"]), str(q["modules"]), str(q["min_score"])),
f"pass={q['pass']} total={q['modules']} min_score={q['min_score']}"),
("Régression · suites gated",
r"\*\*(\d+) suites gated\*\*",
(str(reg["suites"]),),
f"suites={reg['suites']}"),
("Recette · promesses (sprint + métriques)",
r"\*\*(\d+) promesses\*\* \((\d+) livrables sprint \+ (\d+) métriques",
(str(len(rows)), str(n_sprint), str(n_metric)),
f"total={len(rows)} sprint={n_sprint} metric={n_metric}"),
("Recette · promesses in_repo",
r"\*\*(\d+) `in_repo`",
(str(n_inrepo),),
f"in_repo={n_inrepo}"),
("Agents · fiche nav",
r"Fiches des \*\*(\d+) agents\*\*",
(str(n_agents),),
f"AGENT.md={n_agents}"),
("Agents · titre section",
r"## Les (\d+) agents",
(str(n_agents),),
f"AGENT.md={n_agents}"),
]
for label, pat, expected, src in checks:
m = re.search(pat, norm)
if m is None:
bad(f"{label} — claim INTROUVABLE dans README (attendu {src}) · motif=/{pat}/")
continue
got = m.groups()
if got == expected:
good(f"{label} — README {'/'.join(got)} == artefact ({src})")
else:
bad(f"{label} — README dit {'/'.join(got)} MAIS artefact dit {'/'.join(expected)} ({src}) → régénérer README")
# --- Verdicts textuels cités dans le README (PASS / true) --------------------
if re.search(r"verdict `PASS`", norm):
if qv == "PASS":
good(f"Qualité 4Big · verdict README `PASS` == artefact ({qv})")
else:
bad(f"Qualité 4Big · README dit verdict `PASS` MAIS artefact dit {qv}")
else:
bad("Qualité 4Big · verdict `PASS` non cité dans README (attendu)")
if re.search(r"verdict `true`", norm):
if acc_verdict is True:
good("Recette · verdict README `true` == artefact (True)")
else:
bad(f"Recette · README dit verdict `true` MAIS artefact dit {acc_verdict}")
else:
bad("Recette · verdict `true` non cité dans README (attendu)")
# ============================================================================
# Fiche QA (03_agents/qa/AGENT.md) · même classe de « vert trompeur » —
# ----------------------------------------------------------------------------
# Sa section « Verdict agrégé courant » affiche un compte agrégé (« N suites
# gated · M tests · M passés · 0 échec · 0 erreur → verdict PASS ») en déclarant
# « jamais compté à la main » et en citant qa/regression/out/regression_run.json.
# Ce compte se PÉRIME pourtant en silence quand une suite est ajoutée : la fiche
# affichait « 21 suites · 534 tests » alors que le run agrégé faisant autorité en
# disait déjà davantage (matrice recomptée ci-dessous, jamais figée ici). Comme pour
# le README, aucun gate ne comparait la VALEUR au run. On la RECALCULE ici depuis
# le compte agrégé faisant autorité (regression_run.json, commité · déterministe).
# ============================================================================
QA = "03_agents/qa/AGENT.md"
try:
qa_txt = re.sub(r"\s+", " ", open(QA, encoding="utf-8").read())
except OSError as e:
bad(f"Fiche QA · {QA} illisible : {e}"); qa_txt = ""
run = load("qa/regression/out/regression_run.json")
suites = run["suites"]
agg = (
str(len(suites)), # suites
str(sum(s["ran"] for s in suites)), # tests exécutés
str(sum(s["passed"] for s in suites)), # passés
str(sum(s["failures"] for s in suites)), # échecs
str(sum(s["errors"] for s in suites)), # erreurs
run["verdict"], # verdict
)
qa_pat = (r"\*\*(\d+) suites gated · (\d+) tests · (\d+) passés · "
r"(\d+) échec · (\d+) erreur → verdict `(PASS|FAIL)`")
qa_src = (f"suites={agg[0]} tests={agg[1]} passés={agg[2]} "
f"échec={agg[3]} erreur={agg[4]} verdict={agg[5]}")
mqa = re.search(qa_pat, qa_txt)
if mqa is None:
bad(f"Fiche QA · verdict agrégé — claim INTROUVABLE dans {QA} (attendu {qa_src})")
elif mqa.groups() == agg:
good(f"Fiche QA · verdict agrégé — {QA} {'/'.join(mqa.groups())} == regression_run ({qa_src})")
else:
bad(f"Fiche QA · {QA} dit {'/'.join(mqa.groups())} MAIS regression_run dit "
f"{'/'.join(agg)} ({qa_src}) → régénérer la fiche")
# ============================================================================
# Fiche ERPNext Backend (03_agents/erpnext_backend/AGENT.md) · même classe.
# ----------------------------------------------------------------------------
# Sa ligne « source unique » situait les tests backend dans le repo par un
# compte agrégé (« repo : 560 tests au total ») saisi à la main. Ce nombre s'est
# PÉRIMÉ en silence (matrice 534 → 551 → 558) : 560 ne correspondait plus ni à la
# matrice (558) ni au repo-wide (584 méthodes test_*). Aucun gate ne le couvrait.
# On le RECALCULE depuis la même matrice faisant autorité (regression_run) et on
# exige tests + suites + verdict — comme la fiche QA.
# ============================================================================
BE = "03_agents/erpnext_backend/AGENT.md"
try:
be_txt = re.sub(r"\s+", " ", open(BE, encoding="utf-8").read())
except OSError as e:
bad(f"Fiche Backend · {BE} illisible : {e}"); be_txt = ""
be_exp = (
str(sum(s["ran"] for s in suites)), # tests exécutés (matrice)
str(len(suites)), # suites
run["verdict"], # verdict
)
be_pat = (r"matrice\s+de régression du repo : \*\*(\d+) tests · (\d+) suites\*\* · "
r"verdict `(PASS|FAIL)`")
be_src = f"tests={be_exp[0]} suites={be_exp[1]} verdict={be_exp[2]}"
mbe = re.search(be_pat, be_txt)
if mbe is None:
bad(f"Fiche Backend · matrice repo — claim INTROUVABLE dans {BE} (attendu {be_src})")
elif mbe.groups() == be_exp:
good(f"Fiche Backend · matrice repo — {BE} {'/'.join(mbe.groups())} == regression_run ({be_src})")
else:
bad(f"Fiche Backend · {BE} dit {'/'.join(mbe.groups())} MAIS regression_run dit "
f"{'/'.join(be_exp)} ({be_src}) → régénérer la fiche")
# ============================================================================
# README de l'auditeur 4Big (05_deliverables_mvp/qa/audit_4big/README.md) ·
# section « Résultat courant » — même classe de « vert trompeur ».
# ----------------------------------------------------------------------------
# Ironie parfaite : le module qui COMPTE les modules affichait son propre total
# SAISI À LA MAIN (« 17/17 modules à 100/100 »). Ce compte s'est PÉRIMÉ en silence
# quand des modules ont été ajoutés (17 → 22) — le README de l'auditeur contredisait
# son propre artefact `quality_report.json` (totals.modules = 22). Le gate racine
# ne couvrait QUE le README d'entrée, pas ce README de module. On RECALCULE ici
# depuis le même artefact faisant autorité (totals, déjà chargé dans `q`) et on
# exige l'égalité avec ce que la section « Résultat courant » ÉCRIT.
# ============================================================================
A4 = f"{D}/qa/audit_4big/README.md"
try:
a4_txt = re.sub(r"\s+", " ", open(A4, encoding="utf-8").read())
except OSError as e:
bad(f"README audit_4big · {A4} illisible : {e}"); a4_txt = ""
a4_exp = (qv, str(q["pass"]), str(q["modules"]), str(q["min_score"]))
a4_pat = (r"\*\*Verdict `(PASS|FAIL)`\*\* [—-] \*\*(\d+)/(\d+)\*\* "
r"modules à \*\*(\d+)/100\*\*")
a4_src = f"verdict={a4_exp[0]} pass={a4_exp[1]} modules={a4_exp[2]} min_score={a4_exp[3]}"
ma4 = re.search(a4_pat, a4_txt)
if ma4 is None:
bad(f"README audit_4big · « Résultat courant » — claim INTROUVABLE dans {A4} "
f"(attendu {a4_src})")
elif ma4.groups() == a4_exp:
good(f"README audit_4big · Résultat courant — {A4} "
f"{'/'.join(ma4.groups())} == quality_report ({a4_src})")
else:
bad(f"README audit_4big · {A4} dit {'/'.join(ma4.groups())} MAIS quality_report "
f"dit {'/'.join(a4_exp)} ({a4_src}) → régénérer le README")
# ============================================================================
# Fiches agents · comptes de tests PAR SUITE (colonne « Tests » des tables) —
# même classe de « vert trompeur », au grain le PLUS FIN.
# ----------------------------------------------------------------------------
# Chaque fiche agent (03_agents/*/AGENT.md) recense ses livrables dans une table
# dont la dernière colonne « Tests » affiche le nombre de méthodes `def test_` de
# la suite. Ce nombre est SAISI À LA MAIN : il se périme dès qu'un test est
# ajouté/retiré. C'est arrivé (fiche QA : `acceptance` disait 31 alors que la
# suite en portait 37 · `audit_4big` 35→34 · `regression` 25→26). Les gates
# amont ne couvraient QUE la SOMME agrégée (« 564 tests ») : une compensation
# entre deux suites (l'une +1, l'autre 1) laisserait la somme juste et les DEUX
# lignes fausses. On RECOMPUTE ici chaque cellule depuis la source faisant
# autorité — plan.suites[path].test_methods pour les suites gated, et
# reglib.discovery.count_tests (même fonction que le plan · zéro duplication)
# pour le self_module (qa/regression, exclu de la matrice par SoD mais bien
# documenté avec son propre compte).
# ============================================================================
sys.path.insert(0, f"{D}/qa/regression")
try:
from reglib.discovery import count_tests
except Exception as e: # import cassé = red honnête (source de vérité absente)
bad(f"Comptes par suite · import reglib.discovery impossible : {e}")
count_tests = None
plan = load("qa/regression/out/regression_plan.json")
auth = {s["path"]: s["test_methods"] for s in plan["suites"]}
self_mod = plan["coverage"].get("self_module_excluded")
if count_tests is not None and self_mod:
auth[self_mod] = count_tests(f"{D}/{self_mod}/tests")[1]
# Ligne de table Tests : lien vers 05_deliverables_mvp/<path>/README.md + dernier
# champ numérique. On ne vérifie QUE les lignes pointant une suite gatée connue
# (auth) — toute autre ligne de table est ignorée (pas notre périmètre).
row_re = re.compile(
r"^\|\s*\[`\w+/`\]\([^)]*?05_deliverables_mvp/([\w/]+?)/README\.md\)"
r".*\|\s*(\d+)\s*\|\s*$")
fiches = subprocess.run(["git", "ls-files", "03_agents/*/AGENT.md"],
capture_output=True, text=True).stdout.split()
seen_rows = 0
for af in fiches:
try:
lines = open(af, encoding="utf-8").read().splitlines()
except OSError as e:
bad(f"Fiche {af} illisible : {e}"); continue
for ln, line in enumerate(lines, 1):
m = row_re.match(line)
if not m:
continue
path = m.group(1)
if path not in auth: # ligne pointant un non-suite → hors périmètre
continue
claimed, exp = int(m.group(2)), auth[path]
seen_rows += 1
if claimed == exp:
good(f"Fiche {af}:{ln} · {path} — Tests {claimed} == source ({exp})")
else:
bad(f"Fiche {af}:{ln} · {path} — Tests {claimed} MAIS source dit {exp} "
f"(plan.suites/count_tests) → régénérer la fiche")
if seen_rows == 0:
bad("Comptes par suite · AUCUNE ligne de table « Tests » trouvée dans les "
"fiches — la disparition du recensement est elle-même une régression.")
# ============================================================================
# Fiches agents · comptes AGRÉGÉS EN PROSE (« Total X : N tests (a + b + c) ») —
# dernière surface de la MÊME classe, un cran au-dessus des cellules de table.
# ----------------------------------------------------------------------------
# Les cellules « Tests » des tables sont recomputées ci-dessus, mais deux fiches
# rédigent EN PROSE un agrégat multi-suites : `crm` (« Total CRM : 81 tests
# (25 + 31 + 25) ») et `erpnext_backend` (« RBAC 60 tests (10 + 11 + 12 + 11 + 16)
# + e-CF 39 tests »). Ces totaux ET leurs composants sont SAISIS À LA MAIN et
# n'étaient couverts par AUCUN gate : une compensation entre deux suites (l'une
# +1, l'autre 1) laisserait la table juste et la prose fausse — exactement le
# motif per-suite corrigé la session précédente. On RECOMPUTE ici le total ET le
# multiset des composants (ordre-indépendant) depuis `auth` (plan.suites, source
# faisant autorité déjà chargée), et on exige l'égalité avec ce que la fiche ÉCRIT.
# ============================================================================
PROSE = [
("03_agents/crm/AGENT.md", "Total CRM",
r"Total CRM : \*\*(\d+) tests\*\* \(([\d + ]+)\)",
["crm/commissions", "crm/dossier_vente", "crm/workflow_vente"]),
("03_agents/erpnext_backend/AGENT.md", "RBAC backend",
r"\*\*RBAC (\d+) tests\*\* \(([\d + ]+)\)",
["rbac", "rbac/apply_plan", "rbac/fixtures_gen",
"rbac/roleprofile_gen", "rbac/userperm_gen"]),
]
for path_f, label, pat, suites_list in PROSE:
try:
txt = re.sub(r"\s+", " ", open(path_f, encoding="utf-8").read())
except OSError as e:
bad(f"Prose agrégée · {path_f} illisible : {e}"); continue
try:
exp_parts = sorted(auth[s] for s in suites_list)
except KeyError as e:
bad(f"Prose agrégée · {label} — suite {e} absente de plan.suites (câblage ?)")
continue
exp_total = sum(exp_parts)
m = re.search(pat, txt)
if m is None:
bad(f"Prose agrégée · {path_f} · « {label} » — claim INTROUVABLE "
f"(attendu total={exp_total} composants={exp_parts})")
continue
total = int(m.group(1))
parts = sorted(int(x) for x in re.findall(r"\d+", m.group(2)))
if total == exp_total and parts == exp_parts and total == sum(parts):
good(f"Prose agrégée · {path_f} · {label} — "
f"{total} ({'+'.join(map(str, parts))}) == source ({exp_total})")
else:
bad(f"Prose agrégée · {path_f} · {label} — dit total={total} "
f"composants={parts} MAIS source dit total={exp_total} "
f"composants={exp_parts} (interne_ok={total == sum(parts)}) "
f"→ régénérer la fiche")
# e-CF : agrégat en prose à SUITE UNIQUE (pas de composants entre parenthèses).
be_ecf = re.sub(r"\s+", " ", open("03_agents/erpnext_backend/AGENT.md",
encoding="utf-8").read())
ecf_exp = auth["fiscal/ecf_dgii"]
mecf = re.search(r"\*\*e-CF (\d+) tests\*\*", be_ecf)
if mecf is None:
bad(f"Prose agrégée · e-CF — claim INTROUVABLE (attendu {ecf_exp})")
elif int(mecf.group(1)) == ecf_exp:
good(f"Prose agrégée · e-CF — {mecf.group(1)} == source ({ecf_exp})")
else:
bad(f"Prose agrégée · e-CF — dit {mecf.group(1)} MAIS source dit {ecf_exp} "
f"(fiscal/ecf_dgii) → régénérer la fiche")
# ============================================================================
# Portails frontend · comptes PAR WORKSPACE (cartes / liens / rôles) —
# même classe de « vert trompeur », transcrits À LA MAIN dans DEUX docs.
# ----------------------------------------------------------------------------
# Le générateur de portails émet out/MANIFEST.json dont le tableau `workspaces`
# porte, PAR portail, {nb_cartes, nb_liens, nb_roles} recalculés depuis le
# contrat RBAC à chaque build (byte-gaté par check_artifacts). Ces triplets sont
# recopiés À LA MAIN à DEUX endroits : la table du README du module
# (frontend/portails/README.md, colonnes Cartes|Liens|Rôles) ET la prose de la
# fiche agent (03_agents/frontend_console/AGENT.md — « OTO Ventes 4 cartes/11
# liens/12 rôles · … »). Aucun gate ne les couvrait : ajouter un rôle/DocType au
# contrat ferait dériver LES DEUX docs en silence pendant que l'artefact dit
# autre chose — même piège que les cellules « Tests » et la prose agrégée. On
# RECOMPUTE ici chaque triplet depuis MANIFEST (déjà byte-gaté · zéro duplication
# du compteur du générateur) et on exige l'égalité avec les DEUX docs. Un
# workspace absent d'un doc échoue AUSSI (traçabilité).
# ============================================================================
WS_RD = f"{D}/frontend/portails/README.md"
WS_FI = "03_agents/frontend_console/AGENT.md"
try:
ws_list = load("frontend/portails/out/MANIFEST.json")["workspaces"]
except (OSError, KeyError, ValueError) as e:
bad(f"Portails · MANIFEST illisible/inattendu : {e}"); ws_list = []
try:
ws_rd = re.sub(r"\s+", " ", open(WS_RD, encoding="utf-8").read())
except OSError as e:
bad(f"Portails · {WS_RD} illisible : {e}"); ws_rd = ""
try:
ws_fi = re.sub(r"\s+", " ", open(WS_FI, encoding="utf-8").read())
except OSError as e:
bad(f"Portails · {WS_FI} illisible : {e}"); ws_fi = ""
for w in ws_list:
portail, ws = w["portail"], w["workspace"]
exp = (str(w["nb_cartes"]), str(w["nb_liens"]), str(w["nb_roles"]))
src = f"cartes={exp[0]} liens={exp[1]} rôles={exp[2]}"
# 1) Table du README du module : | <portail> | `<ws>` | C | L | R |
rd_pat = (rf"\| {re.escape(portail)} \| `{re.escape(ws)}` \| "
r"(\d+) \| (\d+) \| (\d+) \|")
mrd = re.search(rd_pat, ws_rd)
if mrd is None:
bad(f"Portails README · {ws} — ligne INTROUVABLE dans {WS_RD} (attendu {src})")
elif mrd.groups() == exp:
good(f"Portails README · {ws} — {'/'.join(mrd.groups())} == MANIFEST ({src})")
else:
bad(f"Portails README · {ws} — {WS_RD} dit {'/'.join(mrd.groups())} MAIS "
f"MANIFEST dit {'/'.join(exp)} ({src}) → régénérer le README")
# 2) Prose de la fiche agent : `<ws>` C cartes/L liens/R rôles (ou C/L/R)
fi_pat = (rf"`{re.escape(ws)}` (\d+)(?: cartes)?/(\d+)(?: liens)?/"
r"(\d+)(?: rôles)?")
mfi = re.search(fi_pat, ws_fi)
if mfi is None:
bad(f"Portails fiche · {ws} — claim INTROUVABLE dans {WS_FI} (attendu {src})")
elif mfi.groups() == exp:
good(f"Portails fiche · {ws} — {'/'.join(mfi.groups())} == MANIFEST ({src})")
else:
bad(f"Portails fiche · {ws} — {WS_FI} dit {'/'.join(mfi.groups())} MAIS "
f"MANIFEST dit {'/'.join(exp)} ({src}) → régénérer la fiche")
# Total de workspaces (« N `Workspace` natifs ») cohérent artefact ↔ fiche.
n_ws = len(ws_list)
if n_ws:
mtot = re.search(r"\*\*(\d+) `Workspace` natifs", ws_fi)
if mtot is None:
bad(f"Portails fiche · total — « N `Workspace` natifs » INTROUVABLE "
f"dans {WS_FI} (attendu {n_ws})")
elif int(mtot.group(1)) == n_ws:
good(f"Portails fiche · total — {mtot.group(1)} == MANIFEST ({n_ws} workspaces)")
else:
bad(f"Portails fiche · total — {WS_FI} dit {mtot.group(1)} MAIS "
f"MANIFEST dit {n_ws} → régénérer la fiche")
# ============================================================================
# RBAC · comptes des 3 volets (roleprofile · apply_plan · userperm) —
# même classe de « vert trompeur », recopiés À LA MAIN depuis les MANIFEST.
# ----------------------------------------------------------------------------
# Les trois générateurs RBAC émettent, PAR volet, un out/MANIFEST.json recalculé
# à chaque build depuis le contrat `rbac_50_roles.json` (byte-gaté par
# check_artifacts ⇒ source faisant autorité). Trois READMEs de module rédigent à
# LA MAIN des comptes qui dérivent de ces manifestes, sans AUCUN gate :
# 1) roleprofile_gen/README.md — table « Nb rôles » PAR profil (6 lignes) +
# total. Grain le plus fin (par item), comme la colonne « Tests » des fiches.
# 2) apply_plan/README.md — ligne AGRÉGÉE « Génération réelle : 6 étapes · 50
# rôles / 116 DocPerm / 28 UP templates / 6 Role Profile · confirmations VPS
# 4 DocType custom + 5 Company + 4 rôles equipe » : 8 comptes consolidés de
# TOUS les volets en une phrase.
# 3) userperm_gen/README.md — « Répartition : 28 entite · 16 groupe · 2 own ·
# 4 equipe » : la ventilation par mécanisme d'enforcement.
# Ajouter un rôle/DocType au contrat ferait dériver LES TROIS docs en silence
# pendant que les MANIFEST (byte-gatés) disent autre chose — même piège que les
# cellules « Tests », la prose agrégée CRM/RBAC et les triplets par workspace. On
# RECOMPUTE ici chaque nombre depuis le MANIFEST du volet (zéro duplication des
# compteurs des générateurs) et on exige l'égalité avec ce que la doc ÉCRIT. Un
# claim absent échoue AUSSI (disparition = régression de traçabilité).
# ============================================================================
def read_norm(path):
try:
return re.sub(r"\s+", " ", open(path, encoding="utf-8").read())
except OSError as e:
bad(f"RBAC · {path} illisible : {e}"); return None
# 1) roleprofile_gen — table « Nb rôles » par profil + total bijectif -----------
RP_RD = f"{D}/rbac/roleprofile_gen/README.md"
try:
rp_man = load("rbac/roleprofile_gen/out/MANIFEST.json")
except (OSError, KeyError, ValueError) as e:
bad(f"RBAC roleprofile · MANIFEST illisible : {e}"); rp_man = None
rp_rd = read_norm(RP_RD)
if rp_man is not None and rp_rd is not None:
for p in rp_man["profiles"]:
name, portail, exp = p["role_profile"], p["portail"], str(p["nb_roles"])
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 nb_roles={exp})")
elif m.group(1) == exp:
good(f"RBAC roleprofile · {name} — Nb rôles {m.group(1)} == MANIFEST ({exp})")
else:
bad(f"RBAC roleprofile · {name} — {RP_RD} dit {m.group(1)} MAIS "
f"MANIFEST dit {exp} → régénérer le README")
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")
# 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")
# 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")
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"