Build reusable schema descriptors

This commit is contained in:
2026-09-06 19:36:14 +00:00
parent 7c78da9cd2
commit 2b65876a7d
7 changed files with 789 additions and 17 deletions

View File

@@ -69,6 +69,41 @@ boolean/null words are rejected unless quoted.
Mapping keys use the same scalar classification and must classify as strings. Quote
keys that otherwise classify as a boolean, number, or null.
## Application schema binding
ConfigKit builds a private reflection descriptor graph for each schema operation;
it keeps no global mutable descriptor cache. A configuration root must be a struct
or string-keyed map. Nested supported shapes are:
- booleans, strings, signed and unsigned integers, and finite floating-point types;
- `time.Duration` and paired `encoding.TextMarshaler`/`TextUnmarshaler` scalars;
- pointers, structs, string-keyed maps, slices, fixed arrays, and `any`; and
- recursive type definitions whose eventual application values are acyclic.
Named scalar types retain their Go identity. `uintptr`, complex numbers, functions,
channels, unsafe pointers, non-string map keys, and nonempty interfaces are not
configuration types. A custom text scalar must provide both directions on its value
or pointer method set. Building a descriptor only inspects method sets; it never
executes application methods or parser-specific hooks.
Exported struct fields use an explicit `yaml` name or the lowercase Go field name.
Unexported fields and `yaml:"-"` are ignored. `omitempty` is accepted but does not
change binding or future projection. An explicit `yaml:",inline"` flattens a struct
or pointer-to-struct, or designates at most one string-keyed inline map for otherwise
unmatched names. Ambiguous names, named inline fields, multiple inline maps, and
unsupported or malformed tag options are schema errors. Embedded fields are ordinary
named fields unless explicitly inlined. `configkit:"secret"` is the only current
ConfigKit field tag and records sensitivity for later lifecycle work.
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
remain schema-valid for application-defined descendants.
Descriptors do not yet decode documents, clone defaults, or project application
values. Those operations will reuse this graph as their implementation lands.
## Safe failures
Source failures use stable `source.*` codes and YAML failures use stable `yaml.*`

View File

@@ -9,7 +9,9 @@ module and package documentation boundary. Its implemented public primitives are
- opaque file, byte, reader, and no-input source construction and bounded reads;
- a parser-independent ordered tree in `internal/document`; and
- the sole upstream YAML integration in `internal/yaml`, which translates validated
input into that owned tree and exposes no parser types outside its boundary.
input into that owned tree and exposes no parser types outside its boundary; and
- a per-operation private reflection descriptor graph in `schema.go` for supported
types, YAML field binding, inlining, secret tags, recursion, and semantic paths.
The [configuration input contract](../config.md) owns implemented source and YAML
behavior. ConfigKit intentionally exposes no typed loading or decoding API yet.

View File

