Add audited application resolution

This commit is contained in:
2026-09-06 20:14:55 +00:00
parent cc41653bfd
commit 0da4260ad6
16 changed files with 909 additions and 57 deletions

View File

@@ -5,7 +5,8 @@ An opinionated Go library for applications that use YAML configuration files.
ConfigKit is in early implementation. Its current loader accepts one strict YAML
source (or explicit defaults-only input), applies typed defaults, optionally checks
an application schema version, and returns detached metadata and safe diagnostics.
Composition, profiles, application callbacks, overrides, secrets, and rendering are
Applications may perform audited defaulting and normalization through a resolver.
Composition, profiles, semantic validation, overrides, secrets, and rendering are
still under development.
```go

7
doc.go
View File

@@ -3,7 +3,8 @@
//
// Load currently supports one file, byte, reader, or explicit no-input source with
// strict YAML, typed defaults, schema-version checks, detached metadata, safe
// diagnostics, and structured load errors. Composition, profiles, application
// resolution and validation, overrides, secrets, and effective rendering are not
// available yet. The configuration contract is documented in docs/config.md.
// diagnostics, recorded application resolution, and structured load errors.
// Composition, profiles, semantic validation, overrides, secrets, and effective
// rendering are not available yet. The configuration contract is documented in
// docs/config.md.
package configkit

View File

@@ -47,8 +47,8 @@ version checker, contributes no source, and continues through defaults, decode,
projection. A missing file remains an error; it is never interpreted as no input.
The current `Options` boundary accepts a defaults factory and `VersionRule`.
Imports, active profiles, filesystem context, overrides, resolver and semantic
validator callbacks, and dynamic secret classification return an explicit
Imports, active profiles, filesystem context, overrides, semantic validator
callbacks, and dynamic secret classification return an explicit
`options.unavailable` error because their lifecycle stages are not implemented.
The reserved profile spelling `default` selects no active profile. `NoSource`
additionally rejects imports, active profiles, and filesystem context as invalid
@@ -84,6 +84,44 @@ source coordinates are retained. `History` records the Go-zero or application
default baseline followed by exact base definitions, including equal-valued input.
All metadata queries and returned nested records are detached.
### Recorded application resolution
An optional `Definition.Resolve` callback runs after successful typed decoding. It
receives the working configuration and a per-load `Resolution` context. The context's
`IsExplicit` query uses surviving input presence, so explicit false, zero, empty, and
null are never confused with absence or static defaults.
`Default` assigns and records a derived value only when its path is not explicit.
`Normalize` always assigns and records. For transformations performed directly by
application code, `Resolution.Record` declares a derived-default or normalization
operation; a recorded container covers changes to its descendants. Records require
a schema-valid path, valid kind, safe nonempty reason, and schema-valid dependency
paths. Repeated records at one path are coalesced: reasons and dependencies retain
their first occurrence, the last helper value is authoritative, and any normalization
makes the combined operation a normalization.
ConfigKit projects the configuration before and after the callback. Every net change
must have an exact or ancestor record. Helper-assigned values must agree with the
final value at their declared path, which supports ordinary map-entry copy/writeback
while detecting forgotten writeback or unrelated targets. Invalid paths, kinds,
values, records, helper targets, and unrecorded mutations fail the load. Equal-valued
derived assignments may still record application intent even when there is no
structural change.
`Resolution.Error` and `Resolution.Warning` attach exact or nearest available input
locations. Reported errors fail the load; warnings survive on a successful result. A
returned resolver error adds one generic `resolve.hook_failed` error only when the
callback did not report an error, and its text is never exposed. Callback panics are
programming failures. Resolution and validation contexts are callback-scoped; using
a nil, zero, or retained context outside its active callback panics with fixed,
value-free text.
Successful records update effective origins and append detached metadata history
without changing input explicitness. Removed paths leave effective metadata but
retain history. ConfigKit privately snapshots each recorded before/after subtree for
later safe explanation; caller mutations of `Result.Config` cannot alter those
snapshots.
## YAML documents
The private YAML boundary currently accepts exactly one nonempty document whose root

View File

@@ -3,8 +3,8 @@
This is the contributor entry point for ConfigKit. Read the task-specific owners
below before making changes. The repository is in early implementation: its public
loader currently covers one strict source, typed defaults, schema-version checks,
and base/default metadata. The [component overview](internal/overview.md) owns the
implementation inventory.
recorded application resolution, and metadata. The
[component overview](internal/overview.md) owns the implementation inventory.
## Initial orientation

View File

@@ -192,6 +192,17 @@ later lifecycle option. No application loader or production line was copied.
Narratio's resolution, validation, import/profile, campaign/session, and command
behavior remains assigned to its later owning work.
Resolution implementation record (2026-09-06): ConfigKit adapted the intent of
Narratio's `Resolve`, `applyPipelineDefaults`, component default helpers,
`mergeCampaignSession`, and `selectStableInput` into a generic recorded resolver in
`resolution.go`. The synthetic timeout/artifact cases preserve explicit-input
decisions, derived dependencies, normalization, and map copy/writeback without
copying campaign/session algorithms or concrete defaults. ConfigKit adds structural
mutation auditing, container coverage, helper/path consistency checks, equal-value
intent, coalesced repeated writes, private before/after snapshots, deterministic
record ordering, and safe hook diagnostics. Semantic validation remains assigned to
the next lifecycle work.
### Secrets bootstrap
Assigned to the explicit bootstrap operation:

View File

@@ -19,10 +19,14 @@ module and package documentation boundary. Its implemented public primitives are
baseline/final trees, and initial default/input origins; and
- public single-source lifecycle coordination in `load.go`, version-report contexts
in `validation.go`, and detached base/default metadata assembly in
`load_metadata.go`.
`load_metadata.go`; and
- recorded application defaulting and normalization in `resolution.go`, including
explicitness queries, helper verification, structural mutation audit, coalesced
intent, private before/after snapshots, and resolution metadata integration.
The [configuration contract](../config.md) owns implemented input, schema, decoding,
single-source lifecycle, version checking, metadata, and ownership behavior.
single-source lifecycle, version checking, recorded resolution, metadata, and
ownership behavior.
The source-extraction inventory records the application foundations assigned to
future implementation work:

View File

@@ -2,7 +2,7 @@
## Status and ownership
Status: implementation in progress; Stages 1–7 are complete and later stages are
Status: implementation in progress; Stages 1–8 are complete and later stages are
pending.
Audience: a `gpt-5.6-terra` coding agent implementing one stage per prompt, in
@@ -684,7 +684,36 @@ Acceptance:
## Stage 8 — Implement recorded application resolution
Status: pending.
Status: complete.
Completion evidence (2026-09-06):
- Implemented per-load `Resolution`, `IsExplicit`, `Record`, `Default`, `Normalize`,
`Error`, and `Warning` in [`resolution.go`](../../resolution.go), and enabled the
resolver in `Load` after successful decode while leaving semantic validation gated.
- Added schema-aware path/kind/dependency checks, safe reason requirements, dynamic
and typed helper projection, final helper/path consistency checks, before/after
structural comparison, exact/ancestor record coverage, and distinct safe failures
for invalid values, targets, records, and unrecorded changes.
- Coalesced repeated path intent deterministically, retaining unique reasons and
dependencies, using the last helper value, and promoting mixed operations to
normalization. Equal-valued derived intent remains recorded; map copy/writeback,
container coverage, deletions, and new dynamic/map descendants are supported.
- Integrated resolution origins and history without changing surviving input
explicitness, retained private detached before/after snapshots, sorted records by
semantic path, and made retained callback-context use panic with fixed value-free
text. Resolver errors are suppressed unless explicitly reported through the
context; warnings remain on successful results. The
[current resolution contract](../config.md#recorded-application-resolution) owns
this behavior.
- Focused tests execute the timeout/artifact consumer shape, explicit null/zero and
absent-baseline behavior, normalization, map writeback, equal and repeated helper
intent, stable map-derived record order, subtree removal, snapshot detachment,
unrelated targets, invalid paths/kinds/values, unrecorded mutation, safe hook
failures, and closed resolution/version contexts. Verified `go test ./...`,
`go test -race ./...`, `go vet ./...`, `go build ./...`, `go mod tidy -diff`, and
`git diff --check` with Go 1.26.7 on linux/amd64. No deviations were required;
semantic validation remains later work.
Read: feature [resolution callbacks](initial.md#resolution-and-validation-callbacks).

View File

@@ -571,37 +571,16 @@ rendering remain target work below.
## Recorded resolution and validation
Application-owned resolution runs after decoding and may derive absent values
or normalize explicitly configured ones. Provide a narrow resolution context
that exposes metadata and records each changed semantic path, operation kind
(derived default or normalization), and a safe reason. Dependency paths may be
recorded when a value is derived from other fields.
Application-owned recorded resolution is implemented. The
[current resolution contract](../config.md#recorded-application-resolution) owns
explicitness queries, helpers and manual records, structural mutation auditing,
coalescing, diagnostics, origins, history, and snapshot ownership. Layered input
history, redacted explanation, and secret dependency propagation remain target work.
A resolver may mutate the typed configuration, but ConfigKit compares its
before/after configuration projection and requires records for changed paths.
Unrecorded changes are resolution errors. Container replacement records cover
changed descendants. Capture previous/final values internally from the actual
configuration rather than trusting separately supplied values. Application
resolvers own meaningful reasons; a diff alone cannot explain intent. Reasons
must not embed secrets. The proposed [resolution API](#resolution-and-validation-callbacks)
provides helpers to combine assignments and records. Snapshot comparison verifies
net changes during the hook; it does not reconstruct intermediate assignments.
A record describes the hook's before/final transition and declared intent, not an
automatically recovered event log. Equal-valued derived-default assignments may
still have a record identifying their intent. Multiple records at the same path
are coalesced for that hook, retaining declared reasons and dependencies.
The final configuration, effective output, presence queries, and explanation
records must agree. Do not render the pre-resolution YAML tree as the final
configuration. ConfigKit promises an independently owned snapshot it does not
subsequently mutate, not language-enforced immutability of exported Go values.
Callers should treat returned configuration as read-only; later caller mutations
are outside the captured metadata snapshot.
Canonical typed projection, detached cloning, and structural comparison are now
implemented as private foundations. The [current ownership contract](../config.md#typed-projection-and-ownership)
owns those mechanics; publishing independently captured results remains lifecycle
work.
Canonical typed projection, detached cloning, structural comparison, and publishing
independently captured resolved results are implemented. The
[current ownership contract](../config.md#typed-projection-and-ownership) owns those
mechanics.
Application semantic validation runs after successful resolution and collects
independent errors in a standard diagnostic form. Validators are read-only and
@@ -613,7 +592,9 @@ is outside this lifecycle. Do not run later hooks on an invalid typed value.
Safe diagnostic/provenance value records, canonical diagnostic rendering, and
`LoadError` access are implemented. Their current contracts are owned by
[`records.go`](../../records.go) and [`diagnostic.go`](../../diagnostic.go).
Attaching those records to the loading lifecycle remains target work below.
Base/default and resolution records are attached to the current loading lifecycle.
Additional layers, safe historical values, redaction, and rendering remain target
work below.
Retain input sources, layer, line/column where available, import sites, effective
origins, overridden definition records, and resolution operations. Container
@@ -814,6 +795,10 @@ behavior. Layered callback history and safe historical values remain target beha
### Resolution and validation callbacks
The resolution declarations and behavior below are implemented; the
[current recorded-resolution contract](../config.md#recorded-application-resolution)
is authoritative. Semantic validation behavior in this section remains target work.
```go
func (r *Resolution) IsExplicit(path Path) bool
func (r *Resolution) Record(path Path, kind ChangeKind, reason string, dependencies ...Path)

