#!/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) # fiscal/ecf_dgii/README.md (×3) + 03_agents/erpnext_backend/AGENT.md (format e-NCF) : # • le CONTRAT DE FORMAT de l'e-NCF « E + tipoeCF(2) + secuencia(10) = 13 » # (préfixe · longueur segment tipo · longueur segment secuencia · longueur # totale) transcrit À LA MAIN dans QUATRE endroits de prose → recompté depuis # le SEUL bloc `e_ncf` d'ecf_plan.json (prefix/longueur/regex/structure, byte- # gatés) + cohérences croisées (1+tipo+seq == longueur · |regex digits| == # tipo+seq — mordent un artefact internement incohérent) # 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, unicodedata 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") # ============================================================================ # RBAC · le COMPTE CANONIQUE de rôles ANCRÉ sur CLAUDE.md §Architecture cible # (« RBAC N rôles ») — classe claude-md-constant-anchor, restée NON ancrée. # ---------------------------------------------------------------------------- # CLAUDE.md §Architecture cible FIXE le mandat « RBAC 50 rôles ». Ce nombre # canonique est recopié SANS ancrage vers CLAUDE.md dans : le contrat # rbac_50_roles.json (`cible_rbac_roles` ET la taille réelle du catalogue # `len(roles)`), les schémas (`const: 50`) et la fiche agent erpnext_backend # (« RBAC 50 rôles natif » · « **50 rôles** » · « RBAC **50 rôles** exactement »). # Les gates RBAC amont ne prouvent que la COHÉRENCE INTERNE (les READMEs # apply_plan/userperm/roleprofile == leur MANIFEST, lui-même recompté du contrat) # — AVEUGLES à l'ancrage au MANDAT : changer « RBAC 50 rôles » dans CLAUDE.md, ou # faire dévier le catalogue de 50 de façon INTERNE-cohérente (cible ET len alignés # sur une autre valeur), laisserait le contrat et la fiche affirmer « 50 exactement » # en CONTREDISANT le mandat, en silence. On RE-DÉRIVE N de CLAUDE.md §Architecture # cible (source unique) et on exige : (a) contrat cible_rbac_roles == N, (b) taille # réelle len(roles) == N, (c) cible == réel (le mandat est ATTEINT par le catalogue), # (d) la fiche erpnext_backend porte l'invariant emphatique « RBAC **N rôles** # exactement » ET aucune mention « M rôles » périmée (M != N). Même patron que les # tokens branding #4 ou la persona OTOIA §Architecture cible ; un claim absent # échoue AUSSI (traçabilité #6). # ============================================================================ try: _cm = open("CLAUDE.md", encoding="utf-8").read() except OSError as e: bad(f"RBAC compte · CLAUDE.md illisible : {e}"); _cm = "" _arch = re.search(r"## Architecture cible\n(.*?)(?:\n## |\Z)", _cm, re.S) m_n = re.search(r"RBAC\s+(\d+)\s+rôles", _arch.group(1)) if _arch else None if m_n is None: bad("RBAC compte · « RBAC N rôles » INTROUVABLE dans CLAUDE.md §Architecture " "cible → l'ancre du mandat a disparu") else: N = int(m_n.group(1)) good(f"RBAC compte · ancre CLAUDE.md §Architecture cible = « RBAC {N} rôles »") try: _c = load("rbac/rbac_50_roles.json") cible = _c.get("cible_rbac_roles"); actual = len(_c.get("roles", [])) except (OSError, ValueError) as e: bad(f"RBAC compte · contrat rbac_50_roles.json illisible : {e}") cible = actual = None if cible is not None: if cible == N: good(f"RBAC compte · (a) contrat cible_rbac_roles {cible} == CLAUDE.md ({N})") else: bad(f"RBAC compte · (a) contrat cible_rbac_roles {cible} != CLAUDE.md " f"§Architecture cible ({N}) → le contrat contredit le mandat") if actual == N: good(f"RBAC compte · (b) catalogue réel len(roles) {actual} == CLAUDE.md ({N})") else: bad(f"RBAC compte · (b) catalogue réel len(roles) {actual} != CLAUDE.md " f"§Architecture cible ({N}) → le catalogue a dévié du mandat") if cible == actual: good(f"RBAC compte · (c) cible {cible} == catalogue réel {actual} (mandat atteint)") else: bad(f"RBAC compte · (c) cible_rbac_roles {cible} != catalogue réel {actual} " f"→ cible déclarée non tenue par le catalogue") RBC = "03_agents/erpnext_backend/AGENT.md" try: rbc = re.sub(r"\s+", " ", open(RBC, encoding="utf-8").read()) except OSError as e: bad(f"RBAC compte · {RBC} illisible : {e}"); rbc = "" if re.search(rf"RBAC \*\*{N} rôles\*\* exactement", rbc): good(f"RBAC compte · (d) fiche porte « RBAC **{N} rôles** exactement » == CLAUDE.md") else: bad(f"RBAC compte · (d) invariant « RBAC **{N} rôles** exactement » INTROUVABLE " f"dans {RBC} → claim absent = régression #6") role_mentions = [int(x) for x in re.findall(r"(\d+) rôles", rbc)] stale = sorted({m for m in role_mentions if m != N}) if not role_mentions: bad(f"RBAC compte · (d) aucune mention « N rôles » dans {RBC}") elif stale: bad(f"RBAC compte · (d) mention(s) périmée(s) {stale} rôles dans {RBC} MAIS " f"CLAUDE.md dit {N} → resynchroniser la fiche") else: good(f"RBAC compte · (d) {len(role_mentions)} mention(s) « {N} rôles » " f"dans {RBC} == CLAUDE.md (aucune périmée)") # ============================================================================ # 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//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"]) # métier vs technique : le flag DATA-DERIVED (booléen `metier` du MANIFEST) # doit être transcrit EXACTEMENT dans la colonne « Type ». Sans ce gate la # colonne était un WILDCARD (`[^|]*`) → un portail technique classé « métier » # (ou l'inverse) passait « vert » alors que l'artefact dit autre chose. exp_type = "métier" if p["metier"] else "technique" 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 type={exp_type} nb_roles={exp})") else: got_type, got_n = m.group(1).strip(), m.group(2) if got_n == exp: good(f"RBAC roleprofile · {name} — Nb rôles {got_n} == MANIFEST ({exp})") else: bad(f"RBAC roleprofile · {name} — {RP_RD} dit {got_n} MAIS " f"MANIFEST dit {exp} → régénérer le README") if got_type == exp_type: good(f"RBAC roleprofile · {name} — Type « {got_type} » == MANIFEST " f"(metier={p['metier']})") else: bad(f"RBAC roleprofile · {name} — {RP_RD} classe « {got_type} » MAIS " f"MANIFEST dit « {exp_type} » (metier={p['metier']}) → régénérer") 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") # 1bis) IDENTITÉ de l'ensemble des profils (pas seulement leurs comptes) -------- # La boucle ci-dessus est MANIFEST→README (chaque profil du contrat a une ligne : # capte le MANQUANT). Le sens README→MANIFEST (une ligne FANTÔME dans la table, # profil inventé absent du contrat) reste aveugle → on compte les lignes de profil # de la table et on exige == |profiles|. Ensemble ∩ boucle = identité d'ensemble. exp_prof = len(rp_man["profiles"]) rows = re.findall(r"\| `OTO Portail [^|`]+` \| [^|]+ \| (?:métier|technique) \| \d+ \|", rp_rd) if len(rows) == exp_prof: good(f"RBAC roleprofile · table — {len(rows)} lignes de profil == " f"MANIFEST ({exp_prof}) : aucun profil FANTÔME") else: bad(f"RBAC roleprofile · table — {RP_RD} porte {len(rows)} lignes de profil " f"MAIS MANIFEST en compte {exp_prof} → ligne en trop/manquante ?") # 1ter) prose « N profils (X métier + Y technique) » == comptes MANIFEST -------- # + cohérences croisées : les comptes portails_metier/techniques doivent RECOMPTER # les flags `metier` des profils, et leur somme == role_profiles == |profiles| # (mord un MANIFEST INTERNEMENT incohérent : compte de flag ≠ flags réels). c = rp_man["counts"] cm, ct, cp = c["portails_metier"], c["portails_techniques"], c["role_profiles"] real_m = sum(1 for p in rp_man["profiles"] if p["metier"]) real_t = sum(1 for p in rp_man["profiles"] if not p["metier"]) if cm == real_m and ct == real_t and cm + ct == cp == exp_prof: good(f"RBAC roleprofile · cohérence comptes — portails_metier={cm} + " f"techniques={ct} == role_profiles={cp} == |flags métier réels|") else: bad(f"RBAC roleprofile · cohérence comptes — MANIFEST incohérent : " f"portails_metier={cm} (flags réels={real_m}) · techniques={ct} " f"(réels={real_t}) · role_profiles={cp} · |profiles|={exp_prof}") mp = re.search(r"\*\*(\d+) profils\*\* \((\d+) métier \+ (\d+) technique\)", rp_rd) if mp is None: bad(f"RBAC roleprofile · split métier/technique — prose « N profils " f"(X métier + Y technique) » INTROUVABLE dans {RP_RD} " f"(attendu {cp} profils · {cm} métier + {ct} technique)") elif (mp.group(1), mp.group(2), mp.group(3)) == (str(cp), str(cm), str(ct)): good(f"RBAC roleprofile · split métier/technique — {mp.group(1)} profils " f"({mp.group(2)} métier + {mp.group(3)} technique) == MANIFEST") else: bad(f"RBAC roleprofile · split métier/technique — {RP_RD} dit " f"{mp.group(1)}/{mp.group(2)}/{mp.group(3)} MAIS MANIFEST dit " f"{cp}/{cm}/{ct} → régénérer le README") # 1quater) RBAC_50_ROLES_SPEC.md §3 — table « Nb rôles » PAR portail ------------ # La table §3 « Cartographie portails ↔ familles » transcrit À LA MAIN le nombre de # rôles de CHAQUE portail. C'est une ventilation par portail dans un doc DISTINCT # du MANIFEST roleprofile (gaté ci-dessus) — jamais gaté, et il AVAIT dérivé en # silence : `direction` 10 / `plateforme` 5 alors que le contrat compte 9 / 6, le # total 50 restant préservé (dérive muette classique, #6). On RECOMPTE ici chaque # ligne depuis rbac_50_roles.json (source de vérité, byte-gatée par check_artifacts) # et on exige l'égalité avec ce que la SPEC écrit, total compris. Un portail manquant # de la table échoue aussi (disparition = régression de traçabilité). SPEC_RD = f"{D}/rbac/RBAC_50_ROLES_SPEC.md" try: src_roles = load("rbac/rbac_50_roles.json")["roles"] except (OSError, KeyError, ValueError) as e: bad(f"RBAC SPEC §3 · rbac_50_roles.json illisible : {e}"); src_roles = None spec_rd = read_norm(SPEC_RD) if src_roles is not None and spec_rd is not None: by_portail = {} for r in src_roles: by_portail[r.get("portail")] = by_portail.get(r.get("portail"), 0) + 1 for portail in sorted(by_portail): exp = str(by_portail[portail]) pat = rf"\| \*\*{re.escape(portail)}\*\*[^|]*\| [^|]*\| (\d+) \|" m = re.search(pat, spec_rd) if m is None: bad(f"RBAC SPEC §3 · portail `{portail}` — ligne INTROUVABLE dans " f"{SPEC_RD} (attendu Nb rôles={exp})") elif m.group(1) == exp: good(f"RBAC SPEC §3 · portail `{portail}` — Nb rôles {m.group(1)} == " f"rbac_50_roles.json ({exp})") else: bad(f"RBAC SPEC §3 · portail `{portail}` — {SPEC_RD} dit {m.group(1)} " f"MAIS rbac_50_roles.json compte {exp} → corriger la SPEC") exp_tot = str(len(src_roles)) mt = re.search(r"\*\*Total\*\* \| \*\*(\d+)\*\*", spec_rd) if mt is None: bad(f"RBAC SPEC §3 · total — ligne « Total » INTROUVABLE dans {SPEC_RD} " f"(attendu {exp_tot})") elif mt.group(1) == exp_tot: good(f"RBAC SPEC §3 · total — {mt.group(1)} == rbac_50_roles.json ({exp_tot})") else: bad(f"RBAC SPEC §3 · total — {SPEC_RD} dit {mt.group(1)} MAIS " f"rbac_50_roles.json compte {exp_tot} → corriger la SPEC") # 1quinquies) RBAC_50_ROLES_SPEC.md §7 — comptes du run-book d'application -------- # La NOTE §7 (« ✅ … livré en-repo ») restitue, sur une SURFACE DISTINCTE des READMEs # userperm_gen / roleprofile_gen (déjà gatés), TROIS comptes data-derived du run-book # d'application : « 28 templates `Company` (portée `entite`) », « 6 profils (5 portails # métier + console technique `plateforme`) » et « 50 rôles de façon bijective ». Ces # nombres sont recopiés À LA MAIN dans la SPEC ; le bloc §3 ne gate QUE la ventilation # par portail, et les gates userperm/roleprofile ne regardent QUE leurs propres READMEs # — AVEUGLES à cette note. BUMPER une portée `entite`→`groupe` dans le contrat (le # nombre de `User Permission` templates change), AJOUTER/RETIRER un portail (le nombre # de `Role Profile` change) ou casser la bijection laisse la note PÉRIMÉE en silence # pendant que le contrat dit autre chose → l'agent ERPNext Backend lirait un run-book # qui promet un décompte faux. On RECOMPUTE les 3 comptes depuis rbac_50_roles.json # (source de vérité, byte-gatée par check_artifacts) : 28 == |scope==entite|, 6 == # |portails distincts| (= 1 Role Profile par portail), 5 == portails métier (hors # `plateforme`, la console technique), 50 == |rôles|. Un claim absent échoue AUSSI. if src_roles is not None and spec_rd is not None: # La note §7 vit dans un blockquote (`> `) : les marqueurs survivent au collapse # de read_norm et fragmentent la prose (« 28 > templates »). On les retire pour # ce bloc (aucun `> ` littéral hors blockquote dans cette SPEC ; les flèches sont # des `→`). Le §3 (tables sans blockquote) n'en a pas besoin. spec7 = spec_rd.replace("> ", "") up_entite = sum(1 for r in src_roles if r.get("scope_donnees") == "entite") portails = {r.get("portail") for r in src_roles} metier = portails - {"plateforme"} if "plateforme" not in portails: bad("RBAC SPEC §7 · portail technique `plateforme` ABSENT du contrat " "rbac_50_roles.json → la note « console technique plateforme » est fausse") exp_up = str(up_entite) m_up = re.search(r"(\d+) templates `Company` \(portée `entite`\)", spec7) if m_up is None: bad(f"RBAC SPEC §7 · « N templates Company (portée entite) » INTROUVABLE " f"dans {SPEC_RD} (attendu {exp_up})") elif m_up.group(1) == exp_up: good(f"RBAC SPEC §7 · UP templates {m_up.group(1)} == |scope==entite| " f"rbac_50_roles.json ({exp_up})") else: bad(f"RBAC SPEC §7 · {SPEC_RD} dit {m_up.group(1)} templates Company MAIS " f"rbac_50_roles.json compte {exp_up} rôles `entite` → corriger la SPEC") exp_prof, exp_metier, exp_bij = str(len(portails)), str(len(metier)), str(len(src_roles)) m_pr = re.search( r"\*\*(\d+) profils\*\* \((\d+) portails métier \+ console technique " r"`plateforme`\) couvrant les \*\*(\d+) rôles de façon bijective\*\*", spec7) if m_pr is None: bad(f"RBAC SPEC §7 · « N profils (M portails métier …) couvrant les K rôles " f"de façon bijective » INTROUVABLE dans {SPEC_RD} " f"(attendu {exp_prof}/{exp_metier}/{exp_bij})") elif m_pr.groups() == (exp_prof, exp_metier, exp_bij): good(f"RBAC SPEC §7 · Role Profiles {m_pr.group(1)} (métier {m_pr.group(2)}) " f"bijectifs sur {m_pr.group(3)} rôles == rbac_50_roles.json " f"({exp_prof}/{exp_metier}/{exp_bij})") else: bad(f"RBAC SPEC §7 · {SPEC_RD} dit {'/'.join(m_pr.groups())} MAIS " f"rbac_50_roles.json compte {exp_prof}/{exp_metier}/{exp_bij} " f"(profils=portails distincts / métier=hors plateforme / rôles) " f"→ corriger la SPEC") # 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") # 2bis) apply_plan — IDENTITÉ de la TABLE « Run-book généré » (SPEC §7) ---------- # Le bloc 2 ci-dessus ne gate que la ligne AGRÉGÉE « Génération réelle » (comptes). # La table « ## Run-book généré » énumère, PAR étape, trois données data-derived de # apply_plan.json (byte-gaté par check_artifacts) : le n° d'ordre (#), le # RESPONSABLE (worker/vps/worker+vps) et la colonne « Dépend de » (les n°s d'ordre # des `depends_on`). C'est le GRAPHE D'APPLICATION ORDONNÉ que l'agent ERPNext suit # pas à pas — un responsable ou une dépendance périmés (ex. Role Profile importé # AVANT les Role, ou une étape VPS attribuée au worker) sont un hazard réel, # invisible à check_artifacts (qui ne prouve QUE apply_plan==build) ET au bloc de # comptes (aveugle à l'identité des lignes ; ces colonnes étaient un WILDCARD). # Même classe que la colonne « Type » de roleprofile_gen et la table demo/scenarios. # On RECOMPUTE #/responsable/deps depuis apply_plan.json (zéro duplication) et on # exige que la table les énonce EXACTEMENT. Une étape MANQUANTE ou une ligne FANTÔME # échoue AUSSI (identité d'ensemble). La colonne « Étape » reste libre (paraphrase). # Cohérences croisées en bonus (mordent un plan INTERNEMENT incohérent) : ordres # contigus 1..N sans doublon · responsable ∈ {worker, vps, worker+vps} · toute # dépendance pointe en ARRIÈRE (n° < n° de l'étape ⇒ pas de cycle/renvoi avant). try: ap_plan = load("rbac/apply_plan/out/apply_plan.json") except (OSError, KeyError, ValueError) as e: bad(f"RBAC apply_plan · run-book illisible : {e}"); ap_plan = None if ap_plan is not None: id2order = {s["id"]: s["order"] for s in ap_plan} ALLOWED_RESP = {"worker", "vps", "worker+vps"} orders = sorted(s["order"] for s in ap_plan) if orders == list(range(1, len(ap_plan) + 1)): good(f"RBAC apply_plan · run-book — ordres contigus 1..{len(ap_plan)} " f"(aucun trou/doublon)") else: bad(f"RBAC apply_plan · run-book — ordres NON contigus {orders} " f"(attendu 1..{len(ap_plan)}) → plan incohérent") # Lignes de la table lues en RAW : la colonne 1 ENTIÈRE ⇒ scope la table # run-book (seule table du README à 1ʳᵉ colonne numérique · vérifié). try: ap_lines = open(AP_RD, encoding="utf-8").read().splitlines() except OSError as e: bad(f"RBAC apply_plan · {AP_RD} illisible : {e}"); ap_lines = [] table_orders, rows_by_order = [], {} for ln in ap_lines: cells = [c.strip() for c in ln.strip().strip("|").split("|")] if len(cells) == 4 and re.fullmatch(r"\d+", cells[0]): o = int(cells[0]); table_orders.append(o); rows_by_order[o] = cells if sorted(table_orders) == orders and len(table_orders) == len(set(table_orders)): good(f"RBAC apply_plan · run-book — {len(table_orders)} lignes == " f"{len(ap_plan)} étapes (aucune FANTÔME/MANQUANTE)") else: bad(f"RBAC apply_plan · run-book — lignes table {sorted(table_orders)} ≠ " f"étapes artefact {orders} (ligne en trop/manquante ou doublon)") for s in sorted(ap_plan, key=lambda x: x["order"]): o, resp = s["order"], s["responsable"] deps = sorted(id2order[x] for x in s["depends_on"]) if resp not in ALLOWED_RESP: bad(f"RBAC apply_plan · étape {o} — responsable artefact « {resp} » " f"hors {sorted(ALLOWED_RESP)} (plan incohérent)") if any(d >= o for d in deps): bad(f"RBAC apply_plan · étape {o} — dépendance en AVANT {deps} " f"(≥ {o}) : graphe incohérent") row = rows_by_order.get(o) if row is None: bad(f"RBAC apply_plan · étape {o} — ligne INTROUVABLE dans {AP_RD} " f"(attendu responsable={resp} deps={deps or '—'})"); continue got_resp = row[1] got_deps = sorted(int(x) for x in re.findall(r"\d+", row[3])) if got_resp == resp: good(f"RBAC apply_plan · étape {o} — responsable « {got_resp} » == artefact") else: bad(f"RBAC apply_plan · étape {o} — {AP_RD} dit responsable « {got_resp} » " f"MAIS artefact dit « {resp} » → régénérer le README") if got_deps == deps: good(f"RBAC apply_plan · étape {o} — Dépend de {got_deps or '—'} == artefact") else: bad(f"RBAC apply_plan · étape {o} — {AP_RD} « Dépend de » = {got_deps or '—'} " f"MAIS artefact dit {deps or '—'} → 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") # ============================================================================ # CRM · IDENTITÉ du DocType porteur — le NOM `OTO Dossier Vente` # ---------------------------------------------------------------------------- # Le module dossier_vente émet out/doctype_oto_dossier_vente.json dont le champ # `name` EST le nom du DocType porteur (= MANIFEST.doctype_name), byte-gaté par # check_artifacts ⇒ artefact faisant autorité. Ce NOM est le POINT D'ATTACHE du # pipeline : le Workflow vente s'y branche via son `document_type` # (workflow.json[0].document_type). Le nom est RECOPIÉ À LA MAIN à plusieurs # endroits, AUCUN gaté sur le NOM (le bloc CRM pipeline ci-dessus ne recompute # que le COMPTE d'états, aveugle à l'identité) : # • dossier_vente/README.md — titre « # DocType porteur · `NOM` » ET la ligne # « Nom du DocType = `document_type` du workflow (`NOM`) » # • 03_agents/crm/AGENT.md:25 — « **DocType porteur** `NOM` » # • 03_agents/onapi_legal/AGENT.md:50 — « cible = `workflow.document_type` (`NOM`) » # Piège : RENOMMER le DocType dans doctype_spec.json (`OTO Dossier Vente` → # `OTO Dossier de Vente`) reconstruit les DEUX artefacts de façon COHÉRENTE (le # workflow.document_type suit, les tests — qui testent des FONCTIONS — restent # verts), MAIS les mentions en prose se PÉRIMENT en silence → l'agent ERPNext # Backend importerait un DocType sous un nom pendant que la fiche/le README en # nomment un autre, et le Workflow s'attacherait à un DocType FANTÔME — la # rupture même que « sans lui le pipeline n'a rien à quoi s'attacher » (crm/ # AGENT.md:25) veut prévenir. Aucune suite tests/ (FONCTIONS de graphe, pas la # prose) n'attrape ce « vert trompeur ». On RECOMPUTE le nom depuis l'artefact # (zéro duplication) et on exige que chaque prose le nomme EXACTEMENT (backtické). # Cohérences croisées bonus (mordent un artefact INTERNEMENT incohérent) : # DocType.name == MANIFEST.doctype_name · == workflow.document_type (l'attache # porteur↔workflow). Un claim absent échoue AUSSI (traçabilité #6). # ============================================================================ try: dv_dt = load("crm/dossier_vente/out/doctype_oto_dossier_vente.json") dv_man = load("crm/dossier_vente/out/MANIFEST.json") dt_name = dv_dt["name"] dv_man_name = dv_man["doctype_name"] wf_doc_type = load("crm/workflow_vente/out/workflow.json")[0]["document_type"] except (OSError, KeyError, ValueError, IndexError, TypeError) as e: bad(f"CRM DocType porteur · artefact illisible/inattendu : {e}"); dt_name = None if dt_name: # bonus 1 : cohérence interne du module (DocType.name == MANIFEST.doctype_name) if dv_man_name != dt_name: bad(f"CRM DocType porteur · incohérence artefacts : DocType.name " f"« {dt_name} » MAIS MANIFEST.doctype_name « {dv_man_name} » → régénérer out/") else: good(f"CRM DocType porteur · DocType.name == MANIFEST.doctype_name « {dt_name} »") # bonus 2 : l'attache porteur↔workflow — le Workflow s'y branche par document_type if wf_doc_type != dt_name: bad(f"CRM DocType porteur · attache ROMPUE : workflow.document_type " f"« {wf_doc_type} » ≠ DocType porteur « {dt_name} » → le Workflow " f"s'attacherait à un DocType FANTÔME") else: good(f"CRM DocType porteur · attache workflow.document_type == « {dt_name} » " f"(le pipeline a bien où s'attacher)") # prose : chaque doc doit nommer le DocType EXACTEMENT (backtické) esc = re.escape(dt_name) DV_DOC = [ (DV_RD, rf"# DocType porteur · `{esc}`", "titre dossier_vente"), (DV_RD, rf"Nom du DocType\*\* = `document_type` du workflow \(`{esc}`\)", "ligne « Nom du DocType »"), ("03_agents/crm/AGENT.md", rf"DocType porteur\*\* `{esc}`", "fiche CRM:25"), ("03_agents/onapi_legal/AGENT.md", rf"cible = `workflow\.document_type` \(`{esc}`\)", "fiche onapi_legal:50"), ] for path, pat, label in DV_DOC: txt = read_norm(path) if txt is None: continue if re.search(pat, txt): good(f"CRM DocType porteur · {label} — nomme « {dt_name} » == artefact") else: bad(f"CRM DocType porteur · {label} ({path}) — nom « {dt_name} » " f"INTROUVABLE/périmé → régénérer la doc") # ============================================================================ # CRM (2e surface du MÊME README) · ÉNUMÉRATION des transitions à SÉPARATION # DES POUVOIRS — la phrase « engagent de l'argent ou clôturent — confirmer # réservation, signer contrat, approuver CONFOTUR, annuler/résilier ». # ---------------------------------------------------------------------------- # Le bloc CRM ci-dessus recompute le COMPTE d'états/transitions mais PAS # l'IDENTITÉ des transitions marquées `separation_of_duties`. Or cette liste est # DATA-DERIVED : le générateur émet out/workflow.json (byte-gaté par # check_artifacts ⇒ artefact faisant autorité) dont chaque transition porte # `allow_self_approval` ; l'ensemble des transitions QUATRE-YEUX = {action | # allow_self_approval == 0} — précisément ce que le README énumère À LA MAIN # comme « interdisent l'auto-approbation (`allow_self_approval = 0`) ». Le bloc de # COMPTE reste AVEUGLE au contenu : (a) DÉCLASSER une transition sensible (ex. # « Signer contrat » repasse à allow_self_approval=1 dans le contrat) laisse le # compte d'états/transitions à 9/11 pendant que l'artefact retire l'action de # l'ensemble QUATRE-YEUX — la prose reste à 5 actions FAUSSES ; (b) PROMOUVOIR une # transition (« Déposer CONFOTUR » → self=0) l'ajoute à l'ensemble sans que le # compte bouge, et la prose l'OMET ; (c) RÉORDRE/RENOMMAGE d'action passe aussi — # « vert trompeur » qu'aucune suite tests/ (qui teste des FONCTIONS de graphe, pas # la prose) n'attrape — même patron que l'énumération des onglets Mobile, des open # items de l'audit 5D ou de la carte de renormalisation 4Big. On RECOMPUTE ici # l'ENSEMBLE des actions QUATRE-YEUX depuis workflow.json (zéro duplication du flag # du générateur) et on exige que la phrase l'énumère EXACTEMENT (absent ET en trop # mordus via set-diff). Normalisation : casse ignorée + suffixe « (perdu) » élidé # (les deux transitions vers l'état Perdu — Annuler/Résilier — que la prose groupe # « annuler/résilier »). Cohérence croisée en bonus : 0 < |quatre-yeux| < |trans| # (l'ensemble sensible est NON VIDE — sinon la garde est vacante — et STRICT — sinon # tout le graphe serait bloqué en quatre-yeux, ni l'un ni l'autre n'étant le # contrat). Un claim absent échoue AUSSI (traçabilité). # ============================================================================ try: wf_trans_list = load("crm/workflow_vente/out/workflow.json")[0]["transitions"] # ensemble QUATRE-YEUX = actions dont l'auto-approbation est interdite (self=0), # normalisées (casse ignorée · suffixe « (perdu) » élidé — cf. groupage prose) def _sod_norm(a): return re.sub(r"\s*\(perdu\)\s*", "", a).strip().lower() sod_set = {_sod_norm(t["action"]) for t in wf_trans_list if str(t["allow_self_approval"]) == "0"} n_trans_tot = len(wf_trans_list) except (OSError, KeyError, ValueError, IndexError, TypeError) as e: bad(f"CRM séparation · workflow.json illisible/inattendu : {e}"); sod_set = None if sod_set is not None: # bonus : l'ensemble sensible est non vide ET strictement inclus dans le graphe if not (0 < len(sod_set) < n_trans_tot): bad(f"CRM séparation · ensemble quatre-yeux dégénéré : |sod|={len(sod_set)} " f"vs |transitions|={n_trans_tot} (attendu 0 < sod < total) → vérifier le contrat") else: good(f"CRM séparation · ensemble quatre-yeux non vide et strict : " f"0 < {len(sod_set)} < {n_trans_tot}") # énumération de la phrase : capturer le segment entre « clôturent** — » et « — sont marquées » wf_txt3 = read_norm(WF_RD) if wf_txt3 is not None: ms = re.search(r"engagent de l'argent ou clôturent\*\* — (.+?) — sont marquées", wf_txt3) if ms is None: bad(f"CRM séparation · {WF_RD} — énumération des transitions quatre-yeux " f"INTROUVABLE (attendu {sorted(sod_set)})") else: prose_set = {t.strip().lower() for t in re.split(r"[,/]", ms.group(1)) if t.strip()} if prose_set == sod_set: good(f"CRM séparation · {WF_RD} — énumération quatre-yeux " f"{sorted(prose_set)} == workflow.json (allow_self_approval=0)") else: miss = sorted(sod_set - prose_set) extra = sorted(prose_set - sod_set) detail = (f" · absents={miss}" if miss else "") + \ (f" · en trop={extra}" if extra else "") bad(f"CRM séparation · {WF_RD} — énumère {sorted(prose_set)} MAIS " f"workflow.json dit {sorted(sod_set)}{detail} → 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") # ============================================================================ # Mobile (2e surface) · ÉNUMÉRATION des onglets de l'app Expo — même README, même # ligne récap « **5 onglets** (Ventes/Construction/Achat/Compta/Direction) ». # ---------------------------------------------------------------------------- # Le bloc mobile ci-dessus recompute le COMPTE d'onglets (« 5 ») mais son patron # ne mord QUE le nombre : la parenthèse qui ÉNUMÈRE les 5 portails est matchée par # `\([^)]*\)` et son CONTENU est IGNORÉ. Or cette liste est DATA-DERIVED : le # générateur app_config émet out/role_navigation.json (byte-gaté par # check_artifacts ⇒ artefact faisant autorité) = un onglet par portail métier du # contrat RBAC (bijection 1 onglet ⇔ 1 portail · `plateforme` exclu), chaque # entrée portant `portail` (id) + `label` (« OTO »). Le nom court énuméré au # README = le label sans le préfixe « OTO ». Un ÉCHANGE (Ventes→Marketing), un # RETRAIT (Compta absent — l'énumération tombe à 4 tandis que « **5 onglets** » # reste juste) ou un RÉORDRE ferait dériver la prose EN SILENCE pendant que # l'artefact dit autre chose : « vert trompeur » aveugle au seul compte, qu'aucune # suite tests/ (qui teste des FONCTIONS, pas la prose) n'attrape — même patron que # l'énumération des confirmations DevOps, la carte de renormalisation par archétype # 4Big ou la table « Verdict courant » de l'audit 5D. On RECOMPUTE ici la LISTE # ORDONNÉE des noms courts depuis role_navigation.json (zéro duplication de la # navigation du générateur) et on exige que la parenthèse l'énumère EXACTEMENT # (absent ET en trop mordus, puis ordre). Cohérence croisée en bonus : # len(role_navigation) == MANIFEST.counts.onglets == MANIFEST.counts.portails (la # bijection du générateur) — mord un compteur du manifeste désynchronisé de sa # propre liste de navigation. # ============================================================================ try: mob_nav = load("mobile/app_config/out/role_navigation.json") # nom court = label « OTO » sans le préfixe « OTO » (ce que le doc copie) mob_names = [re.sub(r"^OTO\s+", "", e["label"]).strip() for e in mob_nav] mob_names_c = load("mobile/app_config/out/MANIFEST.json")["counts"] except (OSError, KeyError, ValueError, TypeError) as e: bad(f"Mobile · role_navigation/MANIFEST illisible/inattendu : {e}"); mob_names = None if mob_names is not None: # bijection : la liste de navigation == compteurs onglets ET portails du manifeste n_nav = len(mob_nav) if not (n_nav == mob_names_c.get("onglets") == mob_names_c.get("portails")): bad(f"Mobile · bijection rompue : |role_navigation|={n_nav} MAIS " f"MANIFEST onglets={mob_names_c.get('onglets')} " f"portails={mob_names_c.get('portails')} → régénérer l'artefact") else: good(f"Mobile · bijection onglet⇔portail : {n_nav} == onglets == portails") # énumération de la parenthèse : capturer le contenu entre ( ) qui suit « N onglets » mob_enum_pat = r"\*\*\d+ onglets\*\* \(([^)]*)\)" mob_txt2 = read_norm(MOB_RD) if mob_txt2 is not None: me = re.search(mob_enum_pat, mob_txt2) if me is None: bad(f"Mobile · {MOB_RD} — énumération des onglets INTROUVABLE " f"(attendu {'/'.join(mob_names)})") else: got = [s.strip() for s in me.group(1).split("/") if s.strip()] if got == mob_names: good(f"Mobile · {MOB_RD} — énumération onglets {'/'.join(got)} " f"== role_navigation") else: miss = [x for x in mob_names if x not in got] extra = [x for x in got if x not in mob_names] detail = (f" · absents={miss}" if miss else "") + \ (f" · en trop={extra}" if extra else "") or " · même ensemble, ordre ≠" bad(f"Mobile · {MOB_RD} — énumère {got} MAIS role_navigation dit " f"{mob_names}{detail} → 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") # ============================================================================ # Legal · CONFOTUR — cross-cohérence PERMISSIONS (2e surface du MÊME README) : # l'IDENTITÉ des 3 rôles + leur jeu d'ACTIONS exact — le CŒUR sécurité du livrable. # ---------------------------------------------------------------------------- # Le bloc CONFOTUR ci-dessus ne gate que le COMPTE (« 3 rôles »). La section # « Cœur du livrable : cross-cohérence » du README (05_deliverables_mvp/legal/ # confotur/README.md §Permissions) énumère À LA MAIN, PAR rôle, son portail ET son # jeu d'actions RBAC — annoncé « mot pour mot les permissions_cibles RBAC … ni # ajout ni retrait » : # - `ventes-confotur` (portail Ventes) → read/write/create/print # - `legal-onapi` (portail Direction) → read/write/create # - `legal-directeur` (portail Direction) → read/write/**submit**/report # Ces trois lignes sont DATA-DERIVED d'out/MANIFEST.json (`roles_rbac_utilises[]` : # role_id · portail · `permissions`, recomputé du contrat rbac_50_roles.json à # chaque build) et projetées dans out/doctype_confotur_application.json # (`permissions[]` par nom de rôle) — les DEUX byte-gatés par check_artifacts. Le # compte « 3 rôles » est AVEUGLE à leur identité : PROMOUVOIR `ventes-confotur` à # `submit` (élévation de privilège — casse la séparation des pouvoirs dont # is_submittable est déduit), RETIRER une action, RÉAFFECTER un portail, RENOMMER # un rôle ou AJOUTER une ligne fantôme laisse la prose périmée pendant que les # artefacts disent autre chose → l'agent ERPNext Backend câblerait le mauvais jeu # de permissions (le risque même que la cross-cohérence veut prévenir) — « vert # trompeur » qu'aucune suite tests/ (qui teste des FONCTIONS de mapping RBAC, pas # la prose) n'attrape. Même patron d'IDENTITÉ que le catalogue RBAC fixtures_gen # (séparation des pouvoirs `set_user_permissions`) ou la cross-cohérence e-CF. On # RECOMPUTE chaque jeu d'actions + portail depuis le MANIFEST (zéro duplication du # contrat #6) et on exige que la prose l'encode EXACTEMENT (set-diff : absent ET en # trop · casse normalisée), + l'identité d'ensemble (README ⇔ MANIFEST, aucun rôle # fantôme ni manquant). Cohérences croisées en bonus (mordent un artefact # INTERNEMENT incohérent) : MANIFEST ⇄ DocType d'accord sur le jeu d'actions par # rôle · séparation des pouvoirs : `submit` porté par EXACTEMENT un rôle et # is_submittable=True déduit de sa présence (README:31). Un claim absent échoue # AUSSI (traçabilité). # ============================================================================ CONF_RD2 = f"{D}/legal/confotur/README.md" _CONF_PERM_FLAGS = ("read", "write", "create", "submit", "cancel", "amend", "delete", "report", "export", "import", "print", "email", "share") try: conf_roles = load("legal/confotur/out/MANIFEST.json")["roles_rbac_utilises"] conf_perm_by_id = {r["role_id"]: set(r["permissions"]) for r in conf_roles} conf_portail_by_id = {r["role_id"]: r["portail"] for r in conf_roles} conf_name_by_id = {r["role_id"]: r["erpnext_role_name"] for r in conf_roles} conf_dt = load("legal/confotur/out/doctype_confotur_application.json") conf_dt_perm_by_name = {p["role"]: {f for f in _CONF_PERM_FLAGS if p.get(f)} for p in conf_dt["permissions"]} conf_submittable = bool(conf_dt.get("is_submittable")) except (OSError, KeyError, ValueError, TypeError) as e: bad(f"Legal confotur perms · artefact illisible/inattendu : {e}"); conf_roles = None if conf_roles: # bonus 1 : les DEUX artefacts (MANIFEST ⇄ DocType) d'accord sur le jeu d'actions for rid, perms in conf_perm_by_id.items(): nm = conf_name_by_id[rid] dt_perms = conf_dt_perm_by_name.get(nm) if dt_perms is None: bad(f"Legal confotur perms · rôle `{rid}` ({nm}) ABSENT des permissions du DocType") elif dt_perms != perms: bad(f"Legal confotur perms · incohérence artefacts pour `{rid}` : MANIFEST " f"{sorted(perms)} MAIS DocType {sorted(dt_perms)}") else: good(f"Legal confotur perms · `{rid}` — MANIFEST {sorted(perms)} == DocType (accord)") # bonus 2 : séparation des pouvoirs — `submit` porté par EXACTEMENT un rôle, et # is_submittable DÉDUIT de sa présence (README:31). Singleton sécurité (cf. RBAC Admin). conf_submitters = {rid for rid, p in conf_perm_by_id.items() if "submit" in p} if len(conf_submitters) != 1: bad(f"Legal confotur perms · séparation des pouvoirs : action `submit` portée par " f"{sorted(conf_submitters)} (attendu 1 — ni vacant, ni élévation de privilège)") elif conf_submittable is not True: bad(f"Legal confotur perms · un rôle porte `submit` MAIS is_submittable=" f"{conf_submittable} → DocType incohérent") else: good(f"Legal confotur perms · séparation des pouvoirs — `submit` singleton " f"{sorted(conf_submitters)} · is_submittable=True (déduit · README:31)") conf_rd2 = read_norm(CONF_RD2) if conf_rd2 is not None: for rid in sorted(conf_perm_by_id): exp_perms = conf_perm_by_id[rid] exp_portail = conf_portail_by_id[rid] m = re.search(rf"`{re.escape(rid)}` \(portail ([^)]+)\) → ([\w/*]+)", conf_rd2) if m is None: bad(f"Legal confotur perms · {CONF_RD2} — ligne du rôle `{rid}` INTROUVABLE " f"(attendu portail {exp_portail} · {sorted(exp_perms)})") continue got_portail = m.group(1).strip().lower() got_perms = {t.replace("*", "").strip().lower() for t in m.group(2).split("/") if t.replace("*", "").strip()} if got_portail != exp_portail: bad(f"Legal confotur perms · `{rid}` — {CONF_RD2} dit portail " f"« {m.group(1).strip()} » MAIS MANIFEST dit « {exp_portail} » → régénérer") elif got_perms != exp_perms: miss = sorted(exp_perms - got_perms); extra = sorted(got_perms - exp_perms) detail = (f" · absents={miss}" if miss else "") + \ (f" · en trop={extra}" if extra else "") bad(f"Legal confotur perms · `{rid}` — {CONF_RD2} énumère {sorted(got_perms)} " f"MAIS MANIFEST {sorted(exp_perms)}{detail} → régénérer le README") else: good(f"Legal confotur perms · `{rid}` — portail {got_portail} · actions " f"{sorted(got_perms)} == MANIFEST") # identité d'ensemble : README ⇔ MANIFEST (aucun rôle fantôme NI manquant) — # rd_ids = rôles réellement écrits dans la prose §Permissions (patron identique). rd_ids = set(re.findall(r"`([a-z][a-z-]+)` \(portail [^)]+\) → [\w/*]+", conf_rd2)) exp_ids = set(conf_perm_by_id) if rd_ids == exp_ids: good(f"Legal confotur perms · identité d'ensemble — {sorted(rd_ids)} == " f"roles_rbac_utilises (aucun rôle fantôme/manquant)") else: miss = sorted(exp_ids - rd_ids); extra = sorted(rd_ids - exp_ids) detail = (f" · absents={miss}" if miss else "") + \ (f" · en trop (fantôme)={extra}" if extra else "") bad(f"Legal confotur perms · {CONF_RD2} — rôles énumérés {sorted(rd_ids)} MAIS " f"MANIFEST {sorted(exp_ids)}{detail} → régénérer le README") # ============================================================================ # Legal · CONFOTUR — cross-cohérence PERMISSIONS répliquée dans la FICHE AGENT # (3e surface) : la MÊME table role→portail→actions, transcrite À LA MAIN dans # 03_agents/onapi_legal/AGENT.md:43-45 — jamais gatée. # ---------------------------------------------------------------------------- # Le bloc ci-dessus gate la §Permissions du README du MODULE (legal/confotur/ # README.md). Mais la FICHE de l'agent ONAPI/Legal RECOPIE la MÊME donnée # sécurité — annoncée « **mot pour mot** les `permissions_cibles` RBAC des 3 # rôles » — sous une convention DIFFÉRENTE (` / ` espacé · `**submit**` en gras) : # - `ventes-confotur` (portail Ventes) → read / write / create / print # - `legal-onapi` (portail Direction) → read / write / create # - `legal-directeur` (portail Direction) → read / write / **submit** / report # Ces trois lignes sont DATA-DERIVED du MÊME out/MANIFEST.json (`roles_rbac_ # utilises[]` : role_id · portail · `permissions`, recomputé du contrat rbac_50_ # roles.json à chaque build · byte-gaté par check_artifacts) que le README. Le # gate README est AVEUGLE à la fiche : PROMOUVOIR `ventes-confotur` à `submit` # ici (élévation de privilège · casse la séparation des pouvoirs), RETIRER une # action, RÉAFFECTER un portail, RENOMMER un rôle ou AJOUTER une ligne fantôme # laisse la FICHE périmée pendant que l'artefact (et le README, lui gaté) disent # autre chose → l'agent ONAPI/Legal lirait sa PROPRE doc d'identité mentant sur # le jeu de permissions qu'il porte (le risque même que « mot pour mot » promet # d'écarter) — « vert trompeur » qu'aucune suite tests/ (FONCTIONS de mapping # RBAC, jamais la prose de fiche) n'attrape. Même patron d'IDENTITÉ que les # fiches Mobile/Faisabilité (attributs de rôle) ou la citation #10 (constantes), # appliqué à la SURFACE DISTINCTE qu'est la §Permissions de cette fiche. On # RECOMPUTE chaque jeu d'actions + portail depuis conf_perm_by_id/conf_portail_ # by_id (déjà dérivés du MANIFEST ci-dessus · ZÉRO duplication du contrat #6) et # on exige que la fiche l'encode EXACTEMENT (set-diff : absent ET en trop · gras # `**` et casse normalisés) + l'identité d'ensemble (fiche ⇔ MANIFEST, aucun # rôle fantôme ni manquant). Un claim absent échoue AUSSI (traçabilité). # ============================================================================ CONF_FICHE = "03_agents/onapi_legal/AGENT.md" if conf_roles: conf_fiche = read_norm(CONF_FICHE) if conf_fiche is None: bad(f"Legal confotur fiche · {CONF_FICHE} illisible") else: # une action : mot, éventuellement encadré de `**` (gras), séparé par ` / `. _ACT = r"(?:\*{0,2}\w+\*{0,2}\s*/\s*)*\*{0,2}\w+\*{0,2}" for rid in sorted(conf_perm_by_id): exp_perms = conf_perm_by_id[rid] exp_portail = conf_portail_by_id[rid] pat = r"`" + re.escape(rid) + r"` \(portail ([^)]+)\) → (" + _ACT + r")" m = re.search(pat, conf_fiche) if m is None: bad(f"Legal confotur fiche · {CONF_FICHE} — ligne du rôle `{rid}` INTROUVABLE " f"(attendu portail {exp_portail} · {sorted(exp_perms)})") continue got_portail = m.group(1).strip().lower() got_perms = {t.replace("*", "").strip().lower() for t in m.group(2).split("/") if t.replace("*", "").strip()} if got_portail != exp_portail: bad(f"Legal confotur fiche · `{rid}` — {CONF_FICHE} dit portail " f"« {m.group(1).strip()} » MAIS MANIFEST dit « {exp_portail} » → régénérer") elif got_perms != exp_perms: miss = sorted(exp_perms - got_perms); extra = sorted(got_perms - exp_perms) detail = (f" · absents={miss}" if miss else "") + \ (f" · en trop={extra}" if extra else "") bad(f"Legal confotur fiche · `{rid}` — {CONF_FICHE} énumère {sorted(got_perms)} " f"MAIS MANIFEST {sorted(exp_perms)}{detail} → régénérer la fiche") else: good(f"Legal confotur fiche · `{rid}` — portail {got_portail} · actions " f"{sorted(got_perms)} == MANIFEST") # identité d'ensemble : fiche ⇔ MANIFEST (aucun rôle fantôme NI manquant). fiche_ids = set(re.findall(r"`([a-z][a-z-]+)` \(portail [^)]+\) →", conf_fiche)) exp_ids = set(conf_perm_by_id) if fiche_ids == exp_ids: good(f"Legal confotur fiche · identité d'ensemble — {sorted(fiche_ids)} == " f"roles_rbac_utilises (aucun rôle fantôme/manquant)") else: miss = sorted(exp_ids - fiche_ids); extra = sorted(fiche_ids - exp_ids) detail = (f" · absents={miss}" if miss else "") + \ (f" · en trop (fantôme)={extra}" if extra else "") bad(f"Legal confotur fiche · {CONF_FICHE} — rôles énumérés {sorted(fiche_ids)} MAIS " f"MANIFEST {sorted(exp_ids)}{detail} → régénérer la fiche") # ============================================================================ # 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 = « | · | | | » 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") # 2bis) deploy_runbook — IDENTITÉ de la TABLE « Plan de phases généré » ----------- # Le bloc ci-dessus ne gate que DEUX comptes agrégés (phases · confirmations). La # table « ## Plan de phases généré » énumère, PAR phase, QUATRE données data-derived # de out/deploy_runbook.json (byte-gaté par check_artifacts) : le n° d'ordre (#), le # RESPONSABLE (worker/vps/worker+vps), l'ensemble des MODULES gated déployés dans la # phase, et « Dépend de » (les n°s d'ordre des `depends_on`). C'est le GRAPHE DE # PORTAGE VPS ORDONNÉ que l'agent DevOps suit phase par phase — un module OUBLIÉ de # la colonne Modules (donc jamais déployé), un responsable ou une dépendance périmés # (ex. Frontend avant RBAC) sont un hazard réel, invisible à check_artifacts (qui ne # prouve QUE deploy_runbook==build) ET au bloc de comptes (aveugle à l'identité des # lignes ; ces colonnes étaient un WILDCARD). Même classe que la table run-book de # rbac/apply_plan et la table demo/scenarios. Preuve reproduite : la table omettait # `mobile/app_config` (phase 5) ET `qa/acceptance` (phase 7) — 20 modules listés sur # 22 — pendant que le MANIFEST prouvait la couverture BIJECTIVE 22/22 vs CI : vert # trompeur, l'agent DevOps aurait sauté 2 modules. On RECOMPUTE #/responsable/ # modules/deps depuis deploy_runbook.json (zéro duplication) et on exige que la table # les énonce EXACTEMENT. Identité d'ensemble : {ordres des lignes} == {ordres de # l'artefact} (aucune phase FANTÔME/MANQUANTE) ET l'UNION des cellules Modules == les # 22 modules de l'artefact (aucun module OUBLIÉ/en trop). La colonne « Phase » reste # libre (paraphrase). Cohérences croisées en bonus (mordent un plan INTERNEMENT # incohérent) : ordres contigus 1..N sans doublon · responsable ∈ {worker, vps, # worker+vps} · toute dépendance pointe en ARRIÈRE (n° < n° de la phase ⇒ pas de # cycle/renvoi avant). try: dr_plan = load("devops/deploy_runbook/out/deploy_runbook.json") except (OSError, KeyError, ValueError) as e: bad(f"DevOps · run-book illisible : {e}"); dr_plan = None if dr_plan is not None: id2order = {p["id"]: p["order"] for p in dr_plan} ALLOWED_RESP = {"worker", "vps", "worker+vps"} orders = sorted(p["order"] for p in dr_plan) all_modules = sorted(x["module"] for p in dr_plan for x in p["modules"]) if orders == list(range(1, len(dr_plan) + 1)): good(f"DevOps · run-book — ordres contigus 1..{len(dr_plan)} (aucun trou/doublon)") else: bad(f"DevOps · run-book — ordres NON contigus {orders} " f"(attendu 1..{len(dr_plan)}) → plan incohérent") # Lignes lues en RAW : la table « Plan de phases généré » est la SEULE à 5 # colonnes dont la 1ʳᵉ est numérique (l'autre table est `Fichier | Rôle`). try: dr_tbl = open(DR_RD, encoding="utf-8").read().splitlines() except OSError as e: bad(f"DevOps · {DR_RD} illisible : {e}"); dr_tbl = [] table_orders, rows_by_order = [], {} for ln in dr_tbl: cells = [c.strip() for c in ln.strip().strip("|").split("|")] if len(cells) == 5 and re.fullmatch(r"\d+", cells[0]): o = int(cells[0]); table_orders.append(o); rows_by_order[o] = cells if sorted(table_orders) == orders and len(table_orders) == len(set(table_orders)): good(f"DevOps · run-book — {len(table_orders)} lignes == {len(dr_plan)} phases " f"(aucune FANTÔME/MANQUANTE)") else: bad(f"DevOps · run-book — lignes table {sorted(table_orders)} ≠ phases artefact " f"{orders} (ligne en trop/manquante ou doublon)") table_modules = sorted(m for r in rows_by_order.values() for m in re.findall(r"`([^`]+)`", r[3])) if table_modules == all_modules: good(f"DevOps · run-book — union des Modules ({len(table_modules)}) == " f"{len(all_modules)} modules de l'artefact (aucun OUBLIÉ/en trop)") else: miss = sorted(set(all_modules) - set(table_modules)) extra = sorted(set(table_modules) - set(all_modules)) bad(f"DevOps · run-book — colonne Modules ≠ artefact : manquants={miss or '—'} " f"en_trop={extra or '—'} → régénérer le README") for p in sorted(dr_plan, key=lambda x: x["order"]): o, resp = p["order"], p["responsable"] deps = sorted(id2order[x] for x in p["depends_on"]) mods = sorted(x["module"] for x in p["modules"]) if resp not in ALLOWED_RESP: bad(f"DevOps · phase {o} — responsable artefact « {resp} » " f"hors {sorted(ALLOWED_RESP)} (plan incohérent)") if any(d >= o for d in deps): bad(f"DevOps · phase {o} — dépendance en AVANT {deps} (≥ {o}) : graphe incohérent") row = rows_by_order.get(o) if row is None: bad(f"DevOps · phase {o} — ligne INTROUVABLE dans {DR_RD} " f"(attendu responsable={resp} modules={mods} deps={deps or '—'})"); continue got_resp = row[1] got_mods = sorted(re.findall(r"`([^`]+)`", row[3])) got_deps = sorted(int(x) for x in re.findall(r"\d+", row[4])) if got_resp == resp: good(f"DevOps · phase {o} — responsable « {got_resp} » == artefact") else: bad(f"DevOps · phase {o} — {DR_RD} dit responsable « {got_resp} » MAIS " f"artefact dit « {resp} » → régénérer le README") if got_mods == mods: good(f"DevOps · phase {o} — Modules ({len(got_mods)}) == artefact") else: bad(f"DevOps · phase {o} — {DR_RD} Modules {got_mods} MAIS artefact dit " f"{mods} → régénérer le README") if got_deps == deps: good(f"DevOps · phase {o} — Dépend de {got_deps or '—'} == artefact") else: bad(f"DevOps · phase {o} — {DR_RD} « Dépend de » = {got_deps or '—'} MAIS " f"artefact dit {deps or '—'} → régénérer le README") # 2ter) deploy_runbook — IMPUTABILITÉ des CONFIRMATIONS préalables VPS ------------ # La ligne « Confirmations préalables VPS » du README annote CHAQUE confirmation # réglementaire d'un COUPLE data-derived : `nom` (OWNER · RÉF-CONTRÔLE), ex. # `taux_commission` (Direction · audit_5d D1.1). L'OWNER = QUI doit confirmer le # paramètre avant déploiement ; la RÉF = le contrôle qa/audit_5d (`D#.#`) — ou la # session — qui en assure le suivi. Source faisant autorité : les `confirmations` # de out/deploy_runbook.json (byte-gaté) — chaque item porte `owner` + `source` — # CROISÉES avec le rapport qa/audit_5d (byte-gaté) : le contrôle cité y porte SON # PROPRE `owner`. Les blocs DevOps amont ne gatent que le COMPTE de confirmations # (2) et l'ENSEMBLE de leurs noms (bloc énumération) — AVEUGLES à QUI porte chaque # confirmation et à QUEL contrôle la trace. Piège #6 : RÉAFFECTER `taux_commission` # de Direction à Ventes (mauvais responsable relancé → confirmation jamais obtenue), # MISATTRIBUER le contrôle (`D1.1`→`D2.1`, pointant un contrôle qui ne trace PAS ce # paramètre) ou citer un contrôle FANTÔME (`D9.9`, absent d'audit_5d) laisse la # prose périmée EN SILENCE pendant que l'artefact/l'audit disent autre chose → # l'agent DevOps escaladerait le mauvais owner / le mauvais contrôle. La table # phases (2bis) est aveugle aux confirmations ; aucune suite tests/ (FONCTIONS de # graphe) n'attrape la prose. Cross-anchor à 3 : owner runbook COMPATIBLE avec # owner du contrôle audit_5d (sous-ensemble de tokens · tolère le suffixe éditorial # « / UAF ») · owner README ⊆ owner runbook (tolère l'abréviation « Fiscaliste eCF » # ⊂ « Compta Fiscaliste eCF ») · réf README == contrôle réel (présent dans audit_5d # ET dans la source). Une confirmation tracée par un contrôle DOIT rester annotée # (disparition = régression de traçabilité #6). Une annotation d'un nom FANTÔME # échoue AUSSI. try: a5_rep = load("qa/audit_5d/out/audit_report.json") a5_owner = {c["id"]: c["owner"] for c in a5_rep["controls"]} except (OSError, KeyError, ValueError) as e: bad(f"DevOps · confirmations — rapport audit_5d illisible : {e}"); a5_owner = None dr_rd2 = read_norm(DR_RD) if dr_plan is not None and a5_owner is not None and dr_rd2 is not None: def _toks(s): return {t for t in re.split(r"\s+", s.strip()) if t} def _ctrl(s): m = re.search(r"D\d\.\d+", s) return m.group(0) if m else None # confirmations aplaties (id → item) + unicité inter-phases conf_by_id, dups = {}, [] for p in dr_plan: for c in p.get("confirmations", []): if c["id"] in conf_by_id: dups.append(c["id"]) conf_by_id[c["id"]] = c if dups: bad(f"DevOps · confirmations — id(s) DUPLIQUÉ(s) inter-phases " f"{sorted(set(dups))} (plan incohérent)") # tracked : confirmations dont la `source` cite un contrôle audit_5d `D#.#` tracked = {cid: _ctrl(c["source"]) for cid, c in conf_by_id.items()} # annotations README : `nom` (owner · réf) ann = {} seg = re.search(r"Confirmations préalables VPS\*\* \(consolidées, sourcées\) : " r"(.+?) ## ", dr_rd2) if seg is None: bad(f"DevOps · {DR_RD} — ligne « Confirmations préalables VPS » INTROUVABLE " f"(imputabilité non vérifiable)") else: for name, body in re.findall(r"`([a-z_]+)`\s*\(([^)]+)\)", seg.group(1)): parts = [x.strip() for x in body.split("·")] if len(parts) == 2: ann[name] = {"owner": parts[0], "ref": parts[1]} # (a) cross-module : chaque confirmation tracée porte un contrôle audit_5d RÉEL # dont l'owner est COMPATIBLE avec l'owner du run-book (séparation des # pouvoirs cohérente entre modules). for cid, ctrl in sorted(tracked.items()): if ctrl is None: continue c = conf_by_id[cid] if ctrl not in a5_owner: bad(f"DevOps · confirmation `{cid}` — source cite le contrôle `{ctrl}` " f"ABSENT du rapport qa/audit_5d (contrôle FANTÔME)"); continue if not _toks(c["owner"]) <= _toks(a5_owner[ctrl]): bad(f"DevOps · confirmation `{cid}` — owner run-book « {c['owner']} » " f"INCOMPATIBLE avec owner du contrôle {ctrl} audit_5d " f"« {a5_owner[ctrl]} » (imputabilité divergente inter-modules)") else: good(f"DevOps · confirmation `{cid}` — owner « {c['owner']} » ⊆ owner " f"contrôle {ctrl} audit_5d (imputabilité cohérente)") if cid not in ann: bad(f"DevOps · confirmation `{cid}` (tracée par {ctrl}) — annotation " f"« (owner · réf) » INTROUVABLE dans {DR_RD} → régénérer le README") # (b) chaque annotation README désigne une confirmation RÉELLE + owner/réf exacts for cid, a in sorted(ann.items()): c = conf_by_id.get(cid) if c is None: bad(f"DevOps · annotation README `{cid}` — confirmation FANTÔME " f"(absente du run-book) → régénérer le README"); continue if not _toks(a["owner"]) <= _toks(c["owner"]): bad(f"DevOps · confirmation `{cid}` — README owner « {a['owner']} » " f"n'est PAS un sous-ensemble de l'owner artefact « {c['owner']} » " f"→ mauvais responsable") else: good(f"DevOps · confirmation `{cid}` — README owner « {a['owner']} » ⊆ " f"artefact « {c['owner']} »") ref_ctrl = _ctrl(a["ref"]) if ref_ctrl is not None: # réf = contrôle audit_5d : doit == le contrôle de la source ET exister if ref_ctrl != tracked.get(cid): bad(f"DevOps · confirmation `{cid}` — README réf cite {ref_ctrl} MAIS " f"la source artefact trace {tracked.get(cid) or '(aucun contrôle)'} " f"→ contrôle audit_5d périmé") elif ref_ctrl not in a5_owner: bad(f"DevOps · confirmation `{cid}` — README réf cite le contrôle " f"`{ref_ctrl}` ABSENT du rapport qa/audit_5d (fantôme)") else: good(f"DevOps · confirmation `{cid}` — réf README == contrôle " f"{ref_ctrl} (présent audit_5d + source)") else: # réf textuelle (ex. « session 19 ») : doit apparaître dans la source if a["ref"] not in c["source"]: bad(f"DevOps · confirmation `{cid}` — README réf « {a['ref']} » ABSENTE " f"de la source artefact « {c['source']} »") else: good(f"DevOps · confirmation `{cid}` — réf « {a['ref']} » == source artefact") # ============================================================================ # 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") # ============================================================================ # Chat OTOIA · IDENTITÉ des configs runtime (persona · langues · endpoint) — # ATTRIBUTS transcrits À LA MAIN dans la cellule « Métrique vérifiée » de la # fiche agent, AVEUGLES au gate de COMPTE ci-dessus. # ---------------------------------------------------------------------------- # Le bloc précédent gate le NOMBRE de blocks/mounts/portails ; il est indifférent # à CE QUE chaque config runtime dit. Or la même cellule (03_agents/ # frontend_console/AGENT.md:29) restitue TROIS attributs data-derived du montage : # « persona Amélie … · langues FR/EN/ES · `endpoint: null` ». Ils sortent des # artefacts byte-gatés out/chat_mount.json (une config par portail) + out/ # MANIFEST.json (persona/langues/endpoint_statut consolidés) — jamais de la spec. # • persona.nom : le prénom de l'assistant (le « QC » est le qualificatif de # marque CLAUDE.md « Voix Amélie QC », gardé en littéral — non gaté ici). # • langues : la surface trilingue FR/EN/ES lue par le générateur dans # seo_spec.json (source unique des langues). # • endpoint : `null` est une AFFIRMATION D'HONNÊTETÉ #6/#8 — l'endpoint OTOIA # n'est PAS fabriqué (renseigné côté VPS). Si une config gagnait un endpoint # réel, la fiche « endpoint: null » deviendrait un MENSONGE en silence. # Cohérence croisée (bijection du générateur) : MANIFEST.persona.nom ET # MANIFEST.langues doivent valoir EXACTEMENT ce que porte CHAQUE mount (un mount # désynchronisé de son propre manifeste mord ici) ; endpoint_statut « a_confirmer » # doit correspondre à des endpoints TOUS `null` (sinon incohérence interne de # l'artefact). Un claim absent échoue AUSSI (INTROUVABLE = régression #6). Aucune # suite tests/ (FONCTIONS du builder, jamais la prose de la fiche) n'attrape ce # « vert trompeur ». # ============================================================================ try: ci_man = load("frontend/chat_otoia/out/MANIFEST.json") ci_mounts = load("frontend/chat_otoia/out/chat_mount.json") ci_persona = ci_man["persona"]["nom"] ci_langs = list(ci_man["langues"]) ci_statut = ci_man["endpoint_statut"] if not ci_mounts: bad("Chat OTOIA identité · out/chat_mount.json vide"); ci_persona = None except (OSError, KeyError, ValueError, TypeError) as e: bad(f"Chat OTOIA identité · artefact illisible/inattendu : {e}"); ci_persona = None if ci_persona is not None: # Cohérence croisée MANIFEST ⇔ chaque config runtime (bijection du générateur) bad_persona = sorted({m["persona"]["nom"] for m in ci_mounts if m["persona"]["nom"] != ci_persona}) bad_langs = sorted({"/".join(m["langues"]) for m in ci_mounts if list(m["langues"]) != ci_langs}) all_null = all(m["endpoint"] is None for m in ci_mounts) if bad_persona: bad(f"Chat OTOIA identité · persona MANIFEST={ci_persona!r} MAIS mounts portent " f"aussi {bad_persona} (montage désynchronisé)") if bad_langs: bad(f"Chat OTOIA identité · langues MANIFEST={ci_langs} MAIS mounts portent " f"aussi {bad_langs} (montage désynchronisé)") if all_null and ci_statut != "a_confirmer": bad(f"Chat OTOIA identité · endpoints TOUS null MAIS endpoint_statut={ci_statut!r} " f"(attendu 'a_confirmer' — incohérence interne artefact)") if (not all_null) and ci_statut == "a_confirmer": bad("Chat OTOIA identité · endpoint_statut='a_confirmer' MAIS ≥1 endpoint fabriqué " "(incohérence interne artefact)") exp_langs = "/".join(l.upper() for l in ci_langs) exp_endpoint = "null" if all_null else "NON-null(" + ",".join( sorted({str(m["endpoint"]) for m in ci_mounts if m["endpoint"] is not None})) + ")" co_fi2 = read_norm(CO_FI) if co_fi2 is not None: m = re.search(r"persona (\S+) QC · langues ([A-Z/]+) · `endpoint: ([^`]+)`", co_fi2) if m is None: bad(f"Chat OTOIA identité · {CO_FI} — claim « persona QC · langues " f" · `endpoint: ` » INTROUVABLE (attendu {ci_persona}/{exp_langs}/{exp_endpoint})") else: got = m.groups() want = (ci_persona, exp_langs, exp_endpoint) if got == want: good(f"Chat OTOIA identité · {CO_FI} — persona {got[0]} · langues {got[1]} · " f"endpoint {got[2]} == artefact (chat_mount ×{len(ci_mounts)} ⇔ MANIFEST)") else: bad(f"Chat OTOIA identité · {CO_FI} — dit persona {got[0]}/langues {got[1]}/" f"endpoint {got[2]} MAIS artefact dit {want[0]}/{want[1]}/{want[2]} " f"→ régénérer la fiche") # ============================================================================ # 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") # ============================================================================ # Fiscal · e-CF DGII — le CONTRAT DE FORMAT de l'e-NCF (« E + tipoeCF(2) + # secuencia(10) » = 13) transcrit À LA MAIN dans QUATRE endroits de prose (README # ×3 + fiche agent erpnext_backend), sans AUCUN gate d'IDENTITÉ. # ---------------------------------------------------------------------------- # Le générateur ecf_dgii_gen émet out/ecf_plan.json (byte-gaté par check_artifacts # ⇒ faisant autorité) dont le bloc `e_ncf` PORTE le format de l'identifiant # électronique DGII : `prefix` (« E »), `longueur` (13), `regex` # (« ^E[0-9]{12}$ ») et `structure` (« E + tipoeCF (2 chiffres) + secuencia # (10 chiffres) »). Ce format — préfixe + longueur du segment tipo + longueur du # segment secuencia + longueur totale — est le CŒUR du module (le « composeur # traçable ») et il est recopié À LA MAIN dans QUATRE endroits de prose : # • README:10 « composeur d'e-NCF traçable `E + tipoeCF(2) + secuencia(10)` » # • README:75 (invariant 7) « E+tipo(2)+seq(10)=13 » (porte la longueur TOTALE) # • README:84 (composition) « e_ncf = "E" + tipo(2) + secuencia(10) » # • 03_agents/erpnext_backend/AGENT.md:33 « `E + tipoeCF(2) + secuencia(10)` » # Le bloc racine de check_readme_claims ne gate QUE le COMPTE de tests e-CF # (« 39 »). Aucun gate ne comparait la LONGUEUR des segments à l'artefact : allonger # la secuencia (rango DGII à 12 chiffres → seq=12, longueur=15, regex 14 digits) # OU changer le préfixe ferait dériver les QUATRE proses en silence pendant que # l'ecf_plan byte-gaté dit autre chose — « vert trompeur » qu'aucune suite tests/ # (qui teste des FONCTIONS ncf.compose/parse, pas la prose des docs) n'attrape. # Même patron que l'énumération des onglets Mobile, la carte 4Big ou les open items # 5D. On RECOMPUTE (prefix, tipo_len, seq_len, total) depuis le SEUL bloc `e_ncf` # (zéro duplication du format du générateur #6) et on exige que CHAQUE prose # l'encode EXACTEMENT. Cohérences croisées en bonus (mordent un artefact # INTERNEMENT incohérent) : |prefix| + tipo_len + seq_len == longueur (1+2+10==13) # ET le nombre de chiffres du `regex` == tipo_len + seq_len (12 == 2+10). Un claim # absent échoue AUSSI (la disparition d'un format est une régression · traçabilité). # ============================================================================ ECF_RD = f"{D}/fiscal/ecf_dgii/README.md" ECF_FI = "03_agents/erpnext_backend/AGENT.md" try: encf = load("fiscal/ecf_dgii/out/ecf_plan.json")["e_ncf"] ecf_prefix = encf["prefix"] ecf_total = int(encf["longueur"]) ecf_seglens = [int(x) for x in re.findall(r"\((\d+)\s*chiffres?\)", encf["structure"])] ecf_mdig = re.search(r"\[0-9\]\{(\d+)\}", encf["regex"]) except (OSError, KeyError, ValueError) as e: bad(f"Fiscal · artefact ecf_plan illisible/inattendu : {e}"); encf = None if encf is not None: if len(ecf_seglens) != 2: bad(f"Fiscal · e_ncf.structure malformée : {encf['structure']!r} " f"(attendu 2 segments « (N chiffres) », trouvé {ecf_seglens})") else: ecf_tipo, ecf_seq = ecf_seglens # Cohérences croisées : l'artefact doit être INTERNEMENT cohérent. if len(ecf_prefix) != 1: bad(f"Fiscal · incohérence artefact : prefix={ecf_prefix!r} n'est pas " f"1 caractère") if 1 + ecf_tipo + ecf_seq != ecf_total: bad(f"Fiscal · incohérence artefact : 1+{ecf_tipo}+{ecf_seq}=" f"{1 + ecf_tipo + ecf_seq} MAIS longueur={ecf_total}") if ecf_mdig is None or int(ecf_mdig.group(1)) != ecf_tipo + ecf_seq: got = ecf_mdig.group(1) if ecf_mdig else "?" bad(f"Fiscal · incohérence artefact : regex porte {got} chiffres MAIS " f"tipo+seq={ecf_tipo + ecf_seq}") exp_ncf = f"{ecf_prefix} + tipo({ecf_tipo}) + secuencia({ecf_seq}) = {ecf_total}" def ecf_check(tag, path, txt, m, with_total): if m is None: bad(f"Fiscal · {path} — {tag} : format e-NCF INTROUVABLE " f"(attendu {exp_ncf})") return g = m.groups() ok = (g[0] == ecf_prefix and int(g[1]) == ecf_tipo and int(g[2]) == ecf_seq and (not with_total or int(g[3]) == ecf_total)) if ok: good(f"Fiscal · {path} — {tag} : {exp_ncf} == e_ncf (artefact)") else: shown = "/".join(g) bad(f"Fiscal · {path} — {tag} : dit {shown} MAIS e_ncf dit " f"prefix={ecf_prefix} tipo={ecf_tipo} seq={ecf_seq} " f"total={ecf_total} → régénérer le doc") # 1) README:10 — résumé « composeur d'e-NCF traçable `E + tipoeCF(2) + secuencia(10)` » ecf_rd = read_norm(ECF_RD) if ecf_rd is not None: ecf_check("résumé composeur", ECF_RD, ecf_rd, re.search(r"`([A-Z])\s*\+\s*tipoeCF\((\d+)\)\s*\+\s*" r"secuencia\((\d+)\)`", ecf_rd), False) # 2) README:75 — invariant 7 « E+tipo(2)+seq(10)=13 » (porte la longueur totale) ecf_check("invariant format", ECF_RD, ecf_rd, re.search(r"\(([A-Z])\+tipo\((\d+)\)\+seq\((\d+)\)=(\d+)", ecf_rd), True) # 3) README:84 — composition « e_ncf = "E" + tipo(2) + secuencia(10) » ecf_check("composition ncf.py", ECF_RD, ecf_rd, re.search(r'e_ncf\s*=\s*"([A-Z])"\s*\+\s*tipo\((\d+)\)\s*\+\s*' r"secuencia\((\d+)\)", ecf_rd), False) # 4) Fiche agent erpnext_backend — « `E + tipoeCF(2) + secuencia(10)` » ecf_fi = read_norm(ECF_FI) if ecf_fi is not None: ecf_check("fiche erpnext_backend", ECF_FI, ecf_fi, re.search(r"`([A-Z])\s*\+\s*tipoeCF\((\d+)\)\s*\+\s*" r"secuencia\((\d+)\)`", ecf_fi), False) # ============================================================================ # Fiscal · e-CF DGII (2e surface du MÊME README) · la CROSS-COHÉRENCE # (« le cœur du livrable ») — ENSEMBLES/valeur data-derived transcrits À LA MAIN # sans AUCUN gate d'IDENTITÉ. Le bloc e-NCF ci-dessus ne gate QUE le FORMAT de # l'identifiant ; la section « Cross-cohérence e-CF ↔ workflow ↔ DocType ↔ RBAC » # ÉNUMÈRE trois données dérivées de out/ecf_plan.json (byte-gaté par # check_artifacts ⇒ faisant autorité) : # • README:48 « seulement réservation et contrat » — l'ensemble des états qui # ÉMETTENT un e-CF = {emission_events[].update_value}. # • README:50 « (`montant_reservation`, `montant_contrat`) » — l'ensemble des # champs Currency de base = {emission_events[].base_field}. # • README:59 « FormaPago défaut = 3 (Tarjeta) … via Cardnet (#10) » — le code + # libellé de forma_pago_defaut, ANCRÉ sur CLAUDE.md #10 (Cardnet = carte). # Aucun gate ne comparait ces valeurs à l'artefact : AJOUTER un évènement # d'émission (facturer un devis) · en RETIRER un · RENOMMER un base_field · # CHANGER forma_pago_defaut (Cardnet abandonné → code 2 Transfert) ferait dériver # la prose en silence pendant que l'ecf_plan byte-gaté dit autre chose — l'agent # ERPNext Backend câblerait le mauvais évènement / la mauvaise FormaPago (le risque # même que la cross-cohérence veut prévenir) — « vert trompeur » qu'aucune suite # tests/ (qui teste des FONCTIONS ncf/résolution, pas la prose) n'attrape. Même # patron EXACT que la cross-cohérence CRM/Commissions (base_field + états soumis). # On RECOMPUTE chaque ensemble/valeur depuis l'artefact (zéro duplication du # contrat du générateur #6) et on exige que la prose l'encode EXACTEMENT (absent ET # en trop via set-diff · accents/casse normalisés). Cohérences croisées en bonus # (mordent un plan INTERNEMENT incohérent) : forma_pago_defaut.code ∈ catalogue # formas_pago · libellé prose ⊆ libellé catalogue du code · ensembles NON VIDES. # Un claim absent échoue AUSSI (traçabilité). # ============================================================================ ECF_RD2 = f"{D}/fiscal/ecf_dgii/README.md" def _ecf_acc(s): return "".join(c for c in unicodedata.normalize("NFKD", s) if unicodedata.category(c) != "Mn").lower() try: ecfp = load("fiscal/ecf_dgii/out/ecf_plan.json") ecf_ev = ecfp["emission_events"] ecf_uv = {_ecf_acc(e["update_value"]) for e in ecf_ev} ecf_bf = {e["base_field"] for e in ecf_ev} ecf_fpd = ecfp["forma_pago_defaut"] ecf_fp_code = str(ecf_fpd["code"]) ecf_fp_cat = {str(f["code"]): f["label"] for f in ecfp["formas_pago"]} except (OSError, KeyError, ValueError, TypeError) as e: bad(f"Fiscal cross-cohérence · artefact ecf_plan illisible/inattendu : {e}"); ecf_ev = None if ecf_ev is not None: # bonus : plan INTERNEMENT cohérent (ensembles non vides · défaut ∈ catalogue) if not ecf_uv or not ecf_bf: bad("Fiscal cross-cohérence · aucun évènement d'émission (plan vide) → vérifier le plan") elif ecf_fp_code not in ecf_fp_cat: bad(f"Fiscal cross-cohérence · forma_pago_defaut.code={ecf_fp_code} ABSENT du " f"catalogue formas_pago {sorted(ecf_fp_cat)} → plan incohérent") else: good(f"Fiscal cross-cohérence · forma_pago_defaut {ecf_fp_code} " f"({ecf_fp_cat[ecf_fp_code]}) ∈ catalogue formas_pago · émission {sorted(ecf_uv)} non vide") ecf_rd2 = read_norm(ECF_RD2) if ecf_rd2 is not None: # 1) README:48 — états émetteurs « seulement réservation et contrat » me = re.search(r"seulement\s+([^.]+)\.", ecf_rd2) if me is None: bad(f"Fiscal cross-cohérence · {ECF_RD2} — énumération des états émetteurs " f"INTROUVABLE (attendu {sorted(ecf_uv)})") else: es = {_ecf_acc(t.strip()) for t in re.split(r"\bet\b|[,/]", me.group(1)) if t.strip()} if es == ecf_uv: good(f"Fiscal cross-cohérence · {ECF_RD2} — états émetteurs {sorted(es)} == " f"ecf_plan.emission_events[].update_value") else: miss = sorted(ecf_uv - es); extra = sorted(es - ecf_uv) detail = (f" · absents={miss}" if miss else "") + (f" · en trop={extra}" if extra else "") bad(f"Fiscal cross-cohérence · {ECF_RD2} — énumère {sorted(es)} MAIS plan " f"{sorted(ecf_uv)}{detail} → régénérer le README") # 2) README:50 — champs de base « (`montant_reservation`, `montant_contrat`) » mf = re.search(r"Currency réel\*\* du DocType Dossier Vente\s*\(([^)]*)\)", ecf_rd2) if mf is None: bad(f"Fiscal cross-cohérence · {ECF_RD2} — énumération des champs de base " f"INTROUVABLE (attendu {sorted(ecf_bf)})") else: bs = set(re.findall(r"`([a-z_]+)`", mf.group(1))) if bs == ecf_bf: good(f"Fiscal cross-cohérence · {ECF_RD2} — champs de base {sorted(bs)} == " f"ecf_plan.emission_events[].base_field") else: miss = sorted(ecf_bf - bs); extra = sorted(bs - ecf_bf) detail = (f" · absents={miss}" if miss else "") + (f" · en trop={extra}" if extra else "") bad(f"Fiscal cross-cohérence · {ECF_RD2} — énumère {sorted(bs)} MAIS plan " f"{sorted(ecf_bf)}{detail} → régénérer le README") # 3) README:59 — FormaPago défaut « = 3 (Tarjeta) … via Cardnet (#10) » mp = re.search(r"FormaPago défaut\s*=\s*(\d+)\s*\(([^)]*)\)", ecf_rd2) if mp is None: bad(f"Fiscal cross-cohérence · {ECF_RD2} — FormaPago défaut INTROUVABLE " f"(attendu code {ecf_fp_code} · {ecf_fp_cat.get(ecf_fp_code,'?')})") else: got_code = mp.group(1); got_lbl = _ecf_acc(mp.group(2).strip()) cat_lbl = _ecf_acc(ecf_fp_cat.get(ecf_fp_code, "")) has_cardnet = "cardnet" in _ecf_acc(ecf_rd2[mp.start():mp.start() + 160]) if got_code == ecf_fp_code and got_lbl and got_lbl in cat_lbl and has_cardnet: good(f"Fiscal cross-cohérence · {ECF_RD2} — FormaPago défaut {got_code} " f"({mp.group(2).strip()}) == forma_pago_defaut · ancré Cardnet (#10)") elif got_code != ecf_fp_code: bad(f"Fiscal cross-cohérence · {ECF_RD2} — FormaPago défaut = {got_code} MAIS " f"forma_pago_defaut.code = {ecf_fp_code} → régénérer le README") elif not (got_lbl and got_lbl in cat_lbl): bad(f"Fiscal cross-cohérence · {ECF_RD2} — libellé FormaPago {mp.group(2).strip()!r} " f"≠ catalogue code {ecf_fp_code} ({ecf_fp_cat.get(ecf_fp_code)!r}) → régénérer") else: bad(f"Fiscal cross-cohérence · {ECF_RD2} — FormaPago défaut sans ancrage " f"Cardnet (#10) à proximité → régénérer le README") # ============================================================================ # Fiscal · e-CF DGII (3e surface du MÊME README) · l'IDENTITÉ du RÔLE émetteur — # le seul rôle habilité à ÉMETTRE un e-CF, transcrit À LA MAIN sans gate d'IDENTITÉ. # ---------------------------------------------------------------------------- # La section « Cross-cohérence » du README (bullet `role_id`, README:51-54) NOMME # le rôle émetteur par TROIS attributs data-derived : # • README:54 « le rôle dédié `compta-fiscaliste-ecf` » — le `role_id` que TOUS # les emission_events d'ecf_plan.json (byte-gaté) référencent. # • README:54 « (_OTO Compta Fiscaliste eCF_) » — son `erpnext_role_name`. # • README:53 « appartenir au portail `compta` » — le `portail` de CE rôle dans # le contrat rbac_50_roles.json (schéma-validé · byte-gaté par les 3 MANIFEST). # Le bloc e-NCF gate le FORMAT de l'identifiant, le bloc cross-cohérence gate les # ÉTATS émetteurs / champs de base / FormaPago — mais AUCUN ne compare l'IDENTITÉ # de ce rôle à ses deux sources. RÉAFFECTER l'émission à un autre rôle (dans le # plan), le RENOMMER, ou DÉPLACER le rôle `compta-fiscaliste-ecf` du portail # `compta` vers un autre portail (dans le contrat : l'e-CF serait alors émis HORS # Compta — la séparation même que la cross-cohérence pose) laisse la prose périmée # en silence pendant que l'artefact/le contrat disent autre chose ⇒ l'agent ERPNext # Backend câblerait le mauvais rôle émetteur (le risque même que #6 interdit). # Aucune suite tests/ (qui teste des FONCTIONS ncf/résolution, pas la prose) ne # l'attrape — même patron d'IDENTITÉ que la Fiche Mobile ou les gardes CRM. On # RECOMPUTE `role_id`/`erpnext_role_name` de l'artefact et `portail`/nom Frappe du # contrat (zéro duplication) et on exige la prose EXACTE. Cohérences croisées bonus # (mordent un plan/contrat INTERNEMENT incohérent) : tous les emission_events # pointent UN SEUL rôle (fonction · non vide) · ce role_id existe UNE fois au # contrat · le nom résolu dans l'artefact == le contrat (zéro-dup) · portail == # `compta`. Un claim absent échoue AUSSI (traçabilité #6). # ============================================================================ ECF_RD3 = f"{D}/fiscal/ecf_dgii/README.md" try: ecfp3 = load("fiscal/ecf_dgii/out/ecf_plan.json") ecf_ev3 = ecfp3["emission_events"] ecf_rids = {e["role_id"] for e in ecf_ev3} ecf_rnames = {e["erpnext_role_name"] for e in ecf_ev3} ecf_contract = load("rbac/rbac_50_roles.json")["roles"] except (OSError, KeyError, ValueError, TypeError) as e: bad(f"Fiscal rôle émetteur · ecf_plan / rbac_50_roles illisible/inattendu : {e}"); ecf_ev3 = None if ecf_ev3 is not None: ecf_rd3 = read_norm(ECF_RD3) # bonus : tous les emission_events pointent UN SEUL rôle (fonction · non vide) if len(ecf_rids) != 1 or len(ecf_rnames) != 1: bad(f"Fiscal rôle émetteur · emission_events référencent {sorted(ecf_rids)} role_id / " f"{sorted(ecf_rnames)} noms (attendu 1 rôle émetteur unique) → plan incohérent") elif ecf_rd3 is not None: ecf_rid = next(iter(ecf_rids)); ecf_art_name = next(iter(ecf_rnames)) ecf_match = [r for r in ecf_contract if r.get("id") == ecf_rid] if len(ecf_match) != 1: bad(f"Fiscal rôle émetteur · le contrat porte {len(ecf_match)} rôle(s) `{ecf_rid}` " f"(attendu exactement 1) → rôle absent/dupliqué du contrat rbac_50_roles.json") else: rc = ecf_match[0] ecf_ct_name = rc.get("erpnext_role_name"); ecf_portail = rc.get("portail") # bonus zéro-duplication : le nom résolu dans l'artefact == le contrat if ecf_ct_name != ecf_art_name: bad(f"Fiscal rôle émetteur · ecf_plan dit erpnext_role_name={ecf_art_name!r} MAIS " f"contrat dit {ecf_ct_name!r} pour `{ecf_rid}` → régénérer l'artefact") # bonus séparation des pouvoirs : ce rôle est bien du portail compta (README:53) if ecf_portail != "compta": bad(f"Fiscal rôle émetteur · le rôle `{ecf_rid}` est du portail {ecf_portail!r} " f"≠ `compta` au contrat → l'e-CF serait émis HORS Compta (séparation cassée)") # prose : 3 claims data-derived recomputés (role_id · nom Frappe · portail) for label, pat, exp in [ ("role_id", rf"rôle dédié `{re.escape(ecf_rid)}`", ecf_rid), ("nom d'affichage ERPNext", rf"\(_{re.escape(ecf_ct_name)}_\)", ecf_ct_name), ("portail", rf"portail `{re.escape(ecf_portail)}`", ecf_portail), ]: if re.search(pat, ecf_rd3): good(f"Fiscal rôle émetteur · {label} « {exp} » == ecf_plan/contrat rbac") else: bad(f"Fiscal rôle émetteur · {ECF_RD3} — {label} : la prose ne cite pas " f"« {exp} » (recomputé de emission_events + rbac_50_roles) → régénérer") # ============================================================================ # CRM/Commissions · barème vendeurs — la CROSS-COHÉRENCE (« cœur du livrable ») # transcrite À LA MAIN dans le README sans AUCUN gate d'IDENTITÉ. # ---------------------------------------------------------------------------- # Le générateur commissions_gen émet out/commission_plan.json (byte-gaté par # check_artifacts ⇒ faisant autorité) : chaque évènement porte `update_value` # (l'état du pipeline qui PAIE) et `base_field` (le champ Currency de base). La # section « Cross-cohérence » du README ÉNUMÈRE À LA MAIN deux ensembles # data-derived + une propriété — le CŒUR du livrable : # • README:47 « on ne commissionne pas un brouillon (lead/visite/devis/ # abandonné) » — l'ensemble des états BROUILLON (doc_status=0 du workflow). # • README:50 « base_field … (`montant_reservation`, `montant_contrat`) » — # l'ensemble des champs de base cités par le plan. # • README:46 « correspondre à un état soumis (doc_status = 1) » — INVARIANT 3 # du module : on ne commissionne QUE des états soumis, jamais un brouillon. # Le bloc racine ne gate QUE le COMPTE de tests commissions (« 25 », via l'agrégat # « Total CRM »). Aucun gate ne comparait ces ENSEMBLES aux artefacts : PROMOUVOIR # un état brouillon en soumis (abandonne → doc_status 1) le retire de l'ensemble # brouillon pendant que la prose l'énumère encore ; AJOUTER un champ de base à un # évènement (base_field montant_confotur) l'ajoute sans que la prose bouge ; # RENOMMER un état passe aussi — « vert trompeur » qu'aucune suite tests/ (qui teste # des FONCTIONS finance/résolution, pas la prose) n'attrape. Même patron que # l'énumération des onglets Mobile, des transitions quatre-yeux CRM ou du format # e-NCF fiscal. On RECOMPUTE chaque ensemble depuis SON artefact byte-gaté (zéro # duplication du contrat du générateur #6) et on exige que la prose l'énumère # EXACTEMENT (absent ET en trop mordus via set-diff · accents/casse normalisés). # Cohérence croisée en bonus (mord un plan INTERNEMENT incohérent avec le workflow) : # tout `update_value` commissionné ∈ états soumis (doc_status=1) ET ∉ brouillons — # l'invariant 3 du module, non vide. Un claim absent échoue AUSSI (traçabilité). # ============================================================================ CM_RD = f"{D}/crm/commissions/README.md" def _cm_acc(s): return "".join(c for c in unicodedata.normalize("NFKD", s) if unicodedata.category(c) != "Mn") try: cm_ev = load("crm/commissions/out/commission_plan.json")["evenements"] cm_states = load("crm/workflow_vente/out/workflow.json")[0]["states"] cm_base = {e["base_field"] for e in cm_ev} cm_uv = {e["update_value"] for e in cm_ev} cm_draft = {s["update_value"] for s in cm_states if str(s["doc_status"]) == "0"} cm_subm = {s["update_value"] for s in cm_states if str(s["doc_status"]) == "1"} except (OSError, KeyError, ValueError, IndexError, TypeError) as e: bad(f"CRM commissions · artefact illisible/inattendu : {e}"); cm_ev = None if cm_ev is not None: # bonus : invariant 3 — tout état commissionné est SOUMIS (jamais brouillon), non vide if not cm_uv: bad("CRM commissions · aucun évènement commissionné (plan vide) → vérifier le barème") elif not (cm_uv <= cm_subm): bad(f"CRM commissions · incohérence plan↔workflow : update_values commissionnés " f"{sorted(cm_uv - cm_subm)} PAS soumis (doc_status≠1) → invariant 3 cassé") elif cm_uv & cm_draft: bad(f"CRM commissions · incohérence plan↔workflow : {sorted(cm_uv & cm_draft)} " f"commissionné(s) mais BROUILLON (doc_status=0) → invariant 3 cassé") else: good(f"CRM commissions · invariant 3 — {sorted(cm_uv)} tous soumis (doc_status=1), " f"aucun brouillon") cm_rd = read_norm(CM_RD) if cm_rd is not None: # 1) README:47 — ensemble des états BROUILLON exclus (doc_status=0 du workflow) mb = re.search(r"brouillon \(([^)]*)\)", cm_rd) if mb is None: bad(f"CRM commissions · {CM_RD} — énumération des brouillons exclus " f"INTROUVABLE (attendu {sorted(cm_draft)})") else: ps = {_cm_acc(t.strip().lower()) for t in re.split(r"[/,]", mb.group(1)) if t.strip()} exp = {_cm_acc(x) for x in cm_draft} if ps == exp: good(f"CRM commissions · {CM_RD} — brouillons exclus {sorted(ps)} == " f"workflow.json (doc_status=0)") else: miss = sorted(exp - ps); extra = sorted(ps - exp) detail = (f" · absents={miss}" if miss else "") + \ (f" · en trop={extra}" if extra else "") bad(f"CRM commissions · {CM_RD} — énumère {sorted(ps)} MAIS brouillons " f"workflow {sorted(exp)}{detail} → régénérer le README") # 2) README:50 — ensemble des champs de base (base_field du plan) mf = re.search(r"Currency réel\*\* du DocType Dossier Vente \(([^)]*)\)", cm_rd) if mf is None: bad(f"CRM commissions · {CM_RD} — énumération des champs de base " f"INTROUVABLE (attendu {sorted(cm_base)})") else: bs = set(re.findall(r"`([a-z_]+)`", mf.group(1))) if bs == cm_base: good(f"CRM commissions · {CM_RD} — champs de base {sorted(bs)} == " f"commission_plan.evenements[].base_field") else: miss = sorted(cm_base - bs); extra = sorted(bs - cm_base) detail = (f" · absents={miss}" if miss else "") + \ (f" · en trop={extra}" if extra else "") bad(f"CRM commissions · {CM_RD} — énumère {sorted(bs)} MAIS plan " f"{sorted(cm_base)}{detail} → régénérer le README") # ============================================================================ # RBAC/fixtures_gen · le CATALOGUE des DocTypes `custom` à créer sur le VPS et la # SÉPARATION DES POUVOIRS (le rôle unique porteur de `set_user_permissions`) — # deux faits data-derived transcrits À LA MAIN dans le README sans AUCUN gate # d'IDENTITÉ. # ---------------------------------------------------------------------------- # Le générateur rbac_fixtures_gen émet out/MANIFEST.json et out/custom_docperm.json # (byte-gatés par check_artifacts ⇒ faisant autorité) : # • MANIFEST.custom_doctypes_a_confirmer = l'ensemble des DocTypes `custom: true` # du contrat rbac_50_roles.json (ceux à CRÉER avant import — le reste est natif # v15). Le README §« Application sur VPS » l'ÉNUMÈRE À LA MAIN, en nommant même # le champ : « listés dans `MANIFEST.custom_doctypes_a_confirmer` (`Faisabilité`, # `Publiciste Log`, `CONFOTUR Application`, `API Access`) ». # • custom_docperm.json porte le flag `set_user_permissions`. Le CŒUR sécurité du # module (« séparation des pouvoirs · défense en profondeur ») est que ce flag # n'est émis que pour UN SEUL rôle — le README:48 dit « n'est émis que pour le # rôle **RBAC Admin** ». C'est l'ensemble {role | set_user_permissions=1}. # Le bloc racine ne gate QUE le COMPTE de tests fixtures (« 50 rôles / 116 DocPerm » # via l'agrégat RBAC de l'apply_plan) — jamais l'IDENTITÉ de ces deux ensembles. # Aucun gate ne les comparait à l'artefact : AJOUTER un DocType `custom` au contrat # (le MANIFEST rebâtit 5 entrées) OU RENOMMER/ÉCHANGER l'un des 4 laisse la prose à # ses 4 noms périmés ; PROMOUVOIR un 2ᵉ rôle porteur de `set_user_permissions` (ou # renommer RBAC Admin) laisse la prose « un seul rôle RBAC Admin » fausse — « vert # trompeur » qu'aucune suite tests/ (qui teste des FONCTIONS de mapping, pas la # prose du README) n'attrape. Même patron que l'énumération des onglets Mobile, des # brouillons CRM ou des open items de l'audit 5D. On RECOMPUTE chaque ensemble # depuis SON artefact byte-gaté (zéro duplication du contrat du générateur #6) et on # exige que la prose l'énumère/le nomme EXACTEMENT (absent ET en trop via set-diff · # accents/casse normalisés). Cohérence croisée en bonus : le catalogue est NON VIDE, # sans doublon et TRIÉ (byte-déterminisme du générateur) ; la séparation des pouvoirs # est un SINGLETON (ni vide — la garde serait vacante — ni multiple — élévation de # privilège). Un claim absent échoue AUSSI (traçabilité). # ============================================================================ FX_RD = f"{D}/rbac/fixtures_gen/README.md" def _fx_acc(s): return "".join(c for c in unicodedata.normalize("NFKD", s) if unicodedata.category(c) != "Mn").strip().lower() try: fx_cdt = load("rbac/fixtures_gen/out/MANIFEST.json")["custom_doctypes_a_confirmer"] fx_perm = load("rbac/fixtures_gen/out/custom_docperm.json") fx_sup = sorted({p["role"] for p in fx_perm if p.get("set_user_permissions")}) except (OSError, KeyError, ValueError, TypeError) as e: bad(f"RBAC fixtures · artefact illisible/inattendu : {e}"); fx_cdt = None if fx_cdt is not None: # bonus : le catalogue est un vrai ENSEMBLE trié non vide (byte-déterminisme) if not fx_cdt: bad("RBAC fixtures · custom_doctypes_a_confirmer VIDE → aucun DocType custom " "à créer ? (contrat suspect)") elif fx_cdt != sorted(set(fx_cdt)): bad(f"RBAC fixtures · custom_doctypes_a_confirmer {fx_cdt} non trié/dédupliqué " f"→ générateur non déterministe (attendu {sorted(set(fx_cdt))})") else: good(f"RBAC fixtures · catalogue custom {fx_cdt} — ensemble trié, sans doublon") # bonus : séparation des pouvoirs — un SEUL rôle porte set_user_permissions if len(fx_sup) != 1: bad(f"RBAC fixtures · set_user_permissions porté par {fx_sup} (attendu 1 seul " f"rôle) → séparation des pouvoirs cassée (élévation de privilège possible)") else: good(f"RBAC fixtures · set_user_permissions — singleton {fx_sup[0]!r} " f"(défense en profondeur intacte)") fx_rd = read_norm(FX_RD) if fx_rd is not None: # 1) README — énumération du catalogue custom (parenthèse après le champ nommé) me = re.search(r"custom_doctypes_a_confirmer`?\s*\(([^)]*)\)", fx_rd) if me is None: bad(f"RBAC fixtures · {FX_RD} — énumération du catalogue custom INTROUVABLE " f"(attendu {sorted(fx_cdt)})") else: got = {_fx_acc(t) for t in re.findall(r"`([^`]+)`", me.group(1))} exp = {_fx_acc(x) for x in fx_cdt} if got == exp: good(f"RBAC fixtures · {FX_RD} — catalogue custom {sorted(got)} == " f"MANIFEST.custom_doctypes_a_confirmer") else: miss = sorted(exp - got); extra = sorted(got - exp) detail = (f" · absents={miss}" if miss else "") + \ (f" · en trop={extra}" if extra else "") bad(f"RBAC fixtures · {FX_RD} — énumère {sorted(got)} MAIS MANIFEST " f"{sorted(exp)}{detail} → régénérer le README") # 2) README:48 — le rôle NOMMÉ porteur de set_user_permissions (séparation) if len(fx_sup) == 1: mr = re.search(r"émis que pour le rôle \*\*([^*]+)\*\*", fx_rd) if mr is None: bad(f"RBAC fixtures · {FX_RD} — claim « set_user_permissions n'est émis " f"que pour le rôle ** ** » INTROUVABLE (attendu {fx_sup[0]!r})") elif _fx_acc(mr.group(1)) in _fx_acc(fx_sup[0]): good(f"RBAC fixtures · {FX_RD} — rôle nommé « {mr.group(1).strip()} » ⊆ " f"{fx_sup[0]!r} (le seul porteur du flag)") else: bad(f"RBAC fixtures · {FX_RD} — nomme « {mr.group(1).strip()} » MAIS " f"set_user_permissions porté par {fx_sup[0]!r} → régénérer le README") # ============================================================================ # SEO · la COMPOSITION du graphe schema.org (JSON-LD) — un nœud racine # `Organization` + une `Residence` PAR PROJET — décrite EN PROSE dans le README # sans AUCUN gate d'IDENTITÉ. # ---------------------------------------------------------------------------- # Le générateur SEO émet out/seo_schema_org.json (byte-gaté par check_artifacts ⇒ # faisant autorité). Son `@graph` est l'ENSEMBLE des nœuds JSON-LD : exactement UN # nœud racine `Organization` (@id …#organization) + un listing PAR PROJET, tous du # même @type. Le README:16 décrit cette structure À LA MAIN : « graphe JSON-LD # (`Organization` + une `Residence` par projet) ». Le bloc SEO existant ne gate que # le COMPTE de mots-clés (keywords_total + ventilation par langue) — jamais les # @type du graphe ni la bijection un-listing-par-projet. Aucun gate ne comparait # ces TYPES à l'artefact : RENOMMER le @type des listings (Residence → Apartment, # rango schema.org) OU AJOUTER un 3ᵉ type de nœud (Offer/Product) OU casser la # bijection (2 nœuds pour un projet) ferait dériver la prose en silence pendant que # le graphe byte-gaté dit autre chose — « vert trompeur » qu'aucune suite tests/ # (qui teste des FONCTIONS de génération, pas la prose du README) n'attrape. Même # patron que l'énumération des onglets Mobile ou du catalogue custom RBAC. On # RECOMPUTE l'ENSEMBLE des @type depuis SON artefact byte-gaté (zéro duplication du # contrat du générateur #6) et on exige que la prose les nomme EXACTEMENT (absent ET # en trop via set-diff). Cohérences croisées en bonus : le nœud racine Organization # est un SINGLETON ; les listings sont uniformes (un seul @type) et en BIJECTION avec # les projets (listings == projects == schema_org_nodes−1 du MANIFEST byte-gaté) ; # aucune `offers` (statuts sans prix · USD #10). Un claim absent échoue AUSSI. # ============================================================================ SEO_SG_RD = f"{D}/seo/README.md" try: sg = load("seo/out/seo_schema_org.json")["@graph"] seo_cnt = load("seo/out/MANIFEST.json")["counts"] sg_org = [n for n in sg if str(n.get("@id", "")).endswith("#organization")] sg_list = [n for n in sg if not str(n.get("@id", "")).endswith("#organization")] sg_types = {n["@type"] for n in sg} # ensemble des @type du graphe org_types = {n["@type"] for n in sg_org} list_types = {n["@type"] for n in sg_list} sg_offers = sum(1 for n in sg if "offers" in n) except (OSError, KeyError, ValueError, TypeError) as e: bad(f"SEO schema.org · artefact illisible/inattendu : {e}"); sg = None if sg is not None: n_proj = seo_cnt.get("projects") # bonus 1 : racine Organization = SINGLETON, d'un seul @type if len(sg_org) != 1 or len(org_types) != 1: bad(f"SEO schema.org · nœud racine #organization non-singleton " f"({len(sg_org)} nœud(s), types={sorted(org_types)}) → graphe suspect") else: good(f"SEO schema.org · racine {sorted(org_types)[0]!r} — singleton (1 nœud)") # bonus 2 : listings uniformes + bijection 1 par projet + total du MANIFEST if not sg_list or len(list_types) != 1: bad(f"SEO schema.org · listings non uniformes (types={sorted(list_types)}) " f"→ un @type unique attendu par projet") elif not (len(sg_list) == n_proj == seo_cnt.get("listings") == seo_cnt.get("schema_org_nodes", 0) - 1): bad(f"SEO schema.org · bijection cassée : {len(sg_list)} listing(s) MAIS " f"projects={n_proj} listings={seo_cnt.get('listings')} " f"schema_org_nodes={seo_cnt.get('schema_org_nodes')} (attendu 1+{n_proj})") else: good(f"SEO schema.org · {len(sg_list)} × {sorted(list_types)[0]!r} — bijection " f"un listing par projet ({n_proj}) · total {seo_cnt.get('schema_org_nodes')} nœuds") # bonus 3 : aucune offers/prix (statuts sans prix · #10 · cohérent MANIFEST.offers) if sg_offers != 0 or seo_cnt.get("offers") != 0: bad(f"SEO schema.org · {sg_offers} nœud(s) `offers` (MANIFEST.offers=" f"{seo_cnt.get('offers')}) → prix non attendu pour statuts sans prix (#10)") else: good("SEO schema.org · aucune `offers`/prix (statuts sans prix · USD #10)") seo_sg_rd = read_norm(SEO_SG_RD) if seo_sg_rd is not None: # README:16 — la parenthèse décrit la composition du graphe : ENSEMBLE des @type msg = re.search(r"graphe JSON-LD \(([^)]*)\)", seo_sg_rd) if msg is None: bad(f"SEO schema.org · {SEO_SG_RD} — composition « graphe JSON-LD (…) » " f"INTROUVABLE (attendu {sorted(sg_types)})") else: prose_types = set(re.findall(r"`([A-Za-z]+)`", msg.group(1))) if prose_types == sg_types: good(f"SEO schema.org · {SEO_SG_RD} — @type {sorted(prose_types)} == " f"seo_schema_org.@graph") else: miss = sorted(sg_types - prose_types); extra = sorted(prose_types - sg_types) detail = (f" · absents={miss}" if miss else "") + \ (f" · en trop={extra}" if extra else "") bad(f"SEO schema.org · {SEO_SG_RD} — nomme {sorted(prose_types)} MAIS " f"graphe {sorted(sg_types)}{detail} → régénérer le README") # README:16 — le type PAR PROJET nommé == le @type des listings (racine distincte) if len(list_types) == 1: mpp = re.search(r"une `([A-Za-z]+)` par projet", seo_sg_rd) lt = sorted(list_types)[0] if mpp is None: bad(f"SEO schema.org · {SEO_SG_RD} — « une `` par projet » " f"INTROUVABLE (attendu {lt!r})") elif mpp.group(1) == lt: good(f"SEO schema.org · {SEO_SG_RD} — « une `{lt}` par projet » == " f"listings du graphe") else: bad(f"SEO schema.org · {SEO_SG_RD} — dit « une `{mpp.group(1)}` par projet » " f"MAIS listings sont `{lt}` → régénérer le README") # ============================================================================ # SEO · l'IDENTITÉ du nœud racine `Organization` du graphe schema.org (JSON-LD) # — le nom de marque `Helios RD` (marque publique · CLAUDE.md §Entités) + son # `url` == base_url du site — ANCRÉE sur CLAUDE.md, jamais gatée. # ---------------------------------------------------------------------------- # out/seo_schema_org.json est byte-gaté par check_artifacts : la reproductibilité # prouve qu'il SE RECONSTRUIT depuis seo_spec.json, PAS que son `Organization.name` # == la marque publique nommée dans CLAUDE.md §Entités (« **Helios RD** (marque # publique) sous WAG »). Le bloc SEO schema.org amont gate la COMPOSITION (@type + # bijection un-listing-par-projet), jamais l'IDENTITÉ de la marque ni le rattachement # des nœuds au site. Piège #6 : RENOMMER la marque dans # seo_spec.json[schema_org.organization.name] (ou dans CLAUDE.md §Entités) laisse le # JSON-LD PUBLIC — celui que Google indexe sous `vente.otov7.com` — émettre un nom de # marque qui CONTREDIT le mandat pendant que la byte-gate reste VERTE (l'artefact se # reconstruit fidèlement… depuis un spec dérivé) — « vert trompeur » de la classe des # tokens branding ancrés sur CLAUDE.md #4. Aucune suite tests/ (FONCTIONS de # génération, jamais l'ancrage à CLAUDE.md) ne l'attrape. On RE-DÉRIVE la marque de # CLAUDE.md §Entités (zéro duplication · source unique) + le base_url du MANIFEST # byte-gaté + le country_code du spec, puis on exige : (a) Organization.name == marque # CLAUDE.md ; (b) Organization.url == base_url et @id sous base_url ; (c) chaque nœud # listing rattaché à CETTE Organization (brand.@id == org.@id) et sous base_url ; # (d) addressCountry UNIFORME == country_code du spec (géo-ciblage appliqué à # l'identique à chaque nœud) ; (e) le spec DÉCLARE l'ancrage (organization.name == # marque + `source` cite CLAUDE.md). État courant : aucune valeur périmée — le défaut # est la surface ungated. # ============================================================================ _nfc_org = lambda s: unicodedata.normalize("NFC", str(s)) try: cm_ent = open("CLAUDE.md", encoding="utf-8").read() _mb = re.findall(r"\*\*([^*]+?)\*\*\s*\(marque publique\)", cm_ent) if len(_mb) != 1: raise ValueError(f"« (marque publique) » attendu 1× dans CLAUDE.md §Entités, " f"trouvé {len(_mb)}×") brand = _nfc_org(_mb[0].strip()) except (OSError, ValueError) as e: bad(f"SEO org · marque publique CLAUDE.md §Entités illisible/inattendue : {e}"); brand = None try: sog = load("seo/out/seo_schema_org.json")["@graph"] seo_base = load("seo/out/MANIFEST.json")["base_url"] with open(f"{D}/seo/seo_spec.json", encoding="utf-8") as _f: spec_sorg = json.load(_f)["schema_org"] seo_cc = spec_sorg["country_code"] spec_orgn = spec_sorg["organization"] org_n = [n for n in sog if str(n.get("@id", "")).endswith("#organization")] lst_n = [n for n in sog if not str(n.get("@id", "")).endswith("#organization")] except (OSError, KeyError, ValueError, TypeError) as e: bad(f"SEO org · artefact/spec schema.org illisible/inattendu : {e}"); sog = None if brand is not None and sog is not None: # (0) racine Organization = singleton (pré-requis à toute assertion d'identité) if len(org_n) != 1: bad(f"SEO org · nœud racine #organization non-singleton ({len(org_n)}) " f"→ identité de marque indéterminable") else: org = org_n[0] # (a) ANCRAGE : Organization.name == marque publique CLAUDE.md §Entités got_name = _nfc_org(org.get("name", "")) if got_name == brand: good(f"SEO org · Organization.name {got_name!r} == marque publique " f"CLAUDE.md §Entités") else: bad(f"SEO org · Organization.name {got_name!r} ≠ CLAUDE.md §Entités {brand!r} " f"→ JSON-LD public contredit le mandat (régénérer spec/artefact ou aligner CLAUDE.md)") # (b) Organization.url == base_url + @id sous base_url if org.get("url") == seo_base and str(org.get("@id", "")) == f"{seo_base}/#organization": good(f"SEO org · Organization.url == base_url ({seo_base}) · @id cohérent") else: bad(f"SEO org · Organization.url={org.get('url')!r}/@id={org.get('@id')!r} " f"≠ base_url {seo_base!r}(+/#organization) → graphe incohérent") # (c) chaque listing rattaché à CETTE Organization (brand.@id), sous base_url bad_brand = [n.get("@id") for n in lst_n if (n.get("brand") or {}).get("@id") != org.get("@id")] bad_url = [n.get("@id") for n in lst_n if not str(n.get("url", "")).startswith(f"{seo_base}/")] if not lst_n: bad("SEO org · aucun nœud listing → graphe suspect") elif bad_brand or bad_url: d = (f" · brand.@id≠org: {bad_brand}" if bad_brand else "") + \ (f" · url hors base_url: {bad_url}" if bad_url else "") bad(f"SEO org · {len(lst_n)} listing(s) mais rattachement/URL cassé{d}") else: good(f"SEO org · {len(lst_n)} listing(s) rattaché(s) à l'Organization " f"(brand.@id) sous base_url") # (d) géo-ciblage : addressCountry UNIFORME == country_code du spec (code ISO 2) if not re.fullmatch(r"[A-Z]{2}", str(seo_cc)): bad(f"SEO org · country_code du spec {seo_cc!r} non conforme " f"(code pays ISO 2 lettres attendu)") else: countries = {(n.get("address") or {}).get("addressCountry") for n in lst_n} if countries == {seo_cc}: good(f"SEO org · addressCountry uniforme == country_code spec ({seo_cc}) " f"sur {len(lst_n)} nœud(s)") else: bad(f"SEO org · addressCountry {sorted(map(str, countries))} ≠ {{{seo_cc}}} du spec " f"→ géo-ciblage non uniforme/divergent") # (e) le spec DÉCLARE l'ancrage : organization.name == marque + source cite CLAUDE.md spec_name = _nfc_org(spec_orgn.get("name", "")) spec_src = spec_orgn.get("source", "") if spec_name != brand: bad(f"SEO org · seo_spec.json organization.name {spec_name!r} ≠ CLAUDE.md {brand!r} " f"(l'input du générateur DÉRIVE de CLAUDE.md §Entités)") elif "CLAUDE.md" not in spec_src or brand not in spec_src: bad(f"SEO org · seo_spec.json organization.source ne DÉCLARE pas l'ancrage " f"CLAUDE.md/{brand!r} : {spec_src!r}") else: good(f"SEO org · seo_spec.json déclare l'ancrage CLAUDE.md §Entités pour {brand!r}") # ============================================================================ # SEO · l'IDENTITÉ du CATALOGUE PROJETS (P01..P09) — les 9 projets (`code` + # `nom`) que le bundle SEO expose à Google — ANCRÉE sur CLAUDE.md §Projets. # ---------------------------------------------------------------------------- # La matière première du worker SEO est `seo/fixtures/projets_master.json` # (README:22 « sortie canonique … le worker ne fabrique aucun fait de projet »). # Chaque entrée DÉCLARE `source.fichiers: ["CLAUDE.md#Projets"]` et le README:80 # l'affirme (« 9 projets P01..P09 · noms sourcés de `CLAUDE.md §Projets` ») — MAIS # AUCUN gate ne le VÉRIFIAIT. check_artifacts prouve QUE les out/ SE RECONSTRUISENT # depuis la fixture (byte-for-byte), PAS que ses `code`/`nom` == CLAUDE.md §Projets ; # les blocs SEO amont gatent les COMPTES de mots-clés + la COMPOSITION du graphe # schema.org (bijection un-listing-par-projet) + l'IDENTITÉ de la marque racine # (§Entités), tous AVEUGLES à l'identité de la LISTE de projets. Piège #6 : # RENOMMER un projet dans CLAUDE.md §Projets (« Aqua Terra Las Terrenas » → « … Bay »), # le RENOMMER/AJOUTER/RETIRER dans la fixture, puis régénérer, laisse le JSON-LD PUBLIC # — celui que Google indexe sous `vente.otov7.com/projets/…` — lister un projet # FANTÔME ou en OMETTRE un canonique pendant que la byte-gate reste VERTE (l'artefact # se reconstruit fidèlement… depuis une fixture DÉRIVÉE) et que le compte reste 9 — # « vert trompeur » de la MÊME classe que la marque org (§Entités), les entités # CONFOTUR (§Entités) et les libellés projets du run_sheet démo (§Projets). Aucune # suite tests/ (FONCTIONS de génération, jamais l'ancrage à CLAUDE.md) ne l'attrape. # On RE-DÉRIVE le catalogue de CLAUDE.md §Projets (zéro duplication · source unique) # puis on exige : (a) identité d'ensemble des codes fixture ⇔ CLAUDE.md (aucun # fantôme/manquant · pas de doublon) ; (b) `nom` de chaque entrée == CLAUDE.md §Projets # (accent-insensible · la fixture normalise « Xamaná »→« Xamana ») ; (c) chaque entrée # DÉCLARE l'ancrage `CLAUDE.md#Projets` dans `source.fichiers` ; (d) les `name` des # listings du graphe schema.org PUBLIC == l'ensemble des noms CLAUDE.md §Projets + # bijection (1 listing/projet) + MANIFEST counts projects/listings == |§Projets| ; # (e) le README cite l'ancrage CLAUDE.md §Projets. Un claim absent échoue AUSSI # (disparition = régression). État courant : aucune valeur périmée. # ============================================================================ _pnfc = lambda s: unicodedata.normalize("NFC", str(s)) _pai = lambda s: "".join(c for c in unicodedata.normalize("NFKD", str(s)) if unicodedata.category(c) != "Mn").lower().replace(" ", "") try: cm_pj = open("CLAUDE.md", encoding="utf-8").read() _mpj = re.search(r"^## Projets\s*\n(.+)$", cm_pj, re.M) if _mpj is None: raise ValueError("section « ## Projets » INTROUVABLE") seo_pmap = {} for _t in _mpj.group(1).split("·"): _mc = re.match(r"(P\d{2})\s+(.+)$", _pnfc(_t.strip())) if _mc: seo_pmap[_mc.group(1)] = _mc.group(2).strip() if not seo_pmap: raise ValueError("aucun projet PNN parsé") except (OSError, ValueError) as e: bad(f"SEO projets · CLAUDE.md §Projets illisible/inattendu : {e}"); seo_pmap = None try: with open(f"{D}/seo/fixtures/projets_master.json", encoding="utf-8") as _f: seo_pm = json.load(_f)["projets"] sg_pj = load("seo/out/seo_schema_org.json")["@graph"] seo_cpj = load("seo/out/MANIFEST.json")["counts"] lst_pj = [n for n in sg_pj if not str(n.get("@id", "")).endswith("#organization")] except (OSError, KeyError, ValueError, TypeError) as e: bad(f"SEO projets · fixture/artefact illisible/inattendu : {e}"); seo_pm = None if seo_pmap is not None and seo_pm is not None: # (a) ANCRAGE : identité d'ensemble des codes fixture ⇔ CLAUDE.md §Projets fix_codes = [p.get("code") for p in seo_pm] if len(set(fix_codes)) != len(fix_codes) or None in fix_codes or "" in fix_codes: bad(f"SEO projets · projets_master : codes en doublon/vides {fix_codes}") fx = {c for c in fix_codes if c} miss = sorted(set(seo_pmap) - fx); extra = sorted(fx - set(seo_pmap)) if miss or extra: bad(f"SEO projets · codes projets_master ≠ CLAUDE.md §Projets" f"{' · manquants=' + str(miss) if miss else ''}" f"{' · fantômes=' + str(extra) if extra else ''}") else: good(f"SEO projets · {len(fx)} codes P01..P09 projets_master == CLAUDE.md §Projets") # (b)+(c) nom ancré + déclaration de l'ancrage, par projet for p in seo_pm: code = p.get("code"); nom = _pnfc(p.get("nom", "")) anchor = seo_pmap.get(code) if anchor is None: continue # code fantôme : déjà mordu par le set-diff (a) if _pai(nom) != _pai(anchor): bad(f"SEO projets · {code} nom « {nom} » (projets_master) DÉRIVE de " f"CLAUDE.md §Projets « {anchor} » → aligner (ou CLAUDE.md)") else: good(f"SEO projets · {code} — nom « {nom} » == CLAUDE.md §Projets") srcf = (p.get("source") or {}).get("fichiers") or [] if not any("CLAUDE.md" in str(x) and "Projets" in str(x) for x in srcf): bad(f"SEO projets · {code} — projets_master ne DÉCLARE pas l'ancrage " f"`CLAUDE.md#Projets` dans source.fichiers : {srcf}") # (d) artefact PUBLIC : `name` des listings schema.org == noms CLAUDE.md §Projets if not lst_pj: bad("SEO projets · aucun nœud listing schema.org → graphe suspect") else: got_n = {_pai(n.get("name", "")) for n in lst_pj} want_n = {_pai(v) for v in seo_pmap.values()} gm = sorted(want_n - got_n); ge = sorted(got_n - want_n) if gm or ge: bad(f"SEO projets · `name` des listings schema.org (JSON-LD public) ≠ " f"CLAUDE.md §Projets{' · manquants=' + str(gm) if gm else ''}" f"{' · en trop=' + str(ge) if ge else ''}") elif len(lst_pj) != len(seo_pmap): bad(f"SEO projets · {len(lst_pj)} listing(s) schema.org MAIS " f"{len(seo_pmap)} projet(s) CLAUDE.md §Projets (bijection cassée)") else: good(f"SEO projets · {len(lst_pj)} listings schema.org (name) == CLAUDE.md " f"§Projets (JSON-LD indexé fidèle au mandat)") if seo_cpj.get("projects") != len(seo_pmap) or seo_cpj.get("listings") != len(seo_pmap): bad(f"SEO projets · MANIFEST counts projects={seo_cpj.get('projects')}/" f"listings={seo_cpj.get('listings')} ≠ {len(seo_pmap)} (CLAUDE.md §Projets)") else: good(f"SEO projets · MANIFEST counts projects/listings == {len(seo_pmap)} (§Projets)") # (e) le README cite l'ancrage CLAUDE.md §Projets seo_p_rd = read_norm(f"{D}/seo/README.md") if seo_p_rd is not None: if re.search(r"CLAUDE\.md\s*§?\s*Projets", seo_p_rd): good("SEO projets · README cite l'ancrage CLAUDE.md §Projets") else: bad("SEO projets · README ne cite pas l'ancrage CLAUDE.md §Projets " "(attendu « noms sourcés de CLAUDE.md §Projets »)") # ============================================================================ # SEO · la CARTE hreflang (`alternate` FR/EN/ES + `x-default` par page) — la # 3ᵉ surface du même README SEO — décrite EN PROSE sans AUCUN gate d'IDENTITÉ. # ---------------------------------------------------------------------------- # Le générateur SEO émet out/seo_hreflang.json (byte-gaté par check_artifacts ⇒ # faisant autorité) : une page par projet + accueil, chacune portant un ENSEMBLE # d'`alternate` (un par langue de contenu) + un `x-default`. Le README:17 décrit # cet ensemble À LA MAIN : « carte `alternate` FR/EN/ES + `x-default` par page ». # Les deux blocs SEO existants gatent le COMPTE de mots-clés (keywords_total + # ventilation par langue, artefact seo_keywords) et la composition du graphe # schema.org (artefact seo_schema_org) — jamais le jeu de langues hreflang, qui # vit dans un TROISIÈME artefact (seo_hreflang) au contenu distinct. Aucun gate # ne comparait ces CODES à l'artefact : AJOUTER une langue au bundle (ex. `pt` # → hreflang à 4 langues de contenu) OU RETIRER/ÉCHANGER `es` ferait dériver la # prose en silence pendant que la carte byte-gatée dit autre chose — « vert # trompeur » qu'aucune suite tests/ (qui teste des FONCTIONS de génération, pas # la prose du README) n'attrape. Même patron que l'énumération des onglets # Mobile, du catalogue custom RBAC ou des @type schema.org. On RECOMPUTE # l'ENSEMBLE des langues de contenu depuis SON artefact byte-gaté (zéro # duplication de la carte du générateur #6) et on exige que la prose les nomme # EXACTEMENT (absent ET en trop via set-diff) + mentionne `x-default`. Cohérences # croisées en bonus (mordent un artefact INTERNEMENT incohérent) : toutes les # pages portent le MÊME jeu d'`alternate` (uniforme) ; `x-default` présent sur # CHAQUE page ; la langue par défaut (`x_default_lang`) ∈ langues de contenu (ni # vacante ni étrangère) ; pour chaque page `x-default`.href == `canonical` == # href de la langue par défaut (l'invariant « x-default = langue par défaut = # canonical » du README:61). Un claim absent échoue AUSSI. # ============================================================================ SEO_HL_RD = f"{D}/seo/README.md" try: hl = load("seo/out/seo_hreflang.json") hl_pages = hl["pages"] x_default_lang = hl["x_default_lang"] page_sets = [frozenset(a["hreflang"] for a in p["alternates"]) for p in hl_pages] all_codes = set().union(*page_sets) if page_sets else set() content_langs = all_codes - {"x-default"} # langues de contenu (hors x-default) except (OSError, KeyError, ValueError, TypeError) as e: bad(f"SEO hreflang · artefact illisible/inattendu : {e}"); hl_pages = None if hl_pages: # bonus 1 : toutes les pages portent le MÊME jeu d'alternate (uniforme) if len(set(page_sets)) != 1: bad(f"SEO hreflang · carte NON uniforme : {len(set(page_sets))} jeux distincts " f"d'`alternate` sur {len(hl_pages)} pages → une carte hétérogène") else: good(f"SEO hreflang · {len(hl_pages)} pages · jeu d'`alternate` uniforme " f"{sorted(all_codes)}") # bonus 2 : x-default sur chaque page + langue par défaut ∈ langues de contenu if "x-default" not in all_codes or any("x-default" not in s for s in page_sets): bad("SEO hreflang · `x-default` absent d'au moins une page → carte incomplète") elif x_default_lang not in content_langs: bad(f"SEO hreflang · x_default_lang={x_default_lang!r} ∉ langues de contenu " f"{sorted(content_langs)} → défaut vacant/étranger") else: good(f"SEO hreflang · `x-default` sur chaque page · défaut {x_default_lang!r} " f"∈ langues de contenu") # bonus 3 : href-cohérence — x-default == canonical == href(langue par défaut) incoh = [] for p in hl_pages: alt = {a["hreflang"]: a["href"] for a in p["alternates"]} if not (alt.get("x-default") == p.get("canonical") == alt.get(x_default_lang)): incoh.append(p.get("page")) if incoh: bad(f"SEO hreflang · href incohérent (x-default ≠ canonical ≠ {x_default_lang}) " f"sur {incoh} → artefact interne incohérent") else: good(f"SEO hreflang · `x-default`.href == `canonical` == href({x_default_lang}) " f"(les {len(hl_pages)} pages)") # README:17 — la cellule « carte `alternate` + `x-default` par page » seo_hl_rd = read_norm(SEO_HL_RD) if seo_hl_rd is not None: m = re.search(r"carte `alternate` (.+?) par page", seo_hl_rd) if m is None: bad(f"SEO hreflang · {SEO_HL_RD} — carte « `alternate` … par page » " f"INTROUVABLE (attendu {sorted(content_langs)} + x-default)") else: seg = m.group(1) prose_langs = {t.lower() for t in re.findall(r"\b[A-Z]{2}\b", seg.split("+")[0])} has_xdef = "x-default" in seg if prose_langs == content_langs and has_xdef: good(f"SEO hreflang · {SEO_HL_RD} — langues {sorted(prose_langs)} + " f"`x-default` == seo_hreflang") else: miss = sorted(content_langs - prose_langs) extra = sorted(prose_langs - content_langs) detail = (f" · absents={miss}" if miss else "") + \ (f" · en trop={extra}" if extra else "") + \ ("" if has_xdef else " · `x-default` non mentionné") bad(f"SEO hreflang · {SEO_HL_RD} — nomme {sorted(prose_langs)}" f"{'+x-default' if has_xdef else ''} MAIS carte " f"{sorted(content_langs)}+x-default{detail} → régénérer le README") # ============================================================================ # SEO · FICHE AGENT (03_agents/seo/AGENT.md) · les cellules « Métrique vérifiée » # restituent DEUX attributs data-derived du bundle SEO — gatées seulement côté # README, JAMAIS sur la fiche. # ---------------------------------------------------------------------------- # Le tableau « Livrable réellement produit » (fiche:24-26) transcrit À LA MAIN, # à côté du compte de mots-clés (lui déjà gaté sur README **et** fiche via le # bloc SEO_FI amont), deux sorties byte-gatées mais AVEUGLÉES côté fiche : # L25 « graphe JSON-LD : **10 nœuds** (`Organization` + 9 `Residence`) » # L26 « **10 pages** × FR/EN/ES + `x-default` ». # Les blocs SEO schema.org / hreflang ci-dessus ne lisent QUE le README # (SEO_SG_RD / SEO_HL_RD) : RENOMMER le @type des listings, AJOUTER/RETIRER un # projet (→ N nœuds & N pages dérivent) ou ÉCHANGER une langue de contenu laisse # ces cellules périmées pendant que les trois artefacts byte-gatés disent autre # chose — « vert trompeur » de la classe des fiches-agent qui restituent des # attributs data-derived gatés seulement en AGRÉGAT (mémoire # agent-fiche-role-attrs-ungated ; même patron que la cellule chat_otoia de la # fiche Frontend Console). Aucune suite tests/ (FONCTIONS du builder, jamais la # prose de la fiche) ne l'attrape. On RECOMPUTE depuis les MÊMES artefacts # byte-gatés (schema.org + hreflang) réutilisés par les blocs README ci-dessus # (zéro duplication #6) et on exige l'IDENTITÉ EXACTE ; un claim absent échoue # AUSSI (INTROUVABLE = régression #6). Cohérence arithmétique en bonus : total # nœuds == 1 racine + M listings (un graphe interne incohérent est mordu). # ============================================================================ seo_fi = read_norm(SEO_FI) # SEO_FI défini au bloc « mots-clés » amont if seo_fi is not None and sg is not None: # fiche:25 — « graphe JSON-LD : **N nœuds** (`` + M ``) » root_t = sorted(org_types)[0] if len(org_types) == 1 else None list_t = sorted(list_types)[0] if len(list_types) == 1 else None n_nodes = seo_cnt.get("schema_org_nodes") n_list = seo_cnt.get("listings") m = re.search(r"graphe JSON-LD : \*\*(\d+) nœuds\*\* " r"\(`([A-Za-z]+)` \+ (\d+) `([A-Za-z]+)`\)", seo_fi) if m is None: bad(f"SEO fiche · {SEO_FI} — cellule « graphe JSON-LD : **N nœuds** " f"(`` + M ``) » INTROUVABLE (attendu {n_nodes} nœuds : " f"1 `{root_t}` + {n_list} `{list_t}`)") else: g_tot, g_root = int(m.group(1)), m.group(2) g_m, g_list = int(m.group(3)), m.group(4) errs = [] if g_tot != n_nodes: errs.append(f"total {g_tot}≠{n_nodes}") if g_root != root_t: errs.append(f"racine `{g_root}`≠`{root_t}`") if g_m != n_list: errs.append(f"listings {g_m}≠{n_list}") if g_list != list_t: errs.append(f"type listing `{g_list}`≠`{list_t}`") if g_tot != 1 + g_m: errs.append(f"arith {g_tot}≠1+{g_m}") if errs: bad(f"SEO fiche · {SEO_FI}:25 — {' · '.join(errs)} → régénérer la fiche") else: good(f"SEO fiche · {SEO_FI}:25 — {g_tot} nœuds (1 `{g_root}` + {g_m} " f"`{g_list}`) == seo_schema_org.@graph") if seo_fi is not None and hl_pages: # fiche:26 — « **N pages** × + `x-default` » exp_langs = "/".join(c.upper() for c in sorted(content_langs)) m = re.search(r"\*\*(\d+) pages\*\* × ([A-Z/]+) \+ `x-default`", seo_fi) if m is None: bad(f"SEO fiche · {SEO_FI} — cellule « **N pages** × + `x-default` » " f"INTROUVABLE (attendu {len(hl_pages)} pages × {exp_langs})") else: g_pages = int(m.group(1)) g_langs = {t.lower() for t in m.group(2).split("/") if t} errs = [] if g_pages != len(hl_pages): errs.append(f"pages {g_pages}≠{len(hl_pages)}") if g_langs != content_langs: miss = sorted(content_langs - g_langs); extra = sorted(g_langs - content_langs) errs.append(f"langues {sorted(g_langs)}≠{sorted(content_langs)}" + (f" absents={miss}" if miss else "") + (f" en trop={extra}" if extra else "")) if errs: bad(f"SEO fiche · {SEO_FI}:26 — {' · '.join(errs)} → régénérer la fiche") else: good(f"SEO fiche · {SEO_FI}:26 — {g_pages} pages × {exp_langs} + " f"`x-default` == seo_hreflang") # ============================================================================ # CRM/dossier_vente (2e surface) · CATALOGUE DE PROJETS « P01..P09 ancré sur # CLAUDE.md » — le Select `projet` du DocType porteur. # ---------------------------------------------------------------------------- # Le bloc CRM pipeline ci-dessus recompute le COMPTE d'états du même README # (« les 9 états du pipeline »). Restait, dans le MÊME README, une surface # data-derived distincte et jamais gatée : la liste des PROJETS. Le champ Select # `projet` du DocType (out/doctype_oto_dossier_vente.json, byte-gaté par # check_artifacts) porte l'ENSEMBLE ORDONNÉ des 9 projets OTO # (« P01 Structure », « P02 Coral del Sur », …). Le README:46 le résume # « Projet (`Select` **P01..P09** ancré sur CLAUDE.md) » et le `_comment` du # doctype_spec.json le déclare « ancré sur CLAUDE.md … pas inventé ». Or ce # catalogue est une TRANSCRIPTION MANUELLE : la section « ## Projets » de # CLAUDE.md est la SOURCE FAISANT AUTORITÉ (contraintes #6/#7 · liste des # projets), mais AUCUN gate ne compare les options du DocType à CLAUDE.md. # check_artifacts ne prouve que DocType == spec (byte-for-byte) ; la spec # elle-même recopie la liste — donc TOUTE la chaîne (spec → DocType) peut # DÉRIVER de CLAUDE.md en restant byte-verte : RENOMMER un projet dans CLAUDE.md # (« P03 Nakua » → « P03 Nakua Bay »), en AJOUTER un (P10) ou en RETIRER un # laisse le Select périmé, contredisant silencieusement CLAUDE.md pendant que la # prose « ancré sur CLAUDE.md » devient FAUSSE — « vert trompeur » qu'aucune # suite tests/ (qui teste des FONCTIONS de builder, pas l'ancrage à CLAUDE.md) # n'attrape. Même esprit que le roadmap-anchor (INV11) qui ancre les # `roadmap_line` de l'artefact au fichier roadmap : ici on ANCRE le catalogue # projet du DocType à CLAUDE.md. On RECOMPUTE le catalogue depuis « ## Projets » # de CLAUDE.md (zéro duplication · source unique) et on exige que les options du # Select l'égalent EXACTEMENT (absent ET en trop via set-diff, puis ORDRE), et # que la prose porte la bonne plage P01..P + le mot d'ancrage. Un claim # absent échoue AUSSI. Cohérences croisées en bonus (mordent un catalogue # CLAUDE.md internement incohérent) : catalogue NON VIDE, codes SANS DOUBLON et # CONTIGUS depuis P01 (P01,P02,…,P0N — byte-déterminisme du contrat). # ============================================================================ def _nfc(s): return unicodedata.normalize("NFC", s) try: cm_raw = open("CLAUDE.md", encoding="utf-8").read() mcm = re.search(r"^## Projets\s*\n(.+)$", cm_raw, re.M) if mcm is None: raise ValueError("section « ## Projets » INTROUVABLE") # catalogue faisant autorité = tokens « PNN Label » séparés par « · » cat = [_nfc(t.strip()) for t in mcm.group(1).split("·") if t.strip()] cat_codes = [re.match(r"(P\d{2})\b", p).group(1) for p in cat] except (OSError, ValueError, AttributeError) as e: bad(f"CRM projets · CLAUDE.md « ## Projets » illisible/inattendu : {e}"); cat = None if cat is not None: # bonus : catalogue non vide · codes sans doublon · contigus depuis P01 contig = cat_codes == [f"P{i:02d}" for i in range(1, len(cat_codes) + 1)] if not cat or len(set(cat_codes)) != len(cat_codes) or not contig: bad(f"CRM projets · catalogue CLAUDE.md dégénéré : codes={cat_codes} " f"(attendu P01..P{len(cat_codes):02d} contigus, sans doublon, non vide)") else: good(f"CRM projets · catalogue CLAUDE.md sain : {len(cat)} projets " f"P01..{cat_codes[-1]} contigus") # 1) ANCRAGE : options du Select `projet` du DocType == catalogue CLAUDE.md try: dv_dt = load("crm/dossier_vente/out/doctype_oto_dossier_vente.json") pf = next(f for f in dv_dt["fields"] if f.get("fieldname") == "projet") dt_opts = [_nfc(o.strip()) for o in pf["options"].split("\n") if o.strip()] except (OSError, KeyError, ValueError, StopIteration) as e: bad(f"CRM projets · DocType Select `projet` illisible/inattendu : {e}"); dt_opts = None if dt_opts is not None: if dt_opts == cat: good(f"CRM projets · Select `projet` du DocType == CLAUDE.md " f"({len(cat)} projets, ordre inclus)") elif set(dt_opts) == set(cat): bad(f"CRM projets · Select `projet` porte le bon ENSEMBLE mais un ORDRE " f"≠ CLAUDE.md : DocType={dt_opts} vs {cat} → régénérer le DocType") else: miss = [p for p in cat if p not in dt_opts] extra = [p for p in dt_opts if p not in cat] detail = (f" · absents={miss}" if miss else "") + \ (f" · en trop={extra}" if extra else "") bad(f"CRM projets · Select `projet` du DocType DÉRIVE de CLAUDE.md" f"{detail} → régénérer le DocType (ou aligner CLAUDE.md)") # 2) PROSE : README résume la plage P01..P + ancrage CLAUDE.md dv_rd2 = read_norm(f"{D}/crm/dossier_vente/README.md") if dv_rd2 is not None: mp = re.search(r"Projet \(`Select` \*\*(P\d{2})\.\.(P\d{2})\*\* " r"ancré sur CLAUDE\.md\)", dv_rd2) if mp is None: bad(f"CRM projets · {D}/crm/dossier_vente/README.md — claim « Projet " f"(`Select` P01..P{cat_codes[-1][1:]} ancré sur CLAUDE.md) » INTROUVABLE") elif (mp.group(1), mp.group(2)) == (cat_codes[0], cat_codes[-1]): good(f"CRM projets · README plage {mp.group(1)}..{mp.group(2)} " f"+ ancrage CLAUDE.md == catalogue") else: bad(f"CRM projets · README dit {mp.group(1)}..{mp.group(2)} MAIS catalogue " f"CLAUDE.md va de {cat_codes[0]} à {cat_codes[-1]} → régénérer le README") # ============================================================================ # Portails frontend (2e surface) · CATALOGUE des DocTypes `custom` à CRÉER avant # import — la consigne de hand-off VPS énumérée À LA MAIN, sans gate d'IDENTITÉ. # ---------------------------------------------------------------------------- # Le bloc portails ci-dessus recompute les COMPTES par workspace (cartes/liens/ # rôles) et la table portail⇔workspace. Restait, dans le MÊME README, une surface # data-derived DISTINCTE et jamais gatée : le §« Hand-off → agent ERPNext Backend » # item 2 énumère « DocTypes custom à créer avant import : `CONFOTUR Application`, # `Faisabilité`, `Publiciste Log` (… le reste est natif v15) ». Ce catalogue est # DATA-DERIVED : c'est out/MANIFEST.json `doctypes_custom_a_creer` (byte-gaté par # check_artifacts) = l'UNION des `doctypes_custom` par workspace, eux-mêmes lus du # flag `custom` du contrat rbac_50_roles.json pour les seuls DocTypes visés par une # carte/un lien du portail. C'est un ensemble PROPRE au module portails (sous- # ensemble de celui de rbac/fixtures_gen : ici seuls comptent les DocTypes custom # effectivement CÂBLÉS dans un workspace — API Access, custom mais non câblé, en est # ABSENT). check_artifacts ne prouve QUE MANIFEST==build (byte-for-byte), et le # build RECOMPUTE l'union — donc AJOUTER une carte visant un nouveau DocType custom # au contrat (le MANIFEST rebâtit le catalogue) · en RENOMMER un · en RETIRER un du # câblage laisse la prose de hand-off périmée pendant que l'artefact dit autre # chose : l'agent Backend créerait le mauvais jeu de DocTypes (liens pointant dans # le vide — le risque même que la consigne veut prévenir) — « vert trompeur » # qu'aucune suite tests/ (qui teste des FONCTIONS de mapping, pas la prose de hand- # off) n'attrape. Même patron que le catalogue custom de rbac/fixtures_gen. On # RECOMPUTE le catalogue depuis MANIFEST (zéro duplication du contrat du générateur # #6) et on exige que la prose l'énumère EXACTEMENT (absent ET en trop via set-diff · # accents/casse normalisés). Cohérences croisées en bonus (mordent un MANIFEST # INTERNEMENT incohérent) : catalogue NON VIDE, sans doublon et TRIÉ (byte- # déterminisme) · top-level == UNION des `doctypes_custom` par workspace (pas de # DocType custom câblé mais oublié du catalogue de hand-off, ni l'inverse). Un claim # absent échoue AUSSI (traçabilité). # ============================================================================ PC_RD = f"{D}/frontend/portails/README.md" def _pc_acc(s): return "".join(c for c in unicodedata.normalize("NFKD", s) if unicodedata.category(c) != "Mn").strip().lower() try: pc_man = load("frontend/portails/out/MANIFEST.json") pc_cat = pc_man["doctypes_custom_a_creer"] pc_union = sorted({d for w in pc_man["workspaces"] for d in w.get("doctypes_custom", [])}) except (OSError, KeyError, ValueError, TypeError) as e: bad(f"Portails catalogue · MANIFEST illisible/inattendu : {e}"); pc_cat = None if pc_cat is not None: # bonus : le catalogue est un vrai ENSEMBLE trié non vide (byte-déterminisme) if not pc_cat: bad("Portails catalogue · doctypes_custom_a_creer VIDE → aucun DocType custom " "câblé dans un workspace ? (contrat/spec suspect)") elif pc_cat != sorted(set(pc_cat)): bad(f"Portails catalogue · doctypes_custom_a_creer {pc_cat} non trié/dédupliqué " f"→ générateur non déterministe (attendu {sorted(set(pc_cat))})") else: good(f"Portails catalogue · custom à créer {pc_cat} — ensemble trié, sans doublon") # bonus : cohérence interne — catalogue == union des doctypes_custom par workspace if sorted(set(pc_cat)) != pc_union: miss = sorted(set(pc_union) - set(pc_cat)); extra = sorted(set(pc_cat) - set(pc_union)) detail = (f" · câblés hors catalogue={miss}" if miss else "") + \ (f" · au catalogue mais non câblés={extra}" if extra else "") bad(f"Portails catalogue · doctypes_custom_a_creer {sorted(pc_cat)} ≠ UNION des " f"doctypes_custom par workspace {pc_union}{detail} → MANIFEST incohérent") else: good(f"Portails catalogue · catalogue == union des doctypes_custom par workspace " f"({pc_union})") pc_rd = read_norm(PC_RD) if pc_rd is not None: # README §Hand-off item 2 — énumération du catalogue custom avant « (issus » mp = re.search(r"custom à créer\*\* avant import[^:]*?: (.+?)\(issus", pc_rd) if mp is None: bad(f"Portails catalogue · {PC_RD} — énumération « DocTypes custom à créer … » " f"INTROUVABLE (attendu {sorted(pc_cat)})") else: got = {_pc_acc(t) for t in re.findall(r"`([^`]+)`", mp.group(1))} exp = {_pc_acc(x) for x in pc_cat} if got == exp: good(f"Portails catalogue · {PC_RD} — hand-off {sorted(pc_cat)} == " f"MANIFEST.doctypes_custom_a_creer") else: miss = sorted(exp - got); extra = sorted(got - exp) detail = (f" · absents={miss}" if miss else "") + \ (f" · en trop={extra}" if extra else "") bad(f"Portails catalogue · {PC_RD} — hand-off énumère {sorted(got)} MAIS " f"MANIFEST {sorted(exp)}{detail} → régénérer le README") # ============================================================================ # Faisabilité/generator · PARAMÈTRES CANONIQUES « ancrés sur CLAUDE.md #9/#10 » # — les 6 valeurs imposées à CHAQUE faisabilité (jamais issues du brief). # ---------------------------------------------------------------------------- # Le générateur 4 volets tient sa garantie anti-invention (#6) d'un jeu de # PARAMÈTRES CANONIQUES émis TELS QUELS pour tous les projets : frais d'édition # 3 %, marketing 8.5 %, point d'équilibre 52 % (CLAUDE.md #9) · devises USD+DOP, # format Letter US, paiement Cardnet (CLAUDE.md #10). Ces 6 valeurs sont la # SOURCE FAISANT AUTORITÉ = les contraintes NON-NÉGOCIABLES #9/#10 de CLAUDE.md. # Elles sont RECOPIÉES à deux endroits, tous deux NON gatés : # (a) genlib/model.py `CANONICAL` — le dict que le générateur émet dans CHAQUE # rendu (n'est PAS un out/*.json → invisible à check_artifacts) ; # (b) README:28 « 3 % édition · 8.5 % marketing · 52 % point d'équilibre · # USD+DOP · Cardnet · Letter US, CLAUDE.md #9/#10 » — la prose du module. # Le SEUL contrôle existant (tests/test_generator.py::test_canonical_params…) # assert que le RENDU contient les marqueurs « 3 % », « 52 % »… — mais ces # marqueurs sont HARDCODÉS DANS LE TEST, une copie de plus, JAMAIS comparée à # CLAUDE.md. Donc si la contrainte #9 change (ex. point d'équilibre 52 %→55 %), # model.py, le test ET le README restent « 52 % » : tous internement cohérents, # TOUS contredisant CLAUDE.md — le générateur émettrait alors 52 % dans chaque # faisabilité alors que le mandat dit 55 % : une invention silencieuse de la # classe même que #6 interdit, qu'aucune suite tests/ (qui teste des FONCTIONS, # pas l'ancrage à CLAUDE.md) n'attrape. Même esprit que le roadmap-anchor (INV11) # et le catalogue projets dossier_vente : on ANCRE les consommateurs à CLAUDE.md. # On RECOMPUTE les 6 valeurs depuis les lignes #9/#10 de CLAUDE.md (zéro # duplication · source unique) et on exige que (1) model.py `CANONICAL` les # égale EXACTEMENT (% par valeur numérique · textes accent/espace-insensibles) # et (2) la prose README les énumère EXACTEMENT (set-diff : absent ET en trop). # Un claim absent échoue AUSSI (traçabilité). # ============================================================================ import ast def _ai(s): # accent-insensible, minuscule, sans espaces (comparaison de tokens) return "".join(c for c in unicodedata.normalize("NFKD", s) if unicodedata.category(c) != "Mn").lower().replace(" ", "") def _pnorm(v): # 3.0 → "3%" · 8.5 → "8.5%" (format déterministe) return f"{'%g' % v}%" def _pct(s): # "3 %"/"52%"/… → 3.0/52.0 mm = re.search(r"(\d+(?:\.\d+)?)\s*%", s) return float(mm.group(1)) if mm else None try: cm_raw2 = open("CLAUDE.md", encoding="utf-8").read() l9 = re.search(r"^9\.\s+(.+)$", cm_raw2, re.M) l10 = re.search(r"^10\.\s+(.+)$", cm_raw2, re.M) if not l9 or not l10: raise ValueError("contrainte #9 ou #10 INTROUVABLE") # #9 : trois pourcentages, associés par mot-clé (accent-insensible) seg9 = re.split(r"·", l9.group(1)) def _pct_kw(kw): for s in seg9: if kw in _ai(s): return _pct(s) return None exp = { "frais_edition_pct": _pct_kw("frais"), "marketing_pct": _pct_kw("marketing"), "point_equilibre_pct": _pct_kw("equilibre"), } # #10 : trois valeurs en gras, associées par leur rôle (devises/format/paiement) seg10 = re.split(r"·", l10.group(1)) def _bold_kw(kw): for s in seg10: if kw in _ai(s): mm = re.search(r"\*\*(.+?)\*\*", s) return mm.group(1).strip() if mm else None return None exp["devises"] = _bold_kw("devise") exp["format_doc"] = _bold_kw("format") exp["paiement"] = _bold_kw("paiement") if None in exp.values(): raise ValueError(f"valeurs canoniques illisibles dans #9/#10 : {exp}") except (OSError, ValueError) as e: bad(f"Faisabilité canoniques · CLAUDE.md #9/#10 illisible/inattendu : {e}"); exp = None if exp is not None: good(f"Faisabilité canoniques · CLAUDE.md #9/#10 sain : {_pnorm(exp['frais_edition_pct'])} " f"édition · {_pnorm(exp['marketing_pct'])} mkt · {_pnorm(exp['point_equilibre_pct'])} " f"équilibre · {exp['devises']} · {exp['format_doc']} · {exp['paiement']}") _num_keys = ("frais_edition_pct", "marketing_pct", "point_equilibre_pct") _txt_keys = ("devises", "format_doc", "paiement") # 1) ANCRAGE : genlib/model.py CANONICAL == CLAUDE.md #9/#10 MP = f"{D}/faisabilite/generator/genlib/model.py" try: msrc = open(MP, encoding="utf-8").read() mm = re.search(r"CANONICAL\s*=\s*(\{.*?\})", msrc, re.S) canon = ast.literal_eval(mm.group(1)) except (OSError, ValueError, SyntaxError, AttributeError) as e: bad(f"Faisabilité canoniques · {MP} CANONICAL illisible/inattendu : {e}"); canon = None if canon is not None: drift = [] for k in _num_keys: g = _pct(str(canon.get(k, ""))) if g is None or g != exp[k]: drift.append(f"{k}={canon.get(k)!r} (attendu {_pnorm(exp[k])})") for k in _txt_keys: if _ai(str(canon.get(k, ""))) != _ai(exp[k]): drift.append(f"{k}={canon.get(k)!r} (attendu {exp[k]!r})") if drift: bad(f"Faisabilité canoniques · model.py CANONICAL DÉRIVE de CLAUDE.md #9/#10 : " f"{' · '.join(drift)} → aligner model.py (ou CLAUDE.md)") else: good("Faisabilité canoniques · model.py CANONICAL == CLAUDE.md #9/#10 (6 valeurs)") # 2) PROSE : README:28 énumère les 6 valeurs EXACTEMENT (absent ET en trop) FG_RD = f"{D}/faisabilite/generator/README.md" fg_rd = read_norm(FG_RD) if fg_rd is not None: mr = re.search(r"param[eè]tres canoniques\*\* \((.+?), CLAUDE\.md", fg_rd) if mr is None: bad(f"Faisabilité canoniques · {FG_RD} — énumération « paramètres canoniques " f"(… CLAUDE.md #9/#10) » INTROUVABLE") else: got = set() for tok in re.split(r"·", mr.group(1)): p = _pct(tok) got.add(_pnorm(p) if p is not None else _ai(tok)) want = {_pnorm(exp[k]) for k in _num_keys} | {_ai(exp[k]) for k in _txt_keys} if got == want: good(f"Faisabilité canoniques · {FG_RD} — prose énumère les 6 valeurs == " f"CLAUDE.md #9/#10") else: miss = sorted(want - got); extra = sorted(got - want) detail = (f" · absents={miss}" if miss else "") + \ (f" · en trop={extra}" if extra else "") bad(f"Faisabilité canoniques · {FG_RD} — prose énumère {sorted(got)} MAIS " f"CLAUDE.md {sorted(want)}{detail} → régénérer le README") # ============================================================================ # Fiches agents · CITATION des constantes CLAUDE.md #10 (devises/format/paiement) # — même classe d'ANCRAGE que le bloc « paramètres canoniques » ci-dessus, mais # sur une SURFACE distincte : la prose « Anti-invention » des fiches agents. # ---------------------------------------------------------------------------- # Deux fiches RECOPIENT à la main les valeurs canoniques de CLAUDE.md #10 # (**USD + DOP** devises · **Letter US** format · **Cardnet** paiements, pas # Stripe) en AFFIRMANT « cités depuis CLAUDE.md #10, jamais réinventés » : # • 03_agents/erpnext_backend/AGENT.md:57 — devises **USD + DOP**, format # **Letter US**, paiements **Cardnet** (pas Stripe) # • 03_agents/crm/AGENT.md:37 — devises **USD + DOP** et format **Letter US** # La prose PRÉTEND être une CITATION de #10, mais AUCUN gate ne la liait à #10 : # le bloc « paramètres canoniques » ci-dessus n'ancre QUE genlib/model.py # CANONICAL + le README generator — AVEUGLE à ces deux fiches. Piège #6 : RENOMMER # le prestataire de paiement dans CLAUDE.md #10 (`Cardnet` → `Azul`) ou changer une # devise/format laisse ces citations PÉRIMÉES en silence, contredisant le mandat # pendant que la prose se réclame de « CLAUDE.md #10 » — « vert trompeur » qu'aucune # suite tests/ (FONCTIONS, jamais l'ancre à CLAUDE.md) n'attrape. On RÉUTILISE les # valeurs `exp` déjà recomputées depuis #10 (zéro duplication) + le prestataire # EXCLU (`(pas X)`), et on exige de CHAQUE fiche les tokens en gras qu'elle cite # EXACTEMENT + l'ancre « CLAUDE.md #10 ». Chaque fiche cite un SOUS-ENSEMBLE # distinct (crm omet le paiement) → gate par-fiche ce que la fiche énonce. # ============================================================================ if exp is not None: _m_excl = re.search(r"\(pas\s+(.+?)\)", l10.group(1)) exp_excl = _m_excl.group(1).strip() if _m_excl else None _lab10 = {"devises": "devises", "format_doc": "format", "paiement": "paiement"} # (fiche, [clés #10 citées], cite l'exclusion « (pas X) » ?, libellé) FICHE10 = [ ("03_agents/erpnext_backend/AGENT.md", ["devises", "format_doc", "paiement"], True, "fiche ERPNext Backend:57"), ("03_agents/crm/AGENT.md", ["devises", "format_doc"], False, "fiche CRM:37"), ] for path, keys, cites_excl, label in FICHE10: txt = read_norm(path) if txt is None: continue # ancrage : la citation DOIT se réclamer de CLAUDE.md #10 if not re.search(r"CLAUDE\.md #10", txt): bad(f"Fiches #10 · {label} ({path}) — ancre « CLAUDE.md #10 » " f"INTROUVABLE → la citation ne se réclame plus de la contrainte") continue drift = [] for k in keys: # token en gras **** == valeur recomputée de #10 (casse tolérée) if not re.search(r"\*\*\s*" + re.escape(exp[k]) + r"\s*\*\*", txt, re.IGNORECASE): drift.append(f"{_lab10[k]} attendu **{exp[k]}**") if cites_excl and exp_excl is not None: if not re.search(r"\(pas\s+" + re.escape(exp_excl) + r"\)", txt, re.IGNORECASE): drift.append(f"exclusion attendue « (pas {exp_excl}) »") if drift: bad(f"Fiches #10 · {label} ({path}) — citation DÉRIVE de CLAUDE.md #10 : " f"{' · '.join(drift)} → aligner la fiche (ou CLAUDE.md)") else: _cited = " · ".join(f"**{exp[k]}**" for k in keys) + \ (f" · (pas {exp_excl})" if cites_excl and exp_excl else "") good(f"Fiches #10 · {label} — cite {_cited} == CLAUDE.md #10") # ============================================================================ # Fiscal · ecf_dgii — le JEU DE DEVISES `moneda` (`USD`/`DOP`) ANCRÉ sur CLAUDE.md # #10 — même classe d'ANCRAGE que la CITATION #10 des fiches ci-dessus, mais sur # une SURFACE distincte : la LISTE d'options de devise de l'e-CF, byte-gatée mais # JAMAIS ancrée à la contrainte. # ---------------------------------------------------------------------------- # `moneda.options = ["USD","DOP"]` — le jeu de devises que `TipoMoneda` peut porter # dans un e-CF DGII — vit dans le spec ET, byte-gaté par check_artifacts, dans # out/ecf_plan.json. C'est une valeur CANONIQUE de CLAUDE.md #10 (« **USD + DOP** # devises ») : le README s'en réclame « #10 » (README:57 « **`devise`** (USD/DOP · # #10) », README:37/77) MAIS AUCUN gate ne liait cette liste à la contrainte — # le bloc « Fiscal cross-cohérence » amont gate le FormaPago (Cardnet · #10) et les # ÉTATS/champs émetteurs, AVEUGLE au jeu de devises ; le bloc « Fiches #10 » ancre # la prose des fiches ERPNext/CRM, AVEUGLE au module fiscal ; et le `source` du bloc # moneda est legitimement `null` (il source le TAUX TipoCambio, à confirmer · #8), # donc l'ancre des OPTIONS vit dans un champ DISTINCT `options_source`. # Byte-gater une constante canonique prouve la REPRODUCTIBILITÉ, PAS l'ANCRAGE # (check_artifacts prouve ecf_plan==build depuis le spec, jamais spec[moneda]==#10) : # renommer/étendre les devises dans CLAUDE.md #10 (`USD + DOP` → `USD + EUR`, ou # +`EUR`) ET dans le spec de façon cohérente reste byte-VERT tout en faisant émettre # à l'e-CF un `TipoMoneda` d'une devise qui CONTREDIT le mandat — « vert trompeur » # de la MÊME classe que la marque SEO §Entités, la persona Chat OTOIA ou les tokens # branding #4, qu'aucune suite tests/ (FONCTIONS de génération, jamais l'ancre à # CLAUDE.md) n'attrape. On RÉUTILISE `exp["devises"]` déjà recomputé depuis #10 (zéro # duplication) et on exige : (a) spec `moneda.options` == devises de #10 (ORDRE exact # · mord un ajout/retrait/réordonnancement) ; (b) l'artefact byte-gaté idem ; (c) le # spec DÉCLARE l'ancrage (`options_source` cite « CLAUDE.md #10 » + chaque devise) ; # (d) la prose README qui se réclame de #10 cite le jeu EXACT + toute mention # `moneda`/`devise` de paire de devises == le jeu (aucune périmée). Un claim absent # échoue AUSSI (traçabilité). # ============================================================================ if exp is not None: ECF_MON_RD = f"{D}/fiscal/ecf_dgii/README.md" want_cur = re.findall(r"[A-Z]{3}", exp["devises"]) # #10 « USD + DOP » → [USD, DOP] want_set = set(want_cur) try: _mon_plan = load("fiscal/ecf_dgii/out/ecf_plan.json")["moneda"] _mon_spec = json.load(open(f"{D}/fiscal/ecf_dgii/ecf_spec.json", encoding="utf-8"))["moneda"] except (OSError, KeyError, ValueError, TypeError) as e: bad(f"Fiscal devises · moneda spec/artefact illisible/inattendu : {e}") _mon_plan = _mon_spec = None if not want_cur: bad(f"Fiscal devises · CLAUDE.md #10 devises illisibles : {exp['devises']!r}") elif _mon_plan is not None: _sp_opt = [str(o).upper() for o in _mon_spec.get("options", [])] _pl_opt = [str(o).upper() for o in _mon_plan.get("options", [])] # (a) ANCRAGE — spec moneda.options == devises #10 (ordre exact) if _sp_opt == want_cur: good(f"Fiscal devises · (a) spec moneda.options {_sp_opt} == CLAUDE.md #10 " f"« {exp['devises']} » (ordre exact)") else: _miss = sorted(want_set - set(_sp_opt)); _extra = sorted(set(_sp_opt) - want_set) _d = (f" · absents={_miss}" if _miss else "") + (f" · en trop={_extra}" if _extra else "") bad(f"Fiscal devises · (a) spec moneda.options {_sp_opt} ≠ CLAUDE.md #10 " f"{want_cur}{_d} → aligner le spec (ou CLAUDE.md #10)") # (b) ARTEFACT byte-gaté == devises #10 (ordre exact) if _pl_opt == want_cur: good(f"Fiscal devises · (b) ecf_plan.moneda.options {_pl_opt} == CLAUDE.md #10") else: bad(f"Fiscal devises · (b) ecf_plan.moneda.options {_pl_opt} ≠ CLAUDE.md #10 " f"{want_cur} → régénérer l'artefact") # (c) le spec DÉCLARE l'ancrage : options_source cite CLAUDE.md #10 + chaque devise _osrc = str(_mon_spec.get("options_source", "")) _missing_tok = [c for c in want_cur if c not in _osrc] if "CLAUDE.md #10" not in _osrc or _missing_tok: bad(f"Fiscal devises · (c) moneda.options_source ne DÉCLARE pas l'ancrage " f"CLAUDE.md #10 + {want_cur} (manquant : " f"{'CLAUDE.md #10' if 'CLAUDE.md #10' not in _osrc else _missing_tok}) : {_osrc!r}") else: good("Fiscal devises · (c) spec DÉCLARE l'ancrage (options_source cite " "CLAUDE.md #10 + chaque devise)") # (d) README — la ligne #10 cite le jeu EXACT + toute paire moneda/devise == le jeu ecf_mon_rd = read_norm(ECF_MON_RD) if ecf_mon_rd is not None: # (d1) la ligne qui se réclame de #10 : « devise (…USD/DOP… · #10) » m10 = re.search(r"devise[^\n]*?\(([^)]*#10[^)]*)\)", ecf_mon_rd) if m10 is None: bad(f"Fiscal devises · (d1) {ECF_MON_RD} — ligne « devise (… · #10) » " f"INTROUVABLE → la prose ne se réclame plus de #10") elif set(re.findall(r"[A-Z]{3}", m10.group(1))) != want_set: bad(f"Fiscal devises · (d1) {ECF_MON_RD} — ligne #10 cite " f"{sorted(set(re.findall(r'[A-Z]{3}', m10.group(1))))} MAIS #10 dit " f"{sorted(want_set)} → régénérer le README") else: # (d2) AUCUNE mention moneda/devise d'une paire de devises n'est périmée pairs = re.findall(r"(?:moneda|devise)[^.|\n]{0,40}?\b([A-Z]{3}\s*/\s*[A-Z]{3})\b", ecf_mon_rd) stale = [p for p in pairs if set(re.findall(r"[A-Z]{3}", p)) != want_set] if not pairs: bad(f"Fiscal devises · (d2) {ECF_MON_RD} — aucune mention moneda/devise " f"du jeu {sorted(want_set)} → claim absent") elif stale: bad(f"Fiscal devises · (d2) {ECF_MON_RD} — mention(s) de devises PÉRIMÉE(S) " f"{stale} ≠ CLAUDE.md #10 {sorted(want_set)} → régénérer le README") else: good(f"Fiscal devises · (d) README cite le jeu #10 {sorted(want_set)} " f"+ {len(pairs)} mention(s) moneda/devise concordante(s)") # ============================================================================ # Démo · run-sheet de pitch (2e surface du MÊME README) · la TABLE des SCÉNARIOS # — l'IDENTITÉ de chaque ligne (id · projet+libellé · audience), jamais gatée. # ---------------------------------------------------------------------------- # Le bloc « Démo » ci-dessus recompute le seul COMPTE de modules cités du # diagramme. Restait, dans le MÊME README, une surface data-derived distincte et # NON gatée : la table « Scénario | Projet | Audience | Angle » (README:10-13). # Ses trois colonnes d'identité sont DATA-DERIVED de out/run_sheet.json (byte- # gaté par check_artifacts ⇒ faisant autorité) : `id` (« S-P07-BANQUIER »), # `projet`+`projet_libelle` (« P07 » + « Aqua Terra Las Terrenas ») et `audience` # (« banquier »). Le générateur ne saisit AUCUNE donnée métier — il résout depuis # le disque ; les libellés projets sont d'ailleurs déclarés (README:91-92) # « proviennent **verbatim** de CLAUDE.md · §Projets ». Or AUCUN gate ne le # vérifiait : check_artifacts ne prouve QUE run_sheet==build (byte-for-byte) et # le bloc Démo existant ne gate QUE `modules_cites_uniques` → RENOMMER un id dans # la prose · CLASSER un scénario sous le mauvais projet/audience · AJOUTER une # ligne fantôme d'un scénario inventé · laisser un libellé projet DÉRIVER de # CLAUDE.md (« Aqua Terra » → « Aqua Terra Bay ») restait « vert trompeur » # qu'aucune suite tests/ (qui teste des FONCTIONS de résolution, pas la prose du # README) n'attrape → un présentateur pitcherait le mauvais projet/audience (le # risque même que la run-sheet veut éliminer). Même patron EXACT que la colonne # « Type » de roleprofile_gen (identité de table, fin du wildcard) et l'ancrage # du catalogue projets dossier_vente à CLAUDE.md §Projets. On RECOMPUTE : (1) # ANCRAGE — chaque `P{code} {libelle}` du run_sheet == l'entrée CLAUDE.md §Projets # de ce code (verbatim, comme la prose l'affirme · zéro duplication) ; (2) TABLE — # chaque ligne porte EXACTEMENT id + `P{code} {libelle}` + audience ; (3) IDENTITÉ # d'ensemble — l'ensemble des ids de lignes == l'ensemble des ids du run_sheet == # counts.scenarios (aucune ligne FANTÔME, aucun scénario MANQUANT). Cohérences # croisées en bonus (mordent un artefact INTERNEMENT incohérent) : run_sheet ↔ # MANIFEST d'accord sur (id, projet, audience) · id == f"S-{projet}-{AUDIENCE}" · # counts.scenarios == |scenarios| · ids non vides et sans doublon. Un claim absent # échoue AUSSI (disparition = régression). # ============================================================================ DEMO_RD2 = f"{D}/demo/scenarios/README.md" try: d_rs = load("demo/scenarios/out/run_sheet.json")["scenarios"] d_man = load("demo/scenarios/out/MANIFEST.json") d_man_sc = d_man["scenarios"] d_n = int(d_man["counts"]["scenarios"]) except (OSError, KeyError, ValueError, TypeError) as e: bad(f"Démo scénarios · artefact run_sheet/MANIFEST illisible/inattendu : {e}"); d_rs = None # ancrage libellés projets : CLAUDE.md §Projets = source faisant autorité try: _cmp = open("CLAUDE.md", encoding="utf-8").read() _mp = re.search(r"^## Projets\s*\n(.+)$", _cmp, re.M) if _mp is None: raise ValueError("section « ## Projets » INTROUVABLE") _proj_map = {} for _t in _mp.group(1).split("·"): _t = _nfc(_t.strip()) _mc = re.match(r"(P\d{2})\b", _t) if _mc: _proj_map[_mc.group(1)] = _t if not _proj_map: raise ValueError("aucun projet PNN parsé") except (OSError, ValueError) as e: bad(f"Démo scénarios · CLAUDE.md §Projets illisible/inattendu : {e}"); _proj_map = None if d_rs is not None and _proj_map is not None: d_rd = read_norm(DEMO_RD2) # cohérences croisées internes à l'artefact (avant de croire la prose) rs_ids = [s["id"] for s in d_rs] if len(set(rs_ids)) != len(rs_ids) or "" in rs_ids: bad(f"Démo scénarios · run_sheet incohérent : ids={rs_ids} " f"(doublon ou vide)") if d_n != len(d_rs): bad(f"Démo scénarios · incohérence artefact : counts.scenarios={d_n} " f"MAIS |scenarios|={len(d_rs)}") man_key = {s["id"]: (s["projet"], s["audience"]) for s in d_man_sc} for s in d_rs: sid, proj, aud = s["id"], s["projet"], s["audience"] # id == f"S-{projet}-{AUDIENCE}" (encode projet+audience) exp_id = f"S-{proj}-{aud.upper()}" if sid != exp_id: bad(f"Démo scénarios · run_sheet incohérent : id={sid!r} MAIS " f"projet={proj}/audience={aud} imposent {exp_id!r}") # run_sheet ↔ MANIFEST d'accord sur (projet, audience) if man_key.get(sid) != (proj, aud): bad(f"Démo scénarios · run_sheet ↔ MANIFEST désynchronisés pour {sid} : " f"run_sheet=({proj},{aud}) MANIFEST={man_key.get(sid)}") # ANCRAGE : libellé projet == CLAUDE.md §Projets (verbatim, cf. README:91) full = _nfc(f"{proj} {s['projet_libelle']}") anchor = _proj_map.get(proj) if anchor is None: bad(f"Démo scénarios · projet {proj} du run_sheet ABSENT de CLAUDE.md " f"§Projets (libellé « {full} » non ancré)") elif _ai(full) != _ai(anchor): bad(f"Démo scénarios · libellé projet « {full} » du run_sheet DÉRIVE de " f"CLAUDE.md §Projets « {anchor} » → aligner (ou CLAUDE.md)") else: good(f"Démo scénarios · {sid} — libellé « {full} » == CLAUDE.md §Projets") # TABLE : ligne README porte id + `P{code} {libelle}` + audience if d_rd is not None: pat = (rf"\| `{re.escape(sid)}` \| \*\*([^|]+?)\*\* \| " r"([^|]+?) \|") m = re.search(pat, d_rd) if m is None: bad(f"Démo scénarios · {DEMO_RD2} — ligne `{sid}` INTROUVABLE " f"(attendu projet « {full} » · audience « {aud} »)") else: got_lbl, got_aud = m.group(1).strip(), m.group(2).strip() if _ai(got_lbl) != _ai(full): bad(f"Démo scénarios · {sid} — {DEMO_RD2} porte projet " f"« {got_lbl} » MAIS run_sheet dit « {full} » → régénérer") elif got_aud != aud: bad(f"Démo scénarios · {sid} — {DEMO_RD2} porte audience " f"« {got_aud} » MAIS run_sheet dit « {aud} » → régénérer") else: good(f"Démo scénarios · {sid} — ligne README (projet « {got_lbl} » · " f"audience « {got_aud} ») == run_sheet") # IDENTITÉ d'ensemble : ids des lignes de la table == ids du run_sheet (== counts) if d_rd is not None: row_ids = set(re.findall(r"\| `(S-[^`]+)` \| \*\*[^|]+\*\* \| [^|]+ \|", d_rd)) want_ids = set(rs_ids) if row_ids == want_ids and len(row_ids) == d_n: good(f"Démo scénarios · table — {len(row_ids)} lignes == run_sheet == " f"counts.scenarios ({d_n}) : aucun scénario FANTÔME ni MANQUANT") else: miss = sorted(want_ids - row_ids); extra = sorted(row_ids - want_ids) detail = (f" · absents={miss}" if miss else "") + \ (f" · en trop={extra}" if extra else "") bad(f"Démo scénarios · table — lignes {sorted(row_ids)} MAIS run_sheet " f"{sorted(want_ids)} (counts.scenarios={d_n}){detail} → régénérer le README") # ============================================================================ # CRM/workflow_vente (3e surface du MÊME README) · la TABLE « Rôles = contrat # RBAC » — le RÔLE RBAC qui GARDE chaque transition du pipeline vente. C'est le # CŒUR sécurité du workflow (qui a le droit d'exécuter quel pas), transcrit À LA # MAIN sans AUCUN gate d'IDENTITÉ. # ---------------------------------------------------------------------------- # Les deux blocs CRM/workflow existants gatent (1) le COUPLE de COMPTES du pipeline # (« 9 états · 11 transitions ») et (2) l'ÉNUMÉRATION des transitions à SÉPARATION # DES POUVOIRS (allow_self_approval=0). Restait, dans le MÊME README (l.55-62), une # 3e surface data-derived : la table « Étape → Rôle qui garde la transition » qui # nomme, PAR pas, le rôle RBAC autorisé. Sources faisant autorité (byte-gatées par # check_artifacts) : crm/workflow_vente/out/workflow.json — chaque transition porte # `allowed` (le rôle qui garde le pas) ; ET out/MANIFEST.json.roles_rbac_utilises[] # — l'ensemble des rôles employés, RECOMPUTÉ du contrat rbac_50_roles.json à chaque # build. Les deux blocs de COMPTE/séparation sont AVEUGLES à l'identité de ces rôles : # RÉAFFECTER un pas (« Confirmer réservation » — soumission d'argent — passe de # `OTO Ventes Réservations` à `OTO Ventes Conseiller` : élévation de privilège sur # une étape monétaire), RENOMMER un rôle, en AJOUTER un fantôme dans la prose ou en # OUBLIER un laisse le README périmé pendant que l'artefact dit autre chose → # l'agent ERPNext Backend câblerait le mauvais garde (le risque même que la table # veut prévenir). « Vert trompeur » qu'aucune suite tests/ (qui teste des FONCTIONS # de graphe/résolution RBAC, pas la prose) n'attrape — même patron d'IDENTITÉ que la # cross-cohérence PERMISSIONS Legal/CONFOTUR ou le catalogue RBAC fixtures_gen. # Bonus (mordent un artefact INTERNEMENT incohérent) : {allowed} de workflow.json # == {erpnext_role_name} de MANIFEST.roles_rbac_utilises (aucun rôle gardant une # transition sans être listé dans le manifeste, ni l'inverse) · ensemble NON VIDE. # Cross-cohérence par pas (mord la réaffectation que le set-diff seul manque quand # un rôle sert DEUX transitions) : pour les pas monétaires/de clôture SENSIBLES et # UNIQUES dans le graphe (recomputés `allowed`, jamais figés), la ligne README qui # les nomme doit citer EXACTEMENT ce rôle. # État courant : aucun garde périmé (anti-invention #6) — le défaut est la surface # ungated. Un claim absent échoue AUSSI (traçabilité). # ============================================================================ def _fold(s): s = unicodedata.normalize("NFKD", s) return "".join(c for c in s if not unicodedata.combining(c)).strip().casefold() try: wfv_trans = load("crm/workflow_vente/out/workflow.json")[0]["transitions"] wfv_roles = {t["allowed"] for t in wfv_trans} # gardes du graphe wfv_man = load("crm/workflow_vente/out/MANIFEST.json")["roles_rbac_utilises"] wfv_roles_man = {r["erpnext_role_name"] for r in wfv_man} # rôle par action, UNIQUEMENT quand l'action garde 1 seul rôle (déterministe) wfv_by_act = {} for t in wfv_trans: wfv_by_act.setdefault(t["action"], set()).add(t["allowed"]) wfv_uniq = {a: next(iter(v)) for a, v in wfv_by_act.items() if len(v) == 1} except (OSError, KeyError, ValueError, IndexError, TypeError) as e: bad(f"CRM gardes · workflow.json/MANIFEST illisible/inattendu : {e}"); wfv_roles = None if wfv_roles is not None: # bonus 1 : ensemble non vide if not wfv_roles: bad("CRM gardes · aucun rôle ne garde de transition (ensemble VIDE) → contrat cassé") else: good(f"CRM gardes · {len(wfv_roles)} rôles gardent le pipeline (ensemble non vide)") # bonus 2 : workflow.json ⇄ MANIFEST d'accord sur l'ensemble des rôles employés if wfv_roles == wfv_roles_man: good(f"CRM gardes · {{allowed}} workflow.json == roles_rbac_utilises MANIFEST " f"({len(wfv_roles)} rôles · aucun fantôme/manquant entre artefacts)") else: miss = sorted(wfv_roles_man - wfv_roles); extra = sorted(wfv_roles - wfv_roles_man) detail = (f" · dans MANIFEST pas gardien={miss}" if miss else "") + \ (f" · gardien pas dans MANIFEST={extra}" if extra else "") bad(f"CRM gardes · incohérence artefacts : gardes workflow.json {sorted(wfv_roles)} " f"MAIS roles_rbac_utilises {sorted(wfv_roles_man)}{detail} → régénérer") wfv_txt = read_norm(WF_RD) if wfv_txt is not None: seg = re.search(r"Rôle qui garde la transition \|(.+?)## Séparation", wfv_txt) if seg is None: bad(f"CRM gardes · {WF_RD} — table « Rôle qui garde la transition » INTROUVABLE " f"(attendu {sorted(wfv_roles)})") else: rows = [(l, r) for l, r in re.findall(r"\| ([^|]+?) \| ([^|]+?) \|", seg.group(1)) if set(l.strip()) != {"-"}] # identité d'ensemble : colonne « Rôle » de la table == gardes du graphe rd_roles = set() for _lbl, cell in rows: for tok in cell.split("·"): if tok.strip(): rd_roles.add(tok.strip()) rd_fold = {_fold(x) for x in rd_roles}; wf_fold = {_fold(x) for x in wfv_roles} if rd_fold == wf_fold: good(f"CRM gardes · {WF_RD} — colonne Rôle {sorted(rd_roles)} == " f"{{allowed}} workflow.json ({len(wfv_roles)} rôles · ni fantôme ni manquant)") else: miss = sorted(wf_fold - rd_fold); extra = sorted(rd_fold - wf_fold) detail = (f" · absents={miss}" if miss else "") + \ (f" · en trop={extra}" if extra else "") bad(f"CRM gardes · {WF_RD} — colonne Rôle {sorted(rd_fold)} MAIS " f"{{allowed}} workflow.json {sorted(wf_fold)}{detail} → régénérer le README") # cross-cohérence par pas : les gardes des étapes SENSIBLES (argent/clôture), # uniques dans le graphe, recomputés depuis workflow.json — jamais figés. for act in ("Confirmer réservation", "Signer contrat", "Approuver CONFOTUR"): exp = wfv_uniq.get(act) if exp is None: bad(f"CRM gardes · action sensible « {act} » absente/non-déterministe " f"dans workflow.json → contrat changé de forme, vérifier") continue row = next((c for l, c in rows if _fold(act) in _fold(l)), None) if row is None: bad(f"CRM gardes · {WF_RD} — ligne de l'étape sensible « {act} » " f"INTROUVABLE (attendu garde {exp})") elif _fold(exp) in {_fold(t) for t in row.split("·") if t.strip()}: good(f"CRM gardes · étape sensible « {act} » gardée par « {exp} » " f"== workflow.json (séparation des pouvoirs · #6)") else: bad(f"CRM gardes · {WF_RD} — étape sensible « {act} » : README dit " f"« {row.strip()} » MAIS workflow.json dit garde « {exp} » → " f"réaffectation de privilège non répercutée, régénérer") # ============================================================================ # RBAC/userperm_gen · la TABLE « Mapping scope_donnees → mécanisme » — la # FONCTION d'enforcement ROW-LEVEL : quel mécanisme Frappe natif applique CHAQUE # portée de données, ET si un `User Permission` template est émis. CŒUR sécurité # du module, transcrit À LA MAIN sans AUCUN gate d'IDENTITÉ. # ---------------------------------------------------------------------------- # Le bloc RBAC existant (« 3 volets ») ne gate QUE la VENTILATION par mécanisme # (« 28 entite · 16 groupe · 2 own · 4 equipe » — un COMPTE, aveugle à QUEL # mécanisme applique QUELLE portée). Restait, dans le MÊME README (l.33-38), une # surface data-derived distincte : la table qui énonce, PAR portée, le `mechanism` # Frappe natif ET le verdict « Template émis ? ». Source faisant autorité # (byte-gatée par check_artifacts) : rbac/userperm_gen/out/user_permission_plan.json # — chaque entrée porte `scope_donnees` + `mechanism` + `user_permission_template` # (null ou objet). Le mapping est une FONCTION : chaque portée → exactement un # mécanisme, template émis SSI `entite`/`user_permission_company`. RÉAFFECTER une # portée à un mécanisme plus permissif (`entite` → `none_consolidated` : le # row-level enforcement ABANDONNÉ, sur-exposition des données inter-entités — la # restriction même que le module pose), RENOMMER un mécanisme ou basculer le # verdict « Template émis ? » laisse le README périmé pendant que l'artefact dit # autre chose → l'agent ERPNext câblerait le mauvais mécanisme (le risque même que # la table veut prévenir). « Vert trompeur » qu'aucune suite tests/ (qui teste des # FONCTIONS de mapping/résolution, pas la prose) n'attrape — même patron # d'IDENTITÉ que la cross-cohérence PERMISSIONS Legal/CONFOTUR ou le catalogue RBAC # fixtures_gen. # Bonus (mordent un plan INTERNEMENT incohérent) : chaque portée mappe UN SEUL # mécanisme (fonction, pas relation) · « Template émis » UNIFORME sur les entrées # d'une portée · template émis EXACTEMENT pour `user_permission_company` (l'invariant # « template SSI entite » du README:36/61) · ensemble des portées NON VIDE. # État courant : aucune ligne périmée (anti-invention #6) — le défaut est la surface # ungated. Un claim absent échoue AUSSI (traçabilité). # ============================================================================ try: up_plan = load("rbac/userperm_gen/out/user_permission_plan.json") up_scopes, up_tmpl = {}, {} # portée -> set(mécanisme) / set(template non-null?) for e in up_plan: s = e["scope_donnees"] up_scopes.setdefault(s, set()).add(e["mechanism"]) up_tmpl.setdefault(s, set()).add(e["user_permission_template"] is not None) except (OSError, KeyError, ValueError, TypeError) as e: bad(f"RBAC userperm map · user_permission_plan.json illisible/inattendu : {e}") up_scopes = None if up_scopes is not None: # bonus 1 : ensemble des portées non vide if not up_scopes: bad("RBAC userperm map · plan VIDE (aucune portée) → contrat cassé") else: good(f"RBAC userperm map · {len(up_scopes)} portées dans le plan (ensemble non vide)") # bonus 2 : chaque portée → UN SEUL mécanisme (fonction) + « Template émis » uniforme scope_mech = {} for s in sorted(up_scopes): if len(up_scopes[s]) != 1: bad(f"RBAC userperm map · portée « {s} » mappe {sorted(up_scopes[s])} " f"(attendu 1 mécanisme) → plan incohérent (relation, pas fonction)") continue scope_mech[s] = next(iter(up_scopes[s])) if len(up_tmpl[s]) != 1: bad(f"RBAC userperm map · portée « {s} » — « Template émis ? » NON uniforme " f"sur ses entrées {sorted(up_tmpl[s])} → plan incohérent") # bonus 3 : template émis EXACTEMENT pour user_permission_company (l'invariant SSI) for s, mech in scope_mech.items(): emitted = up_tmpl[s] == {True} if emitted != (mech == "user_permission_company"): bad(f"RBAC userperm map · portée « {s} » (mécanisme={mech}) émet " f"template={emitted} MAIS l'invariant est « template SSI " f"user_permission_company » → plan incohérent") # identité + par-ligne vs README (lignes RAW : la table Mapping est la seule à 3 # cellules dont col1 = une portée backtickée `[a-z_]+` et col3 = le verdict # « Template émis ? » **oui** / non (`null`)). UPM_RD = f"{D}/rbac/userperm_gen/README.md" try: upm_lines = open(UPM_RD, encoding="utf-8").read().splitlines() except OSError as e: bad(f"RBAC userperm map · {UPM_RD} illisible : {e}"); upm_lines = [] map_rows = {} for ln in upm_lines: cells = [c.strip() for c in ln.strip().strip("|").split("|")] if len(cells) != 3: continue ms = re.fullmatch(r"`([a-z_]+)`", cells[0]) if ms and re.search(r"\*\*oui\*\*|non \(`null`\)", cells[2]): map_rows[ms.group(1)] = cells # identité d'ensemble : portées de la table == portées de l'artefact tbl, art = set(map_rows), set(scope_mech) if tbl == art: good(f"RBAC userperm map · table {sorted(tbl)} == portées de l'artefact " f"({len(art)} · ni fantôme ni manquante)") else: miss = sorted(art - tbl); extra = sorted(tbl - art) detail = (f" · absents={miss}" if miss else "") + \ (f" · en trop={extra}" if extra else "") bad(f"RBAC userperm map · {UPM_RD} table {sorted(tbl)} MAIS artefact " f"{sorted(art)}{detail} → régénérer le README") # par portée : mécanisme (1er token backtické de col2) + verdict « Template émis ? » for s in sorted(art): exp_mech = scope_mech[s] exp_emit = up_tmpl[s] == {True} row = map_rows.get(s) if row is None: bad(f"RBAC userperm map · portée « {s} » — ligne INTROUVABLE dans {UPM_RD} " f"(attendu mécanisme={exp_mech} · template=" f"{'oui' if exp_emit else 'non'})") continue mm = re.match(r"`([^`]+)`", row[1]) got_mech = mm.group(1) if mm else "?" if got_mech == exp_mech: good(f"RBAC userperm map · portée « {s} » — mécanisme « {got_mech} » == artefact") else: bad(f"RBAC userperm map · portée « {s} » — {UPM_RD} dit mécanisme " f"« {got_mech} » MAIS artefact dit « {exp_mech} » → réaffectation " f"d'enforcement non répercutée, régénérer") got_emit = row[2].strip() == "**oui**" if got_emit == exp_emit: good(f"RBAC userperm map · portée « {s} » — Template émis=" f"{'oui' if got_emit else 'non'} == artefact") else: bad(f"RBAC userperm map · portée « {s} » — {UPM_RD} dit Template émis=" f"{'oui' if got_emit else 'non'} MAIS artefact dit " f"{'oui' if exp_emit else 'non'} → régénérer le README") # ============================================================================ # Faisabilité/bancable — figures « Génération réelle (fixture) » du README # ---------------------------------------------------------------------------- # Le module `faisabilite/bancable` n'écrit son `out/` que sur disque (« out/ non # commité ») : il est HORS de check_artifacts (aucun artefact byte-gaté) et # AUCUN gate ne le touchait. Or son README §« Vérification en-repo » recopie # QUATRE chiffres DATA-DERIVED que le générateur calcule sur la fixture COMMITÉE # `fixtures/brief_bancable.json` : « 40 unités · valeur catalogue USD 8,560,000 # / DOP 505,040,000 · point d'équilibre 21 unités (⌈52 % × 40⌉) ». Ces valeurs # sont Σ quantités · Σ(qté×prix_usd) · Σ(qté×prix_dop) · ⌈52 % × Σ unités⌉ — le # 52 % ancré sur CLAUDE.md #9. PIÈGE : la suite tests/ teste des FONCTIONS # (l'arithmétique de recoupement) avec des oracles HARDCODÉS ; elle ne compare # JAMAIS la PROSE du README à la génération réelle. Éditer une typologie de la # fixture (quantité/prix), en ajouter/retirer une, ou déplacer le point # d'équilibre de CLAUDE.md #9, laisse les 4 chiffres du README périmés en silence # pendant que le générateur produit autre chose → le banquier lit un dossier # faux, l'invention même que #6 interdit. Ce bloc RECOMPUTE les 4 figures depuis # la fixture (source de vérité), exige que la GÉNÉRATION RÉELLE (bancable_gen # validate) les confirme (mord un générateur/fixture incohérents), puis que la # prose README les énumère EXACTEMENT (valeur + séparateur de milliers + formule # ⌈pct × Σ unités⌉, pct ancré CLAUDE.md #9). Un claim absent échoue AUSSI. # ============================================================================ import math BANC = f"{D}/faisabilite/bancable" BANC_RD = f"{BANC}/README.md" # 1) point d'équilibre % ANCRÉ sur CLAUDE.md #9 (source faisant autorité · #6/#9) try: _cmb = open("CLAUDE.md", encoding="utf-8").read() _l9b = re.search(r"^9\.\s+(.+)$", _cmb, re.M) if not _l9b: raise ValueError("contrainte #9 INTROUVABLE") _peb = None for _sb in re.split(r"·", _l9b.group(1)): if "equilibre" in _ai(_sb): _mmb = re.search(r"(\d+(?:\.\d+)?)\s*%", _sb) _peb = float(_mmb.group(1)) if _mmb else None if _peb is None: raise ValueError("point d'équilibre % absent de #9") except (OSError, ValueError) as e: bad(f"Bancable figures · CLAUDE.md #9 (point d'équilibre) illisible : {e}"); _peb = None # 2) recompute INDÉPENDANT depuis la fixture COMMITÉE (source de vérité · #6) try: _fxb = json.load(open(f"{BANC}/fixtures/brief_bancable.json", encoding="utf-8")) _typb = _fxb["architecture"]["typologies"] rec_units = sum(int(t["quantite"]) for t in _typb) rec_usd = sum(int(t["quantite"]) * int(t["prix_usd"]) for t in _typb) rec_dop = sum(int(t["quantite"]) * int(t["prix_dop"]) for t in _typb) except (OSError, KeyError, ValueError, TypeError) as e: bad(f"Bancable figures · fixture brief_bancable.json illisible/inattendue : {e}"); rec_units = None # 3) génération RÉELLE : le manifeste que le README prétend recopier man_fig = None try: _procb = subprocess.run( [sys.executable, "bancable_gen.py", "validate", "fixtures/brief_bancable.json"], cwd=BANC, capture_output=True, text=True, encoding="utf-8") if _procb.returncode != 0: raise ValueError(f"validate exit {_procb.returncode} : {_procb.stderr.strip()[:160]}") man_fig = {f["cle"]: f["valeur"] for f in json.loads(_procb.stdout)["figures_calculees"]} except (ValueError, KeyError, OSError) as e: bad(f"Bancable figures · génération réelle (bancable_gen validate) échoue : {e}") if _peb is not None and rec_units is not None and man_fig is not None: rec_be = math.ceil(_peb / 100.0 * rec_units) # cohérence croisée : le manifeste GÉNÉRÉ == le recompute indépendant fixture for cle, expv in (("total_unites", rec_units), ("valeur_catalogue_usd", rec_usd), ("valeur_catalogue_dop", rec_dop), ("point_equilibre_unites", rec_be)): gotv = man_fig.get(cle) if gotv is None: bad(f"Bancable figures · manifeste sans figure « {cle} » (attendu {expv})") elif int(gotv) != int(expv): bad(f"Bancable figures · manifeste « {cle} »={gotv} MAIS recompute fixture " f"{expv} → générateur/fixture incohérents (formule cassée)") else: good(f"Bancable figures · manifeste « {cle} »={int(gotv)} == recompute fixture") # 4) PROSE README §« Vérification en-repo » — un seul pattern d'identité try: banc_txt = re.sub(r"\s+", " ", open(BANC_RD, encoding="utf-8").read()) except OSError as e: bad(f"Bancable figures · {BANC_RD} illisible : {e}"); banc_txt = None if banc_txt is not None: pat = (r"(\d[\d,]*)\s*unités\s*·\s*valeur catalogue\s*USD\s*([\d,]+)\s*/\s*" r"DOP\s*([\d,]+)\s*·\s*point d'équilibre\s*(\d+)\s*unités\s*" r"\(⌈\s*(\d+)\s*%\s*×\s*(\d+)\s*⌉\)") m = re.search(pat, banc_txt) if m is None: bad(f"Bancable figures · {BANC_RD} — ligne « Génération réelle (fixture) » " f"INTROUVABLE (attendu {rec_units} unités · USD {rec_usd:,} / DOP " f"{rec_dop:,} · équilibre {rec_be} unités (⌈{_peb:g} % × {rec_units}⌉)) — " f"un claim absent est une régression (#6)") else: g_pct, g_tot = float(m.group(5)), int(m.group(6)) for lbl, raw, expv in (("Σ unités", m.group(1), rec_units), ("valeur catalogue USD", m.group(2), rec_usd), ("valeur catalogue DOP", m.group(3), rec_dop), ("point d'équilibre", m.group(4), rec_be)): got = int(raw.replace(",", "")) if got != expv: bad(f"Bancable figures · README {lbl}={raw} MAIS génération réelle " f"={expv} → figure périmée, régénérer la fixture/le README (#6)") elif expv >= 1000 and "," not in raw: bad(f"Bancable figures · README {lbl}={raw} sans séparateur de milliers " f"(attendu « {expv:,} »)") else: good(f"Bancable figures · README {lbl}={raw} == génération réelle") # formule ⌈52 % × 40⌉ : pct ancré CLAUDE.md #9 · total == Σ unités if g_pct != _peb: bad(f"Bancable figures · README formule ⌈{g_pct:g} %⌉ MAIS CLAUDE.md #9 " f"point d'équilibre = {_peb:g} % → aligner (ou CLAUDE.md)") else: good(f"Bancable figures · README formule pct {g_pct:g} % == CLAUDE.md #9") if g_tot != rec_units: bad(f"Bancable figures · README formule « × {g_tot} » MAIS Σ unités " f"= {rec_units} → total de la formule désaligné") else: good(f"Bancable figures · README formule « × {g_tot} » == Σ unités") # ============================================================================ # Fiche Mobile (03_agents/mobile/AGENT.md) · la FICHE D'IDENTITÉ du rôle RBAC # `plateforme-mobile` — le SEUL ancrage in-repo réellement vérifiable de l'agent # mobile (les builds/stores étant hors-repo, #8), transcrit À LA MAIN sans gate. # ---------------------------------------------------------------------------- # La ligne « Points de contact réellement commités » (AGENT.md:36) recopie, DEPUIS # le contrat rbac_50_roles.json (source de vérité RBAC, schéma-validé · byte-gaté # par les 3 MANIFEST recomputés), TOUS les attributs data-derived du rôle : son # `erpnext_role_name` (« OTO Plateforme Mobile »), son `nom_fr`, sa `famille`/ # `portail`, son `entite_principale`, son `niveau`, son `scope_donnees`, ses # `modules` et sa `permission_cible` (`API Access` custom R/W) + sa `description`. # C'est la carte que l'agent ERPNext Backend lit pour seeder le rôle. AUCUN gate ne # comparait ces valeurs au contrat : le README mobile/app_config est gaté sur des # COMPTES agrégés (« 5 onglets · 44 rôles »), AVEUGLES à l'identité de CE rôle. Éditer # le contrat — élever le `scope_donnees` (`groupe`→`entite` : sur-portée), changer # le `niveau`, l'`entite_principale`, renommer le rôle, ajouter/retirer un module ou # élargir la permission (R/W→+delete) — laisse la fiche périmée en silence pendant # que le contrat dit autre chose ⇒ l'agent câblerait le mauvais rôle (l'invention # même que #6 interdit). Aucune suite tests/ (qui teste des FONCTIONS de résolution # RBAC, pas la prose d'une fiche) n'attrape ce « vert trompeur » — même patron # d'IDENTITÉ que la table gardes CRM/workflow_vente ou le mapping RBAC userperm_gen. # On RECOMPUTE chaque attribut depuis le contrat (zéro duplication) et on exige que # la fiche les énonce EXACTEMENT. Cohérences croisées bonus (mordent un contrat # INTERNEMENT incohérent) : `famille` == `portail` (invariant de bijection portail) · # la permission est bien `custom` · l'ensemble des rôles porte CE role_id UNE fois. # Un claim absent échoue AUSSI (traçabilité). La description (data-derived) est # recoupée verbatim, emphase markdown et point final neutralisés. # ============================================================================ MOB = "03_agents/mobile/AGENT.md" RID = "plateforme-mobile" try: roles = load("rbac/rbac_50_roles.json")["roles"] mob_role = [r for r in roles if r.get("id") == RID] except (OSError, KeyError, ValueError, TypeError) as e: bad(f"Fiche Mobile · rbac_50_roles.json illisible/inattendu : {e}"); mob_role = None mob_txt = read_norm(MOB) if mob_role is not None and mob_txt is not None: if len(mob_role) != 1: bad(f"Fiche Mobile · le contrat porte {len(mob_role)} rôle(s) `{RID}` " f"(attendu exactement 1) → contrat changé de forme, vérifier") else: r = mob_role[0] # cellule « Ce qu'il fixe » de la ligne plateforme-mobile (sans le `|` de fin) seg = re.search(rf"→ rôle `{re.escape(RID)}` \|(.+?)\|", mob_txt) if seg is None: bad(f"Fiche Mobile · {MOB} — ligne de contact du rôle `{RID}` INTROUVABLE " f"(un claim absent est une régression · #6)") else: cell = seg.group(1) # cohérence croisée : famille == portail (bijection portail du contrat) if r.get("famille") != r.get("portail"): bad(f"Fiche Mobile · contrat incohérent : famille={r.get('famille')} " f"≠ portail={r.get('portail')} → bijection portail cassée") # permission_cible : doctype + flag custom + verbes → « R/W » perms = r.get("permissions_cibles") or [] perm = perms[0] if len(perms) == 1 else None if perm is None: bad(f"Fiche Mobile · rôle `{RID}` — attendu 1 permission_cible, " f"trouvé {len(perms)} → contrat changé, vérifier la fiche") elif not perm.get("custom", False): bad(f"Fiche Mobile · rôle `{RID}` — permission « {perm.get('doctype')} » " f"non `custom` dans le contrat MAIS la fiche la dit custom → aligner") acts = set(perm.get("actions", [])) if perm else set() # verbes → sigle canonique ordonné : élargir la perm (ex. +delete) change # le sigle attendu ⇒ la fiche « R/W » périme et le gate mord. _VERB = [("create", "C"), ("read", "R"), ("write", "W"), ("delete", "D"), ("submit", "S"), ("cancel", "X"), ("amend", "A")] rw = "/".join(sig for k, sig in _VERB if k in acts) if acts - {k for k, _ in _VERB}: bad(f"Fiche Mobile · rôle `{RID}` — verbe(s) inconnu(s) " f"{sorted(acts - {k for k, _ in _VERB})} dans le contrat → étendre le gate") mods = "+".join(f"`{m}`" for m in (r.get("modules") or [])) # chaque attribut data-derived recomputé du contrat → prose EXACTE exigée attrs = [ ("nom d'affichage ERPNext", rf"rôle ERPNext \*\*`{re.escape(r['erpnext_role_name'])}`\*\*", r["erpnext_role_name"]), ("nom_fr", rf"« {re.escape(r['nom_fr'])} »", r["nom_fr"]), ("famille/portail", rf"famille/portail \*\*`{re.escape(r['portail'])}`\*\*", r["portail"]), ("entité principale", rf"entité \*\*{re.escape(r['entite_principale'])}\*\*", r["entite_principale"]), ("niveau", rf"niveau {r['niveau']}(?!\d)", str(r["niveau"])), ("scope_donnees", rf"scope `{re.escape(r['scope_donnees'])}`", r["scope_donnees"]), ("modules", rf"modules {re.escape(mods)}(?![+`])", "+".join(r.get("modules") or [])), ] if perm is not None: attrs.append(("permission cible", rf"perm `{re.escape(perm['doctype'])}` custom {re.escape(rw)}(?![\w/])", f"{perm['doctype']} custom {rw}")) for label, pat, exp in attrs: if re.search(pat, cell): good(f"Fiche Mobile · rôle `{RID}` — {label} « {exp} » == contrat") else: bad(f"Fiche Mobile · {MOB} — {label} : la fiche ne cite pas " f"« {exp} » du contrat rbac_50_roles.json (périmé ?) → régénérer") # description data-derived : recoupée verbatim (emphase + point final neutralisés) def _plain(s): return re.sub(r"\s+", " ", s.replace("*", "")).strip().rstrip(".").strip() md = re.search(r"description « (.+?) »\)", cell) exp_desc = _plain(r.get("description", "")) if md is None: bad(f"Fiche Mobile · {MOB} — la description « … » du rôle `{RID}` " f"INTROUVABLE (attendu « {exp_desc} »)") elif _plain(md.group(1)) == exp_desc: good(f"Fiche Mobile · rôle `{RID}` — description == contrat " f"(emphase/point neutralisés)") else: bad(f"Fiche Mobile · {MOB} — description « {_plain(md.group(1))} » " f"MAIS contrat « {exp_desc} » → description périmée, régénérer") # ============================================================================ # Fiches Faisabilité (03_agents/rendu/AGENT.md · 03_agents/ifc_speckle/AGENT.md) · # les FICHES D'IDENTITÉ des rôles RBAC `faisabilite-rendu-3d` et # `faisabilite-ifc-speckle` — même classe de « vert trompeur » que la Fiche Mobile. # ---------------------------------------------------------------------------- # La ligne « Contact in-repo (vérifié) » de chaque fiche (rendu:34 · ifc_speckle:34) # recopie À LA MAIN, DEPUIS le contrat rbac_50_roles.json (source de vérité RBAC, # schéma-validé · byte-gaté par les 3 MANIFEST recomputés), les attributs data-derived # du rôle : son `erpnext_role_name`, son `portail`, son `entite_principale`, son # `niveau`, ses `modules` (rendu), son `nom_fr` (rendu) et ses `permissions_cibles` # (deux doctypes chacune : `File` R/W/create + `Faisabilité` R ou R/W). C'est la carte # que l'agent ERPNext Backend lit pour seeder le rôle porteur des rendus / de l'export # IFC→GLB. AUCUN gate ne comparait ces valeurs au contrat : le README frontend/portails # n'est gaté que sur des COMPTES de rôles par Workspace, AVEUGLES à l'identité de CE # rôle. Éditer le contrat — élever le `scope_donnees`, changer le `niveau`, # l'`entite_principale`, renommer le rôle, ajouter/retirer un module ou élargir la # permission (`Faisabilité` R→R/W : le rôle Rendu 3D gagnerait l'écriture sur le # DocType Faisabilité qu'il ne doit que LIRE) — laisse la fiche périmée en silence # pendant que le contrat dit autre chose ⇒ l'agent câblerait le mauvais rôle # (l'invention même que #6 interdit). Aucune suite tests/ (qui teste des FONCTIONS de # résolution RBAC, pas la prose d'une fiche) n'attrape ce « vert trompeur ». On # RECOMPUTE chaque attribut RESTITUÉ par la fiche depuis le contrat (zéro duplication) # et on exige que la fiche l'énonce EXACTEMENT. Les verbes de permission sont rendus # dans un ORDRE CANONIQUE (read→R · write→W · create/delete/submit/… en toutes # lettres, ordre fixe) : élargir OU rétrécir une permission change la chaîne attendue # (`R/W` ne satisfait plus `R/W/create`, ni l'inverse) ; verbe inconnu ⇒ ROUGE # (étendre le gate). Un claim absent échoue AUSSI (traçabilité). NB : contrairement # à la Fiche Mobile, la DESCRIPTION de ces deux fiches est une PARAPHRASE éditoriale # (le contrat porte « (… — CLAUDE.md) » / « les modèles » que la fiche condense) — # elle n'est donc PAS gatée verbatim (ne rien réécrire de correct pour un gate · #6) ; # seuls les attributs STRUCTURÉS, eux transcrits à l'exact, sont contraints. # ============================================================================ _CANON_VERB = [("read", "R"), ("write", "W"), ("create", "create"), ("delete", "delete"), ("submit", "submit"), ("cancel", "cancel"), ("amend", "amend"), ("print", "print"), ("report", "report")] FICHES_FAIS = [ {"file": "03_agents/rendu/AGENT.md", "rid": "faisabilite-rendu-3d", "attrs": ["role", "nom_fr", "portail", "entite", "niveau", "modules", "perms"]}, {"file": "03_agents/ifc_speckle/AGENT.md", "rid": "faisabilite-ifc-speckle", "attrs": ["role", "portail", "entite", "niveau", "perms"]}, ] try: _fais_roles = load("rbac/rbac_50_roles.json")["roles"] except (OSError, KeyError, ValueError, TypeError) as e: bad(f"Fiches Faisabilité · rbac_50_roles.json illisible/inattendu : {e}") _fais_roles = None if _fais_roles is not None: for fc in FICHES_FAIS: rid, tag = fc["rid"], fc["file"].split("/")[1] cand = [r for r in _fais_roles if r.get("id") == rid] ftxt = read_norm(fc["file"]) if ftxt is None: continue if len(cand) != 1: bad(f"Fiche {tag} · le contrat porte {len(cand)} rôle(s) `{rid}` " f"(attendu exactement 1) → contrat changé de forme, vérifier") continue r = cand[0] seg = re.search(rf"→ rôle `{re.escape(rid)}` \|(.+?)\|", ftxt) if seg is None: bad(f"Fiche {tag} · {fc['file']} — ligne de contact du rôle `{rid}` " f"INTROUVABLE (un claim absent est une régression · #6)") continue cell = seg.group(1) # permissions_cibles → chaîne « `Doctype` R/W/create + `Autre` R », ordre canon _perm_parts, _unknown = [], set() for p in (r.get("permissions_cibles") or []): acts = set(p.get("actions", [])) _unknown |= acts - {k for k, _ in _CANON_VERB} sig = "/".join(tok for k, tok in _CANON_VERB if k in acts) _perm_parts.append(f"`{p.get('doctype')}` {sig}") perm_str = " + ".join(_perm_parts) if _unknown: bad(f"Fiche {tag} · rôle `{rid}` — verbe(s) inconnu(s) " f"{sorted(_unknown)} dans le contrat → étendre le gate") mods = r.get("modules") or [] mod_pat = (rf"module `{re.escape(mods[0])}`(?![+`\w])" if len(mods) == 1 else r"modules " + r"\+".join(f"`{re.escape(m)}`" for m in mods)) _PATS = { "role": (rf"rôle ERPNext \*\*`{re.escape(r['erpnext_role_name'])}`\*\*", r["erpnext_role_name"]), "nom_fr": (rf"« {re.escape(r['nom_fr'])} »", r["nom_fr"]), "portail": (rf"portail {re.escape(r['portail'])}(?![\w])", r["portail"]), "entite": (rf"(? ensemble des titres de Workspace le portant. _mem, _titles = {}, set() for ws in _ws_json: title = ws.get("title") _titles.add(title) for hr in (ws.get("roles") or []): _mem.setdefault(hr.get("role"), set()).add(title) for fc in FICHES_WS: tag = fc["file"].split("/")[1] cand = [r for r in _ws_roles if r.get("id") == fc["rid"]] if len(cand) != 1: bad(f"Fiche {tag} · le contrat porte {len(cand)} rôle(s) `{fc['rid']}` " f"(attendu exactement 1) → contrat changé de forme, vérifier") continue rname = cand[0].get("erpnext_role_name") actual = _mem.get(rname, set()) # Workspaces réels du rôle (Has Role) ftxt = read_norm(fc["file"]) if ftxt is None: continue named = fc.get("ws") # Un Workspace NOMMÉ par la fiche doit exister dans l'artefact (anti-typo : # sinon une assertion in_named/not_in_named « verte » viserait un fantôme). if named is not None and named not in _titles: bad(f"Fiche {tag} · le Workspace nommé `{named}` n'existe pas dans " f"workspace.json (titres : {sorted(_titles)}) → fiche/artefact périmé") continue if fc["kind"] == "in_named": if not re.search(rf"rattaché au Workspace `{re.escape(named)}`", ftxt): bad(f"Fiche {tag} · {fc['file']} — assertion « rattaché au Workspace " f"`{named}` » INTROUVABLE (claim absent = régression · #6)") elif actual == {named}: good(f"Fiche {tag} · rôle « {rname} » ∈ Has Role de `{named}` " f"(et de lui seul) == workspace.json") elif named in actual: bad(f"Fiche {tag} · rôle « {rname} » ∈ `{named}` MAIS AUSSI " f"{sorted(actual - {named})} — la fiche nomme UN seul Workspace ; " f"membership élargie dans workspace.json → réviser fiche/artefact") else: bad(f"Fiche {tag} · {fc['file']} nomme `{named}` MAIS workspace.json " f"rattache « {rname} » à {sorted(actual) or 'aucun Workspace'} → " f"fiche périmée (rôle déplacé/retiré du Has Role), régénérer") elif fc["kind"] == "in_any": if not re.search(r"rattaché à un Workspace", ftxt): bad(f"Fiche {tag} · {fc['file']} — assertion « rattaché à un " f"Workspace » INTROUVABLE (claim absent = régression · #6)") elif actual: good(f"Fiche {tag} · rôle « {rname} » ∈ Has Role de {sorted(actual)} " f"== workspace.json (≥1 Workspace, comme la fiche l'affirme)") else: bad(f"Fiche {tag} · {fc['file']} affirme un rattachement Workspace " f"MAIS « {rname} » n'est dans le Has Role d'AUCUN Workspace → " f"fiche périmée") elif fc["kind"] == "not_in_named": if not re.search(rf"volontairement pas\*\* rattaché au Workspace " rf"`{re.escape(named)}`", ftxt): bad(f"Fiche {tag} · {fc['file']} — assertion négative « volontairement " f"pas rattaché au Workspace `{named}` » INTROUVABLE (#6)") elif named not in actual: good(f"Fiche {tag} · rôle « {rname} » ABSENT du Has Role de `{named}` " f"== workspace.json (assertion négative #6 tenue)") else: bad(f"Fiche {tag} · {fc['file']} affirme « pas rattaché à `{named}` » " f"MAIS workspace.json met « {rname} » dans son Has Role → " f"SUR-EXPOSITION console, assertion #6 VIOLÉE") # ============================================================================ # Publiciste/branding · TOKENS DESIGN CANONIQUES « ancrés sur CLAUDE.md #4 » # — les 4 valeurs de la marque luxury (2 couleurs + 2 typographies), jamais gatées. # ---------------------------------------------------------------------------- # La contrainte NON-NÉGOCIABLE #4 de CLAUDE.md fixe l'identité visuelle : fond # dark `#0a0a12` + accent doré `#f0b429`, titres Fraunces + corps Cormorant # Garamond. C'est la SOURCE FAISANT AUTORITÉ. Ces 4 tokens sont RECOPIÉS à # QUATRE endroits, tous NON gatés : # (a) lib/branding.py CONSTANTES (COLOR_BG/COLOR_ACCENT/FONT_DISPLAY/FONT_BODY) # — la source unique importée par le générateur ET substituée dans le # gabarit ({{COLOR_BG}}…) ; ce n'est PAS un out/*.json → invisible à # check_artifacts ; # (b) le docstring de branding.py (« `#0a0a12` fond · `#f0b429` … Fraunces # (titres) + Cormorant Garamond ») — la prose du module ; # (c) README:43 (« Gabarit dark+doré (Fraunces + Cormorant Garamond) ») ; # (d) tests/test_publiciste.py — le tuple d'oracle HARDCODÉ # ("#0a0a12","#f0b429","Fraunces","Cormorant Garamond") : le SEUL contrôle # existant assert que le RENDU contient ces marqueurs, mais ils sont une # COPIE de plus, JAMAIS comparée à CLAUDE.md. Si #4 change (ex. accent # `#f0b429`→`#e0a420`) et qu'on aligne branding.py, l'oracle du test reste # périmé (rouge trompeur) ; si RIEN ne bouge, tout reste vert alors que le # site public émettrait la mauvaise couleur — invention silencieuse (#6) que # nulle suite tests/ (qui teste des FONCTIONS, pas l'ancrage à CLAUDE.md) # n'attrape. Même patron EXACT que les paramètres canoniques # faisabilite/generator (ancrage CANONICAL↔CLAUDE.md #9/#10). # On RECOMPUTE les 4 tokens depuis la ligne #4 de CLAUDE.md (zéro duplication) et # on exige : (a) branding.py CONSTANTES == #4 ; (b) docstring cite les 4 EXACTS ; # (c) README cite les 2 fontes ; (d) l'oracle du test == #4. Hex insensible à la # casse, fontes comparées à l'exact (strip). Un claim absent échoue AUSSI. # ============================================================================ PUB = f"{D}/publiciste" try: cm_raw4 = open("CLAUDE.md", encoding="utf-8").read() l4 = re.search(r"^4\.\s+(.+)$", cm_raw4, re.M) if not l4: raise ValueError("contrainte #4 INTROUVABLE") hexes = [h.lower() for h in re.findall(r"`(#[0-9a-fA-F]{6})`", l4.group(1))] mfp = re.search(r"\(([^)]+)\)", l4.group(1)) fonts = [f.strip() for f in mfp.group(1).split("+")] if mfp else [] if len(hexes) != 2 or len(fonts) != 2: raise ValueError(f"attendu 2 couleurs + 2 fontes, lu {hexes} / {fonts}") b4 = {"bg": hexes[0], "accent": hexes[1], "display": fonts[0], "body": fonts[1]} except (OSError, ValueError) as e: bad(f"Branding canoniques · CLAUDE.md #4 illisible/inattendu : {e}"); b4 = None if b4 is not None: good(f"Branding canoniques · CLAUDE.md #4 sain : bg={b4['bg']} accent={b4['accent']} " f"display={b4['display']!r} body={b4['body']!r}") # (a) ANCRAGE : lib/branding.py CONSTANTES == CLAUDE.md #4 ------------------- BR = f"{PUB}/lib/branding.py" try: br_src = open(BR, encoding="utf-8").read() except OSError as e: bad(f"Branding canoniques · {BR} illisible : {e}"); br_src = None if br_src is not None: const_pat = { "bg": r'COLOR_BG\s*=\s*"([^"]+)"', "accent": r'COLOR_ACCENT\s*=\s*"([^"]+)"', "display": r'FONT_DISPLAY\s*=\s*"([^"]+)"', "body": r'FONT_BODY\s*=\s*"([^"]+)"', } drift = [] for k, pat in const_pat.items(): m = re.search(pat, br_src) got = m.group(1) if m else None want = b4[k] ok = (got is not None and (got.lower() == want if k in ("bg", "accent") else got.strip() == want)) if not ok: drift.append(f"{k}={got!r} (attendu {want!r})") if drift: bad(f"Branding canoniques · branding.py CONSTANTES DÉRIVENT de CLAUDE.md #4 : " f"{' · '.join(drift)} → aligner branding.py (ou CLAUDE.md)") else: good("Branding canoniques · branding.py CONSTANTES == CLAUDE.md #4 (4 tokens)") # (b) PROSE docstring : les 4 tokens cités AVANT les CONSTANTES ----------- doc = br_src.split("COLOR_BG", 1)[0] doc_hex = {h.lower() for h in re.findall(r"`(#[0-9a-fA-F]{6})`", doc)} miss = [b4[k] for k in ("bg", "accent") if b4[k] not in doc_hex] miss += [b4[k] for k in ("display", "body") if b4[k] not in doc] if miss: bad(f"Branding canoniques · docstring branding.py — tokens ABSENTS de la " f"prose : {miss} (claim absent = régression · #6)") else: good("Branding canoniques · docstring branding.py cite les 4 tokens == CLAUDE.md #4") # (c) README : la ligne du gabarit cite les 2 fontes ----------------------- PBR_RD = f"{PUB}/README.md" pbr_rd = read_norm(PBR_RD) if pbr_rd is not None: rd_miss = [b4[k] for k in ("display", "body") if b4[k] not in pbr_rd] if rd_miss: bad(f"Branding canoniques · {PBR_RD} — fontes ABSENTES de la prose : " f"{rd_miss} (attendu {b4['display']} + {b4['body']} == CLAUDE.md #4)") else: good(f"Branding canoniques · {PBR_RD} cite les 2 fontes == CLAUDE.md #4") # (d) ORACLE du test : le tuple hardcodé == les 4 tokens de CLAUDE.md #4 ---- TBR = f"{PUB}/tests/test_publiciste.py" try: tbr_src = open(TBR, encoding="utf-8").read() except OSError as e: bad(f"Branding canoniques · {TBR} illisible : {e}"); tbr_src = None if tbr_src is not None: mt = re.search(r"for token in \(([^)]+)\)", tbr_src) if mt is None: bad(f"Branding canoniques · {TBR} — tuple d'oracle « for token in (...) » " f"INTROUVABLE (le contrôle #4 du test a disparu · régression)") else: toks = [t.strip().strip('"\'') for t in mt.group(1).split(",") if t.strip()] toks_n = {t.lower() if t.startswith("#") else t for t in toks} want_n = {b4["bg"], b4["accent"], b4["display"], b4["body"]} if toks_n == want_n: good(f"Branding canoniques · {TBR} oracle == CLAUDE.md #4 (4 tokens)") else: miss = sorted(want_n - toks_n); extra = sorted(toks_n - want_n) bad(f"Branding canoniques · {TBR} oracle DÉRIVE de CLAUDE.md #4" + (f" · absents={miss}" if miss else "") + (f" · en trop={extra}" if extra else "") + " → aligner le test") # ============================================================================ # Fiches agents · colonne « Job CI » — chaque nom de job cité DOIT exister dans # .gitea/workflows/ci.yml (même classe de « vert trompeur », côté câblage CI). # ---------------------------------------------------------------------------- # Chaque table de livrables des fiches 03_agents/*/AGENT.md porte une colonne # « Job CI » qui NOMME, mot pour mot et entre backticks, le job Gitea Actions qui # exécute la suite du module (`rbac-tests`, `fiscal-ecf-tests`, …). Ce nom est # SAISI À LA MAIN. La source de vérité est la section `jobs:` de ci.yml. Les # gates existants ne couvraient PAS ce lien : `check_ci_integrity.sh` prouve que # chaque job de ci.yml est bien CÂBLÉ dans `gate.needs` (INV-A/B), et les blocs # amont recomputent les COMPTES de tests par suite — mais RIEN ne vérifiait que # le nom écrit dans la fiche DÉSIGNE un job réel. Renommer un job dans ci.yml # (ex. `rbac-tests` → `rbac-role-tests`, ou `demo-scenario-tests` mal recopié en # `demo-scenarios-tests`) laisse la fiche pointer un job FANTÔME : le CI reste # vert (le vrai job tourne sous son nouveau nom), la doc d'identité de l'agent # ment en silence. On RECOMPUTE ici l'ensemble des jobs depuis la section `jobs:` # de ci.yml (jamais une liste à la main) et on exige que chaque token `*-tests` # cité dans une fiche y figure. Direction fiche→ci.yml (le consommateur) : ci.yml # peut définir des jobs non cités (légitime), mais une fiche ne peut pas citer un # job absent. # ============================================================================ CI_YML = ".gitea/workflows/ci.yml" try: ci_lines = open(CI_YML, encoding="utf-8").read().splitlines() except OSError as e: bad(f"Job CI fiches · {CI_YML} illisible : {e}") ci_lines = None if ci_lines is not None: # Ensemble des jobs = clés à 2 espaces sous la clé top-level `jobs:` # (la section `on:` — push/pull_request/workflow_dispatch — est AVANT `jobs:` # et donc naturellement exclue). Une clé top-level `^\S` clôt la section. jobs = set() in_jobs = False for line in ci_lines: if re.match(r"^jobs:\s*$", line): in_jobs = True continue if in_jobs and re.match(r"^\S", line): # nouvelle clé top-level break if in_jobs: mj = re.match(r"^ ([A-Za-z0-9_-]+):\s*$", line) if mj: jobs.add(mj.group(1)) if not jobs: bad(f"Job CI fiches · aucun job détecté dans la section `jobs:` de " f"{CI_YML} — parse cassé ou fichier vidé (régression de câblage).") fiches_ci = subprocess.run( ["git", "ls-files", "03_agents/*/AGENT.md"], capture_output=True, text=True).stdout.split() tok_re = re.compile(r"`([a-z0-9]+(?:-[a-z0-9]+)*-tests)`") seen_jobrefs = 0 for af in fiches_ci: try: flines = open(af, encoding="utf-8").read().splitlines() except OSError as e: bad(f"Job CI fiches · {af} illisible : {e}"); continue for ln, line in enumerate(flines, 1): for tok in tok_re.findall(line): seen_jobrefs += 1 if jobs and tok in jobs: good(f"Job CI · {af}:{ln} · `{tok}` == job ci.yml") elif jobs: bad(f"Job CI · {af}:{ln} · `{tok}` ABSENT de la section " f"`jobs:` de {CI_YML} → job renommé/supprimé, fiche " f"pointe un job fantôme (régénérer la fiche ou ci.yml)") if seen_jobrefs == 0: bad("Job CI fiches · AUCUN token `*-tests` trouvé dans les fiches — la " "disparition de la colonne « Job CI » est elle-même une régression.") # ============================================================================ # Legal · CONFOTUR — IDENTITÉ des ENTITÉS PORTEUSES ancrée sur CLAUDE.md §Entités # ---------------------------------------------------------------------------- # Le champ Select `entite_porteuse` du DocType CONFOTUR Application — le menu # déroulant qui fixe QUELLE entité juridique porte chaque dossier d'incitation — # offre pour options les 7 entités canoniques du mandat (WAF · WA SRL · AC Arias # Cuevas · Consortium ECR DR · Helios RD · Ploutos · 9060 QC). Ces options sont # DÉRIVÉES via `options_source` de confotur_spec.json[entites] et byte-gatées pour # la REPRODUCTIBILITÉ par check_artifacts (l'artefact se reconstruit depuis le # spec) — mais JAMAIS ANCRÉES à CLAUDE.md §Entités, leur source faisant autorité. # Les blocs CONFOTUR amont gatent le COMPTE de champs/rôles et la cross-cohérence # des PERMISSIONS, aveugles à l'identité de cette liste d'entités. # Piège #6 : RENOMMER une entité dans CLAUDE.md §Entités (9060 QC→9061 QC) ou dans # confotur_spec.json[entites] puis régénérer laisse le DocType offrir un menu qui # CONTREDIT/omet une entité canonique du mandat pendant que check_artifacts reste # VERT (l'artefact se reconstruit… depuis un spec dérivé) — « vert trompeur » de # la MÊME classe que la marque SEO org (§Entités) et les tokens branding (#4). # Aucune suite tests/ (FONCTIONS de génération) ne l'attrape → l'agent ERPNext # Backend importerait un DocType dont le déroulant liste une entité fantôme ou en # omet une (la restriction même que le champ `reqd` pose). # GATE : (a) options de l'artefact == entités CLAUDE.md §Entités (ORDRE exact) ; # (b) spec[entites] == CLAUDE.md (l'INPUT ancré) ; (c) le spec DÉCLARE l'ancrage # (entites_source cite CLAUDE.md + chaque entité) ; (d) wiring intact # (options_source == "entites" · le déroulant DÉRIVE de la liste, pas d'un dur) ; # (e) README cite l'ancre CLAUDE.md + nomme chaque entité (nom complet OU 1er # token — l'abréviation « AC » de « AC Arias Cuevas » est un choix éditorial # toléré · #6). Un claim absent échoue AUSSI (traçabilité #6). # ============================================================================ try: _cm_ce = open("CLAUDE.md", encoding="utf-8").read() _sec_ce = re.search(r"^## Entités\s*\n(.*?)(?=^## )", _cm_ce, re.S | re.M) if not _sec_ce: raise ValueError("section « ## Entités » introuvable") cm_ents = [_nfc(x.strip()) for x in re.findall(r"\*\*([^*]+?)\*\*", _sec_ce.group(1))] if not cm_ents: raise ValueError("aucune entité en gras dans §Entités") if len(cm_ents) != len(set(cm_ents)): raise ValueError(f"entités non uniques : {cm_ents}") except (OSError, ValueError) as e: bad(f"Confotur entités · CLAUDE.md §Entités illisible/inattendu : {e}"); cm_ents = None try: conf_spec_ce = load("legal/confotur/confotur_spec.json") conf_dt_ce = load("legal/confotur/out/doctype_confotur_application.json") ep_art = [f for f in conf_dt_ce.get("fields", []) if f.get("fieldname") == "entite_porteuse"] ep_spec = None for _grp in conf_spec_ce.get("field_groups", []): for _f in _grp.get("fields", []): if _f.get("fieldname") == "entite_porteuse": ep_spec = _f spec_ents = [_nfc(str(x).strip()) for x in conf_spec_ce.get("entites", [])] ents_src = _nfc(str(conf_spec_ce.get("entites_source", ""))) except (OSError, KeyError, ValueError, TypeError) as e: bad(f"Confotur entités · artefact/spec illisible/inattendu : {e}"); cm_ents = None if cm_ents is not None: # (a) ANCRAGE — options du Select de l'artefact == entités CLAUDE.md (ordre exact) if len(ep_art) != 1: bad(f"Confotur entités · champ `entite_porteuse` non-singleton dans l'artefact " f"({len(ep_art)}) → identité du déroulant indéterminable") else: art_opts = [_nfc(o.strip()) for o in str(ep_art[0].get("options", "")).split("\n") if o.strip()] if art_opts == cm_ents: good(f"Confotur entités · (a) options DocType `entite_porteuse` " f"({len(art_opts)}) == CLAUDE.md §Entités (ordre exact)") else: bad(f"Confotur entités · (a) options DocType {art_opts} ≠ CLAUDE.md §Entités " f"{cm_ents} → menu périmé (régénérer depuis le spec / aligner CLAUDE.md)") # (b) l'INPUT ancré — spec[entites] == CLAUDE.md (ordre exact) if spec_ents == cm_ents: good(f"Confotur entités · (b) spec[entites] == CLAUDE.md §Entités") else: bad(f"Confotur entités · (b) spec[entites] {spec_ents} ≠ CLAUDE.md §Entités " f"{cm_ents} → l'input du générateur DÉRIVE du mandat") # (c) le spec DÉCLARE l'ancrage : entites_source cite CLAUDE.md + chaque entité if "CLAUDE.md" not in ents_src: bad(f"Confotur entités · (c) `entites_source` NE CITE PAS CLAUDE.md " f"(ancrage non déclaré) : {ents_src!r}") else: _miss_src = [e for e in cm_ents if e not in ents_src] if _miss_src: bad(f"Confotur entités · (c) `entites_source` cite CLAUDE.md mais OMET " f"{_miss_src} → déclaration d'ancrage périmée") else: good(f"Confotur entités · (c) spec DÉCLARE l'ancrage (entites_source cite " f"CLAUDE.md + les {len(cm_ents)} entités)") # (d) wiring intact — le champ dérive ses options de la liste `entites`, pas d'un dur if ep_spec is None: bad("Confotur entités · (d) champ `entite_porteuse` ABSENT du spec (field_groups)") elif ep_spec.get("options_source") != "entites": bad(f"Confotur entités · (d) `entite_porteuse.options_source` = " f"{ep_spec.get('options_source')!r} ≠ \"entites\" → déroulant DÉCROCHÉ de la " f"liste ancrée (options potentiellement écrites en dur)") else: good("Confotur entités · (d) wiring intact (options_source == \"entites\")") # (e) README cite l'ancre CLAUDE.md + nomme chaque entité (nom complet OU 1er token) CONF_RD3 = f"{D}/legal/confotur/README.md" try: conf_rd3 = re.sub(r"\s+", " ", open(CONF_RD3, encoding="utf-8").read()) except OSError as e: bad(f"Confotur entités · {CONF_RD3} illisible : {e}"); conf_rd3 = None if conf_rd3 is not None: _m_ep = re.search(r"`entite_porteuse`.{0,240}?9060 QC", conf_rd3) if not _m_ep: bad(f"Confotur entités · (e) ligne `entite_porteuse` INTROUVABLE dans " f"{CONF_RD3} (claim absent = régression #6)") else: _epline = _m_ep.group(0) if "CLAUDE.md" not in _epline: bad(f"Confotur entités · (e) ligne `entite_porteuse` ne cite pas l'ancre " f"« CLAUDE.md »") else: _miss_rd = [] for _e in cm_ents: _first = _e.split()[0] if _e in _epline or re.search(r"(? » try: _rm = re.sub(r"\s+", " ", open("04_roadmap/ROADMAP_8_WEEKS_OR_LESS.md", encoding="utf-8").read()) except OSError as e: bad(f"Mobile Expo SDK · roadmap illisible : {e}"); _rm = None if _rm is not None: _rmm = re.search(r"Rebuild Expo (\d+)", _rm) if _rmm is None: bad(f"Mobile Expo SDK · « Rebuild Expo » INTROUVABLE dans la roadmap " f"(attendu {sdk}) → ancre morte") elif int(_rmm.group(1)) == sdk: good(f"Mobile Expo SDK · roadmap « Rebuild Expo {sdk} » == artefact (ancre vive)") else: bad(f"Mobile Expo SDK · roadmap dit « Rebuild Expo {_rmm.group(1)} » MAIS " f"artefact dit {sdk} → régénérer depuis la roadmap") # (c) le spec DÉCLARE son ancrage roadmap (expo_sdk_source cite roadmap + Expo N) _ssrc = str(_mspec.get("app", {}).get("expo_sdk_source", "")) if "roadmap" not in _ssrc.lower() or re.search(rf"Expo {sdk}\b", _ssrc) is None: bad(f"Mobile Expo SDK · spec.app.expo_sdk_source n'ancre pas roadmap+Expo {sdk} " f"(={_ssrc!r}) → ancrage déclaré manquant") else: good("Mobile Expo SDK · spec DÉCLARE l'ancrage roadmap (expo_sdk_source)") # (d) README prose : TOUTE mention « Expo [SDK] N » == artefact (aucune périmée) mob_sdk_rd = read_norm(MOB_SDK_RD) if mob_sdk_rd is not None: nums = [int(x) for x in re.findall(r"Expo(?:\s+SDK)?\s*\*{0,2}(\d+)", mob_sdk_rd)] if not nums: bad(f"Mobile Expo SDK · {MOB_SDK_RD} — aucune mention « Expo » " f"(attendu {sdk}) · claim absent = régression #6") else: stale = sorted({n for n in nums if n != sdk}) if stale: bad(f"Mobile Expo SDK · {MOB_SDK_RD} — mention(s) périmée(s) Expo {stale} " f"MAIS artefact {sdk} → régénérer le README") else: good(f"Mobile Expo SDK · {MOB_SDK_RD} — {len(nums)} mention(s) « Expo {sdk} » " f"== artefact") # (e) la ligne de source du tableau NOMME l'ancre roadmap à côté du majeur if re.search(rf"Expo SDK \*\*{sdk}\*\*[^|]*\|[^|]*roadmap", mob_sdk_rd) is None: bad(f"Mobile Expo SDK · {MOB_SDK_RD} — la ligne « Expo SDK **{sdk}** » ne cite " f"pas son ancre `roadmap` (Sprint 5 l.56) → ancrage prose manquant") else: good(f"Mobile Expo SDK · {MOB_SDK_RD} — « Expo SDK **{sdk}** » cite l'ancre roadmap") 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"