Files
oto-enterprise-os-dtp/ci/check_readme_claims.sh
T
Claude Code DTP Worker 70a8db0f4c [DTP-Worker] Sprint 8 · buffer L75 · Domaine Frontend/Chat OTOIA : le montage de l'assistant conversationnel par portail (frontend/chat_otoia) affichait « 5 Custom Block + 5 configs runtime » (fiche agent frontend_console) et « 5 portails · 5 fixtures · 5 configs runtime » (README du module) — trois nombres data-derived, TOUS = 5 (piège aveugle), transcrits À LA MAIN dans DEUX docs sans AUCUN gate, alors que frontend/chat_otoia/out/MANIFEST.json (byte-gaté par check_artifacts, counts.custom_blocks/mounts/portails recomptés du contrat RBAC à chaque build via la bijection 1 block ⇔ 1 mount ⇔ 1 portail métier, plateforme exclu) fait autorité. Piège aveugle caractéristique : les trois valent 5 aujourd'hui — un gate qui n'en recompterait qu'UN serait aveugle à la dérive des deux autres (ajouter un portail au contrat RBAC → chat_otoia rebâtit 6 blocks/6 mounts/6 portails pendant que les docs restent à 5 en silence) → nouveau bloc dans check_readme_claims recomputant CHAQUE nombre depuis SON PROPRE champ du MANIFEST et exigeant l'égalité avec les deux docs. Cohérence croisée : custom_blocks == mounts == portails == |MANIFEST.portails| (la bijection du générateur) — mord un compteur désynchronisé de sa propre liste. Un claim absent échoue AUSSI (5 morsures vérifiées : dérive custom_block fiche captée là où mounts reste 5 · dérive configs runtime fiche captée là où blocks reste 5 · dérive portails README · dérive fixtures README · claim configs runtime disparu = red ; restauré = green). ci/README.md (table + détail) mis à jour · 7 gates re-verts.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-31 12:37:01 +00:00