28
load.go
View File

@@ -39,14 +39,12 @@ type Override struct {
Flag string
}
// Resolution is the application resolution callback context. Resolution callbacks
// are rejected until the recorded-resolution lifecycle is available.
// Resolution exposes surviving-input presence and records audited application
// resolution changes. A resolver must not retain or concurrently use the context.
type Resolution struct {
state *resolutionState
}
type resolutionState struct{}
// VersionRule declares the input path and application checker for a schema version.
type VersionRule struct {
Path Path
@@ -68,6 +66,7 @@ type Result[T any] struct {
sources []SourceInfo
diagnostics []Diagnostic
final *document.Node
resolution []resolutionSnapshot
}
// Metadata returns detached effective presence, origin, and history records.
@@ -145,12 +144,26 @@ func Load[T any](source Source, supplied ...Options[T]) (*Result[T], error) {
}
configuration := decoded.value.Interface().(T)
metadata := metadataFromDecoded(decoded)
final := decoded.final
var resolutionSnapshots []resolutionSnapshot
if options.Definition.Resolve != nil {
resolved := runResolution(&configuration, options.Definition.Resolve, schema, decoded, parsed.document)
accumulator.add(resolved.diagnostics...)
if hasErrors(resolved.diagnostics) {
return nil, accumulator.loadError()
}
final = resolved.final
resolutionSnapshots = resolved.snapshots
applyResolutionMetadata(&metadata, resolved)
}
return &Result[T]{
Config: configuration,
metadata: metadataFromDecoded(decoded),
metadata: metadata,
sources: cloneSourceInfos(accumulator.sources),
diagnostics: canonicalDiagnostics(accumulator.diagnostics),
final: document.Clone(decoded.final),
final: document.Clone(final),
resolution: cloneResolutionSnapshots(resolutionSnapshots),
}, nil
}
@@ -164,9 +177,6 @@ func selectOptions[T any](source Source, supplied []Options[T]) (Options[T], []D
}
var diagnostics []Diagnostic
definition := options.Definition
if definition.Resolve != nil {
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsUnavailable, "application resolution is not available in this release boundary"))
}
if definition.Validate != nil {
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsUnavailable, "application validation is not available in this release boundary"))
}

