b7fa3bf8c8
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
598 lines
31 KiB
Bash
Executable File
598 lines
31 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)
|
||
# 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")
|
||
|
||
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"
|