7 Commits

25 changed files with 880 additions and 55 deletions

View File

@@ -34,7 +34,15 @@ type decodedConfiguration struct {
final *document.Node
}
type decodeContext struct {
versionPath *Path
}
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 {
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)
if input != nil {
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 {
return decodedConfiguration{}, issues
}
@@ -71,7 +79,7 @@ func decodeConfiguration(schema *schemaDescriptor, defaults reflect.Value, input
}, 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 {
switch descriptor.kind {
case descriptorPointer, descriptorMap, descriptorSlice, descriptorDynamic:
@@ -89,7 +97,7 @@ func decodeValue(node *document.Node, base reflect.Value, descriptor *typeDescri
} else {
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 {
return reflect.Value{}, issues
}
@@ -100,17 +108,17 @@ func decodeValue(node *document.Node, base reflect.Value, descriptor *typeDescri
switch descriptor.kind {
case descriptorStruct:
return decodeStruct(node, base, descriptor, path, schema)
return decodeStruct(node, base, descriptor, path, schema, context)
case descriptorMap:
return decodeMap(node, base, descriptor, path, schema)
return decodeMap(node, base, descriptor, path, schema, context)
case descriptorSlice:
return decodeSlice(node, descriptor, path, schema)
return decodeSlice(node, descriptor, path, schema, context)
case descriptorArray:
return decodeArray(node, descriptor, path, schema)
return decodeArray(node, descriptor, path, schema, context)
case descriptorDynamic:
return decodeDynamic(node, base, path, schema)
return decodeDynamic(node, base, path, schema, context)
case descriptorString:
if node.Kind != document.String {
if node.Kind != document.String && !(pathMatches(context.versionPath, path) && isIntegerVersion(node)) {
return typeMismatch(path, node, "string")
}
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 {
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})
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 {
issues = append(issues, fieldIssues...)
continue
@@ -212,7 +220,7 @@ func decodeStruct(node *document.Node, base reflect.Value, descriptor *typeDescr
continue
}
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...)
}
continue
@@ -222,7 +230,7 @@ func decodeStruct(node *document.Node, base reflect.Value, descriptor *typeDescr
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)
if !ok || mapValue.Kind() != reflect.Map {
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() {
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 {
mapValue.SetMapIndex(key, decoded)
}
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 {
return typeMismatch(path, node, "mapping")
}
@@ -259,7 +267,7 @@ func decodeMap(node *document.Node, base reflect.Value, descriptor *typeDescript
if !entryBase.IsValid() {
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 {
issues = append(issues, entryIssues...)
continue
@@ -269,14 +277,14 @@ func decodeMap(node *document.Node, base reflect.Value, descriptor *typeDescript
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 {
return typeMismatch(path, node, "sequence")
}
result := reflect.MakeSlice(descriptor.typeOf, len(node.Sequence), len(node.Sequence))
var issues []decodeIssue
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 {
issues = append(issues, childIssues...)
continue
@@ -286,7 +294,7 @@ func decodeSlice(node *document.Node, descriptor *typeDescriptor, path Path, sch
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 {
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()
var issues []decodeIssue
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 {
issues = append(issues, childIssues...)
continue
@@ -306,13 +314,13 @@ func decodeArray(node *document.Node, descriptor *typeDescriptor, path Path, sch
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() {
concrete := base.Elem()
operation := valueOperation{schema: schema}
descriptor, issue := operation.dynamicDescriptor(concrete.Type(), path)
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 {
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))
var issues []decodeIssue
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 {
issues = append(issues, childIssues...)
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))
var issues []decodeIssue
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 {
issues = append(issues, childIssues...)
continue

View File

@@ -74,9 +74,27 @@ frozen redacted projection.
### Application schema versions
`VersionRule` declares a semantic path and non-nil checker. Before defaults or typed
decoding, the checker receives a `Version` preserving absent, explicit null, or
string input. Any other input kind is a `version.type` error and the checker does not
run. ConfigKit does not interpret the text or impose a universal version path.
decoding, the checker receives a `Version` preserving absent, explicit null, string,
or strict decimal-integer input. Strings and integers supply their exact spelling in
`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`
and `Validation.Warning`; ConfigKit resolves primary and related paths to exact or
@@ -99,6 +117,13 @@ equal-valued input. Profile replacement removes displaced descendants from effec
metadata while retaining their base definition and the parent replacement operation
in history. All metadata queries and returned nested records are detached.
`Metadata.HistoricalPaths` enumerates observed paths, including removed paths,
without reloading input or resolving unselected credentials. Consumers can use
it with `History` to validate ownership of declarations hidden by an overlay;
domain ownership rules remain application responsibilities. The method's Go
documentation in [metadata.go](../metadata.go) defines enumeration and path-ordering
details.
History is chronological: a path can have a synthetic default record, its exact base
definition, a profile replacement, and a recorded resolution operation. A descendant
query uses the ancestor's path for a whole-container or whole-sequence replacement;
@@ -606,8 +631,10 @@ and redacted inspection.
Schema path traversal uses these same bindings. Explicit key segments address
mappings, explicit index segments address sequences, and parsed numeric pointer
tokens become indices only in a sequence. Sequence indices cannot have leading
zeroes; fixed-array indices are bounds checked. Dynamic `any` and inline-map entries
tokens become indices only in a sequence. Sequence indices must use canonical
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
introduce typed secret references.
@@ -661,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
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
numbers representable by their destination precision. Dynamic numbers become
`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 |
| --- | --- | --- |
| 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 |
| 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,
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
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.
- Canonical module and object bindings replace string shorthand. Unknown fields and
incompatible option shapes fail at their semantic paths.
- Schema versions are strings. Consumers with integer versions migrate their files
instead of enabling another scalar convention.
- Schema versions remain string-backed in application types. A configured
`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`
conversion is explicit and range checked.
- 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 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
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
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
mandatory steps in the [release procedure](../release.md).

50
docs/releases/v0.1.2.md Normal file
View File

@@ -0,0 +1,50 @@
# ConfigKit v0.1.2
This release adds safe enumeration of effective and removed metadata paths.
## Summary
`Metadata.HistoricalPaths` returns the semantic paths observed during a load,
including paths removed by profiles, CLI overrides, or recorded resolution. This
allows applications to discover hidden declarations and inspect their ownership
through the existing `Metadata.History` API without reloading configuration or
resolving unselected credentials.
See the [layered metadata contract](../config.md#layered-metadata) for current
behavior.
## Compatibility
This is a backward-compatible API addition. Existing APIs, configuration syntax,
diagnostic codes, minimum Go version, and supported platforms are unchanged.
Existing consumers do not need to change their code or configuration.
Returned paths are detached, unique by canonical JSON Pointer spelling, and sorted
by that spelling. The enumeration includes defaults and the root, exposes no
configuration values, and performs no I/O or secret resolution.
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.
## Upgrade
No action required. Consumers that want historical path discovery can update with:
```sh
go get gitea.maximumdirect.net/eric/ConfigKit@v0.1.2
```
Then iterate over `result.Metadata().HistoricalPaths()` and use `History` for
chronological definitions or `Lookup` to determine whether each path remains
effective.
## Changes
- Added `Metadata.HistoricalPaths` with detached, deterministic path enumeration.
- Included effective, default, and removed paths while excluding paths that never
existed.
- Added coverage for parent removal, mapping-to-sequence shape replacement, escaped
mapping keys, detached results, zero metadata, composite base ownership, and
avoidance of superseded secret lookup.
- Documented historical path enumeration in the current configuration contract.

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"`
}
// NotariusConfig demonstrates string versions and dynamic catalog options.
// NotariusConfig demonstrates string-backed versions and dynamic catalog options.
type NotariusConfig struct {
Version string `yaml:"version"`
Bindings []ObjectBinding `yaml:"bindings"`
@@ -93,7 +93,7 @@ type NotariusConfig struct {
func LoadNotarius(path string) (*configkit.Result[NotariusConfig], error) {
return configkit.Load(configkit.File(path), configkit.Options[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,
},
})

View File

@@ -26,7 +26,7 @@ func Example_notarius() {
result, err := consumers.LoadNotarius(fixture("notarius", "config.yml"))
window, ok := consumers.NotariusWindow(result.Config, 0)
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() {
@@ -100,6 +100,10 @@ func TestMaintainedConsumerFixturesPreserveDomainOutcomes(t *testing.T) {
if _, ok := notarius.Config.Bindings[0].Options["window_units"].(int64); !ok {
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")))
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
}{
{
name: "integer schema version",
load: func() error { _, err := consumers.LoadNotarius(fixture("notarius", "integer-version.yml")); return err },
name: "unsupported application version",
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",
},
{

View File

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

View File

@@ -1,2 +1,6 @@
version: 1
bindings: []
version: 4
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) {
type cfg struct {
M map[string]any `yaml:"m"`

View File

@@ -113,7 +113,7 @@ func TestLoadRejectsImportConflictsAndFragmentVersions(t *testing.T) {
name 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"},
} {
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.
// At that path, string and decimal-integer input is supplied as Version.Text.
type VersionRule struct {
Path Path
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 {
Present bool
Null bool

View File

@@ -22,7 +22,11 @@ func decodeLoadInput[T any](schema *schemaDescriptor, definition Definition[T],
if definition.Defaults != nil {
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 {
diagnostics := make([]Diagnostic, len(decodeIssues))
for i, issue := range decodeIssues {

View File

@@ -8,6 +8,7 @@ import (
"strings"
"sync/atomic"
"testing"
"time"
)
func TestLoadSingleSourceKindsAndDefaultsOnly(t *testing.T) {
@@ -72,7 +73,9 @@ func TestLoadVersionStatesWarningsAndSafeFailures(t *testing.T) {
{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: "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},
}
for _, test := range tests {
@@ -103,7 +106,7 @@ func TestLoadVersionStatesWarningsAndSafeFailures(t *testing.T) {
t.Fatalf("diagnostics = %#v", diagnostics)
}
if test.wantErr == CodeVersionType && called {
t.Fatal("checker ran for a non-string version")
t.Fatal("checker ran for an unsupported version kind")
}
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) {
t.Parallel()

View File

@@ -1,6 +1,10 @@
package configkit
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
import (
"sort"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
// Metadata provides detached effective presence/origin and chronological history
// queries for a load. Its zero value contains no paths.
@@ -50,6 +54,32 @@ func (m Metadata) History(path Path) []Change {
return cloned
}
// HistoricalPaths returns the paths observed during loading, including effective
// paths and paths removed by overlays or recorded resolution. It includes default
// paths and the root, but not paths that never existed. The zero Metadata returns
// an empty slice.
//
// Paths are detached, unique by canonical JSON Pointer, and sorted by that
// spelling. Like ParsePath results, numeric tokens are resolved contextually;
// one pointer can identify a mapping key in an earlier layer and a sequence index
// in a later one. Use History to inspect definitions and Lookup to check whether
// a path remains effective. Enumeration exposes no configuration values and does
// not perform another load or resolve secrets.
func (m Metadata) HistoricalPaths() []Path {
pointers := make([]string, 0, len(m.historicalPaths))
for pointer := range m.historicalPaths {
pointers = append(pointers, pointer)
}
sort.Strings(pointers)
paths := make([]Path, 0, len(pointers))
for _, pointer := range pointers {
// The index contains only canonical pointers produced by Path.String.
path, _ := ParsePath(pointer)
paths = append(paths, path)
}
return paths
}
// Textual pointers are lookup keys, but explicit key/index kinds remain part of
// the query. Candidate paths are constructed from actual document containers.
func matchesPathShape(query, candidate Path) bool {

View File

@@ -2,9 +2,95 @@ package configkit
import (
"bytes"
"reflect"
"testing"
)
func TestMetadataHistoricalPaths(t *testing.T) {
t.Parallel()
result, err := Load[map[string]any](Bytes("base", []byte("retired: {child: 1}\nshape: {'0': base}\n'a/b~': {'': 2}\n")), Options[map[string]any]{
Overrides: []Override{
{Path: Keys("retired"), Value: nil, Flag: "--retired"},
{Path: Keys("shape"), Value: []any{"replacement"}, Flag: "--shape"},
},
})
if err != nil {
t.Fatal(err)
}
metadata := result.Metadata()
paths := metadata.HistoricalPaths()
var got []string
for _, path := range paths {
got = append(got, path.String())
if len(metadata.History(path)) == 0 {
t.Fatalf("enumerated path %s has no history", path)
}
}
want := []string{"", "/a~1b~0", "/a~1b~0/", "/retired", "/retired/child", "/shape", "/shape/0"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("historical paths = %v, want %v", got, want)
}
if _, ok := metadata.Lookup(paths[4]); ok {
t.Fatal("removed descendant remains effective")
}
if _, ok := metadata.Lookup(paths[6]); !ok {
t.Fatal("enumerated numeric path cannot query the new sequence shape")
}
// Returned slices and nested paths must not alias retained metadata.
paths[1].segments[0].key = "mutated"
paths[2] = Keys("replacement")
if again := metadata.HistoricalPaths(); again[1].String() != want[1] || again[2].String() != want[2] {
t.Fatal("enumeration returned aliased paths")
}
if len((Metadata{}).HistoricalPaths()) != 0 {
t.Fatal("zero metadata contains paths")
}
}
func TestHistoricalPathsExposeHiddenBaseOwnershipWithoutResolvingBaseSecrets(t *testing.T) {
t.Setenv("CONFIGKIT_HISTORY_UNUSED_PROD_KEY", "")
t.Setenv("CONFIGKIT_HISTORY_TEST_KEY", "synthetic-test-key")
type config struct {
Credential *Secret `yaml:"credential"`
Definitions map[string]map[string]string `yaml:"definitions"`
}
for _, credential := range []string{"CONFIGKIT_HISTORY_TEST_KEY", "null"} {
t.Run(credential, func(t *testing.T) {
dir := t.TempDir()
root := writeImportFile(t, dir, "main.yml", "configkit:\n imports: [{path: first.yml}, {path: second.yml}]\ncredential: CONFIGKIT_HISTORY_UNUSED_PROD_KEY\n")
writeImportFile(t, dir, "first.yml", "definitions: {recap: {prompt: recap}}\n")
writeImportFile(t, dir, "second.yml", "definitions: {recap: {output: recap.md}}\n")
writeImportFile(t, dir, "profiles/testing.yml", "definitions: null\ncredential: "+credential+"\n")
result, err := Load[config](File(root), Options[config]{Imports: true, Profile: "testing"})
if err != nil {
t.Fatal(err)
}
metadata := result.Metadata()
found := false
for _, path := range metadata.HistoricalPaths() {
if path.String() != "/definitions/recap" {
continue
}
found = true
owners := map[string]bool{}
for _, change := range metadata.History(path) {
if change.Origin.Layer == LayerBase {
for _, location := range change.Origin.Locations {
owners[location.SourceID] = true
}
}
}
if len(owners) != 2 {
t.Fatalf("hidden declaration owners = %v", owners)
}
}
if !found {
t.Fatal("hidden base declaration was not discoverable")
}
})
}
}
func TestMetadataQueriesReturnDetachedRecords(t *testing.T) {
t.Parallel()

View File

@@ -163,7 +163,7 @@ func TestOverrideValidationRejectsPathsOverlapValuesAndProtectedTargets(t *testi
Nested map[string]any `yaml:"nested"`
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 }}
cycle := map[string]any{}
cycle["self"] = cycle
@@ -213,7 +213,7 @@ func TestOverrideCannotReplaceSchemaVersionAncestor(t *testing.T) {
type config struct {
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 }}},
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) {
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 nil
@@ -135,12 +135,12 @@ func TestLoadProfileRejectsDirectivesVersionsAndBaseAliases(t *testing.T) {
}{
{name: "root directive", profile: "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},
} {
t.Run(test.name, func(t *testing.T) {
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)
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) {
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")
result, err := Load[profileTestConfig](File(mainPath), Options[profileTestConfig]{Profile: "testing", Definition: Definition[profileTestConfig]{Version: versionRule}})
if err != nil || result.Config.Schema["other"] != "allowed" {

View File

@@ -434,5 +434,5 @@ func schemaIndex(segment pathSegment) (int, bool) {
return 0, false
}
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
case document.String:
version.Text = node.Text
case document.Number:
if isIntegerVersion(node) {
version.Text = node.Text
break
}
fallthrough
default:
location, _ := nearestDocumentLocation(root, rule.Path)
primary := documentLocationPointer(location)
return []Diagnostic{{
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)
}
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 {
if !locationKnown(location) {
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")
})
}
}