From f2c650b5ebb1e7b1ceb58671be4bab9a5ee69f66 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sat, 12 Sep 2026 18:33:11 +0000 Subject: [PATCH] Preserve closed callback failures through the facade --- facade_context_test.go | 51 ++++++++++++++++++++++++++++++++++++++++++ lifecycle.go | 37 +++++++++++++++++++++--------- 2 files changed, 78 insertions(+), 10 deletions(-) create mode 100644 facade_context_test.go diff --git a/facade_context_test.go b/facade_context_test.go new file mode 100644 index 0000000..309ea37 --- /dev/null +++ b/facade_context_test.go @@ -0,0 +1,51 @@ +package configkit_test + +import ( + "testing" + + configkit "gitea.maximumdirect.net/eric/ConfigKit" +) + +func TestFacadeInactiveContextsPreserveClosedFailure(t *testing.T) { + t.Parallel() + for _, state := range []struct { + name string + resolution *configkit.Resolution + validation *configkit.Validation + }{ + {name: "nil"}, + {name: "zero", resolution: &configkit.Resolution{}, validation: &configkit.Validation{}}, + } { + t.Run(state.name, func(t *testing.T) { + path := configkit.Keys("value") + var target int + for _, operation := range []struct { + name string + call func() + }{ + {"IsExplicit", func() { state.resolution.IsExplicit(path) }}, + {"InputInfo", func() { state.resolution.InputInfo(path) }}, + {"Source", func() { state.resolution.Source("source") }}, + {"Record", func() { state.resolution.Record(path, configkit.ChangeNormalization, "reason") }}, + {"Resolution.Error", func() { state.resolution.Error(path, "message") }}, + {"Resolution.Warning", func() { state.resolution.Warning(path, "message") }}, + {"Default", func() { configkit.Default(state.resolution, path, &target, 1, "reason") }}, + {"Normalize", func() { configkit.Normalize(state.resolution, path, &target, 1, "reason") }}, + {"Validation.Error", func() { state.validation.Error(path, "message") }}, + {"Validation.Warning", func() { state.validation.Warning(path, "message") }}, + } { + t.Run(operation.name, func(t *testing.T) { + defer func() { + if got := recover(); got != "configkit: callback context is no longer active" { + t.Errorf("panic = %v; want fixed closed-context failure", got) + } + if target != 0 { + t.Error("inactive helper changed target") + } + }() + operation.call() + }) + } + }) + } +} diff --git a/lifecycle.go b/lifecycle.go index b3fa49a..ae93c1a 100644 --- a/lifecycle.go +++ b/lifecycle.go @@ -134,7 +134,9 @@ func (r *Result[T]) Diagnostics() []Diagnostic { } // IsExplicit reports whether surviving input explicitly defined path. -func (r *Resolution) IsExplicit(path Path) bool { return r.value.IsExplicit(toEnginePath(path)) } +func (r *Resolution) IsExplicit(path Path) bool { + return r.engineContext().IsExplicit(toEnginePath(path)) +} // InputInfo returns detached entry-state metadata for path. The entry state is // the decoded configuration, including retained static defaults, immediately @@ -144,7 +146,7 @@ func (r *Resolution) IsExplicit(path Path) bool { return r.value.IsExplicit(toEn // 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)) + value, ok := r.engineContext().InputInfo(toEnginePath(path)) return fromEngineFieldInfo(value), ok } @@ -155,47 +157,62 @@ func (r *Resolution) InputInfo(path Path) (FieldInfo, bool) { // 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) + value, ok := r.engineContext().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)...) + r.engineContext().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)...) + r.engineContext().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)...) + r.engineContext().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)...) + engine.Default(resolution.engineContext(), 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)...) + engine.Normalize(resolution.engineContext(), 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)...) + v.engineContext().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)...) + v.engineContext().Warning(toEnginePath(path), message, toEnginePaths(related)...) +} + +// Pass nil through so the engine remains the owner of the callback guard. +func (r *Resolution) engineContext() *engine.Resolution { + if r == nil { + return nil + } + return r.value +} + +func (v *Validation) engineContext() *engine.Validation { + if v == nil { + return nil + } + return v.value } func toEngineOptions[T any](options Options[T]) engine.Options[T] {