Support integer schema versions through typed decoding
This commit is contained in:
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,12 @@ 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.
|
||||||
|
|
||||||
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
|
||||||
@@ -668,7 +671,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 |
|
||||||
|
|
||||||
@@ -61,8 +61,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).
|
||||||
|
|
||||||
|
Planned feature work: [string and integer schema versions](roadmap/integer-schema-versions.md)
|
||||||
|
and its [sequential implementation plan](roadmap/implementation.md).
|
||||||
|
These describe future behavior, not the current configuration contract.
|
||||||
|
|
||||||
## 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
|
||||||
|
|||||||
152
docs/roadmap/implementation.md
Normal file
152
docs/roadmap/implementation.md
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
# Integer schema versions: implementation plan
|
||||||
|
|
||||||
|
Status: in progress. The [feature roadmap](integer-schema-versions.md) owns the
|
||||||
|
intended behavior and architecture; this document owns execution and progress.
|
||||||
|
|
||||||
|
## Execution rules
|
||||||
|
|
||||||
|
Execute stages sequentially. Each stage is a bounded assignment for one
|
||||||
|
gpt-5.6-terra coding agent: one coherent outcome, targeted files, focused tests,
|
||||||
|
and a reviewable handoff. Do not delegate or parallelize dependent stages by
|
||||||
|
default. No stage authorizes changing downstream repositories or publishing a tag.
|
||||||
|
|
||||||
|
Before each stage, read [development](../development.md), the relevant task-specific
|
||||||
|
policies, the roadmap, and the preceding handoff. Inspect the worktree and preserve
|
||||||
|
unrelated work. Prefer the repository's code graph for code discovery, and verify
|
||||||
|
current symbols rather than assuming the file inventory below is frozen.
|
||||||
|
|
||||||
|
Keep every stage green: add behavioral coverage alongside its implementation, run
|
||||||
|
the stated focused commands and `./scripts/check.sh`, and update implemented
|
||||||
|
contracts in the same change. Do not relax tests unrelated to this feature.
|
||||||
|
Record files changed, commands/results, remaining risks, and the next stage here.
|
||||||
|
Commit a coherent stage when committing is authorized; otherwise hand off its diff.
|
||||||
|
|
||||||
|
## Stage 1 — Deliver the checker-to-decoder vertical slice
|
||||||
|
|
||||||
|
Status: complete (2026-09-12). Depends on: none.
|
||||||
|
|
||||||
|
Scope: `validation.go`, the private decoding plumbing in `decode.go` and
|
||||||
|
`load_result.go`, necessary private call sites, and focused version/decode tests.
|
||||||
|
|
||||||
|
- Add the shared private integer-text recognition and extend `checkVersion`
|
||||||
|
according to the roadmap. Preserve diagnostics, callback order, and public types.
|
||||||
|
- Thread per-load version-path context to recursive decoding; enable integer to
|
||||||
|
plain-string decoding only at that path. Do not mutate source/history trees or
|
||||||
|
type-wide descriptors. Keep no-rule decoder callers strict.
|
||||||
|
- Add public-load coverage for integer and string `4` into `string` and `*string`,
|
||||||
|
identical checker text, absent/null states, and unsupported version kinds.
|
||||||
|
- Keep the suite green by updating the existing integer-version rejection test in
|
||||||
|
`load_test.go`. In `examples/consumers/consumers_test.go`, remove integer input
|
||||||
|
from the structural-rejection expectation or convert it to the appropriate
|
||||||
|
success/domain-policy assertion; do not merely delete migration coverage.
|
||||||
|
- Update `Version`/`VersionRule` comments, the configuration contract's version and
|
||||||
|
conversion sections, and stale consumer claims that all integer versions must
|
||||||
|
be rewritten. Describe only behavior implemented by this stage.
|
||||||
|
|
||||||
|
Acceptance: a full load of `version: 4` with a configured rule and string-backed
|
||||||
|
field succeeds and stores `"4"`; without the rule it still fails strict decoding.
|
||||||
|
An unrelated integer-to-string field still fails. Existing accepted input remains
|
||||||
|
unchanged. Both checking and decoding are implemented before this stage is done.
|
||||||
|
|
||||||
|
Validation: `go test . -run 'Version|Decode'`,
|
||||||
|
`go test ./examples/consumers`, then the everyday checker.
|
||||||
|
|
||||||
|
Completion record: checker and load-local decoder support landed with strict
|
||||||
|
no-rule and unrelated-field behavior retained. Focused package tests, consumer
|
||||||
|
tests, and `./scripts/check.sh` pass with Go 1.26.7 on Linux. Stage 2 should expand
|
||||||
|
the scalar, destination-type, and semantic-path boundary matrix.
|
||||||
|
|
||||||
|
## Stage 2 — Close scalar and path-boundary gaps
|
||||||
|
|
||||||
|
Status: not started. Depends on: Stage 1.
|
||||||
|
|
||||||
|
Scope: focused tests in `load_test.go`, `decode_test.go`, and relevant path/decoder
|
||||||
|
helpers only where those tests expose defects. Avoid broad schema refactoring.
|
||||||
|
|
||||||
|
- Cover zero, negative integer, `-0`, a decimal identifier beyond int64 range,
|
||||||
|
empty string, fractional/exponent numbers, booleans, and containers. Check parser
|
||||||
|
rejection remains separate for non-JSON numeric spellings; reuse parser coverage.
|
||||||
|
- Exercise a nonstandard nested version path, an escaped mapping key, contextual
|
||||||
|
numeric path tokens, and sibling fields sharing a named string type. Prove
|
||||||
|
conversion is path-scoped rather than descriptor-wide or name-based.
|
||||||
|
- Cover named strings and pointer wrappers; show integer destinations and dynamic
|
||||||
|
values retain ordinary types/range errors, while custom text codecs, durations,
|
||||||
|
and secret-reference destinations do not gain integer coercion.
|
||||||
|
- Verify unsupported kinds prevent the version callback, accepted integer input
|
||||||
|
reaches application policy, and rejection by that policy prevents later hooks.
|
||||||
|
Assert codes, semantic paths, and locations, not incidental message wording.
|
||||||
|
|
||||||
|
Acceptance: the target scalar/destination matrix is covered with lean table-driven
|
||||||
|
tests, no silent overflow/rounding, and no coercion leakage to unrelated fields or
|
||||||
|
loads. Expand a stage-1 test instead of duplicating the same behavior.
|
||||||
|
|
||||||
|
Validation: `go test . -run 'Version|Decode|Path'`, then the everyday checker.
|
||||||
|
|
||||||
|
## Stage 3 — Verify provenance and pipeline invariants
|
||||||
|
|
||||||
|
Status: not started. Depends on: Stage 2.
|
||||||
|
|
||||||
|
Scope: `explain_test.go`, `provenance_test.go`, import/profile/override and secret
|
||||||
|
test owners as needed. Production changes only for demonstrated integration gaps.
|
||||||
|
|
||||||
|
- Add an end-to-end integer-version case asserting final string configuration,
|
||||||
|
effective string rendering, original numeric history, and the existing decoding
|
||||||
|
reconciliation event. Confirm locations, explicitness, and historical lookup.
|
||||||
|
- Exercise equal-valued defaults and detached repeated loads/queries so conversion
|
||||||
|
does not masquerade as resolution or mutate retained input.
|
||||||
|
- Verify integer versions do not bypass fragment-version rejection, profile version
|
||||||
|
protection, or CLI overlap checks; extend existing owners rather than recreating
|
||||||
|
each suite. Include a nonstandard version path where practical.
|
||||||
|
- Check sensitive original/effective version snapshots remain redacted and that
|
||||||
|
the conversion adds no secret resolution or credential-bearing diagnostics.
|
||||||
|
Use synthetic fixtures and existing lookup seams, never real credentials.
|
||||||
|
|
||||||
|
Acceptance: typed configuration, metadata, rendering, and explanation agree; the
|
||||||
|
only intentional change is the version input/decoding exception. If inspection
|
||||||
|
needs repair, retain original input rather than suppressing its history.
|
||||||
|
|
||||||
|
Validation: `go test . -run 'Version|Import|Profile|Override|Provenance|Explain|Secret'`,
|
||||||
|
`go test -race ./...`, then the everyday checker.
|
||||||
|
|
||||||
|
## Stage 4 — Complete consumer evidence and release readiness
|
||||||
|
|
||||||
|
Status: not started. Depends on: Stage 3.
|
||||||
|
|
||||||
|
Scope: maintained Notarius-shaped examples/fixtures, consumer documentation,
|
||||||
|
documentation consistency review, and validation handoff. No downstream app edits.
|
||||||
|
|
||||||
|
- Make integer `version: 4` and quoted `version: "4"` representative successful
|
||||||
|
Notarius-shaped inputs with the same string-backed application result. Configure
|
||||||
|
the example's application checker consistently; do not bypass version policy.
|
||||||
|
- Retain negative examples for unsupported application versions and non-integer
|
||||||
|
version kinds, with stable diagnostic codes/paths. Verify unrelated dynamic
|
||||||
|
option integers and runtime conversions remain unchanged.
|
||||||
|
- Audit current documentation and Go examples for string-only claims. Keep
|
||||||
|
historical release notes, extraction records, and ADR rationale historical;
|
||||||
|
repair navigation without rewriting historical decisions. Transfer completed
|
||||||
|
target descriptions from the roadmap to canonical owners.
|
||||||
|
- Run the full release checker using the command below (or qualifying cached
|
||||||
|
toolchains selected under the release runbook). Report actual versions and results.
|
||||||
|
- Hand off the compatibility assessment and remaining release work. Select an
|
||||||
|
unused version with the maintainer; create version-matched notes only for a real
|
||||||
|
candidate. A changed rejection contract must be reviewed explicitly; do not reuse
|
||||||
|
the v0.1.1-only policy exception.
|
||||||
|
|
||||||
|
Acceptance: complete consumer regression and all available automated gates pass;
|
||||||
|
no current documentation promises unimplemented behavior. Mark implementation
|
||||||
|
complete separately from publication. Exact-commit Linux/macOS checks, candidate
|
||||||
|
commit/push, release-note approval, roadmap retirement, tagging, and clean consumer
|
||||||
|
retrieval follow the [release procedure](../release.md) when separately authorized.
|
||||||
|
Unavailable macOS evidence is a release blocker, not permission to waive the gate.
|
||||||
|
|
||||||
|
Validation: `go test ./examples/consumers`, `go test . -run '^Example'`, the everyday
|
||||||
|
checker, and the full release checker:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
CONFIGKIT_BASELINE_TOOLCHAIN=go1.25.5 \
|
||||||
|
CONFIGKIT_NEWER_TOOLCHAIN=go1.26.7 \
|
||||||
|
./scripts/release-check.sh
|
||||||
|
```
|
||||||
|
|
||||||
|
Rerun exact-commit release checks after
|
||||||
|
any later candidate or roadmap-retirement changes.
|
||||||
142
docs/roadmap/integer-schema-versions.md
Normal file
142
docs/roadmap/integer-schema-versions.md
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
# String and integer schema versions
|
||||||
|
|
||||||
|
Status: planned; not implemented. This document owns the intended end state.
|
||||||
|
The [implementation plan](implementation.md) owns sequencing and
|
||||||
|
progress. Current behavior remains defined by the [configuration contract](../config.md).
|
||||||
|
|
||||||
|
## Motivation and scope
|
||||||
|
|
||||||
|
Notarius historically declares `version: 4`. ConfigKit currently rejects this at
|
||||||
|
the version-check boundary and independently rejects assigning an integer to a Go
|
||||||
|
string during decoding. Consumers should be able to retain an integer version
|
||||||
|
declaration while using a string for application version comparison and storage.
|
||||||
|
|
||||||
|
Support this through the existing `VersionRule`, not a new parser mode, global
|
||||||
|
coercion option, public union type, or application-side YAML rewrite. Applications
|
||||||
|
continue to decide which version identifiers are supported. No downstream
|
||||||
|
repository changes or automatic schema migrations are included.
|
||||||
|
|
||||||
|
## Target contract
|
||||||
|
|
||||||
|
The following is intended future behavior, not a description of today's release.
|
||||||
|
|
||||||
|
### Version checking
|
||||||
|
|
||||||
|
Only the semantic path designated by `Definition.Version.Path` receives version
|
||||||
|
handling. There is no special behavior based on a field being named `version`.
|
||||||
|
An absent rule leaves all existing decoding behavior unchanged.
|
||||||
|
|
||||||
|
| Input at the configured path | Checker input |
|
||||||
|
| --- | --- |
|
||||||
|
| Absent | `Version{Present: false}` |
|
||||||
|
| Explicit null | `Version{Present: true, Null: true}` |
|
||||||
|
| String, including empty string | Present, non-null; `Text` preserves the string |
|
||||||
|
| Number with integer syntax | Present, non-null; `Text` preserves the validated decimal spelling |
|
||||||
|
| Fractional/exponent number, boolean, mapping, or sequence | `CodeVersionType`; checker not called |
|
||||||
|
|
||||||
|
Integer syntax means the existing strict decimal integer grammar
|
||||||
|
`^-?(0|[1-9][0-9]*)$`. Thus `4` and `"4"` both supply text `"4"`.
|
||||||
|
`-0` supplies `"-0"`; do not silently canonicalize it to `"0"`. Negative and zero
|
||||||
|
identifiers are structurally accepted; application policy may reject them.
|
||||||
|
`4.0` and `4e0` are not integer syntax, even though their mathematical values are
|
||||||
|
integral. Quoted versions such as `"4.0"` remain strings and remain accepted.
|
||||||
|
The parser's existing rejection of leading zeroes, hexadecimal, leading plus,
|
||||||
|
and other unsupported scalar spellings stays unchanged.
|
||||||
|
|
||||||
|
Use the validated number text directly, without conversion through `int`, `int64`,
|
||||||
|
or floating point. Textual versions need no machine-integer range limit beyond
|
||||||
|
existing input resource limits. Numeric Go destinations retain their normal range
|
||||||
|
checks. Preserve the public `Version` and `VersionRule` shapes: no new kind flag
|
||||||
|
or opt-in field is needed.
|
||||||
|
|
||||||
|
Checking remains before selected-profile application, defaults, typed decoding,
|
||||||
|
resolution, and secret lookup. Callback warnings, errors, absent/null handling,
|
||||||
|
safe diagnostics, and source attribution retain their current behavior.
|
||||||
|
|
||||||
|
### Typed decoding
|
||||||
|
|
||||||
|
At exactly the configured version path, an integer node may decode into a plain
|
||||||
|
Go string descriptor, including a named string without custom text decoding and
|
||||||
|
pointer wrappers around those types. The resulting string is the same text the
|
||||||
|
version checker receives. This permits both integer and quoted version YAML when
|
||||||
|
the application stores its version as a string.
|
||||||
|
|
||||||
|
Other destination types retain ordinary decoding: an integer field can still
|
||||||
|
decode integer YAML, but quoted strings are not coerced into integers. Dynamic
|
||||||
|
`any` retains its usual scalar types and numeric bounds. This feature does not
|
||||||
|
force every destination to become a string. Custom text codecs, durations, and
|
||||||
|
secret-reference types gain no integer conversion, even if their path is the
|
||||||
|
configured version path. Missing/null values and strict unknown-field validation
|
||||||
|
also retain normal typed semantics; passing the checker does not bypass decoding.
|
||||||
|
|
||||||
|
Unrelated fields, sibling fields with the same Go type, ancestors, descendants,
|
||||||
|
and fields merely named `version` receive no coercion. Semantic path matching must
|
||||||
|
respect escaped keys and contextual numeric tokens using existing path machinery.
|
||||||
|
Do not alter pointer-versus-key/index distinctions by comparing field names alone.
|
||||||
|
|
||||||
|
### Composition, inspection, and secrets
|
||||||
|
|
||||||
|
Version ownership remains unchanged: imported fragments cannot supply the
|
||||||
|
configured version, profiles cannot replace it or a covering ancestor, and CLI
|
||||||
|
overrides cannot overlap it. No conversion may hide a composition conflict or
|
||||||
|
weaken those checks.
|
||||||
|
|
||||||
|
Keep the parsed input and retained history numeric when integer syntax was used.
|
||||||
|
For a Go string destination, typed projection and effective YAML show a string;
|
||||||
|
historical explanation should expose the existing decoding reconciliation event
|
||||||
|
between numeric input and string output. Presence, source locations, origins,
|
||||||
|
historical paths, and detached ownership remain intact. Do not represent this
|
||||||
|
conversion as application resolution or invent a synthetic source.
|
||||||
|
|
||||||
|
Existing sensitivity classification and redaction apply to both original and
|
||||||
|
decoded snapshots. Conversion performs no environment access, invokes no codecs
|
||||||
|
that previously required strings, and introduces no raw-value diagnostic text.
|
||||||
|
|
||||||
|
## Intended architecture
|
||||||
|
|
||||||
|
The current entry points are `checkVersion` in `validation.go`,
|
||||||
|
`decodeLoadInput` in `load_result.go`, and `decodeConfiguration`/`decodeValue` in
|
||||||
|
`decode.go`. The lifecycle in `load.go` already supplies the definition to both
|
||||||
|
phases; retain its ordering.
|
||||||
|
|
||||||
|
1. Share a small private integer-version text predicate/extractor between checking
|
||||||
|
and decoding, reusing the existing integer grammar. Do not extend the YAML
|
||||||
|
adapter or expose its node types.
|
||||||
|
2. Carry an optional, per-load version-path capability into typed decoding through
|
||||||
|
private decode context. Follow it through recursive decoding, including
|
||||||
|
pointer, map, and dynamic-structure paths. Default private decoder callers
|
||||||
|
should remain strict unless they explicitly receive that capability.
|
||||||
|
3. Apply the exception only when the current semantic path matches the configured
|
||||||
|
path, the source is an integer number, and the destination descriptor is a
|
||||||
|
plain string. All other cases use existing dispatch and errors.
|
||||||
|
4. Let normal typed projection produce the final string representation. Retain
|
||||||
|
original input for provenance and explanation; avoid rewriting an assembled
|
||||||
|
document or cloning the entire tree solely to convert one scalar.
|
||||||
|
|
||||||
|
Do not attach path-specific conversion to a shared type descriptor: descriptors
|
||||||
|
can be reused by unrelated fields of the same Go type. Keep the capability local
|
||||||
|
to one decoding operation, not global state or a public schema extension. Exact
|
||||||
|
private context names/signatures are implementation choices, not API commitments.
|
||||||
|
|
||||||
|
## Compatibility and completion
|
||||||
|
|
||||||
|
Existing accepted strings keep their meaning. Newly accepted integers intentionally
|
||||||
|
change the previous `version.type` rejection contract and cause application version
|
||||||
|
checkers to run on inputs they previously never saw. Release review must account
|
||||||
|
for that, rather than describing the change as entirely behavior-neutral. A
|
||||||
|
pre-v1 minor release is the conservative choice under the
|
||||||
|
[release policy](../policy/release.md); this roadmap chooses no tag and grants no
|
||||||
|
patch-policy exception or publication authority.
|
||||||
|
|
||||||
|
Completion requires end-to-end integer/string equivalence for a string-backed
|
||||||
|
Notarius-shaped configuration, strict rejection outside the version exception,
|
||||||
|
correct original/effective inspection, and retained version-ownership and secret
|
||||||
|
safety guarantees. Update Go reference comments, the configuration contract, and
|
||||||
|
consumer migration guidance as their behavior lands. Historical ADR rationale and
|
||||||
|
published release notes remain unchanged. If an accepted ADR actually conflicts
|
||||||
|
with the implementation decision, supersede it using the documentation policy;
|
||||||
|
do not rewrite its accepted rationale.
|
||||||
|
|
||||||
|
Once implemented, move target contracts to their canonical owners and replace
|
||||||
|
duplicated roadmap text with links. Retire completed roadmap/plan documents during
|
||||||
|
release preparation, preserving the execution record in Git history.
|
||||||
@@ -124,9 +124,9 @@ func TestMigrationFixturesFailWithStableCodesAndPaths(t *testing.T) {
|
|||||||
path string
|
path string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "integer schema version",
|
name: "unsupported integer schema version",
|
||||||
load: func() error { _, err := consumers.LoadNotarius(fixture("notarius", "integer-version.yml")); return err },
|
load: func() error { _, err := consumers.LoadNotarius(fixture("notarius", "integer-version.yml")); return err },
|
||||||
code: configkit.CodeVersionType, path: "/version",
|
code: configkit.CodeVersionError, path: "/version",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "module shorthand",
|
name: "module shorthand",
|
||||||
|
|||||||
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 {
|
||||||
|
|||||||
96
load_test.go
96
load_test.go
@@ -72,7 +72,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 +105,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 +126,96 @@ 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 TestLoadVersionReportSuppressesReturnedErrorTextAndDefaults(t *testing.T) {
|
func TestLoadVersionReportSuppressesReturnedErrorTextAndDefaults(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package configkit
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"reflect"
|
"reflect"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
)
|
)
|
||||||
@@ -200,12 +201,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 +233,33 @@ 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 {
|
||||||
|
if configured == nil || !configured.valid() || !current.valid() || len(configured.segments) != len(current.segments) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i, expected := range configured.segments {
|
||||||
|
actual := current.segments[i]
|
||||||
|
if expected.kind == pathSegmentParsed {
|
||||||
|
if actual.kind == pathSegmentIndex {
|
||||||
|
if expected.key != strconv.Itoa(actual.index) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
} else if expected.key != actual.key {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if expected.kind != actual.kind || expected.key != actual.key || expected.index != actual.index {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
func documentLocationPointer(location document.Location) *Location {
|
func documentLocationPointer(location document.Location) *Location {
|
||||||
if !locationKnown(location) {
|
if !locationKnown(location) {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
Reference in New Issue
Block a user