diff --git a/.codebase-memory/.gitattributes b/.codebase-memory/.gitattributes new file mode 100644 index 0000000..7ffe250 --- /dev/null +++ b/.codebase-memory/.gitattributes @@ -0,0 +1,3 @@ +# Auto-generated by codebase-memory-mcp +# Prevent merge conflicts on compressed artifact +graph.db.zst merge=ours binary diff --git a/.codebase-memory/artifact.json b/.codebase-memory/artifact.json new file mode 100644 index 0000000..ffde2f1 --- /dev/null +++ b/.codebase-memory/artifact.json @@ -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 +} \ No newline at end of file diff --git a/.codebase-memory/graph.db.zst b/.codebase-memory/graph.db.zst new file mode 100644 index 0000000..9a29a05 Binary files /dev/null and b/.codebase-memory/graph.db.zst differ diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 3b8d9c8..c75eb41 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -382,7 +382,7 @@ lifecycle and callback wrappers to the split engine result. ## Stage 6 — Wire candidate lifecycle and callback wrappers -Status: not started. +Status: complete (2026-09-12). 1. Add candidate Definition, Options, VersionRule, Version, FilesystemContext, 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 -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 Status: not started. diff --git a/internal/facade/diagnostic.go b/internal/facade/diagnostic.go index 027e426..ad02794 100644 --- a/internal/facade/diagnostic.go +++ b/internal/facade/diagnostic.go @@ -20,6 +20,9 @@ func (e *LoadError) Diagnostics() []Diagnostic { return nil } values := e.value.Diagnostics() + if values == nil { + return nil + } result := make([]Diagnostic, len(values)) for i := range values { result[i] = fromEngineDiagnostic(values[i]) @@ -31,6 +34,9 @@ func (e *LoadError) Sources() []SourceInfo { return nil } values := e.value.Sources() + if values == nil { + return nil + } result := make([]SourceInfo, len(values)) for i := range values { result[i] = fromEngineSourceInfo(values[i]) diff --git a/internal/facade/facade_external_test.go b/internal/facade/facade_external_test.go index 5597243..1692666 100644 --- a/internal/facade/facade_external_test.go +++ b/internal/facade/facade_external_test.go @@ -2,6 +2,7 @@ package configkit_test import ( "encoding/json" + "errors" "fmt" "reflect" "testing" @@ -22,7 +23,12 @@ func TestFacadeValuesRemainRootOwned(t *testing.T) { for _, candidate := range []reflect.Type{ reflect.TypeFor[configkit.Path](), reflect.TypeFor[configkit.Source](), 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 { 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) } } + +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) + } +} diff --git a/internal/facade/lifecycle.go b/internal/facade/lifecycle.go new file mode 100644 index 0000000..1808e14 --- /dev/null +++ b/internal/facade/lifecycle.go @@ -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 +} diff --git a/internal/facade/metadata.go b/internal/facade/metadata.go new file mode 100644 index 0000000..3d265ed --- /dev/null +++ b/internal/facade/metadata.go @@ -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 +}