Add facade lifecycle wrappers

This commit is contained in:
2026-09-12 16:16:11 +00:00
parent b8935e77bd
commit da307c9f49
8 changed files with 458 additions and 2 deletions

3
.codebase-memory/.gitattributes vendored Normal file
View File

@@ -0,0 +1,3 @@
# Auto-generated by codebase-memory-mcp
# Prevent merge conflicts on compressed artifact
graph.db.zst merge=ours binary

View File

@@ -0,0 +1,11 @@
{
"schema_version": 2,
"commit": "b8935e77bd5ac1dc347339966fbaba5de9af5348",
"indexed_at": "2026-09-12T16:16:09Z",
"project": "home-eric-Workspace-ConfigKit",
"nodes": 2447,
"edges": 13587,
"original_size": 11206656,
"compressed_size": 1701304,
"compression_level": 3
}

Binary file not shown.

View File

@@ -382,7 +382,7 @@ lifecycle and callback wrappers to the split engine result.
## Stage 6 — Wire candidate lifecycle and callback wrappers ## Stage 6 — Wire candidate lifecycle and callback wrappers
Status: not started. Status: complete (2026-09-12).
1. Add candidate Definition, Options, VersionRule, Version, FilesystemContext, 1. Add candidate Definition, Options, VersionRule, Version, FilesystemContext,
Override, Resolution, and Validation with unchanged public declarations. Override, Resolution, and Validation with unchanged public declarations.
@@ -404,6 +404,23 @@ and detached entry provenance stays fixed. Run
`go test ./internal/facade ./internal/engine -run 'Load|Resolution|Validation|Version|Metadata|Secret|Callback|Facade'`, `go test ./internal/facade ./internal/engine -run 'Load|Resolution|Validation|Version|Metadata|Secret|Callback|Facade'`,
`go test -race ./internal/facade ./internal/engine`, and the everyday check. `go test -race ./internal/facade ./internal/engine`, and the everyday check.
### Completion
Added the temporary facade's root-owned lifecycle declarations, option conversion,
live callback wrappers, `Load`, `Result`, and `Metadata`. Callback wrappers carry
only the active engine context during callback execution; `Result` stores the
published configuration once and keeps its non-generic engine inspection private.
All record and path crossings use explicit conversion, and facade loads translate
engine load failures to the root-owned `LoadError` type.
External candidate tests exercise public version, default, resolution, validation,
metadata, source, and secret behavior, including inactive retained contexts and
typed load errors. Linux validation with Go 1.26.7 passed
`go test ./internal/facade ./internal/engine -run 'Load|Resolution|Validation|Version|Metadata|Secret|Callback|Facade'`,
`go test -race ./internal/facade ./internal/engine`, and `./scripts/check.sh`.
There were no deviations or remaining risks. The next handoff completes the
candidate inspection and secrets-bootstrap surface.
## Stage 7 — Complete candidate inspection and bootstrap surface ## Stage 7 — Complete candidate inspection and bootstrap surface
Status: not started. Status: not started.

View File

