Ce notebook démontre le socle transverse MyIA.AI.Shared (.NET 9.0) sur les trois moments qui le fondent, en partant d’un domaine factice (facturation / catalogue) :
Décoration → introspection. Des attributs ([MainCategory], [AttributeContainer]) rendent des types découvrables sans aucun appel d’enregistrement — un ReflectedProviderContainer.FromAssembly<T>() les regroupe par catégorie et par rôle.
Sérialisation round-trip. Le même graphe d’entités (hiérarchie IChildEntity sur plusieurs niveaux) part et revient intact en JSON et en XML, parent re-lié.
Prédicat universel métier (Flee). Une règle métier est une chaîne — venue d’un fichier, d’un CSV, d’un utilisateur non-développeur — compilée une fois puis appliquée à N instances. C’est la différence entre coder N branches if et piloter la logique métier par les données.
Prérequis. Le socle doit être compilé en Release à la racine du dépôt : dotnet build MyIA.AI.Shared/MyIA.AI.Shared.csproj -c Release.
// Setup : on charge le socle (DLL Release locale) + Flee (moteur de règles) + Newtonsoft.#r "../../../MyIA.AI.Shared/bin/Release/net9.0/MyIA.AI.Shared.dll"#r "nuget:Flee"#r "nuget:Newtonsoft.Json"using System;using System.Collections.Generic;using System.Linq;using MyIA.AI.ComponentModel.Attributes;using MyIA.AI.ComponentModel.Entities;using MyIA.AI.ComponentModel.Providers;using MyIA.AI.ComponentModel.Serialization;using MyIA.AI.ComponentModel.Rules;using Flee.PublicTypes;using Newtonsoft.Json;// Sanity check : les trois références sont résolues. Flee reste chargé car// FleePredicateBuilder (Moment 3) s'appuie sur ce moteur en interne.Console.WriteLine($"socle : {typeof(ReflectedProviderContainer).Assembly.GetName().Version}");Console.WriteLine($"Flee : {typeof(ExpressionContext).Assembly.GetName().Version}");Console.WriteLine($"Newtonsoft: {typeof(JsonConvert).Assembly.GetName().Version}");
The below script needs to be able to find the current output cell; this is an easy method to get it.
Trois références, trois rôles. Le triple message de versions (socle : 1.0.0.0, Flee : 1.0.0.0, Newtonsoft: 13.0.0.0) est plus qu’un rituel de démarrage : il certifie que les trois piliers du notebook sont résolus AVANT que le moindre exemple ne s’exécute. Chacun a un rôle distinct — le socle MyIA.AI.Shared (DLL Release locale, compilée selon le prérequis de l’introduction) porte la découverte par attributs et les sérialiseurs de métadonnées ; Flee est le moteur qui compilera les règles métier du Moment 3 ; Newtonsoft est la couche sur laquelle le round-trip JSON du Moment 2 s’appuie. Le désamorçage est volontairement précoce : une référence manquante se manifesterait ici par un message court et lisible, pas par un échec obscur au milieu d’une démonstration.
Un détail de l’affichage mérite un œil attentif : la ligne « Installed Packages » du rapport .NET Interactive annonce Flee, 2.0.0 et Newtonsoft.Json, 13.0.4 — des numéros de paquet NuGet —, tandis que le sanity check affiche des versions d’assembly différentes. Ces deux échelles ne coïncident pas toujours : la version d’assembly est un identifiant de compatibilité binaire fixé par l’éditeur, la version de paquet suit l’évolution de la distribution. Comparer l’une à l’autre est un piège classique de diagnostic.
Moment 1 — Décoration → introspection
La décoration est le socle d’une architecture métadonnées-driven : on déclare ce qu’une classe est (sa catégorie, son rôle hiérarchique) via des attributs, puis un conteneur de réflexion découvre ces déclarations sans qu’aucun code métier n’ait à appeler un Register(...).
Définissons un domaine factice : un abonnement facturable (feuille plate ISimpleEntity) et un catalogue Catalog > Product > Variant (hiérarchie IChildEntity sur 3 niveaux).
// Domaine : 4 entités decorées. Aucun Register() — tout passe par les attributs.[MainCategory("Billing")]publicsealedclass Subscription : ISimpleEntity{publicstring Name {get;set;}="";publicdouble Montant {get;set;}// en eurospublicstring Pays {get;set;}="";// code pays ISO 2publicbool Actif {get;set;}}[MainCategory("Catalog")][AttributeContainer(ChildType =typeof(Product))]publicsealedclass Catalog : IChildEntity{publicstring Nom {get;set;}="";public IChildEntity? Parent {get;set;}privatereadonly List<IChildEntity> _children =new();public IReadOnlyList<IChildEntity> Children => _children;publicvoidAddChild(IChildEntity child){ child.Parent=this; _children.Add(child);}}[MainCategory("Catalog")][AttributeContainer(ChildType =typeof(Variant))]publicsealedclass Product : IChildEntity{publicstring Nom {get;set;}="";publicdouble Prix {get;set;}public IChildEntity? Parent {get;set;}privatereadonly List<IChildEntity> _children =new();public IReadOnlyList<IChildEntity> Children => _children;publicvoidAddChild(IChildEntity child){ child.Parent=this; _children.Add(child);}}[MainCategory("Catalog")]publicsealedclass Variant : IChildEntity, IMergeable{publicstring Nom {get;set;}="";publicdouble Prix {get;set;}public IChildEntity? Parent {get;set;}public IReadOnlyList<IChildEntity> Children => Array.Empty<IChildEntity>();publicboolMerge(IMergeable other){if(other is Variant v &&string.IsNullOrEmpty(Nom)){ Nom = v.Nom;returntrue;}returnfalse;}}Console.WriteLine("4 entites definies : Subscription (feuille), Catalog/Product/Variant (hiérarchie).");
Quatre types, et toute la taxonomie est déjà écrite. La ligne de sortie résume ce que les attributs viennent de déclarer — aucun appel d’enregistrement n’a été exécuté. Deux conventions cohabitent : Subscription est une entité plate (ISimpleEntity), les trois autres forment une hiérarchie (IChildEntity) où AddChild gère d’un seul geste l’ajout à la liste interne ET le pointeur Parent — le code métier ne peut pas créer de graphe incohérent en assignant un parent à la main.
Les avertissements CS8632 qui suivent sont bénins : chaque cellule d’un notebook .NET Interactive se compile comme une unité autonome, sans le contexte #nullable du fichier projet d’origine, et le compilateur signale alors les annotations nullable de types pourtant corrects. Ils n’interrompent rien — la suite du notebook s’exécute jusqu’au bout. Leur présence est un artefact du support (la cellule isolée), pas un défaut du domaine.
Lire la carte d’identité du domaine. Le conteneur n’a rien construit : il a lu les attributs de la cellule précédente et en a dérivé des vues croisées du même domaine. La première — [Billing] -> Subscription et [Catalog] -> Catalog, Product, Variant — est le regroupement par catégorie, exactement ce qu’un menu d’application afficherait. Les lignes suivantes répondent chacune à une question différente posée au MÊME jeu de types : qui peut contenir des enfants (Conteneurs : Catalog, Product), qui vit dans une hiérarchie (Catalog, Product, Variant), qui est une entité plate (Subscription), qui sait fusionner (Variant).
La ligne des conteneurs est la plus instructive : Product y figure aux CÔTÉS de Catalog, alors qu’il figure aussi parmi les entités hiérarchiques. C’est le milieu de la hiérarchie — un nœud à la fois enfant (il porte un Parent) et conteneur (il accepte des Variant), exactement ce que [AttributeContainer(ChildType = typeof(Variant))] déclarait sur sa classe. Et le fusible solitaire Variant renvoie à l’interface IMergeable visible dans la définition du domaine : la carte était complète dès la décoration, la réflexion n’a fait que la déplier.
Exercice 1 — Enregistrement explicite
ReflectedProviderContainer découvre par décoration. Le socle expose aussi SimpleProviderContainer, symétrique par enregistrement explicite : on déclare soi-même quel type va dans quelle catégorie, sans aucun attribut.
Objectif. Compléter la fonction ConstruireConteneur() pour qu’elle renvoie un SimpleProviderContainer où Subscription est enregistré sous la catégorie "Billing" et Catalog + Product sous "Catalog". Les deux stratégies étant interchangeables via IProviderContainer, le conteneur retourné doit exposer les mêmes Categories que le conteneur par réflexion.
Indice. Le chaînage est fluent : new SimpleProviderContainer().Register<T>("cat")....
// Exercice 1 (a completer) : enregistrement explicite, même lecture que par réflexion.public IProviderContainer?ConstruireConteneur(){// TODO etudiant : renvoyer un SimpleProviderContainer enregistrant// Subscription -> "Billing"// Catalog -> "Catalog"// Product -> "Catalog"returnnull;// TODO etudiant}// --- zone de validation (ne pas modifier) ---var explicite =ConstruireConteneur();if(explicite isnull){ Console.WriteLine("Exercice 1 : non complété (renvoie null).");}else{var cats =string.Join(", ", explicite.Categories); Console.WriteLine($"Catégories (enregistrement explicite) : {cats}");}
Exercice 1 : non complété (renvoie null).
Ce que certifie le message. La sortie Exercice 1 : non complété (renvoie null) n’est pas une erreur : la cellule s’est exécutée proprement et le garde-fou du notebook a simplement constaté que la fonction squelette n’a pas encore été remplie. C’est le contrat de ce notebook — il reste exécutable de bout en bout pendant que vous travaillez. Le critère de réussite est donné par l’énoncé : une fois ConstruireConteneur() complétée, la sortie doit exposer les mêmes catégories que le conteneur par réflexion de la section précédente — la comparaison visuelle entre les deux affichages EST le test.
Moment 2 — Sérialisation round-trip (JSON + XML)
Le même graphe d’entités doit pouvoir être persisté puis reconstitué sans perte — c’est la promesse d’un socle. On construit une hiérarchie Catalog > Product > Variant sur 3 niveaux, on la sérialise dans les deux formats, puis on désérialise et on vérifie que (a) la structure est identique et (b) les références Parent sont re-liées.
// Construction du graphe : Catalog > Product > Variant (3 niveaux).var catalogue =new Catalog { Nom ="Boutique"};var logiciel =new Product { Nom ="Logiciel", Prix =199};var matos =new Product { Nom ="Matériel", Prix =0};catalogue.AddChild(logiciel);catalogue.AddChild(matos);logiciel.AddChild(new Variant { Nom ="Licence Pro", Prix =299});logiciel.AddChild(new Variant { Nom ="Licence Basic", Prix =99});matos.AddChild(new Variant { Nom ="Clé USB", Prix =25});voidAfficher(IChildEntity noeud,int prof =0){var nom = noeud switch{ Catalog c => $"Catalog:{c.Nom}", Product p => $"Product:{p.Nom}", Variant v => $"Variant:{v.Nom}", _ => noeud.GetType().Name};var parent = noeud.Parentisnull?"(racine)": noeud.Parent.GetType().Name; Console.WriteLine($"{new string(' ', prof * 2)}- {nom} [parent: {parent}]");foreach(var enfant in noeud.Children)Afficher(enfant, prof +1);}Console.WriteLine("Graphe original :");Afficher(catalogue);
Graphe original :
- Catalog:Boutique [parent: (racine)]
- Product:Logiciel [parent: Catalog]
- Variant:Licence Pro [parent: Product]
- Variant:Licence Basic [parent: Product]
- Product:Matériel [parent: Catalog]
- Variant:Clé USB [parent: Product]
L’étalon des deux round-trips qui suivent. Le retrait par niveau de profondeur dessine la hiérarchie : Catalog:Boutique à la racine, deux Product (Logiciel, Matériel) au niveau intermédiaire, trois Variant (Licence Pro, Licence Basic sous Logiciel ; Clé USB sous Matériel) aux feuilles. Chaque ligne porte son parent entre crochets — et ces indications sont cohérentes entre elles : personne n’a assigné ces pointeurs à la main, ils découlent tous des appels AddChild du code ci-dessus.
Ce dessin n’est pas décoratif : les deux sérialiseurs du Moment 2 seront jugés contre lui. La preuve de fidélité d’un round-trip sera précisément de reproduire CETTE sortie, retraits et parents compris, après un passage complet par un format texte. Un graphe à plusieurs étages a été choisi pour que la récursivité — de l’affichage comme de la sérialisation — ait plus d’un niveau à parcourir : un arbre à un seul étage de profondeur ne distinguerait pas « sérialiser une liste » de « sérialiser une hiérarchie ».
// Round-trip JSON : sérialise -> désérialise -> compare la structure.string json = MetadataJsonSerializer.Serialize(catalogue);Console.WriteLine("JSON (extrait) :");Console.WriteLine(json.Substring(0, Math.Min(240, json.Length))+(json.Length>240?" ...":""));var depuisJson = MetadataJsonSerializer.Deserialize<Catalog>(json);Console.WriteLine();Console.WriteLine("Graphe rechargé depuis JSON :");Afficher(depuisJson);Console.WriteLine();Console.WriteLine($"Parent du 1er produit re-lié ? {depuisJson.Children[0].Parent?.GetType().Name == "Catalog"}");
Le JSON emporte sa propre notice de montage. L’extrait révèle la mécanique : "Nom": "Boutique" est une donnée, mais les clés $type et $values sont des MÉTADONNÉES de sérialisation. Elles sont indispensables ici pour une raison structurelle : Children est typé IReadOnlyList<IChildEntity>, une interface — le désérialiseur ne peut pas deviner, devant un élément du tableau, s’il doit reconstruire un Catalog, un Product ou un Variant. Le nom de type concret est donc embarqué dans le flux, ce qui rend la charge auto-descriptive mais aussi couplée aux noms de types — le compromis classique entre fidélité du round-trip et interopérabilité.
Le fragment "Submission#3+P..." raconte une curiosité du support : en .NET Interactive, chaque cellule compilée devient une « submission » portant son propre espace de noms, donc les classes du domaine définies dans une cellule précédente vivent sous un nom préfixé. C’est la trace, dans le JSON lui-même, de la frontière entre le code de démonstration et le socle.
La fin est le vrai verdict : le graphe rechargé s’affiche identique au dessin original, et Parent du 1er produit re-lié ? True tranche la question difficile. Un désérialiseur naïf reconstruit les enfants mais laisse Parent orphelin — le pointeur retour n’est pas sérialisable comme donnée sans créer un cycle. Le socle re-lie la parenté à la reconstruction, ce que le True final certifie.
// Round-trip XML : même graphe, autre format, même résultat.// Le serializer XML a besoin du résolveur décoré avec les types concrets connus du graphe// (la moitié A2 du socle : DynamicSurrogate + re-lien Parent via AddChild au rechargement).var xmlSerializer =newMetadataXmlSerializer(newXmlAwareContractResolver(new[]{typeof(Catalog),typeof(Product),typeof(Variant)}));string xml = xmlSerializer.Serialize(catalogue);Console.WriteLine("XML (extrait) :");Console.WriteLine(xml.Substring(0, Math.Min(260, xml.Length))+(xml.Length>260?" ...":""));var depuisXml = xmlSerializer.Deserialize<Catalog>(xml);Console.WriteLine();Console.WriteLine($"Nombre de produits rechargés depuis XML : {depuisXml.Children.Count}");var premierProduit = depuisXml.Children.OfType<Product>().First();Console.WriteLine($"Variantes du 1er produit : {premierProduit.Children.Count}");
XML (extrait) :
<?xml version="1.0" encoding="utf-16"?>
<NodeSurrogate xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xmlns:xsd="http://www.w3.org/2001/XMLSchema" type="Catalog">
<property name="Nom" value="Boutique" />
<child type="Product">
<property name ...
Nombre de produits rechargés depuis XML : 2
Variantes du 1er produit : 2
Un format différent, un contrat identique. Là où le JSON embarquait les types dans le flux ($type), le XML les porte en attributs : type="Catalog" sur l’élément racine, type="Product" sur les enfants — et surtout le vocabulaire a changé de nom. La racine s’appelle NodeSurrogate, et les propriétés deviennent des éléments <property name="Nom" value="Boutique" />. C’est la stratégie du surrogate : XmlSerializer s’accommode mal des collections d’interfaces polymorphes, donc le socle traduit le graphe vers une forme intermédiaire sérialisable en XML, puis reconstruit les objets métier au rechargement. Le prix est visible dans le code : le résolveur doit recevoir la liste des types concrets du graphe — là où le JSON s’auto-décrivait, le XML exige que le vocabulaire soit déclaré à l’avance.
Les lignes finales jouent le rôle de diff : Nombre de produits rechargés depuis XML : 2, puis Variantes du 1er produit : 2 — la structure rechargée rend le compte du graphe original. Notons au passage la déclaration encoding="utf-16" : c’est l’encodage par défaut du rédacteur XML .NET, détail qui importe si l’on veut comparer ces fichiers au niveau octet.
Exercice 2 — Sérialiser une hiérarchie personnalisée
Objectif. Construire un second catalogue contenant un seul produit Formation avec deux variantes (Présentiel, Distanciel), le sérialiser en JSON, puis désérialiser et renvoyer le nom de la première variante du premier produit.
Indice. Utilise MetadataJsonSerializer.Serialize puis Deserialize<Catalog>, et accède à .Children.OfType<Product>().First().Children pour atteindre les variantes.
// Exercice 2 (a completer) : construire, sérialiser, recharger, extraire.publicstring?NomPremiereVariante(){// TODO etudiant : construire Catalog > Product("Formation") >// Variant("Présentiel"), Variant("Distanciel"),// sérialiser en JSON, désérialiser, renvoyer le nom// de la première variante du premier produit.returnnull;// TODO etudiant}// --- zone de validation (ne pas modifier) ---var rep2 =NomPremiereVariante();Console.WriteLine(rep2 isnull?"Exercice 2 : non complété (renvoie null).": $"Exercice 2 -> première variante : {rep2}");
Exercice 2 : non complété (renvoie null).
La discipline du round-trip. Même garde-fou que l’exercice précédent : la sortie constate que la fonction squelette n’a pas été remplie, sans interrompre le notebook. La méthode à mettre en œuvre est celle que le Moment 2 vient de démontrer : construire, sérialiser, recharger, PUIS vérifier — la vérification étant l’étape que l’on oublie toujours. La difficulté réelle de l’exercice n’est pas l’appel au sérialiseur (donné en indice), c’est le trajet de retour complet jusqu’à la donnée demandée.
Moment 3 — Prédicat universel métier (Flee)
C’est le moment où la logique métier cesse d’être codée. Une règle métier (quels abonnements sont éligibles à un traitement) est une chaîne :
Montant > 1000 And Pays = "FR" And Actif = true
Cette chaîne peut venir d’un fichier de configuration, d’une ligne de CSV, ou être saisie par un utilisateur non-développeur dans une console d’administration. On la compile une fois en un délégué réutilisable, puis on l’évalue sur N instances en changeant simplement les variables — sans recompiler l’hôte.
Le socle industrialise ce moment. Plutôt que d’enregistrer chaque variable à la main (ctx.Variables.Add(...)), le socle expose FleePredicateBuilder.Create<T>(regle) : il reflète les propriétés publiques de T comme variables de la règle et renvoie un Func<T, bool> directement utilisable dans LINQ. La signature du type fait office de contrat de variables — l’erreur « variable non déclarée » disparaît.
Syntaxe Flee. Le moteur est d’inspiration VB : And / Or / =, pas&& / || / ==. La comparaison de chaînes utilise =.
// Des abonnements factices sur lesquels appliquer la règle.var abonnements =new List<Subscription>{new(){ Name ="ENT-001", Montant =1500, Pays ="FR", Actif =true},new(){ Name ="ENT-002", Montant =500, Pays ="FR", Actif =true},new(){ Name ="ENT-003", Montant =3000, Pays ="US", Actif =true},new(){ Name ="ENT-004", Montant =2000, Pays ="FR", Actif =false},};// La règle est une CHAÎNE — elle aurait aussi bien pu être lue dans un fichier.string regle ="Montant > 1000 And Pays = \"FR\" And Actif = true";Console.WriteLine($"Règle appliquée : {regle}");Console.WriteLine();// Compilation unique : le socle (FleePredicateBuilder) reflète automatiquement// les propriétés publiques de Subscription (Montant, Pays, Actif) et les expose// comme variables de la règle. Fini l'enregistrement manuel ctx.Variables.Add :// la signature du type fait office de contrat de variables.var predicat = FleePredicateBuilder.Create<Subscription>(regle);Console.WriteLine("Eligibles :");foreach(var ab in abonnements){ Console.WriteLine($" {ab.Name,-8} Montant={ab.Montant,6} Pays={ab.Pays} Actif={ab.Actif,-5} -> {predicat(ab)}");}// On change la règle (les données pilotent), toujours sans recompiler l'hôte :// un nouveau Create<T> recompile un délégué indépendant. Les deux règles coexistent.string regle2 ="Montant > 2000";var predicat2 = FleePredicateBuilder.Create<Subscription>(regle2);Console.WriteLine();Console.WriteLine($"Règle 2 : {regle2}");var gros = abonnements.Where(predicat2);Console.WriteLine($"Gros abonnements (>2000) : {string.Join(",", gros.Select(a => a.Name))}");
Lire les verdicts un à un. La table des éligibles se lit comme une démonstration de la conjonction : chaque ligne False échoue sur exactement un terme. ENT-002 échoue sur le montant (Montant= 500) ; ENT-003 sur le pays (Pays=US) ; ENT-004 échoue sur le drapeau — et c’est la ligne piège : Montant= 2000 en Pays=FR, mais Actif=False. Avec un And, une seule condition non remplie suffit à disqualifier : la règle encode une priorité métier (l’activité prime sur le montant), chose qu’il faudrait autrement écrire en branches if imbriquées. Seul ENT-001 satisfait tous les termes — le True solitaire du tableau.
Deux règles, un hôte. La seconde partie est le point économique de la démonstration : Règle 2 : Montant > 2000 produit un second délégué indépendant, compilé pendant que le premier reste utilisable. Le résultat Gros abonnements (>2000) : ENT-003 appelle une lecture attentive du bord : ENT-004 affiche exactement Montant= 2000 et n’est PAS retenu — la comparaison est stricte, > exclut l’égalité. Changer le seuil, ajouter une condition : tout se joue dans des chaînes — donc dans des données — sans jamais recompiler le programme hôte. La logique a changé de camp, elle vit désormais du côté de la configuration.
Exercice 3 — Une règle à seuil variable
Objectif. Compléter FiltrerParMontant(min) pour qu’elle renvoie les noms des abonnements dont le Montant est supérieur ou égal au seuil passé en paramètre. La règle doit être construite comme une chaîne à partir du seuil ($"Montant >= {min}"), compilée via le socle, puis appliquée.
Indice.FleePredicateBuilder.Create<Subscription>($"Montant >= {min}") renvoie un Func<Subscription, bool> : passez-le directement à .Where(...) puis sélectionnez les Name. Syntaxe Flee : >= pour « supérieur ou égal ».
// Exercice 3 (a completer) : règle de seuil variable, compilée depuis une chaîne.public IEnumerable<string>FiltrerParMontant(IEnumerable<Subscription> abonnements,double min){// TODO etudiant : construire la règle "$"Montant >= {min}"", la compiler via Flee,// et renvoyer les noms des abonnements au-dessus du seuil.return Enumerable.Empty<string>();// TODO etudiant}// --- zone de validation (ne pas modifier) ---var retenus =FiltrerParMontant(abonnements,1500);Console.WriteLine(retenus.Any()? $"Exercice 3 -> abonnements >= 1500 : {string.Join(",", retenus)}":"Exercice 3 : non complété (renvoie une liste vide).");
Exercice 3 : non complété (renvoie une liste vide).
Une liste vide n’est pas un échec de code. Contrairement aux exercices précédents, le garde-fou annonce ici une liste vide — la fonction squelette est censée retourner une collection, et la collection non remplie se présente vide plutôt que nulle. Le travail demandé prolonge directement la cellule précédente : compiler depuis une chaîne un prédicat dont le seuil est variable, et l’appliquer aux mêmes abonnements. L’auto-vérification la plus simple ne nécessite aucun outil : filtrer à la main la table de la section précédente avec votre seuil, puis comparer avec ce que renvoie la cellule complétée.
Conclusion
Trois moments, un même fil rouge : séparer la structure de l’utilisation.
Moment
Ce qui change
Bénéfice
Décoration → introspection
On déclare le rôle d’un type par attribut
Un nouveau type est découvrable sans toucher au code qui le consomme
Sérialisation round-trip
Le graphe persiste et se reconstitue
La configuration peut être un fichier, pas du code
Prédicat universel (Flee)
La règle métier est une chaîne
La logique est pilotée par les données, éditable sans redéploiement
Le socle MyIA.AI.Shared factorise ces trois besoins transverses (découverte, persistance, logique déclarative) afin que les modules métier se concentrent sur leur domaine propre. C’est le sens d’un socle : ce qui était ré-écrit dans chaque projet devient une convention partagée, testée une fois pour toutes (48 tests unitaires au passage).