#!/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) # 05_deliverables_mvp//README.md (comptes de tests self-reportés par MODULE) : # • chaque README de module se re-déclare le compte de SA suite (commentaire # « # N tests » de la commande unittest · bullet « → **N/N verts** » · prose # d'auto-score « en-repo (N tests verts + …) ») — 27 claims sur 16 modules, # surface que row_re (qui ne lit le README de module QUE comme cible de lien) # et les blocs per-suite (tous sur 03_agents/*) n'atteignent pas → recomptés # depuis plan.suites[path].test_methods (`auth`, même source · byte-gatée) ; # un « N/N » interne incohérent (25/24) mord aussi # 03_agents/devops/AGENT.md (cellule « Tests » de la row pipeline CI) : # • le COMPTE de suites gated « N suites gated » — CLASSE distincte des comptes # de MÉTHODES ci-dessus (un compte de SUITES, pas de tests) — non atteint par # row_re (ancre `ci.yml`/`ci/`, pas un module de `auth` · suffixe « suites # gated ») et distinct de l'agrégat de la fiche QA → recompté depuis # regression_plan.totals.suites (`reg["suites"]`, byte-gaté) + cohérence # croisée == regression_run len(suites) (mord une divergence plan↔run) # 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") # ============================================================================ # README de l'auditeur 4Big · SEUILS STRUCTURELS de la colonne « Fait vérifié » # (min_doc_bytes / min_test_methods) — 3e surface du MÊME README, jamais gatée. # ---------------------------------------------------------------------------- # Le bloc BARÈME ci-dessus recompute les POIDS (2e colonne de la table # « Critère | Poids | Fait vérifié »), mais la 3e colonne « Fait vérifié » NOMME # les DEUX seuils structurels que le scorer applique réellement : # • DOC « un *.md (README/SPEC) ≥ 400 octets » → thresholds.min_doc_bytes # • TESTS « ≥ 8 méthodes def test_* dans tests/ » → thresholds.min_test_methods # Ces deux nombres sont DATA-DERIVED de quality_spec.json[thresholds] — l'INPUT # du générateur, byte-gaté par check_artifacts qui prouve que quality_report.json # se rebuild depuis lui — et TRANSCRITS À LA MAIN dans la table. AUCUN gate ne # les liait : le bloc BARÈME ne capture que le poids (`| \`DOC\` | 20 |`), # AVEUGLE à la 3e cellule. Élever min_test_methods (8→12 : un module de 8-11 # tests tomberait sous FAIL — la barre même que le critère pose) ou changer # min_doc_bytes dans le spec rebuild le report SANS TOUCHER au README → prose # « ≥ 8 méthodes » / « ≥ 400 octets » PÉRIMÉE en silence, l'auditeur documentant # un barème qu'il n'applique plus (l'invention #6 interdit). Aucune suite tests/ # (qui teste des FONCTIONS de scoring, pas la prose) n'attrape ce « vert # trompeur ». CROSS-CHECK d'ancrage : l'evidence TESTS de CHAQUE module du report # porte « (seuil N) » (émise par crit_tests) — on exige N == spec.min_test_methods # pour TOUS, prouvant que le seuil ÉCRIT dans le spec == le seuil réellement # APPLIQUÉ à la construction (mord une divergence spec↔report). Un claim absent # échoue AUSSI (traçabilité #6). # ============================================================================ try: _a4_thr = load("qa/audit_4big/quality_spec.json")["thresholds"] min_doc = int(_a4_thr["min_doc_bytes"]) min_test = int(_a4_thr["min_test_methods"]) except (OSError, KeyError, ValueError, TypeError) as e: bad(f"README audit_4big seuils · quality_spec.json[thresholds] illisible/inattendu : {e}") min_doc = min_test = None if min_doc is not None and criteria is not None and a4_txt: # cross-check d'ancrage : seuil TESTS APPLIQUÉ (evidence report) == spec ev_seuils = set() for m in qr_crit["modules"]: for ck in m.get("checks", []): if ck.get("criterion") == "TESTS": mm = re.search(r"\(seuil (\d+)\)", ck.get("evidence", "")) if mm: ev_seuils.add(int(mm.group(1))) if not ev_seuils: bad("README audit_4big seuils · aucune evidence TESTS « (seuil N) » dans le " "report — impossible d'ancrer le seuil réellement appliqué") elif ev_seuils != {min_test}: bad(f"README audit_4big seuils · seuil TESTS APPLIQUÉ (evidence report) " f"{sorted(ev_seuils)} ≠ spec.min_test_methods ({min_test}) → spec↔report " f"divergent (régénérer le report)") else: good(f"README audit_4big seuils · seuil TESTS appliqué (report) == spec " f"({min_test}) sur tous les modules") # prose · DOC « ≥ N octets » (3e cellule de la ligne DOC) md = re.search(r"`DOC`[^|]*\|[^|]*\|[^|]*?≥\s*(\d+)\s*octets", a4_txt) if md is None: bad(f"README audit_4big seuils · ligne DOC « ≥ N octets » INTROUVABLE dans {A4} " f"(attendu {min_doc})") elif md.group(1) == str(min_doc): good(f"README audit_4big seuils · DOC — « ≥ {md.group(1)} octets » == spec ({min_doc})") else: bad(f"README audit_4big seuils · DOC — {A4} dit « ≥ {md.group(1)} octets » MAIS " f"spec.min_doc_bytes == {min_doc} → régénérer le README") # prose · TESTS « ≥ N méthodes » (3e cellule de la ligne TESTS) mt = re.search(r"`TESTS`[^|]*\|[^|]*\|[^|]*?≥\s*(\d+)\s*méthodes", a4_txt) if mt is None: bad(f"README audit_4big seuils · ligne TESTS « ≥ N méthodes » INTROUVABLE dans {A4} " f"(attendu {min_test})") elif mt.group(1) == str(min_test): good(f"README audit_4big seuils · TESTS — « ≥ {mt.group(1)} méthodes » == spec ({min_test})") else: bad(f"README audit_4big seuils · TESTS — {A4} dit « ≥ {mt.group(1)} méthodes » MAIS " f"spec.min_test_methods == {min_test} → 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//.md + dernier # champ numérique. Le .md est l'ENTRÉE du module — le plus souvent README.md, # mais parfois une SPEC (la fiche `rbac` pointe RBAC_50_ROLES_SPEC.md, son point # d'entrée) : on accepte donc tout `*.md` du dossier module, sinon cette cellule-là # reste un angle mort (mutation 10→99 non mordue). On ne vérifie QUE les lignes # pointant une suite gatée connue (auth) — toute autre ligne est ignorée (hors # périmètre), le garde `path not in auth` protège de tout élargissement fortuit. row_re = re.compile( r"^\|\s*\[`\w+/`\]\([^)]*?05_deliverables_mvp/([\w/]+?)/[\w.-]+\.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") # ============================================================================ # Fiches agents · comptes de tests PAR SUITE écrits HORS de la colonne « Tests » # (prose libre + cellule à suffixe) — dernière poche de la MÊME classe per-suite. # ---------------------------------------------------------------------------- # Les deux blocs ci-dessus recomputent (a) chaque CELLULE « Tests » des tables de # livrables via row_re — dont l'ancre de lien est `/` ET la cellule un ENTIER # NU — et (b) les AGRÉGATS multi-suites en prose de crm/erpnext_backend. Restent # CINQ claims de test PAR SUITE que NI l'un NI l'autre n'atteint : # • prose libre (hors table) : seo « **36 tests** » (suite `seo`) · # frontend_console « **19 tests** » (frontend/portails) & « **31 tests** » # (frontend/chat_otoia) · onapi_legal « **44 tests** » (legal/confotur) ; # • cellule de table à SUFFIXE : devops « 29 (dont 14 injections négatives) » # (devops/deploy_runbook) — row_re échoue DEUX fois : l'ancre de lien est un # CHEMIN complet (`05_deliverables_mvp/devops/deploy_runbook/`, pas `/`) # ET la cellule n'est PAS un entier nu (suffixe « (dont …) »). # Ces cinq nombres sont SAISIS À LA MAIN et se périment dès qu'un `def test_` est # ajouté/retiré à la suite — exactement le « vert trompeur » per-suite que le bloc # des cellules corrige, mais sur des surfaces qu'il ne balaie pas (une suite +1 et # une autre −1 laisserait la SOMME « 564 tests » juste et ces lignes fausses). # Aucune suite tests/ (qui teste des FONCTIONS, pas la prose des fiches) n'attrape # ce périmé. On RECOMPUTE chaque nombre depuis `auth` (plan.suites[path]. # test_methods / count_tests · MÊME source déjà chargée · zéro duplication #6) et # on exige que la fiche l'écrive EXACTEMENT. Chaque motif est ANCRÉ sur le nom de # job CI (ou le lien du module) adjacent pour viser LA bonne suite sans ambiguïté. # Un claim absent échoue AUSSI (traçabilité #6 : le recensement qui s'évapore est # lui-même une régression). # ============================================================================ PER_SUITE_HORS_TABLE = [ ("03_agents/seo/AGENT.md", "seo", r"\*\*(\d+) tests\*\*, gated", "seo"), ("03_agents/frontend_console/AGENT.md", "frontend/portails", r"\*\*(\d+) tests\*\* · job CI `frontend-portails-tests`", "portails"), ("03_agents/frontend_console/AGENT.md", "frontend/chat_otoia", r"\*\*(\d+) tests\*\* · job CI `chat-otoia-tests`", "chat_otoia"), ("03_agents/onapi_legal/AGENT.md", "legal/confotur", r"\*\*(\d+) tests\*\* \(dont \d+ négatifs\) · job CI `legal-confotur-tests`", "confotur"), ("03_agents/devops/AGENT.md", "devops/deploy_runbook", r"deploy_runbook/README\.md\).*?\| (\d+) \(dont \d+", "deploy_runbook"), ] for path_f, suite_path, pat, label in PER_SUITE_HORS_TABLE: try: txt = re.sub(r"\s+", " ", open(path_f, encoding="utf-8").read()) except OSError as e: bad(f"Test par suite (hors table) · {path_f} illisible : {e}"); continue if suite_path not in auth: bad(f"Test par suite (hors table) · {label} — suite `{suite_path}` absente " f"de plan.suites/count_tests (câblage rompu ?)"); continue exp = auth[suite_path] m = re.search(pat, txt) if m is None: bad(f"Test par suite (hors table) · {path_f} · {label} — claim « N tests » " f"INTROUVABLE (attendu {exp} · suite `{suite_path}`)") elif int(m.group(1)) == exp: good(f"Test par suite (hors table) · {path_f} · {label} — {m.group(1)} == " f"source ({exp} · `{suite_path}`)") else: bad(f"Test par suite (hors table) · {path_f} · {label} — dit {m.group(1)} " f"MAIS source dit {exp} (plan.suites[`{suite_path}`]) → régénérer la fiche") # ============================================================================ # Fiche DevOps · COMPTE de suites gated (« N suites gated ») — sémantique DISTINCTE # des comptes de tests par suite ci-dessus. # ---------------------------------------------------------------------------- # Tous les blocs de comptes de tests précédents recomputent un compte de MÉTHODES # `def test_` (`auth[path]`). La table de livrables de la fiche DevOps porte, elle, # dans la cellule « Tests » de la ROW du pipeline CI (`.gitea/workflows/ci.yml` + # `ci/`), un compte d'une CLASSE tout autre : « 22 suites gated » — le nombre de # suites que la CI passe au crible, soit `regression_plan.totals.suites` (déjà chargé # dans `reg`, byte-gaté par check_artifacts · recompté à chaque build par la # découverte réelle des suites). Ce nombre est SAISI À LA MAIN dans la fiche et # NI row_re NI aucun bloc amont ne l'atteint : (a) row_re exige un lien d'ancre # `/…/README.md` vers une suite gatée ET une cellule ENTIER NU — or l'ancre ici # est `ci.yml`/`ci/` (pas un module de `auth`) ET la cellule porte le suffixe # « suites gated » ; (b) l'agrégat de la fiche QA (« 22 suites gated · 564 tests… », # gaté sur regression_run) est une SURFACE distincte (autre fiche, autre motif). La # MÊME valeur 22 vit donc à DEUX endroits — un seul était gaté. Ajouter/retirer un # module gated fait bouger `reg["suites"]` tandis que cette cellule DevOps rote en # silence : la doc d'identité de l'agent DevOps affirmerait un périmètre de couverture # CI faux (le « vert trompeur » que #6 interdit). Aucune suite tests/ (FONCTIONS, pas # la prose de fiche) ne l'attrape. On RECOMPUTE depuis `reg["suites"]` (source unique # déjà en portée · zéro duplication #6) et on exige que la cellule l'écrive EXACTEMENT. # Motif ANCRÉ sur le label de rôle « CI/CD Gitea Actions » de CETTE row → vise la # bonne cellule sans capter la valeur 22 de la fiche QA (autre fichier de toute façon). # Claim absent = échec (traçabilité #6). Cohérence croisée : `reg["suites"]` == # regression_run (len(suites)) — la source du compte de tests de la fiche QA — de sorte # qu'une divergence plan↔run (compte de suites) morde ici aussi. # ============================================================================ DEVOPS_FICHE = "03_agents/devops/AGENT.md" try: dv_txt = re.sub(r"\s+", " ", open(DEVOPS_FICHE, encoding="utf-8").read()) except OSError as e: bad(f"Fiche DevOps · {DEVOPS_FICHE} illisible : {e}"); dv_txt = "" run_sc = load("qa/regression/out/regression_run.json") n_suites_run = len(run_sc["suites"]) if reg["suites"] != n_suites_run: bad(f"Fiche DevOps · suites gated — incohérence SOURCE : regression_plan.totals." f"suites={reg['suites']} ≠ regression_run len(suites)={n_suites_run} " f"(plan↔run divergent → régénérer les artefacts qa/regression)") dv_pat = r"CI/CD Gitea Actions.*?\| (\d+) suites gated \|" mdv = re.search(dv_pat, dv_txt) if not dv_txt: pass # illisible déjà signalé elif mdv is None: bad(f"Fiche DevOps · suites gated — cellule « N suites gated » INTROUVABLE dans " f"{DEVOPS_FICHE} (row CI/CD Gitea Actions · attendu {reg['suites']})") elif int(mdv.group(1)) == reg["suites"]: good(f"Fiche DevOps · suites gated — {DEVOPS_FICHE} {mdv.group(1)} == source " f"(regression_plan.totals.suites={reg['suites']})") else: bad(f"Fiche DevOps · suites gated — {DEVOPS_FICHE} dit {mdv.group(1)} MAIS source " f"dit {reg['suites']} (regression_plan.totals.suites) → régénérer la fiche") # ============================================================================ # READMEs de MODULE · comptes de tests self-reportés — surface DISTINCTE des fiches. # ---------------------------------------------------------------------------- # Tous les blocs de comptes de tests ci-dessus visent `03_agents/*/AGENT.md` (la # cellule « Tests » via row_re, les agrégats prose crm/erpnext_backend, les cinq # claims per-suite hors table). AUCUN ne touche le README du MODULE lui-même — # `05_deliverables_mvp//README.md` — qui pourtant se re-déclare, en propre, # le compte de SA suite à plusieurs endroits : le commentaire de la commande # `unittest` (« # N tests »), le bullet « Vérification en-repo » (« → **N/N verts** ») # et la justification d'auto-score (« Validé … en-repo (N tests verts + … ) »). Ces # nombres sont SAISIS À LA MAIN dans le doc d'entrée de chaque module et se périment # dès qu'un `def test_` est ajouté/retiré : la source faisant autorité (regression_ # plan.json → `auth`, recomptée à chaque build par découverte réelle · byte-gatée) # se met à jour, tandis que la prose du README rote EN SILENCE — « vert trompeur » # de la MÊME classe que la cellule « Tests » des fiches, mais sur la surface que # row_re n'atteint pas (il ne lit le README de module QUE comme cible de lien). Aucune # suite tests/ (FONCTIONS de génération, jamais la prose du README) n'attrape ce # périmé. Un « N/N » interne incohérent (ex. `25/24 verts`) mord aussi (les deux # nombres doivent valoir `auth`). On RECOMPUTE chaque nombre depuis `auth` (MÊME # source déjà chargée · zéro duplication #6) et on exige que le README l'écrive # EXACTEMENT ; un claim listé mais absent échoue AUSSI (traçabilité #6 : le compte # qui s'évapore est lui-même une régression). Cinq modules ne self-reportent AUCUN # compte (frontend/chat_otoia · mobile/app_config · publiciste · qa/acceptance · # qa/audit_4big) — volontairement HORS table (rien à ancrer ; ajouter un futur claim # = l'ajouter ici). Chaque motif est ANCRÉ sur un contexte distinctif (commande # `discover`, `**N/N verts**`, « en-repo (N tests verts », nom de fichier de suite…) # pour viser LE compte de CETTE suite sans capter un autre nombre. # ============================================================================ MODULE_README_TESTS = [ ("crm/commissions", [r"discover -s tests[^#]*# (\d+) tests", r"en-repo \((\d+) tests verts"]), ("crm/dossier_vente", [r"discover -s tests[^#]*# (\d+) tests"]), ("crm/workflow_vente", [r"\*\*(\d+)/(\d+) verts\*\*", r"en-repo \((\d+) tests verts"]), ("demo/scenarios", [r"(\d+) tests \(dont injections"]), ("devops/deploy_runbook", [r"(\d+) tests dont \d+ injections"]), ("faisabilite/bancable", [r"\*\*(\d+)/(\d+) verts\*\*", r"en-repo \((\d+) tests verts"]), ("faisabilite/generator", [r"(\d+) tests `unittest`"]), ("fiscal/ecf_dgii", [r"discover -s tests[^#]*# (\d+) tests", r"en-repo \((\d+) tests verts"]), ("frontend/portails", [r"discover -s tests[^#]*# (\d+) tests", r"exacts, (\d+) tests \(dont"]), ("legal/confotur", [r"discover -s tests[^#]*# (\d+) tests \(dont", r"test_confotur\.py\s*# (\d+) tests", r"diffable, (\d+) tests \(dont"]), ("qa/audit_5d", [r"\*\*(\d+) tests\*\* \(`unittest`"]), ("rbac/apply_plan", [r"\*\*(\d+)/(\d+) verts\*\*", r"en-repo \((\d+) tests verts"]), ("rbac/fixtures_gen", [r"\*\*(\d+)/(\d+) verts\*\*", r"en-repo \((\d+) tests verts"]), ("rbac/roleprofile_gen", [r"\*\*(\d+)/(\d+) verts\*\*", r"en-repo \((\d+) tests verts"]), ("rbac/userperm_gen", [r"\*\*(\d+)/(\d+) verts\*\*", r"en-repo \((\d+) tests verts"]), ("seo", [r"discover -s tests[^#]*# (\d+) tests"]), ] for suite_path, pats in MODULE_README_TESTS: rd = f"{D}/{suite_path}/README.md" try: rtxt = re.sub(r"\s+", " ", open(rd, encoding="utf-8").read()) except OSError as e: bad(f"Compte tests README module · {rd} illisible : {e}"); continue if suite_path not in auth: bad(f"Compte tests README module · suite `{suite_path}` absente de " f"plan.suites/count_tests (câblage rompu ?)"); continue exp = auth[suite_path] for pat in pats: m = re.search(pat, rtxt) if m is None: bad(f"Compte tests README module · {rd} — claim « N tests » INTROUVABLE " f"(attendu {exp} · suite `{suite_path}`) · motif=/{pat}/") continue got = [int(g) for g in m.groups()] if all(v == exp for v in got): good(f"Compte tests README module · {rd} — {'/'.join(map(str, got))} == " f"source ({exp} · `{suite_path}`)") else: bad(f"Compte tests README module · {rd} — dit {'/'.join(map(str, got))} " f"MAIS source dit {exp} (plan.suites[`{suite_path}`]) → régénérer le " f"README (ou run-book de régression si un test a bougé)") # ============================================================================ # 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") # ============================================================================ # Portails frontend (3e surface) · IDENTITÉ de l'ENSEMBLE des portails métier + # exclusion NOMMÉE de la console technique — même fiche, autre « vert trompeur » # aveugle au seul COMPTE. # ---------------------------------------------------------------------------- # Le bloc triplets ci-dessus recompute cartes/liens/rôles PAR workspace mais # reste aveugle à QUELS portails composent l'ensemble : la fiche agent # (03_agents/frontend_console/AGENT.md) énumère À LA MAIN, ligne 11, « 5 portails # rôle métier (Ventes / Construction / Achat / Compta / Direction) » et affirme # ligne 46 que « la console technique `plateforme` (6ᵉ portail RBAC) est exclue ». # DEUX assertions data-derived, aucune gatée : # (a) l'ENSEMBLE ORDONNÉ des noms courts = les workspaces métier du frontend # (out/MANIFEST.json workspaces · byte-gaté ⇒ faisant autorité · label # « OTO » sans le préfixe, comme l'énumération des onglets Mobile) ET, # DOUBLE ANCRAGE, leurs ids == `portails_business` du contrat RBAC (l'« # égalité stricte » que la fiche revendique elle-même en :47) ; # (b) `plateforme` EST un vrai portail RBAC (∈ portails distincts des rôles), # il est le 6ᵉ (total distinct == 6 = 5 métier + 1) et il est HORS métier # (∉ portails_business) — donc légitimement « exclu ». # Un ÉCHANGE (Achat→Marketing), un RETRAIT, un RÉORDRE, un RENOMMAGE de la console # technique dans le contrat (`plateforme`→`console`) ou un contrat qui PROMEUT # `plateforme` en métier ferait dériver la fiche EN SILENCE pendant que les # artefacts (frontend workspaces + contrat RBAC) disent autre chose — dérive # qu'aucune suite tests/ (FONCTIONS, jamais la prose) n'attrape. Même patron que # l'énumération des onglets Mobile ou la colonne « Entité » de la SPEC §4. Zéro # duplication : on relit deux artefacts déjà byte-gatés, on ne fige aucune liste. # ============================================================================ try: _fc = load("rbac/rbac_50_roles.json") _pb = _fc["portails_business"] # ordre du contrat _distinct = list(dict.fromkeys(r["portail"] for r in _fc["roles"])) except (OSError, KeyError, ValueError, TypeError) as e: bad(f"Portails ensemble · contrat rbac_50_roles.json illisible/inattendu : {e}") _pb = None; _distinct = None if _pb is not None and ws_list: # double ancrage : ids métier du frontend == portails_business du contrat _fe_ids = [w["portail"] for w in ws_list] if _fe_ids != _pb: bad(f"Portails ensemble · ancrage rompu : ids workspaces frontend {_fe_ids} " f"≠ contrat portails_business {_pb} → resynchroniser artefact/contrat") else: good(f"Portails ensemble · double ancrage : ids workspaces frontend == " f"portails_business ({'/'.join(_pb)})") # (a) noms courts ordonnés = label workspace sans « OTO » (ce que la fiche copie) _short = [re.sub(r"^OTO\s+", "", w["workspace"]).strip() for w in ws_list] ma = re.search(r"(\d+) portails rôle métier \(([^)]*)\)", ws_fi) if ma is None: bad(f"Portails ensemble · fiche — « N portails rôle métier (…) » INTROUVABLE " f"dans {WS_FI} (attendu {'/'.join(_short)})") else: got = [s.strip() for s in ma.group(2).split("/") if s.strip()] if ma.group(1) != str(len(_short)): bad(f"Portails ensemble · fiche — annonce « {ma.group(1)} portails métier » " f"MAIS les workspaces frontend en comptent {len(_short)}") if got == _short: good(f"Portails ensemble · fiche — énumération {'/'.join(got)} == " f"workspaces frontend ({len(_short)})") else: miss = [x for x in _short if x not in got] extra = [x for x in got if x not in _short] detail = (f" · absents={miss}" if miss else "") + \ (f" · en trop={extra}" if extra else "") or " · même ensemble, ordre ≠" bad(f"Portails ensemble · fiche — énumère {got} MAIS workspaces disent " f"{_short}{detail} → régénérer la fiche") if _pb is not None and _distinct is not None: # (b) console technique exclue = l'unique portail RBAC HORS métier _tech = [p for p in _distinct if p not in _pb] if len(_tech) != 1: bad(f"Portails ensemble · attendu exactement 1 portail technique hors métier, " f"trouvé {_tech} (distinct={_distinct}, métier={_pb}) → contrat incohérent") else: tech = _tech[0] # « console technique `` (Nᵉ portail RBAC) est **exclue** » — le \S* # absorbe l'exposant « ᵉ » (multi-octets) sans le coder en dur ici. mt = re.search(r"console technique `([^`]+)` \((\d+)\S* portail RBAC\) " r"est \*\*exclue\*\*", ws_fi) if mt is None: bad(f"Portails ensemble · fiche — claim « console technique `{tech}` " f"(Nᵉ portail RBAC) est **exclue** » INTROUVABLE dans {WS_FI}") elif mt.group(1) != tech: bad(f"Portails ensemble · fiche — nomme la console technique `{mt.group(1)}` " f"MAIS le contrat dit `{tech}` (unique portail hors métier) → régénérer") elif mt.group(2) != str(len(_distinct)): bad(f"Portails ensemble · fiche — dit `{tech}` = {mt.group(2)}ᵉ portail RBAC " f"MAIS le contrat compte {len(_distinct)} portails distincts") else: good(f"Portails ensemble · fiche — console technique `{tech}` = " f"{mt.group(2)}ᵉ portail RBAC, hors métier, exclue ✓") # ============================================================================ # 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") # 1quater-bis) RBAC_50_ROLES_SPEC.md §3 — colonne « Familles rattachées » PAR portail # La MÊME table §3 porte, à GAUCHE de la colonne « Nb rôles » (gatée ci-dessus), la # colonne « Familles rattachées » : pour chaque portail, l'ENSEMBLE des `famille` # distinctes de ses rôles (ex. `direction` → {direction, finance, faisabilite, legal}). # C'est un SET data-derived du contrat `rbac_50_roles.json[roles][].famille` — mais le # bloc §3 ci-dessus l'AVALE par `[^|]*` (il ne capture QUE le compte), donc AUCUN gate # ne le touche. Silent-green réel : reclasser la `famille` d'un rôle À L'INTÉRIEUR d'un # portail (ex. l'unique rôle `legal` de `direction` repassé en `finance`) change le SET # de familles SANS changer le « Nb rôles » du portail → la cellule §3 ment en silence # pendant que le compte-gate reste VERT ; ajouter/retirer une famille d'un portail # (nouveau rôle d'une famille inédite) laisse la cellule PÉRIMÉE. Ni check_artifacts # (SPEC n'est pas un out/*.json) ni le §3 count-gate ni le §5 (regroupements ÉDITORIAUX, # volontairement NON mappés famille↔groupe) ne mordent. On RECOMPUTE le SET par portail # et on exige, par ENSEMBLE (absent ET fantôme), l'égalité avec les jetons `famille` de # la cellule. On ne gate QUE les jetons `famille` bruts (identifiant de tête de chaque # item séparé par `,`), PAS les parenthèses ÉDITORIALES (« finance (CFO) », « faisabilite # (analyste) » — paraphrase, pas contrat, #6). Un portail sans cellule échoue AUSSI # (INTROUVABLE = régression de traçabilité #6). if src_roles is not None and spec_rd is not None: fam_by_portail = {} for r in src_roles: p = r.get("portail"); f = r.get("famille") fam_by_portail.setdefault(p, []) if f not in fam_by_portail[p]: fam_by_portail[p].append(f) for portail in sorted(fam_by_portail): exp_fam = fam_by_portail[portail] # cellule « Familles rattachées » = 2ᵉ colonne (entre le portail et « Nb rôles ») pat = rf"\| \*\*{re.escape(portail)}\*\*[^|]*\| ([^|]*)\| \d+ \|" m = re.search(pat, spec_rd) if m is None: bad(f"RBAC SPEC §3 familles · portail `{portail}` — cellule INTROUVABLE " f"dans {SPEC_RD} (attendu familles {{{', '.join(exp_fam)}}})") continue # jeton `famille` de tête de chaque item séparé par « , » (hors parenthèses éditoriales) got_fam = re.findall(r"(?:^|,)\s*([a-z]+)", m.group(1).strip()) absent = [f for f in exp_fam if f not in got_fam] phantom = [f for f in got_fam if f not in exp_fam] if not absent and not phantom: good(f"RBAC SPEC §3 familles · portail `{portail}` — {{{', '.join(sorted(got_fam))}}} " f"== familles distinctes du contrat") else: det = [] if absent: det.append(f"MANQUE {{{', '.join(absent)}}}") if phantom: det.append(f"FANTÔME {{{', '.join(phantom)}}}") bad(f"RBAC SPEC §3 familles · portail `{portail}` — la cellule diverge du " f"contrat : {' · '.join(det)} (attendu {{{', '.join(exp_fam)}}}) → 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") # 1sexies) RBAC_50_ROLES_SPEC.md §4 — colonne « Entité » ancrée sur CLAUDE.md §Entités # La table §4 « Cartographie rôles ↔ entités » liste, en colonne de tête, les entités # porteuses (`entite_principale`) du RBAC : les 7 entités NOMMÉES du mandat (WAF · WA # SRL · AC Arias Cuevas · Consortium ECR DR · Helios RD · Ploutos · 9060 QC) + le # périmètre consolidé « Groupe ». Cette colonne a DEUX sources faisant autorité — (i) # CLAUDE.md §Entités (l'orthographe + le descripteur parenthétique de chaque entité # nommée) et (ii) rbac_50_roles.json (l'ENSEMBLE des `entite_principale` réellement # portées par les 50 rôles) — mais AUCUN gate ne la touchait : le §3 gate les comptes # par PORTAIL, le §7 les comptes du run-book, les blocs fiches l'`entite` d'UN rôle ; # tous AVEUGLES à l'identité de CETTE liste. AJOUTER au contrat un rôle porté par une # entité absente de §4, RENOMMER une entité dans CLAUDE.md §Entités (9060 QC→9061 QC) # ou DÉRIVER un descripteur laisse la SPEC contredire le mandat/le contrat EN SILENCE # — « vert trompeur » de la MÊME classe que les entités CONFOTUR (§Entités) ou la # marque SEO org, qu'aucune suite tests/ (FONCTIONS RBAC, jamais la prose de la SPEC) # n'attrape. # GATE : (a) ENSEMBLE des entités de §4 == distinct `entite_principale` du contrat # (set-diff : ni fantôme ni manquante) ; (b) ANCRAGE — chaque entité NOMMÉE de # CLAUDE.md §Entités est une ligne de §4 avec son descripteur parenthétique EXACT # (absence comprise) ; (c) chaque entité nommée du mandat est PORTÉE ≥1× par le # contrat (cm_ents ⊆ contrat). Un claim absent échoue AUSSI (traçabilité #6). if src_roles is not None and spec_rd is not None: try: _cm4 = open("CLAUDE.md", encoding="utf-8").read() _sec4 = re.search(r"^## Entités\s*\n(.*?)(?=^## )", _cm4, re.S | re.M) if not _sec4: raise ValueError("section « ## Entités » introuvable") # (entité → descripteur parenthétique | None), dans l'ordre du mandat cm4 = [(m.group(1).strip(), (m.group(2).strip() if m.group(2) else None)) for m in re.finditer(r"\*\*([^*]+?)\*\*(?:\s*\(([^)]+)\))?", _sec4.group(1))] if not cm4: raise ValueError("aucune entité en gras dans §Entités") if len({e for e, _ in cm4}) != len(cm4): raise ValueError(f"entités non uniques : {[e for e, _ in cm4]}") except (OSError, ValueError) as e: bad(f"RBAC SPEC §4 · CLAUDE.md §Entités illisible/inattendu : {e}"); cm4 = None _m4 = re.search(r"## 4\..*?(?=## 5\.)", spec_rd) if _m4 is None: bad(f"RBAC SPEC §4 · section « ## 4. » INTROUVABLE dans {SPEC_RD} " f"(claim absent = régression #6)"); sec4 = None else: sec4 = _m4.group(0) if cm4 is not None and sec4 is not None: # Colonne de tête : « | **Entité** (desc)? | » (le header non-gras et le # séparateur ne matchent pas ; la colonne « Exemples de rôles » n'a pas de gras) spec4 = [(m.group(1).strip(), (m.group(2).strip() if m.group(2) else None)) for m in re.finditer(r"\| \*\*([^*|]+?)\*\*(?: \(([^)|]+)\))? \|", sec4)] spec4_names = {n for n, _ in spec4} contract_ents = {r.get("entite_principale") for r in src_roles} # (a) ENSEMBLE §4 == distinct entite_principale du contrat _miss = sorted(contract_ents - spec4_names) # portées mais absentes de §4 _phan = sorted(spec4_names - contract_ents) # listées mais jamais portées if _miss or _phan: bad(f"RBAC SPEC §4 · (a) colonne Entité ≠ distinct `entite_principale` du " f"contrat — manquantes={_miss} · fantômes={_phan} → corriger la SPEC " f"(ou régénérer si le contrat a changé)") else: good(f"RBAC SPEC §4 · (a) {len(spec4_names)} entités == distinct " f"`entite_principale` rbac_50_roles.json (ni fantôme ni manquante)") # (b) ANCRAGE — chaque entité NOMMÉE de CLAUDE.md est une ligne §4 · descripteur exact spec4_map = dict(spec4) for _e, _d in cm4: if _e not in spec4_map: bad(f"RBAC SPEC §4 · (b) entité canonique `{_e}` (CLAUDE.md §Entités) " f"ABSENTE de la colonne §4 → SPEC périmée") elif spec4_map[_e] != _d: bad(f"RBAC SPEC §4 · (b) `{_e}` — descripteur §4 {spec4_map[_e]!r} ≠ " f"CLAUDE.md §Entités {_d!r} → aligner sur le mandat") else: good(f"RBAC SPEC §4 · (b) `{_e}` ({_d or 'sans descripteur'}) == " f"CLAUDE.md §Entités") # (c) chaque entité nommée du mandat est PORTÉE ≥1× par le contrat _unused = sorted(e for e, _ in cm4 if e not in contract_ents) if _unused: bad(f"RBAC SPEC §4 · (c) entités du mandat JAMAIS portées par un rôle : " f"{_unused} → entité décorative (contrat ou §Entités à revoir)") else: good(f"RBAC SPEC §4 · (c) les {len(cm4)} entités nommées du mandat sont " f"portées ≥1× par le contrat") # 1septies) RBAC_50_ROLES_SPEC.md §5 — « Les N rôles (résumé) » : compte-titre, # comptes de regroupement éditoriaux et LIGNE d'auto-contrôle arithmétique ------- # Le §5 « résumé » restitue le total de rôles sur TROIS surfaces prose qu'aucun # gate ne touchait — (i) le TITRE « ## 5. Les N rôles (résumé) », (ii) NEUF comptes # parenthétiques de regroupement (« **Ventes (8)** » …) et (iii) une LIGNE # d'auto-contrôle arithmétique « _(6+8+…+6 = 50.)_ ». Le §3 gate la ventilation par # PORTAIL, le §7 le run-book, le §4 les entités — TOUS aveugles à ce §5. NB : les 9 # regroupements sont ÉDITORIAUX (« Direction / Gouvernance » agrège des rôles de # familles direction+finance ; ≠ la colonne `famille` du contrat, cf. memory # rbac-spec-portail-count-gate « §5 by-family is editorial ») → on NE mappe PAS # groupe↔famille (ce serait une dérive fausse). On gate ce qui EST data-derived et # vérifiable SANS ce mapping : (a) le compte-titre N == |rôles| du contrat (byte- # gaté par check_artifacts) ; (b) les 9 comptes de regroupement == EXACTEMENT, dans # l'ordre, les termes de la ligne arithmétique (cohérence interne prose↔somme) ; # (c) l'arithmétique est VRAIE (Σ termes == membre droit) ; (d) le membre droit == # |rôles|. AJOUTER un rôle au contrat (|rôles| 50→51) laisse le titre, la somme et # le « = 50 » PÉRIMÉS en silence ; ÉDITER « Ventes (8) »→« (7) » sans toucher la # ligne casse (b) puis (c). Section/titre/ligne/comptes absents = échec (#6). if src_roles is not None and spec_rd is not None: N5 = len(src_roles) _m5 = re.search(r"## 5\..*?(?=## 6\.)", spec_rd) if _m5 is None: bad(f"RBAC SPEC §5 · section « ## 5. » INTROUVABLE dans {SPEC_RD} " f"(claim absent = régression #6)") else: sec5 = _m5.group(0) # (a) compte-titre « ## 5. Les N rôles » mt5 = re.search(r"## 5\. Les (\d+) rôles", sec5) if mt5 is None: bad(f"RBAC SPEC §5 · titre « ## 5. Les N rôles » INTROUVABLE dans {SPEC_RD}") elif mt5.group(1) == str(N5): good(f"RBAC SPEC §5 · (a) compte-titre {mt5.group(1)} == |rôles| " f"rbac_50_roles.json ({N5})") else: bad(f"RBAC SPEC §5 · (a) titre dit {mt5.group(1)} rôles MAIS le contrat " f"en compte {N5} → corriger la SPEC") # 9 comptes de regroupement (gras · « (N) » collant au **fermant) heads = [int(m.group(1)) for m in re.finditer(r"\*\*[^*]+?\((\d+)\)\*\*", sec5)] # ligne d'auto-contrôle « _(a+b+… = T.)_ » ma5 = re.search(r"_\(((?:\d+\+)+\d+) = (\d+)\.\)_", sec5) if ma5 is None: bad(f"RBAC SPEC §5 · ligne d'auto-contrôle « _(a+b+… = T.)_ » " f"INTROUVABLE dans {SPEC_RD} (claim absent = régression #6)") elif not heads: bad(f"RBAC SPEC §5 · aucun compte de regroupement « **… (N)** » trouvé " f"dans {SPEC_RD} (claim absent = régression #6)") else: addends = [int(x) for x in ma5.group(1).split("+")] rhs = int(ma5.group(2)) # (b) comptes de regroupement == termes de la ligne, EN ORDRE if heads == addends: good(f"RBAC SPEC §5 · (b) {len(heads)} comptes de regroupement == " f"termes de la ligne d'auto-contrôle ({'+'.join(map(str, addends))})") else: bad(f"RBAC SPEC §5 · (b) comptes de regroupement {heads} ≠ termes de " f"la ligne {addends} (ordre/valeur) → aligner titres et somme") # (c) arithmétique vraie if sum(addends) == rhs: good(f"RBAC SPEC §5 · (c) Σ termes {'+'.join(map(str, addends))} = " f"{rhs} (arithmétique exacte)") else: bad(f"RBAC SPEC §5 · (c) la ligne écrit « = {rhs} » MAIS Σ termes = " f"{sum(addends)} → corriger la somme") # (d) membre droit == |rôles| du contrat if rhs == N5: good(f"RBAC SPEC §5 · (d) membre droit {rhs} == |rôles| " f"rbac_50_roles.json ({N5})") else: bad(f"RBAC SPEC §5 · (d) la ligne totalise {rhs} MAIS le contrat " f"compte {N5} rôles → SPEC périmée") # 1octies) RBAC_50_ROLES_SPEC.md §2 — ligne « Row-level » : ENSEMBLE des `scope_donnees` # ancré sur l'ENUM du schéma --------------------------------------------------- # Le §2 « Modèle RBAC » ouvre par « trois niveaux, tous couverts PAR LE SCHÉMA » puis # sa 3ᵉ ligne de table (« **Row-level** (User Permission) | `scope_donnees` | … ») ÉNUMÈRE # À LA MAIN l'ensemble EXHAUSTIF des portées row-level : `own` · `equipe` · `entite` # (Company) · `groupe` (consolidé). Cet ensemble EST, par la phrase d'intro, l'`enum` # `scope_donnees` de `rbac.schema.json` (la DÉFINITION du modèle — pas le sous-ensemble # effectivement exercé par les 50 rôles, qui peut être plus petit). AUCUN gate ne le # touchait : §3 compte les rôles par portail, §4 les entités, §5 le résumé, le bloc # userperm gate la TABLE `scope → mécanisme` d'un AUTRE artefact (userperm_gen/out) — # tous AVEUGLES à l'énumération du §2. « Vert trompeur » : le schéma AJOUTE une portée # (`region` pour un besoin ops réel) ou en RETIRE une (`own` supprimé, schéma resserré) # et le §2 reste PÉRIMÉ en silence — il documente un modèle qui contredit le schéma # tout en se réclamant « couvert par le schéma ». Ni check_artifacts (la SPEC n'est pas # un out/*.json) ni tests/ (FONCTIONS de validation, jamais l'ancre SPEC↔schéma) ne # mordent. Même classe que §4 (colonne ancrée sur une source-de-vérité structurée). # GATE : (a) ENSEMBLE des jetons `scope` de la ligne §2 == `enum` scope_donnees du # schéma (set-diff absent ET fantôme) ; (b) ANCRAGE — la colonne du milieu NOMME bien # `scope_donnees` (sinon on gate la mauvaise ligne) ; (c) sanity — toute portée # RÉELLEMENT exercée par le contrat ∈ enum (une portée en usage jamais dans l'enum = # schéma incohérent). On NE gate PAS les gloses parenthétiques `(Company)`/`(consolidé)` # (éditoriales · #6). Ligne/section/enum absents = échec (traçabilité #6). if src_roles is not None and spec_rd is not None: try: _sch = load("rbac/rbac.schema.json") _enum = _sch["definitions"]["role"]["properties"]["scope_donnees"]["enum"] scope_enum = set(_enum) if not scope_enum: raise ValueError("enum scope_donnees vide") if len(scope_enum) != len(_enum): raise ValueError(f"enum scope_donnees non unique : {_enum}") except (OSError, KeyError, ValueError, TypeError) as e: bad(f"RBAC SPEC §2 · rbac.schema.json enum scope_donnees illisible : {e}") scope_enum = None _m2 = re.search(r"## 2\..*?(?=## 3\.)", spec_rd) if _m2 is None: bad(f"RBAC SPEC §2 · section « ## 2. » INTROUVABLE dans {SPEC_RD} " f"(claim absent = régression #6)") elif scope_enum is not None: # Ligne « Row-level » : capte la colonne « champ » (c2) et la colonne énum (c3) _row = re.search(r"\| \*\*Row-level\*\*[^|]*\|([^|]*)\|([^|]*)\|", _m2.group(0)) if _row is None: bad(f"RBAC SPEC §2 · ligne « **Row-level** » INTROUVABLE dans {SPEC_RD} " f"(claim absent = régression #6)") else: c_field, c_enum = _row.group(1), _row.group(2) # (b) la colonne du milieu doit nommer le champ `scope_donnees` if "scope_donnees" not in c_field: bad(f"RBAC SPEC §2 · (b) colonne « champ » de la ligne Row-level ne " f"nomme pas `scope_donnees` (capté {c_field!r}) → ligne mal ancrée") else: good("RBAC SPEC §2 · (b) ligne Row-level ancrée sur le champ " "`scope_donnees`") spec_scopes = set(re.findall(r"`([a-z_]+)`", c_enum)) _miss = sorted(scope_enum - spec_scopes) # dans l'enum, absents du §2 _phan = sorted(spec_scopes - scope_enum) # listés au §2, hors enum if _miss or _phan: bad(f"RBAC SPEC §2 · (a) énumération `scope_donnees` ≠ enum du " f"schéma — manquants={_miss} · fantômes={_phan} → aligner la " f"SPEC sur rbac.schema.json (ou régénérer si le schéma a changé)") else: good(f"RBAC SPEC §2 · (a) {len(spec_scopes)} portées row-level " f"{sorted(spec_scopes)} == enum scope_donnees du schéma") # (c) sanity : toute portée exercée par le contrat ∈ enum _used = {r.get("scope_donnees") for r in src_roles} _illegal = sorted(_used - scope_enum) if _illegal: bad(f"RBAC SPEC §2 · (c) portées `scope_donnees` exercées par le contrat " f"HORS enum du schéma : {_illegal} → schéma/contrat incohérents") else: good(f"RBAC SPEC §2 · (c) les {len(_used)} portées exercées par le contrat " f"sont toutes dans l'enum du schéma") # 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") # ============================================================================ # SEO · IDENTITÉ du COMPTE d'INVARIANTS CI — le README annonce « ## Garanties # (N invariants) » et le générateur se SELF-REPORTE (« schéma + N invariants ») # ---------------------------------------------------------------------------- # CLASSE DISTINCTE du bloc SEO ci-dessus (qui gate des COMPTES d'ARTEFACT — # mots-clés total + par langue, recomptés de MANIFEST.counts). Ici la donnée # gardée est le COMPTE D'INVARIANTS de validation, dont la source d'autorité # n'est PAS un artefact JSON (invisible à check_artifacts qui byte-gate les # out/*.json) mais le LEDGER NUMÉROTÉ de la fonction `_validate` du générateur : # les marqueurs `# N ·` (1 · 2 · … · 15) qui ouvrent chaque contrôle appliqué — # exactement l'analogue du registre `# Invariant N` de Chat OTOIA. Ce compte est # transcrit À LA MAIN à SEPT endroits qu'AUCUN gate ne couvrait : le titre de # section du README (« ## Garanties (N invariants) ») + son bloc de commande # (« schéma de sortie + N invariants »), plus CINQ chaînes du .py lui-même # (docstring module, docstring `_validate`, aide `build`, message de succès CLI # « N invariants verts. », aide du sous-parseur `validate`). Ajouter un contrôle # `# 16 ·` à `_validate` sans toucher les chaînes ⇒ le README ET les self-reports # du .py mentent en silence pendant que la CI applique 16 contrôles — « vert # trompeur » (#6) que ni les suites tests/ (FONCTIONS, jamais la prose) ni le # byte-gate d'artefact (aveugle au .py source) n'attrapent. On RECOMPUTE ici le # ledger depuis le SEUL registre-source (les `# N ·` de `_validate`) et on exige : # (a) le ledger est CONTIGU 1..N (lacune/doublon dans les marqueurs = défaut réel # du registre) ; (b) TOUTE mention « N invariants » du .py (self-reports) == |ledger| # (un seul chiffre désynchronisé mordu) ; (c) le compte annoncé du README == |ledger| ; # (d) TOUTE mention « N invariants » du README == |ledger|. La prose du README # RÉSUME les contrôles (≠ énumération numérotée 1:1) : on gate le COMPTE partout # où il apparaît, pas un set-diff de la liste. Section/compte absent = échec (la # disparition d'un chiffre est une régression de traçabilité · #6). # ============================================================================ SEO_GEN = f"{D}/seo/seo_gen.py" try: seo_src = open(SEO_GEN, encoding="utf-8").read() except OSError as e: bad(f"SEO invariants · générateur {SEO_GEN} illisible : {e}"); seo_src = None if seo_src is not None: # (a) registre-source = marqueurs `# N ·` du LEDGER de `_validate` (autorité). _mfn = re.search(r"def _validate\b", seo_src) if _mfn is None: bad(f"SEO invariants · fonction `_validate` INTROUVABLE dans {SEO_GEN} " "(registre-source du ledger absent → impossible de re-dériver)") else: _bs = _mfn.end() _mn = re.search(r"\ndef ", seo_src[_bs:]) _body = seo_src[_bs: _bs + _mn.start()] if _mn else seo_src[_bs:] seo_led = [int(n) for n in re.findall(r"#\s*(\d+)\s*·", _body)] if not seo_led: bad(f"SEO invariants · aucun marqueur `# N ·` dans `_validate` de {SEO_GEN} " "(ledger vide → impossible de re-dériver)") else: seo_ninv = len(seo_led) if sorted(seo_led) != list(range(1, seo_ninv + 1)): bad(f"SEO invariants · ledger `_validate` NON contigu : {seo_led} " f"(attendu 1..{seo_ninv}) — lacune/doublon dans les marqueurs `# N ·`") else: good(f"SEO invariants · ledger `_validate` contigu 1..{seo_ninv} " f"({seo_ninv} marqueurs `# N ·`)") # (b) self-reports du .py « N invariants » (docstrings + aides + succès CLI). seo_py_rep = sorted({int(x) for x in re.findall(r"(\d+)\s+invariants", seo_src)}) if not seo_py_rep: bad(f"SEO invariants · aucune mention « N invariants » dans {SEO_GEN} " f"(self-report absent → attendu {seo_ninv})") elif seo_py_rep == [seo_ninv]: good(f"SEO invariants · {SEO_GEN} — self-report(s) {seo_py_rep} " f"== ledger ({seo_ninv})") else: bad(f"SEO invariants · {SEO_GEN} — self-report(s) « N invariants » " f"{seo_py_rep} MAIS ledger en compte {seo_ninv} " "(une chaîne du générateur désynchronisée du registre)") # README : titre de section + toute mention « N invariants ». try: seo_rd_raw = open(SEO_RD, encoding="utf-8").read() except OSError as e: bad(f"SEO invariants · {SEO_RD} illisible : {e}"); seo_rd_raw = None if seo_rd_raw is not None: _mh = re.search(r"##\s+Garanties\s+\((\d+)\s+invariants\)", seo_rd_raw) if _mh is None: bad(f"SEO invariants · {SEO_RD} — section « ## Garanties (N invariants) » " f"INTROUVABLE (attendu {seo_ninv})") else: # (c) compte annoncé. if int(_mh.group(1)) == seo_ninv: good(f"SEO invariants · {SEO_RD} — annonce ({_mh.group(1)}) " f"== ledger ({seo_ninv})") else: bad(f"SEO invariants · {SEO_RD} — annonce ({_mh.group(1)}) MAIS " f"ledger en compte {seo_ninv} → régénérer le README") # (d) toute mention « N invariants » du README == ledger. seo_rd_rep = sorted({int(x) for x in re.findall(r"(\d+)\s+invariants", seo_rd_raw)}) if seo_rd_rep == [seo_ninv]: good(f"SEO invariants · {SEO_RD} — mention(s) {seo_rd_rep} " f"== ledger (aucune dérive)") else: bad(f"SEO invariants · {SEO_RD} — mention(s) « N invariants » " f"{seo_rd_rep} MAIS ledger en compte {seo_ninv} → régénérer le README") # ============================================================================ # Fiscal · e-CF DGII · IDENTITÉ du COMPTE D'INVARIANTS de `_validate` — même # classe que le ledger SEO / Chat OTOIA, appliquée au générateur fiscal. # ---------------------------------------------------------------------------- # CLASSE DISTINCTE des blocs Fiscal existants (format e-NCF · cross-cohérence # emission_events · rôle émetteur — qui gatent le CONTENU de out/ecf_plan.json). # Ici la donnée gardée est le COMPTE d'invariants de cross-cohérence appliqués # par `_validate` d'ecf_dgii_gen.py, dont la source d'autorité n'est PAS un # artefact JSON (invisible à check_artifacts qui byte-gate les out/*.json) mais # le LEDGER NUMÉROTÉ de la fonction : les marqueurs `# N ·` qui ouvrent chaque # contrôle — analogue du registre SEO/Chat OTOIA. Le nombre « 12 » est transcrit # À LA MAIN à SEPT endroits qu'AUCUN gate ne couvrait : QUATRE chaînes du .py # (docstring module, docstring `_validate`, message de succès CLI « … 12 # invariants verts. », aide du sous-parseur `validate`) + TROIS du README (bloc # de commande « schéma + 12 invariants », titre « ## Les 12 invariants », prose # de score « 12 invariants de cross-cohérence »). Ajouter un contrôle `# 13 ·` à # `_validate` sans toucher les chaînes ⇒ le README ET les self-reports du .py # mentent en silence pendant que la CI applique 13 contrôles — « vert trompeur » # (#6) que ni les suites tests/ (FONCTIONS, jamais la prose) ni le byte-gate # d'artefact (aveugle au .py source) n'attrapent. On RECOMPUTE ici le ledger # depuis le SEUL registre-source (les `# N ·` de `_validate`) et on exige : # (a) ledger CONTIGU 1..N — l'invariant 1 est la validation de SCHÉMA (ligne # `validate(bundle, schema)`, non marquée `# 1 ·`) ; les marqueurs couvrent # 2..N ; l'invariant 12 est SPLITTÉ en `# 12a ·`/`# 12b ·` (deux volets d'un # même invariant, comptés UNE fois) — un doublon d'un numéro NON suffixé est # lui un vrai défaut de registre et MORD ; (b) TOUTE mention « N invariants » # du .py (self-reports) == |ledger| ; (c) le compte annoncé du titre README # == |ledger| ; (d) TOUTE mention « N invariants » du README == |ledger| ; # (e) l'ÉNUMÉRATION numérotée du README (section « ## Les N invariants », prose # « 1. … 2. … N. ») est elle-même CONTIGUË 1..N (la liste ne saute/duplique # pas un item). Section/compte absent = échec (disparition d'un chiffre = # régression de traçabilité · #6). # ============================================================================ ECF_GEN = f"{D}/fiscal/ecf_dgii/ecf_dgii_gen.py" ECF_RD_INV = f"{D}/fiscal/ecf_dgii/README.md" try: ecf_src = open(ECF_GEN, encoding="utf-8").read() except OSError as e: bad(f"Fiscal invariants · générateur {ECF_GEN} illisible : {e}"); ecf_src = None if ecf_src is not None: _mfn = re.search(r"def _validate\b", ecf_src) if _mfn is None: bad(f"Fiscal invariants · fonction `_validate` INTROUVABLE dans {ECF_GEN} " "(registre-source du ledger absent → impossible de re-dériver)") else: _bs = _mfn.end() _mn = re.search(r"\ndef ", ecf_src[_bs:]) _body = ecf_src[_bs: _bs + _mn.start()] if _mn else ecf_src[_bs:] # marqueurs `# N ·` / `# Na ·` (suffixe lettre = volet d'un même invariant) _pairs = re.findall(r"#\s*(\d+)([a-z]?)\s*·", _body) # doublon d'un numéro NON suffixé = vrai défaut (≠ split 12a/12b légitime) _dup_bare = sorted({n for n in {p[0] for p in _pairs} if [s for num, s in _pairs if num == n and s == ""] and sum(1 for num, _ in _pairs if num == n) > 1}) _schema = bool(re.search(r"validate\(bundle,\s*schema\)", _body)) _top = {int(n) for n, _ in _pairs} if not _top: bad(f"Fiscal invariants · aucun marqueur `# N ·` dans `_validate` de " f"{ECF_GEN} (ledger vide → impossible de re-dériver)") elif _dup_bare: bad(f"Fiscal invariants · marqueur(s) `# N ·` dupliqué(s) sans suffixe " f"a/b dans `_validate` : {_dup_bare} — vrai doublon de registre") elif not _schema: bad(f"Fiscal invariants · l'invariant 1 (validation de schéma, " f"`validate(bundle, schema)`) INTROUVABLE dans `_validate` de {ECF_GEN} " "— le ledger ne peut pas débuter à 1") else: ecf_led = sorted(_top | {1}) # 1 = validation de schéma (non marquée) ecf_ninv = len(ecf_led) if ecf_led != list(range(1, ecf_ninv + 1)): bad(f"Fiscal invariants · ledger `_validate` NON contigu : {ecf_led} " f"(attendu 1..{ecf_ninv}) — lacune dans les marqueurs `# N ·` " "(schéma = 1, marqueurs = 2..N)") else: good(f"Fiscal invariants · ledger `_validate` contigu 1..{ecf_ninv} " f"(schéma #1 + {len(_top)} marqueurs `# N ·`, 12a/12b comptés 1×)") # (b) self-reports du .py « N invariants ». ecf_py_rep = sorted({int(x) for x in re.findall(r"(\d+)\s+invariants", ecf_src)}) if not ecf_py_rep: bad(f"Fiscal invariants · aucune mention « N invariants » dans {ECF_GEN} " f"(self-report absent → attendu {ecf_ninv})") elif ecf_py_rep == [ecf_ninv]: good(f"Fiscal invariants · {ECF_GEN} — self-report(s) {ecf_py_rep} " f"== ledger ({ecf_ninv})") else: bad(f"Fiscal invariants · {ECF_GEN} — self-report(s) « N invariants » " f"{ecf_py_rep} MAIS ledger en compte {ecf_ninv} " "(une chaîne du générateur désynchronisée du registre)") # README : titre de section + toute mention + énumération numérotée. try: ecf_rd_raw = open(ECF_RD_INV, encoding="utf-8").read() except OSError as e: bad(f"Fiscal invariants · {ECF_RD_INV} illisible : {e}"); ecf_rd_raw = None if ecf_rd_raw is not None: _mh = re.search(r"##\s+Les\s+(\d+)\s+invariants", ecf_rd_raw) if _mh is None: bad(f"Fiscal invariants · {ECF_RD_INV} — section « ## Les N invariants » " f"INTROUVABLE (attendu {ecf_ninv})") else: # (c) compte annoncé du titre. if int(_mh.group(1)) == ecf_ninv: good(f"Fiscal invariants · {ECF_RD_INV} — annonce ({_mh.group(1)}) " f"== ledger ({ecf_ninv})") else: bad(f"Fiscal invariants · {ECF_RD_INV} — annonce ({_mh.group(1)}) MAIS " f"ledger en compte {ecf_ninv} → régénérer le README") # (d) toute mention « N invariants » du README == ledger. ecf_rd_rep = sorted({int(x) for x in re.findall(r"(\d+)\s+invariants", ecf_rd_raw)}) if ecf_rd_rep == [ecf_ninv]: good(f"Fiscal invariants · {ECF_RD_INV} — mention(s) {ecf_rd_rep} " f"== ledger (aucune dérive)") else: bad(f"Fiscal invariants · {ECF_RD_INV} — mention(s) « N invariants » " f"{ecf_rd_rep} MAIS ledger en compte {ecf_ninv} → régénérer le README") # (e) énumération numérotée de la section == contiguë 1..N. _msec = re.search(r"##\s+Les\s+\d+\s+invariants.*?(?=\n##\s|\Z)", ecf_rd_raw, re.S) ecf_enum = ([int(x) for x in re.findall(r"(? 1}) _top = sorted({int(n) for n, _ in _pairs}) if not _top: bad(f"CONFOTUR invariants · aucun marqueur `# N ·` dans `_validate` de " f"{CF_GEN} (ledger vide → impossible de re-dériver)") elif _dup_bare: bad(f"CONFOTUR invariants · marqueur(s) `# N ·` dupliqué(s) sans suffixe " f"a/b dans `_validate` : {_dup_bare} — vrai doublon de registre") else: cf_ninv = len(_top) if _top != list(range(1, cf_ninv + 1)): bad(f"CONFOTUR invariants · ledger `_validate` NON contigu : {_top} " f"(attendu 1..{cf_ninv}) — lacune/doublon dans les marqueurs `# N ·`") else: good(f"CONFOTUR invariants · ledger `_validate` contigu 1..{cf_ninv} " f"({cf_ninv} marqueurs `# N ·`)") # (b) self-reports du .py « N invariants » (hors sous-ensemble « refusent »). cf_py_rep = sorted({int(x) for x in re.findall(_CF_RX_TOT, cf_src)}) if not cf_py_rep: bad(f"CONFOTUR invariants · aucune mention-total « N invariants » dans " f"{CF_GEN} (self-report absent → attendu {cf_ninv})") elif cf_py_rep == [cf_ninv]: good(f"CONFOTUR invariants · {CF_GEN} — self-report(s) {cf_py_rep} " f"== ledger ({cf_ninv})") else: bad(f"CONFOTUR invariants · {CF_GEN} — self-report(s) « N invariants » " f"{cf_py_rep} MAIS ledger en compte {cf_ninv} " "(une chaîne du générateur désynchronisée du registre)") # (c) README : mentions-total (le sous-ensemble « N invariants refusent » # est exclu par le lookahead ⇒ ne fausse pas le compte). try: cf_rd_raw = open(CF_RD, encoding="utf-8").read() except OSError as e: bad(f"CONFOTUR invariants · {CF_RD} illisible : {e}"); cf_rd_raw = None if cf_rd_raw is not None: cf_rd_rep = sorted({int(x) for x in re.findall(_CF_RX_TOT, cf_rd_raw)}) if not cf_rd_rep: bad(f"CONFOTUR invariants · {CF_RD} — aucune mention-total « N " f"invariants » (attendu {cf_ninv} · disparition = régression #6)") elif cf_rd_rep == [cf_ninv]: good(f"CONFOTUR invariants · {CF_RD} — mention(s)-total {cf_rd_rep} " "== ledger (aucune dérive · sous-ensemble « refusent » exclu)") else: bad(f"CONFOTUR invariants · {CF_RD} — mention(s)-total « N invariants » " f"{cf_rd_rep} MAIS ledger en compte {cf_ninv} → régénérer le README") # ============================================================================ # 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 · IDENTITÉ du VERDICT GLOBAL — le LABEL de conclusion (`PASS` / # `PASS_WITH_OPEN_ITEMS` / `FAIL`), pas seulement sa ventilation chiffrée. # ---------------------------------------------------------------------------- # Le bloc de ventilation ci-dessus recompute le TRIPLET chiffré (pass/fail/à # confirmer) mais reste AVEUGLE au LABEL de verdict lui-même — la HEADLINE de tout # l'audit. Ce label est data-derived (out/audit_report.json[verdict] == out/ # MANIFEST.json[verdict], byte-gatés par check_artifacts ⇒ faisant autorité), # recalculé par le générateur (qalib/builder.py) via la règle EXACTE : `FAIL` si # ≥1 FAIL · sinon `PASS_WITH_OPEN_ITEMS` si ≥1 A_CONFIRMER · sinon `PASS`. Le README # le transcrit À LA MAIN à DEUX endroits — la RÈGLE (« Verdict global : `FAIL` si … # sinon `PASS_WITH_OPEN_ITEMS` … sinon `PASS` ») ET le VERDICT COURANT # (« ## Verdict courant \n `PASS_WITH_OPEN_ITEMS` — **13 PASS · 0 FAIL · 4 à # confirmer** »). AUCUN gate ne le vérifiait : le triplet peut coller à l'artefact # pendant que le LABEL courant ment (écrire `PASS` alors que 4 open items # subsistent = violation #6 « ne jamais forcer un PASS », le CŒUR même du livrable — # l'audit prétend propre un dossier avec 4 params réglementaires non confirmés) ; # ou la RÈGLE documentée dérive du codomaine réel du générateur (un label renommé # dans builder.py, la prose périmée). check_artifacts ne prouve QUE # audit_report==build (byte) ; aucune suite tests/ (FONCTIONS, pas la prose du # README) n'attrape ces deux dérives. On MIROITE la règle du générateur et on # exige : (a) HONNÊTETÉ interne — audit_report.verdict == verdict recalculé du # triplet (l'audit ne peut afficher un verdict incohérent avec ses propres # chiffres) ; (b) CROISÉ — MANIFEST.verdict == audit_report.verdict ; (c) le LABEL # « Verdict courant » du README == audit_report.verdict ; (d) la RÈGLE du README # énumère EXACTEMENT le codomaine {FAIL, PASS_WITH_OPEN_ITEMS, PASS} (un label # renommé côté code sans mise à jour de la règle est mordu). Un claim absent échoue # AUSSI (disparition = régression). # ============================================================================ A5V_DOMAIN = {"FAIL", "PASS_WITH_OPEN_ITEMS", "PASS"} def _a5_verdict_from_totals(t): # miroir EXACT de qalib/builder.py : FAIL prime, puis open items, sinon PASS if t["fail"]: return "FAIL" if t["a_confirmer"]: return "PASS_WITH_OPEN_ITEMS" return "PASS" try: a5v_rep = load("qa/audit_5d/out/audit_report.json") a5v_verdict = a5v_rep["verdict"] a5v_tot = a5v_rep["totals"] a5v_man_verdict = load("qa/audit_5d/out/MANIFEST.json")["verdict"] except (OSError, KeyError, ValueError, TypeError) as e: bad(f"Audit 5D · verdict — artefact illisible/inattendu : {e}"); a5v_verdict = None if a5v_verdict is not None: # (a) honnêteté interne : le verdict de l'artefact découle de ses propres totals a5v_exp = _a5_verdict_from_totals(a5v_tot) if a5v_verdict != a5v_exp: bad(f"Audit 5D · verdict — audit_report dit « {a5v_verdict} » MAIS ses totals " f"(fail={a5v_tot['fail']} · à_confirmer={a5v_tot['a_confirmer']}) imposent " f"« {a5v_exp} » (verdict FORCÉ, incohérent — viole #6)") else: good(f"Audit 5D · verdict — audit_report « {a5v_verdict} » == recalcul du triplet " f"(fail={a5v_tot['fail']} · à_confirmer={a5v_tot['a_confirmer']})") # (b) croisé artefacts : MANIFEST == rapport if a5v_man_verdict != a5v_verdict: bad(f"Audit 5D · verdict — MANIFEST dit « {a5v_man_verdict} » MAIS audit_report " f"dit « {a5v_verdict} » (artefacts désynchronisés)") else: good(f"Audit 5D · verdict — MANIFEST « {a5v_man_verdict} » == audit_report") a5v_rd = read_norm(A5D_RD) if a5v_rd is not None: # (c) LABEL « Verdict courant » du README == verdict de l'artefact m = re.search(r"## Verdict courant\s+`([A-Z_]+)`\s*—\s*\*\*\d+ PASS", a5v_rd) if m is None: bad(f"Audit 5D · {A5D_RD} — label « Verdict courant » INTROUVABLE " f"(attendu « {a5v_verdict} »)") elif m.group(1) == a5v_verdict: good(f"Audit 5D · {A5D_RD} — « Verdict courant » = « {m.group(1)} » == artefact") else: bad(f"Audit 5D · {A5D_RD} — « Verdict courant » = « {m.group(1)} » MAIS " f"artefact dit « {a5v_verdict} » → régénérer le README") # (d) la RÈGLE documentée énumère EXACTEMENT le codomaine du générateur seg = re.search(r"Verdict global\s*:\s*(.+?)(?: > | ## )", a5v_rd) if seg is None: bad(f"Audit 5D · {A5D_RD} — règle « Verdict global : … » INTROUVABLE " f"(codomaine {sorted(A5V_DOMAIN)} non vérifiable)") else: labels = set(re.findall(r"`([A-Z_]+)`", seg.group(1))) if labels == A5V_DOMAIN: good(f"Audit 5D · {A5D_RD} — règle « Verdict global » énumère le codomaine " f"{sorted(A5V_DOMAIN)} == générateur") else: miss = sorted(A5V_DOMAIN - labels); extra = sorted(labels - A5V_DOMAIN) bad(f"Audit 5D · {A5D_RD} — règle « Verdict global » énumère {sorted(labels)} " f"≠ codomaine générateur {sorted(A5V_DOMAIN)} " f"(manquants={miss or '—'} en_trop={extra or '—'}) → aligner la règle") # ============================================================================ # 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") # ============================================================================ # Audit 5D (3e surface du MÊME README) · IDENTITÉ des 5 DIMENSIONS — le libellé # (`titre`) ET la NORME de référence (`reference`) de chaque dimension du cadre — # même classe de « vert trompeur », transcrite À LA MAIN dans un tableau. # ---------------------------------------------------------------------------- # Le 1er bloc audit_5d gate le COMPTE de dimensions (« 5 dimensions (5D) ») — un # WILDCARD AVEUGLE à QUELLE dimension : renommer « Traçabilité & anti-invention » # ou permuter une norme de référence (D3 fiscal citant la mauvaise Ley, D1 # probatoire citant `ISA 501` au lieu d'`ISA 500`) laisse le compte à 5 pendant que # l'artefact dit autre chose. La table « ## Les 5 dimensions » du README # (05_deliverables_mvp/qa/audit_5d/README.md) énumère PAR LIGNE le triplet # (`id`, `titre`, `reference`) alors que audit_report.dimensions[] (byte-gaté par # check_artifacts ⇒ faisant autorité, chaque dimension recomposée du catalogue de # contrôles à chaque build) fait foi. C'est l'audit de SECOND NIVEAU que # l'auditeur/banquier lit : une norme de référence périmée (D2 AML citant une # mauvaise Ley 155-17, D3 fiscal la mauvaise Ley 32-23) est une AFFIRMATION de # conformité fausse — exactement l'invention que #6 interdit, qu'aucune suite # tests/ (qui teste des FONCTIONS de contrôle, pas la prose) n'attrape. On RECOMPUTE # la liste ORDONNÉE {id:(titre, reference)} depuis audit_report.dimensions[] (zéro # duplication du modèle de l'auditeur · #6) et on exige que la table README # l'énumère EXACTEMENT : identité par ENSEMBLE d'`id` (absent ET en trop mordus), # ORDRE préservé (D1→D5), puis (titre, reference) par LIGNE. Même patron que # l'énumération des open items ci-dessus. Cohérence croisée en bonus : les # dimensions du rapport == celles de audit_spec.json (l'INPUT dont le rapport # dérive) — mord un builder qui renommerait/permuterait une dimension en aval du # spec. Un claim absent (table disparue) échoue AUSSI (traçabilité #6). # ============================================================================ try: a5d_dim = load("qa/audit_5d/out/audit_report.json")["dimensions"] a5d_exp = {d["id"]: (d["titre"], d["reference"]) for d in a5d_dim} a5d_order = [d["id"] for d in a5d_dim] a5d_spec = load("qa/audit_5d/audit_spec.json")["dimensions"] a5d_spec_map = {d["id"]: (d["titre"], d["reference"]) for d in a5d_spec} if a5d_spec_map != a5d_exp: bad(f"Audit 5D · incohérence artefacts : audit_spec dimensions {sorted(a5d_spec_map)} " f"divergent du rapport {sorted(a5d_exp)} (titre/reference) → le rapport doit " f"dériver du spec") except (OSError, KeyError, ValueError) as e: bad(f"Audit 5D · dimensions illisibles/inattendues : {e}"); a5d_exp = None if a5d_exp is not None: try: a5d_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}"); a5d_lines = None if a5d_lines is not None: # ligne de table = « | **D1** | | | » (gras + PAS de # sous-point ⇒ distingue des lignes open items « | D1.1 · … | ») a5d_row_re = re.compile( r"^\|\s*\*\*(D\d+)\*\*\s*\|\s*([^|]+?)\s*\|\s*([^|]+?)\s*\|\s*$") a5d_seen, a5d_seen_order = {}, [] for ln in a5d_lines: m = a5d_row_re.match(ln) if m: a5d_seen[m.group(1)] = (m.group(2).strip(), m.group(3).strip()) a5d_seen_order.append(m.group(1)) a5d_exp_ids, a5d_got_ids = set(a5d_exp), set(a5d_seen) if a5d_got_ids != a5d_exp_ids: bad(f"Audit 5D · IDENTITÉ des dimensions — table README énumère " f"{sorted(a5d_got_ids)} MAIS audit_report dit {sorted(a5d_exp_ids)} " f"→ régénérer le README") elif a5d_seen_order != a5d_order: bad(f"Audit 5D · IDENTITÉ des dimensions — ORDRE README {a5d_seen_order} " f"MAIS audit_report dit {a5d_order} → régénérer le README") else: a5d_mism = [i for i in a5d_order if a5d_seen[i] != a5d_exp[i]] if a5d_mism: for i in a5d_mism: bad(f"Audit 5D · dimension {i} — README dit " f"(titre={a5d_seen[i][0]!r}, reference={a5d_seen[i][1]!r}) MAIS " f"audit_report dit (titre={a5d_exp[i][0]!r}, " f"reference={a5d_exp[i][1]!r})") else: good(f"Audit 5D · IDENTITÉ des 5 dimensions {a5d_order} " f"(id→titre→reference) == audit_report.dimensions") # ============================================================================ # Audit 5D (4e surface du MÊME README) · IDENTITÉ des LIVRABLES AUDITÉS — la liste # des hand-off `out/*.json` que l'audit de second niveau LIT comme matière première, # énumérée À LA MAIN dans la section « ## Livrables audités » — même classe de # « vert trompeur ». # ---------------------------------------------------------------------------- # Les 3 blocs audit_5d ci-dessus gatent la SYNTHÈSE (contrôles/dimensions/verdict), # l'ÉNUMÉRATION des open items et l'IDENTITÉ des 5 dimensions — AUCUN ne touche la # liste des ARTEFACTS SOURCES. Cette liste vit dans MANIFEST.audited_artifacts # (byte-gaté par check_artifacts ⇒ faisant autorité : le générateur la recompute à # chaque build via `sorted(ARTIFACT_PATHS.values())`, le registre des hand-off amont # que `load_artifacts()` charge réellement). Le README (05_deliverables_mvp/qa/ # audit_5d/README.md, section « ## Livrables audités (source = leur `out/`) ») # réénumère ces 5 chemins À LA MAIN — dans un ORDRE DIFFÉRENT du MANIFEST (workflow # 2e ici, 3e au MANIFEST, trié) ⇒ comparaison par ENSEMBLE. Piège : brancher un # nouveau livrable amont sur l'audit (nouvelle entrée ARTIFACT_PATHS → nouveau chemin # dans audited_artifacts) ou en retirer un laisse la section README PÉRIMÉE en # silence pendant que l'audit lit un autre jeu de fichiers — le lecteur/banquier # croirait l'audit couvrir un périmètre qu'il ne couvre plus (l'invention #6 # interdit), qu'aucune suite tests/ (qui teste des FONCTIONS de contrôle, pas la # prose) n'attrape. On RECOMPUTE l'ensemble depuis MANIFEST.audited_artifacts (zéro # duplication du registre) et on exige que les puces README l'énumèrent EXACTEMENT # (absent ET fantôme mordus). Cohérences croisées bonus (mordent un MANIFEST/spec # INTERNEMENT incohérent) : (a) chaque chemin audité RÉSOUT un fichier commité sur # disque (le `_load` de l'auditeur lève sinon — audit à vide impossible) ; (b) le # NOMBRE de livrables audités == le nombre d'ALIAS distincts d'artefacts référencés # par les contrôles du spec (audit_spec.json · union des `controls[].artifacts`) : un # artefact chargé mais jamais contrôlé, ou un alias contrôlé sans chemin de # chargement (KeyError runtime), désynchronise les deux. Section absente ⇒ ROUGE # (traçabilité #6). Direction README/MANIFEST → registre/spec. # ============================================================================ import os try: a5a_exp = load("qa/audit_5d/out/MANIFEST.json")["audited_artifacts"] a5a_ctrls = load("qa/audit_5d/audit_spec.json")["controls"] except (OSError, KeyError, ValueError) as e: bad(f"Audit 5D · livrables audités illisibles/inattendus : {e}"); a5a_exp = None if a5a_exp is not None: a5a_exp_set = set(a5a_exp) # tri/dédup : audited_artifacts = sorted(dict.values()) — mord un MANIFEST malformé if a5a_exp != sorted(a5a_exp_set) or len(a5a_exp) != len(a5a_exp_set): bad(f"Audit 5D · MANIFEST.audited_artifacts non trié/dédupliqué : {a5a_exp}") # (a) chaque chemin audité résout un fichier commité (audit à vide impossible) a5a_missing = [p for p in a5a_exp if not os.path.exists(f"{D}/{p}")] if a5a_missing: bad(f"Audit 5D · livrables audités INTROUVABLES sur disque {a5a_missing} → " f"l'auditeur lèverait FileNotFoundError (aucun audit à vide)") else: good(f"Audit 5D · les {len(a5a_exp)} livrables audités résolvent tous un " f"fichier commité") # (b) |audited| == |alias distincts référencés par les contrôles du spec| a5a_alias = set() for ctl in a5a_ctrls: a5a_alias |= set(ctl.get("artifacts", [])) if len(a5a_alias) == len(a5a_exp_set): good(f"Audit 5D · {len(a5a_exp_set)} livrables audités == {len(a5a_alias)} " f"alias d'artefacts distincts contrôlés par le spec (aucun mort/orphelin)") else: bad(f"Audit 5D · désynchro registre/spec : {len(a5a_exp_set)} livrables " f"audités MAIS {len(a5a_alias)} alias distincts contrôlés " f"({sorted(a5a_alias)}) → artefact chargé jamais contrôlé, ou alias sans " f"chemin de chargement") # README — puces de la section « ## Livrables audités » try: a5a_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}"); a5a_lines = None if a5a_lines is not None: a5a_got, a5a_in_sec = set(), False a5a_bullet = re.compile(r"^-\s+`([^`]+)`\s*$") for ln in a5a_lines: if re.match(r"^##\s+Livrables audités\b", ln): a5a_in_sec = True; continue if a5a_in_sec and ln.startswith("## "): break if a5a_in_sec: mb = a5a_bullet.match(ln) if mb: a5a_got.add(mb.group(1)) if not a5a_got: bad(f"Audit 5D · section « ## Livrables audités » INTROUVABLE/vide dans " f"{D}/qa/audit_5d/README.md (attendu {sorted(a5a_exp_set)})") elif a5a_got == a5a_exp_set: good(f"Audit 5D · livrables audités — les {len(a5a_got)} puces README == " f"MANIFEST.audited_artifacts (aucun fantôme/manquant)") else: miss = sorted(a5a_exp_set - a5a_got); extra = sorted(a5a_got - a5a_exp_set) detail = (f" · absents={miss}" if miss else "") + \ (f" · en trop (fantôme)={extra}" if extra else "") bad(f"Audit 5D · livrables audités — README énumère {sorted(a5a_got)} MAIS " f"MANIFEST dit {sorted(a5a_exp_set)}{detail} → régénérer le README") # ============================================================================ # 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") # ============================================================================ # Chat OTOIA · IDENTITÉ de la LISTE D'INVARIANTS CI — le README du module porte # une section « ## Invariants (N) — garantis en CI » qui ANNONCE un compte (N) # PUIS énumère chaque invariant par son numéro entre parenthèses ((1) · (2) · # … · (11, 13) · (12) · (14)). Cette prose transcrit À LA MAIN le REGISTRE réel # des invariants du générateur — les marqueurs `# Invariant N ·` de # chat_otoia_gen.py, qui sont la source d'autorité de « ce que la CI garantit » # (chaque marqueur = un contrôle appliqué par validate()). Classe DISTINCTE de # tout ce qui précède : les blocs Chat OTOIA plus haut gatent des COMPTES # d'artefact (blocks/mounts/portails) et l'IDENTITÉ persona/langues/endpoint ; # AUCUN ne touche la liste d'invariants — surface ungatée, piège aveugle. Le # générateur se SELF-REPORTE lui-même (« schéma + N invariants verts. » dans son # message CLI), 3ᵉ transcription du même compte. On RECOMPUTE ici le registre # depuis le SEUL registre-source (les marqueurs `# Invariant N` du .py) et on # exige : (a) le registre code est un ensemble CONTIGU 1..max (une lacune/un # doublon dans le ledger code = défaut réel) ; (b) le message CLI du générateur # « N invariants verts » == |registre| (self-report du .py cohérent avec son # propre ledger) ; (c) le compte annoncé « ## Invariants (N) » du README == # |registre| ; (d) l'ÉNUMÉRATION en prose du README == registre EXACT (numéro # absent ET numéro fantôme mordus par set-diff). Ajouter un `# Invariant 15` # (nouveau contrôle) sans toucher la prose ⇒ (c)(d) rouges ; retirer le contrôle # 7 du code ⇒ (7) devient fantôme dans la prose. Aucune suite tests/ ne teste la # PROSE ; le byte-gate d'artefact ignore le .py source ⇒ dérive silencieuse # sinon. Section/compte absent = échec (la disparition d'un chiffre est une # régression de traçabilité · #6). # ============================================================================ CO_GEN = f"{D}/frontend/chat_otoia/chat_otoia_gen.py" try: co_src = open(CO_GEN, encoding="utf-8").read() except OSError as e: bad(f"Chat OTOIA invariants · générateur {CO_GEN} illisible : {e}"); co_src = None if co_src is not None: # (a) registre-source = marqueurs `# Invariant N` du générateur (autorité). inv_reg = sorted({int(n) for n in re.findall(r"#\s*Invariant\s+(\d+)\b", co_src)}) if not inv_reg: bad(f"Chat OTOIA invariants · aucun marqueur `# Invariant N` dans {CO_GEN} " "(registre-source vide → impossible de re-dériver)") else: n_inv = len(inv_reg) contigu = inv_reg == list(range(1, inv_reg[-1] + 1)) if not contigu: bad(f"Chat OTOIA invariants · registre code NON contigu : {inv_reg} " f"(attendu 1..{n_inv}) — lacune/doublon dans le ledger `# Invariant N`") else: good(f"Chat OTOIA invariants · registre code contigu 1..{n_inv} " f"({n_inv} marqueurs `# Invariant N`)") # (b) self-report CLI du générateur « N invariants verts ». mg = re.search(r"(\d+)\s+invariants verts", co_src) if mg is None: bad(f"Chat OTOIA invariants · message CLI « N invariants verts » " f"INTROUVABLE dans {CO_GEN} (attendu {n_inv})") elif int(mg.group(1)) == n_inv: good(f"Chat OTOIA invariants · générateur self-report {mg.group(1)} " f"== registre ({n_inv})") else: bad(f"Chat OTOIA invariants · générateur dit « {mg.group(1)} invariants " f"verts » MAIS registre `# Invariant N` en compte {n_inv} " "(self-report désynchronisé du ledger)") # README : section « ## Invariants (N) — garantis en CI » + énumération. try: co_rd_raw = open(CO_RD, encoding="utf-8").read() except OSError as e: bad(f"Chat OTOIA invariants · {CO_RD} illisible : {e}"); co_rd_raw = None if co_rd_raw is not None: mh = re.search(r"##\s+Invariants\s+\((\d+)\)\s+—\s+garantis en CI", co_rd_raw) if mh is None: bad(f"Chat OTOIA invariants · {CO_RD} — section « ## Invariants (N) — " f"garantis en CI » INTROUVABLE (attendu {n_inv})") else: # (c) compte annoncé. if int(mh.group(1)) == n_inv: good(f"Chat OTOIA invariants · {CO_RD} — annonce ({mh.group(1)}) " f"== registre ({n_inv})") else: bad(f"Chat OTOIA invariants · {CO_RD} — annonce ({mh.group(1)}) MAIS " f"registre en compte {n_inv} → régénérer le README") # (d) énumération en prose == registre EXACT (set-diff). nxt = re.search(r"\n##\s", co_rd_raw[mh.end():]) prose = co_rd_raw[mh.end(): mh.end() + nxt.start()] if nxt else co_rd_raw[mh.end():] enum = sorted({int(x) for grp in re.findall(r"\(([\d,\s]+)\)", prose) for x in re.findall(r"\d+", grp)}) if enum == inv_reg: good(f"Chat OTOIA invariants · {CO_RD} — énumération prose {enum} " "== registre code (aucun absent/fantôme)") else: absents = [x for x in inv_reg if x not in enum] fantomes = [x for x in enum if x not in inv_reg] bad(f"Chat OTOIA invariants · {CO_RD} — énumération prose ≠ registre : " f"absent(s)={absents} · fantôme(s)={fantomes} → régénérer le README") # ============================================================================ # Recette (module) · couple « N livrables de sprint + M métriques succès MVP » # transcrit À LA MAIN dans DEUX docs — même classe de « vert trompeur ». # ---------------------------------------------------------------------------- # La matrice d'acceptation (qa/acceptance/out/acceptance_matrix.json, byte-gatée # par check_artifacts ⇒ faisant autorité) porte une ligne par promesse roadmap, # de `kind` sprint_deliverable OU mvp_metric. Le compte de chaque genre (n_sprint, # n_metric déjà recalculés plus haut depuis la matrice, jamais saisis) est repris # À LA MAIN à DEUX endroits que le bloc racine ne couvre PAS : le README du module # (05_deliverables_mvp/qa/acceptance/README.md — puces « N livrables de sprint » # puis « M métriques succès MVP ») ET la fiche agent QA (03_agents/qa/AGENT.md — # « (N livrables + M métriques) »). Le bloc racine plus haut ne gate que le SEUL # README d'entrée (« N promesses (X sprint + Y métriques) ») ; ces deux docs de # module en dérivent sans gate. Ajouter une promesse roadmap (l'artefact rebâtit # 9/7) ferait dériver LES DEUX docs en silence pendant que la matrice dit autre # chose — piège que ni les suites tests/ (qui testent des FONCTIONS, pas la prose) # ni le roadmap-anchor (qui ancre les `roadmap_line` de l'ARTEFACT, pas la prose # des docs) n'attrapent. On RECOMPUTE ici chaque nombre depuis SON PROPRE genre de # la matrice (jamais figé · zéro duplication #5) et on exige l'égalité avec les # DEUX docs. Recomputer les DEUX (sprint ET métriques) mord une dérive de l'un là # où l'autre resterait aveugle. Cohérence croisée en bonus : # MANIFEST.counts.{sprint_deliverables,mvp_metrics} == (n_sprint, n_metric) — mord # un compteur du manifeste désynchronisé de la matrice qu'il résume. Un claim # absent échoue AUSSI (la disparition d'un chiffre est une régression · traçabilité). # ============================================================================ ACC_RD = f"{D}/qa/acceptance/README.md" ACC_FI = "03_agents/qa/AGENT.md" acc_exp = (str(n_sprint), str(n_metric)) acc_src = f"livrables_sprint={acc_exp[0]} métriques={acc_exp[1]}" try: acc_mc = load("qa/acceptance/out/MANIFEST.json")["counts"] if (str(acc_mc["sprint_deliverables"]), str(acc_mc["mvp_metrics"])) != acc_exp: bad(f"Recette · incohérence artefact : MANIFEST.counts dit " f"{acc_mc['sprint_deliverables']}/{acc_mc['mvp_metrics']} MAIS la matrice " f"dit {'/'.join(acc_exp)} (compteur désynchronisé de sa liste)") except (OSError, KeyError, ValueError) as e: bad(f"Recette · MANIFEST acceptance illisible/inattendu : {e}") # 1) README du module — deux puces distinctes (livrables sprint · métriques MVP) acc_rd = read_norm(ACC_RD) if acc_rd is not None: ms = re.search(r"\*\*(\d+) livrables de sprint\*\*", acc_rd) mm = re.search(r"\*\*(\d+) métriques succès MVP\*\*", acc_rd) if ms is None or mm is None: bad(f"Recette · {ACC_RD} — puces « N livrables de sprint » / « M métriques " f"succès MVP » INTROUVABLE(s) (attendu {acc_src})") elif (ms.group(1), mm.group(1)) == acc_exp: good(f"Recette · {ACC_RD} — {ms.group(1)}/{mm.group(1)} == matrice ({acc_src})") else: bad(f"Recette · {ACC_RD} — dit {ms.group(1)}/{mm.group(1)} MAIS matrice dit " f"{'/'.join(acc_exp)} ({acc_src}) → régénérer le README") # 2) Fiche agent QA — parenthèse « (N livrables + M métriques) » acc_fi = read_norm(ACC_FI) if acc_fi is not None: m = re.search(r"\((\d+) livrables \+ (\d+) métriques\)", acc_fi) if m is None: bad(f"Recette · {ACC_FI} — claim « (N livrables + M métriques) » INTROUVABLE " f"(attendu {acc_src})") elif m.groups() == acc_exp: good(f"Recette · {ACC_FI} — {'/'.join(m.groups())} == matrice ({acc_src})") else: bad(f"Recette · {ACC_FI} — dit {'/'.join(m.groups())} MAIS matrice dit " f"{'/'.join(acc_exp)} ({acc_src}) → régénérer la fiche") # ============================================================================ # 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) # ------------------------------------------------------------------ # 5) 6) 7) 8) 9) — le MÊME contrat de format dans le COMPOSEUR ncf.py # (CODE d'exécution, PAS de la prose) + son ORACLE de test. # `ecflib/ncf.py` porte TROIS copies INDÉPENDANTES du format, AUCUNE # dérivée de l'artefact : la regex de VALIDATION `ENCF_RE` (cœur de # is_valid_encf/parse_encf), la chaîne `FORMULE` publiée par # compose_encf (donc RENVOYÉE au caller VPS/Compupar), et deux # proses-en-code (docstring + commentaire Ley 32-23). En prime, # `tests/test_ecf_dgii.py:246` HARDCODE l'oracle « FORMULE ». Le bloc # e-NCF ci-dessus ne gate QUE la prose (README ×3 + fiche). Or un # rango DGII à 12 chiffres régénère ecf_spec→ecf_plan (donc les 4 # proses gatées ci-dessus SUIVENT), MAIS laisse ENCF_RE à `{2}{10}` : # compose_encf zfill à 10 et is_valid_encf REJETTE un e-NCF désormais # valide → BUG d'exécution du composeur fiscal, pendant que la suite # tests/ reste VERTE (son oracle == FORMULE périmée, self-consistante). # « Vert trompeur » que ni check_artifacts (ncf.py n'est pas un # out/*.json) ni tests/ (teste les FONCTIONS, pas leur ancrage) ne # mordent. Même patron que l'oracle/formateur du Publiciste. On ANCRE # ces surfaces CODE au SEUL bloc e_ncf byte-gaté (réutilise # prefix/tipo/seq/total ci-dessus · zéro re-dérivation du format #6). ECF_NCF = f"{D}/fiscal/ecf_dgii/ecflib/ncf.py" ECF_TEST = f"{D}/fiscal/ecf_dgii/tests/test_ecf_dgii.py" ecf_ncf = read_norm(ECF_NCF) if ecf_ncf is None: bad(f"Fiscal · composeur {ECF_NCF} illisible → format e-NCF non ancré") else: # 5) ENCF_RE — la regex de VALIDATION (prefix + 2 segments de chiffres) ecf_check("composeur ncf.py · ENCF_RE (validateur)", ECF_NCF, ecf_ncf, re.search(r"ENCF_RE\s*=\s*re\.compile\(\s*r?[\"']\^" r"([A-Z])\(\[0-9\]\{(\d+)\}\)\(\[0-9\]\{(\d+)\}\)\$", ecf_ncf), False) # 6) FORMULE — la chaîne publiée par compose_encf (renvoyée au VPS) ecf_check("composeur ncf.py · FORMULE (publiée)", ECF_NCF, ecf_ncf, re.search(r"FORMULE\s*=\s*[\"']([A-Z])\s*\+\s*tipoeCF\((\d+)\)" r"\s*\+\s*secuencia\((\d+)\)[\"']", ecf_ncf), False) # 7) docstring ncf.py — « "E" + tipoeCF (2 chiffres) + secuencia (10 chiffres) → 13 caracteres » ecf_check("composeur ncf.py · docstring", ECF_NCF, ecf_ncf, re.search(r'e-NCF\s*=\s*"([A-Z])"\s*\+\s*tipoeCF\s*\((\d+)' r"\s*chiffres?\)\s*\+\s*secuencia\s*\((\d+)\s*chiffres?\)" r"\s*\S+\s*(\d+)\s*caracteres?", ecf_ncf), True) # 8) commentaire ncf.py — « E + tipo(2) + secuencia(10) = 13 caracteres » ecf_check("composeur ncf.py · commentaire Ley 32-23", ECF_NCF, ecf_ncf, re.search(r"Format e-NCF[^:]*:\s*([A-Z])\s*\+\s*tipo\((\d+)\)" r"\s*\+\s*secuencia\((\d+)\)\s*=\s*(\d+)\s*caracteres?", ecf_ncf), True) # 9) oracle de test — la RAISON pour laquelle la dérive resterait VERTE ecf_test = read_norm(ECF_TEST) if ecf_test is None: bad(f"Fiscal · oracle {ECF_TEST} illisible → FORMULE de test non ancrée") else: ecf_check("oracle test_ecf_dgii.py · formule", ECF_TEST, ecf_test, re.search(r"\[[\"']formule[\"']\]\s*,\s*[\"']([A-Z])\s*\+" r"\s*tipoeCF\((\d+)\)\s*\+\s*secuencia\((\d+)\)[\"']", ecf_test), 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") # ============================================================================ # CRM/Commissions · ANTI-INVENTION (#6) — la promesse « tous les taux sont `null` » # (le CŒUR du livrable · sa RAISON D'ÊTRE) confrontée à l'artefact byte-gaté. # ---------------------------------------------------------------------------- # Le module commissions n'existe QUE pour NE PAS inventer de taux (aucun taux de # commission n'est documenté dans CLAUDE.md). Le README §« Anti-invention (#6) » # le promet noir sur blanc (README:22-23) : « Le barème livré porte `taux_pct: # null` + `source: null` + `a_confirmer: true` pour **chaque** évènement », et # l'invariant CLI #7 REFUSE tout `taux_pct` fourni **sans `source`**. La source # faisant autorité est `out/commission_plan.json` (byte-gaté par check_artifacts) : # chaque évènement porte `taux_pct`/`source`/`a_confirmer`, et `MANIFEST.counts. # taux_a_confirmer` recompte les taux à confirmer. # Le bloc commissions ci-dessus ne gate QUE la CROSS-COHÉRENCE (base_field + # brouillons exclus) ; le bloc racine ne gate QUE le COMPTE de tests (« 25 »). # RIEN ne confrontait la promesse #6 à l'artefact. Piège #6, la dérive la plus # grave possible ici : qu'un `taux_pct` DEVIENNE non-null (un taux INVENTÉ, ou une # valeur remplie SANS `source`) pendant que le README continue d'affirmer « tous # `null` pour chaque évènement » → le calcul `base × taux` émettrait une commission # fondée sur un chiffre inventé (l'invention EXACTE que le module promet d'écarter), # « vert trompeur » qu'aucune suite tests/ (FONCTIONS de calcul, jamais la promesse # du README vs l'artefact commité) n'attrape. On RECOMPUTE l'état null/confirmé # depuis l'artefact et on exige que la promesse du README le REFLÈTE — dans les DEUX # sens (si un taux est un jour confirmé par la Direction, la promesse « tous null » # DOIT être mise à jour, sinon ROUGE : le gate suit la vie du module sans faux # négatif). Cohérences croisées (mordent un plan INTERNEMENT incohérent) : invariant # #7 permanent (aucun `taux_pct` sans `source`) · `a_confirmer` ⟺ `taux_pct is null` # (un taux confirmé n'est plus « à confirmer », et inversement) · MANIFEST.counts # recoupe le plan (`taux_a_confirmer` == nb à confirmer · `evenements` == |plan|). # ============================================================================ try: cta_ev = load("crm/commissions/out/commission_plan.json")["evenements"] cta_counts = load("crm/commissions/out/MANIFEST.json")["counts"] cta_note = load("crm/commissions/out/MANIFEST.json").get("note_taux", "") except (OSError, KeyError, ValueError, TypeError) as e: bad(f"CRM commissions anti-invention · artefact illisible/inattendu : {e}") cta_ev = None if cta_ev is not None: if not cta_ev: bad("CRM commissions anti-invention · plan VIDE → aucun évènement à couvrir " "(barème suspect)") else: # (a) invariant #7 permanent : aucun taux_pct rempli SANS source (invention) invente = sorted(e["update_value"] for e in cta_ev if e.get("taux_pct") is not None and e.get("source") is None) if invente: bad(f"CRM commissions anti-invention · taux_pct RENSEIGNÉ sans `source` pour " f"{invente} → invariant #7 VIOLÉ (taux INVENTÉ · #6) → fournir la source " f"ou remettre à null") else: good("CRM commissions anti-invention · invariant #7 — aucun `taux_pct` sans " "`source` (nul taux inventé)") # (b) a_confirmer ⟺ taux_pct is null (un taux confirmé n'est plus « à confirmer ») incoh = sorted(e["update_value"] for e in cta_ev if bool(e.get("a_confirmer")) != (e.get("taux_pct") is None)) if incoh: bad(f"CRM commissions anti-invention · `a_confirmer` incohérent avec " f"`taux_pct` pour {incoh} (confirmé mais taux null, ou taux rempli mais " f"encore « à confirmer ») → régénérer le plan") else: good("CRM commissions anti-invention · `a_confirmer` ⟺ `taux_pct is null` " "sur les 5 évènements (cohérent)") # (c) MANIFEST.counts recoupe le plan n_conf = sum(1 for e in cta_ev if e.get("a_confirmer")) if cta_counts.get("taux_a_confirmer") != n_conf: bad(f"CRM commissions anti-invention · MANIFEST.counts.taux_a_confirmer=" f"{cta_counts.get('taux_a_confirmer')} ≠ {n_conf} (plan) → MANIFEST périmé") elif cta_counts.get("evenements") != len(cta_ev): bad(f"CRM commissions anti-invention · MANIFEST.counts.evenements=" f"{cta_counts.get('evenements')} ≠ {len(cta_ev)} (plan) → MANIFEST périmé") else: good(f"CRM commissions anti-invention · MANIFEST.counts recoupe le plan " f"({n_conf} à confirmer / {len(cta_ev)} évènements)") # (d) la PROMESSE #6 du README reflète l'état RÉEL de l'artefact (bidirectionnel) all_null = all(e.get("taux_pct") is None and e.get("source") is None and e.get("a_confirmer") is True for e in cta_ev) cta_rd = read_norm(CM_RD) if cta_rd is not None: promesse = re.search( r"`taux_pct:\s*null`\s*\+\s*`source:\s*null`\s*\+\s*" r"`a_confirmer:\s*true`\s*pour\s*\*\*chaque\*\*", cta_rd, re.IGNORECASE) if all_null: if promesse is None: bad(f"CRM commissions anti-invention · {CM_RD} — la promesse « `taux_pct: " f"null` + `source: null` + `a_confirmer: true` pour **chaque** " f"évènement » INTROUVABLE alors que l'artefact est bien tout-null " f"→ la raison d'être du module ne doit pas disparaître (#6)") else: good(f"CRM commissions anti-invention · {CM_RD} — promesse « tout null " f"pour chaque évènement » == artefact (les 5 évènements tout-null)") else: if promesse is not None: bad(f"CRM commissions anti-invention · {CM_RD} — le README affirme " f"« tout `null` pour **chaque** évènement » MAIS l'artefact porte " f"des taux CONFIRMÉS → mettre à jour la §Anti-invention (promesse " f"périmée · « vert trompeur »)") else: good(f"CRM commissions anti-invention · {CM_RD} — la promesse « tout " f"null » a été retirée, cohérent avec des taux désormais confirmés") # (e) la note d'auto-traçabilité du MANIFEST reste cohérente avec l'état if all_null and "null" not in cta_note.lower(): bad(f"CRM commissions anti-invention · MANIFEST.note_taux ne mentionne plus " f"`null` alors que tous les taux le sont → note périmée") # ============================================================================ # CRM/Commissions · IDENTITÉ des RÔLES PAYÉS — appartenance au portail `ventes` # + nom Frappe RÉSOLU (invariants #5/#6 du module · le « à quel rôle » du livrable). # ---------------------------------------------------------------------------- # Le README pose la question fondatrice « quel évènement paie, à quel RÔLE, sur # quel montant » (README:8-9) et promet en §Cross-cohérence (README:51-53) que # chaque `role_id` commissionné « doit être résolu depuis rbac_50_roles.json … # ET appartenir au portail `ventes` » (invariant CLI #5), son `erpnext_role_name` # restant « cohérent avec RBAC » (invariant #6 · le « nom Frappe résolu » du # README:36). C'est une SÉPARATION DES POUVOIRS : on ne commissionne QUE des rôles # du portail ventes, jamais un rôle compta/construction/direction. Les blocs # commissions ci-dessus gatent les états soumis, les champs de base et la promesse # anti-taux — mais RIEN ne confronte cette appartenance-portail ni la résolution du # nom Frappe à l'artefact. Dérive #6 la plus grave : (a) commissionner un rôle d'un # AUTRE portail (payer un fiscaliste compta → la prose « portail `ventes` » devient # un MENSONGE, pouvoirs mélangés) ; (b) un `role_id` FANTÔME absent du contrat (rôle # inventé, l'invention même que #6 interdit) ; (c) un `erpnext_role_name` figé en dur # qui dérive quand le contrat renomme le rôle (nom Frappe périmé → `Has Role` importé # sur un rôle INEXISTANT côté VPS). Aucune suite tests/ (FONCTIONS de résolution/ # calcul, jamais la prose vs l'artefact) ne l'attrape ; ce n'est ni un lien, ni un # terme interdit, ni un `out/` → invisible à check_docs/guard_constraints/artifacts. # On RECOMPUTE depuis les artefacts byte-gatés (plan + contrat + MANIFEST · zéro dup # du contrat du générateur #6) : chaque role_id du plan ∈ contrat, portail == un # SINGLETON == le portail nommé au README == un portail_business, nom Frappe == # contrat, et MANIFEST.roles_rbac_utilises recoupe l'ensemble distinct réellement payé. # ============================================================================ try: cr_ev = load("crm/commissions/out/commission_plan.json")["evenements"] _cr_rbac = load("rbac/rbac_50_roles.json") cr_by = {r["id"]: r for r in _cr_rbac["roles"]} cr_pbiz = set(_cr_rbac["portails_business"]) cr_man = load("crm/commissions/out/MANIFEST.json") except (OSError, KeyError, ValueError, TypeError) as e: bad(f"CRM commissions rôles · artefact illisible/inattendu : {e}"); cr_ev = None if cr_ev is not None: cr_ids = {e["role_id"] for e in cr_ev} if not cr_ids: bad("CRM commissions rôles · plan sans role_id → barème suspect") else: # (a) chaque role_id commissionné se RÉSOUT dans le contrat (jamais fantôme) cr_phantom = sorted(i for i in cr_ids if i not in cr_by) if cr_phantom: bad(f"CRM commissions rôles · role_id(s) {cr_phantom} ABSENT(s) de " f"rbac_50_roles.json → rôle INVENTÉ (invariant #5 · #6)") else: good(f"CRM commissions rôles · les {len(cr_ids)} role_id payés se résolvent " f"tous dans le contrat RBAC") # (b) nom Frappe RÉSOLU : plan.erpnext_role_name == contrat (invariant #6) cr_badname = sorted(e["role_id"] for e in cr_ev if e.get("erpnext_role_name") != cr_by[e["role_id"]]["erpnext_role_name"]) if cr_badname: bad(f"CRM commissions rôles · erpnext_role_name du plan ≠ contrat pour " f"{cr_badname} → nom Frappe périmé/en dur (invariant #6) → régénérer") else: good("CRM commissions rôles · erpnext_role_name == contrat pour chaque " "évènement (nom Frappe résolu · invariant #6)") # (c) appartenance-portail : SINGLETON == portail nommé au README == business cr_ports = {cr_by[i]["portail"] for i in cr_ids} if len(cr_ports) != 1: bad(f"CRM commissions rôles · rôles payés répartis sur PLUSIEURS portails " f"{sorted(cr_ports)} → séparation des pouvoirs rompue (invariant #5 : " f"seul le portail ventes commissionne)") else: cr_port = next(iter(cr_ports)) if cr_port not in cr_pbiz: bad(f"CRM commissions rôles · portail des rôles payés `{cr_port}` ∉ " f"portails_business {sorted(cr_pbiz)} → portail fantôme") else: cr_rd = read_norm(CM_RD) mrp = re.search(r"appartenir au portail `([a-z-]+)`", cr_rd or "") if mrp is None: bad(f"CRM commissions rôles · {CM_RD} — claim « appartenir au portail " f"`…` » INTROUVABLE (attendu `{cr_port}`)") elif mrp.group(1) != cr_port: bad(f"CRM commissions rôles · {CM_RD} — dit portail `{mrp.group(1)}` " f"MAIS les rôles payés sont du portail `{cr_port}` → régénérer le README") else: good(f"CRM commissions rôles · tous les rôles payés ∈ portail " f"`{cr_port}` == README == portail_business (pouvoirs séparés)") # (d) MANIFEST.roles_rbac_utilises recoupe l'ensemble distinct payé cr_mru_pairs = {(r.get("role_id"), r.get("erpnext_role_name")) for r in cr_man.get("roles_rbac_utilises", [])} cr_ev_pairs = {(e["role_id"], cr_by[e["role_id"]]["erpnext_role_name"]) for e in cr_ev if e["role_id"] in cr_by} if cr_mru_pairs != cr_ev_pairs: miss = sorted(cr_ev_pairs - cr_mru_pairs) extra = sorted(cr_mru_pairs - cr_ev_pairs) bad(f"CRM commissions rôles · MANIFEST.roles_rbac_utilises ≠ rôles réellement " f"payés" + (f" · absents={miss}" if miss else "") + (f" · en trop={extra}" if extra else "") + " → MANIFEST périmé") elif cr_man.get("counts", {}).get("roles") != len(cr_ids): bad(f"CRM commissions rôles · MANIFEST.counts.roles=" f"{cr_man.get('counts', {}).get('roles')} ≠ {len(cr_ids)} rôles distincts " f"payés → MANIFEST périmé") else: good(f"CRM commissions rôles · MANIFEST.roles_rbac_utilises == {len(cr_ids)} " f"rôles distincts payés (counts.roles cohérent)") # ============================================================================ # CRM/Commissions · IDENTITÉ du CHAMP DEVISE — `devise_field` = le champ `devise` # (`Select` USD/DOP · #10) du DocType, transcrit À LA MAIN au README:54 sans gate. # ---------------------------------------------------------------------------- # Le générateur commissions_gen émet out/commission_plan.json (byte-gaté par # check_artifacts ⇒ faisant autorité) : le plan porte la clé RACINE `devise_field` # — le champ du Dossier Vente qui PORTE la devise d'un montant commissionné. Son # invariant CLI #8 exige TROIS choses simultanées : `devise_field == "devise"`, le # DocType Dossier Vente (doctype_spec.json, l'INPUT du générateur) porte bien un # champ `devise` de fieldtype `Select`, et ses `options` == `USD`/`DOP` — les DEUX # devises canoniques de CLAUDE.md #10 (« **USD + DOP** devises »). La §Cross-cohérence # RÉÉNONCE le tout EN PROSE (README:54) : « `devise_field` = le champ `devise` # (`Select` **USD/DOP** · #10) du DocType ». Les blocs commissions ci-dessus gatent # les états soumis, les champs de base, les rôles payés et la promesse anti-taux — # mais AUCUN ne touche ce couple champ↔devises. Dérive #6 : RENOMMER le champ # (`devise`→`moneda`) dans le plan/DocType, changer son fieldtype (`Select`→`Data` : # la contrainte d'énumération PERDUE, un montant en monnaie libre), ou altérer ses # options (`USD/DOP`→`USD/EUR` : devise HORS #10, le calcul `base × taux` # commissionnerait dans une monnaie non prévue) laisse la prose périmée EN SILENCE # pendant que l'artefact/DocType dit autre chose → « vert trompeur » qu'aucune suite # tests/ (FONCTIONS finance/résolution, jamais la prose vs artefact) n'attrape ; ce # n'est ni un lien, ni un terme interdit, ni un `out/` byte-gaté du module → invisible # à check_docs/guard_constraints. On RECOMPUTE depuis les artefacts byte-gatés + la # contrainte constitutionnelle : (0) ANCRAGE — les 2 devises depuis CLAUDE.md #10 # (segment gras « USD + DOP ») ; (a) CROSS-ARTEFACT — plan.devise_field == "devise" # == fieldname du champ DocType · fieldtype == "Select" · options (ORDONNÉES) == # devises #10 ; (b) PROSE — README:54 nomme le champ `devise`, le dit `Select`, cite # USD/DOP == #10 et tague #10. Un claim absent échoue AUSSI (traçabilité #6). # Direction plan/DocType/CLAUDE.md #10 → README. # ============================================================================ try: cd_plan = load("crm/commissions/out/commission_plan.json") cd_dt = load("crm/dossier_vente/doctype_spec.json") cd_cm = open("CLAUDE.md", encoding="utf-8").read() except (OSError, KeyError, ValueError, TypeError) as e: bad(f"CRM commissions devise · artefact/CLAUDE.md illisible/inattendu : {e}"); cd_plan = None if cd_plan is not None: # (0) ANCRAGE #10 : les 2 devises canoniques (segment gras « USD + DOP ») cd_cur = None cd_l10 = re.search(r"^10\.\s+(.+)$", cd_cm, re.M) if cd_l10: for seg in re.split(r"·", cd_l10.group(1)): if "devise" in seg.lower(): mm = re.search(r"\*\*(.+?)\*\*", seg) if mm: cd_cur = [t.strip() for t in re.split(r"[+/]", mm.group(1)) if t.strip()] if not cd_cur: bad("CRM commissions devise · devises #10 (« **USD + DOP** ») INTROUVABLES dans " "CLAUDE.md #10 → ancrage rompu") else: cd_field = None for grp in cd_dt.get("field_groups", []): for f in grp.get("fields", []): if f.get("fieldname") == "devise": cd_field = f cd_pf = cd_plan.get("devise_field") cd_opts = ([ln for ln in cd_field.get("options", "").split("\n") if ln] if cd_field else None) # (a) CROSS-ARTEFACT : plan ↔ DocType ↔ devises #10 (invariant CLI #8) if cd_pf != "devise": bad(f"CRM commissions devise · plan.devise_field={cd_pf!r} ≠ 'devise' → " f"invariant #8 cassé (le champ porteur de la devise a changé)") elif cd_field is None: bad("CRM commissions devise · champ `devise` ABSENT du DocType Dossier Vente " "→ invariant #8 (options USD/DOP invérifiables)") elif cd_field.get("fieldtype") != "Select": bad(f"CRM commissions devise · champ `devise` fieldtype={cd_field.get('fieldtype')!r} " f"≠ 'Select' → énumération USD/DOP PERDUE (monnaie libre)") elif cd_opts != cd_cur: bad(f"CRM commissions devise · options du champ `devise` {cd_opts} ≠ devises #10 " f"{cd_cur} (ordre inclus) → devise HORS CLAUDE.md #10") else: good(f"CRM commissions devise · plan.devise_field='devise' == DocType champ `devise` " f"(Select {cd_opts}) == CLAUDE.md #10") # (b) PROSE README:54 — champ · fieldtype · devises · tag #10 cd_rd = read_norm(CM_RD) mpr = re.search( r"`devise_field`\*\* = le champ `([a-z_]+)` \(`(\w+)` \*\*([A-Z/]+)\*\* · #10\)", cd_rd or "") if mpr is None: bad(f"CRM commissions devise · {CM_RD} — claim « `devise_field` = le champ " f"`devise` (`Select` **USD/DOP** · #10) » INTROUVABLE/malformé (traçabilité #6)") else: p_field, p_ftype = mpr.group(1), mpr.group(2) p_cur = mpr.group(3).split("/") errs = [] if p_field != "devise": errs.append(f"champ `{p_field}` ≠ `devise`") if p_ftype != "Select": errs.append(f"type `{p_ftype}` ≠ `Select`") if p_cur != cd_cur: errs.append(f"devises {p_cur} ≠ #10 {cd_cur}") if errs: bad(f"CRM commissions devise · {CM_RD} — prose périmée : " + " · ".join(errs) + " → régénérer le README") else: good(f"CRM commissions devise · {CM_RD} — prose « champ `devise` " f"(`Select` {'/'.join(p_cur)}) · #10 » == artefacts + CLAUDE.md #10") # ============================================================================ # 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") # 3) RBAC_50_ROLES_SPEC.md §6 — 2ᵉ surface prose du MÊME catalogue custom : # « **DocTypes `custom: true`** (`Faisabilité`, `Publiciste Log`, … ) ». Le # bloc apply_plan ne gate que le COMPTE (len custom_doctypes), le point (1) # ci-dessus que le README fixtures — la SPEC §6 restait AVEUGLE : renommer/ # retirer un DocType `custom` du contrat (le MANIFEST suit, byte-gaté) y # laisse les 4 noms périmés en silence, comme la 2ᵉ surface CONFOTUR/entités. if spec_rd is not None: m6 = re.search(r"DocTypes `custom: true`\*\* \(([^)]*)\)", spec_rd) if m6 is None: bad(f"RBAC fixtures · {SPEC_RD} §6 — énumération « DocTypes `custom: " f"true` (…) » INTROUVABLE (attendu {sorted(fx_cdt)} · anti-évaporation #6)") else: got6 = {_fx_acc(t) for t in re.findall(r"`([^`]+)`", m6.group(1))} exp6 = {_fx_acc(x) for x in fx_cdt} if got6 == exp6: good(f"RBAC fixtures · {SPEC_RD} §6 — catalogue custom {sorted(got6)} " f"== MANIFEST.custom_doctypes_a_confirmer") else: miss6 = sorted(exp6 - got6); extra6 = sorted(got6 - exp6) detail6 = (f" · absents={miss6}" if miss6 else "") + \ (f" · en trop={extra6}" if extra6 else "") bad(f"RBAC fixtures · {SPEC_RD} §6 — énumère {sorted(got6)} MAIS " f"MANIFEST {sorted(exp6)}{detail6} → régénérer la SPEC") # 4) RBAC_50_ROLES_SPEC.md §2 — encadré « Défense en profondeur » : 3ᵉ surface prose # du MÊME fait de sécurité. Les points (2)/(3) ancrent la prose du README fixtures # (« émis que pour le rôle **NAME** ») + le catalogue §6 ; le §2 « Modèle RBAC » # ferme sur « **réservé** au seul rôle `` (`scope_donnees = groupe`, # `famille = plateforme`) — séparation des pouvoirs vérifiée par test automatisé » : # TROIS attributs (NOM · scope · famille) qu'AUCUN gate ne touchait. Le test # `test_rbac::test_set_user_permissions_reserve_admin` impose scope/famille à TOUT # détenteur mais JAMAIS le NOM du rôle ni cette prose §2 → renommer le détenteur # (contrat + régénération fixtures/out + audit 4Big) laisse le §2 citer un rôle # DISPARU, VERT (check_artifacts ne voit qu'un HASH de TAILLE du doc, la SPEC n'est # pas un out/*.json). On RÉUTILISE fx_sup (singleton déjà byte-gaté ci-dessus · zéro # re-dérivation de l'unicité #5) comme NOM-autorité et src_roles (contrat byte-gaté) # pour scope/famille. La glose « (gestion des habilitations) » N'est PAS gatée. if spec_rd is not None and len(fx_sup) == 1: _su_hn = fx_sup[0] _su_role = next((r for r in (src_roles or []) if r.get("erpnext_role_name") == _su_hn), None) _su_def = re.search(r"\*\*Défense en profondeur\*\*(.*?)" r"vérifiée par test automatis", spec_rd) if _su_def is None: bad(f"RBAC fixtures · {SPEC_RD} §2 — encadré « **Défense en profondeur** … " f"vérifiée par test automatisé » INTROUVABLE (anti-évaporation #6)") else: _su_reg = _su_def.group(1) _su_toks = re.findall(r"`([^`]+)`", _su_reg) # ancrage : le pouvoir lui-même doit être nommé dans l'encadré if "set_user_permissions" not in _su_toks: bad(f"RBAC fixtures · {SPEC_RD} §2 — encadré ne nomme pas " f"`set_user_permissions` → mal ancré") # (a) NOM du détenteur cité == le seul porteur du flag (byte-gaté) if _fx_acc(_su_hn) in {_fx_acc(t) for t in _su_toks}: good(f"RBAC fixtures · {SPEC_RD} §2 — détenteur nommé « {_su_hn} » == le " f"seul porteur de set_user_permissions (custom_docperm.json)") else: _su_named = [t for t in _su_toks if "=" not in t and t != "set_user_permissions"] bad(f"RBAC fixtures · {SPEC_RD} §2 — nomme {_su_named} MAIS " f"set_user_permissions réservé à {_su_hn!r} → régénérer la SPEC " f"(rôle renommé côté contrat ?)") # (b)/(c) scope + famille cités == attributs du détenteur (contrat byte-gaté) if _su_role is None: bad(f"RBAC fixtures · {SPEC_RD} §2 — détenteur {_su_hn!r} INTROUVABLE dans " f"rbac_50_roles.json (fixtures ↔ contrat désynchronisés)") else: for _su_lbl in ("scope_donnees", "famille"): _su_m = re.search(rf"{_su_lbl}\s*=\s*(\w+)", _su_reg) _su_want = _su_role.get(_su_lbl) if _su_m is None: bad(f"RBAC fixtures · {SPEC_RD} §2 — `{_su_lbl} = …` INTROUVABLE " f"dans l'encadré") elif _su_m.group(1) == _su_want: good(f"RBAC fixtures · {SPEC_RD} §2 — {_su_lbl} `{_su_m.group(1)}` " f"== détenteur (rbac_50_roles.json)") else: bad(f"RBAC fixtures · {SPEC_RD} §2 — encadré dit {_su_lbl}=" f"`{_su_m.group(1)}` MAIS le détenteur {_su_hn!r} a " f"`{_su_want}` → aligner sur le contrat") # 5) §6 row « Séparation des pouvoirs » cite le pouvoir réservé (ancre présente) _su_m6 = re.search(r"## 6\..*?(?=## 7\.)", spec_rd) if _su_m6 and "Séparation des pouvoirs" in _su_m6.group(0) \ and "set_user_permissions" in _su_m6.group(0): good(f"RBAC fixtures · {SPEC_RD} §6 — row « Séparation des pouvoirs » cite " f"bien `set_user_permissions` réservé (ancre présente)") else: bad(f"RBAC fixtures · {SPEC_RD} §6 — row « Séparation des pouvoirs » / " f"`set_user_permissions` réservé INTROUVABLE → ancre évaporée (#6)") # ============================================================================ # 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") # ============================================================================ # Faisabilité/generator (2e surface du MÊME module) · l'ORACLE D'ENFORCEMENT # `_CANONICAL_MARKERS` + les DEUX docstrings du scorer + l'oracle du TEST — trois # copies DE PLUS des 6 marqueurs canoniques, ancrées sur CLAUDE.md #9/#10. # ---------------------------------------------------------------------------- # Le bloc ci-dessus ancre model.py `CANONICAL` + le README à CLAUDE.md #9/#10. # Restent, dans le MÊME module, TROIS surfaces data-derived qui RECOPIENT les # mêmes 6 marqueurs, toutes NON gatées : # (c) genlib/renderer.py `_CANONICAL_MARKERS` — la liste que `check_canonical()` # PARCOURT pour PROUVER que le rendu du volet ingénierie contient les 6 # marqueurs (l'oracle de l'« axe 4 honnêteté »). C'est l'enforcement même : # si cette liste DÉRIVE de CANONICAL (donc de #9/#10), le contrôle devient # FAUX — soit il exige un marqueur périmé (« Cardnet » alors que le rendu, # issu d'un CANONICAL mis à jour, porterait « Azul » → faux rouge), soit on # en retire un et le contrôle NE VÉRIFIE PLUS RIEN (vert vacux). # (d) genlib/scorer.py — DEUX docstrings (barème axe 4 en tête · fonction # `_score_parametres_canoniques`) énumèrent « 3 % / 8.5 % / 52 % · USD+DOP · # Letter US · Cardnet ». Prose recopiée, jamais confrontée à #9/#10. # (e) tests/test_generator.py — la liste `for marker in [...]` : l'oracle du # test lui-même. Le commentaire du bloc ci-dessus (« ces marqueurs sont # HARDCODÉS DANS LE TEST, une copie de plus, JAMAIS comparée à CLAUDE.md ») # NOMMAIT ce défaut sans le gater — on ferme la boucle ici. # La suite tests/ teste des FONCTIONS (dont `check_canonical` — AVEC son propre # oracle hardcodé), jamais l'ancrage à CLAUDE.md. Même classe EXACTE que le tuple # d'oracle du test publiciste/branding ancré sur #4. On RÉUTILISE `exp` (6 valeurs # recomputées de #9/#10 ci-dessus · zéro duplication) : les LISTES (c)/(e) sont # parsées puis comparées à l'ensemble EXACT (set-diff : absent ET en trop) ; les # DOCSTRINGS (d) doivent porter CHAQUE valeur normalisée (drift d'un marqueur ⇒ # ROUGE). Un claim/ligne absent échoue AUSSI (traçabilité). # ============================================================================ if exp is not None: _want6 = {_pnorm(exp[k]) for k in _num_keys} | {_ai(exp[k]) for k in _txt_keys} def _norm_marker(tok): p = _pct(tok) return _pnorm(p) if p is not None else _ai(tok) def _list_vs_want6(path, var_re, label): try: src = open(path, encoding="utf-8").read() m = re.search(var_re, src, re.S) items = ast.literal_eval(m.group(1)) except (OSError, ValueError, SyntaxError, AttributeError) as e: bad(f"Faisabilité canoniques · {path} {label} illisible/inattendu : {e}") return got = {_norm_marker(x) for x in items} if len(got) != len(items): bad(f"Faisabilité canoniques · {path} — {label} contient un DOUBLON ({items})") elif got == _want6: good(f"Faisabilité canoniques · {path} — {label} == CLAUDE.md #9/#10 (6 marqueurs)") else: miss = sorted(_want6 - got); extra = sorted(got - _want6) detail = (f" · absents={miss}" if miss else "") + \ (f" · en trop={extra}" if extra else "") bad(f"Faisabilité canoniques · {path} — {label} {sorted(got)} MAIS " f"CLAUDE.md {sorted(_want6)}{detail} → aligner (ou CLAUDE.md)") # (c) l'oracle d'enforcement renderer._CANONICAL_MARKERS _list_vs_want6(f"{D}/faisabilite/generator/genlib/renderer.py", r"_CANONICAL_MARKERS\s*=\s*(\[.*?\])", "_CANONICAL_MARKERS (oracle check_canonical)") # (e) l'oracle du test tests/test_generator.py (for marker in [...]) _list_vs_want6(f"{D}/faisabilite/generator/tests/test_generator.py", r"for marker in (\[[^\]]*\])", "oracle du test check_canonical") # (d) les DEUX docstrings du scorer : chaque valeur normalisée présente dans la ligne SC = f"{D}/faisabilite/generator/genlib/scorer.py" try: sc_lines = open(SC, encoding="utf-8").read().splitlines() except OSError as e: bad(f"Faisabilité canoniques · {SC} illisible : {e}"); sc_lines = None if sc_lines is not None: # ligne d'énumération = contient un « % » ET « USD » (barème axe 4 + fonction) enum = [ln for ln in sc_lines if "%" in ln and "USD" in ln] if len(enum) < 2: bad(f"Faisabilité canoniques · {SC} — attendu ≥2 énumérations des 6 marqueurs " f"(barème axe 4 + _score_parametres_canoniques), trouvé {len(enum)}") else: for ln in enum: ai = _ai(ln) # espaces/accents retirés ⇒ « USD+DOP » == « USD + DOP » miss = sorted(v for v in _want6 if v not in ai) if miss: bad(f"Faisabilité canoniques · {SC} — docstring « {ln.strip()[:56]}… » " f"n'énumère plus {miss} (attendu les 6 de CLAUDE.md #9/#10)") else: good(f"Faisabilité canoniques · {SC} — docstring énumère les 6 marqueurs " f"== CLAUDE.md #9/#10") # ============================================================================ # 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") # ============================================================================ # Faisabilité/generator · DOMAINE DES CODES PROJET (P01..P09) ancré sur §Projets # — même classe d'ANCRAGE que le CONTRAT DE FORMAT e-NCF (`ncf.py`) : une GARDE # DE VALIDATION en CODE qui encode un domaine constitutionnel SANS l'ancrer. # ---------------------------------------------------------------------------- # Le générateur 4 volets (Livrable Sprint 3 · « P07 Aqua Terra <1h ») REFUSE tout # brief dont le `projet` n'est pas un code canonique. Le DOMAINE des codes valides # EST exactement l'ensemble des projets de CLAUDE.md §Projets (P01..P09). Il est # recopié À LA MAIN à QUATRE surfaces, AUCUNE ancrée sur §Projets, toutes # invisibles à `check_artifacts` (aucune n'est un `out/*.json`) : # (a) `faisabilite/generator/faisabilite_gen.py::_validate_brief` — la GARDE # runtime `code[:2] == "P0" and code[2] in "123456789"` (le domaine effectif) # + le message `attendu P01..P09` (les bornes) ; # (b) `faisabilite/version.schema.json` — `properties.projet.pattern "^P0[1-9]$"` # (validation JSON du `version.json` par projet) + sa `description` ; # (c) `faisabilite/generator/brief.schema.json` — même `pattern "^P0[1-9]$"` # (validation JSON du brief d'entrée) + sa `description`. # « Vert trompeur » : si CLAUDE.md §Projets AJOUTE un 10ᵉ projet (`P10 …`, évènement # de portefeuille réel), la GARDE et les DEUX patterns REJETTENT `P10` comme # « invalide (attendu P01..P09) » → le générateur REFUSE de scaffolder un projet # LÉGITIMEMENT mandaté (bug d'exécution) pendant que la suite `tests/` reste VERTE # (ses oracles P01/P02 sont self-consistants, jamais comparés à §Projets). Ni # `check_artifacts` (schémas/`.py` ne sont pas des `out/*.json`) ni `tests/` (teste # la FONCTION `_validate_brief`, pas son ANCRAGE) ne mordent. Symétriquement, # RETIRER un projet de §Projets laisse la garde trop PERMISSIVE (accepte un code # démobilisé). On RE-DÉRIVE le domaine de §Projets et on exige que chaque surface # accepte EXACTEMENT cet ensemble (test COMPORTEMENTAL du pattern, robuste à la # forme de la classe de caractères), + la déclaration de l'ancrage « §Projets ». # ---------------------------------------------------------------------------- try: _fcm = open("CLAUDE.md", encoding="utf-8").read() _fmp = re.search(r"^## Projets\s*\n(.+)$", _fcm, re.M) if _fmp is None: raise ValueError("section « ## Projets » INTROUVABLE") fpj_codes = sorted(set(re.findall(r"\bP\d{2,}\b", _fmp.group(1)))) if not fpj_codes: raise ValueError("aucun code projet PNN parsé") except (OSError, ValueError) as e: bad(f"Faisabilité domaine · CLAUDE.md §Projets illisible/inattendu : {e}") fpj_codes = None if fpj_codes is not None: fpj_set = set(fpj_codes); fpj_mn, fpj_mx = fpj_codes[0], fpj_codes[-1] # (a) la garde runtime _validate_brief : préfixe + ensemble de chiffres + message FG = f"{D}/faisabilite/generator/faisabilite_gen.py" try: _fg = open(FG, encoding="utf-8").read() except OSError as e: _fg = None; bad(f"Faisabilité domaine · {FG} illisible : {e}") if _fg is not None: _mpre = re.search(r'code\[:2\]\s*==\s*"([^"]+)"', _fg) _mdig = re.search(r'code\[2\]\s*in\s*"([^"]+)"', _fg) if not (_mpre and _mdig): bad("Faisabilité domaine · _validate_brief : préfixe/ensemble-chiffres du " "code projet INTROUVABLE (ancre morte) → la garde a changé de forme") else: _accept = {f"{_mpre.group(1)}{d}" for d in _mdig.group(1)} _miss = sorted(fpj_set - _accept); _extra = sorted(_accept - fpj_set) if _miss or _extra: bad(f"Faisabilité domaine · _validate_brief accepte {sorted(_accept)} ≠ " f"CLAUDE.md §Projets{' · manquants=' + str(_miss) if _miss else ''}" f"{' · en trop=' + str(_extra) if _extra else ''} → aligner la garde " f"(ou CLAUDE.md §Projets)") else: good(f"Faisabilité domaine · _validate_brief accepte exactement les " f"{len(_accept)} codes {fpj_mn}..{fpj_mx} de CLAUDE.md §Projets") _mmsg = re.search(r'brief\.projet invalide \(attendu ([^)]+)\)', _fg) if not _mmsg: bad("Faisabilité domaine · message « brief.projet invalide (attendu …) » " "INTROUVABLE (ancre morte)") elif fpj_mn not in _mmsg.group(1) or fpj_mx not in _mmsg.group(1): bad(f"Faisabilité domaine · message « attendu {_mmsg.group(1)} » ne cite pas " f"les bornes {fpj_mn}..{fpj_mx} de CLAUDE.md §Projets") else: good(f"Faisabilité domaine · message d'erreur cite les bornes " f"{fpj_mn}..{fpj_mx} == CLAUDE.md §Projets") # (b)+(c) les 2 schémas JSON : le pattern accepte EXACTEMENT §Projets (comportemental) _probe = fpj_set | {"P00", "P10", "P99", "P0", "PXX", "P1", "P010", "Q01"} for _rel in ("version.schema.json", "generator/brief.schema.json"): SP = f"{D}/faisabilite/{_rel}" try: _sj = json.load(open(SP, encoding="utf-8")) except (OSError, ValueError) as e: bad(f"Faisabilité domaine · {_rel} illisible/inattendu : {e}"); continue _prop = (_sj.get("properties") or {}).get("projet") or {} _pat = _prop.get("pattern") if not _pat: bad(f"Faisabilité domaine · {_rel} : properties.projet.pattern INTROUVABLE") else: try: _rx = re.compile(_pat) except re.error as e: bad(f"Faisabilité domaine · {_rel} pattern {_pat!r} illégal : {e}"); _rx = None if _rx is not None: _acc = {c for c in _probe if _rx.fullmatch(c)} _miss = sorted(fpj_set - _acc); _extra = sorted(_acc - fpj_set) if _miss or _extra: bad(f"Faisabilité domaine · {_rel} pattern {_pat!r} accepte {sorted(_acc)}" f" ≠ CLAUDE.md §Projets{' · manquants=' + str(_miss) if _miss else ''}" f"{' · en trop=' + str(_extra) if _extra else ''}") else: good(f"Faisabilité domaine · {_rel} pattern accepte exactement les " f"{len(fpj_set)} codes de §Projets") _desc = _prop.get("description", "") or "" if not re.search(r"CLAUDE\.md\s*§?\s*Projets", _desc): bad(f"Faisabilité domaine · {_rel} : description projet ne DÉCLARE pas " f"l'ancrage « CLAUDE.md §Projets » : {_desc!r}") elif fpj_mn not in _desc or fpj_mx not in _desc: bad(f"Faisabilité domaine · {_rel} : description ne cite pas les bornes " f"{fpj_mn}..{fpj_mx} : {_desc!r}") else: good(f"Faisabilité domaine · {_rel} description ancre {fpj_mn}..{fpj_mx} " f"sur CLAUDE.md §Projets") # ============================================================================ # TEMPLATE FAISABILITÉ CANONIQUE v1.0 · PARAMÈTRES #9/#10 — même classe d'ANCRAGE # que « paramètres canoniques » (generator) et « Fiches #10 » ci-dessus, sur une # SURFACE encore distincte : le DOCUMENT-MODÈLE « CANONIQUE » que TOUTE faisabilité # en production DOIT reproduire — `05_deliverables_mvp/faisabilite/ # TEMPLATE_FAISABILITE_CANONIQUE_v1.0.md` (Livrable Sprint 1 · statut « CANONIQUE : # toute faisabilité en production DOIT s'y conformer »). # ---------------------------------------------------------------------------- # Ce template recopie À LA MAIN les 6 valeurs de CLAUDE.md #9/#10 à CINQ surfaces # structurées, TOUTES se réclamant de « contrainte #9 »/« #10 » : # (1) l.28 intro §0 « frais édition **3 %** · marketing **8.5 %** · point # d'équilibre **52 %** pré-vente » (les 3 pourcentages #9, associés par mot-clé) ; # (2) l.144-146 §5.2 — 3 lignes de table « **N %** (fixe · contrainte #9) » # (frais/marketing/équilibre) ; # (3) l.29 intro §0 « **USD + DOP** · format **Letter US** · paiements **Cardnet** » # (les 3 valeurs #10) ; # (4) l.149-151 §5.2 — 3 lignes de table « **valeur** (… contrainte #10) » (devises · # format · paiement, la ligne paiement citant l'exclusion « ❌ pas-X ») ; # (5) l.196 §7 checklist « 3 % / 8.5 % / 52 % · USD+DOP · Letter US · Cardnet présents » # (l'énumération des 6 marqueurs, comme la prose README:28 du generator). # AUCUN gate ne liait ce fichier à CLAUDE.md (grep : absent de tous les `ci/*.sh`) : # check_artifacts est aveugle (ce n'est pas un `out/*.json`), check_docs/guard aussi. # Piège #6 « vert trompeur » : si CLAUDE.md #9 passe 52 %→55 % (ou #10 renomme le # prestataire) et que les modules s'alignent, ce TEMPLATE — la référence que le # générateur 4 volets, le Publiciste et la régénération batch S4 (§8) sont censés # suivre — reste PÉRIMÉ en silence, imposant à TOUTES les faisabilités un paramètre # qui CONTREDIT le mandat, pendant qu'il se réclame « contrainte #9/#10 ». Aucune # suite tests/ (FONCTIONS, jamais l'ancre à CLAUDE.md) ne l'attrape. On RÉUTILISE # `exp` (6 valeurs recomputées de #9/#10 · zéro duplication) + `exp_excl` (prestataire # exclu) et on exige que CHACUNE des 5 surfaces == CLAUDE.md. Une surface INTROUVABLE # échoue AUSSI (le recensement qui s'évapore est lui-même une régression #6). # ============================================================================ if exp is not None: TPL = f"{D}/faisabilite/TEMPLATE_FAISABILITE_CANONIQUE_v1.0.md" tpl = read_norm(TPL) if tpl is None: bad(f"Template canonique · {TPL} illisible") else: _pct_kw_map = (("frais_edition_pct", r"frais", "frais édition"), ("marketing_pct", r"marketing", "marketing"), ("point_equilibre_pct", r"[ée]quilibre", "point d'équilibre")) # (1) intro §0 (l.28) · les 3 pourcentages #9 associés par mot-clé m1 = re.search(r"#9 Param[^→]*→(.+?)pr[ée]-vente", tpl) if m1 is None: bad("Template canonique · (1) intro #9 (l.28) INTROUVABLE " "(attendu frais/marketing/équilibre en gras)") else: seg1 = m1.group(1); drift = [] for key, kw, lab in _pct_kw_map: mm = re.search(kw + r"[^*]*?\*\*\s*(\d+(?:\.\d+)?)\s*%", seg1) got = float(mm.group(1)) if mm else None if got != exp[key]: drift.append(f"{lab}={_pnorm(got) if got is not None else '∅'} " f"(attendu {_pnorm(exp[key])})") if drift: bad("Template canonique · (1) intro #9 (l.28) DÉRIVE de CLAUDE.md #9 : " f"{' · '.join(drift)} → aligner le template (ou CLAUDE.md)") else: good("Template canonique · (1) intro #9 (l.28) — 3 pourcentages == " "CLAUDE.md #9") # (2) §5.2 (l.144-146) · les 3 lignes de table « **N %** (fixe · contrainte #9) » rows9 = re.findall(r"\*\*\s*(\d+(?:\.\d+)?)\s*%\s*\*\*\s*\(fixe · contrainte #9\)", tpl) want9 = {exp[k] for k in _num_keys} got9 = {float(x) for x in rows9} if len(rows9) != 3: bad(f"Template canonique · (2) §5.2 — attendu 3 lignes « **N %** (fixe · " f"contrainte #9) », trouvé {len(rows9)} ({rows9}) → régénérer le template") elif got9 == want9: good("Template canonique · (2) §5.2 — 3 lignes « (fixe · contrainte #9) » == " "CLAUDE.md #9 (3 %/8.5 %/52 %)") else: miss = sorted(_pnorm(v) for v in want9 - got9) extra = sorted(_pnorm(v) for v in got9 - want9) bad(f"Template canonique · (2) §5.2 — lignes #9 {sorted(map(_pnorm, got9))} " f"MAIS CLAUDE.md {sorted(map(_pnorm, want9))}" + (f" · absents={miss}" if miss else "") + (f" · en trop={extra}" if extra else "") + " → régénérer le template") # (3) intro §0 (l.29) · les 3 valeurs #10 en gras m3 = re.search(r"#10 Devises\*\* → (.+?)\.", tpl) if m3 is None: bad("Template canonique · (3) intro #10 (l.29) INTROUVABLE " "(attendu devises/format/paiement en gras)") else: seg3 = m3.group(1); drift = [] for k in _txt_keys: if not re.search(r"\*\*\s*" + re.escape(exp[k]) + r"\s*\*\*", seg3): drift.append(f"{_lab10[k]} attendu **{exp[k]}**") if drift: bad("Template canonique · (3) intro #10 (l.29) DÉRIVE de CLAUDE.md #10 : " f"{' · '.join(drift)} → aligner le template (ou CLAUDE.md)") else: good("Template canonique · (3) intro #10 (l.29) — devises/format/paiement " "== CLAUDE.md #10") # (4) §5.2 (l.149-151) · les 3 lignes de table « **valeur** (… contrainte #10) » drift = [] # devises + format : « **valeur** (contrainte #10) » for k in ("devises", "format_doc"): if not re.search(r"\*\*\s*" + re.escape(exp[k]) + r"\s*\*\*\s*\(contrainte #10\)", tpl): drift.append(f"ligne {_lab10[k]} attendue « **{exp[k]}** (contrainte #10) »") # paiement : « **valeur** (❌ · contrainte #10) » — cite l'exclusion #10 pay_re = (r"\*\*\s*" + re.escape(exp["paiement"]) + r"\s*\*\*\s*\(❌\s*" + (re.escape(exp_excl) if exp_excl else r"\S+") + r"\s*·\s*contrainte #10\)") if not re.search(pay_re, tpl): drift.append(f"ligne paiement attendue « **{exp['paiement']}** (❌ " f"{exp_excl or '?'} · contrainte #10) »") if drift: bad("Template canonique · (4) §5.2 lignes #10 DÉRIVENT de CLAUDE.md #10 : " f"{' · '.join(drift)} → aligner le template (ou CLAUDE.md)") else: good("Template canonique · (4) §5.2 — 3 lignes « (… contrainte #10) » (devises · " f"format · paiement ❌ {exp_excl}) == CLAUDE.md #10") # (5) §7 checklist (l.196) · énumération des 6 marqueurs canoniques _want6_tpl = {_pnorm(exp[k]) for k in _num_keys} | {_ai(exp[k]) for k in _txt_keys} m5 = re.search(r"Param[èe]tres canoniques\*\* \| (.+?) pr[ée]sents", tpl) if m5 is None: bad("Template canonique · (5) §7 checklist — énumération « 3 %/…/Cardnet " "présents » INTROUVABLE") else: got5 = set() for tok in re.split(r"[/·]", m5.group(1)): p = _pct(tok) got5.add(_pnorm(p) if p is not None else _ai(tok)) if got5 == _want6_tpl: good("Template canonique · (5) §7 checklist — énumère les 6 marqueurs == " "CLAUDE.md #9/#10") else: miss = sorted(_want6_tpl - got5); extra = sorted(got5 - _want6_tpl) bad(f"Template canonique · (5) §7 checklist — énumère {sorted(got5)} MAIS " f"CLAUDE.md {sorted(_want6_tpl)}" + (f" · absents={miss}" if miss else "") + (f" · en trop={extra}" if extra else "") + " → régénérer le template") # ============================================================================ # 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") # ============================================================================ # Fiches agents · appartenance à `roles_allowed` du chat OTOIA — QUI peut # INVOQUER l'assistant OTOIA embarqué dans un portail. Surface data-derived # DISTINCTE de la membership Workspace `Has Role` (QUELLE console le rôle atteint, # gatée juste au-dessus) ET des attributs du contrat rbac_50_roles.json (gatés par # les blocs Fiches Faisabilité / Fiche Mobile) : la liste `roles_allowed` ne vit # PAS dans le contrat ni dans workspace.json mais dans # frontend/chat_otoia/out/chat_mount.json (une config runtime par portail, # artefact byte-gaté par check_artifacts). Deux fiches font, EN PROSE, une # assertion d'appartenance à CE contrôle d'accès : # • rendu (:36) — POSITIVE : le rôle « OTO Faisabilité Rendu 3D » fait partie # des `roles_allowed` du portail NOMMÉ `construction` (droit # d'invoquer le chat pour déclencher les rendus assistés). # • mobile (:41) — NÉGATIVE d'honnêteté (#6) : le rôle « OTO Plateforme Mobile » # (famille `plateforme`) n'est VOLONTAIREMENT dans AUCUNE liste # `roles_allowed` — le chat ne couvre que les portails métier. # Aucun gate ne les couvrait : le bloc Chat OTOIA amont gate l'IDENTITÉ de la # persona/capabilities (ancrée CLAUDE.md) et la COMPOSITION (5 mounts ⇔ 5 portails), # AVEUGLE à QUEL rôle figure dans quelle `roles_allowed` ; le bloc Fiches Workspace # gate la membership `Has Role`, un artefact + une sémantique DIFFÉRENTS (accès # console ≠ droit d'invocation du chat). Ajouter le rôle mobile à une `roles_allowed` # (SUR-EXPOSITION : le dev mobile gagnerait le droit de piloter l'IA d'un portail # métier — l'escalade même que la note #6 refuse), retirer le rôle rendu de la # `roles_allowed` construction (perte du déclenchement assisté), ou le DÉPLACER vers # un autre portail, laisse la fiche périmée EN SILENCE pendant que l'artefact dit # autre chose ⇒ l'agent ERPNext Backend câblerait le mauvais contrôle d'accès chat. # Aucune suite tests/ (qui teste des FONCTIONS de montage/résolution, pas la prose # d'une fiche) n'attrape ce « vert trompeur ». On RECOMPUTE, depuis chat_mount.json, # l'ensemble des portails dont la `roles_allowed` porte l'erpnext_role_name du rôle # (résolu du contrat par id, zéro duplication) et on confronte l'assertion de la # fiche. Triple ancrage bonus (POSITIVE) : le portail NOMMÉ par la fiche == le # `portail` du rôle dans le contrat == l'unique portail dont la `roles_allowed` # le porte. Un claim absent échoue AUSSI (traçabilité · #6). # ============================================================================ FICHES_CHAT = [ {"file": "03_agents/rendu/AGENT.md", "rid": "faisabilite-rendu-3d", "kind": "in_named_portail"}, {"file": "03_agents/mobile/AGENT.md", "rid": "plateforme-mobile", "kind": "not_in_any"}, ] _chat_mounts = _chat_roles = None try: _chat_mounts = load("frontend/chat_otoia/out/chat_mount.json") _chat_roles = load("rbac/rbac_50_roles.json")["roles"] except (OSError, KeyError, ValueError, TypeError) as e: bad(f"Fiches Chat · chat_mount.json / rbac_50_roles.json illisible : {e}") if _chat_mounts is not None and _chat_roles is not None: # Index recomputé : erpnext_role_name -> ensemble des portails dont la # `roles_allowed` le porte (le droit d'invoquer le chat de CE portail). _allow, _portails = {}, set() for _mnt in _chat_mounts: _pt = _mnt.get("portail") _portails.add(_pt) for _rn in (_mnt.get("roles_allowed") or []): _allow.setdefault(_rn, set()).add(_pt) if not _chat_mounts: bad("Fiches Chat · aucun mount dans chat_mount.json → artefact suspect") for fc in FICHES_CHAT: tag = fc["file"].split("/")[1] cand = [r for r in _chat_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 r0 = cand[0] rname = r0.get("erpnext_role_name") actual = _allow.get(rname, set()) # portails réels autorisant ce rôle ftxt = read_norm(fc["file"]) if ftxt is None: continue if fc["kind"] == "in_named_portail": # Le portail NOMMÉ par la fiche est data-derived : c'est le `portail` du # rôle dans le contrat (triple ancrage). Il DOIT exister comme mount # (anti-typo : sinon l'assertion « verte » viserait un portail fantôme). named = r0.get("portail") if named not in _portails: bad(f"Fiche {tag} · le rôle `{fc['rid']}` a portail `{named}` (contrat) " f"MAIS aucun mount chat pour ce portail (mounts : {sorted(_portails)}) " f"→ contrat/artefact périmé") continue # La fiche doit NOMMER ce portail dans son assertion roles_allowed + chat. if not re.search(rf"`roles_allowed` \(portail {re.escape(named)}\)", ftxt): bad(f"Fiche {tag} · {fc['file']} — assertion « `roles_allowed` (portail " f"{named}) » INTROUVABLE (claim absent = régression · #6)") elif not re.search(r"autorisés à \*\*invoquer le chat OTOIA\*\*", ftxt): bad(f"Fiche {tag} · {fc['file']} — la fiche nomme le portail mais pas " f"le droit « invoquer le chat OTOIA » → assertion incomplète (#6)") elif actual == {named}: good(f"Fiche {tag} · rôle « {rname} » ∈ roles_allowed du portail " f"`{named}` (et de lui seul) == chat_mount.json") elif named in actual: bad(f"Fiche {tag} · rôle « {rname} » ∈ roles_allowed de `{named}` MAIS " f"AUSSI {sorted(actual - {named})} — la fiche nomme UN seul portail ; " f"droit d'invocation élargi dans chat_mount.json → réviser fiche/artefact") else: bad(f"Fiche {tag} · {fc['file']} nomme le portail `{named}` MAIS " f"chat_mount.json autorise « {rname} » sur " f"{sorted(actual) or 'aucun portail'} → fiche périmée (rôle " f"retiré/déplacé de roles_allowed), régénérer") elif fc["kind"] == "not_in_any": if not re.search(r"volontairement pas\*\* rattaché.*?`roles_allowed` du chat " r"OTOIA", ftxt): bad(f"Fiche {tag} · {fc['file']} — assertion négative « volontairement pas " f"… `roles_allowed` du chat OTOIA » INTROUVABLE (#6)") elif not actual: good(f"Fiche {tag} · rôle « {rname} » ABSENT de TOUTE roles_allowed " f"== chat_mount.json (assertion négative #6 tenue)") else: bad(f"Fiche {tag} · {fc['file']} affirme « pas dans roles_allowed » MAIS " f"chat_mount.json autorise « {rname} » sur {sorted(actual)} → " f"SUR-EXPOSITION du chat OTOIA, 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") # ============================================================================ # Publiciste/generator+branding · DEVISES CANONIQUES du site public « ancrées # sur CLAUDE.md #10 » (USD + DOP) — les libellés monétaires RÉELLEMENT ÉMIS sur # vente.otov7.com, jamais gatés. # ---------------------------------------------------------------------------- # Le bloc branding #4 ci-dessus ancre les 4 tokens DESIGN. Mais le MÊME module # porte une 2ᵉ constante constitutionnelle NON gatée : les DEUX devises de # CLAUDE.md #10 (« **USD + DOP** devises »), recopiées à QUATRE endroits data- # derived, tous invisibles à check_artifacts (pas un out/*.json) et jamais # confrontés à #10 : # (a) lib/branding.py DEVISE_PRIMAIRE/DEVISE_SECONDAIRE — constantes AUTO- # étiquetées « contrainte #10 · USD + DOP », sans aucun gate ; # (b) lib/generator.py `_fmt_usd`/`_fmt_dop` — les DEUX formateurs qui # IMPRIMENT le code devise sur la grille de prix du site public # (`f"USD {value:,.0f}"` / `f"DOP {value:,.0f}"`) ; c'est la SORTIE # FACE-PUBLIC : une devise HORS #10 ici = mandat trahi en silence (#6) ; # (c) lib/generator.py footer (émis dans le HTML) « Prix … en USD + DOP # (Cardnet) » — la prose du pied de page du site ; # (d) tests/test_publiciste.py — l'oracle HARDCODÉ « assertIn("USD 150 000", # …) » : une COPIE de plus de la devise primaire, JAMAIS comparée à #10. # Aucune suite tests/ (qui teste des FONCTIONS de rendu, pas l'ancre à # CLAUDE.md) n'attrape la dérive : renommer la devise #10 (USD→EUR) et aligner # le module reste vert alors que le site émettrait la mauvaise devise ; ne RIEN # bouger reste vert alors que branding se dit « #10 » en mentant. Même patron # EXACT que le bloc branding/#4 et faisabilite/#9-#10. On RE-PARSE les 2 devises # depuis la ligne #10 de CLAUDE.md (indépendamment de `exp` amont · aucun skip # hérité) et on exige : (a) les 2 constantes == #10 (ordre inclus) ; (b) # l'ensemble des codes émis par les formateurs == #10 ; (c) le footer cite les 2 # devises dans l'ordre ; (d) l'oracle du test == devise primaire. Un claim # absent échoue AUSSI (traçabilité #6). # ============================================================================ try: cm_raw10p = open("CLAUDE.md", encoding="utf-8").read() l10p = re.search(r"^10\.\s+(.+)$", cm_raw10p, re.M) if not l10p: raise ValueError("contrainte #10 INTROUVABLE") seg10p = [s for s in re.split(r"·", l10p.group(1)) if "devise" in _ai(s)] mdev = re.search(r"\*\*(.+?)\*\*", seg10p[0]) if seg10p else None if not mdev: raise ValueError("segment devises #10 (« **… devises** ») illisible") pub_cur = re.findall(r"[A-Z]{3}", mdev.group(1)) if len(pub_cur) != 2: raise ValueError(f"attendu 2 devises en gras, lu {pub_cur}") except (OSError, ValueError) as e: bad(f"Publiciste devises · CLAUDE.md #10 illisible/inattendu : {e}"); pub_cur = None if pub_cur is not None: good(f"Publiciste devises · CLAUDE.md #10 sain : primaire={pub_cur[0]} " f"secondaire={pub_cur[1]}") PUB = f"{D}/publiciste" # (a) ANCRAGE : branding.py DEVISE_PRIMAIRE/SECONDAIRE == #10 (ordre inclus) --- BRD = f"{PUB}/lib/branding.py" try: brd_src = open(BRD, encoding="utf-8").read() except OSError as e: bad(f"Publiciste devises · {BRD} illisible : {e}"); brd_src = None if brd_src is not None: dev_pat = { "primaire": (r'DEVISE_PRIMAIRE\s*=\s*"([^"]+)"', pub_cur[0]), "secondaire": (r'DEVISE_SECONDAIRE\s*=\s*"([^"]+)"', pub_cur[1]), } ddrift = [] for k, (pat, want) in dev_pat.items(): m = re.search(pat, brd_src) got = m.group(1) if m else None if got != want: ddrift.append(f"{k}={got!r} (attendu {want!r})") if ddrift: bad(f"Publiciste devises · branding.py CONSTANTES DÉRIVENT de CLAUDE.md #10 : " f"{' · '.join(ddrift)} → aligner branding.py (ou CLAUDE.md)") else: good(f"Publiciste devises · branding.py DEVISE_PRIMAIRE/SECONDAIRE == " f"CLAUDE.md #10 ({pub_cur[0]}/{pub_cur[1]})") # (b) SORTIE : les formateurs generator.py émettent EXACTEMENT les 2 devises #10 GEN = f"{PUB}/lib/generator.py" try: gen_src = open(GEN, encoding="utf-8").read() except OSError as e: bad(f"Publiciste devises · {GEN} illisible : {e}"); gen_src = None if gen_src is not None: emis = re.findall(r'return f"([A-Z]{3}) \{value:[^}]+\}"', gen_src) if set(emis) != set(pub_cur): miss = sorted(set(pub_cur) - set(emis)); extra = sorted(set(emis) - set(pub_cur)) bad(f"Publiciste devises · formateurs generator.py émettent {sorted(set(emis))} " f"MAIS CLAUDE.md #10 = {sorted(set(pub_cur))}" + (f" · absents={miss}" if miss else "") + (f" · HORS #10={extra}" if extra else "") + " → le site public émettrait une devise non conforme (#6)") else: good(f"Publiciste devises · formateurs generator.py émettent {sorted(set(emis))} " f"== CLAUDE.md #10") # (c) FOOTER émis dans le HTML : « en + (Cardnet) » ordre inclus --- mft = re.search(r"en ([A-Z]{3}) \+ ([A-Z]{3}) \(Cardnet\)", gen_src) if mft is None: bad(f"Publiciste devises · {GEN} — pied de page « en + (Cardnet) » " f"INTROUVABLE (claim absent = régression · #6)") elif [mft.group(1), mft.group(2)] == pub_cur: good(f"Publiciste devises · footer generator.py cite {pub_cur[0]} + {pub_cur[1]} " f"== CLAUDE.md #10") else: bad(f"Publiciste devises · footer generator.py cite {mft.group(1)} + {mft.group(2)} " f"MAIS CLAUDE.md #10 = {pub_cur[0]} + {pub_cur[1]} → aligner le footer") # (d) ORACLE du test : « assertIn(" 150 000", self.html) » == devise primaire TP = f"{PUB}/tests/test_publiciste.py" try: tp_src = open(TP, encoding="utf-8").read() except OSError as e: bad(f"Publiciste devises · {TP} illisible : {e}"); tp_src = None if tp_src is not None: mto = re.search(r'assertIn\("([A-Z]{3}) [\d ]+", self\.html\)', tp_src) if mto is None: bad(f"Publiciste devises · {TP} — oracle « assertIn(\" …\", self.html) » " f"INTROUVABLE (le contrôle prix du test a disparu · régression)") elif mto.group(1) == pub_cur[0]: good(f"Publiciste devises · {TP} oracle prix == devise primaire #10 ({pub_cur[0]})") else: bad(f"Publiciste devises · {TP} oracle prix cite {mto.group(1)!r} MAIS devise " f"primaire #10 = {pub_cur[0]!r} → 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.") # ============================================================================ # Fiches agents · colonne « Entrée CLI » — le JEU DE SOUS-COMMANDES documenté # DOIT être l'ensemble EXACT des subparsers argparse du générateur (même classe # de « vert trompeur », côté surface d'invocation du module). # ---------------------------------------------------------------------------- # Chaque table de livrables des fiches 03_agents/*/AGENT.md porte une colonne # « Entrée CLI » qui cite, entre backticks, le point d'entrée du module ET son # jeu de verbes : `publiciste.py parse|validate|generate|run`, # `faisabilite_gen.py score|scaffold|generate|batch`, `rbac_apply_plan.py # build|validate`, … Cette liste est SAISIE À LA MAIN. La source de vérité est # l'ensemble des `subparsers.add_parser("")` du script réel. Les gates # amont couvrent le COMPTE de tests (colonne « Tests »), le NOM du job CI # (colonne « Job CI ») et la reproductibilité des artefacts `out/` — mais RIEN # ne vérifiait que les VERBES écrits dans la fiche sont ceux que le CLI expose # réellement. Renommer une sous-commande (`generate` → `render`), en AJOUTER une # (`publish`), en RETIRER une ou les RÉORDONNER laisse la fiche documenter une # surface d'invocation FANTÔME : le module tourne (le CI appelle le vrai verbe), # la doc d'identité de l'agent — celle qui lui dit COMMENT lancer son propre # livrable — ment en silence. Aucune suite tests/ (FONCTIONS du module, jamais # la prose de fiche) ne l'attrape ; classe explicitement notée en mémoire # (`agent-fiche-role-attrs-ungated`) : la fiche restitue une donnée data-derived # (ici la CLI) sur une surface distincte de tout gate. # # On RECOMPUTE l'ensemble ordonné des sous-commandes depuis l'AST du script # (jamais une liste à la main · zéro chiffre/verbe inventé · #6) et on exige # l'égalité EXACTE (ensemble ET ordre — l'ordre de `add_parser` est celui de # l'aide argparse) avec ce que la fiche ÉCRIT. Le script est résolu par le # chemin du module de la MÊME ligne de table (lien `…/05_deliverables_mvp// # README.md`) + le basename du CLI → `{D}//.py`, ce qui lie chaque # cellule CLI à SON module (pas de collision de basename). Direction fiche→CLI : # la fiche ne peut pas mentir sur les verbes que le script expose. # ============================================================================ def _cli_subcommands(pyfile): """Sous-commandes argparse d'un script, en ordre source (via AST).""" src = open(pyfile, encoding="utf-8").read() tree = ast.parse(src, filename=pyfile) hits = [] for node in ast.walk(tree): if (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute) and node.func.attr == "add_parser" and node.args and isinstance(node.args[0], ast.Constant) and isinstance(node.args[0].value, str)): hits.append((node.lineno, node.col_offset, node.args[0].value)) hits.sort() return [v for _, _, v in hits] # Ligne de table portant À LA FOIS le lien module (…//README.md) et une # cellule CLI `\`.py ||…\`` → on scope aux vraies lignes de # table (une mention CLI en prose, sans lien module, est ignorée). Les `|` # séparateurs de colonnes markdown sont échappés `\|` dans la cellule. cli_pathre = re.compile(r"05_deliverables_mvp/([\w/]+?)/README\.md") cli_cellre = re.compile(r"`([a-z][a-z0-9_]*\.py) ([a-z][a-z|\\]+)`") fiches_cli = subprocess.run( ["git", "ls-files", "03_agents/*/AGENT.md"], capture_output=True, text=True).stdout.split() seen_cli = 0 for af in fiches_cli: try: flines = open(af, encoding="utf-8").read().splitlines() except OSError as e: bad(f"Entrée CLI fiches · {af} illisible : {e}"); continue for ln, line in enumerate(flines, 1): mp = cli_pathre.search(line) mc = cli_cellre.search(line) if not (mp and mc): continue path, cli_file, verbs_raw = mp.group(1), mc.group(1), mc.group(2) # retirer les backslashes d'échappement avant de découper les verbes claimed = verbs_raw.replace("\\", "").split("|") pyfile = f"{D}/{path}/{cli_file}" seen_cli += 1 try: actual = _cli_subcommands(pyfile) except FileNotFoundError: bad(f"Entrée CLI · {af}:{ln} · `{cli_file}` — script INTROUVABLE à " f"`{pyfile}` (module déplacé/renommé ? régénérer la fiche)") continue except SyntaxError as e: bad(f"Entrée CLI · {af}:{ln} · `{cli_file}` — AST cassé ({e}) : " f"source de vérité illisible") continue if not actual: bad(f"Entrée CLI · {af}:{ln} · `{cli_file}` — la fiche cite les " f"verbes {claimed} MAIS le script n'expose AUCUN " f"`add_parser(...)` (CLI refondue ? régénérer la fiche)") elif claimed == actual: good(f"Entrée CLI · {af}:{ln} · `{cli_file}` — verbes " f"{'|'.join(claimed)} == subparsers ({pyfile})") else: miss = [v for v in actual if v not in claimed] extra = [v for v in claimed if v not in actual] order_only = set(claimed) == set(actual) bad(f"Entrée CLI · {af}:{ln} · `{cli_file}` — la fiche dit " f"{claimed} MAIS le CLI expose {actual} " f"(manquants={miss} · en trop={extra} · " f"seul_ordre_diffère={order_only}) → régénérer la fiche") if seen_cli == 0: bad("Entrée CLI fiches · AUCUNE cellule CLI `.py ` liée à un " "module trouvée dans les fiches — la disparition de la colonne « Entrée " "CLI » 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"(? () pour # toute interaction OTOIA ». Elle RECOPIE À LA MAIN les TROIS tokens de la # contrainte CLAUDE.md §Architecture cible « Voix Amélie QC (multilingual_v2) » : # le prénom de la persona (`Amélie`), le qualificatif de marque (`QC`) et le # modèle de voix ElevenLabs (`multilingual_v2`). Le bloc « Chat OTOIA · IDENTITÉ # de la persona » (ci-dessus) RE-DÉRIVE bien persona+voix de CLAUDE.md, MAIS ne # confronte QUE le module chat_otoia (spec + out/ + son propre README + l'oracle # du test) — AVEUGLE aux 11 pieds de page des fiches. L'autre bloc (« Chat OTOIA · # IDENTITÉ des configs runtime ») note même explicitement que le « QC » est # « gardé en littéral — non gaté ici ». Aucune suite tests/ (FONCTIONS du builder, # jamais la prose des fiches) n'attrape ce « vert trompeur ». # Piège #6 : le jour où ElevenLabs déprécie `multilingual_v2` (→ `multilingual_v3`) # ou qu'on renomme la persona/le qualificatif dans CLAUDE.md, ces 11 pieds de page # annonceraient EN SILENCE une voix/persona que le mandat ne prescrit plus, chaque # fiche affirmant orchestrer « toute interaction OTOIA » avec le mauvais modèle. # On RE-DÉRIVE le triplet (persona · qualif · voix) de CLAUDE.md §Architecture cible # (source unique · zéro duplication — mêmes `nom`/`voix` que le bloc persona # ci-dessus, plus le qualificatif jusqu'ici jamais ancré) et on exige de CHAQUE # fiche portant le pied de page que ses trois tokens == CLAUDE.md. Direction # fiche→CLAUDE.md. La disparition TOTALE du pied de page échoue AUSSI (le # recensement qui s'évapore est lui-même une régression #6). État courant : aucune # valeur périmée. # ============================================================================ if True: # RE-DÉRIVATION INDÉPENDANTE de CLAUDE.md (ne dépend PAS du parse persona # ci-dessus, dont la regex fige « QC » en dur : un renommage du qualificatif # doit mordre ICI, pas seulement via l'INTROUVABLE du bloc persona). try: _cm_af = open("CLAUDE.md", encoding="utf-8").read() # triplet canonique : « Voix () » (qualif jamais figé en dur) _maf = re.search(r"Voix\s+(\S+)\s+(\S+)\s*\(([^)]+)\)", _cm_af) if _maf is None: raise ValueError("« Voix () » INTROUVABLE " "dans CLAUDE.md §Architecture cible") af_nom, af_qual, af_voix = (_nfc(_maf.group(1)), _nfc(_maf.group(2)), _nfc(_maf.group(3))) # cohérence avec le bloc persona quand il a pu parser (zéro duplication — # même source, mêmes valeurs) ; s'il a échoué (cm_persona None), on continue. if cm_persona is not None and (af_nom != cm_persona["nom"] or af_voix != cm_persona["voix"]): raise ValueError(f"triplet {af_nom!r}/{af_voix!r} incohérent avec le bloc " f"persona {cm_persona} (parse divergent de CLAUDE.md)") except (OSError, ValueError) as e: bad(f"Fiches agents · pied de page voix CLAUDE.md §Architecture cible " f"illisible/inattendu : {e}") af_nom = None if af_nom is not None: _fiches = subprocess.run(["git", "ls-files", "03_agents/*/AGENT.md"], capture_output=True, text=True).stdout.split() _foot_re = re.compile( r"Voix\s+(\S+)\s+(\S+)\s*\(([^)]+)\)\s+pour toute interaction OTOIA") af_seen = 0 for _fi in _fiches: try: _ftxt = open(_fi, encoding="utf-8").read() except OSError as e: bad(f"Fiches agents · {_fi} illisible : {e}"); continue _mfoot = _foot_re.search(_nfc(_ftxt)) # Fiche sans OTOIA (faisabilite/publiciste) : pas de pied de page attendu. if _mfoot is None: if "interaction OTOIA" in _ftxt: bad(f"Fiches agents · {_fi} — prose « interaction OTOIA » présente " f"MAIS pied de page « Voix () » malformé/" f"INTROUVABLE (attendu {af_nom} {af_qual} ({af_voix}))") continue af_seen += 1 got = (_nfc(_mfoot.group(1)), _nfc(_mfoot.group(2)), _nfc(_mfoot.group(3))) want = (af_nom, af_qual, af_voix) if got == want: good(f"Fiches agents · {_fi} — pied de page « Voix {got[0]} {got[1]} " f"({got[2]}) » == CLAUDE.md §Architecture cible") else: bad(f"Fiches agents · {_fi} — pied de page dit « {got[0]} {got[1]} " f"({got[2]}) » MAIS CLAUDE.md §Architecture cible dit « {want[0]} " f"{want[1]} ({want[2]}) » → persona/qualif/voix périmé (mettre la " f"fiche à jour)") if af_seen == 0: bad("Fiches agents · AUCUNE fiche ne porte le pied de page « Voix " f"{af_nom} {af_qual} ({af_voix}) pour toute interaction OTOIA » — la " f"surface d'identité voix OTOIA s'est évaporée (régression #6)") # ============================================================================ # Mobile · le SDK MAJEUR Expo (« Expo 54 ») — ANCRÉ sur la roadmap Sprint 5 l.56, # transcrit À LA MAIN dans le README sans AUCUN gate d'IDENTITÉ ni d'ancrage. # ---------------------------------------------------------------------------- # La version MAJEURE du SDK Expo que l'app compagnon cible (`expoSdkMajor`) est une # donnée DATA-DERIVED : le générateur émet out/app_config.json (byte-gaté par # check_artifacts ⇒ faisant autorité) portant `expo.extra.expoSdkMajor`, recopié # dans out/MANIFEST.json (`app.expo_sdk_major`), lui-même dérivé de # mobile_spec.json (`app.expo_sdk_major`) dont la SOURCE FAISANT AUTORITÉ est la # roadmap Sprint 5 l.56 « **Rebuild Expo 54** » (le spec le DÉCLARE via # `app.expo_sdk_source`). Le README:4/19/35/72 recopie ce « Expo 54 » CINQ fois À # LA MAIN — dont la ligne de source « Expo SDK **54** | roadmap Sprint 5 l.56 ». # Le bloc « Mobile · récap de l'app Expo » amont ne gate QUE le quadruplet # onglets/rôles/langues/identifiants a_confirmer (comptes de MANIFEST.counts) — # AVEUGLE au majeur du SDK. Aucun gate ne comparait cette valeur à l'artefact ni # ne l'ancrait à la roadmap : BUMPER le SDK (Expo 55 sort · EAS build cible 55) # dans le spec/artefact SANS toucher au README (ou l'inverse), ou dériver la # roadmap de l'artefact, laisse la prose PÉRIMÉE en silence pendant que le graphe # byte-gaté dit autre chose — l'agent Mobile lancerait `eas build` sur le mauvais # SDK (le rebuild même que la ligne roadmap prescrit) — « vert trompeur » qu'aucune # suite tests/ (qui teste des FONCTIONS de génération, jamais la prose ni l'ancre à # la roadmap) n'attrape. Même patron que l'ancrage §Entités/CLAUDE.md (SEO org) ou # les `roadmap_line` de la recette : on RE-DÉRIVE le majeur de SON artefact # byte-gaté (zéro duplication du contrat #6), on ANCRE à la roadmap faisant # autorité, et on exige que TOUTE mention prose == cette valeur. Cohérences # croisées bonus (mordent une chaîne INTERNEMENT incohérente) : artefact ⇔ MANIFEST # ⇔ spec d'accord ; le spec DÉCLARE l'ancre roadmap. Un claim absent échoue AUSSI. # ============================================================================ MOB_SDK_RD = f"{D}/mobile/app_config/README.md" try: sdk = int(load("mobile/app_config/out/app_config.json")["expo"]["extra"]["expoSdkMajor"]) _mman = load("mobile/app_config/out/MANIFEST.json") _mspec = json.load(open(f"{D}/mobile/app_config/mobile_spec.json", encoding="utf-8")) except (OSError, KeyError, ValueError, TypeError) as e: bad(f"Mobile Expo SDK · artefact/spec illisible/inattendu : {e}"); sdk = None if sdk is not None: # (a) cohérence interne : artefact ⇔ MANIFEST ⇔ spec d'accord sur le majeur man_sdk = _mman.get("app", {}).get("expo_sdk_major") spec_sdk = _mspec.get("app", {}).get("expo_sdk_major") if man_sdk != sdk or spec_sdk != sdk: bad(f"Mobile Expo SDK · incohérence artefact={sdk} MANIFEST={man_sdk} " f"spec={spec_sdk} → chaîne de dérivation cassée") else: good(f"Mobile Expo SDK · artefact ⇔ MANIFEST ⇔ spec d'accord (Expo {sdk})") # (b) ANCRAGE roadmap : la roadmap faisant autorité dit « Rebuild Expo » 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") # ============================================================================ # Fiches agents + master prompt · IDENTITÉ de l'endpoint GPU RunPod ComfyUI — # le pod `t68akvx80wv092` (Flux dev fp8) cité DOIT == l'inventaire maître # AGENTS_EXISTING_ASSETS.md (même classe de « vert trompeur », côté infra GPU). # ---------------------------------------------------------------------------- # Les fiches BIM/Rendu et le master prompt NOMMENT, entre backticks, l'ID du pod # RunPod ComfyUI qui exécute le rendu Flux (`t68akvx80wv092`) + le tag du modèle # (« Flux dev fp8 »). Ces valeurs sont SAISIES À LA MAIN ; la source de vérité # in-repo est le bullet §1 de AGENTS_EXISTING_ASSETS.md — « le fichier maître # en-repo » que les fiches CITENT explicitement comme leur ancre. Aucun gate ne # couvrait ce lien : check_docs ne voit qu'un lien Markdown, guard_constraints # qu'un terme interdit, check_artifacts qu'un out/ — un ID de pod n'est aucun des # trois. Or un pod RunPod est RE-CRÉÉ avec un NOUVEL id (le cas le plus courant) : # l'inventaire maître se met à jour, les 6 citations en fiches se PÉRIMENT en # silence → l'agent Rendu/BIM câblerait le batch Flux vers un endpoint GPU MORT # (l'invention même que #6 interdit). Aucune suite tests/ (FONCTIONS du module, # jamais la prose de fiche) ne l'attrape ; classe notée en mémoire # (`agent-fiche-role-attrs-ungated`) : la fiche restitue une donnée data-derived # (ici l'infra GPU) sur une surface distincte de tout gate. # # On RECOMPUTE l'id + le tag depuis le bullet maître (jamais une liste à la main # · #6). Direction fiche→inventaire (le consommateur) : toute fiche/master prompt # qui MENTIONNE RunPod doit citer l'id EXACT (tout token backtické de forme pod — # alphanum minuscule ≥12 avec un chiffre — == canonique) + le tag Flux EXACT. La # disparition TOTALE de la surface (plus aucune mention RunPod) échoue AUSSI. # ============================================================================ RUNPOD_SRC = "AGENTS_EXISTING_ASSETS.md" try: _rp_src = re.sub(r"\s+", " ", open(RUNPOD_SRC, encoding="utf-8").read()) except OSError as e: bad(f"RunPod endpoint · {RUNPOD_SRC} illisible : {e}"); _rp_src = None if _rp_src is not None: _rp_hits = re.findall( r"RunPod ComfyUI\*\*\s*endpoint\s*`([a-z0-9]+)`\s*\((Flux[^)]+)\)", _rp_src) if len(_rp_hits) != 1: bad(f"RunPod endpoint · bullet canonique INTROUVABLE ou en double dans " f"{RUNPOD_SRC} (attendu 1, trouvé {len(_rp_hits)}) → source d'ancrage " f"cassée, impossible de recomputer l'id du pod GPU") else: pod_id, flux_tag = _rp_hits[0] flux_tag = flux_tag.strip() flux_norm = re.sub(r"\s+", " ", flux_tag).lower() pod_shape = re.compile(r"`([a-z0-9]{12,})`") rp_files = subprocess.run( ["git", "ls-files", "03_agents/*/AGENT.md"], capture_output=True, text=True).stdout.split() rp_files.append("02_master_prompt/MASTER_PROMPT_DTP_v2.md") rp_citers = 0 for rf in rp_files: try: rtxt = re.sub(r"\s+", " ", open(rf, encoding="utf-8").read()) except OSError as e: bad(f"RunPod endpoint · {rf} illisible : {e}"); continue if "RunPod" not in rtxt: continue # fiche ne cite pas l'infra GPU (ok) rp_citers += 1 toks = [t for t in pod_shape.findall(rtxt) if re.search(r"\d", t)] if not toks: bad(f"RunPod endpoint · {rf} mentionne RunPod mais ne cite AUCUN id " f"de pod backtické (attendu `{pod_id}`) → citation incomplète") else: stale = sorted({t for t in toks if t != pod_id}) if stale: bad(f"RunPod endpoint · {rf} cite l'id périmé {stale} MAIS " f"l'inventaire maître dit `{pod_id}` → pod re-créé / mal " f"recopié, endpoint GPU fantôme (régénérer la fiche)") else: good(f"RunPod endpoint · {rf} · {len(toks)} citation(s) " f"`{pod_id}` == {RUNPOD_SRC}") if flux_norm not in rtxt.lower(): bad(f"RunPod endpoint · {rf} cite le pod mais PAS le tag modèle " f"« {flux_tag} » (== {RUNPOD_SRC}) → tag Flux périmé/absent") else: good(f"RunPod endpoint · {rf} · tag modèle « {flux_tag} » == inventaire") if rp_citers == 0: bad(f"RunPod endpoint · AUCUNE fiche/master prompt ne mentionne RunPod — " f"la surface d'identité de l'endpoint GPU s'est évaporée (régression #6)") # ============================================================================ # Fiscal · e-CF DGII — IDENTITÉ des ÉVÈNEMENTS D'ÉMISSION (le « cœur du livrable ») # ---------------------------------------------------------------------------- # La section §Cross-cohérence du README (05_deliverables_mvp/fiscal/ecf_dgii/ # README.md:40-59) — présentée comme « le cœur du livrable » — transcrit À LA MAIN # l'IDENTITÉ des emission_events d'out/ecf_plan.json (byte-gaté par check_artifacts # ⇒ artefact faisant autorité) : # • L48 l'ENSEMBLE des évènements qui émettent un e-CF (« seulement réservation # et contrat ») + l'ENSEMBLE exclu « on ne facture pas un brouillon # (lead/visite/devis) » — la RAISON D'ÊTRE du module : jamais de facture # sur un brouillon (états SOUMIS uniquement · doc_status=1) # • L50 l'ENSEMBLE des base_field (« montant_reservation, montant_contrat ») # • L53 le rôle UNIQUE (« compta-fiscaliste-ecf » · « OTO Compta Fiscaliste # eCF » · portail compta) # • L59 la FormaPago défaut (« = 3 (Tarjeta) » · Cardnet #10) # Le SEUL gate fiscal existant couvre le CONTRAT DE FORMAT e-NCF + le compte de # tests ; le bloc racine, le seul agrégat « e-CF 39 tests ». RIEN ne confronte # cette identité à l'artefact. Dérive la plus grave, #6/#5 : AJOUTER un # emission_event sur un état BROUILLON (ex. `devis`) — l'anti-pattern EXACT que le # module interdit (« on ne facture pas un brouillon ») — pendant que L48 dit encore # « seulement réservation et contrat » → l'agent ERPNext Backend câblerait une # facturation sur brouillon (e-CF émis avant soumission/encaissement). Aucune suite # tests/ (FONCTIONS de résolution/composition, jamais la prose vs l'artefact) ne # l'attrape — classe « vert trompeur » notée en mémoire. # # On RECOMPUTE les ENSEMBLES depuis ecf_plan.json (jamais une liste à la main · #6) # et EXIGE que le README les reflète EXACTEMENT (set-diff : ni fantôme ni manquant ; # accents/casse normalisés). Le rôle est en outre DOUBLE-ANCRÉ sur rbac_50_roles # (id → portail compta → nom ERPNext concordant). Cohérences croisées en bonus # (mordent un plan INTERNEMENT incohérent) : rôle UNIQUE sur tous les évènements · # défaut ∈ catalogue FormaPago · ensembles NON VIDES. Un claim absent échoue AUSSI # (traçabilité #6). # ============================================================================ ECF_RD2 = f"{D}/fiscal/ecf_dgii/README.md" try: _ecfp = load("fiscal/ecf_dgii/out/ecf_plan.json") _ecf_ev = _ecfp["emission_events"] if not _ecf_ev: raise ValueError("emission_events vide") ecf_updvals = {e["update_value"] for e in _ecf_ev} ecf_bases = {e["base_field"] for e in _ecf_ev} ecf_roleids = {e["role_id"] for e in _ecf_ev} ecf_rolenms = {e["erpnext_role_name"] for e in _ecf_ev} _ecf_fpd = _ecfp["forma_pago_defaut"] _ecf_fpcat = {str(f["code"]): f["label"] for f in _ecfp["formas_pago"]} except (OSError, KeyError, TypeError, ValueError, json.JSONDecodeError) as e: bad(f"Fiscal e-CF évènements · ecf_plan.json illisible/inattendu : {e}") _ecf_ev = None if _ecf_ev is not None: def _ecf_ai(s): # accent-insensible · minuscule · sans espaces return "".join(c for c in unicodedata.normalize("NFKD", str(s)) if unicodedata.category(c) != "Mn").lower().replace(" ", "") try: ecf_doc = re.sub(r"\s+", " ", open(ECF_RD2, encoding="utf-8").read()) except OSError as e: bad(f"Fiscal e-CF évènements · {ECF_RD2} illisible : {e}"); ecf_doc = None # (0) cohérence interne du plan : rôle UNIQUE + défaut ∈ catalogue FormaPago if len(ecf_roleids) != 1 or len(ecf_rolenms) != 1: bad(f"Fiscal e-CF évènements · plan INCOHÉRENT : rôle non unique sur les " f"évènements (ids={sorted(ecf_roleids)} noms={sorted(ecf_rolenms)}) — " f"le README promet un « rôle dédié » unique") _fp_code = str(_ecf_fpd.get("code")) if _fp_code not in _ecf_fpcat: bad(f"Fiscal e-CF évènements · forma_pago_defaut.code={_fp_code!r} ABSENT " f"du catalogue formas_pago {sorted(_ecf_fpcat)} (plan incohérent)") if ecf_doc is not None: # (1) ENSEMBLE des évènements émetteurs — L48 « seulement X et Y » m_bill = re.search(r"seulement\s+([^.]+?)\.", ecf_doc) want = {_ecf_ai(x) for x in ecf_updvals} if m_bill is None: bad(f"Fiscal e-CF évènements · {ECF_RD2} — énumération « seulement … » " f"INTROUVABLE (attendu {sorted(ecf_updvals)})") else: bill = {_ecf_ai(x) for x in re.split(r"\s+et\s+|,\s*", m_bill.group(1)) if x.strip()} if bill == want: good(f"Fiscal e-CF évènements · émetteurs README == ecf_plan " f"({sorted(ecf_updvals)})") else: bad(f"Fiscal e-CF évènements · émetteurs README {sorted(bill)} ≠ " f"ecf_plan {sorted(want)} (fantôme={sorted(bill-want)} " f"manquant={sorted(want-bill)}) → régénérer le README") # (2) ENSEMBLE exclu (brouillons) DISJOINT des émetteurs — anti-facture-brouillon m_excl = re.search(r"on ne facture pas un brouillon\s*\(([^)]+)\)", ecf_doc) if m_excl is None: bad(f"Fiscal e-CF évènements · {ECF_RD2} — clause « on ne facture pas un " f"brouillon (…) » INTROUVABLE (raison d'être #6 · traçabilité)") else: excl = {_ecf_ai(x) for x in re.split(r"[/,]", m_excl.group(1)) if x.strip()} clash = excl & want if clash: bad(f"Fiscal e-CF évènements · un état déclaré BROUILLON {sorted(clash)} " f"est ÉMETTEUR dans ecf_plan → FACTURE SUR BROUILLON (l'anti-pattern " f"que le module interdit · #6) OU README auto-contradictoire") else: good(f"Fiscal e-CF évènements · brouillons {sorted(excl)} ∩ émetteurs = ∅ " f"(jamais de facture sur brouillon)") # (3) ENSEMBLE des base_field — L50 « (montant_reservation, montant_contrat) » m_base = re.search(r"champ \*\*Currency réel\*\*[^(]*\(([^)]+)\)", ecf_doc) if m_base is None: bad(f"Fiscal e-CF évènements · {ECF_RD2} — énumération des base_field " f"INTROUVABLE (attendu {sorted(ecf_bases)})") else: docbases = set(re.findall(r"`([^`]+)`", m_base.group(1))) if docbases == ecf_bases: good(f"Fiscal e-CF évènements · base_field README == ecf_plan " f"({sorted(ecf_bases)})") else: bad(f"Fiscal e-CF évènements · base_field README {sorted(docbases)} ≠ " f"ecf_plan {sorted(ecf_bases)} (fantôme={sorted(docbases-ecf_bases)} " f"manquant={sorted(ecf_bases-docbases)})") # (4) rôle UNIQUE : backtické + nom ERPNext + DOUBLE-ANCRÉ portail compta (RBAC) _rid = next(iter(ecf_roleids)); _rnm = next(iter(ecf_rolenms)) if f"`{_rid}`" not in ecf_doc: bad(f"Fiscal e-CF évènements · rôle dédié `{_rid}` (== ecf_plan) NON cité " f"backtické dans le README") elif _rnm not in ecf_doc: bad(f"Fiscal e-CF évènements · nom ERPNext « {_rnm} » (== ecf_plan) ABSENT " f"du README") else: try: _rbac = load("rbac/rbac_50_roles.json")["roles"] _rrec = next((r for r in _rbac if r.get("id") == _rid), None) except (OSError, KeyError, TypeError, json.JSONDecodeError) as e: bad(f"Fiscal e-CF évènements · rbac_50_roles illisible : {e}"); _rrec = "ERR" if _rrec is None: bad(f"Fiscal e-CF évènements · rôle `{_rid}` (ecf_plan) INTROUVABLE dans " f"rbac_50_roles → rôle Compta fantôme") elif _rrec == "ERR": pass elif _rrec.get("portail") != "compta": bad(f"Fiscal e-CF évènements · rôle `{_rid}` portail={_rrec.get('portail')!r} " f"≠ « compta » (le README exige le portail compta)") elif _rrec.get("erpnext_role_name") != _rnm: bad(f"Fiscal e-CF évènements · nom ERPNext discordant ecf_plan « {_rnm} » " f"vs RBAC « {_rrec.get('erpnext_role_name')} »") else: good(f"Fiscal e-CF évènements · rôle unique `{_rid}` (« {_rnm} ») == " f"ecf_plan == RBAC (portail compta)") # (5) FormaPago défaut — L59 « = 3 (Tarjeta) » · Cardnet #10 m_fp = re.search(r"FormaPago défaut\s*=\s*(\d+)\s*\(([^)]+)\)", ecf_doc) _lbl = _ecf_fpcat.get(_fp_code, "") if m_fp is None: bad(f"Fiscal e-CF évènements · {ECF_RD2} — « FormaPago défaut = N (…) » " f"INTROUVABLE (attendu {_fp_code} / {_lbl})") elif m_fp.group(1) != _fp_code: bad(f"Fiscal e-CF évènements · FormaPago défaut README ={m_fp.group(1)} " f"MAIS ecf_plan dit ={_fp_code} ({_lbl}) → défaut périmé") elif _ecf_ai(m_fp.group(2)) not in _ecf_ai(_lbl): bad(f"Fiscal e-CF évènements · FormaPago défaut label README " f"« {m_fp.group(2)} » incohérent avec le catalogue « {_lbl} » (code {_fp_code})") elif "cardnet" not in _ecf_ai(_ecf_fpd.get("motif", "")): bad(f"Fiscal e-CF évènements · forma_pago_defaut.motif ne cite plus " f"« Cardnet » (#10) → justification du défaut évaporée") else: good(f"Fiscal e-CF évènements · FormaPago défaut = {_fp_code} " f"({_lbl}) · Cardnet #10 == ecf_plan") # ============================================================================ # CLAUDE.md §VPS · IDENTITÉ des IDENTIFIANTS D'INFRASTRUCTURE (IP + conteneurs # Docker) — même classe de « vert trompeur » que l'endpoint RunPod, côté serveur. # ---------------------------------------------------------------------------- # CLAUDE.md §VPS FIXE l'infrastructure de production du mandat : # • l'IP du VPS `153.75.250.214` # • les DEUX conteneurs Docker `erpnext-backend-1` / `erpnext-frontend-1` # Ces identifiants sont RECOPIÉS À LA MAIN dans quantité de docs — README.md, # le master prompt, AGENTS_EXISTING_ASSETS.md, la fiche `mobile` (backend cible de # l'API), RBAC_50_ROLES_SPEC.md (run-book d'import), tests/README.md (base URL # Playwright), ci/README.md (instance Gitea) — sans AUCUN ancrage vers CLAUDE.md. # Aucun gate ne couvrait ce lien : check_docs ne voit qu'un lien Markdown, # guard_constraints qu'un terme interdit, check_artifacts qu'un out/ — une IP ni un # nom de conteneur n'est aucun des trois. Or une MIGRATION serveur (nouvelle IP) ou # un RENOMMAGE du projet compose (`erpnext_*` → `oto_*`) est un évènement ops # COURANT : CLAUDE.md §VPS se met à jour, les ~10 copies se PÉRIMENT en silence → # un agent câblerait l'API mobile / les tests e2e / le hand-off d'import vers un # serveur MORT ou un conteneur FANTÔME (l'invention même que #6 interdit). Aucune # suite tests/ (FONCTIONS des modules, jamais la prose infra) ne l'attrape — classe # notée en mémoire (`agent-fiche-role-attrs-ungated`, surface infra ~ RunPod). # # On RE-DÉRIVE l'IP + l'ENSEMBLE des conteneurs de CLAUDE.md §VPS (jamais une liste # à la main · #6). Direction copie→source : sur TOUTE doc .md du mandat (hors # CLAUDE.md = la source, hors 05_activity_log/ + daily_reports/ = journaux # historiques figés, à ne pas réécrire), tout token IPv4 DOIT == l'IP canonique et # tout token conteneur `erpnext--1` DOIT ∈ l'ensemble canonique. Le corpus ne # porte QUE ces valeurs (aucune autre IPv4, aucun autre conteneur — vérifié) : un # écart = une copie périmée. Le write-path interdit `/var/www/html/static` relève # de guard_constraints (#interdits), non de ce gate d'identité. Garde # anti-évaporation : l'IP ET ≥1 conteneur canonique doivent rester cités quelque # part (sinon la surface d'identité du VPS a silencieusement disparu · #6). # ============================================================================ VPS_SRC = "CLAUDE.md" try: _vps_cm = open(VPS_SRC, encoding="utf-8").read() except OSError as e: bad(f"VPS infra · {VPS_SRC} illisible : {e}"); _vps_cm = None if _vps_cm is not None: _vps_sec = re.search(r"^## VPS\s*\n(.*?)(?=^## |\Z)", _vps_cm, re.S | re.M) if _vps_sec is None: bad(f"VPS infra · section « ## VPS » INTROUVABLE dans {VPS_SRC} → ancre " f"d'infra cassée, impossible de recomputer IP/conteneurs") else: _vps_body = _vps_sec.group(1) _ip_m = re.search(r"\bIP\s+(\d{1,3}(?:\.\d{1,3}){3})\b", _vps_body) _dk_m = re.search(r"Docker\s*\(([^)]+)\)", _vps_body) if _ip_m is None or _dk_m is None: bad(f"VPS infra · §VPS ne livre pas l'IP et/ou les conteneurs Docker " f"(IP={_ip_m and _ip_m.group(1)} · Docker={_dk_m and _dk_m.group(1)}) " f"→ format de §VPS modifié, recompute impossible") else: vps_ip = _ip_m.group(1) vps_containers = {c.strip() for c in _dk_m.group(1).split(",") if re.fullmatch(r"[a-z0-9]+-[a-z]+-1", c.strip())} # Enveloppe de forme DÉRIVÉE des stems de service canoniques # (backend/frontend), PAS du préfixe compose littéral `erpnext-` : un # RENOMMAGE du projet compose (`erpnext-backend-1` → `oto-backend-1`) # dans une copie DOIT être vu comme fantôme — l'ancrer sur `erpnext-` # le rendrait invisible (le token renommé ne matcherait plus). _stems = sorted({m.group(1) for c in vps_containers if (m := re.fullmatch(r"[a-z0-9]+-([a-z]+)-1", c))}) if not vps_containers or not _stems: bad(f"VPS infra · §VPS « Docker (…) » ne contient aucun conteneur de " f"forme `--1` (lu {_dk_m.group(1)!r}) → format modifié") else: _cont_shape = re.compile(r"[a-z0-9]+-(?:" + "|".join(_stems) + r")-1") _ip_shape = re.compile(r"\b\d{1,3}(?:\.\d{1,3}){3}\b") vps_files = [f for f in subprocess.run( ["git", "ls-files", "*.md"], capture_output=True, text=True).stdout.split() if f != "CLAUDE.md" and not f.startswith("05_activity_log/") and not f.startswith(f"{D}/daily_reports/")] ip_seen = cont_seen = 0 for vf in vps_files: try: vtxt = open(vf, encoding="utf-8").read() except OSError as e: bad(f"VPS infra · {vf} illisible : {e}"); continue # (a) IPv4 : toute IP citée == IP canonique du §VPS ips = set(_ip_shape.findall(vtxt)) if ips: ip_seen += 1 stale_ip = sorted(ips - {vps_ip}) if stale_ip: bad(f"VPS infra · {vf} cite l'IP périmée {stale_ip} MAIS " f"CLAUDE.md §VPS dit `{vps_ip}` → serveur migré / IP " f"mal recopiée (mettre la doc à jour)") else: good(f"VPS infra · {vf} · IP `{vps_ip}` == §VPS") # (b) conteneurs Docker : tout `erpnext--1` ∈ ensemble canonique conts = set(_cont_shape.findall(vtxt)) if conts: cont_seen += 1 phantom = sorted(conts - vps_containers) if phantom: bad(f"VPS infra · {vf} cite le(s) conteneur(s) fantôme(s) " f"{phantom} MAIS §VPS dit {sorted(vps_containers)} → " f"projet compose renommé / nom mal recopié") else: good(f"VPS infra · {vf} · conteneur(s) {sorted(conts)} " f"⊆ §VPS {sorted(vps_containers)}") if ip_seen == 0: bad(f"VPS infra · AUCUNE doc du mandat ne cite l'IP `{vps_ip}` — la " f"surface d'identité du VPS s'est évaporée (régression #6)") if cont_seen == 0: bad(f"VPS infra · AUCUNE doc ne cite de conteneur Docker " f"`erpnext--1` — surface d'identité conteneur évaporée (#6)") # ============================================================================ # Legal · CONFOTUR — IDENTITÉ des champs DÉRIVÉS du WORKFLOW (`dossier_vente` / `estado`) # ---------------------------------------------------------------------------- # La §« Cœur du livrable : cross-cohérence » du README confotur affirme que DEUX # champs du DocType sont DÉRIVÉS du pipeline vente, jamais réécrits en dur : # • `dossier_vente` (Link) : cible = `workflow.document_type` (= `OTO Dossier # Vente`) — README:35-36 · NOMME la valeur en clair entre backticks ; # • `estado` (Select) : options = les états `confotur_*` du **workflow vente** # — README:33-34. # Source faisant autorité : `crm/workflow_vente/workflow_vente_spec.json` — l'INPUT # que le builder confotur LIT (déclaré `workflow_source` dans le MANIFEST), d'où il # tire `document_type` et les `update_value` d'états commençant par « confotur ». # Les blocs CONFOTUR amont gatent le COMPTE de champs/rôles, la cross-cohérence des # PERMISSIONS et l'ancrage des ENTITÉS (`entite_porteuse`) — mais AUCUN ne touche # `dossier_vente`/`estado`. Piège #6 : RENOMMER le DocType porteur côté workflow # (`OTO Dossier Vente` → `OTO Dossier de Vente`) ou AJOUTER/RENOMMER un état # CONFOTUR régénère l'artefact confotur (check_artifacts reste VERT — l'artefact se # reconstruit depuis le spec workflow) MAIS laisse la PROSE README « (= `OTO Dossier # Vente`) » périmée EN SILENCE → l'agent ONAPI/Legal lirait une cible de Link / un # menu d'états que le pipeline ne porte plus. Aucune suite tests/ (FONCTIONS de # génération) n'attrape ce « vert trompeur » de la prose. Même classe EXACTE que # l'ancrage `entite_porteuse` du MÊME README à CLAUDE.md §Entités. # GATE : (0) traçabilité — MANIFEST `workflow_source` cite bien le spec workflow ; # (a) cross-artefact — `dossier_vente.options` (Link) == workflow `document_type` ; # (b) prose — la valeur README « (= `…`) » == workflow `document_type` ; (c) # cross-artefact — `estado.options` == update_value des états `confotur_*` du # workflow (ORDRE du pipeline) ; (c') toute option `estado` porte le préfixe # confotur (le README l'affirme) ; (d) prose — le bullet `estado` DÉCLARE la # dérivation (`confotur_*` + workflow vente). Un claim absent échoue AUSSI (#6). # ============================================================================ CONF_RD4 = f"{D}/legal/confotur/README.md" CONF_WF_SRC = "crm/workflow_vente/workflow_vente_spec.json" try: conf_dt_wd = load("legal/confotur/out/doctype_confotur_application.json") conf_man_wd = load("legal/confotur/out/MANIFEST.json") wf_spec_wd = load(CONF_WF_SRC) wf_doctype = _nfc(str(wf_spec_wd["document_type"]).strip()) wf_confotur = [_nfc(str(s["update_value"]).strip()) for s in wf_spec_wd["states"] if str(s.get("update_value", "")).startswith("confotur")] man_wf_src = _nfc(str(conf_man_wd.get("workflow_source", ""))) except (OSError, KeyError, ValueError, TypeError) as e: bad(f"Confotur champs dérivés · artefact/spec workflow illisible/inattendu : {e}") conf_dt_wd = None if conf_dt_wd is not None: _fields = conf_dt_wd.get("fields", []) dv_art = [f for f in _fields if f.get("fieldname") == "dossier_vente"] es_art = [f for f in _fields if f.get("fieldname") == "estado"] conf_rd4 = read_norm(CONF_RD4) # (0) TRAÇABILITÉ — le MANIFEST DÉCLARE dériver du workflow spec (source de vérité) if CONF_WF_SRC not in man_wf_src: bad(f"Confotur champs dérivés · (0) MANIFEST `workflow_source` = {man_wf_src!r} " f"ne cite PAS {CONF_WF_SRC} → dérivation non déclarée (ancrage cassé)") else: good(f"Confotur champs dérivés · (0) MANIFEST DÉCLARE dériver de {CONF_WF_SRC}") if not wf_confotur: bad(f"Confotur champs dérivés · aucun état `confotur_*` dans {CONF_WF_SRC} → le " f"menu `estado` n'aurait aucune source (workflow incohérent)") # ---- dossier_vente (Link) ---- if len(dv_art) != 1: bad(f"Confotur champs dérivés · champ `dossier_vente` non-singleton dans l'artefact " f"({len(dv_art)}) → cible du Link indéterminable") else: dv = dv_art[0] dv_opt = _nfc(str(dv.get("options", "")).strip()) if dv.get("fieldtype") != "Link": bad(f"Confotur champs dérivés · `dossier_vente` fieldtype " f"{dv.get('fieldtype')!r} ≠ Link → le champ n'est plus un Link") # (a) CROSS-ARTEFACT — cible du Link == document_type du workflow if dv_opt == wf_doctype: good(f"Confotur champs dérivés · (a) `dossier_vente` Link → « {dv_opt} » == " f"workflow.document_type") else: bad(f"Confotur champs dérivés · (a) `dossier_vente` Link → « {dv_opt} » ≠ " f"workflow.document_type « {wf_doctype} » → régénérer le DocType") # (b) PROSE README — la valeur « (= `…`) » == document_type du workflow if conf_rd4 is not None: m = re.search(r"`dossier_vente`.{0,120}?`workflow\.document_type`" r"\s*\(=\s*`([^`]+)`\)", conf_rd4) if m is None: bad(f"Confotur champs dérivés · (b) bullet `dossier_vente` (cible = " f"`workflow.document_type` (= `…`)) INTROUVABLE dans {CONF_RD4} " f"(claim absent = régression #6)") else: got_dv = _nfc(m.group(1).strip()) if got_dv == wf_doctype: good(f"Confotur champs dérivés · (b) README `dossier_vente` = " f"« {got_dv} » == workflow.document_type") else: bad(f"Confotur champs dérivés · (b) README dit `dossier_vente` = " f"« {got_dv} » MAIS workflow.document_type = « {wf_doctype} » " f"→ prose périmée (régénérer le README)") # ---- estado (Select) ---- if len(es_art) != 1: bad(f"Confotur champs dérivés · champ `estado` non-singleton dans l'artefact " f"({len(es_art)}) → menu d'états indéterminable") else: es = es_art[0] es_opts = [_nfc(o.strip()) for o in str(es.get("options", "")).split("\n") if o.strip()] if es.get("fieldtype") != "Select": bad(f"Confotur champs dérivés · `estado` fieldtype {es.get('fieldtype')!r} " f"≠ Select → le champ n'est plus un Select") # (c) CROSS-ARTEFACT — options == états confotur_* du workflow (ordre pipeline) if es_opts == wf_confotur: good(f"Confotur champs dérivés · (c) `estado` options {es_opts} == états " f"confotur_* du workflow (ordre pipeline)") else: bad(f"Confotur champs dérivés · (c) `estado` options {es_opts} ≠ états " f"confotur_* du workflow {wf_confotur} → menu périmé (régénérer)") # (c') toute option `estado` porte le préfixe confotur (le README l'affirme) _bad_pref = [o for o in es_opts if not o.startswith("confotur")] if _bad_pref: bad(f"Confotur champs dérivés · (c') option(s) `estado` HORS préfixe " f"confotur_* {_bad_pref} → contredit « options = les états `confotur_*` »") elif es_opts: good(f"Confotur champs dérivés · (c') les {len(es_opts)} options `estado` " f"portent toutes le préfixe confotur_*") # (d) PROSE README — le bullet `estado` DÉCLARE la dérivation (confotur_* + workflow) if conf_rd4 is not None: m = re.search(r"`estado`\*{0,2}\s*\(Select\).{0,200}?`confotur_\*`" r".{0,80}?workflow vente", conf_rd4) if m is None: bad(f"Confotur champs dérivés · (d) bullet `estado` (options = les états " f"`confotur_*` du workflow vente) INTROUVABLE dans {CONF_RD4} " f"(claim absent = régression #6)") else: good(f"Confotur champs dérivés · (d) README `estado` DÉCLARE dériver des " f"états confotur_* du workflow vente") # ============================================================================ # CRM · Dossier Vente · IDENTITÉ du COMPTE D'INVARIANTS de `_validate` — 5ᵉ # instance de la classe « ledger d'invariants » (après Chat OTOIA · SEO · Fiscal # e-CF · Legal CONFOTUR), appliquée au générateur du DocType porteur. # ---------------------------------------------------------------------------- # Le générateur doctype_dossier_vente_gen.py applique un COMPTE d'invariants de # cross-cohérence workflow ↔ DocType dont la source d'autorité n'est PAS un # artefact JSON (invisible à check_artifacts qui byte-gate les out/*.json) mais le # LEDGER NUMÉROTÉ de `_validate` : les marqueurs `# N ·` (1..12) qui ouvrent chaque # contrôle appliqué. Le nombre « 12 » est transcrit À LA MAIN à SEPT endroits # qu'AUCUN gate ne couvrait : QUATRE chaînes du .py (docstring module, docstring # `_validate`, message de succès CLI « … schéma + 12 invariants verts. », aide du # sous-parseur `validate`) + TROIS du README (bloc de commande « schéma + 12 # invariants », titre de section « ## Les 12 invariants », prose de score « 12 # invariants de cross-cohérence »). Ajouter un contrôle `# 13 ·` à `_validate` sans # toucher les chaînes ⇒ le README ET les self-reports du .py mentent en silence # pendant que la CI applique 13 contrôles — « vert trompeur » (#6) que ni les # suites tests/ (FONCTIONS de validation, jamais la prose) ni le byte-gate # d'artefact (aveugle au .py source) n'attrapent. # PARTICULARITÉ vs Fiscal : ledger PROPRE 1..N (le schéma est compté À PART — # phrasé « schéma + N invariants » — donc PAS de schéma-#1 non marqué, pas de split # a/b) ; on garde la détection de doublon nu comme filet. On RECOMPUTE le ledger # depuis le SEUL registre-source (les `# N ·` de `_validate`) et on exige : # (a) ledger CONTIGU 1..N (lacune OU doublon nu = vrai défaut de registre, mordu) ; # (b) TOUTE mention « N invariants » du .py == |ledger| ; (c) le compte annoncé du # titre README « ## Les N invariants » == |ledger| ; (d) TOUTE mention « N # invariants » du README == |ledger| ; (e) l'ÉNUMÉRATION numérotée de la section # (« 1. … 2. … N. ») elle-même contiguë 1..N (item ni sauté ni dupliqué). Mention # ou registre absent = échec (disparition d'un chiffre = régression de traçabilité # #6). # ============================================================================ DVV_GEN = f"{D}/crm/dossier_vente/doctype_dossier_vente_gen.py" DVV_RD = f"{D}/crm/dossier_vente/README.md" try: dvv_src = open(DVV_GEN, encoding="utf-8").read() except OSError as e: bad(f"Dossier Vente invariants · générateur {DVV_GEN} illisible : {e}"); dvv_src = None if dvv_src is not None: _mfn = re.search(r"def _validate\b", dvv_src) if _mfn is None: bad(f"Dossier Vente invariants · fonction `_validate` INTROUVABLE dans {DVV_GEN} " "(registre-source du ledger absent → impossible de re-dériver)") else: _bs = _mfn.end() _mn = re.search(r"\ndef ", dvv_src[_bs:]) _body = dvv_src[_bs: _bs + _mn.start()] if _mn else dvv_src[_bs:] _pairs = re.findall(r"#\s*(\d+)([a-z]?)\s*·", _body) _dup_bare = sorted({n for n in {p[0] for p in _pairs} if [s for num, s in _pairs if num == n and s == ""] and sum(1 for num, _ in _pairs if num == n) > 1}) _top = sorted({int(n) for n, _ in _pairs}) if not _top: bad(f"Dossier Vente invariants · aucun marqueur `# N ·` dans `_validate` de " f"{DVV_GEN} (ledger vide → impossible de re-dériver)") elif _dup_bare: bad(f"Dossier Vente invariants · marqueur(s) `# N ·` dupliqué(s) sans suffixe " f"a/b dans `_validate` : {_dup_bare} — vrai doublon de registre") else: dvv_ninv = len(_top) if _top != list(range(1, dvv_ninv + 1)): bad(f"Dossier Vente invariants · ledger `_validate` NON contigu : {_top} " f"(attendu 1..{dvv_ninv}) — lacune/doublon dans les marqueurs `# N ·`") else: good(f"Dossier Vente invariants · ledger `_validate` contigu 1..{dvv_ninv} " f"({dvv_ninv} marqueurs `# N ·` · schéma compté à part)") # (b) self-reports du .py « N invariants ». dvv_py_rep = sorted({int(x) for x in re.findall(r"(\d+)\s+invariants", dvv_src)}) if not dvv_py_rep: bad(f"Dossier Vente invariants · aucune mention « N invariants » dans {DVV_GEN} " f"(self-report absent → attendu {dvv_ninv})") elif dvv_py_rep == [dvv_ninv]: good(f"Dossier Vente invariants · {DVV_GEN} — self-report(s) {dvv_py_rep} " f"== ledger ({dvv_ninv})") else: bad(f"Dossier Vente invariants · {DVV_GEN} — self-report(s) « N invariants » " f"{dvv_py_rep} MAIS ledger en compte {dvv_ninv} " "(une chaîne du générateur désynchronisée du registre)") # README : titre de section + toute mention + énumération numérotée. try: dvv_rd_raw = open(DVV_RD, encoding="utf-8").read() except OSError as e: bad(f"Dossier Vente invariants · {DVV_RD} illisible : {e}"); dvv_rd_raw = None if dvv_rd_raw is not None: _mh = re.search(r"##\s+Les\s+(\d+)\s+invariants", dvv_rd_raw) if _mh is None: bad(f"Dossier Vente invariants · {DVV_RD} — section « ## Les N invariants » " f"INTROUVABLE (attendu {dvv_ninv})") else: # (c) compte annoncé du titre. if int(_mh.group(1)) == dvv_ninv: good(f"Dossier Vente invariants · {DVV_RD} — annonce ({_mh.group(1)}) " f"== ledger ({dvv_ninv})") else: bad(f"Dossier Vente invariants · {DVV_RD} — annonce ({_mh.group(1)}) MAIS " f"ledger en compte {dvv_ninv} → régénérer le README") # (d) toute mention « N invariants » du README == ledger. dvv_rd_rep = sorted({int(x) for x in re.findall(r"(\d+)\s+invariants", dvv_rd_raw)}) if dvv_rd_rep == [dvv_ninv]: good(f"Dossier Vente invariants · {DVV_RD} — mention(s) {dvv_rd_rep} " f"== ledger (aucune dérive)") else: bad(f"Dossier Vente invariants · {DVV_RD} — mention(s) « N invariants » " f"{dvv_rd_rep} MAIS ledger en compte {dvv_ninv} → régénérer le README") # (e) énumération numérotée de la section == contiguë 1..N. _msec = re.search(r"##\s+Les\s+\d+\s+invariants.*?(?=\n##\s|\Z)", dvv_rd_raw, re.S) dvv_enum = ([int(x) for x in re.findall(r"(? 1}) _top = sorted({int(n) for n, _ in _pairs}) if not _top: bad(f"Audit 5D invariants · aucun marqueur `# N ·` dans `_validate` de " f"{A5I_GEN} (ledger vide → impossible de re-dériver)") elif _dup_bare: bad(f"Audit 5D invariants · marqueur(s) `# N ·` dupliqué(s) sans suffixe " f"a/b dans `_validate` : {_dup_bare} — vrai doublon de registre") else: a5i_ninv = len(_top) if _top != list(range(1, a5i_ninv + 1)): bad(f"Audit 5D invariants · ledger `_validate` NON contigu : {_top} " f"(attendu 1..{a5i_ninv}) — lacune/doublon dans les marqueurs `# N ·`") else: good(f"Audit 5D invariants · ledger `_validate` contigu 1..{a5i_ninv} " f"({a5i_ninv} marqueurs `# N ·` · schéma compté à part)") # (b) self-reports du .py « N invariants » (le distinct « N contrôles » # est exclu nativement par l'ancre sur le mot « invariants »). a5i_py_rep = sorted({int(x) for x in re.findall(r"(\d+)\s+invariants", a5i_src)}) if not a5i_py_rep: bad(f"Audit 5D invariants · aucune mention « N invariants » dans {A5I_GEN} " f"(self-report absent → attendu {a5i_ninv})") elif a5i_py_rep == [a5i_ninv]: good(f"Audit 5D invariants · {A5I_GEN} — self-report(s) {a5i_py_rep} " f"== ledger ({a5i_ninv})") else: bad(f"Audit 5D invariants · {A5I_GEN} — self-report(s) « N invariants » " f"{a5i_py_rep} MAIS ledger en compte {a5i_ninv} " "(une chaîne du générateur désynchronisée du registre)") # (d) toute mention « N invariants » du README == ledger. try: a5i_rd_raw = open(A5I_RD, encoding="utf-8").read() except OSError as e: bad(f"Audit 5D invariants · {A5I_RD} illisible : {e}"); a5i_rd_raw = None if a5i_rd_raw is not None: a5i_rd_rep = sorted({int(x) for x in re.findall(r"(\d+)\s+invariants", a5i_rd_raw)}) if not a5i_rd_rep: bad(f"Audit 5D invariants · aucune mention « N invariants » dans {A5I_RD} " f"(self-report README absent → attendu {a5i_ninv})") elif a5i_rd_rep == [a5i_ninv]: good(f"Audit 5D invariants · {A5I_RD} — mention(s) {a5i_rd_rep} " f"== ledger (aucune dérive)") else: bad(f"Audit 5D invariants · {A5I_RD} — mention(s) « N invariants » " f"{a5i_rd_rep} MAIS ledger en compte {a5i_ninv} → régénérer le README") # ============================================================================ # CRM · Commissions · IDENTITÉ du COMPTE D'INVARIANTS de `_validate` — 7ᵉ # instance de la classe « ledger d'invariants » (après Chat OTOIA · SEO · Fiscal # e-CF · Legal CONFOTUR · CRM Dossier Vente · QA Audit 5D), appliquée au # générateur du barème de commissions vendeurs (roadmap ligne 51). # ---------------------------------------------------------------------------- # Le générateur commissions_gen.py applique un COMPTE d'invariants de cross- # cohérence barème ↔ workflow ↔ DocType ↔ RBAC (les 4 contrats) dont la source # d'autorité n'est PAS un artefact JSON (invisible à check_artifacts qui byte-gate # les out/*.json) mais le LEDGER NUMÉROTÉ de `_validate` : les marqueurs `# N ·` # (1..10) qui ouvrent chaque contrôle. Le nombre « 10 » est transcrit À LA MAIN à # CINQ endroits qu'AUCUN gate ne couvrait : DEUX chaînes du .py (message de succès # CLI « … schéma + 10 invariants verts. », aide du sous-parseur `validate`) + TROIS # du README (bloc de commande « schéma + 10 invariants », titre de section « ## Les # 10 invariants », prose de score « 10 invariants de cross-cohérence »). Ajouter un # contrôle `# 11 ·` à `_validate` sans toucher les chaînes ⇒ le README ET les # self-reports du .py mentent en silence pendant que la CI applique 11 contrôles — # « vert trompeur » (#6) que ni les suites tests/ (FONCTIONS de validation, jamais # la prose) ni le byte-gate d'artefact (aveugle au .py source) n'attrapent. # PARTICULARITÉ (≈ Dossier Vente) : ledger PROPRE 1..N compté À PART du schéma # (phrasé « schéma + N invariants » — pas de schéma-#1 non marqué, pas de split # a/b ; on garde la détection de doublon nu comme filet). Le module porte AUSSI un # SECOND nombre — « 25 tests » (le compte de la suite unittest, déjà gaté par # ailleurs), présent dans le .py absent mais dans le README : l'ancre sur le mot # « invariants » l'exclut nativement (« 25 tests » ≠ mot « invariants »), donc # AUCUN faux positif. Comme Dossier Vente, ce README A un titre « ## Les N # invariants » ET une énumération numérotée du ledger → on gate (a)(b)(c)(d)(e). # On RECOMPUTE le ledger depuis le SEUL registre-source (les `# N ·` de `_validate`) # et on exige : (a) ledger CONTIGU 1..N (lacune OU doublon nu = vrai défaut de # registre, mordu) ; (b) TOUTE mention « N invariants » du .py == |ledger| ; (c) le # compte annoncé du titre README « ## Les N invariants » == |ledger| ; (d) TOUTE # mention « N invariants » du README == |ledger| ; (e) l'ÉNUMÉRATION numérotée de # la section (« 1. … 2. … N. ») elle-même contiguë 1..N (item ni sauté ni dupliqué). # Mention ou registre absent = échec (disparition d'un chiffre = régression #6). # ============================================================================ CMI_GEN = f"{D}/crm/commissions/commissions_gen.py" CMI_RD = f"{D}/crm/commissions/README.md" try: cmi_src = open(CMI_GEN, encoding="utf-8").read() except OSError as e: bad(f"Commissions invariants · générateur {CMI_GEN} illisible : {e}"); cmi_src = None if cmi_src is not None: _mfn = re.search(r"def _validate\b", cmi_src) if _mfn is None: bad(f"Commissions invariants · fonction `_validate` INTROUVABLE dans {CMI_GEN} " "(registre-source du ledger absent → impossible de re-dériver)") else: _bs = _mfn.end() _mn = re.search(r"\ndef ", cmi_src[_bs:]) _body = cmi_src[_bs: _bs + _mn.start()] if _mn else cmi_src[_bs:] _pairs = re.findall(r"#\s*(\d+)([a-z]?)\s*·", _body) _dup_bare = sorted({n for n in {p[0] for p in _pairs} if [s for num, s in _pairs if num == n and s == ""] and sum(1 for num, _ in _pairs if num == n) > 1}) _top = sorted({int(n) for n, _ in _pairs}) if not _top: bad(f"Commissions invariants · aucun marqueur `# N ·` dans `_validate` de " f"{CMI_GEN} (ledger vide → impossible de re-dériver)") elif _dup_bare: bad(f"Commissions invariants · marqueur(s) `# N ·` dupliqué(s) sans suffixe " f"a/b dans `_validate` : {_dup_bare} — vrai doublon de registre") else: cmi_ninv = len(_top) if _top != list(range(1, cmi_ninv + 1)): bad(f"Commissions invariants · ledger `_validate` NON contigu : {_top} " f"(attendu 1..{cmi_ninv}) — lacune/doublon dans les marqueurs `# N ·`") else: good(f"Commissions invariants · ledger `_validate` contigu 1..{cmi_ninv} " f"({cmi_ninv} marqueurs `# N ·` · schéma compté à part)") # (b) self-reports du .py « N invariants » (le distinct « N tests » est # exclu nativement par l'ancre sur le mot « invariants »). cmi_py_rep = sorted({int(x) for x in re.findall(r"(\d+)\s+invariants", cmi_src)}) if not cmi_py_rep: bad(f"Commissions invariants · aucune mention « N invariants » dans {CMI_GEN} " f"(self-report absent → attendu {cmi_ninv})") elif cmi_py_rep == [cmi_ninv]: good(f"Commissions invariants · {CMI_GEN} — self-report(s) {cmi_py_rep} " f"== ledger ({cmi_ninv})") else: bad(f"Commissions invariants · {CMI_GEN} — self-report(s) « N invariants » " f"{cmi_py_rep} MAIS ledger en compte {cmi_ninv} " "(une chaîne du générateur désynchronisée du registre)") # README : titre de section + toute mention + énumération numérotée. try: cmi_rd_raw = open(CMI_RD, encoding="utf-8").read() except OSError as e: bad(f"Commissions invariants · {CMI_RD} illisible : {e}"); cmi_rd_raw = None if cmi_rd_raw is not None: _mh = re.search(r"##\s+Les\s+(\d+)\s+invariants", cmi_rd_raw) if _mh is None: bad(f"Commissions invariants · {CMI_RD} — section « ## Les N invariants » " f"INTROUVABLE (attendu {cmi_ninv})") else: # (c) compte annoncé du titre. if int(_mh.group(1)) == cmi_ninv: good(f"Commissions invariants · {CMI_RD} — annonce ({_mh.group(1)}) " f"== ledger ({cmi_ninv})") else: bad(f"Commissions invariants · {CMI_RD} — annonce ({_mh.group(1)}) MAIS " f"ledger en compte {cmi_ninv} → régénérer le README") # (d) toute mention « N invariants » du README == ledger. cmi_rd_rep = sorted({int(x) for x in re.findall(r"(\d+)\s+invariants", cmi_rd_raw)}) if cmi_rd_rep == [cmi_ninv]: good(f"Commissions invariants · {CMI_RD} — mention(s) {cmi_rd_rep} " f"== ledger (aucune dérive)") else: bad(f"Commissions invariants · {CMI_RD} — mention(s) « N invariants » " f"{cmi_rd_rep} MAIS ledger en compte {cmi_ninv} → régénérer le README") # (e) énumération numérotée de la section == contiguë 1..N. _msec = re.search(r"##\s+Les\s+\d+\s+invariants.*?(?=\n##\s|\Z)", cmi_rd_raw, re.S) cmi_enum = ([int(x) for x in re.findall(r"(? 1}) _top = sorted({int(n) for n, _ in _pairs}) if not _top: bad(f"Mobile invariants · aucun marqueur `# Invariant N ·` dans `_validate_bundle` " f"de {MBI_GEN} (ledger vide → impossible de re-dériver)") elif _dup_bare: bad(f"Mobile invariants · marqueur(s) `# Invariant N ·` dupliqué(s) sans suffixe " f"a/b dans `_validate_bundle` : {_dup_bare} — vrai doublon de registre") else: mbi_ninv = len(_top) if _top != list(range(1, mbi_ninv + 1)): bad(f"Mobile invariants · ledger `_validate_bundle` NON contigu : {_top} " f"(attendu 1..{mbi_ninv}) — lacune/doublon dans les marqueurs `# Invariant N ·`") else: good(f"Mobile invariants · ledger `_validate_bundle` contigu 1..{mbi_ninv} " f"({mbi_ninv} marqueurs `# Invariant N ·` · schéma compté à part)") # (b) self-report(s) du .py « N invariants ». mbi_py_rep = sorted({int(x) for x in re.findall(r"(\d+)\s+invariants", mbi_src)}) if not mbi_py_rep: bad(f"Mobile invariants · aucune mention « N invariants » dans {MBI_GEN} " f"(self-report absent → attendu {mbi_ninv})") elif mbi_py_rep == [mbi_ninv]: good(f"Mobile invariants · {MBI_GEN} — self-report(s) {mbi_py_rep} " f"== ledger ({mbi_ninv})") else: bad(f"Mobile invariants · {MBI_GEN} — self-report(s) « N invariants » " f"{mbi_py_rep} MAIS ledger en compte {mbi_ninv} " "(une chaîne du générateur désynchronisée du registre)") # (b') self-report du fichier de tests « N invariants » (docstring). try: mbi_ts_raw = open(MBI_TS, encoding="utf-8").read() except OSError as e: bad(f"Mobile invariants · {MBI_TS} illisible : {e}"); mbi_ts_raw = None if mbi_ts_raw is not None: mbi_ts_rep = sorted({int(x) for x in re.findall(r"(\d+)\s+invariants", mbi_ts_raw)}) if not mbi_ts_rep: bad(f"Mobile invariants · aucune mention « N invariants » dans {MBI_TS} " f"(self-report tests absent → attendu {mbi_ninv})") elif mbi_ts_rep == [mbi_ninv]: good(f"Mobile invariants · {MBI_TS} — docstring {mbi_ts_rep} " f"== ledger ({mbi_ninv})") else: bad(f"Mobile invariants · {MBI_TS} — docstring « N invariants » " f"{mbi_ts_rep} MAIS ledger en compte {mbi_ninv} → régénérer la docstring") # (d) toute mention « N invariants » du README == ledger. try: mbi_rd_raw = open(MBI_RD, encoding="utf-8").read() except OSError as e: bad(f"Mobile invariants · {MBI_RD} illisible : {e}"); mbi_rd_raw = None if mbi_rd_raw is not None: mbi_rd_rep = sorted({int(x) for x in re.findall(r"(\d+)\s+invariants", mbi_rd_raw)}) if not mbi_rd_rep: bad(f"Mobile invariants · aucune mention « N invariants » dans {MBI_RD} " f"(self-report README absent → attendu {mbi_ninv})") elif mbi_rd_rep == [mbi_ninv]: good(f"Mobile invariants · {MBI_RD} — mention(s) {mbi_rd_rep} " f"== ledger (aucune dérive)") else: bad(f"Mobile invariants · {MBI_RD} — mention(s) « N invariants » " f"{mbi_rd_rep} MAIS ledger en compte {mbi_ninv} → régénérer le README") # ============================================================================ # CRM · workflow_vente · IDENTITÉ du COMPTE D'INVARIANTS de `_validate_bundle` — # 9ᵉ instance de la classe « ledger d'invariants » (après Chat OTOIA · SEO · # Fiscal e-CF · Legal CONFOTUR · CRM Dossier Vente · QA Audit 5D · CRM # Commissions · Mobile), appliquée au générateur du workflow de vente Frappe # (roadmap Sprint 4 l.50 « lead → visite → devis → réservation → contrat → # CONFOTUR »). # ---------------------------------------------------------------------------- # Le générateur workflow_vente_gen.py applique un COMPTE d'invariants métier de # graphe (unicité des états · transitions vers états déclarés · monotonie # doc_status · unicité (état,action) · atteignabilité depuis Lead · terminal de # succès · séparation des pouvoirs #6 · maîtres = états/actions · cohérence des # comptes du manifeste) dont la source d'autorité n'est PAS un artefact JSON # (invisible à check_artifacts qui byte-gate les out/*.json) mais le LEDGER # NUMÉROTÉ de `_validate_bundle` : les marqueurs `# Invariant N ·` (1..9) qui # ouvrent chaque contrôle. Le nombre « 9 » est transcrit À LA MAIN à QUATRE # endroits qu'AUCUN gate ne couvrait : UNE chaîne du .py (message de succès CLI # « … schéma + 9 invariants verts. ») + TROIS du README (bloc de commande # « schéma + 9 invariants métier », prose de vérification « 9 invariants de # graphe », prose d'auto-score « 9 invariants de graphe »). Ajouter un contrôle # `# Invariant 10 ·` à `_validate_bundle` sans toucher ces chaînes ⇒ le README ET # le self-report CLI mentent en silence pendant que la CI applique 10 contrôles — # « vert trompeur » (#6) que ni les suites tests/ (FONCTIONS de validation, jamais # la prose) ni le byte-gate d'artefact (aveugle au .py source) n'attrapent. # Prouvé ungate par mutation avant ajout (mémoire verify-uncovered-before-gating) : # « 9 invariants » → « 10 » dans le README laissait check_readme_claims exit 0. # PARTICULARITÉS vs les 8 blocs précédents : # · le REGISTRE-source utilise le style `# Invariant N ·` + la fonction # `_validate_bundle` (comme Mobile — les blocs ancrés sur le style nu `# N ·` / # `def _validate\b` ne le voient PAS) ; # · ledger PROPRE 1..N compté À PART du schéma (phrasé « schéma + N invariants ») — # pas de schéma-#1 non marqué, pas de split a/b ; on garde le filet doublon nu ; # · le module porte un SECOND nombre distinct — « 25 tests » (la suite unittest, # déjà gatée par ailleurs) présent dans le README : l'ancre sur le mot # « invariants » l'exclut nativement (« 25 tests » ≠ mot « invariants ») ⇒ aucun # faux positif ; # · ce README N'A NI titre « ## Les N invariants » (son titre « ## Invariants # vérifiés par le CLI » ne porte AUCUN chiffre) NI énumération 1:1 avec le # ledger — sa liste « Invariants vérifiés par le CLI » compte le SCHÉMA en item 1 # puis FUSIONNE les invariants 8 & 9 dans son item 9 (« Maîtres … ; comptes du # manifeste cohérents ») : elle N'EST PAS le ledger et la gater comme contiguë # == ledger serait un faux positif fragile (présentation éditoriale, #6). On # gate donc UNIQUEMENT (a)(b)(d), comme Audit 5D. # On RECOMPUTE le ledger depuis le SEUL registre-source (les `# Invariant N ·` de # `_validate_bundle`) et on exige : (a) ledger CONTIGU 1..N (lacune OU doublon nu = # vrai défaut de registre, mordu) ; (b) TOUTE mention « N invariants » du .py == # |ledger| ; (d) TOUTE mention « N invariants » du README == |ledger|. Mention ou # registre absent = échec (disparition d'un chiffre = régression de traçabilité #6). # ============================================================================ WVI_GEN = f"{D}/crm/workflow_vente/workflow_vente_gen.py" WVI_RD = f"{D}/crm/workflow_vente/README.md" try: wvi_src = open(WVI_GEN, encoding="utf-8").read() except OSError as e: bad(f"workflow_vente invariants · générateur {WVI_GEN} illisible : {e}"); wvi_src = None if wvi_src is not None: _mfn = re.search(r"def _validate_bundle\b", wvi_src) if _mfn is None: bad(f"workflow_vente invariants · fonction `_validate_bundle` INTROUVABLE dans " f"{WVI_GEN} (registre-source du ledger absent → impossible de re-dériver)") else: _bs = _mfn.end() _mn = re.search(r"\ndef ", wvi_src[_bs:]) _body = wvi_src[_bs: _bs + _mn.start()] if _mn else wvi_src[_bs:] _pairs = re.findall(r"#\s*Invariant\s+(\d+)([a-z]?)\s*·", _body) _dup_bare = sorted({n for n in {p[0] for p in _pairs} if [s for num, s in _pairs if num == n and s == ""] and sum(1 for num, _ in _pairs if num == n) > 1}) _top = sorted({int(n) for n, _ in _pairs}) if not _top: bad(f"workflow_vente invariants · aucun marqueur `# Invariant N ·` dans " f"`_validate_bundle` de {WVI_GEN} (ledger vide → impossible de re-dériver)") elif _dup_bare: bad(f"workflow_vente invariants · marqueur(s) `# Invariant N ·` dupliqué(s) sans " f"suffixe a/b dans `_validate_bundle` : {_dup_bare} — vrai doublon de registre") else: wvi_ninv = len(_top) if _top != list(range(1, wvi_ninv + 1)): bad(f"workflow_vente invariants · ledger `_validate_bundle` NON contigu : {_top} " f"(attendu 1..{wvi_ninv}) — lacune/doublon dans les marqueurs `# Invariant N ·`") else: good(f"workflow_vente invariants · ledger `_validate_bundle` contigu 1..{wvi_ninv} " f"({wvi_ninv} marqueurs `# Invariant N ·` · schéma compté à part)") # (b) self-report(s) du .py « N invariants » (le distinct « N tests » est # exclu nativement par l'ancre sur le mot « invariants »). wvi_py_rep = sorted({int(x) for x in re.findall(r"(\d+)\s+invariants", wvi_src)}) if not wvi_py_rep: bad(f"workflow_vente invariants · aucune mention « N invariants » dans {WVI_GEN} " f"(self-report absent → attendu {wvi_ninv})") elif wvi_py_rep == [wvi_ninv]: good(f"workflow_vente invariants · {WVI_GEN} — self-report(s) {wvi_py_rep} " f"== ledger ({wvi_ninv})") else: bad(f"workflow_vente invariants · {WVI_GEN} — self-report(s) « N invariants » " f"{wvi_py_rep} MAIS ledger en compte {wvi_ninv} " "(une chaîne du générateur désynchronisée du registre)") # (d) toute mention « N invariants » du README == ledger. try: wvi_rd_raw = open(WVI_RD, encoding="utf-8").read() except OSError as e: bad(f"workflow_vente invariants · {WVI_RD} illisible : {e}"); wvi_rd_raw = None if wvi_rd_raw is not None: wvi_rd_rep = sorted({int(x) for x in re.findall(r"(\d+)\s+invariants", wvi_rd_raw)}) if not wvi_rd_rep: bad(f"workflow_vente invariants · aucune mention « N invariants » dans {WVI_RD} " f"(self-report README absent → attendu {wvi_ninv})") elif wvi_rd_rep == [wvi_ninv]: good(f"workflow_vente invariants · {WVI_RD} — mention(s) {wvi_rd_rep} " f"== ledger (aucune dérive)") else: bad(f"workflow_vente invariants · {WVI_RD} — mention(s) « N invariants » " f"{wvi_rd_rep} MAIS ledger en compte {wvi_ninv} → régénérer le README") # ============================================================================ # DevOps deploy_runbook + QA acceptance · IDENTITÉ du COMPTE d'INVARIANTS de # `validate_bundle` — DEUXIÈME famille de « ledger d'invariants », distincte des # 8 générateurs déjà gatés (Chat OTOIA · SEO · Fiscal · CONFOTUR · Dossier Vente · # Audit 5D · Commissions · Mobile). # ---------------------------------------------------------------------------- # La série « ledger » précédente a été déclarée close à 8 générateurs — MAIS sur # le SEUL patron `# N ·` / « N invariants » / `_validate`(`_validate_bundle`). # DEUX générateurs portent le MÊME nombre magique dans un DIALECTE différent, resté # HORS de tout gate : marqueurs `# INVn ·` (préfixe `INV`) dans `validate_bundle`, # et phrasé « N **familles d'**invariants ». C'est exactement le piège consigné # (« CLOSED après N était FAUX — un style de marqueur différent avait glissé ») : # • devops/deploy_runbook — ledger `# INV1..INV11` dans `validate_bundle`, compte # « 11 familles d'invariants » recopié à TROIS surfaces (générateur l.272 · README # l.73 · suite tests l.4) ; # • qa/acceptance — ledger `# INV1..INV11` dans `validate_bundle`, compte à DEUX # surfaces (README l.46 « 11 invariants » · suite tests l.4 « 11 familles # d'invariants »). Le .py acceptance ne porte PAS de compte numéroté → non exigé. # Ajouter un contrôle `# INV12 ·` à `validate_bundle` sans toucher ces chaînes ⇒ # README + self-reports mentent en silence pendant que la CI applique 12 contrôles — # « vert trompeur » (#6) que ni les suites tests/ (FONCTIONS de validation, jamais # la prose) ni le byte-gate d'artefact (aveugle au .py source) n'attrapent. Ledger # PROPRE 1..N compté À PART du schéma (phrasé « schéma + N invariants »). Les # mentions NON numérotées de « invariants » (docstrings « invariants de … », « Un # invariant refuse … ») ne sont pas ancrées par un chiffre → aucun faux positif. On # RECOMPUTE le ledger depuis le SEUL registre-source (les `# INVn ·` de # `validate_bundle`) et on exige : (a) ledger CONTIGU 1..N (lacune/doublon = défaut # de registre) ; (b) CHAQUE surface déclarée porte ≥1 mention « N [familles d'] # invariants » toutes == |ledger|. Surface sans mention = self-report évaporé (échec # · régression de traçabilité #6). Les daily_reports/activity_log (snapshots datés, # ex. acceptance « 10 » d'avant la croissance 10→11) NE sont PAS des surfaces vives # et ne sont volontairement pas ancrés. # ============================================================================ DRA_INV_RE = re.compile(r"(\d+)\s+(?:familles\s+d['’ʼ]\s*)?invariants") DRA_MODS = [ ("DevOps · deploy_runbook", f"{D}/devops/deploy_runbook/deploy_runbook_gen.py", [("générateur", f"{D}/devops/deploy_runbook/deploy_runbook_gen.py"), ("README", f"{D}/devops/deploy_runbook/README.md"), ("suite tests", f"{D}/devops/deploy_runbook/tests/test_deploy_runbook.py")]), ("QA · acceptance", f"{D}/qa/acceptance/acceptance_gen.py", [("README", f"{D}/qa/acceptance/README.md"), ("suite tests", f"{D}/qa/acceptance/tests/test_acceptance.py")]), ] for _lbl, _gen, _surfaces in DRA_MODS: try: _src = open(_gen, encoding="utf-8").read() except OSError as e: bad(f"{_lbl} invariants · générateur {_gen} illisible : {e}"); continue _mfn = re.search(r"def validate_bundle\b", _src) if _mfn is None: bad(f"{_lbl} invariants · fonction `validate_bundle` INTROUVABLE dans {_gen} " "(registre-source du ledger absent → impossible de re-dériver)"); continue _bs = _mfn.end() _mn = re.search(r"\ndef ", _src[_bs:]) _body = _src[_bs: _bs + _mn.start()] if _mn else _src[_bs:] _pairs = re.findall(r"#\s*INV\s*(\d+)([a-z]?)\s*·", _body) _dup_bare = sorted({n for n in {p[0] for p in _pairs} if [s for num, s in _pairs if num == n and s == ""] and sum(1 for num, _ in _pairs if num == n) > 1}) _top = sorted({int(n) for n, _ in _pairs}) if not _top: bad(f"{_lbl} invariants · aucun marqueur `# INVn ·` dans `validate_bundle` de " f"{_gen} (ledger vide → impossible de re-dériver)"); continue if _dup_bare: bad(f"{_lbl} invariants · marqueur(s) `# INVn ·` dupliqué(s) dans " f"`validate_bundle` : {_dup_bare} — vrai doublon de registre"); continue _ninv = len(_top) if _top != list(range(1, _ninv + 1)): bad(f"{_lbl} invariants · ledger `validate_bundle` NON contigu : {_top} " f"(attendu 1..{_ninv}) — lacune/doublon dans les marqueurs `# INVn ·`") else: good(f"{_lbl} invariants · ledger `validate_bundle` contigu 1..{_ninv} " f"({_ninv} marqueurs `# INVn ·` · schéma compté à part)") for _sname, _spath in _surfaces: try: _sraw = open(_spath, encoding="utf-8").read() except OSError as e: bad(f"{_lbl} invariants · {_sname} {_spath} illisible : {e}"); continue _rep = sorted({int(x) for x in DRA_INV_RE.findall(_sraw)}) if not _rep: bad(f"{_lbl} invariants · aucune mention « N (familles d')invariants » dans " f"{_spath} ({_sname}) — self-report évaporé (attendu {_ninv} · régression #6)") elif _rep == [_ninv]: good(f"{_lbl} invariants · {_sname} — mention(s) {_rep} == ledger ({_ninv})") else: bad(f"{_lbl} invariants · {_sname} ({_spath}) — mention(s) « N invariants » " f"{_rep} MAIS ledger `validate_bundle` en compte {_ninv} → resynchroniser") # ============================================================================ # Traçabilité roadmap · le `roadmap_ref` VERBATIM des artefacts — la CITATION # (n° de ligne + « libellé bullet ») ANCRÉE sur la ligne RÉELLE de la roadmap, # pas seulement déclarée. # ---------------------------------------------------------------------------- # Quatre modules (demo/scenarios · frontend/chat_otoia · frontend/portails · # mobile/app_config) publient dans out/MANIFEST.json (byte-gaté par # check_artifacts) un `roadmap_ref` de la forme : # « 04_roadmap/ROADMAP_8_WEEKS_OR_LESS.md · Sprint N l.A[-B] « » » # Ce libellé est une TRANSCRIPTION VERBATIM du bullet de la roadmap à la ligne # citée — mais AUCUN gate ne vérifiait que la ligne A[-B] porte RÉELLEMENT ce # texte : check_artifacts ne prouve QUE spec==MANIFEST (byte-for-byte, la MÊME # citation recopiée des deux côtés — l'un ne contredit jamais l'autre) ; # l'ancrage roadmap de qa/acceptance (INV11) ne touche QUE les `roadmap_line` # NUMÉRIQUES de SON artefact, jamais le libellé cité par les autres specs ; # l'ancrage Expo (mobile) ne vérifie que la présence de « roadmap » + « Expo N » # dans `expo_sdk_source`, pas la citation complète. → REORDONNER la roadmap (les # n° de ligne glissent) ou REFORMULER un bullet laisse ces 4 citations pointer # une ligne qui dit AUTRE CHOSE : « vert trompeur » de traçabilité (#6) # qu'aucune suite tests/ (FONCTIONS de résolution, jamais l'ancre) n'attrape. # Prouvé par mutation : reformuler le bullet l.63 laissait les 6 gates VERTS # pendant que chat_otoia citait encore « … Chat OTOIA embedded … » à l.63. # On ANCRE : pour chaque MANIFEST portant un `roadmap_ref` citant (l.A[-B] «…»), # (1) le fichier cité == la roadmap canonique ; (2) le libellé, NORMALISÉ # (markdown `**`/`- ` retirés, espaces compressés, NFC), est SOUS-CHAÎNE des # lignes A..B réelles ; (3) le spec du module porte le MÊME `roadmap_ref` # (identité spec↔artefact publié). Les 4 modules connus sont EXIGÉS présents # (anti-évaporation #6) ; tout AUTRE MANIFEST à citation est ancré en bonus. # ============================================================================ import glob as _glob ROADMAP_REL = "04_roadmap/ROADMAP_8_WEEKS_OR_LESS.md" RMREF_EXPECTED = {"demo/scenarios", "frontend/chat_otoia", "frontend/portails", "mobile/app_config"} def _rmn(s): # normalise une ligne markdown pour comparaison de libellé s = unicodedata.normalize("NFC", str(s)) s = re.sub(r"^\s*[-*+]\s+", "", s) # marqueur de liste en tête de ligne s = s.replace("*", "") # emphase **gras** / *ital* return re.sub(r"\s+", " ", s).strip() try: _rm_lines = open(ROADMAP_REL, encoding="utf-8").read().splitlines() except OSError as e: bad(f"Traçabilité roadmap · {ROADMAP_REL} illisible : {e}"); _rm_lines = None if _rm_lines is not None: _man_paths = sorted(set(_glob.glob(f"{D}/*/*/out/MANIFEST.json") + _glob.glob(f"{D}/*/out/MANIFEST.json"))) _rm_seen = set() for _mp in _man_paths: _mod = _mp[len(D) + 1:].rsplit("/out/", 1)[0] # ex. « demo/scenarios » try: _ref = json.load(open(_mp, encoding="utf-8")).get("roadmap_ref", "") or "" except (OSError, ValueError) as e: bad(f"Traçabilité roadmap · {_mp} illisible : {e}"); continue _mln = re.search(r"l\.(\d+)(?:-(\d+))?", _ref) _mq = re.search(r"«\s*(.+?)\s*»", _ref) if not (_mln and _mq): continue # pas une citation ligne+libellé → hors périmètre de ce gate _rm_seen.add(_mod) _path = _ref.split(" · ", 1)[0].strip() _a = int(_mln.group(1)); _b = int(_mln.group(2) or _mln.group(1)) _quote = _mq.group(1) # (1) fichier cité == roadmap canonique if _path != ROADMAP_REL: bad(f"Traçabilité roadmap · {_mod} — cite « {_path} » ≠ roadmap " f"canonique « {ROADMAP_REL} »"); continue # bornes de ligne valides (renumérotation hors-plage attrapée ici) if not (1 <= _a <= _b <= len(_rm_lines)): bad(f"Traçabilité roadmap · {_mod} — plage l.{_a}-{_b} hors roadmap " f"(1..{len(_rm_lines)})"); continue _hay = " ".join(_rmn(_rm_lines[_i - 1]) for _i in range(_a, _b + 1)) _rng = f"l.{_a}" + ("" if _a == _b else f"-{_b}") # (2) libellé cité == sous-chaîne des lignes réelles A..B if _rmn(_quote) not in _hay: bad(f"Traçabilité roadmap · {_mod} — libellé cité « {_quote} » ABSENT " f"des lignes {_rng} de la roadmap (qui disent « {_hay} ») → " f"REFORMULATION/RENUMÉROTATION : aligner le roadmap_ref (ou la roadmap)") continue # (3) spec du module porte le MÊME roadmap_ref (identité spec↔artefact publié) _specs = _glob.glob(f"{D}/{_mod}/*_spec.json") _spec_ok = True if len(_specs) == 1: try: _sref = json.load(open(_specs[0], encoding="utf-8")).get("roadmap_ref") except (OSError, ValueError): _sref = None if _sref is not None and _sref != _ref: _spec_ok = False bad(f"Traçabilité roadmap · {_mod} — spec roadmap_ref « {_sref} » ≠ " f"MANIFEST « {_ref} » (artefact interne incohérent)") if _spec_ok: good(f"Traçabilité roadmap · {_mod} — citation {_rng} « {_quote} » " f"== roadmap") # anti-évaporation : les 4 citations connues doivent TOUTES subsister _rm_missing = sorted(RMREF_EXPECTED - _rm_seen) if _rm_missing: bad(f"Traçabilité roadmap · citation roadmap_ref ÉVAPORÉE pour {_rm_missing} " f"(module attendu sans (l.N «…») dans out/MANIFEST.json · #6)") elif _rm_seen: good(f"Traçabilité roadmap · {len(_rm_seen)} citation(s) ancrée(s), " f"4 modules attendus présents") 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"