1d21a3788d
CI / Contraintes NON-NÉGOCIABLES (CLAUDE.md) (push) Has been cancelled
CI / Validation JSON (schémas Faisabilité) (push) Has been cancelled
CI / Qualité documentaire (liens + 4Big) (push) Has been cancelled
CI / Reproductibilité des artefacts out/ (build == commité) (push) Has been cancelled
CI / Fraîcheur matrice de régression (run == commité) (push) Has been cancelled
CI / Intégrité du câblage CI (gate agrège tout · gates statiques verrouillés) (push) Has been cancelled
CI / Publiciste · parser + schéma + generator (unittest) (push) Has been cancelled
CI / RBAC · 50 rôles + schéma (unittest) (push) Has been cancelled
CI / Faisabilité · générateur 4 volets + round-trip (unittest) (push) Has been cancelled
CI / RBAC · fixtures ERPNext (Role + Custom DocPerm) (push) Has been cancelled
CI / RBAC · plan User Permission (row-level) (push) Has been cancelled
CI / RBAC · Role Profile (bundles par portail) (push) Has been cancelled
CI / RBAC · run-book d'application unifié (agrégat 3 volets) (push) Has been cancelled
CI / Faisabilité · dossier bancable trilingue FR/EN/ES (push) Has been cancelled
CI / CRM · workflow vente ERPNext (lead → CONFOTUR) (push) Has been cancelled
CI / CRM · DocType porteur OTO Dossier Vente (push) Has been cancelled
CI / CRM · barème commissions vendeurs (push) Has been cancelled
CI / Fiscal · e-CF DGII (Compupar) (push) Has been cancelled
CI / Frontend · Workspaces 5 portails rôle (push) Has been cancelled
CI / Legal · DocType CONFOTUR Application (push) Has been cancelled
CI / QA · Audit 5D conformité (push) Has been cancelled
CI / SEO · mots-clés trilingues + schema.org + hreflang (push) Has been cancelled
CI / Chat OTOIA · montage par portail (Custom Block) (push) Has been cancelled
CI / QA · Audit 4Big (95+/100 sur 100% deliverables) (push) Has been cancelled
CI / Démo · Scénarios (run-sheet P07 banquier / P05 client) (push) Has been cancelled
CI / QA · Matrice de régression exhaustive (Sprint 8) (push) Has been cancelled
CI / DevOps · Run-book de déploiement VPS unifié (Sprint 8) (push) Has been cancelled
CI / QA · Matrice d'acceptation / traçabilité MVP (Sprint 8) (push) Has been cancelled
CI / Mobile · config app Expo/EAS (navigation par rôle) (push) Has been cancelled
CI / E2E baseline Playwright (manuel) (push) Has been cancelled
CI / Gate qualité (agrégat) (push) Has been cancelled
253 lines
10 KiB
Python
253 lines
10 KiB
Python
#!/usr/bin/env python3
|
|
"""Générateur de la config app mobile OTO Enterprise OS (Sprint 5 · Mobile).
|
|
|
|
Livrable Mobile de la roadmap Sprint 5 (l.56-57 « Rebuild Expo 54 + submit App
|
|
Store #32 + Play Store »). Le worker ne BUILD ni ne SUBMIT (VPS/stores · #8) : il
|
|
produit la CONFIG versionnable de l'app compagnon, surface mobile des 5 portails
|
|
rôle ERPNext.
|
|
|
|
Transforme (rbac_50_roles.json + mobile_spec.json + portails_spec.json + seo_spec.json)
|
|
en :
|
|
- app_config.json -> objet Expo `app.config` (thème dark+doré #4, locales seo,
|
|
identifiants de store `null` · a_confirmer)
|
|
- eas_build.json -> objet `eas.json` (profils build/submit, credentials `null`)
|
|
- role_navigation.json -> 1 onglet par portail, gated par la surface RBAC exacte
|
|
- MANIFEST.json -> traçabilité (comptes, résumé par portail, marque, a_confirmer)
|
|
|
|
Sous-commandes :
|
|
build [-o OUT] -> écrit les 4 fichiers ci-dessus
|
|
validate [-o OUT] -> (re)génère en mémoire, valide vs mobile.schema.json + invariants
|
|
|
|
Sortie déterministe (tri stable, aucun horodatage) -> diffable + re-générable.
|
|
Anti-invention (#6) : onglets = portails métier ; rôles = surface RBAC EXACTE du
|
|
portail (mêmes valeurs que les Has Role des Workspaces) ; identifiants de store/build =
|
|
`null` (a_confirmer) — jamais fabriqués ; marque/devises/langues sourcées.
|
|
Ce worker n'écrit JAMAIS sur le VPS ni ne soumet aux stores (#8).
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import argparse
|
|
import json
|
|
import os
|
|
import sys
|
|
|
|
_HERE = os.path.dirname(os.path.abspath(__file__))
|
|
sys.path.insert(0, _HERE)
|
|
|
|
from mobilelib import builder, deps # noqa: E402
|
|
|
|
_DEFAULT_OUT = os.path.join(_HERE, "out")
|
|
|
|
|
|
def _eprint(*args) -> None:
|
|
print(*args, file=sys.stderr)
|
|
|
|
|
|
def _write_json(path: str, data) -> None:
|
|
with open(path, "w", encoding="utf-8") as fh:
|
|
json.dump(data, fh, ensure_ascii=False, indent=2)
|
|
fh.write("\n")
|
|
|
|
|
|
def _build_bundle() -> dict:
|
|
contract = deps.load_contract()
|
|
spec = deps.load_spec()
|
|
portails_spec = deps.load_portails_spec()
|
|
langs, default_lang = deps.load_langs()
|
|
return builder.build_bundle(contract, spec, portails_spec, langs, default_lang)
|
|
|
|
|
|
def _resolve_path(root: dict, dotted: str):
|
|
"""Descend `root` selon un chemin pointé (ex. 'app_config.expo.ios.bundleIdentifier').
|
|
|
|
Retourne (trouvé: bool, valeur). `trouvé=False` si un segment n'existe pas.
|
|
"""
|
|
cur = root
|
|
for seg in dotted.split("."):
|
|
if not isinstance(cur, dict) or seg not in cur:
|
|
return False, None
|
|
cur = cur[seg]
|
|
return True, cur
|
|
|
|
|
|
def _validate_bundle(bundle: dict) -> list[str]:
|
|
errors: list[str] = []
|
|
|
|
schema = deps.load_json(deps.SCHEMA_PATH)
|
|
errors.extend(deps.validate(bundle, schema))
|
|
|
|
contract = deps.load_contract()
|
|
spec = deps.load_spec()
|
|
portails_spec = deps.load_portails_spec()
|
|
langs, default_lang = deps.load_langs()
|
|
|
|
app = bundle["app_config"]["expo"]
|
|
eas = bundle["eas_build"]
|
|
nav = bundle["role_navigation"]
|
|
manifest = bundle["manifest"]
|
|
b = deps.branding
|
|
|
|
roles_map = deps.roles_by_portail(contract)
|
|
labels = {p["key"]: p["label"] for p in portails_spec["portails"]}
|
|
icons = {p["key"]: p["icon"] for p in portails_spec["portails"]}
|
|
|
|
# Invariant 1 · onglets = portails métier RBAC, dans l'ordre de la spec.
|
|
nav_keys = [n["portail"] for n in nav]
|
|
if nav_keys != spec["portail_order"]:
|
|
errors.append(f"ordre des onglets != portail_order spec: {nav_keys}.")
|
|
if set(nav_keys) != set(contract["portails_business"]):
|
|
errors.append("onglets != portails_business du contrat RBAC.")
|
|
|
|
for n in nav:
|
|
key = n["portail"]
|
|
|
|
# Invariant 2 · rôles autorisés = surface RBAC EXACTE du portail (sécurité).
|
|
expected_roles = sorted(roles_map.get(key, []))
|
|
if n["roles_allowed"] != expected_roles:
|
|
errors.append(f"[{key}] roles_allowed != rôles RBAC du portail.")
|
|
if not expected_roles:
|
|
errors.append(f"[{key}] portail sans rôle (surface RBAC vide ?).")
|
|
if n["n_roles"] != len(expected_roles):
|
|
errors.append(f"[{key}] n_roles incohérent.")
|
|
|
|
# Invariant 3 · label + icône = portails_spec (source unique · mise en page).
|
|
if n["label"] != labels.get(key):
|
|
errors.append(f"[{key}] label != portails_spec.")
|
|
if n["icon"] != icons.get(key):
|
|
errors.append(f"[{key}] icône != portails_spec.")
|
|
|
|
# Invariant 4 · route déterministe.
|
|
if n["route"] != f"/{key}":
|
|
errors.append(f"[{key}] route non déterministe: {n['route']!r}.")
|
|
|
|
# Invariant 5 · langues = seo_spec (source unique), aucune langue inventée.
|
|
if list(langs) != ["fr", "en", "es"] or default_lang != "fr":
|
|
errors.append(f"langues seo inattendues: {langs}/{default_lang}.")
|
|
if sorted(app["locales"].keys()) != sorted(langs):
|
|
errors.append("app_config.locales != langues seo.")
|
|
if app["extra"]["defaultLocale"] != default_lang:
|
|
errors.append("extra.defaultLocale != langue par défaut seo.")
|
|
|
|
# Invariant 6 · tokens de marque verbatim (CLAUDE.md #4).
|
|
if app["backgroundColor"] != b.COLOR_BG or app["splash"]["backgroundColor"] != b.COLOR_BG:
|
|
errors.append("backgroundColor != #4 (#0a0a12).")
|
|
if app["primaryColor"] != b.COLOR_ACCENT:
|
|
errors.append("primaryColor != #4 (#f0b429).")
|
|
if app["userInterfaceStyle"] != "dark":
|
|
errors.append("userInterfaceStyle != dark (thème luxury #4).")
|
|
brand = manifest["brand"]
|
|
if brand["fond"]["valeur"] != b.COLOR_BG or brand["accent"]["valeur"] != b.COLOR_ACCENT:
|
|
errors.append("manifest.brand != CLAUDE.md #4.")
|
|
|
|
# Invariant 7 · devises = USD + DOP (CLAUDE.md #10).
|
|
if app["extra"]["devises"] != [b.DEVISE_PRIMAIRE, b.DEVISE_SECONDAIRE]:
|
|
errors.append("extra.devises != [USD, DOP] (#10).")
|
|
|
|
# Invariant 8 · Expo SDK majeur = spec (54) ; orientation portrait.
|
|
if app["extra"]["expoSdkMajor"] != spec["app"]["expo_sdk_major"]:
|
|
errors.append("extra.expoSdkMajor != spec (Expo 54).")
|
|
if app["orientation"] != spec["app"]["orientation"]:
|
|
errors.append("orientation != spec.")
|
|
|
|
# Invariant 9 · ANTI-INVENTION : chaque champ `a_confirmer` reste `null` dans la
|
|
# sortie (aucun identifiant de store/build/credential fabriqué · #6/#8).
|
|
for entry in spec["a_confirmer"]:
|
|
champ = entry["champ"]
|
|
found, value = _resolve_path(bundle, champ)
|
|
if not found:
|
|
errors.append(f"a_confirmer: champ absent du bundle: {champ}.")
|
|
elif value is not None:
|
|
errors.append(f"a_confirmer: {champ} doit rester null (jamais fabriqué #6/#8).")
|
|
if manifest["identifiants_statut"] != "a_confirmer":
|
|
errors.append("manifest.identifiants_statut != a_confirmer.")
|
|
|
|
# Invariant 10 · profils EAS = spec ; credentials submit `null`.
|
|
if sorted(eas["build"].keys()) != sorted(spec["eas"]["build_profiles"]):
|
|
errors.append("eas.build profils != spec.build_profiles.")
|
|
|
|
# Invariant 11 · comptes du manifeste cohérents.
|
|
c = manifest["counts"]
|
|
checks = {
|
|
"onglets": len(nav),
|
|
"portails": len(nav),
|
|
"roles_couverts": sum(n["n_roles"] for n in nav),
|
|
"langues": len(langs),
|
|
"identifiants_a_confirmer": len(spec["a_confirmer"]),
|
|
}
|
|
for k, v in checks.items():
|
|
if c.get(k) != v:
|
|
errors.append(f"counts.{k} incohérent ({c.get(k)} != {v}).")
|
|
|
|
# Invariant 12 · déterminisme : deux builds successifs identiques.
|
|
again = _build_bundle()
|
|
if json.dumps(again, sort_keys=True, ensure_ascii=False) != json.dumps(bundle, sort_keys=True, ensure_ascii=False):
|
|
errors.append("build non déterministe (deux passes divergent).")
|
|
|
|
return errors
|
|
|
|
|
|
def cmd_build(args: argparse.Namespace) -> int:
|
|
bundle = _build_bundle()
|
|
errors = _validate_bundle(bundle)
|
|
if errors:
|
|
_eprint("❌ Bundle invalide — génération refusée (anti-régression) :")
|
|
for e in errors:
|
|
_eprint(f" - {e}")
|
|
return 1
|
|
|
|
out = os.path.abspath(args.out)
|
|
os.makedirs(out, exist_ok=True)
|
|
_write_json(os.path.join(out, "app_config.json"), bundle["app_config"])
|
|
_write_json(os.path.join(out, "eas_build.json"), bundle["eas_build"])
|
|
_write_json(os.path.join(out, "role_navigation.json"), bundle["role_navigation"])
|
|
_write_json(os.path.join(out, "MANIFEST.json"), bundle["manifest"])
|
|
|
|
c = bundle["manifest"]["counts"]
|
|
print(f"✅ Config app mobile générée dans {out}")
|
|
print(
|
|
f" {c['onglets']} onglets (portails) · {c['roles_couverts']} rôles couverts · "
|
|
f"{c['langues']} langues · {c['identifiants_a_confirmer']} identifiants a_confirmer"
|
|
)
|
|
for m in bundle["manifest"]["portails"]:
|
|
print(f" - {m['label']} ({m['icon']}) : {m['nb_roles']} rôles")
|
|
return 0
|
|
|
|
|
|
def cmd_validate(args: argparse.Namespace) -> int:
|
|
bundle = _build_bundle()
|
|
errors = _validate_bundle(bundle)
|
|
if errors:
|
|
_eprint("❌ Validation KO :")
|
|
for e in errors:
|
|
_eprint(f" - {e}")
|
|
return 1
|
|
c = bundle["manifest"]["counts"]
|
|
print(
|
|
f"✅ Validation OK — {c['onglets']} onglets, {c['roles_couverts']} rôles, "
|
|
f"schéma + 12 invariants verts (identifiants a_confirmer)."
|
|
)
|
|
return 0
|
|
|
|
|
|
def main(argv: list[str] | None = None) -> int:
|
|
p = argparse.ArgumentParser(
|
|
description="Générateur de la config app mobile OTO Enterprise OS (Expo/EAS + navigation par rôle)."
|
|
)
|
|
sub = p.add_subparsers(dest="cmd", required=True)
|
|
|
|
pb = sub.add_parser("build", help="génère app_config.json / eas_build.json / role_navigation.json / MANIFEST.json")
|
|
pb.add_argument("-o", "--out", default=_DEFAULT_OUT, help="dossier de sortie (défaut: ./out)")
|
|
pb.set_defaults(func=cmd_build)
|
|
|
|
pv = sub.add_parser("validate", help="valide le bundle (schéma + invariants) sans écrire")
|
|
pv.add_argument("-o", "--out", default=_DEFAULT_OUT, help="ignoré (compat)")
|
|
pv.set_defaults(func=cmd_validate)
|
|
|
|
args = p.parse_args(argv)
|
|
return args.func(args)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|