La librairie compose-go parse les fichiers compose.yaml pour Docker Compose et d'autres outils tiers. La v2 présente un défaut conceptuel : chaque fichier est désérialisé en map[string]any immédiatement après parsing, puis interpolé avant la fusion multi-fichiers. Le contexte de chargement (variables d'environnement, répertoire de travail, fichier d'origine, position ligne/colonne) est perdu dès la conversion. Conséquence :
include:permet de redéfinirproject_directoryetenv_filepour le fichier inclus, mais comme l'interpolation est eager et par-fichier, elle ne peut pas être différée. Toute reconsidération ultérieure du contexte est impossible.extendssouffre d'un problème analogue (un service étendu depuis un fichier B devrait être interpolé dans le contexte de B, ce qui marche partiellement aujourd'hui mais via du code spécifique).- La résolution des chemins relatifs utilise toujours le
WorkingDirracine, même pour des chemins déclarés dans un fichier inclus avec son propreproject_directory(bug latent v2). - Les diagnostics (erreurs de validation, interpolation, schema) ne peuvent pas indiquer
file:line:colcar cette information est perdue dèsconvertToStringKeysRecursive.
Objectif v3 : préserver l'arbre *yaml.Node brut tout au long du pipeline. Accumuler les layers (yaml.Node + SourceContext) issus des fichiers multiples, include, extends sans fusionner eagerly. Une phase finale de résolution applique les règles de merge (override / append) sur les arbres Node. L'interpolation s'exécute après merge, chaque scalaire utilisant le SourceContext de son layer d'origine (lazy interpolation). Les positions sources sont conservées pour offrir un meilleur diagnostic.
Décisions arbitrées avec l'utilisateur :
- Refonte in-place (pas de packages sibling, pas de feature flag) — branche dédiée
v3. - Décodage final via
yaml.v4natif ((*yaml.Node).Decode) — abandon complet demapstructure. - Diagnostics : erreurs enrichies + API publique
Project.Sourcesopt-in. - Aucune compatibilité ascendante requise.
LoadWithContext(ctx, ConfigDetails, options...) (*Project, error)
│
├─ 1. Parse layer-by-layer
│ Pour chaque ConfigFile :
│ a. yaml.Decoder → *yaml.Node (un par document YAML, multi-doc OK)
│ b. ResolveResetOverride(node) → tree + paths !reset / !override
│ c. NormalizeAliases(node) → aliases dépliés (deep clone, cycles protégés)
│ d. Construire Layer{Node, SourceContext{File, WorkingDir, Environment, EnvFiles, Parent}}
│ e. originMap : *yaml.Node → *SourceContext pour chaque scalaire du sous-arbre
│
├─ 2. Récursion include / extends
│ a. Repérer `include:` dans chaque layer
│ - Interpoler UNIQUEMENT le bloc include dans le contexte du layer parent
│ (pour résoudre ${VAR} dans path / project_directory / env_file)
│ - Calculer child SourceContext (project_directory, env_file chargés)
│ - Parser récursivement les fichiers inclus → child Layers
│ b. Repérer `services.*.extends.file` similairement → sub-Layers
│ Résultat : liste plate de Layers ordonnée selon priorité d'override
│ Détection des cycles par filename absolu et (filename, serviceName)
│
├─ 3. Merge des Layers
│ MergeLayers([]Layer) → Layer unique
│ - Walk parallèle de tous les arbres yaml.Node
│ - À chaque path tree.Path, applique la règle override (mergeSpecials)
│ - Synthèse de nodes pour short-form→long-form si besoin
│ - Chaque scalaire du résultat conserve son origine via originMap
│ - Application des !reset / !override paths collectés en phase 1
│
├─ 4. Interpolate (LAZY — la correction clé)
│ Walk de l'arbre fusionné. Pour chaque ScalarNode :
│ - Récupérer SourceContext via originMap[node]
│ - Substituer ${VAR} avec ctx.Environment
│ - Conserver le Style (DoubleQuoted, Plain, etc.) pour round-trip
│
├─ 5. TagNormalizer (remplace le cast hook mapstructure)
│ Pour chaque path dans interpolateTypeCastMapping :
│ - Forcer node.Tag = "!!int" / "!!bool" / "!!float"
│ - yaml.v4 fera la conversion au Decode
│
├─ 6. Canonical transform (short→long form) sur yaml.Node
├─ 7. ResolveRelativePaths sur yaml.Node (per-scalar WorkingDir via originMap)
├─ 8. Schema validation
│ Conversion temporaire vers map[string]any pour jsonschema (Option A)
│ Les erreurs schema sont re-mappées vers les nodes via le path
├─ 9. Normalize (implicit deps, default network, etc.) sur yaml.Node
└─10. Project projection
(*yaml.Node).Decode(&Project) — yaml natif, UnmarshalYAML par type
Populate Project.Sources si Options.Diagnostics activé
// internal/node/layer.go
type SourceContext struct {
File string // chemin absolu ou "(inline)"
WorkingDir string // pour résolution de chemins relatifs
Environment types.Mapping // pour interpolation
EnvFiles []string // ordonnés, déjà chargés dans Environment
Parent *SourceContext // chaîne include/extends pour diagnostics
}
type Layer struct {
Node *yaml.Node // racine MappingNode du document
Context *SourceContext
origins map[*yaml.Node]*SourceContext // construit lazy, utilisé par interpolation/paths
}
// types/diagnostics.go (public)
type Location struct {
File string `json:"file,omitempty"`
Line int `json:"line,omitempty"`
Column int `json:"column,omitempty"`
}
type Diagnostics struct {
Sources map[string]Location // clé = tree.Path en notation pointée
}
// errdefs/diagnostic.go (public)
type Diagnostic struct {
Path tree.Path
Location Location
Cause error
}
func (d *Diagnostic) Error() string { ... }
func (d *Diagnostic) Unwrap() error { return d.Cause }// loader/loader.go
func LoadWithContext(ctx, ConfigDetails, options...) (*Project, error) // signature inchangée
type Options struct {
// ... champs conservés (SkipValidation, SkipInterpolation, etc.) ...
Diagnostics *types.Diagnostics // nouveau, opt-in
}
func WithDiagnostics(d *types.Diagnostics) func(*Options) // nouveau
// types/config.go
type ConfigFile struct {
Filename string
Content []byte
Node *yaml.Node // nouveau, préféré quand le caller a déjà parsé
Config map[string]any // SUPPRIMÉ en v3 (pas de retrocompat)
}
// types/project.go
type Project struct {
// ... champs existants ...
Sources map[string]types.Location `yaml:"-" json:"-"` // peuplé si Options.Diagnostics non nul
}Refonte in-place sur une branche v3. Pas de coexistence v2/v3 — chaque PR fait avancer la branche. La compatibilité testdata garantit qu'aucune fonctionnalité ne régresse.
PR 1 : internal/node/ package
- Créer
internal/node/layer.go:Layer,SourceContext, accessors - Créer
internal/node/walk.go: walker génériqueWalk(*yaml.Node, tree.Path, visit)avec gestion des Kinds (Document, Mapping, Sequence, Scalar, Alias) - Créer
internal/node/origins.go: side-table*yaml.Node→*SourceContext, helpers de propagation - Pas de callers, aucun impact runtime. Tests unitaires du walker.
PR 2 : Reset/override sur yaml.Node
- Extraire la logique de
loader/reset.goversinternal/node/reset.go - Nouvelle fonction
node.ResolveResetOverride(*yaml.Node) (cleaned *yaml.Node, resetPaths, overridePaths []tree.Path, err error) - Garde le mémo de cycles (
alias bomb) et la limiteMaxNodeVisits loader/reset.godevient un adapter en attendant queloadYamlFilesoit réécrit- Tests : tous les
loader/reset_test.godoivent passer via le nouveau code
PR 3 : NormalizeAliases sur yaml.Node
internal/node/aliases.go:NormalizeAliases(*yaml.Node) error- Déplie chaque AliasNode vers une copie profonde du target (pour permettre la mutation par merge sans corruption croisée)
- Traite explicitement les clés
<<:(merge keys YAML) : entrée par entrée, surrounding-wins - Protection contre alias-bomb par visited-set (
map[*yaml.Node]intavec compteur de profondeur) - Tests : aliases imbriqués, anchors partagés, cycles
PR 4 : override.Merge réécrit sur yaml.Node
- Réécrire
override/merge.gopour opérer sur*yaml.Nodeau lieu demap[string]any - Chaque entrée de
mergeSpecials(override, append, mergeBuild, mergeLogging, mergeIPAMConfig, mergeExtraHosts, mergeToSequence, mergeDependsOn, mergeModels, mergeNetworks, mergeUlimit) doit avoir une implémentation Node - Helpers
convertIntoMapping/convertIntoSequenceproduisent des *yaml.Node synthétiques (Line/Column copiés de l'original, flagSyntheticdans le SourceContext) override.MergeYaml(utilisé parloader/include.go:214) disparaît au profit du nouveauMerge- Tests : toute la suite
override/merge_test.goadaptée
PR 5 : override.EnforceUnicity réécrit sur yaml.Node
- Réécriture portant les indexers (par
name, par port, par volume, etc.) sur Node values - Tests existants conservés
PR 6 : interpolation.Interpolate sur yaml.Node
interpolation/interpolation.go: nouvelle signaturefunc Interpolate(root *yaml.Node, opts Options) error
Options.LookupValuedevient une closure par-node :type Options struct { LookupValue func(node *yaml.Node, key string) (string, bool) Substitute func(template string, mapping template.Mapping) (string, error) Casts map[tree.Path]string // "!!int" / "!!bool" / "!!float" }
- Walk recursive de l'arbre, substitution scalaire par scalaire
- Suppression de
interpolation/recursive.go(plus de récursion surmap[string]any) - Préservation du Style YAML
PR 7 : transform.Canonical sur yaml.Node
- Réécriture de
transform/canonical.goet de chaque transformer enregistré (50+) - Chaque
TransformerFuncopère sur*yaml.Nodeet retourne*yaml.Node - Cas particuliers :
transformPorts,transformVolumeMount,transformBuild,transformFileMount,transformSSH,transformGPUs,transformDevices,transformEnvFile,transformLabelFile,transformDependsOn,transformStringSliceToMap - Tests : chaque transform doit avoir une couverture fixture (in-yaml → expected-yaml)
PR 8 : paths.ResolveRelativePaths sur yaml.Node
- Réécrire
paths/resolve.gopour walker l'arbre Node - Correction du bug latent v2 : chaque scalaire utilise
originMap[node].WorkingDirau lieu du WorkingDir racine - Chaque resolver path-spécifique (absContextPath, absPath, absExtendsPath, absVolumeMount, etc.) opère sur Node
- Tests : ajouter un fixture où un volume relatif dans un fichier inclus doit être résolu contre le
project_directoryde l'include
PR 9 : validation.Validate et Normalize sur yaml.Node
validation/validation.go: porter le map de checks vers Node (la clé restetree.Path)loader/normalize.go: normalisation (réseaux par défaut, dépendances implicites, etc.) sur Node- Pour
schema.Validate(jsonschema) : conversion temporaire versmap[string]any(Option A — coût borné, validation = un seul appel). Les erreurs schema sont re-mappées via le path pour retrouver le node et sa Location.
PR 10 : Refonte loadYamlFile → loadLayer
- Réécrire
loader/loader.go::loadYamlFile:func loadLayer(ctx, file types.ConfigFile, ctx SourceContext, opts *Options) (*node.Layer, error)
- Plus de
convertToStringKeysRecursive, plus demap[string]anyintermédiaire - Multi-document YAML : produit
[]Layerpour un seul fichier - Tests : single-file fixtures (
loader_test.gotestaminus include/extends)
PR 11 : Refonte ApplyInclude → collectIncludeLayers
- Réécrire
loader/include.go:func collectIncludeLayers(ctx, parent *node.Layer, opts *Options) ([]*node.Layer, error)
- Interpoler seulement le sous-arbre
include:dans le contexte parent (pour ${VAR} dans path/project_directory/env_file) - Construire les SourceContext enfants (env_file chargé, project_directory résolu)
- Détection de cycle via filenames absolus
- Pas de merge eager — retourne juste les Layers à fusionner plus tard
- Tests :
loader/include_test.go+ nouveaux fixtures lazy-interp
PR 12 : Refonte ApplyExtends → applyExtendsToLayer
- Réécrire
loader/extends.go:func applyExtendsToLayer(ctx, layer *node.Layer, ct *cycleTracker, opts *Options) error
- Charge les fichiers cibles d'extends en one-shot Layers
- Merge per-service via
override.Merge(Node-based) - Tests :
loader/extends_test.go+ scénarios cross-context
PR 13 : Refonte LoadWithContext orchestrateur
- Réécrire
loader/loader.go::load:func load(ctx, cd types.ConfigDetails, opts *Options) (*node.Layer, error) { layers := []*node.Layer{} for _, f := range cd.ConfigFiles { layer, err := loadLayer(ctx, f, rootContext, opts) children, err := collectIncludeLayers(ctx, layer, opts) applyExtendsToLayer(ctx, layer, ct, opts) layers = append(layers, children...) layers = append(layers, layer) } merged, err := node.MergeLayers(layers) node.ApplyResetPaths(merged, allResetPaths) interpolation.Interpolate(merged.Node, opts.Interpolate) node.NormalizeTags(merged, casts) transform.Canonical(merged.Node, opts.SkipInterpolation) paths.ResolveRelativePaths(merged) validation.Validate(merged.Node) normalize.Normalize(merged.Node) return merged, nil }
- Le flux est linéaire et compréhensible.
PR 14 : UnmarshalYAML sur les types
- Audit méthodique de
types/*.go:- Chaque type avec
DecodeMapstructure→UnmarshalYAML(value *yaml.Node) error - Liste à porter :
ServicePortConfig,ServiceVolumeConfig,BuildConfig,ServiceConfig,ServiceSecretConfig,ServiceNetworkConfig,ServiceDependency,SecretConfig,ConfigObjConfig,IncludeConfig,HealthCheckConfig,MappingWithEquals,MappingWithColon,Labels,StringList,StringOrNumberList,ShellCommand,HostsList,UlimitsConfig, etc.
- Chaque type avec
- Pour
Services:UnmarshalYAMLqui injecte la cléName(remplacenameServiceshook mapstructure) - Pour
SecretConfig/ConfigObjConfig: lift deextensions.x-content - Test reflection : pour chaque type exporté de
types, vérifier qu'il a soit un tagyaml:, soitUnmarshalYAML
PR 15 : Project.UnmarshalYAML + Sources
(*Project).UnmarshalYAML(value *yaml.Node) error- Si
Options.Diagnosticsest non nul, populateProject.Sourcesen walkant l'arbre original - Suppression de
loader/mapstructure.goet de la dépendancego-viper/mapstructure/v2
PR 16 : processExtensions sur yaml.Node
- Réécrire
loader/loader.go::processExtensionspour walker l'arbre Node, déplacer lesx-*vers un sous-arbre#extensions - Pour
Options.KnownExtensions, décoder via(*yaml.Node).Decode(target)au lieu de mapstructure - Tests : fixtures x-* existants
PR 17 : Erreurs enrichies
- Refactoring de chaque retour d'erreur dans loader/, override/, interpolation/, transform/, paths/, validation/ pour wrapper en
errdefs.Diagnostic - Tests : assert que les erreurs contiennent
file:line:colpour chaque type de problème- Schema validation error
- Interpolation error (variable non définie en mode strict)
- Type cast error (port non parseable en int)
- Cycle detection (include / extends / depends_on)
- Au moins un test golden pour le format d'erreur
PR 18 : Suppression du code mort v2
- Supprimer
convertToStringKeysRecursive,fixEmptyNotNull,OmitEmpty(équivalents Node-natifs ou inutiles) - Supprimer
loader/mapstructure.goet hooks (nameServices, decoderHook, cast, secretConfigDecoderHook) - Supprimer
ResolveEnvironmentmap-based - Supprimer
interpolation/recursive.go - Suppression de la dépendance
github.com/go-viper/mapstructure/v2dans go.mod - Renommer le module v2 → v3 dans
go.mod
PR 19 : Documentation et release notes
- Mettre à jour
parsing.mdavec le nouveau pipeline - Notes de migration v2 → v3 (changements API)
- Documenter les divergences intentionnelles de comportement (lazy interpolation, per-include path resolution)
loader/testdata/ contient ~80 fixtures couvrant :
extends/(base, depends_on, interpolated, nested, ports, reset, sibling, withdir)include/(basique, project-directory, dir/, dotenv)combined/(extends + include)remote/(cycles, env files, nested)subdir/- Cycles :
compose-include-cycle.yaml,compose-depends-on-cycle.yaml,compose-depends-on-profile-no-cycle.yaml
Stratégie : pour chaque fixture, geler le Project v2 actuel via yaml.Marshal en <fixture>.golden.json. Les tests v3 doivent reproduire ces goldens, à l'exception des cas listés ci-dessous (divergence documentée).
testdata/include/lazy-interp/ — la correction phare
compose.yaml :
include:
- path: included.yaml
env_file: env/.env.parent
included.yaml :
services:
web:
image: nginx:${TAG}
ports: ["${PORT}:80"]
env/.env.parent : TAG=2.0
PORT=8080
included-dir/.env : TAG=1.0
PORT=80
Variantes :
- env_file sur l'include vs sur le parent
env_file: /dev/nulldésactive l'héritage- Service défini dans parent ET inclus — vérifier que chaque scalaire utilise SON SourceContext
testdata/include/per-include-paths/ — correction du bug WorkingDir
Un volume relatif ./data:/data déclaré dans le fichier inclus doit être résolu contre le project_directory de l'include, pas la racine.
testdata/diagnostics/ — file:line dans les erreurs
- Port non parseable comme int → erreur avec
file:line:col - Variable d'env non définie en mode strict → idem
- Service en référence circulaire → chaîne avec positions
testdata/anchors/cross-merge/ — préservation des anchors
- Anchor déclaré dans fichier A, utilisé par un service overridé dans fichier B
<<: *refavec override key dans B doit gagner- Anchor partagé entre plusieurs services avec
!resetdoit reset à chaque site
internal/node/walk_test.go— walker, gestion de tous les Kindsinternal/node/aliases_test.go— normalisation aliases, cycles, alias-bombinternal/node/reset_test.go— reset/override sur Nodeoverride/merge_node_test.go— chaque règle merge, scenarios short/long form, synthèse de Nodesinterpolation/interpolation_test.go— lazy interpolation per-scalar, préservation Styletransform/canonical_test.go— chaque transformer (50+), un test par règlepaths/resolve_test.go— per-Layer WorkingDirloader/diagnostics_test.go— file:line dans toutes les erreurs
FuzzMergeNode— arbres yaml.Node aléatoires, vérifier termination + forme du résultatFuzzResolveAliases— graphes d'aliases bornés, vérifier terminationFuzzInterpolate— env maps et templates aléatoires
- Mesurer
loadtime_v3 / loadtime_v2sur un fixture moyen (testdata/extends/withdir/) - Fixture synthétique large (~1000 services avec anchors)
- Objectif : v3 ≤ 1.5× mémoire v2, ≤ 1.2× wall-time v2
- Suite testdata complète sur chaque PR
- Fuzzing en mode court (10s par fuzz) sur PR
- Fuzzing long (1h) sur cron quotidien
- Benchmark report comparant chaque PR à la branche
v2-baseline
- Décision : normalisation explicite des aliases avant merge cross-fichier
- Aliases dépliés via deep clone pour autoriser la mutation lors du merge
- Merge-keys
<<:traités explicitement (surrounding-wins, pas d'appel àDecode) - Cycles : visited-set keyé par
*yaml.Nodeavec compteur de profondeur - Tests de régression :
TestAliasBombPrevented,TestResetTagWithSharedAlias
- ResetProcessor extrait vers
internal/node/reset.go(PR 2) - Collecte des paths à la phase 1 ; application sur l'arbre fusionné en phase 3
!overridesur un path court-circuite le merge (replace au lieu de merge) — porté dansMergeLayers!resetdans une séquence (par index) : rejet explicite avec erreur claire pointant la ligne. Le TODO deloader/reset.go:267est résolu par un échec déterministe plutôt qu'un comportement indéfini. Cas testé danstestdata/diagnostics/reset-in-sequence.yaml.
- Walk Node-natif pour détecter et déplacer les
x-* - Pour les
KnownExtensions, décodage via(*yaml.Node).Decode(target) - Tests : tous les fixtures x-* existants reproduits
- Quand un transformer synthétise un MappingNode (ex:
build: .→build: { context: . }), copier Line/Column du scalaire original - Le SourceContext de la synthèse pointe vers le parent original
- Flag
Syntheticdans l'entrée de l'originMap pour permettre des messages d'erreur du type "(near line N, derived from short form)"
- Un fichier peut contenir
---séparant plusieurs documents - Produit
[]Layermergés en ordre
santhosh-tekuri/jsonschema/v6attendanyGo-natif- Décoder l'arbre Node fusionné en
map[string]anyuniquement pour validation (throwaway) - Re-mapper les erreurs schema vers les Nodes via le path pour Location
- Suppression du hook mapstructure
TagNormalizerparcourt l'arbre, forcenode.Tag = "!!int"etc. seloninterpolateTypeCastMappingyaml.v4fait la conversion au Decode, même si la source était"80"(chaîne quotée)
- mapstructure faisait du fuzzy match (case-insensitive, snake-case)
yaml.v4est strict- Test reflection : pour chaque type exporté de
types, vérifier la présence de tagsyaml:(ouyaml:"-")
/Users/nicolas/go/src/github.com/compose-spec/compose-go/internal/node/layer.go/Users/nicolas/go/src/github.com/compose-spec/compose-go/internal/node/walk.go/Users/nicolas/go/src/github.com/compose-spec/compose-go/internal/node/origins.go/Users/nicolas/go/src/github.com/compose-spec/compose-go/internal/node/reset.go/Users/nicolas/go/src/github.com/compose-spec/compose-go/internal/node/aliases.go/Users/nicolas/go/src/github.com/compose-spec/compose-go/internal/node/merge.go/Users/nicolas/go/src/github.com/compose-spec/compose-go/types/diagnostics.go/Users/nicolas/go/src/github.com/compose-spec/compose-go/errdefs/diagnostic.go
/Users/nicolas/go/src/github.com/compose-spec/compose-go/loader/loader.go(entry points + orchestrateur)/Users/nicolas/go/src/github.com/compose-spec/compose-go/loader/include.go(collectIncludeLayers)/Users/nicolas/go/src/github.com/compose-spec/compose-go/loader/extends.go(applyExtendsToLayer)/Users/nicolas/go/src/github.com/compose-spec/compose-go/loader/reset.go(devient adapter vers internal/node)/Users/nicolas/go/src/github.com/compose-spec/compose-go/loader/normalize.go/Users/nicolas/go/src/github.com/compose-spec/compose-go/override/merge.go(signature sur *yaml.Node)/Users/nicolas/go/src/github.com/compose-spec/compose-go/override/uncity.go/Users/nicolas/go/src/github.com/compose-spec/compose-go/interpolation/interpolation.go(Interpolate sur Node + per-scalar lookup)/Users/nicolas/go/src/github.com/compose-spec/compose-go/transform/canonical.go(+ chaque transformer dans le même répertoire)/Users/nicolas/go/src/github.com/compose-spec/compose-go/paths/resolve.go(per-Layer WorkingDir)/Users/nicolas/go/src/github.com/compose-spec/compose-go/validation/validation.go
- Tous les types dans
/Users/nicolas/go/src/github.com/compose-spec/compose-go/types/qui ont actuellementDecodeMapstructure
/Users/nicolas/go/src/github.com/compose-spec/compose-go/loader/mapstructure.go/Users/nicolas/go/src/github.com/compose-spec/compose-go/interpolation/recursive.go(si existe — la récursion sur map devient inutile)- Dépendance
github.com/go-viper/mapstructure/v2dansgo.mod - Fonction publique
loader.Transform(source, target): supprimée. Les consommateurs (Docker Compose CLI, devcontainer, etc.) doivent passer à(*yaml.Node).Decode(&target). Documenté dans les notes de migration.
/Users/nicolas/go/src/github.com/compose-spec/compose-go/go.mod— passage dev2àv3dans le path
- Tests fixtures :
go test ./...doit passer avec tous les goldens regénérés - Suite de divergences documentées :
loader/divergence_test.goliste les comportements v3 intentionnellement différents de v2, chacun avec un commentaire justifiant - Diagnostics manuels : prendre 3 fichiers compose réels (un exemple Docker Compose, un avec include, un avec extends), introduire des erreurs ciblées, vérifier que les messages contiennent bien
file:line:col - Benchmark :
go test -bench=BenchmarkLoad -benchmem ./loader/comparé à un baseline checkout de la branchemainactuelle - Intégration externe : compiler Docker Compose CLI contre la branche v3, exécuter sa propre suite d'intégration pour détecter les ruptures non triviales
- Lazy interpolation cas-test manuel : exécuter le fixture
testdata/include/lazy-interp/et vérifier queservices.web.image == "nginx:2.0"(depuis.env.parent) tandis que d'autres champs du même service utilisent le.envduproject_directoryinclus - Préservation des positions :
Project.Sources["services.web.image"]doit contenir{File: "included.yaml", Line: 4, Column: 14}(ou équivalent selon la fixture) - Cycles : tous les fixtures de cycle doivent encore détecter le cycle et le rapporter avec la chaîne complète des fichiers/services impliqués
!resetsur élément de séquence par index : rejet explicite avec erreur claire (file:line). Pas d'implémentation de la suppression par index (ambiguïté entre merges, indices instables). Test dédié danstestdata/diagnostics/reset-in-sequence.yaml.loader.Transform(source, target): suppression complète. Les consommateurs migrent vers(*yaml.Node).Decode. V3 étant un major, la rupture est légitime ; documenté en notes de migration.- Ordre de fold du merge multi-fichiers : left-to-right (comportement v2 préservé) :
merge(merge(merge(f0, f1), f2), f3).ConfigFiles[0]est la base, chaque fichier suivant override le résultat accumulé. Test de régression sur fixture 3-fichiers. Project.Sourcesvisibilité : caché par défaut (yaml:"-" json:"-"). Le champ existe sur la struct mais ne pollue jamais le marshaling. Peuplé uniquement siOptions.Diagnostics(ouWithDiagnostics()) est utilisé. Compatibilité de sérialisation strictement préservée par défaut.
- Round-trip anchor preservation :
yaml.Marshal(project)ne réémet pas les anchors (snapshot plat). À tester explicitement pour confirmer le comportement, mais pas un objectif fonctionnel. - Migration des consommateurs tiers : avant le merge de la branche v3, communiquer en amont avec les maintainers de Docker Compose CLI, devcontainer-cli, et autres consommateurs identifiés via
gh searchsur compose-go. Une PR de migration de référence dans Docker Compose CLI démontre la procédure (notamment le remplacement deloader.Transform).