diff --git a/README.md b/README.md index def1e8b..3991019 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/doc.go b/doc.go index 6c72498..042b3b6 100644 --- a/doc.go +++ b/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 diff --git a/docs/config.md b/docs/config.md index cd8776c..f13a207 100644 --- a/docs/config.md +++ b/docs/config.md @@ -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 diff --git a/docs/development.md b/docs/development.md index f176848..043151a 100644 --- a/docs/development.md +++ b/docs/development.md @@ -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 diff --git a/docs/internal/extraction.md b/docs/internal/extraction.md index 831609e..5bdd711 100644 --- a/docs/internal/extraction.md +++ b/docs/internal/extraction.md @@ -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: diff --git a/docs/internal/overview.md b/docs/internal/overview.md index edce2d7..a64d113 100644 --- a/docs/internal/overview.md +++ b/docs/internal/overview.md @@ -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: diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 709d377..6e3d408 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -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). diff --git a/docs/roadmap/initial.md b/docs/roadmap/initial.md index 9f8fb66..8ce3818 100644 --- a/docs/roadmap/initial.md +++ b/docs/roadmap/initial.md @@ -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) diff --git a/load.go b/load.go index 88f6fd3..787b3cf 100644 --- a/load.go +++ b/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")) } diff --git a/load_metadata.go b/load_metadata.go index 94c7fe9..5c96547 100644 --- a/load_metadata.go +++ b/load_metadata.go @@ -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} + } +} diff --git a/load_test.go b/load_test.go index 5c0acc8..2246368 100644 --- a/load_test.go +++ b/load_test.go @@ -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}, diff --git a/records.go b/records.go index e25bf69..c5d5ca9 100644 --- a/records.go +++ b/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 diff --git a/resolution.go b/resolution.go new file mode 100644 index 0000000..603e549 --- /dev/null +++ b/resolution.go @@ -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 +} diff --git a/resolution_test.go b/resolution_test.go new file mode 100644 index 0000000..52351ce --- /dev/null +++ b/resolution_test.go @@ -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 +} diff --git a/schema.go b/schema.go index 825ad9b..a89b2b2 100644 --- a/schema.go +++ b/schema.go @@ -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) { diff --git a/validation.go b/validation.go index ecf40c5..f8f316b 100644 --- a/validation.go +++ b/validation.go @@ -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 {