Complete single-source semantic validation

This commit is contained in:
2026-09-06 20:19:58 +00:00
parent 0da4260ad6
commit a0cd0977ee
14 changed files with 484 additions and 31 deletions

View File

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

4
doc.go
View File

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

View File

@@ -38,7 +38,8 @@ The current lifecycle is:
```text ```text
validate options → parse one source → reject reserved directives → build schema validate options → parse one source → reject reserved directives → build schema
→ check the optional application version → create and clone defaults → check the optional application version → create and clone defaults
→ strictly decode → snapshot configuration and metadata → strictly decode → recorded application resolution → semantic validation
→ snapshot configuration and metadata
``` ```
`File`, `Bytes`, and `Reader` each contribute one base source with the deterministic `File`, `Bytes`, and `Reader` each contribute one base source with the deterministic
@@ -46,17 +47,17 @@ ID `source-1`. `NoSource` skips parsing, supplies an absent version to a configu
version checker, contributes no source, and continues through defaults, decode, and version checker, contributes no source, and continues through defaults, decode, and
projection. A missing file remains an error; it is never interpreted as no input. projection. A missing file remains an error; it is never interpreted as no input.
The current `Options` boundary accepts a defaults factory and `VersionRule`. The current `Options` boundary accepts a defaults factory, `VersionRule`, resolver,
Imports, active profiles, filesystem context, overrides, semantic validator and semantic validator. Imports, active profiles, filesystem context, overrides,
callbacks, and dynamic secret classification return an explicit and dynamic secret classification return an explicit
`options.unavailable` error because their lifecycle stages are not implemented. `options.unavailable` error because their lifecycle stages are not implemented.
The reserved profile spelling `default` selects no active profile. `NoSource` The reserved profile spelling `default` selects no active profile. `NoSource`
additionally rejects imports, active profiles, and filesystem context as invalid additionally rejects imports, active profiles, and filesystem context as invalid
combinations. Input containing a reserved `_configkit` mapping key is rejected while combinations. Input containing a reserved `_configkit` mapping key is rejected while
imports are disabled. imports are disabled.
Every source, options, schema, version, defaults, or decode failure returns a nil Every source, options, schema, version, defaults, decode, resolution, or validation
result and a `*LoadError`. Successful results contain caller-owned `Config`, detached failure returns a nil result and a `*LoadError`. Successful results contain caller-owned `Config`, detached
`Metadata`, a defensive source list, and defensive nonfatal diagnostics. Mutating `Metadata`, a defensive source list, and defensive nonfatal diagnostics. Mutating
the returned configuration or inspection records cannot alter ConfigKit's private the returned configuration or inspection records cannot alter ConfigKit's private
snapshot. ConfigKit performs no logging and exposes no effective writer yet. snapshot. ConfigKit performs no logging and exposes no effective writer yet.
@@ -122,6 +123,33 @@ retain history. ConfigKit privately snapshots each recorded before/after subtree
later safe explanation; caller mutations of `Result.Config` cannot alter those later safe explanation; caller mutations of `Result.Config` cannot alter those
snapshots. snapshots.
### Semantic validation
An optional `Definition.Validate` callback runs after successful resolution and
receives the resolved configuration plus a per-load `Validation` context. It can
collect multiple independent `validate.error` and `validate.warning` reports.
Warnings continue the lifecycle and are returned on success; any error returns a nil
result with the full safe diagnostic and source report.
Primary and related paths must be valid for the application schema. Omitted declared
fields and missing string-map or dynamic keys are valid report targets; unknown
struct fields and malformed paths produce `validate.invalid_path`. ConfigKit attaches
the exact surviving input location when one exists, otherwise the nearest input
container location, or no primary location for defaults-only synthetic state.
Validation is read-only. ConfigKit projects the configuration immediately before and
after the callback and returns `validate.mutation` at every detected net-change path,
including nested mutable state. A value that can no longer be projected is also a
mutation failure. No mutated or otherwise invalid configuration is published.
Callback panics remain programming failures, and retained contexts use the same
fixed closed-context panic as version and resolution callbacks.
Only schema-bound exported state participates in loading, resolution audit,
validation audit, metadata, and snapshots. Ignored or unexported ordinary fields are
zero in returned detached values. Applications retain responsibility for domain
rules and runtime initialization; validators should not start clients or depend on
live services as a substitute for configuration validation.
## YAML documents ## YAML documents
The private YAML boundary currently accepts exactly one nonempty document whose root The private YAML boundary currently accepts exactly one nonempty document whose root

