ADR-059 : Registre de dispatch des commandes CLI¶
Statut¶
Accepté (2026-06-30). Incréments 1 et 2 livrés : table de dispatch des
commandes opt-in (cli/commands/optin_dispatch.py) et table de dispatch des
commandes du cœur déléguées (forge.py, CORE_COMMANDS). Incrément 3 amorcé :
découverte des commandes opt-in par entry points (forge_mvc.commands) ;
forge-mvc-iot migré en pilote, le cœur ne le liste plus.
Contexte¶
forge.py centralise la résolution de toutes les commandes CLI dans une
fonction main() d'environ 520 lignes : une longue chaîne de if command
== "…" (46 branches). Une grande partie de cette chaîne est consacrée aux
commandes livrées par les opt-ins (mail:*, iot:*, video:*, audio:*,
admin:*, settings:init, audit:init, jobs:init, notifications:init,
deploy:*, rbac:*, upload:init), chacune répétant le même bloc :
if command == "iot:doctor":
try:
from forge_mvc_iot.cli.doctor import main as iot_doctor_main
except ImportError:
cli_fail("module forge-mvc-iot non installé.", hint="…")
rc = iot_doctor_main(args[1:])
if rc:
sys.exit(rc)
return
Conséquences :
- ajouter une commande opt-in impose de modifier le fichier central du cœur,
ce qui contredit l'esprit « noyau minimal, briques opt-in » (charte, principe
8) : le cœur connaît en dur la liste des commandes de chaque opt-in ; - la duplication (le même
try/except ImportErrorrépété une douzaine de fois)
est une dette de maintenance et un risque d'incohérence ; main()grossit à chaque opt-in, jamais l'inverse.
Décision¶
Introduire un registre de dispatch explicite, hors de forge.py, sous
cli/commands/. forge.py reste le lanceur, mais délègue la résolution.
Cet ADR pose la direction et livre le premier incrément : une table
de dispatch des commandes opt-in.
cli/commands/optin_dispatch.pydéclare une tableOPTIN_COMMANDS
(nom de commande -> descripteurOptinCommand: module à importer
paresseusement, paquet, mode de passage des arguments, gestion du code de
retour) et une fonctiondispatch_optin(command, args) -> bool.forge.pyremplace tous les blocs opt-in dupliqués par un unique appel
if dispatch_optin(command, args): return, placé après les branches du
cœur et avant l'erreur « commande inconnue ».
Le comportement est strictement préservé : mêmes commandes, même import
paresseux (l'opt-in n'est tiré qu'à l'invocation), mêmes messages d'erreur
« module … non installé », même passage d'arguments et même gestion du code
de retour.
Le registre reste explicite (table de données lisible, pas de scan
d'imports caché), conforme au principe « refuser la magie cachée ».
Incrément 1 : table opt-in (cli/commands/optin_dispatch.py)¶
Sont déplacés dans la table opt-in : upload:init/media:init, mail:*,
settings:init, audit:init, jobs:init, notifications:init, iot:*,
audio:doctor, video:*, admin:*, deploy:*, rbac:*.
Incrément 2 : table des commandes du cœur déléguées (forge.py, CORE_COMMANDS)¶
Les commandes du cœur qui ne font que déléguer à un sous-module cli.* sont
décrites dans la table CORE_COMMANDS : run, update, make:entity,
make:crud, make:public-*, make:relation, entity:validate,
sync:entity, js:init, i18n:*, auth:*, agents:init, opt-in:*,
module:*, docs:pdf, sync:relations/build:model/check:model,
migration:*, schema:*.
Cette table vit dans forge.py, pas sous cli/commands/, et appelle ses
handlers via une lambda qui les résout dans les globals de forge.py au moment
de l'appel (liaison tardive). Raison : de nombreux tests de routage patchent le
handler sur le module (monkeypatch.setattr(forge, "<handler>", fake)) ; les
handlers du cœur doivent donc rester des noms patchables du module forge. Une
table dans un module séparé, à capture immédiate, casserait ce contrat de test.
Restent natives dans forge.py (hors table) les commandes que forge.py
implémente lui-même ou qui ont une logique propre : new (analyse --profile),
db:init/db:apply, routes:list, doctor, project:check,
project:audit, make:pivot-crud (contrôle d'arguments + opt-in).
Conséquences¶
- Ajouter ou retirer une commande (opt-in ou cœur déléguée) se fait par une
ligne de table, sans toucher la chaîneif/elif. main()deforge.pypasse d'une chaîne de 46 branches (~520 lignes) à un
noyau mince : quelques commandes natives, puisdispatch_coreet
dispatch_optin.
Incrément 3 : découverte par entry points¶
Chaque opt-in déclare ses commandes CLI dans son propre pyproject.toml via le
groupe d'entry points forge_mvc.commands (à l'image des backends BDD, ADR-054),
pointant vers une table déclarative légère <pkg>.commands:COMMANDS (chaînes :
module, attr, full, exit_rc). dispatch_optin les découvre à l'exécution
(entry_points(group="forge_mvc.commands"), mémoïsé) et importe le handler
paresseusement à l'invocation. But final : le cœur ne liste plus aucune commande
opt-in ; ajouter une commande dans un opt-in ne touche jamais le cœur.
all_optin_commands() (union table statique + découverte) est la source unique
des garde-fous, quel que soit le mode d'enregistrement.
Migration incrémentale : forge-mvc-iot migré en pilote (retiré de la table
statique) ; les autres opt-ins suivent, paquet par paquet, jusqu'au retrait
complet de la table statique.
Alternatives écartées¶
- Découverte automatique par scan de paquets : rejetée, magie cachée
(principe 3) ; on préfère une table explicite, puis des entry points
déclaratifs. - Tout réécrire d'un coup (cœur + opt-ins) : rejeté, contraire à « petits
tickets, une responsabilité » et risqué sur un fichier central testé par de
nombreuses suites.