diff --git a/conversion.go b/conversion.go index 0f1876f..b37baf9 100644 --- a/conversion.go +++ b/conversion.go @@ -49,10 +49,30 @@ func toEngineSourceInfo(value SourceInfo) engine.SourceInfo { return engine.SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Layer: engine.Layer(value.Layer), Kind: engine.SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex, ImportedFrom: toEngineLocationPointer(value.ImportedFrom)} } func fromEngineOrigin(value engine.Origin) Origin { - return Origin{Layer: Layer(value.Layer), Locations: fromEngineLocations(value.Locations), Operation: ChangeKind(value.Operation)} + result := Origin{Layer: Layer(value.Layer), Locations: fromEngineLocations(value.Locations), Operation: ChangeKind(value.Operation)} + if value.Inheritance != nil { + result.Inheritance = make([]InheritanceStep, len(value.Inheritance)) + for index, step := range value.Inheritance { + result.Inheritance[index] = InheritanceStep{ + From: fromEnginePath(step.From), To: fromEnginePath(step.To), Reference: fromEnginePath(step.Reference), + Locations: fromEngineLocations(step.Locations), + } + } + } + return result } func toEngineOrigin(value Origin) engine.Origin { - return engine.Origin{Layer: engine.Layer(value.Layer), Locations: toEngineLocations(value.Locations), Operation: engine.ChangeKind(value.Operation)} + result := engine.Origin{Layer: engine.Layer(value.Layer), Locations: toEngineLocations(value.Locations), Operation: engine.ChangeKind(value.Operation)} + if value.Inheritance != nil { + result.Inheritance = make([]engine.InheritanceStep, len(value.Inheritance)) + for index, step := range value.Inheritance { + result.Inheritance[index] = engine.InheritanceStep{ + From: toEnginePath(step.From), To: toEnginePath(step.To), Reference: toEnginePath(step.Reference), + Locations: toEngineLocations(step.Locations), + } + } + } + return result } func fromEngineDiagnostic(value engine.Diagnostic) Diagnostic { return Diagnostic{Code: Code(value.Code), Severity: Severity(value.Severity), Path: fromEnginePath(value.Path), Message: value.Message, Primary: fromEngineLocationPointer(value.Primary), Related: fromEngineLocations(value.Related)} diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index dd5dfd8..052ef61 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -270,6 +270,14 @@ and the everyday checker. ## Stage 7 — Activate the common lifecycle and facade metadata +**Completed 2026-09-29.** Ordinary and layered loads now expand assembled +input before decoding while retaining original source applications, provenance, +history, sensitivity, and resource contexts. Main-owned version paths reject +structural changes without rejecting unrelated inheritance. Root records and +conversions expose detached transfer evidence; public tests cover precedence, +presence, resource context, version timing, cancellation, imports, and secrets. +`./scripts/check.sh` and `go test -race ./...` passed. + Insert expansion in `executeLoadRequest` after ordinary `applyInputLayers` or layered `assembleLayeredInput`, before `decodeLoadInput` invokes defaults/decoding. Include plain single-source loads, creating the minimal composition carrier needed diff --git a/facade_inheritance_test.go b/facade_inheritance_test.go new file mode 100644 index 0000000..1e4510e --- /dev/null +++ b/facade_inheritance_test.go @@ -0,0 +1,284 @@ +package configkit_test + +import ( + "bytes" + "io/fs" + "reflect" + "strings" + "testing" + "testing/fstest" + + configkit "gitea.maximumdirect.net/eric/ConfigKit" +) + +type inheritedEntry struct { + Enabled bool `yaml:"enabled"` + Count int `yaml:"count"` + Resource string `yaml:"resource"` + Settings map[string]map[string]string `yaml:"settings"` +} + +type inheritedConfiguration struct { + Version string `yaml:"version"` + Entries map[string]inheritedEntry `yaml:"entries"` +} + +func TestFacadeInheritancePreservesPresenceOriginsAndHistory(t *testing.T) { + input := []byte(`entries: + parent: + enabled: false + count: 0 + settings: + primary: {value: original} + secondary: {extends: primary} + child: + extends: parent + settings: + primary: {value: custom} +`) + defaultRan := false + result, err := configkit.Load(configkit.Bytes("config.yml", input), configkit.Options[inheritedConfiguration]{Definition: configkit.Definition[inheritedConfiguration]{ + Resolve: func(config *inheritedConfiguration, resolution *configkit.Resolution) error { + if !resolution.IsExplicit(configkit.Keys("entries", "child", "enabled")) { + t.Error("inherited false is not explicit") + } + info, found := resolution.InputInfo(configkit.Keys("entries", "child", "enabled")) + if !found || len(info.Origin.Inheritance) != 1 { + t.Errorf("resolver inherited origin = %#v, %v", info, found) + } + child := config.Entries["child"] + configkit.Default(resolution, configkit.Keys("entries", "child", "enabled"), &child.Enabled, true, "use fallback") + defaultRan = child.Enabled + config.Entries["child"] = child + return nil + }, + }}) + if err != nil { + t.Fatal(err) + } + child := result.Config.Entries["child"] + if defaultRan || child.Enabled || child.Count != 0 || child.Settings["secondary"]["value"] != "custom" { + t.Fatalf("inherited values = %#v, default ran %v", child, defaultRan) + } + path := configkit.Keys("entries", "child", "settings", "secondary", "value") + info, found := result.Metadata().Lookup(path) + if !found || !info.Explicit || len(info.Origin.Inheritance) != 1 || + info.Origin.Inheritance[0].From.String() != "/entries/child/settings/primary/value" || + info.Origin.Inheritance[0].To.String() != path.String() { + t.Fatalf("nested destination origin = %#v, %v", info, found) + } + info.Origin.Inheritance[0].Locations[0].SourceID = "mutated" + info, _ = result.Metadata().Lookup(path) + if info.Origin.Inheritance[0].Locations[0].SourceID == "mutated" { + t.Fatal("public inheritance record aliases inspection state") + } + if _, found := result.Metadata().Lookup(configkit.Keys("entries", "child", "extends")); found { + t.Fatal("consumed directive remained effective") + } + control, found := result.Explain(configkit.Keys("entries", "child", "extends")) + if !found || control.Effective.Present || len(control.History) < 2 || + control.History[len(control.History)-1].Origin.Operation != configkit.ChangeInheritance { + t.Fatalf("consumed directive = %#v, %v", control, found) + } + explanation, found := result.Explain(path) + if !found || explanation.Effective.YAML != "custom\n" { + t.Fatalf("nested explanation = %#v, %v", explanation, found) + } + for _, change := range explanation.History { + if change.Origin.Operation == configkit.ChangeDecoding { + t.Fatalf("inherited value attributed to decoding: %#v", explanation.History) + } + } +} + +func TestFacadeInheritanceLayeredPrecedenceAndSourceContext(t *testing.T) { + filesystem := configkit.NewFilesystem("bundle", fstest.MapFS{ + "config/main.yml": &fstest.MapFile{Data: []byte("entries: {child: {extends: parent}}\n")}, + "shared/parent.yml": &fstest.MapFile{Data: []byte("entries: {parent: {resource: icon.txt, count: 1}}\n")}, + "shared/flags.yml": &fstest.MapFile{Data: []byte("entries: {parent: {enabled: false}}\n")}, + "shared/icon.txt": &fstest.MapFile{Data: []byte("parent-resource")}, + "config/overlays/test.yml": &fstest.MapFile{Data: []byte("entries: {parent: {count: 2}}\n")}, + }) + result, err := configkit.LoadLayered(configkit.Input{Source: filesystem.File("config/main.yml")}, configkit.LayeredOptions[inheritedConfiguration]{ + Underlays: []configkit.Underlay{ + {Inputs: []configkit.Input{{Source: filesystem.File("shared/parent.yml")}}}, + {Inputs: []configkit.Input{{Source: filesystem.File("shared/flags.yml")}}}, + }, + Overlay: "test", OverlayFilesystem: &configkit.FilesystemContext{Root: "config", BaseDir: "config", FS: &filesystem}, + Overrides: []configkit.Override{{Path: configkit.Keys("entries", "child", "count"), Value: 3, Flag: "--count"}}, + }) + if err != nil { + t.Fatal(err) + } + child := result.Config.Entries["child"] + if child.Count != 3 || child.Resource != "icon.txt" { + t.Fatalf("layered inheritance = %#v", child) + } + info, found := result.Metadata().Lookup(configkit.Keys("entries", "child", "resource")) + if !found || len(info.Origin.Inheritance) != 1 || len(info.Origin.Locations) != 1 { + t.Fatalf("resource origin = %#v, %v", info, found) + } + context, found := result.SourceContext(info.Origin.Locations[0].SourceID) + if !found || context.BaseDir != "shared" || context.FS == nil { + t.Fatalf("source context = %#v, %v", context, found) + } + data, err := fs.ReadFile(context.FS, context.BaseDir+"/"+child.Resource) + if err != nil || string(data) != "parent-resource" { + t.Fatalf("source-relative resource = %q, %v", data, err) + } + count, found := result.Metadata().Lookup(configkit.Keys("entries", "child", "count")) + if !found || count.Origin.Layer != configkit.LayerCLI || len(count.Origin.Inheritance) != 0 { + t.Fatalf("CLI replacement origin = %#v, %v", count, found) + } + var rendered bytes.Buffer + if err := result.WriteEffective(&rendered); err != nil || strings.Contains(rendered.String(), "extends:") { + t.Fatalf("effective rendering = %q, %v", rendered.String(), err) + } +} + +func TestFacadeInheritanceMappingMatchesBytes(t *testing.T) { + values := map[string]any{"entries": map[string]any{ + "parent": map[string]any{"enabled": false, "count": 0}, + "child": map[string]any{"extends": "parent"}, + }} + mapped, err := configkit.Load[inheritedConfiguration](configkit.Mapping("mapping", values)) + if err != nil { + t.Fatal(err) + } + fromYAML, err := configkit.Load[inheritedConfiguration](configkit.Bytes("config.yml", []byte("entries: {parent: {enabled: false, count: 0}, child: {extends: parent}}\n"))) + if err != nil || !reflect.DeepEqual(mapped.Config, fromYAML.Config) { + t.Fatalf("mapping and bytes differ: %#v, %#v, %v", mapped, fromYAML, err) + } +} + +func TestFacadeInheritanceProtectsVersionOwnership(t *testing.T) { + for _, test := range []struct { + name, input string + path configkit.Path + wantFailure bool + }{ + {"inherited version", "entries: {parent: {count: 1}, child: {extends: parent}}\n", configkit.Keys("entries", "child", "count"), true}, + {"overridden version", "entries: {parent: {count: 1}, child: {extends: parent, count: 1}}\n", configkit.Keys("entries", "child", "count"), true}, + {"direct unchanged version", "entries: {parent: {enabled: false}, child: {extends: parent, count: 1}}\n", configkit.Keys("entries", "child", "count"), false}, + {"consumed checked directive", "entries: {parent: {count: 1}, child: {extends: parent}}\n", configkit.Keys("entries", "child", "extends"), true}, + {"independent top-level version", "version: v1\nentries: {parent: {count: 1}, child: {extends: parent}}\n", configkit.Keys("version"), false}, + } { + t.Run(test.name, func(t *testing.T) { + checks := struct{ version, defaults, resolution, validation int }{} + result, err := configkit.Load(configkit.Bytes("version.yml", []byte(test.input)), configkit.Options[inheritedConfiguration]{Definition: configkit.Definition[inheritedConfiguration]{ + Version: &configkit.VersionRule{Path: test.path, Check: func(configkit.Version, *configkit.Validation) error { + checks.version++ + return nil + }}, + Defaults: func() inheritedConfiguration { checks.defaults++; return inheritedConfiguration{} }, + Resolve: func(*inheritedConfiguration, *configkit.Resolution) error { checks.resolution++; return nil }, + Validate: func(*inheritedConfiguration, *configkit.Validation) { checks.validation++ }, + }}) + if test.wantFailure { + if result != nil || err == nil { + t.Fatalf("protected version = %#v, %v", result, err) + } + loadError, ok := err.(*configkit.LoadError) + if !ok || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != configkit.CodeInheritanceVersion || + checks != (struct{ version, defaults, resolution, validation int }{version: 1}) { + t.Fatalf("version failure = %v, callbacks %#v", err, checks) + } + } else if err != nil || result == nil || checks != (struct{ version, defaults, resolution, validation int }{1, 1, 1, 1}) { + t.Fatalf("unchanged version = %#v, %v, callbacks %#v", result, err, checks) + } + }) + } +} + +func TestFacadeInheritanceFailureStopsLaterCallbacks(t *testing.T) { + checks := struct{ defaults, resolution, validation int }{} + result, err := configkit.Load(configkit.Bytes("invalid.yml", []byte("entries: {child: {extends: PRIVATE-REFERENCE-CANARY}}\n")), configkit.Options[inheritedConfiguration]{Definition: configkit.Definition[inheritedConfiguration]{ + Defaults: func() inheritedConfiguration { checks.defaults++; return inheritedConfiguration{} }, + Resolve: func(*inheritedConfiguration, *configkit.Resolution) error { checks.resolution++; return nil }, + Validate: func(*inheritedConfiguration, *configkit.Validation) { checks.validation++ }, + }}) + if result != nil || err == nil || checks != (struct{ defaults, resolution, validation int }{}) || strings.Contains(err.Error(), "PRIVATE-REFERENCE-CANARY") { + t.Fatalf("invalid inheritance = %#v, %v, callbacks %#v", result, err, checks) + } + loadError, ok := err.(*configkit.LoadError) + if !ok || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != configkit.CodeInheritanceMissing { + t.Fatalf("missing reference diagnostics = %v", err) + } +} + +func TestFacadeInheritanceResolvesAfterImportsAndCLIReferenceChange(t *testing.T) { + filesystem := configkit.NewFilesystem("bundle", fstest.MapFS{ + "main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: parent.yml}]}\nentries: {child: {extends: first}}\n")}, + "parent.yml": &fstest.MapFile{Data: []byte("entries: {first: {count: 1}, second: {count: 2}}\n")}, + }) + result, err := configkit.Load(filesystem.File("main.yml"), configkit.Options[inheritedConfiguration]{ + Imports: true, Overrides: []configkit.Override{{Path: configkit.Keys("entries", "child", "extends"), Value: "second", Flag: "--extends"}}, + }) + if err != nil || result.Config.Entries["child"].Count != 2 { + t.Fatalf("import and CLI reference = %#v, %v", result, err) + } + history := result.Metadata().History(configkit.Keys("entries", "child", "extends")) + if len(history) < 3 || history[len(history)-1].Origin.Operation != configkit.ChangeInheritance { + t.Fatalf("superseded reference history = %#v", history) + } + conflicting := configkit.NewFilesystem("bundle", fstest.MapFS{ + "main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: parent.yml}]}\nentries: {child: {extends: first}, first: {count: 3}}\n")}, + "parent.yml": &fstest.MapFile{Data: []byte("entries: {first: {count: 1}}\n")}, + }) + _, err = configkit.Load[inheritedConfiguration](conflicting.File("main.yml"), configkit.Options[inheritedConfiguration]{Imports: true}) + loadError, ok := err.(*configkit.LoadError) + if !ok || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != configkit.CodeComposeConflict { + t.Fatalf("strict conflict before expansion = %v", err) + } +} + +func TestFacadeInheritanceSelectedOverlayCancelsRelationship(t *testing.T) { + filesystem := configkit.NewFilesystem("bundle", fstest.MapFS{ + "main.yml": &fstest.MapFile{Data: []byte("entries: {parent: {count: 7}, child: {extends: parent, enabled: false}}\n")}, + "overlays/test.yml": &fstest.MapFile{Data: []byte("entries: {child: {extends: null}}\n")}, + }) + result, err := configkit.Load(filesystem.File("main.yml"), configkit.Options[inheritedConfiguration]{ + Overlay: "test", Filesystem: &configkit.FilesystemContext{Root: ".", FS: &filesystem}, + }) + if err != nil || result.Config.Entries["child"].Count != 0 || result.Config.Entries["child"].Enabled { + if loadError, ok := err.(*configkit.LoadError); ok { + t.Fatalf("cancelled relationship = %#v, %v, diagnostics %#v", result, err, loadError.Diagnostics()) + } + t.Fatalf("cancelled relationship = %#v, %v", result, err) + } + history := result.Metadata().History(configkit.Keys("entries", "child", "extends")) + if len(history) < 3 || history[len(history)-1].Origin.Operation != configkit.ChangeInheritance { + t.Fatalf("cancellation history = %#v", history) + } +} + +func TestFacadeInheritedSecretsStayRedactedAfterLookup(t *testing.T) { + const canary = "INHERITED-RELEASE-TEST-CREDENTIAL" + t.Setenv("CONFIGKIT_INHERITED_TEST_SECRET", canary) + type entry struct { + Credential configkit.Secret `yaml:"credential"` + Plain string `yaml:"plain"` + } + type configuration struct { + Entries map[string]entry `yaml:"entries"` + } + result, err := configkit.Load[configuration](configkit.Bytes("secret.yml", []byte("entries: {parent: {credential: CONFIGKIT_INHERITED_TEST_SECRET, plain: visible}, child: {extends: parent}}\n")), configkit.Options[configuration]{Definition: configkit.Definition[configuration]{ + Secret: func(path configkit.Path) bool { return path.String() == "/entries/parent/credential" }, + }}) + if err != nil || result.Config.Entries["child"].Credential.Value() != canary { + t.Fatalf("inherited credential = %#v, %v", result, err) + } + path := configkit.Keys("entries", "child", "credential") + explanation, found := result.Explain(path) + if !found || !explanation.Effective.Redacted || strings.Contains(explanation.Effective.YAML, canary) { + t.Fatalf("credential explanation = %#v, %v", explanation, found) + } + var rendered bytes.Buffer + if err := result.WriteEffective(&rendered); err != nil || strings.Contains(rendered.String(), canary) || !strings.Contains(rendered.String(), "visible") { + t.Fatalf("effective secret rendering = %q, %v", rendered.String(), err) + } + rendered.Reset() + if err := result.WriteExplanation(&rendered, path); err != nil || strings.Contains(rendered.String(), canary) { + t.Fatalf("historical secret rendering = %q, %v", rendered.String(), err) + } +} diff --git a/internal/engine/inheritance_load.go b/internal/engine/inheritance_load.go new file mode 100644 index 0000000..cebe16a --- /dev/null +++ b/internal/engine/inheritance_load.go @@ -0,0 +1,53 @@ +package engine + +import "gitea.maximumdirect.net/eric/ConfigKit/internal/document" + +func containsInheritanceDirective(root *document.Node) bool { + found := false + walkDocument(root, Path{}, func(path Path, _ *document.Node) { + if len(path.segments) != 0 && path.segments[len(path.segments)-1].kind == pathSegmentKey && + path.segments[len(path.segments)-1].key == inheritanceKey { + found = true + } + }) + return found +} + +func inheritanceVersionDiagnostic(rule *VersionRule, expanded *inheritanceScope) *Diagnostic { + if rule == nil || expanded == nil { + return nil + } + for _, operation := range expanded.operations { + var location Location + var affected bool + if operation.control != nil { + affected = pathsIntersect(operation.control.path, rule.Path) + if len(operation.control.locations) != 0 { + location = operation.control.locations[0] + } + } else if operation.transfer != nil { + if relative, within := relativeHistoryPath(operation.transfer.to, rule.Path); within { + _, affected = documentAtPath(operation.parent, relative) + } else if pathContains(rule.Path, operation.transfer.to) { + affected = true + } + if len(operation.transfer.locations) != 0 { + location = operation.transfer.locations[0] + } + } + if affected { + return &Diagnostic{ + Code: CodeInheritanceVersion, Severity: SeverityError, Path: rule.Path.clone(), + Message: "inheritance cannot change the schema-version path", Primary: &location, + } + } + } + return nil +} + +func inheritanceOperations(expanded *inheritanceScope) []inheritanceOperation { + if expanded == nil { + return nil + } + return expanded.operations +} diff --git a/internal/engine/load.go b/internal/engine/load.go index 0869b9c..839944f 100644 --- a/internal/engine/load.go +++ b/internal/engine/load.go @@ -195,6 +195,25 @@ func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lo mergeResourceContexts(&input.contexts, contexts) } } + if input.document != nil && containsInheritanceDirective(input.document) { + composition, problems := compositionForInput(input) + if len(problems) != 0 { + accumulator.add(problems...) + return nil, accumulator.loadError() + } + expanded, problems := expandInheritanceWithOrigins(composition.root, composition.origins, defaultProjectionLimits()) + if len(problems) != 0 { + accumulator.add(problems...) + return nil, accumulator.loadError() + } + if problem := inheritanceVersionDiagnostic(request.definition.Version, expanded); problem != nil { + accumulator.add(*problem) + return nil, accumulator.loadError() + } + input.composition = composition + input.inheritance = expanded + input.document = expanded.root + } decoded, diagnostics := decodeLoadInput(schema, request.definition, input) accumulator.add(diagnostics...) @@ -206,7 +225,7 @@ func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lo if hasErrors(diagnostics) { return nil, accumulator.loadError() } - if !historyNodesWithinLimit(decoded.baseline, decoded.final, resolved.final, resolved.applications, resolved.snapshots) { + if !historyNodesWithinLimit(decoded.baseline, decoded.final, resolved.final, resolved.applications, resolved.snapshots, input.inheritance) { accumulator.add(Diagnostic{Code: CodeExplainLimitExceeded, Severity: SeverityError, Message: "retained configuration history exceeds the node limit"}) return nil, accumulator.loadError() } diff --git a/internal/engine/load_input.go b/internal/engine/load_input.go index 5522109..045c7ee 100644 --- a/internal/engine/load_input.go +++ b/internal/engine/load_input.go @@ -11,6 +11,7 @@ type assembledInput struct { parsed parsedSource document *document.Node composition *composedDocument + inheritance *inheritanceScope sources []SourceInfo contexts map[string]ResourceContext } diff --git a/internal/engine/load_result.go b/internal/engine/load_result.go index 6e2857f..72d2e76 100644 --- a/internal/engine/load_result.go +++ b/internal/engine/load_result.go @@ -15,6 +15,7 @@ type resolvedLoad[T any] struct { final *document.Node snapshots []resolutionSnapshot applications []historyApplication + inheritance *inheritanceScope contexts map[string]ResourceContext } @@ -35,7 +36,9 @@ func decodeLoadInput[T any](schema *schemaDescriptor, definition Definition[T], } return decodedConfiguration{}, diagnostics } - if input.composition != nil { + if input.inheritance != nil { + applyCompositionOrigins(&decoded, input.inheritance.origins) + } else if input.composition != nil { applyCompositionOrigins(&decoded, input.composition.origins) } @@ -46,7 +49,9 @@ func resolveLoadInput[T any](schema *schemaDescriptor, definition Definition[T], var diagnostics []Diagnostic configuration := decoded.value.Interface().(T) metadata := metadataFromDecoded(decoded) - if input.composition != nil { + if input.inheritance != nil { + applyInheritanceMetadata(&metadata, input.composition.root, input.composition.origins, input.composition.overlays, input.inheritance) + } else if input.composition != nil { applyOverlayMetadata(&metadata, input.composition.overlays) } final := decoded.final @@ -68,7 +73,7 @@ func resolveLoadInput[T any](schema *schemaDescriptor, definition Definition[T], } else if input.document != nil { applications = []historyApplication{{layer: LayerBase, value: input.document}} } - return resolvedLoad[T]{configuration: configuration, decoded: decoded, metadata: metadata, final: final, snapshots: resolutionSnapshots, applications: applications, contexts: input.contexts}, diagnostics + return resolvedLoad[T]{configuration: configuration, decoded: decoded, metadata: metadata, final: final, snapshots: resolutionSnapshots, applications: applications, inheritance: input.inheritance, contexts: input.contexts}, diagnostics } func detachLoadConfiguration[T any](schema *schemaDescriptor, configuration T, mode secretCloneMode) (T, *Diagnostic) { @@ -104,6 +109,7 @@ func publishLoadResult[T any](schema *schemaDescriptor, definition Definition[T] final: final, resolution: resolutionSnapshots, applications: applications, + inheritance: inheritanceOperations(resolved.inheritance), secrets: secrets, secretReferences: cloneSecretReferences(references), } diff --git a/internal/engine/metadata.go b/internal/engine/metadata.go index 0c38b4c..a493f04 100644 --- a/internal/engine/metadata.go +++ b/internal/engine/metadata.go @@ -31,8 +31,8 @@ func (m Metadata) Lookup(path Path) (FieldInfo, bool) { return detached, true } -// IsExplicit reports whether a surviving input contribution explicitly defined -// path. +// IsExplicit reports whether a surviving input contribution, including one +// inherited from a sibling, explicitly defined path. func (m Metadata) IsExplicit(path Path) bool { info, ok := m.Lookup(path) return ok && info.Explicit diff --git a/internal/engine/resolution.go b/internal/engine/resolution.go index 4fcc159..464bb35 100644 --- a/internal/engine/resolution.go +++ b/internal/engine/resolution.go @@ -41,7 +41,7 @@ type resolutionResult struct { diagnostics []Diagnostic } -// IsExplicit reports whether surviving input explicitly defined path. +// IsExplicit reports whether surviving direct or inherited input defined path. func (r *Resolution) IsExplicit(path Path) bool { state := r.activeState() if !state.validPath(path) { diff --git a/lifecycle.go b/lifecycle.go index 960c444..cb2d19e 100644 --- a/lifecycle.go +++ b/lifecycle.go @@ -141,7 +141,7 @@ func (r *Result[T]) Diagnostics() []Diagnostic { return result } -// IsExplicit reports whether surviving input explicitly defined path. +// IsExplicit reports whether surviving direct or inherited input defined path. func (r *Resolution) IsExplicit(path Path) bool { return r.engineContext().IsExplicit(toEnginePath(path)) } diff --git a/metadata.go b/metadata.go index 3d265ed..7e88d57 100644 --- a/metadata.go +++ b/metadata.go @@ -13,8 +13,8 @@ func (m Metadata) Lookup(path Path) (FieldInfo, bool) { return fromEngineFieldInfo(value), ok } -// IsExplicit reports whether a surviving input contribution explicitly defined -// path. +// IsExplicit reports whether a surviving input contribution, including one +// inherited from a sibling, explicitly defined path. func (m Metadata) IsExplicit(path Path) bool { return m.value.IsExplicit(toEnginePath(path)) } diff --git a/records.go b/records.go index 0807aa1..3e56a65 100644 --- a/records.go +++ b/records.go @@ -32,7 +32,10 @@ type ChangeKind string const ( // ChangeDecoding records the built-in transition to canonical typed values. // It is inspection metadata, not an operation accepted by Resolution.Record. - ChangeDecoding ChangeKind = "decoding" + ChangeDecoding ChangeKind = "decoding" + // ChangeInheritance records structural input expansion. It is inspection + // metadata, not an operation accepted by Resolution.Record. + ChangeInheritance ChangeKind = "inheritance" ChangeDerivedDefault ChangeKind = "derived_default" ChangeNormalization ChangeKind = "normalization" ) @@ -161,6 +164,11 @@ const ( CodeOverrideLimitExceeded Code = "override.limit_exceeded" CodeExplainLimitExceeded Code = "explain.limit_exceeded" + + CodeInheritanceInvalid Code = "inheritance.invalid" + CodeInheritanceMissing Code = "inheritance.missing" + CodeInheritanceCycle Code = "inheritance.cycle" + CodeInheritanceVersion Code = "inheritance.version" ) // Location identifies a position in a configuration source. Zero line and column @@ -197,13 +205,23 @@ type Diagnostic struct { Related []Location } +// InheritanceStep identifies a concrete payload transfer controlled by a +// relationship directive. Locations belong to that directive, not its value. +type InheritanceStep struct { + From Path + To Path + Reference Path + Locations []Location +} + // Origin describes where an effective definition came from. Multiple locations // represent a composite origin; resolution origins are synthetic and identify their // operation instead of a file location. type Origin struct { - Layer Layer - Locations []Location - Operation ChangeKind + Layer Layer + Locations []Location + Operation ChangeKind + Inheritance []InheritanceStep } // FieldInfo describes an effective configuration path. @@ -257,6 +275,16 @@ func cloneSourceInfos(sources []SourceInfo) []SourceInfo { func cloneOrigin(origin Origin) Origin { origin.Locations = cloneLocations(origin.Locations) + if origin.Inheritance != nil { + steps := make([]InheritanceStep, len(origin.Inheritance)) + for index, step := range origin.Inheritance { + steps[index] = InheritanceStep{ + From: step.From.clone(), To: step.To.clone(), Reference: step.Reference.clone(), + Locations: cloneLocations(step.Locations), + } + } + origin.Inheritance = steps + } return origin }