Add audited application resolution
This commit is contained in:
@@ -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
7
doc.go
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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).
|
||||
|
||||
|
||||
@@ -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
28
load.go
@@ -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"))
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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},
|
||||
|
||||
10
records.go
10
records.go
@@ -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
359
resolution.go
Normal 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
341
resolution_test.go
Normal 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"), ¤t.Timeout,
|
||||
configuration.Timeout, "inherited configuration timeout", Keys("timeout"))
|
||||
Normalize(resolution, path.Key("output"), ¤t.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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user