46 Commits

Author SHA1 Message Date
cd730d5c99 Prepare ConfigKit v0.1.1 2026-09-07 03:06:14 +00:00
55fd42921a Prepare ConfigKit v0.1.0 2026-09-07 01:52:38 +00:00
4bd83639a3 Retire completed roadmaps and preserve release gates 2026-09-07 01:42:57 +00:00
994a9ea0e2 Add an everyday check command and contributor test map 2026-09-07 01:40:39 +00:00
cb5b4356aa Complete ownership documentation and integration validation 2026-09-07 01:19:00 +00:00
22a03c9f4d Separate explanation replay snapshots and rendering 2026-09-07 01:14:17 +00:00
6a1c7df4be Extract explicit configuration loading phases 2026-09-07 01:13:20 +00:00
75a5829132 Clarify filesystem directive and override ownership 2026-09-07 01:10:21 +00:00
1cc645f30f Separate projection cloning and value comparison 2026-09-07 01:08:43 +00:00
633c5cd519 Share semantic document traversal helpers 2026-09-07 01:07:02 +00:00
fc2b6c81a1 Share bounded regular file read mechanics 2026-09-07 01:06:35 +00:00
a04d32c389 Centralize parser diagnostic translation 2026-09-07 01:04:08 +00:00
49e04442da Remove unused overlay value snapshots 2026-09-07 01:02:27 +00:00
874adaaa18 Document results of a code maintainability audit for an upcoming implementation sprint 2026-09-06 19:58:18 -05:00
9b147bfd96 Reject validator changes to secret classification 2026-09-06 19:20:04 -05:00
fb2e2c5386 Run release checks with the stock macOS Bash 2026-09-06 19:16:00 -05:00
537d26e359 Check canonical source paths correctly on macOS 2026-09-06 19:13:16 -05:00
3e70ad1285 Reject unsupported inline pointer schemas safely 2026-09-06 19:12:19 -05:00
be2ba9a20b Validate metadata queries against semantic path shapes 2026-09-06 19:11:30 -05:00
edd82e15b2 Replay added configuration paths in explanations 2026-09-06 19:08:29 -05:00
831c83a0b5 Explain defaults restored during final decoding 2026-09-06 19:07:03 -05:00
f7ca150a7f Detach callback values before publishing configuration 2026-09-06 19:05:28 -05:00
9c94f2b70a Reject nonregular configuration files before opening them 2026-09-06 19:04:04 -05:00
e0f9541f5f Preserve secret tags in dynamic configuration values 2026-09-06 19:03:00 -05:00
f42ecc70c3 Prepare source release validation 2026-09-06 22:47:12 +00:00
2836f825b6 Harden configuration boundaries with fuzz coverage 2026-09-06 22:35:01 +00:00
a8bfb8848d Add maintained consumer migration examples 2026-09-06 22:26:14 +00:00
6ef240b1a0 Add safe historical configuration explanations 2026-09-06 22:14:54 +00:00
d4ec8607d1 Apply explicit CLI configuration overrides 2026-09-06 21:55:35 +00:00
d6e32d8807 Add typed environment secret references 2026-09-06 21:40:15 +00:00
16f4212770 Add explicit secrets directory bootstrap 2026-09-06 21:29:25 +00:00
828fdd38eb Add redacted effective rendering 2026-09-06 21:21:11 +00:00
26cece6d53 Unify layered provenance history 2026-09-06 21:09:52 +00:00
ef0e6078f7 Add selected profile overlays 2026-09-06 21:03:45 +00:00
71a73bcfd8 Complete bounded glob imports 2026-09-06 20:51:21 +00:00
9f04c09be3 Add explicit path import traversal 2026-09-06 20:41:40 +00:00
3257b2124c Implement strict document composition 2026-09-06 20:26:57 +00:00
a0cd0977ee Complete single-source semantic validation 2026-09-06 20:19:58 +00:00
0da4260ad6 Add audited application resolution 2026-09-06 20:14:55 +00:00
cc41653bfd Add single-source loading and version checks 2026-09-06 20:03:22 +00:00
21e3f633bc Decode typed configuration over defaults 2026-09-06 19:52:45 +00:00
15172294b5 Implement canonical projection and detached copies 2026-09-06 19:44:36 +00:00
2b65876a7d Build reusable schema descriptors 2026-09-06 19:36:14 +00:00
7c78da9cd2 Add bounded sources and strict YAML parsing 2026-09-06 19:30:31 +00:00
14b10cb6a5 Add semantic paths and safe diagnostics 2026-09-06 19:19:24 +00:00
8e3cc40c78 Establish ConfigKit module and extraction baseline 2026-09-06 19:13:36 +00:00
119 changed files with 17170 additions and 2693 deletions

View File

@@ -2,9 +2,38 @@
An opinionated Go library for applications that use YAML configuration files.
ConfigKit is in pre-implementation. This repository currently contains its design
and contributor policies; no usable Go library has been implemented yet.
ConfigKit's initial loader accepts one strict YAML
source (or explicit defaults-only input), optionally traverses explicit path and glob
imports, strictly composes their disjoint definitions, applies typed defaults, and
can overlay one selected fixed profile and check an application schema version. It
returns detached metadata and safe diagnostics.
Applications may perform audited defaulting and normalization through a resolver,
then report domain errors and warnings through read-only semantic validation. Secret
tags and a path predicate drive deterministic redacted effective YAML. Typed secret
references resolve environment names before validation, and explicit
secrets-directory bootstrap is available as a separate startup operation. Explicit
CLI override records apply above profiles. Value explanation is available through
detached structured records and deterministic redacted output.
The [initial target design](docs/roadmap/initial.md) describes planned behavior.
The [implementation plan](docs/roadmap/implementation.md) owns the pending stages.
See the [development guide](docs/development.md) for repository orientation.
```go
type Config struct {
Enabled bool `yaml:"enabled"`
Workers int `yaml:"workers"`
}
func ReadConfig(path string) (Config, error) {
result, err := configkit.Load[Config](configkit.File(path))
if err != nil {
return Config{}, err
}
return result.Config, nil
}
```
The [configuration contract](docs/config.md) describes implemented behavior. The
[consumer adoption guide](docs/consumers/README.md) provides maintained Narratio-,
Notarius-, WeatherReporter-, and Seriatim-shaped examples and migration guidance.
The [deferred scope](docs/roadmap/deferred.md) records deliberate exclusions. See the
[development guide](docs/development.md) for repository orientation and the
[release procedure](docs/release.md) for maintainer-only candidate and publication
steps.

View File

@@ -0,0 +1,52 @@
package configkit
import "testing"
func TestCallbackAssignmentsAreDetachedAcrossLoads(t *testing.T) {
type object struct{ Value string }
type cfg struct {
Map map[string][]*object
Pointer *object
Secret Secret
}
for _, validatorAssignment := range []bool{false, true} {
name := "resolver"
if validatorAssignment {
name = "validator"
}
t.Run(name, func(t *testing.T) {
external := &object{Value: "original"}
shared := map[string][]*object{"key": {external}}
definition := Definition[cfg]{Resolve: func(c *cfg, r *Resolution) error {
Normalize(r, Keys("map"), &c.Map, shared, "populate")
c.Pointer = external
r.Record(Keys("pointer"), ChangeNormalization, "populate")
c.Secret = SecretEnv("CONFIGKIT_CALLBACK_SECRET")
r.Record(Keys("secret"), ChangeNormalization, "reference")
return nil
}}
if validatorAssignment {
definition.Validate = func(c *cfg, v *Validation) { c.Map = shared; c.Pointer = external }
}
calls := 0
lookup := func(string) (string, bool) { calls++; return "credential", true }
first, err := loadWithSecretLookup(NoSource(), lookup, Options[cfg]{Definition: definition})
if err != nil {
t.Fatal(err)
}
second, err := loadWithSecretLookup(NoSource(), lookup, Options[cfg]{Definition: definition})
if err != nil {
t.Fatal(err)
}
first.Config.Map["key"][0].Value = "changed"
first.Config.Pointer.Value = "changed pointer"
external.Value = "external change"
if second.Config.Map["key"][0].Value != "original" || second.Config.Pointer.Value != "original" {
t.Fatal("published configuration shares callback state")
}
if second.Config.Secret.Value() != "credential" || !second.Config.Secret.IsResolved() || calls != 2 {
t.Fatal("publication changed secret state or repeated environment lookup")
}
})
}
}

159
clone.go Normal file
View File

@@ -0,0 +1,159 @@
package configkit
import (
"encoding"
"math"
"reflect"
)
// secretCloneMode distinguishes fresh unresolved references from publication of
// already validated private state, which must not trigger another lookup.
type secretCloneMode uint8
const (
cloneUnresolvedSecrets secretCloneMode = iota
cloneValidatedSecrets
)
func cloneConfiguration(schema *schemaDescriptor, value reflect.Value, limits projectionLimits) (reflect.Value, *projectionIssue) {
return cloneConfigurationWithMode(schema, value, limits, cloneUnresolvedSecrets)
}
func cloneConfigurationWithMode(schema *schemaDescriptor, value reflect.Value, limits projectionLimits, mode secretCloneMode) (reflect.Value, *projectionIssue) {
if schema == nil || schema.root == nil || !value.IsValid() || value.Type() != schema.root.typeOf {
return reflect.Value{}, &projectionIssue{code: CodeSchemaUnsupportedType, message: "configuration value does not match its schema"}
}
operation := valueOperation{schema: schema, limits: limits, active: make(map[valueVisit]bool), cloneMode: mode}
return operation.clone(value, schema.root, Path{}, 0)
}
func (o *valueOperation) clone(value reflect.Value, descriptor *typeDescriptor, path Path, depth int) (reflect.Value, *projectionIssue) {
if issue := o.checkDepth(path, depth); issue != nil {
return reflect.Value{}, issue
}
if descriptor.kind == descriptorPointer {
if value.IsNil() {
return reflect.Zero(value.Type()), nil
}
leave, issue := o.enter(value, path)
if issue != nil {
return reflect.Value{}, issue
}
defer leave()
child, issue := o.clone(value.Elem(), descriptor.element, path, depth)
if issue != nil {
return reflect.Value{}, issue
}
result := reflect.New(value.Type().Elem())
result.Elem().Set(child)
return result, nil
}
switch descriptor.kind {
case descriptorStruct:
result := reflect.New(value.Type()).Elem()
for _, member := range descriptor.members {
memberPath, memberDepth := path, depth
if !member.inline {
memberPath, memberDepth = path.Key(member.name), depth+1
}
cloned, issue := o.clone(value.Field(member.index), member.descriptor, memberPath, memberDepth)
if issue != nil {
return reflect.Value{}, issue
}
result.Field(member.index).Set(cloned)
}
return result, nil
case descriptorMap:
if value.IsNil() {
return reflect.Zero(value.Type()), nil
}
leave, issue := o.enter(value, path)
if issue != nil {
return reflect.Value{}, issue
}
defer leave()
result := reflect.MakeMapWithSize(value.Type(), value.Len())
for _, key := range value.MapKeys() {
cloned, issue := o.clone(value.MapIndex(key), descriptor.element, path.Key(key.String()), depth+1)
if issue != nil {
return reflect.Value{}, issue
}
result.SetMapIndex(key, cloned)
}
return result, nil
case descriptorSlice:
if value.IsNil() {
return reflect.Zero(value.Type()), nil
}
leave, issue := o.enter(value, path)
if issue != nil {
return reflect.Value{}, issue
}
defer leave()
result := reflect.MakeSlice(value.Type(), value.Len(), value.Len())
for i := 0; i < value.Len(); i++ {
cloned, issue := o.clone(value.Index(i), descriptor.element, path.Index(i), depth+1)
if issue != nil {
return reflect.Value{}, issue
}
result.Index(i).Set(cloned)
}
return result, nil
case descriptorArray:
result := reflect.New(value.Type()).Elem()
for i := 0; i < value.Len(); i++ {
cloned, issue := o.clone(value.Index(i), descriptor.element, path.Index(i), depth+1)
if issue != nil {
return reflect.Value{}, issue
}
result.Index(i).Set(cloned)
}
return result, nil
case descriptorDynamic:
if value.IsNil() {
return reflect.Zero(value.Type()), nil
}
concrete := value.Elem()
dynamic, issue := o.dynamicDescriptor(concrete.Type(), path)
if issue != nil {
return reflect.Value{}, issue
}
cloned, issue := o.clone(concrete, dynamic, path, depth)
if issue != nil {
return reflect.Value{}, issue
}
result := reflect.New(value.Type()).Elem()
result.Set(cloned)
return result, nil
case descriptorSecret:
result := reflect.New(value.Type()).Elem()
reference := value.Interface().(Secret)
if o.cloneMode == cloneValidatedSecrets {
result.Set(value)
} else if reference.specified {
result.Set(reflect.ValueOf(SecretEnv(reference.EnvName())))
}
return result, nil
case descriptorText:
text, issue := marshalCanonicalText(value, path)
if issue != nil {
return reflect.Value{}, issue
}
result := reflect.New(value.Type()).Elem()
unmarshaler := result.Addr().Interface().(encoding.TextUnmarshaler)
if err := unmarshaler.UnmarshalText([]byte(text)); err != nil {
return reflect.Value{}, &projectionIssue{code: CodeSchemaCodec, path: path.clone(), message: "custom text scalar could not be reconstructed"}
}
return result, nil
case descriptorFloat:
if math.IsNaN(value.Float()) || math.IsInf(value.Float(), 0) {
return reflect.Value{}, &projectionIssue{code: CodeSchemaNonFinite, path: path.clone(), message: "configuration float must be finite"}
}
fallthrough
default:
result := reflect.New(value.Type()).Elem()
result.Set(value)
return result, nil
}
}

190
compose.go Normal file
View File

@@ -0,0 +1,190 @@
package configkit
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
// composedDocument is the private handoff between source assembly and later
// precedence layers. Claims remain ordered by source and declaration traversal;
// origins retain every source that contributes to a composite mapping.
type composedDocument struct {
root *document.Node
claims []compositionClaim
origins map[string]Origin
overlays []overlayRecord
applications []historyApplication
inputBytes int64
inputNodes int
}
// cloneComposedDocument detaches mutable composition and provenance state;
// history application trees are immutable and retain their existing sharing.
func cloneComposedDocument(base *composedDocument) *composedDocument {
return &composedDocument{
root: document.Clone(base.root), claims: cloneCompositionClaims(base.claims),
origins: cloneOriginIndex(base.origins), overlays: cloneOverlayRecords(base.overlays),
applications: cloneHistoryApplications(base.applications),
inputBytes: base.inputBytes, inputNodes: base.inputNodes,
}
}
type compositionClaim struct {
path Path
kind document.Kind
location Location
}
type compositionClaims struct {
ordered []compositionClaim
byPath map[string][]compositionClaim
}
// composeDocuments recursively joins disjoint mapping entries. Source order
// controls only output traversal and diagnostics: every non-mapping overlap is
// rejected before a result is assembled.
func composeDocuments(inputs ...*document.Node) (*composedDocument, []Diagnostic) {
if len(inputs) == 0 {
return nil, []Diagnostic{compositionInvalidDiagnostic("strict composition requires at least one document", nil)}
}
collected := compositionClaims{byPath: make(map[string][]compositionClaim)}
for _, input := range inputs {
if input == nil || input.Kind != document.Mapping {
var location *Location
if input != nil {
converted := publicLocation(input.Location)
location = &converted
}
return nil, []Diagnostic{compositionInvalidDiagnostic("strict composition requires mapping document roots", location)}
}
collected.append(input, Path{})
}
for _, claim := range collected.ordered {
claims := collected.byPath[claim.path.String()]
if len(claims) < 2 || allMappingClaims(claims) {
continue
}
return nil, []Diagnostic{compositionConflictDiagnostic(claim.path, claims)}
}
root := document.Clone(inputs[0])
for _, input := range inputs[1:] {
mergeDisjointMappings(root, input)
}
applications := make([]historyApplication, 0, len(inputs))
for _, input := range inputs {
applications = append(applications, historyApplication{layer: LayerBase, value: input})
}
return &composedDocument{
root: root,
claims: cloneCompositionClaims(collected.ordered),
origins: compositionOrigins(collected.ordered),
applications: applications,
}, nil
}
func (c *compositionClaims) append(node *document.Node, path Path) {
claim := compositionClaim{
path: path.clone(),
kind: node.Kind,
location: publicLocation(node.Location),
}
c.ordered = append(c.ordered, claim)
pointer := path.String()
c.byPath[pointer] = append(c.byPath[pointer], claim)
if node.Kind != document.Mapping {
return
}
for _, entry := range node.Mapping {
c.append(entry.Value, path.Key(entry.Key))
}
}
func allMappingClaims(claims []compositionClaim) bool {
for _, claim := range claims {
if claim.kind != document.Mapping {
return false
}
}
return true
}
func mergeDisjointMappings(target, incoming *document.Node) {
for _, incomingEntry := range incoming.Mapping {
index := documentEntryIndex(target.Mapping, incomingEntry.Key)
if index < 0 {
cloned := incomingEntry
cloned.Value = document.Clone(incomingEntry.Value)
target.Mapping = append(target.Mapping, cloned)
continue
}
// Claim validation guarantees that the only remaining overlap is a
// mapping pair. Empty mappings naturally contribute no child entries.
mergeDisjointMappings(target.Mapping[index].Value, incomingEntry.Value)
}
}
func documentEntryIndex(entries []document.Entry, key string) int {
for index := range entries {
if entries[index].Key == key {
return index
}
}
return -1
}
func compositionOrigins(claims []compositionClaim) map[string]Origin {
origins := make(map[string]Origin, len(claims))
for _, claim := range claims {
pointer := claim.path.String()
origin := origins[pointer]
origin.Layer = LayerBase
origin.Locations = append(origin.Locations, claim.location)
origins[pointer] = origin
}
return origins
}
func applyCompositionOrigins(decoded *decodedConfiguration, origins map[string]Origin) {
for pointer, origin := range origins {
if _, present := decoded.presence[pointer]; present {
decoded.origins[pointer] = cloneOrigin(origin)
}
}
}
func cloneCompositionClaims(claims []compositionClaim) []compositionClaim {
cloned := make([]compositionClaim, len(claims))
for index, claim := range claims {
cloned[index] = claim
cloned[index].path = claim.path.clone()
}
return cloned
}
func compositionInvalidDiagnostic(message string, location *Location) Diagnostic {
return Diagnostic{
Code: CodeComposeInvalid, Severity: SeverityError, Message: message,
Primary: cloneLocation(location),
}
}
func compositionConflictDiagnostic(path Path, claims []compositionClaim) Diagnostic {
primary := claims[0].location
related := make([]Location, 0, len(claims)-1)
for _, claim := range claims[1:] {
related = append(related, claim.location)
}
return Diagnostic{
Code: CodeComposeConflict,
Severity: SeverityError,
Path: path.clone(),
Message: "configuration path is defined by multiple sources",
Primary: &primary,
Related: related,
}
}
func publicLocation(location document.Location) Location {
return Location{SourceID: location.SourceID, Line: location.Line, Column: location.Column}
}

194
compose_test.go Normal file
View File

@@ -0,0 +1,194 @@
package configkit
import (
"reflect"
"testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
func TestComposeDocumentsJoinsDisjointMappingsAndTracksClaims(t *testing.T) {
base := parseCompositionDocument(t, "source-1", `services:
api:
enabled: true
empty: {}
`)
fragment := parseCompositionDocument(t, "source-2", `services:
worker:
enabled: false
values: [one, two]
`)
baseBefore := document.Clone(base)
fragmentBefore := document.Clone(fragment)
composed, diagnostics := composeDocuments(base, fragment)
if len(diagnostics) != 0 {
t.Fatalf("composeDocuments() diagnostics = %#v", diagnostics)
}
for _, path := range []Path{
Keys("services", "api", "enabled"),
Keys("services", "worker", "enabled"),
Keys("empty"),
Keys("values"),
} {
if _, ok := documentAtPath(composed.root, path); !ok {
t.Errorf("composed document is missing %s", path.String())
}
}
serviceOrigin := composed.origins[Keys("services").String()]
wantLocations := []Location{
{SourceID: "source-1", Line: 2, Column: 3},
{SourceID: "source-2", Line: 2, Column: 3},
}
if !reflect.DeepEqual(serviceOrigin, Origin{Layer: LayerBase, Locations: wantLocations}) {
t.Fatalf("services origin = %#v, want composite origin", serviceOrigin)
}
wantClaimPaths := []string{"", "/services", "/services/api", "/services/api/enabled", "/empty", "", "/services", "/services/worker", "/services/worker/enabled", "/values"}
gotClaimPaths := make([]string, len(composed.claims))
for index, claim := range composed.claims {
gotClaimPaths[index] = claim.path.String()
}
if !reflect.DeepEqual(gotClaimPaths, wantClaimPaths) {
t.Fatalf("claim paths = %#v, want %#v", gotClaimPaths, wantClaimPaths)
}
reversed, reversedDiagnostics := composeDocuments(fragment, base)
if len(reversedDiagnostics) != 0 {
t.Fatalf("reversed composeDocuments() diagnostics = %#v", reversedDiagnostics)
}
if deltas := compareConfigurationValues(composed.root, reversed.root); len(deltas) != 0 {
t.Fatalf("input order changed effective values: %#v", deltas)
}
if !reflect.DeepEqual(base, baseBefore) || !reflect.DeepEqual(fragment, fragmentBefore) {
t.Fatal("composition mutated an input document")
}
composed.root.Mapping[0].Value.Mapping[0].Value.Mapping[0].Value.Text = "changed"
if !reflect.DeepEqual(base, baseBefore) || !reflect.DeepEqual(fragment, fragmentBefore) {
t.Fatal("composed document aliases an input document")
}
}
func TestComposeDocumentsRejectsEveryPresentNonMappingOverlap(t *testing.T) {
tests := []struct {
name string
left string
right string
path string
}{
{name: "equal scalar", left: "value: true\n", right: "value: true\n", path: "/value"},
{name: "different scalar", left: "value: true\n", right: "value: false\n", path: "/value"},
{name: "null", left: "value: null\n", right: "value: null\n", path: "/value"},
{name: "sequence", left: "value: [one]\n", right: "value: [two]\n", path: "/value"},
{name: "mapping and scalar", left: "value:\n nested: true\n", right: "value: text\n", path: "/value"},
{name: "scalar and mapping", left: "value: text\n", right: "value:\n nested: true\n", path: "/value"},
{name: "nested keyed entry", left: "items:\n shared:\n left: 1\n", right: "items:\n shared:\n left: 2\n", path: "/items/shared/left"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
left := parseCompositionDocument(t, "source-1", test.left)
right := parseCompositionDocument(t, "source-2", test.right)
_, diagnostics := composeDocuments(left, right)
assertCompositionConflict(t, diagnostics, test.path, []string{"source-1", "source-2"})
})
}
}
func TestComposeDocumentsReportsAllClaimantsAtExactLocations(t *testing.T) {
first := parseCompositionDocument(t, "source-1", "group:\n value: 1\n")
second := parseCompositionDocument(t, "source-2", "group:\n value: 2\n")
third := parseCompositionDocument(t, "source-3", "group:\n value: 3\n")
_, diagnostics := composeDocuments(first, second, third)
assertCompositionConflict(t, diagnostics, "/group/value", []string{"source-1", "source-2", "source-3"})
locations := append([]Location{*diagnostics[0].Primary}, diagnostics[0].Related...)
for _, location := range locations {
if location.Line != 2 || location.Column != 10 {
t.Fatalf("claim location = %#v, want line 2 column 10", location)
}
}
_, diagnostics = composeDocuments(
parseCompositionDocument(t, "source-1", "group:\n left: 1\n"),
parseCompositionDocument(t, "source-2", "group:\n right: 2\n"),
parseCompositionDocument(t, "source-3", "group: blocked\n"),
)
assertCompositionConflict(t, diagnostics, "/group", []string{"source-1", "source-2", "source-3"})
}
func TestComposeDocumentsTreatsEmptyMappingsAsChildlessContributions(t *testing.T) {
empty, diagnostics := composeDocuments(
parseCompositionDocument(t, "source-1", "{}\n"),
parseCompositionDocument(t, "source-2", "{}\n"),
)
if len(diagnostics) != 0 || len(empty.root.Mapping) != 0 || len(empty.origins[""].Locations) != 2 {
t.Fatalf("empty root composition = (%#v, %#v)", empty, diagnostics)
}
tests := []struct {
name string
left string
right string
}{
{name: "two empty mappings", left: "value: {}\n", right: "value: {}\n"},
{name: "empty then populated", left: "value: {}\n", right: "value: {nested: true}\n"},
{name: "populated then empty", left: "value: {nested: true}\n", right: "value: {}\n"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
composed, diagnostics := composeDocuments(
parseCompositionDocument(t, "source-1", test.left),
parseCompositionDocument(t, "source-2", test.right),
)
if len(diagnostics) != 0 {
t.Fatalf("composeDocuments() diagnostics = %#v", diagnostics)
}
if origin := composed.origins["/value"]; len(origin.Locations) != 2 {
t.Fatalf("value origin = %#v, want both mapping locations", origin)
}
if test.name != "two empty mappings" {
if _, ok := documentAtPath(composed.root, Keys("value", "nested")); !ok {
t.Fatal("populated mapping contribution was not retained")
}
}
})
}
}
func TestComposeDocumentsRejectsInvalidBoundaries(t *testing.T) {
if composed, diagnostics := composeDocuments(); composed != nil || len(diagnostics) != 1 || diagnostics[0].Code != CodeComposeInvalid {
t.Fatalf("empty composition = (%#v, %#v)", composed, diagnostics)
}
nonMapping := &document.Node{Kind: document.String, Location: document.Location{SourceID: "source-1", Line: 4, Column: 2}}
if composed, diagnostics := composeDocuments(nonMapping); composed != nil || len(diagnostics) != 1 || diagnostics[0].Primary == nil || diagnostics[0].Primary.Line != 4 {
t.Fatalf("non-mapping composition = (%#v, %#v)", composed, diagnostics)
}
}
func parseCompositionDocument(t *testing.T, sourceID, input string) *document.Node {
t.Helper()
parsed, diagnostics := parseSource(Bytes(sourceID, []byte(input)), sourceID, defaultParseLimits())
if len(diagnostics) != 0 {
t.Fatalf("parse diagnostics = %#v", diagnostics)
}
return parsed.document
}
func assertCompositionConflict(t *testing.T, diagnostics []Diagnostic, path string, sources []string) {
t.Helper()
if len(diagnostics) != 1 {
t.Fatalf("diagnostics = %#v, want one conflict", diagnostics)
}
diagnostic := diagnostics[0]
if diagnostic.Code != CodeComposeConflict || diagnostic.Path.String() != path || diagnostic.Primary == nil {
t.Fatalf("diagnostic = %#v, want compose conflict at %s", diagnostic, path)
}
locations := append([]Location{*diagnostic.Primary}, diagnostic.Related...)
gotSources := make([]string, len(locations))
for index, location := range locations {
gotSources[index] = location.SourceID
}
if !reflect.DeepEqual(gotSources, sources) {
t.Fatalf("claim sources = %#v, want %#v", gotSources, sources)
}
}

68
composition_fuzz_test.go Normal file
View File

@@ -0,0 +1,68 @@
package configkit
import (
"bytes"
"reflect"
"testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
yamladapter "gitea.maximumdirect.net/eric/ConfigKit/internal/yaml"
)
func FuzzCompositionAndOverlay(f *testing.F) {
for _, seed := range [][2]string{
{"{}\n", "{}\n"},
{"left: {value: one}\n", "right: [two]\n"},
{"shared: {left: one}\n", "shared: {right: two}\n"},
{"value: one\n", "value: two\n"},
} {
f.Add([]byte(seed[0]), []byte(seed[1]))
}
limits := yamladapter.Limits{Depth: 12, Nodes: 256, ParserDepth: 24}
f.Fuzz(func(t *testing.T, leftInput, rightInput []byte) {
if len(leftInput) > 4<<10 || len(rightInput) > 4<<10 {
return
}
left, leftIssues := yamladapter.Parse(bytes.NewReader(leftInput), "left", limits)
right, rightIssues := yamladapter.Parse(bytes.NewReader(rightInput), "right", limits)
if len(leftIssues) != 0 || len(rightIssues) != 0 {
return
}
leftBefore, rightBefore := document.Clone(left), document.Clone(right)
composed, diagnostics := composeDocuments(left, right)
if !reflect.DeepEqual(left, leftBefore) || !reflect.DeepEqual(right, rightBefore) {
t.Fatal("composition mutated an input document")
}
if len(diagnostics) == 0 {
reversed, reversedDiagnostics := composeDocuments(right, left)
if len(reversedDiagnostics) != 0 {
t.Fatalf("reversed composition failed: %#v", reversedDiagnostics)
}
if differences := compareConfigurationValues(composed.root, reversed.root); len(differences) != 0 {
t.Fatalf("composition order changed effective values: %#v", differences)
}
composed.root.Mapping = append(composed.root.Mapping, document.Entry{Key: "fuzz-detachment"})
if !reflect.DeepEqual(left, leftBefore) || !reflect.DeepEqual(right, rightBefore) {
t.Fatal("composition result aliases an input document")
}
}
base, baseDiagnostics := composeDocuments(left)
if len(baseDiagnostics) != 0 {
t.Fatalf("single accepted mapping did not compose: %#v", baseDiagnostics)
}
overlaid, overlayDiagnostics := overlayDocuments(base, right)
if len(overlayDiagnostics) != 0 {
t.Fatalf("accepted mappings did not overlay: %#v", overlayDiagnostics)
}
if !reflect.DeepEqual(left, leftBefore) || !reflect.DeepEqual(right, rightBefore) {
t.Fatal("overlay mutated an input document")
}
overlaid.root.Mapping = append(overlaid.root.Mapping, document.Entry{Key: "fuzz-detachment"})
if !reflect.DeepEqual(left, leftBefore) || !reflect.DeepEqual(right, rightBefore) {
t.Fatal("overlay result aliases an input document")
}
})
}

462
decode.go Normal file
View File

@@ -0,0 +1,462 @@
package configkit
import (
"encoding"
"math"
"reflect"
"regexp"
"strconv"
"time"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
var integerPattern = regexp.MustCompile(`^-?(0|[1-9][0-9]*)$`)
var unsignedIntegerPattern = regexp.MustCompile(`^(0|[1-9][0-9]*)$`)
type decodeIssue struct {
code Code
path Path
message string
location document.Location
}
type inputPresence struct {
null bool
location document.Location
}
type decodedConfiguration struct {
value reflect.Value
presence map[string]inputPresence
origins map[string]Origin
baseline *document.Node
final *document.Node
}
func decodeConfiguration(schema *schemaDescriptor, defaults reflect.Value, input *document.Node) (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"}}
}
baseline, projectionIssue := projectConfiguration(schema, defaults, defaultProjectionLimits())
if projectionIssue != nil {
return decodedConfiguration{}, []decodeIssue{decodeIssueFromProjection(projectionIssue)}
}
working, cloneIssue := cloneConfiguration(schema, defaults, defaultProjectionLimits())
if cloneIssue != nil {
return decodedConfiguration{}, []decodeIssue{decodeIssueFromProjection(cloneIssue)}
}
presence := make(map[string]inputPresence)
if input != nil {
collectPresence(input, Path{}, presence)
decoded, issues := decodeValue(input, working, schema.root, Path{}, schema)
if len(issues) != 0 {
return decodedConfiguration{}, issues
}
working = decoded
}
final, projectionIssue := projectConfiguration(schema, working, defaultProjectionLimits())
if projectionIssue != nil {
return decodedConfiguration{}, []decodeIssue{decodeIssueFromProjection(projectionIssue)}
}
origins := make(map[string]Origin)
collectOrigins(final, Path{}, presence, origins)
return decodedConfiguration{
value: working,
presence: presence,
origins: origins,
baseline: baseline,
final: final,
}, nil
}
func decodeValue(node *document.Node, base reflect.Value, descriptor *typeDescriptor, path Path, schema *schemaDescriptor) (reflect.Value, []decodeIssue) {
if node.Kind == document.Null {
switch descriptor.kind {
case descriptorPointer, descriptorMap, descriptorSlice, descriptorDynamic:
return reflect.Zero(descriptor.typeOf), nil
case descriptorSecret:
return reflect.Value{}, []decodeIssue{newDecodeIssue(CodeSecretRequired, path, node, "required secret reference cannot be null")}
default:
return reflect.Value{}, []decodeIssue{newDecodeIssue(CodeDecodeNull, path, node, "null is not allowed for this configuration type")}
}
}
if descriptor.kind == descriptorPointer {
var elementBase reflect.Value
if base.IsNil() {
elementBase = reflect.Zero(descriptor.typeOf.Elem())
} else {
elementBase = base.Elem()
}
decoded, issues := decodeValue(node, elementBase, descriptor.element, path, schema)
if len(issues) != 0 {
return reflect.Value{}, issues
}
result := reflect.New(descriptor.typeOf.Elem())
result.Elem().Set(decoded)
return result, nil
}
switch descriptor.kind {
case descriptorStruct:
return decodeStruct(node, base, descriptor, path, schema)
case descriptorMap:
return decodeMap(node, base, descriptor, path, schema)
case descriptorSlice:
return decodeSlice(node, descriptor, path, schema)
case descriptorArray:
return decodeArray(node, descriptor, path, schema)
case descriptorDynamic:
return decodeDynamic(node, base, path, schema)
case descriptorString:
if node.Kind != document.String {
return typeMismatch(path, node, "string")
}
result := reflect.New(descriptor.typeOf).Elem()
result.SetString(node.Text)
return result, nil
case descriptorBoolean:
if node.Kind != document.Boolean {
return typeMismatch(path, node, "boolean")
}
result := reflect.New(descriptor.typeOf).Elem()
result.SetBool(node.Text == "true")
return result, nil
case descriptorSigned:
if node.Kind != document.Number || !integerPattern.MatchString(node.Text) {
return typeMismatch(path, node, "integer")
}
value, err := strconv.ParseInt(node.Text, 10, descriptor.typeOf.Bits())
if err != nil {
return rangeFailure(path, node, "integer")
}
result := reflect.New(descriptor.typeOf).Elem()
result.SetInt(value)
return result, nil
case descriptorUnsigned:
if node.Kind != document.Number || !unsignedIntegerPattern.MatchString(node.Text) {
return typeMismatch(path, node, "unsigned integer")
}
value, err := strconv.ParseUint(node.Text, 10, descriptor.typeOf.Bits())
if err != nil {
return rangeFailure(path, node, "unsigned integer")
}
result := reflect.New(descriptor.typeOf).Elem()
result.SetUint(value)
return result, nil
case descriptorFloat:
if node.Kind != document.Number {
return typeMismatch(path, node, "number")
}
value, err := strconv.ParseFloat(node.Text, descriptor.typeOf.Bits())
if err != nil || math.IsNaN(value) || math.IsInf(value, 0) {
return rangeFailure(path, node, "number")
}
result := reflect.New(descriptor.typeOf).Elem()
result.SetFloat(value)
return result, nil
case descriptorDuration:
if node.Kind != document.String {
return typeMismatch(path, node, "duration string")
}
value, err := time.ParseDuration(node.Text)
if err != nil {
return reflect.Value{}, []decodeIssue{newDecodeIssue(CodeDecodeDuration, path, node, "duration string is invalid")}
}
result := reflect.New(descriptor.typeOf).Elem()
result.SetInt(int64(value))
return result, nil
case descriptorSecret:
if node.Kind != document.String {
return typeMismatch(path, node, "secret environment name")
}
return reflect.ValueOf(SecretEnv(node.Text)), nil
case descriptorText:
if node.Kind != document.String {
return typeMismatch(path, node, "text string")
}
result := reflect.New(descriptor.typeOf).Elem()
unmarshaler := result.Addr().Interface().(encoding.TextUnmarshaler)
if err := unmarshaler.UnmarshalText([]byte(node.Text)); err != nil {
return reflect.Value{}, []decodeIssue{newDecodeIssue(CodeDecodeText, path, node, "text value is invalid for its configuration type")}
}
return result, nil
default:
return reflect.Value{}, []decodeIssue{newDecodeIssue(CodeSchemaUnsupportedType, path, node, "configuration type is not supported")}
}
}
func decodeStruct(node *document.Node, base reflect.Value, descriptor *typeDescriptor, path Path, schema *schemaDescriptor) (reflect.Value, []decodeIssue) {
if node.Kind != document.Mapping {
return typeMismatch(path, node, "mapping")
}
result := reflect.New(descriptor.typeOf).Elem()
result.Set(base)
var issues []decodeIssue
for _, entry := range node.Mapping {
childPath := path.Key(entry.Key)
if position, ok := descriptor.fieldByName[entry.Key]; ok {
field := descriptor.fields[position]
target, ok := fieldForDecode(result, field.index, true)
if !ok {
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)
if len(fieldIssues) != 0 {
issues = append(issues, fieldIssues...)
continue
}
target.Set(decoded)
continue
}
if descriptor.inlineMap != nil {
if issue := decodeInlineMapEntry(result, *descriptor.inlineMap, entry, childPath, schema); issue != nil {
issues = append(issues, issue...)
}
continue
}
issues = append(issues, decodeIssue{code: CodeDecodeUnknownField, path: childPath, message: "configuration field is not declared", location: entry.KeyLocation})
}
return result, issues
}
func decodeInlineMapEntry(target reflect.Value, field fieldDescriptor, entry document.Entry, path Path, schema *schemaDescriptor) []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}}
}
if mapValue.IsNil() {
mapValue.Set(reflect.MakeMap(mapValue.Type()))
}
key := reflect.New(mapValue.Type().Key()).Elem()
key.SetString(entry.Key)
base := mapValue.MapIndex(key)
if !base.IsValid() {
base = reflect.Zero(mapValue.Type().Elem())
}
decoded, issues := decodeValue(entry.Value, base, field.descriptor.element, path, schema)
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) {
if node.Kind != document.Mapping {
return typeMismatch(path, node, "mapping")
}
result := base
if result.IsNil() {
result = reflect.MakeMap(descriptor.typeOf)
}
var issues []decodeIssue
for _, entry := range node.Mapping {
key := reflect.New(descriptor.typeOf.Key()).Elem()
key.SetString(entry.Key)
entryBase := result.MapIndex(key)
if !entryBase.IsValid() {
entryBase = reflect.Zero(descriptor.typeOf.Elem())
}
decoded, entryIssues := decodeValue(entry.Value, entryBase, descriptor.element, path.Key(entry.Key), schema)
if len(entryIssues) != 0 {
issues = append(issues, entryIssues...)
continue
}
result.SetMapIndex(key, decoded)
}
return result, issues
}
func decodeSlice(node *document.Node, descriptor *typeDescriptor, path Path, schema *schemaDescriptor) (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)
if len(childIssues) != 0 {
issues = append(issues, childIssues...)
continue
}
result.Index(i).Set(decoded)
}
return result, issues
}
func decodeArray(node *document.Node, descriptor *typeDescriptor, path Path, schema *schemaDescriptor) (reflect.Value, []decodeIssue) {
if node.Kind != document.Sequence {
return typeMismatch(path, node, "sequence")
}
if len(node.Sequence) != descriptor.length {
return reflect.Value{}, []decodeIssue{newDecodeIssue(CodeDecodeArrayLength, path, node, "configuration array has the wrong length")}
}
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)
if len(childIssues) != 0 {
issues = append(issues, childIssues...)
continue
}
result.Index(i).Set(decoded)
}
return result, issues
}
func decodeDynamic(node *document.Node, base reflect.Value, path Path, schema *schemaDescriptor) (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)
if len(issues) != 0 {
return reflect.Value{}, issues
}
result := reflect.New(base.Type()).Elem()
result.Set(decoded)
return result, nil
}
}
var value any
switch node.Kind {
case document.Null:
return reflect.Zero(base.Type()), nil
case document.String:
value = node.Text
case document.Boolean:
value = node.Text == "true"
case document.Number:
if integerPattern.MatchString(node.Text) {
parsed, err := strconv.ParseInt(node.Text, 10, 64)
if err != nil {
return rangeFailure(path, node, "dynamic integer")
}
value = parsed
} else {
parsed, err := strconv.ParseFloat(node.Text, 64)
if err != nil || math.IsNaN(parsed) || math.IsInf(parsed, 0) {
return rangeFailure(path, node, "dynamic number")
}
value = parsed
}
case document.Sequence:
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)
if len(childIssues) != 0 {
issues = append(issues, childIssues...)
continue
}
sequence[i] = decoded.Interface()
}
if len(issues) != 0 {
return reflect.Value{}, issues
}
value = sequence
case document.Mapping:
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)
if len(childIssues) != 0 {
issues = append(issues, childIssues...)
continue
}
mapping[entry.Key] = decoded.Interface()
}
if len(issues) != 0 {
return reflect.Value{}, issues
}
value = mapping
default:
return reflect.Value{}, []decodeIssue{newDecodeIssue(CodeDecodeType, path, node, "dynamic configuration value has an unsupported kind")}
}
result := reflect.New(base.Type()).Elem()
if value != nil {
result.Set(reflect.ValueOf(value))
}
return result, nil
}
func acceptsMapping(descriptor *typeDescriptor) bool {
for descriptor != nil && descriptor.kind == descriptorPointer {
descriptor = descriptor.element
}
return descriptor != nil && (descriptor.kind == descriptorStruct || descriptor.kind == descriptorMap || descriptor.kind == descriptorDynamic)
}
func fieldForDecode(root reflect.Value, index []int, allocate bool) (reflect.Value, bool) {
current := root
for position, fieldIndex := range index {
if current.Kind() != reflect.Struct {
return reflect.Value{}, false
}
current = current.Field(fieldIndex)
if position == len(index)-1 {
return current, current.CanSet()
}
for current.Kind() == reflect.Pointer {
if current.IsNil() {
if !allocate || !current.CanSet() {
return reflect.Value{}, false
}
current.Set(reflect.New(current.Type().Elem()))
}
current = current.Elem()
}
}
return reflect.Value{}, false
}
func collectPresence(node *document.Node, path Path, presence map[string]inputPresence) {
presence[path.String()] = inputPresence{null: node.Kind == document.Null, location: node.Location}
switch node.Kind {
case document.Mapping:
for _, entry := range node.Mapping {
collectPresence(entry.Value, path.Key(entry.Key), presence)
}
case document.Sequence:
for i, child := range node.Sequence {
collectPresence(child, path.Index(i), presence)
}
}
}
func collectOrigins(node *document.Node, path Path, presence map[string]inputPresence, origins map[string]Origin) {
if input, ok := presence[path.String()]; ok {
origins[path.String()] = Origin{Layer: LayerBase, Locations: []Location{{SourceID: input.location.SourceID, Line: input.location.Line, Column: input.location.Column}}}
} else {
origins[path.String()] = Origin{Layer: LayerDefault}
}
switch node.Kind {
case document.Mapping:
for _, entry := range node.Mapping {
collectOrigins(entry.Value, path.Key(entry.Key), presence, origins)
}
case document.Sequence:
for i, child := range node.Sequence {
collectOrigins(child, path.Index(i), presence, origins)
}
}
}
func decodeIssueFromProjection(issue *projectionIssue) decodeIssue {
return decodeIssue{code: issue.code, path: issue.path.clone(), message: issue.message}
}
func newDecodeIssue(code Code, path Path, node *document.Node, message string) decodeIssue {
return decodeIssue{code: code, path: path.clone(), message: message, location: node.Location}
}
func typeMismatch(path Path, node *document.Node, expected string) (reflect.Value, []decodeIssue) {
return reflect.Value{}, []decodeIssue{newDecodeIssue(CodeDecodeType, path, node, "configuration value must be a "+expected)}
}
func rangeFailure(path Path, node *document.Node, expected string) (reflect.Value, []decodeIssue) {
return reflect.Value{}, []decodeIssue{newDecodeIssue(CodeDecodeRange, path, node, expected+" is outside the supported range")}
}

312
decode_test.go Normal file
View File

@@ -0,0 +1,312 @@
package configkit
import (
"errors"
"math"
"reflect"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
type namedSmall int8
type validatedText string
func (value validatedText) MarshalText() ([]byte, error) {
return []byte(value), nil
}
func (value *validatedText) UnmarshalText(text []byte) error {
if string(text) == "bad-secret-canary" {
return errors.New("CANARY_TEXT_ERROR: bad-secret-canary")
}
*value = validatedText(text)
return nil
}
func TestDecodeAppliesInputOverDetachedDefaults(t *testing.T) {
t.Parallel()
type nested struct {
A string `yaml:"a"`
B string `yaml:"b"`
}
type config struct {
Enabled bool `yaml:"enabled"`
Workers int `yaml:"workers"`
Label string `yaml:"label"`
Nested nested `yaml:"nested"`
EmptyMapping nested `yaml:"empty_mapping"`
Objects map[string]nested `yaml:"objects"`
Sequence []string `yaml:"sequence"`
Replacement []string `yaml:"replacement"`
Pointer *nested `yaml:"pointer"`
NilPointer *nested `yaml:"nil_pointer"`
Nullable map[string]string `yaml:"nullable"`
Dynamic any `yaml:"dynamic"`
DynamicShape any `yaml:"dynamic_shape"`
DynamicNull any `yaml:"dynamic_null"`
Fixed [2]int `yaml:"fixed"`
Small namedSmall `yaml:"small"`
Delay time.Duration `yaml:"delay"`
Custom validatedText `yaml:"custom"`
}
defaults := config{
Enabled: true,
Workers: 4,
Label: "default",
Nested: nested{A: "keep", B: "before"},
EmptyMapping: nested{A: "keep-empty", B: "keep-empty"},
Objects: map[string]nested{
"existing": {A: "keep-map", B: "before"},
},
Sequence: []string{"default"},
Replacement: []string{"default"},
Pointer: &nested{A: "keep-pointer", B: "before"},
Nullable: map[string]string{"default": "value"},
Dynamic: map[string]any{"keep": "default", "count": int64(4)},
DynamicShape: nested{A: "keep-dynamic", B: "before"},
DynamicNull: "default",
Fixed: [2]int{1, 2},
Small: 7,
Delay: time.Second,
Custom: "default",
}
input := `
enabled: false
workers: 0
label: ""
nested: {b: changed}
empty_mapping: {}
objects:
existing: {b: changed}
new: {b: added}
sequence: []
replacement: [one, two]
pointer: {b: changed}
nil_pointer: {b: added}
nullable: null
dynamic: {count: 0, fraction: 1.5}
dynamic_shape: {b: changed}
dynamic_null: null
fixed: [3, 4]
small: -8
delay: 1.5s
custom: accepted
`
schema := mustSchemaFor[config](t)
documentRoot := parseDocumentForDecode(t, input)
decoded, issues := decodeConfiguration(schema, reflect.ValueOf(defaults), documentRoot)
if len(issues) != 0 {
t.Fatalf("decode issues = %#v", issues)
}
got := decoded.value.Interface().(config)
if got.Enabled || got.Workers != 0 || got.Label != "" {
t.Fatalf("explicit zero values lost: %#v", got)
}
if got.Nested != (nested{A: "keep", B: "changed"}) || got.EmptyMapping != defaults.EmptyMapping {
t.Fatalf("mapping defaults = %#v / %#v", got.Nested, got.EmptyMapping)
}
if got.Objects["existing"] != (nested{A: "keep-map", B: "changed"}) || got.Objects["new"] != (nested{B: "added"}) {
t.Fatalf("map defaults = %#v", got.Objects)
}
if got.Sequence == nil || len(got.Sequence) != 0 || !reflect.DeepEqual(got.Replacement, []string{"one", "two"}) {
t.Fatalf("sequence replacement = %#v / %#v", got.Sequence, got.Replacement)
}
if got.Pointer == defaults.Pointer || *got.Pointer != (nested{A: "keep-pointer", B: "changed"}) || got.NilPointer == nil || *got.NilPointer != (nested{B: "added"}) {
t.Fatalf("pointer defaults = %#v / %#v", got.Pointer, got.NilPointer)
}
if got.Nullable != nil || got.DynamicNull != nil {
t.Fatalf("null values = %#v / %#v", got.Nullable, got.DynamicNull)
}
dynamic := got.Dynamic.(map[string]any)
if dynamic["keep"] != "default" || dynamic["count"] != int64(0) || dynamic["fraction"] != float64(1.5) {
t.Fatalf("dynamic mapping = %#v", dynamic)
}
if got.DynamicShape != (nested{A: "keep-dynamic", B: "changed"}) {
t.Fatalf("dynamic concrete mapping = %#v", got.DynamicShape)
}
if got.Fixed != [2]int{3, 4} || got.Small != -8 || got.Delay != 1500*time.Millisecond || got.Custom != "accepted" {
t.Fatalf("scalar decoding = %#v", got)
}
for _, path := range []Path{Keys(), Keys("enabled"), Keys("nested"), Keys("nested", "b"), Keys("empty_mapping")} {
if _, ok := decoded.presence[path.String()]; !ok {
t.Errorf("explicit path %q missing", path.String())
}
}
if _, ok := decoded.presence[Keys("nested", "a").String()]; ok {
t.Fatal("parent mapping made omitted child explicit")
}
if decoded.origins[Keys("nested", "a").String()].Layer != LayerDefault || decoded.origins[Keys("nested", "b").String()].Layer != LayerBase {
t.Fatalf("origins = %#v", decoded.origins)
}
if decoded.origins[Keys("nested", "b").String()].Locations[0].Line == 0 {
t.Fatal("input origin lost source coordinates")
}
defaults.Objects["existing"] = nested{A: "mutated"}
defaults.Pointer.B = "mutated"
defaults.Sequence[0] = "mutated"
if got.Objects["existing"].A != "keep-map" || got.Pointer.B != "changed" || len(got.Sequence) != 0 {
t.Fatal("decoded value aliases defaults")
}
got.Pointer.B = "caller mutation"
if mappingValue(t, mappingValue(t, decoded.final, "pointer"), "b").Text != "changed" {
t.Fatal("final snapshot aliases returned typed value")
}
if mappingValue(t, mappingValue(t, decoded.baseline, "nested"), "b").Text != "before" {
t.Fatal("baseline snapshot was mutated by decode")
}
}
func TestDecodeDefaultsOnlyAndRepeatedResultsAreIndependent(t *testing.T) {
t.Parallel()
type config struct {
Values map[string][]string `yaml:"values"`
}
defaults := config{Values: map[string][]string{"one": {"a"}}}
schema := mustSchemaFor[config](t)
first, issues := decodeConfiguration(schema, reflect.ValueOf(defaults), nil)
if len(issues) != 0 {
t.Fatal(issues)
}
second, issues := decodeConfiguration(schema, reflect.ValueOf(defaults), nil)
if len(issues) != 0 {
t.Fatal(issues)
}
firstValue := first.value.Interface().(config)
secondValue := second.value.Interface().(config)
firstValue.Values["one"][0] = "changed"
if secondValue.Values["one"][0] != "a" || defaults.Values["one"][0] != "a" {
t.Fatal("repeated defaults-only results share aliases")
}
if len(first.presence) != 0 || first.origins[Keys("values", "one").String()].Layer != LayerDefault {
t.Fatalf("defaults-only metadata = %#v / %#v", first.presence, first.origins)
}
}
func TestDecodeCollectsIndependentTypeAndShapeFailures(t *testing.T) {
t.Parallel()
type nested struct {
Known int `yaml:"known"`
}
type config struct {
Small int8 `yaml:"small"`
Unsigned uint8 `yaml:"unsigned"`
Number float32 `yaml:"number"`
Text string `yaml:"text"`
Flag bool `yaml:"flag"`
Delay time.Duration `yaml:"delay"`
Fixed [2]int `yaml:"fixed"`
Nested nested `yaml:"nested"`
Custom validatedText `yaml:"custom"`
Dynamic any `yaml:"dynamic"`
}
input := `
small: 128
unsigned: -1
number: 1e999
text: 3
flag: "true"
delay: 10
fixed: [1]
nested: {first_unknown: 1, second_unknown: 2}
custom: bad-secret-canary
dynamic: 9223372036854775808
`
schema := mustSchemaFor[config](t)
_, issues := decodeConfiguration(schema, reflect.ValueOf(config{Custom: "ok"}), parseDocumentForDecode(t, input))
if len(issues) != 11 {
t.Fatalf("issues = %d: %#v", len(issues), issues)
}
want := map[string]Code{
"/small": CodeDecodeRange,
"/unsigned": CodeDecodeType,
"/number": CodeDecodeRange,
"/text": CodeDecodeType,
"/flag": CodeDecodeType,
"/delay": CodeDecodeType,
"/fixed": CodeDecodeArrayLength,
"/nested/first_unknown": CodeDecodeUnknownField,
"/nested/second_unknown": CodeDecodeUnknownField,
"/custom": CodeDecodeText,
"/dynamic": CodeDecodeRange,
}
for _, issue := range issues {
if wantCode, ok := want[issue.path.String()]; !ok || wantCode != issue.code {
t.Errorf("unexpected issue %#v", issue)
}
if issue.location.Line == 0 {
t.Errorf("issue lost input location: %#v", issue)
}
if strings.Contains(issue.message, "CANARY") || strings.Contains(issue.message, "bad-secret") {
t.Errorf("issue exposed value: %#v", issue)
}
}
}
func TestDecodeNullabilityAndNestedMapStrictness(t *testing.T) {
t.Parallel()
type nested struct {
Value int `yaml:"value"`
}
type config struct {
Scalar int `yaml:"scalar"`
Object nested `yaml:"object"`
Array [1]int `yaml:"array"`
Map map[string]nested `yaml:"map"`
}
input := "scalar: null\nobject: null\narray: null\nmap:\n item: {unknown: 1}\n"
_, issues := decodeConfiguration(mustSchemaFor[config](t), reflect.ValueOf(config{}), parseDocumentForDecode(t, input))
want := map[string]Code{
"/scalar": CodeDecodeNull,
"/object": CodeDecodeNull,
"/array": CodeDecodeNull,
"/map/item/unknown": CodeDecodeUnknownField,
}
if len(issues) != len(want) {
t.Fatalf("issues = %#v", issues)
}
for _, issue := range issues {
if want[issue.path.String()] != issue.code {
t.Errorf("issue = %#v", issue)
}
}
}
func TestDecodeRejectsInvalidDefaultsBeforeApplyingInput(t *testing.T) {
t.Parallel()
type config struct {
Name string `yaml:"name"`
Extras map[string]any `yaml:",inline"`
Float float64 `yaml:"float"`
}
schema := mustSchemaFor[config](t)
for name, defaults := range map[string]config{
"inline collision": {Name: "declared", Extras: map[string]any{"name": "duplicate"}},
"non-finite": {Float: math.NaN()},
} {
t.Run(name, func(t *testing.T) {
if _, issues := decodeConfiguration(schema, reflect.ValueOf(defaults), parseDocumentForDecode(t, "{}\n")); len(issues) != 1 || !strings.HasPrefix(string(issues[0].code), "schema.") {
t.Fatalf("issues = %#v", issues)
}
})
}
}
func parseDocumentForDecode(t *testing.T, input string) *document.Node {
t.Helper()
parsed, diagnostics := parseSource(Bytes("decode.yml", []byte(input)), "source-1", defaultParseLimits())
if len(diagnostics) != 0 {
t.Fatalf("parse diagnostics = %#v", diagnostics)
}
return parsed.document
}

248
diagnostic.go Normal file
View File

@@ -0,0 +1,248 @@
package configkit
import (
"cmp"
"fmt"
"io"
"slices"
"strconv"
"strings"
"unicode"
)
// LoadError reports safe structured diagnostics for a failed configuration load.
// It intentionally does not expose or unwrap parser and application errors.
type LoadError struct {
diagnostics []Diagnostic
sources []SourceInfo
}
// Error implements error without rendering diagnostic messages or input values.
func (e *LoadError) Error() string {
if e == nil {
return "configkit: configuration load failed"
}
count := 0
for _, diagnostic := range e.diagnostics {
if diagnostic.Severity == SeverityError {
count++
}
}
if count == 1 {
return "configkit: configuration load failed with 1 error"
}
return fmt.Sprintf("configkit: configuration load failed with %d errors", count)
}
// Diagnostics returns a deep copy of the error's canonical diagnostics.
func (e *LoadError) Diagnostics() []Diagnostic {
if e == nil {
return nil
}
return cloneDiagnostics(e.diagnostics)
}
// Sources returns a deep copy of sources discovered before loading failed.
func (e *LoadError) Sources() []SourceInfo {
if e == nil {
return nil
}
return cloneSourceInfos(e.sources)
}
// WriteDiagnostics writes diagnostics in canonical order. It escapes terminal
// control characters and propagates writer errors, including short writes.
func WriteDiagnostics(output io.Writer, diagnostics []Diagnostic) error {
canonical := canonicalDiagnostics(diagnostics)
for _, diagnostic := range canonical {
if err := writeDiagnostic(output, diagnostic); err != nil {
return err
}
}
return nil
}
type diagnosticAccumulator struct {
diagnostics []Diagnostic
sources []SourceInfo
}
func (a *diagnosticAccumulator) add(diagnostics ...Diagnostic) {
for _, diagnostic := range diagnostics {
a.diagnostics = append(a.diagnostics, cloneDiagnostic(diagnostic))
}
}
func (a *diagnosticAccumulator) loadError() *LoadError {
return &LoadError{
diagnostics: canonicalDiagnostics(a.diagnostics),
sources: cloneSourceInfos(a.sources),
}
}
func canonicalDiagnostics(diagnostics []Diagnostic) []Diagnostic {
canonical := cloneDiagnostics(diagnostics)
for i := range canonical {
slices.SortFunc(canonical[i].Related, compareLocation)
}
slices.SortFunc(canonical, compareDiagnostic)
return canonical
}
func compareDiagnostic(left, right Diagnostic) int {
if order := compareOptionalLocation(left.Primary, right.Primary); order != 0 {
return order
}
if order := cmp.Compare(left.Path.String(), right.Path.String()); order != 0 {
return order
}
if order := cmp.Compare(left.Code, right.Code); order != 0 {
return order
}
if order := cmp.Compare(left.Severity, right.Severity); order != 0 {
return order
}
if order := cmp.Compare(left.Message, right.Message); order != 0 {
return order
}
return compareLocations(left.Related, right.Related)
}
func compareOptionalLocation(left, right *Location) int {
if left == nil && right == nil {
return 0
}
if left == nil {
return -1
}
if right == nil {
return 1
}
return compareLocation(*left, *right)
}
func compareLocations(left, right []Location) int {
for i := 0; i < min(len(left), len(right)); i++ {
if order := compareLocation(left[i], right[i]); order != 0 {
return order
}
}
return cmp.Compare(len(left), len(right))
}
func compareLocation(left, right Location) int {
if order := compareSourceID(left.SourceID, right.SourceID); order != 0 {
return order
}
if order := cmp.Compare(left.Line, right.Line); order != 0 {
return order
}
return cmp.Compare(left.Column, right.Column)
}
func compareSourceID(left, right string) int {
leftOrder, leftOK := sourceSequence(left)
rightOrder, rightOK := sourceSequence(right)
if leftOK && rightOK {
return cmp.Compare(leftOrder, rightOrder)
}
return cmp.Compare(left, right)
}
func sourceSequence(sourceID string) (int, bool) {
const prefix = "source-"
if !strings.HasPrefix(sourceID, prefix) {
return 0, false
}
value, err := strconv.Atoi(strings.TrimPrefix(sourceID, prefix))
if err != nil || value < 1 {
return 0, false
}
return value, true
}
func writeDiagnostic(output io.Writer, diagnostic Diagnostic) error {
severity := diagnostic.Severity
if severity == "" {
severity = SeverityError
}
path := diagnostic.Path.String()
if path == "" {
path = "<root>"
}
var b strings.Builder
b.WriteString(escapeControls(string(severity)))
if diagnostic.Code != "" {
b.WriteString(" [")
b.WriteString(escapeControls(string(diagnostic.Code)))
b.WriteByte(']')
}
b.WriteByte(' ')
b.WriteString(escapeControls(path))
if diagnostic.Primary != nil {
b.WriteString(" (")
writeLocationText(&b, *diagnostic.Primary)
b.WriteByte(')')
}
if diagnostic.Message != "" {
b.WriteString(": ")
b.WriteString(escapeControls(diagnostic.Message))
}
b.WriteByte('\n')
for _, related := range diagnostic.Related {
b.WriteString(" related: ")
writeLocationText(&b, related)
b.WriteByte('\n')
}
return writeString(output, b.String())
}
func writeLocationText(output *strings.Builder, location Location) {
label := location.SourceID
if label == "" {
label = "<synthetic>"
}
output.WriteString(escapeControls(label))
if location.Line > 0 {
output.WriteByte(':')
output.WriteString(strconv.Itoa(location.Line))
if location.Column > 0 {
output.WriteByte(':')
output.WriteString(strconv.Itoa(location.Column))
}
}
}
func writeString(output io.Writer, value string) error {
written, err := io.WriteString(output, value)
if err != nil {
return err
}
if written != len(value) {
return io.ErrShortWrite
}
return nil
}
func escapeControls(value string) string {
var b strings.Builder
for _, r := range value {
if unicode.IsControl(r) {
quoted := strconv.QuoteRune(r)
b.WriteString(quoted[1 : len(quoted)-1])
continue
}
b.WriteRune(r)
}
return b.String()
}
func hasErrors(diagnostics []Diagnostic) bool {
for _, diagnostic := range diagnostics {
if diagnostic.Severity == SeverityError {
return true
}
}
return false
}

143
diagnostic_test.go Normal file
View File

@@ -0,0 +1,143 @@
package configkit
import (
"bytes"
"errors"
"io"
"strings"
"testing"
)
func TestDiagnosticsAreCanonicalAndDetached(t *testing.T) {
t.Parallel()
diagnostics := []Diagnostic{
{
Code: "decode.type",
Severity: SeverityError,
Path: Keys("later"),
Message: "later",
Primary: &Location{SourceID: "source-10", Line: 1, Column: 1},
},
{
Code: "schema.invalid",
Severity: SeverityError,
Path: Keys("root"),
Message: "source-less",
},
{
Code: "decode.type",
Severity: SeverityError,
Path: Keys("earlier"),
Message: "earlier",
Primary: &Location{SourceID: "source-2", Line: 3, Column: 4},
Related: []Location{
{SourceID: "source-10", Line: 1, Column: 1},
{SourceID: "source-2", Line: 9, Column: 1},
},
},
}
var forward bytes.Buffer
if err := WriteDiagnostics(&forward, diagnostics); err != nil {
t.Fatal(err)
}
reversed := []Diagnostic{diagnostics[2], diagnostics[1], diagnostics[0]}
var backward bytes.Buffer
if err := WriteDiagnostics(&backward, reversed); err != nil {
t.Fatal(err)
}
if forward.String() != backward.String() {
t.Fatalf("rendering depends on submission order:\n%s\n---\n%s", forward.String(), backward.String())
}
lines := strings.Split(forward.String(), "\n")
if !strings.Contains(lines[0], "source-less") || !strings.Contains(lines[1], "source-2:3:4") {
t.Fatalf("unexpected canonical order:\n%s", forward.String())
}
if !strings.Contains(lines[2], "source-2:9:1") || !strings.Contains(lines[3], "source-10:1:1") {
t.Fatalf("related locations not canonical:\n%s", forward.String())
}
accumulator := diagnosticAccumulator{
sources: []SourceInfo{{
ID: "source-1",
Label: "config.yml",
ImportedFrom: &Location{SourceID: "source-0", Line: 1},
}},
}
accumulator.add(diagnostics...)
loadErr := accumulator.loadError()
var found *LoadError
if !errors.As(error(loadErr), &found) || found != loadErr {
t.Fatal("errors.As did not find LoadError")
}
first := loadErr.Diagnostics()
first[1].Primary.SourceID = "mutated"
first[1].Path.segments[0].key = "mutated"
first[1].Related[0].SourceID = "mutated"
second := loadErr.Diagnostics()
if second[1].Primary.SourceID != "source-2" || second[1].Path.String() != "/earlier" || second[1].Related[0].SourceID != "source-2" {
t.Fatal("Diagnostics returned aliased records")
}
sources := loadErr.Sources()
sources[0].ImportedFrom.SourceID = "mutated"
if loadErr.Sources()[0].ImportedFrom.SourceID != "source-0" {
t.Fatal("Sources returned aliased records")
}
if got := loadErr.Error(); got != "configkit: configuration load failed with 3 errors" {
t.Fatalf("Error() = %q", got)
}
}
func TestWriteDiagnosticsEscapesControls(t *testing.T) {
t.Parallel()
diagnostic := Diagnostic{
Code: "validate.error",
Severity: SeverityError,
Path: Keys("line\nbreak"),
Message: "message\r\nnext",
Primary: &Location{SourceID: "source\tlabel", Line: 1},
}
var output bytes.Buffer
if err := WriteDiagnostics(&output, []Diagnostic{diagnostic}); err != nil {
t.Fatal(err)
}
got := output.String()
if strings.Count(got, "\n") != 1 {
t.Fatalf("control characters created output lines: %q", got)
}
for _, escaped := range []string{`line\nbreak`, `message\r\nnext`, `source\tlabel`} {
if !strings.Contains(got, escaped) {
t.Errorf("output %q does not contain %q", got, escaped)
}
}
}
func TestWriteDiagnosticsPropagatesWriterFailures(t *testing.T) {
t.Parallel()
diagnostics := []Diagnostic{{Severity: SeverityError, Message: "safe"}}
want := errors.New("write failed")
if err := WriteDiagnostics(errorWriter{err: want}, diagnostics); !errors.Is(err, want) {
t.Fatalf("error writer returned %v", err)
}
if err := WriteDiagnostics(shortWriter{}, diagnostics); !errors.Is(err, io.ErrShortWrite) {
t.Fatalf("short writer returned %v", err)
}
}
type errorWriter struct {
err error
}
func (w errorWriter) Write(p []byte) (int, error) {
return min(3, len(p)), w.err
}
type shortWriter struct{}
func (shortWriter) Write(p []byte) (int, error) {
return max(0, len(p)-1), nil
}

229
directives.go Normal file
View File

@@ -0,0 +1,229 @@
package configkit
import (
"path"
"strings"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
const directiveKey = "configkit"
type importDeclaration struct {
path string
glob string
optional bool
location Location
}
func splitImportDirectives(root *document.Node) (*document.Node, []importDeclaration, []Diagnostic) {
body := document.Clone(root)
var declarations []importDeclaration
directiveIndex := -1
for index, entry := range body.Mapping {
if entry.Key != directiveKey {
continue
}
directiveIndex = index
parsed, diagnostics := parseImportDirective(entry.Value, Keys(directiveKey))
if len(diagnostics) != 0 {
return nil, nil, diagnostics
}
declarations = parsed
break
}
if directiveIndex >= 0 {
body.Mapping = append(body.Mapping[:directiveIndex], body.Mapping[directiveIndex+1:]...)
}
if diagnostics := nestedDirectiveDiagnostics(body, Path{}); len(diagnostics) != 0 {
return nil, nil, diagnostics
}
return body, declarations, nil
}
func parseImportDirective(node *document.Node, directivePath Path) ([]importDeclaration, []Diagnostic) {
if node.Kind != document.Mapping {
return nil, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, directivePath, "configuration directive must be a mapping", node)}
}
var declarations []importDeclaration
for _, entry := range node.Mapping {
fieldPath := directivePath.Key(entry.Key)
if entry.Key != "imports" {
return nil, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "configuration directive field is not recognized", entry.Value)}
}
if entry.Value.Kind != document.Sequence {
return nil, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "imports must be a sequence", entry.Value)}
}
for index, item := range entry.Value.Sequence {
declaration, diagnostics := parseImportDeclaration(item, fieldPath.Index(index))
if len(diagnostics) != 0 {
return nil, diagnostics
}
declarations = append(declarations, declaration)
}
}
return declarations, nil
}
func parseImportDeclaration(node *document.Node, declarationPath Path) (importDeclaration, []Diagnostic) {
if node.Kind != document.Mapping {
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, declarationPath, "import declaration must be a mapping", node)}
}
declaration := importDeclaration{location: publicLocation(node.Location)}
seenTarget := false
for _, entry := range node.Mapping {
fieldPath := declarationPath.Key(entry.Key)
switch entry.Key {
case "path", "glob":
if seenTarget || entry.Value.Kind != document.String || entry.Value.Text == "" || strings.TrimSpace(entry.Value.Text) != entry.Value.Text {
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "import declaration requires exactly one nonempty path or glob string", entry.Value)}
}
seenTarget = true
declaration.location = publicLocation(entry.Value.Location)
if entry.Key == "path" {
declaration.path = entry.Value.Text
} else {
declaration.glob = entry.Value.Text
}
case "optional":
if entry.Value.Kind != document.Boolean {
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "optional must be a boolean", entry.Value)}
}
declaration.optional = entry.Value.Text == "true"
default:
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, fieldPath, "import declaration field is not recognized", entry.Value)}
}
}
if !seenTarget {
return importDeclaration{}, []Diagnostic{nodeImportDiagnostic(CodeImportInvalid, declarationPath, "import declaration requires exactly one path or glob", node)}
}
return declaration, nil
}
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 == 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}}
}
if diagnostics := nestedDirectiveDiagnostics(entry.Value, childPath); len(diagnostics) != 0 {
return diagnostics
}
}
} else if node.Kind == document.Sequence {
for index, child := range node.Sequence {
if diagnostics := nestedDirectiveDiagnostics(child, current.Index(index)); len(diagnostics) != 0 {
return diagnostics
}
}
}
return nil
}
func fragmentVersionClaim(root *document.Node, rule *VersionRule) *document.Node {
if rule == nil {
return nil
}
current := root
if len(rule.Path.segments) == 0 {
return current
}
for _, segment := range rule.Path.segments {
if current.Kind != document.Mapping {
return current
}
if segment.kind == pathSegmentIndex {
return nil
}
key := segment.key
index := documentEntryIndex(current.Mapping, key)
if index < 0 {
return nil
}
current = current.Mapping[index].Value
}
return current
}
func validImportPath(value string) bool {
if value == "" || strings.TrimSpace(value) != value || path.IsAbs(value) || strings.HasPrefix(value, "//") {
return false
}
return !hasImportScheme(value)
}
func hasImportScheme(value string) bool {
colon := strings.IndexByte(value, ':')
slash := strings.IndexByte(value, '/')
if colon <= 0 || (slash >= 0 && slash < colon) {
return false
}
for index := 0; index < colon; index++ {
character := value[index]
if index == 0 {
if (character < 'a' || character > 'z') && (character < 'A' || character > 'Z') {
return false
}
continue
}
if (character < 'a' || character > 'z') && (character < 'A' || character > 'Z') &&
(character < '0' || character > '9') && character != '+' && character != '-' && character != '.' {
return false
}
}
return true
}
func nodeImportDiagnostic(code Code, path Path, message string, node *document.Node) Diagnostic {
location := publicLocation(node.Location)
return Diagnostic{Code: code, Severity: SeverityError, Path: path, Message: message, Primary: &location}
}
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 == directiveKey {
location := publicLocation(entry.KeyLocation)
return []Diagnostic{{
Code: CodeProfileDirective, Severity: SeverityError, Path: childPath,
Message: "profiles cannot contain configuration directives", Primary: &location,
}}
}
if diagnostics := profileDirectiveDiagnostics(entry.Value, childPath); len(diagnostics) != 0 {
return diagnostics
}
}
} else if node.Kind == document.Sequence {
for index, child := range node.Sequence {
if diagnostics := profileDirectiveDiagnostics(child, current.Index(index)); len(diagnostics) != 0 {
return diagnostics
}
}
}
return nil
}
func disabledDirectiveDiagnostics(node *document.Node, path Path) []Diagnostic {
var diagnostics []Diagnostic
if node.Kind == document.Mapping {
for _, entry := range node.Mapping {
childPath := path.Key(entry.Key)
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,
Message: "configuration directives require imports to be enabled", Primary: &location,
})
}
diagnostics = append(diagnostics, disabledDirectiveDiagnostics(entry.Value, childPath)...)
}
} else if node.Kind == document.Sequence {
for index, child := range node.Sequence {
diagnostics = append(diagnostics, disabledDirectiveDiagnostics(child, path.Index(index))...)
}
}
return diagnostics
}

10
doc.go Normal file
View File

@@ -0,0 +1,10 @@
// Package configkit provides deterministic, application-owned configuration
// loading for Go programs.
//
// Load currently supports one file, byte, reader, or explicit no-input source with
// strict YAML, typed defaults, schema-version checks, detached metadata, safe
// diagnostics, recorded application resolution, read-only semantic validation, and
// structured load errors. Composition, profiles, overrides, secrets, and effective
// rendering are not available yet. The configuration contract is documented in
// docs/config.md.
package configkit

View File

@@ -20,8 +20,8 @@ Use distinct strict-composition and intentional-overlay operations. Composition
rejects conflicting definitions; overlay provides explicit precedence. Keep the
operations opinionated rather than adding arbitrary per-field merge strategies.
The [architecture policy](../policy/architecture.md) owns the governing boundary;
the [target design](../roadmap/initial.md#composition-and-overlay) owns the planned
operation table until its current contract lands.
the [current configuration contract](../config.md#strict-document-composition) owns
the operation table and selected-profile overlay behavior.
## Alternatives considered

View File

@@ -20,7 +20,8 @@ ConfigKit defines its supported YAML policy and keeps parser integration private
Application types and diagnostics must not require parser-specific public types.
Use a small internal boundary rather than a general parser/provider framework.
The [architecture policy](../policy/architecture.md) owns this constraint; the
[target design](../roadmap/initial.md#yaml-and-scalar-policy) owns planned syntax.
[current YAML document](../config.md#yaml-documents) and
[scalar](../config.md#scalars) contracts own the implemented syntax.
## Alternatives considered

View File

@@ -22,8 +22,9 @@ values. Keep application defaults and normalization application-owned, but recor
resolution changes so final inspection and metadata describe the actual resolved
snapshot. Do not promise language-enforced immutability of exported Go values.
The [architecture policy](../policy/architecture.md) owns these invariants; the
[target design](../roadmap/initial.md#recorded-resolution-and-validation) owns the
planned recording mechanism and ownership semantics.
[current resolution contract](../config.md#recorded-application-resolution) and
[ownership contract](../config.md#typed-projection-and-ownership) own the implemented
recording mechanism and ownership semantics.
## Alternatives considered

View File

@@ -21,8 +21,9 @@ Existing environment variables win even when empty. Report skipped files and
partial failures without values. Keep bootstrap provenance separate from later
lookup provenance rather than imply an unchanged environment between calls.
General environment-variable configuration overlays are not on the roadmap.
The [feature roadmap](../roadmap/initial.md#secret-references-and-explicit-bootstrap)
owns the unimplemented contract; stages remain in the implementation plan.
The [current reference contract](../config.md#typed-secret-references) and
[bootstrap contract](../config.md#explicit-secrets-directory-bootstrap) own the
implemented behavior.
## Alternatives considered

View File

@@ -19,8 +19,9 @@ node tree before typed decoding, retaining the existing parser adapter boundary.
Recorded domain resolution follows input selection. Provide safe structured
historical values and an explanation renderer in the initial release, building
on ADR 0003's presence and audited resolution decision. Applications own commands.
The [feature roadmap](../roadmap/initial.md#cli-overrides) owns the exact override
contract and [explanation contract](../roadmap/initial.md#historical-value-explanation).
The [current override contract](../config.md#explicit-cli-overrides) and
[explanation contract](../config.md#historical-value-explanation) own the implemented
behavior.
## Alternatives considered

715
docs/config.md Normal file
View File

@@ -0,0 +1,715 @@
# Configuration Input Contract
This document owns ConfigKit's currently implemented source-input, YAML syntax,
schema, typed-decoding, loading, metadata, redaction, rendering, and ownership
contracts. The parser, owned document representation, and decoder remain private
behind the public loading lifecycle.
## Sources
ConfigKit exposes opaque sources through these constructors:
| Constructor | Current contract |
| --- | --- |
| `File(path)` | Requires a nonempty path, opens a regular file for each consumption, and closes only the library-owned file handle. Missing, inaccessible, unreadable, directory, and other nonregular inputs fail. |
| `Bytes(label, data)` | Requires a nonempty, non-secret diagnostic label and snapshots bytes during construction. Later caller mutation cannot affect the source. |
| `Reader(label, input)` | Requires a nonempty, non-secret label and non-nil reader. The reader is claimed once across all copies of the source, consumed but never closed, and cannot be reused. |
| `NoSource()` | Selects defaults-only loading, creates no source record, and marks no paths explicit. |
The zero `Source` is invalid. Constructor argument failures are retained inside the
opaque source and become safe structured diagnostics when the source is consumed.
File sources can observe file changes between consumptions; byte sources cannot.
Each source is limited to 8 MiB. A load using imports or an active profile is limited
to 64 MiB across all accepted sources, 1,024 accepted sources, 64 active import
edges, and 1,000,000 input nodes in aggregate. Glob expansion separately permits
1,000,000 visited directory entries, including nonmatches. Reads request at most the
remaining allowance plus one byte so excess data is rejected rather than silently
truncated. Data returned together with a read error is not accepted as partial input.
## Single-source loading
`Load[T](source, options...)` accepts zero or one `Options[T]`. The configuration
root `T` must satisfy the application schema contract below. With no options it
starts from the Go zero value. A `Definition[T].Defaults` factory, when supplied,
runs exactly once after successful source parsing, schema construction, optional
schema-version checking, and selected-profile loading. Its result is detached before
input is applied. Resolver-provided mutable values are detached before secret lookup
and validation, and a final detached copy prevents equal-valued aliases introduced
by validators from escaping into the returned configuration. The publication copy
preserves already validated secret state without rereading the environment.
The current lifecycle is:
```text
validate options → parse the selected source → traverse/compose enabled imports
→ build schema → check the optional application version → apply a selected profile
→ apply explicit CLI overrides → create and clone defaults → strictly decode
→ recorded application resolution
→ resolve typed secret references → semantic validation → classify secrets
→ snapshot configuration and metadata
```
`File`, `Bytes`, and `Reader` each contribute the first base source with deterministic
ID `source-1`; enabled imports continue in depth-first order as `source-2` onward.
`NoSource` skips parsing, supplies an absent version to a configured version checker,
contributes no source, and continues through defaults, decode, and projection. A
missing file remains an error; it is never interpreted as no input.
The current `Options` boundary accepts a defaults factory, `VersionRule`, resolver,
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
disabled.
Every source, options, schema, version, defaults, decode, resolution, or validation
failure returns a nil result and a `*LoadError`. Successful results contain caller-owned `Config`, detached
`Metadata`, a defensive source list, and defensive nonfatal diagnostics. Mutating
the returned configuration or inspection records cannot alter ConfigKit's private
snapshot. ConfigKit performs no logging. `Result.WriteEffective` exposes only the
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.
The checker reports safe, value-free errors and warnings through `Validation.Error`
and `Validation.Warning`; ConfigKit resolves primary and related paths to exact or
nearest input locations. Reported errors fail loading. If the checker returns an
error without reporting one, ConfigKit emits a generic `version.hook_failed`
diagnostic and suppresses the returned error text. Warnings alone are retained on a
successful result. Callback panics remain programming failures.
### Layered metadata
`Result.Metadata` describes every final canonical path. Queries retain explicit
key/index distinctions using actual effective and historical container shapes;
only parsed numeric tokens are resolved contextually. A descendant that never
existed is not historical merely because an atomic ancestor was replaced. Exact surviving input paths
are explicit and have a base or profile origin; omitted paths have a default origin.
An explicit parent mapping does not make omitted children explicit. Null state and
available source coordinates are retained. `History` records the Go-zero or application
default baseline followed by exact base or profile definitions, including
equal-valued input. Profile replacement removes displaced descendants from effective
metadata while retaining their base definition and the parent replacement operation
in history. All metadata queries and returned nested records are detached.
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;
the descendant can therefore retain history after `Lookup` stops reporting it as
effective. Equal-valued input is still an explicit base definition, while a derived
value for absent input remains non-explicit.
A mapping assembled from the selected file and imports has one composite base origin
whose locations retain source traversal order. A mapping also visited by a profile
has a profile-layer composite origin with the lower contributing locations followed
by the profile location; an empty mapping is still an explicit container even though
it contributes no children. Resolution origins are synthetic: they have layer
`resolution`, no fabricated file location, and a `derived_default` or `normalization`
operation. Reasons and dependency paths retain first occurrence order with duplicates
removed. Source records remain ordered as selected source, depth-first imports, the
selected profile, then synthetic CLI records in supplied order.
### Secret classification and effective rendering
Secret classification combines `configkit:"secret"` field tags with the optional
pure `Definition.Secret(Path) bool` predicate. Tags on concrete structs inside
supported dynamic values are retained in typed projections, including superseded
defaults and pre-resolution values. A tagged or predicate-classified
container makes all descendants sensitive. ConfigKit evaluates final and historical
paths, so replacing or deleting a definition does not erase its classification. The
predicate can add sensitivity but cannot declassify a tagged path.
Recorded resolution dependencies propagate sensitivity to their derived target.
Propagation reaches a fixed point, so record/map iteration order does not affect the
result and dependency cycles terminate. A dependency on a classified descendant or
on a container that contains one is treated conservatively as sensitive. Applications
must classify secret-producing transformations whose dependencies are not recorded.
`Result.WriteEffective` writes the frozen resolved snapshot as deterministic redacted
YAML. Mappings sort by decoded key string, sequences retain order, indentation is two
spaces with noncompact sequence indentation, line endings are LF, the document has no
start or end marker, and output ends with a newline. Nil pointers/maps/slices remain
`null`; non-nil empty maps and sequences remain `{}` and `[]`. A secret root is
replaced by the quoted string `'[REDACTED]'`. Redacted output is intended for
inspection and is not promised to be reloadable.
Rendering uses only a safe adapter tree built from the already completed canonical
typed projection. It does not invoke application YAML marshal hooks or text codecs;
all text-codec projection has succeeded before `Load` returns. Caller mutation of
`Result.Config` cannot change later output. Writer failures, including short writes,
are returned and may leave partial redacted output; internal dumper failures are
translated to a fixed safe error. There is no unredacted rendering option and
ConfigKit performs no implicit logging.
`Result.Config` is the explicit trusted boundary and contains ordinary typed values,
including secrets. Metadata records, diagnostics, and load errors contain no
configuration values. Explanation snapshots deliberately expose non-secret
configuration history after applying the redaction contract below. Applications
must still keep source labels, resolution reasons, and diagnostic messages value-free
because ConfigKit cannot infer and sanitize secrets embedded in arbitrary application
prose.
### Historical-value explanation
`Result.Explain(path)` returns a detached `Explanation` for any known effective or
historical path. Its effective value and each chronological before/after value are
`ValueSnapshot` records containing deterministic YAML text rather than mutable Go or
parser values. `Present=false` with empty YAML means absence or deletion; explicit
null is present and renders as `null`. The history starts with the canonical typed
application-default or Go-zero baseline, then includes only relevant base sources in
traversal order, the selected profile, supplied CLI records, and audited net
resolution operations. Overlapping parent/child resolution records are rendered
as one net subtree event with combined reasons and dependencies, rather than
pretending that the recorded assignments ran in lexical order. Explicit equal
writes remain visible. When input replay
differs from the final typed input, a `ChangeDecoding` event records the transition
before domain resolution. Its origin retains the decoded path's source/default
attribution; its reason identifies static-default retention and canonical decoding.
This built-in inspection operation is not accepted by `Resolution.Record`.
Base, profile, and CLI history preserves the policy-validated input representation,
including a superseded value that could not have decoded into the final Go field.
Baseline, effective, and resolution values use canonical typed projection. A
decoding event bridges replayed input and canonical typed values, so intermediate
null/scalar replacements cannot incorrectly imply permanent default removal. Mapping
queries reconstruct each source or override contribution, while sequences remain
atomic: a descendant history identifies its presence before and after the containing
sequence replacement without implying element-wise merging. Removed paths remain
explainable; unknown and malformed paths return false.
Every snapshot applies the same historical tag, predicate, and recorded-dependency
sensitivity closure as effective rendering before YAML is exposed. A redacted scalar
is the quoted marker `'[REDACTED]'`, and a container snapshot has `Redacted=true` if
it contains a masked descendant. Typed `Secret` snapshots contain only the configured
environment name. `SecretReference` reports that name only for a successfully
resolved effective reference, and is omitted when its path is classified. Credential
values are never retained in explanation history.
`Result.WriteExplanation(output, path)` renders the safe structured result with the
canonical JSON-pointer path, canonical-versus-input representation labels, effective
source, composite locations, chronological values, resolution reasons and
dependencies, and successful secret-reference lookup attribution. Control characters
in paths, source identifiers and labels, reasons, dependencies, and reference names
are escaped. An unknown path returns a fixed safe error before writing anything;
writer errors and short writes are returned and may leave partial output. There is no
unredacted mode.
Retained history consists of immutable source applications and audited subtree
deltas, rather than a full tree copy per field. Shared nodes count once toward a
4,000,000-node retained-history limit; exceeding it fails loading with
`explain.limit_exceeded`. Replaying one requested path is performed on demand, and
the returned YAML strings and nested records cannot mutate later explanations.
Application commands should render the separate `SecretsReport` immediately after
an explicit `LoadSecretsDir` bootstrap, then use `WriteDiagnostics`,
`WriteEffective`, or `WriteExplanation` on the later load result as appropriate.
ConfigKit does not infer bootstrap outcomes from a secret reference and does not
bundle these independent reports into a command framework.
## Typed secret references
Credential-bearing fields use the opaque `Secret` type and store an environment
variable name in YAML, such as `api_key: OPENROUTER_API_KEY`. `SecretEnv(name)`
constructs the same unresolved reference for application defaults or recorded
resolution. Names must match `[A-Za-z_][A-Za-z0-9_]*`; they are public
configuration and remain visible through `EnvName`, canonical projection, history,
and effective output unless the path is separately classified for redaction.
A non-pointer `Secret` is required. An absent, null, or invalid reference, or an
unset or empty environment value, fails with a safe `secret.*` diagnostic at the
configuration path. `*Secret` is optional only when nil; every non-nil reference
must resolve to a nonempty value. Secret references work in typed structs, maps,
pointers, slices, and arrays. A dynamic `any` value does not establish secret
semantics and cannot contain `Secret`; applications must use a typed slot.
ConfigKit resolves only the effective names after recorded application resolution
and before semantic validation. Each unique name is read with `os.LookupEnv` once
per load. Validation can inspect the credential through the deliberately named
`Value()` accessor and distinguish success with `IsResolved()`. Later loads read
the environment again, and cloned defaults are rebuilt from names so a previously
resolved result cannot retain a stale credential. `Load` never writes to the
environment.
Credential bytes are held only in the per-load lookup state and the trusted returned
configuration, and are returned only by `Value`. Secret formatting uses a safe
public-name representation, JSON marshals only the name, and projection, metadata,
histories, diagnostics, effective output,
and resolution snapshots never retain the credential. Validation remains read-only:
in addition to ordinary projected-value comparison, ConfigKit checks the private
resolved state against this load's lookup result. Validator mutation checks also
reject changes to projected field sensitivity, even when a replacement dynamic
concrete type preserves the same textual values. Applications must keep their
validation messages value-free and must not include `Value()` in diagnostics.
For example, a typed pipeline field is declared as:
```go
type PipelineConfig struct {
APIKey configkit.Secret `yaml:"api_key"`
}
```
The executable `ExampleSecretEnv` demonstrates loading and inspecting the safe
reference state without printing its value.
## Explicit secrets-directory bootstrap
`LoadSecretsDir(directory)` is a separate, opt-in startup operation. It is never
called by `Load`, is not a YAML option, and does not participate in configuration
validation. Applications choose the native directory path and should invoke the
operation once before starting concurrent work. The selected path may itself be a
symlink; ConfigKit resolves it to an existing canonical directory before examining
its immediate entries.
The bootstrap enumerates at most 1,024 entries and sorts them by filename. Every
entry must have an ASCII environment-variable name and be a regular, nonsymlink
file. Metadata for all entries is validated before file content is read or process
state is changed. Existing environment variables win, including variables whose
value is empty; their files are not opened. Each eligible file is limited to 1 MiB,
and eligible content is limited to 8 MiB in total. ConfigKit removes exactly one
terminal LF or CRLF, preserves all other whitespace, accepts an empty value, and
rejects NUL bytes.
All eligible files are read successfully before application begins. Presence is
checked again immediately before each `os.Setenv`: a variable populated since
preflight is skipped. A write failure stops application without rollback, so prior
writes remain and later eligible entries are `not_applied`. This operation is not
atomic and does not provide compare-and-set semantics for concurrent environment
mutation.
`SecretsReport.Entries` returns detached, filename-ordered `SecretFileResult`
records with `loaded`, `skipped_existing`, `not_applied`, or `failed` status.
`ExistingEmpty` distinguishes an empty existing winner. `SecretsReport.WriteTo`
renders only quoted names, canonical paths, statuses, and that empty-state flag;
it never retains or renders file contents. Failures return the non-nil partial
report with a `*SecretsError` containing detached `secrets.*` diagnostics and no
raw filesystem or environment error chain. Applications should retain the report
alongside the later configuration result and explicitly present blocked and failed
files when appropriate; ConfigKit stores no implicit global report.
### Recorded application resolution
An optional `Definition.Resolve` callback runs after successful typed decoding. It
receives the working configuration and a per-load `Resolution` context. The context's
`IsExplicit` query uses surviving input presence, so explicit false, zero, empty, and
null are never confused with absence or static defaults.
`Default` assigns and records a derived value only when its path is not explicit.
`Normalize` always assigns and records. For transformations performed directly by
application code, `Resolution.Record` declares a derived-default or normalization
operation; a recorded container covers changes to its descendants. Records require
a schema-valid path, valid kind, safe nonempty reason, and schema-valid dependency
paths. Repeated records at one path are coalesced: reasons and dependencies retain
their first occurrence, the last helper value is authoritative, and any normalization
makes the combined operation a normalization.
ConfigKit projects the configuration before and after the callback. Every net change
must have an exact or ancestor record. Helper-assigned values must agree with the
final value at their declared path, which supports ordinary map-entry copy/writeback
while detecting forgotten writeback or unrelated targets. Invalid paths, kinds,
values, records, helper targets, and unrecorded mutations fail the load. Equal-valued
derived assignments may still record application intent even when there is no
structural change.
`Resolution.Error` and `Resolution.Warning` attach exact or nearest available input
locations. Reported errors fail the load; warnings survive on a successful result. A
returned resolver error adds one generic `resolve.hook_failed` error only when the
callback did not report an error, and its text is never exposed. Callback panics are
programming failures. Resolution and validation contexts are callback-scoped; using
a nil, zero, or retained context outside its active callback panics with fixed,
value-free text.
Successful records update effective origins and append detached metadata history
without changing input explicitness. Removed paths leave effective metadata but
retain history. ConfigKit privately snapshots each recorded before/after subtree for
later safe explanation; caller mutations of `Result.Config` cannot alter those
snapshots.
### Semantic validation
An optional `Definition.Validate` callback runs after successful resolution and
receives the resolved configuration plus a per-load `Validation` context. It can
collect multiple independent `validate.error` and `validate.warning` reports.
Warnings continue the lifecycle and are returned on success; any error returns a nil
result with the full safe diagnostic and source report.
Primary and related paths must be valid for the application schema. Omitted declared
fields and missing string-map or dynamic keys are valid report targets; unknown
struct fields and malformed paths produce `validate.invalid_path`. ConfigKit attaches
the exact surviving input location when one exists, otherwise the nearest input
container location, or no primary location for defaults-only synthetic state.
Validation is read-only. ConfigKit projects the configuration immediately before and
after the callback and returns `validate.mutation` at every detected net-change path,
including nested mutable state. A value that can no longer be projected is also a
mutation failure. No mutated or otherwise invalid configuration is published.
Callback panics remain programming failures, and retained contexts use the same
fixed closed-context panic as version and resolution callbacks.
Only schema-bound exported state participates in loading, resolution audit,
validation audit, metadata, and snapshots. Ignored or unexported ordinary fields are
zero in returned detached values. Applications retain responsibility for domain
rules and runtime initialization; validators should not start clients or depend on
live services as a substitute for configuration validation.
## Strict document composition
ConfigKit strictly composes parsed base documents when `Options.Imports` is true.
Application defaults do not participate in this operation.
| Existing node | Incoming node | Result |
| --- | --- | --- |
| Absent | Absent | Absent |
| Absent | Present | Retain the incoming node |
| Present | Absent | Retain the existing node |
| Mapping | Mapping | Recurse by mapping key |
| Any other pair of present nodes | Any other pair of present nodes | `compose.conflict` |
Every non-mapping overlap is a conflict, even when values are equal. Nulls and
sequences are present atomic values, so they also conflict when repeated; sequences
are never joined by index. Disjoint entries under shared mappings compose, making
stable keyed mappings the natural representation for independently contributed
entries.
An empty mapping contributes no child definitions. Two empty mappings compose, and
an empty mapping neither clears nor conflicts with another mapping's children.
Shared mapping containers retain all contributing value locations as their composite
base origin. A conflict reports the first claim as its primary location and every
remaining claim as related locations, in deterministic source and declaration
traversal order. Traversal order never authorizes replacement. Composition clones
the resulting tree and its ordered claim records rather than mutating or aliasing an
input document.
## Selected profiles
`Options.Profile` selects at most one overlay by name. Names must match
`[a-zA-Z0-9_-]+`; they are not paths. The empty name and exact lowercase name
`default` select no profile, and `profiles/default.yml` is never loaded implicitly.
Every other name resolves only to `<configuration-root>/profiles/<name>.yml`, with
no extension fallback, case normalization, inheritance, or profile stack. A selected
missing profile is an error.
The profile is a separately policy-validated YAML mapping. It is loaded after the
base import graph is composed and its schema version is checked, but before defaults
and typed decoding. Profile bytes, nodes, and source records count toward the same
aggregate limits as base inputs. Its canonical path must remain within the configured
root and must not duplicate a file already present in the base graph.
Overlay is intentionally distinct from strict composition:
| Existing lower node | Incoming profile node | Result |
| --- | --- | --- |
| Absent | Value | Retain the profile value |
| Value | Absent | Retain the lower value |
| Mapping | Mapping | Recurse by mapping key |
| Any other pair of present nodes | Any other pair of present nodes | Replace the entire lower node |
Scalars, sequences, and null are atomic. Thus a profile sequence replaces rather
than extends a base sequence, `[]` clears it, null is an explicit replacement, and
cross-kind replacement is allowed when the final application schema accepts it.
Replacing a subtree removes all displaced descendants from effective presence and
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
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.
## Explicit CLI overrides
`Options.Overrides` accepts only records the application knows were explicitly set
by its CLI parser. Each `Override` supplies a non-root mapping `Path`, a detached
`Value`, and a required safe display `Flag` such as `--workers`; ConfigKit has no CLI
parser dependency. Parser defaults must not be submitted as overrides, and `Flag`
must never contain the raw argument or configured value. General environment
variable overlays are an explicit non-goal: environment access is reserved for
typed secret references.
Overrides apply after the selected profile and before typed defaults/decoding, so
the input precedence is application defaults < composed base < selected profile <
CLI. Recorded resolution, typed secret lookup, and semantic validation follow the
effective override tree. Overrides also work with `NoSource`.
Values are converted directly into ConfigKit's owned tree without YAML parsing or
string coercion. Supported values are `nil`, built-in strings and booleans, all
built-in signed and unsigned integer widths except `uintptr`, finite `float32` and
`float64`, and recursive `[]any`/`map[string]any`. Strings stay strings; duration and
secret fields therefore receive string values. Nil is explicit null; empty strings,
false, and zero remain intentional; empty sequences replace whole lists; and mapping
values recursively overlay existing mappings. Map keys are sorted canonically and
sequence order is preserved.
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
configured schema-version path. Unknown fields and effective type mismatches remain
ordinary strict-decoder errors.
At most 1,024 records are accepted, and each record creates one synthetic CLI
source after file/profile sources in supplied order. Override sources share the
1,024-source ceiling. A record has an 8 MiB string-byte limit; override strings and
source bytes share the 64 MiB aggregate limit. Synthetic mapping keys and values
count toward the existing 1,000,000-node and depth-256 ceilings. Cycles, named or
unsupported types, nonfinite floats, and limit excesses fail with safe `override.*`
diagnostics. Diagnostics and provenance identify the safe `Flag` source, never raw
arguments or values.
Override origins are explicit CLI origins. History retains replaced base/profile
definitions and records equal-value replacements as well as structural changes.
Caller-owned maps and slices are snapshotted during loading and must not be mutated
concurrently with the call. The executable `ExampleOverride` shows translating a
parser's explicit-set state into records while preserving an explicitly supplied
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
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
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
body before its imports. Order stabilizes source records and diagnostics but never
permits replacement. Required missing paths fail; `optional: true` suppresses only
target nonexistence, not invalid paths, containment failures, directories,
permissions, parse errors, or composition errors.
Glob declarations use the slash-based Go `path.Match` grammar: `*`, `?`, character
classes, and backslash escapes are supported, while recursive `**` syntax is invalid.
Backslashes escape pattern characters rather than acting as directory separators.
ConfigKit validates the complete pattern before filesystem traversal, walks only
directories selected by preceding segments, and reads wildcard directories in
bounded batches. Every examined entry consumes the separate visit budget, including
nonmatches. Matches are sorted lexicographically by slash-relative spelling within
each declaration before depth-first source traversal. A required zero-match glob is
`import.missing`; `optional: true` suppresses only zero matches. Invalid patterns,
walk failures, matched directories, invalid documents, escapes, and repeated targets
remain errors. Final candidates are bounded by the remaining source limit before
their contents are accumulated.
The configuration root defaults to a selected file's canonical directory. A file
source may supply `FilesystemContext.Root` to widen that root but may not supply
`BaseDir`. Whenever byte and reader sources supply filesystem context, they must
provide both `BaseDir` and `Root` as absolute existing directories; their diagnostic
labels, including URI-looking labels, never imply filesystem paths. The base and
selected file must be inside the canonical root. Nonregular targets are rejected before opening to avoid blocking on FIFOs;
post-open regular-file checks remain in place under the stable-input assumption.
ConfigKit canonicalizes symlinks,
checks containment with relative paths, and opens the canonical root-relative target
through a library-owned root handle. Contained relative or absolute symlink targets
are accepted; escapes are rejected. Canonical paths define identity, so hard links at
distinct paths remain distinct sources.
An active-stack repeat is `import.cycle` and reports the complete cycle's import
sites. A previously visited canonical path is `import.repeated` and reports both
inclusion routes, including diamonds and aliases. Each accepted file is read once.
Imported fragments may contain their own root directives, but may not claim the
application's schema-version path or replace one of its ancestors; the version must
come from the selected source. No directory is scanned implicitly, including one
named `conf.d`, and ConfigKit performs no remote fetching.
## YAML documents
The private YAML boundary currently accepts exactly one nonempty document whose root
is a mapping. Comments and block or flow collections are supported. It rejects:
- empty input or an empty document;
- every additional document, including an empty one;
- duplicate decoded mapping keys;
- non-string mapping keys;
- explicit tags, anchors, aliases, and merge keys;
- a quoted or plain mapping key equal to `<<`; and
- input exceeding the fixed depth or node limits.
Mapping key comparison is exact: it performs no case folding or Unicode
normalization. The private document tree retains declaration order, exact validated
number spelling, source identifiers, and separate key/value line and column
locations. It contains no upstream parser nodes or types.
Root depth is zero. Input depth is limited to 256 and each parsed input tree to
1,000,000 nodes, counting mapping keys as input nodes. The upstream parser also has
a private calibrated depth guard; passing that guard never bypasses ConfigKit's tree
limits.
## Scalars
Scalar meaning is determined by ConfigKit rather than upstream coercion:
| Input form | Meaning |
| --- | --- |
| Quoted, literal, or folded scalar | String |
| Plain `true` or `false` | Boolean |
| Plain `null`, `~`, or an empty mapping value | Null |
| JSON decimal number | Number, retaining its exact spelling |
| Date/time-looking or other ordinary plain text | String |
JSON numbers permit an optional minus, a zero or nonzero decimal integer without a
leading zero, an optional fraction with at least one digit, and an optional decimal
exponent. Hexadecimal, octal, digit separators, leading plus, leading/trailing-dot
forms, incomplete exponents, and non-finite values are rejected. Legacy boolean
words (`yes`, `no`, `on`, and `off`) and non-lowercase variants of the reserved
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`, typed `Secret` references, 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 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. Inline pointers must
terminate in a struct; pointer-to-map and recursive pointer-only inline declarations
are rejected before the defaults factory runs. Embedded fields are ordinary
named fields unless explicitly inlined. `configkit:"secret"` is the only current
ConfigKit field tag and marks its bound field or container for secret classification
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
remain schema-valid for application-defined descendants, but dynamic values cannot
introduce typed secret references.
Descriptors are reused by typed decoding, projection, and cloning so those operations
cannot acquire different field bindings.
### Canonical module options
Applications with a known module catalog represent each configured item as an object
with an explicit module identifier and an options mapping. Ordinary typed structs,
including a small union of optional option fields, use the same strict binding above;
application validation decides which fields and combinations apply to each module.
String shorthand, a library module registry, and custom structural decoder hooks are
not supported alternatives.
When a runtime-selected catalog genuinely requires dynamic options,
`map[string]any` retains ConfigKit's canonical dynamic scalars. In particular,
integers are `int64`; application validation owns allowed keys, value constraints,
range checks, and conversion to runtime `int` or catalog objects. ConfigKit does not
provide generic subtree conversion, and consumers must not add a YAML marshal/decode
round trip to exercise supported behavior. The
[consumer adoption guide](consumers/README.md) contains compiled typed and dynamic
examples.
## Defaults and typed decoding
The private decoder starts from an independently owned clone of the application's
typed defaults and applies only explicitly present input nodes. Defaults therefore
never become explicit, while equal-valued input remains explicit. Successful decode
produces detached typed state, baseline and final canonical trees, an input-presence
index, and default/input origins for the later public loading lifecycle.
| Configured input | Effect on the typed baseline |
| --- | --- |
| Absent field | Retain the default |
| Explicit false, zero, or empty string | Replace the default |
| Partial mapping or string-keyed map | Recurse and retain omitted default children or entries |
| Empty mapping | Retain existing mapping children |
| Sequence, including an empty sequence | Replace the entire sequence |
| Pointer object | Recurse into a non-nil default; otherwise allocate an object whose omitted children have ordinary Go zero values |
| Allowed null | Replace the value with nil and suppress its default |
Null is allowed only for pointers, maps, slices, and dynamic `any` values. It is an
error for scalars, structs, and arrays. New map entries and sequence elements begin
from their Go zero values; ConfigKit does not invent entry-level defaults.
Decoding is strict at every typed depth. Unknown struct fields, type mismatches,
invalid nulls, numeric overflow, and arrays with the wrong length are errors at the
original semantic path and source coordinates. String-keyed maps accept arbitrary
keys but preserve the strict schema of their values; inline maps accept unmatched
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
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
range and finiteness checks. Named scalar types retain their identity. Durations
decode only from strings accepted by `time.ParseDuration`; integers are rejected.
Paired text scalars decode only from strings through `encoding.TextUnmarshaler`, and
codec failures are classified without exposing application error text or input
values.
This decoder is available only through `Load`; ConfigKit exposes no generic document
conversion API.
## Typed projection and ownership
ConfigKit can internally project a schema-valid application value directly into its
owned canonical tree without YAML serialization. Projection includes every bound
field, including ordinary zero values; `omitempty` has no effect. Struct fields keep
binding order, dynamic map keys sort by decoded string, and sequences keep their
order. Integers retain exact base-10 values, finite floats use their destination
precision, durations use `time.Duration.String`, and paired text scalars use their
canonical marshaled text.
Nil pointers, maps, slices, and dynamic values project as null. Non-nil empty maps
and slices remain empty containers, preserving the distinction. Recursive acyclic
objects are supported. Cyclic values, non-finite floats, unsupported dynamic concrete
types, invalid codec output, and depth/node-limit violations fail with safe schema
classifications that omit codec error text.
The same descriptor graph creates detached typed copies. Pointers, maps, slices,
arrays, nested dynamic values, and shared references are recursively copied; shared
pointer identity is not preserved as configuration meaning. Paired text scalars are
rebuilt by unmarshaling their canonical text into fresh values, detaching private
backing storage. Ignored and unexported struct fields are zeroed rather than copied.
Private structural comparison operates on canonical trees. Mapping additions,
removals, and descendant replacements retain semantic paths. A sequence is atomic:
any sequence change reports the sequence path and never invents correspondence
between old and new indices. Cross-kind and null/container changes replace the
affected path.
These mechanisms support decoding, resolution auditing, metadata,
and rendering. They are not a public generic conversion or cloning API.
## Safe failures
Source failures use stable `source.*` codes and YAML failures use stable `yaml.*`
codes declared by the root package. Diagnostics contain safe classifications,
semantic paths when applicable, and available source coordinates. Raw parser,
conversion, reader-error, and input text is not forwarded. Source labels and
diagnostic messages are application-visible and therefore must never contain
secrets; terminal control characters are escaped by `WriteDiagnostics`.

98
docs/consumers/README.md Normal file
View File

@@ -0,0 +1,98 @@
# Consumer Adoption Guide
ConfigKit removes parser setup, strict decoding, presence bookkeeping, import and
profile traversal, precedence, provenance, secret lookup, and safe inspection from
applications. Consumers still own their configuration structs, defaults, semantic
validation, schema-version policy, CLI parsing, runtime matching and conversion,
resource checks, remote acquisition, and service initialization.
The maintained [`examples/consumers`](../../examples/consumers) package demonstrates
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` |
| 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 |
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.
## Happy path
An application defines ordinary types and passes one explicit source. Optional
features appear only when the application needs them:
```go
result, err := configkit.Load[Config](configkit.File(path), configkit.Options[Config]{
Definition: rules,
Imports: useDeclaredImports,
Profile: selectedConfigProfile,
Overrides: explicitlySetFlags,
})
if err != nil {
return err
}
return start(result.Config)
```
Simple consumers use neither imports nor profiles. A complex consumer can opt into
both without redefining their syntax. ConfigKit profiles are configuration overlays;
they do not select or replace an application's LLM, model, pipeline, report, or
execution profile concepts.
Known module catalogs should use canonical object entries with a module identifier
and typed options. A small union struct is sufficient when the available option
fields are known; validation selects which fields apply to each module. Runtime-
selected catalogs may retain `map[string]any`, whose integers are `int64`; applications
validate keys and ranges and convert only at their runtime boundary. Neither shape
needs YAML reserialization, a parser hook, a ConfigKit registry, or a generic subtree
conversion API.
## Intentional migration changes
The examples preserve domain meaning while adopting one configuration convention:
- Explicit empty strings, lists, zeroes, false values, and nulls remain intentional.
ConfigKit accepts an empty list structurally; WeatherReporter-shaped validation
rejects an explicitly or programmatically supplied empty module/template list as a
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.
- 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
tags/scalars, trailing documents, and unknown typed fields. Diagnostics expose
stable classifications and safe paths, not copied parser text.
- General environment-variable configuration overlays retire. Application CLI
parsers translate only explicitly set configuration flags into `Override` records.
Separate invocation state remains outside configuration.
- Credential fields migrate to environment-name `Secret` references. Applications
explicitly call `LoadSecretsDir` before `Load` when they choose file bootstrap;
blocked empty variables remain blocked, and ordinary `Load` never changes the
process environment.
These examples are migration evidence, not completed changes to Narratio, Notarius,
WeatherReporter, or Seriatim. Updating those repositories, their deployed YAML, CLI
wiring, runtime catalogs, and operational documentation remains separate downstream
work.
## Maintained API examples
Every nontrivial initial API sketch has a compiled example or a
focused behavioral owner:
| Roadmap surface | Maintained owner |
| --- | --- |
| `Load`, `NoSource`, validation, versions, resolution, `SecretEnv`, overrides, and explanation | Executable examples in [`example_test.go`](../../example_test.go) |
| Source/import/profile pipeline rules and artifact defaults | Narratio-shaped executable example and fixture |
| Canonical module objects and dynamic option handling | WeatherReporter- and Notarius-shaped examples and migration tests |
| `LoadSecretsDir`, bootstrap reporting, typed lookup, CLI explanation | `Example_weatherStartup` |
| Results, metadata, diagnostics, effective output, and writer failures | Focused root-package load, provenance, rendering, and explanation tests |
The package-level import-boundary test covers the consumer examples too: supported
usage names no YAML dependency, parser type, parser option, or parser error chain.

View File

@@ -1,16 +1,16 @@
# Development
This is the contributor entry point for ConfigKit. Read the task-specific owners
below before making changes. The repository is in pre-implementation: it has no
Go module, packages, tests, or executable examples yet. The
[component overview](internal/overview.md) owns the implementation inventory.
Start here when contributing to ConfigKit. Read the task-specific owners below
before making changes. The [component overview](internal/overview.md) maps
implementation responsibilities; the [configuration contract](config.md) owns
public behavior.
## Initial orientation
1. Inspect the working tree and preserve unrelated changes.
2. Read the architecture policy for code or design work.
3. Read the policy, contract, and internal documents applicable to the task.
4. Inspect relevant implementation and tests where they exist before changing them.
4. Inspect the relevant implementation and behavioral tests before changing them.
## Task-specific reading guide
@@ -18,43 +18,93 @@ Go module, packages, tests, or executable examples yet. The
| --- | --- |
| Repository orientation or component responsibility | [Architecture policy](policy/architecture.md) and [component overview](internal/overview.md) |
| Documentation changes | [Documentation policy](policy/documentation.md) and the owning document |
| Architecture decisions or future work | [Documentation policy](policy/documentation.md), [initial target design](roadmap/initial.md), and relevant ADRs listed below |
| Code and test changes | [Architecture policy](policy/architecture.md), [testing policy](policy/testing.md), and owning contracts/tests as they are introduced |
| Examples or copyable assets | The owning implemented contract and related maintained examples, once available |
| Release preparation | [Release policy](policy/release.md) and [release-readiness gaps](roadmap/release-readiness.md); the operational runbook will be added before release |
| Architecture decisions or future work | [Documentation policy](policy/documentation.md), [deferred scope](roadmap/deferred.md), and relevant ADRs listed below |
| Code and test changes | [Architecture policy](policy/architecture.md), [testing policy](policy/testing.md), and the owning contract and tests from the map below |
| Examples or copyable assets | The owning implemented contract and [consumer adoption guide](consumers/README.md) |
| Release preparation | [Release policy](policy/release.md) and [release procedure](release.md) |
Accepted decisions:
- [Separate composition and overlay](adr/0001-separate-composition-and-overlay.md)
- [Own YAML semantics behind a private parser boundary](adr/0002-own-yaml-semantics.md)
- [Preserve presence and record application resolution](adr/0003-preserve-presence-and-resolution-history.md)
- [Secret references and explicit bootstrap](adr/0004-secret-references-and-explicit-bootstrap.md)
- [Explicit overrides and safe explanation](adr/0005-explicit-overrides-and-safe-explanation.md)
For cross-cutting changes, follow every applicable row. The
[implementation plan](roadmap/implementation.md) owns the ordered stages and their
acceptance checks; all remain pending until implementation evidence is recorded.
For cross-cutting changes, follow every applicable row. Completed implementation
and remediation plans are preserved in Git history at `cb5b435`; use current
contracts and the component overview for ongoing work. For example:
```sh
git show cb5b435:docs/roadmap/remediation.md
```
Release-candidate, publication, and verification work follows the operational
[release procedure](release.md).
## Baseline validation
For the current documentation-only repository:
Install Go 1.25.0 or a supported patched/newer Go toolchain. Acquire declared
dependencies explicitly before entering an offline development or validation
environment:
```sh
git diff --check
go mod download
```
Also inspect new untracked documents, verify changed local links and heading
fragments, and review paths, examples, ownership, and current-versus-planned
claims. The command above does not check links or untracked content.
Run the everyday check with Bash 3.2 or newer and Git:
Go validation is not runnable until a module and packages exist. The scaffolding
change must establish and verify library test, vet, and build commands here,
including their prerequisites. Do not use an application binary build as the
library baseline. Add executable-example and other checks when those artifacts
are introduced; do not document nonexistent tools as operational.
```sh
./scripts/check.sh
```
Once code exists, use focused checks while iterating and validation proportionate
to the change under the testing policy. Documentation-only work does not require
Go checks unless it changes behavior-bearing examples or commands they validate.
Release validation is a separate, stricter gate governed by release policy.
[The script](../scripts/check.sh) runs from the repository root regardless of the
invocation directory. It checks Go formatting without rewriting files, checks
staged/unstaged and untracked whitespace, then runs `go test ./...`,
`go vet ./...`, and `go build ./...`, stopping on the first failure. It uses the
installed Go toolchain by default, honors an explicit `GOTOOLCHAIN`, and disables
dependency downloads. Acquire dependencies and any selected toolchain beforehand.
Format reported Go files with `gofmt -w <files>`.
The default suite includes executable examples, maintained consumer fixtures,
local documentation-link checks, and the dependency import-boundary check.
It must stay deterministic and runnable without network access or credentials.
Still inspect new/untracked content and review paths, examples, ownership, and
current-versus-planned claims; automated checks do not replace content review.
Use focused checks while iterating, with validation proportionate to the change
under the testing policy. Documentation-only work normally needs the local-link
check below and whitespace review; behavior-bearing examples and commands also
need execution. Use `go test -race ./...` for ownership or concurrency changes.
The separate [release checker](release.md#4-validate-the-source-candidate) adds
two-toolchain, module-hygiene, race, and cross-build gates.
## Task-to-test map
Commands run from the repository root after dependency acquisition. These are
starting points for focused iteration; run the everyday check before handing off
code changes. Use the component overview to find the corresponding implementation.
| Task | Behavioral test owners | Focused command |
| --- | --- | --- |
| YAML syntax, diagnostics, parser limits | [Adapter tests](../internal/yaml/parse_test.go), [source translation](../parse_test.go) | `go test ./internal/yaml` and `go test . -run '^TestParseSource'` |
| File/reader access, imports, globs, containment | [Sources](../source_test.go), [FIFO](../source_fifo_test.go), [imports](../imports_test.go), [globs](../glob_test.go) | `go test . -run 'Source\|Import\|Glob\|Filesystem\|Nonregular\|RegularFile'` |
| Composition, profiles, CLI precedence | [Composition](../compose_test.go), [overlay](../overlay_test.go), [profiles](../profile_test.go), [overrides](../override_test.go) | `go test . -run 'Compose\|Overlay\|Profile\|Override'` |
| Schema, defaults, projection, cloning | [Schema](../schema_test.go), [decoding](../decode_test.go), [projection](../projection_test.go) | `go test . -run 'Schema\|Decode\|Projection\|Clone\|Inline\|CompareDocument'` |
| Lifecycle, resolution, validation, callback ownership | [Loading](../load_test.go), [resolution](../resolution_test.go), [validation](../validation_test.go), [ownership](../callback_ownership_test.go), [independence](../independence_test.go) | `go test . -run 'Load\|Resolution\|Validation\|Validator\|Hook\|Callback\|Parallel'` |
| Paths, metadata, provenance | [Paths](../path_test.go), [metadata](../metadata_test.go), [provenance](../provenance_test.go) | `go test . -run 'Path\|Metadata\|Provenance\|Queries'` |
| Secrets, bootstrap, redaction, explanation | [References](../secret_reference_test.go), [bootstrap](../secrets_test.go), [rendering](../render_test.go), [explanation](../explain_test.go) | `go test . -run 'Secret\|Bootstrap\|Effective\|Explain\|Explanation\|History\|Sensitivity'` |
| Consumer examples and migration fixtures | [Consumer tests](../examples/consumers/consumers_test.go), [API examples](../example_test.go) | `go test ./examples/consumers` and `go test . -run '^Example'` |
| Documentation links and dependency isolation | [Link checks](../documentation_test.go), [import boundary](../import_boundary_test.go) | `go test . -run '^(TestLocalDocumentationLinks\|TestDependencyImportBoundary)$'` |
| Check-command behavior | [Shell regression](../release_check_test.go) | `go test . -run '^TestCheckScripts'` |
## Fuzz validation
Run the bounded fuzz targets independently from the repository root. These exact
commands keep each campaign short and constrain concurrent workers:
```sh
go test . -run='^$' -fuzz='^FuzzParsePath$' -fuzztime=30s -parallel=2
go test ./internal/yaml -run='^$' -fuzz='^FuzzParseDocument$' -fuzztime=30s -parallel=2
go test . -run='^$' -fuzz='^FuzzCompositionAndOverlay$' -fuzztime=30s -parallel=2
```

414
docs/internal/extraction.md Normal file
View File

@@ -0,0 +1,414 @@
# Source Extraction Inventory
This inventory records reusable implementation evidence from sibling applications.
Those checkouts are review inputs only: ConfigKit does not import them, and its
build and tests must not require them. ConfigKit's current contracts and roadmap
govern every adaptation.
## Inspected revisions
| Application | Revision | Worktree | Role |
| --- | --- | --- | --- |
| Narratio | `b68435128be1e24b94fc7ec259d6e9093669fd99` | Clean `main` tracking `origin/main` | Primary parsing, composition, import, profile, presence, defaults, resolution, and inspection source |
| WeatherReporter | `5d306a202471e10fe98923a1df27d0b0fce5c666` | Clean `main` tracking `origin/main` | Secrets bootstrap and simple-consumer/defaults/options evidence |
| Notarius | `77ae132e5609916da40dcd4fcc749c000a63cbac` | Clean `main` tracking `origin/main` | Canonical binding, string-version, and dynamic-option migration evidence |
| Seriatim | `0fc92f364316c874d366b00cdc884a33e1900b25` | Clean `main` tracking `origin/main` | Independent ordered speaker/autocorrect rule evidence |
These revisions were inspected on 2026-09-06. Paths below are relative to each
application repository. Record later source revisions and adaptations here when
the owning implementation work extracts a symbol or case.
## Adaptation rules
- Copy and generalize only the smallest applicable mechanism or behavioral case.
- Replace upstream YAML nodes, application types, commands, logging, digests, and
provider registries with ConfigKit-owned boundaries.
- Preserve strictness, deterministic behavior, and useful failure coverage while
changing expectations that conflict with ConfigKit's accepted contracts.
- Use sanitized local fixtures only. Audio, transcripts, credentials, private
configuration, and whole application package trees are application-only.
- Record the exact copied/generalized symbols and tests in the owning section when
extraction occurs. Assignment prose records the original work boundary; dated
implementation records state what was actually generalized.
## Narratio evidence
### Source parsing and strict input
Assigned to the YAML/source implementation:
- Production: `internal/config/load.go` functions `decodeStrictYAML`,
`decodeStrictYAMLFromReader`, and `LoadSessionBytesWithOptions`; and
`internal/config/composition.go` functions `parseCompositionDocument`,
`parseCompositionBytes`, and `buildCompositionNode`.
- Cases: `TestLoadersRejectTrailingYAMLDocuments`,
`TestStrictYAMLRejectsParserExposedEmptyTrailingDocument`, and
`TestStrictYAMLAcceptsSingleDocumentAndRejectsUnknownFields` in
`internal/config/loader_strict_test.go`; plus duplicate-key, alias, non-mapping,
malformed-input, scalar spelling, presence, declaration-order, and source-label
coverage in `TestParseCompositionDocumentRetainsPresenceOwnershipAndDeclarationOrder`
and `TestParseCompositionDocumentRejectsAmbiguousOrMalformedYAML`.
- Preserve: labeled reader/byte input, strict unknown-field intent, rejection of
trailing documents (including an empty second document), duplicate detection,
and source-aware failures.
- Adapt: translate v4 parser nodes into ConfigKit's private document tree, apply
ConfigKit's scalar grammar and limits, and replace raw parser errors with safe
diagnostics. Narratio's application decoders and YAML node types are not copied.
Implementation record (2026-09-06): the labeled byte/reader ownership and strict
single-document cases above were generalized into `source.go`, `parse.go`, and
`internal/yaml`. No production line was copied verbatim. ConfigKit adds byte and
tree ceilings, shared atomic reader claims, regular-file checks, exact numeric
lexemes, parser-depth calibration, ConfigKit-owned locations, safe diagnostic
codes, and an AST import-boundary regression. Application typed decoding, parser
errors, YAML node types, and session-specific loaders were not retained.
### Strict composition
Assigned to document composition:
- Production: `mergeAdditiveComposition`, `mergeAdditiveCompositions`,
`validateAdditiveClaims`, `appendCompositionClaims`, `mergeAdditiveNodes`,
`newCompositionConflict`, and cloning/path/source helpers in
`internal/config/composition.go`.
- Cases: `TestMergeAdditiveCompositionJoinsOnlyDisjointMappings`,
`TestMergeAdditiveCompositionRejectsEveryDuplicateClass`, and
`TestMergeAdditiveCompositionReportsAllClaimingSources`.
- Preserve: recursive joining of disjoint mappings, collisions for every overlapping
non-mapping value (including equal values and sequences), all claimant sources,
deterministic traversal, and nonmutation of inputs.
- Adapt: empty mappings follow ConfigKit's no-child operation rule; paths use the
ConfigKit semantic path model; both precise locations and composite origins are
retained. Default values never participate in strict composition.
Implementation record (2026-09-06): `composeDocuments`, its pre-order claim
collection, conflict construction, and recursive mapping join in `compose.go` adapt
the assigned production symbols. `TestComposeDocumentsJoinsDisjointMappingsAndTracksClaims`,
`TestComposeDocumentsRejectsEveryPresentNonMappingOverlap`, and
`TestComposeDocumentsReportsAllClaimantsAtExactLocations` adapt the assigned cases.
The intentional empty-mapping difference is covered separately: empty mappings retain
container provenance but contribute no child claims. No other semantic deviation
from the extracted additive composer was required.
### Imports
Assigned to explicit import traversal and glob handling:
- Production: `loadPipelineCompositionSources`,
`splitPipelineCompositionEnvelope`, `loadPipelineImports`, and related ownership
structures in `internal/config/pipeline_composition.go`.
- Cases: `TestSplitPipelineCompositionEnvelopePreservesInputAndDeclarationOrder`,
`TestSplitPipelineCompositionEnvelopeRejectsMalformedShapesWithoutMutation`,
`TestLoadPipelineCompositionImportsDisjointFieldsAndTracksOwnership`,
`TestLoadPipelineCompositionMergesDisjointKeyedEntries`,
`TestLoadPipelineCompositionRejectsBaseConflictsWithAllSources`,
`TestLoadPipelineCompositionRejectsUnsafeOrInvalidImports`,
`TestLoadPipelineCompositionRejectsSameFileAliasesAndImportedComposition`,
`TestLoadPipelineCompositionReportsImportedParseAndSchemaSources`, and
`TestLoadPipelineCompositionKeepsRelativePathsRootBased`.
- Preserve: explicit declarations, declaration-order traversal, disjoint
composition, conflict attribution, malformed/unreadable target failures, directive
removal, and imported parse/schema source labels.
- Adapt: replace the root-only `composition` envelope with recursive
`_configkit.imports`; add path and glob declarations, bounded depth-first graph
traversal, canonical containment, cycle/diamond routes, and fixed source IDs.
Canonical paths—not `os.SameFile`—define identity, so hard links remain distinct.
Path-import implementation record (2026-09-06): `splitImportDirectives`,
`parseImportDeclaration` (now in `directives.go`), `importFilesystem` (now in
`filesystem.go`), and `importTraversal.visitPath`/`loadImportedDocuments` in
`imports.go` adapt the assigned envelope, loading, source,
and failure mechanics. The corresponding ConfigKit tests adapt the assigned
declaration-order, disjoint composition, keyed-entry, all-source conflict, unsafe
target, alias, imported parse/schema, and relative-resolution cases. ConfigKit adds
recursive bounded DFS, `os.OpenRoot` confinement, canonical symlink identity,
cycle/diamond route locations, optional-path semantics, aggregate budgets, and
fragment version-placement enforcement. That checkpoint parsed glob syntax behind an
explicit unsupported gate that the subsequent glob implementation removed; hard
links intentionally replace Narratio's same-file rejection with path identity.
Glob-import implementation record (2026-09-06): ConfigKit retains the extracted
declaration order, import failures, composition, and identity cases while adding the
new `expandImportGlob`, `readGlobMatches`, and pattern-validation mechanisms in
`glob.go`. Focused cases cover lexical ordering, multiple declarations, overlap,
directories, hard links, contained and escaping symlinks, unmatched declarations,
malformed and recursive patterns, escaped metacharacters, character classes,
permission failures, explicit `conf.d` use, and entry/candidate budgets. This work
has no Narratio production equivalent; it uses the selected standard-library
`path.Match` and bounded `File.ReadDir` adaptation rather than its flat loader.
### Profiles and overlay
Assigned to selected-profile overlay:
- Production: `mergeOverlayComposition`, `mergeOverlayNodes`,
`selectPipelineProfile`, `loadPipelineProfileOverlays`, and
`loadedProfileOverlay`.
- Cases: `TestLoadPipelineProfilesSelectDefaultAndExplicitOverlay`,
`TestLoadPipelineProfileSelectionErrors`,
`TestPipelineProfileNamesRejectControlCharacters`,
`TestLoadPipelineProfilesValidateEveryDeclaredOverlay`,
`TestLoadPipelineProfileOverlaySemantics`,
`TestLoadPipelineProfileRejectsNullAndKindChanges`, and
`TestLoadPipelineProfilePairResolvesIndependentEffectivePipelines`.
- Preserve: recursive mapping overlay, atomic scalar/sequence replacement,
deterministic selection failures, source ownership, and independent results.
- Adapt: load only the selected fixed profile path; lowercase `default` means no
overlay. ConfigKit permits null and cross-kind replacement and does not support
declared defaults, inheritance, arbitrary profile paths, or validating every
unselected overlay.
Implementation record (2026-09-06): `overlayDocuments`, `mergeOverlayMappings`,
`loadProfileDocument`, and `profileDirectiveDiagnostics` adapt the assigned merge,
selection, loading, and ownership mechanics. `TestOverlayDocumentsRecursesMappingsAndReplacesAtomicSubtrees`,
`TestLoadAppliesSelectedProfileAfterBaseAndBeforeDefaults`, profile selection/error,
directive/version, containment, and budget cases adapt the assigned tests. ConfigKit
uses exactly one fixed selected `.yml` source, permits null and cross-kind replacement,
retains displaced descendant history, rejects any profile directive or version-path
contribution, and counts the profile in aggregate budgets. Narratio's declared
default, multi-overlay validation, inheritance-like application conventions, and
null/kind restrictions were not retained.
### Effective inspection and provenance
Assigned to layered metadata, redaction, rendering, and explanation:
- Production: `EffectivePipelineValues`, `EffectivePipelineSources`,
`MarshalEffectivePipeline`, `effectivePipelineFieldPaths`,
`effectivePipelineOwners`, and composition `semanticRecords`/
`compactSemanticRecords`.
- Cases: `TestCompositionCanonicalOutputsAreDeterministic`,
`TestEffectivePipelineValuesIgnoreEquivalentSourceLayout`,
`TestConfigValidateAndShowPipelineOnlyAreSideEffectFree`,
`TestConfigSourcesReportsStableOwnership`,
`TestConfigSourcesReportsCanonicalFamilyAndPublishOrigins`,
`TestConfigSourcesPreservesProfileOwnershipThroughFamilyExpansion`, and
`TestConfigSourcesReportsRootImportProfileAndDefaultOwnership`.
- Preserve: deterministic effective projection, stable logical paths, source
ownership, side-effect-free inspection, and domain-attribution evidence.
- Adapt: project directly from ConfigKit-owned typed values without YAML round trips;
add line/column locations, composite origins, complete operation history, detached
records, and redaction. Narratio commands, diff/digest APIs, generated artifact
attribution, campaign logic, and party expansion remain application-owned.
Layered-provenance implementation record (2026-09-06): ConfigKit integrated its
previously extracted source claims, overlay displacement records, typed baseline,
and audited resolution snapshots in `load_metadata.go`. The representative
`TestLayeredProvenanceAgreesWithResolvedSnapshot` adapts Narratio's stable ownership,
profile ownership, deterministic effective projection, and side-effect-free
inspection cases across one default/import/profile/resolution workflow. ConfigKit
adds chronological descendant history for parent and whole-sequence operations,
composite empty-mapping origins, canonical resolution reasons/dependencies, private
snapshot agreement, defensive nested copies, and concurrent load isolation. Value
That checkpoint deliberately left rendering, redaction, explanation, commands,
generated attribution, diffs, and digests outside its scope.
Effective-rendering implementation record (2026-09-06): ConfigKit adapts Narratio's
deterministic effective-output and side-effect-free inspection cases in
`classifySecretPaths`, `redactedDocument`, `Result.WriteEffective`, and the private
`internal/yaml.Write` adapter. Tests cover stable sorted mappings, preserved sequence
order, explicit indentation/line/document formatting, tagged containers, dynamic
current and historical paths, fixed-point dependency cycles, nil/empty distinctions,
resolved values, text-scalar snapshot reuse, application YAML-hook isolation,
non-disclosure through failures and record formatting, caller mutation, and writer
failures. Unlike Narratio's marshal/reparse path, ConfigKit renders only its owned
frozen tree and substitutes redaction before the upstream dumper boundary. Historical
value explanation and secret references were implemented at their later owning
boundaries and are exercised by the consumer startup example below.
### Defaults, presence, and domain resolution
Assigned across schema, decoding, resolution, validation, and consumer examples:
- Production: `NormalizeConfig.UnmarshalYAML`, `outputPathWasSet`,
`applyPipelineDefaults` and its component default helpers, `Resolve`,
`ResolveLoadedPipelineCampaign`, `mergeCampaignSession`, `selectStableInput`, and
application-supplied byte loading in `internal/app/config_loader.go`.
- Cases: `TestNormalizeLoadAndValidate`,
`TestRepresentativeLoadDefaultsAndValidation`,
`TestMarshalEffectivePipelineRendersStablePublicConfiguration`,
`TestPipelineCampaignRegistryStrictDecode`,
`TestCampaignSessionMergeFillsStableInputs`,
`TestCampaignSessionMergeSessionOverridesStableInputs`,
`TestLoadWithSessionOptionsCarriesExplicitProfilePresence`, and the maintained
extraction-example coverage in `TestMaintainedExtractionExamplesPreservePublishedContracts`.
- Preserve: fresh defaults, explicit-presence-sensitive domain decisions, strict
nested types, stable input selection, application-supplied labeled bytes, and
validation after resolution.
- Adapt: presence comes from metadata rather than custom YAML hooks or pointer/zero
guesses; explicit empty and null values are retained; defaults are cloned and
application-owned; every domain mutation is recorded. Campaign/session fallback,
search paths, fetching, storage, and concrete default values remain in Narratio.
Implementation record (2026-09-06): ConfigKit generalized Narratio's fresh-default,
strict nested-type, and explicit-presence cases into `decode.go` and
`decode_test.go`. The decoder applies the owned document tree over a detached typed
baseline, treats mappings recursively and sequences atomically, preserves explicit
false/zero/empty/null, rejects unknown nested fields, and returns private presence
and default/input origin artifacts. Unlike Narratio's custom YAML hooks and
pointer/zero inference, presence comes directly from the parsed tree and equal-valued
input remains explicit. ConfigKit also adds destination-width numeric checks,
dynamic `int64`/`float64`, exact arrays, duration strings, paired text codecs, safe
multi-error collection, and repeated-result isolation. Resolution, validation,
consumer defaults, and all campaign/session algorithms remain assigned to later
work.
Lifecycle integration record (2026-09-06): the public single-source `Load` boundary
combines the already extracted labeled-source and strict-decoding mechanisms with
Narratio's fresh-default and presence expectations. ConfigKit adds an application-
defined pre-decode string-version check, defaults-only `NoSource` operation,
detached base/default metadata and source reports, and explicit gates for every
later lifecycle option. No application loader or production line was copied.
Narratio's resolution, validation, import/profile, campaign/session, and command
behavior remains assigned to its later owning work.
Resolution implementation record (2026-09-06): ConfigKit adapted the intent of
Narratio's `Resolve`, `applyPipelineDefaults`, component default helpers,
`mergeCampaignSession`, and `selectStableInput` into a generic recorded resolver in
`resolution.go`. The synthetic timeout/artifact cases preserve explicit-input
decisions, derived dependencies, normalization, and map copy/writeback without
copying campaign/session algorithms or concrete defaults. ConfigKit adds structural
mutation auditing, container coverage, helper/path consistency checks, equal-value
intent, coalesced repeated writes, private before/after snapshots, deterministic
record ordering, and safe hook diagnostics. Semantic validation remains assigned to
the next lifecycle work.
Validation implementation record (2026-09-06): ConfigKit generalized Narratio's
`TestNormalizeLoadAndValidate`, `TestRepresentativeLoadDefaultsAndValidation`,
strict registry decoding, and maintained extraction-example expectations into the
post-resolution `Validation` lifecycle and synthetic consumer examples. ConfigKit
keeps domain rules application-owned, resolves schema-valid missing/dynamic report
paths to nearest input locations, collects independent warnings/errors, and audits
the full typed projection to reject validator mutation. Live resources, registries,
campaign/session logic, commands, and application runtime initialization were not
extracted.
### Secrets bootstrap
Assigned to the explicit bootstrap operation:
- Production: `loadSecretsFromConfigWithDependencies`, `validateSecretDirectory`,
`validateSecretFile`, `readAPIKeyFile`, and
`internal/fileops/read_bounded.go` function `ReadFileBounded`.
- Cases: `TestLoadSecretsFromConfigLoadsValidFiles`,
`TestLoadSecretsFromConfigPreservesExistingEnv`,
`TestLoadSecretsFromConfigRelativeDirUsesCWD`,
`TestLoadSecretsFromConfigMissingDirFails`,
`TestLoadSecretsFromConfigUnreadableValidEntryFails`,
`TestLoadSecretsFromConfigRejectsUnsafeModes`,
`TestLoadSecretsFromConfigRejectsNonRegularAndOversizedEntries`, and
`TestLoadSecretsFromConfigRejectsSymlinkAndAncestorReplacement`.
- Preserve: bounded reads, valid environment names, existing variables (including
empty values) winning, regular-file checks, symlink rejection, and failure cases.
- Adapt: use ConfigKit's deterministic report/preflight/apply state machine and
fixed budgets. Logging, configuration-driven implicit invocation, private-mode
conventions, and application API-key plumbing remain in Narratio.
Implementation record (2026-09-06): `LoadSecretsDir`, `readSecretsEntries`,
`readSecretValue`, and the private environment/file seams in `secrets.go` adapt
Narratio's bounded-read and preserve-existing mechanisms at revision
`b68435128be1e24b94fc7ec259d6e9093669fd99`. The corresponding tests retain its
valid-file, existing-variable (including empty), missing/permission/read,
nonregular, symlink, and byte-limit cases. ConfigKit validates invalid entries
rather than skipping them, permits ordinary deployment permission modes, resolves
the selected directory symlink, performs complete preflight before mutation, and
emits per-file safe records without application configuration or logging.
## WeatherReporter evidence
### Secrets bootstrap
Assigned to the explicit bootstrap operation:
- Production: `loadSecrets`, `applySecrets`, `restoreEnvironment`, `secretValue`,
and `environmentValue` in `internal/config/secrets.go`.
- Cases: `TestLoadSecretsDisabledLeavesEnvironmentUnchanged`,
`TestLoadSecretsOverwritesExistingEnvironment`,
`TestLoadSecretsTrimsOneTrailingLineEnding`,
`TestLoadSecretsRejectsInvalidDirectoryEntries`,
`TestLoadSecretsRejectsMissingDirectory`,
`TestLoadFileSecretDirectoryFailureLeavesEnvironmentUnchanged`,
`TestLoadFileValidationFailureLeavesSecretEnvironmentUnset`, and
`TestApplySecretsRollsBackOnEnvironmentFailure`.
- Preserve: separate read/preflight and apply mechanisms, one-newline handling,
invalid-name/type/read coverage, no writes after preflight failure, and controlled
environment-write failure coverage.
- Adapt: existing variables—including empty ones—are skipped, never overwritten;
application failures produce an accurate partial report and no rollback. Skip
entries are not read. Bootstrap is explicitly invoked and remains independent of
YAML loading and semantic validation.
Implementation record (2026-09-06): the same `secrets.go` state machine adapts
WeatherReporter's `loadSecrets`/`applySecrets` separation, environment-name check,
regular-file rejection, and exact one-LF/CRLF removal at revision
`5d306a202471e10fe98923a1df27d0b0fce5c666`. Tests adapt its newline matrix,
invalid-entry and preflight-no-write cases, and controlled application failure.
The overwrite/rollback expectation is intentionally replaced by existing-winner,
presence-recheck, and partial-progress statuses; no configuration loader invokes
the bootstrap.
### Defaults, explicit presence, and module options
Assigned to consumer-shaped examples and migration checks:
- Production: `Load`, `mergeFile`, `normalizeReportModules`,
`traverseReportModules`, `normalizeModuleOptions`, and `decodeKnownOptions`.
- Cases: `TestDefaults`, `TestOutputDirectoryLoading`,
`TestOutputDirectoryValidationIsConsistentForLoadedAndConstructedConfigs`,
`TestLoadMinimalExampleConfig`,
`TestReportDistributorPathTemplatesSetTracksExplicitEmptyList`,
`TestValidateReportModuleKeysWithoutMutatingOptions`,
`TestReportModuleOverridesNormalizesConstructedOptionsWithoutMutatingConfig`,
`TestReportModuleOverridesCanonicalizesTypedPointerOptions`,
`TestReportDistributorPathOverridesConsistentForLoadedAndConstructedConfig`,
`TestReportModuleValidationConsistentForLoadedAndConstructedConfig`,
`TestConstructedReportOverridesUseExportedFields`, and
`TestConstructedExplicitEmptyReportOverridesAreRejected`.
- Preserve: defaults-only use, consistent loaded/constructed outcomes, explicit
empty-list domain rejection, typed module-specific options, normalization without
mutating caller input, and unknown-field rejection.
- Adapt: ConfigKit permits empty lists structurally and exposes their presence to
application validation. Object bindings replace shorthand, explicitly set flags
become CLI override records, and general environment overlays retire. Report
semantics, registries, rendering, and module option validation remain application
responsibilities.
Consumer-example implementation record (2026-09-06): at Narratio revision
`b68435128be1e24b94fc7ec259d6e9093669fd99`, the maintained consumer cases adapt
`TestMaintainedExtractionExamplesPreservePublishedContracts`,
`TestConfigValidateAndShowPipelineOnlyAreSideEffectFree`, and the stable source-
ownership tests into a sanitized partial-import/artifact fixture. ConfigKit replaces
application composition envelopes and command output with `_configkit.imports`,
typed defaults, recorded resolution, metadata, and explanation. Artifact meaning,
model/pipeline profiles, commands, and generated-domain attribution remain in the
example application layer; no production function was copied.
At WeatherReporter revision `5d306a202471e10fe98923a1df27d0b0fce5c666`,
`TestDefaults`, explicit-empty-list cases, loaded/constructed consistency cases, and
nonmutating module-option normalization were adapted into the WeatherReporter-shaped
fixtures and tests in [`examples/consumers`](../../examples/consumers). ConfigKit's
detached defaults replace source mutation, canonical module objects replace string
shorthand, ordinary validation retains empty-list and option compatibility rules,
and explicit `Override` records replace external configuration-field overlays. The
startup example composes the separately implemented bootstrap report, typed secret
lookup, and explanation APIs without copying WeatherReporter's registries or
rendering/runtime modules.
Notarius revision `77ae132e5609916da40dcd4fcc749c000a63cbac` supplied
binding/version/dynamic-option migration evidence only. The local example uses an
ordinary canonical object, a string `VersionRule`, ConfigKit's `int64` dynamic
integer convention, and application range checking; it does not copy a catalog or
environment overlay. Seriatim revision
`0fc92f364316c874d366b00cdc884a33e1900b25` supplied ordered rule-file evidence.
The local examples load speaker and autocorrect files independently and keep their
matching/replacement algorithms outside ConfigKit. Neither source is a build or test
dependency.
## Application-only material
Do not extract application commands, filesystem discovery, logging, runtime
initialization, remote clients, LLM/provider setup, audio/transcript fixtures,
configuration digests, report rendering, campaign/session algorithms, or consumer
resource validation into ConfigKit. Maintained consumer examples use only small
synthetic types and fixtures to prove that these responsibilities remain
straightforward.

View File

@@ -1,10 +1,89 @@
# Internal Component Overview
ConfigKit has no implemented Go packages or components yet. This document will
inventory components and link to their current contracts and focused internal
documentation as they are introduced.
ConfigKit exposes the root `configkit` package and keeps its document and YAML
representations private. The implemented components and their owners are:
- immutable semantic `Path` construction and strict JSON Pointer parsing;
- detached effective presence/origin and chronological history queries in
`metadata.go`, with provenance and diagnostic record types;
- canonical, control-safe diagnostic rendering and structured `LoadError` access;
- opaque file, byte, reader, and no-input source construction in `source.go`,
with shared regular-file checks and bounded read mechanics in `source_file.go`;
- a parser-independent ordered tree in `internal/document`, with root-private
semantic lookup, indexing, and value traversal in `document_walk.go`;
- the sole upstream YAML integration in `internal/yaml`, which translates validated
input into that owned tree and exposes no parser types outside its boundary;
`parse.go` owns shared translation into detached, ordered public diagnostics;
- a private strict composer in `compose.go` that joins only disjoint document-tree
definitions, retains ordered claims and composite mapping origins, reports every
conflicting location, and never aliases an input tree;
- fixed selected-profile loading in `profile.go` and recursive, non-aliasing overlay
with displacement records in `overlay.go`; overlay records retain origins,
while value explanation uses separate history applications;
- CLI override validation/preparation in `override.go`, parser-neutral values in
`override_value.go`, and synthetic-source application in `override_apply.go`,
sharing overlay, source, byte, node, and depth policies;
- a per-operation private reflection descriptor graph in `schema.go` for supported
types, YAML field binding, inlining, secret tags, recursion, and semantic paths;
- descriptor-driven canonical projection in `projection.go`, detached typed
cloning with explicit secret-state modes in `clone.go`, shared reflection and
cycle/limit state in `value_operation.go`, and configuration-value comparison
in `document_compare.go`; validation separately audits sensitivity and secrets;
- strict schema-driven decoding over detached defaults in `decode.go`, including
mapping/sequence semantics, null and numeric policy, safe codec errors, presence,
baseline/final trees, and initial default/input origins;
- public lifecycle coordination in `load.go`, option selection in `options.go`,
input assembly in `load_input.go`, typed phases/publication in `load_result.go`,
bounded canonical import DFS in `imports.go`, filesystem context/containment in
`filesystem.go`, directive parsing/restrictions in `directives.go`,
pattern-directed glob expansion in `glob.go`, schema-version checking and report
contexts in `validation.go`, and layered metadata/history assembly in
`load_metadata.go`;
- recorded application defaulting and normalization in `resolution.go`, including
explicitness queries, helper verification, structural mutation audit, coalesced
intent, private before/after snapshots, and resolution metadata integration;
- post-resolution schema-aware report collection and validator mutation detection in
`validation.go`, completing the public callback lifecycle after composed input;
- opaque typed environment references and per-load name resolution in
`secret_reference.go`, including credential-free projection/cloning and private
validator-state auditing;
- explicit bounded startup environment bootstrap and safe partial reporting in
`secrets.go`;
- fixed-point tag/predicate/dependency sensitivity classification in `secret.go`;
- canonical effective output in `render.go`, using frozen-snapshot redaction
shared with explanation in `snapshot.go`;
- detached public explanation records and coordination in `explain.go`, immutable
input applications/replay and retained-node budgeting in `history.go`, shared
safe snapshot projection in `snapshot.go`, and deterministic human output in
`explain_render.go`; the only upstream dumper integration remains isolated in
`internal/yaml/write.go`;
- a separate maintained `examples/consumers` package with four sanitized consumer
shapes, fixtures, domain validators, and runtime-boundary functions; and
- an everyday single-toolchain check in `scripts/check.sh` and a stricter
source-candidate checker in `scripts/release-check.sh`, with local
documentation-link and public import-boundary gates owned by root-package tests.
Dependency direction remains inward: the public lifecycle coordinates private
source, document, schema, and inspection helpers. Semantic paths stay in the root
package; `internal/document` owns only representation and cloning and never imports
the root package. Only `internal/yaml` imports the upstream YAML package. Maintained
consumer examples use the public package and standard library.
The [configuration contract](../config.md) owns implemented input, schema, decoding,
loading lifecycle, path imports, composition, profile/CLI overlay, version checking,
recorded resolution, typed secret references/bootstrap, semantic validation,
metadata, secret classification, redacted effective rendering, historical
explanation, and ownership behavior.
The [consumer adoption guide](../consumers/README.md) owns current application-fit
and migration guidance.
The source-extraction inventory records the application foundations used by the
implementation:
- [Extraction inventory](extraction.md)
The [architecture policy](../policy/architecture.md) owns binding boundaries and
invariants. The [initial target design](../roadmap/initial.md) describes planned
behavior; it is not an inventory of implemented features. See the
[development guide](../development.md) for contributor orientation and validation.
invariants. The [deferred scope](../roadmap/deferred.md) records deliberate
exclusions. See the
[development guide](../development.md) for contributor orientation and validation
and the [release procedure](../release.md) for source-candidate gates.

View File

@@ -7,10 +7,10 @@
## Purpose and applicability
ConfigKit is an opinionated Go library for consistent YAML configuration loading
and resolution across consuming applications. This policy governs contributions
now; it does not claim that the target features are implemented. The
[component overview](../internal/overview.md) owns implementation inventory and
[initial roadmap](../roadmap/initial.md) owns unimplemented target behavior.
and resolution across consuming applications. This policy governs contributions.
The [component overview](../internal/overview.md) owns implementation inventory, the
[configuration contract](../config.md) owns implemented behavior, and the
[deferred scope](../roadmap/deferred.md) preserves deferred scope.
## Success criteria
@@ -48,8 +48,8 @@ existing primitives cannot accommodate it cleanly, and show how the extension
preserves a coherent model. Application-specific interpretation and validation
remain appropriate. Reuse Narratio's applicable infrastructure under these
boundaries, and keep simple consumers independent of optional composition
features. The [consumer API design](../roadmap/initial.md#consumer-api-design)
describes the proposed surface; this policy does not claim it is implemented.
features. The [consumer adoption guide](../consumers/README.md) demonstrates the
implemented surface against representative simple and complex shapes.
## Responsibility boundaries
@@ -103,12 +103,12 @@ decoding. Preserve presence and source information through processing. Ordinary
values must not be used to infer whether input was absent. Final configuration,
metadata, and effective inspection must agree, including application resolution
changes; see [ADR 0003](../adr/0003-preserve-presence-and-resolution-history.md).
Safe historical values and an explanation renderer belong to the initial target,
Safe historical values and an explanation renderer are part of the implemented contract,
including superseded/deleted paths and effective source attribution. Inspection
must agree with the final typed snapshot and apply historical sensitivity rules.
See [ADR 0005](../adr/0005-explicit-overrides-and-safe-explanation.md).
Detailed syntax and operation tables belong to the target roadmap until their
current configuration contract is implemented.
Detailed syntax and operation tables belong to the
[current configuration contract](../config.md).
## Ownership and confidentiality
@@ -136,6 +136,7 @@ for safe messages supplied by their hooks.
Do not introduce automatic directory discovery, network providers, arbitrary
merge strategies, additional configuration languages, or application runtime
management without concrete consumer evidence and an explicit design decision.
The roadmap owns the complete deferred-feature inventory. Significant boundary
The [deferred scope](../roadmap/deferred.md#explicit-non-goals-and-deferred-scope)
owns the deferred-feature inventory. Significant boundary
changes require an ADR and corresponding policy/contract updates; implementation
convenience alone is not a reason to broaden the public API.

View File

@@ -28,8 +28,8 @@ semantics, defaults, paths, diagnostics, or compatibility guarantees.
implementation; execution status belongs in roadmaps.
- Roadmaps own unimplemented targets, deferred work, and execution status.
The initial target and implementation plan have separate owners. Do not put
staged steps or progress in `docs/roadmap/initial.md`. `docs/roadmap/implementation.md` owns those details.
Future implementation plans own their task order, acceptance gates, and progress;
`docs/roadmap/deferred.md` owns deliberate exclusions, not execution instructions.
As behavior lands, move its normative definition into the current contract and
replace the roadmap definition with a link. Retire completed roadmap material
as part of release preparation; ADRs preserve significant decision history.
@@ -56,30 +56,29 @@ handling, not secret values.
| Topic | Owner | Boundary |
| --- | --- | --- |
| Product orientation | `README.md` | Current status, purpose, navigation, and a minimal working example once available |
| Product orientation | `README.md` | Current status, purpose, navigation, and a minimal working example |
| Contributor entry point | `docs/development.md` | Reading guide and actual baseline validation commands |
| Architecture rules | `docs/policy/architecture.md` | Responsibilities, dependency constraints, invariants, and scope boundaries |
| Documentation rules | `docs/policy/documentation.md` | Ownership and document lifecycle |
| Testing rules | `docs/policy/testing.md` | Risk-based sufficiency, test boundaries, and maintenance principles |
| Release rules | `docs/policy/release.md` | Compatibility, integrity, gates, immutability, and exceptions |
| Release procedure | `docs/release.md`, when operational | Exact maintainer commands, prerequisites, supported targets, and verification |
| Release procedure | `docs/release.md` | Exact maintainer commands, prerequisites, supported targets, and verification |
| Exported Go API | Go documentation beside declarations | Symbol contracts, errors, ownership, and lifecycle API behavior |
| YAML and resolution semantics | `docs/config.md`, as behavior lands | Syntax, composition, precedence, conversion, secret references/bootstrap, and explanation semantics; Go reference docs link here for shared rules |
| YAML and resolution semantics | `docs/config.md` | Syntax, composition, precedence, conversion, secret references/bootstrap, and explanation semantics; Go reference docs link here for shared rules |
| Consumer walkthroughs | `docs/consumers/`, when useful | Task-oriented usage and consumer responsibilities, not duplicate API reference |
| Component inventory | `docs/internal/overview.md` | Implemented components and links to their owners |
| Component mechanics | Other `docs/internal/` files, when needed | Detailed implementation behavior and change recipes |
| Architectural decisions | `docs/adr/` | Context, decisions, alternatives, consequences, and supersession history |
| Initial target | `docs/roadmap/initial.md` | Unimplemented end-state and deferred feature scope |
| Implementation plan | `docs/roadmap/implementation.md` | Stages, dependencies, acceptance gates, and progress |
| Release readiness gaps | `docs/roadmap/release-readiness.md` | Outstanding release prerequisites and closure evidence |
| Deferred scope | `docs/roadmap/deferred.md` | Deliberate exclusions and the conditions for reconsidering them |
| Future implementation plans | Task-specific files under `docs/roadmap/` | Unfinished work, task order, acceptance gates, and progress; completed plans retire to Git history |
| Historical release notes | `docs/releases/` | Version-specific historical summaries required by release policy |
| Complete examples | `examples/` and executable Go examples | Maintained artifacts; prose links instead of duplicating full examples |
A listed future owner is not a requirement to create an empty file now. Create
API, consumer, example, and component documents when the corresponding behavior
A listed owner is not a requirement to create an empty file before it has useful
content. Create API, consumer, example, and component documents when the corresponding behavior
exists and there is useful content to own. Link only to existing files; use plain
code paths for planned files. Release prerequisites have the explicit temporary
exception recorded in the release policy.
code paths for planned files. Unvalidated candidate and publication work has the
explicit temporary exception recorded in the release policy.
ConfigKit has no owned HTTP service, application CLI, or runtime operations
interface. Do not create those reference documents for consuming applications.

View File

@@ -2,7 +2,7 @@
**Base Policy Version:** 0.1.0
**Last Updated:** 2026-09-06
**Last Updated:** 2026-09-07
## Purpose
@@ -220,19 +220,12 @@ restating or redefining its requirements.
## Policy Exceptions
### Temporary bootstrap deferral
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.
The release procedure (`docs/release.md`) and complete release-check command are
not yet present because ConfigKit has no Go module or implementation to validate.
Their creation is temporarily deferred during initial development. This exception
permits implementation work; it does not permit publishing a release without
any gate required above.
Compensating controls: no release may be published until the
[release-readiness gaps](../roadmap/release-readiness.md) are closed, the runbook
and checker exist, and the exact candidate passes all applicable gates. The
maintainer preparing the first release owns closure. Remove this temporary
exception when readiness is verified. Any other exception requires a documented repository constraint, rationale,
compensating controls, scope, and closure condition here; none is currently
adopted. Do not silently weaken the base integrity, validation, documentation,
verification, or immutability guarantees.
Candidate selection, publication, and post-publication verification remain
mandatory steps in the [release procedure](../release.md).

View File

@@ -39,7 +39,7 @@ boundaries. No credential bytes may appear in inspection or failure fixtures'
expected output; use disposable canaries to prove non-disclosure.
Baseline commands belong in the [development guide](../development.md). Release
gates belong in the [release policy](release.md) and its eventual runbook. This
gates belong in the [release policy](release.md) and [maintainer runbook](../release.md). This
policy does not imply that those tools or tests already exist.
## Every test has a cost

241
docs/release.md Normal file
View File

@@ -0,0 +1,241 @@
# ConfigKit Release Procedure
This is the maintainer runbook for ConfigKit's [release policy](policy/release.md).
ConfigKit publishes one source-only Go module: the immutable source tag and the
matching checked-in release note are the deliverables. There is no application
binary, container image, generated package, or separate registry publication.
## Release identity and support
- Module: `gitea.maximumdirect.net/eric/ConfigKit`
- Primary branch: `main`
- Authoritative remote: `origin`, expected to fetch from
`https://gitea.maximumdirect.net/eric/ConfigKit.git`
- Tags: stable Semantic Versions such as `v0.1.0`; prerelease tags are not supported
- Minimum Go language/toolchain line: Go 1.25, tested with a supported patched
Go 1.25 toolchain and one newer supported toolchain
- Build targets: Linux and macOS on amd64 and arm64
The automated checker cross-builds all four targets. Before release, the maintainer
must also obtain native test, race, symlink, and permission-fixture evidence on Linux
and macOS; cross-compilation is not native execution evidence.
## 1. Prepare access and tools
The release maintainer owns this procedure. Work in a dedicated clone with Git, Bash 3.2 or newer,
and two supported Go toolchains available. The checker supports the stock macOS
Bash; its filename enumeration is null-delimited, including names with whitespace. Configure private-host Git and Go module
access through the normal credential manager before starting. Keep tokens, passwords,
SSH material, and environment dumps outside the repository and command transcript.
Verify access without displaying credentials:
```sh
git remote get-url origin
git ls-remote origin HEAD
```
The remote URL must equal the authoritative URL above. Select the latest supported
patch in the Go 1.25 line and a newer supported toolchain. The values below are the
toolchains used while this runbook was introduced; update them to supported patch
releases during candidate preparation and record the actual versions in the release
note or validation record.
```sh
export CONFIGKIT_BASELINE_TOOLCHAIN=go1.25.5
export CONFIGKIT_NEWER_TOOLCHAIN=go1.26.7
GOTOOLCHAIN="$CONFIGKIT_BASELINE_TOOLCHAIN" go version
GOTOOLCHAIN="$CONFIGKIT_NEWER_TOOLCHAIN" go version
GOTOOLCHAIN="$CONFIGKIT_BASELINE_TOOLCHAIN" go mod download
GOTOOLCHAIN="$CONFIGKIT_NEWER_TOOLCHAIN" go mod download
```
Those commands explicitly acquire toolchains and dependencies. The checker itself
sets `GOPROXY=off` and fails if its required cache is incomplete.
## 2. Select the version and review compatibility
Choose an unused stable version and compare the candidate with the previous tag.
For the first release, review the whole public surface and current contracts.
```sh
release_version=v0.1.0
printf '%s\n' "$release_version" | grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'
git show-ref --verify --quiet "refs/tags/$release_version" && exit 1 || :
if git ls-remote --exit-code --tags origin "refs/tags/$release_version" >/dev/null; then
printf 'remote tag already exists: %s\n' "$release_version" >&2
exit 1
else
test $? -eq 2
fi
git log --oneline --decorate --no-merges
```
Review exported Go signatures and ownership, YAML and scalar behavior, precedence,
diagnostic codes, minimum Go version, and platform support. Before `v1.0.0`, any
intentional incompatible minor-release change must be explained with consumer action;
patch releases must remain compatible within their minor line. Review the maintained
consumer examples and the migration guide even when signatures did not change.
Reassess `go.yaml.in/yaml/v4` rather than using `@latest` implicitly:
```sh
go list -m -versions go.yaml.in/yaml/v4
go list -m -json go.yaml.in/yaml/v4@latest
```
If changing the explicit pin, make that a reviewed dependency change and rerun the
complete checker, especially parser policy, limits, diagnostics, and rendering tests.
If retaining a prerelease pin, record that decision in Compatibility.
## 3. Write the release note
Create `docs/releases/$release_version.md` before selecting the candidate. It must
begin with `# ConfigKit $release_version` and contain `Summary`, `Compatibility`,
`Upgrade`, and `Changes` sections. Use “No action required” where appropriate. Link
to current canonical contracts instead of copying their definitions. Do not create a
release note until there is a real candidate and reviewed content.
## 4. Validate the source candidate
Run the complete checker from the repository root after dependencies have been
acquired. It validates module manifests in a disposable copy, both supported Go
toolchains, offline default tests, the native race detector, vet, native builds,
CGO-disabled cross-builds, formatting and whitespace (including untracked files),
examples, package isolation, and local documentation links.
```sh
./scripts/release-check.sh
```
`go vet` is the repository's static-analysis baseline. No additional linter is
claimed. Links to current ConfigKit files and headings are strict. Recorded links to
sibling consumer source are checked when those checkouts exist and are otherwise
inapplicable in an isolated clone; their immutable revisions remain recorded in the
extraction inventory.
Complete these manual gates and record their evidence with the candidate:
- The maintainer reviews compatibility and downstream migration impact against
`docs/config.md`, Go API comments, and the consumer guide.
- The maintainer inspects `git status --short`, `git diff`, and the candidate file
list for credentials, private data, generated output, unintended files, and
unrelated changes.
- The maintainer records successful native checker runs on Linux and macOS, including
filesystem capability tests. If either host is unavailable, release remains
blocked unless platform support is formally narrowed in current documentation.
- The maintainer removes completed, obsolete documents under `docs/roadmap/`, repairs
inbound links, and reruns the checker. ADR rationale remains historical.
- The maintainer reviews the exact version-matched release note for accuracy and
confirms it describes only deliverables that will actually be published.
## 5. Commit, push, and record the candidate
Commit all candidate source, contract, example, roadmap-retirement, and release-note
changes, rerun the checker against that clean commit, then push only `main` and record
the exact commit in Git metadata local to the clone:
```sh
git add .
git commit --message "Prepare ConfigKit $release_version"
./scripts/release-check.sh
test -z "$(git status --porcelain=v1 --untracked-files=all)"
git push origin HEAD:refs/heads/main
git rev-parse HEAD | tee .git/configkit-release-candidate
candidate_commit=$(cat .git/configkit-release-candidate)
```
If review changes the source after this point, create and validate a new candidate;
do not reuse the recorded commit.
## 6. Run publication guards
Immediately before tagging, fetch the authoritative branch and tags, restore the
recorded variables if this is a new shell, and execute every guard:
```sh
release_version=v0.1.0
candidate_commit=$(cat .git/configkit-release-candidate)
git fetch --prune origin main
git fetch --tags origin
test "$(git branch --show-current)" = main
test -z "$(git status --porcelain=v1 --untracked-files=all)"
test "$(git rev-parse HEAD)" = "$candidate_commit"
test "$(git rev-parse refs/remotes/origin/main)" = "$candidate_commit"
test "$(git remote get-url origin)" = 'https://gitea.maximumdirect.net/eric/ConfigKit.git'
printf '%s\n' "$release_version" | grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'
test -f "docs/releases/$release_version.md"
test "$(sed -n '1p' "docs/releases/$release_version.md")" = "# ConfigKit $release_version"
for heading in Summary Compatibility Upgrade Changes; do
grep -Fxq "## $heading" "docs/releases/$release_version.md"
done
git show-ref --verify --quiet "refs/tags/$release_version" && exit 1 || :
if git ls-remote --exit-code --tags origin "refs/tags/$release_version" >/dev/null; then
printf 'remote tag already exists: %s\n' "$release_version" >&2
exit 1
else
test $? -eq 2
fi
```
Confirm the recorded automated and manual approvals apply to exactly
`$candidate_commit`. A successful older run is not evidence for a changed commit.
## 7. Tag and publish the exact ref
Create one annotated tag at the guarded commit, verify its peeled target, and push
only that ref. Do not use `git push --tags`.
```sh
git tag --annotate "$release_version" --message "ConfigKit $release_version" "$candidate_commit"
test "$(git rev-list -n 1 "$release_version")" = "$candidate_commit"
git push origin "refs/tags/$release_version:refs/tags/$release_version"
```
The pushed tag completes the declared source-only publication. No Gitea release
object or binary upload is required.
## 8. Verify publication and clean consumer retrieval
Verify the authoritative tag and then retrieve that exact version into a new module
without a local `replace` directive. Private-host credentials must already be
configured. Use direct origin retrieval unless an explicitly approved internal Go
proxy is the publication channel.
```sh
remote_tag_commit=$(git ls-remote origin "refs/tags/$release_version^{}" | awk '{print $1}')
test "$remote_tag_commit" = "$candidate_commit"
consumer_dir=$(mktemp -d)
(
cd "$consumer_dir"
go mod init example.invalid/configkit-release-check
GOPRIVATE=gitea.maximumdirect.net GOPROXY=direct go get "gitea.maximumdirect.net/eric/ConfigKit@$release_version"
cat > main.go <<'EOF'
package main
import configkit "gitea.maximumdirect.net/eric/ConfigKit"
func main() {
_ = configkit.NoSource()
}
EOF
gofmt -w main.go
go test ./...
go build ./...
go list -m -f '{{.Path}} {{.Version}}' gitea.maximumdirect.net/eric/ConfigKit
)
rm -rf -- "$consumer_dir"
```
The final `go list` output must show the exact module path and `$release_version`.
Record the result with the release evidence.
## 9. Handle failures without replacement
Before publication, fix the candidate, rerun every gate, commit, push, and record a
new exact candidate. An unpublished local tag may be deleted only after verifying it
was never pushed or used by another release channel.
After publication, never move, delete, or recreate the tag and never replace its
release note. Correct the defect on `main`, choose a new semantically appropriate
version, and repeat this procedure. A supported advisory or deprecation mechanism
may warn consumers about the old version but cannot change its contents.

72
docs/releases/v0.1.0.md Normal file
View File

@@ -0,0 +1,72 @@
# ConfigKit v0.1.0
This is the first source release of ConfigKit, an opinionated Go library for strict,
explainable YAML configuration loading.
## Summary
ConfigKit provides one typed loading lifecycle for file, byte, reader, and
defaults-only inputs. It supports bounded explicit imports, strict composition,
selected profiles, explicit CLI overrides, application-owned defaults and recorded
resolution, semantic validation, provenance, redacted effective output, and safe
historical explanation.
Typed environment-name secret references and an explicit secrets-directory bootstrap
keep credential acquisition separate from ordinary loading. Maintained consumer
examples demonstrate simple and composed application shapes without exposing the
private YAML parser dependency.
See the [configuration contract](../config.md) and
[consumer adoption guide](../consumers/README.md) for current behavior and examples.
## Compatibility
This is the initial public release, so there is no earlier ConfigKit release to
upgrade from. The module requires Go 1.25 or newer and supports Linux and macOS on
amd64 and arm64. Candidate validation uses patched Go 1.25 and Go 1.26 toolchains;
native Linux and macOS evidence is required in addition to cross-builds.
Configuration intentionally uses a strict YAML subset. Duplicate keys, aliases,
anchors, merge keys, unsupported tags and scalar forms, trailing documents, and
unknown typed fields are rejected. Composition rejects overlapping non-mapping
definitions; replacement occurs only through selected profiles, explicit CLI
overrides, or recorded application resolution.
The private parser dependency remains explicitly pinned to
`go.yaml.in/yaml/v4 v4.0.0-rc.6`, which was still the newest available v4 release when
the candidate was prepared. No upstream YAML declarations are part of ConfigKit's
public API.
## Upgrade
No action is required for an earlier ConfigKit version because this is the first
release. New consumers can add the module with:
```sh
go get gitea.maximumdirect.net/eric/ConfigKit@v0.1.0
```
The authoritative host may require its normal private-module credentials. Consumers
adapting existing application configuration should review the intentional migration
differences in the [consumer adoption guide](../consumers/README.md), particularly
strict YAML, string schema versions, explicit empty/null behavior, canonical module
objects, `int64` dynamic integers, explicit CLI records, and typed secret references.
## Changes
- Added opaque file, byte, reader, and defaults-only sources with fixed resource
limits and safe structured diagnostics.
- Added an owned strict YAML model, typed schema binding, detached defaults and
results, canonical projection, and private parser isolation.
- Added explicit path/glob imports, deterministic strict composition, canonical
source identity and containment, and selected profile overlays.
- Added layered presence, origins, chronological history, version checks, recorded
defaulting and normalization, and read-only semantic validation.
- Added secret classification, deterministic redacted effective YAML, typed
environment references, and bounded explicit secrets-directory bootstrap reports.
- Added parser-neutral explicit CLI overrides and safe structured/human historical
value explanations, including removed and superseded values.
- Added maintained Narratio-, Notarius-, WeatherReporter-, and Seriatim-shaped
examples and migration coverage using only ConfigKit and the standard library.
- Added race and bounded fuzz coverage plus a two-toolchain, offline, cross-platform
source-release checker and maintainer runbook.

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.

28
docs/roadmap/deferred.md Normal file
View File

@@ -0,0 +1,28 @@
# Deferred Scope
This document owns intentionally excluded features, not a committed delivery plan.
The [architecture policy](../policy/architecture.md) governs changes to these
boundaries. Implemented behavior belongs in the [configuration contract](../config.md)
and consumer adoption in the [consumer guide](../consumers/README.md).
## Explicit non-goals and deferred scope
General environment-variable configuration overlays are not supported. Typed secret
references and explicit directory bootstrap are the environment convention. Literal
credential configuration and implicit secret-directory discovery are unsupported.
The initial release deliberately excludes:
- automatic `conf.d` discovery;
- remote imports, arbitrary providers, and additional configuration formats;
- custom merge strategies, sequence merging, reset operators, and profile inheritance;
- parser-specific or custom structural-decoding extension APIs;
- generic dynamic-subtree conversion and module-registry APIs;
- automatic schema migrations;
- hot reload, runtime resource replacement, and remote resource validation;
- a bundled CLI framework or application commands; and
- configuration diffs and fingerprints.
Applications may build their own validate, effective-value, source, bootstrap, and
explanation commands around the library. Broadening these boundaries requires
concrete consumer evidence, compatibility review, and an explicit design change.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -1,25 +0,0 @@
# Release Readiness
## Status and ownership
ConfigKit is not release-ready. This document tracks outstanding release
prerequisites under the [temporary bootstrap exception](../policy/release.md#temporary-bootstrap-deferral).
It is not the library implementation plan and does not assign implementation
stages. The maintainer preparing the first release owns closure.
## Outstanding prerequisites
| Gap | Evidence required to close |
| --- | --- |
| No Go module or declared support matrix | Established module path, supported Go versions and platform targets documented in the release procedure |
| No release runbook | `docs/release.md` exists with the prerequisites, commands, publication guards, and verification required by release policy |
| No complete release checker | A documented command exists and exercises every applicable automated candidate gate; manual gates and inapplicable checks are explicit |
| No consumer verification procedure | Runbook verifies retrieval of the exact released module version and a representative consumer build without local replacement directives |
| No verified secret/precedence/explanation contracts | Current contracts and passing bootstrap skip/partial-failure, secret isolation, CLI precedence, and safe historical-value regressions; representative consumer examples |
| No validated release candidate | Version-matched release note, current contracts/examples, and complete checks against the exact committed candidate |
No release can proceed while these prerequisites remain open. Close items only
on evidence from actual files and checks, not proposed commands. Record closure
evidence here while this roadmap remains active. Once all gaps are closed,
remove the bootstrap exception and retire this document in favor of the
operational runbook and normal release records.

110
document_compare.go Normal file
View File

@@ -0,0 +1,110 @@
package configkit
import (
"cmp"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
"slices"
)
type documentDeltaKind uint8
const (
documentAdded documentDeltaKind = iota + 1
documentRemoved
documentReplaced
)
type documentDelta struct {
path Path
kind documentDeltaKind
}
// compareConfigurationValues ignores locations and sensitivity metadata. Mapping
// order is immaterial; sequences are atomic for change reporting.
func compareConfigurationValues(before, after *document.Node) []documentDelta {
var deltas []documentDelta
compareConfigurationValue(before, after, Path{}, &deltas)
slices.SortFunc(deltas, func(left, right documentDelta) int {
if order := cmp.Compare(left.path.String(), right.path.String()); order != 0 {
return order
}
return int(left.kind) - int(right.kind)
})
return deltas
}
func compareConfigurationValue(before, after *document.Node, path Path, deltas *[]documentDelta) {
if before == nil && after == nil {
return
}
if before == nil {
*deltas = append(*deltas, documentDelta{path: path.clone(), kind: documentAdded})
return
}
if after == nil {
*deltas = append(*deltas, documentDelta{path: path.clone(), kind: documentRemoved})
return
}
if before.Kind != after.Kind {
*deltas = append(*deltas, documentDelta{path: path.clone(), kind: documentReplaced})
return
}
switch before.Kind {
case document.Mapping:
beforeEntries := make(map[string]*document.Node, len(before.Mapping))
afterEntries := make(map[string]*document.Node, len(after.Mapping))
keys := make(map[string]bool, len(before.Mapping)+len(after.Mapping))
for _, entry := range before.Mapping {
beforeEntries[entry.Key] = entry.Value
keys[entry.Key] = true
}
for _, entry := range after.Mapping {
afterEntries[entry.Key] = entry.Value
keys[entry.Key] = true
}
ordered := make([]string, 0, len(keys))
for key := range keys {
ordered = append(ordered, key)
}
slices.Sort(ordered)
for _, key := range ordered {
compareConfigurationValue(beforeEntries[key], afterEntries[key], path.Key(key), deltas)
}
case document.Sequence:
if !configurationValuesEqual(before, after) {
*deltas = append(*deltas, documentDelta{path: path.clone(), kind: documentReplaced})
}
default:
if before.Text != after.Text {
*deltas = append(*deltas, documentDelta{path: path.clone(), kind: documentReplaced})
}
}
}
// configurationValuesEqual compares only configuration values, not provenance or
// sensitivity. Callers auditing validation must check sensitivity separately.
func configurationValuesEqual(left, right *document.Node) bool {
if left == nil || right == nil {
return left == right
}
if left.Kind != right.Kind || left.Text != right.Text || len(left.Mapping) != len(right.Mapping) || len(left.Sequence) != len(right.Sequence) {
return false
}
if left.Kind == document.Mapping {
leftMap := make(map[string]*document.Node, len(left.Mapping))
for _, entry := range left.Mapping {
leftMap[entry.Key] = entry.Value
}
for _, entry := range right.Mapping {
if !configurationValuesEqual(leftMap[entry.Key], entry.Value) {
return false
}
}
}
for i := range left.Sequence {
if !configurationValuesEqual(left.Sequence[i], right.Sequence[i]) {
return false
}
}
return true
}

86
document_walk.go Normal file
View File

@@ -0,0 +1,86 @@
package configkit
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
// walkDocument visits value nodes in preorder, preserving stored mapping order
// and sequence index order. Mapping keys are not separate nodes in this walk.
// The callback borrows paths and nodes; clone any mutable state it retains.
// Specialized origin, pruning, and budget traversals retain their own semantics.
func walkDocument(node *document.Node, path Path, visit func(Path, *document.Node)) {
if node == nil {
return
}
visit(path, node)
switch node.Kind {
case document.Mapping:
for _, entry := range node.Mapping {
walkDocument(entry.Value, path.Key(entry.Key), visit)
}
case document.Sequence:
for index, child := range node.Sequence {
walkDocument(child, path.Index(index), visit)
}
}
}
// indexDocument borrows nodes from its caller-owned tree.
func indexDocument(node *document.Node, path Path, index map[string]*document.Node) {
walkDocument(node, path, func(path Path, node *document.Node) {
index[path.String()] = node
})
}
func documentAtPath(root *document.Node, path Path) (*document.Node, bool) {
if root == nil || !path.valid() {
return nil, false
}
current := root
for _, segment := range path.segments {
switch current.Kind {
case document.Mapping:
if segment.kind == pathSegmentIndex {
return nil, false
}
found := false
for _, entry := range current.Mapping {
if entry.Key == segment.key {
current = entry.Value
found = true
break
}
}
if !found {
return nil, false
}
case document.Sequence:
index, ok := schemaIndex(segment)
if !ok || index >= len(current.Sequence) {
return nil, false
}
current = current.Sequence[index]
default:
return nil, false
}
}
return current, true
}
// countInputNodes includes mapping-key nodes, unlike walkDocument's value-only
// traversal. Aggregate source budgets charge every input occurrence.
func countInputNodes(node *document.Node) int {
if node == nil {
return 0
}
count := 1
if node.Kind == document.Mapping {
count += len(node.Mapping)
for _, entry := range node.Mapping {
count += countInputNodes(entry.Value)
}
} else if node.Kind == document.Sequence {
for _, child := range node.Sequence {
count += countInputNodes(child)
}
}
return count
}

151
documentation_test.go Normal file
View File

@@ -0,0 +1,151 @@
package configkit
import (
"bufio"
"fmt"
"net/url"
"os"
"path/filepath"
"regexp"
"strings"
"testing"
"unicode"
)
var markdownLinkPattern = regexp.MustCompile(`(?m)(?:^|[[:space:]])\[[^\]]*\]\(([^)[:space:]]+)`)
func TestLocalDocumentationLinks(t *testing.T) {
repository, err := filepath.Abs(".")
if err != nil {
t.Fatal(err)
}
var failures []string
err = filepath.WalkDir(repository, func(path string, entry os.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if entry.IsDir() {
if entry.Name() == ".git" {
return filepath.SkipDir
}
return nil
}
if filepath.Ext(path) != ".md" {
return nil
}
content, readErr := os.ReadFile(path)
if readErr != nil {
return readErr
}
for _, match := range markdownLinkPattern.FindAllStringSubmatch(string(content), -1) {
target := strings.Trim(match[1], "<>")
if strings.Contains(target, "://") || strings.HasPrefix(target, "mailto:") {
continue
}
filePart, fragment, _ := strings.Cut(target, "#")
decoded, decodeErr := url.PathUnescape(filePart)
if decodeErr != nil {
failures = append(failures, fmt.Sprintf("%s: invalid escaped link %q", relativePath(repository, path), target))
continue
}
linked := path
if decoded != "" {
linked = filepath.Clean(filepath.Join(filepath.Dir(path), filepath.FromSlash(decoded)))
}
_, statErr := os.Stat(linked)
outside := linked != repository && !strings.HasPrefix(linked, repository+string(filepath.Separator))
if os.IsNotExist(statErr) && outside && optionalSiblingSource(repository, linked) {
continue // Recorded sibling-source links are optional in isolated clones.
}
if statErr != nil {
failures = append(failures, fmt.Sprintf("%s: missing local link %q", relativePath(repository, path), target))
continue
}
if fragment != "" && filepath.Ext(linked) == ".md" && !markdownHasFragment(linked, fragment) {
failures = append(failures, fmt.Sprintf("%s: missing fragment in %q", relativePath(repository, path), target))
}
}
return nil
})
if err != nil {
t.Fatal(err)
}
if len(failures) != 0 {
t.Fatalf("documentation link failures:\n%s", strings.Join(failures, "\n"))
}
}
func optionalSiblingSource(repository, linked string) bool {
relative, err := filepath.Rel(filepath.Dir(repository), linked)
if err != nil || relative == "." || filepath.IsAbs(relative) {
return false
}
parts := strings.Split(filepath.ToSlash(relative), "/")
if len(parts) == 0 {
return false
}
switch strings.ToLower(parts[0]) {
case "narratio", "notarius", "weatherreporter", "seriatim":
return true
default:
return false
}
}
func markdownHasFragment(path, wanted string) bool {
file, err := os.Open(path)
if err != nil {
return false
}
defer file.Close()
wanted, err = url.PathUnescape(wanted)
if err != nil {
return false
}
seen := make(map[string]int)
scanner := bufio.NewScanner(file)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if !strings.HasPrefix(line, "#") {
continue
}
heading := strings.TrimSpace(strings.TrimLeft(line, "#"))
base := markdownFragment(heading)
count := seen[base]
seen[base]++
fragment := base
if count > 0 {
fragment = fmt.Sprintf("%s-%d", base, count)
}
if fragment == wanted {
return true
}
}
return false
}
func markdownFragment(heading string) string {
var result strings.Builder
previousSpace := false
for _, character := range strings.ToLower(heading) {
switch {
case unicode.IsLetter(character), unicode.IsDigit(character), character == '_', character == '-':
result.WriteRune(character)
previousSpace = false
case unicode.IsSpace(character):
if result.Len() > 0 && !previousSpace {
result.WriteByte('-')
previousSpace = true
}
}
}
return strings.TrimRight(result.String(), "-")
}
func relativePath(root, path string) string {
relative, err := filepath.Rel(root, path)
if err != nil {
return path
}
return filepath.ToSlash(relative)
}

150
example_test.go Normal file
View File

@@ -0,0 +1,150 @@
package configkit_test
import (
"errors"
"fmt"
"os"
"strings"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
func ExampleLoad() {
type Config struct {
Enabled bool `yaml:"enabled"`
Workers int `yaml:"workers"`
}
result, err := configkit.Load[Config](configkit.Bytes("example.yml", []byte("enabled: true\nworkers: 3\n")))
fmt.Println(result.Config, err)
// Output: {true 3} <nil>
}
func ExampleNoSource() {
type Config struct {
Workers int `yaml:"workers"`
}
result, err := configkit.Load(configkit.NoSource(), configkit.Options[Config]{
Definition: configkit.Definition[Config]{Defaults: func() Config { return Config{Workers: 4} }},
})
fmt.Println(result.Config.Workers, len(result.Sources()), err)
// Output: 4 0 <nil>
}
func ExampleValidation_Error() {
type Config struct {
First string `yaml:"first"`
Second string `yaml:"second"`
}
_, err := configkit.Load(configkit.Bytes("duplicates.yml", []byte("first: same\nsecond: same\n")), configkit.Options[Config]{
Definition: configkit.Definition[Config]{Validate: func(configuration *Config, validation *configkit.Validation) {
if configuration.First == configuration.Second {
validation.Error(configkit.Keys("second"), "must differ from first", configkit.Keys("first"))
}
}},
})
var loadError *configkit.LoadError
errors.As(err, &loadError)
diagnostic := loadError.Diagnostics()[0]
fmt.Println(diagnostic.Code, diagnostic.Path, len(diagnostic.Related))
// Output: validate.error /second 1
}
func ExampleVersionRule() {
type Config struct {
Version string `yaml:"version"`
}
_, err := configkit.Load(configkit.Bytes("version.yml", []byte("version: old\n")), configkit.Options[Config]{
Definition: configkit.Definition[Config]{Version: &configkit.VersionRule{
Path: configkit.Keys("version"),
Check: func(version configkit.Version, validation *configkit.Validation) error {
if version.Text != "v1" {
validation.Error(configkit.Keys("version"), "unsupported version; migrate using the application guide")
}
return nil
},
}},
})
var loadError *configkit.LoadError
errors.As(err, &loadError)
fmt.Println(loadError.Diagnostics()[0].Message)
// Output: unsupported version; migrate using the application guide
}
func ExampleResolution() {
type Config struct {
Input int `yaml:"input"`
Derived int `yaml:"derived"`
}
var order []string
result, err := configkit.Load(configkit.Bytes("resolved.yml", []byte("input: 3\n")), configkit.Options[Config]{
Definition: configkit.Definition[Config]{
Resolve: func(configuration *Config, resolution *configkit.Resolution) error {
order = append(order, "resolve")
configkit.Default(resolution, configkit.Keys("derived"), &configuration.Derived,
configuration.Input*2, "derived from input", configkit.Keys("input"))
return nil
},
Validate: func(configuration *Config, validation *configkit.Validation) {
order = append(order, "validate")
if configuration.Derived < configuration.Input {
validation.Error(configkit.Keys("derived"), "must not be less than input")
}
},
},
})
fmt.Println(strings.Join(order, " then "), result.Config.Derived, err)
// Output: resolve then validate 6 <nil>
}
func ExampleSecretEnv() {
const name = "CONFIGKIT_EXAMPLE_API_KEY"
previous, present := os.LookupEnv(name)
_ = os.Setenv(name, "disposable-example-value")
defer func() {
if present {
_ = os.Setenv(name, previous)
} else {
_ = os.Unsetenv(name)
}
}()
type Config struct {
APIKey configkit.Secret `yaml:"api_key"`
}
result, err := configkit.Load[Config](configkit.Bytes("secret.yml", []byte("api_key: "+name+"\n")))
fmt.Println(result.Config.APIKey.EnvName(), result.Config.APIKey.IsResolved(), err)
// Output: CONFIGKIT_EXAMPLE_API_KEY true <nil>
}
func ExampleOverride() {
type Config struct {
Workers int `yaml:"workers"`
}
workers, workersWasSet := 0, true // Supplied by the application's CLI parser.
var overrides []configkit.Override
if workersWasSet {
overrides = append(overrides, configkit.Override{
Path: configkit.Keys("workers"), Value: workers, Flag: "--workers",
})
}
result, err := configkit.Load(configkit.NoSource(), configkit.Options[Config]{
Definition: configkit.Definition[Config]{Defaults: func() Config { return Config{Workers: 4} }},
Overrides: overrides,
})
fmt.Println(result.Config.Workers, result.Sources()[0].Label, err)
// Output: 0 --workers <nil>
}
func ExampleResult_Explain() {
type Config struct {
Workers int `yaml:"workers"`
}
result, err := configkit.Load(configkit.Bytes("config.yml", []byte("workers: 8\n")), configkit.Options[Config]{
Definition: configkit.Definition[Config]{Defaults: func() Config { return Config{Workers: 4} }},
})
explanation, ok := result.Explain(configkit.Keys("workers"))
fmt.Printf("%s -> %s known=%t error=%v\n",
strings.TrimSpace(explanation.History[0].After.YAML),
strings.TrimSpace(explanation.Effective.YAML), ok, err)
// Output: 4 -> 8 known=true error=<nil>
}

View File

@@ -0,0 +1,331 @@
// Package consumers contains small, maintained application shapes that exercise
// ConfigKit without making consumer domain policy part of the library API.
package consumers
import (
"math"
"strconv"
"strings"
"time"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
// Artifact is a Narratio-shaped generated output declaration.
type Artifact struct {
Output string `yaml:"output"`
Timeout time.Duration `yaml:"timeout"`
}
// NarratioConfig demonstrates application defaults, partial imports, and
// application-owned model-profile and artifact semantics.
type NarratioConfig struct {
PollInterval time.Duration `yaml:"poll_interval"`
Timeout time.Duration `yaml:"timeout"`
ModelProfile string `yaml:"model_profile"`
Artifacts map[string]Artifact `yaml:"artifacts"`
}
// LoadNarratio loads the maintained import fixture with Narratio-shaped rules.
func LoadNarratio(path string) (*configkit.Result[NarratioConfig], error) {
return LoadNarratioProfile(path, "")
}
// LoadNarratioProfile selects a ConfigKit configuration overlay independently
// from the application's ModelProfile field.
func LoadNarratioProfile(path, profile string) (*configkit.Result[NarratioConfig], error) {
return configkit.Load(configkit.File(path), configkit.Options[NarratioConfig]{
Imports: true, Profile: profile,
Definition: configkit.Definition[NarratioConfig]{
Defaults: func() NarratioConfig { return NarratioConfig{PollInterval: time.Minute} },
Resolve: resolveNarratio,
Validate: validateNarratio,
},
})
}
func resolveNarratio(configuration *NarratioConfig, resolution *configkit.Resolution) error {
configkit.Default(resolution, configkit.Keys("timeout"), &configuration.Timeout,
configuration.PollInterval/10, "derived from poll interval", configkit.Keys("poll_interval"))
for name, artifact := range configuration.Artifacts {
path := configkit.Keys("artifacts", name)
configkit.Default(resolution, path.Key("timeout"), &artifact.Timeout,
configuration.Timeout, "inherited pipeline timeout", configkit.Keys("timeout"))
configkit.Normalize(resolution, path.Key("output"), &artifact.Output,
strings.TrimSpace(artifact.Output), "trimmed artifact output")
configuration.Artifacts[name] = artifact
}
return nil
}
func validateNarratio(configuration *NarratioConfig, validation *configkit.Validation) {
if configuration.PollInterval <= 0 {
validation.Error(configkit.Keys("poll_interval"), "must be positive")
}
if configuration.Timeout <= 0 {
validation.Error(configkit.Keys("timeout"), "must be positive")
}
for name, artifact := range configuration.Artifacts {
path := configkit.Keys("artifacts", name)
if artifact.Output == "" {
validation.Error(path.Key("output"), "must be nonempty")
}
if artifact.Timeout <= 0 {
validation.Error(path.Key("timeout"), "must be positive")
}
}
}
// ObjectBinding is a Notarius-shaped canonical object binding. Options remain
// dynamic because the application catalog selects their schema at runtime.
type ObjectBinding struct {
Object string `yaml:"object"`
Options map[string]any `yaml:"options"`
}
// NotariusConfig demonstrates string versions and dynamic catalog options.
type NotariusConfig struct {
Version string `yaml:"version"`
Bindings []ObjectBinding `yaml:"bindings"`
}
// LoadNotarius loads a Notarius-shaped configuration.
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")},
Validate: validateNotarius,
},
})
}
func requireVersion(expected string) func(configkit.Version, *configkit.Validation) error {
return func(version configkit.Version, validation *configkit.Validation) error {
if !version.Present || version.Null || version.Text != expected {
validation.Error(configkit.Keys("version"), "unsupported configuration version")
}
return nil
}
}
func validateNotarius(configuration *NotariusConfig, validation *configkit.Validation) {
for index, binding := range configuration.Bindings {
path := configkit.Keys("bindings").Index(index)
if binding.Object != "summary" && binding.Object != "registry" {
validation.Error(path.Key("object"), "unsupported object binding")
}
for key, value := range binding.Options {
optionPath := path.Key("options").Key(key)
switch key {
case "window_units":
units, ok := value.(int64)
if !ok || units < 1 {
validation.Error(optionPath, "must be a positive integer")
}
case "format":
if _, ok := value.(string); !ok {
validation.Error(optionPath, "must be a string")
}
default:
validation.Error(optionPath, "unsupported object option")
}
}
}
}
// NotariusWindow converts ConfigKit's portable dynamic integer only at the
// application runtime boundary.
func NotariusWindow(configuration NotariusConfig, binding int) (int, bool) {
if binding < 0 || binding >= len(configuration.Bindings) {
return 0, false
}
value, ok := configuration.Bindings[binding].Options["window_units"].(int64)
if !ok || value < 1 || (strconv.IntSize == 32 && value > math.MaxInt32) {
return 0, false
}
return int(value), true
}
// ModuleOptions is a WeatherReporter-shaped union of known module option fields.
type ModuleOptions struct {
WindowUnits *int `yaml:"window_units"`
Sections []string `yaml:"sections"`
}
// ModuleSpec uses the canonical object form instead of a string shorthand.
type ModuleSpec struct {
Module string `yaml:"module"`
Options ModuleOptions `yaml:"options"`
}
// ReportConfig keeps report-specific lists application-owned.
type ReportConfig struct {
Modules []ModuleSpec `yaml:"modules"`
PathTemplates []string `yaml:"path_templates"`
}
// WeatherConfig demonstrates simple defaults, reports, module options, and an
// optional typed secret reference.
type WeatherConfig struct {
Units string `yaml:"units"`
Reports map[string]ReportConfig `yaml:"reports"`
Token *configkit.Secret `yaml:"token"`
}
// LoadWeather loads a WeatherReporter-shaped source and explicit CLI records.
func LoadWeather(source configkit.Source, overrides ...configkit.Override) (*configkit.Result[WeatherConfig], error) {
return configkit.Load(source, configkit.Options[WeatherConfig]{
Overrides: overrides,
Definition: weatherDefinition(func() WeatherConfig {
return WeatherConfig{Units: "metric"}
}),
})
}
// LoadConstructedWeather runs the same rules over a detached constructed value.
func LoadConstructedWeather(configuration WeatherConfig) (*configkit.Result[WeatherConfig], error) {
return configkit.Load(configkit.NoSource(), configkit.Options[WeatherConfig]{
Definition: weatherDefinition(func() WeatherConfig { return configuration }),
})
}
func weatherDefinition(defaults func() WeatherConfig) configkit.Definition[WeatherConfig] {
return configkit.Definition[WeatherConfig]{
Defaults: defaults,
Resolve: func(configuration *WeatherConfig, resolution *configkit.Resolution) error {
for reportName, report := range configuration.Reports {
var modules []ModuleSpec
if report.Modules != nil {
modules = append(make([]ModuleSpec, 0, len(report.Modules)), report.Modules...)
}
for index, module := range modules {
if module.Options.Sections != nil {
sections := append([]string(nil), module.Options.Sections...)
for position := range sections {
sections[position] = strings.TrimSpace(sections[position])
}
module.Options.Sections = sections
}
modules[index] = module
}
configkit.Normalize(resolution, configkit.Keys("reports", reportName, "modules"),
&report.Modules, modules, "normalized module options")
configuration.Reports[reportName] = report
}
return nil
},
Validate: validateWeather,
}
}
func validateWeather(configuration *WeatherConfig, validation *configkit.Validation) {
if configuration.Units != "metric" && configuration.Units != "us" {
validation.Error(configkit.Keys("units"), "must be metric or us")
}
for reportName, report := range configuration.Reports {
path := configkit.Keys("reports", reportName)
if report.Modules != nil && len(report.Modules) == 0 {
validation.Error(path.Key("modules"), "must contain at least one module")
}
if report.PathTemplates != nil && len(report.PathTemplates) == 0 {
validation.Error(path.Key("path_templates"), "must contain at least one template")
}
for index, module := range report.Modules {
modulePath := path.Key("modules").Index(index)
switch module.Module {
case "chunker":
if module.Options.WindowUnits == nil || *module.Options.WindowUnits < 1 {
validation.Error(modulePath.Key("options").Key("window_units"), "must be a positive integer")
}
if module.Options.Sections != nil {
validation.Error(modulePath.Key("options").Key("sections"), "not supported by this module")
}
case "discussion":
if module.Options.WindowUnits != nil {
validation.Error(modulePath.Key("options").Key("window_units"), "not supported by this module")
}
if len(module.Options.Sections) == 0 {
validation.Error(modulePath.Key("options").Key("sections"), "must contain at least one section")
}
default:
validation.Error(modulePath.Key("module"), "unsupported module")
}
}
}
}
// SpeakerRule and SpeakerRules preserve declaration order for application matching.
type SpeakerRule struct {
Prefix string `yaml:"prefix"`
Name string `yaml:"name"`
}
type SpeakerRules struct {
Rules []SpeakerRule `yaml:"rules"`
}
// AutocorrectRule and AutocorrectRules preserve replacement order.
type AutocorrectRule struct {
Find string `yaml:"find"`
Replace string `yaml:"replace"`
}
type AutocorrectRules struct {
Rules []AutocorrectRule `yaml:"rules"`
}
// LoadSpeakerRules loads one independent Seriatim-shaped rule file.
func LoadSpeakerRules(path string) (*configkit.Result[SpeakerRules], error) {
return configkit.Load(configkit.File(path), configkit.Options[SpeakerRules]{
Definition: configkit.Definition[SpeakerRules]{Validate: validateSpeakerRules},
})
}
// LoadAutocorrectRules loads another independent ordered rule file.
func LoadAutocorrectRules(path string) (*configkit.Result[AutocorrectRules], error) {
return configkit.Load(configkit.File(path), configkit.Options[AutocorrectRules]{
Definition: configkit.Definition[AutocorrectRules]{Validate: validateAutocorrectRules},
})
}
func validateSpeakerRules(configuration *SpeakerRules, validation *configkit.Validation) {
seen := make(map[string]int)
for index, rule := range configuration.Rules {
path := configkit.Keys("rules").Index(index)
if rule.Prefix == "" {
validation.Error(path.Key("prefix"), "must be nonempty")
}
if previous, ok := seen[rule.Prefix]; ok {
validation.Error(path.Key("prefix"), "prefix must be unique",
configkit.Keys("rules").Index(previous).Key("prefix"))
}
seen[rule.Prefix] = index
}
}
func validateAutocorrectRules(configuration *AutocorrectRules, validation *configkit.Validation) {
for index, rule := range configuration.Rules {
path := configkit.Keys("rules").Index(index)
if rule.Find == "" {
validation.Error(path.Key("find"), "must be nonempty")
}
}
}
// SpeakerName is application runtime matching, not configuration infrastructure.
func SpeakerName(rules SpeakerRules, input string) (string, bool) {
for _, rule := range rules.Rules {
if strings.HasPrefix(input, rule.Prefix) {
return rule.Name, true
}
}
return "", false
}
// ApplyAutocorrect is application runtime replacement in declared order.
func ApplyAutocorrect(rules AutocorrectRules, input string) string {
for _, rule := range rules.Rules {
input = strings.ReplaceAll(input, rule.Find, rule.Replace)
}
return input
}

View File

@@ -0,0 +1,252 @@
package consumers_test
import (
"bytes"
"errors"
"fmt"
"os"
"path/filepath"
"reflect"
"runtime"
"strings"
"testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
"gitea.maximumdirect.net/eric/ConfigKit/examples/consumers"
)
func Example_narratio() {
result, err := consumers.LoadNarratio(fixture("narratio", "pipeline.yml"))
artifact := result.Config.Artifacts["transcript"]
fmt.Println(result.Config.ModelProfile, artifact.Output, result.Config.Timeout, artifact.Timeout, err)
// Output: concise transcript.txt 6s 6s <nil>
}
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>
}
func Example_weatherReporter() {
defaults, defaultsErr := consumers.LoadWeather(configkit.NoSource())
result, err := consumers.LoadWeather(configkit.File(fixture("weather", "config.yml")))
discussion := result.Config.Reports["daily"].Modules[1]
fmt.Println(defaults.Config.Units, result.Config.Units, discussion.Options.Sections[0], defaultsErr, err)
// Output: metric us short_term <nil> <nil>
}
func Example_seriatim() {
speakers, speakerErr := consumers.LoadSpeakerRules(fixture("seriatim", "speakers.yml"))
autocorrect, autocorrectErr := consumers.LoadAutocorrectRules(fixture("seriatim", "autocorrect.yml"))
name, found := consumers.SpeakerName(speakers.Config, "Guest: teh event occured")
text := consumers.ApplyAutocorrect(autocorrect.Config, "teh event occured")
fmt.Println(name, found, text, speakerErr, autocorrectErr)
// Output: visitor true the event occurred <nil> <nil>
}
func Example_weatherStartup() {
const blockedName = "CONFIGKIT_WEATHER_EXAMPLE_BLOCKED"
const tokenName = "CONFIGKIT_WEATHER_EXAMPLE_TOKEN"
restoreBlocked := preserveEnvironment(blockedName)
restoreToken := preserveEnvironment(tokenName)
defer restoreBlocked()
defer restoreToken()
_ = os.Setenv(blockedName, "")
_ = os.Unsetenv(tokenName)
directory, _ := os.MkdirTemp("", "configkit-weather-example-")
defer os.RemoveAll(directory)
_ = os.WriteFile(filepath.Join(directory, blockedName), []byte("unused-example-value\n"), 0o600)
_ = os.WriteFile(filepath.Join(directory, tokenName), []byte("disposable-example-credential\n"), 0o600)
report, bootstrapErr := configkit.LoadSecretsDir(directory)
var reportOutput bytes.Buffer
_, reportWriteErr := report.WriteTo(&reportOutput)
result, loadErr := consumers.LoadWeather(configkit.Bytes("weather.yml", []byte("units: metric\ntoken: "+tokenName+"\n")),
configkit.Override{Path: configkit.Keys("units"), Value: "us", Flag: "--units"})
var explanation bytes.Buffer
explainErr := result.WriteExplanation(&explanation, configkit.Keys("units"))
blocked, loaded := secretStatus(report, blockedName), secretStatus(report, tokenName)
value, present := os.LookupEnv(tokenName)
fmt.Printf("blocked=%s loaded=%s report=%t reference=%s explained=%t unchanged=%t errors=%t\n",
blocked, loaded, reportOutput.Len() > 0, result.Config.Token.EnvName(),
strings.Contains(explanation.String(), "--units"), present && value == "disposable-example-credential",
bootstrapErr != nil || reportWriteErr != nil || loadErr != nil || explainErr != nil)
// Output: blocked=skipped_existing loaded=loaded report=true reference=CONFIGKIT_WEATHER_EXAMPLE_TOKEN explained=true unchanged=true errors=false
}
func TestMaintainedConsumerFixturesPreserveDomainOutcomes(t *testing.T) {
narratio, err := consumers.LoadNarratio(fixture("narratio", "pipeline.yml"))
if err != nil || narratio.Config.Artifacts["transcript"].Output != "transcript.txt" ||
narratio.Config.ModelProfile != "concise" || len(narratio.Sources()) != 2 {
t.Fatalf("Narratio shape = %#v, %v", narratio, err)
}
if origin, ok := narratio.Metadata().Lookup(configkit.Keys("artifacts", "transcript", "output")); !ok || origin.Origin.Layer != configkit.LayerResolution {
t.Fatalf("artifact origin = %#v, %v", origin, ok)
}
profiled, err := consumers.LoadNarratioProfile(fixture("narratio", "pipeline.yml"), "testing")
if err != nil || profiled.Config.PollInterval.String() != "2m0s" ||
profiled.Config.ModelProfile != "concise" || len(profiled.Sources()) != 3 {
t.Fatalf("configuration/application profiles = %#v, %v", profiled, err)
}
notarius, err := consumers.LoadNotarius(fixture("notarius", "config.yml"))
window, converted := consumers.NotariusWindow(notarius.Config, 0)
if err != nil || !converted || window != 12 {
t.Fatalf("Notarius shape = %#v, %v, %v", notarius, converted, err)
}
if _, ok := notarius.Config.Bindings[0].Options["window_units"].(int64); !ok {
t.Fatalf("dynamic integer type = %T", notarius.Config.Bindings[0].Options["window_units"])
}
weather, err := consumers.LoadWeather(configkit.File(fixture("weather", "config.yml")))
if err != nil || weather.Config.Reports["daily"].Modules[1].Options.Sections[0] != "short_term" {
t.Fatalf("WeatherReporter shape = %#v, %v", weather, err)
}
speakers, err := consumers.LoadSpeakerRules(fixture("seriatim", "speakers.yml"))
if err != nil {
t.Fatal(err)
}
name, ok := consumers.SpeakerName(speakers.Config, "Host: introduction")
if !ok || name != "narrator" || speakers.Config.Rules[0].Prefix != "Host:" {
t.Fatalf("ordered speaker rules = %#v, %q, %v", speakers.Config.Rules, name, ok)
}
}
func TestMigrationFixturesFailWithStableCodesAndPaths(t *testing.T) {
tests := []struct {
name string
load func() error
code configkit.Code
path string
}{
{
name: "integer schema version",
load: func() error { _, err := consumers.LoadNotarius(fixture("notarius", "integer-version.yml")); return err },
code: configkit.CodeVersionType, path: "/version",
},
{
name: "module shorthand",
load: func() error {
_, err := consumers.LoadWeather(configkit.File(fixture("weather", "shorthand-module.yml")))
return err
},
code: configkit.CodeDecodeType, path: "/reports/daily/modules/0",
},
{
name: "explicit empty modules",
load: func() error {
_, err := consumers.LoadWeather(configkit.File(fixture("weather", "empty-modules.yml")))
return err
},
code: configkit.CodeValidateError, path: "/reports/daily/modules",
},
{
name: "explicit empty path templates",
load: func() error {
_, err := consumers.LoadWeather(configkit.File(fixture("weather", "empty-paths.yml")))
return err
},
code: configkit.CodeValidateError, path: "/reports/daily/path_templates",
},
{
name: "explicit null required option",
load: func() error {
_, err := consumers.LoadWeather(configkit.File(fixture("weather", "null-window.yml")))
return err
},
code: configkit.CodeValidateError, path: "/reports/daily/modules/0/options/window_units",
},
{
name: "strict unknown rule field",
load: func() error {
_, err := consumers.LoadSpeakerRules(fixture("seriatim", "unknown-field.yml"))
return err
},
code: configkit.CodeDecodeUnknownField, path: "/rules/0/color",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
var loadError *configkit.LoadError
if err := test.load(); !errors.As(err, &loadError) {
t.Fatalf("error = %T %v", err, err)
}
diagnostics := loadError.Diagnostics()
if len(diagnostics) == 0 || diagnostics[0].Code != test.code || diagnostics[0].Path.String() != test.path {
t.Fatalf("diagnostics = %#v", diagnostics)
}
if strings.Contains(diagnostics[0].Message, "wrong-before-profile") {
t.Fatalf("diagnostic exposed raw parser input: %#v", diagnostics[0])
}
})
}
}
func TestWeatherLoadedAndConstructedRulesAgreeWithoutMutation(t *testing.T) {
loaded, err := consumers.LoadWeather(configkit.File(fixture("weather", "config.yml")))
if err != nil {
t.Fatal(err)
}
reconstructed, err := consumers.LoadConstructedWeather(loaded.Config)
if err != nil || !reflect.DeepEqual(reconstructed.Config, loaded.Config) {
t.Fatalf("loaded/constructed outcomes differ: %#v, %v", reconstructed, err)
}
constructed := consumers.WeatherConfig{Units: loaded.Config.Units, Reports: map[string]consumers.ReportConfig{}}
constructed.Reports["daily"] = consumers.ReportConfig{
Modules: []consumers.ModuleSpec{{Module: "discussion", Options: consumers.ModuleOptions{Sections: []string{" short_term "}}}},
PathTemplates: []string{"daily/{date}.md"},
}
wantInput := constructed.Reports["daily"].Modules[0].Options.Sections[0]
result, err := consumers.LoadConstructedWeather(constructed)
if err != nil {
t.Fatal(err)
}
if got := constructed.Reports["daily"].Modules[0].Options.Sections[0]; got != wantInput {
t.Fatalf("constructed options mutated from %q to %q", wantInput, got)
}
if result.Config.Reports["daily"].Modules[0].Options.Sections[0] != "short_term" {
t.Fatalf("normalized result = %#v", result.Config.Reports["daily"])
}
empty := consumers.WeatherConfig{Units: "metric", Reports: map[string]consumers.ReportConfig{
"daily": {Modules: []consumers.ModuleSpec{}},
}}
_, err = consumers.LoadConstructedWeather(empty)
var loadError *configkit.LoadError
if !errors.As(err, &loadError) || loadError.Diagnostics()[0].Code != configkit.CodeValidateError {
t.Fatalf("constructed empty modules error = %v", err)
}
if !reflect.DeepEqual(empty.Reports["daily"].Modules, []consumers.ModuleSpec{}) {
t.Fatal("constructed empty module input was mutated")
}
}
func fixture(parts ...string) string {
_, file, _, _ := runtime.Caller(0)
values := append([]string{filepath.Dir(file), "testdata"}, parts...)
return filepath.Join(values...)
}
func preserveEnvironment(name string) func() {
value, present := os.LookupEnv(name)
return func() {
if present {
_ = os.Setenv(name, value)
} else {
_ = os.Unsetenv(name)
}
}
}
func secretStatus(report *configkit.SecretsReport, name string) configkit.SecretFileStatus {
for _, entry := range report.Entries() {
if entry.Name == name {
return entry.Status
}
}
return "missing"
}

View File

@@ -0,0 +1,3 @@
artifacts:
transcript:
output: ' transcript.txt '

View File

@@ -0,0 +1,5 @@
configkit:
imports:
- path: artifacts.yml
poll_interval: 1m
model_profile: concise

View File

@@ -0,0 +1 @@
poll_interval: 2m

View File

@@ -0,0 +1,6 @@
version: v1
bindings:
- object: summary
options:
window_units: 12
format: markdown

View File

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

View File

@@ -0,0 +1,5 @@
rules:
- find: teh
replace: the
- find: occured
replace: occurred

View File

@@ -0,0 +1,5 @@
rules:
- prefix: 'Host:'
name: narrator
- prefix: 'Guest:'
name: visitor

View File

@@ -0,0 +1,4 @@
rules:
- prefix: 'Host:'
name: narrator
color: blue

View File

@@ -0,0 +1,11 @@
units: us
reports:
daily:
modules:
- module: chunker
options:
window_units: 12
- module: discussion
options:
sections: [' short_term ']
path_templates: ['daily/{date}.md']

View File

@@ -0,0 +1,3 @@
reports:
daily:
modules: []

View File

@@ -0,0 +1,3 @@
reports:
daily:
path_templates: []

View File

@@ -0,0 +1,6 @@
reports:
daily:
modules:
- module: chunker
options:
window_units: null

View File

@@ -0,0 +1,3 @@
reports:
daily:
modules: [chunker]

122
explain.go Normal file
View File

@@ -0,0 +1,122 @@
package configkit
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
// ValueSnapshot is a detached, deterministic YAML representation of one value.
// Present distinguishes absence or deletion from an explicit null value.
type ValueSnapshot struct {
Present bool
Redacted bool
YAML string
}
// ValueChange describes one explicit definition or audited domain operation.
type ValueChange struct {
Before ValueSnapshot
After ValueSnapshot
Origin Origin
Reasons []string
Dependencies []Path
}
// SecretReferenceInfo describes a successfully resolved effective reference.
// Name is the environment-variable name, never its value.
type SecretReferenceInfo struct {
Name string
}
// Explanation is a detached safe account of one effective or historical path.
type Explanation struct {
Path Path
Effective ValueSnapshot
Origin Origin
History []ValueChange
SecretReference *SecretReferenceInfo
}
// Explain returns safe effective and chronological values for a known final or
// historical path. Unknown and malformed paths return false.
func (r *Result[T]) Explain(path Path) (Explanation, bool) {
if r == nil || r.final == nil || r.baseline == nil || !r.metadata.hasHistoricalPath(path) {
return Explanation{}, false
}
explanation := Explanation{Path: path.clone(), Effective: valueSnapshot(r.final, path, r.secrets)}
if info, ok := r.metadata.Lookup(path); ok {
explanation.Origin = cloneOrigin(info.Origin)
}
working := document.Clone(r.baseline)
if _, present := documentAtPath(working, path); present {
explanation.History = append(explanation.History, ValueChange{
After: valueSnapshot(working, path, r.secrets), Origin: Origin{Layer: LayerDefault},
})
}
for _, application := range r.applications {
operationPath, relevant := applicationOperationPath(application, path)
var before ValueSnapshot
if relevant {
before = valueSnapshot(working, path, r.secrets)
}
applyHistoryApplication(&working, application)
if !relevant {
continue
}
after := valueSnapshot(working, path, r.secrets)
if !before.Present && !after.Present {
continue
}
explanation.History = append(explanation.History, ValueChange{
Before: before,
After: after,
Origin: historyApplicationOrigin(application, operationPath),
})
}
// Intermediate input replacements do not permanently suppress defaults:
// decoding applies them to the final input tree. Show that reconciliation
// explicitly, including canonical scalar conversions, before domain changes.
replayed, _ := documentAtPath(working, path)
decoded, _ := documentAtPath(r.input, path)
if !configurationValuesEqual(replayed, decoded) {
origin := cloneOrigin(r.inputOrigins[path.String()])
origin.Operation = ChangeDecoding
explanation.History = append(explanation.History, ValueChange{
Before: valueSnapshot(working, path, r.secrets),
After: valueSnapshot(r.input, path, r.secrets),
Origin: origin,
Reasons: []string{"applied retained defaults and canonical typed decoding"},
})
}
working = document.Clone(r.input)
for _, snapshot := range coalescedResolutionSnapshots(r.resolution) {
relevant := pathsIntersect(snapshot.change.Path, path)
var before ValueSnapshot
if relevant {
before = valueSnapshot(working, path, r.secrets)
}
replaceHistoryPath(&working, snapshot.change.Path, snapshot.after)
if !relevant {
continue
}
after := valueSnapshot(working, path, r.secrets)
if !before.Present && !after.Present {
continue
}
explanation.History = append(explanation.History, ValueChange{
Before: before,
After: after,
Origin: cloneOrigin(snapshot.change.Origin),
Reasons: append([]string(nil), snapshot.change.Reasons...),
Dependencies: clonePaths(snapshot.change.Dependencies),
})
}
if !explanation.Effective.Present && len(explanation.History) == 0 {
return Explanation{}, false
}
if name, ok := r.secretReferences[path.String()]; ok && !pathIsSecret(r.secrets, path) {
explanation.SecretReference = &SecretReferenceInfo{Name: name}
}
return explanation, true
}

123
explain_render.go Normal file
View File

@@ -0,0 +1,123 @@
package configkit
import (
"errors"
"io"
"strconv"
"strings"
)
var (
errExplanationUnavailable = errors.New("configkit: configuration explanation is unavailable")
errExplanationUnknownPath = errors.New("configkit: configuration explanation path is unknown")
)
// WriteExplanation writes a deterministic human-readable rendering of Explain.
// Unknown paths produce no output. Writer failures may leave partial output.
func (r *Result[T]) WriteExplanation(output io.Writer, path Path) error {
if r == nil || output == nil {
return errExplanationUnavailable
}
explanation, ok := r.Explain(path)
if !ok {
return errExplanationUnknownPath
}
var rendered strings.Builder
rendered.WriteString("path: ")
rendered.WriteString(strconv.Quote(explanation.Path.String()))
rendered.WriteByte('\n')
writeExplanationOrigin(&rendered, "effective source", explanation.Origin, r.sources)
writeExplanationSnapshot(&rendered, "effective (canonical typed)", explanation.Effective, 0)
if explanation.SecretReference != nil {
rendered.WriteString("secret reference: ")
rendered.WriteString(strconv.Quote(explanation.SecretReference.Name))
rendered.WriteString(" (resolved from process environment)\n")
}
rendered.WriteString("history:\n")
for _, change := range explanation.History {
rendered.WriteString(" - representation: ")
if change.Origin.Operation == ChangeDecoding {
rendered.WriteString("input to canonical typed\n")
} else if change.Origin.Layer == LayerBase || change.Origin.Layer == LayerProfile || change.Origin.Layer == LayerCLI {
rendered.WriteString("policy-validated input\n")
} else {
rendered.WriteString("canonical typed\n")
}
writeExplanationOrigin(&rendered, " source", change.Origin, r.sources)
writeExplanationSnapshot(&rendered, " before", change.Before, 4)
writeExplanationSnapshot(&rendered, " after", change.After, 4)
for _, reason := range change.Reasons {
rendered.WriteString(" reason: ")
rendered.WriteString(strconv.Quote(reason))
rendered.WriteByte('\n')
}
for _, dependency := range change.Dependencies {
rendered.WriteString(" dependency: ")
rendered.WriteString(strconv.Quote(dependency.String()))
rendered.WriteByte('\n')
}
}
data := []byte(rendered.String())
written, err := output.Write(data)
if err == nil && written != len(data) {
return io.ErrShortWrite
}
return err
}
func writeExplanationOrigin(output *strings.Builder, label string, origin Origin, sources []SourceInfo) {
output.WriteString(label)
output.WriteString(": layer=")
output.WriteString(strconv.Quote(string(origin.Layer)))
if origin.Operation != "" {
output.WriteString(" operation=")
output.WriteString(strconv.Quote(string(origin.Operation)))
}
output.WriteByte('\n')
for _, location := range origin.Locations {
output.WriteString(" location: source=")
output.WriteString(strconv.Quote(location.SourceID))
if label := sourceLabel(sources, location.SourceID); label != "" {
output.WriteString(" label=")
output.WriteString(strconv.Quote(label))
}
output.WriteString(" line=")
output.WriteString(strconv.Itoa(location.Line))
output.WriteString(" column=")
output.WriteString(strconv.Itoa(location.Column))
output.WriteByte('\n')
}
}
func sourceLabel(sources []SourceInfo, sourceID string) string {
for _, source := range sources {
if source.ID == sourceID {
return source.Label
}
}
return ""
}
func writeExplanationSnapshot(output *strings.Builder, label string, snapshot ValueSnapshot, indent int) {
if !snapshot.Present {
output.WriteString(label)
output.WriteString(": absent\n")
return
}
output.WriteString(label)
if snapshot.Redacted {
output.WriteString(" (redacted)")
}
output.WriteString(":\n")
prefix := strings.Repeat(" ", indent+2)
for _, line := range strings.SplitAfter(snapshot.YAML, "\n") {
if line == "" {
continue
}
output.WriteString(prefix)
output.WriteString(line)
if !strings.HasSuffix(line, "\n") {
output.WriteByte('\n')
}
}
}

412
explain_test.go Normal file
View File

@@ -0,0 +1,412 @@
package configkit
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
type explanationConcurrency struct {
Transcription struct {
Concurrency int `yaml:"concurrency"`
} `yaml:"transcription"`
}
func TestExplainShowsDefaultBaseAndEffectiveProfileValue(t *testing.T) {
directory := t.TempDir()
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]{
Imports: true, Profile: "testing",
Definition: Definition[explanationConcurrency]{Defaults: func() explanationConcurrency {
value := explanationConcurrency{}
value.Transcription.Concurrency = 4
return value
}},
})
if err != nil {
t.Fatal(err)
}
explanation, ok := result.Explain(Keys("transcription", "concurrency"))
if !ok {
t.Fatal("known path was not explained")
}
if got := explanationValues(explanation); !reflect.DeepEqual(got, []string{"4\n", "8\n", "1\n"}) {
t.Fatalf("history values = %#v", got)
}
if explanation.Effective.YAML != "1\n" || explanation.Origin.Layer != LayerProfile ||
len(explanation.Origin.Locations) != 1 || explanation.Origin.Locations[0].SourceID != "source-3" ||
explanation.Origin.Locations[0].Line != 2 {
t.Fatalf("effective explanation = %#v", explanation)
}
}
type explanationConfig struct {
Concurrency int `yaml:"concurrency"`
Equal string `yaml:"equal"`
Limit int `yaml:"limit"`
Items []string `yaml:"items"`
Labels map[string]string `yaml:"labels"`
Dynamic any `yaml:"dynamic"`
Maybe *string `yaml:"maybe"`
Empty []string `yaml:"empty"`
Credential Secret `yaml:"credential"`
Classified Secret `yaml:"classified"`
DerivedA string `yaml:"derived_a"`
DerivedB string `yaml:"derived_b"`
}
func TestExplainReplaysCLIResolutionContainersAndSafeHistoricalValues(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "config.yml", `concurrency: 8
equal: same
limit: wrong-before-profile
items: [base, removed]
labels: {base: retained}
dynamic: {retired: HISTORICAL_EXPLAIN_CANARY, public: visible}
credential: APP_SECRET
classified: APP_SECRET
`)
writeImportFile(t, directory, filepath.Join("profiles", "testing.yml"), `concurrency: 1
equal: same
limit: 7
items: [profile]
labels: {profile: retained}
dynamic: selected
`)
result, err := loadWithSecretLookup(File(mainPath), func(name string) (string, bool) {
if name != "APP_SECRET" {
t.Fatalf("secret lookup = %q", name)
}
return "RESOLVED_EXPLAIN_CANARY", true
}, Options[explanationConfig]{
Profile: "testing",
Overrides: []Override{
{Path: Keys("concurrency"), Value: 2, Flag: "--concurrency"},
{Path: Keys("equal"), Value: "same", Flag: "--equal"},
{Path: Keys("items"), Value: []any{"cli", "restored"}, Flag: "--items"},
{Path: Keys("labels"), Value: map[string]any{"cli": "retained"}, Flag: "--label"},
},
Definition: Definition[explanationConfig]{
Defaults: func() explanationConfig {
return explanationConfig{
Concurrency: 4, Equal: "same", Labels: map[string]string{"default": "retained"},
Maybe: nil, Empty: []string{}, Credential: SecretEnv("APP_SECRET"), Classified: SecretEnv("APP_SECRET"),
}
},
Secret: func(path Path) bool {
return path.String() == "/dynamic/retired" || path.String() == "/classified"
},
Resolve: func(configuration *explanationConfig, resolution *Resolution) error {
Normalize(resolution, Keys("concurrency"), &configuration.Concurrency, 3,
"normalized\nconcurrency", Keys("labels"))
Normalize(resolution, Keys("equal"), &configuration.Equal, "same", "record equal value")
Normalize(resolution, Keys("derived_a"), &configuration.DerivedA, "DERIVED_EXPLAIN_A",
"derived cycle a", Keys("derived_b"))
Normalize(resolution, Keys("derived_b"), &configuration.DerivedB, "DERIVED_EXPLAIN_B",
"derived cycle b", Keys("derived_a"), Keys("dynamic"))
return nil
},
},
})
if err != nil {
t.Fatal(err)
}
concurrency, ok := result.Explain(Keys("concurrency"))
if !ok {
t.Fatal("concurrency was not explained")
}
if got := explanationValues(concurrency); !reflect.DeepEqual(got, []string{"4\n", "8\n", "1\n", "2\n", "3\n"}) {
t.Fatalf("concurrency history = %#v", got)
}
if got := historyValueLayers(concurrency); !reflect.DeepEqual(got, []Layer{LayerDefault, LayerBase, LayerProfile, LayerCLI, LayerResolution}) {
t.Fatalf("concurrency layers = %#v", got)
}
if concurrency.History[4].Before.YAML != "2\n" || concurrency.History[4].Reasons[0] != "normalized\nconcurrency" ||
concurrency.History[4].Dependencies[0].String() != "/labels" {
t.Fatalf("resolution history = %#v", concurrency.History[4])
}
equal, _ := result.Explain(Keys("equal"))
if len(equal.History) != 5 {
t.Fatalf("equal writes were lost: %#v", equal.History)
}
for _, change := range equal.History[1:] {
if change.Before.YAML != "same\n" || change.After.YAML != "same\n" {
t.Fatalf("equal replacement = %#v", change)
}
}
limit, _ := result.Explain(Keys("limit"))
if got := explanationValues(limit); !reflect.DeepEqual(got, []string{"0\n", "wrong-before-profile\n", "7\n"}) {
t.Fatalf("overridden invalid input was not retained: %#v", got)
}
removed, ok := result.Explain(Keys("dynamic", "retired"))
if !ok || removed.Effective.Present || len(removed.History) < 2 {
t.Fatalf("removed path = %#v, %v", removed, ok)
}
for _, change := range removed.History {
if strings.Contains(change.Before.YAML+change.After.YAML, "HISTORICAL_EXPLAIN_CANARY") {
t.Fatalf("historical secret escaped redaction: %#v", change)
}
}
if !removed.History[0].After.Redacted || removed.History[len(removed.History)-1].After.Present {
t.Fatalf("removed secret history = %#v", removed.History)
}
dynamic, _ := result.Explain(Keys("dynamic"))
if len(dynamic.History) < 3 || !dynamic.History[1].After.Redacted ||
strings.Contains(dynamic.History[1].After.YAML, "HISTORICAL_EXPLAIN_CANARY") {
t.Fatalf("container redaction history = %#v", dynamic.History)
}
items, _ := result.Explain(Keys("items"))
if len(items.History) != 4 || !strings.Contains(items.History[1].After.YAML, "removed") ||
strings.Contains(items.History[2].After.YAML, "removed") || !strings.Contains(items.History[3].After.YAML, "restored") {
t.Fatalf("atomic sequence history = %#v", items.History)
}
removedIndex, ok := result.Explain(Keys("items").Index(1))
if !ok || !removedIndex.History[1].Before.Present || removedIndex.History[1].After.Present ||
!removedIndex.History[2].After.Present {
t.Fatalf("sequence descendant history = %#v", removedIndex.History)
}
labels, _ := result.Explain(Keys("labels"))
if len(labels.History) != 4 || !strings.Contains(labels.Effective.YAML, "default: retained") ||
!strings.Contains(labels.Effective.YAML, "profile: retained") || !strings.Contains(labels.Effective.YAML, "cli: retained") {
t.Fatalf("composite mapping history = %#v", labels)
}
if labels.Origin.Layer != LayerCLI || len(labels.Origin.Locations) != 3 {
t.Fatalf("composite effective origin = %#v", labels.Origin)
}
maybe, _ := result.Explain(Keys("maybe"))
empty, _ := result.Explain(Keys("empty"))
if !maybe.Effective.Present || maybe.Effective.YAML != "null\n" || !empty.Effective.Present || empty.Effective.YAML != "[]\n" {
t.Fatalf("null/empty snapshots = maybe %#v empty %#v", maybe.Effective, empty.Effective)
}
credential, _ := result.Explain(Keys("credential"))
if credential.SecretReference == nil || credential.SecretReference.Name != "APP_SECRET" || credential.Effective.YAML != "APP_SECRET\n" {
t.Fatalf("secret reference = %#v", credential)
}
classified, _ := result.Explain(Keys("classified"))
if classified.SecretReference != nil || !classified.Effective.Redacted ||
strings.Contains(classified.Effective.YAML, "APP_SECRET") {
t.Fatalf("classified secret reference = %#v", classified)
}
var derivedExplanations []Explanation
for _, path := range []Path{Keys("derived_a"), Keys("derived_b")} {
derived, _ := result.Explain(path)
if !derived.Effective.Redacted || strings.Contains(derived.Effective.YAML, "DERIVED_EXPLAIN") {
t.Fatalf("dependency closure at %s = %#v", path.String(), derived)
}
derivedExplanations = append(derivedExplanations, derived)
}
formatted := fmt.Sprintf("%v %#v %v", removed, credential, derivedExplanations)
encoded, marshalErr := json.Marshal(append([]Explanation{removed, credential, classified}, derivedExplanations...))
if marshalErr != nil || strings.Contains(formatted+string(encoded), "RESOLVED_EXPLAIN_CANARY") ||
strings.Contains(formatted+string(encoded), "HISTORICAL_EXPLAIN_CANARY") ||
strings.Contains(formatted+string(encoded), "DERIVED_EXPLAIN") {
t.Fatalf("public records disclosed a canary: %s %s", formatted, encoded)
}
concurrency.Path.segments[0].key = "mutated"
concurrency.History[4].Reasons[0] = "mutated"
concurrency.History[4].Dependencies[0].segments[0].key = "mutated"
result.Config.Concurrency = 99
again, _ := result.Explain(Keys("concurrency"))
if again.Path.String() != "/concurrency" || again.Effective.YAML != "3\n" ||
again.History[4].Reasons[0] != "normalized\nconcurrency" ||
again.History[4].Dependencies[0].String() != "/labels" {
t.Fatalf("returned explanation aliased internal history: %#v", again)
}
var first, second bytes.Buffer
if err := result.WriteExplanation(&first, Keys("concurrency")); err != nil {
t.Fatal(err)
}
if err := result.WriteExplanation(&second, Keys("concurrency")); err != nil || first.String() != second.String() {
t.Fatalf("nondeterministic rendering: %v\n%s\n%s", err, first.String(), second.String())
}
if strings.Contains(first.String(), "\nconcurrency\"") || !strings.Contains(first.String(), `"normalized\nconcurrency"`) {
t.Fatalf("reason controls were not escaped: %q", first.String())
}
unknownOutput := bytes.NewBufferString("unchanged")
if err := result.WriteExplanation(unknownOutput, Keys("unknown")); !errors.Is(err, errExplanationUnknownPath) || unknownOutput.String() != "unchanged" {
t.Fatalf("unknown writer = %q, %v", unknownOutput.String(), err)
}
if _, ok := result.Explain(Keys("items").Index(-1)); ok {
t.Fatal("malformed path was explained")
}
wantWriteError := errors.New("write failed")
if err := result.WriteExplanation(errorWriter{err: wantWriteError}, Keys("concurrency")); !errors.Is(err, wantWriteError) {
t.Fatalf("writer error = %v", err)
}
if err := result.WriteExplanation(shortWriter{}, Keys("concurrency")); !errors.Is(err, io.ErrShortWrite) {
t.Fatalf("short writer error = %v", err)
}
var invalidInput bytes.Buffer
if err := result.WriteExplanation(&invalidInput, Keys("limit")); err != nil ||
!strings.Contains(invalidInput.String(), "policy-validated input") ||
strings.Contains(invalidInput.String(), "wrong-before-profile\n source: layer=\"base\"\n before (canonical typed)") {
t.Fatalf("historical representation rendering = %v\n%s", err, invalidInput.String())
}
}
func TestSecretsBootstrapLoadAndExplanationRemainSeparateAndSafe(t *testing.T) {
const name = "CONFIGKIT_EXPLAIN_BOOTSTRAP_TEST"
previous, present := os.LookupEnv(name)
if err := os.Unsetenv(name); err != nil {
t.Fatal(err)
}
t.Cleanup(func() {
if present {
_ = os.Setenv(name, previous)
} else {
_ = os.Unsetenv(name)
}
})
directory := t.TempDir()
writeSecretTestFile(t, directory, name, "BOOTSTRAP_EXPLAIN_CANARY\n")
report, err := LoadSecretsDir(directory)
if err != nil || len(report.Entries()) != 1 || report.Entries()[0].Status != SecretFileLoaded {
t.Fatalf("bootstrap = %#v, %v", report, err)
}
type config struct {
Credential Secret `yaml:"credential"`
}
result, err := Load[config](Bytes("config.yml", []byte("credential: "+name+"\n")))
if err != nil {
t.Fatal(err)
}
var output bytes.Buffer
if err := result.WriteExplanation(&output, Keys("credential")); err != nil {
t.Fatal(err)
}
if !strings.Contains(output.String(), name) || strings.Contains(output.String(), "BOOTSTRAP_EXPLAIN_CANARY") {
t.Fatalf("explanation mixed bootstrap value into reference report: %s", output.String())
}
}
func TestHistoryNodeBudgetCountsSharedTreesOnce(t *testing.T) {
leaf := &document.Node{Kind: document.String, Text: "value"}
root := &document.Node{Kind: document.Mapping, Mapping: []document.Entry{{Key: "value", Value: leaf}}}
applications := []historyApplication{{layer: LayerBase, value: root}}
if !historyNodesWithin(2, root, root, root, applications, nil) {
t.Fatal("structurally shared history was counted more than once")
}
other := document.Clone(root)
if historyNodesWithin(3, root, other, root, applications, nil) {
t.Fatal("distinct retained nodes did not consume the budget")
}
}
func explanationValues(explanation Explanation) []string {
values := make([]string, len(explanation.History))
for index, change := range explanation.History {
values[index] = change.After.YAML
}
return values
}
func historyValueLayers(explanation Explanation) []Layer {
layers := make([]Layer, len(explanation.History))
for index, change := range explanation.History {
layers[index] = change.Origin.Layer
}
return layers
}
func TestExplanationReconcilesDefaultsAfterInputReplacement(t *testing.T) {
type cfg struct {
M map[string]int `yaml:"m"`
}
for _, base := range []string{"null", "replaced-text"} {
for _, cli := range []bool{false, true} {
t.Run(fmt.Sprintf("%s/cli=%v", base, cli), func(t *testing.T) {
root := t.TempDir()
main := writeImportFile(t, root, "main.yml", "m: "+base+"\n")
opts := Options[cfg]{Definition: Definition[cfg]{Defaults: func() cfg { return cfg{M: map[string]int{"a": 1}} }}}
if cli {
opts.Overrides = []Override{{Path: Keys("m"), Value: map[string]any{"b": 2}, Flag: "--m"}}
} else {
writeImportFile(t, root, "profiles/p.yml", "m: {b: 2}\n")
opts.Profile = "p"
}
result, err := Load(File(main), opts)
if err != nil {
t.Fatal(err)
}
for _, path := range []Path{Keys("m"), Keys("m", "a")} {
explanation, ok := result.Explain(path)
if !ok {
t.Fatal("missing explanation")
}
last := explanation.History[len(explanation.History)-1]
if last.After != explanation.Effective || last.Origin.Operation != ChangeDecoding {
t.Fatalf("history does not reconcile final value: %#v", explanation)
}
info, _ := result.Metadata().Lookup(path)
if last.Origin.Layer != info.Origin.Layer {
t.Fatal("decoding lost effective origin")
}
}
})
}
}
}
func TestExplanationReplaysResolutionAdditionsAndOverlappingIntent(t *testing.T) {
type cfg struct {
M map[string]any `yaml:"m"`
}
for _, overlap := range []bool{false, true} {
t.Run(fmt.Sprint(overlap), func(t *testing.T) {
result, err := Load(Bytes("base", []byte("m: {old: 1}\n")), Options[cfg]{Definition: Definition[cfg]{Resolve: func(c *cfg, r *Resolution) error {
c.M["new"] = map[string]any{"child": int64(7)}
delete(c.M, "old")
r.Record(Keys("m", "new"), ChangeDerivedDefault, "add nested", Keys("m", "old"))
r.Record(Keys("m", "old"), ChangeNormalization, "remove old")
if overlap {
r.Record(Keys("m"), ChangeNormalization, "update container")
}
return nil
}}})
if err != nil {
t.Fatal(err)
}
for _, path := range []Path{Keys("m"), Keys("m", "new"), Keys("m", "new", "child"), Keys("m", "old")} {
x, ok := result.Explain(path)
if !ok {
t.Fatal("missing known history")
}
var count int
for _, v := range x.History {
if v.Origin.Layer == LayerResolution {
count++
if overlap && len(v.Reasons) != 3 {
t.Fatal("lost overlapping intent")
}
}
}
if overlap && count != 1 {
t.Fatalf("overlapping records applied %d times", count)
}
last := x.History[len(x.History)-1]
if last.After != x.Effective {
t.Fatalf("incorrect replay for %s: %#v", path, x)
}
}
})
}
}

121
filesystem.go Normal file
View File

@@ -0,0 +1,121 @@
package configkit
import (
"os"
"path/filepath"
"strings"
)
func validateFilesystemContext(source Source, context *FilesystemContext, main parsedSource) *Diagnostic {
rootPath, _, _, diagnostic := importFilesystem(source, context, main)
if diagnostic != nil {
return diagnostic
}
root, err := os.OpenRoot(rootPath)
if err != nil {
value := globFilesystemDiagnostic(err, "configuration root could not be opened", Location{})
value.Primary = nil
return &value
}
if err := root.Close(); err != nil {
value := importDiagnostic(CodeImportRead, "configuration root could not be closed", nil)
return &value
}
return nil
}
func filesystemOptionDiagnostics(source Source, context *FilesystemContext) []Diagnostic {
switch source.kind {
case sourceNone:
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "NoSource cannot use filesystem context")}
case sourceFile:
if context.BaseDir != "" {
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "file sources determine their own import base directory")}
}
case sourceBytes, sourceReader:
if context.BaseDir == "" || context.Root == "" {
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "byte and reader filesystem context requires base and root directories")}
}
if !filepath.IsAbs(context.BaseDir) || !filepath.IsAbs(context.Root) {
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "byte and reader filesystem base and root directories must be absolute")}
}
}
return nil
}
func importFilesystem(source Source, context *FilesystemContext, main parsedSource) (rootPath, basePath, mainPath string, diagnostic *Diagnostic) {
if main.source == nil || main.document == nil {
value := importDiagnostic(CodeImportInvalid, "imports require a selected configuration source", nil)
return "", "", "", &value
}
if source.kind == sourceFile {
if context != nil && context.BaseDir != "" {
value := optionDiagnostic(CodeOptionsInvalid, "file sources determine their own import base directory")
return "", "", "", &value
}
canonical, err := canonicalExistingPath(source.path)
if err != nil {
value := importDiagnostic(CodeImportInvalid, "selected source path could not be canonicalized", nil)
return "", "", "", &value
}
mainPath = canonical
basePath = filepath.Dir(canonical)
rootPath = basePath
if context != nil && context.Root != "" {
rootPath, err = canonicalExistingPath(context.Root)
if err != nil {
value := importDiagnostic(CodeImportInvalid, "configuration root could not be canonicalized", nil)
return "", "", "", &value
}
}
} else {
if context == nil || context.BaseDir == "" || context.Root == "" {
value := optionDiagnostic(CodeOptionsInvalid, "byte and reader imports require absolute base and root directories")
return "", "", "", &value
}
if !filepath.IsAbs(context.BaseDir) || !filepath.IsAbs(context.Root) {
value := optionDiagnostic(CodeOptionsInvalid, "byte and reader import base and root directories must be absolute")
return "", "", "", &value
}
var err error
basePath, err = canonicalExistingPath(context.BaseDir)
if err != nil {
value := importDiagnostic(CodeImportInvalid, "import base directory could not be canonicalized", nil)
return "", "", "", &value
}
rootPath, err = canonicalExistingPath(context.Root)
if err != nil {
value := importDiagnostic(CodeImportInvalid, "configuration root could not be canonicalized", nil)
return "", "", "", &value
}
}
if !containedPath(rootPath, basePath) || (mainPath != "" && !containedPath(rootPath, mainPath)) {
value := importDiagnostic(CodeImportOutsideRoot, "selected source and import base must stay inside the configuration root", nil)
return "", "", "", &value
}
for _, candidate := range []string{rootPath, basePath} {
info, err := os.Stat(candidate)
if err != nil || !info.IsDir() {
value := importDiagnostic(CodeImportInvalid, "configuration root and import base must be directories", nil)
return "", "", "", &value
}
}
return rootPath, basePath, mainPath, nil
}
func canonicalExistingPath(name string) (string, error) {
absolute, err := filepath.Abs(name)
if err != nil {
return "", err
}
return filepath.EvalSymlinks(filepath.Clean(absolute))
}
func containedPath(root, target string) bool {
relative, err := filepath.Rel(root, target)
if err != nil || filepath.IsAbs(relative) {
return false
}
return relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator))
}

205
glob.go Normal file
View File

@@ -0,0 +1,205 @@
package configkit
import (
"errors"
"io"
"io/fs"
"os"
"path"
"path/filepath"
"slices"
"strings"
)
const globReadBatch = 128
type globCandidate struct {
absolute string
relative string
}
func (s *importTraversal) expandImportGlob(basePath string, declaration importDeclaration) ([]string, []Diagnostic) {
location := declaration.location
segments, valid := validGlobPattern(declaration.glob)
if !valid {
return nil, []Diagnostic{importDiagnostic(CodeImportInvalid, "import glob pattern is invalid or uses recursive ** syntax", &location)}
}
candidates := []globCandidate{{absolute: basePath}}
for index, segment := range segments {
last := index == len(segments)-1
literal, patterned := globLiteralSegment(segment)
var next []globCandidate
for _, candidate := range candidates {
var names []string
if patterned {
matched, diagnostics := s.readGlobMatches(candidate.absolute, segment, location)
if len(diagnostics) != 0 {
return nil, diagnostics
}
names = matched
} else {
names = []string{literal}
}
for _, name := range names {
absolute := filepath.Join(candidate.absolute, filepath.FromSlash(name))
relative := path.Join(candidate.relative, name)
if !containedPath(s.rootPath, filepath.Clean(absolute)) {
return nil, []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "glob traversal is outside the configuration root", &location)}
}
canonical, err := canonicalExistingPath(absolute)
if err != nil {
if errors.Is(err, fs.ErrNotExist) && !patterned {
continue
}
return nil, []Diagnostic{globFilesystemDiagnostic(err, "glob match could not be canonicalized", location)}
}
if !containedPath(s.rootPath, canonical) {
return nil, []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "glob traversal resolves outside the configuration root", &location)}
}
if last {
next = append(next, globCandidate{absolute: canonical, relative: relative})
if len(s.sources)+len(next) > s.limits.sources {
return nil, []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "glob matches exceed the remaining source-count limit", &location)}
}
continue
}
info, err := os.Stat(canonical)
if err != nil {
return nil, []Diagnostic{globFilesystemDiagnostic(err, "glob traversal target could not be inspected", location)}
}
if info.IsDir() {
next = append(next, globCandidate{absolute: canonical, relative: relative})
}
}
}
candidates = next
if len(candidates) == 0 {
return nil, nil
}
}
matches := make([]string, len(candidates))
for index, candidate := range candidates {
matches[index] = candidate.relative
}
slices.Sort(matches)
return matches, nil
}
func (s *importTraversal) readGlobMatches(directory, pattern string, location Location) ([]string, []Diagnostic) {
canonical, err := canonicalExistingPath(directory)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return nil, nil
}
return nil, []Diagnostic{globFilesystemDiagnostic(err, "glob directory could not be canonicalized", location)}
}
if !containedPath(s.rootPath, canonical) {
return nil, []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "glob directory resolves outside the configuration root", &location)}
}
relative, err := filepath.Rel(s.rootPath, canonical)
if err != nil || filepath.IsAbs(relative) || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
return nil, []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "glob directory could not be addressed inside the configuration root", &location)}
}
directoryFile, err := s.root.Open(relative)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return nil, nil
}
return nil, []Diagnostic{globFilesystemDiagnostic(err, "glob directory could not be opened", location)}
}
var matches []string
for {
entries, readErr := directoryFile.ReadDir(globReadBatch)
for _, entry := range entries {
s.usedEntries++
if s.usedEntries > s.limits.entries {
_ = directoryFile.Close()
return nil, []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "glob traversal exceeds the directory-entry visit limit", &location)}
}
matched, matchErr := path.Match(pattern, entry.Name())
if matchErr != nil {
_ = directoryFile.Close()
return nil, []Diagnostic{importDiagnostic(CodeImportInvalid, "import glob pattern is invalid", &location)}
}
if matched {
matches = append(matches, entry.Name())
}
}
if errors.Is(readErr, io.EOF) {
break
}
if readErr != nil {
_ = directoryFile.Close()
return nil, []Diagnostic{globFilesystemDiagnostic(readErr, "glob directory could not be read", location)}
}
}
if err := directoryFile.Close(); err != nil {
return nil, []Diagnostic{globFilesystemDiagnostic(err, "glob directory could not be closed", location)}
}
slices.Sort(matches)
return matches, nil
}
func validGlobPattern(pattern string) ([]string, bool) {
if !validImportPath(pattern) {
return nil, false
}
segments := strings.Split(pattern, "/")
for _, segment := range segments {
if segment == "" || hasRecursiveStars(segment) {
return nil, false
}
if _, err := path.Match(segment, ""); err != nil {
return nil, false
}
}
return segments, true
}
func hasRecursiveStars(segment string) bool {
inClass := false
for index := 0; index < len(segment); index++ {
switch segment[index] {
case '\\':
index++
case '[':
inClass = true
case ']':
inClass = false
case '*':
if !inClass && index+1 < len(segment) && segment[index+1] == '*' {
return true
}
}
}
return false
}
func globLiteralSegment(segment string) (string, bool) {
var literal strings.Builder
for index := 0; index < len(segment); index++ {
switch segment[index] {
case '*', '?', '[':
return "", true
case '\\':
index++
if index < len(segment) {
literal.WriteByte(segment[index])
}
default:
literal.WriteByte(segment[index])
}
}
return literal.String(), false
}
func globFilesystemDiagnostic(err error, message string, location Location) Diagnostic {
code := CodeImportRead
if errors.Is(err, fs.ErrPermission) {
code = CodeImportPermission
}
return importDiagnostic(code, message, &location)
}

273
glob_test.go Normal file
View File

@@ -0,0 +1,273 @@
package configkit
import (
"os"
"path/filepath"
"reflect"
"runtime"
"testing"
)
func TestLoadGlobImportsUseLexicalDeclarationOrderAndNestedDFS(t *testing.T) {
directory := t.TempDir()
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:
imports: [{path: nested/child.yml}]
items: {alpha: alpha}
`)
childPath := writeImportFile(t, directory, "fragments/nested/child.yml", "items: {child: child}\n")
bPath := writeImportFile(t, directory, "fragments/b.yml", "items: {beta: beta}\n")
afterPath := writeImportFile(t, directory, "after.yml", "items: {after: after}\n")
var previous []SourceInfo
for run := 0; run < 2; run++ {
result, err := Load[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
if err != nil {
t.Fatal(err)
}
wantItems := map[string]string{"root": "root", "alpha": "alpha", "child": "child", "beta": "beta", "after": "after"}
if !reflect.DeepEqual(result.Config.Items, wantItems) {
t.Fatalf("items = %#v, want %#v", result.Config.Items, wantItems)
}
sources := result.Sources()
wantPaths := []string{
canonicalTestPath(t, mainPath), canonicalTestPath(t, aPath),
canonicalTestPath(t, childPath), canonicalTestPath(t, bPath),
canonicalTestPath(t, afterPath),
}
gotPaths := make([]string, len(sources))
for index, source := range sources {
gotPaths[index] = source.Path
}
if !reflect.DeepEqual(gotPaths, wantPaths) {
t.Fatalf("source paths = %#v, want %#v", gotPaths, wantPaths)
}
if run != 0 && !reflect.DeepEqual(sources, previous) {
t.Fatalf("repeated traversal changed sources: %#v != %#v", sources, previous)
}
previous = sources
}
}
func TestLoadGlobImportsSupportEscapesClassesAndExplicitConfD(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
imports:
- glob: 'conf.d/literal\*.yml'
- glob: 'conf.d/item-[ab].yml'
- glob: 'conf.d/question?.yml'
items: {root: root}
`)
writeImportFile(t, directory, "conf.d/literal*.yml", "items: {literal: literal}\n")
writeImportFile(t, directory, "conf.d/item-b.yml", "items: {b: b}\n")
writeImportFile(t, directory, "conf.d/item-a.yml", "items: {a: a}\n")
writeImportFile(t, directory, "conf.d/question1.yml", "items: {question: question}\n")
result, err := Load[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
if err != nil {
t.Fatal(err)
}
if want := map[string]string{"root": "root", "literal": "literal", "a": "a", "b": "b", "question": "question"}; !reflect.DeepEqual(result.Config.Items, want) {
t.Fatalf("items = %#v, want %#v", result.Config.Items, want)
}
ignoredPath := writeImportFile(t, directory, "conf.d/not-discovered.yml", "unknown: value\n")
_ = ignoredPath
plainPath := writeImportFile(t, directory, "plain.yml", "items: {root: only}\n")
plain, err := Load[importTestConfig](File(plainPath), Options[importTestConfig]{Imports: true})
if err != nil || len(plain.Sources()) != 1 || plain.Config.Items["root"] != "only" {
t.Fatalf("implicit discovery result = %#v, %v", plain, err)
}
}
func TestLoadGlobImportsRejectInvalidAndUnmatchedPatterns(t *testing.T) {
tests := []struct {
name string
pattern string
code Code
}{
{name: "bad class", pattern: "empty/[", code: CodeImportInvalid},
{name: "recursive star segment", pattern: "empty/**/value.yml", code: CodeImportInvalid},
{name: "recursive adjacent stars", pattern: "empty/value**.yml", code: CodeImportInvalid},
{name: "trailing separator", pattern: "empty/", code: CodeImportInvalid},
{name: "absolute", pattern: "/tmp/*.yml", code: CodeImportInvalid},
{name: "URL", pattern: "https://example.invalid/*.yml", code: CodeImportInvalid},
{name: "missing parent escape", pattern: "../missing/*.yml", code: CodeImportOutsideRoot},
{name: "unmatched", pattern: "empty/*.yml", code: CodeImportMissing},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
directory := t.TempDir()
if err := os.Mkdir(filepath.Join(directory, "empty"), 0o755); err != nil {
t.Fatal(err)
}
mainPath := writeGlobRoot(t, directory, test.pattern, false)
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, test.code)
})
}
directory := t.TempDir()
if err := os.Mkdir(filepath.Join(directory, "empty"), 0o755); err != nil {
t.Fatal(err)
}
mainPath := writeGlobRoot(t, directory, "empty/*.yml", true)
result, err := Load[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
if err != nil || len(result.Sources()) != 1 {
t.Fatalf("optional unmatched glob = %#v, %v", result, err)
}
}
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:
imports:
- glob: conf/*.yml
- glob: conf/value.*
`)
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportRepeated)
})
t.Run("matched directory", func(t *testing.T) {
directory := t.TempDir()
if err := os.MkdirAll(filepath.Join(directory, "conf/matched.yml"), 0o755); err != nil {
t.Fatal(err)
}
mainPath := writeGlobRoot(t, directory, "conf/*.yml", true)
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeSourceNotRegular)
})
t.Run("hard links stay distinct", func(t *testing.T) {
directory := t.TempDir()
one := writeImportFile(t, directory, "conf/one.yml", "items: {same: value}\n")
if err := os.Link(one, filepath.Join(directory, "conf/two.yml")); err != nil {
t.Skipf("hard links unavailable: %v", err)
}
mainPath := writeGlobRoot(t, directory, "conf/*.yml", false)
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeComposeConflict)
})
}
func TestLoadGlobImportsConfineSymlinkDirectories(t *testing.T) {
if runtime.GOOS != "linux" && runtime.GOOS != "darwin" {
t.Skip("initial filesystem contract covers Linux and macOS")
}
t.Run("contained absolute target", func(t *testing.T) {
root := t.TempDir()
target := filepath.Join(root, "target")
writeImportFile(t, target, "value.yml", "items: {linked: linked}\n")
if err := os.Symlink(target, filepath.Join(root, "linked")); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
mainPath := writeGlobRoot(t, root, "linked/*.yml", false)
result, err := Load[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
if err != nil || result.Config.Items["linked"] != "linked" {
t.Fatalf("contained directory symlink = %#v, %v", result, err)
}
})
t.Run("escaping target", func(t *testing.T) {
root := t.TempDir()
outside := t.TempDir()
writeImportFile(t, outside, "value.yml", "items: {escaped: escaped}\n")
if err := os.Symlink(outside, filepath.Join(root, "linked")); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
mainPath := writeGlobRoot(t, root, "linked/*.yml", true)
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportOutsideRoot)
})
}
func TestOptionalGlobDoesNotSuppressDirectoryPermissionFailure(t *testing.T) {
directory := t.TempDir()
locked := filepath.Join(directory, "locked")
if err := os.Mkdir(locked, 0o700); err != nil {
t.Fatal(err)
}
if err := os.Chmod(locked, 0); err != nil {
t.Skipf("cannot create unreadable directory fixture: %v", err)
}
defer func() { _ = os.Chmod(locked, 0o700) }()
if opened, err := os.Open(locked); err == nil {
_ = opened.Close()
t.Skip("filesystem does not enforce the unreadable-directory fixture")
}
mainPath := writeGlobRoot(t, directory, "locked/*.yml", true)
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportPermission)
}
func TestGlobTraversalBoundsNonmatchesAndAcceptedCandidates(t *testing.T) {
directory := t.TempDir()
for _, name := range []string{"one.txt", "two.txt", "three.txt"} {
writeImportFile(t, directory, "many/"+name, "ignored\n")
}
mainPath := writeGlobRoot(t, directory, "many/*.yml", true)
main := parseImportMain(t, mainPath)
limits := defaultImportLimits()
limits.entries = 2
_, _, diagnostics := loadImportedDocuments(File(mainPath), nil, main, nil, limits)
if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportLimitExceeded {
t.Fatalf("entry-budget diagnostics = %#v", diagnostics)
}
for _, name := range []string{"a.yml", "b.yml"} {
writeImportFile(t, directory, "matches/"+name, "items: {"+name[:1]+": value}\n")
}
mainPath = writeGlobRoot(t, directory, "matches/*.yml", false)
main = parseImportMain(t, mainPath)
limits = defaultImportLimits()
limits.sources = 2
_, _, diagnostics = loadImportedDocuments(File(mainPath), nil, main, nil, limits)
if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportLimitExceeded {
t.Fatalf("candidate-budget diagnostics = %#v", diagnostics)
}
}
func TestFilesystemContextIsValidatedWithoutImportsOrActiveProfile(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "items: {root: root}\n")
result, err := Load[importTestConfig](File(mainPath), Options[importTestConfig]{
Profile: "default", Filesystem: &FilesystemContext{Root: directory},
})
if err != nil || result.Config.Items["root"] != "root" {
t.Fatalf("file filesystem context = %#v, %v", result, err)
}
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Filesystem: &FilesystemContext{BaseDir: directory, Root: directory}}, CodeOptionsInvalid)
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Filesystem: &FilesystemContext{Root: filepath.Join(directory, "missing")}}, CodeImportInvalid)
input := Bytes("memory.yml", []byte("items: {root: memory}\n"))
result, err = Load[importTestConfig](input, Options[importTestConfig]{Filesystem: &FilesystemContext{BaseDir: directory, Root: directory}})
if err != nil || result.Config.Items["root"] != "memory" {
t.Fatalf("byte filesystem context = %#v, %v", result, err)
}
requireLoadCode(t, Bytes("memory.yml", []byte("{}\n")), Options[importTestConfig]{Filesystem: &FilesystemContext{BaseDir: directory}}, CodeOptionsInvalid)
defaultsOnly, err := Load[importTestConfig](NoSource(), Options[importTestConfig]{Profile: "default"})
if err != nil || defaultsOnly == nil {
t.Fatalf("default profile with NoSource = %#v, %v", defaultsOnly, err)
}
if runtime.GOOS == "linux" || runtime.GOOS == "darwin" {
selectedLink := filepath.Join(directory, "selected-link.yml")
if err := os.Symlink(mainPath, selectedLink); err == nil {
linked, err := Load[importTestConfig](File(selectedLink), Options[importTestConfig]{Filesystem: &FilesystemContext{Root: directory}})
if err != nil || linked.Sources()[0].Path != canonicalTestPath(t, mainPath) {
t.Fatalf("selected symlink identity = %#v, %v", linked, err)
}
}
}
}
func writeGlobRoot(t *testing.T, directory, pattern string, optional bool) string {
t.Helper()
optionalLine := ""
if optional {
optionalLine = "\n optional: true"
}
return writeImportFile(t, directory, "main.yml", "configkit:\n imports:\n - glob: '"+pattern+"'"+optionalLine+"\n")
}

5
go.mod Normal file
View File

@@ -0,0 +1,5 @@
module gitea.maximumdirect.net/eric/ConfigKit
go 1.25.0
require go.yaml.in/yaml/v4 v4.0.0-rc.6

2
go.sum Normal file
View File

@@ -0,0 +1,2 @@
go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=
go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=

276
history.go Normal file
View File

@@ -0,0 +1,276 @@
package configkit
import (
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
const historyNodeLimit = 4_000_000
// History applications borrow immutable policy-validated input trees. Metadata
// records answer presence and origin queries; replay reconstructs value changes.
// Replay reconciles with canonical decoding before domain resolution so both
// contracts agree on the effective result without retaining a tree per field.
type historyApplication struct {
layer Layer
path Path
value *document.Node
origin Origin
}
func cloneHistoryApplications(values []historyApplication) []historyApplication {
if values == nil {
return nil
}
cloned := make([]historyApplication, len(values))
for index, value := range values {
cloned[index] = historyApplication{
layer: value.layer, path: value.path.clone(), value: value.value,
origin: cloneOrigin(value.origin),
}
}
return cloned
}
func applicationOperationPath(application historyApplication, query Path) (Path, bool) {
if !application.path.valid() || application.value == nil {
return Path{}, false
}
if application.path.String() != "" {
if pathsIntersect(application.path, query) {
return application.path.clone(), true
}
return Path{}, false
}
if len(query.segments) == 0 {
return Path{}, true
}
for length := 1; length <= len(query.segments); length++ {
prefix := Path{segments: clonePathSegments(query.segments[:length], 0)}
node, present := documentAtPath(application.value, prefix)
if !present {
return Path{}, false
}
if length == len(query.segments) || node.Kind != document.Mapping {
return prefix, true
}
}
return Path{}, false
}
func historyApplicationOrigin(application historyApplication, operationPath Path) Origin {
if application.origin.Layer != "" {
return cloneOrigin(application.origin)
}
origin := Origin{Layer: application.layer}
relative, ok := relativeHistoryPath(application.path, operationPath)
if !ok {
return origin
}
if node, present := documentAtPath(application.value, relative); present {
origin.Locations = []Location{publicLocation(node.Location)}
}
return origin
}
func relativeHistoryPath(root, path Path) (Path, bool) {
if !pathContains(root, path) {
return Path{}, false
}
return Path{segments: clonePathSegments(path.segments[len(root.segments):], 0)}, true
}
func pathsIntersect(left, right Path) bool {
return pathContains(left, right) || pathContains(right, left)
}
func applyHistoryApplication(root **document.Node, application historyApplication) {
if application.path.String() == "" {
mergeHistoryDocument(root, application.value)
return
}
mergeHistoryAtPath(root, application.path, application.value)
}
func mergeHistoryDocument(target **document.Node, incoming *document.Node) {
if incoming == nil {
return
}
if *target == nil || (*target).Kind != document.Mapping || incoming.Kind != document.Mapping {
*target = document.Clone(incoming)
return
}
for _, entry := range incoming.Mapping {
position := documentEntryIndex((*target).Mapping, entry.Key)
if position < 0 {
cloned := entry
cloned.Value = document.Clone(entry.Value)
(*target).Mapping = append((*target).Mapping, cloned)
continue
}
mergeHistoryDocument(&(*target).Mapping[position].Value, entry.Value)
}
}
func mergeHistoryAtPath(root **document.Node, path Path, incoming *document.Node) {
if root == nil || *root == nil || len(path.segments) == 0 {
mergeHistoryDocument(root, incoming)
return
}
current := *root
for index, segment := range path.segments {
if current.Kind != document.Mapping || segment.kind == pathSegmentIndex {
return
}
position := documentEntryIndex(current.Mapping, segment.key)
if index == len(path.segments)-1 {
if position < 0 {
current.Mapping = append(current.Mapping, document.Entry{Key: segment.key, Value: document.Clone(incoming)})
} else {
mergeHistoryDocument(&current.Mapping[position].Value, incoming)
}
return
}
if position < 0 {
child := &document.Node{Kind: document.Mapping}
current.Mapping = append(current.Mapping, document.Entry{Key: segment.key, Value: child})
current = child
} else {
current = current.Mapping[position].Value
}
}
}
// Resolution records describe one before/final transformation, not assignment
// order. A parent already carries its descendants' final values; merge overlapping
// intent into that event rather than replay a child assignment a second time.
func coalescedResolutionSnapshots(snapshots []resolutionSnapshot) []resolutionSnapshot {
groups := make([]resolutionSnapshot, 0, len(snapshots))
for _, snapshot := range snapshots {
merged := false
for i := range groups {
if !pathContains(groups[i].change.Path, snapshot.change.Path) {
continue
}
for _, reason := range snapshot.change.Reasons {
groups[i].change.Reasons = appendUniqueString(groups[i].change.Reasons, reason)
}
for _, dependency := range snapshot.change.Dependencies {
groups[i].change.Dependencies = appendUniquePath(groups[i].change.Dependencies, dependency)
}
if snapshot.change.Origin.Operation == ChangeNormalization {
groups[i].change.Origin.Operation = ChangeNormalization
}
merged = true
break
}
if !merged {
snapshot.change = cloneChange(snapshot.change)
groups = append(groups, snapshot)
}
}
return groups
}
func replaceHistoryPath(root **document.Node, path Path, replacement *document.Node) {
if root == nil || *root == nil {
return
}
if len(path.segments) == 0 {
*root = document.Clone(replacement)
return
}
current := *root
for index, segment := range path.segments {
last := index == len(path.segments)-1
switch current.Kind {
case document.Mapping:
if segment.kind == pathSegmentIndex {
return
}
position := documentEntryIndex(current.Mapping, segment.key)
if position < 0 {
if replacement == nil {
return
}
child := &document.Node{Kind: document.Mapping}
if last {
child = document.Clone(replacement)
}
current.Mapping = append(current.Mapping, document.Entry{Key: segment.key, Value: child})
if last {
return
}
current = child
continue
}
if last {
if replacement == nil {
current.Mapping = append(current.Mapping[:position], current.Mapping[position+1:]...)
} else {
current.Mapping[position].Value = document.Clone(replacement)
}
return
}
current = current.Mapping[position].Value
case document.Sequence:
position, ok := schemaIndex(segment)
if !ok || position >= len(current.Sequence) {
return
}
if last {
if replacement != nil {
current.Sequence[position] = document.Clone(replacement)
}
return
}
current = current.Sequence[position]
default:
return
}
}
}
func historyNodesWithinLimit(baseline, input, final *document.Node, applications []historyApplication, resolutions []resolutionSnapshot) bool {
return historyNodesWithin(historyNodeLimit, baseline, input, final, applications, resolutions)
}
func historyNodesWithin(limit int, baseline, input, final *document.Node, applications []historyApplication, resolutions []resolutionSnapshot) bool {
seen := make(map[*document.Node]bool)
count := 0
var visit func(*document.Node) bool
visit = func(node *document.Node) bool {
if node == nil || seen[node] {
return true
}
seen[node] = true
count++
if count > limit {
return false
}
for _, entry := range node.Mapping {
if !visit(entry.Value) {
return false
}
}
for _, child := range node.Sequence {
if !visit(child) {
return false
}
}
return true
}
if !visit(baseline) || !visit(input) || !visit(final) {
return false
}
for _, application := range applications {
if !visit(application.value) {
return false
}
}
for _, resolution := range resolutions {
if !visit(resolution.before) || !visit(resolution.after) {
return false
}
}
return true
}

50
import_boundary_test.go Normal file
View File

@@ -0,0 +1,50 @@
package configkit
import (
"go/parser"
"go/token"
"io/fs"
"path/filepath"
"strconv"
"strings"
"testing"
)
func TestDependencyImportBoundary(t *testing.T) {
t.Parallel()
err := filepath.WalkDir(".", func(path string, entry fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if entry.IsDir() {
if path == ".git" {
return filepath.SkipDir
}
return nil
}
if filepath.Ext(path) != ".go" {
return nil
}
file, err := parser.ParseFile(token.NewFileSet(), path, nil, parser.ImportsOnly)
if err != nil {
return err
}
for _, imported := range file.Imports {
importPath, err := strconv.Unquote(imported.Path.Value)
if err != nil {
return err
}
if strings.HasPrefix(importPath, "go.yaml.in/yaml/") && !strings.HasPrefix(filepath.ToSlash(path), "internal/yaml/") {
t.Errorf("%s imports private YAML dependency %s", path, importPath)
}
if strings.HasPrefix(importPath, "github.com/google/go-cmp/") && !strings.HasSuffix(path, "_test.go") {
t.Errorf("%s imports test-only dependency %s", path, importPath)
}
}
return nil
})
if err != nil {
t.Fatal(err)
}
}

324
imports.go Normal file
View File

@@ -0,0 +1,324 @@
package configkit
import (
"bytes"
"errors"
"io/fs"
"os"
"path"
"path/filepath"
"strconv"
"strings"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
yamladapter "gitea.maximumdirect.net/eric/ConfigKit/internal/yaml"
)
const (
maxImportDepth = 64
maxImportSources = 1024
maxGlobEntries = 1_000_000
)
type importLimits struct {
depth int
sources int
bytes int64
nodes int
entries int
}
func defaultImportLimits() importLimits {
return importLimits{
depth: maxImportDepth, sources: maxImportSources,
bytes: maxTotalBytes, nodes: maxDocumentNodes, entries: maxGlobEntries,
}
}
type importRoute struct {
canonical string
locations []Location
}
type importTraversal struct {
rootPath string
root *os.Root
limits importLimits
rule *VersionRule
documents []*document.Node
sources []SourceInfo
active map[string]int
visited map[string]importRoute
stack []importRoute
usedBytes int64
usedNodes int
usedEntries int
}
func loadImportedDocuments(source Source, context *FilesystemContext, main parsedSource, rule *VersionRule, limits importLimits) (*composedDocument, []SourceInfo, []Diagnostic) {
rootPath, basePath, mainPath, diagnostic := importFilesystem(source, context, main)
if diagnostic != nil {
return nil, sourceInfos(main.source), []Diagnostic{*diagnostic}
}
root, err := os.OpenRoot(rootPath)
if err != nil {
diagnostic := globFilesystemDiagnostic(err, "configuration root could not be opened", Location{})
diagnostic.Primary = nil
return nil, sourceInfos(main.source), []Diagnostic{diagnostic}
}
mainInfo := cloneSourceInfo(*main.source)
if mainPath != "" {
mainInfo.Path = mainPath
}
state := importTraversal{
rootPath: rootPath, root: root, limits: limits, rule: rule,
documents: make([]*document.Node, 0, 1),
sources: []SourceInfo{mainInfo},
active: make(map[string]int), visited: make(map[string]importRoute),
usedBytes: main.bytes, usedNodes: countInputNodes(main.document),
}
if diagnostic := state.checkInitialLimits(); diagnostic != nil {
_ = root.Close()
return nil, cloneSourceInfos(state.sources), []Diagnostic{*diagnostic}
}
mainIdentity := mainPath
if mainIdentity == "" {
mainIdentity = "<selected-source>"
}
mainRoute := importRoute{canonical: mainIdentity}
state.active[mainIdentity] = 0
state.visited[mainIdentity] = mainRoute
state.stack = append(state.stack, mainRoute)
body, declarations, diagnostics := splitImportDirectives(main.document)
if len(diagnostics) == 0 {
state.documents = append(state.documents, body)
diagnostics = state.walkDeclarations(basePath, declarations, 0)
}
closeErr := root.Close()
if len(diagnostics) != 0 {
return nil, cloneSourceInfos(state.sources), diagnostics
}
if closeErr != nil {
return nil, cloneSourceInfos(state.sources), []Diagnostic{importDiagnostic(CodeImportRead, "configuration root could not be closed", nil)}
}
composed, diagnostics := composeDocuments(state.documents...)
if composed != nil {
composed.inputBytes = state.usedBytes
composed.inputNodes = state.usedNodes
}
return composed, cloneSourceInfos(state.sources), diagnostics
}
func (s *importTraversal) checkInitialLimits() *Diagnostic {
if s.limits.sources < 1 {
value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the source-count limit", nil)
return &value
}
if s.usedBytes > s.limits.bytes {
value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate byte limit", nil)
return &value
}
if s.usedNodes > s.limits.nodes {
value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", nil)
return &value
}
return nil
}
func (s *importTraversal) walkDeclarations(basePath string, declarations []importDeclaration, depth int) []Diagnostic {
for _, declaration := range declarations {
if declaration.glob != "" {
matches, diagnostics := s.expandImportGlob(basePath, declaration)
if len(diagnostics) != 0 {
return diagnostics
}
if len(matches) == 0 {
if declaration.optional {
continue
}
location := declaration.location
return []Diagnostic{importDiagnostic(CodeImportMissing, "required import glob matched no files", &location)}
}
for _, match := range matches {
matched := declaration
matched.path = match
matched.glob = ""
if diagnostics := s.visitPath(basePath, matched, depth+1); len(diagnostics) != 0 {
return diagnostics
}
}
continue
}
if diagnostics := s.visitPath(basePath, declaration, depth+1); len(diagnostics) != 0 {
return diagnostics
}
}
return nil
}
func (s *importTraversal) visitPath(basePath string, declaration importDeclaration, depth int) []Diagnostic {
location := declaration.location
if depth > s.limits.depth {
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the active import-depth limit", &location)}
}
if !validImportPath(declaration.path) {
return []Diagnostic{importDiagnostic(CodeImportInvalid, "import path must be a nonempty slash-relative path without a network scheme", &location)}
}
candidate := filepath.Join(basePath, filepath.FromSlash(path.Clean(declaration.path)))
if !containedPath(s.rootPath, filepath.Clean(candidate)) {
return []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "import target is outside the configuration root", &location)}
}
canonical, err := canonicalExistingPath(candidate)
if err != nil {
if declaration.optional && errors.Is(err, fs.ErrNotExist) {
return nil
}
code := CodeImportInvalid
message := "import target could not be canonicalized"
if errors.Is(err, fs.ErrNotExist) {
code, message = CodeImportMissing, "required import target does not exist"
} else if errors.Is(err, fs.ErrPermission) {
message = "import target cannot be accessed"
}
return []Diagnostic{importDiagnostic(code, message, &location)}
}
if !containedPath(s.rootPath, canonical) {
return []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "import target resolves outside the configuration root", &location)}
}
currentRoute := s.currentRoute(location)
if activeIndex, ok := s.active[canonical]; ok {
cycle := append([]importRoute(nil), s.stack[activeIndex:]...)
return []Diagnostic{importCycleDiagnostic(canonical, location, cycle)}
}
if previous, ok := s.visited[canonical]; ok {
return []Diagnostic{importRepeatedDiagnostic(location, previous, currentRoute)}
}
if len(s.sources)+1 > s.limits.sources {
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the source-count limit", &location)}
}
relative, err := filepath.Rel(s.rootPath, canonical)
if err != nil || filepath.IsAbs(relative) || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
return []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "import target could not be addressed inside the configuration root", &location)}
}
sourceID := "source-" + strconv.Itoa(len(s.sources)+1)
info := SourceInfo{ID: sourceID, Label: declaration.path, Path: canonical, Layer: LayerBase, ImportedFrom: &location}
s.sources = append(s.sources, info)
data, skipped, diagnostic := s.readImport(relative, sourceID, declaration.optional)
if diagnostic != nil {
return []Diagnostic{*diagnostic}
}
if skipped {
s.sources = s.sources[:len(s.sources)-1]
return nil
}
root, issues := yamladapter.Parse(bytes.NewReader(data), sourceID, yamladapter.Limits{
Depth: maxDocumentDepth, Nodes: maxDocumentNodes,
})
if len(issues) != 0 {
return parserDiagnostics(sourceID, issues)
}
nodeCount := countInputNodes(root)
if s.usedNodes+nodeCount > s.limits.nodes {
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", &location)}
}
s.usedNodes += nodeCount
body, declarations, diagnostics := splitImportDirectives(root)
if len(diagnostics) != 0 {
return diagnostics
}
if versionNode := fragmentVersionClaim(body, s.rule); versionNode != nil {
versionLocation := publicLocation(versionNode.Location)
return []Diagnostic{{
Code: CodeImportVersion, Severity: SeverityError, Path: s.rule.Path.clone(),
Message: "imported fragments cannot contribute to the schema-version path", Primary: &versionLocation,
}}
}
route := importRoute{canonical: canonical, locations: currentRoute.locations}
s.documents = append(s.documents, body)
s.active[canonical] = len(s.stack)
s.visited[canonical] = route
s.stack = append(s.stack, route)
diagnostics = s.walkDeclarations(filepath.Dir(canonical), declarations, depth)
delete(s.active, canonical)
s.stack = s.stack[:len(s.stack)-1]
return diagnostics
}
func (s *importTraversal) readImport(relative, sourceID string, optional bool) ([]byte, bool, *Diagnostic) {
remaining := s.limits.bytes - s.usedBytes
limit := min(int64(maxSourceBytes), remaining)
if limit < 0 {
limit = 0
}
data, failure := readRegularFile(relative, s.root.Stat, s.root.Open, limit)
if failure != nil {
if optional && failure.missingTarget {
return nil, true, nil
}
value := sourceReadDiagnostic(failure, sourceID, CodeImportLimitExceeded, "configuration exceeds a source or aggregate byte limit")
return nil, false, &value
}
s.usedBytes += int64(len(data))
return data, false, nil
}
func (s *importTraversal) currentRoute(location Location) importRoute {
locations := make([]Location, 0, len(s.stack)+1)
for _, route := range s.stack {
if len(route.locations) != 0 {
locations = append(locations, route.locations[len(route.locations)-1])
}
}
locations = append(locations, location)
return importRoute{locations: locations}
}
func sourceInfos(source *SourceInfo) []SourceInfo {
if source == nil {
return nil
}
return []SourceInfo{cloneSourceInfo(*source)}
}
func importDiagnostic(code Code, message string, location *Location) Diagnostic {
return Diagnostic{Code: code, Severity: SeverityError, Message: message, Primary: cloneLocation(location)}
}
func importCycleDiagnostic(target string, location Location, cycle []importRoute) Diagnostic {
paths := make([]string, 0, len(cycle)+1)
related := make([]Location, 0, len(cycle))
for _, route := range cycle {
paths = append(paths, route.canonical)
if len(route.locations) != 0 {
related = append(related, route.locations[len(route.locations)-1])
}
}
paths = append(paths, target)
return Diagnostic{
Code: CodeImportCycle, Severity: SeverityError,
Message: "import cycle detected: " + strings.Join(paths, " -> "),
Primary: &location, Related: related,
}
}
func importRepeatedDiagnostic(location Location, previous, current importRoute) Diagnostic {
related := append([]Location(nil), previous.locations...)
if len(current.locations) > 1 {
related = append(related, current.locations[:len(current.locations)-1]...)
}
return Diagnostic{
Code: CodeImportRepeated, Severity: SeverityError,
Message: "configuration source is included by more than one import route",
Primary: &location, Related: related,
}
}

476
imports_test.go Normal file
View File

@@ -0,0 +1,476 @@
package configkit
import (
"errors"
"os"
"path/filepath"
"reflect"
"runtime"
"strconv"
"strings"
"testing"
)
type importTestConfig struct {
Settings struct {
Root string `yaml:"root"`
First string `yaml:"first"`
Nested string `yaml:"nested"`
Second string `yaml:"second"`
} `yaml:"settings"`
Version string `yaml:"version"`
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:
imports:
- path: first.yml
- path: second.yml
settings:
root: root
`)
firstPath := writeImportFile(t, directory, "first.yml", `configkit:
imports:
- path: nested/third.yml
settings:
first: first
`)
thirdPath := writeImportFile(t, directory, "nested/third.yml", "settings:\n nested: nested\n")
secondPath := writeImportFile(t, directory, "second.yml", "settings:\n second: second\n")
result, err := Load[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
if err != nil {
t.Fatal(err)
}
if got := result.Config.Settings; got.Root != "root" || got.First != "first" || got.Nested != "nested" || got.Second != "second" {
t.Fatalf("composed settings = %#v", got)
}
sources := result.Sources()
wantPaths := []string{canonicalTestPath(t, mainPath), canonicalTestPath(t, firstPath), canonicalTestPath(t, thirdPath), canonicalTestPath(t, secondPath)}
gotPaths := make([]string, len(sources))
for index, source := range sources {
gotPaths[index] = source.Path
if source.ID != "source-"+strconv.Itoa(index+1) {
t.Errorf("source %d ID = %q", index, source.ID)
}
if index > 0 && source.ImportedFrom == nil {
t.Errorf("source %d has no import location", index)
}
}
if !reflect.DeepEqual(gotPaths, wantPaths) {
t.Fatalf("source paths = %#v, want depth-first %#v", gotPaths, wantPaths)
}
info, ok := result.Metadata().Lookup(Keys("settings"))
if !ok || !info.Explicit || len(info.Origin.Locations) != 4 {
t.Fatalf("settings metadata = %#v, %v", info, ok)
}
}
func TestLoadRejectsImportConflictsAndFragmentVersions(t *testing.T) {
t.Run("all duplicate claimants", func(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
imports:
- path: one.yml
- path: two.yml
- path: three.yml
`)
for index, name := range []string{"one.yml", "two.yml", "three.yml"} {
writeImportFile(t, directory, name, "settings:\n root: "+string(rune('a'+index))+"\n")
}
loadError := requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeComposeConflict)
diagnostic := loadError.Diagnostics()[0]
if diagnostic.Path.String() != "/settings/root" || diagnostic.Primary == nil || len(diagnostic.Related) != 2 {
t.Fatalf("conflict diagnostic = %#v", diagnostic)
}
})
versionRule := &VersionRule{Path: Keys("settings", "version"), Check: func(Version, *Validation) error { return nil }}
for _, test := range []struct {
name string
fragment string
}{
{name: "exact", fragment: "settings:\n version: v2\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")
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]
if diagnostic.Path.String() != "/settings/version" || diagnostic.Primary == nil || diagnostic.Primary.SourceID != "source-2" {
t.Fatalf("version placement diagnostic = %#v", diagnostic)
}
})
}
}
func TestLoadImportDirectiveValidationAndTemporaryGlobGate(t *testing.T) {
tests := []struct {
name string
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},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", test.body)
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, test.code)
})
}
}
func TestLoadImportMissingContainmentAndRegularFileRules(t *testing.T) {
t.Run("optional missing", func(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
imports:
- path: missing.yml
optional: true
settings: {root: retained}
`)
result, err := Load[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
if err != nil || result.Config.Settings.Root != "retained" || len(result.Sources()) != 1 {
t.Fatalf("optional result = %#v, %v", result, err)
}
})
tests := []struct {
name string
path string
setup func(*testing.T, string)
code Code
}{
{name: "required missing", path: "missing.yml", code: CodeImportMissing},
{name: "parent traversal", path: "../outside.yml", setup: func(t *testing.T, directory string) {
writeImportFile(t, filepath.Dir(directory), "outside.yml", "settings: {root: outside}\n")
}, code: CodeImportOutsideRoot},
{name: "directory", path: "directory", setup: func(t *testing.T, directory string) {
if err := os.Mkdir(filepath.Join(directory, "directory"), 0o755); err != nil {
t.Fatal(err)
}
}, code: CodeSourceNotRegular},
{name: "absolute", path: "/tmp/configkit.yml", code: CodeImportInvalid},
{name: "network URL", path: "https://example.invalid/config.yml", code: CodeImportInvalid},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
directory := t.TempDir()
if test.setup != nil {
test.setup(t, directory)
}
mainPath := writeImportFile(t, directory, "main.yml", "configkit:\n imports:\n - path: \""+test.path+"\"\n")
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, test.code)
})
}
}
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")
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") {
t.Fatalf("cycle diagnostic = %#v", diagnostic)
}
})
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")
writeImportFile(t, directory, "shared.yml", "settings: {root: shared}\n")
loadError := requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportRepeated)
diagnostic := loadError.Diagnostics()[0]
if diagnostic.Primary == nil || len(diagnostic.Related) < 3 {
t.Fatalf("repeated-route diagnostic = %#v", diagnostic)
}
})
t.Run("symlink aliases", func(t *testing.T) {
if runtime.GOOS != "linux" && runtime.GOOS != "darwin" {
t.Skip("initial filesystem contract covers Linux and macOS")
}
directory := t.TempDir()
writeImportFile(t, directory, "target.yml", "settings: {root: target}\n")
if err := os.Symlink("target.yml", filepath.Join(directory, "first-link.yml")); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
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")
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportRepeated)
})
}
func TestLoadImportsCanonicalSymlinksAndKeepsHardLinksDistinct(t *testing.T) {
if runtime.GOOS != "linux" && runtime.GOOS != "darwin" {
t.Skip("initial filesystem contract covers Linux and macOS")
}
t.Run("contained absolute symlink", func(t *testing.T) {
directory := t.TempDir()
target := writeImportFile(t, directory, "target.yml", "settings: {first: linked}\n")
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")
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)
}
})
t.Run("symlink escape", func(t *testing.T) {
root := t.TempDir()
outside := t.TempDir()
target := writeImportFile(t, outside, "outside.yml", "settings: {root: outside}\n")
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")
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportOutsideRoot)
})
t.Run("hard links are path identities", func(t *testing.T) {
directory := t.TempDir()
one := writeImportFile(t, directory, "one.yml", "settings: {first: one}\n")
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")
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeComposeConflict)
})
t.Run("canonicalization failure", func(t *testing.T) {
directory := t.TempDir()
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")
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportInvalid)
})
}
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")
context := &FilesystemContext{BaseDir: directory, Root: directory}
for _, test := range []struct {
name string
source func() Source
}{
{name: "bytes URI label", source: func() Source { return Bytes("s3://bucket/main.yml", input) }},
{name: "reader URI label", source: func() Source { return Reader("https://example.invalid/main.yml", strings.NewReader(string(input))) }},
} {
t.Run(test.name, func(t *testing.T) {
result, err := Load[importTestConfig](test.source(), Options[importTestConfig]{Imports: true, Filesystem: context})
if err != nil || result.Config.Settings.First != "imported" {
t.Fatalf("explicit filesystem result = %#v, %v", result, err)
}
})
}
requireLoadCode(t, Bytes("memory.yml", input), Options[importTestConfig]{Imports: true}, CodeOptionsInvalid)
requireLoadCode(t, Bytes("memory.yml", input), Options[importTestConfig]{Imports: true, Filesystem: &FilesystemContext{BaseDir: "relative", Root: directory}}, CodeOptionsInvalid)
requireLoadCode(t, Bytes("memory.yml", input), Options[importTestConfig]{Imports: true, Filesystem: &FilesystemContext{BaseDir: directory, Root: t.TempDir()}}, CodeImportOutsideRoot)
}
func TestFileImportRootCanContainTheSelectedFileDirectory(t *testing.T) {
root := t.TempDir()
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},
})
if err != nil || result.Config.Settings.Root != "shared" {
t.Fatalf("explicit-root result = %#v, %v", result, err)
}
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true, Filesystem: &FilesystemContext{BaseDir: filepath.Dir(mainPath), Root: root}}, CodeOptionsInvalid)
}
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")
writeImportFile(t, directory, "broken.yml", "settings: [\n")
loadError := requireAnyLoadError(t, File(mainPath), Options[importTestConfig]{Imports: true})
diagnostics := loadError.Diagnostics()
if diagnostics[0].Code != CodeYAMLInvalid || diagnostics[0].Primary == nil || diagnostics[0].Primary.SourceID != "source-2" || len(loadError.Sources()) != 2 {
t.Fatalf("parse failure = diagnostics %#v, sources %#v", diagnostics, loadError.Sources())
}
})
t.Run("decode", func(t *testing.T) {
directory := t.TempDir()
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]
if diagnostic.Primary == nil || diagnostic.Primary.SourceID != "source-2" {
t.Fatalf("decode diagnostic = %#v", diagnostic)
}
})
}
func TestOptionalImportDoesNotSuppressUnsafeOrUnreadableTargets(t *testing.T) {
directory := t.TempDir()
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")
if err := os.Chmod(locked, 0); err != nil {
t.Skipf("cannot create unreadable fixture: %v", err)
}
defer func() { _ = os.Chmod(locked, 0o600) }()
if file, err := os.Open(locked); err == nil {
_ = 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")
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")
writeImportFile(t, directory, "one.yml", "settings: {first: one}\n")
writeImportFile(t, directory, "two.yml", "settings: {second: two}\n")
main := parseImportMain(t, mainPath)
for _, test := range []struct {
name string
limits importLimits
}{
{name: "source count", limits: importLimits{depth: 8, sources: 2, bytes: maxTotalBytes, nodes: maxDocumentNodes}},
{name: "aggregate bytes", limits: importLimits{depth: 8, sources: 8, bytes: main.bytes + 1, nodes: maxDocumentNodes}},
{name: "aggregate nodes", limits: importLimits{depth: 8, sources: 8, bytes: maxTotalBytes, nodes: countInputNodes(main.document) + 1}},
} {
t.Run(test.name, func(t *testing.T) {
_, _, diagnostics := loadImportedDocuments(File(mainPath), nil, main, nil, test.limits)
if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportLimitExceeded {
t.Fatalf("budget diagnostics = %#v", diagnostics)
}
})
}
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})
if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportLimitExceeded {
t.Fatalf("depth diagnostics = %#v", diagnostics)
}
}
func TestImportTraversalAcceptsExactPrivateBudgets(t *testing.T) {
directory := t.TempDir()
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)
data, err := os.ReadFile(importedPath)
if err != nil {
t.Fatal(err)
}
limits := importLimits{
depth: 1,
sources: 2,
bytes: main.bytes + int64(len(data)),
nodes: countInputNodes(main.document) + countInputNodes(imported.document),
}
composed, sources, diagnostics := loadImportedDocuments(File(mainPath), nil, main, nil, limits)
if len(diagnostics) != 0 || composed == nil || len(sources) != 2 {
t.Fatalf("exact-limit composition = (%#v, %#v, %#v)", composed, sources, diagnostics)
}
}
func writeImportFile(t *testing.T, root, relative, contents string) string {
t.Helper()
filename := filepath.Join(root, filepath.FromSlash(relative))
if err := os.MkdirAll(filepath.Dir(filename), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filename, []byte(contents), 0o600); err != nil {
t.Fatal(err)
}
return filename
}
func canonicalTestPath(t *testing.T, filename string) string {
t.Helper()
canonical, err := canonicalExistingPath(filename)
if err != nil {
t.Fatal(err)
}
return canonical
}
func requireLoadCode(t *testing.T, source Source, options Options[importTestConfig], code Code) *LoadError {
t.Helper()
loadError := requireAnyLoadError(t, source, options)
diagnostics := loadError.Diagnostics()
if len(diagnostics) == 0 || diagnostics[0].Code != code {
t.Fatalf("diagnostics = %#v, want %s", diagnostics, code)
}
return loadError
}
func requireAnyLoadError(t *testing.T, source Source, options Options[importTestConfig]) *LoadError {
t.Helper()
result, err := Load[importTestConfig](source, options)
if result != nil || err == nil {
t.Fatalf("result, error = %#v, %v", result, err)
}
var loadError *LoadError
if !errors.As(err, &loadError) {
t.Fatalf("error type = %T", err)
}
return loadError
}
func parseImportMain(t *testing.T, filename string) parsedSource {
t.Helper()
parsed, diagnostics := parseSource(File(filename), "source-1", defaultParseLimits())
if len(diagnostics) != 0 {
t.Fatalf("parse diagnostics = %#v", diagnostics)
}
return parsed
}

116
independence_test.go Normal file
View File

@@ -0,0 +1,116 @@
package configkit
import (
"bytes"
"errors"
"fmt"
"strings"
"sync"
"testing"
)
type independentText string
func (value independentText) MarshalText() ([]byte, error) {
return []byte(value), nil
}
func (value *independentText) UnmarshalText(text []byte) error {
*value = independentText(text)
return nil
}
type independentConfig struct {
Labels map[string]string `yaml:"labels"`
Items []string `yaml:"items"`
Custom independentText `yaml:"custom"`
}
func TestParallelLoadsShareRulesButNotMutableValues(t *testing.T) {
definition := Definition[independentConfig]{
Defaults: func() independentConfig {
return independentConfig{
Labels: map[string]string{"default": "kept"},
Items: []string{"default"},
Custom: "default",
}
},
}
const count = 32
results := make([]*Result[independentConfig], count)
errorsByIndex := make([]error, count)
var group sync.WaitGroup
for index := range count {
group.Add(1)
go func(index int) {
defer group.Done()
input := fmt.Sprintf("custom: value-%d\n", index)
results[index], errorsByIndex[index] = Load(Bytes("parallel.yml", []byte(input)), Options[independentConfig]{Definition: definition})
}(index)
}
group.Wait()
for index, err := range errorsByIndex {
if err != nil {
t.Fatalf("Load(%d) failed: %v", index, err)
}
}
results[0].Config.Labels["default"] = "mutated"
results[0].Config.Items[0] = "mutated"
results[0].Config.Custom = "mutated"
for index := 1; index < count; index++ {
if results[index].Config.Labels["default"] != "kept" || results[index].Config.Items[0] != "default" || results[index].Config.Custom != independentText(fmt.Sprintf("value-%d", index)) {
t.Fatalf("result %d shares mutable state: %#v", index, results[index].Config)
}
}
var effective bytes.Buffer
if err := results[0].WriteEffective(&effective); err != nil {
t.Fatal(err)
}
if strings.Contains(effective.String(), "mutated") || !strings.Contains(effective.String(), "default: kept") || !strings.Contains(effective.String(), "value-0") {
t.Fatalf("frozen effective snapshot = %q", effective.String())
}
}
func TestConcurrentLoadsOfCopiedReaderSourceHaveOneClaim(t *testing.T) {
source := Reader("shared", strings.NewReader("value: one\n"))
copied := source
type config struct {
Value string `yaml:"value"`
}
start := make(chan struct{})
errorsFound := make(chan error, 2)
var group sync.WaitGroup
for _, candidate := range []Source{source, copied} {
group.Add(1)
go func(candidate Source) {
defer group.Done()
<-start
_, err := Load[config](candidate)
errorsFound <- err
}(candidate)
}
close(start)
group.Wait()
close(errorsFound)
successes, used := 0, 0
for err := range errorsFound {
if err == nil {
successes++
continue
}
var loadError *LoadError
if errors.As(err, &loadError) {
diagnostics := loadError.Diagnostics()
if len(diagnostics) == 1 && diagnostics[0].Code == CodeSourceReaderUsed {
used++
}
}
}
if successes != 1 || used != 1 {
t.Fatalf("successes=%d reader-used=%d", successes, used)
}
}

View File

@@ -0,0 +1,65 @@
// Package document defines ConfigKit's parser-independent configuration tree.
package document
// Kind identifies a configuration node category.
type Kind uint8
const (
Mapping Kind = iota + 1
Sequence
String
Boolean
Number
Null
)
// Location identifies a source coordinate. Root depth and unavailable coordinates
// are represented independently by callers; line and column are one-based when set.
type Location struct {
SourceID string
Line int
Column int
}
// Entry is an ordered mapping entry with separate key and value locations.
type Entry struct {
Key string
KeyLocation Location
ValueLocation Location
Value *Node
}
// Node is ConfigKit's owned configuration representation. Number Text retains its
// exact validated decimal spelling; Boolean Text is "true" or "false".
type Node struct {
// Sensitive preserves field classification from concrete typed projections.
// It is metadata, not part of the configuration value or emitted YAML.
Sensitive bool
Kind Kind
Location Location
Text string
Mapping []Entry
Sequence []*Node
}
// Clone returns a detached copy of a node tree.
func Clone(node *Node) *Node {
if node == nil {
return nil
}
cloned := *node
if node.Mapping != nil {
cloned.Mapping = make([]Entry, len(node.Mapping))
for i, entry := range node.Mapping {
cloned.Mapping[i] = entry
cloned.Mapping[i].Value = Clone(entry.Value)
}
}
if node.Sequence != nil {
cloned.Sequence = make([]*Node, len(node.Sequence))
for i, child := range node.Sequence {
cloned.Sequence[i] = Clone(child)
}
}
return &cloned
}

View File

@@ -0,0 +1,61 @@
package yaml
import (
"bytes"
"reflect"
"testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
func FuzzParseDocument(f *testing.F) {
for _, seed := range []string{
"{}\n",
"service:\n enabled: true\n ports: [8080, 8081]\n",
"values: {empty: '', nothing: null, number: -1.5e2}\n",
"duplicate: one\nduplicate: two\n",
"alias: &value one\nother: *value\n",
} {
f.Add([]byte(seed))
}
limits := Limits{Depth: 64, Nodes: 4096, ParserDepth: 128}
f.Fuzz(func(t *testing.T, input []byte) {
if len(input) > 64<<10 {
return
}
parsed, issues := Parse(bytes.NewReader(input), "fuzz-input", limits)
if len(issues) != 0 {
return
}
var output bytes.Buffer
if err := Write(&output, parsed); err != nil {
t.Fatalf("Write() failed for accepted document: %v", err)
}
reparsed, issues := Parse(bytes.NewReader(output.Bytes()), "round-trip", limits)
if len(issues) != 0 {
t.Fatalf("rendered accepted document was rejected: %#v", issues)
}
clearLocations(parsed)
clearLocations(reparsed)
if !reflect.DeepEqual(parsed, reparsed) {
t.Fatalf("document changed across parse/write/parse: before=%#v after=%#v", parsed, reparsed)
}
})
}
func clearLocations(node *document.Node) {
if node == nil {
return
}
node.Location = document.Location{}
for index := range node.Mapping {
node.Mapping[index].KeyLocation = document.Location{}
node.Mapping[index].ValueLocation = document.Location{}
clearLocations(node.Mapping[index].Value)
}
for _, child := range node.Sequence {
clearLocations(child)
}
}

274
internal/yaml/parse.go Normal file
View File

@@ -0,0 +1,274 @@
// Package yaml adapts the private upstream parser to ConfigKit's document model.
package yaml
import (
"errors"
"io"
"regexp"
"strings"
"go.yaml.in/yaml/v4"
"go.yaml.in/yaml/v4/plugin/limit"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
const (
codeInvalid = "yaml.invalid"
codeEmpty = "yaml.empty"
codeMultipleDocuments = "yaml.multiple_documents"
codeRootType = "yaml.root_type"
codeDuplicateKey = "yaml.duplicate_key"
codeTag = "yaml.tag"
codeAnchor = "yaml.anchor"
codeAlias = "yaml.alias"
codeMergeKey = "yaml.merge_key"
codeScalar = "yaml.scalar"
codeDepthLimit = "yaml.depth_limit"
codeNodeLimit = "yaml.node_limit"
)
var (
jsonNumberPattern = regexp.MustCompile(`^-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?$`)
decimalLikePattern = regexp.MustCompile(`^[+-]?[0-9][0-9_]*(\.[0-9_]*)?([eE][+-]?[0-9_]*)?$`)
prefixedNumberPattern = regexp.MustCompile(`^[+-]?0[xXoO][0-9A-Fa-f_]+$`)
leadingDecimalPattern = regexp.MustCompile(`^[+-]?\.[0-9_]+$`)
nonFiniteNumberPattern = regexp.MustCompile(`(?i)^[+-]?\.(inf|nan)$`)
errParserDepth = errors.New("configkit yaml parser depth limit")
)
// Limits controls private parser and translated-tree resource bounds.
type Limits struct {
Depth int
Nodes int
ParserDepth int
}
// Issue is a parser-independent, value-free YAML diagnostic.
type Issue struct {
Code string
Message string
Line int
Column int
Related []document.Location
}
// Parse loads exactly one policy-valid mapping document.
func Parse(input io.Reader, sourceID string, limits Limits) (*document.Node, []Issue) {
parserDepth := limits.ParserDepth
if parserDepth <= 0 {
parserDepth = limits.Depth + 2
}
depthExceeded := false
plugin := limit.New(limit.DepthFunc(func(depth int, _ *limit.DepthContext) error {
if depth > parserDepth {
depthExceeded = true
return errParserDepth
}
return nil
}))
loader, err := yaml.NewLoader(input, yaml.WithPlugin(plugin))
if err != nil {
return nil, []Issue{{Code: codeInvalid, Message: "YAML parser could not be initialized"}}
}
var parsed yaml.Node
if err := loader.Load(&parsed); err != nil {
if errors.Is(err, io.EOF) {
return nil, []Issue{{Code: codeEmpty, Message: "configuration input is empty"}}
}
return nil, []Issue{parserIssue(err, depthExceeded)}
}
var trailing yaml.Node
if err := loader.Load(&trailing); err == nil {
return nil, []Issue{{Code: codeMultipleDocuments, Message: "configuration input must contain exactly one document", Line: trailing.Line, Column: trailing.Column}}
} else if !errors.Is(err, io.EOF) {
return nil, []Issue{parserIssue(err, depthExceeded)}
}
root := documentRoot(&parsed)
if root == nil || (root.Kind == yaml.ScalarNode && root.Value == "" && root.ShortTag() == "!!null") {
return nil, []Issue{{Code: codeEmpty, Message: "configuration document is empty", Line: parsed.Line, Column: parsed.Column}}
}
if root.Kind != yaml.MappingNode {
return nil, []Issue{{Code: codeRootType, Message: "configuration document root must be a mapping", Line: root.Line, Column: root.Column}}
}
state := translationState{sourceID: sourceID, limits: limits}
translated, issue := state.translate(root, 0)
if issue != nil {
return nil, []Issue{*issue}
}
return translated, nil
}
type translationState struct {
sourceID string
limits Limits
nodes int
}
func (s *translationState) translate(node *yaml.Node, depth int) (*document.Node, *Issue) {
if depth > s.limits.Depth {
return nil, nodeIssue(codeDepthLimit, "configuration exceeds the nesting depth limit", node)
}
if node.Style&yaml.TaggedStyle != 0 {
return nil, nodeIssue(codeTag, "explicit YAML tags are not supported", node)
}
if node.Anchor != "" {
return nil, nodeIssue(codeAnchor, "YAML anchors are not supported", node)
}
if node.Kind == yaml.AliasNode || node.Alias != nil {
return nil, nodeIssue(codeAlias, "YAML aliases are not supported", node)
}
if issue := s.chargeNode(node); issue != nil {
return nil, issue
}
location := document.Location{SourceID: s.sourceID, Line: node.Line, Column: node.Column}
switch node.Kind {
case yaml.MappingNode:
if len(node.Content)%2 != 0 {
return nil, nodeIssue(codeInvalid, "mapping structure is invalid", node)
}
result := &document.Node{Kind: document.Mapping, Location: location, Mapping: make([]document.Entry, 0, len(node.Content)/2)}
seen := make(map[string]document.Location, len(node.Content)/2)
for i := 0; i < len(node.Content); i += 2 {
keyNode, valueNode := node.Content[i], node.Content[i+1]
if issue := s.chargeNode(keyNode); issue != nil {
return nil, issue
}
key, issue := mappingKey(keyNode)
if issue != nil {
return nil, issue
}
if key == "<<" {
return nil, nodeIssue(codeMergeKey, "YAML merge keys are not supported", keyNode)
}
keyLocation := document.Location{SourceID: s.sourceID, Line: keyNode.Line, Column: keyNode.Column}
if previous, ok := seen[key]; ok {
issue := nodeIssue(codeDuplicateKey, "mapping key is defined more than once", keyNode)
issue.Related = []document.Location{previous}
return nil, issue
}
seen[key] = keyLocation
value, issue := s.translate(valueNode, depth+1)
if issue != nil {
return nil, issue
}
result.Mapping = append(result.Mapping, document.Entry{
Key: key,
KeyLocation: keyLocation,
ValueLocation: value.Location,
Value: value,
})
}
return result, nil
case yaml.SequenceNode:
result := &document.Node{Kind: document.Sequence, Location: location, Sequence: make([]*document.Node, 0, len(node.Content))}
for _, child := range node.Content {
translated, issue := s.translate(child, depth+1)
if issue != nil {
return nil, issue
}
result.Sequence = append(result.Sequence, translated)
}
return result, nil
case yaml.ScalarNode:
kind, text, issue := scalar(node)
if issue != nil {
return nil, issue
}
return &document.Node{Kind: kind, Location: location, Text: text}, nil
default:
return nil, nodeIssue(codeInvalid, "YAML node kind is not supported", node)
}
}
func (s *translationState) chargeNode(node *yaml.Node) *Issue {
s.nodes++
if s.limits.Nodes >= 0 && s.nodes > s.limits.Nodes {
return nodeIssue(codeNodeLimit, "configuration exceeds the node limit", node)
}
return nil
}
func mappingKey(node *yaml.Node) (string, *Issue) {
if node.Kind == yaml.AliasNode || node.Alias != nil {
return "", nodeIssue(codeAlias, "YAML aliases are not supported", node)
}
if node.Kind != yaml.ScalarNode {
return "", nodeIssue(codeScalar, "mapping keys must be strings", node)
}
if node.Style&yaml.TaggedStyle != 0 {
return "", nodeIssue(codeTag, "explicit YAML tags are not supported", node)
}
if node.Anchor != "" {
return "", nodeIssue(codeAnchor, "YAML anchors are not supported", node)
}
kind, text, issue := scalar(node)
if issue != nil {
return "", issue
}
if kind != document.String {
return "", nodeIssue(codeScalar, "mapping keys must be strings", node)
}
return text, nil
}
func scalar(node *yaml.Node) (document.Kind, string, *Issue) {
if node.Style&(yaml.DoubleQuotedStyle|yaml.SingleQuotedStyle|yaml.LiteralStyle|yaml.FoldedStyle) != 0 {
return document.String, node.Value, nil
}
value := node.Value
switch value {
case "":
if node.ShortTag() == "!!null" {
return document.Null, "", nil
}
case "null", "~":
return document.Null, "", nil
case "true", "false":
return document.Boolean, value, nil
}
lower := strings.ToLower(value)
if lower == "true" || lower == "false" || lower == "null" || lower == "yes" || lower == "no" || lower == "on" || lower == "off" {
return 0, "", nodeIssue(codeScalar, "plain scalar uses an unsupported reserved form", node)
}
if jsonNumberPattern.MatchString(value) {
return document.Number, value, nil
}
if decimalLikePattern.MatchString(value) || prefixedNumberPattern.MatchString(value) ||
leadingDecimalPattern.MatchString(value) || nonFiniteNumberPattern.MatchString(value) {
return 0, "", nodeIssue(codeScalar, "plain scalar uses an unsupported numeric form", node)
}
if tag := node.ShortTag(); tag == "!!int" || tag == "!!float" {
return 0, "", nodeIssue(codeScalar, "plain scalar uses an unsupported numeric form", node)
}
return document.String, value, nil
}
func documentRoot(node *yaml.Node) *yaml.Node {
if node.Kind == yaml.DocumentNode && len(node.Content) == 1 {
return node.Content[0]
}
return node
}
func parserIssue(err error, depthExceeded bool) Issue {
code := codeInvalid
if depthExceeded || errors.Is(err, errParserDepth) {
code = codeDepthLimit
}
issue := Issue{Code: code, Message: "configuration contains invalid YAML"}
var loadError *yaml.LoadError
if errors.As(err, &loadError) {
issue.Line = loadError.Mark.Line
issue.Column = loadError.Mark.Column
}
return issue
}
func nodeIssue(code, message string, node *yaml.Node) *Issue {
return &Issue{Code: code, Message: message, Line: node.Line, Column: node.Column}
}

200
internal/yaml/parse_test.go Normal file
View File

@@ -0,0 +1,200 @@
package yaml
import (
"io"
"strings"
"testing"
"go.yaml.in/yaml/v4"
"go.yaml.in/yaml/v4/plugin/limit"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
func TestParsePreservesDocumentValuesAndLocations(t *testing.T) {
t.Parallel()
input := "# comment\nwide: 9223372036854775808\nwhen: 2026-09-06\nquoted: \"yes\"\nflow: [true, null, ~, false]\nempty:\n"
root, issues := Parse(strings.NewReader(input), "source-1", generousLimits())
if len(issues) != 0 {
t.Fatalf("Parse() issues = %#v", issues)
}
if root.Kind != document.Mapping || len(root.Mapping) != 5 {
t.Fatalf("root = %#v", root)
}
if root.Mapping[0].Value.Kind != document.Number || root.Mapping[0].Value.Text != "9223372036854775808" {
t.Fatalf("wide number = %#v", root.Mapping[0].Value)
}
if root.Mapping[0].KeyLocation.Line != 2 || root.Mapping[0].ValueLocation.Column != 7 {
t.Fatalf("locations = %#v", root.Mapping[0])
}
if root.Mapping[1].Value.Kind != document.String || root.Mapping[1].Value.Text != "2026-09-06" {
t.Fatalf("timestamp-like text = %#v", root.Mapping[1].Value)
}
if root.Mapping[3].Value.Sequence[1].Kind != document.Null || root.Mapping[3].Value.Sequence[2].Kind != document.Null {
t.Fatalf("null spellings = %#v", root.Mapping[3].Value.Sequence)
}
if root.Mapping[4].Value.Kind != document.Null {
t.Fatalf("empty value = %#v", root.Mapping[4].Value)
}
}
func TestParsePolicyTable(t *testing.T) {
t.Parallel()
tests := []struct {
name string
input string
code string
}{
{name: "empty input", input: "", code: codeEmpty},
{name: "empty document", input: "---\n", code: codeEmpty},
{name: "sequence root", input: "- value\n", code: codeRootType},
{name: "scalar root", input: "value\n", code: codeRootType},
{name: "second document", input: "a: 1\n---\nb: 2\n", code: codeMultipleDocuments},
{name: "empty second document", input: "a: 1\n---\n", code: codeMultipleDocuments},
{name: "duplicate key", input: "a: 1\na: 2\n", code: codeDuplicateKey},
{name: "nested duplicate", input: "a: {b: 1, b: 2}\n", code: codeDuplicateKey},
{name: "explicit standard tag", input: "a: !!str value\n", code: codeTag},
{name: "explicit custom tag", input: "a: !custom value\n", code: codeTag},
{name: "anchor", input: "a: &name value\n", code: codeAnchor},
{name: "alias source anchor", input: "a: &name value\nb: *name\n", code: codeAnchor},
{name: "merge key", input: "<<: value\n", code: codeMergeKey},
{name: "quoted merge key", input: "\"<<\": value\n", code: codeMergeKey},
{name: "boolean key", input: "true: value\n", code: codeScalar},
{name: "number key", input: "1: value\n", code: codeScalar},
{name: "null key", input: "null: value\n", code: codeScalar},
{name: "legacy yes", input: "a: yes\n", code: codeScalar},
{name: "legacy mixed", input: "a: OfF\n", code: codeScalar},
{name: "reserved case", input: "a: TRUE\n", code: codeScalar},
{name: "hex", input: "a: 0x10\n", code: codeScalar},
{name: "octal", input: "a: 0o10\n", code: codeScalar},
{name: "separator", input: "a: 1_000\n", code: codeScalar},
{name: "leading plus", input: "a: +1\n", code: codeScalar},
{name: "leading zero", input: "a: 01\n", code: codeScalar},
{name: "negative leading zero", input: "a: -01\n", code: codeScalar},
{name: "leading dot", input: "a: .5\n", code: codeScalar},
{name: "trailing dot", input: "a: 1.\n", code: codeScalar},
{name: "incomplete exponent", input: "a: 1e\n", code: codeScalar},
{name: "incomplete signed exponent", input: "a: 1e+\n", code: codeScalar},
{name: "infinity", input: "a: .inf\n", code: codeScalar},
{name: "nan", input: "a: .NaN\n", code: codeScalar},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
_, issues := Parse(strings.NewReader(test.input), "source-1", generousLimits())
if len(issues) != 1 || issues[0].Code != test.code {
t.Fatalf("issues = %#v, want code %q", issues, test.code)
}
})
}
}
func TestTranslationRejectsAliasNodes(t *testing.T) {
t.Parallel()
node := &yaml.Node{Kind: yaml.AliasNode, Line: 2, Column: 3}
state := translationState{sourceID: "source-1", limits: generousLimits()}
if _, issue := state.translate(node, 0); issue == nil || issue.Code != codeAlias {
t.Fatalf("alias issue = %#v", issue)
}
}
func TestParseAcceptsSupportedSyntax(t *testing.T) {
t.Parallel()
inputs := []string{
"plain: text\nquoted_bool: 'ON'\nquoted_number: \"01\"\nblock: |\n text\n",
"{zero: 0, negative: -1, fraction: 1.25, exponent: 1e+3, boolean: false}\n",
"\"true\": value\n\"1\": number key\n\"null\": null key\n",
}
for _, input := range inputs {
if _, issues := Parse(strings.NewReader(input), "source-1", generousLimits()); len(issues) != 0 {
t.Errorf("Parse(%q) issues = %#v", input, issues)
}
}
}
func TestDuplicateKeyReportsBothLocations(t *testing.T) {
t.Parallel()
_, issues := Parse(strings.NewReader("same: first\nsame: second\n"), "source-1", generousLimits())
if len(issues) != 1 || issues[0].Code != codeDuplicateKey || issues[0].Line != 2 || len(issues[0].Related) != 1 || issues[0].Related[0].Line != 1 {
t.Fatalf("duplicate issues = %#v", issues)
}
}
func TestParseEnforcesTreeAndParserLimits(t *testing.T) {
t.Parallel()
for _, input := range []string{"a:\n b: value\n", "{a: {b: value}}\n"} {
if _, issues := Parse(strings.NewReader(input), "source-1", Limits{Depth: 2, Nodes: 20, ParserDepth: 20}); len(issues) != 0 {
t.Fatalf("depth boundary rejected for %q: %#v", input, issues)
}
if _, issues := Parse(strings.NewReader(input), "source-1", Limits{Depth: 1, Nodes: 20, ParserDepth: 20}); len(issues) != 1 || issues[0].Code != codeDepthLimit {
t.Fatalf("depth overflow for %q = %#v", input, issues)
}
}
if _, issues := Parse(strings.NewReader("a: 1\n"), "source-1", Limits{Depth: 2, Nodes: 3, ParserDepth: 20}); len(issues) != 0 {
t.Fatalf("node boundary rejected: %#v", issues)
}
if _, issues := Parse(strings.NewReader("a: 1\n"), "source-1", Limits{Depth: 2, Nodes: 2, ParserDepth: 20}); len(issues) != 1 || issues[0].Code != codeNodeLimit {
t.Fatalf("node overflow = %#v", issues)
}
if _, issues := Parse(strings.NewReader("a:\n b:\n c: value\n"), "source-1", Limits{Depth: 20, Nodes: 20, ParserDepth: 1}); len(issues) != 1 || issues[0].Code != codeDepthLimit {
t.Fatalf("parser depth overflow = %#v", issues)
}
}
func TestParserDepthCalibrationForBlockAndFlow(t *testing.T) {
t.Parallel()
inputs := []string{"a:\n b: value\n", "{a: {b: value}}\n"}
for _, input := range inputs {
maximum := observedParserDepth(t, input)
if maximum > 4 {
t.Fatalf("parser depth %d exceeds root-zero tree depth plus safety offset", maximum)
}
if _, issues := Parse(strings.NewReader(input), "source-1", Limits{Depth: 2, Nodes: 20, ParserDepth: maximum}); len(issues) != 0 {
t.Fatalf("parser boundary %d rejected %q: %#v", maximum, input, issues)
}
if _, issues := Parse(strings.NewReader(input), "source-1", Limits{Depth: 20, Nodes: 20, ParserDepth: maximum - 1}); len(issues) != 1 || issues[0].Code != codeDepthLimit {
t.Fatalf("parser overflow at %d for %q: %#v", maximum-1, input, issues)
}
}
}
func observedParserDepth(t *testing.T, input string) int {
t.Helper()
maximum := 0
depthPlugin := limit.New(limit.DepthFunc(func(depth int, _ *limit.DepthContext) error {
maximum = max(maximum, depth)
return nil
}))
loader, err := yaml.NewLoader(strings.NewReader(input), yaml.WithPlugin(depthPlugin))
if err != nil {
t.Fatal(err)
}
var node yaml.Node
if err := loader.Load(&node); err != nil && err != io.EOF {
t.Fatal(err)
}
return maximum
}
func TestMalformedParserDiagnosticIsSafe(t *testing.T) {
t.Parallel()
const canary = "CANARY_SECRET_VALUE"
_, issues := Parse(strings.NewReader("value: ["+canary+"\n"), "source-1", generousLimits())
if len(issues) != 1 || issues[0].Code != codeInvalid || issues[0].Line == 0 {
t.Fatalf("issues = %#v", issues)
}
if strings.Contains(issues[0].Message, canary) {
t.Fatal("parser diagnostic exposed input")
}
}
func generousLimits() Limits {
return Limits{Depth: 32, Nodes: 1_000, ParserDepth: 64}
}

103
internal/yaml/write.go Normal file
View File

@@ -0,0 +1,103 @@
package yaml
import (
"errors"
"io"
"strings"
"go.yaml.in/yaml/v4"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
var errWrite = errors.New("configkit: effective YAML rendering failed")
// Write serializes one owned document tree with fully specified stable formatting.
func Write(output io.Writer, root *document.Node) error {
if output == nil || root == nil {
return errWrite
}
dumper, err := yaml.NewDumper(strictWriter{output},
yaml.WithIndent(2),
yaml.WithCompactSeqIndent(false),
yaml.WithLineWidth(-1),
yaml.WithUnicode(true),
yaml.WithUniqueKeys(true),
yaml.WithCanonical(false),
yaml.WithLineBreak(yaml.LineBreakLN),
yaml.WithExplicitStart(false),
yaml.WithExplicitEnd(false),
yaml.WithFlowSimpleCollections(false),
yaml.WithQuotePreference(yaml.QuoteSingle),
yaml.WithAllDocuments(false),
)
if err != nil {
return errWrite
}
if err := dumper.Dump(adapterNode(root)); err != nil {
return safeWriteError(err)
}
if err := dumper.Close(); err != nil {
return safeWriteError(err)
}
return nil
}
func adapterNode(node *document.Node) *yaml.Node {
result := &yaml.Node{}
switch node.Kind {
case document.Mapping:
result.Kind, result.Tag = yaml.MappingNode, "!!map"
result.Content = make([]*yaml.Node, 0, len(node.Mapping)*2)
for _, entry := range node.Mapping {
result.Content = append(result.Content,
&yaml.Node{Kind: yaml.ScalarNode, Tag: "!!str", Value: entry.Key},
adapterNode(entry.Value),
)
}
case document.Sequence:
result.Kind, result.Tag = yaml.SequenceNode, "!!seq"
result.Content = make([]*yaml.Node, len(node.Sequence))
for index, child := range node.Sequence {
result.Content[index] = adapterNode(child)
}
case document.String:
result.Kind, result.Tag, result.Value = yaml.ScalarNode, "!!str", node.Text
if node.Text == "[REDACTED]" {
result.Style = yaml.SingleQuotedStyle
}
case document.Boolean:
result.Kind, result.Tag, result.Value = yaml.ScalarNode, "!!bool", node.Text
case document.Number:
result.Kind, result.Value = yaml.ScalarNode, node.Text
result.Tag = "!!int"
if strings.ContainsAny(node.Text, ".eE") {
result.Tag = "!!float"
}
case document.Null:
result.Kind, result.Tag, result.Value = yaml.ScalarNode, "!!null", "null"
}
return result
}
func safeWriteError(err error) error {
var dumpError *yaml.DumpError
if errors.As(err, &dumpError) && dumpError.Stage == yaml.WriterStage {
if cause := errors.Unwrap(dumpError); cause != nil {
return cause
}
}
return errWrite
}
type strictWriter struct {
output io.Writer
}
func (writer strictWriter) Write(data []byte) (int, error) {
written, err := writer.output.Write(data)
if err == nil && written != len(data) {
err = io.ErrShortWrite
}
return written, err
}

View File

@@ -0,0 +1,59 @@
package yaml
import (
"bytes"
"errors"
"io"
"testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
func TestWriteUsesStableExplicitFormatting(t *testing.T) {
t.Parallel()
root := &document.Node{Kind: document.Mapping, Mapping: []document.Entry{
{Key: "empty_map", Value: &document.Node{Kind: document.Mapping, Mapping: []document.Entry{}}},
{Key: "empty_sequence", Value: &document.Node{Kind: document.Sequence, Sequence: []*document.Node{}}},
{Key: "number", Value: &document.Node{Kind: document.Number, Text: "1.25e+2"}},
{Key: "redacted", Value: &document.Node{Kind: document.String, Text: "[REDACTED]"}},
{Key: "sequence", Value: &document.Node{Kind: document.Sequence, Sequence: []*document.Node{
{Kind: document.String, Text: "first"},
{Kind: document.String, Text: "true"},
}}},
{Key: "nil_value", Value: &document.Node{Kind: document.Null}},
}}
var output bytes.Buffer
if err := Write(&output, root); err != nil {
t.Fatal(err)
}
want := "empty_map: {}\nempty_sequence: []\nnumber: 1.25e+2\nredacted: '[REDACTED]'\nsequence:\n - first\n - 'true'\nnil_value: null\n"
if output.String() != want {
t.Fatalf("output:\n%s\nwant:\n%s", output.String(), want)
}
}
func TestWritePropagatesWriterErrorsAndShortWrites(t *testing.T) {
t.Parallel()
root := &document.Node{Kind: document.Mapping, Mapping: []document.Entry{{Key: "value", Value: &document.Node{Kind: document.String, Text: "safe"}}}}
want := errors.New("write failed")
if err := Write(failingWriter{err: want}, root); !errors.Is(err, want) {
t.Fatalf("writer error = %v", err)
}
if err := Write(shortOutput{}, root); !errors.Is(err, io.ErrShortWrite) {
t.Fatalf("short writer error = %v", err)
}
}
type failingWriter struct {
err error
}
func (writer failingWriter) Write(data []byte) (int, error) {
return min(2, len(data)), writer.err
}
type shortOutput struct{}
func (shortOutput) Write(data []byte) (int, error) {
return max(0, len(data)-1), nil
}

182
load.go Normal file
View File

@@ -0,0 +1,182 @@
package configkit
import (
"os"
"reflect"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
// Definition contains reusable application rules for a configuration type.
// Secret is a pure predicate that can add sensitivity to semantic paths.
// Definitions should be treated as read-only after being shared.
type Definition[T any] struct {
Defaults func() T
Resolve func(*T, *Resolution) error
Validate func(*T, *Validation)
Version *VersionRule
Secret func(Path) bool
}
// Options contains the application definition and per-load input selections.
// Imports enables explicit path and glob imports; Profile selects one fixed profile
// overlay; Overrides contains only values explicitly supplied by an application CLI.
type Options[T any] struct {
Definition Definition[T]
Imports bool
Profile string
Filesystem *FilesystemContext
Overrides []Override
}
// FilesystemContext supplies filesystem roots for imports and profiles.
type FilesystemContext struct {
BaseDir string
Root string
}
// Override describes one explicitly supplied application override. Value uses
// parser-neutral built-in scalars, nil, []any, or map[string]any; Flag is a safe
// display name and must not contain a raw argument or value.
type Override struct {
Path Path
Value any
Flag string
}
// Resolution exposes surviving-input presence and records audited application
// resolution changes. A resolver must not retain or concurrently use the context.
type Resolution struct {
state *resolutionState
}
// VersionRule declares the input path and application checker for a schema version.
type VersionRule struct {
Path Path
Check func(Version, *Validation) error
}
// Version preserves the absent, null, and string states of a schema-version input.
type Version struct {
Present bool
Null bool
Text string
}
// Result is a successful independently owned configuration load.
type Result[T any] struct {
Config T
metadata Metadata
sources []SourceInfo
diagnostics []Diagnostic
baseline *document.Node
input *document.Node
inputOrigins map[string]Origin
final *document.Node
resolution []resolutionSnapshot
applications []historyApplication
secrets map[string]bool
secretReferences map[string]string
}
// Metadata returns detached effective presence, origin, and history records.
func (r *Result[T]) Metadata() Metadata {
if r == nil {
return Metadata{}
}
return cloneMetadata(r.metadata)
}
// Sources returns a detached list of sources used by the load.
func (r *Result[T]) Sources() []SourceInfo {
if r == nil {
return nil
}
return cloneSourceInfos(r.sources)
}
// Diagnostics returns detached nonfatal diagnostics from the load.
func (r *Result[T]) Diagnostics() []Diagnostic {
if r == nil {
return nil
}
return cloneDiagnostics(r.diagnostics)
}
// Load parses and strictly decodes zero or one selected source, explicitly enabled
// imports, and one selected profile. It accepts at most one Options value and
// returns no result when any error is reported.
func Load[T any](source Source, supplied ...Options[T]) (*Result[T], error) {
return loadWithSecretLookup(source, os.LookupEnv, supplied...)
}
func loadWithSecretLookup[T any](source Source, lookup secretLookup, supplied ...Options[T]) (*Result[T], error) {
accumulator := diagnosticAccumulator{}
options, diagnostics := selectOptions(source, supplied)
accumulator.add(diagnostics...)
if len(diagnostics) != 0 {
return nil, accumulator.loadError()
}
input, diagnostics := assembleBaseInput(source, options)
accumulator.sources = input.sources
accumulator.add(diagnostics...)
if len(diagnostics) != 0 {
return nil, accumulator.loadError()
}
schema, schemaIssue := buildSchema(reflect.TypeFor[T]())
if schemaIssue != nil {
accumulator.add(diagnosticFromSchemaIssue(schemaIssue))
return nil, accumulator.loadError()
}
versionDiagnostics := checkVersion(options.Definition.Version, input.document)
accumulator.add(versionDiagnostics...)
if hasErrors(versionDiagnostics) {
return nil, accumulator.loadError()
}
input, diagnostics = applyInputLayers(source, options, input)
accumulator.sources = input.sources
accumulator.add(diagnostics...)
if len(diagnostics) != 0 {
return nil, accumulator.loadError()
}
decoded, diagnostics := decodeLoadInput(schema, options.Definition, input)
accumulator.add(diagnostics...)
if len(diagnostics) != 0 {
return nil, accumulator.loadError()
}
resolved, diagnostics := resolveLoadInput(schema, options.Definition, input, decoded)
accumulator.add(diagnostics...)
if hasErrors(diagnostics) {
return nil, accumulator.loadError()
}
if !historyNodesWithinLimit(decoded.baseline, decoded.final, resolved.final, resolved.applications, resolved.snapshots) {
accumulator.add(Diagnostic{Code: CodeExplainLimitExceeded, Severity: SeverityError, Message: "retained configuration history exceeds the node limit"})
return nil, accumulator.loadError()
}
// Detach resolver-borrowed values before reference mutation or validation.
configuration, ownershipIssue := detachLoadConfiguration(schema, resolved.configuration, cloneUnresolvedSecrets)
if ownershipIssue != nil {
accumulator.add(*ownershipIssue)
return nil, accumulator.loadError()
}
secretResolution := resolveSecretReferences(reflect.ValueOf(&configuration).Elem(), schema, input.document, lookup)
accumulator.add(secretResolution.diagnostics...)
if hasErrors(secretResolution.diagnostics) {
return nil, accumulator.loadError()
}
if options.Definition.Validate != nil {
diagnostics = runValidation(&configuration, options.Definition.Validate, schema, resolved.final, input.document, secretResolution.lookups)
accumulator.add(diagnostics...)
if hasErrors(diagnostics) {
return nil, accumulator.loadError()
}
}
resolved.configuration = configuration
// Publication detaches equal-valued validator replacements, preserving the
// validated secret state, then finalizes metadata and sensitivity.
return publishLoadResult(schema, options.Definition, resolved, secretResolution.references, &accumulator)
}

88
load_input.go Normal file
View File

@@ -0,0 +1,88 @@
package configkit
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
// assembledInput is the source/document handoff across schema and version checks.
type assembledInput struct {
parsed parsedSource
document *document.Node
composition *composedDocument
sources []SourceInfo
}
func assembleBaseInput[T any](source Source, options Options[T]) (assembledInput, []Diagnostic) {
parsed, diagnostics := parseSource(source, "source-1", defaultParseLimits())
input := assembledInput{parsed: parsed, document: parsed.document}
if len(diagnostics) != 0 {
input.sources = sourceInfos(parsed.source)
return input, diagnostics
}
if options.Imports {
input.composition, input.sources, diagnostics = loadImportedDocuments(source, options.Filesystem, parsed, options.Definition.Version, defaultImportLimits())
if len(diagnostics) != 0 {
return input, diagnostics
}
input.document = input.composition.root
} else {
input.sources = sourceInfos(parsed.source)
if options.Filesystem != nil && !activeProfile(options.Profile) {
if diagnostic := validateFilesystemContext(source, options.Filesystem, parsed); diagnostic != nil {
return input, []Diagnostic{*diagnostic}
}
}
}
if !options.Imports && input.document != nil {
if diagnostics := disabledDirectiveDiagnostics(input.document, Path{}); len(diagnostics) != 0 {
return input, diagnostics
}
}
return input, nil
}
func applyInputLayers[T any](source Source, options Options[T], input assembledInput) (assembledInput, []Diagnostic) {
if !activeProfile(options.Profile) && len(options.Overrides) == 0 {
return input, nil
}
composition, diagnostics := compositionForInput(input)
if len(diagnostics) != 0 {
return input, diagnostics
}
if activeProfile(options.Profile) {
composition, input.sources, diagnostics = loadProfileDocument(
source, options.Filesystem, input.parsed, composition, input.sources,
options.Profile, options.Definition.Version, defaultImportLimits(),
)
if len(diagnostics) != 0 {
return input, diagnostics
}
}
if len(options.Overrides) != 0 {
composition, input.sources, diagnostics = applyOverrides(
composition, input.sources, options.Overrides, options.Definition.Version, defaultImportLimits(),
)
if len(diagnostics) != 0 {
return input, diagnostics
}
}
input.composition = composition
input.document = composition.root
return input, nil
}
// compositionForInput initializes one handoff for profiles and CLI, including
// defaults-only CLI input. Raw source bytes and mapping keys retain their budgets.
func compositionForInput(input assembledInput) (*composedDocument, []Diagnostic) {
if input.composition != nil {
return input.composition, nil
}
if input.document == nil {
return &composedDocument{root: &document.Node{Kind: document.Mapping}, origins: make(map[string]Origin)}, nil
}
composition, diagnostics := composeDocuments(input.document)
if len(diagnostics) != 0 {
return nil, diagnostics
}
composition.inputBytes = input.parsed.bytes
composition.inputNodes = countInputNodes(input.document)
return composition, nil
}

289
load_metadata.go Normal file
View File

@@ -0,0 +1,289 @@
package configkit
import (
"slices"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
func metadataFromDecoded(decoded decodedConfiguration) Metadata {
metadata := Metadata{
fields: make(map[string]FieldInfo),
history: make(map[string][]Change),
}
baseline := make(map[string]*document.Node)
indexDocument(decoded.baseline, Path{}, baseline)
for pointer := range baseline {
path, err := ParsePath(pointer)
if err != nil {
continue
}
metadata.history[pointer] = append(metadata.history[pointer], Change{
Path: path, Origin: Origin{Layer: LayerDefault},
})
}
for pointer := range decoded.presence {
path, err := ParsePath(pointer)
if err != nil {
continue
}
origin := cloneOrigin(decoded.origins[pointer])
change := Change{Path: path, Origin: origin}
if _, ok := baseline[pointer]; ok {
change.Previous = []Origin{{Layer: LayerDefault}}
}
metadata.history[pointer] = append(metadata.history[pointer], change)
}
collectFieldInfo(decoded.final, Path{}, decoded, metadata.fields)
return metadata
}
func applyOverlayMetadata(metadata *Metadata, records []overlayRecord) {
baseline := make(map[string][]Change, len(metadata.history))
for pointer, history := range metadata.history {
for _, change := range history {
if change.Origin.Layer == LayerDefault {
baseline[pointer] = append(baseline[pointer], cloneChange(change))
}
}
}
events := make(map[string][]Change)
for _, record := range records {
exact := false
for _, displaced := range record.displaced {
pointer := displaced.path.String()
appendInputOriginEvent(events, baseline, pointer, displaced.path, displaced.origin)
appendInputChangeEvent(events, baseline, pointer, record.change)
if pointer == record.change.Path.String() {
exact = true
}
}
if !exact {
pointer := record.change.Path.String()
for _, previous := range record.change.Previous {
appendInputOriginEvent(events, baseline, pointer, record.change.Path, previous)
}
appendInputChangeEvent(events, baseline, pointer, record.change)
}
}
for pointer, info := range metadata.fields {
if !info.Explicit {
continue
}
path, err := ParsePath(pointer)
if err != nil {
continue
}
if history := events[pointer]; len(history) != 0 && history[len(history)-1].Origin.Layer == info.Origin.Layer && history[len(history)-1].Origin.Operation == info.Origin.Operation {
continue
}
appendInputOriginEvent(events, baseline, pointer, path, info.Origin)
}
metadata.history = make(map[string][]Change, len(baseline)+len(events))
for pointer, history := range baseline {
metadata.history[pointer] = cloneChanges(history)
}
for pointer, history := range events {
metadata.history[pointer] = append(metadata.history[pointer], cloneChanges(history)...)
}
}
func appendInputOriginEvent(events map[string][]Change, baseline map[string][]Change, pointer string, path Path, origin Origin) {
if origin.Layer == "" {
return
}
history := events[pointer]
if len(history) != 0 && originContainedBy(history[len(history)-1].Origin, origin) {
return
}
appendInputChangeEvent(events, baseline, pointer, Change{Path: path.clone(), Origin: cloneOrigin(origin)})
}
func appendInputChangeEvent(events map[string][]Change, baseline map[string][]Change, pointer string, value Change) {
change := cloneChange(value)
history := events[pointer]
if len(history) != 0 {
change.Previous = []Origin{cloneOrigin(history[len(history)-1].Origin)}
} else if defaults := baseline[pointer]; len(defaults) != 0 {
change.Previous = []Origin{cloneOrigin(defaults[len(defaults)-1].Origin)}
} else {
change.Previous = nil
}
events[pointer] = append(history, change)
}
func originContainedBy(candidate, container Origin) bool {
if candidate.Layer != container.Layer || candidate.Operation != container.Operation {
return false
}
for _, location := range candidate.Locations {
found := false
for _, available := range container.Locations {
if location == available {
found = true
break
}
}
if !found {
return false
}
}
return true
}
func cloneChanges(changes []Change) []Change {
cloned := make([]Change, len(changes))
for index, change := range changes {
cloned[index] = cloneChange(change)
}
return cloned
}
func collectFieldInfo(node *document.Node, path Path, decoded decodedConfiguration, fields map[string]FieldInfo) {
pointer := path.String()
_, explicit := decoded.presence[pointer]
fields[pointer] = FieldInfo{
Explicit: explicit,
Null: node.Kind == document.Null,
Origin: cloneOrigin(decoded.origins[pointer]),
}
switch node.Kind {
case document.Mapping:
for _, entry := range node.Mapping {
collectFieldInfo(entry.Value, path.Key(entry.Key), decoded, fields)
}
case document.Sequence:
for index, child := range node.Sequence {
collectFieldInfo(child, path.Index(index), decoded, fields)
}
}
}
func cloneMetadata(metadata Metadata) Metadata {
if metadata.fields == nil && metadata.history == nil {
return Metadata{}
}
cloned := Metadata{
fields: make(map[string]FieldInfo, len(metadata.fields)),
history: make(map[string][]Change, len(metadata.history)),
}
cloned.effectivePaths = make(map[string]Path, len(metadata.effectivePaths))
for pointer, path := range metadata.effectivePaths {
cloned.effectivePaths[pointer] = path.clone()
}
cloned.historicalPaths = make(map[string][]Path, len(metadata.historicalPaths))
for pointer, paths := range metadata.historicalPaths {
cloned.historicalPaths[pointer] = clonePaths(paths)
}
for path, info := range metadata.fields {
cloned.fields[path] = cloneFieldInfo(info)
}
for path, changes := range metadata.history {
clonedChanges := make([]Change, len(changes))
for index, change := range changes {
clonedChanges[index] = cloneChange(change)
}
cloned.history[path] = clonedChanges
}
return cloned
}
func applyResolutionMetadata(metadata *Metadata, resolved resolutionResult) {
for _, snapshot := range resolved.snapshots {
pointer := snapshot.change.Path.String()
change := cloneChange(snapshot.change)
if info, ok := metadata.fields[pointer]; ok {
change.Previous = []Origin{cloneOrigin(info.Origin)}
info.Origin = cloneOrigin(change.Origin)
metadata.fields[pointer] = info
}
metadata.history[pointer] = append(metadata.history[pointer], change)
}
appendResolutionDescendantHistory(metadata, resolved)
for _, delta := range resolved.deltas {
record := coveringResolutionSnapshot(resolved.snapshots, delta.path)
if record == nil {
continue
}
for pointer, info := range metadata.fields {
path, err := ParsePath(pointer)
if err != nil || !pathContains(delta.path, path) {
continue
}
info.Origin = cloneOrigin(record.change.Origin)
metadata.fields[pointer] = info
}
}
refreshResolutionFields(metadata.fields, resolved.final, resolved.snapshots)
}
func appendResolutionDescendantHistory(metadata *Metadata, resolved resolutionResult) {
finalPaths := make(map[string]*document.Node)
indexDocument(resolved.final, Path{}, finalPaths)
for _, delta := range resolved.deltas {
record := coveringResolutionSnapshot(resolved.snapshots, delta.path)
if record == nil {
continue
}
affected := make(map[string]bool)
for pointer := range metadata.history {
if path, err := ParsePath(pointer); err == nil && pathContains(delta.path, path) {
affected[pointer] = true
}
}
for pointer := range finalPaths {
if path, err := ParsePath(pointer); err == nil && pathContains(delta.path, path) {
affected[pointer] = true
}
}
pointers := make([]string, 0, len(affected))
for pointer := range affected {
if pointer != record.change.Path.String() {
pointers = append(pointers, pointer)
}
}
slices.Sort(pointers)
for _, pointer := range pointers {
change := cloneChange(record.change)
if info, ok := metadata.fields[pointer]; ok {
change.Previous = []Origin{cloneOrigin(info.Origin)}
}
metadata.history[pointer] = append(metadata.history[pointer], change)
}
}
}
func coveringResolutionSnapshot(snapshots []resolutionSnapshot, path Path) *resolutionSnapshot {
var covering *resolutionSnapshot
for index := range snapshots {
snapshot := &snapshots[index]
if pathContains(snapshot.change.Path, path) && (covering == nil || len(snapshot.change.Path.segments) > len(covering.change.Path.segments)) {
covering = snapshot
}
}
return covering
}
func refreshResolutionFields(fields map[string]FieldInfo, final *document.Node, snapshots []resolutionSnapshot) {
finalPaths := make(map[string]*document.Node)
indexDocument(final, Path{}, finalPaths)
for pointer := range fields {
if _, ok := finalPaths[pointer]; !ok {
delete(fields, pointer)
}
}
for pointer, node := range finalPaths {
if info, ok := fields[pointer]; ok {
info.Null = node.Kind == document.Null
fields[pointer] = info
continue
}
origin := Origin{Layer: LayerResolution}
if path, err := ParsePath(pointer); err == nil {
if covering := coveringResolutionSnapshot(snapshots, path); covering != nil {
origin = cloneOrigin(covering.change.Origin)
}
}
fields[pointer] = FieldInfo{Null: node.Kind == document.Null, Origin: origin}
}
}

115
load_result.go Normal file
View File

@@ -0,0 +1,115 @@
package configkit
import (
"reflect"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
// resolvedLoad carries typed phase outputs needed for validation and publication;
// it owns no source access, options, or long-lived schema state.
type resolvedLoad[T any] struct {
configuration T
decoded decodedConfiguration
metadata Metadata
final *document.Node
snapshots []resolutionSnapshot
applications []historyApplication
}
func decodeLoadInput[T any](schema *schemaDescriptor, definition Definition[T], input assembledInput) (decodedConfiguration, []Diagnostic) {
defaults := reflect.Zero(reflect.TypeFor[T]())
if definition.Defaults != nil {
defaults = reflect.ValueOf(definition.Defaults())
}
decoded, decodeIssues := decodeConfiguration(schema, defaults, input.document)
if len(decodeIssues) != 0 {
diagnostics := make([]Diagnostic, len(decodeIssues))
for i, issue := range decodeIssues {
diagnostics[i] = diagnosticFromDecodeIssue(issue)
}
return decodedConfiguration{}, diagnostics
}
if input.composition != nil {
applyCompositionOrigins(&decoded, input.composition.origins)
}
return decoded, nil
}
func resolveLoadInput[T any](schema *schemaDescriptor, definition Definition[T], input assembledInput, decoded decodedConfiguration) (resolvedLoad[T], []Diagnostic) {
var diagnostics []Diagnostic
configuration := decoded.value.Interface().(T)
metadata := metadataFromDecoded(decoded)
if input.composition != nil {
applyOverlayMetadata(&metadata, input.composition.overlays)
}
final := decoded.final
var resolutionSnapshots []resolutionSnapshot
if definition.Resolve != nil {
resolved := runResolution(&configuration, definition.Resolve, schema, decoded, input.document)
diagnostics = append(diagnostics, resolved.diagnostics...)
if hasErrors(resolved.diagnostics) {
return resolvedLoad[T]{}, diagnostics
}
final = resolved.final
resolutionSnapshots = resolved.snapshots
applyResolutionMetadata(&metadata, resolved)
}
applications := []historyApplication(nil)
if input.composition != nil {
applications = cloneHistoryApplications(input.composition.applications)
} else if input.document != nil {
applications = []historyApplication{{layer: LayerBase, value: input.document}}
}
return resolvedLoad[T]{configuration: configuration, decoded: decoded, metadata: metadata, final: final, snapshots: resolutionSnapshots, applications: applications}, diagnostics
}
func detachLoadConfiguration[T any](schema *schemaDescriptor, configuration T, mode secretCloneMode) (T, *Diagnostic) {
detached, issue := cloneConfigurationWithMode(schema, reflect.ValueOf(configuration), defaultProjectionLimits(), mode)
if issue != nil {
diagnostic := diagnosticFromDecodeIssue(decodeIssueFromProjection(issue))
var zero T
return zero, &diagnostic
}
return detached.Interface().(T), nil
}
func publishLoadResult[T any](schema *schemaDescriptor, definition Definition[T], resolved resolvedLoad[T], references map[string]string, accumulator *diagnosticAccumulator) (*Result[T], error) {
// A read-only validator can replace a value with an equal borrowed object.
// Detach without re-reading the environment or discarding validated state.
configuration, issue := detachLoadConfiguration(schema, resolved.configuration, cloneValidatedSecrets)
if issue != nil {
accumulator.add(*issue)
return nil, accumulator.loadError()
}
decoded, metadata := resolved.decoded, resolved.metadata
final, applications, resolutionSnapshots := resolved.final, resolved.applications, resolved.snapshots
finalizeMetadataPaths(&metadata, final, applications, decoded.baseline, decoded.final)
secrets := classifySecretPaths(schema, definition.Secret, metadata, final, applications, decoded.baseline, decoded.final)
return &Result[T]{
Config: configuration,
metadata: metadata,
sources: cloneSourceInfos(accumulator.sources),
diagnostics: canonicalDiagnostics(accumulator.diagnostics),
baseline: decoded.baseline,
input: decoded.final,
inputOrigins: cloneOriginIndex(decoded.origins),
final: final,
resolution: resolutionSnapshots,
applications: applications,
secrets: secrets,
secretReferences: cloneSecretReferences(references),
}, nil
}
func diagnosticFromSchemaIssue(issue *schemaIssue) Diagnostic {
return Diagnostic{Code: issue.code, Severity: SeverityError, Path: issue.path.clone(), Message: issue.message}
}
func diagnosticFromDecodeIssue(issue decodeIssue) Diagnostic {
return Diagnostic{
Code: issue.code, Severity: SeverityError, Path: issue.path.clone(), Message: issue.message,
Primary: documentLocationPointer(issue.location),
}
}

295
load_test.go Normal file
View File

@@ -0,0 +1,295 @@
package configkit
import (
"errors"
"os"
"path/filepath"
"reflect"
"strings"
"sync/atomic"
"testing"
)
func TestLoadSingleSourceKindsAndDefaultsOnly(t *testing.T) {
t.Parallel()
type config struct {
Name string `yaml:"name"`
}
input := []byte("name: configured\n")
temporary := t.TempDir()
filename := filepath.Join(temporary, "config.yml")
if err := os.WriteFile(filename, input, 0o600); err != nil {
t.Fatal(err)
}
tests := []struct {
name string
source func() Source
want string
wantSources int
}{
{name: "file", source: func() Source { return File(filename) }, want: "configured", wantSources: 1},
{name: "bytes", source: func() Source { return Bytes("memory.yml", input) }, want: "configured", wantSources: 1},
{name: "reader", source: func() Source { return Reader("reader.yml", strings.NewReader(string(input))) }, want: "configured", wantSources: 1},
{name: "no source", source: NoSource, want: "default", wantSources: 0},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
var defaultsCalls atomic.Int32
result, err := Load(test.source(), Options[config]{Definition: Definition[config]{
Defaults: func() config {
defaultsCalls.Add(1)
return config{Name: "default"}
},
}})
if err != nil {
t.Fatal(err)
}
if result.Config.Name != test.want || defaultsCalls.Load() != 1 || len(result.Sources()) != test.wantSources {
t.Fatalf("result = %#v, calls = %d, sources = %#v", result.Config, defaultsCalls.Load(), result.Sources())
}
})
}
}
func TestLoadVersionStatesWarningsAndSafeFailures(t *testing.T) {
t.Parallel()
type config struct {
SchemaVersion *string `yaml:"schema_version"`
}
versionPath := Keys("schema_version")
tests := []struct {
name string
source Source
want Version
wantLine int
wantErr Code
returnOnly bool
}{
{name: "absent", source: Bytes("absent.yml", []byte("{}\n")), want: Version{}, wantLine: 1},
{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: "returned error", source: Bytes("return.yml", []byte("{}\n")), wantErr: CodeVersionHookFailed, returnOnly: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
called := false
rule := &VersionRule{Path: versionPath, Check: func(version Version, validation *Validation) error {
called = true
if test.returnOnly {
return errors.New("CANARY_VERSION_ERROR")
}
if version != test.want {
t.Errorf("version = %#v, want %#v", version, test.want)
}
validation.Warning(versionPath, "review schema compatibility", Keys("missing_related"))
return nil
}}
result, err := Load(test.source, Options[config]{Definition: Definition[config]{Version: rule}})
if test.wantErr != "" {
if result != nil || err == nil {
t.Fatalf("result, err = %#v, %v", result, err)
}
var loadError *LoadError
if !errors.As(err, &loadError) {
t.Fatalf("error type = %T", err)
}
diagnostics := loadError.Diagnostics()
if len(diagnostics) != 1 || diagnostics[0].Code != test.wantErr || strings.Contains(diagnostics[0].Message, "CANARY") {
t.Fatalf("diagnostics = %#v", diagnostics)
}
if test.wantErr == CodeVersionType && called {
t.Fatal("checker ran for a non-string version")
}
return
}
if err != nil {
t.Fatal(err)
}
diagnostics := result.Diagnostics()
if len(diagnostics) != 1 || diagnostics[0].Code != CodeVersionWarning || diagnostics[0].Severity != SeverityWarning {
t.Fatalf("diagnostics = %#v", diagnostics)
}
if test.wantLine == 0 && (diagnostics[0].Primary != nil || len(diagnostics[0].Related) != 0) {
t.Fatalf("synthetic diagnostic locations = %#v", diagnostics[0])
}
if test.wantLine != 0 && (diagnostics[0].Primary == nil || diagnostics[0].Primary.Line != test.wantLine || len(diagnostics[0].Related) != 1) {
t.Fatalf("diagnostic locations = %#v", diagnostics[0])
}
})
}
}
func TestLoadVersionReportSuppressesReturnedErrorTextAndDefaults(t *testing.T) {
t.Parallel()
type config struct {
Name string `yaml:"name"`
}
var defaultsCalls atomic.Int32
result, err := Load(Bytes("version.yml", []byte("name: value\n")), Options[config]{Definition: Definition[config]{
Defaults: func() config {
defaultsCalls.Add(1)
return config{Name: "default"}
},
Version: &VersionRule{Path: Keys("schema_version"), Check: func(_ Version, validation *Validation) error {
validation.Error(Keys("schema_version"), "schema_version is required")
return errors.New("CANARY_RETURNED_ERROR")
}},
}})
if result != nil || err == nil || defaultsCalls.Load() != 0 {
t.Fatalf("result = %#v, err = %v, calls = %d", result, err, defaultsCalls.Load())
}
var loadError *LoadError
if !errors.As(err, &loadError) {
t.Fatalf("error type = %T", err)
}
diagnostics := loadError.Diagnostics()
if len(diagnostics) != 1 || diagnostics[0].Code != CodeVersionError || strings.Contains(diagnostics[0].Message, "CANARY") {
t.Fatalf("diagnostics = %#v", diagnostics)
}
}
func TestLoadMetadataReflectsDefaultsInputAndDetachedRecords(t *testing.T) {
t.Parallel()
type nested struct {
Kept string `yaml:"kept"`
Set string `yaml:"set"`
}
type config struct {
Nested nested `yaml:"nested"`
Nullable map[string]string `yaml:"nullable"`
}
result, err := Load(Bytes("metadata.yml", []byte("nested: {set: configured}\nnullable: null\n")), Options[config]{Definition: Definition[config]{
Defaults: func() config {
return config{Nested: nested{Kept: "default", Set: "default"}, Nullable: map[string]string{"key": "value"}}
},
}})
if err != nil {
t.Fatal(err)
}
metadata := result.Metadata()
kept, ok := metadata.Lookup(Keys("nested", "kept"))
if !ok || kept.Explicit || kept.Origin.Layer != LayerDefault {
t.Fatalf("kept = %#v, %v", kept, ok)
}
set, ok := metadata.Lookup(Keys("nested", "set"))
if !ok || !set.Explicit || set.Origin.Layer != LayerBase || len(set.Origin.Locations) != 1 {
t.Fatalf("set = %#v, %v", set, ok)
}
nullable, ok := metadata.Lookup(Keys("nullable"))
if !ok || !nullable.Explicit || !nullable.Null || nullable.Origin.Layer != LayerBase {
t.Fatalf("nullable = %#v, %v", nullable, ok)
}
if metadata.IsExplicit(Keys("nested", "kept")) || !metadata.IsExplicit(Keys("nested", "set")) {
t.Fatal("explicit presence disagrees with lookup")
}
if history := metadata.History(Keys("nested", "set")); len(history) != 2 || history[0].Origin.Layer != LayerDefault || history[1].Origin.Layer != LayerBase || len(history[1].Previous) != 1 {
t.Fatalf("history = %#v", history)
}
set.Origin.Locations[0].Line = 99
history := metadata.History(Keys("nested", "set"))
history[1].Origin.Locations[0].Line = 99
sources := result.Sources()
sources[0].Label = "changed"
diagnostics := result.Diagnostics()
if diagnostics != nil {
diagnostics[0].Message = "changed"
}
fresh, _ := result.Metadata().Lookup(Keys("nested", "set"))
if fresh.Origin.Locations[0].Line == 99 || result.Sources()[0].Label != "metadata.yml" || result.Diagnostics() != nil {
t.Fatal("result inspection aliases internal records")
}
}
func TestLoadRejectsUnavailableOptionsAndDisabledDirectives(t *testing.T) {
t.Parallel()
type config struct {
Name string `yaml:"name"`
}
validSource := func() Source { return Bytes("options.yml", []byte("name: value\n")) }
tests := []struct {
name string
source Source
options []Options[config]
code Code
}{
{name: "multiple options", source: validSource(), options: []Options[config]{{}, {}}, code: CodeOptionsInvalid},
{name: "imports without filesystem context", source: validSource(), options: []Options[config]{{Imports: true}}, code: CodeOptionsInvalid},
{name: "incomplete filesystem", source: validSource(), options: []Options[config]{{Filesystem: &FilesystemContext{}}}, code: CodeOptionsInvalid},
{name: "missing version checker", source: validSource(), options: []Options[config]{{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version")}}}}, code: CodeOptionsInvalid},
{name: "no-source filesystem", source: NoSource(), options: []Options[config]{{Filesystem: &FilesystemContext{}}}, code: CodeOptionsInvalid},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
result, err := Load(test.source, test.options...)
if result != nil || err == nil {
t.Fatalf("result, err = %#v, %v", result, err)
}
var loadError *LoadError
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != test.code {
t.Fatalf("error = %#v", err)
}
})
}
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)
}
var loadError *LoadError
if !errors.As(err, &loadError) {
t.Fatal(err)
}
diagnostic := loadError.Diagnostics()[0]
if diagnostic.Code != CodeImportDisabled || diagnostic.Path.String() != "/nested/configkit" || diagnostic.Primary == nil || diagnostic.Primary.Line != 2 {
t.Fatalf("diagnostic = %#v", diagnostic)
}
}
func TestLoadReturnsNilForSourceSchemaAndDecodeFailures(t *testing.T) {
t.Parallel()
type invalidSchema struct {
Channel chan int `yaml:"channel"`
}
type validConfig struct {
Known int `yaml:"known"`
}
tests := []struct {
name string
run func() (any, error)
code Code
}{
{name: "source", run: func() (any, error) { return Load[validConfig](File(filepath.Join(t.TempDir(), "missing.yml"))) }, code: CodeSourceMissing},
{name: "schema", run: func() (any, error) { return Load[invalidSchema](Bytes("schema.yml", []byte("{}\n"))) }, code: CodeSchemaUnsupportedType},
{name: "decode", run: func() (any, error) { return Load[validConfig](Bytes("decode.yml", []byte("unknown: 1\n"))) }, code: CodeDecodeUnknownField},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
result, err := test.run()
if !isNilInterface(result) || err == nil {
t.Fatalf("result, err = %#v, %v", result, err)
}
var loadError *LoadError
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != test.code {
t.Fatalf("error = %#v", err)
}
})
}
}
func isNilInterface(value any) bool {
if value == nil {
return true
}
reflected := reflect.ValueOf(value)
return reflected.Kind() == reflect.Pointer && reflected.IsNil()
}

110
metadata.go Normal file
View File

@@ -0,0 +1,110 @@
package configkit
import "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.
type Metadata struct {
effectivePaths map[string]Path
historicalPaths map[string][]Path
fields map[string]FieldInfo
history map[string][]Change
}
// Lookup returns information for an effective path. Unknown and invalid paths
// return false.
func (m Metadata) Lookup(path Path) (FieldInfo, bool) {
if !path.valid() || !matchesPathShape(path, m.effectivePaths[path.String()]) {
return FieldInfo{}, false
}
info, ok := m.fields[path.String()]
if !ok {
return FieldInfo{}, false
}
return cloneFieldInfo(info), true
}
// IsExplicit reports whether a surviving input contribution explicitly defined
// path.
func (m Metadata) IsExplicit(path Path) bool {
info, ok := m.Lookup(path)
return ok && info.Explicit
}
// History returns detached definition and resolution records for path. A record's
// Path can be an ancestor when that container operation replaced or removed path.
// Removed historical paths can have history even though Lookup returns false.
// Unknown and invalid paths return nil.
func (m Metadata) History(path Path) []Change {
if !m.hasHistoricalPath(path) {
return nil
}
history, ok := m.history[path.String()]
if !ok {
return nil
}
cloned := make([]Change, len(history))
for i, change := range history {
cloned[i] = cloneChange(change)
}
return cloned
}
// 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 {
if !query.valid() || query.String() != candidate.String() || len(query.segments) != len(candidate.segments) {
return false
}
for i, segment := range query.segments {
actual := candidate.segments[i]
if actual.kind == pathSegmentIndex {
index, ok := schemaIndex(segment)
if !ok || index != actual.index {
return false
}
} else if segment.kind == pathSegmentIndex {
return false
}
}
return true
}
func (m Metadata) hasHistoricalPath(path Path) bool {
if !path.valid() {
return false
}
for _, candidate := range m.historicalPaths[path.String()] {
if matchesPathShape(path, candidate) {
return true
}
}
return false
}
func finalizeMetadataPaths(m *Metadata, final *document.Node, applications []historyApplication, earlier ...*document.Node) {
m.effectivePaths = make(map[string]Path)
m.historicalPaths = make(map[string][]Path)
walkDocument(final, Path{}, func(path Path, _ *document.Node) { m.effectivePaths[path.String()] = path.clone() })
add := func(path Path, _ *document.Node) {
pointer := path.String()
for _, prior := range m.historicalPaths[pointer] {
if matchesPathShape(path, prior) {
return
}
}
m.historicalPaths[pointer] = append(m.historicalPaths[pointer], path.clone())
}
walkDocument(final, Path{}, add)
for _, tree := range earlier {
walkDocument(tree, Path{}, add)
}
for _, application := range applications {
// Overrides have mapping-only prefixes; parsed tokens are keys in this layer.
prefix := Path{}
for _, segment := range application.path.segments {
prefix = prefix.Key(segment.key)
}
walkDocument(application.value, prefix, add)
}
}

105
metadata_test.go Normal file
View File

@@ -0,0 +1,105 @@
package configkit
import (
"bytes"
"testing"
)
func TestMetadataQueriesReturnDetachedRecords(t *testing.T) {
t.Parallel()
path := Keys("service", "workers")
metadata := Metadata{
effectivePaths: map[string]Path{path.String(): path},
historicalPaths: map[string][]Path{path.String(): {path}},
fields: map[string]FieldInfo{
path.String(): {
Explicit: true,
Origin: Origin{
Layer: LayerBase,
Locations: []Location{{SourceID: "source-1", Line: 2, Column: 3}},
},
},
},
history: map[string][]Change{
path.String(): {{
Path: path,
Origin: Origin{Layer: LayerResolution, Locations: []Location{{SourceID: "source-1"}}},
Previous: []Origin{{Layer: LayerDefault, Locations: []Location{{SourceID: "default"}}}},
Reasons: []string{"bounded worker count"},
Dependencies: []Path{Keys("limits")},
}},
},
}
info, ok := metadata.Lookup(path)
if !ok || !info.Explicit || !metadata.IsExplicit(path) {
t.Fatalf("Lookup() = %#v, %v", info, ok)
}
info.Origin.Locations[0].SourceID = "mutated"
again, _ := metadata.Lookup(path)
if again.Origin.Locations[0].SourceID != "source-1" {
t.Fatal("Lookup returned aliased locations")
}
history := metadata.History(path)
history[0].Reasons[0] = "mutated"
history[0].Previous[0].Locations[0].SourceID = "mutated"
history[0].Dependencies[0].segments[0].key = "mutated"
againHistory := metadata.History(path)
if againHistory[0].Reasons[0] != "bounded worker count" ||
againHistory[0].Previous[0].Locations[0].SourceID != "default" ||
againHistory[0].Dependencies[0].String() != "/limits" {
t.Fatal("History returned aliased nested records")
}
missing := Keys("missing")
if _, ok := metadata.Lookup(missing); ok || metadata.IsExplicit(missing) || metadata.History(missing) != nil {
t.Fatal("unknown path produced metadata")
}
if _, ok := metadata.Lookup(Path{}.Index(-1)); ok {
t.Fatal("invalid path produced metadata")
}
}
func TestQueriesRespectActualHistoricalAndEffectiveContainers(t *testing.T) {
result, err := Load[map[string]any](Bytes("base", []byte("items: [1]\nm: {'0': 2}\nx: 3\nretired: {child: 4}\n")), Options[map[string]any]{Overrides: []Override{{Path: Keys("retired"), Value: "removed", Flag: "--retired"}}})
if err != nil {
t.Fatal(err)
}
for _, path := range []Path{Keys("items", "0"), Keys("m").Index(0), Keys("x", "missing"), Keys("items").Index(8)} {
if _, ok := result.Metadata().Lookup(path); ok {
t.Fatalf("invalid lookup %s", path)
}
if result.Metadata().History(path) != nil {
t.Fatalf("invalid history %s", path)
}
if _, ok := result.Explain(path); ok {
t.Fatalf("invalid explanation %s", path)
}
var out bytes.Buffer
if err := result.WriteExplanation(&out, path); err == nil || out.Len() != 0 {
t.Fatalf("invalid path wrote output: %s", path)
}
}
for _, pointer := range []string{"/items/0", "/m/0"} {
path, _ := ParsePath(pointer)
if _, ok := result.Metadata().Lookup(path); !ok {
t.Fatal("parsed path lost lookup")
}
if _, ok := result.Explain(path); !ok {
t.Fatal("parsed path lost explanation")
}
}
retired := Keys("retired", "child")
if _, ok := result.Metadata().Lookup(retired); ok {
t.Fatal("removed path remains effective")
}
if len(result.Metadata().History(retired)) == 0 {
t.Fatal("removed history missing")
}
x, ok := result.Explain(retired)
if !ok || x.Effective.Present || len(x.History) == 0 {
t.Fatal("removed explanation missing")
}
}

41
options.go Normal file
View File

@@ -0,0 +1,41 @@
package configkit
func selectOptions[T any](source Source, supplied []Options[T]) (Options[T], []Diagnostic) {
if len(supplied) > 1 {
return Options[T]{}, []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "Load accepts at most one options value")}
}
var options Options[T]
if len(supplied) == 1 {
options = supplied[0]
}
var diagnostics []Diagnostic
definition := options.Definition
if !validProfileName(options.Profile) {
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "profile name must contain only ASCII letters, digits, underscore, or hyphen"))
}
if options.Filesystem != nil {
diagnostics = append(diagnostics, filesystemOptionDiagnostics(source, options.Filesystem)...)
}
if source.kind == sourceNone && options.Imports {
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "NoSource cannot enable imports"))
}
if source.kind == sourceNone && options.Profile != "" && options.Profile != "default" {
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "NoSource cannot select an active profile"))
}
if options.Filesystem == nil && (source.kind == sourceBytes || source.kind == sourceReader) && (options.Imports || activeProfile(options.Profile)) {
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "byte and reader imports or profiles require filesystem context"))
}
if definition.Version != nil {
if !definition.Version.Path.valid() {
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "schema-version path is invalid"))
}
if definition.Version.Check == nil {
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "schema-version rule requires a checker"))
}
}
return options, canonicalDiagnostics(diagnostics)
}
func optionDiagnostic(code Code, message string) Diagnostic {
return Diagnostic{Code: code, Severity: SeverityError, Message: message}
}

161
overlay.go Normal file
View File

@@ -0,0 +1,161 @@
package configkit
import (
"slices"
"strings"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
type overlayRecord struct {
change Change
displaced []displacedDefinition
}
type displacedDefinition struct {
path Path
origin Origin
}
func overlayDocuments(base *composedDocument, overlay *document.Node) (*composedDocument, []Diagnostic) {
if base == nil || base.root == nil || base.root.Kind != document.Mapping || overlay == nil || overlay.Kind != document.Mapping {
return nil, []Diagnostic{{
Code: CodeProfileInvalid, Severity: SeverityError,
Message: "profile overlay requires mapping document roots",
}}
}
result := cloneComposedDocument(base)
mergeOverlayOrigin(result.origins, Path{}, overlay.Location, LayerProfile)
mergeOverlayMappings(result, result.root, overlay, Path{}, LayerProfile, true)
result.applications = append(result.applications, historyApplication{layer: LayerProfile, value: overlay})
return result, nil
}
func mergeOverlayMappings(result *composedDocument, target, overlay *document.Node, current Path, layer Layer, recordReplacements bool) {
for _, incoming := range overlay.Mapping {
path := current.Key(incoming.Key)
index := documentEntryIndex(target.Mapping, incoming.Key)
if index < 0 {
cloned := incoming
cloned.Value = document.Clone(incoming.Value)
target.Mapping = append(target.Mapping, cloned)
collectOverlayOrigins(result.origins, incoming.Value, path, layer)
continue
}
existing := target.Mapping[index].Value
if existing.Kind == document.Mapping && incoming.Value.Kind == document.Mapping {
mergeOverlayOrigin(result.origins, path, incoming.Value.Location, layer)
mergeOverlayMappings(result, existing, incoming.Value, path, layer, recordReplacements)
continue
}
displaced := displacedOrigins(existing, result.origins, path)
previous := cloneOrigin(result.origins[path.String()])
deleteOriginSubtree(result.origins, path)
target.Mapping[index] = incoming
target.Mapping[index].Value = document.Clone(incoming.Value)
collectOverlayOrigins(result.origins, incoming.Value, path, layer)
if recordReplacements {
result.overlays = append(result.overlays, overlayRecord{
change: Change{
Path: path.clone(), Origin: overlayOrigin(layer, incoming.Value.Location),
Previous: previousOrigins(previous),
},
displaced: displaced,
})
}
}
}
func collectOverlayOrigins(origins map[string]Origin, node *document.Node, path Path, layer Layer) {
origins[path.String()] = overlayOrigin(layer, node.Location)
switch node.Kind {
case document.Mapping:
for _, entry := range node.Mapping {
collectOverlayOrigins(origins, entry.Value, path.Key(entry.Key), layer)
}
case document.Sequence:
for index, child := range node.Sequence {
collectOverlayOrigins(origins, child, path.Index(index), layer)
}
}
}
func mergeOverlayOrigin(origins map[string]Origin, path Path, location document.Location, layer Layer) {
pointer := path.String()
origin := cloneOrigin(origins[pointer])
origin.Layer = layer
origin.Locations = append(origin.Locations, publicLocation(location))
origins[pointer] = origin
}
func overlayOrigin(layer Layer, location document.Location) Origin {
return Origin{Layer: layer, Locations: []Location{publicLocation(location)}}
}
func previousOrigins(origin Origin) []Origin {
if origin.Layer == "" {
return nil
}
return []Origin{origin}
}
func displacedOrigins(node *document.Node, origins map[string]Origin, path Path) []displacedDefinition {
var displaced []displacedDefinition
collectDisplacedOrigins(&displaced, node, origins, path, Origin{})
slices.SortFunc(displaced, func(left, right displacedDefinition) int {
return strings.Compare(left.path.String(), right.path.String())
})
return displaced
}
func collectDisplacedOrigins(displaced *[]displacedDefinition, node *document.Node, origins map[string]Origin, path Path, inherited Origin) {
origin, ok := origins[path.String()]
if !ok {
origin = cloneOrigin(inherited)
origin.Locations = []Location{publicLocation(node.Location)}
}
*displaced = append(*displaced, displacedDefinition{path: path.clone(), origin: cloneOrigin(origin)})
switch node.Kind {
case document.Mapping:
for _, entry := range node.Mapping {
collectDisplacedOrigins(displaced, entry.Value, origins, path.Key(entry.Key), origin)
}
case document.Sequence:
for index, child := range node.Sequence {
collectDisplacedOrigins(displaced, child, origins, path.Index(index), origin)
}
}
}
func deleteOriginSubtree(origins map[string]Origin, path Path) {
prefix := path.String()
for pointer := range origins {
if pointer == prefix || strings.HasPrefix(pointer, prefix+"/") {
delete(origins, pointer)
}
}
}
func cloneOriginIndex(origins map[string]Origin) map[string]Origin {
cloned := make(map[string]Origin, len(origins))
for pointer, origin := range origins {
cloned[pointer] = cloneOrigin(origin)
}
return cloned
}
func cloneOverlayRecords(records []overlayRecord) []overlayRecord {
cloned := make([]overlayRecord, len(records))
for index, record := range records {
cloned[index].change = cloneChange(record.change)
cloned[index].displaced = make([]displacedDefinition, len(record.displaced))
for displacedIndex, displaced := range record.displaced {
cloned[index].displaced[displacedIndex] = displacedDefinition{
path: displaced.path.clone(), origin: cloneOrigin(displaced.origin),
}
}
}
return cloned
}

78
overlay_test.go Normal file
View File

@@ -0,0 +1,78 @@
package configkit
import (
"reflect"
"testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
func TestOverlayDocumentsRecursesMappingsAndReplacesAtomicSubtrees(t *testing.T) {
baseRoot := parseCompositionDocument(t, "source-1", `shared:
kept: base
changed: old
sequence: [one, two]
nullable: value
to_map: scalar
to_scalar: {child: old}
`)
base, diagnostics := composeDocuments(baseRoot)
if len(diagnostics) != 0 {
t.Fatal(diagnostics)
}
overlay := parseCompositionDocument(t, "source-2", `shared:
changed: new
added: profile
sequence: []
nullable: null
to_map: {child: new}
to_scalar: scalar
`)
baseBefore := document.Clone(base.root)
overlayBefore := document.Clone(overlay)
result, diagnostics := overlayDocuments(base, overlay)
if len(diagnostics) != 0 {
t.Fatalf("overlayDocuments() diagnostics = %#v", diagnostics)
}
checks := []struct {
path Path
kind document.Kind
text string
}{
{path: Keys("shared", "kept"), kind: document.String, text: "base"},
{path: Keys("shared", "changed"), kind: document.String, text: "new"},
{path: Keys("shared", "added"), kind: document.String, text: "profile"},
{path: Keys("sequence"), kind: document.Sequence},
{path: Keys("nullable"), kind: document.Null},
{path: Keys("to_map"), kind: document.Mapping},
{path: Keys("to_map", "child"), kind: document.String, text: "new"},
{path: Keys("to_scalar"), kind: document.String, text: "scalar"},
}
for _, check := range checks {
node, ok := documentAtPath(result.root, check.path)
if !ok || node.Kind != check.kind || node.Text != check.text {
t.Errorf("node %s = %#v, %v", check.path.String(), node, ok)
}
}
if _, exists := result.origins["/sequence/0"]; exists {
t.Fatal("replaced empty sequence retained a stale index origin")
}
if _, exists := result.origins["/to_scalar/child"]; exists {
t.Fatal("parent replacement retained a stale child origin")
}
shared := result.origins["/shared"]
if shared.Layer != LayerProfile || len(shared.Locations) != 2 {
t.Fatalf("shared mapping origin = %#v", shared)
}
if len(result.overlays) != 5 {
t.Fatalf("overlay records = %#v, want five replacements", result.overlays)
}
if !reflect.DeepEqual(base.root, baseBefore) || !reflect.DeepEqual(overlay, overlayBefore) {
t.Fatal("overlay mutated an input document")
}
result.root.Mapping[0].Value.Mapping[0].Value.Text = "changed"
if !reflect.DeepEqual(base.root, baseBefore) || !reflect.DeepEqual(overlay, overlayBefore) {
t.Fatal("overlay result aliases an input document")
}
}

223
override.go Normal file
View File

@@ -0,0 +1,223 @@
package configkit
import (
"strconv"
"strings"
"unicode"
"unicode/utf8"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
const maxOverrides = 1024
type preparedOverride struct {
path Path
value *document.Node
source SourceInfo
bytes int64
nodes int
}
func prepareOverrides(base *composedDocument, sources []SourceInfo, overrides []Override, rule *VersionRule, limits importLimits) ([]preparedOverride, []SourceInfo, []Diagnostic) {
if len(overrides) > maxOverrides || len(sources)+len(overrides) > limits.sources {
return nil, cloneSourceInfos(sources), []Diagnostic{{
Code: CodeOverrideLimitExceeded, Severity: SeverityError,
Message: "configuration overrides exceed the record or source-count limit",
}}
}
var diagnostics []Diagnostic
for index, override := range overrides {
if !validOverrideLabel(override.Flag) {
diagnostics = append(diagnostics, Diagnostic{
Code: CodeOverrideInvalidLabel, Severity: SeverityError,
Message: "override record " + strconv.Itoa(index+1) + " requires a safe nonempty flag name",
})
}
}
if len(diagnostics) != 0 {
return nil, cloneSourceInfos(sources), canonicalDiagnostics(diagnostics)
}
prepared := make([]preparedOverride, len(overrides))
allSources := cloneSourceInfos(sources)
for index, override := range overrides {
sourceID := "source-" + strconv.Itoa(len(sources)+index+1)
info := SourceInfo{ID: sourceID, Label: override.Flag, Layer: LayerCLI}
allSources = append(allSources, info)
prepared[index].source = info
}
seen := make(map[string]int, len(overrides))
pathAccepted := make([]bool, len(overrides))
var usedBytes int64
usedNodes := 0
for index, override := range overrides {
location := &Location{SourceID: prepared[index].source.ID}
if issue := validateOverridePath(override.Path, base.root); issue != nil {
issue.Primary = location
diagnostics = append(diagnostics, *issue)
continue
}
pointer := override.Path.String()
if previous, duplicate := seen[pointer]; duplicate {
diagnostics = append(diagnostics, Diagnostic{
Code: CodeOverrideDuplicate, Severity: SeverityError, Path: override.Path.clone(),
Message: "configuration override path is repeated", Primary: location,
Related: []Location{{SourceID: prepared[previous].source.ID}},
})
continue
}
seen[pointer] = index
if conflicting := overlappingOverride(overrides[:index], pathAccepted[:index], override.Path); conflicting >= 0 {
diagnostics = append(diagnostics, Diagnostic{
Code: CodeOverrideOverlap, Severity: SeverityError, Path: override.Path.clone(),
Message: "configuration override paths overlap", Primary: location,
Related: []Location{{SourceID: prepared[conflicting].source.ID}},
})
continue
}
if rule != nil && (pathContains(override.Path, rule.Path) || pathContains(rule.Path, override.Path)) {
diagnostics = append(diagnostics, Diagnostic{
Code: CodeOverrideVersion, Severity: SeverityError, Path: override.Path.clone(),
Message: "configuration overrides cannot replace the schema-version path", Primary: location,
})
continue
}
if pathContainsDirective(override.Path) {
diagnostics = append(diagnostics, Diagnostic{
Code: CodeOverrideDirective, Severity: SeverityError, Path: override.Path.clone(),
Message: "configuration overrides cannot create configuration directives", Primary: location,
})
continue
}
pathAccepted[index] = true
builder := overrideValueBuilder{sourceID: prepared[index].source.ID, active: make(map[valueVisit]bool)}
value, issue := builder.build(override.Value, override.Path, len(override.Path.segments))
if issue != nil {
issue.Primary = location
diagnostics = append(diagnostics, *issue)
continue
}
if relative, ok := overrideDirectivePath(value, Path{}); ok {
diagnostics = append(diagnostics, Diagnostic{
Code: CodeOverrideDirective, Severity: SeverityError, Path: appendOverridePath(override.Path, relative),
Message: "configuration overrides cannot create configuration directives", Primary: location,
})
continue
}
nodes := builder.nodes + len(override.Path.segments)*2
if nodes > limits.nodes || base.inputNodes+usedNodes+nodes > limits.nodes {
diagnostics = append(diagnostics, Diagnostic{
Code: CodeOverrideLimitExceeded, Severity: SeverityError, Path: override.Path.clone(),
Message: "configuration overrides exceed the aggregate node limit", Primary: location,
})
continue
}
if builder.bytes > maxSourceBytes || base.inputBytes+usedBytes+builder.bytes > limits.bytes {
diagnostics = append(diagnostics, Diagnostic{
Code: CodeOverrideLimitExceeded, Severity: SeverityError, Path: override.Path.clone(),
Message: "configuration overrides exceed a record or aggregate string-byte limit", Primary: location,
})
continue
}
prepared[index].path = override.Path.clone()
prepared[index].value = value
prepared[index].bytes = builder.bytes
prepared[index].nodes = nodes
usedBytes += builder.bytes
usedNodes += nodes
}
if len(diagnostics) != 0 {
return nil, allSources, canonicalDiagnostics(diagnostics)
}
return prepared, allSources, nil
}
func validOverrideLabel(label string) bool {
if label == "" || strings.TrimSpace(label) != label || !utf8.ValidString(label) {
return false
}
for _, character := range label {
if unicode.IsControl(character) {
return false
}
}
return true
}
func validateOverridePath(path Path, root *document.Node) *Diagnostic {
if !path.valid() || len(path.segments) == 0 {
return &Diagnostic{Code: CodeOverrideInvalidPath, Severity: SeverityError, Path: path.clone(), Message: "configuration override path must be a valid non-root mapping path"}
}
current := root
for index, segment := range path.segments {
if segment.kind == pathSegmentIndex {
return &Diagnostic{Code: CodeOverrideInvalidPath, Severity: SeverityError, Path: path.clone(), Message: "configuration override paths cannot contain sequence indices"}
}
if current == nil {
continue
}
if current.Kind != document.Mapping {
return &Diagnostic{Code: CodeOverrideInvalidPath, Severity: SeverityError, Path: path.clone(), Message: "configuration override path traverses a non-mapping value"}
}
position := documentEntryIndex(current.Mapping, segment.key)
if index == len(path.segments)-1 {
continue
}
if position < 0 {
current = nil
continue
}
current = current.Mapping[position].Value
}
return nil
}
func overlappingOverride(previous []Override, valid []bool, path Path) int {
for index, override := range previous {
if valid[index] && (pathContains(override.Path, path) || pathContains(path, override.Path)) {
return index
}
}
return -1
}
func pathContainsDirective(path Path) bool {
for _, segment := range path.segments {
if segment.key == directiveKey {
return true
}
}
return false
}
func appendOverridePath(base, relative Path) Path {
result := base.clone()
result.segments = append(result.segments, clonePathSegments(relative.segments, 0)...)
return result
}
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 == directiveKey {
return child, true
}
if found, ok := overrideDirectivePath(entry.Value, child); ok {
return found, true
}
}
} else if node.Kind == document.Sequence {
for index, child := range node.Sequence {
if found, ok := overrideDirectivePath(child, path.Index(index)); ok {
return found, true
}
}
}
return Path{}, false
}

100
override_apply.go Normal file
View File

@@ -0,0 +1,100 @@
package configkit
import (
"slices"
"strings"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
func applyOverrides(base *composedDocument, sources []SourceInfo, overrides []Override, rule *VersionRule, limits importLimits) (*composedDocument, []SourceInfo, []Diagnostic) {
if len(overrides) == 0 {
return base, cloneSourceInfos(sources), nil
}
prepared, allSources, diagnostics := prepareOverrides(base, sources, overrides, rule, limits)
if len(diagnostics) != 0 {
return nil, allSources, diagnostics
}
result := cloneComposedDocument(base)
for _, override := range prepared {
applyPreparedOverride(result, override)
result.inputBytes += override.bytes
result.inputNodes += override.nodes
}
return result, allSources, nil
}
func applyPreparedOverride(result *composedDocument, override preparedOverride) {
location := document.Location{SourceID: override.source.ID}
mergeOverlayOrigin(result.origins, Path{}, location, LayerCLI)
current := result.root
currentPath := Path{}
for index, segment := range override.path.segments {
path := currentPath.Key(segment.key)
position := documentEntryIndex(current.Mapping, segment.key)
if index < len(override.path.segments)-1 {
if position < 0 {
child := &document.Node{Kind: document.Mapping, Location: location, Mapping: []document.Entry{}}
current.Mapping = append(current.Mapping, document.Entry{Key: segment.key, KeyLocation: location, ValueLocation: location, Value: child})
collectOverlayOrigins(result.origins, child, path, LayerCLI)
current = child
} else {
current = current.Mapping[position].Value
mergeOverlayOrigin(result.origins, path, location, LayerCLI)
}
currentPath = path
continue
}
previous := cloneOrigin(result.origins[path.String()])
var displaced []displacedDefinition
if position < 0 {
incoming := document.Clone(override.value)
current.Mapping = append(current.Mapping, document.Entry{Key: segment.key, KeyLocation: location, ValueLocation: location, Value: incoming})
collectOverlayOrigins(result.origins, incoming, path, LayerCLI)
} else {
existing := current.Mapping[position].Value
displaced = affectedOverrideOrigins(existing, override.value, result.origins, path)
if existing.Kind == document.Mapping && override.value.Kind == document.Mapping {
mergeOverlayOrigin(result.origins, path, location, LayerCLI)
mergeOverlayMappings(result, existing, override.value, path, LayerCLI, false)
} else {
deleteOriginSubtree(result.origins, path)
current.Mapping[position] = document.Entry{Key: segment.key, KeyLocation: location, ValueLocation: location, Value: document.Clone(override.value)}
collectOverlayOrigins(result.origins, current.Mapping[position].Value, path, LayerCLI)
}
}
result.overlays = append(result.overlays, overlayRecord{change: Change{
Path: path.clone(), Origin: overlayOrigin(LayerCLI, location), Previous: previousOrigins(previous),
}, displaced: displaced})
result.applications = append(result.applications, historyApplication{
layer: LayerCLI, path: path.clone(), value: override.value,
origin: overlayOrigin(LayerCLI, location),
})
}
}
func affectedOverrideOrigins(existing, incoming *document.Node, origins map[string]Origin, path Path) []displacedDefinition {
displaced := []displacedDefinition{{path: path.clone(), origin: cloneOrigin(origins[path.String()])}}
if existing.Kind == document.Mapping && incoming.Kind == document.Mapping {
for _, entry := range incoming.Mapping {
position := documentEntryIndex(existing.Mapping, entry.Key)
if position < 0 {
continue
}
childPath := path.Key(entry.Key)
childExisting := existing.Mapping[position].Value
if childExisting.Kind == document.Mapping && entry.Value.Kind == document.Mapping {
displaced = append(displaced, affectedOverrideOrigins(childExisting, entry.Value, origins, childPath)...)
} else {
displaced = append(displaced, displacedOrigins(childExisting, origins, childPath)...)
}
}
} else {
displaced = displacedOrigins(existing, origins, path)
}
slices.SortFunc(displaced, func(left, right displacedDefinition) int {
return strings.Compare(left.path.String(), right.path.String())
})
return displaced
}

307
override_test.go Normal file
View File

@@ -0,0 +1,307 @@
package configkit
import (
"errors"
"math"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
type overrideLifecycleSettings struct {
Target string `yaml:"target"`
Count int `yaml:"count"`
Enabled bool `yaml:"enabled"`
Text string `yaml:"text"`
Items []string `yaml:"items"`
Maybe *string `yaml:"maybe"`
Labels map[string]string `yaml:"labels"`
APIKey Secret `yaml:"api_key"`
}
type overrideLifecycleConfig struct {
Version string `yaml:"version"`
Settings overrideLifecycleSettings `yaml:"settings"`
}
func TestOverridesRunAtHighestInputPrecedenceBeforeResolutionAndValidation(t *testing.T) {
directory := t.TempDir()
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
enabled: true
text: base
items: [base]
maybe: base
labels: {base: retained}
api_key: BASE_SECRET
`)
writeImportFile(t, directory, filepath.Join("profiles", "testing.yml"), `settings:
target: profile
labels: {profile: retained}
`)
validated := false
overrides := []Override{
{Path: Keys("settings", "target"), Value: " cli ", Flag: "--target"},
{Path: Keys("settings", "count"), Value: 0, Flag: "--count"},
{Path: Keys("settings", "enabled"), Value: false, Flag: "--enabled"},
{Path: Keys("settings", "text"), Value: "", Flag: "--text"},
{Path: Keys("settings", "items"), Value: []any{}, Flag: "--items"},
{Path: Keys("settings", "maybe"), Value: nil, Flag: "--maybe"},
{Path: Keys("settings", "labels"), Value: map[string]any{"cli": "selected"}, Flag: "--label"},
{Path: Keys("settings", "api_key"), Value: "CLI_SECRET", Flag: "--api-key-env"},
}
result, err := loadWithSecretLookup(File(mainPath), func(name string) (string, bool) {
if name != "CLI_SECRET" {
t.Fatalf("looked up superseded secret name %q", name)
}
return "credential-value", true
}, Options[overrideLifecycleConfig]{
Imports: true, Profile: "testing", Overrides: overrides,
Definition: Definition[overrideLifecycleConfig]{
Defaults: func() overrideLifecycleConfig {
return overrideLifecycleConfig{Settings: overrideLifecycleSettings{Target: "default", Count: 9, Text: "default", Items: []string{"default"}}}
},
Version: &VersionRule{Path: Keys("version"), Check: func(version Version, validation *Validation) error {
if version.Text != "v1" {
validation.Error(Keys("version"), "unsupported version")
}
return nil
}},
Resolve: func(configuration *overrideLifecycleConfig, resolution *Resolution) error {
Normalize(resolution, Keys("settings", "target"), &configuration.Settings.Target, strings.TrimSpace(configuration.Settings.Target), "trimmed target")
return nil
},
Validate: func(configuration *overrideLifecycleConfig, validation *Validation) {
validated = configuration.Settings.Target == "cli" && configuration.Settings.APIKey.Value() == "credential-value"
},
},
})
if err != nil {
t.Fatalf("loadWithSecretLookup() error = %v", err)
}
want := overrideLifecycleSettings{
Target: "cli", Labels: map[string]string{"base": "retained", "profile": "retained", "cli": "selected"},
APIKey: result.Config.Settings.APIKey,
}
if !validated || result.Config.Settings.Count != 0 || result.Config.Settings.Enabled || result.Config.Settings.Text != "" ||
len(result.Config.Settings.Items) != 0 || result.Config.Settings.Items == nil || result.Config.Settings.Maybe != nil ||
result.Config.Settings.Target != want.Target || !reflect.DeepEqual(result.Config.Settings.Labels, want.Labels) {
t.Fatalf("effective settings = %#v", result.Config.Settings)
}
sources := result.Sources()
if len(sources) != 3+len(overrides) || sources[0].Layer != LayerBase || sources[1].Layer != LayerBase || sources[2].Layer != LayerProfile {
t.Fatalf("sources = %#v", sources)
}
for index, override := range overrides {
if sources[index+3].Layer != LayerCLI || sources[index+3].Label != override.Flag || sources[index+3].Path != "" {
t.Fatalf("CLI source %d = %#v", index, sources[index+3])
}
}
history := result.Metadata().History(Keys("settings", "target"))
wantLayers := []Layer{LayerDefault, LayerBase, LayerProfile, LayerCLI, LayerResolution}
if got := historyLayers(history); !reflect.DeepEqual(got, wantLayers) {
t.Fatalf("target history layers = %#v, want %#v; history %#v", got, wantLayers, history)
}
}
func TestOverridesSupportDefaultsOnlyMapMergeListReplacementAndNumericKeys(t *testing.T) {
type config struct {
Labels map[string]string `yaml:"labels"`
Items []int `yaml:"items"`
Numbers map[string]int `yaml:"numbers"`
Dynamic any `yaml:"dynamic"`
}
labels := map[string]any{"0": "zero", "new": "value"}
items := []any{0, 2}
dynamic := map[string]any{
"int": int8(1), "uint": uint16(2), "float": float32(3.5), "bool": false,
"null": nil, "list": []any{"value"}, "map": map[string]any{"key": "value"},
}
result, err := Load(NoSource(), Options[config]{
Definition: Definition[config]{Defaults: func() config {
return config{Labels: map[string]string{"kept": "default"}, Items: []int{9}}
}},
Overrides: []Override{
{Path: parseOverrideTestPath(t, "/labels"), Value: labels, Flag: "--labels"},
{Path: Keys("items"), Value: items, Flag: "--items"},
{Path: parseOverrideTestPath(t, "/numbers/0"), Value: 7, Flag: "--number"},
{Path: Keys("dynamic"), Value: dynamic, Flag: "--dynamic"},
},
})
labels["new"] = "mutated"
items[0] = 99
dynamic["int"] = 99
if err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(result.Config.Labels, map[string]string{"kept": "default", "0": "zero", "new": "value"}) || !reflect.DeepEqual(result.Config.Items, []int{0, 2}) || result.Config.Numbers["0"] != 7 || result.Config.Dynamic.(map[string]any)["int"] != int64(1) {
t.Fatalf("defaults-only overrides = %#v", result.Config)
}
var effective strings.Builder
if err := result.WriteEffective(&effective); err != nil {
t.Fatal(err)
}
if strings.Contains(effective.String(), "mutated") || !strings.Contains(effective.String(), "new: value") {
t.Fatalf("detached effective output = %q", effective.String())
}
if info, ok := result.Metadata().Lookup(Keys("labels", "0")); !ok || !info.Explicit || info.Origin.Layer != LayerCLI {
t.Fatalf("numeric mapping key metadata = %#v, %v", info, ok)
}
}
func TestOverrideValidationRejectsPathsOverlapValuesAndProtectedTargets(t *testing.T) {
type config struct {
Version string `yaml:"version"`
Scalar string `yaml:"scalar"`
Items []string `yaml:"items"`
Nested map[string]any `yaml:"nested"`
Unknown string `yaml:"unknown"`
}
base := Bytes("override-errors.yml", []byte("version: v1\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
deep := any("leaf")
for range maxDocumentDepth + 1 {
deep = []any{deep}
}
for _, test := range []struct {
name string
overrides []Override
code Code
}{
{name: "root", overrides: []Override{{Path: Keys(), Value: "x", Flag: "--root"}}, code: CodeOverrideInvalidPath},
{name: "index", overrides: []Override{{Path: Keys("items").Index(0), Value: "x", Flag: "--item"}}, code: CodeOverrideInvalidPath},
{name: "parsed sequence index", overrides: []Override{{Path: parseOverrideTestPath(t, "/items/0"), Value: "x", Flag: "--item"}}, code: CodeOverrideInvalidPath},
{name: "scalar ancestor", overrides: []Override{{Path: Keys("scalar", "child"), Value: "x", Flag: "--child"}}, code: CodeOverrideInvalidPath},
{name: "duplicate", overrides: []Override{{Path: Keys("scalar"), Value: "a", Flag: "--first"}, {Path: Keys("scalar"), Value: "b", Flag: "--second"}}, code: CodeOverrideDuplicate},
{name: "overlap", overrides: []Override{{Path: Keys("nested"), Value: map[string]any{}, Flag: "--nested"}, {Path: Keys("nested", "child"), Value: "x", Flag: "--child"}}, code: CodeOverrideOverlap},
{name: "unsupported", overrides: []Override{{Path: Keys("scalar"), Value: time.Second, Flag: "--scalar"}}, code: CodeOverrideInvalidValue},
{name: "cycle", overrides: []Override{{Path: Keys("nested"), Value: cycle, Flag: "--nested"}}, code: CodeOverrideInvalidValue},
{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: "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},
} {
t.Run(test.name, func(t *testing.T) {
result, err := Load(base, Options[config]{Definition: Definition[config]{Version: version}, Overrides: test.overrides})
if result != nil {
t.Fatalf("result = %#v, want nil", result)
}
loadError := requireSecretLoadCode(t, err, test.code)
if strings.Contains(fmtLoadError(loadError), strings.Repeat("x", 64)) {
t.Fatal("override diagnostic leaked a raw value")
}
})
}
}
func TestOverrideCannotReplaceSchemaVersionAncestor(t *testing.T) {
type schemaConfig struct {
Version string `yaml:"version"`
}
type config struct {
Schema schemaConfig `yaml:"schema"`
}
_, err := Load(Bytes("version.yml", []byte("schema: {version: v1}\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"}},
})
requireSecretLoadCode(t, err, CodeOverrideVersion)
}
func TestOverrideDecodeFailureUsesFlagSourceWithoutRawValue(t *testing.T) {
type config struct {
Count int `yaml:"count"`
}
result, err := Load(Bytes("base.yml", []byte("count: 1\n")), Options[config]{Overrides: []Override{{
Path: Keys("missing"), Value: "RAW-ARG-CANARY", Flag: "--missing",
}}})
if result != nil {
t.Fatalf("result = %#v", result)
}
var loadError *LoadError
if !errors.As(err, &loadError) {
t.Fatalf("error = %T", err)
}
diagnostics := loadError.Diagnostics()
sources := loadError.Sources()
if len(diagnostics) != 1 || diagnostics[0].Code != CodeDecodeUnknownField || diagnostics[0].Primary == nil ||
len(sources) != 2 || sources[1].Label != "--missing" || diagnostics[0].Primary.SourceID != sources[1].ID {
t.Fatalf("diagnostics/sources = %#v / %#v", diagnostics, sources)
}
if strings.Contains(fmtLoadError(loadError), "RAW-ARG-CANARY") {
t.Fatal("decode failure leaked override value")
}
}
func TestOverrideTypeMismatchUsesNormalStrictDecode(t *testing.T) {
type config struct {
Count int `yaml:"count"`
}
_, err := Load(Bytes("base.yml", []byte("count: 1\n")), Options[config]{Overrides: []Override{{
Path: Keys("count"), Value: "not-an-integer", Flag: "--count",
}}})
loadError := requireSecretLoadCode(t, err, CodeDecodeType)
if loadError.Diagnostics()[0].Primary == nil || loadError.Sources()[1].Label != "--count" || strings.Contains(fmtLoadError(loadError), "not-an-integer") {
t.Fatalf("type mismatch diagnostics/sources = %#v / %#v", loadError.Diagnostics(), loadError.Sources())
}
}
func TestOverrideEqualReplacementStillAppearsInHistory(t *testing.T) {
type config struct {
Value string `yaml:"value"`
}
result, err := Load(Bytes("equal.yml", []byte("value: same\n")), Options[config]{Overrides: []Override{{Path: Keys("value"), Value: "same", Flag: "--value"}}})
if err != nil {
t.Fatal(err)
}
if got := historyLayers(result.Metadata().History(Keys("value"))); !reflect.DeepEqual(got, []Layer{LayerDefault, LayerBase, LayerCLI}) {
t.Fatalf("equal override history = %#v", result.Metadata().History(Keys("value")))
}
}
func TestOverrideAggregateLimitsFailBeforeApplication(t *testing.T) {
base, diagnostics := composeDocuments(&document.Node{Kind: document.Mapping})
if len(diagnostics) != 0 {
t.Fatal(diagnostics)
}
base.inputNodes = 1
result, sources, diagnostics := applyOverrides(base, nil, []Override{{Path: Keys("value"), Value: "x", Flag: "--value"}}, nil, importLimits{sources: 1, bytes: 1, nodes: 2})
if result != nil || len(sources) != 1 || len(diagnostics) != 1 || diagnostics[0].Code != CodeOverrideLimitExceeded || len(base.root.Mapping) != 0 {
t.Fatalf("bounded apply = %#v, %#v, %#v; base %#v", result, sources, diagnostics, base.root)
}
}
func historyLayers(history []Change) []Layer {
layers := make([]Layer, len(history))
for index, change := range history {
layers[index] = change.Origin.Layer
}
return layers
}
func parseOverrideTestPath(t *testing.T, value string) Path {
t.Helper()
path, err := ParsePath(value)
if err != nil {
t.Fatal(err)
}
return path
}
func fmtLoadError(loadError *LoadError) string {
var output strings.Builder
_ = WriteDiagnostics(&output, loadError.Diagnostics())
return loadError.Error() + output.String()
}

150
override_value.go Normal file
View File

@@ -0,0 +1,150 @@
package configkit
import (
"math"
"reflect"
"slices"
"strconv"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
type overrideValueBuilder struct {
sourceID string
active map[valueVisit]bool
bytes int64
nodes int
}
func (b *overrideValueBuilder) build(value any, path Path, depth int) (*document.Node, *Diagnostic) {
if depth > maxDocumentDepth {
return nil, overrideValueDiagnostic(CodeOverrideLimitExceeded, path, "configuration override exceeds the depth limit")
}
b.nodes++
if b.nodes > maxDocumentNodes {
return nil, overrideValueDiagnostic(CodeOverrideLimitExceeded, path, "configuration override exceeds the node limit")
}
location := document.Location{SourceID: b.sourceID}
switch value := value.(type) {
case nil:
return &document.Node{Kind: document.Null, Location: location}, nil
case string:
if !b.addBytes(len(value)) {
return nil, overrideValueDiagnostic(CodeOverrideLimitExceeded, path, "configuration override exceeds the record string-byte limit")
}
return &document.Node{Kind: document.String, Text: value, Location: location}, nil
case bool:
return &document.Node{Kind: document.Boolean, Text: strconv.FormatBool(value), Location: location}, nil
case int:
return overrideNumber(strconv.FormatInt(int64(value), 10), location), nil
case int8:
return overrideNumber(strconv.FormatInt(int64(value), 10), location), nil
case int16:
return overrideNumber(strconv.FormatInt(int64(value), 10), location), nil
case int32:
return overrideNumber(strconv.FormatInt(int64(value), 10), location), nil
case int64:
return overrideNumber(strconv.FormatInt(value, 10), location), nil
case uint:
return overrideNumber(strconv.FormatUint(uint64(value), 10), location), nil
case uint8:
return overrideNumber(strconv.FormatUint(uint64(value), 10), location), nil
case uint16:
return overrideNumber(strconv.FormatUint(uint64(value), 10), location), nil
case uint32:
return overrideNumber(strconv.FormatUint(uint64(value), 10), location), nil
case uint64:
return overrideNumber(strconv.FormatUint(value, 10), location), nil
case float32:
if math.IsNaN(float64(value)) || math.IsInf(float64(value), 0) {
return nil, overrideValueDiagnostic(CodeOverrideInvalidValue, path, "configuration override float must be finite")
}
return overrideNumber(strconv.FormatFloat(float64(value), 'g', -1, 32), location), nil
case float64:
if math.IsNaN(value) || math.IsInf(value, 0) {
return nil, overrideValueDiagnostic(CodeOverrideInvalidValue, path, "configuration override float must be finite")
}
return overrideNumber(strconv.FormatFloat(value, 'g', -1, 64), location), nil
case []any:
if value == nil {
return &document.Node{Kind: document.Null, Location: location}, nil
}
leave, diagnostic := b.enter(reflect.ValueOf(value), path)
if diagnostic != nil {
return nil, diagnostic
}
defer leave()
if len(value) > maxDocumentNodes-b.nodes {
return nil, overrideValueDiagnostic(CodeOverrideLimitExceeded, path, "configuration override exceeds the node limit")
}
node := &document.Node{Kind: document.Sequence, Location: location, Sequence: make([]*document.Node, len(value))}
for index, child := range value {
built, diagnostic := b.build(child, path.Index(index), depth+1)
if diagnostic != nil {
return nil, diagnostic
}
node.Sequence[index] = built
}
return node, nil
case map[string]any:
if value == nil {
return &document.Node{Kind: document.Null, Location: location}, nil
}
leave, diagnostic := b.enter(reflect.ValueOf(value), path)
if diagnostic != nil {
return nil, diagnostic
}
defer leave()
if len(value) > (maxDocumentNodes-b.nodes)/2 {
return nil, overrideValueDiagnostic(CodeOverrideLimitExceeded, path, "configuration override exceeds the node limit")
}
keys := make([]string, 0, len(value))
for key := range value {
keys = append(keys, key)
}
slices.Sort(keys)
node := &document.Node{Kind: document.Mapping, Location: location, Mapping: make([]document.Entry, 0, len(keys))}
for _, key := range keys {
b.nodes++ // mapping key
if !b.addBytes(len(key)) {
return nil, overrideValueDiagnostic(CodeOverrideLimitExceeded, path.Key(key), "configuration override exceeds the record string-byte limit")
}
built, diagnostic := b.build(value[key], path.Key(key), depth+1)
if diagnostic != nil {
return nil, diagnostic
}
node.Mapping = append(node.Mapping, document.Entry{Key: key, KeyLocation: location, ValueLocation: location, Value: built})
}
return node, nil
default:
return nil, overrideValueDiagnostic(CodeOverrideInvalidValue, path, "configuration override value has an unsupported type")
}
}
func (b *overrideValueBuilder) addBytes(count int) bool {
if count < 0 || int64(count) > maxSourceBytes-b.bytes {
return false
}
b.bytes += int64(count)
return true
}
func (b *overrideValueBuilder) enter(value reflect.Value, path Path) (func(), *Diagnostic) {
visit, ok := visitFor(value)
if !ok {
return func() {}, nil
}
if b.active[visit] {
return nil, overrideValueDiagnostic(CodeOverrideInvalidValue, path, "configuration override values must not contain cycles")
}
b.active[visit] = true
return func() { delete(b.active, visit) }, nil
}
func overrideValueDiagnostic(code Code, path Path, message string) *Diagnostic {
return &Diagnostic{Code: code, Severity: SeverityError, Path: path.clone(), Message: message}
}
func overrideNumber(text string, location document.Location) *document.Node {
return &document.Node{Kind: document.Number, Text: text, Location: location}
}

62
parse.go Normal file
View File

@@ -0,0 +1,62 @@
package configkit
import (
"bytes"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
yamladapter "gitea.maximumdirect.net/eric/ConfigKit/internal/yaml"
)
const (
maxDocumentDepth = 256
maxDocumentNodes = 1_000_000
)
type parseLimits struct {
source sourceLimits
document yamladapter.Limits
}
func defaultParseLimits() parseLimits {
return parseLimits{
source: defaultSourceLimits(),
document: yamladapter.Limits{
Depth: maxDocumentDepth,
Nodes: maxDocumentNodes,
},
}
}
type parsedSource struct {
document *document.Node
source *SourceInfo
bytes int64
}
// parserDiagnostics translates adapter issues into detached, ordered public records.
func parserDiagnostics(sourceID string, issues []yamladapter.Issue) []Diagnostic {
diagnostics := make([]Diagnostic, len(issues))
for index, issue := range issues {
diagnostics[index] = Diagnostic{
Code: Code(issue.Code), Severity: SeverityError, Message: issue.Message,
Primary: &Location{SourceID: sourceID, Line: issue.Line, Column: issue.Column},
}
for _, related := range issue.Related {
diagnostics[index].Related = append(diagnostics[index].Related, publicLocation(related))
}
}
return canonicalDiagnostics(diagnostics)
}
func parseSource(source Source, sourceID string, limits parseLimits) (parsedSource, []Diagnostic) {
read, diagnostics := readSource(source, sourceID, limits.source)
if len(diagnostics) != 0 || read.info == nil {
return parsedSource{source: read.info}, diagnostics
}
root, issues := yamladapter.Parse(bytes.NewReader(read.data), sourceID, limits.document)
if len(issues) != 0 {
return parsedSource{source: read.info, bytes: int64(len(read.data))}, parserDiagnostics(sourceID, issues)
}
return parsedSource{document: root, source: read.info, bytes: int64(len(read.data))}, nil
}

38
parse_test.go Normal file
View File

@@ -0,0 +1,38 @@
package configkit
import (
"strings"
"testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
"gitea.maximumdirect.net/eric/ConfigKit/internal/yaml"
)
func TestParseSourceOwnsDocumentAndSafeDiagnostics(t *testing.T) {
t.Parallel()
limits := defaultParseLimits()
parsed, diagnostics := parseSource(Bytes("memory.yml", []byte("name: config\n")), "source-1", limits)
if len(diagnostics) != 0 || parsed.source == nil || parsed.source.Label != "memory.yml" || parsed.document.Kind != document.Mapping {
t.Fatalf("parse result = %#v, diagnostics = %#v", parsed, diagnostics)
}
const canary = "CANARY_PRIVATE_INPUT"
failed, diagnostics := parseSource(Bytes("unsafe\nlabel", []byte("name: ["+canary+"\n")), "source-1", limits)
if failed.document != nil || failed.source == nil || len(diagnostics) != 1 || diagnostics[0].Code != CodeYAMLInvalid {
t.Fatalf("failed parse = %#v, diagnostics = %#v", failed, diagnostics)
}
var output strings.Builder
if err := WriteDiagnostics(&output, diagnostics); err != nil {
t.Fatal(err)
}
if strings.Contains(output.String(), canary) || strings.Count(output.String(), "\n") != 1 {
t.Fatalf("unsafe rendered diagnostic = %q", output.String())
}
limits.document = yaml.Limits{Depth: 1, Nodes: 20, ParserDepth: 20}
_, diagnostics = parseSource(Bytes("deep", []byte("a:\n b: value\n")), "source-1", limits)
if len(diagnostics) != 1 || diagnostics[0].Code != CodeYAMLDepthLimit || diagnostics[0].Primary.Line == 0 {
t.Fatalf("depth diagnostics = %#v", diagnostics)
}
}

148
path.go Normal file
View File

@@ -0,0 +1,148 @@
package configkit
import (
"errors"
"strconv"
"strings"
)
// Path identifies a configuration value using YAML field names. Its textual form
// is a JSON Pointer: the root is empty, ~ escapes as ~0, and / escapes as ~1.
// Paths are semantic rather than filesystem or URL paths: empty, dot, dot-dot,
// percent-containing, and numeric keys retain their literal meaning. Parsed
// numeric tokens are resolved only when traversal knows their containing type.
//
// Paths are immutable. The zero value is the root path.
type Path struct {
segments []pathSegment
invalid bool
}
type pathSegmentKind uint8
const (
pathSegmentKey pathSegmentKind = iota
pathSegmentIndex
pathSegmentParsed
)
type pathSegment struct {
kind pathSegmentKind
key string
index int
}
// Keys constructs a path of mapping keys. Keys with no arguments returns the
// root path.
func Keys(keys ...string) Path {
p := Path{}
for _, key := range keys {
p = p.Key(key)
}
return p
}
// ParsePath parses a JSON Pointer. Numeric tokens remain unresolved until schema
// or document traversal determines whether the containing value is a mapping or
// sequence.
func ParsePath(pointer string) (Path, error) {
if pointer == "" {
return Path{}, nil
}
if pointer[0] != '/' {
return Path{}, errors.New("configkit: path must be empty or begin with slash")
}
parts := strings.Split(pointer[1:], "/")
segments := make([]pathSegment, 0, len(parts))
for _, part := range parts {
key, ok := unescapePathToken(part)
if !ok {
return Path{}, errors.New("configkit: path contains an invalid tilde escape")
}
segments = append(segments, pathSegment{kind: pathSegmentParsed, key: key})
}
return Path{segments: segments}, nil
}
// Key returns a new path with key appended as a mapping-key segment.
func (p Path) Key(key string) Path {
segments := clonePathSegments(p.segments, 1)
segments = append(segments, pathSegment{kind: pathSegmentKey, key: key})
return Path{segments: segments, invalid: p.invalid}
}
// Index returns a new path with index appended as a sequence-index segment.
// A negative index produces an invalid path that configuration operations reject.
func (p Path) Index(index int) Path {
segments := clonePathSegments(p.segments, 1)
segments = append(segments, pathSegment{kind: pathSegmentIndex, index: index})
return Path{segments: segments, invalid: p.invalid || index < 0}
}
// String returns the path's canonical JSON Pointer spelling.
func (p Path) String() string {
if len(p.segments) == 0 {
return ""
}
var b strings.Builder
for _, segment := range p.segments {
b.WriteByte('/')
if segment.kind == pathSegmentIndex {
b.WriteString(strconv.Itoa(segment.index))
continue
}
b.WriteString(escapePathToken(segment.key))
}
return b.String()
}
func (p Path) clone() Path {
return Path{segments: clonePathSegments(p.segments, 0), invalid: p.invalid}
}
func (p Path) valid() bool {
return !p.invalid
}
func clonePathSegments(segments []pathSegment, extra int) []pathSegment {
if len(segments) == 0 && extra == 0 {
return nil
}
cloned := make([]pathSegment, len(segments), len(segments)+extra)
copy(cloned, segments)
return cloned
}
func escapePathToken(token string) string {
token = strings.ReplaceAll(token, "~", "~0")
return strings.ReplaceAll(token, "/", "~1")
}
func unescapePathToken(token string) (string, bool) {
if !strings.Contains(token, "~") {
return token, true
}
var b strings.Builder
for i := 0; i < len(token); i++ {
if token[i] != '~' {
b.WriteByte(token[i])
continue
}
if i+1 >= len(token) {
return "", false
}
i++
switch token[i] {
case '0':
b.WriteByte('~')
case '1':
b.WriteByte('/')
default:
return "", false
}
}
return b.String(), true
}

27
path_fuzz_test.go Normal file
View File

@@ -0,0 +1,27 @@
package configkit
import "testing"
func FuzzParsePath(f *testing.F) {
for _, seed := range []string{"", "/", "/service/port", "/a~1b/~0key", "/0", "relative", "/bad~2escape"} {
f.Add(seed)
}
f.Fuzz(func(t *testing.T, input string) {
if len(input) > 4096 {
return
}
path, err := ParsePath(input)
if err != nil {
return
}
canonical := path.String()
reparsed, err := ParsePath(canonical)
if err != nil {
t.Fatalf("canonical path %q did not parse: %v", canonical, err)
}
if reparsed.String() != canonical {
t.Fatalf("path round trip = %q, want %q", reparsed.String(), canonical)
}
})
}

88
path_test.go Normal file
View File

@@ -0,0 +1,88 @@
package configkit
import "testing"
func TestPathRoundTripsSemanticKeys(t *testing.T) {
t.Parallel()
tests := []struct {
name string
pointer string
}{
{name: "root", pointer: ""},
{name: "slash and tilde", pointer: "/a~1b/c~0d"},
{name: "empty key", pointer: "/"},
{name: "dotted key", pointer: "/outputs/a.b"},
{name: "numeric key", pointer: "/outputs/0"},
{name: "dot keys", pointer: "/./.."},
{name: "percent text", pointer: "/%2F/%7E"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
parsed, err := ParsePath(test.pointer)
if err != nil {
t.Fatalf("ParsePath() error = %v", err)
}
if got := parsed.String(); got != test.pointer {
t.Fatalf("String() = %q, want %q", got, test.pointer)
}
})
}
if got := Keys("a/b", "c~d", "", "..").String(); got != "/a~1b/c~0d//.." {
t.Fatalf("Keys().String() = %q", got)
}
if got := Keys("items").Index(12).Key("name").String(); got != "/items/12/name" {
t.Fatalf("indexed path = %q", got)
}
}
func TestPathPreservesSegmentIntentAndImmutability(t *testing.T) {
t.Parallel()
key := Keys("items", "0")
index := Keys("items").Index(0)
parsed, err := ParsePath("/items/0")
if err != nil {
t.Fatal(err)
}
if key.String() != index.String() || index.String() != parsed.String() {
t.Fatal("equivalent textual paths should share a JSON Pointer spelling")
}
if key.segments[1].kind != pathSegmentKey {
t.Fatal("Keys numeric segment lost mapping-key intent")
}
if index.segments[1].kind != pathSegmentIndex {
t.Fatal("Index segment lost sequence intent")
}
if parsed.segments[1].kind != pathSegmentParsed {
t.Fatal("parsed numeric segment was resolved before traversal")
}
base := Keys("parent")
child := base.Key("child")
child.segments[0].key = "changed"
if got := base.String(); got != "/parent" {
t.Fatalf("builder mutated parent: %q", got)
}
negative := Path{}.Index(-1)
if negative.valid() {
t.Fatal("negative index must produce an invalid path")
}
}
func TestParsePathRejectsMalformedPointers(t *testing.T) {
t.Parallel()
for _, pointer := range []string{"relative", "~0", "/bad~", "/bad~2", "/bad~01~"} {
if _, err := ParsePath(pointer); err == nil {
t.Errorf("ParsePath(%q) succeeded", pointer)
}
}
if parsed, err := ParsePath("/missing"); err != nil || !parsed.valid() {
t.Fatalf("valid missing path rejected: path=%v error=%v", parsed, err)
}
}

131
profile.go Normal file
View File

@@ -0,0 +1,131 @@
package configkit
import (
"bytes"
"errors"
"io/fs"
"os"
"path/filepath"
"strconv"
"strings"
yamladapter "gitea.maximumdirect.net/eric/ConfigKit/internal/yaml"
)
func activeProfile(name string) bool {
return name != "" && name != "default"
}
func validProfileName(name string) bool {
if !activeProfile(name) {
return true
}
for _, character := range name {
if (character >= 'a' && character <= 'z') ||
(character >= 'A' && character <= 'Z') ||
(character >= '0' && character <= '9') || character == '_' || character == '-' {
continue
}
return false
}
return true
}
func loadProfileDocument(source Source, context *FilesystemContext, main parsedSource, base *composedDocument, sources []SourceInfo, name string, rule *VersionRule, limits importLimits) (*composedDocument, []SourceInfo, []Diagnostic) {
rootPath, _, _, diagnostic := importFilesystem(source, context, main)
if diagnostic != nil {
return nil, cloneSourceInfos(sources), []Diagnostic{*diagnostic}
}
profilePath := filepath.Join(rootPath, "profiles", name+".yml")
canonical, err := canonicalExistingPath(profilePath)
if err != nil {
code := CodeProfileInvalid
message := "selected profile could not be resolved"
if errors.Is(err, fs.ErrNotExist) {
code, message = CodeProfileMissing, "selected profile does not exist"
} else if errors.Is(err, fs.ErrPermission) {
code, message = CodeImportPermission, "selected profile cannot be accessed"
}
return nil, cloneSourceInfos(sources), []Diagnostic{importDiagnostic(code, message, nil)}
}
if !containedPath(rootPath, canonical) {
return nil, cloneSourceInfos(sources), []Diagnostic{importDiagnostic(CodeProfileOutsideRoot, "selected profile resolves outside the configuration root", nil)}
}
for _, existing := range sources {
if existing.Path != "" && existing.Path == canonical {
return nil, cloneSourceInfos(sources), []Diagnostic{importDiagnostic(CodeProfileRepeated, "selected profile is already present in the base source graph", nil)}
}
}
if len(sources)+1 > limits.sources {
return nil, cloneSourceInfos(sources), []Diagnostic{importDiagnostic(CodeProfileLimitExceeded, "selected profile exceeds the source-count limit", nil)}
}
root, err := os.OpenRoot(rootPath)
if err != nil {
value := globFilesystemDiagnostic(err, "configuration root could not be opened for the selected profile", Location{})
value.Primary = nil
return nil, cloneSourceInfos(sources), []Diagnostic{value}
}
rootClosed := false
defer func() {
if !rootClosed {
_ = root.Close()
}
}()
relative, err := filepath.Rel(rootPath, canonical)
if err != nil || filepath.IsAbs(relative) || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
return nil, cloneSourceInfos(sources), []Diagnostic{importDiagnostic(CodeProfileOutsideRoot, "selected profile could not be addressed inside the configuration root", nil)}
}
sourceID := "source-" + strconv.Itoa(len(sources)+1)
profileLocation := &Location{SourceID: sourceID}
profileSource := SourceInfo{ID: sourceID, Label: filepath.ToSlash(filepath.Join("profiles", name+".yml")), Path: canonical, Layer: LayerProfile}
sources = append(cloneSourceInfos(sources), profileSource)
data, diagnostic := readProfileFile(root, relative, sourceID, limits.bytes-base.inputBytes)
if diagnostic != nil {
return nil, sources, []Diagnostic{*diagnostic}
}
profileRoot, issues := yamladapter.Parse(bytes.NewReader(data), sourceID, yamladapter.Limits{Depth: maxDocumentDepth, Nodes: maxDocumentNodes})
if len(issues) != 0 {
return nil, sources, parserDiagnostics(sourceID, issues)
}
nodeCount := countInputNodes(profileRoot)
if base.inputNodes+nodeCount > limits.nodes {
return nil, sources, []Diagnostic{importDiagnostic(CodeProfileLimitExceeded, "selected profile exceeds the aggregate node limit", profileLocation)}
}
if diagnostics := profileDirectiveDiagnostics(profileRoot, Path{}); len(diagnostics) != 0 {
return nil, sources, diagnostics
}
if versionNode := fragmentVersionClaim(profileRoot, rule); versionNode != nil {
location := publicLocation(versionNode.Location)
return nil, sources, []Diagnostic{{
Code: CodeProfileVersion, Severity: SeverityError, Path: rule.Path.clone(),
Message: "profiles cannot contribute to or replace the schema-version path", Primary: &location,
}}
}
closeErr := root.Close()
rootClosed = true
if closeErr != nil {
return nil, sources, []Diagnostic{importDiagnostic(CodeImportRead, "configuration root could not be closed after reading the selected profile", nil)}
}
profiled, diagnostics := overlayDocuments(base, profileRoot)
if profiled != nil {
profiled.inputBytes = base.inputBytes + int64(len(data))
profiled.inputNodes = base.inputNodes + nodeCount
}
return profiled, sources, diagnostics
}
func readProfileFile(root *os.Root, relative, sourceID string, remaining int64) ([]byte, *Diagnostic) {
limit := min(int64(maxSourceBytes), remaining)
if limit < 0 {
limit = 0
}
data, failure := readRegularFile(relative, root.Stat, root.Open, limit)
if failure != nil {
value := sourceReadDiagnostic(failure, sourceID, CodeProfileLimitExceeded, "selected profile exceeds a source or aggregate byte limit")
return nil, &value
}
return data, nil
}

267
profile_test.go Normal file
View File

@@ -0,0 +1,267 @@
package configkit
import (
"errors"
"os"
"path/filepath"
"reflect"
"runtime"
"testing"
)
type profileTestConfig struct {
Settings struct {
Kept string `yaml:"kept"`
Changed string `yaml:"changed"`
Added string `yaml:"added"`
} `yaml:"settings"`
Values []string `yaml:"values"`
Nullable *string `yaml:"nullable"`
Value any `yaml:"value"`
Schema map[string]string `yaml:"schema"`
}
func TestLoadAppliesSelectedProfileAfterBaseAndBeforeDefaults(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
imports: [{path: base.yml}]
settings:
kept: main
changed: base
values: [base, stale]
nullable: base
value: {child: base}
`)
basePath := writeImportFile(t, directory, "base.yml", "settings: {added: imported}\n")
profilePath := writeImportFile(t, directory, "profiles/testing.yml", `settings:
changed: profile
values: [profile]
nullable: null
value: scalar
`)
result, err := Load[profileTestConfig](File(mainPath), Options[profileTestConfig]{
Imports: true,
Profile: "testing",
Definition: Definition[profileTestConfig]{Defaults: func() profileTestConfig {
defaultValue := "default"
return profileTestConfig{
Values: []string{"default"}, Nullable: &defaultValue,
Settings: struct {
Kept string `yaml:"kept"`
Changed string `yaml:"changed"`
Added string `yaml:"added"`
}{Kept: "default", Changed: "default", Added: "default"},
}
}},
})
if err != nil {
t.Fatal(err)
}
if result.Config.Settings.Kept != "main" || result.Config.Settings.Changed != "profile" || result.Config.Settings.Added != "imported" {
t.Fatalf("settings = %#v", result.Config.Settings)
}
if !reflect.DeepEqual(result.Config.Values, []string{"profile"}) || result.Config.Nullable != nil || result.Config.Value != "scalar" {
t.Fatalf("profile values = values %#v nullable %#v value %#v", result.Config.Values, result.Config.Nullable, result.Config.Value)
}
sources := result.Sources()
wantPaths := []string{canonicalTestPath(t, mainPath), canonicalTestPath(t, basePath), canonicalTestPath(t, profilePath)}
gotPaths := make([]string, len(sources))
for index, source := range sources {
gotPaths[index] = source.Path
}
if !reflect.DeepEqual(gotPaths, wantPaths) || sources[2].Layer != LayerProfile {
t.Fatalf("sources = %#v", sources)
}
changed, _ := result.Metadata().Lookup(Keys("settings", "changed"))
kept, _ := result.Metadata().Lookup(Keys("settings", "kept"))
if changed.Origin.Layer != LayerProfile || changed.Origin.Locations[0].SourceID != "source-3" || kept.Origin.Layer != LayerBase {
t.Fatalf("origins = changed %#v kept %#v", changed, kept)
}
if _, ok := result.Metadata().Lookup(Keys("value", "child")); ok {
t.Fatal("removed child remains effective")
}
childHistory := result.Metadata().History(Keys("value", "child"))
if len(childHistory) < 2 || childHistory[len(childHistory)-1].Path.String() != "/value" || childHistory[len(childHistory)-1].Origin.Layer != LayerProfile {
t.Fatalf("removed child history = %#v", childHistory)
}
if _, ok := result.Metadata().Lookup(Keys("values").Index(1)); ok {
t.Fatal("removed sequence index remains effective")
}
indexHistory := result.Metadata().History(Keys("values").Index(1))
if len(indexHistory) < 2 || indexHistory[len(indexHistory)-1].Path.String() != "/values" {
t.Fatalf("removed sequence history = %#v", indexHistory)
}
}
func TestLoadProfileSelectionUsesOneFixedYAMLFile(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "settings: {kept: base}\n")
writeImportFile(t, directory, "profiles/testing.yml", "settings: {changed: selected}\n")
writeImportFile(t, directory, "profiles/default.yml", "settings: {changed: forbidden}\n")
for _, name := range []string{"", "default"} {
result, err := Load[profileTestConfig](File(mainPath), Options[profileTestConfig]{Profile: name})
if err != nil || result.Config.Settings.Changed != "" || len(result.Sources()) != 1 {
t.Fatalf("inactive profile %q = %#v, %v", name, result, err)
}
}
selected, err := Load[profileTestConfig](File(mainPath), Options[profileTestConfig]{Profile: "testing"})
if err != nil || selected.Config.Settings.Changed != "selected" || len(selected.Sources()) != 2 {
t.Fatalf("selected profile = %#v, %v", selected, err)
}
for _, name := range []string{"../testing", "testing.yml", "one,two", "with space", "é", "bad\x00name"} {
t.Run(name, func(t *testing.T) {
requireProfileLoadCode(t, File(mainPath), Options[profileTestConfig]{Profile: name}, CodeOptionsInvalid)
})
}
requireProfileLoadCode(t, File(mainPath), Options[profileTestConfig]{Profile: "missing"}, CodeProfileMissing)
writeImportFile(t, directory, "profiles/fallback.yaml", "settings: {changed: fallback}\n")
requireProfileLoadCode(t, File(mainPath), Options[profileTestConfig]{Profile: "fallback"}, CodeProfileMissing)
}
func TestLoadProfileRejectsDirectivesVersionsAndBaseAliases(t *testing.T) {
versionRule := &VersionRule{Path: Keys("schema", "version"), Check: func(version Version, _ *Validation) error {
if !version.Present || version.Text != "v1" {
return errors.New("unexpected version")
}
return nil
}}
for _, test := range []struct {
name string
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: "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")
writeImportFile(t, directory, "profiles/testing.yml", test.profile)
requireProfileLoadCode(t, File(mainPath), Options[profileTestConfig]{Profile: "testing", Definition: Definition[profileTestConfig]{Version: versionRule}}, test.code)
})
}
t.Run("mapping sibling is allowed", func(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "schema: {version: v1}\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" {
t.Fatalf("mapping sibling = %#v, %v", result, err)
}
})
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")
writeImportFile(t, directory, "profiles/testing.yml", "settings: {changed: value}\n")
requireProfileLoadCode(t, File(mainPath), Options[profileTestConfig]{Imports: true, Profile: "testing"}, CodeProfileRepeated)
})
}
func TestLoadProfileKeepsBaseFailuresAndFilesystemContainment(t *testing.T) {
t.Run("base parse precedes profile", func(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "settings: [\n")
writeImportFile(t, directory, "profiles/testing.yml", "settings: {changed: profile}\n")
requireProfileLoadCode(t, File(mainPath), Options[profileTestConfig]{Profile: "testing"}, CodeYAMLInvalid)
})
t.Run("profile parse", func(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "{}\n")
writeImportFile(t, directory, "profiles/testing.yml", "settings: [\n")
loadError := requireProfileLoadCode(t, File(mainPath), Options[profileTestConfig]{Profile: "testing"}, CodeYAMLInvalid)
if loadError.Diagnostics()[0].Primary.SourceID != "source-2" || len(loadError.Sources()) != 2 {
t.Fatalf("profile parse attribution = %#v / %#v", loadError.Diagnostics(), loadError.Sources())
}
})
if runtime.GOOS == "linux" || runtime.GOOS == "darwin" {
t.Run("symlink escape", func(t *testing.T) {
root := t.TempDir()
outside := t.TempDir()
outsideProfile := writeImportFile(t, outside, "testing.yml", "settings: {changed: outside}\n")
if err := os.Mkdir(filepath.Join(root, "profiles"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outsideProfile, filepath.Join(root, "profiles/testing.yml")); err != nil {
t.Skipf("symlinks unavailable: %v", err)
}
mainPath := writeImportFile(t, root, "main.yml", "{}\n")
requireProfileLoadCode(t, File(mainPath), Options[profileTestConfig]{Profile: "testing"}, CodeProfileOutsideRoot)
})
}
t.Run("byte source context", func(t *testing.T) {
directory := t.TempDir()
writeImportFile(t, directory, "profiles/testing.yml", "settings: {changed: selected}\n")
result, err := Load[profileTestConfig](Bytes("s3://bucket/main.yml", []byte("{}\n")), Options[profileTestConfig]{
Profile: "testing", Filesystem: &FilesystemContext{BaseDir: directory, Root: directory},
})
if err != nil || result.Config.Settings.Changed != "selected" {
t.Fatalf("byte profile = %#v, %v", result, err)
}
requireProfileLoadCode(t, Bytes("memory.yml", []byte("{}\n")), Options[profileTestConfig]{Profile: "testing"}, CodeOptionsInvalid)
})
}
func TestLoadProfileCountsAgainstAggregateBudgets(t *testing.T) {
for _, test := range []struct {
name string
limits func(parsedSource, int) importLimits
}{
{name: "source count", limits: func(_ parsedSource, _ int) importLimits {
return importLimits{sources: 1, bytes: maxTotalBytes, nodes: maxDocumentNodes}
}},
{name: "aggregate bytes", limits: func(main parsedSource, profileBytes int) importLimits {
return importLimits{sources: 2, bytes: main.bytes + int64(profileBytes) - 1, nodes: maxDocumentNodes}
}},
{name: "aggregate nodes", limits: func(main parsedSource, _ int) importLimits {
return importLimits{sources: 2, bytes: maxTotalBytes, nodes: countInputNodes(main.document) + 1}
}},
} {
t.Run(test.name, func(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "{}\n")
profileData := "settings: {changed: selected}\n"
writeImportFile(t, directory, "profiles/testing.yml", profileData)
main, diagnostics := parseSource(File(mainPath), "source-1", defaultParseLimits())
if len(diagnostics) != 0 {
t.Fatal(diagnostics)
}
base, diagnostics := composeDocuments(main.document)
if len(diagnostics) != 0 {
t.Fatal(diagnostics)
}
base.inputBytes = main.bytes
base.inputNodes = countInputNodes(main.document)
_, _, diagnostics = loadProfileDocument(File(mainPath), nil, main, base, sourceInfos(main.source), "testing", nil, test.limits(main, len(profileData)))
if len(diagnostics) == 0 || diagnostics[0].Code != CodeProfileLimitExceeded {
t.Fatalf("diagnostics = %#v", diagnostics)
}
})
}
}
func requireProfileLoadCode(t *testing.T, source Source, options Options[profileTestConfig], code Code) *LoadError {
t.Helper()
result, err := Load[profileTestConfig](source, options)
if result != nil || err == nil {
t.Fatalf("result, error = %#v, %v", result, err)
}
var loadError *LoadError
if !errors.As(err, &loadError) {
t.Fatalf("error type = %T", err)
}
diagnostics := loadError.Diagnostics()
if len(diagnostics) == 0 || diagnostics[0].Code != code {
t.Fatalf("diagnostics = %#v, want %s", diagnostics, code)
}
return loadError
}

248
projection.go Normal file
View File

@@ -0,0 +1,248 @@
package configkit
import (
"cmp"
"encoding"
"math"
"reflect"
"slices"
"strconv"
"time"
"unicode/utf8"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
func projectConfiguration(schema *schemaDescriptor, value reflect.Value, limits projectionLimits) (*document.Node, *projectionIssue) {
if schema == nil || schema.root == nil || !value.IsValid() || value.Type() != schema.root.typeOf {
return nil, &projectionIssue{code: CodeSchemaUnsupportedType, message: "configuration value does not match its schema"}
}
operation := valueOperation{schema: schema, limits: limits, active: make(map[valueVisit]bool)}
return operation.project(value, schema.root, Path{}, 0)
}
func (o *valueOperation) project(value reflect.Value, descriptor *typeDescriptor, path Path, depth int) (*document.Node, *projectionIssue) {
if issue := o.checkDepth(path, depth); issue != nil {
return nil, issue
}
if descriptor.kind == descriptorPointer {
if value.IsNil() {
return o.node(document.Null, path, depth)
}
leave, issue := o.enter(value, path)
if issue != nil {
return nil, issue
}
defer leave()
return o.project(value.Elem(), descriptor.element, path, depth)
}
switch descriptor.kind {
case descriptorStruct:
result, issue := o.node(document.Mapping, path, depth)
if issue != nil {
return nil, issue
}
result.Mapping = make([]document.Entry, 0, len(descriptor.fields))
seen := make(map[string]bool, len(descriptor.fields))
for _, member := range descriptor.members {
memberValue := value.Field(member.index)
if member.inline {
if isNilValue(memberValue) {
continue
}
inline, issue := o.project(memberValue, member.descriptor, path, depth)
if issue != nil {
return nil, issue
}
if inline.Kind != document.Mapping {
return nil, &projectionIssue{code: CodeSchemaUnsupportedType, path: path.clone(), message: "inline value did not project to a mapping"}
}
o.nodes-- // The flattened container is not retained in the output tree.
for _, entry := range inline.Mapping {
if seen[entry.Key] {
return nil, &projectionIssue{code: CodeSchemaAmbiguousField, path: path.Key(entry.Key), message: "inline value conflicts with a declared field"}
}
seen[entry.Key] = true
result.Mapping = append(result.Mapping, entry)
}
continue
}
childPath := path.Key(member.name)
if seen[member.name] {
return nil, &projectionIssue{code: CodeSchemaAmbiguousField, path: childPath, message: "inline value conflicts with a declared field"}
}
if issue := o.charge(childPath); issue != nil { // mapping key
return nil, issue
}
child, issue := o.project(memberValue, member.descriptor, childPath, depth+1)
if issue != nil {
return nil, issue
}
seen[member.name] = true
result.Mapping = append(result.Mapping, document.Entry{Key: member.name, Value: child})
}
for _, entry := range result.Mapping {
field := descriptor.inlineMap
if index, ok := descriptor.fieldByName[entry.Key]; ok {
field = &descriptor.fields[index]
}
if field != nil && field.secret {
entry.Value.Sensitive = true
}
}
return result, nil
case descriptorMap:
if value.IsNil() {
return o.node(document.Null, path, depth)
}
leave, issue := o.enter(value, path)
if issue != nil {
return nil, issue
}
defer leave()
result, issue := o.node(document.Mapping, path, depth)
if issue != nil {
return nil, issue
}
result.Mapping = make([]document.Entry, 0, value.Len())
keys := value.MapKeys()
slices.SortFunc(keys, func(left, right reflect.Value) int {
return cmp.Compare(left.String(), right.String())
})
for _, key := range keys {
childPath := path.Key(key.String())
if issue := o.charge(childPath); issue != nil {
return nil, issue
}
child, issue := o.project(value.MapIndex(key), descriptor.element, childPath, depth+1)
if issue != nil {
return nil, issue
}
result.Mapping = append(result.Mapping, document.Entry{Key: key.String(), Value: child})
}
return result, nil
case descriptorSlice:
if value.IsNil() {
return o.node(document.Null, path, depth)
}
leave, issue := o.enter(value, path)
if issue != nil {
return nil, issue
}
defer leave()
fallthrough
case descriptorArray:
result, issue := o.node(document.Sequence, path, depth)
if issue != nil {
return nil, issue
}
result.Sequence = make([]*document.Node, 0, value.Len())
for i := 0; i < value.Len(); i++ {
childPath := path.Index(i)
child, issue := o.project(value.Index(i), descriptor.element, childPath, depth+1)
if issue != nil {
return nil, issue
}
result.Sequence = append(result.Sequence, child)
}
return result, nil
case descriptorDynamic:
if value.IsNil() {
return o.node(document.Null, path, depth)
}
concrete := value.Elem()
dynamic, issue := o.dynamicDescriptor(concrete.Type(), path)
if issue != nil {
return nil, issue
}
return o.project(concrete, dynamic, path, depth)
case descriptorString:
node, issue := o.node(document.String, path, depth)
if issue == nil {
node.Text = value.String()
}
return node, issue
case descriptorBoolean:
node, issue := o.node(document.Boolean, path, depth)
if issue == nil {
node.Text = strconv.FormatBool(value.Bool())
}
return node, issue
case descriptorSigned:
node, issue := o.node(document.Number, path, depth)
if issue == nil {
node.Text = strconv.FormatInt(value.Int(), 10)
}
return node, issue
case descriptorUnsigned:
node, issue := o.node(document.Number, path, depth)
if issue == nil {
node.Text = strconv.FormatUint(value.Uint(), 10)
}
return node, issue
case descriptorFloat:
if math.IsNaN(value.Float()) || math.IsInf(value.Float(), 0) {
return nil, &projectionIssue{code: CodeSchemaNonFinite, path: path.clone(), message: "configuration float must be finite"}
}
node, issue := o.node(document.Number, path, depth)
if issue == nil {
node.Text = strconv.FormatFloat(value.Float(), 'g', -1, value.Type().Bits())
}
return node, issue
case descriptorDuration:
node, issue := o.node(document.String, path, depth)
if issue == nil {
node.Text = time.Duration(value.Int()).String()
}
return node, issue
case descriptorSecret:
node, issue := o.node(document.String, path, depth)
if issue == nil {
node.Text = value.Interface().(Secret).EnvName()
}
return node, issue
case descriptorText:
text, issue := marshalCanonicalText(value, path)
if issue != nil {
return nil, issue
}
node, nodeIssue := o.node(document.String, path, depth)
if nodeIssue == nil {
node.Text = text
}
return node, nodeIssue
default:
return nil, &projectionIssue{code: CodeSchemaUnsupportedType, path: path.clone(), message: "configuration type is not supported"}
}
}
func (o *valueOperation) node(kind document.Kind, path Path, depth int) (*document.Node, *projectionIssue) {
if issue := o.checkDepth(path, depth); issue != nil {
return nil, issue
}
if issue := o.charge(path); issue != nil {
return nil, issue
}
return &document.Node{Kind: kind}, nil
}
func marshalCanonicalText(value reflect.Value, path Path) (string, *projectionIssue) {
var marshaler encoding.TextMarshaler
if value.CanInterface() {
marshaler, _ = value.Interface().(encoding.TextMarshaler)
}
if marshaler == nil {
addressable := reflect.New(value.Type())
addressable.Elem().Set(value)
marshaler, _ = addressable.Interface().(encoding.TextMarshaler)
}
if marshaler == nil {
return "", &projectionIssue{code: CodeSchemaCodec, path: path.clone(), message: "custom text scalar has no usable text marshaler"}
}
text, err := marshaler.MarshalText()
if err != nil || !utf8.Valid(text) {
return "", &projectionIssue{code: CodeSchemaCodec, path: path.clone(), message: "custom text scalar could not be projected"}
}
return string(text), nil
}

394
projection_test.go Normal file
View File

@@ -0,0 +1,394 @@
package configkit
import (
"errors"
"math"
"reflect"
"strings"
"testing"
"time"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
type mutableText struct {
data []byte
}
func (value mutableText) MarshalText() ([]byte, error) {
return value.data, nil
}
func (value *mutableText) UnmarshalText(text []byte) error {
value.data = append(value.data[:0], text...)
return nil
}
type failingText struct {
failUnmarshal bool
}
func (value failingText) MarshalText() ([]byte, error) {
return []byte("CANARY_CODEC_TEXT"), errors.New("CANARY_MARSHAL_ERROR")
}
func (value *failingText) UnmarshalText([]byte) error {
if value.failUnmarshal {
return errors.New("CANARY_UNMARSHAL_ERROR")
}
return nil
}
type reconstructFailText string
func (value reconstructFailText) MarshalText() ([]byte, error) {
return []byte(value), nil
}
func (value *reconstructFailText) UnmarshalText([]byte) error {
return errors.New("CANARY_RECONSTRUCT_ERROR")
}
func TestProjectionPreservesCanonicalTypesAndNilEmptyDistinctions(t *testing.T) {
t.Parallel()
type nested struct {
Value string `yaml:"value"`
}
type config struct {
Zero int `yaml:"zero"`
Wide uint64 `yaml:"wide"`
Delay time.Duration `yaml:"delay"`
Pointer *nested `yaml:"pointer"`
NilPointer *nested `yaml:"nil_pointer"`
EmptyMap map[string]string `yaml:"empty_map"`
NilMap map[string]string `yaml:"nil_map"`
EmptySlice []string `yaml:"empty_slice"`
NilSlice []string `yaml:"nil_slice"`
Objects map[string]*nested `yaml:"objects"`
Fixed [2]int `yaml:"fixed"`
Custom mutableText `yaml:"custom"`
Dynamic any `yaml:"dynamic"`
Ignored *nested `yaml:"-"`
private *nested
}
value := config{
Wide: math.MaxUint64,
Delay: 1500 * time.Millisecond,
Pointer: &nested{Value: "pointer"},
EmptyMap: map[string]string{},
EmptySlice: []string{},
Objects: map[string]*nested{
"z": {Value: "last"},
"a": {Value: "first"},
},
Fixed: [2]int{1, 2},
Custom: mutableText{data: []byte("canonical")},
Dynamic: map[string]any{"count": int64(9), "items": []any{"x", false}},
Ignored: &nested{Value: "ignored"},
private: &nested{Value: "private"},
}
schema := mustSchemaFor[config](t)
projected, issue := projectConfiguration(schema, reflect.ValueOf(value), defaultProjectionLimits())
if issue != nil {
t.Fatalf("projectConfiguration() issue = %#v", issue)
}
assertDocument(t, mappingValue(t, projected, "zero"), document.Number, "0")
assertDocument(t, mappingValue(t, projected, "wide"), document.Number, "18446744073709551615")
assertDocument(t, mappingValue(t, projected, "delay"), document.String, "1.5s")
assertDocument(t, mappingValue(t, projected, "custom"), document.String, "canonical")
if mappingValue(t, projected, "nil_pointer").Kind != document.Null || mappingValue(t, projected, "nil_map").Kind != document.Null || mappingValue(t, projected, "nil_slice").Kind != document.Null {
t.Fatal("nil values did not project as null")
}
if empty := mappingValue(t, projected, "empty_map"); empty.Kind != document.Mapping || empty.Mapping == nil || len(empty.Mapping) != 0 {
t.Fatalf("empty map = %#v", empty)
}
if empty := mappingValue(t, projected, "empty_slice"); empty.Kind != document.Sequence || empty.Sequence == nil || len(empty.Sequence) != 0 {
t.Fatalf("empty slice = %#v", empty)
}
objects := mappingValue(t, projected, "objects")
if len(objects.Mapping) != 2 || objects.Mapping[0].Key != "a" || objects.Mapping[1].Key != "z" {
t.Fatalf("map order = %#v", objects.Mapping)
}
if hasMappingKey(projected, "ignored") || hasMappingKey(projected, "private") {
t.Fatal("ignored field entered projection")
}
clonedValue, cloneIssue := cloneConfiguration(schema, reflect.ValueOf(value), defaultProjectionLimits())
if cloneIssue != nil {
t.Fatal(cloneIssue.message)
}
cloned := clonedValue.Interface().(config)
if cloned.NilMap != nil || cloned.NilSlice != nil || cloned.NilPointer != nil || cloned.EmptyMap == nil || cloned.EmptySlice == nil {
t.Fatalf("clone lost nil/empty distinctions: %#v", cloned)
}
}
func TestCloneConfigurationDetachesAllSupportedMutableValues(t *testing.T) {
t.Parallel()
type nested struct {
Values []string `yaml:"values"`
}
type config struct {
First *nested `yaml:"first"`
Second *nested `yaml:"second"`
Map map[string]*nested `yaml:"map"`
Slice []map[string]int `yaml:"slice"`
Custom mutableText `yaml:"custom"`
Dynamic any `yaml:"dynamic"`
Ignored *nested `yaml:"-"`
}
shared := &nested{Values: []string{"original"}}
value := config{
First: shared,
Second: shared,
Map: map[string]*nested{"entry": {Values: []string{"map"}}},
Slice: []map[string]int{{"count": 1}},
Custom: mutableText{data: []byte("text")},
Dynamic: map[string]any{
"items": []any{map[string]any{"name": "dynamic"}},
},
Ignored: &nested{Values: []string{"ignored"}},
}
schema := mustSchemaFor[config](t)
clonedValue, issue := cloneConfiguration(schema, reflect.ValueOf(value), defaultProjectionLimits())
if issue != nil {
t.Fatalf("cloneConfiguration() issue = %#v", issue)
}
cloned := clonedValue.Interface().(config)
if cloned.First == cloned.Second || cloned.First == shared || cloned.Custom.data == nil || &cloned.Custom.data[0] == &value.Custom.data[0] {
t.Fatal("clone retained mutable identity")
}
if cloned.Ignored != nil {
t.Fatal("ignored field was retained")
}
value.First.Values[0] = "changed"
value.Map["entry"].Values[0] = "changed"
value.Slice[0]["count"] = 2
value.Custom.data[0] = 'X'
value.Dynamic.(map[string]any)["items"].([]any)[0].(map[string]any)["name"] = "changed"
if cloned.First.Values[0] != "original" || cloned.Map["entry"].Values[0] != "map" || cloned.Slice[0]["count"] != 1 || string(cloned.Custom.data) != "text" || cloned.Dynamic.(map[string]any)["items"].([]any)[0].(map[string]any)["name"] != "dynamic" {
t.Fatalf("clone changed after source mutation: %#v", cloned)
}
}
func TestProjectionAndCloneRejectCyclesAndInvalidDynamicValues(t *testing.T) {
t.Parallel()
type recursive struct {
Next *recursive `yaml:"next"`
}
type config struct {
Tree *recursive `yaml:"tree"`
Dynamic any `yaml:"dynamic"`
}
cyclic := &recursive{}
cyclic.Next = cyclic
schema := mustSchemaFor[config](t)
acyclic := config{Tree: &recursive{Next: &recursive{}}}
if _, issue := projectConfiguration(schema, reflect.ValueOf(acyclic), defaultProjectionLimits()); issue != nil {
t.Fatalf("acyclic recursive projection issue = %#v", issue)
}
if _, issue := cloneConfiguration(schema, reflect.ValueOf(acyclic), defaultProjectionLimits()); issue != nil {
t.Fatalf("acyclic recursive clone issue = %#v", issue)
}
value := config{Tree: cyclic}
if _, issue := projectConfiguration(schema, reflect.ValueOf(value), defaultProjectionLimits()); issue == nil || issue.code != CodeSchemaCycle || issue.path.String() != "/tree/next" {
t.Fatalf("projection cycle issue = %#v", issue)
}
if _, issue := cloneConfiguration(schema, reflect.ValueOf(value), defaultProjectionLimits()); issue == nil || issue.code != CodeSchemaCycle {
t.Fatalf("clone cycle issue = %#v", issue)
}
dynamicCycle := map[string]any{}
dynamicCycle["self"] = dynamicCycle
value = config{Dynamic: dynamicCycle}
if _, issue := projectConfiguration(schema, reflect.ValueOf(value), defaultProjectionLimits()); issue == nil || issue.code != CodeSchemaCycle {
t.Fatalf("dynamic cycle issue = %#v", issue)
}
value = config{Dynamic: make(chan int)}
if _, issue := projectConfiguration(schema, reflect.ValueOf(value), defaultProjectionLimits()); issue == nil || issue.code != CodeSchemaDynamicType {
t.Fatalf("dynamic type issue = %#v", issue)
}
if _, issue := cloneConfiguration(schema, reflect.ValueOf(value), defaultProjectionLimits()); issue == nil || issue.code != CodeSchemaDynamicType {
t.Fatalf("dynamic clone issue = %#v", issue)
}
}
func TestProjectionRejectsNonFiniteLimitsAndCodecFailuresSafely(t *testing.T) {
t.Parallel()
type numbers struct {
Value float64 `yaml:"value"`
}
numberSchema := mustSchemaFor[numbers](t)
for _, value := range []float64{math.NaN(), math.Inf(1), math.Inf(-1)} {
if _, issue := projectConfiguration(numberSchema, reflect.ValueOf(numbers{Value: value}), defaultProjectionLimits()); issue == nil || issue.code != CodeSchemaNonFinite {
t.Fatalf("non-finite issue = %#v", issue)
}
}
type codecConfig struct {
Value failingText `yaml:"value"`
}
codecSchema := mustSchemaFor[codecConfig](t)
if _, issue := projectConfiguration(codecSchema, reflect.ValueOf(codecConfig{}), defaultProjectionLimits()); issue == nil || issue.code != CodeSchemaCodec || strings.Contains(issue.message, "CANARY") {
t.Fatalf("codec projection issue = %#v", issue)
}
type reconstructConfig struct {
Value reconstructFailText `yaml:"value"`
}
reconstructSchema := mustSchemaFor[reconstructConfig](t)
if _, issue := cloneConfiguration(reconstructSchema, reflect.ValueOf(reconstructConfig{Value: "safe-name"}), defaultProjectionLimits()); issue == nil || issue.code != CodeSchemaCodec || strings.Contains(issue.message, "CANARY") {
t.Fatalf("codec clone issue = %#v", issue)
}
type simple struct {
Value string `yaml:"value"`
}
simpleSchema := mustSchemaFor[simple](t)
if _, issue := projectConfiguration(simpleSchema, reflect.ValueOf(simple{}), projectionLimits{depth: 1, nodes: 3}); issue != nil {
t.Fatalf("exact node limit rejected: %#v", issue)
}
if _, issue := projectConfiguration(simpleSchema, reflect.ValueOf(simple{}), projectionLimits{depth: 1, nodes: 2}); issue == nil || issue.code != CodeSchemaProjectionLimit {
t.Fatalf("node overflow issue = %#v", issue)
}
if _, issue := projectConfiguration(simpleSchema, reflect.ValueOf(simple{}), projectionLimits{depth: 0, nodes: 3}); issue == nil || issue.code != CodeSchemaProjectionLimit {
t.Fatalf("depth overflow issue = %#v", issue)
}
}
func TestInlineProjectionAndCloneUsePhysicalMembers(t *testing.T) {
t.Parallel()
type Embedded struct {
Name string `yaml:"name"`
Items map[string]string `yaml:"items"`
}
type config struct {
*Embedded `yaml:",inline"`
Extras map[string]any `yaml:",inline"`
}
value := config{
Embedded: &Embedded{Name: "value", Items: map[string]string{"a": "b"}},
Extras: map[string]any{"extra": []string{"one"}},
}
schema := mustSchemaFor[config](t)
projected, issue := projectConfiguration(schema, reflect.ValueOf(value), defaultProjectionLimits())
if issue != nil || !hasMappingKey(projected, "name") || !hasMappingKey(projected, "items") || !hasMappingKey(projected, "extra") {
t.Fatalf("inline projection = %#v, issue=%#v", projected, issue)
}
clonedValue, issue := cloneConfiguration(schema, reflect.ValueOf(value), defaultProjectionLimits())
if issue != nil {
t.Fatal(issue.message)
}
cloned := clonedValue.Interface().(config)
value.Items["a"] = "changed"
value.Extras["extra"].([]string)[0] = "changed"
if cloned.Items["a"] != "b" || cloned.Extras["extra"].([]string)[0] != "one" {
t.Fatal("inline clone retained aliases")
}
value.Extras["name"] = "collision"
if _, issue := projectConfiguration(schema, reflect.ValueOf(value), defaultProjectionLimits()); issue == nil || issue.code != CodeSchemaAmbiguousField || issue.path.String() != "/name" {
t.Fatalf("inline collision issue = %#v", issue)
}
}
func TestCompareDocumentTreesTracksSemanticChanges(t *testing.T) {
t.Parallel()
type nested struct {
Value string `yaml:"value"`
}
type config struct {
Nested nested `yaml:"nested"`
Mapping map[string]int `yaml:"mapping"`
Sequence []string `yaml:"sequence"`
Maybe map[string]int `yaml:"maybe"`
}
schema := mustSchemaFor[config](t)
before, issue := projectConfiguration(schema, reflect.ValueOf(config{
Nested: nested{Value: "before"},
Mapping: map[string]int{"remove": 1, "same": 2},
Sequence: []string{"a", "b"},
}), defaultProjectionLimits())
if issue != nil {
t.Fatal(issue.message)
}
after, issue := projectConfiguration(schema, reflect.ValueOf(config{
Nested: nested{Value: "after"},
Mapping: map[string]int{"add": 3, "same": 2},
Sequence: []string{"a", "changed"},
Maybe: map[string]int{},
}), defaultProjectionLimits())
if issue != nil {
t.Fatal(issue.message)
}
deltas := compareConfigurationValues(before, after)
want := []struct {
path string
kind documentDeltaKind
}{
{path: "/mapping/add", kind: documentAdded},
{path: "/mapping/remove", kind: documentRemoved},
{path: "/maybe", kind: documentReplaced},
{path: "/nested/value", kind: documentReplaced},
{path: "/sequence", kind: documentReplaced},
}
if len(deltas) != len(want) {
t.Fatalf("deltas = %#v", deltas)
}
for i := range want {
if deltas[i].path.String() != want[i].path || deltas[i].kind != want[i].kind {
t.Fatalf("delta[%d] = %#v, want %#v", i, deltas[i], want[i])
}
}
for _, delta := range deltas {
if strings.HasPrefix(delta.path.String(), "/sequence/") {
t.Fatal("sequence comparison created index correspondence")
}
}
if equal := compareConfigurationValues(after, document.Clone(after)); len(equal) != 0 {
t.Fatalf("equal trees changed = %#v", equal)
}
}
func mustSchemaFor[T any](t *testing.T) *schemaDescriptor {
t.Helper()
schema, issue := buildSchema(reflect.TypeFor[T]())
if issue != nil {
t.Fatalf("buildSchema() issue = %#v", issue)
}
return schema
}
func mappingValue(t *testing.T, node *document.Node, key string) *document.Node {
t.Helper()
for _, entry := range node.Mapping {
if entry.Key == key {
return entry.Value
}
}
t.Fatalf("mapping key %q missing", key)
return nil
}
func hasMappingKey(node *document.Node, key string) bool {
for _, entry := range node.Mapping {
if entry.Key == key {
return true
}
}
return false
}
func assertDocument(t *testing.T, node *document.Node, kind document.Kind, text string) {
t.Helper()
if node.Kind != kind || node.Text != text {
t.Fatalf("node = %#v, want kind=%v text=%q", node, kind, text)
}
}

218
provenance_test.go Normal file
View File

@@ -0,0 +1,218 @@
package configkit
import (
"errors"
"reflect"
"sync"
"testing"
)
type layeredItem struct {
Value string `yaml:"value"`
}
type layeredConfig struct {
Shared struct {
Equal string `yaml:"equal"`
Imported string `yaml:"imported"`
Profile string `yaml:"profile"`
} `yaml:"shared"`
Values []int `yaml:"values"`
Items map[string]layeredItem `yaml:"items"`
Value any `yaml:"value"`
Nullable *string `yaml:"nullable"`
Derived int `yaml:"derived"`
Empty map[string]string `yaml:"empty"`
}
func TestLayeredProvenanceAgreesWithResolvedSnapshot(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
imports: [{path: imported.yml}]
shared: {equal: same}
values: [1, 2]
items:
keep: {value: 'yes'}
remove: {value: 'no'}
value: {child: base}
nullable: base
empty: {}
`)
importPath := writeImportFile(t, directory, "imported.yml", "shared: {imported: base}\n")
profilePath := writeImportFile(t, directory, "profiles/testing.yml", `shared: {profile: selected}
values: [3, 4]
value: selected
nullable: null
empty: {}
`)
defaultNullable := "default"
definition := Definition[layeredConfig]{
Defaults: func() layeredConfig {
defaults := layeredConfig{
Values: []int{0, 0}, Value: map[string]any{"child": "default"},
Nullable: &defaultNullable, Empty: map[string]string{"retained": "default"},
}
defaults.Shared.Equal = "same"
return defaults
},
Resolve: func(configuration *layeredConfig, resolution *Resolution) error {
Normalize(resolution, Keys("values"), &configuration.Values, []int{5},
"bounded values", Keys("shared", "imported"), Keys("shared", "imported"))
Normalize(resolution, Keys("values"), &configuration.Values, []int{5},
"bounded values", Keys("shared", "imported"))
delete(configuration.Items, "remove")
resolution.Record(Keys("items"), ChangeNormalization, "removed disabled items")
Default(resolution, Keys("derived"), &configuration.Derived, 7,
"derived from equal", Keys("shared", "equal"), Keys("shared", "equal"))
resolution.Warning(Keys("values"), "values were bounded", Keys("shared", "imported"))
return nil
},
}
result, err := Load[layeredConfig](File(mainPath), Options[layeredConfig]{
Imports: true, Profile: "testing", Definition: definition,
})
if err != nil {
var loadError *LoadError
_ = errors.As(err, &loadError)
t.Fatalf("%v: %#v", err, loadError.Diagnostics())
}
wantSources := []string{canonicalTestPath(t, mainPath), canonicalTestPath(t, importPath), canonicalTestPath(t, profilePath)}
sources := result.Sources()
gotSources := make([]string, len(sources))
for index, source := range sources {
gotSources[index] = source.Path
}
if !reflect.DeepEqual(gotSources, wantSources) || sources[2].Layer != LayerProfile {
t.Fatalf("sources = %#v", sources)
}
if result.Config.Shared.Equal != "same" || result.Config.Shared.Imported != "base" || result.Config.Shared.Profile != "selected" ||
!reflect.DeepEqual(result.Config.Values, []int{5}) || result.Config.Nullable != nil || result.Config.Derived != 7 ||
result.Config.Empty["retained"] != "default" {
t.Fatalf("configuration = %#v", result.Config)
}
metadata := result.Metadata()
equal, _ := metadata.Lookup(Keys("shared", "equal"))
derived, _ := metadata.Lookup(Keys("derived"))
nullable, _ := metadata.Lookup(Keys("nullable"))
empty, _ := metadata.Lookup(Keys("empty"))
firstValue, _ := metadata.Lookup(Keys("values").Index(0))
if !equal.Explicit || equal.Origin.Layer != LayerBase || derived.Explicit || derived.Origin.Operation != ChangeDerivedDefault ||
!nullable.Explicit || !nullable.Null || nullable.Origin.Layer != LayerProfile ||
!empty.Explicit || empty.Origin.Layer != LayerProfile || len(empty.Origin.Locations) != 2 ||
!firstValue.Explicit || firstValue.Origin.Operation != ChangeNormalization {
t.Fatalf("metadata = equal %#v derived %#v nullable %#v empty %#v value %#v", equal, derived, nullable, empty, firstValue)
}
if _, ok := metadata.Lookup(Keys("value", "child")); ok {
t.Fatal("profile-removed child remains effective")
}
if _, ok := metadata.Lookup(Keys("values").Index(1)); ok {
t.Fatal("resolution-removed sequence index remains effective")
}
if _, ok := metadata.Lookup(Keys("items", "remove", "value")); ok {
t.Fatal("resolution-removed mapping child remains effective")
}
assertHistoryLayersAndPaths(t, metadata.History(Keys("values").Index(1)),
[]Layer{LayerDefault, LayerBase, LayerProfile, LayerResolution},
[]string{"/values/1", "/values/1", "/values", "/values"})
assertHistoryLayersAndPaths(t, metadata.History(Keys("value", "child")),
[]Layer{LayerDefault, LayerBase, LayerProfile},
[]string{"/value/child", "/value/child", "/value"})
removedHistory := metadata.History(Keys("items", "remove", "value"))
assertHistoryLayersAndPaths(t, removedHistory,
[]Layer{LayerBase, LayerResolution},
[]string{"/items/remove/value", "/items"})
if !reflect.DeepEqual(removedHistory[1].Reasons, []string{"removed disabled items"}) {
t.Fatalf("removed history = %#v", removedHistory)
}
valuesHistory := metadata.History(Keys("values").Index(0))
lastValue := valuesHistory[len(valuesHistory)-1]
if !reflect.DeepEqual(lastValue.Reasons, []string{"bounded values"}) || len(lastValue.Dependencies) != 1 || lastValue.Dependencies[0].String() != "/shared/imported" {
t.Fatalf("canonical resolution history = %#v", lastValue)
}
schema, issue := buildSchema(reflect.TypeFor[layeredConfig]())
if issue != nil {
t.Fatal(issue)
}
projected, projectionIssue := projectConfiguration(schema, reflect.ValueOf(result.Config), defaultProjectionLimits())
if projectionIssue != nil || !configurationValuesEqual(projected, result.final) {
t.Fatalf("typed projection and private snapshot disagree: %#v", projectionIssue)
}
metadata.history[Keys("values").Index(0).String()][0].Origin.Layer = "mutated"
sources[1].ImportedFrom.SourceID = "mutated"
diagnostics := result.Diagnostics()
diagnostics[0].Related[0].SourceID = "mutated"
result.Config.Values[0] = 99
freshHistory := result.Metadata().History(Keys("values").Index(0))
if freshHistory[0].Origin.Layer == "mutated" || result.Sources()[1].ImportedFrom.SourceID == "mutated" ||
result.Diagnostics()[0].Related[0].SourceID == "mutated" {
t.Fatal("returned inspection records alias private state")
}
snapshotValue, ok := documentAtPath(result.final, Keys("values").Index(0))
if !ok || snapshotValue.Text != "5" {
t.Fatal("caller configuration mutation changed the private final snapshot")
}
}
func TestSelectedProfileLoadsRemainIndependentConcurrently(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "values: [1, 2]\n")
writeImportFile(t, directory, "profiles/testing.yml", "values: [3, 4]\n")
options := Options[layeredConfig]{Profile: "testing"}
const loads = 16
results := make(chan *Result[layeredConfig], loads)
errors := make(chan error, loads)
var group sync.WaitGroup
for index := 0; index < loads; index++ {
group.Add(1)
go func() {
defer group.Done()
result, err := Load[layeredConfig](File(mainPath), options)
if err != nil {
errors <- err
return
}
results <- result
}()
}
group.Wait()
close(results)
close(errors)
for err := range errors {
t.Fatal(err)
}
var collected []*Result[layeredConfig]
for result := range results {
collected = append(collected, result)
}
if len(collected) != loads {
t.Fatalf("completed loads = %d", len(collected))
}
collected[0].Config.Values[0] = 99
collected[0].metadata.fields[Keys("values").String()] = FieldInfo{}
for _, result := range collected[1:] {
if !reflect.DeepEqual(result.Config.Values, []int{3, 4}) {
t.Fatalf("concurrent result contaminated = %#v", result.Config.Values)
}
if info, ok := result.Metadata().Lookup(Keys("values")); !ok || info.Origin.Layer != LayerProfile {
t.Fatalf("concurrent metadata contaminated = %#v, %v", info, ok)
}
}
}
func assertHistoryLayersAndPaths(t *testing.T, history []Change, layers []Layer, paths []string) {
t.Helper()
if len(history) != len(layers) || len(history) != len(paths) {
t.Fatalf("history = %#v", history)
}
for index, change := range history {
if change.Origin.Layer != layers[index] || change.Path.String() != paths[index] {
t.Fatalf("history[%d] = %#v", index, change)
}
}
}

286
records.go Normal file
View File

@@ -0,0 +1,286 @@
package configkit
// Layer identifies the precedence layer responsible for a configuration value.
type Layer string
const (
LayerDefault Layer = "default"
LayerBase Layer = "base"
LayerProfile Layer = "profile"
LayerCLI Layer = "cli"
LayerResolution Layer = "resolution"
)
// ChangeKind identifies a recorded operation. The zero value is
// used for input and default origins.
type ChangeKind string
const (
// ChangeDecoding records the built-in transition to canonical typed values.
// It is inspection metadata, not an operation accepted by Resolution.Record.
ChangeDecoding ChangeKind = "decoding"
ChangeDerivedDefault ChangeKind = "derived_default"
ChangeNormalization ChangeKind = "normalization"
)
// Severity classifies a diagnostic.
type Severity string
const (
SeverityError Severity = "error"
SeverityWarning Severity = "warning"
)
// Code is a stable, machine-readable diagnostic classification.
type Code string
const (
CodeSourceInvalid Code = "source.invalid"
CodeSourceMissing Code = "source.missing"
CodeSourcePermission Code = "source.permission"
CodeSourceRead Code = "source.read"
CodeSourceNotRegular Code = "source.not_regular"
CodeSourceLimitExceeded Code = "source.limit_exceeded"
CodeSourceReaderUsed Code = "source.reader_used"
CodeYAMLInvalid Code = "yaml.invalid"
CodeYAMLEmpty Code = "yaml.empty"
CodeYAMLMultipleDocuments Code = "yaml.multiple_documents"
CodeYAMLRootType Code = "yaml.root_type"
CodeYAMLDuplicateKey Code = "yaml.duplicate_key"
CodeYAMLTag Code = "yaml.tag"
CodeYAMLAnchor Code = "yaml.anchor"
CodeYAMLAlias Code = "yaml.alias"
CodeYAMLMergeKey Code = "yaml.merge_key"
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"
CodeSchemaCycle Code = "schema.cycle"
CodeSchemaProjectionLimit Code = "schema.projection_limit"
CodeSchemaCodec Code = "schema.codec"
CodeSchemaNonFinite Code = "schema.non_finite"
CodeSchemaDynamicType Code = "schema.dynamic_type"
CodeDecodeType Code = "decode.type"
CodeDecodeNull Code = "decode.null"
CodeDecodeRange Code = "decode.range"
CodeDecodeDuration Code = "decode.duration"
CodeDecodeText Code = "decode.text"
CodeDecodeUnknownField Code = "decode.unknown_field"
CodeDecodeArrayLength Code = "decode.array_length"
CodeOptionsInvalid Code = "options.invalid"
CodeOptionsUnavailable Code = "options.unavailable"
CodeImportDisabled Code = "import.disabled"
CodeImportInvalid Code = "import.invalid"
CodeImportMissing Code = "import.missing"
CodeImportPermission Code = "import.permission"
CodeImportRead Code = "import.read"
CodeImportOutsideRoot Code = "import.outside_root"
CodeImportCycle Code = "import.cycle"
CodeImportRepeated Code = "import.repeated"
CodeImportLimitExceeded Code = "import.limit_exceeded"
CodeImportVersion Code = "import.version"
CodeProfileInvalid Code = "profile.invalid"
CodeProfileMissing Code = "profile.missing"
CodeProfileOutsideRoot Code = "profile.outside_root"
CodeProfileRepeated Code = "profile.repeated"
CodeProfileDirective Code = "profile.directive"
CodeProfileVersion Code = "profile.version"
CodeProfileLimitExceeded Code = "profile.limit_exceeded"
CodeComposeInvalid Code = "compose.invalid"
CodeComposeConflict Code = "compose.conflict"
CodeVersionType Code = "version.type"
CodeVersionError Code = "version.error"
CodeVersionWarning Code = "version.warning"
CodeVersionHookFailed Code = "version.hook_failed"
CodeResolveError Code = "resolve.error"
CodeResolveWarning Code = "resolve.warning"
CodeResolveHookFailed Code = "resolve.hook_failed"
CodeResolveInvalidPath Code = "resolve.invalid_path"
CodeResolveInvalidKind Code = "resolve.invalid_kind"
CodeResolveInvalidRecord Code = "resolve.invalid_record"
CodeResolveInvalidTarget Code = "resolve.invalid_target"
CodeResolveInvalidValue Code = "resolve.invalid_value"
CodeResolveUnrecordedChange Code = "resolve.unrecorded_change"
CodeValidateError Code = "validate.error"
CodeValidateWarning Code = "validate.warning"
CodeValidateInvalidPath Code = "validate.invalid_path"
CodeValidateMutation Code = "validate.mutation"
CodeSecretsInvalidPath Code = "secrets.invalid_path"
CodeSecretsMissing Code = "secrets.missing"
CodeSecretsPermission Code = "secrets.permission"
CodeSecretsRead Code = "secrets.read"
CodeSecretsNotRegular Code = "secrets.not_regular"
CodeSecretsInvalidName Code = "secrets.invalid_name"
CodeSecretsLimitExceeded Code = "secrets.limit_exceeded"
CodeSecretsInvalidValue Code = "secrets.invalid_value"
CodeSecretsApplyFailed Code = "secrets.apply_failed"
CodeSecretRequired Code = "secret.required"
CodeSecretInvalidName Code = "secret.invalid_name"
CodeSecretUnset Code = "secret.unset"
CodeSecretEmpty Code = "secret.empty"
CodeOverrideInvalidPath Code = "override.invalid_path"
CodeOverrideInvalidLabel Code = "override.invalid_label"
CodeOverrideInvalidValue Code = "override.invalid_value"
CodeOverrideDuplicate Code = "override.duplicate"
CodeOverrideOverlap Code = "override.overlap"
CodeOverrideDirective Code = "override.directive"
CodeOverrideVersion Code = "override.version"
CodeOverrideLimitExceeded Code = "override.limit_exceeded"
CodeExplainLimitExceeded Code = "explain.limit_exceeded"
)
// Location identifies a position in a configuration source. Zero line and column
// values mean that coordinates are unavailable or synthetic.
type Location struct {
SourceID string
Line int
Column int
}
// SourceInfo describes a source discovered during a load. Path is a canonical
// local path when one exists. ImportedFrom identifies the declaration that
// included this source.
type SourceInfo struct {
ID string
Label string
Path string
Layer Layer
ImportedFrom *Location
}
// Diagnostic is a safe structured problem report. Message and source labels must
// never contain configuration values or other secrets.
type Diagnostic struct {
Code Code
Severity Severity
Path Path
Message string
Primary *Location
Related []Location
}
// Origin describes where an effective definition came from. Multiple locations
// represent a composite origin; resolution origins are synthetic and identify their
// operation instead of a file location.
type Origin struct {
Layer Layer
Locations []Location
Operation ChangeKind
}
// FieldInfo describes an effective configuration path.
type FieldInfo struct {
Explicit bool
Null bool
Origin Origin
}
// Change describes a definition or application-resolution operation. Path can name
// an ancestor operation affecting the queried descendant. Historical values are
// intentionally not exposed by this record.
type Change struct {
Path Path
Origin Origin
Previous []Origin
Reasons []string
Dependencies []Path
}
func cloneLocation(location *Location) *Location {
if location == nil {
return nil
}
cloned := *location
return &cloned
}
func cloneLocations(locations []Location) []Location {
if locations == nil {
return nil
}
return append([]Location(nil), locations...)
}
func cloneSourceInfo(source SourceInfo) SourceInfo {
source.ImportedFrom = cloneLocation(source.ImportedFrom)
return source
}
func cloneSourceInfos(sources []SourceInfo) []SourceInfo {
if sources == nil {
return nil
}
cloned := make([]SourceInfo, len(sources))
for i, source := range sources {
cloned[i] = cloneSourceInfo(source)
}
return cloned
}
func cloneOrigin(origin Origin) Origin {
origin.Locations = cloneLocations(origin.Locations)
return origin
}
func cloneFieldInfo(info FieldInfo) FieldInfo {
info.Origin = cloneOrigin(info.Origin)
return info
}
func cloneChange(change Change) Change {
change.Path = change.Path.clone()
change.Origin = cloneOrigin(change.Origin)
if change.Previous != nil {
previous := make([]Origin, len(change.Previous))
for i, origin := range change.Previous {
previous[i] = cloneOrigin(origin)
}
change.Previous = previous
}
change.Reasons = append([]string(nil), change.Reasons...)
if change.Dependencies != nil {
dependencies := make([]Path, len(change.Dependencies))
for i, path := range change.Dependencies {
dependencies[i] = path.clone()
}
change.Dependencies = dependencies
}
return change
}
func cloneDiagnostic(diagnostic Diagnostic) Diagnostic {
diagnostic.Path = diagnostic.Path.clone()
diagnostic.Primary = cloneLocation(diagnostic.Primary)
diagnostic.Related = cloneLocations(diagnostic.Related)
return diagnostic
}
func cloneDiagnostics(diagnostics []Diagnostic) []Diagnostic {
if diagnostics == nil {
return nil
}
cloned := make([]Diagnostic, len(diagnostics))
for i, diagnostic := range diagnostics {
cloned[i] = cloneDiagnostic(diagnostic)
}
return cloned
}

108
release_check_test.go Normal file
View File

@@ -0,0 +1,108 @@
package configkit
import (
"bytes"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
func TestCheckScriptsHandleFilenamesAndStopOnFailure(t *testing.T) {
for _, scriptName := range []string{"release-check.sh", "check.sh"} {
t.Run(scriptName, func(t *testing.T) {
script, err := os.ReadFile("scripts/" + scriptName)
if err != nil {
t.Fatal(err)
}
for _, name := range []string{"success", "failed-go-check", "untracked-whitespace"} {
fail := name != "success"
t.Run(name, func(t *testing.T) {
root := t.TempDir()
bin := t.TempDir()
log := filepath.Join(bin, "gofmt.args")
calls := filepath.Join(bin, "go.calls")
for _, directory := range []string{"scripts", "nested"} {
if err := os.Mkdir(filepath.Join(root, directory), 0700); err != nil {
t.Fatal(err)
}
}
write := func(path string, data []byte, mode os.FileMode) {
t.Helper()
if err := os.WriteFile(path, data, mode); err != nil {
t.Fatal(err)
}
}
write(filepath.Join(root, "scripts", scriptName), script, 0700)
write(filepath.Join(root, "go.mod"), []byte("module example.invalid/check\n"), 0600)
write(filepath.Join(root, "go.sum"), nil, 0600)
write(filepath.Join(root, "obsolete.go"), []byte("package obsolete\n"), 0600)
files := []string{"nested/a space.go", "nested/a\nnewline.go"}
for _, file := range files {
write(filepath.Join(root, file), []byte("package example\n"), 0600)
}
if name == "untracked-whitespace" {
write(filepath.Join(root, "notes.md"), []byte("trailing space \n"), 0600)
}
// Isolate expensive toolchain commands: this test owns shell orchestration,
// not Go's compiler or the production tests exercised by the real checker.
write(filepath.Join(bin, "go"), []byte("#!/bin/sh\nprintf '%s\\n' \"$*\" >> \"$CONFIGKIT_TEST_GO_LOG\"\nif [ \"${CONFIGKIT_TEST_FAIL-}\" = yes ] && [ \"${1-}\" = test ]; then exit 37; fi\n"), 0700)
write(filepath.Join(bin, "gofmt"), []byte("#!/bin/sh\nprintf '%s\\000' \"$@\" > \"$CONFIGKIT_TEST_FMT_LOG\"\n"), 0700)
init := exec.Command("git", "init", "-q")
init.Dir = root
if out, err := init.CombinedOutput(); err != nil {
t.Fatalf("git init: %v %s", err, out)
}
// HEAD is required by the whitespace gate. Commit only the module baseline.
for _, args := range [][]string{{"add", "go.mod", "go.sum", "obsolete.go"}, {"-c", "user.name=Test", "-c", "user.email=test@example.invalid", "-c", "commit.gpgsign=false", "commit", "-qm", "Test fixture"}} {
cmd := exec.Command("git", args...)
cmd.Dir = root
if out, err := cmd.CombinedOutput(); err != nil {
t.Fatalf("git fixture: %v %s", err, out)
}
}
if err := os.Remove(filepath.Join(root, "obsolete.go")); err != nil {
t.Fatal(err)
}
cmd := exec.Command("/bin/bash", filepath.Join(root, "scripts", scriptName))
cmd.Env = append(os.Environ(), "PATH="+bin+string(os.PathListSeparator)+os.Getenv("PATH"), "CONFIGKIT_BASELINE_TOOLCHAIN=test-baseline", "CONFIGKIT_NEWER_TOOLCHAIN=test-newer", "CONFIGKIT_TEST_FMT_LOG="+log, "CONFIGKIT_TEST_GO_LOG="+calls)
if name == "failed-go-check" {
cmd.Env = append(cmd.Env, "CONFIGKIT_TEST_FAIL=yes")
}
out, err := cmd.CombinedOutput()
if fail {
if err == nil {
t.Fatal("checker ignored failed Go test")
}
} else if err != nil {
t.Fatalf("checker: %v %s", err, out)
}
arguments, err := os.ReadFile(log)
if err != nil {
t.Fatal(err)
}
parts := bytes.Split(arguments, []byte{0})
for _, file := range files {
found := false
for _, arg := range parts {
if string(arg) == file {
found = true
}
}
if !found {
t.Fatalf("filename was split: %q", file)
}
}
invoked, err := os.ReadFile(calls)
if err != nil {
t.Fatal(err)
}
if fail && strings.Contains(string(invoked), "vet ./...") {
t.Fatal("checker continued after failed test")
}
})
}
})
}
}

46
render.go Normal file
View File

@@ -0,0 +1,46 @@
package configkit
import (
"errors"
"io"
"strings"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
yamladapter "gitea.maximumdirect.net/eric/ConfigKit/internal/yaml"
)
var errEffectiveUnavailable = errors.New("configkit: effective configuration is unavailable")
// WriteEffective writes deterministic redacted YAML from the frozen resolved
// snapshot. The output is intended for inspection and is not promised reloadable.
// Writer errors, including short writes, are returned and may leave partial output.
func (r *Result[T]) WriteEffective(output io.Writer) error {
if r == nil || r.final == nil || output == nil {
return errEffectiveUnavailable
}
safe := redactedDocument(r.final, r.secrets, Path{})
return yamladapter.Write(output, safe)
}
func redactedDocument(node *document.Node, secret map[string]bool, path Path) *document.Node {
result, _ := safeSnapshotDocument(node, secret, path)
return result
}
func pathIsSecret(secret map[string]bool, path Path) bool {
if secret[""] {
return true
}
pointer := path.String()
for pointer != "" {
if secret[pointer] {
return true
}
position := strings.LastIndexByte(pointer, '/')
if position <= 0 {
break
}
pointer = pointer[:position]
}
return false
}

250
render_test.go Normal file
View File

@@ -0,0 +1,250 @@
package configkit
import (
"bytes"
"errors"
"fmt"
"io"
"strings"
"sync/atomic"
"testing"
)
const (
taggedCanary = "TAGGED_CANARY_7c65"
dynamicCanary = "DYNAMIC_CANARY_6a12"
historicalCanary = "HISTORICAL_CANARY_91ef"
derivedACanary = "DERIVED_A_CANARY_4c20"
derivedBCanary = "DERIVED_B_CANARY_831d"
)
var renderTextMarshalCalls atomic.Int64
var renderYAMLMarshalCalls atomic.Int64
type renderText string
func (value renderText) MarshalText() ([]byte, error) {
renderTextMarshalCalls.Add(1)
return []byte(value), nil
}
func (value *renderText) UnmarshalText(text []byte) error {
*value = renderText(text)
return nil
}
type renderCredentials struct {
Token string `yaml:"token"`
Nested []string `yaml:"nested"`
}
type renderConfig struct {
Credentials renderCredentials `yaml:"credentials" configkit:"secret"`
Custom renderText `yaml:"custom"`
DerivedA string `yaml:"derived_a"`
DerivedB string `yaml:"derived_b"`
Dynamic any `yaml:"dynamic"`
EmptyMap map[string]string `yaml:"empty_map"`
EmptySequence []string `yaml:"empty_sequence"`
Labels map[string]string `yaml:"labels"`
NilMap map[string]string `yaml:"nil_map"`
NilSequence []string `yaml:"nil_sequence"`
Sequence []string `yaml:"sequence"`
}
func (renderConfig) MarshalYAML() (any, error) {
renderYAMLMarshalCalls.Add(1)
return map[string]string{"forbidden": "application YAML hook ran"}, nil
}
func TestWriteEffectiveRedactsTagsDynamicHistoryAndDependencyCycles(t *testing.T) {
renderTextMarshalCalls.Store(0)
renderYAMLMarshalCalls.Store(0)
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", `credentials:
token: TAGGED_CANARY_7c65
nested: [nested-secret]
custom: custom value
dynamic: {retired: HISTORICAL_CANARY_91ef}
empty_map: {}
empty_sequence: []
labels: {hidden: DYNAMIC_CANARY_6a12, public: visible}
sequence: [second, first]
`)
writeImportFile(t, directory, "profiles/testing.yml", "dynamic: selected\n")
definition := Definition[renderConfig]{
Secret: func(path Path) bool {
return path.String() == "/labels/hidden" || path.String() == "/dynamic/retired"
},
Resolve: func(configuration *renderConfig, resolution *Resolution) error {
Normalize(resolution, Keys("derived_a"), &configuration.DerivedA, derivedACanary,
"derived through cycle", Keys("derived_b"))
Normalize(resolution, Keys("derived_b"), &configuration.DerivedB, derivedBCanary,
"derived from retired secret", Keys("derived_a"), Keys("dynamic"))
resolution.Warning(Keys("derived_a"), "derived output is configured")
return nil
},
}
result, err := Load[renderConfig](File(mainPath), Options[renderConfig]{Definition: definition, Profile: "testing"})
if err != nil {
t.Fatal(err)
}
if result.Config.Credentials.Token != taggedCanary || result.Config.Labels["hidden"] != dynamicCanary ||
result.Config.DerivedA != derivedACanary || result.Config.DerivedB != derivedBCanary {
t.Fatal("typed configuration did not retain trusted values")
}
if !result.secrets["/dynamic/retired"] ||
!result.secrets["/derived_a"] || !result.secrets["/derived_b"] {
t.Fatalf("secret closure = %#v", result.secrets)
}
if renderYAMLMarshalCalls.Load() != 0 {
t.Fatal("loading called an application YAML marshal hook")
}
textCalls := renderTextMarshalCalls.Load()
var output bytes.Buffer
if err := result.WriteEffective(&output); err != nil {
t.Fatal(err)
}
want := "credentials: '[REDACTED]'\ncustom: custom value\nderived_a: '[REDACTED]'\nderived_b: '[REDACTED]'\ndynamic: selected\nempty_map: {}\nempty_sequence: []\nlabels:\n hidden: '[REDACTED]'\n public: visible\nnil_map: null\nnil_sequence: null\nsequence:\n - second\n - first\n"
if output.String() != want {
t.Fatalf("effective output:\n%s\nwant:\n%s", output.String(), want)
}
if renderTextMarshalCalls.Load() != textCalls || renderYAMLMarshalCalls.Load() != 0 {
t.Fatal("rendering called an application marshal hook")
}
for _, canary := range []string{taggedCanary, dynamicCanary, historicalCanary, derivedACanary, derivedBCanary, "nested-secret"} {
if strings.Contains(output.String(), canary) {
t.Fatalf("effective output contains %q", canary)
}
formatted := fmt.Sprintf("%v %#v %v %#v", result.Metadata(), result.Metadata(), result.Diagnostics(), result.Diagnostics())
if strings.Contains(formatted, canary) {
t.Fatalf("metadata formatting contains %q", canary)
}
}
beforeMutation := output.String()
result.Config.Credentials.Token = "MUTATED_SECRET"
result.Config.Labels["hidden"] = "MUTATED_DYNAMIC_SECRET"
result.Config.Sequence[0] = "mutated"
output.Reset()
if err := result.WriteEffective(&output); err != nil || output.String() != beforeMutation {
t.Fatalf("frozen output after caller mutation = %q, %v", output.String(), err)
}
wantWriteError := errors.New("write failed")
if err := result.WriteEffective(errorWriter{err: wantWriteError}); !errors.Is(err, wantWriteError) {
t.Fatalf("writer error = %v", err)
}
if err := result.WriteEffective(shortWriter{}); !errors.Is(err, io.ErrShortWrite) {
t.Fatalf("short writer error = %v", err)
}
var nilResult *Result[renderConfig]
if err := nilResult.WriteEffective(io.Discard); !errors.Is(err, errEffectiveUnavailable) {
t.Fatalf("nil result error = %v", err)
}
}
func TestSecretValuesStayOutOfLoadFailuresAndWarnings(t *testing.T) {
tests := []struct {
name string
input string
definition Definition[renderConfig]
}{
{name: "parse", input: "credentials: [" + taggedCanary + "\n"},
{name: "decode", input: "credentials: " + taggedCanary + "\n"},
{name: "returned hook error", input: "credentials: {token: " + taggedCanary + "}\n", definition: Definition[renderConfig]{
Resolve: func(*renderConfig, *Resolution) error { return errors.New(taggedCanary) },
}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
test.definition.Secret = func(path Path) bool { return path.String() == "/credentials" }
result, err := Load[renderConfig](Bytes("secret-failure.yml", []byte(test.input)), Options[renderConfig]{Definition: test.definition})
if result != nil || err == nil {
t.Fatalf("result, error = %#v, %v", result, err)
}
var loadError *LoadError
if !errors.As(err, &loadError) {
t.Fatalf("error type = %T", err)
}
formatted := fmt.Sprintf("%v %#v %v %#v %v %#v", err, err, loadError.Diagnostics(), loadError.Diagnostics(), loadError.Sources(), loadError.Sources())
if strings.Contains(formatted, taggedCanary) {
t.Fatalf("failure formatting disclosed a secret: %s", formatted)
}
})
}
}
func TestDynamicConcreteTagsRemainSecretThroughHistory(t *testing.T) {
type sensitive struct {
Token string `yaml:"token" configkit:"secret"`
}
type cfg struct {
Payload any `yaml:"payload"`
Derived string `yaml:"derived"`
}
for _, change := range []string{"retain", "replace", "delete", "introduce"} {
t.Run(change, func(t *testing.T) {
const canary = "DYNAMIC_TAG_CANARY"
definition := Definition[cfg]{Defaults: func() cfg { return cfg{Payload: map[string]any{"nested": []any{sensitive{Token: canary}}}} }}
path := Keys("payload", "nested").Index(0).Key("token")
if change == "introduce" {
definition.Defaults = nil
}
definition.Resolve = func(c *cfg, r *Resolution) error {
if change == "introduce" {
Normalize(r, Keys("payload"), &c.Payload, any(map[string]any{"nested": []any{sensitive{Token: canary}}}), "create")
}
if change == "replace" {
Normalize(r, Keys("payload"), &c.Payload, any("public"), "replace")
}
if change == "delete" {
Normalize(r, Keys("payload"), &c.Payload, any(nil), "remove")
}
Normalize(r, Keys("derived"), &c.Derived, canary, "derive", path)
return nil
}
result, err := Load(NoSource(), Options[cfg]{Definition: definition})
if err != nil {
t.Fatal(err)
}
var output bytes.Buffer
if err := result.WriteEffective(&output); err != nil {
t.Fatal(err)
}
for _, query := range []Path{Keys(), path, Keys("derived")} {
explanation, _ := result.Explain(query)
output.WriteString(fmt.Sprintf("%#v", explanation))
}
if strings.Contains(output.String(), canary) {
t.Fatal("dynamic secret escaped inspection")
}
if !result.secrets[path.String()] || !result.secrets["/derived"] {
t.Fatal("dynamic tag or dependency classification missing")
}
})
}
}
func TestValidatorCannotChangeDynamicSensitivityWithoutChangingText(t *testing.T) {
type sensitive struct {
Token string `yaml:"token" configkit:"secret"`
}
type cfg struct {
Payload any `yaml:"payload"`
}
_, err := Load(Bytes("base", []byte("payload: {token: disposable}\n")), Options[cfg]{Definition: Definition[cfg]{Validate: func(c *cfg, v *Validation) {
c.Payload = sensitive{Token: c.Payload.(map[string]any)["token"].(string)}
}}})
var failure *LoadError
if !errors.As(err, &failure) {
t.Fatal("validator changed inspection sensitivity without a mutation error")
}
for _, d := range failure.Diagnostics() {
if d.Code == CodeValidateMutation {
return
}
}
t.Fatal("missing validation mutation diagnostic")
}

344
resolution.go Normal file
View File

@@ -0,0 +1,344 @@
package configkit
import (
"reflect"
"slices"
"strings"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
const closedCallbackContextMessage = "configkit: callback context is no longer active"
type resolutionState struct {
active bool
schema *schemaDescriptor
presence map[string]inputPresence
document *document.Node
records map[string]*resolutionRecord
diagnostics []Diagnostic
}
type resolutionRecord struct {
path Path
kind ChangeKind
reasons []string
dependencies []Path
helperExpected *document.Node
}
type resolutionSnapshot struct {
change Change
before *document.Node
after *document.Node
}
type resolutionResult struct {
final *document.Node
deltas []documentDelta
snapshots []resolutionSnapshot
diagnostics []Diagnostic
}
// IsExplicit reports whether surviving input explicitly defined path.
func (r *Resolution) IsExplicit(path Path) bool {
state := r.activeState()
if !state.validPath(path) {
return false
}
_, explicit := state.presence[path.String()]
return explicit
}
// Record declares the intent for a resolver mutation. A container path covers
// mutations to its descendants.
func (r *Resolution) Record(path Path, kind ChangeKind, reason string, dependencies ...Path) {
state := r.activeState()
state.addRecord(path, kind, reason, dependencies, nil)
}
// Error reports a safe, value-free resolution error at path.
func (r *Resolution) Error(path Path, message string, related ...Path) {
r.activeState().report(SeverityError, path, message, related)
}
// Warning reports a safe, value-free resolution warning at path.
func (r *Resolution) Warning(path Path, message string, related ...Path) {
r.activeState().report(SeverityWarning, path, message, related)
}
// Default assigns and records value only when path was absent from surviving
// input. Static defaults are not considered explicit.
func Default[V any](resolution *Resolution, path Path, target *V, value V, reason string, dependencies ...Path) {
state := resolution.activeState()
descriptor, ok := state.helperDescriptor(path, target)
if !ok {
return
}
if _, explicit := state.presence[path.String()]; explicit {
return
}
*target = value
expected, ok := state.projectHelperValue(path, descriptor, reflect.ValueOf(target).Elem())
if !ok {
return
}
state.addRecord(path, ChangeDerivedDefault, reason, dependencies, expected)
}
// Normalize assigns and records value regardless of input presence.
func Normalize[V any](resolution *Resolution, path Path, target *V, value V, reason string, dependencies ...Path) {
state := resolution.activeState()
descriptor, ok := state.helperDescriptor(path, target)
if !ok {
return
}
*target = value
expected, ok := state.projectHelperValue(path, descriptor, reflect.ValueOf(target).Elem())
if !ok {
return
}
state.addRecord(path, ChangeNormalization, reason, dependencies, expected)
}
func (r *Resolution) activeState() *resolutionState {
if r == nil || r.state == nil || !r.state.active {
panic(closedCallbackContextMessage)
}
return r.state
}
func (state *resolutionState) validPath(path Path) bool {
if _, ok := resolveSchemaPath(state.schema, path); ok {
return true
}
state.addDiagnostic(CodeResolveInvalidPath, path, "resolution path is not valid for the configuration schema")
return false
}
func (state *resolutionState) helperDescriptor(path Path, target any) (*typeDescriptor, bool) {
resolved, ok := resolveSchemaPath(state.schema, path)
if !ok {
state.addDiagnostic(CodeResolveInvalidPath, path, "resolution helper path is not valid for the configuration schema")
return nil, false
}
value := reflect.ValueOf(target)
if !value.IsValid() || value.Kind() != reflect.Pointer || value.IsNil() {
state.addDiagnostic(CodeResolveInvalidTarget, path, "resolution helper target must be a non-nil pointer")
return nil, false
}
if resolved.descriptor.kind != descriptorDynamic && value.Elem().Type() != resolved.descriptor.typeOf {
state.addDiagnostic(CodeResolveInvalidTarget, path, "resolution helper target type does not match its configuration path")
return nil, false
}
return resolved.descriptor, true
}
func (state *resolutionState) projectHelperValue(path Path, descriptor *typeDescriptor, value reflect.Value) (*document.Node, bool) {
if descriptor.kind == descriptorDynamic {
wrapped := reflect.New(descriptor.typeOf).Elem()
if value.IsValid() {
wrapped.Set(value)
}
value = wrapped
}
operation := valueOperation{
schema: state.schema, limits: defaultProjectionLimits(), active: make(map[valueVisit]bool),
}
node, issue := operation.project(value, descriptor, path, 0)
if issue != nil {
state.addDiagnostic(CodeResolveInvalidValue, issue.path, "resolution helper value is not a valid configuration value")
return nil, false
}
return node, true
}
func (state *resolutionState) addRecord(path Path, kind ChangeKind, reason string, dependencies []Path, expected *document.Node) {
if !state.validPath(path) {
return
}
if kind != ChangeDerivedDefault && kind != ChangeNormalization {
state.addDiagnostic(CodeResolveInvalidKind, path, "resolution record has an invalid change kind")
return
}
if reason == "" {
state.addDiagnostic(CodeResolveInvalidRecord, path, "resolution record requires a safe reason")
return
}
for _, dependency := range dependencies {
if !state.validPath(dependency) {
return
}
}
pointer := path.String()
record := state.records[pointer]
if record == nil {
record = &resolutionRecord{path: path.clone(), kind: kind}
state.records[pointer] = record
}
if kind == ChangeNormalization {
record.kind = ChangeNormalization
}
record.reasons = appendUniqueString(record.reasons, reason)
for _, dependency := range dependencies {
record.dependencies = appendUniquePath(record.dependencies, dependency)
}
if expected != nil {
record.helperExpected = document.Clone(expected)
}
}
func (state *resolutionState) report(severity Severity, path Path, message string, related []Path) {
code := CodeResolveError
if severity == SeverityWarning {
code = CodeResolveWarning
}
if !state.validPath(path) {
return
}
diagnostic := Diagnostic{Code: code, Severity: severity, Path: path.clone(), Message: message}
if location, ok := nearestDocumentLocation(state.document, path); ok {
diagnostic.Primary = documentLocationPointer(location)
}
for _, relatedPath := range related {
if !state.validPath(relatedPath) {
continue
}
if location, ok := nearestDocumentLocation(state.document, relatedPath); ok {
diagnostic.Related = append(diagnostic.Related, Location{
SourceID: location.SourceID, Line: location.Line, Column: location.Column,
})
}
}
state.diagnostics = append(state.diagnostics, diagnostic)
}
func (state *resolutionState) addDiagnostic(code Code, path Path, message string) {
diagnostic := Diagnostic{Code: code, Severity: SeverityError, Path: path.clone(), Message: message}
if location, ok := nearestDocumentLocation(state.document, path); ok {
diagnostic.Primary = documentLocationPointer(location)
}
state.diagnostics = append(state.diagnostics, diagnostic)
}
func runResolution[T any](configuration *T, resolver func(*T, *Resolution) error, schema *schemaDescriptor, decoded decodedConfiguration, input *document.Node) resolutionResult {
state := &resolutionState{
active: true, schema: schema, presence: decoded.presence, document: input,
records: make(map[string]*resolutionRecord),
}
context := &Resolution{state: state}
err := resolver(configuration, context)
state.active = false
if err != nil && !hasErrors(state.diagnostics) {
state.addDiagnostic(CodeResolveHookFailed, Path{}, "application resolution failed")
}
if hasErrors(state.diagnostics) {
return resolutionResult{diagnostics: canonicalDiagnostics(state.diagnostics)}
}
after, issue := projectConfiguration(schema, reflect.ValueOf(*configuration), defaultProjectionLimits())
if issue != nil {
state.addDiagnostic(CodeResolveInvalidValue, issue.path, "application resolution produced an invalid configuration value")
return resolutionResult{diagnostics: canonicalDiagnostics(state.diagnostics)}
}
deltas := compareConfigurationValues(decoded.final, after)
ordered := orderedResolutionRecords(state.records)
for _, delta := range deltas {
if coveringResolutionRecord(ordered, delta.path) == nil {
state.addDiagnostic(CodeResolveUnrecordedChange, delta.path, "application resolution changed a value without a covering record")
}
}
for _, record := range ordered {
_, beforePresent := documentAtPath(decoded.final, record.path)
afterNode, afterPresent := documentAtPath(after, record.path)
if !beforePresent && !afterPresent {
state.addDiagnostic(CodeResolveInvalidRecord, record.path, "resolution record does not identify an existing or removed value")
continue
}
if record.helperExpected != nil && (!afterPresent || !configurationValuesEqual(record.helperExpected, afterNode)) {
state.addDiagnostic(CodeResolveInvalidTarget, record.path, "resolution helper assignment does not match its declared configuration path")
}
}
if hasErrors(state.diagnostics) {
return resolutionResult{diagnostics: canonicalDiagnostics(state.diagnostics)}
}
snapshots := make([]resolutionSnapshot, 0, len(ordered))
for _, record := range ordered {
beforeNode, _ := documentAtPath(decoded.final, record.path)
afterNode, _ := documentAtPath(after, record.path)
snapshots = append(snapshots, resolutionSnapshot{
change: Change{
Path: record.path.clone(),
Origin: Origin{Layer: LayerResolution, Operation: record.kind},
Reasons: append([]string(nil), record.reasons...),
Dependencies: clonePaths(record.dependencies),
},
before: document.Clone(beforeNode), after: document.Clone(afterNode),
})
}
return resolutionResult{
final: after, deltas: deltas, snapshots: snapshots,
diagnostics: canonicalDiagnostics(state.diagnostics),
}
}
func orderedResolutionRecords(records map[string]*resolutionRecord) []*resolutionRecord {
ordered := make([]*resolutionRecord, 0, len(records))
for _, record := range records {
ordered = append(ordered, record)
}
slices.SortFunc(ordered, func(left, right *resolutionRecord) int {
return strings.Compare(left.path.String(), right.path.String())
})
return ordered
}
func coveringResolutionRecord(records []*resolutionRecord, path Path) *resolutionRecord {
var covering *resolutionRecord
for _, record := range records {
if pathContains(record.path, path) && (covering == nil || len(record.path.segments) > len(covering.path.segments)) {
covering = record
}
}
return covering
}
func pathContains(container, descendant Path) bool {
if !container.valid() || !descendant.valid() || len(container.segments) > len(descendant.segments) {
return false
}
if container.String() == "" {
return true
}
prefix := container.String()
value := descendant.String()
return value == prefix || strings.HasPrefix(value, prefix+"/")
}
func appendUniqueString(values []string, value string) []string {
if !slices.Contains(values, value) {
return append(values, value)
}
return values
}
func appendUniquePath(paths []Path, path Path) []Path {
for _, existing := range paths {
if existing.String() == path.String() {
return paths
}
}
return append(paths, path.clone())
}
func clonePaths(paths []Path) []Path {
if paths == nil {
return nil
}
cloned := make([]Path, len(paths))
for index, path := range paths {
cloned[index] = path.clone()
}
return cloned
}

Some files were not shown because too many files have changed in this diff Show More