diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 73d9d97..4828ea5 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -394,6 +394,8 @@ existing tests for ordinary dependency closure and credential non-retention. ## Stage 12 — Verify the complete feature through the public facade +**Status:** Complete. Public resolution, identity, diff, disclosure, and boundary checks passed. + **Scope:** representative integration and ownership regressions. 1. Add a focused public fixture, for example diff --git a/facade_resolution_sequences_test.go b/facade_resolution_sequences_test.go new file mode 100644 index 0000000..9c3ea24 --- /dev/null +++ b/facade_resolution_sequences_test.go @@ -0,0 +1,223 @@ +package configkit_test + +import ( + "errors" + "strings" + "testing" + + "go.maximumdirect.net/configkit" +) + +type resolutionSequenceItem struct { + ID string `yaml:"id"` + Value string `yaml:"value"` +} +type resolutionSequenceConfig struct { + Groups map[string][]resolutionSequenceItem `yaml:"groups"` +} + +func TestPublicResolutionOrdinaryListField(t *testing.T) { + type config struct { + Items []resolutionSequenceItem `yaml:"items"` + } + definition := configkit.Definition[config]{Resolve: func(value *config, r *configkit.Resolution) error { + configkit.Normalize(r, configkit.Keys("items").Index(0).Key("value"), &value.Items[0].Value, "changed", "normalize") + return nil + }} + result, err := configkit.Load(configkit.Bytes("items", []byte("items: [{id: a, value: old}]\n")), configkit.Options[config]{Definition: definition}) + if err != nil || result.Config.Items[0].Value != "changed" { + t.Fatalf("result = %#v, %v", result, err) + } +} + +func TestPublicRegisteredResolutionHistoryAndOwnership(t *testing.T) { + selector := configkit.SequenceSelector{configkit.SequenceLiteralKey("groups"), configkit.SequenceAnyMappingKey()} + path := configkit.Keys("groups", "active") + entryFixed := false + definition := configkit.Definition[resolutionSequenceConfig]{ + SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityMatching(selector, "id")}, + Resolve: func(value *resolutionSequenceConfig, r *configkit.Resolution) error { + entryFixed = r.IsExplicit(path.Index(0).Key("value")) && !r.IsExplicit(path.Index(1).Key("value")) + items := value.Groups["active"] + items[0], items[1] = items[1], items[0] + items[1].Value = "normalized" + value.Groups["active"] = items + r.RecordNormalization(path, "reorder") + r.RecordNormalization(path.Index(1).Key("value"), "normalize") + return nil + }, + } + source := configkit.Bytes("records", []byte("groups:\n active:\n - id: a\n value: original\n - id: b\n")) + load := func() *configkit.Result[resolutionSequenceConfig] { + result, err := configkit.Load(source, configkit.Options[resolutionSequenceConfig]{Definition: definition}) + if err != nil { + t.Fatal(err) + } + return result + } + result := load() + if !entryFixed || result.Config.Groups["active"][1].Value != "normalized" { + t.Fatalf("result = %#v", result.Config) + } + info, ok := result.Metadata().Lookup(path.Index(1).Key("value")) + if !ok || !info.Explicit || info.Origin.Layer != configkit.LayerResolution { + t.Fatalf("metadata = %#v, %t", info, ok) + } + explanation, ok := result.Explain(path.Index(1).Key("value")) + if !ok { + t.Fatal("missing explanation") + } + found := false + for _, change := range explanation.History { + if change.Origin.Layer == configkit.LayerResolution && change.Before.YAML == "original\n" && change.After.YAML == "normalized\n" { + found = true + } + } + if !found { + t.Fatalf("history = %#v", explanation.History) + } + other := load() + result.Config.Groups["active"][1].Value = "mutated" + if len(explanation.History) != 0 { + explanation.History[0].After.YAML = "mutated" + } + again, _ := result.Explain(path.Index(1).Key("value")) + if strings.Contains(again.Effective.YAML, "mutated") || other.Config.Groups["active"][1].Value != "normalized" { + t.Fatal("inspection or independent load changed") + } + plain, err := configkit.Load[resolutionSequenceConfig](source) + if err != nil { + t.Fatal(err) + } + differences, err := configkit.Diff(plain, result) + if err != nil || len(differences) != 1 || differences[0].Path.String() != "/groups/active" { + t.Fatalf("diff = %#v, %v", differences, err) + } + layered, err := configkit.LoadLayered(configkit.Input{Source: source}, configkit.LayeredOptions[resolutionSequenceConfig]{Definition: definition}) + if err != nil || layered.Config.Groups["active"][1].Value != other.Config.Groups["active"][1].Value { + t.Fatalf("layered = %#v, %v", layered, err) + } +} + +func TestPublicRegisteredMoveRequiresContainerRecord(t *testing.T) { + type config struct { + Items []resolutionSequenceItem `yaml:"items"` + } + _, err := configkit.Load(configkit.Bytes("items", []byte("items: [{id: a}, {id: b}]\n")), configkit.Options[config]{Definition: configkit.Definition[config]{ + SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("items"), "id")}, + Resolve: func(value *config, r *configkit.Resolution) error { + value.Items[0], value.Items[1] = value.Items[1], value.Items[0] + r.RecordNormalization(configkit.Keys("items").Index(1).Key("id"), "record field") + return nil + }, + }}) + var failure *configkit.LoadError + if !errors.As(err, &failure) { + t.Fatalf("error = %v", err) + } + for _, diagnostic := range failure.Diagnostics() { + if diagnostic.Code == configkit.CodeResolveUnrecordedChange && diagnostic.Path.String() == "/items" { + return + } + } + t.Fatalf("diagnostics = %#v", failure.Diagnostics()) +} + +func TestPublicRegisteredRemovalKeepsSurvivorHistory(t *testing.T) { + type config struct { + Items []resolutionSequenceItem `yaml:"items"` + } + definition := configkit.Definition[config]{ + SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("items"), "id")}, + Resolve: func(value *config, r *configkit.Resolution) error { + value.Items = value.Items[1:] + r.RecordNormalization(configkit.Keys("items"), "remove first") + return nil + }, + } + result, err := configkit.Load(configkit.Bytes("items", []byte("items:\n - id: reports\n value: report\n - id: invoices\n value: invoice\n")), configkit.Options[config]{Definition: definition}) + if err != nil { + t.Fatal(err) + } + explanation, ok := result.Explain(configkit.Keys("items").Index(0).Key("value")) + if !ok { + t.Fatal("missing survivor") + } + for _, change := range explanation.History { + if change.Origin.Layer == configkit.LayerMain && change.After.YAML == "report\n" { + t.Fatalf("wrong lineage = %#v", explanation.History) + } + } + container, ok := result.Explain(configkit.Keys("items")) + if !ok { + t.Fatal("missing collection explanation") + } + found := false + for _, change := range container.History { + if change.Origin.Layer == configkit.LayerResolution && strings.Contains(change.Before.YAML, "reports") { + found = true + } + } + if !found { + t.Fatalf("missing removal evidence = %#v", container.History) + } + if _, ok := result.Explain(configkit.Keys("items").Index(1)); !ok { + t.Fatal("missing historical position") + } +} + +func TestPublicYAMLIdentityRemainsPositionalDuringResolution(t *testing.T) { + type config struct { + Items []resolutionSequenceItem `yaml:"items"` + } + result, err := configkit.Load(configkit.Bytes("items", []byte("config: {sequences: [{destination: [items], key: id}]}\nitems: [{id: a, value: first}, {id: b, value: second}]\n")), configkit.Options[config]{Definition: configkit.Definition[config]{Resolve: func(value *config, r *configkit.Resolution) error { + value.Items[0], value.Items[1] = value.Items[1], value.Items[0] + r.RecordNormalization(configkit.Keys("items"), "reorder") + return nil + }}}) + if err != nil { + t.Fatal(err) + } + explanation, ok := result.Explain(configkit.Keys("items").Index(0).Key("value")) + if !ok { + t.Fatal("missing explanation") + } + for _, change := range explanation.History { + if change.Origin.Layer == configkit.LayerResolution && change.Before.YAML == "first\n" && change.After.YAML == "second\n" { + return + } + } + t.Fatalf("positional history = %#v", explanation.History) +} + +func TestPublicMovedSensitiveRecordIsHidden(t *testing.T) { + type config struct { + Items []resolutionSequenceItem `yaml:"items"` + } + result, err := configkit.Load(configkit.Bytes("items", []byte("items: [{id: a, value: FACADE_PRIVATE_CANARY}, {id: b, value: public}]\n")), configkit.Options[config]{Definition: configkit.Definition[config]{ + SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("items"), "id")}, + Sensitive: func(path configkit.Path) bool { return path.String() == "/items/0/value" }, + Resolve: func(value *config, r *configkit.Resolution) error { + value.Items[0], value.Items[1] = value.Items[1], value.Items[0] + r.RecordNormalization(configkit.Keys("items"), "reorder") + return nil + }, + }}) + if err != nil { + t.Fatal(err) + } + for _, path := range []configkit.Path{configkit.Keys("items"), configkit.Keys("items").Index(1).Key("value")} { + explanation, ok := result.Explain(path) + if !ok { + t.Fatalf("missing explanation %s", path.String()) + } + if strings.Contains(explanation.Effective.YAML, "FACADE_PRIVATE_CANARY") { + t.Fatal("effective leak") + } + for _, change := range explanation.History { + if strings.Contains(change.Before.YAML, "FACADE_PRIVATE_CANARY") || strings.Contains(change.After.YAML, "FACADE_PRIVATE_CANARY") { + t.Fatal("history leak") + } + } + } +}