@@ -20,6 +20,9 @@ func (e *LoadError) Diagnostics() []Diagnostic {
return nil return nil
} }
values := e.value.Diagnostics() values := e.value.Diagnostics()
if values == nil {
return nil
}
result := make([]Diagnostic, len(values)) result := make([]Diagnostic, len(values))
for i := range values { for i := range values {
result[i] = fromEngineDiagnostic(values[i]) result[i] = fromEngineDiagnostic(values[i])
@@ -31,6 +34,9 @@ func (e *LoadError) Sources() []SourceInfo {
return nil return nil
} }
values := e.value.Sources() values := e.value.Sources()
if values == nil {
return nil
}
result := make([]SourceInfo, len(values)) result := make([]SourceInfo, len(values))
for i := range values { for i := range values {
result[i] = fromEngineSourceInfo(values[i]) result[i] = fromEngineSourceInfo(values[i])

View File

@@ -2,6 +2,7 @@ package configkit_test
import ( import (
"encoding/json" "encoding/json"
"errors"
"fmt" "fmt"
"reflect" "reflect"
"testing" "testing"
@@ -22,7 +23,12 @@ func TestFacadeValuesRemainRootOwned(t *testing.T) {
for _, candidate := range []reflect.Type{ for _, candidate := range []reflect.Type{
reflect.TypeFor[configkit.Path](), reflect.TypeFor[configkit.Source](), reflect.TypeFor[configkit.Path](), reflect.TypeFor[configkit.Source](),
reflect.TypeFor[configkit.Secret](), reflect.TypeFor[configkit.Diagnostic](), reflect.TypeFor[configkit.Secret](), reflect.TypeFor[configkit.Diagnostic](),
reflect.TypeFor[configkit.LoadError](), reflect.TypeFor[configkit.LoadError](), reflect.TypeFor[configkit.Metadata](),
reflect.TypeFor[configkit.Resolution](), reflect.TypeFor[configkit.Validation](),
reflect.TypeFor[configkit.Version](), reflect.TypeFor[configkit.VersionRule](),
reflect.TypeFor[configkit.FilesystemContext](), reflect.TypeFor[configkit.Override](),
reflect.TypeFor[configkit.Definition[struct{}]](), reflect.TypeFor[configkit.Options[struct{}]](),
reflect.TypeFor[configkit.Result[struct{}]](),
} { } {
if candidate.PkgPath() != packagePath { if candidate.PkgPath() != packagePath {
t.Fatalf("type %s belongs to %q", candidate, candidate.PkgPath()) t.Fatalf("type %s belongs to %q", candidate, candidate.PkgPath())
@@ -41,3 +47,106 @@ func TestFacadeValuesRemainRootOwned(t *testing.T) {
t.Fatalf("zero LoadError = %q", got) t.Fatalf("zero LoadError = %q", got)
} }
} }
func TestFacadeLoadBridgesLifecycleCallbacks(t *testing.T) {
t.Setenv("FACADE_SECRET_NAME", "credential")
type configuration struct {
Version string `yaml:"version"`
Input int `yaml:"input"`
Derived int `yaml:"derived"`
Secret configkit.Secret `yaml:"secret"`
Labels map[string]string `yaml:"labels"`
}
var retainedResolution *configkit.Resolution
var retainedValidation *configkit.Validation
versionCalls, defaultsCalls, resolveCalls, validateCalls := 0, 0, 0, 0
definition := configkit.Definition[configuration]{
Version: &configkit.VersionRule{Path: configkit.Keys("version"), Check: func(version configkit.Version, validation *configkit.Validation) error {
versionCalls++
retainedValidation = validation
if !version.Present || version.Text != "1" {
validation.Error(configkit.Keys("version"), "unsupported version")
}
return nil
}},
Defaults: func() configuration {
defaultsCalls++
return configuration{Derived: 1, Labels: map[string]string{"default": "kept"}}
},
Resolve: func(configuration *configuration, resolution *configkit.Resolution) error {
resolveCalls++
retainedResolution = resolution
if !resolution.IsExplicit(configkit.Keys("input")) {
resolution.Error(configkit.Keys("input"), "input is required")
}
input, ok := resolution.InputInfo(configkit.Keys("input"))
if !ok || !input.Explicit {
resolution.Error(configkit.Keys("input"), "missing input provenance")
}
source, ok := resolution.Source("source-1")
if !ok || source.Label != "facade.yml" {
resolution.Error(configkit.Keys("input"), "missing source")
}
configkit.Default(resolution, configkit.Keys("derived"), &configuration.Derived, configuration.Input*2, "derived from input", configkit.Keys("input"))
return nil
},
Validate: func(configuration *configuration, validation *configkit.Validation) {
validateCalls++
retainedValidation = validation
if !configuration.Secret.IsResolved() || configuration.Secret.Value() != "credential" {
validation.Error(configkit.Keys("secret"), "secret did not resolve")
}
},
}
if versionCalls != 0 || defaultsCalls != 0 || resolveCalls != 0 || validateCalls != 0 {
t.Fatal("callbacks ran while constructing options")
}
result, err := configkit.Load(configkit.Bytes("facade.yml", []byte("version: 1\ninput: 3\nsecret: FACADE_SECRET_NAME\n")), configkit.Options[configuration]{Definition: definition})
if err != nil {
t.Fatal(err)
}
if versionCalls != 1 || defaultsCalls != 1 || resolveCalls != 1 || validateCalls != 1 || result.Config.Derived != 6 {
t.Fatalf("callback counts/configuration = %d/%d/%d/%d %#v", versionCalls, defaultsCalls, resolveCalls, validateCalls, result.Config)
}
derived, ok := result.Metadata().Lookup(configkit.Keys("derived"))
if !ok || derived.Explicit || derived.Origin.Operation != configkit.ChangeDerivedDefault {
t.Fatalf("derived metadata = %#v, %v", derived, ok)
}
if sources := result.Sources(); len(sources) != 1 || sources[0].Label != "facade.yml" {
t.Fatalf("sources = %#v", sources)
}
if result.Diagnostics() != nil {
t.Fatalf("diagnostics = %#v", result.Diagnostics())
}
for _, context := range []func(){
func() { retainedResolution.IsExplicit(configkit.Keys("input")) },
func() { retainedValidation.Warning(configkit.Keys("input"), "too late") },
} {
func() {
defer func() {
if recover() == nil {
t.Fatal("retained callback context remained active")
}
}()
context()
}()
}
}
func TestFacadeLoadReturnsFacadeLoadError(t *testing.T) {
t.Parallel()
type configuration struct {
Value int `yaml:"value"`
}
result, err := configkit.Load[configuration](configkit.Bytes("invalid.yml", []byte("value: wrong\n")))
if result != nil || err == nil {
t.Fatalf("result, err = %#v, %v", result, err)
}
var loadError *configkit.LoadError
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != configkit.CodeDecodeType {
t.Fatalf("facade error = %#v", err)
}
}

View File

@@ -0,0 +1,254 @@
package configkit
import (
"errors"
"gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
)
// Definition contains reusable application rules for a configuration type.
// Secret is a pure predicate that can add sensitivity to semantic paths.
// Definitions should be treated as read-only after being shared.
type Definition[T any] struct {
Defaults func() T
Resolve func(*T, *Resolution) error
Validate func(*T, *Validation)
Version *VersionRule
Secret func(Path) bool
}
// Options contains the application definition and per-load input selections.
// Imports enables explicit path and glob imports; Profile selects one fixed profile
// overlay; Overrides contains only values explicitly supplied by an application CLI.
type Options[T any] struct {
Definition Definition[T]
Imports bool
Profile string
Filesystem *FilesystemContext
Overrides []Override
}
// FilesystemContext supplies filesystem roots for imports and profiles.
type FilesystemContext struct {
BaseDir string
Root string
}
// Override describes one explicitly supplied application override. Value uses
// parser-neutral built-in scalars, nil, []any, or map[string]any; Flag is a safe
// display name and must not contain a raw argument or value.
type Override struct {
Path Path
Value any
Flag string
}
// Resolution exposes entry-state input provenance, surviving-input presence, and
// audited application resolution changes. A resolver must not retain or
// concurrently use the context.
type Resolution struct{ value *engine.Resolution }
// VersionRule declares the input path and application checker for a schema version.
// At that path, string and decimal-integer input is supplied as Version.Text.
type VersionRule struct {
Path Path
Check func(Version, *Validation) error
}
// Version preserves the absent, null, and textual states of a schema-version input.
// Text is the exact spelling of accepted string or decimal-integer input.
type Version struct {
Present bool
Null bool
Text string
}
// Validation collects safe application diagnostics during a supported callback.
// A callback must not retain the context or use it concurrently.
type Validation struct{ value *engine.Validation }
// Result is a successful independently owned configuration load.
type Result[T any] struct {
Config T
inspection *engine.Inspection
}
// Load parses and strictly decodes zero or one selected source, explicitly enabled
// imports, and one selected profile. It accepts at most one Options value and
// returns no result when any error is reported.
func Load[T any](source Source, supplied ...Options[T]) (*Result[T], error) {
options := make([]engine.Options[T], len(supplied))
for index := range supplied {
options[index] = toEngineOptions(supplied[index])
}
loaded, err := engine.Load(source.toEngine(), secretBinding(), options...)
if err != nil {
var loadError *engine.LoadError
if errors.As(err, &loadError) {
return nil, &LoadError{value: loadError}
}
return nil, &LoadError{}
}
return &Result[T]{Config: loaded.Config, inspection: loaded.Inspection}, nil
}
// Metadata returns detached effective presence, origin, and history records.
func (r *Result[T]) Metadata() Metadata {
if r == nil || r.inspection == nil {
return Metadata{}
}
return Metadata{value: r.inspection.Metadata()}
}
// Sources returns a detached list of sources used by the load.
func (r *Result[T]) Sources() []SourceInfo {
if r == nil || r.inspection == nil {
return nil
}
values := r.inspection.Sources()
if values == nil {
return nil
}
result := make([]SourceInfo, len(values))
for index := range values {
result[index] = fromEngineSourceInfo(values[index])
}
return result
}
// Diagnostics returns detached nonfatal diagnostics from the load.
func (r *Result[T]) Diagnostics() []Diagnostic {
if r == nil || r.inspection == nil {
return nil
}
values := r.inspection.Diagnostics()
if values == nil {
return nil
}
result := make([]Diagnostic, len(values))
for index := range values {
result[index] = fromEngineDiagnostic(values[index])
}
return result
}
// IsExplicit reports whether surviving input explicitly defined path.
func (r *Resolution) IsExplicit(path Path) bool { return r.value.IsExplicit(toEnginePath(path)) }
// InputInfo returns detached entry-state metadata for path. The entry state is
// the decoded configuration, including retained static defaults, immediately
// before resolution starts. It is fixed while resolution records mutate the
// working configuration; unknown or shape-mismatched paths return false.
//
// InputInfo is valid only during the resolution callback. See the configuration
// contract for source and provenance semantics.
func (r *Resolution) InputInfo(path Path) (FieldInfo, bool) {
value, ok := r.value.InputInfo(toEnginePath(path))
return fromEngineFieldInfo(value), ok
}
// Source returns a detached record for a source acquired by this load. Empty or
// unknown IDs return false and do not perform input, filesystem, or credential
// access. A source path is present only when that acquired source has one.
//
// Source is valid only during the resolution callback. See the configuration
// contract for source and provenance semantics.
func (r *Resolution) Source(id string) (SourceInfo, bool) {
value, ok := r.value.Source(id)
return fromEngineSourceInfo(value), ok
}
// Record declares the intent for a resolver mutation. A container path covers
// mutations to its descendants.
func (r *Resolution) Record(path Path, kind ChangeKind, reason string, dependencies ...Path) {
r.value.Record(toEnginePath(path), engine.ChangeKind(kind), reason, toEnginePaths(dependencies)...)
}
// Error reports a safe, value-free resolution error at path.
func (r *Resolution) Error(path Path, message string, related ...Path) {
r.value.Error(toEnginePath(path), message, toEnginePaths(related)...)
}
// Warning reports a safe, value-free resolution warning at path.
func (r *Resolution) Warning(path Path, message string, related ...Path) {
r.value.Warning(toEnginePath(path), message, toEnginePaths(related)...)
}
// Default assigns and records value only when path was absent from surviving
// input. Static defaults are not considered explicit.
func Default[V any](resolution *Resolution, path Path, target *V, value V, reason string, dependencies ...Path) {
engine.Default(resolution.value, toEnginePath(path), target, value, reason, toEnginePaths(dependencies)...)
}
// Normalize assigns and records value regardless of input presence.
func Normalize[V any](resolution *Resolution, path Path, target *V, value V, reason string, dependencies ...Path) {
engine.Normalize(resolution.value, toEnginePath(path), target, value, reason, toEnginePaths(dependencies)...)
}
// Error reports a value-free application error at path. Related paths are
// resolved to source locations when possible.
func (v *Validation) Error(path Path, message string, related ...Path) {
v.value.Error(toEnginePath(path), message, toEnginePaths(related)...)
}
// Warning reports a value-free application warning at path. Related paths are
// resolved to source locations when possible.
func (v *Validation) Warning(path Path, message string, related ...Path) {
v.value.Warning(toEnginePath(path), message, toEnginePaths(related)...)
}
func toEngineOptions[T any](options Options[T]) engine.Options[T] {
result := engine.Options[T]{
Definition: toEngineDefinition(options.Definition),
Imports: options.Imports,
Profile: options.Profile,
}
if options.Filesystem != nil {
result.Filesystem = &engine.FilesystemContext{BaseDir: options.Filesystem.BaseDir, Root: options.Filesystem.Root}
}
if options.Overrides != nil {
result.Overrides = make([]engine.Override, len(options.Overrides))
for index := range options.Overrides {
result.Overrides[index] = engine.Override{
Path: toEnginePath(options.Overrides[index].Path), Value: options.Overrides[index].Value, Flag: options.Overrides[index].Flag,
}
}
}
return result
}
func toEngineDefinition[T any](definition Definition[T]) engine.Definition[T] {
result := engine.Definition[T]{Defaults: definition.Defaults}
if definition.Resolve != nil {
result.Resolve = func(configuration *T, resolution *engine.Resolution) error {
return definition.Resolve(configuration, &Resolution{value: resolution})
}
}
if definition.Validate != nil {
result.Validate = func(configuration *T, validation *engine.Validation) {
definition.Validate(configuration, &Validation{value: validation})
}
}
if definition.Version != nil {
rule := definition.Version
result.Version = &engine.VersionRule{Path: toEnginePath(rule.Path), Check: func(version engine.Version, validation *engine.Validation) error {
return rule.Check(Version{Present: version.Present, Null: version.Null, Text: version.Text}, &Validation{value: validation})
}}
}
if definition.Secret != nil {
result.Secret = func(path engine.Path) bool { return definition.Secret(fromEnginePath(path)) }
}
return result
}
func toEnginePaths(paths []Path) []engine.Path {
if paths == nil {
return nil
}
result := make([]engine.Path, len(paths))
for index := range paths {
result[index] = toEnginePath(paths[index])
}
return result
}

View File

@@ -0,0 +1,56 @@
package configkit
import "gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
// Metadata provides detached effective presence/origin and chronological history
// queries for a load. Its zero value contains no paths.
type Metadata struct{ value engine.Metadata }
// Lookup returns information for an effective path. Unknown and invalid paths
// return false.
func (m Metadata) Lookup(path Path) (FieldInfo, bool) {
value, ok := m.value.Lookup(toEnginePath(path))
return fromEngineFieldInfo(value), ok
}
// IsExplicit reports whether a surviving input contribution explicitly defined
// path.
func (m Metadata) IsExplicit(path Path) bool {
return m.value.IsExplicit(toEnginePath(path))
}
// History returns detached definition and resolution records for path. A record's
// Path can be an ancestor when that container operation replaced or removed path.
// Removed historical paths can have history even though Lookup returns false.
// Unknown and invalid paths return nil.
func (m Metadata) History(path Path) []Change {
values := m.value.History(toEnginePath(path))
if values == nil {
return nil
}
result := make([]Change, len(values))
for index := range values {
result[index] = fromEngineChange(values[index])
}
return result
}
// HistoricalPaths returns the paths observed during loading, including effective
// paths and paths removed by overlays or recorded resolution. It includes default
// paths and the root, but not paths that never existed. The zero Metadata returns
// an empty slice.
//
// Paths are detached, unique by canonical JSON Pointer, and sorted by that
// spelling. Like ParsePath results, numeric tokens are resolved contextually;
// one pointer can identify a mapping key in an earlier layer and a sequence index
// in a later one. Use History to inspect definitions and Lookup to check whether
// a path remains effective. Enumeration exposes no configuration values and does
// not perform another load or resolve secrets.
func (m Metadata) HistoricalPaths() []Path {
values := m.value.HistoricalPaths()
result := make([]Path, len(values))
for index := range values {
result[index] = fromEnginePath(values[index])
}
return result
}