Ce que Part 09 a laissé non-dit
Le transformer composite décrit Part 09 — celui qui projette M1-OpenStore en M1-composé et émet six packages npm — était écrit à la main. Quatre rules, deux helpers, une centaine de lignes de TypeScript dans assets/transformer-composite.ts. Ce n'est pas beaucoup à écrire pour OpenStore. Mais on imagine la prochaine étape : un second projet, avec d'autres capabilities ; on récrit tout le transformer. Un troisième projet, avec un cinquième métamodèle ajouté (@frenchexdev/payments par exemple) ; on récrit encore. À la dixième fois, on commet l'inévitable erreur — on oublie un guard, on inverse une cardinalité, on duplique une rule.
C'est précisément la situation que le M3 résout. Le M3 est le cadre qui regarde une Composition et GÉNÈRE le code TS du transformer qui projette les M1 conformes à cette Composition vers le M2-cible désigné. C'est de la génération de générateurs — du meta-meta-programming. C'est l'apport final de la série, et c'est ce qui rend tout le travail précédent rétroactivement utile.
Le diagramme du M3 lui-même
Lecture critique du diagramme. Quatre choses méritent l'œil. Premièrement, c'est plat — tous les constructs M3 sont sous une racine commune M3 — Cadre meta-meta. Aucune hiérarchie artificielle (pas de core vs extended) parce que tous sont nécessaires et tous sont peers. Deuxièmement, le TransformerGenerator est à part — la seule flèche pointillée du diagramme va de lui vers Transformation, indiquant qu'il écrit du code qui sera une instance de Transformation. C'est cette boucle qui matérialise le meta-meta. Troisièmement, RequirementSpec / FeatureSpec / AcceptanceCriterion / ImplementationSlot / QualityGate sont des Constructs M3 à part entière — pas un greffon optionnel. La chaîne Req → Feat → AC → Impl est fondamentale, pas accessoire. Quatrièmement, le diagramme reste fini — quinze constructs, pas davantage. Cette finitude est revendiquée : un M3 utile est décrit en une page, pas dispersé en deux cents.
Le geste central : generateFromComposition
Le TransformerGenerator est défini dans assets/cadre.ts par cette interface minimaliste :
export interface TransformerGenerator {
readonly id: string;
generateFromComposition(
composition: Composition,
target: M2Schema,
): EmittedTree;
}export interface TransformerGenerator {
readonly id: string;
generateFromComposition(
composition: Composition,
target: M2Schema,
): EmittedTree;
}Trois paramètres, un retour. C'est l'opération du M3.
Elle prend une Composition (les quatre M2Schemas DDD/CQRS/VS/Telemetry plus leurs CrossRelations et ConflictPolicy d'OpenStore) et un M2Schema cible (« émission TypeScript ») et produit un EmittedTree — un arbre de fichiers .ts qui constituent le code source d'une Transformation lisible et compilable. Ce code, exécuté ensuite, prendra un M1 quelconque conforme à la Composition et produira un M1-composé conforme au target schema.
Le résultat est un fichier transformer-composite.generated.ts qui ressemble exactement à assets/transformer-composite.ts. À une différence près : il a été dérivé mécaniquement de la Composition. Personne ne l'a tapé. Personne ne risque d'oublier une CrossRelation. Personne ne peut diverger d'une convention.
Comment le TransformerGenerator procède
Le generateFromComposition lit la Composition et applique une procédure qui peut être décrite en six étapes :
- Pour chaque Concept source dans la Composition (l'union des Concepts des M2Schemas source), chercher les CrossRelations partantes qui pointent vers un Concept du M2Schema cible. Chacune devient une Rule candidate.
- Pour chaque Rule candidate, dériver son
kind:matchedsi la CrossRelation est1..*(s'applique à toute instance) ;lazysi elle est0..*ou si elle est référencée par une autre Rule (donc déclenchée à la demande) ;calledreste réservé aux cas qu'on annote explicitement. - Pour chaque Rule, dériver son guard : si la Relation source a une cardinalité conditionnelle ou si elle dépend d'un attribut (par exemple
UseCase.kind === 'command'), un Guard est synthétisé. - Pour chaque Concept partagé (résolu par
ConflictPolicy.share), n'émettre qu'une seule rule ; pourConflictPolicy.rename, en émettre deux distinctes. - Pour les helpers, émettre une bibliothèque commune :
pascalCase,camelCase,kebabCase,nameFromConceptInstance. Les helpers métier-spécifiques (commeaggregateNameFromUseCase) sont déclarés par l'utilisateur dans un fichiertransformer.user.tsséparé que le code généré importe. - Pour les traces, instrumenter chaque
apply()avec unectx.emit(trace)automatique. Pas d'oubli possible.
Cette procédure est déterministe : même Composition → même code TS. Elle est traçable : chaque rule générée a en commentaire la liste des CrossRelations qui l'ont produite. Elle est complète : pas de rule omise pour les CrossRelations déclarées (assert programmatique).
La séparation utilisateur / généré
Le code émis par generateFromComposition est strictement additif — il n'écrit que sous un dossier generated/. Les helpers métier, les overrides de naming, les conventions spécifiques au projet vivent dans un fichier user séparé que le code généré importe :
packages/openstore-transformer/
├── src/
│ ├── generated/ ← émis par TransformerGenerator
│ │ ├── rules.generated.ts
│ │ ├── helpers.generated.ts
│ │ └── transformer.generated.ts
│ ├── user/ ← écrit par humain ou IA
│ │ └── helpers.user.ts
│ └── index.ts ← émis ; ré-exporte tout
└── package.jsonpackages/openstore-transformer/
├── src/
│ ├── generated/ ← émis par TransformerGenerator
│ │ ├── rules.generated.ts
│ │ ├── helpers.generated.ts
│ │ └── transformer.generated.ts
│ ├── user/ ← écrit par humain ou IA
│ │ └── helpers.user.ts
│ └── index.ts ← émis ; ré-exporte tout
└── package.jsonC'est l'analogue exact du partial class pattern de C# — la générativité s'arrête à une frontière, l'humain (ou l'IA) prend la suite au-dessous de cette frontière, et la régénération n'écrase jamais le user code. C'est ce qui rend l'approche soutenable en pratique sur plusieurs années.
Les garanties par construction
C'est ici que la promesse SOLID + DRY + Req-Feat-AC-Impl prend sa forme finale.
Single Responsibility
Le TransformerGenerator vérifie programmatiquement qu'une Slice du M1 correspond à exactement un RequirementSpec. Si la Composition déclare la CrossRelation Slice ─derives→ RequirementSpec (cardinalité 1..1) et que le M1 viole cette cardinalité, la génération échoue avec une erreur explicite avant tout fichier émis. Pas de Slice violant SRP par accident.
Open/Closed
Le TransformerGenerator est lui-même paramétré par la Composition — c'est sa seule entrée structurante. Ajouter un cinquième M2Schema (@frenchexdev/payments) = ajouter ses CrossRelations dans la Composition. Le code de generateFromComposition ne change pas. C'est la garantie OCP littérale : le cadre est ouvert à l'extension (nouveau M2Schema), fermé à la modification.
Liskov
Tous les CommandHandler émis étendent une classe de base CommandHandlerBase<TCommand, TEvent>. Tous les Repository émis implémentent une interface RepositoryBase<TAggregate>. La substituabilité est imposée par génération — pas négociée à chaque code review.
Interface Segregation
Les excerpts (articles 04-07) sont précisément l'application d'ISP au M2 lui-même. Chaque M2Schema ne déclare que les Concepts nécessaires aux transformations qui le consomment. Pas de god-Concept exposant 40 attributs dont 35 inutilisés.
Dependency Inversion
Tout Handler émis est généré avec ses dépendances en paramètres de constructeur. Pas de new PostgresQuoteRepository() à l'intérieur. Le wiring (quel adapter pour quel port) est fait à un seul endroit : le composition root du @openstore/api, lui-même émis par un Emitter dédié qui lit le M1 + la configuration de runtime.
DRY
Trois mécanismes superposés :
Helperfactorise le code répété entre Rules.ConflictPolicy.sharefactorise les Concepts cross-M2 (le casDomainEvent).TransformerGeneratorfactorise le code commun à toutes les Transformations qui suivent une même forme.
Req → Feat → AC → Impl
C'est la chaîne explicite, déclarative, traçable :
RequirementSpec(M1) — besoin formel attaché à un Concept M1 (la@UseCase BuyNowporte son SLO p95).FeatureSpec(sortie M2M) — Feature dérivée par le transformer, liant un Requirement à un Slice + Handler.AcceptanceCriterion(sortie M2C) — prédicat exécutable (test, validator, property) inscrit dans le code émis.ImplementationSlot(M2C) — borne explicite à la frontière où l'IA ou l'humain doit fournir le body, sous garde deQualityGatequi évalue les AC.
C'est la matrice de traçabilité. À toute ligne de code émis, on peut demander :
- Quelle Rule l'a produite ? → réponse dans le banner
// @trace ruleId=…. - Quel UseCase du M1 a déclenché cette Rule ? → réponse dans la trace
sourceElementId=M1-OpenStore/UseCase/…. - Quel Requirement formalisait cette UseCase ? → cross-reference avec le
Megamodel. - Quels AcceptanceCriteria cette ligne doit-elle satisfaire ? → liste dans le fichier
feature.tsdu Slice. - Le code humain/IA qui remplit le slot passe-t-il ces AC ? → réponse du
QualityGateà chaque CI run.
Cette chaîne n'a rien de magique. Elle est du travail — du travail bien fait, du travail automatisé, du travail tracé.
Positionnement face à ts-codegen-pipeline, requirements-lib, Langium
Trois positions à clarifier.
Face à @frenchexdev/ts-codegen-pipeline
Le cadre M3 décrit ici est une couche au-dessus. Il consomme ts-codegen-pipeline comme moteur d'exécution : ses Emitters s'adossent à VirtFs.addSource() et runFixpoint(). Le M3 ne réinvente pas la VirtFS, le banner SHA256, le fixpoint. Il fournit le vocabulaire éditorial qui permet de décrire ce que les SourceGenerator doivent faire à un niveau plus abstrait. Sans ts-codegen-pipeline, le M3 n'a pas d'exécution ; sans M3, ts-codegen-pipeline reste un moteur générique sans cadre d'usage prescrit.
Face à @frenchexdev/requirements-lib
Le cadre M3 interopère. Les constructs RequirementSpec, FeatureSpec, AcceptanceCriterion, QualityGate du M3 ont des correspondants 1:1 dans requirements-lib (au lookup près des noms et des shapes runtime). Le M2C de cette série émet du code qui utilise requirements-lib (@Feature, @Verifies, compliance-core.ts). Une fois la série implémentée comme package, le pont serait formalisé en code ; pour l'instant, il est documentaire.
Face à Langium
Les deux outils sont complémentaires. Langium est le bon choix quand on doit parser une grammaire textuelle externe (un DSL métier dédié, lu et écrit par des non-développeurs). Le cadre M3 est le bon choix quand on a TypeScript déjà comme langage hôte et qu'on annote des classes décorées. Une série voisine pourrait montrer un M1 Langium-parsed (un fichier .openstore avec syntaxe métier) qui se projette dans le même M1-composé via un adaptateur — Langium en entrée, M3 au milieu, ts-codegen-pipeline en sortie. Cette série prend la voie sans Langium ; ce n'est pas une disqualification, c'est un choix éditorial.
La perspective Ide.Dsl-TS
Part 12 de ts-source-generator annonçait un futur kernel Ide.Dsl-TS qui serait le pendant TypeScript du Ide.Dsl C#. Le cadre M3 décrit ici est probablement la fondation de ce kernel. La raison est structurelle : Ide.Dsl est une composition de microDSLs, exactement le pattern défendu par cette série, avec un niveau M3 explicite qui régit la composition.
Quand Ide.Dsl-TS arrivera, son article 1 commencera par : « On suppose le cadre M3 décrit dans model-driven-typescript ». Le cadre décrit ici n'est donc pas un objet isolé — c'est une infrastructure éditoriale qui peut servir d'autres séries. C'est ce qui justifie de l'avoir documenté en public, par fragments lisibles, en dix chapitres, avant de l'implémenter.
Ce qui reste à écrire
C'est design in public — on dit donc ce qui reste à faire. Quatre tâches majeures, par ordre de priorité.
- Implémenter
TransformerGenerator.generateFromCompositioncomme un vraiSourceGeneratorau sensts-codegen-pipeline. C'est un effort de 800-1200 lignes de TypeScript, tractable. - Implémenter les six Emitters d'OpenStore comme exemple de référence. Chacun ~300-500 lignes. Total ~2-3 KLOC.
- Écrire des
AcceptanceCriterionexécutables qui valident le code émis (round-trip : générer, compiler, exécuter les tests sur les implémentations slot-fillées). C'est ce qui transforme la promesse en garantie. - Documenter le mapping vers
requirements-libformellement — quelle est la version@FeatureTest/@Verifiesqui correspond exactement auxRequirementSpec/FeatureSpec/AcceptanceCriteriondu M3.
Chacune de ces tâches mérite sa propre série, ou son propre package du monorepo. Cette série a fait son travail si, après l'avoir lue, ces quatre tâches paraissent évidentes à entreprendre — pas faciles, mais bien posées.
Pour aller plus loin
- Part 09 — Le transformer composite, écrit à la main, à relire après ce chapitre avec l'œil du quoi générer.
@frenchexdev/ts-codegen-pipeline— le moteur sur lequel le M3 s'adosse.@frenchexdev/requirements-lib— l'écosystème Requirements/Features/AcceptanceCriteria avec lequel le M3 interopère.- microdsls-and-kernel — un autre éclairage sur la composition de microDSLs autour d'un kernel.
- ts-source-generator — Part 12 : Ide.Dsl-TS — la perspective d'un kernel TypeScript généralisé construit sur le cadre M3 d'ici.
Dix chapitres, dix assets TypeScript qui compilent, neuf diagrammes Mermaid lisibles, un cadre méta-méta posé en public. La série est close. Le travail d'implémentation commence — ailleurs.