View File

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

View File

@@ -203,6 +203,16 @@ intent, coalesced repeated writes, private before/after snapshots, deterministic
record ordering, and safe hook diagnostics. Semantic validation remains assigned to record ordering, and safe hook diagnostics. Semantic validation remains assigned to
the next lifecycle work. the next lifecycle work.
Validation implementation record (2026-09-06): ConfigKit generalized Narratio's
`TestNormalizeLoadAndValidate`, `TestRepresentativeLoadDefaultsAndValidation`,
strict registry decoding, and maintained extraction-example expectations into the
post-resolution `Validation` lifecycle and synthetic consumer examples. ConfigKit
keeps domain rules application-owned, resolves schema-valid missing/dynamic report
paths to nearest input locations, collects independent warnings/errors, and audits
the full typed projection to reject validator mutation. Live resources, registries,
campaign/session logic, commands, and application runtime initialization were not
extracted.
### Secrets bootstrap ### Secrets bootstrap
Assigned to the explicit bootstrap operation: Assigned to the explicit bootstrap operation:

View File

@@ -22,11 +22,13 @@ module and package documentation boundary. Its implemented public primitives are
`load_metadata.go`; and `load_metadata.go`; and
- recorded application defaulting and normalization in `resolution.go`, including - recorded application defaulting and normalization in `resolution.go`, including
explicitness queries, helper verification, structural mutation audit, coalesced explicitness queries, helper verification, structural mutation audit, coalesced
intent, private before/after snapshots, and resolution metadata integration. intent, private before/after snapshots, and resolution metadata integration; and
- post-resolution schema-aware report collection and validator mutation detection in
`validation.go`, completing the public single-source callback lifecycle.
The [configuration contract](../config.md) owns implemented input, schema, decoding, The [configuration contract](../config.md) owns implemented input, schema, decoding,
single-source lifecycle, version checking, recorded resolution, metadata, and single-source lifecycle, version checking, recorded resolution, semantic validation,
ownership behavior. metadata, and ownership behavior.
The source-extraction inventory records the application foundations assigned to The source-extraction inventory records the application foundations assigned to
future implementation work: future implementation work:

View File

