Files
oto-enterprise-os-dtp/ci
Claude Code DTP Worker 607b22282b [DTP-Worker] Sprint 8 · buffer L60 · Domaine SEO (3e surface) : la CARTE hreflang (« carte alternate FR/EN/ES + x-default par page ») était décrite EN PROSE dans le README du module seo sans AUCUN gate d'IDENTITÉ. Les deux blocs SEO existants gatent le COMPTE de mots-clés (artefact seo_keywords) et les @type 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. Ce jeu est DATA-DERIVED : langues de contenu = {a.hreflang} − {x-default} de seo/out/seo_hreflang.json (byte-gaté par check_artifacts) = {en,es,fr} · x_default_lang = fr. Aucun gate ne comparait ces CODES à l'artefact : AJOUTER une langue au bundle (pt → 4 langues de contenu) · RETIRER/ÉCHANGER es ferait dériver la prose (README:17) 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 → nouveau bloc recomputant l'ENSEMBLE des langues de contenu depuis seo_hreflang.json (zéro duplication de la carte du générateur #6) et exigeant que la cellule les nomme EXACTEMENT (absent ET en trop via set-diff · casse normalisée) + mentionne x-default. Cohérences croisées en bonus (mordent un artefact INTERNEMENT incohérent) : carte UNIFORME (toutes les pages portent le même jeu d'alternate) · x-default présent sur CHAQUE page · 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 README:61). Un claim absent échoue AUSSI. 7 morsures vérifiées : prose échange ES→PT captée (absents=[es] en trop=[pt]) · prose retrait ES (sous-ensemble) capté · prose x-default supprimé capté · prose reformulée (carte INTROUVABLE) captée · artefact +langue pt sur 1 page (non uniforme) capté (2 jeux distincts + prose absents=[pt]) · artefact x_default_lang→de (défaut étranger + href incohérent) capté · artefact x-default.href≠canonical sur 1 page capté ; restauré = green : [en,es,fr] + x-default == seo_hreflang · uniforme · défaut fr · href cohérent (10 pages). État courant : aucune langue périmée (anti-invention #6, rien à réécrire) — le défaut est la surface ungated. ci/README.md (table + détail « 3e surface SEO ») mis à jour · 7 gates re-verts.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-31 18:07:08 +00:00
..
[DTP-Worker] Sprint 8 · buffer L60 · Domaine SEO (3e surface) : la CARTE hreflang (« carte alternate FR/EN/ES + x-default par page ») était décrite EN PROSE dans le README du module seo sans AUCUN gate d'IDENTITÉ. Les deux blocs SEO existants gatent le COMPTE de mots-clés (artefact seo_keywords) et les @type 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. Ce jeu est DATA-DERIVED : langues de contenu = {a.hreflang} − {x-default} de seo/out/seo_hreflang.json (byte-gaté par check_artifacts) = {en,es,fr} · x_default_lang = fr. Aucun gate ne comparait ces CODES à l'artefact : AJOUTER une langue au bundle (pt → 4 langues de contenu) · RETIRER/ÉCHANGER es ferait dériver la prose (README:17) 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 → nouveau bloc recomputant l'ENSEMBLE des langues de contenu depuis seo_hreflang.json (zéro duplication de la carte du générateur #6) et exigeant que la cellule les nomme EXACTEMENT (absent ET en trop via set-diff · casse normalisée) + mentionne x-default. Cohérences croisées en bonus (mordent un artefact INTERNEMENT incohérent) : carte UNIFORME (toutes les pages portent le même jeu d'alternate) · x-default présent sur CHAQUE page · 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 README:61). Un claim absent échoue AUSSI. 7 morsures vérifiées : prose échange ES→PT captée (absents=[es] en trop=[pt]) · prose retrait ES (sous-ensemble) capté · prose x-default supprimé capté · prose reformulée (carte INTROUVABLE) captée · artefact +langue pt sur 1 page (non uniforme) capté (2 jeux distincts + prose absents=[pt]) · artefact x_default_lang→de (défaut étranger + href incohérent) capté · artefact x-default.href≠canonical sur 1 page capté ; restauré = green : [en,es,fr] + x-default == seo_hreflang · uniforme · défaut fr · href cohérent (10 pages). État courant : aucune langue périmée (anti-invention #6, rien à réécrire) — le défaut est la surface ungated. ci/README.md (table + détail « 3e surface SEO ») mis à jour · 7 gates re-verts.
2026-07-31 18:07:08 +00:00
[DTP-Worker] Sprint 8 · buffer L60 · Domaine SEO (3e surface) : la CARTE hreflang (« carte alternate FR/EN/ES + x-default par page ») était décrite EN PROSE dans le README du module seo sans AUCUN gate d'IDENTITÉ. Les deux blocs SEO existants gatent le COMPTE de mots-clés (artefact seo_keywords) et les @type 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. Ce jeu est DATA-DERIVED : langues de contenu = {a.hreflang} − {x-default} de seo/out/seo_hreflang.json (byte-gaté par check_artifacts) = {en,es,fr} · x_default_lang = fr. Aucun gate ne comparait ces CODES à l'artefact : AJOUTER une langue au bundle (pt → 4 langues de contenu) · RETIRER/ÉCHANGER es ferait dériver la prose (README:17) 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 → nouveau bloc recomputant l'ENSEMBLE des langues de contenu depuis seo_hreflang.json (zéro duplication de la carte du générateur #6) et exigeant que la cellule les nomme EXACTEMENT (absent ET en trop via set-diff · casse normalisée) + mentionne x-default. Cohérences croisées en bonus (mordent un artefact INTERNEMENT incohérent) : carte UNIFORME (toutes les pages portent le même jeu d'alternate) · x-default présent sur CHAQUE page · 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 README:61). Un claim absent échoue AUSSI. 7 morsures vérifiées : prose échange ES→PT captée (absents=[es] en trop=[pt]) · prose retrait ES (sous-ensemble) capté · prose x-default supprimé capté · prose reformulée (carte INTROUVABLE) captée · artefact +langue pt sur 1 page (non uniforme) capté (2 jeux distincts + prose absents=[pt]) · artefact x_default_lang→de (défaut étranger + href incohérent) capté · artefact x-default.href≠canonical sur 1 page capté ; restauré = green : [en,es,fr] + x-default == seo_hreflang · uniforme · défaut fr · href cohérent (10 pages). État courant : aucune langue périmée (anti-invention #6, rien à réécrire) — le défaut est la surface ungated. ci/README.md (table + détail « 3e surface SEO ») mis à jour · 7 gates re-verts.
2026-07-31 18:07:08 +00:00

CI/CD · OTO Enterprise OS DTP · Gitea Actions

Livrable Sprint 1 (roadmap 04_roadmap/ROADMAP_8_WEEKS_OR_LESS.md §Sprint 1 · DevOps) « CI/CD Gitea Actions » — gate qualité qui conditionne tous les sprints suivants.

Plateforme : Gitea Actions UNIQUEMENT (CLAUDE.md contrainte #2 · JAMAIS GitHub).


1. Ce que fait le pipeline

Workflow : .gitea/workflows/ci.yml. Déclenché sur push / pull_request vers main et manuellement (workflow_dispatch). Jobs statiques (+ une suite unittest par module) :

Job Script Rôle Blocant
constraints-guard ci/guard_constraints.sh Enforce les contraintes NON-NÉGOCIABLES de CLAUDE.md oui
validate-json ci/validate_json.sh Parse strict de tous les *.json (schémas Faisabilité↔Publiciste) oui
check-docs ci/check_docs.sh Liens Markdown internes + présence auto-score 4Big oui (liens)
check-artifacts ci/check_artifacts.sh Reproductibilité : chaque out/*.json versionné == build frais oui
check-regression ci/check_regression.sh Fraîcheur : qa/regression/out/regression_run.json (run) == run frais + verdict PASS oui
check-ci-integrity ci/check_ci_integrity.sh Intégrité du câblage : gate.needs == tous les jobs non-manuels · chaque ci/*.sh câblé au gate oui
check-readme-claims ci/check_readme_claims.sh Intégrité des chiffres des docs d'entrée : chaque nombre de README.md « État courant », de la fiche QA « Verdict agrégé » et de la colonne « Tests » par suite des tables de fiches et des agrégats en prose (Total CRM / RBAC / e-CF) et des comptes par workspace des portails (cartes/liens/rôles, ×2 docs) et des comptes RBAC des 3 volets (table Nb rôles/profil, ligne agrégée apply_plan, ventilation userperm) et des comptes de mots-clés SEO (total + fr/en/es, ×2 docs) et du pipeline vente CRM (états/transitions, ×2 docs + énumération des transitions à séparation des pouvoirs `{action allow_self_approval=0}recomputée deworkflow.json) **et** de l'app mobile Expo (onglets/rôles/langues/identifiants a_confirmer + énumération ordonnée des onglets recomputée de role_navigation, bijection onglet⇔portail) **et** du DocType CONFOTUR (champs/données/sections/rôles/évènements de dépôt, ×2 docs) **et** de l'audit 5D (contrôles/dimensions ×2 docs + ventilation verdict pass/fail/à-confirmer + énumération des open items control→dimension→propriétairerecomputée deaudit_report.open_items) **et** du run-book de déploiement VPS (phases ×2 dans la fiche devops + confirmations préalables count & énumération dans le README) **et** du run-sheet de démo (nombre de modules cités dans le diagramme du README) **et** du montage Chat OTOIA par portail (Custom Block/configs runtime/portails, tous = 5 aujourd'hui, ×2 docs) **et** du module de recette qa/acceptance(couple livrables de sprint + métriques succès MVP, ×2 docs de module au-delà du README d'entrée) **et** du barème de l'auditeur 4Big (nombre de critères en prose + poids par critère de la table + « generator (N critères) », recomputés dequality_report.criteria) **et** de la carte de renormalisation par archétype de l'auditeur 4Big (critères EXCLUS de data_room/parser/contract, recomputés des checkspar archétype dequality_report.modules) **et** du contrat de format de l'e-NCF fiscal (E + tipoeCF(2) + secuencia(10) = 13: préfixe/longueurs des segments/longueur totale, ×3 README + ficheerpnext_backend, recomputés du bloc e_ncf d'ecf_plan.json+ cohérences croisées1+tipo+seq==longueur&
gate Agrégat vert = gate qualité 4Big franchi oui

Aucune dépendance réseau/marketplace hors actions/checkout. Tout tourne avec bash + git + python3 (déjà présents sur un runner standard).

2. Détail des contrôles

guard_constraints.sh — contraintes NON-NÉGOCIABLES

Détecte l'usage (pas la simple mention) de :

  • Plateformes git interdites : github.com, gitlab.com, bitbucket.org (#2).
  • CRM interdits : EspoCRM, HubSpot (#3).
  • Paiement interdit : Stripe (#10 · Cardnet only).
  • Écriture directe dans /var/www/html/static/ (Interdit absolu).
  • Commande git clean (Interdit absolu).
  • Remote git pointant ailleurs que Gitea/interne.

Zéro faux positif : une ligne contenant un marqueur de prohibition (jamais, , only, pas de, interdit…) est un rappel de règle → ignorée. Escape hatch documenté : ajouter ci-allow sur une ligne pour l'exclure.

validate_json.sh

Parse chaque *.json suivi (dont version.schema.json et projets_master.schema.json, contrat de données Faisabilité → Publiciste). Un JSON cassé casse le pipeline aval → attrapé ici.

check_docs.sh

  • [HARD] liens Markdown relatifs internes : la cible doit exister.
  • [SOFT] livrables 05_deliverables_mvp/*.md : mention d'auto-score 4Big attendue (≥95/100, CLAUDE.md #5). Avertissement seul, non blocant.

check_artifacts.sh

Régénère chaque artefact 05_deliverables_mvp/**/out/ versionné depuis son générateur (build -o <tmp>) et exige que tout fichier produit soit (a) suivi par git — jamais un artefact seulement sur disque (out/ .gitignore-é ou non-git add), qui serait absent en CI propre et rendrait le gate « vert en local » par artefact fantôme (même classe que le bug regression_run.json) — et (b) en égalité byte-for-byte avec le fichier commité. Découverte automatique (zéro liste à la main · #6) : tout module avec un out/ et un générateur build entre dans le gate.

Cible la dérive silencieuse : un module auto-résout des valeurs depuis les artefacts d'autres modules (ex. demo/scenarios lit qa/audit_4big/coverage/ci_modules_count) ; quand la source grandit, l'artefact consommateur devient périmé s'il n'est pas régénéré — dérive qu'aucune suite tests/ (qui teste des fonctions, pas le fichier commité) n'attrape. Correctif : build -o out puis commit. Les artefacts d'exécution non produits par build (p.ex. qa/regression/out/regression_run.json, issu de run) sont hors de ce gate — ils sont couverts par check-regression ci-dessous.

check_regression.sh

Rejoue la matrice de régression complète (qa/regression/regression_gen.py run, ~5 s, stdlib pur) vers un tmp et exige que le regression_run.json frais soit byte-identique au commité, puis que son verdict soit PASS. Complément direct de check_artifacts : celui-ci ne rejoue que build (→ regression_plan.json) et laisse hors périmètre l'artefact d'exécution regression_run.json — pourtant c'est lui qui porte le compte agrégé (suites · tests · passés · verdict) cité dans la doc et les logs — recompté par check_readme_claims dans les fiches d'entrée, jamais figé en dur ici. Sans ce gate, ce compte pouvait se périmer en silence (module + job CI ajoutés sans régénérer la matrice → compte de suites faux ; c'est la dérive « demo 18→21 » corrigée à la main), ou une matrice rouge être commitée verte. regression_run.json ne contient aucun horodatage/hôte → le run est déterministe et l'égalité exacte licite. Correctif : regression_gen.py run -o out puis commit.

check_ci_integrity.sh

Prouve, en lisant .gitea/workflows/ci.yml, que le câblage du workflow tient — car le job gate est le seul verrou de merge : un check absent de son needs: ne bloque rien, même rouge. Deux invariants :

  • INV-Agate.needs == { tous les jobs définis } { gate } { jobs manuels } (un job manuel = gardé par if: … workflow_dispatch …, ex. e2e-baseline, légitimement hors du gate push/PR car il exige un serveur live). Détecte un job oublié du gate (MISSING → ne bloque pas), une référence fantôme (DANGLING → typo / job renommé-supprimé) et un job manuel glissé dans needs (gate en attente perpétuelle sur push).
  • INV-B — chaque script ci/*.sh du dépôt est câblé : soit lancé par un job (run: bash ci/<script>) lui-même dans gate.needs, soit sourcé par ≥1 gate script (lib partagée ci/lib.sh — jamais un job propre). Un nouveau gate statique non câblé (script mort) ou décâblé, ou une lib morte (sourcée par personne), casse le check.

Ferme le trou laissé par les couvertures existantes (audit_4big/registry, qa/regression/discovery) qui ne prouvent l'appartenance au gate que des jobs de module (ceux portant un working-directory:) — les gates statiques sans working-directory n'étaient gardés par personne. Même classe d'anti-dérive que INV4 (disque→CI), appliquée au câblage CI. stdlib pur (bash/awk/git), zéro réseau.

check_readme_claims.sh

Le README.md est le point d'entrée du mandat ; sa section « État courant (sourcé) » affiche des chiffres et déclare « 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 gardée par aucun gate : check_docs.sh ne valide que les liens (la cible existe), jamais la valeur des nombres. 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 dit autre chose — un README qui se contredit avec sa propre source est un « vert trompeur » (même classe que la matrice périmée « demo 18→21 » ou 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 trois docs d'entrée — README.md :

  • modules gated N/M à K/100 + verdict PASSqa/audit_4big/out/quality_report.json (totals) ;
  • N suites gated → qa/regression/out/regression_plan.json (totals.suites) ;
  • N promesses (X sprint + Y métriques), Z in_repo, verdict trueqa/acceptance/out/acceptance_matrix.json ;
  • « 13 agents » (×2 : nav + titre) → git ls-files 03_agents/*/AGENT.md.

Et la fiche QA 03_agents/qa/AGENT.md (section « Verdict agrégé courant », qui se disait « jamais compté à la main » tout en portant un compte figé qui s'est périmé — « 21 suites · 534 tests » alors que le run agrégé faisant autorité en disait davantage) :

  • N suites · M tests · M passés · E échec · E erreur · verdict → qa/regression/out/regression_run.json (compte agrégé faisant autorité, commité).

Et la fiche ERPNext Backend 03_agents/erpnext_backend/AGENT.md (ligne « source unique », même compte agrégé jadis saisi à la main qui s'était périmé — « 560 tests ») :

  • N tests · M suites · verdict → qa/regression/out/regression_run.json (matrice de régression du repo, même source faisant autorité que la fiche QA).

Et — au grain le plus fin — la colonne « Tests » des tables de livrables de toutes les fiches (03_agents/*/AGENT.md · crm, faisabilite, publiciste, qa) : chaque cellule par suite était saisie à la main et se périmait dès qu'un test était ajouté (fiche QA : acceptance 31 alors que la suite en portait 37 · audit_4big 35→34 · regression 25→26). Le compte agrégé ci-dessus (« 564 tests ») ne suffit pas : une compensation entre deux suites (+1 / 1) laisserait la somme juste et les deux lignes fausses. On recompute donc chaque cellule depuis qa/regression/out/regression_plan.json (suites[path].test_methods) — et depuis 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é.

Enfin — même classe, un cran au-dessus des cellules — les agrégats rédigés en prose dans deux fiches (crm : « Total CRM 81 tests (25 + 31 + 25) » ; erpnext_backend : « RBAC 60 tests (10 + 11 + 12 + 11 + 16) + e-CF 39 tests »). Le total ET le multiset des composants (ordre-indépendant) sont recomputés depuis suites[path].test_methods : une compensation entre deux suites laisserait la table (gatée ci-dessus) juste et la prose fausse — c'est le même piège que les cellules, au niveau agrégé.

Même classe encore, hors du domaine « tests » : les comptes par workspace (cartes / liens / rôles) des portails frontend, transcrits à la main dans deux docs — la table du README du module (frontend/portails/README.md) et la prose de la fiche agent (03_agents/frontend_console/AGENT.md : « OTO Ventes 4 cartes/11 liens/12 rôles · … »). Chaque triplet et le total « N Workspace natifs » sont recomputés depuis frontend/portails/out/MANIFEST.json (tableau workspaces, lui-même byte-gaté par check_artifacts) : ajouter un rôle/DocType au contrat RBAC ferait dériver les deux docs en silence sinon.

Même classe enfin sur les comptes RBAC des 3 volets (rbac/roleprofile_gen, rbac/apply_plan, rbac/userperm_gen), recopiés à la main dans leurs READMEs de module : la table « Nb rôles » par profil (+ total bijectif 50), la ligne agrégée « Génération réelle : 6 étapes · 50 rôles / 116 DocPerm / 28 UP templates / 6 Role Profile · 4 DocType custom + 5 Company + 4 rôles equipe » (8 comptes de tous les volets en une phrase) et la ventilation « 28 entite · 16 groupe · 2 own · 4 equipe ». Chaque nombre est recomputé depuis le out/MANIFEST.json du volet (recalculé du contrat rbac_50_roles.json à chaque build, byte-gaté par check_artifacts) : ajouter un rôle/DocType ferait dériver les trois READMEs en silence sinon.

Même classe enfin côté SEO : le total et la ventilation par langue des mots-clés (« 258 mots-clés (fr=87 · en=87 · es=84) ») étaient transcrits à la main dans deux docs — la table « Livrables » du README de module (05_deliverables_mvp/seo/README.md) et la même ligne de la fiche agent (03_agents/seo/AGENT.md) — sans aucun gate. Le quadruplet (total + fr/en/es) est recomputé depuis seo/out/MANIFEST.json (counts.keywords_total + counts.keywords_per_lang, recomptés de projets_master.json à chaque build, byte-gaté par check_artifacts). Recomputer les trois langues, pas seulement le total, attrape une compensation entre langues (fr 1 / en +1) qui laisserait le total juste et la ventilation fausse — ajouter un projet ferait sinon dériver les deux docs en silence. 2ᵉ surface du même README SEO : la table « Livrables » décrit en prose la composition du graphe schema.org (« graphe JSON-LD (Organization + une Residence par projet) »). Le bloc des comptes de mots-clés est aveugle à ces @type : renommer le type des listings (ResidenceApartment, un @type voisin du rango schema.org), ajouter un 3ᵉ type de nœud (Offer/Product) ou casser la bijection (deux nœuds pour un projet) laisse la prose figée pendant que seo/out/seo_schema_org.json (byte-gaté par check_artifacts) dit autre chose. On recompute l'ensemble des @type du @graph et on exige que la parenthèse les nomme exactement (set-diff : absent ET en trop ; plus le type « par projet » nommé == le @type des listings). En bonus, trois cohérences croisées de l'artefact : le nœud racine Organization (@id …#organization) est un singleton, les listings sont uniformes (un seul @type) et en bijection avec les projets (listings == projects == schema_org_nodes1 du MANIFEST), et le graphe ne porte aucune offers — cohérent avec « aucun prix pour un statut sans prix » (USD · #10). Un claim supprimé (« composition INTROUVABLE ») échoue aussi.

3ᵉ surface du même README SEO : la table « Livrables » décrit en prose la carte hreflang (« carte alternate FR/EN/ES + x-default par page »). Les deux blocs SEO précédents (comptes de mots-clés · graphe schema.org) sont aveugles à ces codes de langue, qui vivent dans un troisième artefact (seo/out/seo_hreflang.json, byte-gaté par check_artifacts) : ajouter une langue au bundle (pt → 4 langues de contenu), retirer ou échanger es laisse la prose figée pendant que la carte dit autre chose. On recompute l'ensemble des langues de contenu (alternate hors x-default) et on exige que la cellule les nomme exactement (set-diff : absent ET en trop) + mentionne x-default. En bonus, quatre cohérences croisées de l'artefact : la carte est uniforme (toutes les pages portent le même jeu d'alternate), x-default est présent sur chaque page, la langue par défaut (x_default_lang) ∈ langues de contenu (ni vacante ni étrangère), et 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). Un claim supprimé (« carte INTROUVABLE ») échoue aussi.

Même classe enfin sur le pipeline vente CRM : le couple « 9 états · 11 transitions » (titre de section du README du module crm/workflow_vente) et le même compte d'états « les 9 états du pipeline » (README du DocType porteur crm/dossier_vente, dérivé du même graphe) étaient transcrits à la main dans deux docs sans aucun gate. Les deux nombres sont recomputés depuis crm/workflow_vente/out/MANIFEST.json (counts.states + counts.transitions, recomptés du workflow_vente_spec.json à chaque build, byte-gaté par check_artifacts). Recomputer transitions en plus de states mord une dérive du 2ᵉ nombre là où le compte d'états seul (repris dans dossier_vente) resterait aveugle — ajouter un état/une transition ferait sinon dériver les deux docs en silence. 2ᵉ surface du même README : la phrase « séparation des pouvoirs » énumère à la main les transitions quatre-yeux (« confirmer réservation, signer contrat, approuver CONFOTUR, annuler/résilier — … allow_self_approval = 0 »). Le bloc de compte est aveugle à leur identité : déclasser une transition sensible (allow_self_approval 0→1 dans le contrat) laisse le compte à 9/11 tandis que l'artefact la retire de l'ensemble ; promouvoir une transition (ex. Déposer CONFOTUR → self=0) l'y ajoute sans que le compte bouge et la prose l'omet ; un échange d'action passe aussi. On recompute l'ensemble des actions quatre-yeux depuis crm/workflow_vente/out/workflow.json ({action | allow_self_approval == 0}, byte-gaté) et on exige que la phrase l'énumère exactement (absent et en trop mordus par set-diff ; 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 : 0 < |quatre-yeux| < |transitions| (l'ensemble sensible est non vide et strict). 4 morsures vérifiées (déclassement Signer contrat capté là où le compte reste 9/11 · promotion Déposer CONFOTUR captée · échange prose signer contratsigner bail capté · énumération supprimée = INTROUVABLE ; restauré = green).

Même classe encore sur l'app mobile Expo : la ligne récap du README du module mobile/app_config5 onglets · 44 rôles couverts · 3 langues · 13 identifiants a_confirmer ») transcrivait à la main un quadruplet dont chaque nombre est recompté à chaque build depuis rbac_50_roles.json (portails métier + rôles), seo_spec.json (langues) et la liste a_confirmer (credentials EAS/stores jamais fabriqués). Les quatre sont recomputés depuis mobile/app_config/out/MANIFEST.json (counts.{onglets,roles_couverts,langues, identifiants_a_confirmer}, byte-gaté par check_artifacts). Recomputer les quatre volets mord une dérive de n'importe lequel (ajouter un rôle/portail RBAC, une langue, un credential store) là où un seul nombre resterait aveugle. 2ᵉ surface du même README : la même ligne récap énumère les 5 onglets (« (Ventes/Construction/Achat/Compta/Direction) ») — le patron du compte matche la parenthèse par \([^)]*\) mais en ignore le contenu. Cette liste est data-derived : mobile/app_config/out/role_navigation.json (byte-gaté) porte un onglet par portail métier (bijection 1 onglet ⇔ 1 portail), chaque entrée = son label « OTO ». On recompute la liste ordonnée des noms courts et on exige que la parenthèse l'énumère exactement : un échange (Ventes→ Marketing), un retrait (Compta absent — l'énumération tombe à 4 tandis que « 5 onglets » reste juste) ou un réordre est mordu là où le seul compte est aveugle. Cohérence croisée : len(role_navigation) == counts.onglets == counts.portails — mord un compteur du manifeste désynchronisé de sa navigation.

Même classe enfin sur le DocType CONFOTUR Application (livrable legal/confotur) : la ligne de synthèse du README du module (« 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 transcrivaient à la main le même quintuplet, dérivé du spec du DocType. Les cinq nombres sont recomputés depuis legal/confotur/out/MANIFEST.json (counts.{fields,data_fields,sections,roles, depot_events}, recomptés du confotur_spec.json à chaque build, byte-gaté par check_artifacts) et comparés aux deux docs. Recomputer les cinq volets mord une dérive de n'importe lequel (ajouter un champ/une section/un rôle au spec) là où un seul nombre resterait aveugle.

Même classe enfin sur la synthèse de l'audit 5D (livrable qa/audit_5d) : la phrase d'ouverture du README du module (« 17 contrôles répartis en 5 dimensions (5D) ») et la cellule de la fiche agent qa (« 17 contrôles / 5 dimensions ») transcrivaient à la main le même couple, dérivé du catalogue de contrôles. Ce couple est recomputé depuis qa/audit_5d/out/MANIFEST.json (controls_total + len(dimensions), byte-gaté par check_artifacts) et comparé aux deux docs. La ventilation du verdict portée par le seul README (« 13 PASS · 0 FAIL · 4 à confirmer ») est recomputée à part depuis qa/audit_5d/out/audit_report.json (totals.{pass,fail,a_confirmer}, aussi byte-gaté) : elle mord une bascule d'un contrôle en A_CONFIRMER (pass 13→12, à confirmer 4→5) là où le total de contrôles reste 17 et resterait aveugle. Cohérence croisée en bonus : controls_total du MANIFEST doit égaler totals.controls du rapport.

Deuxième surface du même README de l'audit 5D : la table « Verdict courant » énumère à la main les 4 open items (| D1.1 · … | D1 | Direction | …), chacun portant trois faits data-derived — l'id du contrôle, sa dimension et son propriétaire. Le bloc de ventilation ci-dessus ne gate que le compte (« 4 à confirmer ») : un échange d'open item (ex. D2.3 → D3.1), une dimension mal étiquetée (D1.3 rangé sous D2) ou un propriétaire réattribué laisserait le compte à 4 et passerait en silence. On RECOMPUTE l'ensemble {control:(dimension, propriétaire)} depuis qa/audit_5d/out/audit_report.json (open_items[], byte-gaté) 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.

Même classe enfin sur le run-book de déploiement VPS (livrable devops/deploy_runbook) : le compte de phases (fiche agent devops — « graphe de 7 phases ordonnées » et « exécute les 7 phases sur le VPS ») et le compte de confirmations préalables (README du module — « renseigner les 7 confirmations ») transcrivaient à la main deux nombres qui, aujourd'hui, valent tous deux 7 — un gate qui n'en recompterait qu'un serait aveugle à la dérive de l'autre. Chacun est recomputé depuis son propre champ de devops/deploy_runbook/out/MANIFEST.json (counts.phases = len(graph.phase_order) · counts.confirmations = len(confirmations_ouvertes), byte-gaté par check_artifacts) et comparé aux docs. Bonus anti-dérive : l'énumération des confirmations du README (les tokens snake_case) doit être l'ensemble exact de confirmations_ouvertes — mord l'échange d'un nom de confirmation là où le seul compte (reste 7) resterait aveugle.

Même classe enfin sur le run-sheet de démo (livrable demo/scenarios) : le nombre de modules cités, transcrit à la main dans le diagramme d'architecture du README du module (« out/ des modules livrés (10 modules cités) »), pointait l'artefact généré sans qu'aucun gate ne le recompute. Il est recomputé depuis demo/scenarios/out/MANIFEST.json (counts.modules_cites_uniques = |union des modules_cites des deux scénarios|, byte-gaté par check_artifacts) et comparé au README ; ajouter un beat qui cite un module non encore tissé (11), ou retirer la dernière citation d'un module (9), ferait dériver le diagramme en silence. Cohérence croisée en bonus : counts.modules_cites_uniques == |modules_cites| (liste dédupliquée du manifeste) — mord un compteur désynchronisé de sa propre liste.

Même classe enfin sur le montage Chat OTOIA par portail (livrable frontend/chat_otoia) : le nombre de Custom Block, de configs runtime et de portails couverts, transcrits à la main dans DEUX docs (fiche agent frontend_console — « N Custom Block + M configs runtime » · README du module — « Portails (P, plateforme exclu) », « N fixtures Custom Block », « M configs runtime »), pointaient l'artefact sans qu'aucun gate ne les recompute. Ces trois nombres valent tous 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). Chacun est recomputé depuis son propre champ de frontend/chat_otoia/out/MANIFEST.json (counts.custom_blocks · counts.mounts · counts.portails, byte-gaté par check_artifacts) et comparé aux docs. Cohérence croisée : custom_blocks == mounts == portails == |MANIFEST.portails| (la bijection 1 block ⇔ 1 mount ⇔ 1 portail du générateur) — mord un compteur désynchronisé de sa propre liste.

Même classe enfin sur le module de recette (qa/acceptance) : le couple « N livrables de sprint + M métriques succès MVP » est transcrit à la main dans DEUX docs que le bloc racine ne couvre pas — le README du module (deux puces) ET la fiche agent QA (« (N livrables + M métriques) »). Le bloc racine ne gate que le SEUL README d'entrée (« N promesses (X sprint + Y métriques) ») ; ces deux docs de module en dérivent. Ajouter une promesse roadmap (l'artefact rebâtit 9/7) ferait dériver les deux docs en silence — piège que ni les suites tests/ ni le roadmap-anchor (qui ancre les roadmap_line de l'ARTEFACT, pas la prose des docs) n'attrapent. Chaque nombre est recomputé depuis son propre genre de la matrice qa/acceptance/out/acceptance_matrix.json (kind sprint_deliverable / mvp_metric, byte-gatée par check_artifacts) et comparé aux deux docs. Cohérence croisée en bonus : MANIFEST.counts.{sprint_deliverables,mvp_metrics} == le compte de la matrice qu'il résume — mord un compteur du manifeste désynchronisé de sa liste.

Même classe enfin sur le barème de l'auditeur 4Big (livrable qa/audit_4big). Ironie déjà notée : le module qui NOTE les autres retranscrit son propre référentiel de scoring à la main dans son README (05_deliverables_mvp/qa/audit_4big/README.md) — la prose « Note chaque module sur N critères 4Big déterministes », la table « Critère | Poids | … » (une ligne id→poids par critère) et l'archétype « generator (N critères) ». Ces trois surfaces DÉRIVENT de quality_report.json.criteria (byte-gaté par check_artifacts, recomputé du référentiel à chaque build) ; ajouter/retirer un critère ou changer un poids ferait dériver le README en silence pendant que l'artefact dit autre chose — dérive qu'aucune suite tests/ (qui teste des FONCTIONS, pas la prose) n'attrape. Le bloc racine ne gate que la ligne « Résultat courant » (verdict/modules), jamais le barème. On recompute donc le nombre de critères (prose + « generator »), et chaque poids depuis criteria[].weight (zéro duplication du barème du générateur · #6). Cohérences croisées : Σ poids == 100 (base de renormalisation documentée) ET tout module d'archétype generator porte EXACTEMENT len(criteria) checks — c.-à-d. « generator = tous les critères », ce qui rend le « generator (N critères) » du README vérifiablement vrai (mord une dérive du modèle qui le rendrait faux). Une compensation (un poids +5 / un autre 5) laisserait Σ == 100 juste et deux lignes fausses : d'où le contrôle par critère, pas seulement de la somme. 4 morsures vérifiées : dérive prose 5→6 (poids intacts) · dérive poids TESTS 25→30 (compte intact) · dérive generator (5→4) · claim prose disparu ; restauré = vert.

Une autre surface du même README est la carte de renormalisation par archétype : « generator (5 critères) · data_room (sans HANDOFF) · parser (sans CONTRAT local ni HANDOFF) · contract (sans CLI ni HANDOFF) ». Le bloc barème ne recomputait QUE generator ; les exclusions des trois autres archétypes n'étaient recomputées par AUCUN gate. Or elles sont DATA-DERIVED : pour un archétype, l'ensemble des critères applicables = {check.criterion} de ses modules dans quality_report.json (les non-applicables sont absents des checks) ⇒ exclus = ordre du barème applicables. Changer la règle de renormalisation d'un archétype (ex. data_room exclut aussi CLI) ferait dériver cette prose en silence. On recompute donc, PAR archétype, l'ensemble des critères exclus et on exige que la parenthèse du README l'énumère exactement. Bonus d'uniformité : tous les modules d'un même archétype portent le même jeu applicable (« 1 archétype → 1 jeu de critères », base même de la carte). 3 morsures vérifiées : data_room « sans HANDOFF »→« sans CLI » (mauvais critère) · parser exclusion HANDOFF retirée (sous-ensemble) · énumération contract supprimée ; restauré = vert.

Même classe enfin sur le contrat de format de l'e-NCF (livrable fiscal/ecf_dgii) : le format de l'identifiant fiscal électronique DGII « E + tipoeCF(2) + secuencia(10) » (= 13) — préfixe, longueur du segment tipo, longueur du segment secuencia et longueur totale — est le CŒUR du module (le « composeur traçable ») et il est retranscrit à la main dans quatre endroits de prose : le résumé du README (E + tipoeCF(2) + secuencia(10)), l'invariant 7 du README (E+tipo(2)+seq(10)=13, seul à porter la longueur totale), la section composition du README (e_ncf = "E" + tipo(2) + secuencia(10)) et la fiche agent erpnext_backend. Le bloc racine ne gate que le compte de tests e-CF (« 39 ») ; ce format n'était recomputé par AUCUN gate. Or il est data-derived du bloc e_ncf d'fiscal/ecf_dgii/out/ecf_plan.json (byte-gaté par check_artifacts) : prefix (« E »), longueur (13), regex^E[0-9]{12}$ ») et structure (« E + tipoeCF (2 chiffres) + secuencia (10 chiffres) »). Allonger la secuencia (un rango DGII à 12 chiffres → longueur 15) ou changer le préfixe ferait dériver les quatre proses en silence pendant que l'artefact dit autre chose — dérive qu'aucune suite tests/ (qui teste les FONCTIONS compose_encf/parse_encf, pas la prose) n'attrape. 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) : 1 + tipo_len + seq_len == longueur (1+2+10 == 13) ET le nombre de chiffres du regex == tipo_len + seq_len (12 == 2+10). 5 morsures vérifiées : drift de l'artefact source (secuencia 10→12, les 4 proses mordues) · résumé README tipo 2→3 · invariant README total 13→14 (aveugle au segment) · fiche erpnext_backend secuencia 10→9 · composition README supprimée (INTROUVABLE) ; plus la morsure d'incohérence d'artefact (regex désaligné du segment) ; restauré = vert.

Même classe enfin sur la cross-cohérence du barème de commissions (livrable crm/commissions) — le CŒUR du module. La section « Cross-cohérence » de son README énumère à la main deux ensembles data-derived + une propriété : les états brouillon exclus « lead/visite/devis/abandonné » (README:47), les champs de base « montant_reservation, montant_contrat » (README:50) et l'invariant « on ne commissionne QUE des états soumis (doc_status = 1) » (README:46). Le bloc racine ne gate que le compte de tests commissions (« 25 », via l'agrégat « Total CRM ») ; ces ensembles n'étaient recomputés par AUCUN gate. Or ils sont data-derived : les brouillons = {update_value | doc_status == 0} de crm/workflow_vente/out/workflow.json, les champs de base = {base_field} de crm/commissions/out/commission_plan.json (les deux byte-gatés par check_artifacts). PROMOUVOIR un état brouillon en soumis, AJOUTER/RENOMMER un champ de base ou ÉCHANGER un état ferait dériver la prose en silence pendant que l'artefact dit autre chose — dérive qu'aucune suite tests/ (qui teste des FONCTIONS finance/résolution, pas la prose) n'attrape. On RECOMPUTE chaque ensemble depuis SON artefact (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 ET ∉ brouillons — l'invariant 3 du module, non vide. 5 morsures vérifiées : échange brouillon (abandonné→perdu) · sous-ensemble des champs de base (retrait montant_contrat) · énumération des brouillons supprimée (INTROUVABLE) · plan pointant un brouillon (reservation→lead, invariant 3 cassé) · promotion de l'état abandonne en soumis dans l'artefact (draft set change, « en trop » dans la prose) ; restauré = vert.

Même classe enfin sur le catalogue RBAC (livrable rbac/fixtures_gen) — deux faits data-derived transcrits à la main dans le README. (1) La liste des DocTypes custom à créer avant import « (Faisabilité, Publiciste Log, CONFOTUR Application, API Access) » (README §« Application sur VPS ») == l'ensemble custom_doctypes_a_confirmer d'rbac/fixtures_gen/out/MANIFEST.json (les DocTypes custom: true du contrat RBAC). (2) La séparation des pouvoirs — « le flag set_user_permissions n'est émis que pour le rôle RBAC Admin » (README:48) == l'ensemble {role | set_user_permissions == 1} d'out/custom_docperm.json. Le bloc racine ne gate que le compte (« 50 rôles / 116 DocPerm » via l'agrégat RBAC de l'apply_plan) — jamais l'identité de ces deux ensembles. AJOUTER un DocType custom au contrat, RENOMMER/ÉCHANGER l'un des 4 ou PROMOUVOIR un 2ᵉ rôle porteur du flag (élévation de privilège) ferait dériver la prose en silence pendant que l'artefact byte-gaté dit autre chose — dérive qu'aucune suite tests/ (qui teste des FONCTIONS de mapping, pas la prose du README) n'attrape. On RECOMPUTE chaque ensemble depuis SON artefact (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 mordus via set-diff · accents/casse normalisés). Cohérences croisées en bonus : le catalogue est non vide, sans doublon et trié (byte-déterminisme du générateur) et la séparation des pouvoirs est un singleton (ni vide — garde vacante — ni multiple — élévation de privilège). 6 morsures vérifiées : échange d'un nom de DocType (Faisabilité → Faisabilite2) · sous-ensemble (retrait API Access) · rôle nommé faux (RBAC Admin → Ventes Conseiller) · énumération supprimée (INTROUVABLE) · 2ᵉ rôle promu au flag dans l'artefact (singleton cassé) · catalogue MANIFEST non trié (byte-déterminisme) ; restauré = vert.

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é). stdlib pur (bash/git/python3), zéro réseau.

lib.sh — helper partagé (sourcé, jamais exécuté seul)

Les gates commençaient tous par cd "$(git rev-parse --show-toplevel)". Hors d'un arbre de travail git (tarball, git archive | tar -x, git absent du PATH), git rev-parse n'écrit rien → cd "" est un no-op qui RETOURNE SUCCÈS : le gate poursuivait, git ls-files renvoyait une liste vide, et validate_json / guard_constraints / check_docs sortaient exit 0 VERT en n'ayant RIEN contrôlé — le pire « vert trompeur ». ci/lib.sh factorise ce préambule dans cd_repo_root, qui échoue bruyamment (exit 3, code distinct d'un échec de contrôle 1) hors d'un checkout git. check_ci_integrity (INV-B) prouve que lib.sh est bien sourcé par des gates (pas du code mort) sans exiger qu'il figure dans un job.

3. Exécution locale (avant push)

bash ci/guard_constraints.sh   # contraintes CLAUDE.md
bash ci/validate_json.sh       # schémas JSON
bash ci/check_docs.sh          # liens + score
bash ci/check_artifacts.sh     # reproductibilité out/ (build)
bash ci/check_regression.sh    # fraîcheur matrice régression (run)
bash ci/check_ci_integrity.sh  # intégrité du câblage CI (gate ⊇ tous jobs)
bash ci/check_readme_claims.sh # chiffres du README == artefacts cités (#6)

Chaque script retourne 0 si conforme, 1 sinon. Reproduit exactement ce que fait la CI (mêmes scripts, aucune logique cachée côté YAML).

4. Enregistrement du runner Gitea (ops · à faire sur le VPS)

⚠ Étape hors périmètre de ce worker (touche au VPS). À réaliser par l'agent DevOps. Documenté ici pour traçabilité.

Le workflow cible un runner avec le label ubuntu-latest. Sur le VPS Gitea (:3015), enregistrer un act_runner :

# Sur le VPS, récupérer le token runner :
#   Gitea → Site Administration → Actions → Runners → Create new Runner
act_runner register \
  --instance http://153.75.250.214:3015 \
  --token <RUNNER_TOKEN> \
  --labels ubuntu-latest:docker://node:20-bookworm \
  --name oto-dtp-runner
act_runner daemon    # ou service systemd dédié

Vérifier ensuite : Gitea → repo michel/oto-enterprise-os-dtpSettings → Actions doit être activé, et le runner doit apparaître « Idle ».

5. Checklist de vérification (DevOps, sur VPS)

  • [ ] Gitea Actions activé au niveau instance ET repo.
  • [ ] act_runner enregistré, label ubuntu-latest, statut Idle.
  • [ ] Push de test → les 7 gates statiques apparaissent et passent au vert.
  • [ ] PR de test avec violation volontaire → constraints-guard bloque (rouge).

Auto-score 4Big du livrable : 96/100. Réserve 4 : l'enregistrement du runner (§4) est hors périmètre repo et reste à confirmer sur le VPS par DevOps ; tant que le runner n'est pas Idle, la CI ne s'exécute pas côté serveur bien que les scripts soient validés localement.