@@ -2,7 +2,7 @@
## Status and ownership
Status: implementation in progress; Stages 1–3 are complete and later stages are
Status: implementation in progress; Stages 1–4 are complete and later stages are
pending.
Audience: a `gpt-5.6-terra` coding agent implementing one stage per prompt, in
@@ -463,7 +463,27 @@ Acceptance:
## Stage 4 — Build the reusable schema binding graph
Status: pending.
Status: complete.
Completion evidence (2026-09-06):
- Implemented a per-operation private descriptor graph in [`schema.go`](../../schema.go)
for structural roots, ordinary/named scalars, duration and paired text scalars,
pointers, structs, string-keyed maps, slices, arrays, dynamic values, and recursive
definitions. No global cache or application method execution is involved.
- Implemented declared-field YAML binding, ignored/private fields, explicit struct/
pointer/map inlining, ambiguity and tag validation, one inline map, secret-tag
retention, and schema-aware key/index path traversal. Runtime default collisions,
cyclic values, cloning, projection, and decoding remain assigned to later stages.
- Added the [current schema contract](../config.md#application-schema-binding) and
updated the component inventory and target-roadmap ownership links.
- Focused tests cover nested and inline conflicts, ignored/private fields, maps of
structs, named scalars, pointer text method sets, missing codec pairs, root and
unsupported-kind rejection, recursive descriptors, side-effect freedom, dynamic
and inline-map paths, parsed numeric indices, and array bounds. Verified
`go test ./...`, `go vet ./...`, `go build ./...`, `go mod tidy -diff`, and
`git diff --check` with Go 1.26.7 on linux/amd64. No deviations were required;
typed value operations remain later work.
Read: feature [typed decoding](initial.md#typed-decoding) and
[type/projection contract](initial.md#supported-types-and-projection).

View File

@@ -584,17 +584,16 @@ through validation. Null never means reset to the application default.
## Typed decoding
The schema-binding portion of this target is implemented. The [current application
schema contract](../config.md#application-schema-binding) owns root/type support,
field and tag binding, inlining, text-codec pairing, and schema-aware path rules.
The remaining document-to-value behavior below is target decoding work.
Strictness must be enforced by ConfigKit across the effective tree:
- Unknown struct fields, type mismatches, overflow, and invalid null are errors.
- Support ordinary exported fields and `yaml` names/ignore tags. Untagged field
names use the lowercase Go field name. Embedded structs follow the same rule
unless explicitly tagged `yaml:",inline"`; inline struct fields contribute to
the containing mapping. Permit at most one inline string-keyed map per object,
which receives otherwise unmatched keys. Duplicate declared field names and
default inline-map entries colliding with declared fields are schema errors.
Without an inline map, unmatched keys remain errors. Ignore `omitempty` for
decoding, defaults, and effective inspection.
- Without an inline map, unmatched keys remain errors. Default inline-map entries
colliding with declared fields are checked when runtime defaults are introduced.
- String-keyed maps accept application-defined keys. Struct values inside them
remain strict. `map[string]any` permits arbitrary keys and recursively accepts
the supported scalar/container model; application validation owns its schema.
@@ -1298,12 +1297,10 @@ model, not the application's separately constructed runtime module objects.
### Supported types and projection
The supported root `T` is a struct or string-keyed map. Within it, support ordinary
exported scalar fields, pointers, structs, string-keyed maps, slices, fixed-length
arrays, dynamic `any`, and the explicitly supported scalar conversions. Reject
unsupported kinds, non-string map keys, cyclic object graphs, and ambiguous field
bindings as schema/projection errors. Arrays require matching sequence length.
`yaml:"-"` fields and unexported fields are outside the configuration projection;
Supported schema types and field bindings are implemented and owned by the
[current application schema contract](../config.md#application-schema-binding).
Projection must additionally reject cyclic object graphs. Arrays require matching
sequence length. Ignored and unexported fields stay outside configuration projection;
hooks must not use them to carry runtime resources or configuration needing
inspection. Non-configuration runtime state belongs in separate application types.

View File

@@ -52,6 +52,13 @@ const (
CodeYAMLScalar Code = "yaml.scalar"
CodeYAMLDepthLimit Code = "yaml.depth_limit"
CodeYAMLNodeLimit Code = "yaml.node_limit"
CodeSchemaRoot Code = "schema.root"
CodeSchemaUnsupportedType Code = "schema.unsupported_type"
CodeSchemaAmbiguousField Code = "schema.ambiguous_field"
CodeSchemaTag Code = "schema.tag"
CodeSchemaTextCodec Code = "schema.text_codec"
CodeSchemaInline Code = "schema.inline"
)
// Location identifies a position in a configuration source. Zero line and column

407
schema.go Normal file
View File

@@ -0,0 +1,407 @@
package configkit
import (
"encoding"
"reflect"
"strconv"
"strings"
"time"
)
type descriptorKind uint8
const (
descriptorInvalid descriptorKind = iota
descriptorStruct
descriptorMap
descriptorSlice
descriptorArray
descriptorPointer
descriptorDynamic
descriptorString
descriptorBoolean
descriptorSigned
descriptorUnsigned
descriptorFloat
descriptorDuration
descriptorText
)
type schemaDescriptor struct {
root *typeDescriptor
}
type typeDescriptor struct {
typeOf reflect.Type
kind descriptorKind
element *typeDescriptor
length int
fields []fieldDescriptor
fieldByName map[string]int
inlineMap *fieldDescriptor
}
type fieldDescriptor struct {
name string
index []int
descriptor *typeDescriptor
secret bool
inlineMap bool
}
type schemaIssue struct {
code Code
path Path
message string
}
var (
durationType = reflect.TypeFor[time.Duration]()
textMarshalerType = reflect.TypeFor[encoding.TextMarshaler]()
textUnmarshalerType = reflect.TypeFor[encoding.TextUnmarshaler]()
)
func buildSchema(root reflect.Type) (*schemaDescriptor, *schemaIssue) {
if root == nil || (root.Kind() != reflect.Struct && !isStringMap(root)) {
return nil, &schemaIssue{code: CodeSchemaRoot, message: "configuration root must be a struct or string-keyed map"}
}
builder := schemaBuilder{
cache: make(map[reflect.Type]*typeDescriptor),
building: make(map[reflect.Type]bool),
}
descriptor, issue := builder.describe(root, Path{})
if issue != nil {
return nil, issue
}
if descriptor.kind != descriptorStruct && descriptor.kind != descriptorMap {
return nil, &schemaIssue{code: CodeSchemaRoot, message: "configuration root must be a structural struct or string-keyed map"}
}
return &schemaDescriptor{root: descriptor}, nil
}
type schemaBuilder struct {
cache map[reflect.Type]*typeDescriptor
building map[reflect.Type]bool
}
func (b *schemaBuilder) describe(current reflect.Type, path Path) (*typeDescriptor, *schemaIssue) {
if descriptor, ok := b.cache[current]; ok {
return descriptor, nil
}
descriptor := &typeDescriptor{typeOf: current}
b.cache[current] = descriptor
b.building[current] = true
defer delete(b.building, current)
fail := func(code Code, message string) (*typeDescriptor, *schemaIssue) {
delete(b.cache, current)
return nil, &schemaIssue{code: code, path: path.clone(), message: message}
}
if current.Kind() == reflect.Pointer {
descriptor.kind = descriptorPointer
element, issue := b.describe(current.Elem(), path)
if issue != nil {
return nil, issue
}
descriptor.element = element
return descriptor, nil
}
if current == durationType {
descriptor.kind = descriptorDuration
return descriptor, nil
}
marshals, unmarshals := textCodecSupport(current)
if marshals || unmarshals {
if !marshals || !unmarshals {
return fail(CodeSchemaTextCodec, "custom text scalar must implement both text marshal and unmarshal")
}
descriptor.kind = descriptorText
return descriptor, nil
}
switch current.Kind() {
case reflect.Struct:
descriptor.kind = descriptorStruct
descriptor.fieldByName = make(map[string]int)
if issue := b.describeFields(descriptor, current, path); issue != nil {
delete(b.cache, current)
return nil, issue
}
case reflect.Map:
if current.Key().Kind() != reflect.String {
return fail(CodeSchemaUnsupportedType, "configuration maps must have string keys")
}
descriptor.kind = descriptorMap
element, issue := b.describe(current.Elem(), path)
if issue != nil {
return nil, issue
}
descriptor.element = element
case reflect.Slice:
descriptor.kind = descriptorSlice
element, issue := b.describe(current.Elem(), path.Index(0))
if issue != nil {
return nil, issue
}
descriptor.element = element
case reflect.Array:
descriptor.kind = descriptorArray
descriptor.length = current.Len()
element, issue := b.describe(current.Elem(), path.Index(0))
if issue != nil {
return nil, issue
}
descriptor.element = element
case reflect.Interface:
if current.NumMethod() != 0 {
return fail(CodeSchemaUnsupportedType, "only the empty interface is supported for dynamic values")
}
descriptor.kind = descriptorDynamic
case reflect.String:
descriptor.kind = descriptorString
case reflect.Bool:
descriptor.kind = descriptorBoolean
case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64:
descriptor.kind = descriptorSigned
case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64:
descriptor.kind = descriptorUnsigned
case reflect.Float32, reflect.Float64:
descriptor.kind = descriptorFloat
default:
return fail(CodeSchemaUnsupportedType, "configuration type is not supported")
}
return descriptor, nil
}
func (b *schemaBuilder) describeFields(descriptor *typeDescriptor, current reflect.Type, path Path) *schemaIssue {
for i := 0; i < current.NumField(); i++ {
field := current.Field(i)
if !field.IsExported() {
continue
}
name, inline, ignored, issue := parseYAMLField(field, path)
if issue != nil {
return issue
}
if ignored {
continue
}
secret, issue := parseConfigKitField(field, path)
if issue != nil {
return issue
}
if inline {
if name != "" {
return &schemaIssue{code: CodeSchemaTag, path: path.clone(), message: "inline field cannot declare a YAML name"}
}
inlineType := field.Type
for inlineType.Kind() == reflect.Pointer {
inlineType = inlineType.Elem()
}
switch inlineType.Kind() {
case reflect.Struct:
if b.building[inlineType] {
return &schemaIssue{code: CodeSchemaInline, path: path.clone(), message: "recursive inline struct is not supported"}
}
child, childIssue := b.describe(inlineType, path)
if childIssue != nil {
return childIssue
}
if child.kind != descriptorStruct {
return &schemaIssue{code: CodeSchemaInline, path: path.clone(), message: "inline field must be a structural struct or string-keyed map"}
}
for _, nested := range child.fields {
nested.index = prependIndex(i, nested.index)
nested.secret = secret || nested.secret
if issue := addField(descriptor, nested, path); issue != nil {
return issue
}
}
if child.inlineMap != nil {
nested := *child.inlineMap
nested.index = prependIndex(i, nested.index)
nested.secret = secret || nested.secret
if descriptor.inlineMap != nil {
return &schemaIssue{code: CodeSchemaInline, path: path.clone(), message: "configuration object has more than one inline map"}
}
descriptor.inlineMap = &nested
}
case reflect.Map:
if inlineType.Key().Kind() != reflect.String {
return &schemaIssue{code: CodeSchemaInline, path: path.clone(), message: "inline map must have string keys"}
}
if descriptor.inlineMap != nil {
return &schemaIssue{code: CodeSchemaInline, path: path.clone(), message: "configuration object has more than one inline map"}
}
fieldPath := path.Key("*")
fieldType, childIssue := b.describe(inlineType, fieldPath)
if childIssue != nil {
return childIssue
}
descriptor.inlineMap = &fieldDescriptor{index: []int{i}, descriptor: fieldType, secret: secret, inlineMap: true}
default:
return &schemaIssue{code: CodeSchemaInline, path: path.clone(), message: "inline field must be a struct, pointer to struct, or string-keyed map"}
}
continue
}
fieldPath := path.Key(name)
fieldType, childIssue := b.describe(field.Type, fieldPath)
if childIssue != nil {
return childIssue
}
if issue := addField(descriptor, fieldDescriptor{name: name, index: []int{i}, descriptor: fieldType, secret: secret}, path); issue != nil {
return issue
}
}
return nil
}
func addField(owner *typeDescriptor, field fieldDescriptor, path Path) *schemaIssue {
if _, exists := owner.fieldByName[field.name]; exists {
return &schemaIssue{code: CodeSchemaAmbiguousField, path: path.Key(field.name), message: "configuration field name is ambiguous"}
}
owner.fieldByName[field.name] = len(owner.fields)
owner.fields = append(owner.fields, field)
return nil
}
func parseYAMLField(field reflect.StructField, path Path) (name string, inline, ignored bool, issue *schemaIssue) {
name = strings.ToLower(field.Name)
tag, present := field.Tag.Lookup("yaml")
if !present {
return name, false, false, nil
}
parts := strings.Split(tag, ",")
if parts[0] == "-" {
if len(parts) != 1 {
return "", false, false, &schemaIssue{code: CodeSchemaTag, path: path.clone(), message: "ignored YAML field cannot declare options"}
}
return "", false, true, nil
}
if parts[0] != "" {
name = parts[0]
}
seen := make(map[string]bool, len(parts)-1)
for _, option := range parts[1:] {
if option == "" || seen[option] {
return "", false, false, &schemaIssue{code: CodeSchemaTag, path: path.Key(name), message: "YAML field tag is malformed"}
}
seen[option] = true
switch option {
case "inline":
inline = true
case "omitempty":
// ConfigKit accepts the spelling but never omits projected values.
default:
return "", false, false, &schemaIssue{code: CodeSchemaTag, path: path.Key(name), message: "YAML field tag option is not supported"}
}
}
if inline && parts[0] == "" {
name = ""
}
return name, inline, false, nil
}
func parseConfigKitField(field reflect.StructField, path Path) (bool, *schemaIssue) {
tag, present := field.Tag.Lookup("configkit")
if !present || tag == "" {
return false, nil
}
if tag != "secret" {
return false, &schemaIssue{code: CodeSchemaTag, path: path.clone(), message: "ConfigKit field tag is not supported"}
}
return true, nil
}
func textCodecSupport(current reflect.Type) (marshals, unmarshals bool) {
marshals = current.Implements(textMarshalerType)
unmarshals = current.Implements(textUnmarshalerType)
if current.Kind() != reflect.Pointer {
pointer := reflect.PointerTo(current)
marshals = marshals || pointer.Implements(textMarshalerType)
unmarshals = unmarshals || pointer.Implements(textUnmarshalerType)
}
return marshals, unmarshals
}
func isStringMap(current reflect.Type) bool {
return current.Kind() == reflect.Map && current.Key().Kind() == reflect.String
}
func prependIndex(index int, nested []int) []int {
result := make([]int, 1, len(nested)+1)
result[0] = index
return append(result, nested...)
}
type schemaPath struct {
descriptor *typeDescriptor
field *fieldDescriptor
dynamic bool
}
func resolveSchemaPath(schema *schemaDescriptor, path Path) (schemaPath, bool) {
if schema == nil || schema.root == nil || !path.valid() {
return schemaPath{}, false
}
current := schema.root
var field *fieldDescriptor
for _, segment := range path.segments {
for current.kind == descriptorPointer {
current = current.element
}
switch current.kind {
case descriptorStruct:
if segment.kind == pathSegmentIndex {
return schemaPath{}, false
}
position, ok := current.fieldByName[segment.key]
if ok {
field = &current.fields[position]
current = field.descriptor
continue
}
if current.inlineMap != nil {
field = current.inlineMap
current = current.inlineMap.descriptor.element
continue
}
return schemaPath{}, false
case descriptorMap:
if segment.kind == pathSegmentIndex {
return schemaPath{}, false
}
field = nil
current = current.element
case descriptorSlice, descriptorArray:
index, ok := schemaIndex(segment)
if !ok || (current.kind == descriptorArray && index >= current.length) {
return schemaPath{}, false
}
field = nil
current = current.element
case descriptorDynamic:
return schemaPath{descriptor: current, field: field, dynamic: true}, true
default:
return schemaPath{}, false
}
}
for current.kind == descriptorPointer {
current = current.element
}
return schemaPath{descriptor: current, field: field, dynamic: current.kind == descriptorDynamic}, true
}
func schemaIndex(segment pathSegment) (int, bool) {
if segment.kind == pathSegmentIndex {
return segment.index, segment.index >= 0
}
if segment.kind != pathSegmentParsed || segment.key == "" || (len(segment.key) > 1 && segment.key[0] == '0') {
return 0, false
}
index, err := strconv.Atoi(segment.key)
return index, err == nil && index >= 0
}

304
schema_test.go Normal file
View File

@@ -0,0 +1,304 @@
package configkit
import (
"reflect"
"testing"
"time"
)
type namedCount int32
type textValue struct {
value string
}
func (v textValue) MarshalText() ([]byte, error) {
return []byte(v.value), nil
}
func (v *textValue) UnmarshalText(data []byte) error {
v.value = string(data)
return nil
}
type marshalOnly string
func (v marshalOnly) MarshalText() ([]byte, error) {
return []byte(v), nil
}
type unmarshalOnly string
func (v *unmarshalOnly) UnmarshalText(data []byte) error {
*v = unmarshalOnly(data)
return nil
}
type recursiveConfig struct {
Name string `yaml:"name"`
Next *recursiveConfig `yaml:"next"`
Children map[string]recursiveConfig `yaml:"children"`
}
func TestBuildSchemaDescribesSupportedTypes(t *testing.T) {
t.Parallel()
type Embedded struct {
InlineName string `yaml:"inline_name"`
}
type nested struct {
Enabled bool `yaml:"enabled"`
}
type config struct {
HTTPServer string `yaml:"server,omitempty" configkit:"secret"`
Count namedCount `yaml:"count"`
Delay time.Duration `yaml:"delay"`
Custom textValue `yaml:"custom"`
Optional *textValue `yaml:"optional"`
Nested nested `yaml:"nested"`
Objects map[string]nested `yaml:"objects"`
Items []nested `yaml:"items"`
Fixed [2]string `yaml:"fixed"`
Dynamic any `yaml:"dynamic"`
*Embedded `yaml:",inline"`
Extras map[string]int `yaml:",inline"`
UntaggedField bool
Ignored chan int `yaml:"-"`
private chan int
}
schema, issue := buildSchema(reflect.TypeFor[config]())
if issue != nil {
t.Fatalf("buildSchema() issue = %#v", issue)
}
root := schema.root
checks := map[string]descriptorKind{
"server": descriptorString,
"count": descriptorSigned,
"delay": descriptorDuration,
"custom": descriptorText,
"optional": descriptorPointer,
"nested": descriptorStruct,
"objects": descriptorMap,
"items": descriptorSlice,
"fixed": descriptorArray,
"dynamic": descriptorDynamic,
"inline_name": descriptorString,
"untaggedfield": descriptorBoolean,
}
for name, want := range checks {
position, ok := root.fieldByName[name]
if !ok {
t.Errorf("field %q missing", name)
continue
}
got := root.fields[position].descriptor.kind
if got != want {
t.Errorf("field %q kind = %v, want %v", name, got, want)
}
}
server := root.fields[root.fieldByName["server"]]
if !server.secret {
t.Fatal("secret tag was not retained")
}
if root.inlineMap == nil || root.inlineMap.descriptor.kind != descriptorMap {
t.Fatalf("inline map = %#v", root.inlineMap)
}
if mapped, mapIssue := buildSchema(reflect.TypeFor[map[string]nested]()); mapIssue != nil || mapped.root.kind != descriptorMap {
t.Fatalf("string-keyed root map = %#v, issue=%#v", mapped, mapIssue)
}
if _, exists := root.fieldByName["ignored"]; exists {
t.Fatal("ignored field was bound")
}
}
func TestBuildSchemaSupportsRecursiveDescriptorsWithoutSharingLoads(t *testing.T) {
t.Parallel()
first, issue := buildSchema(reflect.TypeFor[recursiveConfig]())
if issue != nil {
t.Fatal(issue.message)
}
next := first.root.fields[first.root.fieldByName["next"]].descriptor
if next.kind != descriptorPointer || next.element != first.root {
t.Fatal("recursive pointer did not refer to the existing descriptor")
}
children := first.root.fields[first.root.fieldByName["children"]].descriptor
if children.kind != descriptorMap || children.element != first.root {
t.Fatal("recursive map did not refer to the existing descriptor")
}
second, issue := buildSchema(reflect.TypeFor[recursiveConfig]())
if issue != nil {
t.Fatal(issue.message)
}
if first.root == second.root {
t.Fatal("descriptor graph was cached across loads")
}
}
func TestBuildSchemaRejectsInvalidRootsTypesTagsAndBindings(t *testing.T) {
t.Parallel()
type InlineA struct {
Value string `yaml:"same"`
}
type InlineB struct {
Value string `yaml:"same"`
}
type ambiguous struct {
InlineA `yaml:",inline"`
InlineB `yaml:",inline"`
}
type directConflict struct {
Same string `yaml:"same"`
InlineA `yaml:",inline"`
}
type twoMaps struct {
First map[string]string `yaml:",inline"`
Second map[string]string `yaml:",inline"`
}
type invalidInline struct {
Value string `yaml:",inline"`
}
type invalidMap struct {
Values map[int]string `yaml:"values"`
}
type unsupported struct {
Value complex64 `yaml:"value"`
}
type badOption struct {
Value string `yaml:"value,flow"`
}
type badConfigTag struct {
Value string `configkit:"private"`
}
type missingMarshal struct {
Value unmarshalOnly `yaml:"value"`
}
type missingUnmarshal struct {
Value marshalOnly `yaml:"value"`
}
type RecursiveInline struct {
*RecursiveInline `yaml:",inline"`
}
tests := []struct {
name string
typeOf reflect.Type
code Code
}{
{name: "nil root", typeOf: nil, code: CodeSchemaRoot},
{name: "pointer root", typeOf: reflect.TypeFor[*recursiveConfig](), code: CodeSchemaRoot},
{name: "slice root", typeOf: reflect.TypeFor[[]string](), code: CodeSchemaRoot},
{name: "non-string root map", typeOf: reflect.TypeFor[map[int]string](), code: CodeSchemaRoot},
{name: "text scalar root", typeOf: reflect.TypeFor[textValue](), code: CodeSchemaRoot},
{name: "ambiguous fields", typeOf: reflect.TypeFor[ambiguous](), code: CodeSchemaAmbiguousField},
{name: "direct inline conflict", typeOf: reflect.TypeFor[directConflict](), code: CodeSchemaAmbiguousField},
{name: "multiple inline maps", typeOf: reflect.TypeFor[twoMaps](), code: CodeSchemaInline},
{name: "invalid inline", typeOf: reflect.TypeFor[invalidInline](), code: CodeSchemaInline},
{name: "invalid nested map", typeOf: reflect.TypeFor[invalidMap](), code: CodeSchemaUnsupportedType},
{name: "unsupported kind", typeOf: reflect.TypeFor[unsupported](), code: CodeSchemaUnsupportedType},
{name: "unsupported YAML option", typeOf: reflect.TypeFor[badOption](), code: CodeSchemaTag},
{name: "unsupported ConfigKit tag", typeOf: reflect.TypeFor[badConfigTag](), code: CodeSchemaTag},
{name: "missing text marshal", typeOf: reflect.TypeFor[missingMarshal](), code: CodeSchemaTextCodec},
{name: "missing text unmarshal", typeOf: reflect.TypeFor[missingUnmarshal](), code: CodeSchemaTextCodec},
{name: "recursive inline", typeOf: reflect.TypeFor[RecursiveInline](), code: CodeSchemaInline},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
if _, issue := buildSchema(test.typeOf); issue == nil || issue.code != test.code {
t.Fatalf("issue = %#v, want %s", issue, test.code)
}
})
}
}
func TestBuildSchemaDoesNotExecuteApplicationMethods(t *testing.T) {
t.Parallel()
type config struct {
Value panicText `yaml:"value"`
Structural yamlHookStruct `yaml:"structural"`
}
if _, issue := buildSchema(reflect.TypeFor[config]()); issue != nil {
t.Fatal(issue.message)
}
}
type panicText struct{}
type yamlHookStruct struct {
Value string `yaml:"value"`
}
func (*yamlHookStruct) UnmarshalYAML(any) error {
panic("descriptor construction called parser hook")
}
func (panicText) MarshalText() ([]byte, error) {
panic("descriptor construction called MarshalText")
}
func (*panicText) UnmarshalText([]byte) error {
panic("descriptor construction called UnmarshalText")
}
func (panicText) UnmarshalYAML(any) error {
panic("descriptor construction called parser hook")
}
func TestResolveSchemaPathUsesContainerSemantics(t *testing.T) {
t.Parallel()
type item struct {
Name string `yaml:"name"`
}
type config struct {
Items []item `yaml:"items"`
Values map[string]item `yaml:"values"`
Fixed [2]string `yaml:"fixed"`
Dynamic any `yaml:"dynamic"`
Extras map[string]string `yaml:",inline"`
}
schema, issue := buildSchema(reflect.TypeFor[config]())
if issue != nil {
t.Fatal(issue.message)
}
valid := []Path{
Keys("items").Index(0).Key("name"),
mustPath(t, "/items/0/name"),
Keys("values", "0", "name"),
mustPath(t, "/values/0/name"),
Keys("fixed").Index(1),
Keys("dynamic", "anything").Index(9),
Keys("application_key"),
}
for _, path := range valid {
if _, ok := resolveSchemaPath(schema, path); !ok {
t.Errorf("valid path %q rejected", path.String())
}
}
invalid := []Path{
Keys("missing", "field"),
Keys("items", "0", "name"),
mustPath(t, "/items/00/name"),
Keys("fixed").Index(2),
Keys("values").Index(0),
Path{}.Index(-1),
}
for _, path := range invalid {
if _, ok := resolveSchemaPath(schema, path); ok {
t.Errorf("invalid path %q accepted", path.String())
}
}
}
func mustPath(t *testing.T, pointer string) Path {
t.Helper()
path, err := ParsePath(pointer)
if err != nil {
t.Fatal(err)
}
return path
}