@@ -2,7 +2,7 @@
## Status and ownership ## Status and ownership
Status: implementation in progress; Stages 1–8 are complete and later stages are Status: implementation in progress; Stages 1–9 are complete and later stages are
pending. pending.
Audience: a `gpt-5.6-terra` coding agent implementing one stage per prompt, in Audience: a `gpt-5.6-terra` coding agent implementing one stage per prompt, in
@@ -744,7 +744,37 @@ Acceptance:
## Stage 9 — Add semantic validation and finish the single-source lifecycle ## Stage 9 — Add semantic validation and finish the single-source lifecycle
Status: pending. Status: complete.
Completion evidence (2026-09-06):
- Reused `Validation` for post-resolution application reports in
[`validation.go`](../../validation.go), with `validate.error`,
`validate.warning`, and `validate.invalid_path` classifications. Omitted declared
fields and missing string-map/dynamic keys remain valid report paths; unknown
struct fields and malformed primary or related paths fail safely.
- Attached exact or nearest input locations to primary and related reports, retained
independent errors and warnings, continued through warnings, and returned no
configuration on any semantic error. Version, decode, and resolution failures stop
before later hooks.
- Added before/after typed projection around the validator. Any net scalar,
container, or nested mutable-state change, and any newly unprojectable value,
returns `validate.mutation` without publishing the mutated object. Callback
contexts close after invocation and callback panics remain programming failures.
- Removed the semantic-validator options gate, finalized metadata only after
successful resolution/validation, and retained defensive configuration snapshot,
metadata, diagnostic, and source ownership. The
[current semantic-validation contract](../config.md#semantic-validation) owns the
completed single-source lifecycle and application/runtime-state boundary.
- Added compiling executable examples in [`example_test.go`](../../example_test.go)
for minimal loading, defaults-only operation, related-path validation, actionable
schema-version guidance, and resolution followed by validation. Focused tests cover
multiple reports, missing/dynamic paths, related/nearest locations, invalid paths,
validator mutation, hook ordering and stop points, warnings, and final value/
explicitness/origin/history agreement. 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;
imports and profiles remain deliberately unavailable.
Read: feature [diagnostic callbacks](initial.md#resolution-and-validation-callbacks) Read: feature [diagnostic callbacks](initial.md#resolution-and-validation-callbacks)
and [schema reports](initial.md#schema-checking-and-secret-classification). and [schema reports](initial.md#schema-checking-and-secret-classification).

View File

@@ -582,10 +582,10 @@ independently captured resolved results are implemented. The
[current ownership contract](../config.md#typed-projection-and-ownership) owns those [current ownership contract](../config.md#typed-projection-and-ownership) owns those
mechanics. mechanics.
Application semantic validation runs after successful resolution and collects Application semantic validation is implemented. The
independent errors in a standard diagnostic form. Validators are read-only and [current semantic-validation contract](../config.md#semantic-validation) owns
own domain requirements; checking live S3 buckets or starting runtime clients schema-aware reporting, nearest locations, warning/error behavior, mutation audit,
is outside this lifecycle. Do not run later hooks on an invalid typed value. callback lifetime, and application/runtime responsibility boundaries.
## Provenance, diagnostics, and redaction ## Provenance, diagnostics, and redaction
@@ -795,9 +795,10 @@ behavior. Layered callback history and safe historical values remain target beha
### Resolution and validation callbacks ### Resolution and validation callbacks
The resolution declarations and behavior below are implemented; the The resolution and semantic-validation declarations and behavior below are
[current recorded-resolution contract](../config.md#recorded-application-resolution) implemented. The [current recorded-resolution contract](../config.md#recorded-application-resolution)
is authoritative. Semantic validation behavior in this section remains target work. and [semantic-validation contract](../config.md#semantic-validation) are
authoritative.
```go ```go
func (r *Resolution) IsExplicit(path Path) bool func (r *Resolution) IsExplicit(path Path) bool

96
example_test.go Normal file
View File

@@ -0,0 +1,96 @@
package configkit_test
import (
"errors"
"fmt"
"strings"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
func ExampleLoad() {
type Config struct {
Enabled bool `yaml:"enabled"`
Workers int `yaml:"workers"`
}
result, err := configkit.Load[Config](configkit.Bytes("example.yml", []byte("enabled: true\nworkers: 3\n")))
fmt.Println(result.Config, err)
// Output: {true 3} <nil>
}
func ExampleNoSource() {
type Config struct {
Workers int `yaml:"workers"`
}
result, err := configkit.Load(configkit.NoSource(), configkit.Options[Config]{
Definition: configkit.Definition[Config]{Defaults: func() Config { return Config{Workers: 4} }},
})
fmt.Println(result.Config.Workers, len(result.Sources()), err)
// Output: 4 0 <nil>
}
func ExampleValidation_Error() {
type Config struct {
First string `yaml:"first"`
Second string `yaml:"second"`
}
_, err := configkit.Load(configkit.Bytes("duplicates.yml", []byte("first: same\nsecond: same\n")), configkit.Options[Config]{
Definition: configkit.Definition[Config]{Validate: func(configuration *Config, validation *configkit.Validation) {
if configuration.First == configuration.Second {
validation.Error(configkit.Keys("second"), "must differ from first", configkit.Keys("first"))
}
}},
})
var loadError *configkit.LoadError
errors.As(err, &loadError)
diagnostic := loadError.Diagnostics()[0]
fmt.Println(diagnostic.Code, diagnostic.Path, len(diagnostic.Related))
// Output: validate.error /second 1
}
func ExampleVersionRule() {
type Config struct {
Version string `yaml:"version"`
}
_, err := configkit.Load(configkit.Bytes("version.yml", []byte("version: old\n")), configkit.Options[Config]{
Definition: configkit.Definition[Config]{Version: &configkit.VersionRule{
Path: configkit.Keys("version"),
Check: func(version configkit.Version, validation *configkit.Validation) error {
if version.Text != "v1" {
validation.Error(configkit.Keys("version"), "unsupported version; migrate using the application guide")
}
return nil
},
}},
})
var loadError *configkit.LoadError
errors.As(err, &loadError)
fmt.Println(loadError.Diagnostics()[0].Message)
// Output: unsupported version; migrate using the application guide
}
func ExampleResolution() {
type Config struct {
Input int `yaml:"input"`
Derived int `yaml:"derived"`
}
var order []string
result, err := configkit.Load(configkit.Bytes("resolved.yml", []byte("input: 3\n")), configkit.Options[Config]{
Definition: configkit.Definition[Config]{
Resolve: func(configuration *Config, resolution *configkit.Resolution) error {
order = append(order, "resolve")
configkit.Default(resolution, configkit.Keys("derived"), &configuration.Derived,
configuration.Input*2, "derived from input", configkit.Keys("input"))
return nil
},
Validate: func(configuration *Config, validation *configkit.Validation) {
order = append(order, "validate")
if configuration.Derived < configuration.Input {
validation.Error(configkit.Keys("derived"), "must not be less than input")
}
},
},
})
fmt.Println(strings.Join(order, " then "), result.Config.Derived, err)
// Output: resolve then validate 6 <nil>
}

12
load.go
View File

@@ -157,6 +157,13 @@ func Load[T any](source Source, supplied ...Options[T]) (*Result[T], error) {
resolutionSnapshots = resolved.snapshots resolutionSnapshots = resolved.snapshots
applyResolutionMetadata(&metadata, resolved) applyResolutionMetadata(&metadata, resolved)
} }
if options.Definition.Validate != nil {
validationDiagnostics := runValidation(&configuration, options.Definition.Validate, schema, final, parsed.document)
accumulator.add(validationDiagnostics...)
if hasErrors(validationDiagnostics) {
return nil, accumulator.loadError()
}
}
return &Result[T]{ return &Result[T]{
Config: configuration, Config: configuration,
metadata: metadata, metadata: metadata,
@@ -177,9 +184,6 @@ func selectOptions[T any](source Source, supplied []Options[T]) (Options[T], []D
} }
var diagnostics []Diagnostic var diagnostics []Diagnostic
definition := options.Definition definition := options.Definition
if definition.Validate != nil {
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsUnavailable, "application validation is not available in this release boundary"))
}
if definition.Secret != nil { if definition.Secret != nil {
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsUnavailable, "dynamic secret classification is not available in this release boundary")) diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsUnavailable, "dynamic secret classification is not available in this release boundary"))
} }
@@ -268,7 +272,7 @@ func checkVersion(rule *VersionRule, root *document.Node) []Diagnostic {
} }
state := &validationState{ state := &validationState{
active: true, errorCode: CodeVersionError, warningCode: CodeVersionWarning, active: true, errorCode: CodeVersionError, warningCode: CodeVersionWarning,
document: root, invalidPath: CodeVersionError, document: root,
} }
validation := &Validation{state: state} validation := &Validation{state: state}
err := rule.Check(version, validation) err := rule.Check(version, validation)

View File

@@ -222,7 +222,6 @@ func TestLoadRejectsUnavailableOptionsAndDisabledDirectives(t *testing.T) {
code Code code Code
}{ }{
{name: "multiple options", source: validSource(), options: []Options[config]{{}, {}}, code: CodeOptionsInvalid}, {name: "multiple options", source: validSource(), options: []Options[config]{{}, {}}, code: CodeOptionsInvalid},
{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: "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}, {name: "imports", source: validSource(), options: []Options[config]{{Imports: true}}, code: CodeOptionsUnavailable},
{name: "profile", source: validSource(), options: []Options[config]{{Profile: "testing"}}, code: CodeOptionsUnavailable}, {name: "profile", source: validSource(), options: []Options[config]{{Profile: "testing"}}, code: CodeOptionsUnavailable},

View File

@@ -92,6 +92,11 @@ const (
CodeResolveInvalidTarget Code = "resolve.invalid_target" CodeResolveInvalidTarget Code = "resolve.invalid_target"
CodeResolveInvalidValue Code = "resolve.invalid_value" CodeResolveInvalidValue Code = "resolve.invalid_value"
CodeResolveUnrecordedChange Code = "resolve.unrecorded_change" CodeResolveUnrecordedChange Code = "resolve.unrecorded_change"
CodeValidateError Code = "validate.error"
CodeValidateWarning Code = "validate.warning"
CodeValidateInvalidPath Code = "validate.invalid_path"
CodeValidateMutation Code = "validate.mutation"
) )
// Location identifies a position in a configuration source. Zero line and column // Location identifies a position in a configuration source. Zero line and column

View File

@@ -1,6 +1,10 @@
package configkit package configkit
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document" import (
"reflect"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
// Validation collects safe application diagnostics during a supported callback. // Validation collects safe application diagnostics during a supported callback.
// A callback must not retain the context or use it concurrently. // A callback must not retain the context or use it concurrently.
@@ -12,6 +16,8 @@ type validationState struct {
active bool active bool
errorCode Code errorCode Code
warningCode Code warningCode Code
invalidPath Code
schema *schemaDescriptor
document *document.Node document *document.Node
diagnostics []Diagnostic diagnostics []Diagnostic
} }
@@ -38,17 +44,49 @@ func (v *Validation) report(severity Severity, path Path, message string, relate
} }
diagnostic := Diagnostic{Code: code, Severity: severity, Path: path.clone(), Message: message} diagnostic := Diagnostic{Code: code, Severity: severity, Path: path.clone(), Message: message}
if !path.valid() { if !path.valid() {
invalidCode := v.state.invalidPath
if invalidCode == "" {
invalidCode = v.state.errorCode
}
diagnostic.Severity = SeverityError diagnostic.Severity = SeverityError
diagnostic.Code = v.state.errorCode diagnostic.Code = invalidCode
diagnostic.Path = Path{} diagnostic.Path = Path{}
diagnostic.Message = "application reported an invalid configuration path" diagnostic.Message = "application reported an invalid configuration path"
} else if v.state.schema != nil {
if _, ok := resolveSchemaPath(v.state.schema, path); !ok {
diagnostic.Severity = SeverityError
diagnostic.Code = v.state.invalidPath
diagnostic.Message = "application reported a path outside the configuration schema"
v.state.diagnostics = append(v.state.diagnostics, diagnostic)
return
}
if location, ok := nearestDocumentLocation(v.state.document, path); ok {
diagnostic.Primary = documentLocationPointer(location)
}
} else if location, ok := nearestDocumentLocation(v.state.document, path); ok { } else if location, ok := nearestDocumentLocation(v.state.document, path); ok {
diagnostic.Primary = documentLocationPointer(location) diagnostic.Primary = documentLocationPointer(location)
} }
for _, relatedPath := range related { for _, relatedPath := range related {
if !relatedPath.valid() { if !relatedPath.valid() {
invalidCode := v.state.invalidPath
if invalidCode == "" {
invalidCode = v.state.errorCode
}
v.state.diagnostics = append(v.state.diagnostics, Diagnostic{
Code: invalidCode, Severity: SeverityError,
Message: "application reported an invalid related configuration path",
})
continue continue
} }
if v.state.schema != nil {
if _, ok := resolveSchemaPath(v.state.schema, relatedPath); !ok {
v.state.diagnostics = append(v.state.diagnostics, Diagnostic{
Code: v.state.invalidPath, Severity: SeverityError, Path: relatedPath.clone(),
Message: "application reported a related path outside the configuration schema",
})
continue
}
}
if location, ok := nearestDocumentLocation(v.state.document, relatedPath); ok { if location, ok := nearestDocumentLocation(v.state.document, relatedPath); ok {
diagnostic.Related = append(diagnostic.Related, Location{ diagnostic.Related = append(diagnostic.Related, Location{
SourceID: location.SourceID, Line: location.Line, Column: location.Column, SourceID: location.SourceID, Line: location.Line, Column: location.Column,
@@ -58,6 +96,32 @@ func (v *Validation) report(severity Severity, path Path, message string, relate
v.state.diagnostics = append(v.state.diagnostics, diagnostic) v.state.diagnostics = append(v.state.diagnostics, diagnostic)
} }
func runValidation[T any](configuration *T, validator func(*T, *Validation), schema *schemaDescriptor, before, input *document.Node) []Diagnostic {
state := &validationState{
active: true, errorCode: CodeValidateError, warningCode: CodeValidateWarning,
invalidPath: CodeValidateInvalidPath, schema: schema, document: input,
}
context := &Validation{state: state}
validator(configuration, context)
state.active = false
after, issue := projectConfiguration(schema, reflect.ValueOf(*configuration), defaultProjectionLimits())
if issue != nil {
state.diagnostics = append(state.diagnostics, validationMutationDiagnostic(issue.path))
return canonicalDiagnostics(state.diagnostics)
}
for _, delta := range compareDocumentTrees(before, after) {
state.diagnostics = append(state.diagnostics, validationMutationDiagnostic(delta.path))
}
return canonicalDiagnostics(state.diagnostics)
}
func validationMutationDiagnostic(path Path) Diagnostic {
return Diagnostic{
Code: CodeValidateMutation, Severity: SeverityError, Path: path.clone(),
Message: "application validation mutated the configuration",
}
}
func nearestDocumentLocation(root *document.Node, path Path) (document.Location, bool) { func nearestDocumentLocation(root *document.Node, path Path) (document.Location, bool) {
if root == nil || !path.valid() { if root == nil || !path.valid() {
return document.Location{}, false return document.Location{}, false

214
validation_test.go Normal file
View File

@@ -0,0 +1,214 @@
package configkit
import (
"errors"
"reflect"
"testing"
)
func TestValidationCollectsSchemaAwareReportsAndNearestLocations(t *testing.T) {
t.Parallel()
type config struct {
Primary string `yaml:"primary"`
Missing string `yaml:"missing"`
Dynamic map[string]any `yaml:"dynamic"`
}
result, err := Load(Bytes("validation.yml", []byte("primary: configured\ndynamic: {present: 1}\n")), Options[config]{Definition: Definition[config]{
Validate: func(_ *config, validation *Validation) {
validation.Error(Keys("missing"), "is required", Keys("primary"))
validation.Error(Keys("dynamic", "missing"), "dynamic entry is required")
validation.Warning(Keys("primary"), "primary value is deprecated")
},
}})
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) != 3 {
t.Fatalf("diagnostics = %#v", diagnostics)
}
byPath := make(map[string]Diagnostic)
for _, diagnostic := range diagnostics {
byPath[diagnostic.Path.String()] = diagnostic
}
missing := byPath["/missing"]
if missing.Code != CodeValidateError || missing.Primary == nil || missing.Primary.Line != 1 || len(missing.Related) != 1 || missing.Related[0].Line != 1 {
t.Fatalf("missing diagnostic = %#v", missing)
}
dynamic := byPath["/dynamic/missing"]
if dynamic.Code != CodeValidateError || dynamic.Primary == nil || dynamic.Primary.Line != 2 {
t.Fatalf("dynamic diagnostic = %#v", dynamic)
}
if warning := byPath["/primary"]; warning.Code != CodeValidateWarning || warning.Severity != SeverityWarning {
t.Fatalf("warning = %#v", warning)
}
if len(loadError.Sources()) != 1 {
t.Fatalf("sources = %#v", loadError.Sources())
}
}
func TestValidationRejectsUnknownStructPaths(t *testing.T) {
t.Parallel()
type config struct {
Known string `yaml:"known"`
}
result, err := Load(Bytes("validation-path.yml", []byte("known: value\n")), Options[config]{Definition: Definition[config]{
Validate: func(_ *config, validation *Validation) {
validation.Error(Keys("unknown"), "must be configured")
},
}})
assertLoadDiagnostic(t, result, err, CodeValidateInvalidPath)
}
func TestValidationMutationFailsWithoutPublishingResult(t *testing.T) {
t.Parallel()
type config struct {
Value int `yaml:"value"`
Items map[string]int `yaml:"items"`
}
result, err := Load(Bytes("mutation.yml", []byte("value: 1\nitems: {one: 1}\n")), Options[config]{Definition: Definition[config]{
Validate: func(configuration *config, _ *Validation) {
configuration.Value = 2
configuration.Items["one"] = 2
},
}})
if result != nil || err == nil {
t.Fatalf("result, err = %#v, %v", result, err)
}
var loadError *LoadError
if !errors.As(err, &loadError) {
t.Fatal(err)
}
var paths []string
for _, diagnostic := range loadError.Diagnostics() {
if diagnostic.Code != CodeValidateMutation || diagnostic.Message != "application validation mutated the configuration" {
t.Fatalf("diagnostic = %#v", diagnostic)
}
paths = append(paths, diagnostic.Path.String())
}
if !reflect.DeepEqual(paths, []string{"/items/one", "/value"}) {
t.Fatalf("mutation paths = %#v", paths)
}
}
func TestHookOrderStopsAfterErrorsAndContinuesAfterWarnings(t *testing.T) {
t.Parallel()
type config struct {
Version string `yaml:"version"`
Value int `yaml:"value"`
}
t.Run("warnings continue", func(t *testing.T) {
var events []string
definition := Definition[config]{
Version: &VersionRule{Path: Keys("version"), Check: func(_ Version, validation *Validation) error {
events = append(events, "version")
validation.Warning(Keys("version"), "version warning")
return nil
}},
Defaults: func() config {
events = append(events, "defaults")
return config{Value: 1}
},
Resolve: func(_ *config, resolution *Resolution) error {
events = append(events, "resolve")
resolution.Warning(Keys("value"), "resolution warning")
return nil
},
Validate: func(_ *config, validation *Validation) {
events = append(events, "validate")
validation.Warning(Keys("value"), "validation warning")
},
}
result, err := Load(Bytes("order.yml", []byte("version: one\n")), Options[config]{Definition: definition})
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(events, []string{"version", "defaults", "resolve", "validate"}) || len(result.Diagnostics()) != 3 {
t.Fatalf("events = %#v, diagnostics = %#v", events, result.Diagnostics())
}
})
t.Run("version error stops later hooks", func(t *testing.T) {
var events []string
definition := Definition[config]{
Version: &VersionRule{Path: Keys("version"), Check: func(_ Version, validation *Validation) error {
events = append(events, "version")
validation.Error(Keys("version"), "unsupported version")
return nil
}},
Defaults: func() config { events = append(events, "defaults"); return config{} },
Resolve: func(_ *config, _ *Resolution) error { events = append(events, "resolve"); return nil },
Validate: func(_ *config, _ *Validation) { events = append(events, "validate") },
}
result, err := Load(Bytes("order-error.yml", []byte("version: one\n")), Options[config]{Definition: definition})
assertLoadDiagnostic(t, result, err, CodeVersionError)
if !reflect.DeepEqual(events, []string{"version"}) {
t.Fatalf("events = %#v", events)
}
})
t.Run("resolution error stops validation", func(t *testing.T) {
var validated bool
result, err := Load(Bytes("resolution-order.yml", []byte("value: 1\nversion: one\n")), Options[config]{Definition: Definition[config]{
Resolve: func(_ *config, resolution *Resolution) error {
resolution.Error(Keys("value"), "cannot resolve")
return nil
},
Validate: func(_ *config, _ *Validation) { validated = true },
}})
assertLoadDiagnostic(t, result, err, CodeResolveError)
if validated {
t.Fatal("validation ran after resolution error")
}
})
t.Run("decode error stops application hooks", func(t *testing.T) {
var resolved, validated bool
result, err := Load(Bytes("decode-order.yml", []byte("value: wrong\nversion: one\n")), Options[config]{Definition: Definition[config]{
Resolve: func(_ *config, _ *Resolution) error { resolved = true; return nil },
Validate: func(_ *config, _ *Validation) { validated = true },
}})
assertLoadDiagnostic(t, result, err, CodeDecodeType)
if resolved || validated {
t.Fatalf("later hooks ran: resolve=%v validate=%v", resolved, validated)
}
})
}
func TestCompleteSingleSourceLifecycleKeepsMetadataAgreement(t *testing.T) {
t.Parallel()
type config struct {
Input int `yaml:"input"`
Derived int `yaml:"derived"`
}
result, err := Load(Bytes("complete.yml", []byte("input: 3\n")), Options[config]{Definition: Definition[config]{
Defaults: func() config { return config{Derived: 1} },
Resolve: func(configuration *config, resolution *Resolution) error {
Default(resolution, Keys("derived"), &configuration.Derived, configuration.Input*2, "derived from input", Keys("input"))
return nil
},
Validate: func(configuration *config, validation *Validation) {
if configuration.Derived != 6 {
validation.Error(Keys("derived"), "derived value is inconsistent", Keys("input"))
}
},
}})
if err != nil {
t.Fatal(err)
}
input, _ := result.Metadata().Lookup(Keys("input"))
derived, _ := result.Metadata().Lookup(Keys("derived"))
history := result.Metadata().History(Keys("derived"))
if result.Config != (config{Input: 3, Derived: 6}) || !input.Explicit || input.Origin.Layer != LayerBase || derived.Explicit || derived.Origin.Operation != ChangeDerivedDefault || history[len(history)-1].Dependencies[0].String() != "/input" {
t.Fatalf("config/metadata = %#v / %#v / %#v / %#v", result.Config, input, derived, history)
}
}