From 2b65876a7d30bd352d1f6e41fb100e9fc142d202 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sun, 6 Sep 2026 19:36:14 +0000 Subject: [PATCH] Build reusable schema descriptors --- docs/config.md | 35 +++ docs/internal/overview.md | 4 +- docs/roadmap/implementation.md | 24 +- docs/roadmap/initial.md | 25 +- records.go | 7 + schema.go | 407 +++++++++++++++++++++++++++++++++ schema_test.go | 304 ++++++++++++++++++++++++ 7 files changed, 789 insertions(+), 17 deletions(-) create mode 100644 schema.go create mode 100644 schema_test.go diff --git a/docs/config.md b/docs/config.md index ce9be67..8a17c53 100644 --- a/docs/config.md +++ b/docs/config.md @@ -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.*` diff --git a/docs/internal/overview.md b/docs/internal/overview.md index 8dc3beb..25e43c4 100644 --- a/docs/internal/overview.md +++ b/docs/internal/overview.md @@ -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. diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 9f97eaf..19f0c5f 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -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). diff --git a/docs/roadmap/initial.md b/docs/roadmap/initial.md index a671ae3..4f8e924 100644 --- a/docs/roadmap/initial.md +++ b/docs/roadmap/initial.md @@ -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. diff --git a/records.go b/records.go index 4e210cf..1a8ba5b 100644 --- a/records.go +++ b/records.go @@ -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 diff --git a/schema.go b/schema.go new file mode 100644 index 0000000..fc048f3 --- /dev/null +++ b/schema.go @@ -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 = ¤t.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 +} diff --git a/schema_test.go b/schema_test.go new file mode 100644 index 0000000..1072f9b --- /dev/null +++ b/schema_test.go @@ -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 +}