diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 45da0be..12395a3 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -253,7 +253,7 @@ double wrapping or application-level directive leakage. ## Stage 5 — Complete strict tiers and main version ownership -Status: planned. Scope: assembly and version interactions, not a new merger. +Status: complete. Scope: assembly and version interactions, not a new merger. 1. Review `acquireUnderlayGroup`: version checks must use placed content, and documents taken from `acquired.composition.applications` must already be @@ -273,8 +273,14 @@ Status: planned. Scope: assembly and version interactions, not a new merger. Preserve exact configured-path integer conversion and ordinary nested typing. Validate: destination/composition/overlay/version/grouped tests and everyday -check. Done: placed and authored equivalents agree on values and conflicts; -main ownership and lifecycle timing are preserved. +check. Validated with `go test ./internal/engine -run 'LayeredDestination'`, +`go test ./internal/engine -run 'Destination|Composition|Overlay|Version|Grouped'`, +and `./scripts/check.sh` (go1.26.8 linux/amd64); all passed. The new mounted +cases verify strict same-tier conflicts and disjoint composition, independent +destinations and nested paths, recursive/atomic overlay behavior, explicit +null/false/zero, and main-only nested version checking after imports compose. +Done: placed and authored equivalents agree on values and conflicts; main +ownership and lifecycle timing are preserved. ## Stage 6 — Reconcile diagnostic coordinate systems diff --git a/internal/engine/destination_tiers_test.go b/internal/engine/destination_tiers_test.go new file mode 100644 index 0000000..463511b --- /dev/null +++ b/internal/engine/destination_tiers_test.go @@ -0,0 +1,328 @@ +package engine + +import ( + "path/filepath" + "reflect" + "testing" + "testing/fstest" +) + +func TestLayeredDestinationStrictCompositionBoundaries(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + inputs []Input + wantPath []string + want any + alsoPath []string + alsoWant any + }{ + { + name: "disjoint peers at one destination", + inputs: []Input{ + {Source: Bytes("left", []byte("left: one\n")), Destination: Keys("application")}, + {Source: Bytes("right", []byte("right: two\n")), Destination: Keys("application")}, + }, + wantPath: []string{"application", "right"}, + want: "two", + alsoPath: []string{"application", "left"}, + alsoWant: "one", + }, + { + name: "different destinations", + inputs: []Input{ + {Source: Bytes("left", []byte("value: shared\n")), Destination: Keys("left")}, + {Source: Bytes("right", []byte("value: shared\n")), Destination: Keys("right")}, + }, + wantPath: []string{"right", "value"}, + want: "shared", + }, + { + name: "ancestor and descendant destinations", + inputs: []Input{ + {Source: Bytes("parent", []byte("branch: {base: one}\n")), Destination: Keys("application")}, + {Source: Bytes("child", []byte("leaf: two\n")), Destination: Keys("application", "branch")}, + }, + wantPath: []string{"application", "branch", "leaf"}, + want: "two", + alsoPath: []string{"application", "branch", "base"}, + alsoWant: "one", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + loaded, err := LoadLayered(Input{Source: NoSource()}, testSecretBinding(), LayeredOptions[map[string]any]{ + Underlays: []Underlay{{Inputs: test.inputs}}, + }) + if err != nil { + t.Fatalf("LoadLayered: %v", err) + } + result := testLoaded(loaded) + if got := layeredMapValue(t, result.Config, test.wantPath...); got != test.want { + t.Fatalf("value at %v = %#v, want %#v", test.wantPath, got, test.want) + } + if len(test.alsoPath) != 0 { + if got := layeredMapValue(t, result.Config, test.alsoPath...); got != test.alsoWant { + t.Fatalf("value at %v = %#v, want %#v", test.alsoPath, got, test.alsoWant) + } + } + }) + } + + for _, test := range []struct { + name string + left string + right string + }{ + {name: "equal values conflict", left: "value: same\n", right: "value: same\n"}, + {name: "unequal values conflict", left: "value: left\n", right: "value: right\n"}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + inputs := []Input{ + {Source: Bytes("left", []byte(test.left)), Destination: Keys("application")}, + {Source: Bytes("right", []byte(test.right)), Destination: Keys("application")}, + } + loaded, err := LoadLayered(Input{Source: NoSource()}, testSecretBinding(), LayeredOptions[map[string]any]{ + Underlays: []Underlay{{Inputs: inputs}}, + }) + if loaded != nil || groupedErrorCode(t, err) != CodeComposeConflict { + t.Fatalf("strict composition result = %#v, %v", loaded, err) + } + }) + } + + t.Run("strict conflict is not hidden by an upper override", func(t *testing.T) { + inputs := []Input{ + {Source: Bytes("left", []byte("value: left\n")), Destination: Keys("application")}, + {Source: Bytes("right", []byte("value: right\n")), Destination: Keys("application")}, + } + loaded, err := LoadLayered( + Input{Source: Bytes("main", []byte("value: upper\n")), Destination: Keys("application")}, + testSecretBinding(), + LayeredOptions[map[string]any]{Underlays: []Underlay{{Inputs: inputs}}}, + ) + if loaded != nil || groupedErrorCode(t, err) != CodeComposeConflict { + t.Fatalf("strict composition result = %#v, %v", loaded, err) + } + }) +} + +func TestLayeredDestinationMatchesExplicitlyWrappedInput(t *testing.T) { + t.Parallel() + + load := func(input Input) map[string]any { + t.Helper() + loaded, err := LoadLayered(Input{Source: NoSource()}, testSecretBinding(), LayeredOptions[map[string]any]{ + Underlays: []Underlay{{Inputs: []Input{input}}}, + }) + if err != nil { + t.Fatalf("LoadLayered: %v", err) + } + return testLoaded(loaded).Config + } + + placed := load(Input{Source: Bytes("placed", []byte("field: value\n")), Destination: Keys("application")}) + authored := load(Input{Source: Bytes("authored", []byte("application: {field: value}\n"))}) + if !reflect.DeepEqual(placed, authored) { + t.Fatalf("placed configuration %#v differs from explicitly wrapped %#v", placed, authored) + } +} + +func TestLayeredDestinationOverlayRetainsOrdinaryValueSemantics(t *testing.T) { + t.Parallel() + + filesystem := NewFilesystem("bundle", fstest.MapFS{ + "overlays/test.yml": &fstest.MapFile{Data: []byte("settings:\n metadata:\n version: overlay\n")}, + }) + loaded, err := LoadLayered( + Input{Source: Bytes("main", []byte("mapping: {shared: main}\nsequence: [9]\ncleared: null\nenabled: false\nmetadata: {version: main}\n")), Destination: Keys("settings")}, + testSecretBinding(), + LayeredOptions[map[string]any]{ + Underlays: []Underlay{{Inputs: []Input{{ + Source: Bytes("underlay", []byte("mapping: {retained: lower, shared: lower}\nsequence: [1, 2]\ncleared: {old: true}\nenabled: true\ncount: 7\nmetadata: {version: lower, label: retained}\n")), + Destination: Keys("settings"), + }}}}, + Overlay: "test", + OverlayFilesystem: &FilesystemContext{Root: ".", BaseDir: ".", FS: &filesystem}, + Overrides: []Override{{Path: Keys("settings", "count"), Value: 0, Flag: "--count"}}, + }, + ) + if err != nil { + t.Fatalf("LoadLayered: %v", err) + } + result := testLoaded(loaded) + settings := layeredMap(t, result.Config, "settings") + mapping := layeredMap(t, settings, "mapping") + if mapping["retained"] != "lower" || mapping["shared"] != "main" { + t.Fatalf("recursive mapping overlay = %#v", mapping) + } + sequence, ok := settings["sequence"].([]any) + if !ok || len(sequence) != 1 || sequence[0] != int64(9) { + t.Fatalf("sequence overlay = %#v", settings["sequence"]) + } + if value, present := settings["cleared"]; !present || value != nil { + t.Fatalf("explicit null = %#v, present %v", value, present) + } + if value, ok := settings["enabled"].(bool); !ok || value { + t.Fatalf("explicit false = %#v", settings["enabled"]) + } + if value, ok := settings["count"].(int64); !ok || value != 0 { + t.Fatalf("explicit zero override = %#v", settings["count"]) + } + metadata := layeredMap(t, settings, "metadata") + if metadata["version"] != "overlay" || metadata["label"] != "retained" { + t.Fatalf("version-only overlay = %#v", metadata) + } + if _, present := settings["settings"]; present { + t.Fatalf("upper tiers were incorrectly nested under the main destination: %#v", result.Config) + } +} + +func TestLayeredDestinationChecksVersionOnceOnComposedMain(t *testing.T) { + t.Parallel() + + type appConfig struct { + Version int8 `yaml:"version"` + Count int `yaml:"count"` + Main string `yaml:"main"` + Imported string `yaml:"imported"` + Lower string `yaml:"lower"` + } + type config struct { + App appConfig `yaml:"app"` + } + filesystem := NewFilesystem("bundle", fstest.MapFS{ + "main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: fragment.yml}]}\nversion: 6\ncount: 4\nmain: main\n")}, + "fragment.yml": &fstest.MapFile{Data: []byte("imported: included\n")}, + }) + checks := 0 + loaded, err := LoadLayered( + Input{Source: filesystem.File("main.yml"), Imports: true, Destination: Keys("app")}, + testSecretBinding(), + LayeredOptions[config]{ + Definition: Definition[config]{Version: &VersionRule{Path: Keys("app", "version"), Check: func(version Version, _ *Validation) error { + checks++ + if !version.Present || version.Text != "6" { + t.Errorf("nested main version = %#v", version) + } + return nil + }}}, + Underlays: []Underlay{{Inputs: []Input{{ + Source: Bytes("lower", []byte("lower: underlay\n")), Destination: Keys("app"), + }}}}, + }, + ) + if err != nil { + t.Fatalf("LoadLayered: %v", err) + } + result := testLoaded(loaded) + if checks != 1 { + t.Fatalf("version hook calls = %d, want 1", checks) + } + if result.Config.App.Version != 6 || result.Config.App.Count != 4 || result.Config.App.Main != "main" || result.Config.App.Imported != "included" || result.Config.App.Lower != "underlay" { + t.Fatalf("nested app = %#v", result.Config.App) + } +} + +func TestLayeredDestinationVersionClaimsUsePlacedApplicationPaths(t *testing.T) { + t.Parallel() + + rule := &VersionRule{Path: Keys("app", "schema", "version"), Check: func(Version, *Validation) error { return nil }} + for _, test := range []struct { + name string + body string + wantCode Code + wantValue any + }{ + {name: "underlay direct claim", body: "schema: {version: v2}\n", wantCode: CodeUnderlayVersion}, + {name: "underlay ancestor claim", body: "schema: null\n", wantCode: CodeUnderlayVersion}, + {name: "underlay mapping ancestor without claim", body: "schema: {name: ordinary}\n", wantValue: "ordinary"}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + loaded, err := LoadLayered( + Input{Source: NoSource()}, + testSecretBinding(), + LayeredOptions[map[string]any]{ + Definition: Definition[map[string]any]{Version: rule}, + Underlays: []Underlay{{Inputs: []Input{{ + Source: Bytes("underlay", []byte(test.body)), Destination: Keys("app"), + }}}}, + }, + ) + if test.wantCode != "" { + if loaded != nil || groupedErrorCode(t, err) != test.wantCode { + t.Fatalf("version claim result = %#v, %v", loaded, err) + } + return + } + if err != nil { + t.Fatalf("nonclaiming mapping ancestor was rejected: %v", err) + } + result := testLoaded(loaded) + if got := layeredMapValue(t, result.Config, "app", "schema", "name"); got != test.wantValue { + t.Fatalf("nested ordinary value = %#v", got) + } + }) + } + + for _, test := range []struct { + name string + body string + wantCode Code + }{ + {name: "imported direct claim", body: "schema: {version: v2}\n", wantCode: CodeImportVersion}, + {name: "imported ancestor claim", body: "schema: null\n", wantCode: CodeImportVersion}, + {name: "imported mapping ancestor without claim", body: "schema: {name: ordinary}\n"}, + } { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + directory := t.TempDir() + writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: fragment.yml}]}\n") + writeImportFile(t, directory, "fragment.yml", test.body) + loaded, err := LoadLayered( + Input{Source: NoSource()}, + testSecretBinding(), + LayeredOptions[map[string]any]{ + Definition: Definition[map[string]any]{Version: rule}, + Underlays: []Underlay{{Inputs: []Input{{ + Source: File(filepath.Join(directory, "main.yml")), Imports: true, Destination: Keys("app"), + }}}}, + }, + ) + if test.wantCode != "" { + if loaded != nil || groupedErrorCode(t, err) != test.wantCode { + t.Fatalf("version claim result = %#v, %v", loaded, err) + } + return + } + if err != nil { + t.Fatalf("nonclaiming imported mapping ancestor was rejected: %v", err) + } + result := testLoaded(loaded) + if got := layeredMapValue(t, result.Config, "app", "schema", "name"); got != "ordinary" { + t.Fatalf("nested ordinary value = %#v", got) + } + }) + } +} + +func layeredMap(t *testing.T, value map[string]any, key string) map[string]any { + t.Helper() + result, ok := value[key].(map[string]any) + if !ok { + t.Fatalf("%q = %#v, want mapping", key, value[key]) + } + return result +} + +func layeredMapValue(t *testing.T, value map[string]any, keys ...string) any { + t.Helper() + for _, key := range keys[:len(keys)-1] { + value = layeredMap(t, value, key) + } + return value[keys[len(keys)-1]] +}