Complete single-source semantic validation
This commit is contained in:
@@ -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
|
||||
source (or explicit defaults-only input), applies typed defaults, optionally checks
|
||||
an application schema version, and returns detached metadata and safe diagnostics.
|
||||
Applications may perform audited defaulting and normalization through a resolver.
|
||||
Composition, profiles, semantic validation, overrides, secrets, and rendering are
|
||||
still under development.
|
||||
Applications may perform audited defaulting and normalization through a resolver,
|
||||
then report domain errors and warnings through read-only semantic validation.
|
||||
Composition, profiles, overrides, secrets, and rendering are still under development.
|
||||
|
||||
```go
|
||||
type Config struct {
|
||||
|
||||
4
doc.go
4
doc.go
@@ -3,8 +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, recorded application resolution, and structured load errors.
|
||||
// Composition, profiles, semantic validation, overrides, secrets, and effective
|
||||
// diagnostics, recorded application resolution, read-only semantic validation, and
|
||||
// structured load errors. Composition, profiles, overrides, secrets, and effective
|
||||
// rendering are not available yet. The configuration contract is documented in
|
||||
// docs/config.md.
|
||||
package configkit
|
||||
|
||||
@@ -38,7 +38,8 @@ The current lifecycle is:
|
||||
```text
|
||||
validate options → parse one source → reject reserved directives → build schema
|
||||
→ 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
|
||||
@@ -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
|
||||
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, semantic validator
|
||||
callbacks, and dynamic secret classification return an explicit
|
||||
The current `Options` boundary accepts a defaults factory, `VersionRule`, resolver,
|
||||
and semantic validator. Imports, active profiles, filesystem context, overrides,
|
||||
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
|
||||
combinations. Input containing a reserved `_configkit` mapping key is rejected while
|
||||
imports are disabled.
|
||||
|
||||
Every source, options, schema, version, defaults, or decode failure returns a nil
|
||||
result and a `*LoadError`. Successful results contain caller-owned `Config`, detached
|
||||
Every source, options, schema, version, defaults, decode, resolution, or validation
|
||||
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
|
||||
the returned configuration or inspection records cannot alter ConfigKit's private
|
||||
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
|
||||
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
|
||||
|
||||
The private YAML boundary currently accepts exactly one nonempty document whose root
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
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,
|
||||
recorded application resolution, and metadata. The
|
||||
recorded application resolution, semantic validation, and metadata. The
|
||||
[component overview](internal/overview.md) owns the implementation inventory.
|
||||
|
||||
## Initial orientation
|
||||
|
||||
@@ -203,6 +203,16 @@ intent, coalesced repeated writes, private before/after snapshots, deterministic
|
||||
record ordering, and safe hook diagnostics. Semantic validation remains assigned to
|
||||
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
|
||||
|
||||
Assigned to the explicit bootstrap operation:
|
||||
|
||||
@@ -22,11 +22,13 @@ module and package documentation boundary. Its implemented public primitives are
|
||||
`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.
|
||||
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,
|
||||
single-source lifecycle, version checking, recorded resolution, metadata, and
|
||||
ownership behavior.
|
||||
single-source lifecycle, version checking, recorded resolution, semantic validation,
|
||||
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–8 are complete and later stages are
|
||||
Status: implementation in progress; Stages 1–9 are complete and later stages are
|
||||
pending.
|
||||
|
||||
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
|
||||
|
||||
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)
|
||||
and [schema reports](initial.md#schema-checking-and-secret-classification).
|
||||
|
||||
@@ -582,10 +582,10 @@ 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
|
||||
own domain requirements; checking live S3 buckets or starting runtime clients
|
||||
is outside this lifecycle. Do not run later hooks on an invalid typed value.
|
||||
Application semantic validation is implemented. The
|
||||
[current semantic-validation contract](../config.md#semantic-validation) owns
|
||||
schema-aware reporting, nearest locations, warning/error behavior, mutation audit,
|
||||
callback lifetime, and application/runtime responsibility boundaries.
|
||||
|
||||
## Provenance, diagnostics, and redaction
|
||||
|
||||
@@ -795,9 +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.
|
||||
The resolution and semantic-validation declarations and behavior below are
|
||||
implemented. The [current recorded-resolution contract](../config.md#recorded-application-resolution)
|
||||
and [semantic-validation contract](../config.md#semantic-validation) are
|
||||
authoritative.
|
||||
|
||||
```go
|
||||
func (r *Resolution) IsExplicit(path Path) bool
|
||||
|
||||
96
example_test.go
Normal file
96
example_test.go
Normal 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
12
load.go
@@ -157,6 +157,13 @@ func Load[T any](source Source, supplied ...Options[T]) (*Result[T], error) {
|
||||
resolutionSnapshots = resolved.snapshots
|
||||
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]{
|
||||
Config: configuration,
|
||||
metadata: metadata,
|
||||
@@ -177,9 +184,6 @@ func selectOptions[T any](source Source, supplied []Options[T]) (Options[T], []D
|
||||
}
|
||||
var diagnostics []Diagnostic
|
||||
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 {
|
||||
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{
|
||||
active: true, errorCode: CodeVersionError, warningCode: CodeVersionWarning,
|
||||
document: root,
|
||||
invalidPath: CodeVersionError, document: root,
|
||||
}
|
||||
validation := &Validation{state: state}
|
||||
err := rule.Check(version, validation)
|
||||
|
||||
@@ -222,7 +222,6 @@ func TestLoadRejectsUnavailableOptionsAndDisabledDirectives(t *testing.T) {
|
||||
code Code
|
||||
}{
|
||||
{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: "imports", source: validSource(), options: []Options[config]{{Imports: true}}, code: CodeOptionsUnavailable},
|
||||
{name: "profile", source: validSource(), options: []Options[config]{{Profile: "testing"}}, code: CodeOptionsUnavailable},
|
||||
|
||||
@@ -92,6 +92,11 @@ const (
|
||||
CodeResolveInvalidTarget Code = "resolve.invalid_target"
|
||||
CodeResolveInvalidValue Code = "resolve.invalid_value"
|
||||
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
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
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.
|
||||
// A callback must not retain the context or use it concurrently.
|
||||
@@ -12,6 +16,8 @@ type validationState struct {
|
||||
active bool
|
||||
errorCode Code
|
||||
warningCode Code
|
||||
invalidPath Code
|
||||
schema *schemaDescriptor
|
||||
document *document.Node
|
||||
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}
|
||||
if !path.valid() {
|
||||
invalidCode := v.state.invalidPath
|
||||
if invalidCode == "" {
|
||||
invalidCode = v.state.errorCode
|
||||
}
|
||||
diagnostic.Severity = SeverityError
|
||||
diagnostic.Code = v.state.errorCode
|
||||
diagnostic.Code = invalidCode
|
||||
diagnostic.Path = 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 {
|
||||
diagnostic.Primary = documentLocationPointer(location)
|
||||
}
|
||||
for _, relatedPath := range related {
|
||||
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
|
||||
}
|
||||
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 {
|
||||
diagnostic.Related = append(diagnostic.Related, Location{
|
||||
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)
|
||||
}
|
||||
|
||||
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) {
|
||||
if root == nil || !path.valid() {
|
||||
return document.Location{}, false
|
||||
|
||||
214
validation_test.go
Normal file
214
validation_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user