Skip to content

Commit 1233bd6

Browse files
fix: spec-compliant overlay merge with version-aware dispatch (#169)
1 parent dc11a8d commit 1233bd6

File tree

3 files changed

+645
-167
lines changed

3 files changed

+645
-167
lines changed

overlay/apply.go

Lines changed: 133 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -20,9 +20,9 @@ func (o *Overlay) ApplyTo(root *yaml.Node) error {
2020
case action.Remove:
2121
err = o.applyRemoveAction(root, action, nil)
2222
case !action.Update.IsZero():
23-
err = o.applyUpdateAction(root, action, &[]string{})
23+
err = o.applyUpdateAction(root, action, &[]string{}, false)
2424
case action.Copy != "":
25-
err = o.applyCopyAction(root, action, &[]string{})
25+
err = o.applyCopyAction(root, action, &[]string{}, false)
2626
}
2727

2828
if err != nil {
@@ -62,10 +62,10 @@ func (o *Overlay) ApplyToStrict(root *yaml.Node) ([]string, error) {
6262
err = o.applyRemoveAction(root, action, &actionWarnings)
6363
case !action.Update.IsZero():
6464
actionType = "update"
65-
err = o.applyUpdateAction(root, action, &actionWarnings)
65+
err = o.applyUpdateAction(root, action, &actionWarnings, true)
6666
case action.Copy != "":
6767
actionType = "copy"
68-
err = o.applyCopyAction(root, action, &actionWarnings)
68+
err = o.applyCopyAction(root, action, &actionWarnings, true)
6969
default:
7070
err = fmt.Errorf("unknown action type: %v", action)
7171
}
@@ -171,7 +171,13 @@ func removeNode(idx parentIndex, node *yaml.Node) {
171171
}
172172
}
173173

174-
func (o *Overlay) applyUpdateAction(root *yaml.Node, action Action, warnings *[]string) error {
174+
// mergeOptions carries version and strictness context through the merge call chain.
175+
type mergeOptions struct {
176+
v110 bool
177+
strict bool
178+
}
179+
180+
func (o *Overlay) applyUpdateAction(root *yaml.Node, action Action, warnings *[]string, strict bool) error {
175181
if action.Target == "" {
176182
return nil
177183
}
@@ -187,9 +193,26 @@ func (o *Overlay) applyUpdateAction(root *yaml.Node, action Action, warnings *[]
187193

188194
nodes := p.Query(root)
189195

196+
opts := mergeOptions{v110: o.IsV110OrLater(), strict: strict}
197+
198+
// Homogeneity check: 1.1.0 strict mode requires all targets to be the same kind
199+
if strict && opts.v110 && len(nodes) > 1 {
200+
firstKind := nodes[0].Kind
201+
for _, n := range nodes[1:] {
202+
if n.Kind != firstKind {
203+
return fmt.Errorf("target selected mixed node types (%s and %s); all targets must be the same type",
204+
nodeKindName(firstKind), nodeKindName(n.Kind))
205+
}
206+
}
207+
}
208+
190209
didMakeChange := false
191210
for _, node := range nodes {
192-
didMakeChange = updateNode(node, &action.Update) || didMakeChange
211+
changed, err := applyMerge(node, &action.Update, opts)
212+
if err != nil {
213+
return err
214+
}
215+
didMakeChange = changed || didMakeChange
193216
}
194217
if !didMakeChange {
195218
*warnings = append(*warnings, "does nothing")
@@ -198,37 +221,85 @@ func (o *Overlay) applyUpdateAction(root *yaml.Node, action Action, warnings *[]
198221
return nil
199222
}
200223

201-
func updateNode(node *yaml.Node, updateNode *yaml.Node) bool {
202-
return mergeNode(node, updateNode)
224+
// applyMerge is the top-level merge dispatch for update/copy actions.
225+
// It implements the Action Object rules (spec §4.4.3) which differ from
226+
// the recursive merge rules applied within object properties.
227+
func applyMerge(node *yaml.Node, update *yaml.Node, opts mergeOptions) (bool, error) {
228+
if !opts.v110 {
229+
// 1.0.0: delegate entirely to recursive merge (preserves existing behavior)
230+
return mergeNode(node, update, opts)
231+
}
232+
233+
// 1.1.0 top-level dispatch per spec §4.4.3:
234+
switch node.Kind {
235+
case yaml.SequenceNode:
236+
// Array target: concatenate if update is array, append if object/primitive
237+
if update.Kind == yaml.SequenceNode {
238+
return mergeSequenceNode(node, update), nil
239+
}
240+
// Append non-array value as single element
241+
node.Content = append(node.Content, clone(update))
242+
return true, nil
243+
244+
case yaml.MappingNode:
245+
if update.Kind != yaml.MappingNode {
246+
// Spec: update MUST be an object for object targets
247+
if opts.strict {
248+
return false, fmt.Errorf("target is object but update is %s", nodeKindName(update.Kind))
249+
}
250+
// Lax: replace gracefully
251+
*node = *clone(update)
252+
return true, nil
253+
}
254+
return mergeNode(node, update, opts)
255+
256+
default:
257+
// Primitive target: replace
258+
if update.Kind != yaml.ScalarNode && update.Kind != 0 {
259+
if opts.strict {
260+
return false, fmt.Errorf("target is primitive but update is %s", nodeKindName(update.Kind))
261+
}
262+
// Non-scalar update replaces the whole node
263+
isChanged := node.Value != update.Value || node.Kind != update.Kind
264+
*node = *clone(update)
265+
return isChanged, nil
266+
}
267+
// Scalar-to-scalar: update value only, preserving target's style/tag
268+
isChanged := node.Value != update.Value
269+
node.Value = update.Value
270+
return isChanged, nil
271+
}
203272
}
204273

205-
func mergeNode(node *yaml.Node, merge *yaml.Node) bool {
274+
// mergeNode is the recursive merge function used within object property merging.
275+
// Type mismatches at this level follow the recursive merge rules, not the
276+
// top-level Action Object rules.
277+
func mergeNode(node *yaml.Node, merge *yaml.Node, opts mergeOptions) (bool, error) {
206278
if node.Kind != merge.Kind {
207-
// Per Overlay 1.1.0 spec: "For arrays, the update value SHALL be
208-
// appended to the targeted array. [...] otherwise the update value
209-
// SHALL be appended as a single element."
210-
if node.Kind == yaml.SequenceNode {
211-
node.Content = append(node.Content, clone(merge))
212-
return true
279+
// 1.1.0 strict: "Other property value combinations are incompatible and result in an error"
280+
if opts.v110 && opts.strict {
281+
return false, fmt.Errorf("type mismatch: target is %s but update is %s",
282+
nodeKindName(node.Kind), nodeKindName(merge.Kind))
213283
}
284+
// 1.0.0 or 1.1.0 lax: gracefully replace (pre-existing behavior)
214285
*node = *clone(merge)
215-
return true
286+
return true, nil
216287
}
217288
switch node.Kind {
289+
case yaml.MappingNode:
290+
return mergeMappingNode(node, merge, opts)
291+
case yaml.SequenceNode:
292+
return mergeSequenceNode(node, merge), nil
218293
default:
219294
isChanged := node.Value != merge.Value
220295
node.Value = merge.Value
221-
return isChanged
222-
case yaml.MappingNode:
223-
return mergeMappingNode(node, merge)
224-
case yaml.SequenceNode:
225-
return mergeSequenceNode(node, merge)
296+
return isChanged, nil
226297
}
227298
}
228299

229300
// mergeMappingNode will perform a shallow merge of the merge node into the main
230301
// node.
231-
func mergeMappingNode(node *yaml.Node, merge *yaml.Node) bool {
302+
func mergeMappingNode(node *yaml.Node, merge *yaml.Node, opts mergeOptions) (bool, error) {
232303
anyChange := false
233304

234305
// If the target is an empty flow-style mapping and we're merging content,
@@ -245,15 +316,33 @@ NextKey:
245316
for j := 0; j < len(node.Content); j += 2 {
246317
nodeKey := node.Content[j].Value
247318
if nodeKey == mergeKey {
248-
anyChange = mergeNode(node.Content[j+1], mergeValue) || anyChange
319+
changed, err := mergeNode(node.Content[j+1], mergeValue, opts)
320+
if err != nil {
321+
return anyChange, fmt.Errorf("key %q: %w", mergeKey, err)
322+
}
323+
anyChange = changed || anyChange
249324
continue NextKey
250325
}
251326
}
252327

253328
node.Content = append(node.Content, merge.Content[i], clone(mergeValue))
254329
anyChange = true
255330
}
256-
return anyChange
331+
return anyChange, nil
332+
}
333+
334+
// nodeKindName returns a human-readable name for a YAML node kind.
335+
func nodeKindName(kind yaml.Kind) string {
336+
switch kind {
337+
case yaml.MappingNode:
338+
return "object"
339+
case yaml.SequenceNode:
340+
return "array"
341+
case yaml.ScalarNode:
342+
return "scalar"
343+
default:
344+
return "unknown"
345+
}
257346
}
258347

259348
// mergeSequenceNode will append the merge node's content to the original node.
@@ -285,9 +374,8 @@ func clone(node *yaml.Node) *yaml.Node {
285374
return newNode
286375
}
287376

288-
// applyCopyAction applies a copy action to the document
289-
// This is a stub implementation for the copy feature from Overlay Specification v1.1.0
290-
func (o *Overlay) applyCopyAction(root *yaml.Node, action Action, warnings *[]string) error {
377+
// applyCopyAction applies a copy action to the document.
378+
func (o *Overlay) applyCopyAction(root *yaml.Node, action Action, warnings *[]string, strict bool) error {
291379
if action.Target == "" {
292380
return nil
293381
}
@@ -328,14 +416,31 @@ func (o *Overlay) applyCopyAction(root *yaml.Node, action Action, warnings *[]st
328416
// Query the target nodes
329417
targetNodes := targetPath.Query(root)
330418

419+
opts := mergeOptions{v110: o.IsV110OrLater(), strict: strict}
420+
421+
// Homogeneity check: 1.1.0 strict mode requires all targets to be the same kind
422+
if strict && opts.v110 && len(targetNodes) > 1 {
423+
firstKind := targetNodes[0].Kind
424+
for _, n := range targetNodes[1:] {
425+
if n.Kind != firstKind {
426+
return fmt.Errorf("target selected mixed node types (%s and %s); all targets must be the same type",
427+
nodeKindName(firstKind), nodeKindName(n.Kind))
428+
}
429+
}
430+
}
431+
331432
// Copy the source node to each target
332433
didMakeChange := false
333434
for _, targetNode := range targetNodes {
334435
// Clone the source node to avoid reference issues
335436
copiedNode := clone(sourceNode)
336437

337438
// Merge the copied node into the target
338-
didMakeChange = mergeNode(targetNode, copiedNode) || didMakeChange
439+
changed, err := applyMerge(targetNode, copiedNode, opts)
440+
if err != nil {
441+
return err
442+
}
443+
didMakeChange = changed || didMakeChange
339444
}
340445

341446
if !didMakeChange && warnings != nil {

0 commit comments

Comments
 (0)