#!/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) # 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//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 : | | `` | 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 : `` 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") 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"