6a40bfccce
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1400 lines
81 KiB
Bash
Executable File
1400 lines
81 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# ============================================================================
|
||
# check_readme_claims.sh · OTO Enterprise OS DTP
|
||
# ----------------------------------------------------------------------------
|
||
# Gate d'INTÉGRITÉ des chiffres du README (Sprint 8 buffer · CLAUDE.md #6 —
|
||
# « Zéro invention de chiffres · toujours vérifier sources archives »).
|
||
#
|
||
# Le README.md est le POINT D'ENTRÉE du mandat. Sa section « État courant
|
||
# (sourcé) » affiche des chiffres et déclare, mot pour mot :
|
||
# « Chaque chiffre ci-dessous est sourcé vers un artefact commité
|
||
# (anti-invention #6) ; ce README n'introduit aucune donnée nouvelle. »
|
||
# Cette promesse n'était garantie par AUCUN gate. `check_docs.sh` ne valide que
|
||
# les liens (cible existe) — pas la VALEUR des nombres. Résultat : quand un
|
||
# module + son job CI sont ajoutés (21→22 modules, 21→22 suites) ou qu'une
|
||
# promesse change de statut (14 in_repo + 1 out_of_scope → 15 in_repo), les
|
||
# chiffres du README se PÉRIMENT en silence tout en restant « sourcés » vers un
|
||
# artefact qui, lui, dit autre chose. Un README qui se contredit avec sa propre
|
||
# source est un « vert trompeur » de la même classe que la matrice périmée
|
||
# (« demo 18→21 ») ou l'orphan_tests_dirs (INV4) — appliqué à la doc d'entrée.
|
||
#
|
||
# Ce gate RECOMPUTE chaque chiffre depuis l'artefact cité (jamais une liste à la
|
||
# main · #6) et exige l'égalité avec ce qui est ÉCRIT dans les docs d'entrée :
|
||
# README.md :
|
||
# • Qualité 4Big N/M modules gated à K/100, verdict → qa/audit_4big totals
|
||
# • Régression N suites gated → qa/regression plan
|
||
# • Recette N promesses (X sprint + Y métriques), Z in_repo, verdict
|
||
# → qa/acceptance matrix
|
||
# • Agents « 13 agents » (×2 : nav + titre) → 03_agents/*/AGENT.md
|
||
# 03_agents/qa/AGENT.md (fiche QA · « Verdict agrégé courant ») :
|
||
# • N suites · M tests · M passés · E échec · E erreur · verdict
|
||
# → qa/regression run
|
||
# 03_agents/erpnext_backend/AGENT.md (« source unique ») :
|
||
# • N tests · M suites · verdict (matrice de régression du repo)
|
||
# → qa/regression run
|
||
# 05_deliverables_mvp/qa/audit_4big/README.md (« Résultat courant ») :
|
||
# • N/M modules à K/100, verdict (l'auditeur compte les modules mais figeait
|
||
# son PROPRE total à la main : 17→22) → qa/audit_4big totals
|
||
# 03_agents/*/AGENT.md (tables de livrables · colonne « Tests ») :
|
||
# • chaque cellule « Tests » par suite gatée → plan.suites[path]
|
||
# (+ count_tests pour le self_module qa/regression, exclu de la matrice)
|
||
# 03_agents/{crm,erpnext_backend}/AGENT.md (agrégats EN PROSE) :
|
||
# • « Total CRM N (a+b+c) » · « RBAC N (…) » · « e-CF N » — total ET multiset
|
||
# des composants recomputés (une compensation entre suites laisserait la
|
||
# table juste et la prose fausse) → plan.suites[path]
|
||
# frontend/portails/README.md + 03_agents/frontend_console/AGENT.md :
|
||
# • comptes PAR WORKSPACE (cartes / liens / rôles) transcrits À LA MAIN dans
|
||
# DEUX docs (table du README + prose « OTO Ventes 4 cartes/11 liens/12
|
||
# rôles ») + total « N Workspace natifs » → portails MANIFEST
|
||
# (tableau `workspaces`, byte-gaté par check_artifacts)
|
||
# seo/README.md + 03_agents/seo/AGENT.md (comptes de mots-clés) :
|
||
# • total + ventilation « 258 mots-clés (fr=87 · en=87 · es=84) » transcrits À
|
||
# LA MAIN dans DEUX docs → seo MANIFEST.counts
|
||
# (keywords_total + keywords_per_lang, recomptés de projets_master, byte-gatés)
|
||
# crm/workflow_vente/README.md + crm/dossier_vente/README.md (pipeline vente) :
|
||
# • « N états · M transitions » (titre du README workflow) + « N états du
|
||
# pipeline » (README DocType porteur, même compte dérivé du même graphe)
|
||
# transcrits À LA MAIN dans DEUX docs → workflow_vente
|
||
# MANIFEST.counts (states + transitions, recomptés du spec pipeline, byte-gatés)
|
||
# rbac/{roleprofile_gen,apply_plan,userperm_gen}/README.md (comptes 3 volets) :
|
||
# • table « Nb rôles » PAR profil + total bijectif → roleprofile MANIFEST
|
||
# • ligne agrégée « 6 étapes · 50 rôles / 116 DocPerm / 28 UP / 6 Role
|
||
# Profile · 4 DocType custom + 5 Company + 4 rôles » → apply_plan MANIFEST
|
||
# • ventilation « 28 entite · 16 groupe · 2 own · 4 equipe » → userperm MANIFEST
|
||
# (les 3 MANIFEST recalculés du contrat rbac_50_roles.json, byte-gatés)
|
||
# mobile/app_config/README.md (ligne récap de l'app Expo) :
|
||
# • quadruplet « 5 onglets · 44 rôles couverts · 3 langues · 13 identifiants
|
||
# a_confirmer » transcrit À LA MAIN → app_config
|
||
# MANIFEST.counts (onglets/roles_couverts/langues/identifiants_a_confirmer,
|
||
# recomptés de rbac_50_roles + seo_spec + a_confirmer, byte-gatés)
|
||
# legal/confotur/README.md + 03_agents/onapi_legal/AGENT.md (DocType CONFOTUR) :
|
||
# • quintuplet « 18 champs (14 de donnée) · 4 sections · 3 rôles · 2 évènements
|
||
# de dépôt » transcrit À LA MAIN dans DEUX docs → confotur
|
||
# MANIFEST.counts (fields/data_fields/sections/roles/depot_events, recomptés
|
||
# de confotur_spec.json, byte-gatés)
|
||
# frontend/chat_otoia/README.md + 03_agents/frontend_console/AGENT.md (montage
|
||
# du Chat OTOIA par portail) :
|
||
# • triplet « N `Custom Block` · M configs runtime · P portails » (tous = 5
|
||
# aujourd'hui — piège aveugle) transcrit À LA MAIN dans DEUX docs → chat_otoia
|
||
# MANIFEST.counts (custom_blocks/mounts/portails, recomptés du contrat RBAC,
|
||
# byte-gatés) + cohérence croisée == |MANIFEST.portails| (bijection 1 block ⇔
|
||
# 1 mount ⇔ 1 portail)
|
||
# qa/acceptance/README.md + 03_agents/qa/AGENT.md (module de recette) :
|
||
# • couple « N livrables de sprint + M métriques succès MVP » transcrit À LA MAIN
|
||
# dans DEUX docs de module (le bloc racine ne gate que le README d'entrée)
|
||
# → recompté depuis la matrice acceptance (kind sprint_deliverable / mvp_metric)
|
||
# + cohérence croisée == MANIFEST.counts (compteur ⇔ matrice résumée)
|
||
# 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")
|
||
|
||
# ============================================================================
|
||
# README de l'auditeur 4Big · BARÈME de notation (N critères + poids par critère)
|
||
# — même classe de « vert trompeur », au grain du référentiel de scoring.
|
||
# ----------------------------------------------------------------------------
|
||
# Le barème 4Big est DATA-DERIVED : quality_report.json.criteria (byte-gaté par
|
||
# check_artifacts, RECOMPUTÉ du référentiel à chaque build) porte la liste des
|
||
# critères et leur poids. Le README les RETRANSCRIT À LA MAIN à trois endroits :
|
||
# • la prose « Note chaque module sur N critères 4Big déterministes »,
|
||
# • la table « Critère | Poids | Fait vérifié » (une ligne id→poids par critère),
|
||
# • l'archétype « generator (N critères) » (generator applique TOUS les critères).
|
||
# Ajouter/retirer un critère ou changer un poids ferait dériver ces trois surfaces
|
||
# en silence pendant que l'artefact dit autre chose — aucune suite tests/ (qui teste
|
||
# des FONCTIONS, pas la prose du README) ne l'attrape. On RECOMPUTE ici chaque nombre
|
||
# depuis `criteria` (zéro duplication du barème du générateur · #6) et on exige
|
||
# l'égalité avec ce que la doc ÉCRIT. Cohérences croisées : Σ poids == 100 (base de
|
||
# renormalisation) ET tout module d'archétype `generator` porte EXACTEMENT
|
||
# len(criteria) checks — c.-à-d. « generator = tous les critères » (mord une dérive
|
||
# du modèle qui rendrait le « generator (N critères) » du README faux). Un claim
|
||
# absent échoue AUSSI (disparition = régression de traçabilité).
|
||
# Bloc (6) : la carte de RENORMALISATION PAR ARCHÉTYPE (« data_room sans HANDOFF ·
|
||
# parser sans CONTRAT ni HANDOFF · contract sans CLI ni HANDOFF ») est elle aussi
|
||
# data-derived — critères applicables d'un archétype = {check.criterion} de ses
|
||
# modules ⇒ exclus = barème − applicables. Recomputés par archétype, comparés à la
|
||
# parenthèse du README (exclusions des 3 archétypes non-generator, jamais gatées
|
||
# avant), + uniformité « 1 archétype → 1 jeu ».
|
||
# ============================================================================
|
||
try:
|
||
qr_crit = load("qa/audit_4big/out/quality_report.json")
|
||
criteria = qr_crit["criteria"]
|
||
except (OSError, KeyError, ValueError) as e:
|
||
bad(f"README audit_4big · quality_report illisible : {e}"); criteria = None
|
||
if criteria is not None and a4_txt:
|
||
n_crit = len(criteria)
|
||
# 1) prose « N critères 4Big déterministes »
|
||
mc = re.search(r"sur (\d+) critères 4Big déterministes", a4_txt)
|
||
if mc is None:
|
||
bad(f"README audit_4big · prose « N critères 4Big » — claim INTROUVABLE dans "
|
||
f"{A4} (attendu {n_crit})")
|
||
elif mc.group(1) == str(n_crit):
|
||
good(f"README audit_4big · prose critères — {mc.group(1)} == criteria ({n_crit})")
|
||
else:
|
||
bad(f"README audit_4big · prose critères — {A4} dit {mc.group(1)} MAIS criteria "
|
||
f"en compte {n_crit} → régénérer le README")
|
||
# 2) table « Critère | Poids | … » — une ligne id→poids par critère
|
||
for c in criteria:
|
||
cid, w = c["id"], str(c["weight"])
|
||
mw = re.search(rf"\| `{re.escape(cid)}` \| (\d+) \|", a4_txt)
|
||
if mw is None:
|
||
bad(f"README audit_4big · critère {cid} — ligne INTROUVABLE dans {A4} "
|
||
f"(attendu poids={w})")
|
||
elif mw.group(1) == w:
|
||
good(f"README audit_4big · critère {cid} — poids {mw.group(1)} == criteria ({w})")
|
||
else:
|
||
bad(f"README audit_4big · critère {cid} — {A4} dit poids {mw.group(1)} MAIS "
|
||
f"criteria dit {w} → régénérer le README")
|
||
# 3) archétype « generator (N critères) » — generator applique TOUS les critères
|
||
mg = re.search(r"`generator` \((\d+) critères\)", a4_txt)
|
||
if mg is None:
|
||
bad(f"README audit_4big · archétype « generator (N critères) » — claim "
|
||
f"INTROUVABLE dans {A4} (attendu {n_crit})")
|
||
elif mg.group(1) == str(n_crit):
|
||
good(f"README audit_4big · generator — {mg.group(1)} critères == criteria ({n_crit})")
|
||
else:
|
||
bad(f"README audit_4big · generator — {A4} dit {mg.group(1)} MAIS l'archétype "
|
||
f"generator applique les {n_crit} critères → régénérer le README")
|
||
# 4) cohérence croisée : Σ poids == 100 (base de renormalisation)
|
||
w_sum = sum(c["weight"] for c in criteria)
|
||
if w_sum == 100:
|
||
good(f"README audit_4big · Σ poids critères == {w_sum} (base 100 cohérente)")
|
||
else:
|
||
bad(f"README audit_4big · Σ poids critères == {w_sum} ≠ 100 — barème incohérent "
|
||
f"avec la renormalisation documentée")
|
||
# 5) cohérence croisée : tout module `generator` porte len(criteria) checks
|
||
gen = [m for m in qr_crit["modules"] if m.get("archetype") == "generator"]
|
||
gen_bad = [m["id"] for m in gen if len(m.get("checks", [])) != n_crit]
|
||
if not gen:
|
||
good("README audit_4big · aucun module d'archétype generator (rien à recouper)")
|
||
elif gen_bad:
|
||
bad(f"README audit_4big · modules generator avec ≠ {n_crit} checks : {gen_bad} "
|
||
f"→ « generator = tous les critères » violé")
|
||
else:
|
||
good(f"README audit_4big · {len(gen)} module(s) generator portent {n_crit} "
|
||
f"checks (« generator = tous les critères » vérifié)")
|
||
# 6) carte « archétype → critères EXCLUS » (renormalisation) : le README énumère
|
||
# data_room « sans HANDOFF », parser « sans CONTRAT … ni HANDOFF », contract
|
||
# « sans CLI ni HANDOFF ». Cette carte est DATA-DERIVED : pour un archétype,
|
||
# l'ensemble des critères APPLICABLES = {check.criterion} de ses modules (les
|
||
# non-applicables sont ABSENTS des checks) ⇒ EXCLUS = ordre du barème − appl.
|
||
# Le bloc (3) ne recompute QUE generator (5 critères) ; les exclusions des
|
||
# trois autres archétypes n'étaient recomputées par AUCUN gate. Changer la
|
||
# règle de renormalisation d'un archétype (ex. data_room exclut aussi CLI)
|
||
# ferait dériver cette prose en silence pendant que quality_report dit autre
|
||
# chose. On recompute par archétype + on exige que la parenthèse du README
|
||
# énumère EXACTEMENT cet ensemble. Uniformité en bonus : tous les modules
|
||
# d'un même archétype portent le MÊME ensemble applicable (sinon la carte
|
||
# « 1 archétype → 1 jeu de critères » n'a pas de sens).
|
||
crit_ids = [c["id"] for c in criteria]
|
||
crit_set = set(crit_ids)
|
||
by_arch, uni_bad = {}, []
|
||
for m in qr_crit["modules"]:
|
||
a = m.get("archetype")
|
||
appl = frozenset(ck["criterion"] for ck in m.get("checks", []))
|
||
if a in by_arch and by_arch[a] != appl:
|
||
uni_bad.append(m["id"])
|
||
else:
|
||
by_arch.setdefault(a, appl)
|
||
if uni_bad:
|
||
bad(f"README audit_4big · archétypes NON uniformes (modules aux critères "
|
||
f"applicables divergents) : {uni_bad} → renormalisation incohérente")
|
||
else:
|
||
good(f"README audit_4big · {len(by_arch)} archétype(s) à critères applicables "
|
||
f"uniformes (« 1 archétype → 1 jeu » vérifié)")
|
||
for a in sorted(by_arch):
|
||
if a == "generator":
|
||
continue # traité en (3) : « generator (N critères) », zéro exclusion
|
||
exp = [c for c in crit_ids if c not in by_arch[a]] # ordre du barème, stable
|
||
ma = re.search(rf"`{re.escape(a)}` \(([^)]*)\)", a4_txt)
|
||
if ma is None:
|
||
bad(f"README audit_4big · archétype `{a}` — énumération INTROUVABLE dans "
|
||
f"{A4} (attendu « sans {' ni '.join(exp)} »)")
|
||
continue
|
||
got = set(re.findall(r"[A-Z]{2,}", ma.group(1))) & crit_set
|
||
if got == set(exp):
|
||
good(f"README audit_4big · archétype `{a}` — exclus {sorted(got)} == "
|
||
f"référentiel ({exp})")
|
||
else:
|
||
bad(f"README audit_4big · archétype `{a}` — {A4} exclut {sorted(got)} MAIS "
|
||
f"le référentiel exclut {exp} → régénérer le README")
|
||
|
||
# ============================================================================
|
||
# Fiches agents · comptes de tests PAR SUITE (colonne « Tests » des tables) —
|
||
# même classe de « vert trompeur », au grain le PLUS FIN.
|
||
# ----------------------------------------------------------------------------
|
||
# Chaque fiche agent (03_agents/*/AGENT.md) recense ses livrables dans une table
|
||
# dont la dernière colonne « Tests » affiche le nombre de méthodes `def test_` de
|
||
# la suite. Ce nombre est SAISI À LA MAIN : il se périme dès qu'un test est
|
||
# ajouté/retiré. C'est arrivé (fiche QA : `acceptance` disait 31 alors que la
|
||
# suite en portait 37 · `audit_4big` 35→34 · `regression` 25→26). Les gates
|
||
# amont ne couvraient QUE la SOMME agrégée (« 564 tests ») : une compensation
|
||
# entre deux suites (l'une +1, l'autre −1) laisserait la somme juste et les DEUX
|
||
# lignes fausses. On RECOMPUTE ici chaque cellule depuis la source faisant
|
||
# autorité — plan.suites[path].test_methods pour les suites gated, et
|
||
# reglib.discovery.count_tests (même fonction que le plan · zéro duplication)
|
||
# pour le self_module (qa/regression, exclu de la matrice par SoD mais bien
|
||
# documenté avec son propre compte).
|
||
# ============================================================================
|
||
sys.path.insert(0, f"{D}/qa/regression")
|
||
try:
|
||
from reglib.discovery import count_tests
|
||
except Exception as e: # import cassé = red honnête (source de vérité absente)
|
||
bad(f"Comptes par suite · import reglib.discovery impossible : {e}")
|
||
count_tests = None
|
||
|
||
plan = load("qa/regression/out/regression_plan.json")
|
||
auth = {s["path"]: s["test_methods"] for s in plan["suites"]}
|
||
self_mod = plan["coverage"].get("self_module_excluded")
|
||
if count_tests is not None and self_mod:
|
||
auth[self_mod] = count_tests(f"{D}/{self_mod}/tests")[1]
|
||
|
||
# Ligne de table Tests : lien vers 05_deliverables_mvp/<path>/README.md + dernier
|
||
# champ numérique. On ne vérifie QUE les lignes pointant une suite gatée connue
|
||
# (auth) — toute autre ligne de table est ignorée (pas notre périmètre).
|
||
row_re = re.compile(
|
||
r"^\|\s*\[`\w+/`\]\([^)]*?05_deliverables_mvp/([\w/]+?)/README\.md\)"
|
||
r".*\|\s*(\d+)\s*\|\s*$")
|
||
fiches = subprocess.run(["git", "ls-files", "03_agents/*/AGENT.md"],
|
||
capture_output=True, text=True).stdout.split()
|
||
seen_rows = 0
|
||
for af in fiches:
|
||
try:
|
||
lines = open(af, encoding="utf-8").read().splitlines()
|
||
except OSError as e:
|
||
bad(f"Fiche {af} illisible : {e}"); continue
|
||
for ln, line in enumerate(lines, 1):
|
||
m = row_re.match(line)
|
||
if not m:
|
||
continue
|
||
path = m.group(1)
|
||
if path not in auth: # ligne pointant un non-suite → hors périmètre
|
||
continue
|
||
claimed, exp = int(m.group(2)), auth[path]
|
||
seen_rows += 1
|
||
if claimed == exp:
|
||
good(f"Fiche {af}:{ln} · {path} — Tests {claimed} == source ({exp})")
|
||
else:
|
||
bad(f"Fiche {af}:{ln} · {path} — Tests {claimed} MAIS source dit {exp} "
|
||
f"(plan.suites/count_tests) → régénérer la fiche")
|
||
if seen_rows == 0:
|
||
bad("Comptes par suite · AUCUNE ligne de table « Tests » trouvée dans les "
|
||
"fiches — la disparition du recensement est elle-même une régression.")
|
||
|
||
# ============================================================================
|
||
# Fiches agents · comptes AGRÉGÉS EN PROSE (« Total X : N tests (a + b + c) ») —
|
||
# dernière surface de la MÊME classe, un cran au-dessus des cellules de table.
|
||
# ----------------------------------------------------------------------------
|
||
# Les cellules « Tests » des tables sont recomputées ci-dessus, mais deux fiches
|
||
# rédigent EN PROSE un agrégat multi-suites : `crm` (« Total CRM : 81 tests
|
||
# (25 + 31 + 25) ») et `erpnext_backend` (« RBAC 60 tests (10 + 11 + 12 + 11 + 16)
|
||
# + e-CF 39 tests »). Ces totaux ET leurs composants sont SAISIS À LA MAIN et
|
||
# n'étaient couverts par AUCUN gate : une compensation entre deux suites (l'une
|
||
# +1, l'autre −1) laisserait la table juste et la prose fausse — exactement le
|
||
# motif per-suite corrigé la session précédente. On RECOMPUTE ici le total ET le
|
||
# multiset des composants (ordre-indépendant) depuis `auth` (plan.suites, source
|
||
# faisant autorité déjà chargée), et on exige l'égalité avec ce que la fiche ÉCRIT.
|
||
# ============================================================================
|
||
PROSE = [
|
||
("03_agents/crm/AGENT.md", "Total CRM",
|
||
r"Total CRM : \*\*(\d+) tests\*\* \(([\d + ]+)\)",
|
||
["crm/commissions", "crm/dossier_vente", "crm/workflow_vente"]),
|
||
("03_agents/erpnext_backend/AGENT.md", "RBAC backend",
|
||
r"\*\*RBAC (\d+) tests\*\* \(([\d + ]+)\)",
|
||
["rbac", "rbac/apply_plan", "rbac/fixtures_gen",
|
||
"rbac/roleprofile_gen", "rbac/userperm_gen"]),
|
||
]
|
||
for path_f, label, pat, suites_list in PROSE:
|
||
try:
|
||
txt = re.sub(r"\s+", " ", open(path_f, encoding="utf-8").read())
|
||
except OSError as e:
|
||
bad(f"Prose agrégée · {path_f} illisible : {e}"); continue
|
||
try:
|
||
exp_parts = sorted(auth[s] for s in suites_list)
|
||
except KeyError as e:
|
||
bad(f"Prose agrégée · {label} — suite {e} absente de plan.suites (câblage ?)")
|
||
continue
|
||
exp_total = sum(exp_parts)
|
||
m = re.search(pat, txt)
|
||
if m is None:
|
||
bad(f"Prose agrégée · {path_f} · « {label} » — claim INTROUVABLE "
|
||
f"(attendu total={exp_total} composants={exp_parts})")
|
||
continue
|
||
total = int(m.group(1))
|
||
parts = sorted(int(x) for x in re.findall(r"\d+", m.group(2)))
|
||
if total == exp_total and parts == exp_parts and total == sum(parts):
|
||
good(f"Prose agrégée · {path_f} · {label} — "
|
||
f"{total} ({'+'.join(map(str, parts))}) == source ({exp_total})")
|
||
else:
|
||
bad(f"Prose agrégée · {path_f} · {label} — dit total={total} "
|
||
f"composants={parts} MAIS source dit total={exp_total} "
|
||
f"composants={exp_parts} (interne_ok={total == sum(parts)}) "
|
||
f"→ régénérer la fiche")
|
||
|
||
# e-CF : agrégat en prose à SUITE UNIQUE (pas de composants entre parenthèses).
|
||
be_ecf = re.sub(r"\s+", " ", open("03_agents/erpnext_backend/AGENT.md",
|
||
encoding="utf-8").read())
|
||
ecf_exp = auth["fiscal/ecf_dgii"]
|
||
mecf = re.search(r"\*\*e-CF (\d+) tests\*\*", be_ecf)
|
||
if mecf is None:
|
||
bad(f"Prose agrégée · e-CF — claim INTROUVABLE (attendu {ecf_exp})")
|
||
elif int(mecf.group(1)) == ecf_exp:
|
||
good(f"Prose agrégée · e-CF — {mecf.group(1)} == source ({ecf_exp})")
|
||
else:
|
||
bad(f"Prose agrégée · e-CF — dit {mecf.group(1)} MAIS source dit {ecf_exp} "
|
||
f"(fiscal/ecf_dgii) → régénérer la fiche")
|
||
|
||
# ============================================================================
|
||
# Portails frontend · comptes PAR WORKSPACE (cartes / liens / rôles) —
|
||
# même classe de « vert trompeur », transcrits À LA MAIN dans DEUX docs.
|
||
# ----------------------------------------------------------------------------
|
||
# Le générateur de portails émet out/MANIFEST.json dont le tableau `workspaces`
|
||
# porte, PAR portail, {nb_cartes, nb_liens, nb_roles} recalculés depuis le
|
||
# contrat RBAC à chaque build (byte-gaté par check_artifacts). Ces triplets sont
|
||
# recopiés À LA MAIN à DEUX endroits : la table du README du module
|
||
# (frontend/portails/README.md, colonnes Cartes|Liens|Rôles) ET la prose de la
|
||
# fiche agent (03_agents/frontend_console/AGENT.md — « OTO Ventes 4 cartes/11
|
||
# liens/12 rôles · … »). Aucun gate ne les couvrait : ajouter un rôle/DocType au
|
||
# contrat ferait dériver LES DEUX docs en silence pendant que l'artefact dit
|
||
# autre chose — même piège que les cellules « Tests » et la prose agrégée. On
|
||
# RECOMPUTE ici chaque triplet depuis MANIFEST (déjà byte-gaté · zéro duplication
|
||
# du compteur du générateur) et on exige l'égalité avec les DEUX docs. Un
|
||
# workspace absent d'un doc échoue AUSSI (traçabilité).
|
||
# ============================================================================
|
||
WS_RD = f"{D}/frontend/portails/README.md"
|
||
WS_FI = "03_agents/frontend_console/AGENT.md"
|
||
try:
|
||
ws_list = load("frontend/portails/out/MANIFEST.json")["workspaces"]
|
||
except (OSError, KeyError, ValueError) as e:
|
||
bad(f"Portails · MANIFEST illisible/inattendu : {e}"); ws_list = []
|
||
try:
|
||
ws_rd = re.sub(r"\s+", " ", open(WS_RD, encoding="utf-8").read())
|
||
except OSError as e:
|
||
bad(f"Portails · {WS_RD} illisible : {e}"); ws_rd = ""
|
||
try:
|
||
ws_fi = re.sub(r"\s+", " ", open(WS_FI, encoding="utf-8").read())
|
||
except OSError as e:
|
||
bad(f"Portails · {WS_FI} illisible : {e}"); ws_fi = ""
|
||
|
||
for w in ws_list:
|
||
portail, ws = w["portail"], w["workspace"]
|
||
exp = (str(w["nb_cartes"]), str(w["nb_liens"]), str(w["nb_roles"]))
|
||
src = f"cartes={exp[0]} liens={exp[1]} rôles={exp[2]}"
|
||
# 1) Table du README du module : | <portail> | `<ws>` | C | L | R |
|
||
rd_pat = (rf"\| {re.escape(portail)} \| `{re.escape(ws)}` \| "
|
||
r"(\d+) \| (\d+) \| (\d+) \|")
|
||
mrd = re.search(rd_pat, ws_rd)
|
||
if mrd is None:
|
||
bad(f"Portails README · {ws} — ligne INTROUVABLE dans {WS_RD} (attendu {src})")
|
||
elif mrd.groups() == exp:
|
||
good(f"Portails README · {ws} — {'/'.join(mrd.groups())} == MANIFEST ({src})")
|
||
else:
|
||
bad(f"Portails README · {ws} — {WS_RD} dit {'/'.join(mrd.groups())} MAIS "
|
||
f"MANIFEST dit {'/'.join(exp)} ({src}) → régénérer le README")
|
||
# 2) Prose de la fiche agent : `<ws>` C cartes/L liens/R rôles (ou C/L/R)
|
||
fi_pat = (rf"`{re.escape(ws)}` (\d+)(?: cartes)?/(\d+)(?: liens)?/"
|
||
r"(\d+)(?: rôles)?")
|
||
mfi = re.search(fi_pat, ws_fi)
|
||
if mfi is None:
|
||
bad(f"Portails fiche · {ws} — claim INTROUVABLE dans {WS_FI} (attendu {src})")
|
||
elif mfi.groups() == exp:
|
||
good(f"Portails fiche · {ws} — {'/'.join(mfi.groups())} == MANIFEST ({src})")
|
||
else:
|
||
bad(f"Portails fiche · {ws} — {WS_FI} dit {'/'.join(mfi.groups())} MAIS "
|
||
f"MANIFEST dit {'/'.join(exp)} ({src}) → régénérer la fiche")
|
||
|
||
# Total de workspaces (« N `Workspace` natifs ») cohérent artefact ↔ fiche.
|
||
n_ws = len(ws_list)
|
||
if n_ws:
|
||
mtot = re.search(r"\*\*(\d+) `Workspace` natifs", ws_fi)
|
||
if mtot is None:
|
||
bad(f"Portails fiche · total — « N `Workspace` natifs » INTROUVABLE "
|
||
f"dans {WS_FI} (attendu {n_ws})")
|
||
elif int(mtot.group(1)) == n_ws:
|
||
good(f"Portails fiche · total — {mtot.group(1)} == MANIFEST ({n_ws} workspaces)")
|
||
else:
|
||
bad(f"Portails fiche · total — {WS_FI} dit {mtot.group(1)} MAIS "
|
||
f"MANIFEST dit {n_ws} → régénérer la fiche")
|
||
|
||
# ============================================================================
|
||
# RBAC · comptes des 3 volets (roleprofile · apply_plan · userperm) —
|
||
# même classe de « vert trompeur », recopiés À LA MAIN depuis les MANIFEST.
|
||
# ----------------------------------------------------------------------------
|
||
# Les trois générateurs RBAC émettent, PAR volet, un out/MANIFEST.json recalculé
|
||
# à chaque build depuis le contrat `rbac_50_roles.json` (byte-gaté par
|
||
# check_artifacts ⇒ source faisant autorité). Trois READMEs de module rédigent à
|
||
# LA MAIN des comptes qui dérivent de ces manifestes, sans AUCUN gate :
|
||
# 1) roleprofile_gen/README.md — table « Nb rôles » PAR profil (6 lignes) +
|
||
# total. Grain le plus fin (par item), comme la colonne « Tests » des fiches.
|
||
# 2) apply_plan/README.md — ligne AGRÉGÉE « Génération réelle : 6 étapes · 50
|
||
# rôles / 116 DocPerm / 28 UP templates / 6 Role Profile · confirmations VPS
|
||
# 4 DocType custom + 5 Company + 4 rôles equipe » : 8 comptes consolidés de
|
||
# TOUS les volets en une phrase.
|
||
# 3) userperm_gen/README.md — « Répartition : 28 entite · 16 groupe · 2 own ·
|
||
# 4 equipe » : la ventilation par mécanisme d'enforcement.
|
||
# Ajouter un rôle/DocType au contrat ferait dériver LES TROIS docs en silence
|
||
# pendant que les MANIFEST (byte-gatés) disent autre chose — même piège que les
|
||
# cellules « Tests », la prose agrégée CRM/RBAC et les triplets par workspace. On
|
||
# RECOMPUTE ici chaque nombre depuis le MANIFEST du volet (zéro duplication des
|
||
# compteurs des générateurs) et on exige l'égalité avec ce que la doc ÉCRIT. Un
|
||
# claim absent échoue AUSSI (disparition = régression de traçabilité).
|
||
# ============================================================================
|
||
def read_norm(path):
|
||
try:
|
||
return re.sub(r"\s+", " ", open(path, encoding="utf-8").read())
|
||
except OSError as e:
|
||
bad(f"RBAC · {path} illisible : {e}"); return None
|
||
|
||
# 1) roleprofile_gen — table « Nb rôles » par profil + total bijectif -----------
|
||
RP_RD = f"{D}/rbac/roleprofile_gen/README.md"
|
||
try:
|
||
rp_man = load("rbac/roleprofile_gen/out/MANIFEST.json")
|
||
except (OSError, KeyError, ValueError) as e:
|
||
bad(f"RBAC roleprofile · MANIFEST illisible : {e}"); rp_man = None
|
||
rp_rd = read_norm(RP_RD)
|
||
if rp_man is not None and rp_rd is not None:
|
||
for p in rp_man["profiles"]:
|
||
name, portail, exp = p["role_profile"], p["portail"], str(p["nb_roles"])
|
||
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")
|
||
|
||
# ============================================================================
|
||
# Audit 5D (2e surface du MÊME README) · ÉNUMÉRATION des open items du « Verdict
|
||
# courant » (control → dimension → propriétaire) — même classe de « vert trompeur ».
|
||
# ----------------------------------------------------------------------------
|
||
# Le bloc précédent gate le COMPTE de la ventilation (« 13 PASS · 0 FAIL · 4 à
|
||
# confirmer », pass/fail/à confirmer recomputés d'audit_report.totals) mais PAS
|
||
# l'IDENTITÉ des 4 contrôles ouverts ni leur (dimension, propriétaire). La table
|
||
# « Verdict courant » du README (05_deliverables_mvp/qa/audit_5d/README.md) énumère
|
||
# ces 4 lignes À LA MAIN, alors que audit_report.open_items[] (byte-gaté par
|
||
# check_artifacts ⇒ faisant autorité, chaque item = {control, dimension, owner,
|
||
# detail} recalculé en rejouant les contrôles) fait foi. Un ÉCHANGE d'open item
|
||
# (ex. D2.3 → D3.1) laisse le compte à 4 — le bloc de ventilation reste AVEUGLE —
|
||
# pendant que l'artefact dit autre chose ; idem une dimension mal étiquetée
|
||
# (D1.3 rangé sous D2) ou un propriétaire réattribué. « Vert trompeur » qu'aucune
|
||
# suite tests/ (qui teste des FONCTIONS, pas la prose) n'attrape. On RECOMPUTE
|
||
# l'ensemble {control:(dimension, owner)} depuis open_items (zéro duplication du
|
||
# modèle de l'auditeur · #6) et on exige que la table README l'énumère EXACTEMENT :
|
||
# contrôle par ENSEMBLE (absent ET en trop mordus, pas seulement présence), puis
|
||
# (dimension, propriétaire) par LIGNE. Même patron que l'énumération des
|
||
# confirmations DevOps ou la carte de renormalisation par archétype 4Big. Cohérence
|
||
# croisée en bonus : l'ensemble des `control` == MANIFEST.open_items (le manifeste
|
||
# qui résume le rapport) — mord un manifeste désynchronisé de son propre rapport.
|
||
# ============================================================================
|
||
try:
|
||
a5_oi = load("qa/audit_5d/out/audit_report.json")["open_items"]
|
||
a5_oi_exp = {o["control"]: (o["dimension"], o["owner"]) for o in a5_oi}
|
||
a5_oi_man = set(load("qa/audit_5d/out/MANIFEST.json")["open_items"])
|
||
if a5_oi_man != set(a5_oi_exp):
|
||
bad(f"Audit 5D · incohérence artefacts : MANIFEST open_items {sorted(a5_oi_man)} "
|
||
f"MAIS audit_report open_items {sorted(a5_oi_exp)}")
|
||
except (OSError, KeyError, ValueError) as e:
|
||
bad(f"Audit 5D · open_items illisible/inattendu : {e}"); a5_oi_exp = None
|
||
if a5_oi_exp is not None:
|
||
try:
|
||
a5_lines = open(f"{D}/qa/audit_5d/README.md",
|
||
encoding="utf-8").read().splitlines()
|
||
except OSError as e:
|
||
bad(f"Audit 5D · README illisible : {e}"); a5_lines = None
|
||
if a5_lines is not None:
|
||
# ligne de table = « | <control> · <libellé> | <dimension> | <propriétaire> | »
|
||
a5_row_re = re.compile(
|
||
r"^\|\s*(D\d+\.\d+)\b[^|]*\|\s*([^|]+?)\s*\|\s*([^|]+?)\s*\|\s*$")
|
||
a5_seen = {}
|
||
for ln in a5_lines:
|
||
m = a5_row_re.match(ln)
|
||
if m:
|
||
a5_seen[m.group(1)] = (m.group(2).strip(), m.group(3).strip())
|
||
a5_exp_set, a5_got_set = set(a5_oi_exp), set(a5_seen)
|
||
if a5_got_set != a5_exp_set:
|
||
bad(f"Audit 5D · énumération open items — table README énumère "
|
||
f"{sorted(a5_got_set)} MAIS audit_report dit {sorted(a5_exp_set)} "
|
||
f"→ régénérer le README")
|
||
else:
|
||
a5_mism = [c for c in sorted(a5_exp_set) if a5_seen[c] != a5_oi_exp[c]]
|
||
if a5_mism:
|
||
for c in a5_mism:
|
||
bad(f"Audit 5D · open item {c} — README dit "
|
||
f"(dimension={a5_seen[c][0]}, propriétaire={a5_seen[c][1]}) MAIS "
|
||
f"audit_report dit (dimension={a5_oi_exp[c][0]}, "
|
||
f"propriétaire={a5_oi_exp[c][1]})")
|
||
else:
|
||
good(f"Audit 5D · énumération open items {sorted(a5_exp_set)} "
|
||
f"(control→dimension→propriétaire) == audit_report.open_items")
|
||
|
||
# ============================================================================
|
||
# DevOps · run-book de déploiement VPS (phases ordonnées / confirmations
|
||
# préalables) — même classe de « vert trompeur », transcrite À LA MAIN dans
|
||
# DEUX docs, sans AUCUN gate.
|
||
# ----------------------------------------------------------------------------
|
||
# Le générateur deploy_runbook émet out/MANIFEST.json (byte-gaté par
|
||
# check_artifacts ⇒ faisant autorité) dont `counts.phases` et
|
||
# `counts.confirmations` sont RECOMPTÉS à chaque build (phases = |graph.phase_order| ·
|
||
# confirmations = |confirmations_ouvertes|, l'ensemble consolidé des paramètres
|
||
# réglementaires non confirmés). Deux nombres de cette synthèse sont recopiés À LA
|
||
# MAIN : le compte de PHASES dans la fiche agent DevOps (03_agents/devops/AGENT.md —
|
||
# « graphe de **7 phases ordonnées** » ET, plus bas, « exécute les 7 phases sur le
|
||
# VPS ») et le compte de CONFIRMATIONS dans le README du module
|
||
# (05_deliverables_mvp/devops/deploy_runbook/README.md — « renseigner les 7
|
||
# confirmations »). Piège « aveugle » caractéristique (cf. bascule verdict audit_5d,
|
||
# compensation SEO entre langues) : phases ET confirmations valent 7 AUJOURD'HUI —
|
||
# un gate qui ne recompterait qu'UN nombre serait AVEUGLE à la dérive de l'autre
|
||
# (ajouter une phase au graphe → 8 phases pendant que les confirmations restent 7,
|
||
# ou l'inverse). On RECOMPUTE donc CHAQUE nombre depuis SON PROPRE champ du MANIFEST
|
||
# (jamais figé ici · zéro duplication du compteur du générateur #5) et on exige
|
||
# l'égalité avec les docs. Bonus anti-dérive : l'ÉNUMÉRATION des confirmations du
|
||
# README (les tokens `snake_case` de la phrase « Confirmations préalables VPS »)
|
||
# doit être l'ensemble EXACT de confirmations_ouvertes — capte l'ÉCHANGE d'un nom de
|
||
# confirmation (count aveugle à un swap 1↔1). Cohérence croisée : counts.phases ==
|
||
# |phase_order| · counts.confirmations == |confirmations_ouvertes|. Un claim absent
|
||
# échoue AUSSI (la disparition d'un chiffre est une régression · traçabilité).
|
||
# ============================================================================
|
||
DR_RD = f"{D}/devops/deploy_runbook/README.md"
|
||
DR_FI = "03_agents/devops/AGENT.md"
|
||
try:
|
||
dr_man = load("devops/deploy_runbook/out/MANIFEST.json")
|
||
dr_ph = str(dr_man["counts"]["phases"])
|
||
dr_cf = str(dr_man["counts"]["confirmations"])
|
||
dr_order = dr_man["graph"]["phase_order"]
|
||
dr_open = dr_man["confirmations_ouvertes"]
|
||
if dr_ph != str(len(dr_order)):
|
||
bad(f"DevOps · incohérence artefact : counts.phases={dr_ph} MAIS "
|
||
f"|graph.phase_order|={len(dr_order)}")
|
||
if dr_cf != str(len(dr_open)):
|
||
bad(f"DevOps · incohérence artefact : counts.confirmations={dr_cf} MAIS "
|
||
f"|confirmations_ouvertes|={len(dr_open)}")
|
||
except (OSError, KeyError, ValueError) as e:
|
||
bad(f"DevOps · artefact deploy_runbook illisible/inattendu : {e}"); dr_ph = None
|
||
if dr_ph is not None:
|
||
# 1) Fiche agent DevOps — « graphe de N phases ordonnées »
|
||
dr_fi = read_norm(DR_FI)
|
||
if dr_fi is not None:
|
||
m = re.search(r"graphe de \*\*(\d+) phases ordonnées\*\*", dr_fi)
|
||
if m is None:
|
||
bad(f"DevOps · {DR_FI} — claim « graphe de N phases ordonnées » INTROUVABLE "
|
||
f"(attendu phases={dr_ph})")
|
||
elif m.group(1) == dr_ph:
|
||
good(f"DevOps · {DR_FI} — {m.group(1)} phases == artefact (phases={dr_ph})")
|
||
else:
|
||
bad(f"DevOps · {DR_FI} — dit {m.group(1)} phases MAIS artefact dit {dr_ph} "
|
||
f"→ régénérer la fiche")
|
||
# 2) Fiche agent DevOps — 2e transcription « exécute les N phases »
|
||
m = re.search(r"exécute les (\d+) phases", dr_fi)
|
||
if m is None:
|
||
bad(f"DevOps · {DR_FI} — 2e claim « exécute les N phases » INTROUVABLE "
|
||
f"(attendu phases={dr_ph})")
|
||
elif m.group(1) == dr_ph:
|
||
good(f"DevOps · {DR_FI} — « exécute les {m.group(1)} phases » == artefact "
|
||
f"(phases={dr_ph})")
|
||
else:
|
||
bad(f"DevOps · {DR_FI} — 2e claim dit {m.group(1)} phases MAIS artefact dit "
|
||
f"{dr_ph} → régénérer la fiche")
|
||
# 3) README module — « renseigner les N confirmations » (compte)
|
||
dr_rd = read_norm(DR_RD)
|
||
if dr_rd is not None:
|
||
m = re.search(r"renseigner les (\d+) confirmations", dr_rd)
|
||
if m is None:
|
||
bad(f"DevOps · {DR_RD} — claim « renseigner les N confirmations » INTROUVABLE "
|
||
f"(attendu confirmations={dr_cf})")
|
||
elif m.group(1) == dr_cf:
|
||
good(f"DevOps · {DR_RD} — {m.group(1)} confirmations == artefact "
|
||
f"(confirmations={dr_cf})")
|
||
else:
|
||
bad(f"DevOps · {DR_RD} — dit {m.group(1)} confirmations MAIS artefact dit "
|
||
f"{dr_cf} → régénérer le README")
|
||
# 4) README module — l'ÉNUMÉRATION doit être l'ensemble EXACT des confirmations
|
||
seg = re.search(r"Confirmations préalables VPS\*\* \(consolidées, sourcées\) : "
|
||
r"(.+?) ## ", dr_rd)
|
||
if seg is None:
|
||
bad(f"DevOps · {DR_RD} — énumération « Confirmations préalables VPS » "
|
||
f"INTROUVABLE (attendu {sorted(dr_open)})")
|
||
else:
|
||
listed = sorted(set(re.findall(r"`([a-z_]+)`", seg.group(1))))
|
||
if listed == sorted(dr_open):
|
||
good(f"DevOps · {DR_RD} — énumération ({len(listed)}) == confirmations_ouvertes")
|
||
else:
|
||
bad(f"DevOps · {DR_RD} — énumération {listed} ≠ confirmations_ouvertes "
|
||
f"{sorted(dr_open)} → régénérer le README")
|
||
|
||
# ============================================================================
|
||
# Démo · run-sheet de pitch (nombre de modules cités) — même classe de « vert
|
||
# trompeur », transcrit À LA MAIN dans le diagramme d'architecture du README du
|
||
# module, sans AUCUN gate.
|
||
# ----------------------------------------------------------------------------
|
||
# Le générateur demo_scenario_gen émet out/MANIFEST.json (byte-gaté par
|
||
# check_artifacts ⇒ faisant autorité) dont `counts.modules_cites_uniques` est
|
||
# RECOMPTÉ à chaque build = |union des `modules_cites` des deux scénarios| — les
|
||
# hand-off amont que les beats citent par pointeur RFC 6901 (le module ne saisit
|
||
# AUCUNE valeur métier ; il résout depuis le disque). Ce nombre est recopié À LA
|
||
# MAIN dans le diagramme du README du module (05_deliverables_mvp/demo/scenarios/
|
||
# README.md — « out/ des modules livrés (10 modules cités) »). Aucun gate ne le
|
||
# couvrait : ajouter un beat qui cite un module encore non tissé (11) ou retirer
|
||
# la dernière citation d'un module d'un scénario (9) ferait dériver le diagramme
|
||
# en silence pendant que le MANIFEST byte-gaté dit autre chose — dérive qu'aucune
|
||
# suite tests/ (qui teste des FONCTIONS, pas la prose du README) n'attrape. Même
|
||
# piège que les mots-clés SEO, les triplets par workspace des portails, le
|
||
# quintuplet CONFOTUR ou la synthèse audit_5d. On RECOMPUTE depuis
|
||
# counts.modules_cites_uniques (jamais figé ici · zéro duplication du compteur du
|
||
# générateur #5) et on exige l'égalité. Cohérence croisée en bonus :
|
||
# counts.modules_cites_uniques == |modules_cites| (liste dédupliquée du manifeste,
|
||
# elle-même sans doublon) — mord un compteur désynchronisé de sa propre liste. Un
|
||
# claim absent échoue AUSSI (la disparition d'un chiffre est une régression).
|
||
# ============================================================================
|
||
DEMO_RD = f"{D}/demo/scenarios/README.md"
|
||
try:
|
||
demo_man = load("demo/scenarios/out/MANIFEST.json")
|
||
demo_n = str(demo_man["counts"]["modules_cites_uniques"])
|
||
demo_list = demo_man["modules_cites"]
|
||
if len(demo_list) != len(set(demo_list)):
|
||
bad(f"Démo · incohérence artefact : modules_cites contient des doublons "
|
||
f"({len(demo_list)} entrées, {len(set(demo_list))} uniques)")
|
||
if demo_n != str(len(demo_list)):
|
||
bad(f"Démo · incohérence artefact : counts.modules_cites_uniques={demo_n} "
|
||
f"MAIS |modules_cites|={len(demo_list)}")
|
||
except (OSError, KeyError, ValueError) as e:
|
||
bad(f"Démo · artefact scenarios illisible/inattendu : {e}"); demo_n = None
|
||
if demo_n is not None:
|
||
demo_rd = read_norm(DEMO_RD)
|
||
if demo_rd is not None:
|
||
m = re.search(r"out/ des modules livrés \((\d+) modules cités\)", demo_rd)
|
||
if m is None:
|
||
bad(f"Démo · {DEMO_RD} — claim « (N modules cités) » INTROUVABLE "
|
||
f"(attendu modules_cites_uniques={demo_n})")
|
||
elif m.group(1) == demo_n:
|
||
good(f"Démo · {DEMO_RD} — {m.group(1)} modules cités == artefact "
|
||
f"(modules_cites_uniques={demo_n})")
|
||
else:
|
||
bad(f"Démo · {DEMO_RD} — dit {m.group(1)} modules cités MAIS artefact dit "
|
||
f"{demo_n} → régénérer le README")
|
||
|
||
# ============================================================================
|
||
# Chat OTOIA · montage de l'assistant conversationnel par portail (nombre de
|
||
# Custom Block / configs runtime / portails) — même classe de « vert trompeur »,
|
||
# transcrit À LA MAIN dans DEUX docs, sans AUCUN gate.
|
||
# ----------------------------------------------------------------------------
|
||
# Le générateur chat_otoia_gen émet out/MANIFEST.json (byte-gaté par
|
||
# check_artifacts ⇒ faisant autorité) dont `counts.custom_blocks`, `counts.mounts`
|
||
# et `counts.portails` sont RECOMPTÉS à chaque build depuis le contrat RBAC (un
|
||
# `Custom Block` ⇔ une config runtime ⇔ un portail métier, `plateforme` exclu —
|
||
# invariant 2 du générateur : bijection 1 block ⇔ 1 mount ⇔ 1 portail). Ces
|
||
# nombres sont recopiés À LA MAIN : la fiche agent Frontend Console
|
||
# (03_agents/frontend_console/AGENT.md — « **N `Custom Block` + M configs
|
||
# runtime** ») ET le README du module (05_deliverables_mvp/frontend/chat_otoia/
|
||
# README.md — « Portails (N, `plateforme` exclu) », « N fixtures `Custom Block` »,
|
||
# « M configs runtime »). Piège « aveugle » caractéristique (cf. DevOps phases ⁄
|
||
# confirmations, bascule verdict audit_5d) : custom_blocks, mounts ET portails
|
||
# valent TOUS TROIS 5 AUJOURD'HUI — un gate qui n'en recompterait qu'un serait
|
||
# AVEUGLE à la dérive des autres (ajouter un portail au contrat RBAC → chat_otoia
|
||
# rebâtit 6 blocks ⁄ 6 mounts ⁄ 6 portails pendant que les docs restent à 5). On
|
||
# RECOMPUTE donc CHAQUE nombre depuis SON PROPRE champ du MANIFEST (jamais figé
|
||
# ici · zéro duplication du compteur du générateur #5) et on exige l'égalité avec
|
||
# les docs. Cohérence croisée : counts.custom_blocks == counts.mounts ==
|
||
# counts.portails == |MANIFEST.portails| (la bijection du générateur) — mord un
|
||
# compteur désynchronisé de sa propre liste. Un claim absent échoue AUSSI (la
|
||
# disparition d'un chiffre est une régression · traçabilité).
|
||
# ============================================================================
|
||
CO_RD = f"{D}/frontend/chat_otoia/README.md"
|
||
CO_FI = "03_agents/frontend_console/AGENT.md"
|
||
try:
|
||
co_man = load("frontend/chat_otoia/out/MANIFEST.json")
|
||
co_cb = str(co_man["counts"]["custom_blocks"])
|
||
co_mo = str(co_man["counts"]["mounts"])
|
||
co_po = str(co_man["counts"]["portails"])
|
||
co_nlist = len(co_man["portails"])
|
||
if not (co_cb == co_mo == co_po == str(co_nlist)):
|
||
bad(f"Chat OTOIA · incohérence artefact : custom_blocks={co_cb} · "
|
||
f"mounts={co_mo} · portails={co_po} · |portails|={co_nlist} "
|
||
f"(bijection 1 block ⇔ 1 mount ⇔ 1 portail attendue)")
|
||
except (OSError, KeyError, ValueError) as e:
|
||
bad(f"Chat OTOIA · artefact chat_otoia illisible/inattendu : {e}"); co_cb = None
|
||
if co_cb is not None:
|
||
# 1) Fiche agent Frontend Console — « N `Custom Block` + M configs runtime »
|
||
co_fi = read_norm(CO_FI)
|
||
if co_fi is not None:
|
||
m = re.search(r"\*\*(\d+) `Custom Block` \+ (\d+) configs runtime\*\*", co_fi)
|
||
if m is None:
|
||
bad(f"Chat OTOIA · {CO_FI} — claim « N `Custom Block` + M configs runtime » "
|
||
f"INTROUVABLE (attendu {co_cb}/{co_mo})")
|
||
elif m.groups() == (co_cb, co_mo):
|
||
good(f"Chat OTOIA · {CO_FI} — {'/'.join(m.groups())} == artefact "
|
||
f"(custom_blocks={co_cb} · mounts={co_mo})")
|
||
else:
|
||
bad(f"Chat OTOIA · {CO_FI} — dit {'/'.join(m.groups())} MAIS artefact dit "
|
||
f"{co_cb}/{co_mo} → régénérer la fiche")
|
||
# 2) README module — « Portails (N, `plateforme` exclu) »
|
||
co_rd = read_norm(CO_RD)
|
||
if co_rd is not None:
|
||
m = re.search(r"Portails \((\d+), `plateforme` exclu\)", co_rd)
|
||
if m is None:
|
||
bad(f"Chat OTOIA · {CO_RD} — claim « Portails (N, `plateforme` exclu) » "
|
||
f"INTROUVABLE (attendu portails={co_po})")
|
||
elif m.group(1) == co_po:
|
||
good(f"Chat OTOIA · {CO_RD} — {m.group(1)} portails == artefact "
|
||
f"(portails={co_po})")
|
||
else:
|
||
bad(f"Chat OTOIA · {CO_RD} — dit {m.group(1)} portails MAIS artefact dit "
|
||
f"{co_po} → régénérer le README")
|
||
# 3) README module — « N fixtures `Custom Block` »
|
||
m = re.search(r"(\d+) fixtures `Custom Block`", co_rd)
|
||
if m is None:
|
||
bad(f"Chat OTOIA · {CO_RD} — claim « N fixtures `Custom Block` » INTROUVABLE "
|
||
f"(attendu custom_blocks={co_cb})")
|
||
elif m.group(1) == co_cb:
|
||
good(f"Chat OTOIA · {CO_RD} — {m.group(1)} fixtures == artefact "
|
||
f"(custom_blocks={co_cb})")
|
||
else:
|
||
bad(f"Chat OTOIA · {CO_RD} — dit {m.group(1)} fixtures MAIS artefact dit "
|
||
f"{co_cb} → régénérer le README")
|
||
# 4) README module — « M configs runtime » (2e transcription des mounts)
|
||
m = re.search(r"(\d+) configs runtime", co_rd)
|
||
if m is None:
|
||
bad(f"Chat OTOIA · {CO_RD} — claim « M configs runtime » INTROUVABLE "
|
||
f"(attendu mounts={co_mo})")
|
||
elif m.group(1) == co_mo:
|
||
good(f"Chat OTOIA · {CO_RD} — {m.group(1)} configs runtime == artefact "
|
||
f"(mounts={co_mo})")
|
||
else:
|
||
bad(f"Chat OTOIA · {CO_RD} — dit {m.group(1)} configs runtime MAIS artefact "
|
||
f"dit {co_mo} → régénérer le README")
|
||
|
||
# ============================================================================
|
||
# Recette (module) · couple « N livrables de sprint + M métriques succès MVP »
|
||
# transcrit À LA MAIN dans DEUX docs — même classe de « vert trompeur ».
|
||
# ----------------------------------------------------------------------------
|
||
# La matrice d'acceptation (qa/acceptance/out/acceptance_matrix.json, byte-gatée
|
||
# par check_artifacts ⇒ faisant autorité) porte une ligne par promesse roadmap,
|
||
# de `kind` sprint_deliverable OU mvp_metric. Le compte de chaque genre (n_sprint,
|
||
# n_metric déjà recalculés plus haut depuis la matrice, jamais saisis) est repris
|
||
# À LA MAIN à DEUX endroits que le bloc racine ne couvre PAS : le README du module
|
||
# (05_deliverables_mvp/qa/acceptance/README.md — puces « N livrables de sprint »
|
||
# puis « M métriques succès MVP ») ET la fiche agent QA (03_agents/qa/AGENT.md —
|
||
# « (N livrables + M métriques) »). Le bloc racine plus haut ne gate que le SEUL
|
||
# README d'entrée (« N promesses (X sprint + Y métriques) ») ; ces deux docs de
|
||
# module en dérivent sans gate. Ajouter une promesse roadmap (l'artefact rebâtit
|
||
# 9/7) ferait dériver LES DEUX docs en silence pendant que la matrice dit autre
|
||
# chose — piège que ni les suites tests/ (qui testent des FONCTIONS, pas la prose)
|
||
# ni le roadmap-anchor (qui ancre les `roadmap_line` de l'ARTEFACT, pas la prose
|
||
# des docs) n'attrapent. On RECOMPUTE ici chaque nombre depuis SON PROPRE genre de
|
||
# la matrice (jamais figé · zéro duplication #5) et on exige l'égalité avec les
|
||
# DEUX docs. Recomputer les DEUX (sprint ET métriques) mord une dérive de l'un là
|
||
# où l'autre resterait aveugle. Cohérence croisée en bonus :
|
||
# MANIFEST.counts.{sprint_deliverables,mvp_metrics} == (n_sprint, n_metric) — mord
|
||
# un compteur du manifeste désynchronisé de la matrice qu'il résume. Un claim
|
||
# absent échoue AUSSI (la disparition d'un chiffre est une régression · traçabilité).
|
||
# ============================================================================
|
||
ACC_RD = f"{D}/qa/acceptance/README.md"
|
||
ACC_FI = "03_agents/qa/AGENT.md"
|
||
acc_exp = (str(n_sprint), str(n_metric))
|
||
acc_src = f"livrables_sprint={acc_exp[0]} métriques={acc_exp[1]}"
|
||
try:
|
||
acc_mc = load("qa/acceptance/out/MANIFEST.json")["counts"]
|
||
if (str(acc_mc["sprint_deliverables"]), str(acc_mc["mvp_metrics"])) != acc_exp:
|
||
bad(f"Recette · incohérence artefact : MANIFEST.counts dit "
|
||
f"{acc_mc['sprint_deliverables']}/{acc_mc['mvp_metrics']} MAIS la matrice "
|
||
f"dit {'/'.join(acc_exp)} (compteur désynchronisé de sa liste)")
|
||
except (OSError, KeyError, ValueError) as e:
|
||
bad(f"Recette · MANIFEST acceptance illisible/inattendu : {e}")
|
||
# 1) README du module — deux puces distinctes (livrables sprint · métriques MVP)
|
||
acc_rd = read_norm(ACC_RD)
|
||
if acc_rd is not None:
|
||
ms = re.search(r"\*\*(\d+) livrables de sprint\*\*", acc_rd)
|
||
mm = re.search(r"\*\*(\d+) métriques succès MVP\*\*", acc_rd)
|
||
if ms is None or mm is None:
|
||
bad(f"Recette · {ACC_RD} — puces « N livrables de sprint » / « M métriques "
|
||
f"succès MVP » INTROUVABLE(s) (attendu {acc_src})")
|
||
elif (ms.group(1), mm.group(1)) == acc_exp:
|
||
good(f"Recette · {ACC_RD} — {ms.group(1)}/{mm.group(1)} == matrice ({acc_src})")
|
||
else:
|
||
bad(f"Recette · {ACC_RD} — dit {ms.group(1)}/{mm.group(1)} MAIS matrice dit "
|
||
f"{'/'.join(acc_exp)} ({acc_src}) → régénérer le README")
|
||
# 2) Fiche agent QA — parenthèse « (N livrables + M métriques) »
|
||
acc_fi = read_norm(ACC_FI)
|
||
if acc_fi is not None:
|
||
m = re.search(r"\((\d+) livrables \+ (\d+) métriques\)", acc_fi)
|
||
if m is None:
|
||
bad(f"Recette · {ACC_FI} — claim « (N livrables + M métriques) » INTROUVABLE "
|
||
f"(attendu {acc_src})")
|
||
elif m.groups() == acc_exp:
|
||
good(f"Recette · {ACC_FI} — {'/'.join(m.groups())} == matrice ({acc_src})")
|
||
else:
|
||
bad(f"Recette · {ACC_FI} — dit {'/'.join(m.groups())} MAIS matrice dit "
|
||
f"{'/'.join(acc_exp)} ({acc_src}) → régénérer la fiche")
|
||
|
||
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"
|