6 Commits

22 changed files with 706 additions and 54 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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
View 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.

View File

@@ -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,
}, },
}) })

View File

@@ -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",
}, },
{ {

View File

@@ -1,4 +1,4 @@
version: v1 version: "4"
bindings: bindings:
- object: summary - object: summary
options: options:

View File

@@ -1,2 +1,6 @@
version: 1 version: 4
bindings: [] bindings:
- object: summary
options:
window_units: 12
format: markdown

View File

@@ -0,0 +1,2 @@
version: 4.0
bindings: []

View File

@@ -0,0 +1,2 @@
version: 5
bindings: []

View File

@@ -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"`

View File

@@ -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) {

View File

@@ -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

View File

@@ -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 {

View File

@@ -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()

View File

@@ -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"}},
}) })

View File

@@ -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" {

View File

@@ -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
} }

View File

@@ -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
View 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")
})
}
}