View File

@@ -93,3 +93,66 @@ func cloneMetadata(metadata Metadata) Metadata {
}
return cloned
}
func applyResolutionMetadata(metadata *Metadata, resolved resolutionResult) {
for _, snapshot := range resolved.snapshots {
pointer := snapshot.change.Path.String()
change := cloneChange(snapshot.change)
if info, ok := metadata.fields[pointer]; ok {
change.Previous = []Origin{cloneOrigin(info.Origin)}
info.Origin = cloneOrigin(change.Origin)
metadata.fields[pointer] = info
}
metadata.history[pointer] = append(metadata.history[pointer], change)
}
for _, delta := range resolved.deltas {
record := coveringResolutionSnapshot(resolved.snapshots, delta.path)
if record == nil {
continue
}
for pointer, info := range metadata.fields {
path, err := ParsePath(pointer)
if err != nil || !pathContains(delta.path, path) {
continue
}
info.Origin = cloneOrigin(record.change.Origin)
metadata.fields[pointer] = info
}
}
refreshResolutionFields(metadata.fields, resolved.final, resolved.snapshots)
}
func coveringResolutionSnapshot(snapshots []resolutionSnapshot, path Path) *resolutionSnapshot {
var covering *resolutionSnapshot
for index := range snapshots {
snapshot := &snapshots[index]
if pathContains(snapshot.change.Path, path) && (covering == nil || len(snapshot.change.Path.segments) > len(covering.change.Path.segments)) {
covering = snapshot
}
}
return covering
}
func refreshResolutionFields(fields map[string]FieldInfo, final *document.Node, snapshots []resolutionSnapshot) {
finalPaths := make(map[string]*document.Node)
indexDocument(final, Path{}, finalPaths)
for pointer := range fields {
if _, ok := finalPaths[pointer]; !ok {
delete(fields, pointer)
}
}
for pointer, node := range finalPaths {
if info, ok := fields[pointer]; ok {
info.Null = node.Kind == document.Null
fields[pointer] = info
continue
}
origin := Origin{Layer: LayerResolution}
if path, err := ParsePath(pointer); err == nil {
if covering := coveringResolutionSnapshot(snapshots, path); covering != nil {
origin = cloneOrigin(covering.change.Origin)
}
}
fields[pointer] = FieldInfo{Null: node.Kind == document.Null, Origin: origin}
}
}

