8 Commits

32 changed files with 1027 additions and 123 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

@@ -7,6 +7,8 @@ import (
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
const directiveKey = "configkit"
type importDeclaration struct {
path string
glob string
@@ -19,11 +21,11 @@ func splitImportDirectives(root *document.Node) (*document.Node, []importDeclara
var declarations []importDeclaration
directiveIndex := -1
for index, entry := range body.Mapping {
if entry.Key != "_configkit" {
if entry.Key != directiveKey {
continue
}
directiveIndex = index
parsed, diagnostics := parseImportDirective(entry.Value, Keys("_configkit"))
parsed, diagnostics := parseImportDirective(entry.Value, Keys(directiveKey))
if len(diagnostics) != 0 {
return nil, nil, diagnostics
}
@@ -102,7 +104,7 @@ func nestedDirectiveDiagnostics(node *document.Node, current Path) []Diagnostic
if node.Kind == document.Mapping {
for _, entry := range node.Mapping {
childPath := current.Key(entry.Key)
if entry.Key == "_configkit" {
if entry.Key == directiveKey {
location := publicLocation(entry.KeyLocation)
return []Diagnostic{{Code: CodeImportInvalid, Severity: SeverityError, Path: childPath, Message: "configuration directives are permitted only at the document root", Primary: &location}}
}
@@ -183,7 +185,7 @@ func profileDirectiveDiagnostics(node *document.Node, current Path) []Diagnostic
if node.Kind == document.Mapping {
for _, entry := range node.Mapping {
childPath := current.Key(entry.Key)
if entry.Key == "_configkit" {
if entry.Key == directiveKey {
location := publicLocation(entry.KeyLocation)
return []Diagnostic{{
Code: CodeProfileDirective, Severity: SeverityError, Path: childPath,
@@ -209,7 +211,7 @@ func disabledDirectiveDiagnostics(node *document.Node, path Path) []Diagnostic {
if node.Kind == document.Mapping {
for _, entry := range node.Mapping {
childPath := path.Key(entry.Key)
if entry.Key == "_configkit" {
if entry.Key == directiveKey {
location := Location{SourceID: entry.KeyLocation.SourceID, Line: entry.KeyLocation.Line, Column: entry.KeyLocation.Column}
diagnostics = append(diagnostics, Diagnostic{
Code: CodeImportDisabled, Severity: SeverityError, Path: childPath,

View File

@@ -61,7 +61,7 @@ semantic validator, explicit path and glob imports, one selected profile, and
filesystem context. It also accepts explicit CLI override records, static secret
tags, and a dynamic path predicate. `NoSource` rejects imports, active profiles,
and filesystem context as invalid combinations but accepts overrides. Input
containing a reserved `_configkit` mapping key is rejected while imports are
containing a reserved `configkit` mapping key is rejected while imports are
disabled.
Every source, options, schema, version, defaults, decode, resolution, or validation
@@ -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;
@@ -419,7 +444,7 @@ origin data; history retains the displaced definitions and identifies the parent
replacement. Surviving profile paths have profile origins, while recursively shared
mapping containers retain their contributing locations.
A profile may not contain `_configkit` anywhere, contribute the configured schema
A profile may not contain `configkit` anywhere, contribute the configured schema
version, or replace an ancestor of that version with a scalar or null. These rules
apply even if the supplied version text would be unchanged. Profiles cannot mask a
base parse, composition, or version failure because those checks run first.
@@ -452,7 +477,7 @@ All records are detached and validated before any is applied. Root and explicit
sequence-index paths are invalid; parsed numeric tokens remain mapping keys. A path
may create missing mapping ancestors but cannot traverse an existing scalar, null,
or sequence. Duplicate and ancestor/descendant-overlapping records are rejected.
Neither paths nor values may create `_configkit`, and no record may overlap the
Neither paths nor values may create `configkit`, and no record may overlap the
configured schema-version path. Unknown fields and effective type mismatches remain
ordinary strict-decoder errors.
@@ -475,12 +500,18 @@ zero.
## Explicit imports
Imports are opt-in. With `Options.Imports` enabled, a document may reserve its root
`_configkit` mapping for an `imports` sequence. ConfigKit removes that mapping before
`configkit` mapping for an `imports` sequence. ConfigKit removes that mapping before
application decoding. Each sequence item is a mapping with exactly one nonempty
string `path` or `glob` plus an optional boolean `optional`; all other shapes and
fields are `import.invalid`. A `_configkit` key below the root is invalid. With
fields are `import.invalid`. A `configkit` key below the root is invalid. With
imports disabled, any occurrence remains `import.disabled`.
The directive key is `configkit`, not the `_configkit` spelling used in v0.1.0.
There is no legacy alias: `_configkit` is ordinary application data, subject to
normal schema validation, and does not trigger imports. When upgrading, rename
directive keys in root files and imported fragments, and rename any application
fields previously named `configkit` to avoid the newly reserved key.
Path declarations use slash-separated relative paths. Absolute paths and network
URLs are invalid. Each path resolves from its declaring file's canonical directory,
and files are traversed depth-first in declaration order: a file contributes its own
@@ -600,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.
@@ -655,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

@@ -2,7 +2,7 @@
**Base Policy Version:** 0.1.0
**Last Updated:** 2026-09-06
**Last Updated:** 2026-09-07
## Purpose
@@ -220,5 +220,18 @@ restating or redefining its requirements.
## Policy Exceptions
None. Candidate selection, publication, and post-publication verification remain
mandatory steps in the [release procedure](../release.md), not policy exceptions.
For v0.1.1 only, the maintainer approved renaming the reserved `_configkit` key to
`configkit` as an error-correcting point release because there are no current
consumers. This intentionally changes the v0.1.0 configuration contract without a
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).

43
docs/releases/v0.1.1.md Normal file
View File

@@ -0,0 +1,43 @@
# ConfigKit v0.1.1
This release corrects the spelling of ConfigKit's reserved configuration key.
## Summary
The reserved directive key is now `configkit` instead of `_configkit`. Import
declarations continue to use the same `imports` sequence and path/glob syntax.
See the [configuration contract](../config.md#explicit-imports).
## Compatibility
This is an intentional configuration compatibility change, approved as a one-time
[patch-release exception](../policy/release.md#policy-exceptions) because there are
no current consumers. `_configkit` is no longer a directive or a reserved key; it
is ordinary application data and is subject to normal schema validation. There is
no legacy alias. `configkit` is now reserved, including in nested mappings, profiles,
and override paths and values.
The Go API, diagnostic codes, Go minimum version, and supported platforms are
unchanged. 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
Rename `_configkit` directive keys to `configkit` in root configuration files and
imported fragments. Rename any application-owned `configkit` fields to avoid the
newly reserved key. Update diagnostic-path expectations to the new spelling.
Files retaining `_configkit` will not load their declared imports.
Consumers can select this release with:
```sh
go get gitea.maximumdirect.net/eric/ConfigKit@v0.1.1
```
## Changes
- Renamed the directive key consistently across imports, profiles, and overrides.
- Updated current configuration documentation and the composed consumer fixture.
- Updated behavioral tests and added coverage proving the old spelling is not an
import alias.

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 @@
_configkit:
configkit:
imports:
- path: artifacts.yml
poll_interval: 1m

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

@@ -23,7 +23,7 @@ type explanationConcurrency struct {
func TestExplainShowsDefaultBaseAndEffectiveProfileValue(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "config.yml", "_configkit:\n imports: [{path: transcription.yml}]\n")
mainPath := writeImportFile(t, directory, "config.yml", "configkit:\n imports: [{path: transcription.yml}]\n")
writeImportFile(t, directory, "transcription.yml", "transcription:\n concurrency: 8\n")
writeImportFile(t, directory, filepath.Join("profiles", "testing.yml"), "transcription:\n concurrency: 1\n")
result, err := Load[explanationConcurrency](File(mainPath), Options[explanationConcurrency]{
@@ -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

@@ -10,13 +10,13 @@ import (
func TestLoadGlobImportsUseLexicalDeclarationOrderAndNestedDFS(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", `_configkit:
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
imports:
- glob: fragments/*.yml
- path: after.yml
items: {root: root}
`)
aPath := writeImportFile(t, directory, "fragments/a.yml", `_configkit:
aPath := writeImportFile(t, directory, "fragments/a.yml", `configkit:
imports: [{path: nested/child.yml}]
items: {alpha: alpha}
`)
@@ -56,7 +56,7 @@ items: {alpha: alpha}
func TestLoadGlobImportsSupportEscapesClassesAndExplicitConfD(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", `_configkit:
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
imports:
- glob: 'conf.d/literal\*.yml'
- glob: 'conf.d/item-[ab].yml'
@@ -126,7 +126,7 @@ func TestLoadGlobImportsPreserveIdentityAndFileRules(t *testing.T) {
t.Run("overlapping declarations", func(t *testing.T) {
directory := t.TempDir()
writeImportFile(t, directory, "conf/value.yml", "items: {value: value}\n")
mainPath := writeImportFile(t, directory, "main.yml", `_configkit:
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
imports:
- glob: conf/*.yml
- glob: conf/value.*
@@ -269,5 +269,5 @@ func writeGlobRoot(t *testing.T, directory, pattern string, optional bool) strin
if optional {
optionalLine = "\n optional: true"
}
return writeImportFile(t, directory, "main.yml", "_configkit:\n imports:\n - glob: '"+pattern+"'"+optionalLine+"\n")
return writeImportFile(t, directory, "main.yml", "configkit:\n imports:\n - glob: '"+pattern+"'"+optionalLine+"\n")
}

View File

@@ -22,16 +22,37 @@ type importTestConfig struct {
Items map[string]string `yaml:"items"`
}
func TestLoadFormerDirectiveKeyIsApplicationData(t *testing.T) {
type config struct {
Legacy struct {
Imports []struct {
Path string `yaml:"path"`
} `yaml:"imports"`
} `yaml:"_configkit"`
}
for _, imports := range []bool{false, true} {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "_configkit:\n imports:\n - path: nonexistent.yml\n")
result, err := Load[config](File(mainPath), Options[config]{Imports: imports})
if err != nil {
t.Fatalf("Imports=%v: %v", imports, err)
}
if len(result.Config.Legacy.Imports) != 1 || result.Config.Legacy.Imports[0].Path != "nonexistent.yml" || len(result.Sources()) != 1 {
t.Fatalf("Imports=%v: former directive was not retained as application data: %#v", imports, result)
}
}
}
func TestLoadTraversesPathImportsDepthFirstAndComposesOrigins(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", `_configkit:
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
imports:
- path: first.yml
- path: second.yml
settings:
root: root
`)
firstPath := writeImportFile(t, directory, "first.yml", `_configkit:
firstPath := writeImportFile(t, directory, "first.yml", `configkit:
imports:
- path: nested/third.yml
settings:
@@ -71,7 +92,7 @@ settings:
func TestLoadRejectsImportConflictsAndFragmentVersions(t *testing.T) {
t.Run("all duplicate claimants", func(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", `_configkit:
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
imports:
- path: one.yml
- path: two.yml
@@ -92,12 +113,12 @@ 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) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "_configkit:\n imports:\n - path: fragment.yml\n")
mainPath := writeImportFile(t, directory, "main.yml", "configkit:\n imports:\n - path: fragment.yml\n")
writeImportFile(t, directory, "fragment.yml", test.fragment)
loadError := requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true, Definition: Definition[importTestConfig]{Version: versionRule}}, CodeImportVersion)
diagnostic := loadError.Diagnostics()[0]
@@ -114,15 +135,15 @@ func TestLoadImportDirectiveValidationAndTemporaryGlobGate(t *testing.T) {
body string
code Code
}{
{name: "directive kind", body: "_configkit: []\n", code: CodeImportInvalid},
{name: "unknown directive field", body: "_configkit: {unknown: true}\n", code: CodeImportInvalid},
{name: "imports kind", body: "_configkit: {imports: {}}\n", code: CodeImportInvalid},
{name: "declaration kind", body: "_configkit: {imports: [file.yml]}\n", code: CodeImportInvalid},
{name: "missing target", body: "_configkit: {imports: [{optional: true}]}\n", code: CodeImportInvalid},
{name: "two targets", body: "_configkit: {imports: [{path: a.yml, glob: '*.yml'}]}\n", code: CodeImportInvalid},
{name: "unknown declaration field", body: "_configkit: {imports: [{path: a.yml, extra: true}]}\n", code: CodeImportInvalid},
{name: "optional kind", body: "_configkit: {imports: [{path: a.yml, optional: 'yes'}]}\n", code: CodeImportInvalid},
{name: "nested directive", body: "nested: {_configkit: {}}\n", code: CodeImportInvalid},
{name: "directive kind", body: "configkit: []\n", code: CodeImportInvalid},
{name: "unknown directive field", body: "configkit: {unknown: true}\n", code: CodeImportInvalid},
{name: "imports kind", body: "configkit: {imports: {}}\n", code: CodeImportInvalid},
{name: "declaration kind", body: "configkit: {imports: [file.yml]}\n", code: CodeImportInvalid},
{name: "missing target", body: "configkit: {imports: [{optional: true}]}\n", code: CodeImportInvalid},
{name: "two targets", body: "configkit: {imports: [{path: a.yml, glob: '*.yml'}]}\n", code: CodeImportInvalid},
{name: "unknown declaration field", body: "configkit: {imports: [{path: a.yml, extra: true}]}\n", code: CodeImportInvalid},
{name: "optional kind", body: "configkit: {imports: [{path: a.yml, optional: 'yes'}]}\n", code: CodeImportInvalid},
{name: "nested directive", body: "nested: {configkit: {}}\n", code: CodeImportInvalid},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
@@ -136,7 +157,7 @@ func TestLoadImportDirectiveValidationAndTemporaryGlobGate(t *testing.T) {
func TestLoadImportMissingContainmentAndRegularFileRules(t *testing.T) {
t.Run("optional missing", func(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", `_configkit:
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
imports:
- path: missing.yml
optional: true
@@ -172,7 +193,7 @@ settings: {root: retained}
if test.setup != nil {
test.setup(t, directory)
}
mainPath := writeImportFile(t, directory, "main.yml", "_configkit:\n imports:\n - path: \""+test.path+"\"\n")
mainPath := writeImportFile(t, directory, "main.yml", "configkit:\n imports:\n - path: \""+test.path+"\"\n")
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, test.code)
})
}
@@ -181,9 +202,9 @@ settings: {root: retained}
func TestLoadImportIdentityReportsCyclesAndRepeatedRoutes(t *testing.T) {
t.Run("cycle", func(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "_configkit:\n imports: [{path: a.yml}]\n")
writeImportFile(t, directory, "a.yml", "_configkit:\n imports: [{path: b.yml}]\n")
writeImportFile(t, directory, "b.yml", "_configkit:\n imports: [{path: main.yml}]\n")
mainPath := writeImportFile(t, directory, "main.yml", "configkit:\n imports: [{path: a.yml}]\n")
writeImportFile(t, directory, "a.yml", "configkit:\n imports: [{path: b.yml}]\n")
writeImportFile(t, directory, "b.yml", "configkit:\n imports: [{path: main.yml}]\n")
loadError := requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportCycle)
diagnostic := loadError.Diagnostics()[0]
if diagnostic.Primary == nil || len(diagnostic.Related) != 2 || !strings.Contains(diagnostic.Message, "main.yml") || !strings.Contains(diagnostic.Message, "a.yml") || !strings.Contains(diagnostic.Message, "b.yml") {
@@ -193,9 +214,9 @@ func TestLoadImportIdentityReportsCyclesAndRepeatedRoutes(t *testing.T) {
t.Run("diamond", func(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "_configkit:\n imports: [{path: left.yml}, {path: right.yml}]\n")
writeImportFile(t, directory, "left.yml", "_configkit:\n imports: [{path: shared.yml}]\n")
writeImportFile(t, directory, "right.yml", "_configkit:\n imports: [{path: shared.yml}]\n")
mainPath := writeImportFile(t, directory, "main.yml", "configkit:\n imports: [{path: left.yml}, {path: right.yml}]\n")
writeImportFile(t, directory, "left.yml", "configkit:\n imports: [{path: shared.yml}]\n")
writeImportFile(t, directory, "right.yml", "configkit:\n imports: [{path: shared.yml}]\n")
writeImportFile(t, directory, "shared.yml", "settings: {root: shared}\n")
loadError := requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportRepeated)
diagnostic := loadError.Diagnostics()[0]
@@ -216,7 +237,7 @@ func TestLoadImportIdentityReportsCyclesAndRepeatedRoutes(t *testing.T) {
if err := os.Symlink("target.yml", filepath.Join(directory, "second-link.yml")); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
mainPath := writeImportFile(t, directory, "main.yml", "_configkit: {imports: [{path: first-link.yml}, {path: second-link.yml}]}\n")
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: first-link.yml}, {path: second-link.yml}]}\n")
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportRepeated)
})
}
@@ -231,7 +252,7 @@ func TestLoadImportsCanonicalSymlinksAndKeepsHardLinksDistinct(t *testing.T) {
if err := os.Symlink(target, filepath.Join(directory, "link.yml")); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
mainPath := writeImportFile(t, directory, "main.yml", "_configkit: {imports: [{path: link.yml}]}\n")
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: link.yml}]}\n")
result, err := Load[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
if err != nil || result.Config.Settings.First != "linked" || result.Sources()[1].Path != canonicalTestPath(t, target) {
t.Fatalf("symlink result = %#v, %v", result, err)
@@ -245,7 +266,7 @@ func TestLoadImportsCanonicalSymlinksAndKeepsHardLinksDistinct(t *testing.T) {
if err := os.Symlink(target, filepath.Join(root, "escape.yml")); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
mainPath := writeImportFile(t, root, "main.yml", "_configkit: {imports: [{path: escape.yml}]}\n")
mainPath := writeImportFile(t, root, "main.yml", "configkit: {imports: [{path: escape.yml}]}\n")
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportOutsideRoot)
})
@@ -255,7 +276,7 @@ func TestLoadImportsCanonicalSymlinksAndKeepsHardLinksDistinct(t *testing.T) {
if err := os.Link(one, filepath.Join(directory, "two.yml")); err != nil {
t.Skipf("hard links unavailable: %v", err)
}
mainPath := writeImportFile(t, directory, "main.yml", "_configkit: {imports: [{path: one.yml}, {path: two.yml}]}\n")
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: one.yml}, {path: two.yml}]}\n")
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeComposeConflict)
})
@@ -264,7 +285,7 @@ func TestLoadImportsCanonicalSymlinksAndKeepsHardLinksDistinct(t *testing.T) {
if err := os.Symlink("loop.yml", filepath.Join(directory, "loop.yml")); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
mainPath := writeImportFile(t, directory, "main.yml", "_configkit: {imports: [{path: loop.yml}]}\n")
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: loop.yml}]}\n")
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportInvalid)
})
}
@@ -272,7 +293,7 @@ func TestLoadImportsCanonicalSymlinksAndKeepsHardLinksDistinct(t *testing.T) {
func TestLoadByteAndReaderImportsRequireExplicitFilesystemContext(t *testing.T) {
directory := t.TempDir()
writeImportFile(t, directory, "fragment.yml", "settings: {first: imported}\n")
input := []byte("_configkit: {imports: [{path: fragment.yml}]}\n")
input := []byte("configkit: {imports: [{path: fragment.yml}]}\n")
context := &FilesystemContext{BaseDir: directory, Root: directory}
for _, test := range []struct {
name string
@@ -295,7 +316,7 @@ func TestLoadByteAndReaderImportsRequireExplicitFilesystemContext(t *testing.T)
func TestFileImportRootCanContainTheSelectedFileDirectory(t *testing.T) {
root := t.TempDir()
mainPath := writeImportFile(t, root, "configs/main.yml", "_configkit: {imports: [{path: ../shared.yml}]}\n")
mainPath := writeImportFile(t, root, "configs/main.yml", "configkit: {imports: [{path: ../shared.yml}]}\n")
writeImportFile(t, root, "shared.yml", "settings: {root: shared}\n")
result, err := Load[importTestConfig](File(mainPath), Options[importTestConfig]{
Imports: true, Filesystem: &FilesystemContext{Root: root},
@@ -309,7 +330,7 @@ func TestFileImportRootCanContainTheSelectedFileDirectory(t *testing.T) {
func TestImportedParseAndDecodeDiagnosticsKeepTheirSource(t *testing.T) {
t.Run("parse", func(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "_configkit: {imports: [{path: broken.yml}]}\n")
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: broken.yml}]}\n")
writeImportFile(t, directory, "broken.yml", "settings: [\n")
loadError := requireAnyLoadError(t, File(mainPath), Options[importTestConfig]{Imports: true})
diagnostics := loadError.Diagnostics()
@@ -320,7 +341,7 @@ func TestImportedParseAndDecodeDiagnosticsKeepTheirSource(t *testing.T) {
t.Run("decode", func(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "_configkit: {imports: [{path: unknown.yml}]}\n")
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: unknown.yml}]}\n")
writeImportFile(t, directory, "unknown.yml", "unknown: value\n")
loadError := requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeDecodeUnknownField)
diagnostic := loadError.Diagnostics()[0]
@@ -332,7 +353,7 @@ func TestImportedParseAndDecodeDiagnosticsKeepTheirSource(t *testing.T) {
func TestOptionalImportDoesNotSuppressUnsafeOrUnreadableTargets(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "outside.yml", "_configkit: {imports: [{path: ../missing.yml, optional: true}]}\n")
mainPath := writeImportFile(t, directory, "outside.yml", "configkit: {imports: [{path: ../missing.yml, optional: true}]}\n")
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportOutsideRoot)
locked := writeImportFile(t, directory, "locked.yml", "settings: {root: locked}\n")
@@ -344,13 +365,13 @@ func TestOptionalImportDoesNotSuppressUnsafeOrUnreadableTargets(t *testing.T) {
_ = file.Close()
t.Skip("filesystem does not enforce the unreadable-file fixture")
}
mainPath = writeImportFile(t, directory, "main.yml", "_configkit: {imports: [{path: locked.yml, optional: true}]}\n")
mainPath = writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: locked.yml, optional: true}]}\n")
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeSourcePermission)
}
func TestImportTraversalEnforcesPrivateBudgets(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "_configkit: {imports: [{path: one.yml}, {path: two.yml}]}\n")
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: one.yml}, {path: two.yml}]}\n")
writeImportFile(t, directory, "one.yml", "settings: {first: one}\n")
writeImportFile(t, directory, "two.yml", "settings: {second: two}\n")
main := parseImportMain(t, mainPath)
@@ -370,7 +391,7 @@ func TestImportTraversalEnforcesPrivateBudgets(t *testing.T) {
})
}
writeImportFile(t, directory, "one.yml", "_configkit: {imports: [{path: nested.yml}]}\n")
writeImportFile(t, directory, "one.yml", "configkit: {imports: [{path: nested.yml}]}\n")
writeImportFile(t, directory, "nested.yml", "settings: {nested: nested}\n")
main = parseImportMain(t, mainPath)
_, _, diagnostics := loadImportedDocuments(File(mainPath), nil, main, nil, importLimits{depth: 1, sources: 8, bytes: maxTotalBytes, nodes: maxDocumentNodes})
@@ -381,7 +402,7 @@ func TestImportTraversalEnforcesPrivateBudgets(t *testing.T) {
func TestImportTraversalAcceptsExactPrivateBudgets(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "_configkit: {imports: [{path: imported.yml}]}\n")
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: imported.yml}]}\n")
importedPath := writeImportFile(t, directory, "imported.yml", "settings: {first: imported}\n")
main := parseImportMain(t, mainPath)
imported := parseImportMain(t, importedPath)

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()
@@ -240,7 +549,7 @@ func TestLoadRejectsUnavailableOptionsAndDisabledDirectives(t *testing.T) {
})
}
result, err := Load[config](Bytes("directive.yml", []byte("nested:\n _configkit: {}\n")))
result, err := Load[config](Bytes("directive.yml", []byte("nested:\n configkit: {}\n")))
if result != nil || err == nil {
t.Fatalf("result, err = %#v, %v", result, err)
}
@@ -249,7 +558,7 @@ func TestLoadRejectsUnavailableOptionsAndDisabledDirectives(t *testing.T) {
t.Fatal(err)
}
diagnostic := loadError.Diagnostics()[0]
if diagnostic.Code != CodeImportDisabled || diagnostic.Path.String() != "/nested/_configkit" || diagnostic.Primary == nil || diagnostic.Primary.Line != 2 {
if diagnostic.Code != CodeImportDisabled || diagnostic.Path.String() != "/nested/configkit" || diagnostic.Primary == nil || diagnostic.Primary.Line != 2 {
t.Fatalf("diagnostic = %#v", diagnostic)
}
}

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

@@ -188,7 +188,7 @@ func overlappingOverride(previous []Override, valid []bool, path Path) int {
func pathContainsDirective(path Path) bool {
for _, segment := range path.segments {
if segment.key == "_configkit" {
if segment.key == directiveKey {
return true
}
}
@@ -205,7 +205,7 @@ func overrideDirectivePath(node *document.Node, path Path) (Path, bool) {
if node.Kind == document.Mapping {
for _, entry := range node.Mapping {
child := path.Key(entry.Key)
if entry.Key == "_configkit" {
if entry.Key == directiveKey {
return child, true
}
if found, ok := overrideDirectivePath(entry.Value, child); ok {

View File

@@ -30,7 +30,7 @@ type overrideLifecycleConfig struct {
func TestOverridesRunAtHighestInputPrecedenceBeforeResolutionAndValidation(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "_configkit:\n imports: [{path: base.yml}]\nversion: v1\n")
mainPath := writeImportFile(t, directory, "main.yml", "configkit:\n imports: [{path: base.yml}]\nversion: v1\n")
writeImportFile(t, directory, "base.yml", `settings:
target: base
count: 4
@@ -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
@@ -187,8 +187,8 @@ func TestOverrideValidationRejectsPathsOverlapValuesAndProtectedTargets(t *testi
{name: "nonfinite", overrides: []Override{{Path: Keys("scalar"), Value: math.Inf(1), Flag: "--scalar"}}, code: CodeOverrideInvalidValue},
{name: "depth", overrides: []Override{{Path: Keys("nested"), Value: deep, Flag: "--nested"}}, code: CodeOverrideLimitExceeded},
{name: "record bytes", overrides: []Override{{Path: Keys("scalar"), Value: strings.Repeat("x", maxSourceBytes+1), Flag: "--scalar"}}, code: CodeOverrideLimitExceeded},
{name: "directive path", overrides: []Override{{Path: Keys("_configkit", "x"), Value: true, Flag: "--directive"}}, code: CodeOverrideDirective},
{name: "directive value", overrides: []Override{{Path: Keys("nested"), Value: map[string]any{"_configkit": map[string]any{}}, Flag: "--nested"}}, code: CodeOverrideDirective},
{name: "directive path", overrides: []Override{{Path: Keys("configkit", "x"), Value: true, Flag: "--directive"}}, code: CodeOverrideDirective},
{name: "directive value", overrides: []Override{{Path: Keys("nested"), Value: map[string]any{"configkit": map[string]any{}}, Flag: "--nested"}}, code: CodeOverrideDirective},
{name: "version", overrides: []Override{{Path: Keys("version"), Value: "v2", Flag: "--version"}}, code: CodeOverrideVersion},
{name: "version ancestor", overrides: []Override{{Path: Keys("version"), Value: map[string]any{"child": "v2"}, Flag: "--version"}}, code: CodeOverrideVersion},
{name: "label", overrides: []Override{{Path: Keys("scalar"), Value: "x", Flag: "\nCANARY"}}, code: CodeOverrideInvalidLabel},
@@ -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

@@ -23,7 +23,7 @@ type profileTestConfig struct {
func TestLoadAppliesSelectedProfileAfterBaseAndBeforeDefaults(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", `_configkit:
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
imports: [{path: base.yml}]
settings:
kept: main
@@ -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
@@ -133,14 +133,14 @@ func TestLoadProfileRejectsDirectivesVersionsAndBaseAliases(t *testing.T) {
profile string
code Code
}{
{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: "root directive", profile: "configkit: {}\n", code: CodeProfileDirective},
{name: "nested directive", profile: "settings: {configkit: {}}\n", code: CodeProfileDirective},
{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" {
@@ -158,7 +158,7 @@ func TestLoadProfileRejectsDirectivesVersionsAndBaseAliases(t *testing.T) {
t.Run("profile aliases imported base", func(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "_configkit: {imports: [{path: profiles/testing.yml}]}\n")
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: profiles/testing.yml}]}\n")
writeImportFile(t, directory, "profiles/testing.yml", "settings: {changed: value}\n")
requireProfileLoadCode(t, File(mainPath), Options[profileTestConfig]{Imports: true, Profile: "testing"}, CodeProfileRepeated)
})

View File

@@ -27,7 +27,7 @@ type layeredConfig struct {
func TestLayeredProvenanceAgreesWithResolvedSnapshot(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", `_configkit:
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
imports: [{path: imported.yml}]
shared: {equal: same}
values: [1, 2]

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

@@ -43,7 +43,7 @@ func TestNonregularSourcesDoNotBlock(t *testing.T) {
root := t.TempDir()
input := "value: 1\n"
if mode == "import" {
input = "_configkit:\n imports:\n - path: pipe.yml\n"
input = "configkit:\n imports:\n - path: pipe.yml\n"
}
if err := os.WriteFile(filepath.Join(root, "config.yml"), []byte(input), 0600); err != nil {
t.Fatal(err)

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