Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 8a62b42503 | |||
| 244104f84f | |||
| fd8a68d268 | |||
| 8702121fa4 | |||
| 6eb40c5492 | |||
| f5bbc2be5d |
56
decode.go
56
decode.go
@@ -34,7 +34,15 @@ type decodedConfiguration struct {
|
|||||||
final *document.Node
|
final *document.Node
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type decodeContext struct {
|
||||||
|
versionPath *Path
|
||||||
|
}
|
||||||
|
|
||||||
func decodeConfiguration(schema *schemaDescriptor, defaults reflect.Value, input *document.Node) (decodedConfiguration, []decodeIssue) {
|
func decodeConfiguration(schema *schemaDescriptor, defaults reflect.Value, input *document.Node) (decodedConfiguration, []decodeIssue) {
|
||||||
|
return decodeConfigurationWithContext(schema, defaults, input, decodeContext{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeConfigurationWithContext(schema *schemaDescriptor, defaults reflect.Value, input *document.Node, context decodeContext) (decodedConfiguration, []decodeIssue) {
|
||||||
if schema == nil || schema.root == nil || !defaults.IsValid() || defaults.Type() != schema.root.typeOf {
|
if schema == nil || schema.root == nil || !defaults.IsValid() || defaults.Type() != schema.root.typeOf {
|
||||||
return decodedConfiguration{}, []decodeIssue{{code: CodeSchemaUnsupportedType, message: "configuration defaults do not match the schema"}}
|
return decodedConfiguration{}, []decodeIssue{{code: CodeSchemaUnsupportedType, message: "configuration defaults do not match the schema"}}
|
||||||
}
|
}
|
||||||
@@ -50,7 +58,7 @@ func decodeConfiguration(schema *schemaDescriptor, defaults reflect.Value, input
|
|||||||
presence := make(map[string]inputPresence)
|
presence := make(map[string]inputPresence)
|
||||||
if input != nil {
|
if input != nil {
|
||||||
collectPresence(input, Path{}, presence)
|
collectPresence(input, Path{}, presence)
|
||||||
decoded, issues := decodeValue(input, working, schema.root, Path{}, schema)
|
decoded, issues := decodeValue(input, working, schema.root, Path{}, schema, context)
|
||||||
if len(issues) != 0 {
|
if len(issues) != 0 {
|
||||||
return decodedConfiguration{}, issues
|
return decodedConfiguration{}, issues
|
||||||
}
|
}
|
||||||
@@ -71,7 +79,7 @@ func decodeConfiguration(schema *schemaDescriptor, defaults reflect.Value, input
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func decodeValue(node *document.Node, base reflect.Value, descriptor *typeDescriptor, path Path, schema *schemaDescriptor) (reflect.Value, []decodeIssue) {
|
func decodeValue(node *document.Node, base reflect.Value, descriptor *typeDescriptor, path Path, schema *schemaDescriptor, context decodeContext) (reflect.Value, []decodeIssue) {
|
||||||
if node.Kind == document.Null {
|
if node.Kind == document.Null {
|
||||||
switch descriptor.kind {
|
switch descriptor.kind {
|
||||||
case descriptorPointer, descriptorMap, descriptorSlice, descriptorDynamic:
|
case descriptorPointer, descriptorMap, descriptorSlice, descriptorDynamic:
|
||||||
@@ -89,7 +97,7 @@ func decodeValue(node *document.Node, base reflect.Value, descriptor *typeDescri
|
|||||||
} else {
|
} else {
|
||||||
elementBase = base.Elem()
|
elementBase = base.Elem()
|
||||||
}
|
}
|
||||||
decoded, issues := decodeValue(node, elementBase, descriptor.element, path, schema)
|
decoded, issues := decodeValue(node, elementBase, descriptor.element, path, schema, context)
|
||||||
if len(issues) != 0 {
|
if len(issues) != 0 {
|
||||||
return reflect.Value{}, issues
|
return reflect.Value{}, issues
|
||||||
}
|
}
|
||||||
@@ -100,17 +108,17 @@ func decodeValue(node *document.Node, base reflect.Value, descriptor *typeDescri
|
|||||||
|
|
||||||
switch descriptor.kind {
|
switch descriptor.kind {
|
||||||
case descriptorStruct:
|
case descriptorStruct:
|
||||||
return decodeStruct(node, base, descriptor, path, schema)
|
return decodeStruct(node, base, descriptor, path, schema, context)
|
||||||
case descriptorMap:
|
case descriptorMap:
|
||||||
return decodeMap(node, base, descriptor, path, schema)
|
return decodeMap(node, base, descriptor, path, schema, context)
|
||||||
case descriptorSlice:
|
case descriptorSlice:
|
||||||
return decodeSlice(node, descriptor, path, schema)
|
return decodeSlice(node, descriptor, path, schema, context)
|
||||||
case descriptorArray:
|
case descriptorArray:
|
||||||
return decodeArray(node, descriptor, path, schema)
|
return decodeArray(node, descriptor, path, schema, context)
|
||||||
case descriptorDynamic:
|
case descriptorDynamic:
|
||||||
return decodeDynamic(node, base, path, schema)
|
return decodeDynamic(node, base, path, schema, context)
|
||||||
case descriptorString:
|
case descriptorString:
|
||||||
if node.Kind != document.String {
|
if node.Kind != document.String && !(pathMatches(context.versionPath, path) && isIntegerVersion(node)) {
|
||||||
return typeMismatch(path, node, "string")
|
return typeMismatch(path, node, "string")
|
||||||
}
|
}
|
||||||
result := reflect.New(descriptor.typeOf).Elem()
|
result := reflect.New(descriptor.typeOf).Elem()
|
||||||
@@ -187,7 +195,7 @@ func decodeValue(node *document.Node, base reflect.Value, descriptor *typeDescri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func decodeStruct(node *document.Node, base reflect.Value, descriptor *typeDescriptor, path Path, schema *schemaDescriptor) (reflect.Value, []decodeIssue) {
|
func decodeStruct(node *document.Node, base reflect.Value, descriptor *typeDescriptor, path Path, schema *schemaDescriptor, context decodeContext) (reflect.Value, []decodeIssue) {
|
||||||
if node.Kind != document.Mapping {
|
if node.Kind != document.Mapping {
|
||||||
return typeMismatch(path, node, "mapping")
|
return typeMismatch(path, node, "mapping")
|
||||||
}
|
}
|
||||||
@@ -203,7 +211,7 @@ func decodeStruct(node *document.Node, base reflect.Value, descriptor *typeDescr
|
|||||||
issues = append(issues, decodeIssue{code: CodeSchemaUnsupportedType, path: childPath, message: "configuration field could not be addressed", location: entry.ValueLocation})
|
issues = append(issues, decodeIssue{code: CodeSchemaUnsupportedType, path: childPath, message: "configuration field could not be addressed", location: entry.ValueLocation})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
decoded, fieldIssues := decodeValue(entry.Value, target, field.descriptor, childPath, schema)
|
decoded, fieldIssues := decodeValue(entry.Value, target, field.descriptor, childPath, schema, context)
|
||||||
if len(fieldIssues) != 0 {
|
if len(fieldIssues) != 0 {
|
||||||
issues = append(issues, fieldIssues...)
|
issues = append(issues, fieldIssues...)
|
||||||
continue
|
continue
|
||||||
@@ -212,7 +220,7 @@ func decodeStruct(node *document.Node, base reflect.Value, descriptor *typeDescr
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if descriptor.inlineMap != nil {
|
if descriptor.inlineMap != nil {
|
||||||
if issue := decodeInlineMapEntry(result, *descriptor.inlineMap, entry, childPath, schema); issue != nil {
|
if issue := decodeInlineMapEntry(result, *descriptor.inlineMap, entry, childPath, schema, context); issue != nil {
|
||||||
issues = append(issues, issue...)
|
issues = append(issues, issue...)
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
@@ -222,7 +230,7 @@ func decodeStruct(node *document.Node, base reflect.Value, descriptor *typeDescr
|
|||||||
return result, issues
|
return result, issues
|
||||||
}
|
}
|
||||||
|
|
||||||
func decodeInlineMapEntry(target reflect.Value, field fieldDescriptor, entry document.Entry, path Path, schema *schemaDescriptor) []decodeIssue {
|
func decodeInlineMapEntry(target reflect.Value, field fieldDescriptor, entry document.Entry, path Path, schema *schemaDescriptor, context decodeContext) []decodeIssue {
|
||||||
mapValue, ok := fieldForDecode(target, field.index, true)
|
mapValue, ok := fieldForDecode(target, field.index, true)
|
||||||
if !ok || mapValue.Kind() != reflect.Map {
|
if !ok || mapValue.Kind() != reflect.Map {
|
||||||
return []decodeIssue{{code: CodeSchemaInline, path: path, message: "inline map could not be addressed", location: entry.KeyLocation}}
|
return []decodeIssue{{code: CodeSchemaInline, path: path, message: "inline map could not be addressed", location: entry.KeyLocation}}
|
||||||
@@ -236,14 +244,14 @@ func decodeInlineMapEntry(target reflect.Value, field fieldDescriptor, entry doc
|
|||||||
if !base.IsValid() {
|
if !base.IsValid() {
|
||||||
base = reflect.Zero(mapValue.Type().Elem())
|
base = reflect.Zero(mapValue.Type().Elem())
|
||||||
}
|
}
|
||||||
decoded, issues := decodeValue(entry.Value, base, field.descriptor.element, path, schema)
|
decoded, issues := decodeValue(entry.Value, base, field.descriptor.element, path, schema, context)
|
||||||
if len(issues) == 0 {
|
if len(issues) == 0 {
|
||||||
mapValue.SetMapIndex(key, decoded)
|
mapValue.SetMapIndex(key, decoded)
|
||||||
}
|
}
|
||||||
return issues
|
return issues
|
||||||
}
|
}
|
||||||
|
|
||||||
func decodeMap(node *document.Node, base reflect.Value, descriptor *typeDescriptor, path Path, schema *schemaDescriptor) (reflect.Value, []decodeIssue) {
|
func decodeMap(node *document.Node, base reflect.Value, descriptor *typeDescriptor, path Path, schema *schemaDescriptor, context decodeContext) (reflect.Value, []decodeIssue) {
|
||||||
if node.Kind != document.Mapping {
|
if node.Kind != document.Mapping {
|
||||||
return typeMismatch(path, node, "mapping")
|
return typeMismatch(path, node, "mapping")
|
||||||
}
|
}
|
||||||
@@ -259,7 +267,7 @@ func decodeMap(node *document.Node, base reflect.Value, descriptor *typeDescript
|
|||||||
if !entryBase.IsValid() {
|
if !entryBase.IsValid() {
|
||||||
entryBase = reflect.Zero(descriptor.typeOf.Elem())
|
entryBase = reflect.Zero(descriptor.typeOf.Elem())
|
||||||
}
|
}
|
||||||
decoded, entryIssues := decodeValue(entry.Value, entryBase, descriptor.element, path.Key(entry.Key), schema)
|
decoded, entryIssues := decodeValue(entry.Value, entryBase, descriptor.element, path.Key(entry.Key), schema, context)
|
||||||
if len(entryIssues) != 0 {
|
if len(entryIssues) != 0 {
|
||||||
issues = append(issues, entryIssues...)
|
issues = append(issues, entryIssues...)
|
||||||
continue
|
continue
|
||||||
@@ -269,14 +277,14 @@ func decodeMap(node *document.Node, base reflect.Value, descriptor *typeDescript
|
|||||||
return result, issues
|
return result, issues
|
||||||
}
|
}
|
||||||
|
|
||||||
func decodeSlice(node *document.Node, descriptor *typeDescriptor, path Path, schema *schemaDescriptor) (reflect.Value, []decodeIssue) {
|
func decodeSlice(node *document.Node, descriptor *typeDescriptor, path Path, schema *schemaDescriptor, context decodeContext) (reflect.Value, []decodeIssue) {
|
||||||
if node.Kind != document.Sequence {
|
if node.Kind != document.Sequence {
|
||||||
return typeMismatch(path, node, "sequence")
|
return typeMismatch(path, node, "sequence")
|
||||||
}
|
}
|
||||||
result := reflect.MakeSlice(descriptor.typeOf, len(node.Sequence), len(node.Sequence))
|
result := reflect.MakeSlice(descriptor.typeOf, len(node.Sequence), len(node.Sequence))
|
||||||
var issues []decodeIssue
|
var issues []decodeIssue
|
||||||
for i, child := range node.Sequence {
|
for i, child := range node.Sequence {
|
||||||
decoded, childIssues := decodeValue(child, reflect.Zero(descriptor.typeOf.Elem()), descriptor.element, path.Index(i), schema)
|
decoded, childIssues := decodeValue(child, reflect.Zero(descriptor.typeOf.Elem()), descriptor.element, path.Index(i), schema, context)
|
||||||
if len(childIssues) != 0 {
|
if len(childIssues) != 0 {
|
||||||
issues = append(issues, childIssues...)
|
issues = append(issues, childIssues...)
|
||||||
continue
|
continue
|
||||||
@@ -286,7 +294,7 @@ func decodeSlice(node *document.Node, descriptor *typeDescriptor, path Path, sch
|
|||||||
return result, issues
|
return result, issues
|
||||||
}
|
}
|
||||||
|
|
||||||
func decodeArray(node *document.Node, descriptor *typeDescriptor, path Path, schema *schemaDescriptor) (reflect.Value, []decodeIssue) {
|
func decodeArray(node *document.Node, descriptor *typeDescriptor, path Path, schema *schemaDescriptor, context decodeContext) (reflect.Value, []decodeIssue) {
|
||||||
if node.Kind != document.Sequence {
|
if node.Kind != document.Sequence {
|
||||||
return typeMismatch(path, node, "sequence")
|
return typeMismatch(path, node, "sequence")
|
||||||
}
|
}
|
||||||
@@ -296,7 +304,7 @@ func decodeArray(node *document.Node, descriptor *typeDescriptor, path Path, sch
|
|||||||
result := reflect.New(descriptor.typeOf).Elem()
|
result := reflect.New(descriptor.typeOf).Elem()
|
||||||
var issues []decodeIssue
|
var issues []decodeIssue
|
||||||
for i, child := range node.Sequence {
|
for i, child := range node.Sequence {
|
||||||
decoded, childIssues := decodeValue(child, reflect.Zero(descriptor.typeOf.Elem()), descriptor.element, path.Index(i), schema)
|
decoded, childIssues := decodeValue(child, reflect.Zero(descriptor.typeOf.Elem()), descriptor.element, path.Index(i), schema, context)
|
||||||
if len(childIssues) != 0 {
|
if len(childIssues) != 0 {
|
||||||
issues = append(issues, childIssues...)
|
issues = append(issues, childIssues...)
|
||||||
continue
|
continue
|
||||||
@@ -306,13 +314,13 @@ func decodeArray(node *document.Node, descriptor *typeDescriptor, path Path, sch
|
|||||||
return result, issues
|
return result, issues
|
||||||
}
|
}
|
||||||
|
|
||||||
func decodeDynamic(node *document.Node, base reflect.Value, path Path, schema *schemaDescriptor) (reflect.Value, []decodeIssue) {
|
func decodeDynamic(node *document.Node, base reflect.Value, path Path, schema *schemaDescriptor, context decodeContext) (reflect.Value, []decodeIssue) {
|
||||||
if node.Kind == document.Mapping && base.IsValid() && !base.IsNil() {
|
if node.Kind == document.Mapping && base.IsValid() && !base.IsNil() {
|
||||||
concrete := base.Elem()
|
concrete := base.Elem()
|
||||||
operation := valueOperation{schema: schema}
|
operation := valueOperation{schema: schema}
|
||||||
descriptor, issue := operation.dynamicDescriptor(concrete.Type(), path)
|
descriptor, issue := operation.dynamicDescriptor(concrete.Type(), path)
|
||||||
if issue == nil && acceptsMapping(descriptor) {
|
if issue == nil && acceptsMapping(descriptor) {
|
||||||
decoded, issues := decodeValue(node, concrete, descriptor, path, schema)
|
decoded, issues := decodeValue(node, concrete, descriptor, path, schema, context)
|
||||||
if len(issues) != 0 {
|
if len(issues) != 0 {
|
||||||
return reflect.Value{}, issues
|
return reflect.Value{}, issues
|
||||||
}
|
}
|
||||||
@@ -347,7 +355,7 @@ func decodeDynamic(node *document.Node, base reflect.Value, path Path, schema *s
|
|||||||
sequence := make([]any, len(node.Sequence))
|
sequence := make([]any, len(node.Sequence))
|
||||||
var issues []decodeIssue
|
var issues []decodeIssue
|
||||||
for i, child := range node.Sequence {
|
for i, child := range node.Sequence {
|
||||||
decoded, childIssues := decodeDynamic(child, reflect.Zero(reflect.TypeFor[any]()), path.Index(i), schema)
|
decoded, childIssues := decodeDynamic(child, reflect.Zero(reflect.TypeFor[any]()), path.Index(i), schema, context)
|
||||||
if len(childIssues) != 0 {
|
if len(childIssues) != 0 {
|
||||||
issues = append(issues, childIssues...)
|
issues = append(issues, childIssues...)
|
||||||
continue
|
continue
|
||||||
@@ -362,7 +370,7 @@ func decodeDynamic(node *document.Node, base reflect.Value, path Path, schema *s
|
|||||||
mapping := make(map[string]any, len(node.Mapping))
|
mapping := make(map[string]any, len(node.Mapping))
|
||||||
var issues []decodeIssue
|
var issues []decodeIssue
|
||||||
for _, entry := range node.Mapping {
|
for _, entry := range node.Mapping {
|
||||||
decoded, childIssues := decodeDynamic(entry.Value, reflect.Zero(reflect.TypeFor[any]()), path.Key(entry.Key), schema)
|
decoded, childIssues := decodeDynamic(entry.Value, reflect.Zero(reflect.TypeFor[any]()), path.Key(entry.Key), schema, context)
|
||||||
if len(childIssues) != 0 {
|
if len(childIssues) != 0 {
|
||||||
issues = append(issues, childIssues...)
|
issues = append(issues, childIssues...)
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -74,9 +74,27 @@ frozen redacted projection.
|
|||||||
### Application schema versions
|
### Application schema versions
|
||||||
|
|
||||||
`VersionRule` declares a semantic path and non-nil checker. Before defaults or typed
|
`VersionRule` declares a semantic path and non-nil checker. Before defaults or typed
|
||||||
decoding, the checker receives a `Version` preserving absent, explicit null, or
|
decoding, the checker receives a `Version` preserving absent, explicit null, string,
|
||||||
string input. Any other input kind is a `version.type` error and the checker does not
|
or strict decimal-integer input. Strings and integers supply their exact spelling in
|
||||||
run. ConfigKit does not interpret the text or impose a universal version path.
|
`Version.Text`; ConfigKit does not impose a machine-integer range. Fractional or
|
||||||
|
exponent numbers, booleans, mappings, and sequences produce `version.type`, and the
|
||||||
|
checker does not run. ConfigKit does not interpret the text or impose a universal
|
||||||
|
version path.
|
||||||
|
|
||||||
|
Integer versions use the strict grammar `^-?(0|[1-9][0-9]*)$`. Their spelling is
|
||||||
|
preserved exactly, including `-0`, and application policy decides which identifiers
|
||||||
|
are supported. A numeric version has no built-in `int64` range limit. Existing parser
|
||||||
|
policy still rejects leading zeroes, leading plus signs, hexadecimal notation, and
|
||||||
|
other non-JSON scalar forms before version checking.
|
||||||
|
|
||||||
|
At exactly the configured semantic path, accepted integer input can decode into a
|
||||||
|
plain Go string or named string without a text codec, including pointer wrappers.
|
||||||
|
This is a load-local exception: siblings and unrelated fields, dynamic values,
|
||||||
|
integer and float destinations, durations, `Secret`, and text codecs retain ordinary
|
||||||
|
strict decoding. The retained source and history stay numeric while typed projection
|
||||||
|
and effective YAML contain the resulting string; explanation records this through
|
||||||
|
the normal decoding reconciliation event. Fragment, profile, and CLI ownership
|
||||||
|
protections apply before this conversion.
|
||||||
|
|
||||||
The checker reports safe, value-free errors and warnings through `Validation.Error`
|
The checker reports safe, value-free errors and warnings through `Validation.Error`
|
||||||
and `Validation.Warning`; ConfigKit resolves primary and related paths to exact or
|
and `Validation.Warning`; ConfigKit resolves primary and related paths to exact or
|
||||||
@@ -613,8 +631,10 @@ and redacted inspection.
|
|||||||
|
|
||||||
Schema path traversal uses these same bindings. Explicit key segments address
|
Schema path traversal uses these same bindings. Explicit key segments address
|
||||||
mappings, explicit index segments address sequences, and parsed numeric pointer
|
mappings, explicit index segments address sequences, and parsed numeric pointer
|
||||||
tokens become indices only in a sequence. Sequence indices cannot have leading
|
tokens become indices only in a sequence. Sequence indices must use canonical
|
||||||
zeroes; fixed-array indices are bounds checked. Dynamic `any` and inline-map entries
|
nonnegative decimal spelling: no signs or leading zeroes except `0` itself.
|
||||||
|
Tokens such as `+0` and `-0` remain literal keys in mappings but do not identify
|
||||||
|
sequence elements. Fixed-array indices are bounds checked. Dynamic `any` and inline-map entries
|
||||||
remain schema-valid for application-defined descendants, but dynamic values cannot
|
remain schema-valid for application-defined descendants, but dynamic values cannot
|
||||||
introduce typed secret references.
|
introduce typed secret references.
|
||||||
|
|
||||||
@@ -668,7 +688,11 @@ keys but preserve the strict schema of their values; inline maps accept unmatche
|
|||||||
keys according to their element descriptor. Multiple independent field failures are
|
keys according to their element descriptor. Multiple independent field failures are
|
||||||
collected without publishing the partially decoded object.
|
collected without publishing the partially decoded object.
|
||||||
|
|
||||||
Booleans and strings are not coerced. Integer destinations require integer syntax
|
Booleans and strings are not generally coerced. At exactly a configured
|
||||||
|
`VersionRule.Path`, strict decimal-integer input may decode into a plain `string`, a
|
||||||
|
named string without a text codec, or pointer wrappers around those types. The
|
||||||
|
exception is load-local and does not apply to unrelated fields or loads without a
|
||||||
|
version rule. Integer destinations require integer syntax
|
||||||
and a value in the destination type's range. Float destinations accept finite JSON
|
and a value in the destination type's range. Float destinations accept finite JSON
|
||||||
numbers representable by their destination precision. Dynamic numbers become
|
numbers representable by their destination precision. Dynamic numbers become
|
||||||
`int64` for integer syntax and `float64` for fractional or exponent syntax, with
|
`int64` for integer syntax and `float64` for fractional or exponent syntax, with
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ four sanitized shapes using only ConfigKit and the Go standard library:
|
|||||||
| Shape | Demonstrated fit | Application code deliberately retained |
|
| Shape | Demonstrated fit | Application code deliberately retained |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| Narratio-like | Fresh defaults, explicit partial imports, artifact maps, recorded inherited defaults, normalization, and source ownership | Artifact meaning and the separate `model_profile` domain field |
|
| Narratio-like | Fresh defaults, explicit partial imports, artifact maps, recorded inherited defaults, normalization, and source ownership | Artifact meaning and the separate `model_profile` domain field |
|
||||||
| Notarius-like | Canonical object bindings, a string schema version, strict dynamic options, and `int64` dynamic integers | Catalog selection, allowed option keys, range-checked conversion to runtime `int` |
|
| Notarius-like | Canonical object bindings, a string-backed schema version accepting string or integer YAML, strict dynamic options, and `int64` dynamic integers | Catalog selection, allowed option keys, range-checked conversion to runtime `int` |
|
||||||
| WeatherReporter-like | Defaults-only loading, report maps, canonical module objects, typed option unions, explicit CLI records, and typed secret startup | Report/module compatibility, rejection of explicitly empty domain lists, CLI parsing, and startup ordering |
|
| WeatherReporter-like | Defaults-only loading, report maps, canonical module objects, typed option unions, explicit CLI records, and typed secret startup | Report/module compatibility, rejection of explicitly empty domain lists, CLI parsing, and startup ordering |
|
||||||
| Seriatim-like | Independent strict ordered speaker and autocorrect rule files | First-match and ordered replacement algorithms; no central application configuration file |
|
| Seriatim-like | Independent strict ordered speaker and autocorrect rule files | First-match and ordered replacement algorithms; no central application configuration file |
|
||||||
|
|
||||||
@@ -20,6 +20,11 @@ The YAML under [`testdata`](../../examples/consumers/testdata) is loaded by the
|
|||||||
executable examples and ordinary tests. It contains no private application data,
|
executable examples and ordinary tests. It contains no private application data,
|
||||||
credentials, network calls, or dependency on a sibling checkout.
|
credentials, network calls, or dependency on a sibling checkout.
|
||||||
|
|
||||||
|
The Notarius-shaped fixtures deliberately load both `version: 4` and
|
||||||
|
`version: "4"` into the same string-backed application field. Its version checker
|
||||||
|
still owns the supported identifier; fractional numeric input and unsupported
|
||||||
|
integer identifiers remain negative examples.
|
||||||
|
|
||||||
## Happy path
|
## Happy path
|
||||||
|
|
||||||
An application defines ordinary types and passes one explicit source. Optional
|
An application defines ordinary types and passes one explicit source. Optional
|
||||||
@@ -61,8 +66,9 @@ The examples preserve domain meaning while adopting one configuration convention
|
|||||||
domain rule. Loaded and constructed values use the same validator.
|
domain rule. Loaded and constructed values use the same validator.
|
||||||
- Canonical module and object bindings replace string shorthand. Unknown fields and
|
- Canonical module and object bindings replace string shorthand. Unknown fields and
|
||||||
incompatible option shapes fail at their semantic paths.
|
incompatible option shapes fail at their semantic paths.
|
||||||
- Schema versions are strings. Consumers with integer versions migrate their files
|
- Schema versions remain string-backed in application types. A configured
|
||||||
instead of enabling another scalar convention.
|
`VersionRule` accepts string or strict decimal-integer YAML at its exact path and
|
||||||
|
supplies the spelling to both the checker and a plain string destination.
|
||||||
- Integers inside dynamic options are portable `int64` values. Runtime `int`
|
- Integers inside dynamic options are portable `int64` values. Runtime `int`
|
||||||
conversion is explicit and range checked.
|
conversion is explicit and range checked.
|
||||||
- Strict YAML intentionally rejects duplicate keys, aliases, merge keys, unsupported
|
- Strict YAML intentionally rejects duplicate keys, aliases, merge keys, unsupported
|
||||||
|
|||||||
@@ -42,6 +42,10 @@ git show cb5b435:docs/roadmap/remediation.md
|
|||||||
Release-candidate, publication, and verification work follows the operational
|
Release-candidate, publication, and verification work follows the operational
|
||||||
[release procedure](release.md).
|
[release procedure](release.md).
|
||||||
|
|
||||||
|
The completed string-and-integer schema-version implementation and remediation
|
||||||
|
records are preserved in Git history at `244104f`. The
|
||||||
|
[configuration contract](config.md#application-schema-versions) owns current behavior.
|
||||||
|
|
||||||
## Baseline validation
|
## Baseline validation
|
||||||
|
|
||||||
Install Go 1.25.0 or a supported patched/newer Go toolchain. Acquire declared
|
Install Go 1.25.0 or a supported patched/newer Go toolchain. Acquire declared
|
||||||
|
|||||||
@@ -227,5 +227,11 @@ minor-version increment. The release note must explain the renamed directive,
|
|||||||
the lack of a legacy alias, and the newly reserved application key. This exception
|
the lack of a legacy alias, and the newly reserved application key. This exception
|
||||||
does not apply to subsequent releases or waive any validation or publication gate.
|
does not apply to subsequent releases or waive any validation or publication gate.
|
||||||
|
|
||||||
|
For v0.2.0 only, the maintainer accepted the successful native macOS check at
|
||||||
|
`244104f` for the final candidate, provided subsequent changes are documentation
|
||||||
|
only. The final candidate still requires the complete Linux release checker.
|
||||||
|
This exception waives only repeating the macOS check after those documentation
|
||||||
|
changes; it does not waive other publication or verification gates.
|
||||||
|
|
||||||
Candidate selection, publication, and post-publication verification remain
|
Candidate selection, publication, and post-publication verification remain
|
||||||
mandatory steps in the [release procedure](../release.md).
|
mandatory steps in the [release procedure](../release.md).
|
||||||
|
|||||||
58
docs/releases/v0.2.0.md
Normal file
58
docs/releases/v0.2.0.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# ConfigKit v0.2.0
|
||||||
|
|
||||||
|
This release adds integer schema-version input and consistent canonical sequence
|
||||||
|
path handling.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Applications can retain YAML such as `version: 4` while storing and checking their
|
||||||
|
version as a Go string. The exception applies only to the path designated by
|
||||||
|
`VersionRule`; unrelated fields keep strict decoding. See the
|
||||||
|
[configuration contract](../config.md#application-schema-versions).
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
This minor release intentionally changes integer schema-version input from a
|
||||||
|
`version.type` rejection to invoking the application checker with its decimal text.
|
||||||
|
Plain string destinations at that path now accept it. Public Go API shapes,
|
||||||
|
minimum Go version, and supported platforms are unchanged.
|
||||||
|
|
||||||
|
Parsed sequence indices now require canonical nonnegative decimal spelling.
|
||||||
|
Signed tokens such as `+0` and `-0`, previously accepted by some traversal paths,
|
||||||
|
no longer identify sequence elements. They remain valid literal mapping keys.
|
||||||
|
Version checking, decoding, and metadata matching now agree on this boundary.
|
||||||
|
|
||||||
|
The private YAML dependency remains pinned to `go.yaml.in/yaml/v4 v4.0.0-rc.6`,
|
||||||
|
confirmed as the newest available v4 release during candidate preparation. Linux
|
||||||
|
candidate checks use Go 1.25.5 and Go 1.26.7. The maintainer confirmed native macOS
|
||||||
|
checks at `244104f` and approved carrying that result across the documentation-only
|
||||||
|
candidate changes under the [recorded exception](../policy/release.md#policy-exceptions).
|
||||||
|
|
||||||
|
## Upgrade
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get gitea.maximumdirect.net/eric/ConfigKit@v0.2.0
|
||||||
|
```
|
||||||
|
|
||||||
|
Existing string schema versions need no configuration changes. Applications using
|
||||||
|
integer YAML versions can retain them and use a plain string field plus a
|
||||||
|
`VersionRule`. Review the application checker: it now receives integer identifiers
|
||||||
|
as text, and remains responsible for rejecting unsupported versions. Fractions,
|
||||||
|
exponents, booleans, and containers remain invalid version kinds.
|
||||||
|
|
||||||
|
Replace signed sequence-index tokens in configured paths with canonical indices
|
||||||
|
(for example, `/versions/+0` becomes `/versions/0`). Do not change signed mapping
|
||||||
|
keys. Literal integers elsewhere still do not decode into strings, and numeric
|
||||||
|
destinations retain their existing range checks.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Added load-local, path-scoped integer-to-string schema-version decoding without
|
||||||
|
mutating original input or introducing general scalar coercion.
|
||||||
|
- Preserved numeric historical input, string effective output, decoding
|
||||||
|
reconciliation, source attribution, and secret redaction.
|
||||||
|
- Unified sequence path interpretation and added regression coverage for signed
|
||||||
|
tokens, literal mapping keys, and explicit key/index distinctions.
|
||||||
|
- Updated maintained Notarius-shaped fixtures and migration guidance for quoted
|
||||||
|
and integer version forms.
|
||||||
|
- Retired completed implementation and remediation roadmaps to Git history.
|
||||||
@@ -83,7 +83,7 @@ type ObjectBinding struct {
|
|||||||
Options map[string]any `yaml:"options"`
|
Options map[string]any `yaml:"options"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NotariusConfig demonstrates string versions and dynamic catalog options.
|
// NotariusConfig demonstrates string-backed versions and dynamic catalog options.
|
||||||
type NotariusConfig struct {
|
type NotariusConfig struct {
|
||||||
Version string `yaml:"version"`
|
Version string `yaml:"version"`
|
||||||
Bindings []ObjectBinding `yaml:"bindings"`
|
Bindings []ObjectBinding `yaml:"bindings"`
|
||||||
@@ -93,7 +93,7 @@ type NotariusConfig struct {
|
|||||||
func LoadNotarius(path string) (*configkit.Result[NotariusConfig], error) {
|
func LoadNotarius(path string) (*configkit.Result[NotariusConfig], error) {
|
||||||
return configkit.Load(configkit.File(path), configkit.Options[NotariusConfig]{
|
return configkit.Load(configkit.File(path), configkit.Options[NotariusConfig]{
|
||||||
Definition: configkit.Definition[NotariusConfig]{
|
Definition: configkit.Definition[NotariusConfig]{
|
||||||
Version: &configkit.VersionRule{Path: configkit.Keys("version"), Check: requireVersion("v1")},
|
Version: &configkit.VersionRule{Path: configkit.Keys("version"), Check: requireVersion("4")},
|
||||||
Validate: validateNotarius,
|
Validate: validateNotarius,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ func Example_notarius() {
|
|||||||
result, err := consumers.LoadNotarius(fixture("notarius", "config.yml"))
|
result, err := consumers.LoadNotarius(fixture("notarius", "config.yml"))
|
||||||
window, ok := consumers.NotariusWindow(result.Config, 0)
|
window, ok := consumers.NotariusWindow(result.Config, 0)
|
||||||
fmt.Println(result.Config.Version, result.Config.Bindings[0].Object, window, ok, err)
|
fmt.Println(result.Config.Version, result.Config.Bindings[0].Object, window, ok, err)
|
||||||
// Output: v1 summary 12 true <nil>
|
// Output: 4 summary 12 true <nil>
|
||||||
}
|
}
|
||||||
|
|
||||||
func Example_weatherReporter() {
|
func Example_weatherReporter() {
|
||||||
@@ -100,6 +100,10 @@ func TestMaintainedConsumerFixturesPreserveDomainOutcomes(t *testing.T) {
|
|||||||
if _, ok := notarius.Config.Bindings[0].Options["window_units"].(int64); !ok {
|
if _, ok := notarius.Config.Bindings[0].Options["window_units"].(int64); !ok {
|
||||||
t.Fatalf("dynamic integer type = %T", notarius.Config.Bindings[0].Options["window_units"])
|
t.Fatalf("dynamic integer type = %T", notarius.Config.Bindings[0].Options["window_units"])
|
||||||
}
|
}
|
||||||
|
integerNotarius, err := consumers.LoadNotarius(fixture("notarius", "integer-version.yml"))
|
||||||
|
if err != nil || integerNotarius.Config.Version != notarius.Config.Version || integerNotarius.Config.Version != "4" {
|
||||||
|
t.Fatalf("Notarius version forms = %#v / %#v, %v", notarius.Config, integerNotarius, err)
|
||||||
|
}
|
||||||
|
|
||||||
weather, err := consumers.LoadWeather(configkit.File(fixture("weather", "config.yml")))
|
weather, err := consumers.LoadWeather(configkit.File(fixture("weather", "config.yml")))
|
||||||
if err != nil || weather.Config.Reports["daily"].Modules[1].Options.Sections[0] != "short_term" {
|
if err != nil || weather.Config.Reports["daily"].Modules[1].Options.Sections[0] != "short_term" {
|
||||||
@@ -124,8 +128,19 @@ func TestMigrationFixturesFailWithStableCodesAndPaths(t *testing.T) {
|
|||||||
path string
|
path string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "integer schema version",
|
name: "unsupported application version",
|
||||||
load: func() error { _, err := consumers.LoadNotarius(fixture("notarius", "integer-version.yml")); return err },
|
load: func() error {
|
||||||
|
_, err := consumers.LoadNotarius(fixture("notarius", "unsupported-version.yml"))
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
code: configkit.CodeVersionError, path: "/version",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non-integer numeric version",
|
||||||
|
load: func() error {
|
||||||
|
_, err := consumers.LoadNotarius(fixture("notarius", "noninteger-version.yml"))
|
||||||
|
return err
|
||||||
|
},
|
||||||
code: configkit.CodeVersionType, path: "/version",
|
code: configkit.CodeVersionType, path: "/version",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
version: v1
|
version: "4"
|
||||||
bindings:
|
bindings:
|
||||||
- object: summary
|
- object: summary
|
||||||
options:
|
options:
|
||||||
|
|||||||
@@ -1,2 +1,6 @@
|
|||||||
version: 1
|
version: 4
|
||||||
bindings: []
|
bindings:
|
||||||
|
- object: summary
|
||||||
|
options:
|
||||||
|
window_units: 12
|
||||||
|
format: markdown
|
||||||
|
|||||||
2
examples/consumers/testdata/notarius/noninteger-version.yml
vendored
Normal file
2
examples/consumers/testdata/notarius/noninteger-version.yml
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
version: 4.0
|
||||||
|
bindings: []
|
||||||
2
examples/consumers/testdata/notarius/unsupported-version.yml
vendored
Normal file
2
examples/consumers/testdata/notarius/unsupported-version.yml
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
version: 5
|
||||||
|
bindings: []
|
||||||
104
explain_test.go
104
explain_test.go
@@ -366,6 +366,110 @@ func TestExplanationReconcilesDefaultsAfterInputReplacement(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIntegerVersionInspectionRetainsInputAndReconcilesTypedValue(t *testing.T) {
|
||||||
|
type config struct {
|
||||||
|
Version string `yaml:"version"`
|
||||||
|
}
|
||||||
|
rule := &VersionRule{Path: Keys("version"), Check: func(version Version, _ *Validation) error {
|
||||||
|
if version.Text != "4" {
|
||||||
|
t.Errorf("version = %#v", version)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}}
|
||||||
|
options := Options[config]{Definition: Definition[config]{
|
||||||
|
Defaults: func() config { return config{Version: "4"} },
|
||||||
|
Version: rule,
|
||||||
|
}}
|
||||||
|
first, err := Load(Bytes("version.yml", []byte("version: 4\n")), options)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
second, err := Load(Bytes("version.yml", []byte("version: 4\n")), options)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if first.Config.Version != "4" || second.Config.Version != "4" {
|
||||||
|
t.Fatalf("configurations = %#v / %#v", first.Config, second.Config)
|
||||||
|
}
|
||||||
|
inputVersion, inputOK := documentAtPath(first.applications[0].value, Keys("version"))
|
||||||
|
finalVersion, finalOK := documentAtPath(first.final, Keys("version"))
|
||||||
|
if !inputOK || inputVersion.Kind != document.Number || inputVersion.Text != "4" ||
|
||||||
|
!finalOK || finalVersion.Kind != document.String || finalVersion.Text != "4" {
|
||||||
|
t.Fatalf("input/final = %#v / %#v", inputVersion, finalVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
info, ok := first.Metadata().Lookup(Keys("version"))
|
||||||
|
if !ok || !info.Explicit || info.Null || info.Origin.Layer != LayerBase || len(info.Origin.Locations) != 1 ||
|
||||||
|
info.Origin.Locations[0].SourceID != "source-1" || info.Origin.Locations[0].Line != 1 {
|
||||||
|
t.Fatalf("version metadata = %#v, %v", info, ok)
|
||||||
|
}
|
||||||
|
explanation, ok := first.Explain(Keys("version"))
|
||||||
|
if !ok || explanation.Effective.YAML != "'4'\n" {
|
||||||
|
t.Fatalf("version explanation = %#v, %v", explanation, ok)
|
||||||
|
}
|
||||||
|
foundInput, foundDecoding := false, false
|
||||||
|
for _, change := range explanation.History {
|
||||||
|
if change.After.YAML == "4\n" && change.Origin.Layer == LayerBase {
|
||||||
|
foundInput = true
|
||||||
|
}
|
||||||
|
if change.Before.YAML == "4\n" && change.After.YAML == "'4'\n" && change.Origin.Operation == ChangeDecoding {
|
||||||
|
foundDecoding = true
|
||||||
|
}
|
||||||
|
if change.Origin.Layer == LayerResolution {
|
||||||
|
t.Fatalf("conversion recorded as resolution: %#v", change)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundInput || !foundDecoding {
|
||||||
|
t.Fatalf("version history = %#v", explanation.History)
|
||||||
|
}
|
||||||
|
paths := first.Metadata().HistoricalPaths()
|
||||||
|
foundPath := false
|
||||||
|
for _, path := range paths {
|
||||||
|
if path.String() == "/version" {
|
||||||
|
foundPath = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundPath || len(first.Metadata().History(Keys("version"))) < 2 {
|
||||||
|
t.Fatalf("historical version paths/history = %#v / %#v", paths, first.Metadata().History(Keys("version")))
|
||||||
|
}
|
||||||
|
|
||||||
|
var effective strings.Builder
|
||||||
|
if err := first.WriteEffective(&effective); err != nil || effective.String() != "version: '4'\n" {
|
||||||
|
t.Fatalf("effective output = %q, %v", effective.String(), err)
|
||||||
|
}
|
||||||
|
first.Config.Version = "mutated"
|
||||||
|
effective.Reset()
|
||||||
|
if err := first.WriteEffective(&effective); err != nil || effective.String() != "version: '4'\n" || second.Config.Version != "4" {
|
||||||
|
t.Fatalf("detached results = %q / %#v, %v", effective.String(), second.Config, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSensitiveIntegerVersionStaysRedactedWithoutSecretLookup(t *testing.T) {
|
||||||
|
type config struct {
|
||||||
|
Version string `yaml:"version"`
|
||||||
|
}
|
||||||
|
lookups := 0
|
||||||
|
result, err := loadWithSecretLookup(Bytes("version.yml", []byte("version: 987654321\n")), func(string) (string, bool) {
|
||||||
|
lookups++
|
||||||
|
return "CREDENTIAL_CANARY", true
|
||||||
|
}, Options[config]{Definition: Definition[config]{
|
||||||
|
Version: &VersionRule{Path: Keys("version"), Check: func(Version, *Validation) error { return nil }},
|
||||||
|
Secret: func(path Path) bool { return path.String() == "/version" },
|
||||||
|
}})
|
||||||
|
if err != nil || result.Config.Version != "987654321" || lookups != 0 {
|
||||||
|
t.Fatalf("result = %#v, lookups = %d, err = %v", result, lookups, err)
|
||||||
|
}
|
||||||
|
var effective strings.Builder
|
||||||
|
if err := result.WriteEffective(&effective); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
explanation, ok := result.Explain(Keys("version"))
|
||||||
|
formatted := effective.String() + fmt.Sprintf("%#v %#v", explanation, result.Metadata().History(Keys("version")))
|
||||||
|
if !ok || !explanation.Effective.Redacted || strings.Contains(formatted, "987654321") || strings.Contains(formatted, "CREDENTIAL_CANARY") {
|
||||||
|
t.Fatalf("sensitive inspection = %q / %#v", effective.String(), explanation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExplanationReplaysResolutionAdditionsAndOverlappingIntent(t *testing.T) {
|
func TestExplanationReplaysResolutionAdditionsAndOverlappingIntent(t *testing.T) {
|
||||||
type cfg struct {
|
type cfg struct {
|
||||||
M map[string]any `yaml:"m"`
|
M map[string]any `yaml:"m"`
|
||||||
|
|||||||
@@ -113,7 +113,7 @@ func TestLoadRejectsImportConflictsAndFragmentVersions(t *testing.T) {
|
|||||||
name string
|
name string
|
||||||
fragment string
|
fragment string
|
||||||
}{
|
}{
|
||||||
{name: "exact", fragment: "settings:\n version: v2\n"},
|
{name: "exact integer", fragment: "settings:\n version: 4\n"},
|
||||||
{name: "ancestor scalar", fragment: "settings: blocked\n"},
|
{name: "ancestor scalar", fragment: "settings: blocked\n"},
|
||||||
} {
|
} {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
|||||||
4
load.go
4
load.go
@@ -51,12 +51,14 @@ type Resolution struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// VersionRule declares the input path and application checker for a schema version.
|
// VersionRule declares the input path and application checker for a schema version.
|
||||||
|
// At that path, string and decimal-integer input is supplied as Version.Text.
|
||||||
type VersionRule struct {
|
type VersionRule struct {
|
||||||
Path Path
|
Path Path
|
||||||
Check func(Version, *Validation) error
|
Check func(Version, *Validation) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// Version preserves the absent, null, and string states of a schema-version input.
|
// Version preserves the absent, null, and textual states of a schema-version input.
|
||||||
|
// Text is the exact spelling of accepted string or decimal-integer input.
|
||||||
type Version struct {
|
type Version struct {
|
||||||
Present bool
|
Present bool
|
||||||
Null bool
|
Null bool
|
||||||
|
|||||||
@@ -22,7 +22,11 @@ func decodeLoadInput[T any](schema *schemaDescriptor, definition Definition[T],
|
|||||||
if definition.Defaults != nil {
|
if definition.Defaults != nil {
|
||||||
defaults = reflect.ValueOf(definition.Defaults())
|
defaults = reflect.ValueOf(definition.Defaults())
|
||||||
}
|
}
|
||||||
decoded, decodeIssues := decodeConfiguration(schema, defaults, input.document)
|
context := decodeContext{}
|
||||||
|
if definition.Version != nil {
|
||||||
|
context.versionPath = &definition.Version.Path
|
||||||
|
}
|
||||||
|
decoded, decodeIssues := decodeConfigurationWithContext(schema, defaults, input.document, context)
|
||||||
if len(decodeIssues) != 0 {
|
if len(decodeIssues) != 0 {
|
||||||
diagnostics := make([]Diagnostic, len(decodeIssues))
|
diagnostics := make([]Diagnostic, len(decodeIssues))
|
||||||
for i, issue := range decodeIssues {
|
for i, issue := range decodeIssues {
|
||||||
|
|||||||
313
load_test.go
313
load_test.go
@@ -8,6 +8,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestLoadSingleSourceKindsAndDefaultsOnly(t *testing.T) {
|
func TestLoadSingleSourceKindsAndDefaultsOnly(t *testing.T) {
|
||||||
@@ -72,7 +73,9 @@ func TestLoadVersionStatesWarningsAndSafeFailures(t *testing.T) {
|
|||||||
{name: "no source absent", source: NoSource(), want: Version{}},
|
{name: "no source absent", source: NoSource(), want: Version{}},
|
||||||
{name: "null", source: Bytes("null.yml", []byte("schema_version: null\n")), want: Version{Present: true, Null: true}, wantLine: 1},
|
{name: "null", source: Bytes("null.yml", []byte("schema_version: null\n")), want: Version{Present: true, Null: true}, wantLine: 1},
|
||||||
{name: "string", source: Bytes("string.yml", []byte("schema_version: app.v1\n")), want: Version{Present: true, Text: "app.v1"}, wantLine: 1},
|
{name: "string", source: Bytes("string.yml", []byte("schema_version: app.v1\n")), want: Version{Present: true, Text: "app.v1"}, wantLine: 1},
|
||||||
{name: "integer", source: Bytes("integer.yml", []byte("schema_version: 1\n")), wantErr: CodeVersionType},
|
{name: "integer", source: Bytes("integer.yml", []byte("schema_version: 1\n")), want: Version{Present: true, Text: "1"}, wantLine: 1},
|
||||||
|
{name: "boolean", source: Bytes("boolean.yml", []byte("schema_version: true\n")), wantErr: CodeVersionType},
|
||||||
|
{name: "sequence", source: Bytes("sequence.yml", []byte("schema_version: []\n")), wantErr: CodeVersionType},
|
||||||
{name: "returned error", source: Bytes("return.yml", []byte("{}\n")), wantErr: CodeVersionHookFailed, returnOnly: true},
|
{name: "returned error", source: Bytes("return.yml", []byte("{}\n")), wantErr: CodeVersionHookFailed, returnOnly: true},
|
||||||
}
|
}
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
@@ -103,7 +106,7 @@ func TestLoadVersionStatesWarningsAndSafeFailures(t *testing.T) {
|
|||||||
t.Fatalf("diagnostics = %#v", diagnostics)
|
t.Fatalf("diagnostics = %#v", diagnostics)
|
||||||
}
|
}
|
||||||
if test.wantErr == CodeVersionType && called {
|
if test.wantErr == CodeVersionType && called {
|
||||||
t.Fatal("checker ran for a non-string version")
|
t.Fatal("checker ran for an unsupported version kind")
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -124,6 +127,312 @@ func TestLoadVersionStatesWarningsAndSafeFailures(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadDecodesIntegerVersionOnlyAtConfiguredStringPath(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
type stringConfig struct {
|
||||||
|
Version string `yaml:"version"`
|
||||||
|
}
|
||||||
|
type pointerConfig struct {
|
||||||
|
Version *string `yaml:"version"`
|
||||||
|
}
|
||||||
|
versionPath := Keys("version")
|
||||||
|
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
source string
|
||||||
|
load func(Source, *VersionRule) (string, error)
|
||||||
|
}{
|
||||||
|
{name: "integer into string", source: "version: 4\n", load: func(source Source, rule *VersionRule) (string, error) {
|
||||||
|
result, err := Load(source, Options[stringConfig]{Definition: Definition[stringConfig]{Version: rule}})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return result.Config.Version, nil
|
||||||
|
}},
|
||||||
|
{name: "string into string", source: "version: \"4\"\n", load: func(source Source, rule *VersionRule) (string, error) {
|
||||||
|
result, err := Load(source, Options[stringConfig]{Definition: Definition[stringConfig]{Version: rule}})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return result.Config.Version, nil
|
||||||
|
}},
|
||||||
|
{name: "integer into pointer", source: "version: 4\n", load: func(source Source, rule *VersionRule) (string, error) {
|
||||||
|
result, err := Load(source, Options[pointerConfig]{Definition: Definition[pointerConfig]{Version: rule}})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if result.Config.Version == nil {
|
||||||
|
t.Fatal("version pointer is nil")
|
||||||
|
}
|
||||||
|
return *result.Config.Version, nil
|
||||||
|
}},
|
||||||
|
{name: "string into pointer", source: "version: \"4\"\n", load: func(source Source, rule *VersionRule) (string, error) {
|
||||||
|
result, err := Load(source, Options[pointerConfig]{Definition: Definition[pointerConfig]{Version: rule}})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if result.Config.Version == nil {
|
||||||
|
t.Fatal("version pointer is nil")
|
||||||
|
}
|
||||||
|
return *result.Config.Version, nil
|
||||||
|
}},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
var checked Version
|
||||||
|
rule := &VersionRule{Path: versionPath, Check: func(version Version, _ *Validation) error {
|
||||||
|
checked = version
|
||||||
|
return nil
|
||||||
|
}}
|
||||||
|
got, err := test.load(Bytes("version.yml", []byte(test.source)), rule)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got != "4" || checked != (Version{Present: true, Text: "4"}) {
|
||||||
|
t.Fatalf("value = %q, checked = %#v", got, checked)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if result, err := Load(Bytes("version.yml", []byte("version: 4\n")), Options[stringConfig]{}); result != nil {
|
||||||
|
t.Fatalf("load without rule = %#v, %v", result, err)
|
||||||
|
} else {
|
||||||
|
var loadError *LoadError
|
||||||
|
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != CodeDecodeType {
|
||||||
|
t.Fatalf("load without rule error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
type unrelatedConfig struct {
|
||||||
|
Version string `yaml:"version"`
|
||||||
|
Label string `yaml:"label"`
|
||||||
|
}
|
||||||
|
rule := &VersionRule{Path: versionPath, Check: func(Version, *Validation) error { return nil }}
|
||||||
|
if result, err := Load(Bytes("version.yml", []byte("version: 4\nlabel: 5\n")), Options[unrelatedConfig]{Definition: Definition[unrelatedConfig]{Version: rule}}); result != nil {
|
||||||
|
t.Fatalf("unrelated coercion = %#v, %v", result, err)
|
||||||
|
} else {
|
||||||
|
var loadError *LoadError
|
||||||
|
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != CodeDecodeType || loadError.Diagnostics()[0].Path.String() != "/label" {
|
||||||
|
t.Fatalf("unrelated coercion error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadVersionScalarAndPathBoundaries(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
type config struct {
|
||||||
|
Version string `yaml:"version"`
|
||||||
|
}
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
value string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "zero", value: "0", want: "0"},
|
||||||
|
{name: "negative", value: "-7", want: "-7"},
|
||||||
|
{name: "negative zero", value: "-0", want: "-0"},
|
||||||
|
{name: "beyond int64", value: "123456789012345678901234567890", want: "123456789012345678901234567890"},
|
||||||
|
{name: "empty string", value: `""`, want: ""},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
var checked Version
|
||||||
|
rule := &VersionRule{Path: Keys("version"), Check: func(version Version, _ *Validation) error {
|
||||||
|
checked = version
|
||||||
|
return nil
|
||||||
|
}}
|
||||||
|
result, err := Load(Bytes("version.yml", []byte("version: "+test.value+"\n")), Options[config]{Definition: Definition[config]{Version: rule}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result.Config.Version != test.want || checked.Text != test.want || !checked.Present || checked.Null {
|
||||||
|
t.Fatalf("configuration = %#v, checked = %#v", result.Config, checked)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
value string
|
||||||
|
}{
|
||||||
|
{name: "fraction", value: "4.0"},
|
||||||
|
{name: "exponent", value: "4e0"},
|
||||||
|
{name: "boolean", value: "true"},
|
||||||
|
{name: "mapping", value: "{}"},
|
||||||
|
{name: "sequence", value: "[]"},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
called := false
|
||||||
|
rule := &VersionRule{Path: Keys("version"), Check: func(Version, *Validation) error {
|
||||||
|
called = true
|
||||||
|
return nil
|
||||||
|
}}
|
||||||
|
result, err := Load(Bytes("version.yml", []byte("version: "+test.value+"\n")), Options[config]{Definition: Definition[config]{Version: rule}})
|
||||||
|
if result != nil || called {
|
||||||
|
t.Fatalf("result = %#v, checker called = %v", result, called)
|
||||||
|
}
|
||||||
|
requireDiagnostic(t, err, CodeVersionType, "/version")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type namedVersion string
|
||||||
|
type nested struct {
|
||||||
|
Versions map[string]**namedVersion `yaml:"versions"`
|
||||||
|
Sibling namedVersion `yaml:"sibling"`
|
||||||
|
}
|
||||||
|
parsed, err := ParsePath("/versions/0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rule := &VersionRule{Path: parsed, Check: func(version Version, _ *Validation) error {
|
||||||
|
if version.Text != "4" {
|
||||||
|
t.Errorf("version = %#v", version)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}}
|
||||||
|
result, err := Load(Bytes("nested.yml", []byte("versions:\n \"0\": 4\nsibling: quoted\n")), Options[nested]{Definition: Definition[nested]{Version: rule}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result.Config.Versions["0"] == nil || *result.Config.Versions["0"] == nil || **result.Config.Versions["0"] != "4" || result.Config.Sibling != "quoted" {
|
||||||
|
t.Fatalf("nested configuration = %#v", result.Config)
|
||||||
|
}
|
||||||
|
type sequenceConfig struct {
|
||||||
|
Versions []namedVersion `yaml:"versions"`
|
||||||
|
}
|
||||||
|
sequenceResult, err := Load(Bytes("sequence.yml", []byte("versions: [6]\n")), Options[sequenceConfig]{Definition: Definition[sequenceConfig]{Version: &VersionRule{Path: parsed, Check: func(Version, *Validation) error { return nil }}}})
|
||||||
|
if err != nil || len(sequenceResult.Config.Versions) != 1 || sequenceResult.Config.Versions[0] != "6" {
|
||||||
|
t.Fatalf("sequence configuration = %#v, %v", sequenceResult, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
type escaped struct {
|
||||||
|
Versions map[string]namedVersion `yaml:"versions"`
|
||||||
|
}
|
||||||
|
escapedPath, err := ParsePath("/versions/a~1b")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
escapedResult, err := Load(Bytes("escaped.yml", []byte("versions:\n a/b: 9\n")), Options[escaped]{Definition: Definition[escaped]{Version: &VersionRule{Path: escapedPath, Check: func(Version, *Validation) error { return nil }}}})
|
||||||
|
if err != nil || escapedResult.Config.Versions["a/b"] != "9" {
|
||||||
|
t.Fatalf("escaped configuration = %#v, %v", escapedResult, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
siblingRule := &VersionRule{Path: Keys("versions", "target"), Check: func(Version, *Validation) error { return nil }}
|
||||||
|
if result, err := Load(Bytes("sibling.yml", []byte("versions:\n target: 4\n other: 5\n")), Options[escaped]{Definition: Definition[escaped]{Version: siblingRule}}); result != nil {
|
||||||
|
t.Fatalf("same-type sibling result = %#v, %v", result, err)
|
||||||
|
} else {
|
||||||
|
requireDiagnostic(t, err, CodeDecodeType, "/versions/other")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadVersionDestinationTypesKeepOrdinaryDecoding(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
check := func(Version, *Validation) error { return nil }
|
||||||
|
t.Run("integer destination", func(t *testing.T) {
|
||||||
|
type config struct {
|
||||||
|
Version int8 `yaml:"version"`
|
||||||
|
}
|
||||||
|
result, err := Load(Bytes("version.yml", []byte("version: 4\n")), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: check}}})
|
||||||
|
if err != nil || result.Config.Version != 4 {
|
||||||
|
t.Fatalf("result = %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
result, err = Load(Bytes("version.yml", []byte("version: 999\n")), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: check}}})
|
||||||
|
if result != nil {
|
||||||
|
t.Fatalf("range result = %#v", result)
|
||||||
|
}
|
||||||
|
requireDiagnostic(t, err, CodeDecodeRange, "/version")
|
||||||
|
result, err = Load(Bytes("version.yml", []byte("version: \"4\"\n")), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: check}}})
|
||||||
|
if result != nil {
|
||||||
|
t.Fatalf("quoted integer result = %#v", result)
|
||||||
|
}
|
||||||
|
requireDiagnostic(t, err, CodeDecodeType, "/version")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("dynamic destination", func(t *testing.T) {
|
||||||
|
type config struct {
|
||||||
|
Version any `yaml:"version"`
|
||||||
|
}
|
||||||
|
result, err := Load(Bytes("version.yml", []byte("version: 4\n")), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: check}}})
|
||||||
|
if err != nil || result.Config.Version != int64(4) {
|
||||||
|
t.Fatalf("result = %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
result, err = Load(Bytes("version.yml", []byte("version: 123456789012345678901234567890\n")), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: check}}})
|
||||||
|
if result != nil {
|
||||||
|
t.Fatalf("range result = %#v", result)
|
||||||
|
}
|
||||||
|
requireDiagnostic(t, err, CodeDecodeRange, "/version")
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
load func() error
|
||||||
|
}{
|
||||||
|
{name: "text codec", load: func() error {
|
||||||
|
type config struct {
|
||||||
|
Version validatedText `yaml:"version"`
|
||||||
|
}
|
||||||
|
_, err := Load(Bytes("version.yml", []byte("version: 4\n")), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: check}}})
|
||||||
|
return err
|
||||||
|
}},
|
||||||
|
{name: "duration", load: func() error {
|
||||||
|
type config struct {
|
||||||
|
Version time.Duration `yaml:"version"`
|
||||||
|
}
|
||||||
|
_, err := Load(Bytes("version.yml", []byte("version: 4\n")), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: check}}})
|
||||||
|
return err
|
||||||
|
}},
|
||||||
|
{name: "secret", load: func() error {
|
||||||
|
type config struct {
|
||||||
|
Version Secret `yaml:"version"`
|
||||||
|
}
|
||||||
|
_, err := Load(Bytes("version.yml", []byte("version: 4\n")), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: check}}})
|
||||||
|
return err
|
||||||
|
}},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
requireDiagnostic(t, test.load(), CodeDecodeType, "/version")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadVersionPolicyStopsLaterHooks(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
type config struct {
|
||||||
|
Version string `yaml:"version"`
|
||||||
|
}
|
||||||
|
var defaultsCalled, resolutionCalled, validationCalled bool
|
||||||
|
rule := &VersionRule{Path: Keys("version"), Check: func(version Version, validation *Validation) error {
|
||||||
|
if version.Text != "4" {
|
||||||
|
t.Errorf("version = %#v", version)
|
||||||
|
}
|
||||||
|
validation.Error(Keys("version"), "unsupported configuration version")
|
||||||
|
return nil
|
||||||
|
}}
|
||||||
|
result, err := Load(Bytes("version.yml", []byte("version: 4\n")), Options[config]{Definition: Definition[config]{
|
||||||
|
Defaults: func() config { defaultsCalled = true; return config{} },
|
||||||
|
Version: rule,
|
||||||
|
Resolve: func(*config, *Resolution) error { resolutionCalled = true; return nil },
|
||||||
|
Validate: func(*config, *Validation) { validationCalled = true },
|
||||||
|
}})
|
||||||
|
if result != nil || defaultsCalled || resolutionCalled || validationCalled {
|
||||||
|
t.Fatalf("result = %#v, hooks = %v/%v/%v", result, defaultsCalled, resolutionCalled, validationCalled)
|
||||||
|
}
|
||||||
|
requireDiagnostic(t, err, CodeVersionError, "/version")
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireDiagnostic(t *testing.T, err error, code Code, path string) {
|
||||||
|
t.Helper()
|
||||||
|
var loadError *LoadError
|
||||||
|
if !errors.As(err, &loadError) {
|
||||||
|
t.Fatalf("error = %T %v", err, err)
|
||||||
|
}
|
||||||
|
diagnostics := loadError.Diagnostics()
|
||||||
|
if len(diagnostics) != 1 || diagnostics[0].Code != code || diagnostics[0].Path.String() != path || diagnostics[0].Primary == nil {
|
||||||
|
t.Fatalf("diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoadVersionReportSuppressesReturnedErrorTextAndDefaults(t *testing.T) {
|
func TestLoadVersionReportSuppressesReturnedErrorTextAndDefaults(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ func TestOverrideValidationRejectsPathsOverlapValuesAndProtectedTargets(t *testi
|
|||||||
Nested map[string]any `yaml:"nested"`
|
Nested map[string]any `yaml:"nested"`
|
||||||
Unknown string `yaml:"unknown"`
|
Unknown string `yaml:"unknown"`
|
||||||
}
|
}
|
||||||
base := Bytes("override-errors.yml", []byte("version: v1\nscalar: value\nitems: [one]\nnested: {}\n"))
|
base := Bytes("override-errors.yml", []byte("version: 4\nscalar: value\nitems: [one]\nnested: {}\n"))
|
||||||
version := &VersionRule{Path: Keys("version"), Check: func(Version, *Validation) error { return nil }}
|
version := &VersionRule{Path: Keys("version"), Check: func(Version, *Validation) error { return nil }}
|
||||||
cycle := map[string]any{}
|
cycle := map[string]any{}
|
||||||
cycle["self"] = cycle
|
cycle["self"] = cycle
|
||||||
@@ -213,7 +213,7 @@ func TestOverrideCannotReplaceSchemaVersionAncestor(t *testing.T) {
|
|||||||
type config struct {
|
type config struct {
|
||||||
Schema schemaConfig `yaml:"schema"`
|
Schema schemaConfig `yaml:"schema"`
|
||||||
}
|
}
|
||||||
_, err := Load(Bytes("version.yml", []byte("schema: {version: v1}\n")), Options[config]{
|
_, err := Load(Bytes("version.yml", []byte("schema: {version: 4}\n")), Options[config]{
|
||||||
Definition: Definition[config]{Version: &VersionRule{Path: Keys("schema", "version"), Check: func(Version, *Validation) error { return nil }}},
|
Definition: Definition[config]{Version: &VersionRule{Path: Keys("schema", "version"), Check: func(Version, *Validation) error { return nil }}},
|
||||||
Overrides: []Override{{Path: Keys("schema"), Value: map[string]any{"other": "value"}, Flag: "--schema"}},
|
Overrides: []Override{{Path: Keys("schema"), Value: map[string]any{"other": "value"}, Flag: "--schema"}},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -123,7 +123,7 @@ func TestLoadProfileSelectionUsesOneFixedYAMLFile(t *testing.T) {
|
|||||||
|
|
||||||
func TestLoadProfileRejectsDirectivesVersionsAndBaseAliases(t *testing.T) {
|
func TestLoadProfileRejectsDirectivesVersionsAndBaseAliases(t *testing.T) {
|
||||||
versionRule := &VersionRule{Path: Keys("schema", "version"), Check: func(version Version, _ *Validation) error {
|
versionRule := &VersionRule{Path: Keys("schema", "version"), Check: func(version Version, _ *Validation) error {
|
||||||
if !version.Present || version.Text != "v1" {
|
if !version.Present || version.Text != "4" {
|
||||||
return errors.New("unexpected version")
|
return errors.New("unexpected version")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -135,12 +135,12 @@ func TestLoadProfileRejectsDirectivesVersionsAndBaseAliases(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{name: "root directive", profile: "configkit: {}\n", code: CodeProfileDirective},
|
{name: "root directive", profile: "configkit: {}\n", code: CodeProfileDirective},
|
||||||
{name: "nested directive", profile: "settings: {configkit: {}}\n", code: CodeProfileDirective},
|
{name: "nested directive", profile: "settings: {configkit: {}}\n", code: CodeProfileDirective},
|
||||||
{name: "equal version", profile: "schema: {version: v1}\n", code: CodeProfileVersion},
|
{name: "equal integer version", profile: "schema: {version: 4}\n", code: CodeProfileVersion},
|
||||||
{name: "ancestor scalar", profile: "schema: null\n", code: CodeProfileVersion},
|
{name: "ancestor scalar", profile: "schema: null\n", code: CodeProfileVersion},
|
||||||
} {
|
} {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", "schema: {version: v1}\n")
|
mainPath := writeImportFile(t, directory, "main.yml", "schema: {version: 4}\n")
|
||||||
writeImportFile(t, directory, "profiles/testing.yml", test.profile)
|
writeImportFile(t, directory, "profiles/testing.yml", test.profile)
|
||||||
requireProfileLoadCode(t, File(mainPath), Options[profileTestConfig]{Profile: "testing", Definition: Definition[profileTestConfig]{Version: versionRule}}, test.code)
|
requireProfileLoadCode(t, File(mainPath), Options[profileTestConfig]{Profile: "testing", Definition: Definition[profileTestConfig]{Version: versionRule}}, test.code)
|
||||||
})
|
})
|
||||||
@@ -148,7 +148,7 @@ func TestLoadProfileRejectsDirectivesVersionsAndBaseAliases(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("mapping sibling is allowed", func(t *testing.T) {
|
t.Run("mapping sibling is allowed", func(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", "schema: {version: v1}\n")
|
mainPath := writeImportFile(t, directory, "main.yml", "schema: {version: 4}\n")
|
||||||
writeImportFile(t, directory, "profiles/testing.yml", "schema: {other: allowed}\n")
|
writeImportFile(t, directory, "profiles/testing.yml", "schema: {other: allowed}\n")
|
||||||
result, err := Load[profileTestConfig](File(mainPath), Options[profileTestConfig]{Profile: "testing", Definition: Definition[profileTestConfig]{Version: versionRule}})
|
result, err := Load[profileTestConfig](File(mainPath), Options[profileTestConfig]{Profile: "testing", Definition: Definition[profileTestConfig]{Version: versionRule}})
|
||||||
if err != nil || result.Config.Schema["other"] != "allowed" {
|
if err != nil || result.Config.Schema["other"] != "allowed" {
|
||||||
|
|||||||
@@ -434,5 +434,5 @@ func schemaIndex(segment pathSegment) (int, bool) {
|
|||||||
return 0, false
|
return 0, false
|
||||||
}
|
}
|
||||||
index, err := strconv.Atoi(segment.key)
|
index, err := strconv.Atoi(segment.key)
|
||||||
return index, err == nil && index >= 0
|
return index, err == nil && index >= 0 && strconv.Itoa(index) == segment.key
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -200,12 +200,18 @@ func checkVersion(rule *VersionRule, root *document.Node) []Diagnostic {
|
|||||||
version.Null = true
|
version.Null = true
|
||||||
case document.String:
|
case document.String:
|
||||||
version.Text = node.Text
|
version.Text = node.Text
|
||||||
|
case document.Number:
|
||||||
|
if isIntegerVersion(node) {
|
||||||
|
version.Text = node.Text
|
||||||
|
break
|
||||||
|
}
|
||||||
|
fallthrough
|
||||||
default:
|
default:
|
||||||
location, _ := nearestDocumentLocation(root, rule.Path)
|
location, _ := nearestDocumentLocation(root, rule.Path)
|
||||||
primary := documentLocationPointer(location)
|
primary := documentLocationPointer(location)
|
||||||
return []Diagnostic{{
|
return []Diagnostic{{
|
||||||
Code: CodeVersionType, Severity: SeverityError, Path: rule.Path.clone(),
|
Code: CodeVersionType, Severity: SeverityError, Path: rule.Path.clone(),
|
||||||
Message: "schema version must be a string or null", Primary: primary,
|
Message: "schema version must be a string, integer, or null", Primary: primary,
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -226,6 +232,14 @@ func checkVersion(rule *VersionRule, root *document.Node) []Diagnostic {
|
|||||||
return canonicalDiagnostics(state.diagnostics)
|
return canonicalDiagnostics(state.diagnostics)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func isIntegerVersion(node *document.Node) bool {
|
||||||
|
return node != nil && node.Kind == document.Number && integerPattern.MatchString(node.Text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pathMatches(configured *Path, current Path) bool {
|
||||||
|
return configured != nil && current.valid() && matchesPathShape(*configured, current)
|
||||||
|
}
|
||||||
|
|
||||||
func documentLocationPointer(location document.Location) *Location {
|
func documentLocationPointer(location document.Location) *Location {
|
||||||
if !locationKnown(location) {
|
if !locationKnown(location) {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
90
version_path_test.go
Normal file
90
version_path_test.go
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
package configkit
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestVersionPathsShareCanonicalSequenceSemantics(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
type config struct {
|
||||||
|
Versions []string `yaml:"versions"`
|
||||||
|
}
|
||||||
|
for _, token := range []string{"0", "+0", "-0", "00", "+1"} {
|
||||||
|
for _, value := range []string{"4", "'4'"} {
|
||||||
|
t.Run(token+"/"+value, func(t *testing.T) {
|
||||||
|
path, err := ParsePath("/versions/" + token)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var checked Version
|
||||||
|
result, err := Load[config](Bytes("version.yml", []byte("versions: ["+value+"]\n")), Options[config]{Definition: Definition[config]{
|
||||||
|
Version: &VersionRule{Path: path, Check: func(v Version, _ *Validation) error { checked = v; return nil }},
|
||||||
|
}})
|
||||||
|
want := Version{}
|
||||||
|
if token == "0" {
|
||||||
|
want = Version{Present: true, Text: "4"}
|
||||||
|
}
|
||||||
|
if checked != want {
|
||||||
|
t.Fatalf("checked = %#v, want %#v", checked, want)
|
||||||
|
}
|
||||||
|
if token != "0" && value == "4" {
|
||||||
|
requireDiagnostic(t, err, CodeDecodeType, "/versions/0")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil || result.Config.Versions[0] != "4" {
|
||||||
|
t.Fatalf("result = %#v, err = %v", result, err)
|
||||||
|
}
|
||||||
|
_, found := result.Metadata().Lookup(path)
|
||||||
|
if found != (token == "0") {
|
||||||
|
t.Fatalf("metadata lookup = %v", found)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVersionPathsPreserveLiteralMappingKeysAndSegmentIntent(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
for _, key := range []string{"0", "+0", "-0", "00", "a/b~"} {
|
||||||
|
t.Run(key, func(t *testing.T) {
|
||||||
|
path, err := ParsePath(Keys(key).String())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
result, err := Load[map[string]string](Bytes("version.yml", []byte("'"+key+"': 4\n")), Options[map[string]string]{Definition: Definition[map[string]string]{
|
||||||
|
Version: &VersionRule{Path: path, Check: func(v Version, _ *Validation) error {
|
||||||
|
if !v.Present || v.Text != "4" {
|
||||||
|
t.Errorf("checked = %#v", v)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
}})
|
||||||
|
if err != nil || result.Config[key] != "4" {
|
||||||
|
t.Fatalf("result = %#v, err = %v", result, err)
|
||||||
|
}
|
||||||
|
if _, ok := result.Metadata().Lookup(path); !ok {
|
||||||
|
t.Fatal("literal key missing from metadata")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
path Path
|
||||||
|
input string
|
||||||
|
}{
|
||||||
|
{"key cannot address sequence", Keys("versions", "0"), "versions: [4]\n"},
|
||||||
|
{"index cannot address mapping", Keys("versions").Index(0), "versions: {'0': 4}\n"},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
type config struct {
|
||||||
|
Versions any `yaml:"versions"`
|
||||||
|
}
|
||||||
|
_, err := Load[config](Bytes("version.yml", []byte(test.input)), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: test.path, Check: func(v Version, validation *Validation) error {
|
||||||
|
if v.Present {
|
||||||
|
t.Errorf("wrong segment kind found version: %#v", v)
|
||||||
|
}
|
||||||
|
validation.Error(Keys("versions"), "version absent")
|
||||||
|
return nil
|
||||||
|
}}}})
|
||||||
|
requireDiagnostic(t, err, CodeVersionError, "/versions")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user