View File

@@ -222,7 +222,6 @@ func TestLoadRejectsUnavailableOptionsAndDisabledDirectives(t *testing.T) {
code Code
}{
{name: "multiple options", source: validSource(), options: []Options[config]{{}, {}}, code: CodeOptionsInvalid},
{name: "resolver", source: validSource(), options: []Options[config]{{Definition: Definition[config]{Resolve: func(*config, *Resolution) error { return nil }}}}, code: CodeOptionsUnavailable},
{name: "validator", source: validSource(), options: []Options[config]{{Definition: Definition[config]{Validate: func(*config, *Validation) {}}}}, code: CodeOptionsUnavailable},
{name: "secret classifier", source: validSource(), options: []Options[config]{{Definition: Definition[config]{Secret: func(Path) bool { return false }}}}, code: CodeOptionsUnavailable},
{name: "imports", source: validSource(), options: []Options[config]{{Imports: true}}, code: CodeOptionsUnavailable},

View File

@@ -82,6 +82,16 @@ const (
CodeVersionError Code = "version.error"
CodeVersionWarning Code = "version.warning"
CodeVersionHookFailed Code = "version.hook_failed"
CodeResolveError Code = "resolve.error"
CodeResolveWarning Code = "resolve.warning"
CodeResolveHookFailed Code = "resolve.hook_failed"
CodeResolveInvalidPath Code = "resolve.invalid_path"
CodeResolveInvalidKind Code = "resolve.invalid_kind"
CodeResolveInvalidRecord Code = "resolve.invalid_record"
CodeResolveInvalidTarget Code = "resolve.invalid_target"
CodeResolveInvalidValue Code = "resolve.invalid_value"
CodeResolveUnrecordedChange Code = "resolve.unrecorded_change"
)
// Location identifies a position in a configuration source. Zero line and column

359
resolution.go Normal file
View File

@@ -0,0 +1,359 @@
package configkit
import (
"reflect"
"slices"
"strings"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
const closedCallbackContextMessage = "configkit: callback context is no longer active"
type resolutionState struct {
active bool
schema *schemaDescriptor
presence map[string]inputPresence
document *document.Node
records map[string]*resolutionRecord
diagnostics []Diagnostic
}
type resolutionRecord struct {
path Path
kind ChangeKind
reasons []string
dependencies []Path
helperExpected *document.Node
}
type resolutionSnapshot struct {
change Change
before *document.Node
after *document.Node
}
type resolutionResult struct {
final *document.Node
deltas []documentDelta
snapshots []resolutionSnapshot
diagnostics []Diagnostic
}
// IsExplicit reports whether surviving input explicitly defined path.
func (r *Resolution) IsExplicit(path Path) bool {
state := r.activeState()
if !state.validPath(path) {
return false
}
_, explicit := state.presence[path.String()]
return explicit
}
// 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) {
state := r.activeState()
state.addRecord(path, kind, reason, dependencies, nil)
}
// Error reports a safe, value-free resolution error at path.
func (r *Resolution) Error(path Path, message string, related ...Path) {
r.activeState().report(SeverityError, path, message, related)
}
// Warning reports a safe, value-free resolution warning at path.
func (r *Resolution) Warning(path Path, message string, related ...Path) {
r.activeState().report(SeverityWarning, path, message, 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) {
state := resolution.activeState()
descriptor, ok := state.helperDescriptor(path, target)
if !ok {
return
}
if _, explicit := state.presence[path.String()]; explicit {
return
}
*target = value
expected, ok := state.projectHelperValue(path, descriptor, reflect.ValueOf(target).Elem())
if !ok {
return
}
state.addRecord(path, ChangeDerivedDefault, reason, dependencies, expected)
}
// 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) {
state := resolution.activeState()
descriptor, ok := state.helperDescriptor(path, target)
if !ok {
return
}
*target = value
expected, ok := state.projectHelperValue(path, descriptor, reflect.ValueOf(target).Elem())
if !ok {
return
}
state.addRecord(path, ChangeNormalization, reason, dependencies, expected)
}
func (r *Resolution) activeState() *resolutionState {
if r == nil || r.state == nil || !r.state.active {
panic(closedCallbackContextMessage)
}
return r.state
}
func (state *resolutionState) validPath(path Path) bool {
if _, ok := resolveSchemaPath(state.schema, path); ok {
return true
}
state.addDiagnostic(CodeResolveInvalidPath, path, "resolution path is not valid for the configuration schema")
return false
}
func (state *resolutionState) helperDescriptor(path Path, target any) (*typeDescriptor, bool) {
resolved, ok := resolveSchemaPath(state.schema, path)
if !ok {
state.addDiagnostic(CodeResolveInvalidPath, path, "resolution helper path is not valid for the configuration schema")
return nil, false
}
value := reflect.ValueOf(target)
if !value.IsValid() || value.Kind() != reflect.Pointer || value.IsNil() {
state.addDiagnostic(CodeResolveInvalidTarget, path, "resolution helper target must be a non-nil pointer")
return nil, false
}
if resolved.descriptor.kind != descriptorDynamic && value.Elem().Type() != resolved.descriptor.typeOf {
state.addDiagnostic(CodeResolveInvalidTarget, path, "resolution helper target type does not match its configuration path")
return nil, false
}
return resolved.descriptor, true
}
func (state *resolutionState) projectHelperValue(path Path, descriptor *typeDescriptor, value reflect.Value) (*document.Node, bool) {
if descriptor.kind == descriptorDynamic {
wrapped := reflect.New(descriptor.typeOf).Elem()
if value.IsValid() {
wrapped.Set(value)
}
value = wrapped
}
operation := valueOperation{
schema: state.schema, limits: defaultProjectionLimits(), active: make(map[valueVisit]bool),
}
node, issue := operation.project(value, descriptor, path, 0)
if issue != nil {
state.addDiagnostic(CodeResolveInvalidValue, issue.path, "resolution helper value is not a valid configuration value")
return nil, false
}
return node, true
}
func (state *resolutionState) addRecord(path Path, kind ChangeKind, reason string, dependencies []Path, expected *document.Node) {
if !state.validPath(path) {
return
}
if kind != ChangeDerivedDefault && kind != ChangeNormalization {
state.addDiagnostic(CodeResolveInvalidKind, path, "resolution record has an invalid change kind")
return
}
if reason == "" {
state.addDiagnostic(CodeResolveInvalidRecord, path, "resolution record requires a safe reason")
return
}
for _, dependency := range dependencies {
if !state.validPath(dependency) {
return
}
}
pointer := path.String()
record := state.records[pointer]
if record == nil {
record = &resolutionRecord{path: path.clone(), kind: kind}
state.records[pointer] = record
}
if kind == ChangeNormalization {
record.kind = ChangeNormalization
}
record.reasons = appendUniqueString(record.reasons, reason)
for _, dependency := range dependencies {
record.dependencies = appendUniquePath(record.dependencies, dependency)
}
if expected != nil {
record.helperExpected = document.Clone(expected)
}
}
func (state *resolutionState) report(severity Severity, path Path, message string, related []Path) {
code := CodeResolveError
if severity == SeverityWarning {
code = CodeResolveWarning
}
if !state.validPath(path) {
return
}
diagnostic := Diagnostic{Code: code, Severity: severity, Path: path.clone(), Message: message}
if location, ok := nearestDocumentLocation(state.document, path); ok {
diagnostic.Primary = documentLocationPointer(location)
}
for _, relatedPath := range related {
if !state.validPath(relatedPath) {
continue
}
if location, ok := nearestDocumentLocation(state.document, relatedPath); ok {
diagnostic.Related = append(diagnostic.Related, Location{
SourceID: location.SourceID, Line: location.Line, Column: location.Column,
})
}
}
state.diagnostics = append(state.diagnostics, diagnostic)
}
func (state *resolutionState) addDiagnostic(code Code, path Path, message string) {
diagnostic := Diagnostic{Code: code, Severity: SeverityError, Path: path.clone(), Message: message}
if location, ok := nearestDocumentLocation(state.document, path); ok {
diagnostic.Primary = documentLocationPointer(location)
}
state.diagnostics = append(state.diagnostics, diagnostic)
}
func runResolution[T any](configuration *T, resolver func(*T, *Resolution) error, schema *schemaDescriptor, decoded decodedConfiguration, input *document.Node) resolutionResult {
state := &resolutionState{
active: true, schema: schema, presence: decoded.presence, document: input,
records: make(map[string]*resolutionRecord),
}
context := &Resolution{state: state}
err := resolver(configuration, context)
state.active = false
if err != nil && !hasErrors(state.diagnostics) {
state.addDiagnostic(CodeResolveHookFailed, Path{}, "application resolution failed")
}
if hasErrors(state.diagnostics) {
return resolutionResult{diagnostics: canonicalDiagnostics(state.diagnostics)}
}
after, issue := projectConfiguration(schema, reflect.ValueOf(*configuration), defaultProjectionLimits())
if issue != nil {
state.addDiagnostic(CodeResolveInvalidValue, issue.path, "application resolution produced an invalid configuration value")
return resolutionResult{diagnostics: canonicalDiagnostics(state.diagnostics)}
}
deltas := compareDocumentTrees(decoded.final, after)
ordered := orderedResolutionRecords(state.records)
for _, delta := range deltas {
if coveringResolutionRecord(ordered, delta.path) == nil {
state.addDiagnostic(CodeResolveUnrecordedChange, delta.path, "application resolution changed a value without a covering record")
}
}
for _, record := range ordered {
beforeNode, beforePresent := documentAtPath(decoded.final, record.path)
afterNode, afterPresent := documentAtPath(after, record.path)
if !beforePresent && !afterPresent {
state.addDiagnostic(CodeResolveInvalidRecord, record.path, "resolution record does not identify an existing or removed value")
continue
}
if record.helperExpected != nil && (!afterPresent || !documentNodesEqual(record.helperExpected, afterNode)) {
state.addDiagnostic(CodeResolveInvalidTarget, record.path, "resolution helper assignment does not match its declared configuration path")
}
_ = beforeNode
}
if hasErrors(state.diagnostics) {
return resolutionResult{diagnostics: canonicalDiagnostics(state.diagnostics)}
}
snapshots := make([]resolutionSnapshot, 0, len(ordered))
for _, record := range ordered {
beforeNode, _ := documentAtPath(decoded.final, record.path)
afterNode, _ := documentAtPath(after, record.path)
snapshots = append(snapshots, resolutionSnapshot{
change: Change{
Path: record.path.clone(),
Origin: Origin{Layer: LayerResolution, Operation: record.kind},
Reasons: append([]string(nil), record.reasons...),
Dependencies: clonePaths(record.dependencies),
},
before: document.Clone(beforeNode), after: document.Clone(afterNode),
})
}
return resolutionResult{
final: after, deltas: deltas, snapshots: snapshots,
diagnostics: canonicalDiagnostics(state.diagnostics),
}
}
func orderedResolutionRecords(records map[string]*resolutionRecord) []*resolutionRecord {
ordered := make([]*resolutionRecord, 0, len(records))
for _, record := range records {
ordered = append(ordered, record)
}
slices.SortFunc(ordered, func(left, right *resolutionRecord) int {
return strings.Compare(left.path.String(), right.path.String())
})
return ordered
}
func coveringResolutionRecord(records []*resolutionRecord, path Path) *resolutionRecord {
var covering *resolutionRecord
for _, record := range records {
if pathContains(record.path, path) && (covering == nil || len(record.path.segments) > len(covering.path.segments)) {
covering = record
}
}
return covering
}
func pathContains(container, descendant Path) bool {
if !container.valid() || !descendant.valid() || len(container.segments) > len(descendant.segments) {
return false
}
if container.String() == "" {
return true
}
prefix := container.String()
value := descendant.String()
return value == prefix || strings.HasPrefix(value, prefix+"/")
}
func appendUniqueString(values []string, value string) []string {
if !slices.Contains(values, value) {
return append(values, value)
}
return values
}
func appendUniquePath(paths []Path, path Path) []Path {
for _, existing := range paths {
if existing.String() == path.String() {
return paths
}
}
return append(paths, path.clone())
}
func clonePaths(paths []Path) []Path {
if paths == nil {
return nil
}
cloned := make([]Path, len(paths))
for index, path := range paths {
cloned[index] = path.clone()
}
return cloned
}
func cloneResolutionSnapshots(snapshots []resolutionSnapshot) []resolutionSnapshot {
if snapshots == nil {
return nil
}
cloned := make([]resolutionSnapshot, len(snapshots))
for index, snapshot := range snapshots {
cloned[index] = resolutionSnapshot{
change: cloneChange(snapshot.change),
before: document.Clone(snapshot.before), after: document.Clone(snapshot.after),
}
}
return cloned
}

341
resolution_test.go Normal file
View File

@@ -0,0 +1,341 @@
package configkit
import (
"errors"
"math"
"reflect"
"strings"
"testing"
"time"
)
func TestLoadRunsRecordedResolutionWithMapWriteback(t *testing.T) {
t.Parallel()
type artifact struct {
Output string `yaml:"output"`
Timeout time.Duration `yaml:"timeout"`
}
type config struct {
PollInterval time.Duration `yaml:"poll_interval"`
Timeout time.Duration `yaml:"timeout"`
Artifacts map[string]artifact `yaml:"artifacts"`
}
definition := Definition[config]{
Defaults: func() config {
return config{PollInterval: time.Minute, Timeout: 5 * time.Second}
},
Resolve: func(configuration *config, resolution *Resolution) error {
Default(resolution, Keys("timeout"), &configuration.Timeout,
configuration.PollInterval/10, "derived from poll interval", Keys("poll_interval"))
for name, current := range configuration.Artifacts {
path := Keys("artifacts", name)
Default(resolution, path.Key("timeout"), &current.Timeout,
configuration.Timeout, "inherited configuration timeout", Keys("timeout"))
Normalize(resolution, path.Key("output"), &current.Output,
strings.TrimSpace(current.Output), "trimmed surrounding whitespace")
configuration.Artifacts[name] = current
}
resolution.Warning(Keys("timeout"), "using derived timeout", Keys("poll_interval"))
return nil
},
}
input := "poll_interval: 1m\nartifacts:\n second: {output: ' second '}\n first: {output: ' first '}\n"
result, err := Load(Bytes("resolution.yml", []byte(input)), Options[config]{Definition: definition})
if err != nil {
t.Fatal(err)
}
if result.Config.Timeout != 6*time.Second {
t.Fatalf("timeout = %s", result.Config.Timeout)
}
for name, want := range map[string]string{"first": "first", "second": "second"} {
current := result.Config.Artifacts[name]
if current.Timeout != 6*time.Second || current.Output != want {
t.Errorf("artifact %q = %#v", name, current)
}
}
if diagnostics := result.Diagnostics(); len(diagnostics) != 1 || diagnostics[0].Code != CodeResolveWarning || diagnostics[0].Primary == nil || len(diagnostics[0].Related) != 1 {
t.Fatalf("diagnostics = %#v", diagnostics)
}
metadata := result.Metadata()
timeout, ok := metadata.Lookup(Keys("timeout"))
if !ok || timeout.Explicit || timeout.Origin.Layer != LayerResolution || timeout.Origin.Operation != ChangeDerivedDefault {
t.Fatalf("timeout metadata = %#v, %v", timeout, ok)
}
output, ok := metadata.Lookup(Keys("artifacts", "first", "output"))
if !ok || !output.Explicit || output.Origin.Operation != ChangeNormalization {
t.Fatalf("output metadata = %#v, %v", output, ok)
}
history := metadata.History(Keys("timeout"))
last := history[len(history)-1]
if last.Origin.Operation != ChangeDerivedDefault || !reflect.DeepEqual(last.Reasons, []string{"derived from poll interval"}) || len(last.Dependencies) != 1 || last.Dependencies[0].String() != "/poll_interval" {
t.Fatalf("timeout history = %#v", history)
}
wantOrder := []string{
"/artifacts/first/output", "/artifacts/first/timeout",
"/artifacts/second/output", "/artifacts/second/timeout", "/timeout",
}
gotOrder := make([]string, len(result.resolution))
for index, snapshot := range result.resolution {
gotOrder[index] = snapshot.change.Path.String()
}
if !reflect.DeepEqual(gotOrder, wantOrder) {
t.Fatalf("resolution order = %#v", gotOrder)
}
}
func TestResolutionPreservesExplicitNullAndZeroWhileReplacingAbsentBaseline(t *testing.T) {
t.Parallel()
type config struct {
Optional *int `yaml:"optional"`
Count int `yaml:"count"`
Absent int `yaml:"absent"`
}
five := 5
result, err := Load(Bytes("explicit.yml", []byte("optional: null\ncount: 0\n")), Options[config]{Definition: Definition[config]{
Defaults: func() config { return config{Optional: &five, Count: 5, Absent: 5} },
Resolve: func(configuration *config, resolution *Resolution) error {
if !resolution.IsExplicit(Keys("optional")) || !resolution.IsExplicit(Keys("count")) || resolution.IsExplicit(Keys("absent")) {
return errors.New("unexpected presence")
}
Default(resolution, Keys("optional"), &configuration.Optional, &five, "would derive optional")
Default(resolution, Keys("count"), &configuration.Count, 9, "would derive count")
Default(resolution, Keys("absent"), &configuration.Absent, 9, "derived absent value")
return nil
},
}})
if err != nil {
var loadError *LoadError
_ = errors.As(err, &loadError)
t.Fatalf("%v: %#v", err, loadError.Diagnostics())
}
if result.Config.Optional != nil || result.Config.Count != 0 || result.Config.Absent != 9 {
t.Fatalf("config = %#v", result.Config)
}
if len(result.Metadata().History(Keys("optional"))) != 2 || len(result.Metadata().History(Keys("count"))) != 2 || len(result.Metadata().History(Keys("absent"))) != 2 {
t.Fatal("resolution history disagrees with explicitness")
}
}
func TestResolutionCoalescesEqualAndRepeatedHelperIntent(t *testing.T) {
t.Parallel()
type config struct {
Value int `yaml:"value"`
Other int `yaml:"other"`
}
result, err := Load(NoSource(), Options[config]{Definition: Definition[config]{
Defaults: func() config { return config{Value: 5} },
Resolve: func(configuration *config, resolution *Resolution) error {
Default(resolution, Keys("value"), &configuration.Value, 5, "equal derived intent", Keys("other"))
Default(resolution, Keys("value"), &configuration.Value, 7, "updated derived intent", Keys("other"))
Normalize(resolution, Keys("value"), &configuration.Value, 7, "canonicalized value", Keys("other"))
return nil
},
}})
if err != nil {
t.Fatal(err)
}
history := result.Metadata().History(Keys("value"))
if len(history) != 2 {
t.Fatalf("history = %#v", history)
}
change := history[1]
if change.Origin.Operation != ChangeNormalization || !reflect.DeepEqual(change.Reasons, []string{"equal derived intent", "updated derived intent", "canonicalized value"}) || len(change.Dependencies) != 1 {
t.Fatalf("coalesced change = %#v", change)
}
}
func TestResolutionContainerRecordCoversSubtreeRemovalAndSnapshotsAreDetached(t *testing.T) {
t.Parallel()
type item struct {
Value string `yaml:"value"`
}
type config struct {
Items map[string]item `yaml:"items"`
}
result, err := Load(Bytes("remove.yml", []byte("items:\n keep: {value: 'yes'}\n remove: {value: 'no'}\n")), Options[config]{Definition: Definition[config]{
Resolve: func(configuration *config, resolution *Resolution) error {
delete(configuration.Items, "remove")
resolution.Record(Keys("items"), ChangeNormalization, "removed disabled entries")
return nil
},
}})
if err != nil {
t.Fatal(err)
}
if _, exists := result.Config.Items["remove"]; exists {
t.Fatal("removed entry remains")
}
if _, ok := result.Metadata().Lookup(Keys("items", "remove")); ok {
t.Fatal("removed entry remains effective metadata")
}
if len(result.resolution) != 1 || result.resolution[0].before == nil || result.resolution[0].after == nil {
t.Fatalf("snapshots = %#v", result.resolution)
}
result.Config.Items["keep"] = item{Value: "changed"}
if mappingValue(t, result.resolution[0].after, "keep").Mapping[0].Value.Text != "yes" {
t.Fatal("caller mutation changed private resolution snapshot")
}
}
func TestResolutionSequenceReplacementUpdatesDescendantOrigins(t *testing.T) {
t.Parallel()
type config struct {
Values []int `yaml:"values"`
}
result, err := Load(Bytes("sequence.yml", []byte("values: [1, 2]\n")), Options[config]{Definition: Definition[config]{
Resolve: func(configuration *config, resolution *Resolution) error {
Normalize(resolution, Keys("values"), &configuration.Values, []int{3}, "replaced values")
return nil
},
}})
if err != nil {
t.Fatal(err)
}
info, ok := result.Metadata().Lookup(Keys("values").Index(0))
if !ok || !info.Explicit || info.Origin.Layer != LayerResolution || info.Origin.Operation != ChangeNormalization {
t.Fatalf("element metadata = %#v, %v", info, ok)
}
if _, ok := result.Metadata().Lookup(Keys("values").Index(1)); ok {
t.Fatal("removed sequence element remains effective")
}
}
func TestResolutionRejectsUnrecordedAndMismatchedMutations(t *testing.T) {
t.Parallel()
type config struct {
Value int `yaml:"value"`
}
tests := []struct {
name string
resolve func(*config, *Resolution) error
code Code
}{
{name: "unrecorded", resolve: func(configuration *config, _ *Resolution) error {
configuration.Value = 2
return nil
}, code: CodeResolveUnrecordedChange},
{name: "unrelated target", resolve: func(_ *config, resolution *Resolution) error {
unrelated := 1
Normalize(resolution, Keys("value"), &unrelated, 2, "changed value")
return nil
}, code: CodeResolveInvalidTarget},
{name: "invalid record kind", resolve: func(_ *config, resolution *Resolution) error {
resolution.Record(Keys("value"), ChangeKind("other"), "invalid kind")
return nil
}, code: CodeResolveInvalidKind},
{name: "invalid path", resolve: func(_ *config, resolution *Resolution) error {
resolution.Record(Keys("unknown"), ChangeNormalization, "unknown field")
return nil
}, code: CodeResolveInvalidPath},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
result, err := Load(Bytes("invalid-resolution.yml", []byte("value: 1\n")), Options[config]{Definition: Definition[config]{Resolve: test.resolve}})
assertLoadDiagnostic(t, result, err, test.code)
})
}
}
func TestResolutionRejectsInvalidProjectedValuesAndSuppressesReturnedErrors(t *testing.T) {
t.Parallel()
type config struct {
Number float64 `yaml:"number"`
Value int `yaml:"value"`
}
tests := []struct {
name string
resolve func(*config, *Resolution) error
code Code
}{
{name: "invalid helper value", resolve: func(configuration *config, resolution *Resolution) error {
Normalize(resolution, Keys("number"), &configuration.Number, math.NaN(), "invalid number")
return nil
}, code: CodeResolveInvalidValue},
{name: "reported error", resolve: func(_ *config, resolution *Resolution) error {
resolution.Error(Keys("value"), "value cannot be resolved")
return errors.New("CANARY_RESOLUTION_ERROR")
}, code: CodeResolveError},
{name: "returned error", resolve: func(_ *config, _ *Resolution) error {
return errors.New("CANARY_RESOLUTION_ERROR")
}, code: CodeResolveHookFailed},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
result, err := Load(Bytes("resolution-errors.yml", []byte("value: 1\nnumber: 1\n")), Options[config]{Definition: Definition[config]{Resolve: test.resolve}})
loadError := assertLoadDiagnostic(t, result, err, test.code)
for _, diagnostic := range loadError.Diagnostics() {
if strings.Contains(diagnostic.Message, "CANARY") {
t.Fatalf("diagnostic exposed returned error: %#v", diagnostic)
}
}
})
}
}
func TestResolutionContextPanicsAfterCallback(t *testing.T) {
t.Parallel()
type config struct {
Value int `yaml:"value"`
}
var retained *Resolution
if _, err := Load(NoSource(), Options[config]{Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error {
retained = resolution
return nil
}}}); err != nil {
t.Fatal(err)
}
defer func() {
if recovered := recover(); recovered != closedCallbackContextMessage {
t.Fatalf("panic = %#v", recovered)
}
}()
retained.IsExplicit(Keys("value"))
}
func TestValidationContextPanicsAfterVersionCallback(t *testing.T) {
t.Parallel()
type config struct {
Version string `yaml:"version"`
}
var retained *Validation
if _, err := Load(Bytes("version-context.yml", []byte("version: one\n")), Options[config]{Definition: Definition[config]{
Version: &VersionRule{Path: Keys("version"), Check: func(_ Version, validation *Validation) error {
retained = validation
return nil
}},
}}); err != nil {
t.Fatal(err)
}
defer func() {
if recovered := recover(); recovered != closedCallbackContextMessage {
t.Fatalf("panic = %#v", recovered)
}
}()
retained.Warning(Keys("version"), "too late")
}
func assertLoadDiagnostic[T any](t *testing.T, result *Result[T], err error, code Code) *LoadError {
t.Helper()
if result != nil || err == nil {
t.Fatalf("result, err = %#v, %v", result, err)
}
var loadError *LoadError
if !errors.As(err, &loadError) {
t.Fatalf("error type = %T", err)
}
diagnostics := loadError.Diagnostics()
if len(diagnostics) == 0 || diagnostics[0].Code != code {
t.Fatalf("diagnostics = %#v", diagnostics)
}
return loadError
}

View File

@@ -405,10 +405,11 @@ func resolveSchemaPath(schema *schemaDescriptor, path Path) (schemaPath, bool) {
return schemaPath{}, false
}
}
for current.kind == descriptorPointer {
current = current.element
dynamic := current
for dynamic.kind == descriptorPointer {
dynamic = dynamic.element
}
return schemaPath{descriptor: current, field: field, dynamic: current.kind == descriptorDynamic}, true
return schemaPath{descriptor: current, field: field, dynamic: dynamic.kind == descriptorDynamic}, true
}
func schemaIndex(segment pathSegment) (int, bool) {

View File

@@ -30,7 +30,7 @@ func (v *Validation) Warning(path Path, message string, related ...Path) {
func (v *Validation) report(severity Severity, path Path, message string, related []Path) {
if v == nil || v.state == nil || !v.state.active {
return
panic(closedCallbackContextMessage)
}
code := v.state.errorCode
if severity == SeverityWarning {