1139 lines
65 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# ============================================================================
# check_readme_claims.sh · OTO Enterprise OS DTP
# ----------------------------------------------------------------------------
# Gate d'INTÉGRITÉ des chiffres du README (Sprint 8 buffer · CLAUDE.md #6 —
# « Zéro invention de chiffres · toujours vérifier sources archives »).
#
# Le README.md est le POINT D'ENTRÉE du mandat. Sa section « État courant
# (sourcé) » affiche des chiffres et déclare, mot pour mot :
# « Chaque chiffre ci-dessous est sourcé vers un artefact commité
# (anti-invention #6) ; ce README n'introduit aucune donnée nouvelle. »
# Cette promesse n'était garantie par AUCUN gate. `check_docs.sh` ne valide que
# les liens (cible existe) — pas la VALEUR des nombres. Résultat : quand un
# module + son job CI sont ajoutés (21→22 modules, 21→22 suites) ou qu'une
# promesse change de statut (14 in_repo + 1 out_of_scope → 15 in_repo), les
# chiffres du README se PÉRIMENT en silence tout en restant « sourcés » vers un
# artefact qui, lui, dit autre chose. Un README qui se contredit avec sa propre
# source est un « vert trompeur » de la même classe que la matrice périmée
# (« demo 18→21 ») ou l'orphan_tests_dirs (INV4) — appliqué à la doc d'entrée.
#
# Ce gate RECOMPUTE chaque chiffre depuis l'artefact cité (jamais une liste à la
# main · #6) et exige l'égalité avec ce qui est ÉCRIT dans les docs d'entrée :
# README.md :
# • Qualité 4Big N/M modules gated à K/100, verdict → qa/audit_4big totals
# • Régression N suites gated → qa/regression plan
# • Recette N promesses (X sprint + Y métriques), Z in_repo, verdict
# → qa/acceptance matrix
# • Agents « 13 agents » (×2 : nav + titre) → 03_agents/*/AGENT.md
# 03_agents/qa/AGENT.md (fiche QA · « Verdict agrégé courant ») :
# • N suites · M tests · M passés · E échec · E erreur · verdict
# → qa/regression run
# 03_agents/erpnext_backend/AGENT.md (« source unique ») :
# • N tests · M suites · verdict (matrice de régression du repo)
# → qa/regression run
# 05_deliverables_mvp/qa/audit_4big/README.md (« Résultat courant ») :
# • N/M modules à K/100, verdict (l'auditeur compte les modules mais figeait
# son PROPRE total à la main : 17→22) → qa/audit_4big totals
# 03_agents/*/AGENT.md (tables de livrables · colonne « Tests ») :
# • chaque cellule « Tests » par suite gatée → plan.suites[path]
# (+ count_tests pour le self_module qa/regression, exclu de la matrice)
# 03_agents/{crm,erpnext_backend}/AGENT.md (agrégats EN PROSE) :
# • « Total CRM N (a+b+c) » · « RBAC N (…) » · « e-CF N » — total ET multiset
# des composants recomputés (une compensation entre suites laisserait la
# table juste et la prose fausse) → plan.suites[path]
# frontend/portails/README.md + 03_agents/frontend_console/AGENT.md :
# • comptes PAR WORKSPACE (cartes / liens / rôles) transcrits À LA MAIN dans
# DEUX docs (table du README + prose « OTO Ventes 4 cartes/11 liens/12
# rôles ») + total « N Workspace natifs » → portails MANIFEST
# (tableau `workspaces`, byte-gaté par check_artifacts)
# seo/README.md + 03_agents/seo/AGENT.md (comptes de mots-clés) :
# • total + ventilation « 258 mots-clés (fr=87 · en=87 · es=84) » transcrits À
# LA MAIN dans DEUX docs → seo MANIFEST.counts
# (keywords_total + keywords_per_lang, recomptés de projets_master, byte-gatés)
# crm/workflow_vente/README.md + crm/dossier_vente/README.md (pipeline vente) :
# • « N états · M transitions » (titre du README workflow) + « N états du
# pipeline » (README DocType porteur, même compte dérivé du même graphe)
# transcrits À LA MAIN dans DEUX docs → workflow_vente
# MANIFEST.counts (states + transitions, recomptés du spec pipeline, byte-gatés)
# rbac/{roleprofile_gen,apply_plan,userperm_gen}/README.md (comptes 3 volets) :
# • table « Nb rôles » PAR profil + total bijectif → roleprofile MANIFEST
# • ligne agrégée « 6 étapes · 50 rôles / 116 DocPerm / 28 UP / 6 Role
# Profile · 4 DocType custom + 5 Company + 4 rôles » → apply_plan MANIFEST
# • ventilation « 28 entite · 16 groupe · 2 own · 4 equipe » → userperm MANIFEST
# (les 3 MANIFEST recalculés du contrat rbac_50_roles.json, byte-gatés)
# mobile/app_config/README.md (ligne récap de l'app Expo) :
# • quadruplet « 5 onglets · 44 rôles couverts · 3 langues · 13 identifiants
# a_confirmer » transcrit À LA MAIN → app_config
# MANIFEST.counts (onglets/roles_couverts/langues/identifiants_a_confirmer,
# recomptés de rbac_50_roles + seo_spec + a_confirmer, byte-gatés)
# legal/confotur/README.md + 03_agents/onapi_legal/AGENT.md (DocType CONFOTUR) :
# • quintuplet « 18 champs (14 de donnée) · 4 sections · 3 rôles · 2 évènements
# de dépôt » transcrit À LA MAIN dans DEUX docs → confotur
# MANIFEST.counts (fields/data_fields/sections/roles/depot_events, recomptés
# de confotur_spec.json, byte-gatés)
# frontend/chat_otoia/README.md + 03_agents/frontend_console/AGENT.md (montage
# du Chat OTOIA par portail) :
# • triplet « N `Custom Block` · M configs runtime · P portails » (tous = 5
# aujourd'hui — piège aveugle) transcrit À LA MAIN dans DEUX docs → chat_otoia
# MANIFEST.counts (custom_blocks/mounts/portails, recomptés du contrat RBAC,
# byte-gatés) + cohérence croisée == |MANIFEST.portails| (bijection 1 block ⇔
# 1 mount ⇔ 1 portail)
# 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")
# ============================================================================
# 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")
# ============================================================================
# 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")
# ============================================================================
# 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")
# ============================================================================
# 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")
# ============================================================================
# DevOps · run-book de déploiement VPS (phases ordonnées / confirmations
# préalables) — même classe de « vert trompeur », transcrite À LA MAIN dans
# DEUX docs, sans AUCUN gate.
# ----------------------------------------------------------------------------
# Le générateur deploy_runbook émet out/MANIFEST.json (byte-gaté par
# check_artifacts ⇒ faisant autorité) dont `counts.phases` et
# `counts.confirmations` sont RECOMPTÉS à chaque build (phases = |graph.phase_order| ·
# confirmations = |confirmations_ouvertes|, l'ensemble consolidé des paramètres
# réglementaires non confirmés). Deux nombres de cette synthèse sont recopiés À LA
# MAIN : le compte de PHASES dans la fiche agent DevOps (03_agents/devops/AGENT.md —
# « graphe de **7 phases ordonnées** » ET, plus bas, « exécute les 7 phases sur le
# VPS ») et le compte de CONFIRMATIONS dans le README du module
# (05_deliverables_mvp/devops/deploy_runbook/README.md — « renseigner les 7
# confirmations »). Piège « aveugle » caractéristique (cf. bascule verdict audit_5d,
# compensation SEO entre langues) : phases ET confirmations valent 7 AUJOURD'HUI —
# un gate qui ne recompterait qu'UN nombre serait AVEUGLE à la dérive de l'autre
# (ajouter une phase au graphe → 8 phases pendant que les confirmations restent 7,
# ou l'inverse). On RECOMPUTE donc CHAQUE nombre depuis SON PROPRE champ du MANIFEST
# (jamais figé ici · zéro duplication du compteur du générateur #5) et on exige
# l'égalité avec les docs. Bonus anti-dérive : l'ÉNUMÉRATION des confirmations du
# README (les tokens `snake_case` de la phrase « Confirmations préalables VPS »)
# doit être l'ensemble EXACT de confirmations_ouvertes — capte l'ÉCHANGE d'un nom de
# confirmation (count aveugle à un swap 1↔1). Cohérence croisée : counts.phases ==
# |phase_order| · counts.confirmations == |confirmations_ouvertes|. Un claim absent
# échoue AUSSI (la disparition d'un chiffre est une régression · traçabilité).
# ============================================================================
DR_RD = f"{D}/devops/deploy_runbook/README.md"
DR_FI = "03_agents/devops/AGENT.md"
try:
dr_man = load("devops/deploy_runbook/out/MANIFEST.json")
dr_ph = str(dr_man["counts"]["phases"])
dr_cf = str(dr_man["counts"]["confirmations"])
dr_order = dr_man["graph"]["phase_order"]
dr_open = dr_man["confirmations_ouvertes"]
if dr_ph != str(len(dr_order)):
bad(f"DevOps · incohérence artefact : counts.phases={dr_ph} MAIS "
f"|graph.phase_order|={len(dr_order)}")
if dr_cf != str(len(dr_open)):
bad(f"DevOps · incohérence artefact : counts.confirmations={dr_cf} MAIS "
f"|confirmations_ouvertes|={len(dr_open)}")
except (OSError, KeyError, ValueError) as e:
bad(f"DevOps · artefact deploy_runbook illisible/inattendu : {e}"); dr_ph = None
if dr_ph is not None:
# 1) Fiche agent DevOps — « graphe de N phases ordonnées »
dr_fi = read_norm(DR_FI)
if dr_fi is not None:
m = re.search(r"graphe de \*\*(\d+) phases ordonnées\*\*", dr_fi)
if m is None:
bad(f"DevOps · {DR_FI} — claim « graphe de N phases ordonnées » INTROUVABLE "
f"(attendu phases={dr_ph})")
elif m.group(1) == dr_ph:
good(f"DevOps · {DR_FI} — {m.group(1)} phases == artefact (phases={dr_ph})")
else:
bad(f"DevOps · {DR_FI} — dit {m.group(1)} phases MAIS artefact dit {dr_ph} "
f"→ régénérer la fiche")
# 2) Fiche agent DevOps — 2e transcription « exécute les N phases »
m = re.search(r"exécute les (\d+) phases", dr_fi)
if m is None:
bad(f"DevOps · {DR_FI} — 2e claim « exécute les N phases » INTROUVABLE "
f"(attendu phases={dr_ph})")
elif m.group(1) == dr_ph:
good(f"DevOps · {DR_FI} — « exécute les {m.group(1)} phases » == artefact "
f"(phases={dr_ph})")
else:
bad(f"DevOps · {DR_FI} — 2e claim dit {m.group(1)} phases MAIS artefact dit "
f"{dr_ph} → régénérer la fiche")
# 3) README module — « renseigner les N confirmations » (compte)
dr_rd = read_norm(DR_RD)
if dr_rd is not None:
m = re.search(r"renseigner les (\d+) confirmations", dr_rd)
if m is None:
bad(f"DevOps · {DR_RD} — claim « renseigner les N confirmations » INTROUVABLE "
f"(attendu confirmations={dr_cf})")
elif m.group(1) == dr_cf:
good(f"DevOps · {DR_RD} — {m.group(1)} confirmations == artefact "
f"(confirmations={dr_cf})")
else:
bad(f"DevOps · {DR_RD} — dit {m.group(1)} confirmations MAIS artefact dit "
f"{dr_cf} → régénérer le README")
# 4) README module — l'ÉNUMÉRATION doit être l'ensemble EXACT des confirmations
seg = re.search(r"Confirmations préalables VPS\*\* \(consolidées, sourcées\) : "
r"(.+?) ## ", dr_rd)
if seg is None:
bad(f"DevOps · {DR_RD} — énumération « Confirmations préalables VPS » "
f"INTROUVABLE (attendu {sorted(dr_open)})")
else:
listed = sorted(set(re.findall(r"`([a-z_]+)`", seg.group(1))))
if listed == sorted(dr_open):
good(f"DevOps · {DR_RD} — énumération ({len(listed)}) == confirmations_ouvertes")
else:
bad(f"DevOps · {DR_RD} — énumération {listed} ≠ confirmations_ouvertes "
f"{sorted(dr_open)} → régénérer le README")
# ============================================================================
# Démo · run-sheet de pitch (nombre de modules cités) — même classe de « vert
# trompeur », transcrit À LA MAIN dans le diagramme d'architecture du README du
# module, sans AUCUN gate.
# ----------------------------------------------------------------------------
# Le générateur demo_scenario_gen émet out/MANIFEST.json (byte-gaté par
# check_artifacts ⇒ faisant autorité) dont `counts.modules_cites_uniques` est
# RECOMPTÉ à chaque build = |union des `modules_cites` des deux scénarios| — les
# hand-off amont que les beats citent par pointeur RFC 6901 (le module ne saisit
# AUCUNE valeur métier ; il résout depuis le disque). Ce nombre est recopié À LA
# MAIN dans le diagramme du README du module (05_deliverables_mvp/demo/scenarios/
# README.md — « out/ des modules livrés (10 modules cités) »). Aucun gate ne le
# couvrait : ajouter un beat qui cite un module encore non tissé (11) ou retirer
# la dernière citation d'un module d'un scénario (9) ferait dériver le diagramme
# en silence pendant que le MANIFEST byte-gaté dit autre chose — dérive qu'aucune
# suite tests/ (qui teste des FONCTIONS, pas la prose du README) n'attrape. Même
# piège que les mots-clés SEO, les triplets par workspace des portails, le
# quintuplet CONFOTUR ou la synthèse audit_5d. On RECOMPUTE depuis
# counts.modules_cites_uniques (jamais figé ici · zéro duplication du compteur du
# générateur #5) et on exige l'égalité. Cohérence croisée en bonus :
# counts.modules_cites_uniques == |modules_cites| (liste dédupliquée du manifeste,
# elle-même sans doublon) — mord un compteur désynchronisé de sa propre liste. Un
# claim absent échoue AUSSI (la disparition d'un chiffre est une régression).
# ============================================================================
DEMO_RD = f"{D}/demo/scenarios/README.md"
try:
demo_man = load("demo/scenarios/out/MANIFEST.json")
demo_n = str(demo_man["counts"]["modules_cites_uniques"])
demo_list = demo_man["modules_cites"]
if len(demo_list) != len(set(demo_list)):
bad(f"Démo · incohérence artefact : modules_cites contient des doublons "
f"({len(demo_list)} entrées, {len(set(demo_list))} uniques)")
if demo_n != str(len(demo_list)):
bad(f"Démo · incohérence artefact : counts.modules_cites_uniques={demo_n} "
f"MAIS |modules_cites|={len(demo_list)}")
except (OSError, KeyError, ValueError) as e:
bad(f"Démo · artefact scenarios illisible/inattendu : {e}"); demo_n = None
if demo_n is not None:
demo_rd = read_norm(DEMO_RD)
if demo_rd is not None:
m = re.search(r"out/ des modules livrés \((\d+) modules cités\)", demo_rd)
if m is None:
bad(f"Démo · {DEMO_RD} — claim « (N modules cités) » INTROUVABLE "
f"(attendu modules_cites_uniques={demo_n})")
elif m.group(1) == demo_n:
good(f"Démo · {DEMO_RD} — {m.group(1)} modules cités == artefact "
f"(modules_cites_uniques={demo_n})")
else:
bad(f"Démo · {DEMO_RD} — dit {m.group(1)} modules cités MAIS artefact dit "
f"{demo_n} → régénérer le README")
# ============================================================================
# Chat OTOIA · montage de l'assistant conversationnel par portail (nombre de
# Custom Block / configs runtime / portails) — même classe de « vert trompeur »,
# transcrit À LA MAIN dans DEUX docs, sans AUCUN gate.
# ----------------------------------------------------------------------------
# Le générateur chat_otoia_gen émet out/MANIFEST.json (byte-gaté par
# check_artifacts ⇒ faisant autorité) dont `counts.custom_blocks`, `counts.mounts`
# et `counts.portails` sont RECOMPTÉS à chaque build depuis le contrat RBAC (un
# `Custom Block` ⇔ une config runtime ⇔ un portail métier, `plateforme` exclu —
# invariant 2 du générateur : bijection 1 block ⇔ 1 mount ⇔ 1 portail). Ces
# nombres sont recopiés À LA MAIN : la fiche agent Frontend Console
# (03_agents/frontend_console/AGENT.md — « **N `Custom Block` + M configs
# runtime** ») ET le README du module (05_deliverables_mvp/frontend/chat_otoia/
# README.md — « Portails (N, `plateforme` exclu) », « N fixtures `Custom Block` »,
# « M configs runtime »). Piège « aveugle » caractéristique (cf. DevOps phases
# confirmations, bascule verdict audit_5d) : custom_blocks, mounts ET portails
# valent TOUS TROIS 5 AUJOURD'HUI — un gate qui n'en recompterait qu'un serait
# AVEUGLE à la dérive des autres (ajouter un portail au contrat RBAC → chat_otoia
# rebâtit 6 blocks 6 mounts 6 portails pendant que les docs restent à 5). On
# RECOMPUTE donc CHAQUE nombre depuis SON PROPRE champ du MANIFEST (jamais figé
# ici · zéro duplication du compteur du générateur #5) et on exige l'égalité avec
# les docs. Cohérence croisée : counts.custom_blocks == counts.mounts ==
# counts.portails == |MANIFEST.portails| (la bijection du générateur) — mord un
# compteur désynchronisé de sa propre liste. Un claim absent échoue AUSSI (la
# disparition d'un chiffre est une régression · traçabilité).
# ============================================================================
CO_RD = f"{D}/frontend/chat_otoia/README.md"
CO_FI = "03_agents/frontend_console/AGENT.md"
try:
co_man = load("frontend/chat_otoia/out/MANIFEST.json")
co_cb = str(co_man["counts"]["custom_blocks"])
co_mo = str(co_man["counts"]["mounts"])
co_po = str(co_man["counts"]["portails"])
co_nlist = len(co_man["portails"])
if not (co_cb == co_mo == co_po == str(co_nlist)):
bad(f"Chat OTOIA · incohérence artefact : custom_blocks={co_cb} · "
f"mounts={co_mo} · portails={co_po} · |portails|={co_nlist} "
f"(bijection 1 block ⇔ 1 mount ⇔ 1 portail attendue)")
except (OSError, KeyError, ValueError) as e:
bad(f"Chat OTOIA · artefact chat_otoia illisible/inattendu : {e}"); co_cb = None
if co_cb is not None:
# 1) Fiche agent Frontend Console — « N `Custom Block` + M configs runtime »
co_fi = read_norm(CO_FI)
if co_fi is not None:
m = re.search(r"\*\*(\d+) `Custom Block` \+ (\d+) configs runtime\*\*", co_fi)
if m is None:
bad(f"Chat OTOIA · {CO_FI} — claim « N `Custom Block` + M configs runtime » "
f"INTROUVABLE (attendu {co_cb}/{co_mo})")
elif m.groups() == (co_cb, co_mo):
good(f"Chat OTOIA · {CO_FI} — {'/'.join(m.groups())} == artefact "
f"(custom_blocks={co_cb} · mounts={co_mo})")
else:
bad(f"Chat OTOIA · {CO_FI} — dit {'/'.join(m.groups())} MAIS artefact dit "
f"{co_cb}/{co_mo} → régénérer la fiche")
# 2) README module — « Portails (N, `plateforme` exclu) »
co_rd = read_norm(CO_RD)
if co_rd is not None:
m = re.search(r"Portails \((\d+), `plateforme` exclu\)", co_rd)
if m is None:
bad(f"Chat OTOIA · {CO_RD} — claim « Portails (N, `plateforme` exclu) » "
f"INTROUVABLE (attendu portails={co_po})")
elif m.group(1) == co_po:
good(f"Chat OTOIA · {CO_RD} — {m.group(1)} portails == artefact "
f"(portails={co_po})")
else:
bad(f"Chat OTOIA · {CO_RD} — dit {m.group(1)} portails MAIS artefact dit "
f"{co_po} → régénérer le README")
# 3) README module — « N fixtures `Custom Block` »
m = re.search(r"(\d+) fixtures `Custom Block`", co_rd)
if m is None:
bad(f"Chat OTOIA · {CO_RD} — claim « N fixtures `Custom Block` » INTROUVABLE "
f"(attendu custom_blocks={co_cb})")
elif m.group(1) == co_cb:
good(f"Chat OTOIA · {CO_RD} — {m.group(1)} fixtures == artefact "
f"(custom_blocks={co_cb})")
else:
bad(f"Chat OTOIA · {CO_RD} — dit {m.group(1)} fixtures MAIS artefact dit "
f"{co_cb} → régénérer le README")
# 4) README module — « M configs runtime » (2e transcription des mounts)
m = re.search(r"(\d+) configs runtime", co_rd)
if m is None:
bad(f"Chat OTOIA · {CO_RD} — claim « M configs runtime » INTROUVABLE "
f"(attendu mounts={co_mo})")
elif m.group(1) == co_mo:
good(f"Chat OTOIA · {CO_RD} — {m.group(1)} configs runtime == artefact "
f"(mounts={co_mo})")
else:
bad(f"Chat OTOIA · {CO_RD} — dit {m.group(1)} configs runtime MAIS artefact "
f"dit {co_mo} → régénérer le README")
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"