Activate sibling inheritance in configuration loading

This commit is contained in:
2026-09-29 23:04:13 +00:00
parent 501d59f21b
commit df9a02539c
12 changed files with 435 additions and 16 deletions

View File

@@ -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)} 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 { 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 { 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 { 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)} 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)}

View File

@@ -270,6 +270,14 @@ and the everyday checker.
## Stage 7 — Activate the common lifecycle and facade metadata ## 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 Insert expansion in `executeLoadRequest` after ordinary `applyInputLayers` or
layered `assembleLayeredInput`, before `decodeLoadInput` invokes defaults/decoding. layered `assembleLayeredInput`, before `decodeLoadInput` invokes defaults/decoding.
Include plain single-source loads, creating the minimal composition carrier needed Include plain single-source loads, creating the minimal composition carrier needed

284
facade_inheritance_test.go Normal file
View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -195,6 +195,25 @@ func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lo
mergeResourceContexts(&input.contexts, contexts) 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) decoded, diagnostics := decodeLoadInput(schema, request.definition, input)
accumulator.add(diagnostics...) accumulator.add(diagnostics...)
@@ -206,7 +225,7 @@ func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lo
if hasErrors(diagnostics) { if hasErrors(diagnostics) {
return nil, accumulator.loadError() 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"}) accumulator.add(Diagnostic{Code: CodeExplainLimitExceeded, Severity: SeverityError, Message: "retained configuration history exceeds the node limit"})
return nil, accumulator.loadError() return nil, accumulator.loadError()
} }

View File

@@ -11,6 +11,7 @@ type assembledInput struct {
parsed parsedSource parsed parsedSource
document *document.Node document *document.Node
composition *composedDocument composition *composedDocument
inheritance *inheritanceScope
sources []SourceInfo sources []SourceInfo
contexts map[string]ResourceContext contexts map[string]ResourceContext
} }

View File

@@ -15,6 +15,7 @@ type resolvedLoad[T any] struct {
final *document.Node final *document.Node
snapshots []resolutionSnapshot snapshots []resolutionSnapshot
applications []historyApplication applications []historyApplication
inheritance *inheritanceScope
contexts map[string]ResourceContext contexts map[string]ResourceContext
} }
@@ -35,7 +36,9 @@ func decodeLoadInput[T any](schema *schemaDescriptor, definition Definition[T],
} }
return decodedConfiguration{}, diagnostics 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) applyCompositionOrigins(&decoded, input.composition.origins)
} }
@@ -46,7 +49,9 @@ func resolveLoadInput[T any](schema *schemaDescriptor, definition Definition[T],
var diagnostics []Diagnostic var diagnostics []Diagnostic
configuration := decoded.value.Interface().(T) configuration := decoded.value.Interface().(T)
metadata := metadataFromDecoded(decoded) 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) applyOverlayMetadata(&metadata, input.composition.overlays)
} }
final := decoded.final final := decoded.final
@@ -68,7 +73,7 @@ func resolveLoadInput[T any](schema *schemaDescriptor, definition Definition[T],
} else if input.document != nil { } else if input.document != nil {
applications = []historyApplication{{layer: LayerBase, value: input.document}} 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) { 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, final: final,
resolution: resolutionSnapshots, resolution: resolutionSnapshots,
applications: applications, applications: applications,
inheritance: inheritanceOperations(resolved.inheritance),
secrets: secrets, secrets: secrets,
secretReferences: cloneSecretReferences(references), secretReferences: cloneSecretReferences(references),
} }

View File

@@ -31,8 +31,8 @@ func (m Metadata) Lookup(path Path) (FieldInfo, bool) {
return detached, true return detached, true
} }
// IsExplicit reports whether a surviving input contribution explicitly defined // IsExplicit reports whether a surviving input contribution, including one
// path. // inherited from a sibling, explicitly defined path.
func (m Metadata) IsExplicit(path Path) bool { func (m Metadata) IsExplicit(path Path) bool {
info, ok := m.Lookup(path) info, ok := m.Lookup(path)
return ok && info.Explicit return ok && info.Explicit

View File

@@ -41,7 +41,7 @@ type resolutionResult struct {
diagnostics []Diagnostic 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 { func (r *Resolution) IsExplicit(path Path) bool {
state := r.activeState() state := r.activeState()
if !state.validPath(path) { if !state.validPath(path) {

View File

@@ -141,7 +141,7 @@ func (r *Result[T]) Diagnostics() []Diagnostic {
return result 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 { func (r *Resolution) IsExplicit(path Path) bool {
return r.engineContext().IsExplicit(toEnginePath(path)) return r.engineContext().IsExplicit(toEnginePath(path))
} }

View File

@@ -13,8 +13,8 @@ func (m Metadata) Lookup(path Path) (FieldInfo, bool) {
return fromEngineFieldInfo(value), ok return fromEngineFieldInfo(value), ok
} }
// IsExplicit reports whether a surviving input contribution explicitly defined // IsExplicit reports whether a surviving input contribution, including one
// path. // inherited from a sibling, explicitly defined path.
func (m Metadata) IsExplicit(path Path) bool { func (m Metadata) IsExplicit(path Path) bool {
return m.value.IsExplicit(toEnginePath(path)) return m.value.IsExplicit(toEnginePath(path))
} }

View File

@@ -32,7 +32,10 @@ type ChangeKind string
const ( const (
// ChangeDecoding records the built-in transition to canonical typed values. // ChangeDecoding records the built-in transition to canonical typed values.
// It is inspection metadata, not an operation accepted by Resolution.Record. // 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" ChangeDerivedDefault ChangeKind = "derived_default"
ChangeNormalization ChangeKind = "normalization" ChangeNormalization ChangeKind = "normalization"
) )
@@ -161,6 +164,11 @@ const (
CodeOverrideLimitExceeded Code = "override.limit_exceeded" CodeOverrideLimitExceeded Code = "override.limit_exceeded"
CodeExplainLimitExceeded Code = "explain.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 // Location identifies a position in a configuration source. Zero line and column
@@ -197,13 +205,23 @@ type Diagnostic struct {
Related []Location 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 // Origin describes where an effective definition came from. Multiple locations
// represent a composite origin; resolution origins are synthetic and identify their // represent a composite origin; resolution origins are synthetic and identify their
// operation instead of a file location. // operation instead of a file location.
type Origin struct { type Origin struct {
Layer Layer Layer Layer
Locations []Location Locations []Location
Operation ChangeKind Operation ChangeKind
Inheritance []InheritanceStep
} }
// FieldInfo describes an effective configuration path. // FieldInfo describes an effective configuration path.
@@ -257,6 +275,16 @@ func cloneSourceInfos(sources []SourceInfo) []SourceInfo {
func cloneOrigin(origin Origin) Origin { func cloneOrigin(origin Origin) Origin {
origin.Locations = cloneLocations(origin.Locations) 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 return origin
} }