293 lines
12 KiB
Python
293 lines
12 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
|
|
- store_listing.json -> fiche store FR/EN/ES (sous-titre, descriptions, mots-clés)
|
|
dérivée : portails/langues injectés depuis les sources (#6)
|
|
- MANIFEST.json -> traçabilité (comptes, résumé par portail, marque, a_confirmer)
|
|
|
|
Sous-commandes :
|
|
build [-o OUT] -> écrit les 5 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 · fiche store : couvre EXACTEMENT les langues seo (aucune inventée).
|
|
sl = bundle["store_listing"]
|
|
sl_spec = spec["store_listing"]
|
|
if sorted(sl["content"].keys()) != sorted(langs):
|
|
errors.append("store_listing.content != langues seo (FR/EN/ES).")
|
|
if sl["langues"] != list(langs):
|
|
errors.append("store_listing.langues != langues seo.")
|
|
if sl["limits"] != sl_spec["limits"]:
|
|
errors.append("store_listing.limits != spec.store_listing.limits.")
|
|
|
|
# Invariant 13 · chaque champ de fiche respecte sa limite de caractères store
|
|
# (App Store / Play — voir limits_source) et n'est jamais vide.
|
|
for lang, fields in sl["content"].items():
|
|
for champ, limit in sl_spec["limits"].items():
|
|
val = fields.get(champ)
|
|
if not val:
|
|
errors.append(f"store_listing[{lang}].{champ} vide/absent.")
|
|
elif len(val) > limit:
|
|
errors.append(
|
|
f"store_listing[{lang}].{champ} dépasse la limite store {limit} "
|
|
f"caractères (={len(val)})."
|
|
)
|
|
|
|
# Invariant 14 · ANTI-DÉRIVE : l'énumération des portails et les langues injectées
|
|
# dans chaque description longue == la surface RÉELLE (labels role_navigation ·
|
|
# langues seo) — la copie marketing ne peut pas mentir sur les portails/langues.
|
|
portails_str = " · ".join(n["label"] for n in nav)
|
|
langues_str = "/".join(lang.upper() for lang in langs)
|
|
for lang, fields in sl["content"].items():
|
|
full = fields.get("full_description", "")
|
|
if portails_str not in full:
|
|
errors.append(f"store_listing[{lang}].full_description n'injecte pas les portails réels.")
|
|
if langues_str not in full:
|
|
errors.append(f"store_listing[{lang}].full_description n'injecte pas les langues réelles.")
|
|
if "{" in full or "}" in full:
|
|
errors.append(f"store_listing[{lang}].full_description : placeholder non substitué.")
|
|
|
|
# Invariant 15 · 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, "store_listing.json"), bundle["store_listing"])
|
|
_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 + 15 invariants verts (identifiants a_confirmer · fiche store dérivée)."
|
|
)
|
|
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())
|