Skip to main content
Welcome. This site supports keyboard navigation and screen readers. Press ? at any time for keyboard shortcuts. Press [ to focus the sidebar, ] to focus the content. High-contrast themes are available via the toolbar.
serard@dev00:~/cv

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

Diagram

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;
}

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 :

  1. 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.
  2. Pour chaque Rule candidate, dériver son kind : matched si la CrossRelation est 1..* (s'applique à toute instance) ; lazy si elle est 0..* ou si elle est référencée par une autre Rule (donc déclenchée à la demande) ; called reste réservé aux cas qu'on annote explicitement.
  3. 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é.
  4. Pour chaque Concept partagé (résolu par ConflictPolicy.share), n'émettre qu'une seule rule ; pour ConflictPolicy.rename, en émettre deux distinctes.
  5. Pour les helpers, émettre une bibliothèque commune : pascalCase, camelCase, kebabCase, nameFromConceptInstance. Les helpers métier-spécifiques (comme aggregateNameFromUseCase) sont déclarés par l'utilisateur dans un fichier transformer.user.ts séparé que le code généré importe.
  6. Pour les traces, instrumenter chaque apply() avec une ctx.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.json

C'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 :

  • Helper factorise le code répété entre Rules.
  • ConflictPolicy.share factorise les Concepts cross-M2 (le cas DomainEvent).
  • TransformerGenerator factorise 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 BuyNow porte 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 de QualityGate qui évalue les AC.

C'est la matrice de traçabilité. À toute ligne de code émis, on peut demander :

  1. Quelle Rule l'a produite ? → réponse dans le banner // @trace ruleId=….
  2. Quel UseCase du M1 a déclenché cette Rule ? → réponse dans la trace sourceElementId=M1-OpenStore/UseCase/….
  3. Quel Requirement formalisait cette UseCase ? → cross-reference avec le Megamodel.
  4. Quels AcceptanceCriteria cette ligne doit-elle satisfaire ? → liste dans le fichier feature.ts du Slice.
  5. 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é.

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é.

  1. Implémenter TransformerGenerator.generateFromComposition comme un vrai SourceGenerator au sens ts-codegen-pipeline. C'est un effort de 800-1200 lignes de TypeScript, tractable.
  2. Implémenter les six Emitters d'OpenStore comme exemple de référence. Chacun ~300-500 lignes. Total ~2-3 KLOC.
  3. Écrire des AcceptanceCriterion exé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.
  4. Documenter le mapping vers requirements-lib formellement — quelle est la version @FeatureTest/@Verifies qui correspond exactement aux RequirementSpec/FeatureSpec/AcceptanceCriterion du 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


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.

⬇ Download