#!/usr/bin/env bash # ============================================================================ # check_readme_claims.sh · OTO Enterprise OS DTP # ---------------------------------------------------------------------------- # Gate d'INTÉGRITÉ des chiffres du README (Sprint 8 buffer · CLAUDE.md #6 — # « Zéro invention de chiffres · toujours vérifier sources archives »). # # Le README.md est le POINT D'ENTRÉE du mandat. Sa section « État courant # (sourcé) » affiche des chiffres et déclare, mot pour mot : # « Chaque chiffre ci-dessous est sourcé vers un artefact commité # (anti-invention #6) ; ce README n'introduit aucune donnée nouvelle. » # Cette promesse n'était garantie par AUCUN gate. `check_docs.sh` ne valide que # les liens (cible existe) — pas la VALEUR des nombres. Résultat : quand un # module + son job CI sont ajoutés (21→22 modules, 21→22 suites) ou qu'une # promesse change de statut (14 in_repo + 1 out_of_scope → 15 in_repo), les # chiffres du README se PÉRIMENT en silence tout en restant « sourcés » vers un # artefact qui, lui, dit autre chose. Un README qui se contredit avec sa propre # source est un « vert trompeur » de la même classe que la matrice périmée # (« demo 18→21 ») ou l'orphan_tests_dirs (INV4) — appliqué à la doc d'entrée. # # Ce gate RECOMPUTE chaque chiffre depuis l'artefact cité (jamais une liste à la # main · #6) et exige l'égalité avec ce qui est ÉCRIT dans les docs d'entrée : # README.md : # • Qualité 4Big N/M modules gated à K/100, verdict → qa/audit_4big totals # • Régression N suites gated → qa/regression plan # • Recette N promesses (X sprint + Y métriques), Z in_repo, verdict # → qa/acceptance matrix # • Agents « 13 agents » (×2 : nav + titre) → 03_agents/*/AGENT.md # 03_agents/qa/AGENT.md (fiche QA · « Verdict agrégé courant ») : # • N suites · M tests · M passés · E échec · E erreur · verdict # → qa/regression run # 03_agents/erpnext_backend/AGENT.md (« source unique ») : # • N tests · M suites · verdict (matrice de régression du repo) # → qa/regression run # 05_deliverables_mvp/qa/audit_4big/README.md (« Résultat courant ») : # • N/M modules à K/100, verdict (l'auditeur compte les modules mais figeait # son PROPRE total à la main : 17→22) → qa/audit_4big totals # 03_agents/*/AGENT.md (tables de livrables · colonne « Tests ») : # • chaque cellule « Tests » par suite gatée → plan.suites[path] # (+ count_tests pour le self_module qa/regression, exclu de la matrice) # 03_agents/{crm,erpnext_backend}/AGENT.md (agrégats EN PROSE) : # • « Total CRM N (a+b+c) » · « RBAC N (…) » · « e-CF N » — total ET multiset # des composants recomputés (une compensation entre suites laisserait la # table juste et la prose fausse) → plan.suites[path] # frontend/portails/README.md + 03_agents/frontend_console/AGENT.md : # • comptes PAR WORKSPACE (cartes / liens / rôles) transcrits À LA MAIN dans # DEUX docs (table du README + prose « OTO Ventes 4 cartes/11 liens/12 # rôles ») + total « N Workspace natifs » → portails MANIFEST # (tableau `workspaces`, byte-gaté par check_artifacts) # seo/README.md + 03_agents/seo/AGENT.md (comptes de mots-clés) : # • total + ventilation « 258 mots-clés (fr=87 · en=87 · es=84) » transcrits À # LA MAIN dans DEUX docs → seo MANIFEST.counts # (keywords_total + keywords_per_lang, recomptés de projets_master, byte-gatés) # crm/workflow_vente/README.md + crm/dossier_vente/README.md (pipeline vente) : # • « N états · M transitions » (titre du README workflow) + « N états du # pipeline » (README DocType porteur, même compte dérivé du même graphe) # transcrits À LA MAIN dans DEUX docs → workflow_vente # MANIFEST.counts (states + transitions, recomptés du spec pipeline, byte-gatés) # rbac/{roleprofile_gen,apply_plan,userperm_gen}/README.md (comptes 3 volets) : # • table « Nb rôles » PAR profil + total bijectif → roleprofile MANIFEST # • ligne agrégée « 6 étapes · 50 rôles / 116 DocPerm / 28 UP / 6 Role # Profile · 4 DocType custom + 5 Company + 4 rôles » → apply_plan MANIFEST # • ventilation « 28 entite · 16 groupe · 2 own · 4 equipe » → userperm MANIFEST # (les 3 MANIFEST recalculés du contrat rbac_50_roles.json, byte-gatés) # mobile/app_config/README.md (ligne récap de l'app Expo) : # • quadruplet « 5 onglets · 44 rôles couverts · 3 langues · 13 identifiants # a_confirmer » transcrit À LA MAIN → app_config # MANIFEST.counts (onglets/roles_couverts/langues/identifiants_a_confirmer, # recomptés de rbac_50_roles + seo_spec + a_confirmer, byte-gatés) # legal/confotur/README.md + 03_agents/onapi_legal/AGENT.md (DocType CONFOTUR) : # • quintuplet « 18 champs (14 de donnée) · 4 sections · 3 rôles · 2 évènements # de dépôt » transcrit À LA MAIN dans DEUX docs → confotur # MANIFEST.counts (fields/data_fields/sections/roles/depot_events, recomptés # de confotur_spec.json, byte-gatés) # frontend/chat_otoia/README.md + 03_agents/frontend_console/AGENT.md (montage # du Chat OTOIA par portail) : # • triplet « N `Custom Block` · M configs runtime · P portails » (tous = 5 # aujourd'hui — piège aveugle) transcrit À LA MAIN dans DEUX docs → chat_otoia # MANIFEST.counts (custom_blocks/mounts/portails, recomptés du contrat RBAC, # byte-gatés) + cohérence croisée == |MANIFEST.portails| (bijection 1 block ⇔ # 1 mount ⇔ 1 portail) # qa/acceptance/README.md + 03_agents/qa/AGENT.md (module de recette) : # • couple « N livrables de sprint + M métriques succès MVP » transcrit À LA MAIN # dans DEUX docs de module (le bloc racine ne gate que le README d'entrée) # → recompté depuis la matrice acceptance (kind sprint_deliverable / mvp_metric) # + cohérence croisée == MANIFEST.counts (compteur ⇔ matrice résumée) # fiscal/ecf_dgii/README.md (×3) + 03_agents/erpnext_backend/AGENT.md (format e-NCF) : # • le CONTRAT DE FORMAT de l'e-NCF « E + tipoeCF(2) + secuencia(10) = 13 » # (préfixe · longueur segment tipo · longueur segment secuencia · longueur # totale) transcrit À LA MAIN dans QUATRE endroits de prose → recompté depuis # le SEUL bloc `e_ncf` d'ecf_plan.json (prefix/longueur/regex/structure, byte- # gatés) + cohérences croisées (1+tipo+seq == longueur · |regex digits| == # tipo+seq — mordent un artefact internement incohérent) # Un claim absent échoue AUSSI (la dérive de formulation qui ferait disparaître # un chiffre est elle-même une régression de traçabilité). # # Dépendances : bash, git, python3 (stdlib pur · CLAUDE.md #2 — runner sans pip). # ============================================================================ set -uo pipefail # shellcheck source=ci/lib.sh source "$(dirname "${BASH_SOURCE[0]}")/lib.sh" || exit 3 cd_repo_root # racine du dépôt, ou ROUGE honnête si hors arbre git (cf. lib.sh) echo "== Intégrité des chiffres du README (valeur == artefact cité · #6) ==" python3 - <<'PY' import json, re, subprocess, sys, unicodedata D = "05_deliverables_mvp" FAIL = 0 def bad(m): global FAIL; FAIL = 1; print(f" \033[31m✗\033[0m {m}") def good(m): print(f" \033[32m✓\033[0m {m}") try: readme = open("README.md", encoding="utf-8").read() except OSError as e: print(f" \033[31m✗\033[0m README.md illisible : {e}"); sys.exit(1) # Texte normalisé (espaces/newlines compressés) pour les claims multi-lignes. norm = re.sub(r"\s+", " ", readme) def load(p): with open(f"{D}/{p}", encoding="utf-8") as f: return json.load(f) # --- Vérités-terrain recalculées depuis les artefacts (jamais saisies) ------- q = load("qa/audit_4big/out/quality_report.json")["totals"] qv = load("qa/audit_4big/out/quality_report.json")["verdict"] reg = load("qa/regression/out/regression_plan.json")["totals"] acc = load("qa/acceptance/out/acceptance_matrix.json") rows = acc["matrix"] n_sprint = sum(1 for r in rows if r["kind"] == "sprint_deliverable") n_metric = sum(1 for r in rows if r["kind"] == "mvp_metric") n_inrepo = sum(1 for r in rows if r["status"] == "in_repo") acc_verdict = acc["verdict"] agents = subprocess.run(["git", "ls-files", "03_agents/*/AGENT.md"], capture_output=True, text=True).stdout.split() n_agents = len(agents) # --- Chaque claim : (libellé, regex sur `norm`, groupes attendus) ------------ # Le n-uplet capturé (str) est comparé à l'attendu (str). Un claim non trouvé # échoue (regex is None) — la disparition d'un chiffre est une régression. checks = [ ("Qualité 4Big · modules gated", r"\*\*(\d+)/(\d+) modules gated à (\d+)/100\*\*", (str(q["pass"]), str(q["modules"]), str(q["min_score"])), f"pass={q['pass']} total={q['modules']} min_score={q['min_score']}"), ("Régression · suites gated", r"\*\*(\d+) suites gated\*\*", (str(reg["suites"]),), f"suites={reg['suites']}"), ("Recette · promesses (sprint + métriques)", r"\*\*(\d+) promesses\*\* \((\d+) livrables sprint \+ (\d+) métriques", (str(len(rows)), str(n_sprint), str(n_metric)), f"total={len(rows)} sprint={n_sprint} metric={n_metric}"), ("Recette · promesses in_repo", r"\*\*(\d+) `in_repo`", (str(n_inrepo),), f"in_repo={n_inrepo}"), ("Agents · fiche nav", r"Fiches des \*\*(\d+) agents\*\*", (str(n_agents),), f"AGENT.md={n_agents}"), ("Agents · titre section", r"## Les (\d+) agents", (str(n_agents),), f"AGENT.md={n_agents}"), ] for label, pat, expected, src in checks: m = re.search(pat, norm) if m is None: bad(f"{label} — claim INTROUVABLE dans README (attendu {src}) · motif=/{pat}/") continue got = m.groups() if got == expected: good(f"{label} — README {'/'.join(got)} == artefact ({src})") else: bad(f"{label} — README dit {'/'.join(got)} MAIS artefact dit {'/'.join(expected)} ({src}) → régénérer README") # --- Verdicts textuels cités dans le README (PASS / true) -------------------- if re.search(r"verdict `PASS`", norm): if qv == "PASS": good(f"Qualité 4Big · verdict README `PASS` == artefact ({qv})") else: bad(f"Qualité 4Big · README dit verdict `PASS` MAIS artefact dit {qv}") else: bad("Qualité 4Big · verdict `PASS` non cité dans README (attendu)") if re.search(r"verdict `true`", norm): if acc_verdict is True: good("Recette · verdict README `true` == artefact (True)") else: bad(f"Recette · README dit verdict `true` MAIS artefact dit {acc_verdict}") else: bad("Recette · verdict `true` non cité dans README (attendu)") # ============================================================================ # Fiche QA (03_agents/qa/AGENT.md) · même classe de « vert trompeur » — # ---------------------------------------------------------------------------- # Sa section « Verdict agrégé courant » affiche un compte agrégé (« N suites # gated · M tests · M passés · 0 échec · 0 erreur → verdict PASS ») en déclarant # « jamais compté à la main » et en citant qa/regression/out/regression_run.json. # Ce compte se PÉRIME pourtant en silence quand une suite est ajoutée : la fiche # affichait « 21 suites · 534 tests » alors que le run agrégé faisant autorité en # disait déjà davantage (matrice recomptée ci-dessous, jamais figée ici). Comme pour # le README, aucun gate ne comparait la VALEUR au run. On la RECALCULE ici depuis # le compte agrégé faisant autorité (regression_run.json, commité · déterministe). # ============================================================================ QA = "03_agents/qa/AGENT.md" try: qa_txt = re.sub(r"\s+", " ", open(QA, encoding="utf-8").read()) except OSError as e: bad(f"Fiche QA · {QA} illisible : {e}"); qa_txt = "" run = load("qa/regression/out/regression_run.json") suites = run["suites"] agg = ( str(len(suites)), # suites str(sum(s["ran"] for s in suites)), # tests exécutés str(sum(s["passed"] for s in suites)), # passés str(sum(s["failures"] for s in suites)), # échecs str(sum(s["errors"] for s in suites)), # erreurs run["verdict"], # verdict ) qa_pat = (r"\*\*(\d+) suites gated · (\d+) tests · (\d+) passés · " r"(\d+) échec · (\d+) erreur → verdict `(PASS|FAIL)`") qa_src = (f"suites={agg[0]} tests={agg[1]} passés={agg[2]} " f"échec={agg[3]} erreur={agg[4]} verdict={agg[5]}") mqa = re.search(qa_pat, qa_txt) if mqa is None: bad(f"Fiche QA · verdict agrégé — claim INTROUVABLE dans {QA} (attendu {qa_src})") elif mqa.groups() == agg: good(f"Fiche QA · verdict agrégé — {QA} {'/'.join(mqa.groups())} == regression_run ({qa_src})") else: bad(f"Fiche QA · {QA} dit {'/'.join(mqa.groups())} MAIS regression_run dit " f"{'/'.join(agg)} ({qa_src}) → régénérer la fiche") # ============================================================================ # Fiche ERPNext Backend (03_agents/erpnext_backend/AGENT.md) · même classe. # ---------------------------------------------------------------------------- # Sa ligne « source unique » situait les tests backend dans le repo par un # compte agrégé (« repo : 560 tests au total ») saisi à la main. Ce nombre s'est # PÉRIMÉ en silence (matrice 534 → 551 → 558) : 560 ne correspondait plus ni à la # matrice (558) ni au repo-wide (584 méthodes test_*). Aucun gate ne le couvrait. # On le RECALCULE depuis la même matrice faisant autorité (regression_run) et on # exige tests + suites + verdict — comme la fiche QA. # ============================================================================ BE = "03_agents/erpnext_backend/AGENT.md" try: be_txt = re.sub(r"\s+", " ", open(BE, encoding="utf-8").read()) except OSError as e: bad(f"Fiche Backend · {BE} illisible : {e}"); be_txt = "" be_exp = ( str(sum(s["ran"] for s in suites)), # tests exécutés (matrice) str(len(suites)), # suites run["verdict"], # verdict ) be_pat = (r"matrice\s+de régression du repo : \*\*(\d+) tests · (\d+) suites\*\* · " r"verdict `(PASS|FAIL)`") be_src = f"tests={be_exp[0]} suites={be_exp[1]} verdict={be_exp[2]}" mbe = re.search(be_pat, be_txt) if mbe is None: bad(f"Fiche Backend · matrice repo — claim INTROUVABLE dans {BE} (attendu {be_src})") elif mbe.groups() == be_exp: good(f"Fiche Backend · matrice repo — {BE} {'/'.join(mbe.groups())} == regression_run ({be_src})") else: bad(f"Fiche Backend · {BE} dit {'/'.join(mbe.groups())} MAIS regression_run dit " f"{'/'.join(be_exp)} ({be_src}) → régénérer la fiche") # ============================================================================ # README de l'auditeur 4Big (05_deliverables_mvp/qa/audit_4big/README.md) · # section « Résultat courant » — même classe de « vert trompeur ». # ---------------------------------------------------------------------------- # Ironie parfaite : le module qui COMPTE les modules affichait son propre total # SAISI À LA MAIN (« 17/17 modules à 100/100 »). Ce compte s'est PÉRIMÉ en silence # quand des modules ont été ajoutés (17 → 22) — le README de l'auditeur contredisait # son propre artefact `quality_report.json` (totals.modules = 22). Le gate racine # ne couvrait QUE le README d'entrée, pas ce README de module. On RECALCULE ici # depuis le même artefact faisant autorité (totals, déjà chargé dans `q`) et on # exige l'égalité avec ce que la section « Résultat courant » ÉCRIT. # ============================================================================ A4 = f"{D}/qa/audit_4big/README.md" try: a4_txt = re.sub(r"\s+", " ", open(A4, encoding="utf-8").read()) except OSError as e: bad(f"README audit_4big · {A4} illisible : {e}"); a4_txt = "" a4_exp = (qv, str(q["pass"]), str(q["modules"]), str(q["min_score"])) a4_pat = (r"\*\*Verdict `(PASS|FAIL)`\*\* [—-] \*\*(\d+)/(\d+)\*\* " r"modules à \*\*(\d+)/100\*\*") a4_src = f"verdict={a4_exp[0]} pass={a4_exp[1]} modules={a4_exp[2]} min_score={a4_exp[3]}" ma4 = re.search(a4_pat, a4_txt) if ma4 is None: bad(f"README audit_4big · « Résultat courant » — claim INTROUVABLE dans {A4} " f"(attendu {a4_src})") elif ma4.groups() == a4_exp: good(f"README audit_4big · Résultat courant — {A4} " f"{'/'.join(ma4.groups())} == quality_report ({a4_src})") else: bad(f"README audit_4big · {A4} dit {'/'.join(ma4.groups())} MAIS quality_report " f"dit {'/'.join(a4_exp)} ({a4_src}) → régénérer le README") # ============================================================================ # README de l'auditeur 4Big · BARÈME de notation (N critères + poids par critère) # — même classe de « vert trompeur », au grain du référentiel de scoring. # ---------------------------------------------------------------------------- # Le barème 4Big est DATA-DERIVED : quality_report.json.criteria (byte-gaté par # check_artifacts, RECOMPUTÉ du référentiel à chaque build) porte la liste des # critères et leur poids. Le README les RETRANSCRIT À LA MAIN à trois endroits : # • la prose « Note chaque module sur N critères 4Big déterministes », # • la table « Critère | Poids | Fait vérifié » (une ligne id→poids par critère), # • l'archétype « generator (N critères) » (generator applique TOUS les critères). # Ajouter/retirer un critère ou changer un poids ferait dériver ces trois surfaces # en silence pendant que l'artefact dit autre chose — aucune suite tests/ (qui teste # des FONCTIONS, pas la prose du README) ne l'attrape. On RECOMPUTE ici chaque nombre # depuis `criteria` (zéro duplication du barème du générateur · #6) et on exige # l'égalité avec ce que la doc ÉCRIT. Cohérences croisées : Σ poids == 100 (base de # renormalisation) ET tout module d'archétype `generator` porte EXACTEMENT # len(criteria) checks — c.-à-d. « generator = tous les critères » (mord une dérive # du modèle qui rendrait le « generator (N critères) » du README faux). Un claim # absent échoue AUSSI (disparition = régression de traçabilité). # Bloc (6) : la carte de RENORMALISATION PAR ARCHÉTYPE (« data_room sans HANDOFF · # parser sans CONTRAT ni HANDOFF · contract sans CLI ni HANDOFF ») est elle aussi # data-derived — critères applicables d'un archétype = {check.criterion} de ses # modules ⇒ exclus = barème − applicables. Recomputés par archétype, comparés à la # parenthèse du README (exclusions des 3 archétypes non-generator, jamais gatées # avant), + uniformité « 1 archétype → 1 jeu ». # ============================================================================ try: qr_crit = load("qa/audit_4big/out/quality_report.json") criteria = qr_crit["criteria"] except (OSError, KeyError, ValueError) as e: bad(f"README audit_4big · quality_report illisible : {e}"); criteria = None if criteria is not None and a4_txt: n_crit = len(criteria) # 1) prose « N critères 4Big déterministes » mc = re.search(r"sur (\d+) critères 4Big déterministes", a4_txt) if mc is None: bad(f"README audit_4big · prose « N critères 4Big » — claim INTROUVABLE dans " f"{A4} (attendu {n_crit})") elif mc.group(1) == str(n_crit): good(f"README audit_4big · prose critères — {mc.group(1)} == criteria ({n_crit})") else: bad(f"README audit_4big · prose critères — {A4} dit {mc.group(1)} MAIS criteria " f"en compte {n_crit} → régénérer le README") # 2) table « Critère | Poids | … » — une ligne id→poids par critère for c in criteria: cid, w = c["id"], str(c["weight"]) mw = re.search(rf"\| `{re.escape(cid)}` \| (\d+) \|", a4_txt) if mw is None: bad(f"README audit_4big · critère {cid} — ligne INTROUVABLE dans {A4} " f"(attendu poids={w})") elif mw.group(1) == w: good(f"README audit_4big · critère {cid} — poids {mw.group(1)} == criteria ({w})") else: bad(f"README audit_4big · critère {cid} — {A4} dit poids {mw.group(1)} MAIS " f"criteria dit {w} → régénérer le README") # 3) archétype « generator (N critères) » — generator applique TOUS les critères mg = re.search(r"`generator` \((\d+) critères\)", a4_txt) if mg is None: bad(f"README audit_4big · archétype « generator (N critères) » — claim " f"INTROUVABLE dans {A4} (attendu {n_crit})") elif mg.group(1) == str(n_crit): good(f"README audit_4big · generator — {mg.group(1)} critères == criteria ({n_crit})") else: bad(f"README audit_4big · generator — {A4} dit {mg.group(1)} MAIS l'archétype " f"generator applique les {n_crit} critères → régénérer le README") # 4) cohérence croisée : Σ poids == 100 (base de renormalisation) w_sum = sum(c["weight"] for c in criteria) if w_sum == 100: good(f"README audit_4big · Σ poids critères == {w_sum} (base 100 cohérente)") else: bad(f"README audit_4big · Σ poids critères == {w_sum} ≠ 100 — barème incohérent " f"avec la renormalisation documentée") # 5) cohérence croisée : tout module `generator` porte len(criteria) checks gen = [m for m in qr_crit["modules"] if m.get("archetype") == "generator"] gen_bad = [m["id"] for m in gen if len(m.get("checks", [])) != n_crit] if not gen: good("README audit_4big · aucun module d'archétype generator (rien à recouper)") elif gen_bad: bad(f"README audit_4big · modules generator avec ≠ {n_crit} checks : {gen_bad} " f"→ « generator = tous les critères » violé") else: good(f"README audit_4big · {len(gen)} module(s) generator portent {n_crit} " f"checks (« generator = tous les critères » vérifié)") # 6) carte « archétype → critères EXCLUS » (renormalisation) : le README énumère # data_room « sans HANDOFF », parser « sans CONTRAT … ni HANDOFF », contract # « sans CLI ni HANDOFF ». Cette carte est DATA-DERIVED : pour un archétype, # l'ensemble des critères APPLICABLES = {check.criterion} de ses modules (les # non-applicables sont ABSENTS des checks) ⇒ EXCLUS = ordre du barème − appl. # Le bloc (3) ne recompute QUE generator (5 critères) ; les exclusions des # trois autres archétypes n'étaient recomputées par AUCUN gate. Changer la # règle de renormalisation d'un archétype (ex. data_room exclut aussi CLI) # ferait dériver cette prose en silence pendant que quality_report dit autre # chose. On recompute par archétype + on exige que la parenthèse du README # énumère EXACTEMENT cet ensemble. Uniformité en bonus : tous les modules # d'un même archétype portent le MÊME ensemble applicable (sinon la carte # « 1 archétype → 1 jeu de critères » n'a pas de sens). crit_ids = [c["id"] for c in criteria] crit_set = set(crit_ids) by_arch, uni_bad = {}, [] for m in qr_crit["modules"]: a = m.get("archetype") appl = frozenset(ck["criterion"] for ck in m.get("checks", [])) if a in by_arch and by_arch[a] != appl: uni_bad.append(m["id"]) else: by_arch.setdefault(a, appl) if uni_bad: bad(f"README audit_4big · archétypes NON uniformes (modules aux critères " f"applicables divergents) : {uni_bad} → renormalisation incohérente") else: good(f"README audit_4big · {len(by_arch)} archétype(s) à critères applicables " f"uniformes (« 1 archétype → 1 jeu » vérifié)") for a in sorted(by_arch): if a == "generator": continue # traité en (3) : « generator (N critères) », zéro exclusion exp = [c for c in crit_ids if c not in by_arch[a]] # ordre du barème, stable ma = re.search(rf"`{re.escape(a)}` \(([^)]*)\)", a4_txt) if ma is None: bad(f"README audit_4big · archétype `{a}` — énumération INTROUVABLE dans " f"{A4} (attendu « sans {' ni '.join(exp)} »)") continue got = set(re.findall(r"[A-Z]{2,}", ma.group(1))) & crit_set if got == set(exp): good(f"README audit_4big · archétype `{a}` — exclus {sorted(got)} == " f"référentiel ({exp})") else: bad(f"README audit_4big · archétype `{a}` — {A4} exclut {sorted(got)} MAIS " f"le référentiel exclut {exp} → régénérer le README") # ============================================================================ # Fiches agents · comptes de tests PAR SUITE (colonne « Tests » des tables) — # même classe de « vert trompeur », au grain le PLUS FIN. # ---------------------------------------------------------------------------- # Chaque fiche agent (03_agents/*/AGENT.md) recense ses livrables dans une table # dont la dernière colonne « Tests » affiche le nombre de méthodes `def test_` de # la suite. Ce nombre est SAISI À LA MAIN : il se périme dès qu'un test est # ajouté/retiré. C'est arrivé (fiche QA : `acceptance` disait 31 alors que la # suite en portait 37 · `audit_4big` 35→34 · `regression` 25→26). Les gates # amont ne couvraient QUE la SOMME agrégée (« 564 tests ») : une compensation # entre deux suites (l'une +1, l'autre −1) laisserait la somme juste et les DEUX # lignes fausses. On RECOMPUTE ici chaque cellule depuis la source faisant # autorité — plan.suites[path].test_methods pour les suites gated, et # reglib.discovery.count_tests (même fonction que le plan · zéro duplication) # pour le self_module (qa/regression, exclu de la matrice par SoD mais bien # documenté avec son propre compte). # ============================================================================ sys.path.insert(0, f"{D}/qa/regression") try: from reglib.discovery import count_tests except Exception as e: # import cassé = red honnête (source de vérité absente) bad(f"Comptes par suite · import reglib.discovery impossible : {e}") count_tests = None plan = load("qa/regression/out/regression_plan.json") auth = {s["path"]: s["test_methods"] for s in plan["suites"]} self_mod = plan["coverage"].get("self_module_excluded") if count_tests is not None and self_mod: auth[self_mod] = count_tests(f"{D}/{self_mod}/tests")[1] # Ligne de table Tests : lien vers 05_deliverables_mvp//README.md + dernier # champ numérique. On ne vérifie QUE les lignes pointant une suite gatée connue # (auth) — toute autre ligne de table est ignorée (pas notre périmètre). row_re = re.compile( r"^\|\s*\[`\w+/`\]\([^)]*?05_deliverables_mvp/([\w/]+?)/README\.md\)" r".*\|\s*(\d+)\s*\|\s*$") fiches = subprocess.run(["git", "ls-files", "03_agents/*/AGENT.md"], capture_output=True, text=True).stdout.split() seen_rows = 0 for af in fiches: try: lines = open(af, encoding="utf-8").read().splitlines() except OSError as e: bad(f"Fiche {af} illisible : {e}"); continue for ln, line in enumerate(lines, 1): m = row_re.match(line) if not m: continue path = m.group(1) if path not in auth: # ligne pointant un non-suite → hors périmètre continue claimed, exp = int(m.group(2)), auth[path] seen_rows += 1 if claimed == exp: good(f"Fiche {af}:{ln} · {path} — Tests {claimed} == source ({exp})") else: bad(f"Fiche {af}:{ln} · {path} — Tests {claimed} MAIS source dit {exp} " f"(plan.suites/count_tests) → régénérer la fiche") if seen_rows == 0: bad("Comptes par suite · AUCUNE ligne de table « Tests » trouvée dans les " "fiches — la disparition du recensement est elle-même une régression.") # ============================================================================ # Fiches agents · comptes AGRÉGÉS EN PROSE (« Total X : N tests (a + b + c) ») — # dernière surface de la MÊME classe, un cran au-dessus des cellules de table. # ---------------------------------------------------------------------------- # Les cellules « Tests » des tables sont recomputées ci-dessus, mais deux fiches # rédigent EN PROSE un agrégat multi-suites : `crm` (« Total CRM : 81 tests # (25 + 31 + 25) ») et `erpnext_backend` (« RBAC 60 tests (10 + 11 + 12 + 11 + 16) # + e-CF 39 tests »). Ces totaux ET leurs composants sont SAISIS À LA MAIN et # n'étaient couverts par AUCUN gate : une compensation entre deux suites (l'une # +1, l'autre −1) laisserait la table juste et la prose fausse — exactement le # motif per-suite corrigé la session précédente. On RECOMPUTE ici le total ET le # multiset des composants (ordre-indépendant) depuis `auth` (plan.suites, source # faisant autorité déjà chargée), et on exige l'égalité avec ce que la fiche ÉCRIT. # ============================================================================ PROSE = [ ("03_agents/crm/AGENT.md", "Total CRM", r"Total CRM : \*\*(\d+) tests\*\* \(([\d + ]+)\)", ["crm/commissions", "crm/dossier_vente", "crm/workflow_vente"]), ("03_agents/erpnext_backend/AGENT.md", "RBAC backend", r"\*\*RBAC (\d+) tests\*\* \(([\d + ]+)\)", ["rbac", "rbac/apply_plan", "rbac/fixtures_gen", "rbac/roleprofile_gen", "rbac/userperm_gen"]), ] for path_f, label, pat, suites_list in PROSE: try: txt = re.sub(r"\s+", " ", open(path_f, encoding="utf-8").read()) except OSError as e: bad(f"Prose agrégée · {path_f} illisible : {e}"); continue try: exp_parts = sorted(auth[s] for s in suites_list) except KeyError as e: bad(f"Prose agrégée · {label} — suite {e} absente de plan.suites (câblage ?)") continue exp_total = sum(exp_parts) m = re.search(pat, txt) if m is None: bad(f"Prose agrégée · {path_f} · « {label} » — claim INTROUVABLE " f"(attendu total={exp_total} composants={exp_parts})") continue total = int(m.group(1)) parts = sorted(int(x) for x in re.findall(r"\d+", m.group(2))) if total == exp_total and parts == exp_parts and total == sum(parts): good(f"Prose agrégée · {path_f} · {label} — " f"{total} ({'+'.join(map(str, parts))}) == source ({exp_total})") else: bad(f"Prose agrégée · {path_f} · {label} — dit total={total} " f"composants={parts} MAIS source dit total={exp_total} " f"composants={exp_parts} (interne_ok={total == sum(parts)}) " f"→ régénérer la fiche") # e-CF : agrégat en prose à SUITE UNIQUE (pas de composants entre parenthèses). be_ecf = re.sub(r"\s+", " ", open("03_agents/erpnext_backend/AGENT.md", encoding="utf-8").read()) ecf_exp = auth["fiscal/ecf_dgii"] mecf = re.search(r"\*\*e-CF (\d+) tests\*\*", be_ecf) if mecf is None: bad(f"Prose agrégée · e-CF — claim INTROUVABLE (attendu {ecf_exp})") elif int(mecf.group(1)) == ecf_exp: good(f"Prose agrégée · e-CF — {mecf.group(1)} == source ({ecf_exp})") else: bad(f"Prose agrégée · e-CF — dit {mecf.group(1)} MAIS source dit {ecf_exp} " f"(fiscal/ecf_dgii) → régénérer la fiche") # ============================================================================ # Portails frontend · comptes PAR WORKSPACE (cartes / liens / rôles) — # même classe de « vert trompeur », transcrits À LA MAIN dans DEUX docs. # ---------------------------------------------------------------------------- # Le générateur de portails émet out/MANIFEST.json dont le tableau `workspaces` # porte, PAR portail, {nb_cartes, nb_liens, nb_roles} recalculés depuis le # contrat RBAC à chaque build (byte-gaté par check_artifacts). Ces triplets sont # recopiés À LA MAIN à DEUX endroits : la table du README du module # (frontend/portails/README.md, colonnes Cartes|Liens|Rôles) ET la prose de la # fiche agent (03_agents/frontend_console/AGENT.md — « OTO Ventes 4 cartes/11 # liens/12 rôles · … »). Aucun gate ne les couvrait : ajouter un rôle/DocType au # contrat ferait dériver LES DEUX docs en silence pendant que l'artefact dit # autre chose — même piège que les cellules « Tests » et la prose agrégée. On # RECOMPUTE ici chaque triplet depuis MANIFEST (déjà byte-gaté · zéro duplication # du compteur du générateur) et on exige l'égalité avec les DEUX docs. Un # workspace absent d'un doc échoue AUSSI (traçabilité). # ============================================================================ WS_RD = f"{D}/frontend/portails/README.md" WS_FI = "03_agents/frontend_console/AGENT.md" try: ws_list = load("frontend/portails/out/MANIFEST.json")["workspaces"] except (OSError, KeyError, ValueError) as e: bad(f"Portails · MANIFEST illisible/inattendu : {e}"); ws_list = [] try: ws_rd = re.sub(r"\s+", " ", open(WS_RD, encoding="utf-8").read()) except OSError as e: bad(f"Portails · {WS_RD} illisible : {e}"); ws_rd = "" try: ws_fi = re.sub(r"\s+", " ", open(WS_FI, encoding="utf-8").read()) except OSError as e: bad(f"Portails · {WS_FI} illisible : {e}"); ws_fi = "" for w in ws_list: portail, ws = w["portail"], w["workspace"] exp = (str(w["nb_cartes"]), str(w["nb_liens"]), str(w["nb_roles"])) src = f"cartes={exp[0]} liens={exp[1]} rôles={exp[2]}" # 1) Table du README du module : | | `` | C | L | R | rd_pat = (rf"\| {re.escape(portail)} \| `{re.escape(ws)}` \| " r"(\d+) \| (\d+) \| (\d+) \|") mrd = re.search(rd_pat, ws_rd) if mrd is None: bad(f"Portails README · {ws} — ligne INTROUVABLE dans {WS_RD} (attendu {src})") elif mrd.groups() == exp: good(f"Portails README · {ws} — {'/'.join(mrd.groups())} == MANIFEST ({src})") else: bad(f"Portails README · {ws} — {WS_RD} dit {'/'.join(mrd.groups())} MAIS " f"MANIFEST dit {'/'.join(exp)} ({src}) → régénérer le README") # 2) Prose de la fiche agent : `` C cartes/L liens/R rôles (ou C/L/R) fi_pat = (rf"`{re.escape(ws)}` (\d+)(?: cartes)?/(\d+)(?: liens)?/" r"(\d+)(?: rôles)?") mfi = re.search(fi_pat, ws_fi) if mfi is None: bad(f"Portails fiche · {ws} — claim INTROUVABLE dans {WS_FI} (attendu {src})") elif mfi.groups() == exp: good(f"Portails fiche · {ws} — {'/'.join(mfi.groups())} == MANIFEST ({src})") else: bad(f"Portails fiche · {ws} — {WS_FI} dit {'/'.join(mfi.groups())} MAIS " f"MANIFEST dit {'/'.join(exp)} ({src}) → régénérer la fiche") # Total de workspaces (« N `Workspace` natifs ») cohérent artefact ↔ fiche. n_ws = len(ws_list) if n_ws: mtot = re.search(r"\*\*(\d+) `Workspace` natifs", ws_fi) if mtot is None: bad(f"Portails fiche · total — « N `Workspace` natifs » INTROUVABLE " f"dans {WS_FI} (attendu {n_ws})") elif int(mtot.group(1)) == n_ws: good(f"Portails fiche · total — {mtot.group(1)} == MANIFEST ({n_ws} workspaces)") else: bad(f"Portails fiche · total — {WS_FI} dit {mtot.group(1)} MAIS " f"MANIFEST dit {n_ws} → régénérer la fiche") # ============================================================================ # RBAC · comptes des 3 volets (roleprofile · apply_plan · userperm) — # même classe de « vert trompeur », recopiés À LA MAIN depuis les MANIFEST. # ---------------------------------------------------------------------------- # Les trois générateurs RBAC émettent, PAR volet, un out/MANIFEST.json recalculé # à chaque build depuis le contrat `rbac_50_roles.json` (byte-gaté par # check_artifacts ⇒ source faisant autorité). Trois READMEs de module rédigent à # LA MAIN des comptes qui dérivent de ces manifestes, sans AUCUN gate : # 1) roleprofile_gen/README.md — table « Nb rôles » PAR profil (6 lignes) + # total. Grain le plus fin (par item), comme la colonne « Tests » des fiches. # 2) apply_plan/README.md — ligne AGRÉGÉE « Génération réelle : 6 étapes · 50 # rôles / 116 DocPerm / 28 UP templates / 6 Role Profile · confirmations VPS # 4 DocType custom + 5 Company + 4 rôles equipe » : 8 comptes consolidés de # TOUS les volets en une phrase. # 3) userperm_gen/README.md — « Répartition : 28 entite · 16 groupe · 2 own · # 4 equipe » : la ventilation par mécanisme d'enforcement. # Ajouter un rôle/DocType au contrat ferait dériver LES TROIS docs en silence # pendant que les MANIFEST (byte-gatés) disent autre chose — même piège que les # cellules « Tests », la prose agrégée CRM/RBAC et les triplets par workspace. On # RECOMPUTE ici chaque nombre depuis le MANIFEST du volet (zéro duplication des # compteurs des générateurs) et on exige l'égalité avec ce que la doc ÉCRIT. Un # claim absent échoue AUSSI (disparition = régression de traçabilité). # ============================================================================ def read_norm(path): try: return re.sub(r"\s+", " ", open(path, encoding="utf-8").read()) except OSError as e: bad(f"RBAC · {path} illisible : {e}"); return None # 1) roleprofile_gen — table « Nb rôles » par profil + total bijectif ----------- RP_RD = f"{D}/rbac/roleprofile_gen/README.md" try: rp_man = load("rbac/roleprofile_gen/out/MANIFEST.json") except (OSError, KeyError, ValueError) as e: bad(f"RBAC roleprofile · MANIFEST illisible : {e}"); rp_man = None rp_rd = read_norm(RP_RD) if rp_man is not None and rp_rd is not None: for p in rp_man["profiles"]: name, portail, exp = p["role_profile"], p["portail"], str(p["nb_roles"]) # métier vs technique : le flag DATA-DERIVED (booléen `metier` du MANIFEST) # doit être transcrit EXACTEMENT dans la colonne « Type ». Sans ce gate la # colonne était un WILDCARD (`[^|]*`) → un portail technique classé « métier » # (ou l'inverse) passait « vert » alors que l'artefact dit autre chose. exp_type = "métier" if p["metier"] else "technique" pat = (rf"\| `{re.escape(name)}` \| {re.escape(portail)} \| " r"([^|]+?) \| (\d+) \|") m = re.search(pat, rp_rd) if m is None: bad(f"RBAC roleprofile · {name} — ligne INTROUVABLE dans {RP_RD} " f"(attendu type={exp_type} nb_roles={exp})") else: got_type, got_n = m.group(1).strip(), m.group(2) if got_n == exp: good(f"RBAC roleprofile · {name} — Nb rôles {got_n} == MANIFEST ({exp})") else: bad(f"RBAC roleprofile · {name} — {RP_RD} dit {got_n} MAIS " f"MANIFEST dit {exp} → régénérer le README") if got_type == exp_type: good(f"RBAC roleprofile · {name} — Type « {got_type} » == MANIFEST " f"(metier={p['metier']})") else: bad(f"RBAC roleprofile · {name} — {RP_RD} classe « {got_type} » MAIS " f"MANIFEST dit « {exp_type} » (metier={p['metier']}) → régénérer") exp_tot = str(rp_man["counts"]["roles_couverts"]) mt = re.search(r"\*\*Total\*\* \| \*\*(\d+)\*\*", rp_rd) if mt is None: bad(f"RBAC roleprofile · total — ligne « Total » INTROUVABLE dans {RP_RD} " f"(attendu {exp_tot})") elif mt.group(1) == exp_tot: good(f"RBAC roleprofile · total — {mt.group(1)} == MANIFEST ({exp_tot} couverts)") else: bad(f"RBAC roleprofile · total — {RP_RD} dit {mt.group(1)} MAIS MANIFEST " f"dit {exp_tot} → régénérer le README") # 1bis) IDENTITÉ de l'ensemble des profils (pas seulement leurs comptes) -------- # La boucle ci-dessus est MANIFEST→README (chaque profil du contrat a une ligne : # capte le MANQUANT). Le sens README→MANIFEST (une ligne FANTÔME dans la table, # profil inventé absent du contrat) reste aveugle → on compte les lignes de profil # de la table et on exige == |profiles|. Ensemble ∩ boucle = identité d'ensemble. exp_prof = len(rp_man["profiles"]) rows = re.findall(r"\| `OTO Portail [^|`]+` \| [^|]+ \| (?:métier|technique) \| \d+ \|", rp_rd) if len(rows) == exp_prof: good(f"RBAC roleprofile · table — {len(rows)} lignes de profil == " f"MANIFEST ({exp_prof}) : aucun profil FANTÔME") else: bad(f"RBAC roleprofile · table — {RP_RD} porte {len(rows)} lignes de profil " f"MAIS MANIFEST en compte {exp_prof} → ligne en trop/manquante ?") # 1ter) prose « N profils (X métier + Y technique) » == comptes MANIFEST -------- # + cohérences croisées : les comptes portails_metier/techniques doivent RECOMPTER # les flags `metier` des profils, et leur somme == role_profiles == |profiles| # (mord un MANIFEST INTERNEMENT incohérent : compte de flag ≠ flags réels). c = rp_man["counts"] cm, ct, cp = c["portails_metier"], c["portails_techniques"], c["role_profiles"] real_m = sum(1 for p in rp_man["profiles"] if p["metier"]) real_t = sum(1 for p in rp_man["profiles"] if not p["metier"]) if cm == real_m and ct == real_t and cm + ct == cp == exp_prof: good(f"RBAC roleprofile · cohérence comptes — portails_metier={cm} + " f"techniques={ct} == role_profiles={cp} == |flags métier réels|") else: bad(f"RBAC roleprofile · cohérence comptes — MANIFEST incohérent : " f"portails_metier={cm} (flags réels={real_m}) · techniques={ct} " f"(réels={real_t}) · role_profiles={cp} · |profiles|={exp_prof}") mp = re.search(r"\*\*(\d+) profils\*\* \((\d+) métier \+ (\d+) technique\)", rp_rd) if mp is None: bad(f"RBAC roleprofile · split métier/technique — prose « N profils " f"(X métier + Y technique) » INTROUVABLE dans {RP_RD} " f"(attendu {cp} profils · {cm} métier + {ct} technique)") elif (mp.group(1), mp.group(2), mp.group(3)) == (str(cp), str(cm), str(ct)): good(f"RBAC roleprofile · split métier/technique — {mp.group(1)} profils " f"({mp.group(2)} métier + {mp.group(3)} technique) == MANIFEST") else: bad(f"RBAC roleprofile · split métier/technique — {RP_RD} dit " f"{mp.group(1)}/{mp.group(2)}/{mp.group(3)} MAIS MANIFEST dit " f"{cp}/{cm}/{ct} → régénérer le README") # 2) apply_plan — ligne « Génération réelle » agrégeant les 3 volets ------------ AP_RD = f"{D}/rbac/apply_plan/README.md" try: ap_man = load("rbac/apply_plan/out/MANIFEST.json") ap_steps = len(load("rbac/apply_plan/out/apply_plan.json")) except (OSError, KeyError, ValueError) as e: bad(f"RBAC apply_plan · artefact illisible : {e}"); ap_man = None ap_rd = read_norm(AP_RD) if ap_man is not None and ap_rd is not None: c, v = ap_man["counts"], ap_man["confirmations_vps"] ap_exp = (str(ap_steps), str(c["roles"]), str(c["custom_docperm"]), str(c["user_permission_templates"]), str(c["role_profiles"]), str(len(v["custom_doctypes"])), str(len(v["companies"])), str(len(v["roles_scope_equipe"]))) ap_src = (f"étapes={ap_exp[0]} rôles={ap_exp[1]} docperm={ap_exp[2]} " f"up={ap_exp[3]} role_profiles={ap_exp[4]} custom_dt={ap_exp[5]} " f"companies={ap_exp[6]} equipe={ap_exp[7]}") ap_pat = (r"\*\*(\d+) étapes\*\* · (\d+) rôles / (\d+) DocPerm / " r"(\d+) UP templates / (\d+) Role Profile · confirmations VPS " r"(\d+) DocType custom \+ (\d+) Company \+ (\d+) rôles") ma = re.search(ap_pat, ap_rd) if ma is None: bad(f"RBAC apply_plan · « Génération réelle » — claim INTROUVABLE dans " f"{AP_RD} (attendu {ap_src})") elif ma.groups() == ap_exp: good(f"RBAC apply_plan · agrégat — {AP_RD} {'/'.join(ma.groups())} == " f"MANIFEST ({ap_src})") else: bad(f"RBAC apply_plan · {AP_RD} dit {'/'.join(ma.groups())} MAIS MANIFEST " f"dit {'/'.join(ap_exp)} ({ap_src}) → régénérer le README") # 3) userperm — ventilation par mécanisme d'enforcement ------------------------- UP_RD = f"{D}/rbac/userperm_gen/README.md" try: up_bm = load("rbac/userperm_gen/out/MANIFEST.json")["counts"]["by_mechanism"] except (OSError, KeyError, ValueError) as e: bad(f"RBAC userperm · MANIFEST illisible : {e}"); up_bm = None up_rd = read_norm(UP_RD) if up_bm is not None and up_rd is not None: up_exp = (str(up_bm["user_permission_company"]), str(up_bm["none_consolidated"]), str(up_bm["docperm_if_owner"]), str(up_bm["vps_confirm_team"])) up_src = (f"entite={up_exp[0]} groupe={up_exp[1]} own={up_exp[2]} " f"equipe={up_exp[3]}") up_pat = (r"\*\*(\d+)\*\* `entite` · \*\*(\d+)\*\* `groupe` · " r"\*\*(\d+)\*\* `own` · \*\*(\d+)\*\* `equipe`") mu = re.search(up_pat, up_rd) if mu is None: bad(f"RBAC userperm · répartition — claim INTROUVABLE dans {UP_RD} " f"(attendu {up_src})") elif mu.groups() == up_exp: good(f"RBAC userperm · répartition — {UP_RD} {'/'.join(mu.groups())} == " f"MANIFEST ({up_src})") else: bad(f"RBAC userperm · {UP_RD} dit {'/'.join(mu.groups())} MAIS MANIFEST " f"dit {'/'.join(up_exp)} ({up_src}) → régénérer le README") # ============================================================================ # SEO · comptes de mots-clés (total + ventilation par langue) — # même classe de « vert trompeur », transcrits À LA MAIN dans DEUX docs. # ---------------------------------------------------------------------------- # Le générateur SEO émet out/MANIFEST.json dont le bloc `counts` porte # keywords_total et keywords_per_lang{fr,en,es}, RECOMPTÉS à chaque build depuis # `projets_master.json` (byte-gaté par check_artifacts ⇒ source faisant autorité). # Ce quadruplet est recopié À LA MAIN à DEUX endroits : la table « Livrables » du # README du module (05_deliverables_mvp/seo/README.md — « **258** mots-clés # (fr=87 · en=87 · es=84) ») ET la même ligne de la fiche agent # (03_agents/seo/AGENT.md). Aucun gate ne les couvrait : ajouter un projet ou du # vocabulaire éditorial ferait dériver LES DEUX docs en silence pendant que # l'artefact dit autre chose — même piège que les triplets par workspace des # portails. On RECOMPUTE ici (total + 3 langues) depuis MANIFEST.counts (déjà # byte-gaté · zéro duplication du compteur du générateur) et on exige l'égalité # avec les DEUX docs. Un claim absent échoue AUSSI (traçabilité). # ============================================================================ SEO_RD = f"{D}/seo/README.md" SEO_FI = "03_agents/seo/AGENT.md" try: seo_c = load("seo/out/MANIFEST.json")["counts"] seo_exp = (str(seo_c["keywords_total"]), str(seo_c["keywords_per_lang"]["fr"]), str(seo_c["keywords_per_lang"]["en"]), str(seo_c["keywords_per_lang"]["es"])) except (OSError, KeyError, ValueError) as e: bad(f"SEO · MANIFEST illisible/inattendu : {e}"); seo_exp = None if seo_exp is not None: seo_src = (f"total={seo_exp[0]} fr={seo_exp[1]} en={seo_exp[2]} es={seo_exp[3]}") seo_pat = r"\*\*(\d+)\*\* mots-clés \(fr=(\d+) · en=(\d+) · es=(\d+)\)" for path_f in (SEO_RD, SEO_FI): txt = read_norm(path_f) if txt is None: continue m = re.search(seo_pat, txt) if m is None: bad(f"SEO · {path_f} — claim mots-clés INTROUVABLE (attendu {seo_src})") elif m.groups() == seo_exp: good(f"SEO · {path_f} — {'/'.join(m.groups())} == MANIFEST ({seo_src})") else: bad(f"SEO · {path_f} — dit {'/'.join(m.groups())} MAIS MANIFEST dit " f"{'/'.join(seo_exp)} ({seo_src}) → régénérer le doc") # ============================================================================ # CRM · pipeline vente (états / transitions) — même classe de « vert trompeur », # transcrit À LA MAIN dans DEUX README de module. # ---------------------------------------------------------------------------- # Le générateur workflow_vente émet out/MANIFEST.json dont `counts` porte states # et transitions, RECOMPTÉS à chaque build depuis workflow_vente_spec.json (le # graphe pipeline · byte-gaté par check_artifacts ⇒ source faisant autorité). Ce # couple est recopié À LA MAIN à DEUX endroits : le titre de section du README du # module workflow (« ## Le pipeline (9 états · 11 transitions) ») ET le README du # DocType porteur dossier_vente (« options = les **9 états** du pipeline »), qui # DÉRIVE le même compte d'états du même graphe (source unique · #5). Aucun gate ne # les couvrait : ajouter un état/une transition au pipeline ferait dériver LES # DEUX docs en silence pendant que l'artefact dit autre chose — même piège que les # triplets par workspace des portails ou les mots-clés SEO. On RECOMPUTE ici # (states + transitions) depuis workflow_vente MANIFEST.counts (déjà byte-gaté · # zéro duplication du compteur du générateur) et on exige l'égalité avec les DEUX # docs. Un claim absent échoue AUSSI (traçabilité). Recomputer transitions EN PLUS # de states mord une dérive du 2e nombre là où le seul titre du README workflow # (qui n'a pas les transitions dans dossier_vente) resterait aveugle. # ============================================================================ WF_RD = f"{D}/crm/workflow_vente/README.md" DV_RD = f"{D}/crm/dossier_vente/README.md" try: wf_c = load("crm/workflow_vente/out/MANIFEST.json")["counts"] wf_states, wf_trans = str(wf_c["states"]), str(wf_c["transitions"]) except (OSError, KeyError, ValueError) as e: bad(f"CRM pipeline · MANIFEST workflow_vente illisible/inattendu : {e}") wf_states = None if wf_states is not None: # 1) README workflow — titre de section (états + transitions) wf_src = f"états={wf_states} transitions={wf_trans}" wf_txt = read_norm(WF_RD) if wf_txt is not None: m = re.search(r"## Le pipeline \((\d+) états · (\d+) transitions\)", wf_txt) if m is None: bad(f"CRM pipeline · {WF_RD} — titre pipeline INTROUVABLE (attendu {wf_src})") elif m.groups() == (wf_states, wf_trans): good(f"CRM pipeline · {WF_RD} — {'/'.join(m.groups())} == MANIFEST ({wf_src})") else: bad(f"CRM pipeline · {WF_RD} — dit {'/'.join(m.groups())} MAIS MANIFEST " f"dit {wf_states}/{wf_trans} ({wf_src}) → régénérer le README") # 2) README dossier_vente — même compte d'états, dérivé du même graphe dv_txt = read_norm(DV_RD) if dv_txt is not None: m = re.search(r"les \*\*(\d+) états\*\* du pipeline", dv_txt) if m is None: bad(f"CRM pipeline · {DV_RD} — claim « N états du pipeline » INTROUVABLE " f"(attendu états={wf_states})") elif m.group(1) == wf_states: good(f"CRM pipeline · {DV_RD} — états={m.group(1)} == MANIFEST (états={wf_states})") else: bad(f"CRM pipeline · {DV_RD} — dit états={m.group(1)} MAIS MANIFEST dit " f"états={wf_states} → régénérer le README") # ============================================================================ # 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") # ============================================================================ # Audit 5D · synthèse de l'audit de conformité (contrôles / dimensions / verdict # pass-fail-à confirmer) — même classe de « vert trompeur », transcrite À LA MAIN # dans DEUX docs. # ---------------------------------------------------------------------------- # Le générateur audit_5d émet DEUX artefacts byte-gatés par check_artifacts # (⇒ faisant autorité) : out/MANIFEST.json (controls_total, `dimensions`, # `audited_artifacts`, `open_items`, verdict — recomptés du catalogue de contrôles # à chaque build) ET out/audit_report.json dont `totals` porte la ventilation # {controls, pass, fail, a_confirmer} recalculée en rejouant les 17 contrôles sur # les hand-off amont. Deux nombres de cette synthèse sont recopiés À LA MAIN à # DEUX endroits : la phrase d'ouverture du README du module (05_deliverables_mvp/ # qa/audit_5d/README.md — « **17 contrôles** répartis en **5 dimensions (5D)** ») # ET la cellule de la fiche agent QA (03_agents/qa/AGENT.md — « 17 contrôles / 5 # dimensions »). La ventilation du verdict (« **13 PASS · 0 FAIL · 4 à confirmer** ») # n'est portée QUE par le README, mais se PÉRIME dès qu'un contrôle bascule en # A_CONFIRMER (pass 13→12, à confirmer 4→5) alors que le TOTAL de contrôles reste # 17 — dérive AVEUGLE au seul compte de contrôles, d'où sa recomputation séparée # depuis audit_report.totals. Aucun gate ne couvrait ces claims : ajouter un # contrôle/une dimension au catalogue, ou faire basculer un contrôle, ferait # dériver LES DEUX docs (et la ligne verdict) en silence pendant que les artefacts # disent autre chose — même piège que les mots-clés SEO, les triplets par workspace # des portails ou le quintuplet CONFOTUR. On RECOMPUTE ici (contrôles + dimensions # depuis le MANIFEST · pass/fail/à confirmer depuis audit_report.totals · déjà # byte-gatés · zéro duplication du compteur du générateur) et on exige l'égalité. # Un claim absent échoue AUSSI (traçabilité). Cohérence croisée en bonus : le # `controls_total` du MANIFEST doit égaler `totals.controls` du rapport. # ============================================================================ A5D_RD = f"{D}/qa/audit_5d/README.md" A5D_FI = "03_agents/qa/AGENT.md" try: a5_man = load("qa/audit_5d/out/MANIFEST.json") a5_tot = load("qa/audit_5d/out/audit_report.json")["totals"] a5_ctl = str(a5_man["controls_total"]) a5_dim = str(len(a5_man["dimensions"])) if a5_ctl != str(a5_tot["controls"]): bad(f"Audit 5D · incohérence artefacts : MANIFEST controls_total={a5_ctl} " f"MAIS audit_report totals.controls={a5_tot['controls']}") a5_pass, a5_fail = str(a5_tot["pass"]), str(a5_tot["fail"]) a5_conf = str(a5_tot["a_confirmer"]) except (OSError, KeyError, ValueError) as e: bad(f"Audit 5D · artefact illisible/inattendu : {e}"); a5_ctl = None if a5_ctl is not None: a5_src = f"contrôles={a5_ctl} dimensions={a5_dim}" # 1) README module — phrase d'ouverture (contrôles + dimensions) a5_rd = read_norm(A5D_RD) if a5_rd is not None: m = re.search(r"\*\*(\d+) contrôles\*\* répartis en \*\*(\d+) dimensions \(5D\)\*\*", a5_rd) if m is None: bad(f"Audit 5D · {A5D_RD} — synthèse contrôles/dimensions INTROUVABLE " f"(attendu {a5_src})") elif m.groups() == (a5_ctl, a5_dim): good(f"Audit 5D · {A5D_RD} — {'/'.join(m.groups())} == artefact ({a5_src})") else: bad(f"Audit 5D · {A5D_RD} — dit {'/'.join(m.groups())} MAIS artefact dit " f"{a5_ctl}/{a5_dim} ({a5_src}) → régénérer le README") # 2) README module — ventilation du verdict (pass / fail / à confirmer) a5_vsrc = f"pass={a5_pass} fail={a5_fail} à_confirmer={a5_conf}" m = re.search(r"\*\*(\d+) PASS · (\d+) FAIL · (\d+) à confirmer\*\*", a5_rd) if m is None: bad(f"Audit 5D · {A5D_RD} — ventilation verdict INTROUVABLE (attendu {a5_vsrc})") elif m.groups() == (a5_pass, a5_fail, a5_conf): good(f"Audit 5D · {A5D_RD} — verdict {'/'.join(m.groups())} == " f"audit_report.totals ({a5_vsrc})") else: bad(f"Audit 5D · {A5D_RD} — dit {'/'.join(m.groups())} MAIS totals disent " f"{a5_pass}/{a5_fail}/{a5_conf} ({a5_vsrc}) → régénérer le README") # 3) Fiche agent QA — même couple contrôles/dimensions, 2e transcription a5_fi = read_norm(A5D_FI) if a5_fi is not None: m = re.search(r"(\d+) contrôles / (\d+) dimensions", a5_fi) if m is None: bad(f"Audit 5D · {A5D_FI} — claim « N contrôles / M dimensions » INTROUVABLE " f"(attendu {a5_src})") elif m.groups() == (a5_ctl, a5_dim): good(f"Audit 5D · {A5D_FI} — {'/'.join(m.groups())} == artefact ({a5_src})") else: bad(f"Audit 5D · {A5D_FI} — dit {'/'.join(m.groups())} MAIS artefact dit " f"{a5_ctl}/{a5_dim} ({a5_src}) → régénérer la fiche") # ============================================================================ # Audit 5D (2e surface du MÊME README) · ÉNUMÉRATION des open items du « Verdict # courant » (control → dimension → propriétaire) — même classe de « vert trompeur ». # ---------------------------------------------------------------------------- # Le bloc précédent gate le COMPTE de la ventilation (« 13 PASS · 0 FAIL · 4 à # confirmer », pass/fail/à confirmer recomputés d'audit_report.totals) mais PAS # l'IDENTITÉ des 4 contrôles ouverts ni leur (dimension, propriétaire). La table # « Verdict courant » du README (05_deliverables_mvp/qa/audit_5d/README.md) énumère # ces 4 lignes À LA MAIN, alors que audit_report.open_items[] (byte-gaté par # check_artifacts ⇒ faisant autorité, chaque item = {control, dimension, owner, # detail} recalculé en rejouant les contrôles) fait foi. Un ÉCHANGE d'open item # (ex. D2.3 → D3.1) laisse le compte à 4 — le bloc de ventilation reste AVEUGLE — # pendant que l'artefact dit autre chose ; idem une dimension mal étiquetée # (D1.3 rangé sous D2) ou un propriétaire réattribué. « Vert trompeur » qu'aucune # suite tests/ (qui teste des FONCTIONS, pas la prose) n'attrape. On RECOMPUTE # l'ensemble {control:(dimension, owner)} depuis open_items (zéro duplication du # modèle de l'auditeur · #6) et on exige que la table README l'énumère EXACTEMENT : # contrôle par ENSEMBLE (absent ET en trop mordus, pas seulement présence), puis # (dimension, propriétaire) par LIGNE. Même patron que l'énumération des # confirmations DevOps ou la carte de renormalisation par archétype 4Big. Cohérence # croisée en bonus : l'ensemble des `control` == MANIFEST.open_items (le manifeste # qui résume le rapport) — mord un manifeste désynchronisé de son propre rapport. # ============================================================================ try: a5_oi = load("qa/audit_5d/out/audit_report.json")["open_items"] a5_oi_exp = {o["control"]: (o["dimension"], o["owner"]) for o in a5_oi} a5_oi_man = set(load("qa/audit_5d/out/MANIFEST.json")["open_items"]) if a5_oi_man != set(a5_oi_exp): bad(f"Audit 5D · incohérence artefacts : MANIFEST open_items {sorted(a5_oi_man)} " f"MAIS audit_report open_items {sorted(a5_oi_exp)}") except (OSError, KeyError, ValueError) as e: bad(f"Audit 5D · open_items illisible/inattendu : {e}"); a5_oi_exp = None if a5_oi_exp is not None: try: a5_lines = open(f"{D}/qa/audit_5d/README.md", encoding="utf-8").read().splitlines() except OSError as e: bad(f"Audit 5D · README illisible : {e}"); a5_lines = None if a5_lines is not None: # ligne de table = « | · | | | » a5_row_re = re.compile( r"^\|\s*(D\d+\.\d+)\b[^|]*\|\s*([^|]+?)\s*\|\s*([^|]+?)\s*\|\s*$") a5_seen = {} for ln in a5_lines: m = a5_row_re.match(ln) if m: a5_seen[m.group(1)] = (m.group(2).strip(), m.group(3).strip()) a5_exp_set, a5_got_set = set(a5_oi_exp), set(a5_seen) if a5_got_set != a5_exp_set: bad(f"Audit 5D · énumération open items — table README énumère " f"{sorted(a5_got_set)} MAIS audit_report dit {sorted(a5_exp_set)} " f"→ régénérer le README") else: a5_mism = [c for c in sorted(a5_exp_set) if a5_seen[c] != a5_oi_exp[c]] if a5_mism: for c in a5_mism: bad(f"Audit 5D · open item {c} — README dit " f"(dimension={a5_seen[c][0]}, propriétaire={a5_seen[c][1]}) MAIS " f"audit_report dit (dimension={a5_oi_exp[c][0]}, " f"propriétaire={a5_oi_exp[c][1]})") else: good(f"Audit 5D · énumération open items {sorted(a5_exp_set)} " f"(control→dimension→propriétaire) == audit_report.open_items") # ============================================================================ # DevOps · run-book de déploiement VPS (phases ordonnées / confirmations # préalables) — même classe de « vert trompeur », transcrite À LA MAIN dans # DEUX docs, sans AUCUN gate. # ---------------------------------------------------------------------------- # Le générateur deploy_runbook émet out/MANIFEST.json (byte-gaté par # check_artifacts ⇒ faisant autorité) dont `counts.phases` et # `counts.confirmations` sont RECOMPTÉS à chaque build (phases = |graph.phase_order| · # confirmations = |confirmations_ouvertes|, l'ensemble consolidé des paramètres # réglementaires non confirmés). Deux nombres de cette synthèse sont recopiés À LA # MAIN : le compte de PHASES dans la fiche agent DevOps (03_agents/devops/AGENT.md — # « graphe de **7 phases ordonnées** » ET, plus bas, « exécute les 7 phases sur le # VPS ») et le compte de CONFIRMATIONS dans le README du module # (05_deliverables_mvp/devops/deploy_runbook/README.md — « renseigner les 7 # confirmations »). Piège « aveugle » caractéristique (cf. bascule verdict audit_5d, # compensation SEO entre langues) : phases ET confirmations valent 7 AUJOURD'HUI — # un gate qui ne recompterait qu'UN nombre serait AVEUGLE à la dérive de l'autre # (ajouter une phase au graphe → 8 phases pendant que les confirmations restent 7, # ou l'inverse). On RECOMPUTE donc CHAQUE nombre depuis SON PROPRE champ du MANIFEST # (jamais figé ici · zéro duplication du compteur du générateur #5) et on exige # l'égalité avec les docs. Bonus anti-dérive : l'ÉNUMÉRATION des confirmations du # README (les tokens `snake_case` de la phrase « Confirmations préalables VPS ») # doit être l'ensemble EXACT de confirmations_ouvertes — capte l'ÉCHANGE d'un nom de # confirmation (count aveugle à un swap 1↔1). Cohérence croisée : counts.phases == # |phase_order| · counts.confirmations == |confirmations_ouvertes|. Un claim absent # échoue AUSSI (la disparition d'un chiffre est une régression · traçabilité). # ============================================================================ DR_RD = f"{D}/devops/deploy_runbook/README.md" DR_FI = "03_agents/devops/AGENT.md" try: dr_man = load("devops/deploy_runbook/out/MANIFEST.json") dr_ph = str(dr_man["counts"]["phases"]) dr_cf = str(dr_man["counts"]["confirmations"]) dr_order = dr_man["graph"]["phase_order"] dr_open = dr_man["confirmations_ouvertes"] if dr_ph != str(len(dr_order)): bad(f"DevOps · incohérence artefact : counts.phases={dr_ph} MAIS " f"|graph.phase_order|={len(dr_order)}") if dr_cf != str(len(dr_open)): bad(f"DevOps · incohérence artefact : counts.confirmations={dr_cf} MAIS " f"|confirmations_ouvertes|={len(dr_open)}") except (OSError, KeyError, ValueError) as e: bad(f"DevOps · artefact deploy_runbook illisible/inattendu : {e}"); dr_ph = None if dr_ph is not None: # 1) Fiche agent DevOps — « graphe de N phases ordonnées » dr_fi = read_norm(DR_FI) if dr_fi is not None: m = re.search(r"graphe de \*\*(\d+) phases ordonnées\*\*", dr_fi) if m is None: bad(f"DevOps · {DR_FI} — claim « graphe de N phases ordonnées » INTROUVABLE " f"(attendu phases={dr_ph})") elif m.group(1) == dr_ph: good(f"DevOps · {DR_FI} — {m.group(1)} phases == artefact (phases={dr_ph})") else: bad(f"DevOps · {DR_FI} — dit {m.group(1)} phases MAIS artefact dit {dr_ph} " f"→ régénérer la fiche") # 2) Fiche agent DevOps — 2e transcription « exécute les N phases » m = re.search(r"exécute les (\d+) phases", dr_fi) if m is None: bad(f"DevOps · {DR_FI} — 2e claim « exécute les N phases » INTROUVABLE " f"(attendu phases={dr_ph})") elif m.group(1) == dr_ph: good(f"DevOps · {DR_FI} — « exécute les {m.group(1)} phases » == artefact " f"(phases={dr_ph})") else: bad(f"DevOps · {DR_FI} — 2e claim dit {m.group(1)} phases MAIS artefact dit " f"{dr_ph} → régénérer la fiche") # 3) README module — « renseigner les N confirmations » (compte) dr_rd = read_norm(DR_RD) if dr_rd is not None: m = re.search(r"renseigner les (\d+) confirmations", dr_rd) if m is None: bad(f"DevOps · {DR_RD} — claim « renseigner les N confirmations » INTROUVABLE " f"(attendu confirmations={dr_cf})") elif m.group(1) == dr_cf: good(f"DevOps · {DR_RD} — {m.group(1)} confirmations == artefact " f"(confirmations={dr_cf})") else: bad(f"DevOps · {DR_RD} — dit {m.group(1)} confirmations MAIS artefact dit " f"{dr_cf} → régénérer le README") # 4) README module — l'ÉNUMÉRATION doit être l'ensemble EXACT des confirmations seg = re.search(r"Confirmations préalables VPS\*\* \(consolidées, sourcées\) : " r"(.+?) ## ", dr_rd) if seg is None: bad(f"DevOps · {DR_RD} — énumération « Confirmations préalables VPS » " f"INTROUVABLE (attendu {sorted(dr_open)})") else: listed = sorted(set(re.findall(r"`([a-z_]+)`", seg.group(1)))) if listed == sorted(dr_open): good(f"DevOps · {DR_RD} — énumération ({len(listed)}) == confirmations_ouvertes") else: bad(f"DevOps · {DR_RD} — énumération {listed} ≠ confirmations_ouvertes " f"{sorted(dr_open)} → régénérer le README") # ============================================================================ # Démo · run-sheet de pitch (nombre de modules cités) — même classe de « vert # trompeur », transcrit À LA MAIN dans le diagramme d'architecture du README du # module, sans AUCUN gate. # ---------------------------------------------------------------------------- # Le générateur demo_scenario_gen émet out/MANIFEST.json (byte-gaté par # check_artifacts ⇒ faisant autorité) dont `counts.modules_cites_uniques` est # RECOMPTÉ à chaque build = |union des `modules_cites` des deux scénarios| — les # hand-off amont que les beats citent par pointeur RFC 6901 (le module ne saisit # AUCUNE valeur métier ; il résout depuis le disque). Ce nombre est recopié À LA # MAIN dans le diagramme du README du module (05_deliverables_mvp/demo/scenarios/ # README.md — « out/ des modules livrés (10 modules cités) »). Aucun gate ne le # couvrait : ajouter un beat qui cite un module encore non tissé (11) ou retirer # la dernière citation d'un module d'un scénario (9) ferait dériver le diagramme # en silence pendant que le MANIFEST byte-gaté dit autre chose — dérive qu'aucune # suite tests/ (qui teste des FONCTIONS, pas la prose du README) n'attrape. Même # piège que les mots-clés SEO, les triplets par workspace des portails, le # quintuplet CONFOTUR ou la synthèse audit_5d. On RECOMPUTE depuis # counts.modules_cites_uniques (jamais figé ici · zéro duplication du compteur du # générateur #5) et on exige l'égalité. Cohérence croisée en bonus : # counts.modules_cites_uniques == |modules_cites| (liste dédupliquée du manifeste, # elle-même sans doublon) — mord un compteur désynchronisé de sa propre liste. Un # claim absent échoue AUSSI (la disparition d'un chiffre est une régression). # ============================================================================ DEMO_RD = f"{D}/demo/scenarios/README.md" try: demo_man = load("demo/scenarios/out/MANIFEST.json") demo_n = str(demo_man["counts"]["modules_cites_uniques"]) demo_list = demo_man["modules_cites"] if len(demo_list) != len(set(demo_list)): bad(f"Démo · incohérence artefact : modules_cites contient des doublons " f"({len(demo_list)} entrées, {len(set(demo_list))} uniques)") if demo_n != str(len(demo_list)): bad(f"Démo · incohérence artefact : counts.modules_cites_uniques={demo_n} " f"MAIS |modules_cites|={len(demo_list)}") except (OSError, KeyError, ValueError) as e: bad(f"Démo · artefact scenarios illisible/inattendu : {e}"); demo_n = None if demo_n is not None: demo_rd = read_norm(DEMO_RD) if demo_rd is not None: m = re.search(r"out/ des modules livrés \((\d+) modules cités\)", demo_rd) if m is None: bad(f"Démo · {DEMO_RD} — claim « (N modules cités) » INTROUVABLE " f"(attendu modules_cites_uniques={demo_n})") elif m.group(1) == demo_n: good(f"Démo · {DEMO_RD} — {m.group(1)} modules cités == artefact " f"(modules_cites_uniques={demo_n})") else: bad(f"Démo · {DEMO_RD} — dit {m.group(1)} modules cités MAIS artefact dit " f"{demo_n} → régénérer le README") # ============================================================================ # Chat OTOIA · montage de l'assistant conversationnel par portail (nombre de # Custom Block / configs runtime / portails) — même classe de « vert trompeur », # transcrit À LA MAIN dans DEUX docs, sans AUCUN gate. # ---------------------------------------------------------------------------- # Le générateur chat_otoia_gen émet out/MANIFEST.json (byte-gaté par # check_artifacts ⇒ faisant autorité) dont `counts.custom_blocks`, `counts.mounts` # et `counts.portails` sont RECOMPTÉS à chaque build depuis le contrat RBAC (un # `Custom Block` ⇔ une config runtime ⇔ un portail métier, `plateforme` exclu — # invariant 2 du générateur : bijection 1 block ⇔ 1 mount ⇔ 1 portail). Ces # nombres sont recopiés À LA MAIN : la fiche agent Frontend Console # (03_agents/frontend_console/AGENT.md — « **N `Custom Block` + M configs # runtime** ») ET le README du module (05_deliverables_mvp/frontend/chat_otoia/ # README.md — « Portails (N, `plateforme` exclu) », « N fixtures `Custom Block` », # « M configs runtime »). Piège « aveugle » caractéristique (cf. DevOps phases ⁄ # confirmations, bascule verdict audit_5d) : custom_blocks, mounts ET portails # valent TOUS TROIS 5 AUJOURD'HUI — un gate qui n'en recompterait qu'un serait # AVEUGLE à la dérive des autres (ajouter un portail au contrat RBAC → chat_otoia # rebâtit 6 blocks ⁄ 6 mounts ⁄ 6 portails pendant que les docs restent à 5). On # RECOMPUTE donc CHAQUE nombre depuis SON PROPRE champ du MANIFEST (jamais figé # ici · zéro duplication du compteur du générateur #5) et on exige l'égalité avec # les docs. Cohérence croisée : counts.custom_blocks == counts.mounts == # counts.portails == |MANIFEST.portails| (la bijection du générateur) — mord un # compteur désynchronisé de sa propre liste. Un claim absent échoue AUSSI (la # disparition d'un chiffre est une régression · traçabilité). # ============================================================================ CO_RD = f"{D}/frontend/chat_otoia/README.md" CO_FI = "03_agents/frontend_console/AGENT.md" try: co_man = load("frontend/chat_otoia/out/MANIFEST.json") co_cb = str(co_man["counts"]["custom_blocks"]) co_mo = str(co_man["counts"]["mounts"]) co_po = str(co_man["counts"]["portails"]) co_nlist = len(co_man["portails"]) if not (co_cb == co_mo == co_po == str(co_nlist)): bad(f"Chat OTOIA · incohérence artefact : custom_blocks={co_cb} · " f"mounts={co_mo} · portails={co_po} · |portails|={co_nlist} " f"(bijection 1 block ⇔ 1 mount ⇔ 1 portail attendue)") except (OSError, KeyError, ValueError) as e: bad(f"Chat OTOIA · artefact chat_otoia illisible/inattendu : {e}"); co_cb = None if co_cb is not None: # 1) Fiche agent Frontend Console — « N `Custom Block` + M configs runtime » co_fi = read_norm(CO_FI) if co_fi is not None: m = re.search(r"\*\*(\d+) `Custom Block` \+ (\d+) configs runtime\*\*", co_fi) if m is None: bad(f"Chat OTOIA · {CO_FI} — claim « N `Custom Block` + M configs runtime » " f"INTROUVABLE (attendu {co_cb}/{co_mo})") elif m.groups() == (co_cb, co_mo): good(f"Chat OTOIA · {CO_FI} — {'/'.join(m.groups())} == artefact " f"(custom_blocks={co_cb} · mounts={co_mo})") else: bad(f"Chat OTOIA · {CO_FI} — dit {'/'.join(m.groups())} MAIS artefact dit " f"{co_cb}/{co_mo} → régénérer la fiche") # 2) README module — « Portails (N, `plateforme` exclu) » co_rd = read_norm(CO_RD) if co_rd is not None: m = re.search(r"Portails \((\d+), `plateforme` exclu\)", co_rd) if m is None: bad(f"Chat OTOIA · {CO_RD} — claim « Portails (N, `plateforme` exclu) » " f"INTROUVABLE (attendu portails={co_po})") elif m.group(1) == co_po: good(f"Chat OTOIA · {CO_RD} — {m.group(1)} portails == artefact " f"(portails={co_po})") else: bad(f"Chat OTOIA · {CO_RD} — dit {m.group(1)} portails MAIS artefact dit " f"{co_po} → régénérer le README") # 3) README module — « N fixtures `Custom Block` » m = re.search(r"(\d+) fixtures `Custom Block`", co_rd) if m is None: bad(f"Chat OTOIA · {CO_RD} — claim « N fixtures `Custom Block` » INTROUVABLE " f"(attendu custom_blocks={co_cb})") elif m.group(1) == co_cb: good(f"Chat OTOIA · {CO_RD} — {m.group(1)} fixtures == artefact " f"(custom_blocks={co_cb})") else: bad(f"Chat OTOIA · {CO_RD} — dit {m.group(1)} fixtures MAIS artefact dit " f"{co_cb} → régénérer le README") # 4) README module — « M configs runtime » (2e transcription des mounts) m = re.search(r"(\d+) configs runtime", co_rd) if m is None: bad(f"Chat OTOIA · {CO_RD} — claim « M configs runtime » INTROUVABLE " f"(attendu mounts={co_mo})") elif m.group(1) == co_mo: good(f"Chat OTOIA · {CO_RD} — {m.group(1)} configs runtime == artefact " f"(mounts={co_mo})") else: bad(f"Chat OTOIA · {CO_RD} — dit {m.group(1)} configs runtime MAIS artefact " f"dit {co_mo} → régénérer le README") # ============================================================================ # Recette (module) · couple « N livrables de sprint + M métriques succès MVP » # transcrit À LA MAIN dans DEUX docs — même classe de « vert trompeur ». # ---------------------------------------------------------------------------- # La matrice d'acceptation (qa/acceptance/out/acceptance_matrix.json, byte-gatée # par check_artifacts ⇒ faisant autorité) porte une ligne par promesse roadmap, # de `kind` sprint_deliverable OU mvp_metric. Le compte de chaque genre (n_sprint, # n_metric déjà recalculés plus haut depuis la matrice, jamais saisis) est repris # À LA MAIN à DEUX endroits que le bloc racine ne couvre PAS : le README du module # (05_deliverables_mvp/qa/acceptance/README.md — puces « N livrables de sprint » # puis « M métriques succès MVP ») ET la fiche agent QA (03_agents/qa/AGENT.md — # « (N livrables + M métriques) »). Le bloc racine plus haut ne gate que le SEUL # README d'entrée (« N promesses (X sprint + Y métriques) ») ; ces deux docs de # module en dérivent sans gate. Ajouter une promesse roadmap (l'artefact rebâtit # 9/7) ferait dériver LES DEUX docs en silence pendant que la matrice dit autre # chose — piège que ni les suites tests/ (qui testent des FONCTIONS, pas la prose) # ni le roadmap-anchor (qui ancre les `roadmap_line` de l'ARTEFACT, pas la prose # des docs) n'attrapent. On RECOMPUTE ici chaque nombre depuis SON PROPRE genre de # la matrice (jamais figé · zéro duplication #5) et on exige l'égalité avec les # DEUX docs. Recomputer les DEUX (sprint ET métriques) mord une dérive de l'un là # où l'autre resterait aveugle. Cohérence croisée en bonus : # MANIFEST.counts.{sprint_deliverables,mvp_metrics} == (n_sprint, n_metric) — mord # un compteur du manifeste désynchronisé de la matrice qu'il résume. Un claim # absent échoue AUSSI (la disparition d'un chiffre est une régression · traçabilité). # ============================================================================ ACC_RD = f"{D}/qa/acceptance/README.md" ACC_FI = "03_agents/qa/AGENT.md" acc_exp = (str(n_sprint), str(n_metric)) acc_src = f"livrables_sprint={acc_exp[0]} métriques={acc_exp[1]}" try: acc_mc = load("qa/acceptance/out/MANIFEST.json")["counts"] if (str(acc_mc["sprint_deliverables"]), str(acc_mc["mvp_metrics"])) != acc_exp: bad(f"Recette · incohérence artefact : MANIFEST.counts dit " f"{acc_mc['sprint_deliverables']}/{acc_mc['mvp_metrics']} MAIS la matrice " f"dit {'/'.join(acc_exp)} (compteur désynchronisé de sa liste)") except (OSError, KeyError, ValueError) as e: bad(f"Recette · MANIFEST acceptance illisible/inattendu : {e}") # 1) README du module — deux puces distinctes (livrables sprint · métriques MVP) acc_rd = read_norm(ACC_RD) if acc_rd is not None: ms = re.search(r"\*\*(\d+) livrables de sprint\*\*", acc_rd) mm = re.search(r"\*\*(\d+) métriques succès MVP\*\*", acc_rd) if ms is None or mm is None: bad(f"Recette · {ACC_RD} — puces « N livrables de sprint » / « M métriques " f"succès MVP » INTROUVABLE(s) (attendu {acc_src})") elif (ms.group(1), mm.group(1)) == acc_exp: good(f"Recette · {ACC_RD} — {ms.group(1)}/{mm.group(1)} == matrice ({acc_src})") else: bad(f"Recette · {ACC_RD} — dit {ms.group(1)}/{mm.group(1)} MAIS matrice dit " f"{'/'.join(acc_exp)} ({acc_src}) → régénérer le README") # 2) Fiche agent QA — parenthèse « (N livrables + M métriques) » acc_fi = read_norm(ACC_FI) if acc_fi is not None: m = re.search(r"\((\d+) livrables \+ (\d+) métriques\)", acc_fi) if m is None: bad(f"Recette · {ACC_FI} — claim « (N livrables + M métriques) » INTROUVABLE " f"(attendu {acc_src})") elif m.groups() == acc_exp: good(f"Recette · {ACC_FI} — {'/'.join(m.groups())} == matrice ({acc_src})") else: bad(f"Recette · {ACC_FI} — dit {'/'.join(m.groups())} MAIS matrice dit " f"{'/'.join(acc_exp)} ({acc_src}) → régénérer la fiche") # ============================================================================ # 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) # ============================================================================ # CRM/Commissions · barème vendeurs — la CROSS-COHÉRENCE (« cœur du livrable ») # transcrite À LA MAIN dans le README sans AUCUN gate d'IDENTITÉ. # ---------------------------------------------------------------------------- # Le générateur commissions_gen émet out/commission_plan.json (byte-gaté par # check_artifacts ⇒ faisant autorité) : chaque évènement porte `update_value` # (l'état du pipeline qui PAIE) et `base_field` (le champ Currency de base). La # section « Cross-cohérence » du README ÉNUMÈRE À LA MAIN deux ensembles # data-derived + une propriété — le CŒUR du livrable : # • README:47 « on ne commissionne pas un brouillon (lead/visite/devis/ # abandonné) » — l'ensemble des états BROUILLON (doc_status=0 du workflow). # • README:50 « base_field … (`montant_reservation`, `montant_contrat`) » — # l'ensemble des champs de base cités par le plan. # • README:46 « correspondre à un état soumis (doc_status = 1) » — INVARIANT 3 # du module : on ne commissionne QUE des états soumis, jamais un brouillon. # Le bloc racine ne gate QUE le COMPTE de tests commissions (« 25 », via l'agrégat # « Total CRM »). Aucun gate ne comparait ces ENSEMBLES aux artefacts : PROMOUVOIR # un état brouillon en soumis (abandonne → doc_status 1) le retire de l'ensemble # brouillon pendant que la prose l'énumère encore ; AJOUTER un champ de base à un # évènement (base_field montant_confotur) l'ajoute sans que la prose bouge ; # RENOMMER un état passe aussi — « vert trompeur » qu'aucune suite tests/ (qui teste # des FONCTIONS finance/résolution, pas la prose) n'attrape. Même patron que # l'énumération des onglets Mobile, des transitions quatre-yeux CRM ou du format # e-NCF fiscal. On RECOMPUTE chaque ensemble depuis SON artefact byte-gaté (zéro # duplication du contrat du générateur #6) et on exige que la prose l'énumère # EXACTEMENT (absent ET en trop mordus via set-diff · accents/casse normalisés). # Cohérence croisée en bonus (mord un plan INTERNEMENT incohérent avec le workflow) : # tout `update_value` commissionné ∈ états soumis (doc_status=1) ET ∉ brouillons — # l'invariant 3 du module, non vide. Un claim absent échoue AUSSI (traçabilité). # ============================================================================ CM_RD = f"{D}/crm/commissions/README.md" def _cm_acc(s): return "".join(c for c in unicodedata.normalize("NFKD", s) if unicodedata.category(c) != "Mn") try: cm_ev = load("crm/commissions/out/commission_plan.json")["evenements"] cm_states = load("crm/workflow_vente/out/workflow.json")[0]["states"] cm_base = {e["base_field"] for e in cm_ev} cm_uv = {e["update_value"] for e in cm_ev} cm_draft = {s["update_value"] for s in cm_states if str(s["doc_status"]) == "0"} cm_subm = {s["update_value"] for s in cm_states if str(s["doc_status"]) == "1"} except (OSError, KeyError, ValueError, IndexError, TypeError) as e: bad(f"CRM commissions · artefact illisible/inattendu : {e}"); cm_ev = None if cm_ev is not None: # bonus : invariant 3 — tout état commissionné est SOUMIS (jamais brouillon), non vide if not cm_uv: bad("CRM commissions · aucun évènement commissionné (plan vide) → vérifier le barème") elif not (cm_uv <= cm_subm): bad(f"CRM commissions · incohérence plan↔workflow : update_values commissionnés " f"{sorted(cm_uv - cm_subm)} PAS soumis (doc_status≠1) → invariant 3 cassé") elif cm_uv & cm_draft: bad(f"CRM commissions · incohérence plan↔workflow : {sorted(cm_uv & cm_draft)} " f"commissionné(s) mais BROUILLON (doc_status=0) → invariant 3 cassé") else: good(f"CRM commissions · invariant 3 — {sorted(cm_uv)} tous soumis (doc_status=1), " f"aucun brouillon") cm_rd = read_norm(CM_RD) if cm_rd is not None: # 1) README:47 — ensemble des états BROUILLON exclus (doc_status=0 du workflow) mb = re.search(r"brouillon \(([^)]*)\)", cm_rd) if mb is None: bad(f"CRM commissions · {CM_RD} — énumération des brouillons exclus " f"INTROUVABLE (attendu {sorted(cm_draft)})") else: ps = {_cm_acc(t.strip().lower()) for t in re.split(r"[/,]", mb.group(1)) if t.strip()} exp = {_cm_acc(x) for x in cm_draft} if ps == exp: good(f"CRM commissions · {CM_RD} — brouillons exclus {sorted(ps)} == " f"workflow.json (doc_status=0)") else: miss = sorted(exp - ps); extra = sorted(ps - exp) detail = (f" · absents={miss}" if miss else "") + \ (f" · en trop={extra}" if extra else "") bad(f"CRM commissions · {CM_RD} — énumère {sorted(ps)} MAIS brouillons " f"workflow {sorted(exp)}{detail} → régénérer le README") # 2) README:50 — ensemble des champs de base (base_field du plan) mf = re.search(r"Currency réel\*\* du DocType Dossier Vente \(([^)]*)\)", cm_rd) if mf is None: bad(f"CRM commissions · {CM_RD} — énumération des champs de base " f"INTROUVABLE (attendu {sorted(cm_base)})") else: bs = set(re.findall(r"`([a-z_]+)`", mf.group(1))) if bs == cm_base: good(f"CRM commissions · {CM_RD} — champs de base {sorted(bs)} == " f"commission_plan.evenements[].base_field") else: miss = sorted(cm_base - bs); extra = sorted(bs - cm_base) detail = (f" · absents={miss}" if miss else "") + \ (f" · en trop={extra}" if extra else "") bad(f"CRM commissions · {CM_RD} — énumère {sorted(bs)} MAIS plan " f"{sorted(cm_base)}{detail} → régénérer le README") # ============================================================================ # RBAC/fixtures_gen · le CATALOGUE des DocTypes `custom` à créer sur le VPS et la # SÉPARATION DES POUVOIRS (le rôle unique porteur de `set_user_permissions`) — # deux faits data-derived transcrits À LA MAIN dans le README sans AUCUN gate # d'IDENTITÉ. # ---------------------------------------------------------------------------- # Le générateur rbac_fixtures_gen émet out/MANIFEST.json et out/custom_docperm.json # (byte-gatés par check_artifacts ⇒ faisant autorité) : # • MANIFEST.custom_doctypes_a_confirmer = l'ensemble des DocTypes `custom: true` # du contrat rbac_50_roles.json (ceux à CRÉER avant import — le reste est natif # v15). Le README §« Application sur VPS » l'ÉNUMÈRE À LA MAIN, en nommant même # le champ : « listés dans `MANIFEST.custom_doctypes_a_confirmer` (`Faisabilité`, # `Publiciste Log`, `CONFOTUR Application`, `API Access`) ». # • custom_docperm.json porte le flag `set_user_permissions`. Le CŒUR sécurité du # module (« séparation des pouvoirs · défense en profondeur ») est que ce flag # n'est émis que pour UN SEUL rôle — le README:48 dit « n'est émis que pour le # rôle **RBAC Admin** ». C'est l'ensemble {role | set_user_permissions=1}. # Le bloc racine ne gate QUE le COMPTE de tests fixtures (« 50 rôles / 116 DocPerm » # via l'agrégat RBAC de l'apply_plan) — jamais l'IDENTITÉ de ces deux ensembles. # Aucun gate ne les comparait à l'artefact : AJOUTER un DocType `custom` au contrat # (le MANIFEST rebâtit 5 entrées) OU RENOMMER/ÉCHANGER l'un des 4 laisse la prose à # ses 4 noms périmés ; PROMOUVOIR un 2ᵉ rôle porteur de `set_user_permissions` (ou # renommer RBAC Admin) laisse la prose « un seul rôle RBAC Admin » fausse — « vert # trompeur » qu'aucune suite tests/ (qui teste des FONCTIONS de mapping, pas la # prose du README) n'attrape. Même patron que l'énumération des onglets Mobile, des # brouillons CRM ou des open items de l'audit 5D. On RECOMPUTE chaque ensemble # depuis SON artefact byte-gaté (zéro duplication du contrat du générateur #6) et on # exige que la prose l'énumère/le nomme EXACTEMENT (absent ET en trop via set-diff · # accents/casse normalisés). Cohérence croisée en bonus : le catalogue est NON VIDE, # sans doublon et TRIÉ (byte-déterminisme du générateur) ; la séparation des pouvoirs # est un SINGLETON (ni vide — la garde serait vacante — ni multiple — élévation de # privilège). Un claim absent échoue AUSSI (traçabilité). # ============================================================================ FX_RD = f"{D}/rbac/fixtures_gen/README.md" def _fx_acc(s): return "".join(c for c in unicodedata.normalize("NFKD", s) if unicodedata.category(c) != "Mn").strip().lower() try: fx_cdt = load("rbac/fixtures_gen/out/MANIFEST.json")["custom_doctypes_a_confirmer"] fx_perm = load("rbac/fixtures_gen/out/custom_docperm.json") fx_sup = sorted({p["role"] for p in fx_perm if p.get("set_user_permissions")}) except (OSError, KeyError, ValueError, TypeError) as e: bad(f"RBAC fixtures · artefact illisible/inattendu : {e}"); fx_cdt = None if fx_cdt is not None: # bonus : le catalogue est un vrai ENSEMBLE trié non vide (byte-déterminisme) if not fx_cdt: bad("RBAC fixtures · custom_doctypes_a_confirmer VIDE → aucun DocType custom " "à créer ? (contrat suspect)") elif fx_cdt != sorted(set(fx_cdt)): bad(f"RBAC fixtures · custom_doctypes_a_confirmer {fx_cdt} non trié/dédupliqué " f"→ générateur non déterministe (attendu {sorted(set(fx_cdt))})") else: good(f"RBAC fixtures · catalogue custom {fx_cdt} — ensemble trié, sans doublon") # bonus : séparation des pouvoirs — un SEUL rôle porte set_user_permissions if len(fx_sup) != 1: bad(f"RBAC fixtures · set_user_permissions porté par {fx_sup} (attendu 1 seul " f"rôle) → séparation des pouvoirs cassée (élévation de privilège possible)") else: good(f"RBAC fixtures · set_user_permissions — singleton {fx_sup[0]!r} " f"(défense en profondeur intacte)") fx_rd = read_norm(FX_RD) if fx_rd is not None: # 1) README — énumération du catalogue custom (parenthèse après le champ nommé) me = re.search(r"custom_doctypes_a_confirmer`?\s*\(([^)]*)\)", fx_rd) if me is None: bad(f"RBAC fixtures · {FX_RD} — énumération du catalogue custom INTROUVABLE " f"(attendu {sorted(fx_cdt)})") else: got = {_fx_acc(t) for t in re.findall(r"`([^`]+)`", me.group(1))} exp = {_fx_acc(x) for x in fx_cdt} if got == exp: good(f"RBAC fixtures · {FX_RD} — catalogue custom {sorted(got)} == " f"MANIFEST.custom_doctypes_a_confirmer") else: miss = sorted(exp - got); extra = sorted(got - exp) detail = (f" · absents={miss}" if miss else "") + \ (f" · en trop={extra}" if extra else "") bad(f"RBAC fixtures · {FX_RD} — énumère {sorted(got)} MAIS MANIFEST " f"{sorted(exp)}{detail} → régénérer le README") # 2) README:48 — le rôle NOMMÉ porteur de set_user_permissions (séparation) if len(fx_sup) == 1: mr = re.search(r"émis que pour le rôle \*\*([^*]+)\*\*", fx_rd) if mr is None: bad(f"RBAC fixtures · {FX_RD} — claim « set_user_permissions n'est émis " f"que pour le rôle ** ** » INTROUVABLE (attendu {fx_sup[0]!r})") elif _fx_acc(mr.group(1)) in _fx_acc(fx_sup[0]): good(f"RBAC fixtures · {FX_RD} — rôle nommé « {mr.group(1).strip()} » ⊆ " f"{fx_sup[0]!r} (le seul porteur du flag)") else: bad(f"RBAC fixtures · {FX_RD} — nomme « {mr.group(1).strip()} » MAIS " f"set_user_permissions porté par {fx_sup[0]!r} → régénérer le README") # ============================================================================ # SEO · la COMPOSITION du graphe schema.org (JSON-LD) — un nœud racine # `Organization` + une `Residence` PAR PROJET — décrite EN PROSE dans le README # sans AUCUN gate d'IDENTITÉ. # ---------------------------------------------------------------------------- # Le générateur SEO émet out/seo_schema_org.json (byte-gaté par check_artifacts ⇒ # faisant autorité). Son `@graph` est l'ENSEMBLE des nœuds JSON-LD : exactement UN # nœud racine `Organization` (@id …#organization) + un listing PAR PROJET, tous du # même @type. Le README:16 décrit cette structure À LA MAIN : « graphe JSON-LD # (`Organization` + une `Residence` par projet) ». Le bloc SEO existant ne gate que # le COMPTE de mots-clés (keywords_total + ventilation par langue) — jamais les # @type du graphe ni la bijection un-listing-par-projet. Aucun gate ne comparait # ces TYPES à l'artefact : RENOMMER le @type des listings (Residence → Apartment, # rango schema.org) OU AJOUTER un 3ᵉ type de nœud (Offer/Product) OU casser la # bijection (2 nœuds pour un projet) ferait dériver la prose en silence pendant que # le graphe byte-gaté dit autre chose — « vert trompeur » qu'aucune suite tests/ # (qui teste des FONCTIONS de génération, pas la prose du README) n'attrape. Même # patron que l'énumération des onglets Mobile ou du catalogue custom RBAC. On # RECOMPUTE l'ENSEMBLE des @type depuis SON artefact byte-gaté (zéro duplication du # contrat du générateur #6) et on exige que la prose les nomme EXACTEMENT (absent ET # en trop via set-diff). Cohérences croisées en bonus : le nœud racine Organization # est un SINGLETON ; les listings sont uniformes (un seul @type) et en BIJECTION avec # les projets (listings == projects == schema_org_nodes−1 du MANIFEST byte-gaté) ; # aucune `offers` (statuts sans prix · USD #10). Un claim absent échoue AUSSI. # ============================================================================ SEO_SG_RD = f"{D}/seo/README.md" try: sg = load("seo/out/seo_schema_org.json")["@graph"] seo_cnt = load("seo/out/MANIFEST.json")["counts"] sg_org = [n for n in sg if str(n.get("@id", "")).endswith("#organization")] sg_list = [n for n in sg if not str(n.get("@id", "")).endswith("#organization")] sg_types = {n["@type"] for n in sg} # ensemble des @type du graphe org_types = {n["@type"] for n in sg_org} list_types = {n["@type"] for n in sg_list} sg_offers = sum(1 for n in sg if "offers" in n) except (OSError, KeyError, ValueError, TypeError) as e: bad(f"SEO schema.org · artefact illisible/inattendu : {e}"); sg = None if sg is not None: n_proj = seo_cnt.get("projects") # bonus 1 : racine Organization = SINGLETON, d'un seul @type if len(sg_org) != 1 or len(org_types) != 1: bad(f"SEO schema.org · nœud racine #organization non-singleton " f"({len(sg_org)} nœud(s), types={sorted(org_types)}) → graphe suspect") else: good(f"SEO schema.org · racine {sorted(org_types)[0]!r} — singleton (1 nœud)") # bonus 2 : listings uniformes + bijection 1 par projet + total du MANIFEST if not sg_list or len(list_types) != 1: bad(f"SEO schema.org · listings non uniformes (types={sorted(list_types)}) " f"→ un @type unique attendu par projet") elif not (len(sg_list) == n_proj == seo_cnt.get("listings") == seo_cnt.get("schema_org_nodes", 0) - 1): bad(f"SEO schema.org · bijection cassée : {len(sg_list)} listing(s) MAIS " f"projects={n_proj} listings={seo_cnt.get('listings')} " f"schema_org_nodes={seo_cnt.get('schema_org_nodes')} (attendu 1+{n_proj})") else: good(f"SEO schema.org · {len(sg_list)} × {sorted(list_types)[0]!r} — bijection " f"un listing par projet ({n_proj}) · total {seo_cnt.get('schema_org_nodes')} nœuds") # bonus 3 : aucune offers/prix (statuts sans prix · #10 · cohérent MANIFEST.offers) if sg_offers != 0 or seo_cnt.get("offers") != 0: bad(f"SEO schema.org · {sg_offers} nœud(s) `offers` (MANIFEST.offers=" f"{seo_cnt.get('offers')}) → prix non attendu pour statuts sans prix (#10)") else: good("SEO schema.org · aucune `offers`/prix (statuts sans prix · USD #10)") seo_sg_rd = read_norm(SEO_SG_RD) if seo_sg_rd is not None: # README:16 — la parenthèse décrit la composition du graphe : ENSEMBLE des @type msg = re.search(r"graphe JSON-LD \(([^)]*)\)", seo_sg_rd) if msg is None: bad(f"SEO schema.org · {SEO_SG_RD} — composition « graphe JSON-LD (…) » " f"INTROUVABLE (attendu {sorted(sg_types)})") else: prose_types = set(re.findall(r"`([A-Za-z]+)`", msg.group(1))) if prose_types == sg_types: good(f"SEO schema.org · {SEO_SG_RD} — @type {sorted(prose_types)} == " f"seo_schema_org.@graph") else: miss = sorted(sg_types - prose_types); extra = sorted(prose_types - sg_types) detail = (f" · absents={miss}" if miss else "") + \ (f" · en trop={extra}" if extra else "") bad(f"SEO schema.org · {SEO_SG_RD} — nomme {sorted(prose_types)} MAIS " f"graphe {sorted(sg_types)}{detail} → régénérer le README") # README:16 — le type PAR PROJET nommé == le @type des listings (racine distincte) if len(list_types) == 1: mpp = re.search(r"une `([A-Za-z]+)` par projet", seo_sg_rd) lt = sorted(list_types)[0] if mpp is None: bad(f"SEO schema.org · {SEO_SG_RD} — « une `` par projet » " f"INTROUVABLE (attendu {lt!r})") elif mpp.group(1) == lt: good(f"SEO schema.org · {SEO_SG_RD} — « une `{lt}` par projet » == " f"listings du graphe") else: bad(f"SEO schema.org · {SEO_SG_RD} — dit « une `{mpp.group(1)}` par projet » " f"MAIS listings sont `{lt}` → régénérer le README") # ============================================================================ # SEO · 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") # ============================================================================ # 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") 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"