Compare commits
20 Commits
874adaaa18
...
v0.2.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 8a62b42503 | |||
| 244104f84f | |||
| fd8a68d268 | |||
| 8702121fa4 | |||
| 6eb40c5492 | |||
| f5bbc2be5d | |||
| b3e7b62119 | |||
| cd730d5c99 | |||
| 55fd42921a | |||
| 4bd83639a3 | |||
| 994a9ea0e2 | |||
| cb5b4356aa | |||
| 22a03c9f4d | |||
| 6a1c7df4be | |||
| 75a5829132 | |||
| 1cc645f30f | |||
| 633c5cd519 | |||
| fc2b6c81a1 | |||
| a04d32c389 | |||
| 49e04442da |
@@ -33,9 +33,7 @@ func ReadConfig(path string) (Config, error) {
|
|||||||
The [configuration contract](docs/config.md) describes implemented behavior. The
|
The [configuration contract](docs/config.md) describes implemented behavior. The
|
||||||
[consumer adoption guide](docs/consumers/README.md) provides maintained Narratio-,
|
[consumer adoption guide](docs/consumers/README.md) provides maintained Narratio-,
|
||||||
Notarius-, WeatherReporter-, and Seriatim-shaped examples and migration guidance.
|
Notarius-, WeatherReporter-, and Seriatim-shaped examples and migration guidance.
|
||||||
The [initial target record](docs/roadmap/initial.md) links implemented feature owners
|
The [deferred scope](docs/roadmap/deferred.md) records deliberate exclusions. See the
|
||||||
and preserves deliberately deferred scope. The [implementation plan](docs/roadmap/implementation.md)
|
|
||||||
records completion evidence until release-candidate preparation retires it. See the
|
|
||||||
[development guide](docs/development.md) for repository orientation and the
|
[development guide](docs/development.md) for repository orientation and the
|
||||||
[release procedure](docs/release.md) for maintainer-only candidate and publication
|
[release procedure](docs/release.md) for maintainer-only candidate and publication
|
||||||
steps.
|
steps.
|
||||||
|
|||||||
159
clone.go
Normal file
159
clone.go
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
11
compose.go
11
compose.go
@@ -15,6 +15,17 @@ type composedDocument struct {
|
|||||||
inputNodes int
|
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 {
|
type compositionClaim struct {
|
||||||
path Path
|
path Path
|
||||||
kind document.Kind
|
kind document.Kind
|
||||||
|
|||||||
@@ -56,7 +56,7 @@ values: [one, two]
|
|||||||
if len(reversedDiagnostics) != 0 {
|
if len(reversedDiagnostics) != 0 {
|
||||||
t.Fatalf("reversed composeDocuments() diagnostics = %#v", reversedDiagnostics)
|
t.Fatalf("reversed composeDocuments() diagnostics = %#v", reversedDiagnostics)
|
||||||
}
|
}
|
||||||
if deltas := compareDocumentTrees(composed.root, reversed.root); len(deltas) != 0 {
|
if deltas := compareConfigurationValues(composed.root, reversed.root); len(deltas) != 0 {
|
||||||
t.Fatalf("input order changed effective values: %#v", deltas)
|
t.Fatalf("input order changed effective values: %#v", deltas)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ func FuzzCompositionAndOverlay(f *testing.F) {
|
|||||||
if len(reversedDiagnostics) != 0 {
|
if len(reversedDiagnostics) != 0 {
|
||||||
t.Fatalf("reversed composition failed: %#v", reversedDiagnostics)
|
t.Fatalf("reversed composition failed: %#v", reversedDiagnostics)
|
||||||
}
|
}
|
||||||
if differences := compareDocumentTrees(composed.root, reversed.root); len(differences) != 0 {
|
if differences := compareConfigurationValues(composed.root, reversed.root); len(differences) != 0 {
|
||||||
t.Fatalf("composition order changed effective values: %#v", differences)
|
t.Fatalf("composition order changed effective values: %#v", differences)
|
||||||
}
|
}
|
||||||
composed.root.Mapping = append(composed.root.Mapping, document.Entry{Key: "fuzz-detachment"})
|
composed.root.Mapping = append(composed.root.Mapping, document.Entry{Key: "fuzz-detachment"})
|
||||||
|
|||||||
56
decode.go
56
decode.go
@@ -34,7 +34,15 @@ type decodedConfiguration struct {
|
|||||||
final *document.Node
|
final *document.Node
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type decodeContext struct {
|
||||||
|
versionPath *Path
|
||||||
|
}
|
||||||
|
|
||||||
func decodeConfiguration(schema *schemaDescriptor, defaults reflect.Value, input *document.Node) (decodedConfiguration, []decodeIssue) {
|
func decodeConfiguration(schema *schemaDescriptor, defaults reflect.Value, input *document.Node) (decodedConfiguration, []decodeIssue) {
|
||||||
|
return decodeConfigurationWithContext(schema, defaults, input, decodeContext{})
|
||||||
|
}
|
||||||
|
|
||||||
|
func decodeConfigurationWithContext(schema *schemaDescriptor, defaults reflect.Value, input *document.Node, context decodeContext) (decodedConfiguration, []decodeIssue) {
|
||||||
if schema == nil || schema.root == nil || !defaults.IsValid() || defaults.Type() != schema.root.typeOf {
|
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"}}
|
return decodedConfiguration{}, []decodeIssue{{code: CodeSchemaUnsupportedType, message: "configuration defaults do not match the schema"}}
|
||||||
}
|
}
|
||||||
@@ -50,7 +58,7 @@ func decodeConfiguration(schema *schemaDescriptor, defaults reflect.Value, input
|
|||||||
presence := make(map[string]inputPresence)
|
presence := make(map[string]inputPresence)
|
||||||
if input != nil {
|
if input != nil {
|
||||||
collectPresence(input, Path{}, presence)
|
collectPresence(input, Path{}, presence)
|
||||||
decoded, issues := decodeValue(input, working, schema.root, Path{}, schema)
|
decoded, issues := decodeValue(input, working, schema.root, Path{}, schema, context)
|
||||||
if len(issues) != 0 {
|
if len(issues) != 0 {
|
||||||
return decodedConfiguration{}, issues
|
return decodedConfiguration{}, issues
|
||||||
}
|
}
|
||||||
@@ -71,7 +79,7 @@ func decodeConfiguration(schema *schemaDescriptor, defaults reflect.Value, input
|
|||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func decodeValue(node *document.Node, base reflect.Value, descriptor *typeDescriptor, path Path, schema *schemaDescriptor) (reflect.Value, []decodeIssue) {
|
func decodeValue(node *document.Node, base reflect.Value, descriptor *typeDescriptor, path Path, schema *schemaDescriptor, context decodeContext) (reflect.Value, []decodeIssue) {
|
||||||
if node.Kind == document.Null {
|
if node.Kind == document.Null {
|
||||||
switch descriptor.kind {
|
switch descriptor.kind {
|
||||||
case descriptorPointer, descriptorMap, descriptorSlice, descriptorDynamic:
|
case descriptorPointer, descriptorMap, descriptorSlice, descriptorDynamic:
|
||||||
@@ -89,7 +97,7 @@ func decodeValue(node *document.Node, base reflect.Value, descriptor *typeDescri
|
|||||||
} else {
|
} else {
|
||||||
elementBase = base.Elem()
|
elementBase = base.Elem()
|
||||||
}
|
}
|
||||||
decoded, issues := decodeValue(node, elementBase, descriptor.element, path, schema)
|
decoded, issues := decodeValue(node, elementBase, descriptor.element, path, schema, context)
|
||||||
if len(issues) != 0 {
|
if len(issues) != 0 {
|
||||||
return reflect.Value{}, issues
|
return reflect.Value{}, issues
|
||||||
}
|
}
|
||||||
@@ -100,17 +108,17 @@ func decodeValue(node *document.Node, base reflect.Value, descriptor *typeDescri
|
|||||||
|
|
||||||
switch descriptor.kind {
|
switch descriptor.kind {
|
||||||
case descriptorStruct:
|
case descriptorStruct:
|
||||||
return decodeStruct(node, base, descriptor, path, schema)
|
return decodeStruct(node, base, descriptor, path, schema, context)
|
||||||
case descriptorMap:
|
case descriptorMap:
|
||||||
return decodeMap(node, base, descriptor, path, schema)
|
return decodeMap(node, base, descriptor, path, schema, context)
|
||||||
case descriptorSlice:
|
case descriptorSlice:
|
||||||
return decodeSlice(node, descriptor, path, schema)
|
return decodeSlice(node, descriptor, path, schema, context)
|
||||||
case descriptorArray:
|
case descriptorArray:
|
||||||
return decodeArray(node, descriptor, path, schema)
|
return decodeArray(node, descriptor, path, schema, context)
|
||||||
case descriptorDynamic:
|
case descriptorDynamic:
|
||||||
return decodeDynamic(node, base, path, schema)
|
return decodeDynamic(node, base, path, schema, context)
|
||||||
case descriptorString:
|
case descriptorString:
|
||||||
if node.Kind != document.String {
|
if node.Kind != document.String && !(pathMatches(context.versionPath, path) && isIntegerVersion(node)) {
|
||||||
return typeMismatch(path, node, "string")
|
return typeMismatch(path, node, "string")
|
||||||
}
|
}
|
||||||
result := reflect.New(descriptor.typeOf).Elem()
|
result := reflect.New(descriptor.typeOf).Elem()
|
||||||
@@ -187,7 +195,7 @@ func decodeValue(node *document.Node, base reflect.Value, descriptor *typeDescri
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func decodeStruct(node *document.Node, base reflect.Value, descriptor *typeDescriptor, path Path, schema *schemaDescriptor) (reflect.Value, []decodeIssue) {
|
func decodeStruct(node *document.Node, base reflect.Value, descriptor *typeDescriptor, path Path, schema *schemaDescriptor, context decodeContext) (reflect.Value, []decodeIssue) {
|
||||||
if node.Kind != document.Mapping {
|
if node.Kind != document.Mapping {
|
||||||
return typeMismatch(path, node, "mapping")
|
return typeMismatch(path, node, "mapping")
|
||||||
}
|
}
|
||||||
@@ -203,7 +211,7 @@ func decodeStruct(node *document.Node, base reflect.Value, descriptor *typeDescr
|
|||||||
issues = append(issues, decodeIssue{code: CodeSchemaUnsupportedType, path: childPath, message: "configuration field could not be addressed", location: entry.ValueLocation})
|
issues = append(issues, decodeIssue{code: CodeSchemaUnsupportedType, path: childPath, message: "configuration field could not be addressed", location: entry.ValueLocation})
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
decoded, fieldIssues := decodeValue(entry.Value, target, field.descriptor, childPath, schema)
|
decoded, fieldIssues := decodeValue(entry.Value, target, field.descriptor, childPath, schema, context)
|
||||||
if len(fieldIssues) != 0 {
|
if len(fieldIssues) != 0 {
|
||||||
issues = append(issues, fieldIssues...)
|
issues = append(issues, fieldIssues...)
|
||||||
continue
|
continue
|
||||||
@@ -212,7 +220,7 @@ func decodeStruct(node *document.Node, base reflect.Value, descriptor *typeDescr
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if descriptor.inlineMap != nil {
|
if descriptor.inlineMap != nil {
|
||||||
if issue := decodeInlineMapEntry(result, *descriptor.inlineMap, entry, childPath, schema); issue != nil {
|
if issue := decodeInlineMapEntry(result, *descriptor.inlineMap, entry, childPath, schema, context); issue != nil {
|
||||||
issues = append(issues, issue...)
|
issues = append(issues, issue...)
|
||||||
}
|
}
|
||||||
continue
|
continue
|
||||||
@@ -222,7 +230,7 @@ func decodeStruct(node *document.Node, base reflect.Value, descriptor *typeDescr
|
|||||||
return result, issues
|
return result, issues
|
||||||
}
|
}
|
||||||
|
|
||||||
func decodeInlineMapEntry(target reflect.Value, field fieldDescriptor, entry document.Entry, path Path, schema *schemaDescriptor) []decodeIssue {
|
func decodeInlineMapEntry(target reflect.Value, field fieldDescriptor, entry document.Entry, path Path, schema *schemaDescriptor, context decodeContext) []decodeIssue {
|
||||||
mapValue, ok := fieldForDecode(target, field.index, true)
|
mapValue, ok := fieldForDecode(target, field.index, true)
|
||||||
if !ok || mapValue.Kind() != reflect.Map {
|
if !ok || mapValue.Kind() != reflect.Map {
|
||||||
return []decodeIssue{{code: CodeSchemaInline, path: path, message: "inline map could not be addressed", location: entry.KeyLocation}}
|
return []decodeIssue{{code: CodeSchemaInline, path: path, message: "inline map could not be addressed", location: entry.KeyLocation}}
|
||||||
@@ -236,14 +244,14 @@ func decodeInlineMapEntry(target reflect.Value, field fieldDescriptor, entry doc
|
|||||||
if !base.IsValid() {
|
if !base.IsValid() {
|
||||||
base = reflect.Zero(mapValue.Type().Elem())
|
base = reflect.Zero(mapValue.Type().Elem())
|
||||||
}
|
}
|
||||||
decoded, issues := decodeValue(entry.Value, base, field.descriptor.element, path, schema)
|
decoded, issues := decodeValue(entry.Value, base, field.descriptor.element, path, schema, context)
|
||||||
if len(issues) == 0 {
|
if len(issues) == 0 {
|
||||||
mapValue.SetMapIndex(key, decoded)
|
mapValue.SetMapIndex(key, decoded)
|
||||||
}
|
}
|
||||||
return issues
|
return issues
|
||||||
}
|
}
|
||||||
|
|
||||||
func decodeMap(node *document.Node, base reflect.Value, descriptor *typeDescriptor, path Path, schema *schemaDescriptor) (reflect.Value, []decodeIssue) {
|
func decodeMap(node *document.Node, base reflect.Value, descriptor *typeDescriptor, path Path, schema *schemaDescriptor, context decodeContext) (reflect.Value, []decodeIssue) {
|
||||||
if node.Kind != document.Mapping {
|
if node.Kind != document.Mapping {
|
||||||
return typeMismatch(path, node, "mapping")
|
return typeMismatch(path, node, "mapping")
|
||||||
}
|
}
|
||||||
@@ -259,7 +267,7 @@ func decodeMap(node *document.Node, base reflect.Value, descriptor *typeDescript
|
|||||||
if !entryBase.IsValid() {
|
if !entryBase.IsValid() {
|
||||||
entryBase = reflect.Zero(descriptor.typeOf.Elem())
|
entryBase = reflect.Zero(descriptor.typeOf.Elem())
|
||||||
}
|
}
|
||||||
decoded, entryIssues := decodeValue(entry.Value, entryBase, descriptor.element, path.Key(entry.Key), schema)
|
decoded, entryIssues := decodeValue(entry.Value, entryBase, descriptor.element, path.Key(entry.Key), schema, context)
|
||||||
if len(entryIssues) != 0 {
|
if len(entryIssues) != 0 {
|
||||||
issues = append(issues, entryIssues...)
|
issues = append(issues, entryIssues...)
|
||||||
continue
|
continue
|
||||||
@@ -269,14 +277,14 @@ func decodeMap(node *document.Node, base reflect.Value, descriptor *typeDescript
|
|||||||
return result, issues
|
return result, issues
|
||||||
}
|
}
|
||||||
|
|
||||||
func decodeSlice(node *document.Node, descriptor *typeDescriptor, path Path, schema *schemaDescriptor) (reflect.Value, []decodeIssue) {
|
func decodeSlice(node *document.Node, descriptor *typeDescriptor, path Path, schema *schemaDescriptor, context decodeContext) (reflect.Value, []decodeIssue) {
|
||||||
if node.Kind != document.Sequence {
|
if node.Kind != document.Sequence {
|
||||||
return typeMismatch(path, node, "sequence")
|
return typeMismatch(path, node, "sequence")
|
||||||
}
|
}
|
||||||
result := reflect.MakeSlice(descriptor.typeOf, len(node.Sequence), len(node.Sequence))
|
result := reflect.MakeSlice(descriptor.typeOf, len(node.Sequence), len(node.Sequence))
|
||||||
var issues []decodeIssue
|
var issues []decodeIssue
|
||||||
for i, child := range node.Sequence {
|
for i, child := range node.Sequence {
|
||||||
decoded, childIssues := decodeValue(child, reflect.Zero(descriptor.typeOf.Elem()), descriptor.element, path.Index(i), schema)
|
decoded, childIssues := decodeValue(child, reflect.Zero(descriptor.typeOf.Elem()), descriptor.element, path.Index(i), schema, context)
|
||||||
if len(childIssues) != 0 {
|
if len(childIssues) != 0 {
|
||||||
issues = append(issues, childIssues...)
|
issues = append(issues, childIssues...)
|
||||||
continue
|
continue
|
||||||
@@ -286,7 +294,7 @@ func decodeSlice(node *document.Node, descriptor *typeDescriptor, path Path, sch
|
|||||||
return result, issues
|
return result, issues
|
||||||
}
|
}
|
||||||
|
|
||||||
func decodeArray(node *document.Node, descriptor *typeDescriptor, path Path, schema *schemaDescriptor) (reflect.Value, []decodeIssue) {
|
func decodeArray(node *document.Node, descriptor *typeDescriptor, path Path, schema *schemaDescriptor, context decodeContext) (reflect.Value, []decodeIssue) {
|
||||||
if node.Kind != document.Sequence {
|
if node.Kind != document.Sequence {
|
||||||
return typeMismatch(path, node, "sequence")
|
return typeMismatch(path, node, "sequence")
|
||||||
}
|
}
|
||||||
@@ -296,7 +304,7 @@ func decodeArray(node *document.Node, descriptor *typeDescriptor, path Path, sch
|
|||||||
result := reflect.New(descriptor.typeOf).Elem()
|
result := reflect.New(descriptor.typeOf).Elem()
|
||||||
var issues []decodeIssue
|
var issues []decodeIssue
|
||||||
for i, child := range node.Sequence {
|
for i, child := range node.Sequence {
|
||||||
decoded, childIssues := decodeValue(child, reflect.Zero(descriptor.typeOf.Elem()), descriptor.element, path.Index(i), schema)
|
decoded, childIssues := decodeValue(child, reflect.Zero(descriptor.typeOf.Elem()), descriptor.element, path.Index(i), schema, context)
|
||||||
if len(childIssues) != 0 {
|
if len(childIssues) != 0 {
|
||||||
issues = append(issues, childIssues...)
|
issues = append(issues, childIssues...)
|
||||||
continue
|
continue
|
||||||
@@ -306,13 +314,13 @@ func decodeArray(node *document.Node, descriptor *typeDescriptor, path Path, sch
|
|||||||
return result, issues
|
return result, issues
|
||||||
}
|
}
|
||||||
|
|
||||||
func decodeDynamic(node *document.Node, base reflect.Value, path Path, schema *schemaDescriptor) (reflect.Value, []decodeIssue) {
|
func decodeDynamic(node *document.Node, base reflect.Value, path Path, schema *schemaDescriptor, context decodeContext) (reflect.Value, []decodeIssue) {
|
||||||
if node.Kind == document.Mapping && base.IsValid() && !base.IsNil() {
|
if node.Kind == document.Mapping && base.IsValid() && !base.IsNil() {
|
||||||
concrete := base.Elem()
|
concrete := base.Elem()
|
||||||
operation := valueOperation{schema: schema}
|
operation := valueOperation{schema: schema}
|
||||||
descriptor, issue := operation.dynamicDescriptor(concrete.Type(), path)
|
descriptor, issue := operation.dynamicDescriptor(concrete.Type(), path)
|
||||||
if issue == nil && acceptsMapping(descriptor) {
|
if issue == nil && acceptsMapping(descriptor) {
|
||||||
decoded, issues := decodeValue(node, concrete, descriptor, path, schema)
|
decoded, issues := decodeValue(node, concrete, descriptor, path, schema, context)
|
||||||
if len(issues) != 0 {
|
if len(issues) != 0 {
|
||||||
return reflect.Value{}, issues
|
return reflect.Value{}, issues
|
||||||
}
|
}
|
||||||
@@ -347,7 +355,7 @@ func decodeDynamic(node *document.Node, base reflect.Value, path Path, schema *s
|
|||||||
sequence := make([]any, len(node.Sequence))
|
sequence := make([]any, len(node.Sequence))
|
||||||
var issues []decodeIssue
|
var issues []decodeIssue
|
||||||
for i, child := range node.Sequence {
|
for i, child := range node.Sequence {
|
||||||
decoded, childIssues := decodeDynamic(child, reflect.Zero(reflect.TypeFor[any]()), path.Index(i), schema)
|
decoded, childIssues := decodeDynamic(child, reflect.Zero(reflect.TypeFor[any]()), path.Index(i), schema, context)
|
||||||
if len(childIssues) != 0 {
|
if len(childIssues) != 0 {
|
||||||
issues = append(issues, childIssues...)
|
issues = append(issues, childIssues...)
|
||||||
continue
|
continue
|
||||||
@@ -362,7 +370,7 @@ func decodeDynamic(node *document.Node, base reflect.Value, path Path, schema *s
|
|||||||
mapping := make(map[string]any, len(node.Mapping))
|
mapping := make(map[string]any, len(node.Mapping))
|
||||||
var issues []decodeIssue
|
var issues []decodeIssue
|
||||||
for _, entry := range node.Mapping {
|
for _, entry := range node.Mapping {
|
||||||
decoded, childIssues := decodeDynamic(entry.Value, reflect.Zero(reflect.TypeFor[any]()), path.Key(entry.Key), schema)
|
decoded, childIssues := decodeDynamic(entry.Value, reflect.Zero(reflect.TypeFor[any]()), path.Key(entry.Key), schema, context)
|
||||||
if len(childIssues) != 0 {
|
if len(childIssues) != 0 {
|
||||||
issues = append(issues, childIssues...)
|
issues = append(issues, childIssues...)
|
||||||
continue
|
continue
|
||||||
|
|||||||
@@ -237,3 +237,12 @@ func escapeControls(value string) string {
|
|||||||
}
|
}
|
||||||
return b.String()
|
return b.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func hasErrors(diagnostics []Diagnostic) bool {
|
||||||
|
for _, diagnostic := range diagnostics {
|
||||||
|
if diagnostic.Severity == SeverityError {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|||||||
229
directives.go
Normal file
229
directives.go
Normal 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
|
||||||
|
}
|
||||||
@@ -61,7 +61,7 @@ semantic validator, explicit path and glob imports, one selected profile, and
|
|||||||
filesystem context. It also accepts explicit CLI override records, static secret
|
filesystem context. It also accepts explicit CLI override records, static secret
|
||||||
tags, and a dynamic path predicate. `NoSource` rejects imports, active profiles,
|
tags, and a dynamic path predicate. `NoSource` rejects imports, active profiles,
|
||||||
and filesystem context as invalid combinations but accepts overrides. Input
|
and filesystem context as invalid combinations but accepts overrides. Input
|
||||||
containing a reserved `_configkit` mapping key is rejected while imports are
|
containing a reserved `configkit` mapping key is rejected while imports are
|
||||||
disabled.
|
disabled.
|
||||||
|
|
||||||
Every source, options, schema, version, defaults, decode, resolution, or validation
|
Every source, options, schema, version, defaults, decode, resolution, or validation
|
||||||
@@ -74,9 +74,27 @@ frozen redacted projection.
|
|||||||
### Application schema versions
|
### Application schema versions
|
||||||
|
|
||||||
`VersionRule` declares a semantic path and non-nil checker. Before defaults or typed
|
`VersionRule` declares a semantic path and non-nil checker. Before defaults or typed
|
||||||
decoding, the checker receives a `Version` preserving absent, explicit null, or
|
decoding, the checker receives a `Version` preserving absent, explicit null, string,
|
||||||
string input. Any other input kind is a `version.type` error and the checker does not
|
or strict decimal-integer input. Strings and integers supply their exact spelling in
|
||||||
run. ConfigKit does not interpret the text or impose a universal version path.
|
`Version.Text`; ConfigKit does not impose a machine-integer range. Fractional or
|
||||||
|
exponent numbers, booleans, mappings, and sequences produce `version.type`, and the
|
||||||
|
checker does not run. ConfigKit does not interpret the text or impose a universal
|
||||||
|
version path.
|
||||||
|
|
||||||
|
Integer versions use the strict grammar `^-?(0|[1-9][0-9]*)$`. Their spelling is
|
||||||
|
preserved exactly, including `-0`, and application policy decides which identifiers
|
||||||
|
are supported. A numeric version has no built-in `int64` range limit. Existing parser
|
||||||
|
policy still rejects leading zeroes, leading plus signs, hexadecimal notation, and
|
||||||
|
other non-JSON scalar forms before version checking.
|
||||||
|
|
||||||
|
At exactly the configured semantic path, accepted integer input can decode into a
|
||||||
|
plain Go string or named string without a text codec, including pointer wrappers.
|
||||||
|
This is a load-local exception: siblings and unrelated fields, dynamic values,
|
||||||
|
integer and float destinations, durations, `Secret`, and text codecs retain ordinary
|
||||||
|
strict decoding. The retained source and history stay numeric while typed projection
|
||||||
|
and effective YAML contain the resulting string; explanation records this through
|
||||||
|
the normal decoding reconciliation event. Fragment, profile, and CLI ownership
|
||||||
|
protections apply before this conversion.
|
||||||
|
|
||||||
The checker reports safe, value-free errors and warnings through `Validation.Error`
|
The checker reports safe, value-free errors and warnings through `Validation.Error`
|
||||||
and `Validation.Warning`; ConfigKit resolves primary and related paths to exact or
|
and `Validation.Warning`; ConfigKit resolves primary and related paths to exact or
|
||||||
@@ -99,6 +117,13 @@ equal-valued input. Profile replacement removes displaced descendants from effec
|
|||||||
metadata while retaining their base definition and the parent replacement operation
|
metadata while retaining their base definition and the parent replacement operation
|
||||||
in history. All metadata queries and returned nested records are detached.
|
in history. All metadata queries and returned nested records are detached.
|
||||||
|
|
||||||
|
`Metadata.HistoricalPaths` enumerates observed paths, including removed paths,
|
||||||
|
without reloading input or resolving unselected credentials. Consumers can use
|
||||||
|
it with `History` to validate ownership of declarations hidden by an overlay;
|
||||||
|
domain ownership rules remain application responsibilities. The method's Go
|
||||||
|
documentation in [metadata.go](../metadata.go) defines enumeration and path-ordering
|
||||||
|
details.
|
||||||
|
|
||||||
History is chronological: a path can have a synthetic default record, its exact base
|
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
|
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;
|
query uses the ancestor's path for a whole-container or whole-sequence replacement;
|
||||||
@@ -419,7 +444,7 @@ origin data; history retains the displaced definitions and identifies the parent
|
|||||||
replacement. Surviving profile paths have profile origins, while recursively shared
|
replacement. Surviving profile paths have profile origins, while recursively shared
|
||||||
mapping containers retain their contributing locations.
|
mapping containers retain their contributing locations.
|
||||||
|
|
||||||
A profile may not contain `_configkit` anywhere, contribute the configured schema
|
A profile may not contain `configkit` anywhere, contribute the configured schema
|
||||||
version, or replace an ancestor of that version with a scalar or null. These rules
|
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
|
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.
|
base parse, composition, or version failure because those checks run first.
|
||||||
@@ -452,7 +477,7 @@ All records are detached and validated before any is applied. Root and explicit
|
|||||||
sequence-index paths are invalid; parsed numeric tokens remain mapping keys. A path
|
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,
|
may create missing mapping ancestors but cannot traverse an existing scalar, null,
|
||||||
or sequence. Duplicate and ancestor/descendant-overlapping records are rejected.
|
or sequence. Duplicate and ancestor/descendant-overlapping records are rejected.
|
||||||
Neither paths nor values may create `_configkit`, and no record may overlap the
|
Neither paths nor values may create `configkit`, and no record may overlap the
|
||||||
configured schema-version path. Unknown fields and effective type mismatches remain
|
configured schema-version path. Unknown fields and effective type mismatches remain
|
||||||
ordinary strict-decoder errors.
|
ordinary strict-decoder errors.
|
||||||
|
|
||||||
@@ -475,12 +500,18 @@ zero.
|
|||||||
## Explicit imports
|
## Explicit imports
|
||||||
|
|
||||||
Imports are opt-in. With `Options.Imports` enabled, a document may reserve its root
|
Imports are opt-in. With `Options.Imports` enabled, a document may reserve its root
|
||||||
`_configkit` mapping for an `imports` sequence. ConfigKit removes that mapping before
|
`configkit` mapping for an `imports` sequence. ConfigKit removes that mapping before
|
||||||
application decoding. Each sequence item is a mapping with exactly one nonempty
|
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
|
string `path` or `glob` plus an optional boolean `optional`; all other shapes and
|
||||||
fields are `import.invalid`. A `_configkit` key below the root is invalid. With
|
fields are `import.invalid`. A `configkit` key below the root is invalid. With
|
||||||
imports disabled, any occurrence remains `import.disabled`.
|
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
|
Path declarations use slash-separated relative paths. Absolute paths and network
|
||||||
URLs are invalid. Each path resolves from its declaring file's canonical directory,
|
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
|
and files are traversed depth-first in declaration order: a file contributes its own
|
||||||
@@ -600,8 +631,10 @@ and redacted inspection.
|
|||||||
|
|
||||||
Schema path traversal uses these same bindings. Explicit key segments address
|
Schema path traversal uses these same bindings. Explicit key segments address
|
||||||
mappings, explicit index segments address sequences, and parsed numeric pointer
|
mappings, explicit index segments address sequences, and parsed numeric pointer
|
||||||
tokens become indices only in a sequence. Sequence indices cannot have leading
|
tokens become indices only in a sequence. Sequence indices must use canonical
|
||||||
zeroes; fixed-array indices are bounds checked. Dynamic `any` and inline-map entries
|
nonnegative decimal spelling: no signs or leading zeroes except `0` itself.
|
||||||
|
Tokens such as `+0` and `-0` remain literal keys in mappings but do not identify
|
||||||
|
sequence elements. Fixed-array indices are bounds checked. Dynamic `any` and inline-map entries
|
||||||
remain schema-valid for application-defined descendants, but dynamic values cannot
|
remain schema-valid for application-defined descendants, but dynamic values cannot
|
||||||
introduce typed secret references.
|
introduce typed secret references.
|
||||||
|
|
||||||
@@ -655,7 +688,11 @@ keys but preserve the strict schema of their values; inline maps accept unmatche
|
|||||||
keys according to their element descriptor. Multiple independent field failures are
|
keys according to their element descriptor. Multiple independent field failures are
|
||||||
collected without publishing the partially decoded object.
|
collected without publishing the partially decoded object.
|
||||||
|
|
||||||
Booleans and strings are not coerced. Integer destinations require integer syntax
|
Booleans and strings are not generally coerced. At exactly a configured
|
||||||
|
`VersionRule.Path`, strict decimal-integer input may decode into a plain `string`, a
|
||||||
|
named string without a text codec, or pointer wrappers around those types. The
|
||||||
|
exception is load-local and does not apply to unrelated fields or loads without a
|
||||||
|
version rule. Integer destinations require integer syntax
|
||||||
and a value in the destination type's range. Float destinations accept finite JSON
|
and a value in the destination type's range. Float destinations accept finite JSON
|
||||||
numbers representable by their destination precision. Dynamic numbers become
|
numbers representable by their destination precision. Dynamic numbers become
|
||||||
`int64` for integer syntax and `float64` for fractional or exponent syntax, with
|
`int64` for integer syntax and `float64` for fractional or exponent syntax, with
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ four sanitized shapes using only ConfigKit and the Go standard library:
|
|||||||
| Shape | Demonstrated fit | Application code deliberately retained |
|
| 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 |
|
| Narratio-like | Fresh defaults, explicit partial imports, artifact maps, recorded inherited defaults, normalization, and source ownership | Artifact meaning and the separate `model_profile` domain field |
|
||||||
| Notarius-like | Canonical object bindings, a string schema version, strict dynamic options, and `int64` dynamic integers | Catalog selection, allowed option keys, range-checked conversion to runtime `int` |
|
| Notarius-like | Canonical object bindings, a string-backed schema version accepting string or integer YAML, strict dynamic options, and `int64` dynamic integers | Catalog selection, allowed option keys, range-checked conversion to runtime `int` |
|
||||||
| WeatherReporter-like | Defaults-only loading, report maps, canonical module objects, typed option unions, explicit CLI records, and typed secret startup | Report/module compatibility, rejection of explicitly empty domain lists, CLI parsing, and startup ordering |
|
| 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 |
|
| Seriatim-like | Independent strict ordered speaker and autocorrect rule files | First-match and ordered replacement algorithms; no central application configuration file |
|
||||||
|
|
||||||
@@ -20,6 +20,11 @@ The YAML under [`testdata`](../../examples/consumers/testdata) is loaded by the
|
|||||||
executable examples and ordinary tests. It contains no private application data,
|
executable examples and ordinary tests. It contains no private application data,
|
||||||
credentials, network calls, or dependency on a sibling checkout.
|
credentials, network calls, or dependency on a sibling checkout.
|
||||||
|
|
||||||
|
The Notarius-shaped fixtures deliberately load both `version: 4` and
|
||||||
|
`version: "4"` into the same string-backed application field. Its version checker
|
||||||
|
still owns the supported identifier; fractional numeric input and unsupported
|
||||||
|
integer identifiers remain negative examples.
|
||||||
|
|
||||||
## Happy path
|
## Happy path
|
||||||
|
|
||||||
An application defines ordinary types and passes one explicit source. Optional
|
An application defines ordinary types and passes one explicit source. Optional
|
||||||
@@ -61,8 +66,9 @@ The examples preserve domain meaning while adopting one configuration convention
|
|||||||
domain rule. Loaded and constructed values use the same validator.
|
domain rule. Loaded and constructed values use the same validator.
|
||||||
- Canonical module and object bindings replace string shorthand. Unknown fields and
|
- Canonical module and object bindings replace string shorthand. Unknown fields and
|
||||||
incompatible option shapes fail at their semantic paths.
|
incompatible option shapes fail at their semantic paths.
|
||||||
- Schema versions are strings. Consumers with integer versions migrate their files
|
- Schema versions remain string-backed in application types. A configured
|
||||||
instead of enabling another scalar convention.
|
`VersionRule` accepts string or strict decimal-integer YAML at its exact path and
|
||||||
|
supplies the spelling to both the checker and a plain string destination.
|
||||||
- Integers inside dynamic options are portable `int64` values. Runtime `int`
|
- Integers inside dynamic options are portable `int64` values. Runtime `int`
|
||||||
conversion is explicit and range checked.
|
conversion is explicit and range checked.
|
||||||
- Strict YAML intentionally rejects duplicate keys, aliases, merge keys, unsupported
|
- Strict YAML intentionally rejects duplicate keys, aliases, merge keys, unsupported
|
||||||
|
|||||||
@@ -1,21 +1,16 @@
|
|||||||
# Development
|
# Development
|
||||||
|
|
||||||
This is the contributor entry point for ConfigKit. Read the task-specific owners
|
Start here when contributing to ConfigKit. Read the task-specific owners below
|
||||||
below before making changes. Its public
|
before making changes. The [component overview](internal/overview.md) maps
|
||||||
loader currently covers strict selected sources, explicit path/glob import graphs,
|
implementation responsibilities; the [configuration contract](config.md) owns
|
||||||
composition, selected profile overlays, typed defaults, schema-version checks,
|
public behavior.
|
||||||
explicit CLI overrides, recorded application resolution, semantic validation,
|
|
||||||
layered metadata, secret
|
|
||||||
classification, typed environment references, explicit secrets bootstrap, and
|
|
||||||
redacted effective rendering and historical value explanation. The
|
|
||||||
[component overview](internal/overview.md) owns the implementation inventory.
|
|
||||||
|
|
||||||
## Initial orientation
|
## Initial orientation
|
||||||
|
|
||||||
1. Inspect the working tree and preserve unrelated changes.
|
1. Inspect the working tree and preserve unrelated changes.
|
||||||
2. Read the architecture policy for code or design work.
|
2. Read the architecture policy for code or design work.
|
||||||
3. Read the policy, contract, and internal documents applicable to the task.
|
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
|
## Task-specific reading guide
|
||||||
|
|
||||||
@@ -23,23 +18,33 @@ redacted effective rendering and historical value explanation. The
|
|||||||
| --- | --- |
|
| --- | --- |
|
||||||
| Repository orientation or component responsibility | [Architecture policy](policy/architecture.md) and [component overview](internal/overview.md) |
|
| 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 |
|
| Documentation changes | [Documentation policy](policy/documentation.md) and the owning document |
|
||||||
| Architecture decisions or future work | [Documentation policy](policy/documentation.md), [initial target record](roadmap/initial.md), and relevant ADRs listed below |
|
| 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 owning contracts/tests as they are introduced |
|
| 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) |
|
| Examples or copyable assets | The owning implemented contract and [consumer adoption guide](consumers/README.md) |
|
||||||
| Release preparation | [Release policy](policy/release.md), [release procedure](release.md), and [release-readiness evidence](roadmap/release-readiness.md) |
|
| Release preparation | [Release policy](policy/release.md) and [release procedure](release.md) |
|
||||||
|
|
||||||
Accepted decisions:
|
Accepted decisions:
|
||||||
|
|
||||||
- [Separate composition and overlay](adr/0001-separate-composition-and-overlay.md)
|
- [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)
|
- [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)
|
- [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)
|
- [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)
|
- [Explicit overrides and safe explanation](adr/0005-explicit-overrides-and-safe-explanation.md)
|
||||||
|
|
||||||
For cross-cutting changes, follow every applicable row. The
|
For cross-cutting changes, follow every applicable row. Completed implementation
|
||||||
[implementation plan](roadmap/implementation.md) owns the ordered stages and their
|
and remediation plans are preserved in Git history at `cb5b435`; use current
|
||||||
acceptance checks and recorded implementation evidence.
|
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).
|
||||||
|
|
||||||
|
The completed string-and-integer schema-version implementation and remediation
|
||||||
|
records are preserved in Git history at `244104f`. The
|
||||||
|
[configuration contract](config.md#application-schema-versions) owns current behavior.
|
||||||
|
|
||||||
## Baseline validation
|
## Baseline validation
|
||||||
|
|
||||||
@@ -51,27 +56,51 @@ environment:
|
|||||||
go mod download
|
go mod download
|
||||||
```
|
```
|
||||||
|
|
||||||
Run the library baseline from the repository root:
|
Run the everyday check with Bash 3.2 or newer and Git:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
go test ./...
|
./scripts/check.sh
|
||||||
go vet ./...
|
|
||||||
go build ./...
|
|
||||||
git diff --check
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The default tests must remain deterministic and runnable without network access or
|
[The script](../scripts/check.sh) runs from the repository root regardless of the
|
||||||
credentials after dependency acquisition. Also inspect new untracked files, verify
|
invocation directory. It checks Go formatting without rewriting files, checks
|
||||||
changed local links and heading fragments, and review paths, examples, ownership,
|
staged/unstaged and untracked whitespace, then runs `go test ./...`,
|
||||||
and current-versus-planned claims. The commands above do not check links or
|
`go vet ./...`, and `go build ./...`, stopping on the first failure. It uses the
|
||||||
untracked content. Do not use an application binary build as the library baseline.
|
installed Go toolchain by default, honors an explicit `GOTOOLCHAIN`, and disables
|
||||||
Add executable-example and other checks when those artifacts are introduced; do
|
dependency downloads. Acquire dependencies and any selected toolchain beforehand.
|
||||||
not document nonexistent tools as operational.
|
Format reported Go files with `gofmt -w <files>`.
|
||||||
|
|
||||||
Once code exists, use focused checks while iterating and validation proportionate
|
The default suite includes executable examples, maintained consumer fixtures,
|
||||||
to the change under the testing policy. Documentation-only work does not require
|
local documentation-link checks, and the dependency import-boundary check.
|
||||||
Go checks unless it changes behavior-bearing examples or commands they validate.
|
It must stay deterministic and runnable without network access or credentials.
|
||||||
Release validation is a separate, stricter gate governed by release policy.
|
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
|
## Fuzz validation
|
||||||
|
|
||||||
|
|||||||
@@ -115,8 +115,9 @@ Assigned to explicit import traversal and glob handling:
|
|||||||
Canonical paths—not `os.SameFile`—define identity, so hard links remain distinct.
|
Canonical paths—not `os.SameFile`—define identity, so hard links remain distinct.
|
||||||
|
|
||||||
Path-import implementation record (2026-09-06): `splitImportDirectives`,
|
Path-import implementation record (2026-09-06): `splitImportDirectives`,
|
||||||
`parseImportDeclaration`, `importFilesystem`, `importTraversal.visitPath`, and
|
`parseImportDeclaration` (now in `directives.go`), `importFilesystem` (now in
|
||||||
`loadImportedDocuments` in `imports.go` adapt the assigned envelope, loading, source,
|
`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
|
and failure mechanics. The corresponding ConfigKit tests adapt the assigned
|
||||||
declaration-order, disjoint composition, keyed-entry, all-source conflict, unsafe
|
declaration-order, disjoint composition, keyed-entry, all-source conflict, unsafe
|
||||||
target, alias, imported parse/schema, and relative-resolution cases. ConfigKit adds
|
target, alias, imported parse/schema, and relative-resolution cases. ConfigKit adds
|
||||||
|
|||||||
@@ -1,32 +1,43 @@
|
|||||||
# Internal Component Overview
|
# Internal Component Overview
|
||||||
|
|
||||||
ConfigKit currently contains the root `configkit` package, which establishes the
|
ConfigKit exposes the root `configkit` package and keeps its document and YAML
|
||||||
module and package documentation boundary. Its implemented public primitives are:
|
representations private. The implemented components and their owners are:
|
||||||
|
|
||||||
- immutable semantic `Path` construction and strict JSON Pointer parsing;
|
- immutable semantic `Path` construction and strict JSON Pointer parsing;
|
||||||
- detached provenance and diagnostic record types with empty `Metadata` queries;
|
- 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;
|
- canonical, control-safe diagnostic rendering and structured `LoadError` access;
|
||||||
- opaque file, byte, reader, and no-input source construction and bounded reads;
|
- opaque file, byte, reader, and no-input source construction in `source.go`,
|
||||||
- a parser-independent ordered tree in `internal/document`;
|
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
|
- the sole upstream YAML integration in `internal/yaml`, which translates validated
|
||||||
input into that owned tree and exposes no parser types outside its boundary;
|
input into that owned tree and exposes no parser types outside its boundary;
|
||||||
|
`parse.go` owns shared translation into detached, ordered public diagnostics;
|
||||||
- a private strict composer in `compose.go` that joins only disjoint document-tree
|
- a private strict composer in `compose.go` that joins only disjoint document-tree
|
||||||
definitions, retains ordered claims and composite mapping origins, reports every
|
definitions, retains ordered claims and composite mapping origins, reports every
|
||||||
conflicting location, and never aliases an input tree;
|
conflicting location, and never aliases an input tree;
|
||||||
- fixed selected-profile loading in `profile.go` and recursive, non-aliasing overlay
|
- fixed selected-profile loading in `profile.go` and recursive, non-aliasing overlay
|
||||||
with displacement records in `overlay.go`;
|
with displacement records in `overlay.go`; overlay records retain origins,
|
||||||
- detached parser-neutral CLI override validation and synthetic-source application
|
while value explanation uses separate history applications;
|
||||||
in `override.go`, sharing overlay, source, byte, node, and depth policies;
|
- 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
|
- a per-operation private reflection descriptor graph in `schema.go` for supported
|
||||||
types, YAML field binding, inlining, secret tags, recursion, and semantic paths;
|
types, YAML field binding, inlining, secret tags, recursion, and semantic paths;
|
||||||
- descriptor-driven canonical projection, detached typed cloning, cycle/limit
|
- descriptor-driven canonical projection in `projection.go`, detached typed
|
||||||
enforcement, and structural comparison in `projection.go`;
|
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
|
- strict schema-driven decoding over detached defaults in `decode.go`, including
|
||||||
mapping/sequence semantics, null and numeric policy, safe codec errors, presence,
|
mapping/sequence semantics, null and numeric policy, safe codec errors, presence,
|
||||||
baseline/final trees, and initial default/input origins;
|
baseline/final trees, and initial default/input origins;
|
||||||
- public lifecycle coordination in `load.go`, bounded canonical import DFS in
|
- public lifecycle coordination in `load.go`, option selection in `options.go`,
|
||||||
`imports.go`, pattern-directed glob expansion in `glob.go`, version-report contexts
|
input assembly in `load_input.go`, typed phases/publication in `load_result.go`,
|
||||||
in `validation.go`, and detached layered metadata/history assembly in
|
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`;
|
`load_metadata.go`;
|
||||||
- recorded application defaulting and normalization in `resolution.go`, including
|
- recorded application defaulting and normalization in `resolution.go`, including
|
||||||
explicitness queries, helper verification, structural mutation audit, coalesced
|
explicitness queries, helper verification, structural mutation audit, coalesced
|
||||||
@@ -39,15 +50,25 @@ module and package documentation boundary. Its implemented public primitives are
|
|||||||
- explicit bounded startup environment bootstrap and safe partial reporting in
|
- explicit bounded startup environment bootstrap and safe partial reporting in
|
||||||
`secrets.go`;
|
`secrets.go`;
|
||||||
- fixed-point tag/predicate/dependency sensitivity classification in `secret.go`;
|
- fixed-point tag/predicate/dependency sensitivity classification in `secret.go`;
|
||||||
- frozen-snapshot redaction and canonical effective output in `render.go`;
|
- canonical effective output in `render.go`, using frozen-snapshot redaction
|
||||||
- bounded immutable input applications, safe historical replay, detached explanation
|
shared with explanation in `snapshot.go`;
|
||||||
records, and deterministic human rendering in `explain.go`, with the
|
- detached public explanation records and coordination in `explain.go`, immutable
|
||||||
only upstream dumper integration isolated in `internal/yaml/write.go`;
|
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
|
- a separate maintained `examples/consumers` package with four sanitized consumer
|
||||||
shapes, fixtures, domain validators, and runtime-boundary functions; and
|
shapes, fixtures, domain validators, and runtime-boundary functions; and
|
||||||
- a fail-fast source-candidate checker in `scripts/release-check.sh`, with local
|
- 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.
|
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,
|
The [configuration contract](../config.md) owns implemented input, schema, decoding,
|
||||||
loading lifecycle, path imports, composition, profile/CLI overlay, version checking,
|
loading lifecycle, path imports, composition, profile/CLI overlay, version checking,
|
||||||
recorded resolution, typed secret references/bootstrap, semantic validation,
|
recorded resolution, typed secret references/bootstrap, semantic validation,
|
||||||
@@ -62,7 +83,7 @@ implementation:
|
|||||||
- [Extraction inventory](extraction.md)
|
- [Extraction inventory](extraction.md)
|
||||||
|
|
||||||
The [architecture policy](../policy/architecture.md) owns binding boundaries and
|
The [architecture policy](../policy/architecture.md) owns binding boundaries and
|
||||||
invariants. The [initial target record](../roadmap/initial.md) links current feature
|
invariants. The [deferred scope](../roadmap/deferred.md) records deliberate
|
||||||
owners and preserves deferred scope; it is not an implementation inventory. See the
|
exclusions. See the
|
||||||
[development guide](../development.md) for contributor orientation and validation
|
[development guide](../development.md) for contributor orientation and validation
|
||||||
and the [release procedure](../release.md) for source-candidate gates.
|
and the [release procedure](../release.md) for source-candidate gates.
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ ConfigKit is an opinionated Go library for consistent YAML configuration loading
|
|||||||
and resolution across consuming applications. This policy governs contributions.
|
and resolution across consuming applications. This policy governs contributions.
|
||||||
The [component overview](../internal/overview.md) owns implementation inventory, the
|
The [component overview](../internal/overview.md) owns implementation inventory, the
|
||||||
[configuration contract](../config.md) owns implemented behavior, and the
|
[configuration contract](../config.md) owns implemented behavior, and the
|
||||||
[initial target record](../roadmap/initial.md) preserves deferred scope.
|
[deferred scope](../roadmap/deferred.md) preserves deferred scope.
|
||||||
|
|
||||||
## Success criteria
|
## Success criteria
|
||||||
|
|
||||||
@@ -136,7 +136,7 @@ for safe messages supplied by their hooks.
|
|||||||
Do not introduce automatic directory discovery, network providers, arbitrary
|
Do not introduce automatic directory discovery, network providers, arbitrary
|
||||||
merge strategies, additional configuration languages, or application runtime
|
merge strategies, additional configuration languages, or application runtime
|
||||||
management without concrete consumer evidence and an explicit design decision.
|
management without concrete consumer evidence and an explicit design decision.
|
||||||
The [initial target record](../roadmap/initial.md#explicit-non-goals-and-deferred-scope)
|
The [deferred scope](../roadmap/deferred.md#explicit-non-goals-and-deferred-scope)
|
||||||
owns the deferred-feature inventory. Significant boundary
|
owns the deferred-feature inventory. Significant boundary
|
||||||
changes require an ADR and corresponding policy/contract updates; implementation
|
changes require an ADR and corresponding policy/contract updates; implementation
|
||||||
convenience alone is not a reason to broaden the public API.
|
convenience alone is not a reason to broaden the public API.
|
||||||
|
|||||||
@@ -28,8 +28,8 @@ semantics, defaults, paths, diagnostics, or compatibility guarantees.
|
|||||||
implementation; execution status belongs in roadmaps.
|
implementation; execution status belongs in roadmaps.
|
||||||
- Roadmaps own unimplemented targets, deferred work, and execution status.
|
- Roadmaps own unimplemented targets, deferred work, and execution status.
|
||||||
|
|
||||||
The initial target and implementation plan have separate owners. Do not put
|
Future implementation plans own their task order, acceptance gates, and progress;
|
||||||
staged steps or progress in `docs/roadmap/initial.md`. `docs/roadmap/implementation.md` owns those details.
|
`docs/roadmap/deferred.md` owns deliberate exclusions, not execution instructions.
|
||||||
As behavior lands, move its normative definition into the current contract and
|
As behavior lands, move its normative definition into the current contract and
|
||||||
replace the roadmap definition with a link. Retire completed roadmap material
|
replace the roadmap definition with a link. Retire completed roadmap material
|
||||||
as part of release preparation; ADRs preserve significant decision history.
|
as part of release preparation; ADRs preserve significant decision history.
|
||||||
@@ -56,7 +56,7 @@ handling, not secret values.
|
|||||||
|
|
||||||
| Topic | Owner | Boundary |
|
| 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 |
|
| 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 |
|
| Architecture rules | `docs/policy/architecture.md` | Responsibilities, dependency constraints, invariants, and scope boundaries |
|
||||||
| Documentation rules | `docs/policy/documentation.md` | Ownership and document lifecycle |
|
| Documentation rules | `docs/policy/documentation.md` | Ownership and document lifecycle |
|
||||||
@@ -64,14 +64,13 @@ handling, not secret values.
|
|||||||
| Release rules | `docs/policy/release.md` | Compatibility, integrity, gates, immutability, and exceptions |
|
| Release rules | `docs/policy/release.md` | Compatibility, integrity, gates, immutability, and exceptions |
|
||||||
| Release procedure | `docs/release.md` | 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 |
|
| 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 |
|
| 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 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 |
|
| 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 |
|
| Architectural decisions | `docs/adr/` | Context, decisions, alternatives, consequences, and supersession history |
|
||||||
| Initial target record | `docs/roadmap/initial.md` | Implemented-feature owner links and deferred feature scope until roadmap retirement |
|
| Deferred scope | `docs/roadmap/deferred.md` | Deliberate exclusions and the conditions for reconsidering them |
|
||||||
| Implementation plan | `docs/roadmap/implementation.md` | Stages, dependencies, acceptance gates, and progress |
|
| Future implementation plans | Task-specific files under `docs/roadmap/` | Unfinished work, task order, acceptance gates, and progress; completed plans retire to Git history |
|
||||||
| Release readiness evidence | `docs/roadmap/release-readiness.md` | Closed preparation gaps and outstanding candidate/publication evidence until roadmap retirement |
|
|
||||||
| Historical release notes | `docs/releases/` | Version-specific historical summaries required by release policy |
|
| 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 |
|
| Complete examples | `examples/` and executable Go examples | Maintained artifacts; prose links instead of duplicating full examples |
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
**Base Policy Version:** 0.1.0
|
**Base Policy Version:** 0.1.0
|
||||||
|
|
||||||
**Last Updated:** 2026-09-06
|
**Last Updated:** 2026-09-07
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
@@ -220,23 +220,18 @@ restating or redefining its requirements.
|
|||||||
|
|
||||||
## Policy Exceptions
|
## 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 and automated source-candidate checker now exist, but no exact
|
For v0.2.0 only, the maintainer accepted the successful native macOS check at
|
||||||
release candidate, version-matched release note, native macOS execution evidence, or
|
`244104f` for the final candidate, provided subsequent changes are documentation
|
||||||
post-publication module retrieval has been validated. Initial implementation may be
|
only. The final candidate still requires the complete Linux release checker.
|
||||||
completed without fabricating those later facts. This exception does not authorize
|
This exception waives only repeating the macOS check after those documentation
|
||||||
publication or waive any candidate, platform, remote, or consumer-verification gate.
|
changes; it does not waive other publication or verification gates.
|
||||||
|
|
||||||
Compensating controls: the [release procedure](../release.md) keeps every unvalidated
|
Candidate selection, publication, and post-publication verification remain
|
||||||
step as an explicit maintainer gate, and the
|
mandatory steps in the [release procedure](../release.md).
|
||||||
[release-readiness evidence](../roadmap/release-readiness.md) records what is closed
|
|
||||||
and outstanding. No release may be published until an exact committed candidate has
|
|
||||||
the required release note, passes all applicable automated and native-platform gates,
|
|
||||||
is verified against `origin/main`, and completes guarded tag publication and clean
|
|
||||||
consumer retrieval. The maintainer preparing the first release owns closure. Remove
|
|
||||||
this temporary exception when that evidence is recorded and obsolete roadmap files
|
|
||||||
are retired. 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.
|
|
||||||
|
|||||||
72
docs/releases/v0.1.0.md
Normal file
72
docs/releases/v0.1.0.md
Normal 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
43
docs/releases/v0.1.1.md
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
# ConfigKit v0.1.1
|
||||||
|
|
||||||
|
This release corrects the spelling of ConfigKit's reserved configuration key.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
The reserved directive key is now `configkit` instead of `_configkit`. Import
|
||||||
|
declarations continue to use the same `imports` sequence and path/glob syntax.
|
||||||
|
See the [configuration contract](../config.md#explicit-imports).
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
This is an intentional configuration compatibility change, approved as a one-time
|
||||||
|
[patch-release exception](../policy/release.md#policy-exceptions) because there are
|
||||||
|
no current consumers. `_configkit` is no longer a directive or a reserved key; it
|
||||||
|
is ordinary application data and is subject to normal schema validation. There is
|
||||||
|
no legacy alias. `configkit` is now reserved, including in nested mappings, profiles,
|
||||||
|
and override paths and values.
|
||||||
|
|
||||||
|
The Go API, diagnostic codes, Go minimum version, and supported platforms are
|
||||||
|
unchanged. The private YAML dependency remains pinned to
|
||||||
|
`go.yaml.in/yaml/v4 v4.0.0-rc.6`, confirmed as the newest available v4 release
|
||||||
|
during candidate preparation.
|
||||||
|
|
||||||
|
## Upgrade
|
||||||
|
|
||||||
|
Rename `_configkit` directive keys to `configkit` in root configuration files and
|
||||||
|
imported fragments. Rename any application-owned `configkit` fields to avoid the
|
||||||
|
newly reserved key. Update diagnostic-path expectations to the new spelling.
|
||||||
|
Files retaining `_configkit` will not load their declared imports.
|
||||||
|
|
||||||
|
Consumers can select this release with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get gitea.maximumdirect.net/eric/ConfigKit@v0.1.1
|
||||||
|
```
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Renamed the directive key consistently across imports, profiles, and overrides.
|
||||||
|
- Updated current configuration documentation and the composed consumer fixture.
|
||||||
|
- Updated behavioral tests and added coverage proving the old spelling is not an
|
||||||
|
import alias.
|
||||||
50
docs/releases/v0.1.2.md
Normal file
50
docs/releases/v0.1.2.md
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
# ConfigKit v0.1.2
|
||||||
|
|
||||||
|
This release adds safe enumeration of effective and removed metadata paths.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
`Metadata.HistoricalPaths` returns the semantic paths observed during a load,
|
||||||
|
including paths removed by profiles, CLI overrides, or recorded resolution. This
|
||||||
|
allows applications to discover hidden declarations and inspect their ownership
|
||||||
|
through the existing `Metadata.History` API without reloading configuration or
|
||||||
|
resolving unselected credentials.
|
||||||
|
|
||||||
|
See the [layered metadata contract](../config.md#layered-metadata) for current
|
||||||
|
behavior.
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
This is a backward-compatible API addition. Existing APIs, configuration syntax,
|
||||||
|
diagnostic codes, minimum Go version, and supported platforms are unchanged.
|
||||||
|
Existing consumers do not need to change their code or configuration.
|
||||||
|
|
||||||
|
Returned paths are detached, unique by canonical JSON Pointer spelling, and sorted
|
||||||
|
by that spelling. The enumeration includes defaults and the root, exposes no
|
||||||
|
configuration values, and performs no I/O or secret resolution.
|
||||||
|
|
||||||
|
The private YAML dependency remains pinned to
|
||||||
|
`go.yaml.in/yaml/v4 v4.0.0-rc.6`, confirmed as the newest available v4 release during
|
||||||
|
candidate preparation.
|
||||||
|
|
||||||
|
## Upgrade
|
||||||
|
|
||||||
|
No action required. Consumers that want historical path discovery can update with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get gitea.maximumdirect.net/eric/ConfigKit@v0.1.2
|
||||||
|
```
|
||||||
|
|
||||||
|
Then iterate over `result.Metadata().HistoricalPaths()` and use `History` for
|
||||||
|
chronological definitions or `Lookup` to determine whether each path remains
|
||||||
|
effective.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Added `Metadata.HistoricalPaths` with detached, deterministic path enumeration.
|
||||||
|
- Included effective, default, and removed paths while excluding paths that never
|
||||||
|
existed.
|
||||||
|
- Added coverage for parent removal, mapping-to-sequence shape replacement, escaped
|
||||||
|
mapping keys, detached results, zero metadata, composite base ownership, and
|
||||||
|
avoidance of superseded secret lookup.
|
||||||
|
- Documented historical path enumeration in the current configuration contract.
|
||||||
58
docs/releases/v0.2.0.md
Normal file
58
docs/releases/v0.2.0.md
Normal file
@@ -0,0 +1,58 @@
|
|||||||
|
# ConfigKit v0.2.0
|
||||||
|
|
||||||
|
This release adds integer schema-version input and consistent canonical sequence
|
||||||
|
path handling.
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
|
||||||
|
Applications can retain YAML such as `version: 4` while storing and checking their
|
||||||
|
version as a Go string. The exception applies only to the path designated by
|
||||||
|
`VersionRule`; unrelated fields keep strict decoding. See the
|
||||||
|
[configuration contract](../config.md#application-schema-versions).
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
This minor release intentionally changes integer schema-version input from a
|
||||||
|
`version.type` rejection to invoking the application checker with its decimal text.
|
||||||
|
Plain string destinations at that path now accept it. Public Go API shapes,
|
||||||
|
minimum Go version, and supported platforms are unchanged.
|
||||||
|
|
||||||
|
Parsed sequence indices now require canonical nonnegative decimal spelling.
|
||||||
|
Signed tokens such as `+0` and `-0`, previously accepted by some traversal paths,
|
||||||
|
no longer identify sequence elements. They remain valid literal mapping keys.
|
||||||
|
Version checking, decoding, and metadata matching now agree on this boundary.
|
||||||
|
|
||||||
|
The private YAML dependency remains pinned to `go.yaml.in/yaml/v4 v4.0.0-rc.6`,
|
||||||
|
confirmed as the newest available v4 release during candidate preparation. Linux
|
||||||
|
candidate checks use Go 1.25.5 and Go 1.26.7. The maintainer confirmed native macOS
|
||||||
|
checks at `244104f` and approved carrying that result across the documentation-only
|
||||||
|
candidate changes under the [recorded exception](../policy/release.md#policy-exceptions).
|
||||||
|
|
||||||
|
## Upgrade
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get gitea.maximumdirect.net/eric/ConfigKit@v0.2.0
|
||||||
|
```
|
||||||
|
|
||||||
|
Existing string schema versions need no configuration changes. Applications using
|
||||||
|
integer YAML versions can retain them and use a plain string field plus a
|
||||||
|
`VersionRule`. Review the application checker: it now receives integer identifiers
|
||||||
|
as text, and remains responsible for rejecting unsupported versions. Fractions,
|
||||||
|
exponents, booleans, and containers remain invalid version kinds.
|
||||||
|
|
||||||
|
Replace signed sequence-index tokens in configured paths with canonical indices
|
||||||
|
(for example, `/versions/+0` becomes `/versions/0`). Do not change signed mapping
|
||||||
|
keys. Literal integers elsewhere still do not decode into strings, and numeric
|
||||||
|
destinations retain their existing range checks.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Added load-local, path-scoped integer-to-string schema-version decoding without
|
||||||
|
mutating original input or introducing general scalar coercion.
|
||||||
|
- Preserved numeric historical input, string effective output, decoding
|
||||||
|
reconciliation, source attribution, and secret redaction.
|
||||||
|
- Unified sequence path interpretation and added regression coverage for signed
|
||||||
|
tokens, literal mapping keys, and explicit key/index distinctions.
|
||||||
|
- Updated maintained Notarius-shaped fixtures and migration guidance for quoted
|
||||||
|
and integer version forms.
|
||||||
|
- Retired completed implementation and remediation roadmaps to Git history.
|
||||||
28
docs/roadmap/deferred.md
Normal file
28
docs/roadmap/deferred.md
Normal 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
@@ -1,83 +0,0 @@
|
|||||||
# ConfigKit Initial Target Record
|
|
||||||
|
|
||||||
## Status and ownership
|
|
||||||
|
|
||||||
The initial feature target is implemented. This document now records the intended
|
|
||||||
outcome, source foundations, and deliberately deferred scope without duplicating the
|
|
||||||
current configuration contract or Go API reference. Ordered implementation history
|
|
||||||
and acceptance evidence remain in the [implementation plan](implementation.md) until
|
|
||||||
the maintainer retires completed roadmaps while preparing a release candidate.
|
|
||||||
|
|
||||||
The [architecture policy](../policy/architecture.md) owns responsibilities,
|
|
||||||
dependency direction, confidentiality, and the two success criteria. The
|
|
||||||
[configuration contract](../config.md) and Go declarations own implemented behavior.
|
|
||||||
The [consumer adoption guide](../consumers/README.md) owns consumer fit and migration
|
|
||||||
guidance. Accepted rationale remains in the [ADRs](../development.md#task-specific-reading-guide).
|
|
||||||
|
|
||||||
## Initial outcome
|
|
||||||
|
|
||||||
ConfigKit is one opinionated Go library for strict YAML configuration loading. It
|
|
||||||
removes source handling, parsing, explicit import/profile traversal, composition,
|
|
||||||
precedence, presence tracking, provenance, secret lookup/bootstrap, diagnostics, and
|
|
||||||
safe inspection plumbing from applications. Applications retain their types,
|
|
||||||
defaults, schema-version policy, recorded normalization, semantic validation, CLI
|
|
||||||
parsing, remote acquisition, domain algorithms, resource checks, and runtime setup.
|
|
||||||
|
|
||||||
The maintained Narratio-, Notarius-, WeatherReporter-, and Seriatim-shaped examples
|
|
||||||
demonstrate both architecture success criteria: simple consumers avoid optional
|
|
||||||
composition machinery, complex consumers use the same conventions, and domain logic
|
|
||||||
remains recognizable application code. These examples are migration evidence, not a
|
|
||||||
claim that any sibling application has migrated.
|
|
||||||
|
|
||||||
## Implementation foundations
|
|
||||||
|
|
||||||
The implementation was generalized from maintainer-authorized application code and
|
|
||||||
tests at fixed clean revisions. The [extraction inventory](../internal/extraction.md)
|
|
||||||
records exact revisions, symbols, adapted cases, intentional semantic differences,
|
|
||||||
and the boundary that keeps sibling checkouts out of builds and tests. Links from
|
|
||||||
that inventory or historical implementation evidence to sibling source are optional
|
|
||||||
in isolated clones; immutable revision references remain authoritative evidence.
|
|
||||||
|
|
||||||
The module uses `go.yaml.in/yaml/v4` only behind `internal/yaml`. ConfigKit owns the
|
|
||||||
document model, field binding, scalar grammar, errors, limits, and output behavior.
|
|
||||||
No upstream type, option, plugin, error chain, hidden parser value, or generic
|
|
||||||
constraint crosses the public API. The package-level import-boundary test enforces
|
|
||||||
this isolation for production, tests, and maintained examples.
|
|
||||||
|
|
||||||
## Current feature owners
|
|
||||||
|
|
||||||
| Initial feature area | Current owner |
|
|
||||||
| --- | --- |
|
|
||||||
| Sources, lifecycle, limits, YAML/scalars, decoding, and ownership | [Configuration contract](../config.md) and Go reference comments |
|
|
||||||
| Paths, presence, origins, sources, and definition history | [Layered metadata](../config.md#layered-metadata), [`path.go`](../../path.go), and [`metadata.go`](../../metadata.go) |
|
|
||||||
| Strict composition, imports, globs, source identity, and profiles | [Strict composition](../config.md#strict-document-composition), [explicit imports](../config.md#explicit-imports), and [selected profiles](../config.md#selected-profiles) |
|
|
||||||
| Static defaults, null, types, projection, and custom text scalars | [Defaults and typed decoding](../config.md#defaults-and-typed-decoding), [schema binding](../config.md#application-schema-binding), and [typed projection](../config.md#typed-projection-and-ownership) |
|
|
||||||
| Schema versions, recorded resolution, and semantic validation | [Version contract](../config.md#application-schema-versions), [resolution](../config.md#recorded-application-resolution), and [validation](../config.md#semantic-validation) |
|
|
||||||
| Secret classification, typed references, and directory bootstrap | [Redaction](../config.md#secret-classification-and-effective-rendering), [references](../config.md#typed-secret-references), and [bootstrap](../config.md#explicit-secrets-directory-bootstrap) |
|
|
||||||
| Explicit CLI overrides and complete precedence | [CLI override contract](../config.md#explicit-cli-overrides) |
|
|
||||||
| Effective rendering and safe historical explanation | [Effective rendering](../config.md#secret-classification-and-effective-rendering) and [historical explanation](../config.md#historical-value-explanation) |
|
|
||||||
| Canonical module options and downstream adoption | [Module options](../config.md#canonical-module-options) and [consumer guide](../consumers/README.md) |
|
|
||||||
| Diagnostics and structured errors | [`records.go`](../../records.go), [`diagnostic.go`](../../diagnostic.go), and relevant configuration-contract sections |
|
|
||||||
| Internal components and dependency placement | [Component overview](../internal/overview.md) and [architecture policy](../policy/architecture.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.
|
|
||||||
@@ -1,37 +0,0 @@
|
|||||||
# Release Readiness
|
|
||||||
|
|
||||||
## Status and ownership
|
|
||||||
|
|
||||||
ConfigKit's implementation is prepared for release-candidate validation, but no
|
|
||||||
release candidate has been selected or publication authorized. The maintainer
|
|
||||||
preparing the first release owns the remaining gates under the
|
|
||||||
[temporary bootstrap exception](../policy/release.md#temporary-bootstrap-deferral).
|
|
||||||
This evidence record remains until candidate preparation retires completed roadmap
|
|
||||||
material in favor of the operational [release procedure](../release.md) and normal
|
|
||||||
release notes.
|
|
||||||
|
|
||||||
## Preparation evidence
|
|
||||||
|
|
||||||
| Preparation item | Status and evidence |
|
|
||||||
| --- | --- |
|
|
||||||
| Module and support matrix | Closed: the runbook records module `gitea.maximumdirect.net/eric/ConfigKit`, minimum Go 1.25 with a patched 1.25 plus newer supported toolchain, and Linux/macOS on amd64/arm64. |
|
|
||||||
| Maintainer release procedure | Closed: `docs/release.md` owns access prerequisites, candidate recording, complete validation, exact remote/commit/tag guards, source-only publication, consumer retrieval, and correction. |
|
|
||||||
| Automated source checker | Closed for current-source preparation: `scripts/release-check.sh` passed on 2026-09-06 with Go 1.25.5 and Go 1.26.7 on linux/amd64. It performs disposable-copy module hygiene, cached/offline tests, race, vet, native and four-target cross-builds, formatting/whitespace, examples, import isolation, and local-link gates. The exact candidate must rerun it. |
|
|
||||||
| Consumer verification procedure | Closed as procedure: the runbook retrieves an exact published version into a clean temporary module with no local replacement and tests/builds it. Actual retrieval remains a post-publication gate. |
|
|
||||||
| Secret/precedence/explanation evidence | Closed locally: current contracts and deterministic tests cover bootstrap skip/partial failure, credential isolation, defaults/base/profile/CLI precedence, historical redaction, and maintained consumer startup examples. |
|
|
||||||
| Parser dependency reassessment | Closed for preparation: on 2026-09-06, `go list -m -versions go.yaml.in/yaml/v4` and `go list -m -json go.yaml.in/yaml/v4@latest` both showed `v4.0.0-rc.6` as the newest available release. The explicit pin is retained; a candidate compatibility review must reassess and record it again. |
|
|
||||||
|
|
||||||
## Outstanding release gates
|
|
||||||
|
|
||||||
| Gate | Owner and closure evidence |
|
|
||||||
| --- | --- |
|
|
||||||
| Exact candidate and release note | Maintainer selects an unused stable version, writes `docs/releases/<version>.md`, commits all release changes, runs the checker against that exact clean commit, pushes it to `origin/main`, and records the commit. |
|
|
||||||
| Native platform execution | Maintainer records applicable checker, race, symlink, and permission-fixture results on native Linux and macOS. Current local evidence is Linux/amd64 only; cross-builds do not close macOS execution. |
|
|
||||||
| Compatibility, migration, and content review | Maintainer records public API/configuration/diagnostic review, downstream migration impact, release-note approval, and inspection for credentials, private data, generated output, unintended files, and unrelated changes. |
|
|
||||||
| Roadmap retirement | Maintainer removes obsolete completed files under `docs/roadmap/`, repairs inbound links, and reruns the checker as part of the candidate. This record and the implementation plan intentionally remain while no candidate exists. |
|
|
||||||
| Remote publication guards | Maintainer fetches authoritative `origin/main` and tags immediately before publication and proves branch, clean worktree/index, candidate commit, remote commit, release note, approvals, and unused version all agree. |
|
|
||||||
| Tag and retrieval verification | After authorization, maintainer pushes only the exact immutable source tag, verifies its peeled remote commit, and retrieves/tests/builds the exact version in a clean consumer module without `replace`. |
|
|
||||||
|
|
||||||
No tag, push, downstream migration, release note, or release publication was
|
|
||||||
performed during implementation. A green local checker does not close the exact
|
|
||||||
candidate, native macOS, remote, or post-publication gates.
|
|
||||||
@@ -1,355 +0,0 @@
|
|||||||
# Pre-release Maintainability Remediation
|
|
||||||
|
|
||||||
## Status and scope
|
|
||||||
|
|
||||||
Status: planned; no stages have been implemented under this roadmap.
|
|
||||||
Audit baseline: `9b147bfd96aec25e0125dcae66e136e56dc7a670`.
|
|
||||||
|
|
||||||
This roadmap owns the agreed maintainability cleanup before v0.1.0. It replaces
|
|
||||||
the completed implementation-defect remediation record, whose findings, fixes,
|
|
||||||
and validation evidence remain available in Git history. It does not reopen
|
|
||||||
those defects or replace the original [implementation plan](implementation.md).
|
|
||||||
|
|
||||||
The audit found worthwhile consolidation and ownership improvements, rather than
|
|
||||||
a need to redesign ConfigKit. The offline default suite and vet passed at the
|
|
||||||
audit baseline. That evidence is not validation of future changes or a release
|
|
||||||
candidate.
|
|
||||||
|
|
||||||
Read the [development guide](../development.md),
|
|
||||||
[architecture policy](../policy/architecture.md),
|
|
||||||
[testing policy](../policy/testing.md), and
|
|
||||||
[documentation policy](../policy/documentation.md) before implementation.
|
|
||||||
The [configuration contract](../config.md) owns current semantics; the
|
|
||||||
[component overview](../internal/overview.md) owns implementation inventory.
|
|
||||||
Release preparation remains governed by the [release policy](../policy/release.md)
|
|
||||||
and [maintainer procedure](../release.md).
|
|
||||||
|
|
||||||
## Execution rules and invariants
|
|
||||||
|
|
||||||
Implement the numbered stages in order. Each stage is one reviewable unit of
|
|
||||||
work: inspect its current owners and tests, make the change, validate it, update
|
|
||||||
its status and evidence here, and commit the nonempty unit with a short plain
|
|
||||||
English message. Do not create empty commits. Record any discovered behavioral
|
|
||||||
defect separately with a reproduction rather than silently changing semantics
|
|
||||||
under a refactoring stage.
|
|
||||||
|
|
||||||
Keep the root public package and the existing `internal/document` and
|
|
||||||
`internal/yaml` packages. Do not introduce dependencies, public API changes,
|
|
||||||
provider interfaces, configurable merge policies, or a generic visitor framework.
|
|
||||||
Private file splits do not require new packages or ADRs. Parser-specific types
|
|
||||||
must remain behind the existing YAML adapter boundary.
|
|
||||||
|
|
||||||
Preserve the implemented contracts, particularly:
|
|
||||||
|
|
||||||
- Strict composition remains distinct from intentional profile/CLI overlay.
|
|
||||||
- Input precedence, final-only typed decoding, retained defaults, canonical
|
|
||||||
decoding explanation events, and recorded domain resolution remain unchanged.
|
|
||||||
- Source identity, containment, optional-import rules, limits, diagnostic codes,
|
|
||||||
source coordinates, ordering, and early callback termination remain intact.
|
|
||||||
- Semantic paths retain explicit key/index distinctions and historical shapes.
|
|
||||||
- Returned values and inspection snapshots remain detached; secret values remain
|
|
||||||
absent from history, rendering, and library-generated diagnostics.
|
|
||||||
- Resolution runs before secret lookup and validation; ordinary loading never
|
|
||||||
bootstraps environment variables implicitly.
|
|
||||||
|
|
||||||
Use focused existing behavioral tests during each stage and the baseline checks
|
|
||||||
from the development guide before committing production changes. Add tests only
|
|
||||||
for meaningful gaps exposed by extraction, not to enforce file locations or
|
|
||||||
private call choreography. Update current owning documentation alongside each
|
|
||||||
move. Planned filenames below are private implementation destinations, not new
|
|
||||||
consumer contracts.
|
|
||||||
|
|
||||||
## Stage 1 — Remove unused overlay value snapshots
|
|
||||||
|
|
||||||
Status: pending.
|
|
||||||
|
|
||||||
Evidence: `overlayRecord` in [overlay.go](../../overlay.go) retains `before` and
|
|
||||||
`after` trees; profile and CLI processing construct them and
|
|
||||||
`cloneOverlayRecords` clones them. Production consumers use the record's change
|
|
||||||
and displaced origins, while explanation uses `historyApplication` instead.
|
|
||||||
|
|
||||||
Work:
|
|
||||||
|
|
||||||
1. Reconfirm all field reads before removal. Remove only the unused overlay
|
|
||||||
snapshot fields and their construction/cloning in `overlay.go` and
|
|
||||||
[override.go](../../override.go).
|
|
||||||
2. Preserve `change`, `displaced`, input applications, and their ownership rules.
|
|
||||||
Do not remove resolution snapshots or retained decoded trees: those have
|
|
||||||
separate consumers and budget semantics.
|
|
||||||
3. Remove locals and imports made unnecessary by the deletion. Do not replace
|
|
||||||
the removed state with a new cache or history representation.
|
|
||||||
|
|
||||||
Acceptance: profile/CLI provenance, explanation replay, composition independence,
|
|
||||||
and redaction tests pass unchanged in observable behavior. Source inspection
|
|
||||||
confirms no unused overlay snapshot construction remains. An allocation benchmark
|
|
||||||
is not required to justify removing provably unread clones.
|
|
||||||
|
|
||||||
## Stage 2 — Centralize parser issue translation
|
|
||||||
|
|
||||||
Status: pending.
|
|
||||||
|
|
||||||
Evidence: [parse.go](../../parse.go) translates adapter issues inline, while
|
|
||||||
`importParseDiagnostics` in [imports.go](../../imports.go) repeats the translation
|
|
||||||
for imports and profiles.
|
|
||||||
|
|
||||||
Work:
|
|
||||||
|
|
||||||
1. Define one private `parserDiagnostics` helper in `parse.go` that maps adapter
|
|
||||||
issues to detached public diagnostics, including related locations.
|
|
||||||
2. Route selected-source, import, and profile parsing through it. Remove the
|
|
||||||
import-specific translator and keep upstream parser errors private.
|
|
||||||
3. Return canonically ordered diagnostics from the shared translation boundary.
|
|
||||||
Preserve existing codes, messages, primary/related coordinates, and source IDs.
|
|
||||||
Keep per-source parsing budgets and graph accounting at their current owners.
|
|
||||||
|
|
||||||
Acceptance: malformed selected/imported/profile input retains safe, correctly
|
|
||||||
attributed diagnostics. Use existing parser and import diagnostic tests; add a
|
|
||||||
small cross-entry regression only if a source path lacks equivalent coverage.
|
|
||||||
|
|
||||||
## Stage 3 — Share bounded regular-file read mechanics
|
|
||||||
|
|
||||||
Status: pending.
|
|
||||||
|
|
||||||
Evidence: `readSource`, `importTraversal.readImport`, and `readProfileFile` repeat
|
|
||||||
preflight stat, open, post-open stat, regular-file checks, size checks, and bounded
|
|
||||||
reads in [source.go](../../source.go), [imports.go](../../imports.go), and
|
|
||||||
[profile.go](../../profile.go).
|
|
||||||
|
|
||||||
Work:
|
|
||||||
|
|
||||||
1. Introduce private helpers in `source_file.go` for regular-file opening and
|
|
||||||
bounded reading. Use narrow function parameters for stat/open operations so
|
|
||||||
callers can supply `os.Stat`/`os.Open` or root-bound methods. Do not introduce
|
|
||||||
an exported filesystem interface or substitute unrestricted opening for
|
|
||||||
`os.Root` access.
|
|
||||||
2. Keep preflight before open and post-open verification. Close every opened
|
|
||||||
file on all paths. Preserve the existing treatment of read-only file close
|
|
||||||
errors; root-directory close diagnostics are a separate responsibility.
|
|
||||||
3. Return private classified failures with enough distinction for callers to
|
|
||||||
map errors safely. Optional imports may suppress only the currently allowed
|
|
||||||
missing-target failures at stat/open, not later read failures. Do not expose
|
|
||||||
filesystem errors downstream through wrapping.
|
|
||||||
4. Keep source metadata, canonicalization, containment, per-source/aggregate
|
|
||||||
budgets, byte accounting, and caller-specific limit diagnostics outside the
|
|
||||||
shared mechanics. Pass the computed byte limit into the reader and retain
|
|
||||||
both the size precheck and limit-plus-one read enforcement.
|
|
||||||
5. Reuse bounded read mechanics for reader/byte sources only where it simplifies
|
|
||||||
existing code without changing single-use or ownership behavior. Keep secrets
|
|
||||||
bootstrap independent: its symlink, environment, and reporting policies differ.
|
|
||||||
|
|
||||||
Acceptance: source, import, profile, FIFO, containment, symlink, optional-missing,
|
|
||||||
exact-budget, and reader-ownership tests pass. Retain the deadline-isolated FIFO
|
|
||||||
regression. Cover private failure classification only where public tests cannot
|
|
||||||
reliably induce an important filesystem failure. Run race tests for this unit.
|
|
||||||
|
|
||||||
## Stage 4 — Establish shared document traversal ownership
|
|
||||||
|
|
||||||
Status: pending.
|
|
||||||
|
|
||||||
Evidence: `indexDocument`, `walkDocumentPaths`, and `projectedSensitivity` repeat
|
|
||||||
semantic tree traversal in [load_metadata.go](../../load_metadata.go),
|
|
||||||
[metadata.go](../../metadata.go), and [secret.go](../../secret.go). General document
|
|
||||||
lookup currently lives in [load.go](../../load.go).
|
|
||||||
|
|
||||||
Work:
|
|
||||||
|
|
||||||
1. Create root-private `document_walk.go` with a nil-safe preorder walker taking
|
|
||||||
a starting `Path` and a callback receiving both path and node. Visit mappings
|
|
||||||
in stored order and sequences in index order; construct explicit Key/Index
|
|
||||||
segments. It visits value nodes, not synthetic mapping-key nodes.
|
|
||||||
2. Move `indexDocument` and `documentAtPath` to this owner. Implement indexing,
|
|
||||||
metadata shape collection, and projected sensitivity collection using the
|
|
||||||
walker where their traversal semantics match.
|
|
||||||
3. Document callback borrowing: callers must clone paths or nodes when retaining
|
|
||||||
mutable state. Preserve existing defensive copies and avoid sharing mutable
|
|
||||||
metadata across results.
|
|
||||||
4. Keep path-bearing helpers in the root package so `internal/document` does not
|
|
||||||
depend on the public package. Pure node representation and cloning stay in
|
|
||||||
`internal/document`.
|
|
||||||
5. Leave specialized traversals explicit where they need inherited origins,
|
|
||||||
pruning, node identity deduplication, or different counting. In particular,
|
|
||||||
input limits count mapping keys and retained-history limits count shared nodes
|
|
||||||
once; do not implement either using the new walker without those semantics.
|
|
||||||
|
|
||||||
Acceptance: numeric map keys versus sequence indices, escaped paths, unknown
|
|
||||||
paths, historical shape changes, deleted descendants, dynamic secret tags, and
|
|
||||||
metadata detachment remain protected by existing tests. No public path behavior
|
|
||||||
or predicate coverage changes. Do not add tests that assert walker call counts.
|
|
||||||
|
|
||||||
## Stage 5 — Separate typed projection, cloning, and comparison
|
|
||||||
|
|
||||||
Status: pending.
|
|
||||||
|
|
||||||
Evidence: [projection.go](../../projection.go) combines roughly 622 lines of
|
|
||||||
projection, detached cloning, dynamic schema handling, cycle/limit enforcement,
|
|
||||||
and structural comparison. Value equality deliberately ignores sensitivity,
|
|
||||||
which validation must check separately.
|
|
||||||
|
|
||||||
Work:
|
|
||||||
|
|
||||||
1. Keep canonical typed projection in `projection.go`; move cloning into
|
|
||||||
`clone.go`, shared reflection operation state and cycle helpers into
|
|
||||||
`value_operation.go`, and value-tree comparison into `document_compare.go`.
|
|
||||||
Preserve the descriptor-driven approach and per-operation schema cache.
|
|
||||||
2. Rename the private equality/comparison entry points to make configuration-value
|
|
||||||
semantics explicit. Document that location and sensitivity metadata are
|
|
||||||
excluded, mapping order is immaterial, and sequences are compared atomically
|
|
||||||
for change reporting. Update all callers without changing their semantics.
|
|
||||||
3. Give validator mutation comparison a named private owner in `validation.go`.
|
|
||||||
It must combine projected value changes and sensitivity changes, while
|
|
||||||
retaining private resolved-secret-state auditing as an additional check.
|
|
||||||
Do not make resolver value comparison start treating sensitivity as a value.
|
|
||||||
4. Preserve the distinction between cloning unresolved public secret references
|
|
||||||
and publication cloning of already validated private state. Name/document
|
|
||||||
the clone mode explicitly rather than leaving an unexplained boolean at
|
|
||||||
sensitive call sites.
|
|
||||||
5. Replace custom `cmpString` calls with the standard library comparator and
|
|
||||||
remove that helper. Do not perform unrelated test-style rewrites.
|
|
||||||
|
|
||||||
Acceptance: projection/cloning tests cover nil versus empty, custom text codecs,
|
|
||||||
inline fields, dynamic types, cycles, and limits. Validator mutation regressions
|
|
||||||
must still reject equal-text sensitivity changes and secret-state changes.
|
|
||||||
Callback ownership and secret lookup tests must prove publication does not share
|
|
||||||
borrowed values or reread the environment. Run race tests for this unit.
|
|
||||||
|
|
||||||
## Stage 6 — Clarify source assembly and override ownership
|
|
||||||
|
|
||||||
Status: pending.
|
|
||||||
|
|
||||||
Evidence: [imports.go](../../imports.go) mixes filesystem context, directives,
|
|
||||||
graph traversal, and diagnostics; [override.go](../../override.go) combines value
|
|
||||||
construction/validation and application. Shared composition cloning is located
|
|
||||||
inside the override feature, while profile overlay repeats it.
|
|
||||||
|
|
||||||
Work:
|
|
||||||
|
|
||||||
1. Keep import traversal and route diagnostics in `imports.go`. Move filesystem
|
|
||||||
context validation/canonicalization/containment to `filesystem.go`, and
|
|
||||||
directive structures/parsing/fragment restrictions to `directives.go`.
|
|
||||||
Give source-independent node counting an appropriate shared document owner,
|
|
||||||
with its mapping-key accounting documented.
|
|
||||||
2. Keep override validation and preparation in `override.go`, move parser-neutral
|
|
||||||
value building to `override_value.go`, and application/displacement mechanics
|
|
||||||
to `override_apply.go`.
|
|
||||||
3. Move `cloneComposedDocument` next to `composedDocument` in `compose.go`; use it
|
|
||||||
for both profile overlay and overrides. Ensure every retained field is copied
|
|
||||||
according to its existing ownership contract.
|
|
||||||
4. Keep strict composition, overlay, and history replay separate. Share small
|
|
||||||
mechanics when identical; do not introduce merge strategy flags or one general
|
|
||||||
merge engine. Preserve the existing profile-versus-CLI recording behavior.
|
|
||||||
5. Simplify profile root cleanup using deferred failure-path cleanup plus an
|
|
||||||
explicit checked success-path close. Preserve primary failure diagnostics and
|
|
||||||
the existing successful-read root-close error diagnostic; avoid double-close
|
|
||||||
effects. Import traversal cleanup may remain explicit where already clear.
|
|
||||||
|
|
||||||
Acceptance: imports/globs, directive restrictions, source ordering, profiles,
|
|
||||||
overrides, version restrictions, limits, and composition independence tests pass.
|
|
||||||
No new cross-package dependencies appear. Root handles close on every path and
|
|
||||||
profile/CLI provenance remains identical.
|
|
||||||
|
|
||||||
## Stage 7 — Extract explicit loader lifecycle phases
|
|
||||||
|
|
||||||
Status: pending.
|
|
||||||
|
|
||||||
Evidence: `loadWithSecretLookup` in [load.go](../../load.go) coordinates roughly
|
|
||||||
200 lines of source assembly, decoding, callbacks, history, secrets, and result
|
|
||||||
publication, including repeated creation of a composition handoff.
|
|
||||||
|
|
||||||
Work:
|
|
||||||
|
|
||||||
1. Keep the exported entry point and a readable linear coordinator in `load.go`.
|
|
||||||
Move option selection to `options.go`, input assembly to `load_input.go`, and
|
|
||||||
typed lifecycle/result publication to `load_result.go`. Move version checking
|
|
||||||
beside the validation machinery rather than keeping it among document helpers.
|
|
||||||
2. Use small private handoff structs for assembled input and necessary phase
|
|
||||||
outputs, not a mutable all-purpose loader object or a long list of output
|
|
||||||
parameters. Reuse one composition initialization helper for profile and CLI
|
|
||||||
processing, including NoSource override handling and source byte/node totals.
|
|
||||||
3. Preserve the exact existing order of option validation, parsing/import checks,
|
|
||||||
schema validation, version checking, profile/CLI application, defaults factory,
|
|
||||||
typed decoding, resolution, retained-history limit enforcement, secret lookup,
|
|
||||||
validation, metadata/classification, and result publication.
|
|
||||||
4. Keep diagnostic/source accumulation explicit. Warnings survive successful
|
|
||||||
phases; errors stop dependent work and never return a usable partial Result.
|
|
||||||
5. Preserve both ownership barriers: detach resolver-borrowed values before secret
|
|
||||||
resolution and validation, then detach equal-valued validator replacements
|
|
||||||
before publication while preserving validated secret state. Do not remove
|
|
||||||
either clone as apparently redundant work.
|
|
||||||
6. Group Result's frozen inspection state only if it clarifies this handoff; do
|
|
||||||
not add an additional history copy or change caller ownership of Config.
|
|
||||||
|
|
||||||
Acceptance: lifecycle ordering/early-stop tests, defaults-only loading, all input
|
|
||||||
combinations, callback ownership, secrets, warning retention, and complete
|
|
||||||
provenance agreement pass. Keep orchestration visible enough to verify phase
|
|
||||||
ordering by reading the coordinator. Run the complete race suite.
|
|
||||||
|
|
||||||
## Stage 8 — Separate explanation replay, snapshots, and rendering
|
|
||||||
|
|
||||||
Status: pending.
|
|
||||||
|
|
||||||
Evidence: [explain.go](../../explain.go) combines approximately 576 lines of public
|
|
||||||
records, replay algorithms, snapshot redaction, retained-history accounting, and
|
|
||||||
human-readable output.
|
|
||||||
|
|
||||||
Work:
|
|
||||||
|
|
||||||
1. Keep public explanation records and Explain coordination in `explain.go`.
|
|
||||||
Move application/snapshot replay and history budgeting to `history.go`, safe
|
|
||||||
snapshot projection to `snapshot.go`, and WriteExplanation with its formatting
|
|
||||||
helpers to `explain_render.go`.
|
|
||||||
2. Make effective rendering and explanation snapshots use the existing shared
|
|
||||||
redaction implementation; do not create separate sensitivity rules.
|
|
||||||
3. Retain the canonical decoding reconciliation event between input replay and
|
|
||||||
domain resolution, overlapping resolution intent coalescing, added/deleted
|
|
||||||
paths, effective source attribution, and historical secret classification.
|
|
||||||
4. Document the differences between metadata origin records and value replay:
|
|
||||||
they serve different query contracts but must agree on effective results.
|
|
||||||
Do not replace them with a new event-store abstraction in this cleanup.
|
|
||||||
5. Preserve writer failure behavior and ownership of retained trees. Do not cache
|
|
||||||
explanations or optimize full-tree replay without measured need.
|
|
||||||
|
|
||||||
Acceptance: explanation, effective rendering, history budget, provenance, secret
|
|
||||||
redaction, and writer-failure tests pass without output changes. Container and
|
|
||||||
leaf histories still reconcile with the frozen effective typed configuration.
|
|
||||||
|
|
||||||
## Stage 9 — Complete ownership documentation and integration validation
|
|
||||||
|
|
||||||
Status: pending.
|
|
||||||
|
|
||||||
Work:
|
|
||||||
|
|
||||||
1. Review the final code for obsolete helpers, duplicate traversal/translation,
|
|
||||||
unread retained state, stale comments, and imports left by earlier stages.
|
|
||||||
Confirm file splits reflect discrete responsibilities rather than line quotas.
|
|
||||||
2. Update the component overview to describe the actual owners and fix its stale
|
|
||||||
claim that metadata queries are empty. Keep detailed consumer semantics in
|
|
||||||
their existing contract; repair navigation and moved-file links throughout
|
|
||||||
documentation, including this roadmap's evidence links if necessary.
|
|
||||||
3. State the retained dependency direction clearly: public lifecycle coordination
|
|
||||||
uses private source/document/schema/inspection helpers; the owned document
|
|
||||||
representation has no dependency on the root package; only the private YAML
|
|
||||||
adapter imports the upstream YAML package.
|
|
||||||
4. Run the full baseline and race suites, local documentation and dependency
|
|
||||||
boundary checks, and the complete source-candidate checker described in the
|
|
||||||
release procedure with available qualifying toolchains. Record exact versions,
|
|
||||||
host platform, results, and any unavailable platform evidence. Cross-builds
|
|
||||||
are not native execution evidence.
|
|
||||||
5. Review the accumulated diff for public API/configuration/diagnostic changes.
|
|
||||||
Expected outcome: no consumer migration or dependency change. If any contract
|
|
||||||
change is discovered, resolve it explicitly before closing this roadmap.
|
|
||||||
6. Record completion evidence and commit this final nonempty unit. Release
|
|
||||||
preparation must subsequently retire completed roadmap material and validate
|
|
||||||
the exact committed candidate under release policy. Completing this roadmap
|
|
||||||
does not create a tag, push refs, or authorize publication.
|
|
||||||
|
|
||||||
Acceptance: all stages are complete with recorded validation, existing behavioral
|
|
||||||
regressions remain protected, documentation matches the new ownership layout,
|
|
||||||
and the worktree is clean after commits. Required release gates remain visible
|
|
||||||
in the release procedure rather than being declared satisfied by this refactor.
|
|
||||||
|
|
||||||
## Open Questions
|
|
||||||
|
|
||||||
None required to begin implementation. The package boundaries, compatibility
|
|
||||||
constraints, ordered units, and acceptance criteria above are decided. Routine
|
|
||||||
private naming adjustments are acceptable when they preserve those decisions;
|
|
||||||
new behavioral discoveries require explicit evidence and disposition.
|
|
||||||
110
document_compare.go
Normal file
110
document_compare.go
Normal 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
86
document_walk.go
Normal 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
|
||||||
|
}
|
||||||
@@ -83,7 +83,7 @@ type ObjectBinding struct {
|
|||||||
Options map[string]any `yaml:"options"`
|
Options map[string]any `yaml:"options"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// NotariusConfig demonstrates string versions and dynamic catalog options.
|
// NotariusConfig demonstrates string-backed versions and dynamic catalog options.
|
||||||
type NotariusConfig struct {
|
type NotariusConfig struct {
|
||||||
Version string `yaml:"version"`
|
Version string `yaml:"version"`
|
||||||
Bindings []ObjectBinding `yaml:"bindings"`
|
Bindings []ObjectBinding `yaml:"bindings"`
|
||||||
@@ -93,7 +93,7 @@ type NotariusConfig struct {
|
|||||||
func LoadNotarius(path string) (*configkit.Result[NotariusConfig], error) {
|
func LoadNotarius(path string) (*configkit.Result[NotariusConfig], error) {
|
||||||
return configkit.Load(configkit.File(path), configkit.Options[NotariusConfig]{
|
return configkit.Load(configkit.File(path), configkit.Options[NotariusConfig]{
|
||||||
Definition: configkit.Definition[NotariusConfig]{
|
Definition: configkit.Definition[NotariusConfig]{
|
||||||
Version: &configkit.VersionRule{Path: configkit.Keys("version"), Check: requireVersion("v1")},
|
Version: &configkit.VersionRule{Path: configkit.Keys("version"), Check: requireVersion("4")},
|
||||||
Validate: validateNotarius,
|
Validate: validateNotarius,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ func Example_notarius() {
|
|||||||
result, err := consumers.LoadNotarius(fixture("notarius", "config.yml"))
|
result, err := consumers.LoadNotarius(fixture("notarius", "config.yml"))
|
||||||
window, ok := consumers.NotariusWindow(result.Config, 0)
|
window, ok := consumers.NotariusWindow(result.Config, 0)
|
||||||
fmt.Println(result.Config.Version, result.Config.Bindings[0].Object, window, ok, err)
|
fmt.Println(result.Config.Version, result.Config.Bindings[0].Object, window, ok, err)
|
||||||
// Output: v1 summary 12 true <nil>
|
// Output: 4 summary 12 true <nil>
|
||||||
}
|
}
|
||||||
|
|
||||||
func Example_weatherReporter() {
|
func Example_weatherReporter() {
|
||||||
@@ -100,6 +100,10 @@ func TestMaintainedConsumerFixturesPreserveDomainOutcomes(t *testing.T) {
|
|||||||
if _, ok := notarius.Config.Bindings[0].Options["window_units"].(int64); !ok {
|
if _, ok := notarius.Config.Bindings[0].Options["window_units"].(int64); !ok {
|
||||||
t.Fatalf("dynamic integer type = %T", notarius.Config.Bindings[0].Options["window_units"])
|
t.Fatalf("dynamic integer type = %T", notarius.Config.Bindings[0].Options["window_units"])
|
||||||
}
|
}
|
||||||
|
integerNotarius, err := consumers.LoadNotarius(fixture("notarius", "integer-version.yml"))
|
||||||
|
if err != nil || integerNotarius.Config.Version != notarius.Config.Version || integerNotarius.Config.Version != "4" {
|
||||||
|
t.Fatalf("Notarius version forms = %#v / %#v, %v", notarius.Config, integerNotarius, err)
|
||||||
|
}
|
||||||
|
|
||||||
weather, err := consumers.LoadWeather(configkit.File(fixture("weather", "config.yml")))
|
weather, err := consumers.LoadWeather(configkit.File(fixture("weather", "config.yml")))
|
||||||
if err != nil || weather.Config.Reports["daily"].Modules[1].Options.Sections[0] != "short_term" {
|
if err != nil || weather.Config.Reports["daily"].Modules[1].Options.Sections[0] != "short_term" {
|
||||||
@@ -124,8 +128,19 @@ func TestMigrationFixturesFailWithStableCodesAndPaths(t *testing.T) {
|
|||||||
path string
|
path string
|
||||||
}{
|
}{
|
||||||
{
|
{
|
||||||
name: "integer schema version",
|
name: "unsupported application version",
|
||||||
load: func() error { _, err := consumers.LoadNotarius(fixture("notarius", "integer-version.yml")); return err },
|
load: func() error {
|
||||||
|
_, err := consumers.LoadNotarius(fixture("notarius", "unsupported-version.yml"))
|
||||||
|
return err
|
||||||
|
},
|
||||||
|
code: configkit.CodeVersionError, path: "/version",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "non-integer numeric version",
|
||||||
|
load: func() error {
|
||||||
|
_, err := consumers.LoadNotarius(fixture("notarius", "noninteger-version.yml"))
|
||||||
|
return err
|
||||||
|
},
|
||||||
code: configkit.CodeVersionType, path: "/version",
|
code: configkit.CodeVersionType, path: "/version",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
_configkit:
|
configkit:
|
||||||
imports:
|
imports:
|
||||||
- path: artifacts.yml
|
- path: artifacts.yml
|
||||||
poll_interval: 1m
|
poll_interval: 1m
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
version: v1
|
version: "4"
|
||||||
bindings:
|
bindings:
|
||||||
- object: summary
|
- object: summary
|
||||||
options:
|
options:
|
||||||
|
|||||||
@@ -1,2 +1,6 @@
|
|||||||
version: 1
|
version: 4
|
||||||
bindings: []
|
bindings:
|
||||||
|
- object: summary
|
||||||
|
options:
|
||||||
|
window_units: 12
|
||||||
|
format: markdown
|
||||||
|
|||||||
2
examples/consumers/testdata/notarius/noninteger-version.yml
vendored
Normal file
2
examples/consumers/testdata/notarius/noninteger-version.yml
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
version: 4.0
|
||||||
|
bindings: []
|
||||||
2
examples/consumers/testdata/notarius/unsupported-version.yml
vendored
Normal file
2
examples/consumers/testdata/notarius/unsupported-version.yml
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
version: 5
|
||||||
|
bindings: []
|
||||||
458
explain.go
458
explain.go
@@ -1,23 +1,6 @@
|
|||||||
package configkit
|
package configkit
|
||||||
|
|
||||||
import (
|
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
"bytes"
|
|
||||||
"errors"
|
|
||||||
"io"
|
|
||||||
"slices"
|
|
||||||
"strconv"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
|
||||||
yamladapter "gitea.maximumdirect.net/eric/ConfigKit/internal/yaml"
|
|
||||||
)
|
|
||||||
|
|
||||||
const historyNodeLimit = 4_000_000
|
|
||||||
|
|
||||||
var (
|
|
||||||
errExplanationUnavailable = errors.New("configkit: configuration explanation is unavailable")
|
|
||||||
errExplanationUnknownPath = errors.New("configkit: configuration explanation path is unknown")
|
|
||||||
)
|
|
||||||
|
|
||||||
// ValueSnapshot is a detached, deterministic YAML representation of one value.
|
// ValueSnapshot is a detached, deterministic YAML representation of one value.
|
||||||
// Present distinguishes absence or deletion from an explicit null value.
|
// Present distinguishes absence or deletion from an explicit null value.
|
||||||
@@ -51,38 +34,6 @@ type Explanation struct {
|
|||||||
SecretReference *SecretReferenceInfo
|
SecretReference *SecretReferenceInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
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 cloneSecretReferences(values map[string]string) map[string]string {
|
|
||||||
if values == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
cloned := make(map[string]string, len(values))
|
|
||||||
for path, name := range values {
|
|
||||||
cloned[path] = name
|
|
||||||
}
|
|
||||||
return cloned
|
|
||||||
}
|
|
||||||
|
|
||||||
// Explain returns safe effective and chronological values for a known final or
|
// Explain returns safe effective and chronological values for a known final or
|
||||||
// historical path. Unknown and malformed paths return false.
|
// historical path. Unknown and malformed paths return false.
|
||||||
func (r *Result[T]) Explain(path Path) (Explanation, bool) {
|
func (r *Result[T]) Explain(path Path) (Explanation, bool) {
|
||||||
@@ -126,7 +77,7 @@ func (r *Result[T]) Explain(path Path) (Explanation, bool) {
|
|||||||
// explicitly, including canonical scalar conversions, before domain changes.
|
// explicitly, including canonical scalar conversions, before domain changes.
|
||||||
replayed, _ := documentAtPath(working, path)
|
replayed, _ := documentAtPath(working, path)
|
||||||
decoded, _ := documentAtPath(r.input, path)
|
decoded, _ := documentAtPath(r.input, path)
|
||||||
if !documentNodesEqual(replayed, decoded) {
|
if !configurationValuesEqual(replayed, decoded) {
|
||||||
origin := cloneOrigin(r.inputOrigins[path.String()])
|
origin := cloneOrigin(r.inputOrigins[path.String()])
|
||||||
origin.Operation = ChangeDecoding
|
origin.Operation = ChangeDecoding
|
||||||
explanation.History = append(explanation.History, ValueChange{
|
explanation.History = append(explanation.History, ValueChange{
|
||||||
@@ -169,408 +120,3 @@ func (r *Result[T]) Explain(path Path) (Explanation, bool) {
|
|||||||
}
|
}
|
||||||
return explanation, true
|
return explanation, true
|
||||||
}
|
}
|
||||||
|
|
||||||
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(¤t.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 valueSnapshot(root *document.Node, path Path, secrets map[string]bool) ValueSnapshot {
|
|
||||||
node, present := documentAtPath(root, path)
|
|
||||||
if !present {
|
|
||||||
return ValueSnapshot{}
|
|
||||||
}
|
|
||||||
safe, redacted := safeSnapshotDocument(node, secrets, path)
|
|
||||||
var output bytes.Buffer
|
|
||||||
if err := yamladapter.Write(&output, safe); err != nil {
|
|
||||||
return ValueSnapshot{Present: true, Redacted: true, YAML: "'[REDACTED]'\n"}
|
|
||||||
}
|
|
||||||
return ValueSnapshot{Present: true, Redacted: redacted, YAML: output.String()}
|
|
||||||
}
|
|
||||||
|
|
||||||
func safeSnapshotDocument(node *document.Node, secrets map[string]bool, path Path) (*document.Node, bool) {
|
|
||||||
if node == nil {
|
|
||||||
return nil, false
|
|
||||||
}
|
|
||||||
if pathIsSecret(secrets, path) {
|
|
||||||
return &document.Node{Kind: document.String, Text: "[REDACTED]"}, true
|
|
||||||
}
|
|
||||||
result := *node
|
|
||||||
redacted := false
|
|
||||||
switch node.Kind {
|
|
||||||
case document.Mapping:
|
|
||||||
result.Mapping = make([]document.Entry, len(node.Mapping))
|
|
||||||
copy(result.Mapping, node.Mapping)
|
|
||||||
sortDocumentMapping(result.Mapping)
|
|
||||||
for index := range result.Mapping {
|
|
||||||
entry := &result.Mapping[index]
|
|
||||||
entry.Value, redacted = safeSnapshotChild(entry.Value, secrets, path.Key(entry.Key), redacted)
|
|
||||||
}
|
|
||||||
case document.Sequence:
|
|
||||||
result.Sequence = make([]*document.Node, len(node.Sequence))
|
|
||||||
for index, child := range node.Sequence {
|
|
||||||
result.Sequence[index], redacted = safeSnapshotChild(child, secrets, path.Index(index), redacted)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return &result, redacted
|
|
||||||
}
|
|
||||||
|
|
||||||
func safeSnapshotChild(node *document.Node, secrets map[string]bool, path Path, prior bool) (*document.Node, bool) {
|
|
||||||
safe, redacted := safeSnapshotDocument(node, secrets, path)
|
|
||||||
return safe, prior || redacted
|
|
||||||
}
|
|
||||||
|
|
||||||
func sortDocumentMapping(entries []document.Entry) {
|
|
||||||
slices.SortFunc(entries, func(left, right document.Entry) int {
|
|
||||||
return strings.Compare(left.Key, right.Key)
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
123
explain_render.go
Normal file
123
explain_render.go
Normal 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')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
106
explain_test.go
106
explain_test.go
@@ -23,7 +23,7 @@ type explanationConcurrency struct {
|
|||||||
|
|
||||||
func TestExplainShowsDefaultBaseAndEffectiveProfileValue(t *testing.T) {
|
func TestExplainShowsDefaultBaseAndEffectiveProfileValue(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "config.yml", "_configkit:\n imports: [{path: transcription.yml}]\n")
|
mainPath := writeImportFile(t, directory, "config.yml", "configkit:\n imports: [{path: transcription.yml}]\n")
|
||||||
writeImportFile(t, directory, "transcription.yml", "transcription:\n concurrency: 8\n")
|
writeImportFile(t, directory, "transcription.yml", "transcription:\n concurrency: 8\n")
|
||||||
writeImportFile(t, directory, filepath.Join("profiles", "testing.yml"), "transcription:\n concurrency: 1\n")
|
writeImportFile(t, directory, filepath.Join("profiles", "testing.yml"), "transcription:\n concurrency: 1\n")
|
||||||
result, err := Load[explanationConcurrency](File(mainPath), Options[explanationConcurrency]{
|
result, err := Load[explanationConcurrency](File(mainPath), Options[explanationConcurrency]{
|
||||||
@@ -366,6 +366,110 @@ func TestExplanationReconcilesDefaultsAfterInputReplacement(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestIntegerVersionInspectionRetainsInputAndReconcilesTypedValue(t *testing.T) {
|
||||||
|
type config struct {
|
||||||
|
Version string `yaml:"version"`
|
||||||
|
}
|
||||||
|
rule := &VersionRule{Path: Keys("version"), Check: func(version Version, _ *Validation) error {
|
||||||
|
if version.Text != "4" {
|
||||||
|
t.Errorf("version = %#v", version)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}}
|
||||||
|
options := Options[config]{Definition: Definition[config]{
|
||||||
|
Defaults: func() config { return config{Version: "4"} },
|
||||||
|
Version: rule,
|
||||||
|
}}
|
||||||
|
first, err := Load(Bytes("version.yml", []byte("version: 4\n")), options)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
second, err := Load(Bytes("version.yml", []byte("version: 4\n")), options)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if first.Config.Version != "4" || second.Config.Version != "4" {
|
||||||
|
t.Fatalf("configurations = %#v / %#v", first.Config, second.Config)
|
||||||
|
}
|
||||||
|
inputVersion, inputOK := documentAtPath(first.applications[0].value, Keys("version"))
|
||||||
|
finalVersion, finalOK := documentAtPath(first.final, Keys("version"))
|
||||||
|
if !inputOK || inputVersion.Kind != document.Number || inputVersion.Text != "4" ||
|
||||||
|
!finalOK || finalVersion.Kind != document.String || finalVersion.Text != "4" {
|
||||||
|
t.Fatalf("input/final = %#v / %#v", inputVersion, finalVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
info, ok := first.Metadata().Lookup(Keys("version"))
|
||||||
|
if !ok || !info.Explicit || info.Null || info.Origin.Layer != LayerBase || len(info.Origin.Locations) != 1 ||
|
||||||
|
info.Origin.Locations[0].SourceID != "source-1" || info.Origin.Locations[0].Line != 1 {
|
||||||
|
t.Fatalf("version metadata = %#v, %v", info, ok)
|
||||||
|
}
|
||||||
|
explanation, ok := first.Explain(Keys("version"))
|
||||||
|
if !ok || explanation.Effective.YAML != "'4'\n" {
|
||||||
|
t.Fatalf("version explanation = %#v, %v", explanation, ok)
|
||||||
|
}
|
||||||
|
foundInput, foundDecoding := false, false
|
||||||
|
for _, change := range explanation.History {
|
||||||
|
if change.After.YAML == "4\n" && change.Origin.Layer == LayerBase {
|
||||||
|
foundInput = true
|
||||||
|
}
|
||||||
|
if change.Before.YAML == "4\n" && change.After.YAML == "'4'\n" && change.Origin.Operation == ChangeDecoding {
|
||||||
|
foundDecoding = true
|
||||||
|
}
|
||||||
|
if change.Origin.Layer == LayerResolution {
|
||||||
|
t.Fatalf("conversion recorded as resolution: %#v", change)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundInput || !foundDecoding {
|
||||||
|
t.Fatalf("version history = %#v", explanation.History)
|
||||||
|
}
|
||||||
|
paths := first.Metadata().HistoricalPaths()
|
||||||
|
foundPath := false
|
||||||
|
for _, path := range paths {
|
||||||
|
if path.String() == "/version" {
|
||||||
|
foundPath = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundPath || len(first.Metadata().History(Keys("version"))) < 2 {
|
||||||
|
t.Fatalf("historical version paths/history = %#v / %#v", paths, first.Metadata().History(Keys("version")))
|
||||||
|
}
|
||||||
|
|
||||||
|
var effective strings.Builder
|
||||||
|
if err := first.WriteEffective(&effective); err != nil || effective.String() != "version: '4'\n" {
|
||||||
|
t.Fatalf("effective output = %q, %v", effective.String(), err)
|
||||||
|
}
|
||||||
|
first.Config.Version = "mutated"
|
||||||
|
effective.Reset()
|
||||||
|
if err := first.WriteEffective(&effective); err != nil || effective.String() != "version: '4'\n" || second.Config.Version != "4" {
|
||||||
|
t.Fatalf("detached results = %q / %#v, %v", effective.String(), second.Config, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSensitiveIntegerVersionStaysRedactedWithoutSecretLookup(t *testing.T) {
|
||||||
|
type config struct {
|
||||||
|
Version string `yaml:"version"`
|
||||||
|
}
|
||||||
|
lookups := 0
|
||||||
|
result, err := loadWithSecretLookup(Bytes("version.yml", []byte("version: 987654321\n")), func(string) (string, bool) {
|
||||||
|
lookups++
|
||||||
|
return "CREDENTIAL_CANARY", true
|
||||||
|
}, Options[config]{Definition: Definition[config]{
|
||||||
|
Version: &VersionRule{Path: Keys("version"), Check: func(Version, *Validation) error { return nil }},
|
||||||
|
Secret: func(path Path) bool { return path.String() == "/version" },
|
||||||
|
}})
|
||||||
|
if err != nil || result.Config.Version != "987654321" || lookups != 0 {
|
||||||
|
t.Fatalf("result = %#v, lookups = %d, err = %v", result, lookups, err)
|
||||||
|
}
|
||||||
|
var effective strings.Builder
|
||||||
|
if err := result.WriteEffective(&effective); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
explanation, ok := result.Explain(Keys("version"))
|
||||||
|
formatted := effective.String() + fmt.Sprintf("%#v %#v", explanation, result.Metadata().History(Keys("version")))
|
||||||
|
if !ok || !explanation.Effective.Redacted || strings.Contains(formatted, "987654321") || strings.Contains(formatted, "CREDENTIAL_CANARY") {
|
||||||
|
t.Fatalf("sensitive inspection = %q / %#v", effective.String(), explanation)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestExplanationReplaysResolutionAdditionsAndOverlappingIntent(t *testing.T) {
|
func TestExplanationReplaysResolutionAdditionsAndOverlappingIntent(t *testing.T) {
|
||||||
type cfg struct {
|
type cfg struct {
|
||||||
M map[string]any `yaml:"m"`
|
M map[string]any `yaml:"m"`
|
||||||
|
|||||||
121
filesystem.go
Normal file
121
filesystem.go
Normal 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))
|
||||||
|
}
|
||||||
10
glob_test.go
10
glob_test.go
@@ -10,13 +10,13 @@ import (
|
|||||||
|
|
||||||
func TestLoadGlobImportsUseLexicalDeclarationOrderAndNestedDFS(t *testing.T) {
|
func TestLoadGlobImportsUseLexicalDeclarationOrderAndNestedDFS(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", `_configkit:
|
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||||
imports:
|
imports:
|
||||||
- glob: fragments/*.yml
|
- glob: fragments/*.yml
|
||||||
- path: after.yml
|
- path: after.yml
|
||||||
items: {root: root}
|
items: {root: root}
|
||||||
`)
|
`)
|
||||||
aPath := writeImportFile(t, directory, "fragments/a.yml", `_configkit:
|
aPath := writeImportFile(t, directory, "fragments/a.yml", `configkit:
|
||||||
imports: [{path: nested/child.yml}]
|
imports: [{path: nested/child.yml}]
|
||||||
items: {alpha: alpha}
|
items: {alpha: alpha}
|
||||||
`)
|
`)
|
||||||
@@ -56,7 +56,7 @@ items: {alpha: alpha}
|
|||||||
|
|
||||||
func TestLoadGlobImportsSupportEscapesClassesAndExplicitConfD(t *testing.T) {
|
func TestLoadGlobImportsSupportEscapesClassesAndExplicitConfD(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", `_configkit:
|
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||||
imports:
|
imports:
|
||||||
- glob: 'conf.d/literal\*.yml'
|
- glob: 'conf.d/literal\*.yml'
|
||||||
- glob: 'conf.d/item-[ab].yml'
|
- glob: 'conf.d/item-[ab].yml'
|
||||||
@@ -126,7 +126,7 @@ func TestLoadGlobImportsPreserveIdentityAndFileRules(t *testing.T) {
|
|||||||
t.Run("overlapping declarations", func(t *testing.T) {
|
t.Run("overlapping declarations", func(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
writeImportFile(t, directory, "conf/value.yml", "items: {value: value}\n")
|
writeImportFile(t, directory, "conf/value.yml", "items: {value: value}\n")
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", `_configkit:
|
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||||
imports:
|
imports:
|
||||||
- glob: conf/*.yml
|
- glob: conf/*.yml
|
||||||
- glob: conf/value.*
|
- glob: conf/value.*
|
||||||
@@ -269,5 +269,5 @@ func writeGlobRoot(t *testing.T, directory, pattern string, optional bool) strin
|
|||||||
if optional {
|
if optional {
|
||||||
optionalLine = "\n optional: true"
|
optionalLine = "\n optional: true"
|
||||||
}
|
}
|
||||||
return writeImportFile(t, directory, "main.yml", "_configkit:\n imports:\n - glob: '"+pattern+"'"+optionalLine+"\n")
|
return writeImportFile(t, directory, "main.yml", "configkit:\n imports:\n - glob: '"+pattern+"'"+optionalLine+"\n")
|
||||||
}
|
}
|
||||||
|
|||||||
276
history.go
Normal file
276
history.go
Normal 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(¤t.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
|
||||||
|
}
|
||||||
364
imports.go
364
imports.go
@@ -3,7 +3,6 @@ package configkit
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"path"
|
"path"
|
||||||
@@ -36,13 +35,6 @@ func defaultImportLimits() importLimits {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type importDeclaration struct {
|
|
||||||
path string
|
|
||||||
glob string
|
|
||||||
optional bool
|
|
||||||
location Location
|
|
||||||
}
|
|
||||||
|
|
||||||
type importRoute struct {
|
type importRoute struct {
|
||||||
canonical string
|
canonical string
|
||||||
locations []Location
|
locations []Location
|
||||||
@@ -121,120 +113,6 @@ func loadImportedDocuments(source Source, context *FilesystemContext, main parse
|
|||||||
return composed, cloneSourceInfos(state.sources), diagnostics
|
return composed, cloneSourceInfos(state.sources), diagnostics
|
||||||
}
|
}
|
||||||
|
|
||||||
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))
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *importTraversal) checkInitialLimits() *Diagnostic {
|
func (s *importTraversal) checkInitialLimits() *Diagnostic {
|
||||||
if s.limits.sources < 1 {
|
if s.limits.sources < 1 {
|
||||||
value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the source-count limit", nil)
|
value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the source-count limit", nil)
|
||||||
@@ -345,7 +223,7 @@ func (s *importTraversal) visitPath(basePath string, declaration importDeclarati
|
|||||||
Depth: maxDocumentDepth, Nodes: maxDocumentNodes,
|
Depth: maxDocumentDepth, Nodes: maxDocumentNodes,
|
||||||
})
|
})
|
||||||
if len(issues) != 0 {
|
if len(issues) != 0 {
|
||||||
return importParseDiagnostics(sourceID, issues)
|
return parserDiagnostics(sourceID, issues)
|
||||||
}
|
}
|
||||||
nodeCount := countInputNodes(root)
|
nodeCount := countInputNodes(root)
|
||||||
if s.usedNodes+nodeCount > s.limits.nodes {
|
if s.usedNodes+nodeCount > s.limits.nodes {
|
||||||
@@ -382,47 +260,12 @@ func (s *importTraversal) readImport(relative, sourceID string, optional bool) (
|
|||||||
if limit < 0 {
|
if limit < 0 {
|
||||||
limit = 0
|
limit = 0
|
||||||
}
|
}
|
||||||
preflight, err := s.root.Stat(relative)
|
data, failure := readRegularFile(relative, s.root.Stat, s.root.Open, limit)
|
||||||
if err != nil {
|
if failure != nil {
|
||||||
if optional && errors.Is(err, fs.ErrNotExist) {
|
if optional && failure.missingTarget {
|
||||||
return nil, true, nil
|
return nil, true, nil
|
||||||
}
|
}
|
||||||
value := classifySourceError(err, sourceID)
|
value := sourceReadDiagnostic(failure, sourceID, CodeImportLimitExceeded, "configuration exceeds a source or aggregate byte limit")
|
||||||
return nil, false, &value
|
|
||||||
}
|
|
||||||
if !preflight.Mode().IsRegular() {
|
|
||||||
value := sourceDiagnostic(CodeSourceNotRegular, sourceID, "source is not a regular file")
|
|
||||||
return nil, false, &value
|
|
||||||
}
|
|
||||||
file, err := s.root.Open(relative)
|
|
||||||
if err != nil {
|
|
||||||
if optional && errors.Is(err, fs.ErrNotExist) {
|
|
||||||
return nil, true, nil
|
|
||||||
}
|
|
||||||
value := classifySourceError(err, sourceID)
|
|
||||||
return nil, false, &value
|
|
||||||
}
|
|
||||||
defer func() { _ = file.Close() }()
|
|
||||||
info, err := file.Stat()
|
|
||||||
if err != nil {
|
|
||||||
value := classifySourceError(err, sourceID)
|
|
||||||
return nil, false, &value
|
|
||||||
}
|
|
||||||
if !info.Mode().IsRegular() {
|
|
||||||
value := sourceDiagnostic(CodeSourceNotRegular, sourceID, "source is not a regular file")
|
|
||||||
return nil, false, &value
|
|
||||||
}
|
|
||||||
if info.Size() > limit {
|
|
||||||
value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds a source or aggregate byte limit", &Location{SourceID: sourceID})
|
|
||||||
return nil, false, &value
|
|
||||||
}
|
|
||||||
data, err := io.ReadAll(io.LimitReader(file, limit+1))
|
|
||||||
if err != nil {
|
|
||||||
value := classifySourceError(err, sourceID)
|
|
||||||
return nil, false, &value
|
|
||||||
}
|
|
||||||
if int64(len(data)) > limit {
|
|
||||||
value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds a source or aggregate byte limit", &Location{SourceID: sourceID})
|
|
||||||
return nil, false, &value
|
return nil, false, &value
|
||||||
}
|
}
|
||||||
s.usedBytes += int64(len(data))
|
s.usedBytes += int64(len(data))
|
||||||
@@ -440,184 +283,6 @@ func (s *importTraversal) currentRoute(location Location) importRoute {
|
|||||||
return importRoute{locations: locations}
|
return importRoute{locations: locations}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 != "_configkit" {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
directiveIndex = index
|
|
||||||
parsed, diagnostics := parseImportDirective(entry.Value, Keys("_configkit"))
|
|
||||||
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 == "_configkit" {
|
|
||||||
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 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
|
|
||||||
}
|
|
||||||
|
|
||||||
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 sourceInfos(source *SourceInfo) []SourceInfo {
|
func sourceInfos(source *SourceInfo) []SourceInfo {
|
||||||
if source == nil {
|
if source == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -625,29 +290,10 @@ func sourceInfos(source *SourceInfo) []SourceInfo {
|
|||||||
return []SourceInfo{cloneSourceInfo(*source)}
|
return []SourceInfo{cloneSourceInfo(*source)}
|
||||||
}
|
}
|
||||||
|
|
||||||
func importParseDiagnostics(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 diagnostics
|
|
||||||
}
|
|
||||||
|
|
||||||
func importDiagnostic(code Code, message string, location *Location) Diagnostic {
|
func importDiagnostic(code Code, message string, location *Location) Diagnostic {
|
||||||
return Diagnostic{Code: code, Severity: SeverityError, Message: message, Primary: cloneLocation(location)}
|
return Diagnostic{Code: code, Severity: SeverityError, Message: message, Primary: cloneLocation(location)}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 importCycleDiagnostic(target string, location Location, cycle []importRoute) Diagnostic {
|
func importCycleDiagnostic(target string, location Location, cycle []importRoute) Diagnostic {
|
||||||
paths := make([]string, 0, len(cycle)+1)
|
paths := make([]string, 0, len(cycle)+1)
|
||||||
related := make([]Location, 0, len(cycle))
|
related := make([]Location, 0, len(cycle))
|
||||||
|
|||||||
@@ -22,16 +22,37 @@ type importTestConfig struct {
|
|||||||
Items map[string]string `yaml:"items"`
|
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) {
|
func TestLoadTraversesPathImportsDepthFirstAndComposesOrigins(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", `_configkit:
|
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||||
imports:
|
imports:
|
||||||
- path: first.yml
|
- path: first.yml
|
||||||
- path: second.yml
|
- path: second.yml
|
||||||
settings:
|
settings:
|
||||||
root: root
|
root: root
|
||||||
`)
|
`)
|
||||||
firstPath := writeImportFile(t, directory, "first.yml", `_configkit:
|
firstPath := writeImportFile(t, directory, "first.yml", `configkit:
|
||||||
imports:
|
imports:
|
||||||
- path: nested/third.yml
|
- path: nested/third.yml
|
||||||
settings:
|
settings:
|
||||||
@@ -71,7 +92,7 @@ settings:
|
|||||||
func TestLoadRejectsImportConflictsAndFragmentVersions(t *testing.T) {
|
func TestLoadRejectsImportConflictsAndFragmentVersions(t *testing.T) {
|
||||||
t.Run("all duplicate claimants", func(t *testing.T) {
|
t.Run("all duplicate claimants", func(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", `_configkit:
|
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||||
imports:
|
imports:
|
||||||
- path: one.yml
|
- path: one.yml
|
||||||
- path: two.yml
|
- path: two.yml
|
||||||
@@ -92,12 +113,12 @@ func TestLoadRejectsImportConflictsAndFragmentVersions(t *testing.T) {
|
|||||||
name string
|
name string
|
||||||
fragment string
|
fragment string
|
||||||
}{
|
}{
|
||||||
{name: "exact", fragment: "settings:\n version: v2\n"},
|
{name: "exact integer", fragment: "settings:\n version: 4\n"},
|
||||||
{name: "ancestor scalar", fragment: "settings: blocked\n"},
|
{name: "ancestor scalar", fragment: "settings: blocked\n"},
|
||||||
} {
|
} {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", "_configkit:\n imports:\n - path: fragment.yml\n")
|
mainPath := writeImportFile(t, directory, "main.yml", "configkit:\n imports:\n - path: fragment.yml\n")
|
||||||
writeImportFile(t, directory, "fragment.yml", test.fragment)
|
writeImportFile(t, directory, "fragment.yml", test.fragment)
|
||||||
loadError := requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true, Definition: Definition[importTestConfig]{Version: versionRule}}, CodeImportVersion)
|
loadError := requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true, Definition: Definition[importTestConfig]{Version: versionRule}}, CodeImportVersion)
|
||||||
diagnostic := loadError.Diagnostics()[0]
|
diagnostic := loadError.Diagnostics()[0]
|
||||||
@@ -114,15 +135,15 @@ func TestLoadImportDirectiveValidationAndTemporaryGlobGate(t *testing.T) {
|
|||||||
body string
|
body string
|
||||||
code Code
|
code Code
|
||||||
}{
|
}{
|
||||||
{name: "directive kind", body: "_configkit: []\n", code: CodeImportInvalid},
|
{name: "directive kind", body: "configkit: []\n", code: CodeImportInvalid},
|
||||||
{name: "unknown directive field", body: "_configkit: {unknown: true}\n", code: CodeImportInvalid},
|
{name: "unknown directive field", body: "configkit: {unknown: true}\n", code: CodeImportInvalid},
|
||||||
{name: "imports kind", body: "_configkit: {imports: {}}\n", code: CodeImportInvalid},
|
{name: "imports kind", body: "configkit: {imports: {}}\n", code: CodeImportInvalid},
|
||||||
{name: "declaration kind", body: "_configkit: {imports: [file.yml]}\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: "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: "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: "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: "optional kind", body: "configkit: {imports: [{path: a.yml, optional: 'yes'}]}\n", code: CodeImportInvalid},
|
||||||
{name: "nested directive", body: "nested: {_configkit: {}}\n", code: CodeImportInvalid},
|
{name: "nested directive", body: "nested: {configkit: {}}\n", code: CodeImportInvalid},
|
||||||
}
|
}
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
@@ -136,7 +157,7 @@ func TestLoadImportDirectiveValidationAndTemporaryGlobGate(t *testing.T) {
|
|||||||
func TestLoadImportMissingContainmentAndRegularFileRules(t *testing.T) {
|
func TestLoadImportMissingContainmentAndRegularFileRules(t *testing.T) {
|
||||||
t.Run("optional missing", func(t *testing.T) {
|
t.Run("optional missing", func(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", `_configkit:
|
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||||
imports:
|
imports:
|
||||||
- path: missing.yml
|
- path: missing.yml
|
||||||
optional: true
|
optional: true
|
||||||
@@ -172,7 +193,7 @@ settings: {root: retained}
|
|||||||
if test.setup != nil {
|
if test.setup != nil {
|
||||||
test.setup(t, directory)
|
test.setup(t, directory)
|
||||||
}
|
}
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", "_configkit:\n imports:\n - path: \""+test.path+"\"\n")
|
mainPath := writeImportFile(t, directory, "main.yml", "configkit:\n imports:\n - path: \""+test.path+"\"\n")
|
||||||
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, test.code)
|
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, test.code)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -181,9 +202,9 @@ settings: {root: retained}
|
|||||||
func TestLoadImportIdentityReportsCyclesAndRepeatedRoutes(t *testing.T) {
|
func TestLoadImportIdentityReportsCyclesAndRepeatedRoutes(t *testing.T) {
|
||||||
t.Run("cycle", func(t *testing.T) {
|
t.Run("cycle", func(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", "_configkit:\n imports: [{path: a.yml}]\n")
|
mainPath := writeImportFile(t, directory, "main.yml", "configkit:\n imports: [{path: a.yml}]\n")
|
||||||
writeImportFile(t, directory, "a.yml", "_configkit:\n imports: [{path: b.yml}]\n")
|
writeImportFile(t, directory, "a.yml", "configkit:\n imports: [{path: b.yml}]\n")
|
||||||
writeImportFile(t, directory, "b.yml", "_configkit:\n imports: [{path: main.yml}]\n")
|
writeImportFile(t, directory, "b.yml", "configkit:\n imports: [{path: main.yml}]\n")
|
||||||
loadError := requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportCycle)
|
loadError := requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportCycle)
|
||||||
diagnostic := loadError.Diagnostics()[0]
|
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") {
|
if diagnostic.Primary == nil || len(diagnostic.Related) != 2 || !strings.Contains(diagnostic.Message, "main.yml") || !strings.Contains(diagnostic.Message, "a.yml") || !strings.Contains(diagnostic.Message, "b.yml") {
|
||||||
@@ -193,9 +214,9 @@ func TestLoadImportIdentityReportsCyclesAndRepeatedRoutes(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("diamond", func(t *testing.T) {
|
t.Run("diamond", func(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", "_configkit:\n imports: [{path: left.yml}, {path: right.yml}]\n")
|
mainPath := writeImportFile(t, directory, "main.yml", "configkit:\n imports: [{path: left.yml}, {path: right.yml}]\n")
|
||||||
writeImportFile(t, directory, "left.yml", "_configkit:\n imports: [{path: shared.yml}]\n")
|
writeImportFile(t, directory, "left.yml", "configkit:\n imports: [{path: shared.yml}]\n")
|
||||||
writeImportFile(t, directory, "right.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")
|
writeImportFile(t, directory, "shared.yml", "settings: {root: shared}\n")
|
||||||
loadError := requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportRepeated)
|
loadError := requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportRepeated)
|
||||||
diagnostic := loadError.Diagnostics()[0]
|
diagnostic := loadError.Diagnostics()[0]
|
||||||
@@ -216,7 +237,7 @@ func TestLoadImportIdentityReportsCyclesAndRepeatedRoutes(t *testing.T) {
|
|||||||
if err := os.Symlink("target.yml", filepath.Join(directory, "second-link.yml")); err != nil {
|
if err := os.Symlink("target.yml", filepath.Join(directory, "second-link.yml")); err != nil {
|
||||||
t.Skipf("symlinks unavailable: %v", err)
|
t.Skipf("symlinks unavailable: %v", err)
|
||||||
}
|
}
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", "_configkit: {imports: [{path: first-link.yml}, {path: second-link.yml}]}\n")
|
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: first-link.yml}, {path: second-link.yml}]}\n")
|
||||||
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportRepeated)
|
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportRepeated)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -231,7 +252,7 @@ func TestLoadImportsCanonicalSymlinksAndKeepsHardLinksDistinct(t *testing.T) {
|
|||||||
if err := os.Symlink(target, filepath.Join(directory, "link.yml")); err != nil {
|
if err := os.Symlink(target, filepath.Join(directory, "link.yml")); err != nil {
|
||||||
t.Skipf("symlinks unavailable: %v", err)
|
t.Skipf("symlinks unavailable: %v", err)
|
||||||
}
|
}
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", "_configkit: {imports: [{path: link.yml}]}\n")
|
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: link.yml}]}\n")
|
||||||
result, err := Load[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
|
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) {
|
if err != nil || result.Config.Settings.First != "linked" || result.Sources()[1].Path != canonicalTestPath(t, target) {
|
||||||
t.Fatalf("symlink result = %#v, %v", result, err)
|
t.Fatalf("symlink result = %#v, %v", result, err)
|
||||||
@@ -245,7 +266,7 @@ func TestLoadImportsCanonicalSymlinksAndKeepsHardLinksDistinct(t *testing.T) {
|
|||||||
if err := os.Symlink(target, filepath.Join(root, "escape.yml")); err != nil {
|
if err := os.Symlink(target, filepath.Join(root, "escape.yml")); err != nil {
|
||||||
t.Skipf("symlinks unavailable: %v", err)
|
t.Skipf("symlinks unavailable: %v", err)
|
||||||
}
|
}
|
||||||
mainPath := writeImportFile(t, root, "main.yml", "_configkit: {imports: [{path: escape.yml}]}\n")
|
mainPath := writeImportFile(t, root, "main.yml", "configkit: {imports: [{path: escape.yml}]}\n")
|
||||||
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportOutsideRoot)
|
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportOutsideRoot)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -255,7 +276,7 @@ func TestLoadImportsCanonicalSymlinksAndKeepsHardLinksDistinct(t *testing.T) {
|
|||||||
if err := os.Link(one, filepath.Join(directory, "two.yml")); err != nil {
|
if err := os.Link(one, filepath.Join(directory, "two.yml")); err != nil {
|
||||||
t.Skipf("hard links unavailable: %v", err)
|
t.Skipf("hard links unavailable: %v", err)
|
||||||
}
|
}
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", "_configkit: {imports: [{path: one.yml}, {path: two.yml}]}\n")
|
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: one.yml}, {path: two.yml}]}\n")
|
||||||
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeComposeConflict)
|
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeComposeConflict)
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -264,7 +285,7 @@ func TestLoadImportsCanonicalSymlinksAndKeepsHardLinksDistinct(t *testing.T) {
|
|||||||
if err := os.Symlink("loop.yml", filepath.Join(directory, "loop.yml")); err != nil {
|
if err := os.Symlink("loop.yml", filepath.Join(directory, "loop.yml")); err != nil {
|
||||||
t.Skipf("symlinks unavailable: %v", err)
|
t.Skipf("symlinks unavailable: %v", err)
|
||||||
}
|
}
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", "_configkit: {imports: [{path: loop.yml}]}\n")
|
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: loop.yml}]}\n")
|
||||||
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportInvalid)
|
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportInvalid)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -272,7 +293,7 @@ func TestLoadImportsCanonicalSymlinksAndKeepsHardLinksDistinct(t *testing.T) {
|
|||||||
func TestLoadByteAndReaderImportsRequireExplicitFilesystemContext(t *testing.T) {
|
func TestLoadByteAndReaderImportsRequireExplicitFilesystemContext(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
writeImportFile(t, directory, "fragment.yml", "settings: {first: imported}\n")
|
writeImportFile(t, directory, "fragment.yml", "settings: {first: imported}\n")
|
||||||
input := []byte("_configkit: {imports: [{path: fragment.yml}]}\n")
|
input := []byte("configkit: {imports: [{path: fragment.yml}]}\n")
|
||||||
context := &FilesystemContext{BaseDir: directory, Root: directory}
|
context := &FilesystemContext{BaseDir: directory, Root: directory}
|
||||||
for _, test := range []struct {
|
for _, test := range []struct {
|
||||||
name string
|
name string
|
||||||
@@ -295,7 +316,7 @@ func TestLoadByteAndReaderImportsRequireExplicitFilesystemContext(t *testing.T)
|
|||||||
|
|
||||||
func TestFileImportRootCanContainTheSelectedFileDirectory(t *testing.T) {
|
func TestFileImportRootCanContainTheSelectedFileDirectory(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
mainPath := writeImportFile(t, root, "configs/main.yml", "_configkit: {imports: [{path: ../shared.yml}]}\n")
|
mainPath := writeImportFile(t, root, "configs/main.yml", "configkit: {imports: [{path: ../shared.yml}]}\n")
|
||||||
writeImportFile(t, root, "shared.yml", "settings: {root: shared}\n")
|
writeImportFile(t, root, "shared.yml", "settings: {root: shared}\n")
|
||||||
result, err := Load[importTestConfig](File(mainPath), Options[importTestConfig]{
|
result, err := Load[importTestConfig](File(mainPath), Options[importTestConfig]{
|
||||||
Imports: true, Filesystem: &FilesystemContext{Root: root},
|
Imports: true, Filesystem: &FilesystemContext{Root: root},
|
||||||
@@ -309,7 +330,7 @@ func TestFileImportRootCanContainTheSelectedFileDirectory(t *testing.T) {
|
|||||||
func TestImportedParseAndDecodeDiagnosticsKeepTheirSource(t *testing.T) {
|
func TestImportedParseAndDecodeDiagnosticsKeepTheirSource(t *testing.T) {
|
||||||
t.Run("parse", func(t *testing.T) {
|
t.Run("parse", func(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", "_configkit: {imports: [{path: broken.yml}]}\n")
|
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: broken.yml}]}\n")
|
||||||
writeImportFile(t, directory, "broken.yml", "settings: [\n")
|
writeImportFile(t, directory, "broken.yml", "settings: [\n")
|
||||||
loadError := requireAnyLoadError(t, File(mainPath), Options[importTestConfig]{Imports: true})
|
loadError := requireAnyLoadError(t, File(mainPath), Options[importTestConfig]{Imports: true})
|
||||||
diagnostics := loadError.Diagnostics()
|
diagnostics := loadError.Diagnostics()
|
||||||
@@ -320,7 +341,7 @@ func TestImportedParseAndDecodeDiagnosticsKeepTheirSource(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("decode", func(t *testing.T) {
|
t.Run("decode", func(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", "_configkit: {imports: [{path: unknown.yml}]}\n")
|
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: unknown.yml}]}\n")
|
||||||
writeImportFile(t, directory, "unknown.yml", "unknown: value\n")
|
writeImportFile(t, directory, "unknown.yml", "unknown: value\n")
|
||||||
loadError := requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeDecodeUnknownField)
|
loadError := requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeDecodeUnknownField)
|
||||||
diagnostic := loadError.Diagnostics()[0]
|
diagnostic := loadError.Diagnostics()[0]
|
||||||
@@ -332,7 +353,7 @@ func TestImportedParseAndDecodeDiagnosticsKeepTheirSource(t *testing.T) {
|
|||||||
|
|
||||||
func TestOptionalImportDoesNotSuppressUnsafeOrUnreadableTargets(t *testing.T) {
|
func TestOptionalImportDoesNotSuppressUnsafeOrUnreadableTargets(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "outside.yml", "_configkit: {imports: [{path: ../missing.yml, optional: true}]}\n")
|
mainPath := writeImportFile(t, directory, "outside.yml", "configkit: {imports: [{path: ../missing.yml, optional: true}]}\n")
|
||||||
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportOutsideRoot)
|
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportOutsideRoot)
|
||||||
|
|
||||||
locked := writeImportFile(t, directory, "locked.yml", "settings: {root: locked}\n")
|
locked := writeImportFile(t, directory, "locked.yml", "settings: {root: locked}\n")
|
||||||
@@ -344,13 +365,13 @@ func TestOptionalImportDoesNotSuppressUnsafeOrUnreadableTargets(t *testing.T) {
|
|||||||
_ = file.Close()
|
_ = file.Close()
|
||||||
t.Skip("filesystem does not enforce the unreadable-file fixture")
|
t.Skip("filesystem does not enforce the unreadable-file fixture")
|
||||||
}
|
}
|
||||||
mainPath = writeImportFile(t, directory, "main.yml", "_configkit: {imports: [{path: locked.yml, optional: true}]}\n")
|
mainPath = writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: locked.yml, optional: true}]}\n")
|
||||||
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeSourcePermission)
|
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeSourcePermission)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImportTraversalEnforcesPrivateBudgets(t *testing.T) {
|
func TestImportTraversalEnforcesPrivateBudgets(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", "_configkit: {imports: [{path: one.yml}, {path: two.yml}]}\n")
|
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: one.yml}, {path: two.yml}]}\n")
|
||||||
writeImportFile(t, directory, "one.yml", "settings: {first: one}\n")
|
writeImportFile(t, directory, "one.yml", "settings: {first: one}\n")
|
||||||
writeImportFile(t, directory, "two.yml", "settings: {second: two}\n")
|
writeImportFile(t, directory, "two.yml", "settings: {second: two}\n")
|
||||||
main := parseImportMain(t, mainPath)
|
main := parseImportMain(t, mainPath)
|
||||||
@@ -370,7 +391,7 @@ func TestImportTraversalEnforcesPrivateBudgets(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
writeImportFile(t, directory, "one.yml", "_configkit: {imports: [{path: nested.yml}]}\n")
|
writeImportFile(t, directory, "one.yml", "configkit: {imports: [{path: nested.yml}]}\n")
|
||||||
writeImportFile(t, directory, "nested.yml", "settings: {nested: nested}\n")
|
writeImportFile(t, directory, "nested.yml", "settings: {nested: nested}\n")
|
||||||
main = parseImportMain(t, mainPath)
|
main = parseImportMain(t, mainPath)
|
||||||
_, _, diagnostics := loadImportedDocuments(File(mainPath), nil, main, nil, importLimits{depth: 1, sources: 8, bytes: maxTotalBytes, nodes: maxDocumentNodes})
|
_, _, diagnostics := loadImportedDocuments(File(mainPath), nil, main, nil, importLimits{depth: 1, sources: 8, bytes: maxTotalBytes, nodes: maxDocumentNodes})
|
||||||
@@ -381,7 +402,7 @@ func TestImportTraversalEnforcesPrivateBudgets(t *testing.T) {
|
|||||||
|
|
||||||
func TestImportTraversalAcceptsExactPrivateBudgets(t *testing.T) {
|
func TestImportTraversalAcceptsExactPrivateBudgets(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", "_configkit: {imports: [{path: imported.yml}]}\n")
|
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: imported.yml}]}\n")
|
||||||
importedPath := writeImportFile(t, directory, "imported.yml", "settings: {first: imported}\n")
|
importedPath := writeImportFile(t, directory, "imported.yml", "settings: {first: imported}\n")
|
||||||
main := parseImportMain(t, mainPath)
|
main := parseImportMain(t, mainPath)
|
||||||
imported := parseImportMain(t, importedPath)
|
imported := parseImportMain(t, importedPath)
|
||||||
|
|||||||
345
load.go
345
load.go
@@ -51,12 +51,14 @@ type Resolution struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// VersionRule declares the input path and application checker for a schema version.
|
// VersionRule declares the input path and application checker for a schema version.
|
||||||
|
// At that path, string and decimal-integer input is supplied as Version.Text.
|
||||||
type VersionRule struct {
|
type VersionRule struct {
|
||||||
Path Path
|
Path Path
|
||||||
Check func(Version, *Validation) error
|
Check func(Version, *Validation) error
|
||||||
}
|
}
|
||||||
|
|
||||||
// Version preserves the absent, null, and string states of a schema-version input.
|
// Version preserves the absent, null, and textual states of a schema-version input.
|
||||||
|
// Text is the exact spelling of accepted string or decimal-integer input.
|
||||||
type Version struct {
|
type Version struct {
|
||||||
Present bool
|
Present bool
|
||||||
Null bool
|
Null bool
|
||||||
@@ -114,350 +116,69 @@ func Load[T any](source Source, supplied ...Options[T]) (*Result[T], error) {
|
|||||||
func loadWithSecretLookup[T any](source Source, lookup secretLookup, supplied ...Options[T]) (*Result[T], error) {
|
func loadWithSecretLookup[T any](source Source, lookup secretLookup, supplied ...Options[T]) (*Result[T], error) {
|
||||||
accumulator := diagnosticAccumulator{}
|
accumulator := diagnosticAccumulator{}
|
||||||
options, diagnostics := selectOptions(source, supplied)
|
options, diagnostics := selectOptions(source, supplied)
|
||||||
|
accumulator.add(diagnostics...)
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
accumulator.add(diagnostics...)
|
|
||||||
return nil, accumulator.loadError()
|
return nil, accumulator.loadError()
|
||||||
}
|
}
|
||||||
|
|
||||||
parsed, diagnostics := parseSource(source, "source-1", defaultParseLimits())
|
input, diagnostics := assembleBaseInput(source, options)
|
||||||
|
accumulator.sources = input.sources
|
||||||
|
accumulator.add(diagnostics...)
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
if parsed.source != nil {
|
|
||||||
accumulator.sources = append(accumulator.sources, *parsed.source)
|
|
||||||
}
|
|
||||||
accumulator.add(diagnostics...)
|
|
||||||
return nil, accumulator.loadError()
|
return nil, accumulator.loadError()
|
||||||
}
|
}
|
||||||
|
|
||||||
inputDocument := parsed.document
|
|
||||||
var composition *composedDocument
|
|
||||||
if options.Imports {
|
|
||||||
composition, accumulator.sources, diagnostics = loadImportedDocuments(source, options.Filesystem, parsed, options.Definition.Version, defaultImportLimits())
|
|
||||||
if len(diagnostics) != 0 {
|
|
||||||
accumulator.add(diagnostics...)
|
|
||||||
return nil, accumulator.loadError()
|
|
||||||
}
|
|
||||||
inputDocument = composition.root
|
|
||||||
} else {
|
|
||||||
if parsed.source != nil {
|
|
||||||
accumulator.sources = append(accumulator.sources, *parsed.source)
|
|
||||||
}
|
|
||||||
if options.Filesystem != nil && !activeProfile(options.Profile) {
|
|
||||||
if diagnostic := validateFilesystemContext(source, options.Filesystem, parsed); diagnostic != nil {
|
|
||||||
accumulator.add(*diagnostic)
|
|
||||||
return nil, accumulator.loadError()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if !options.Imports && inputDocument != nil {
|
|
||||||
directiveDiagnostics := disabledDirectiveDiagnostics(inputDocument, Path{})
|
|
||||||
if len(directiveDiagnostics) != 0 {
|
|
||||||
accumulator.add(directiveDiagnostics...)
|
|
||||||
return nil, accumulator.loadError()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
schema, schemaIssue := buildSchema(reflect.TypeFor[T]())
|
schema, schemaIssue := buildSchema(reflect.TypeFor[T]())
|
||||||
if schemaIssue != nil {
|
if schemaIssue != nil {
|
||||||
accumulator.add(diagnosticFromSchemaIssue(schemaIssue))
|
accumulator.add(diagnosticFromSchemaIssue(schemaIssue))
|
||||||
return nil, accumulator.loadError()
|
return nil, accumulator.loadError()
|
||||||
}
|
}
|
||||||
|
versionDiagnostics := checkVersion(options.Definition.Version, input.document)
|
||||||
versionDiagnostics := checkVersion(options.Definition.Version, inputDocument)
|
|
||||||
accumulator.add(versionDiagnostics...)
|
accumulator.add(versionDiagnostics...)
|
||||||
if hasErrors(versionDiagnostics) {
|
if hasErrors(versionDiagnostics) {
|
||||||
return nil, accumulator.loadError()
|
return nil, accumulator.loadError()
|
||||||
}
|
}
|
||||||
if activeProfile(options.Profile) {
|
input, diagnostics = applyInputLayers(source, options, input)
|
||||||
if composition == nil {
|
accumulator.sources = input.sources
|
||||||
composition, diagnostics = composeDocuments(inputDocument)
|
accumulator.add(diagnostics...)
|
||||||
if len(diagnostics) != 0 {
|
if len(diagnostics) != 0 {
|
||||||
accumulator.add(diagnostics...)
|
|
||||||
return nil, accumulator.loadError()
|
|
||||||
}
|
|
||||||
composition.inputBytes = parsed.bytes
|
|
||||||
composition.inputNodes = countInputNodes(parsed.document)
|
|
||||||
}
|
|
||||||
composition, accumulator.sources, diagnostics = loadProfileDocument(
|
|
||||||
source, options.Filesystem, parsed, composition, accumulator.sources,
|
|
||||||
options.Profile, options.Definition.Version, defaultImportLimits(),
|
|
||||||
)
|
|
||||||
if len(diagnostics) != 0 {
|
|
||||||
accumulator.add(diagnostics...)
|
|
||||||
return nil, accumulator.loadError()
|
|
||||||
}
|
|
||||||
inputDocument = composition.root
|
|
||||||
}
|
|
||||||
if len(options.Overrides) != 0 {
|
|
||||||
if composition == nil {
|
|
||||||
if inputDocument == nil {
|
|
||||||
composition = &composedDocument{root: &document.Node{Kind: document.Mapping}, origins: make(map[string]Origin)}
|
|
||||||
} else {
|
|
||||||
composition, diagnostics = composeDocuments(inputDocument)
|
|
||||||
if len(diagnostics) != 0 {
|
|
||||||
accumulator.add(diagnostics...)
|
|
||||||
return nil, accumulator.loadError()
|
|
||||||
}
|
|
||||||
composition.inputBytes = parsed.bytes
|
|
||||||
composition.inputNodes = countInputNodes(inputDocument)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
composition, accumulator.sources, diagnostics = applyOverrides(
|
|
||||||
composition, accumulator.sources, options.Overrides, options.Definition.Version, defaultImportLimits(),
|
|
||||||
)
|
|
||||||
if len(diagnostics) != 0 {
|
|
||||||
accumulator.add(diagnostics...)
|
|
||||||
return nil, accumulator.loadError()
|
|
||||||
}
|
|
||||||
inputDocument = composition.root
|
|
||||||
}
|
|
||||||
|
|
||||||
defaults := reflect.Zero(reflect.TypeFor[T]())
|
|
||||||
if options.Definition.Defaults != nil {
|
|
||||||
defaults = reflect.ValueOf(options.Definition.Defaults())
|
|
||||||
}
|
|
||||||
decoded, decodeIssues := decodeConfiguration(schema, defaults, inputDocument)
|
|
||||||
if len(decodeIssues) != 0 {
|
|
||||||
for _, issue := range decodeIssues {
|
|
||||||
accumulator.add(diagnosticFromDecodeIssue(issue))
|
|
||||||
}
|
|
||||||
return nil, accumulator.loadError()
|
return nil, accumulator.loadError()
|
||||||
}
|
}
|
||||||
if composition != nil {
|
|
||||||
applyCompositionOrigins(&decoded, composition.origins)
|
|
||||||
}
|
|
||||||
|
|
||||||
configuration := decoded.value.Interface().(T)
|
decoded, diagnostics := decodeLoadInput(schema, options.Definition, input)
|
||||||
metadata := metadataFromDecoded(decoded)
|
accumulator.add(diagnostics...)
|
||||||
if composition != nil {
|
if len(diagnostics) != 0 {
|
||||||
applyOverlayMetadata(&metadata, composition.overlays)
|
return nil, accumulator.loadError()
|
||||||
}
|
}
|
||||||
final := decoded.final
|
resolved, diagnostics := resolveLoadInput(schema, options.Definition, input, decoded)
|
||||||
var resolutionSnapshots []resolutionSnapshot
|
accumulator.add(diagnostics...)
|
||||||
if options.Definition.Resolve != nil {
|
if hasErrors(diagnostics) {
|
||||||
resolved := runResolution(&configuration, options.Definition.Resolve, schema, decoded, inputDocument)
|
return nil, accumulator.loadError()
|
||||||
accumulator.add(resolved.diagnostics...)
|
|
||||||
if hasErrors(resolved.diagnostics) {
|
|
||||||
return nil, accumulator.loadError()
|
|
||||||
}
|
|
||||||
final = resolved.final
|
|
||||||
resolutionSnapshots = resolved.snapshots
|
|
||||||
applyResolutionMetadata(&metadata, resolved)
|
|
||||||
}
|
}
|
||||||
applications := []historyApplication(nil)
|
if !historyNodesWithinLimit(decoded.baseline, decoded.final, resolved.final, resolved.applications, resolved.snapshots) {
|
||||||
if composition != nil {
|
|
||||||
applications = cloneHistoryApplications(composition.applications)
|
|
||||||
} else if inputDocument != nil {
|
|
||||||
applications = []historyApplication{{layer: LayerBase, value: inputDocument}}
|
|
||||||
}
|
|
||||||
if !historyNodesWithinLimit(decoded.baseline, decoded.final, final, applications, resolutionSnapshots) {
|
|
||||||
accumulator.add(Diagnostic{Code: CodeExplainLimitExceeded, Severity: SeverityError, Message: "retained configuration history exceeds the node limit"})
|
accumulator.add(Diagnostic{Code: CodeExplainLimitExceeded, Severity: SeverityError, Message: "retained configuration history exceeds the node limit"})
|
||||||
return nil, accumulator.loadError()
|
return nil, accumulator.loadError()
|
||||||
}
|
}
|
||||||
// Resolver assignments may borrow application-owned mutable values. Detach
|
|
||||||
// before secret resolution mutates reference fields or validation sees them.
|
// Detach resolver-borrowed values before reference mutation or validation.
|
||||||
detached, ownershipIssue := cloneConfiguration(schema, reflect.ValueOf(configuration), defaultProjectionLimits())
|
configuration, ownershipIssue := detachLoadConfiguration(schema, resolved.configuration, cloneUnresolvedSecrets)
|
||||||
if ownershipIssue != nil {
|
if ownershipIssue != nil {
|
||||||
accumulator.add(diagnosticFromDecodeIssue(decodeIssueFromProjection(ownershipIssue)))
|
accumulator.add(*ownershipIssue)
|
||||||
return nil, accumulator.loadError()
|
return nil, accumulator.loadError()
|
||||||
}
|
}
|
||||||
configuration = detached.Interface().(T)
|
secretResolution := resolveSecretReferences(reflect.ValueOf(&configuration).Elem(), schema, input.document, lookup)
|
||||||
secretResolution := resolveSecretReferences(reflect.ValueOf(&configuration).Elem(), schema, inputDocument, lookup)
|
|
||||||
accumulator.add(secretResolution.diagnostics...)
|
accumulator.add(secretResolution.diagnostics...)
|
||||||
if hasErrors(secretResolution.diagnostics) {
|
if hasErrors(secretResolution.diagnostics) {
|
||||||
return nil, accumulator.loadError()
|
return nil, accumulator.loadError()
|
||||||
}
|
}
|
||||||
if options.Definition.Validate != nil {
|
if options.Definition.Validate != nil {
|
||||||
validationDiagnostics := runValidation(&configuration, options.Definition.Validate, schema, final, inputDocument, secretResolution.lookups)
|
diagnostics = runValidation(&configuration, options.Definition.Validate, schema, resolved.final, input.document, secretResolution.lookups)
|
||||||
accumulator.add(validationDiagnostics...)
|
accumulator.add(diagnostics...)
|
||||||
if hasErrors(validationDiagnostics) {
|
if hasErrors(diagnostics) {
|
||||||
return nil, accumulator.loadError()
|
return nil, accumulator.loadError()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// A read-only validator can replace a value with an equal borrowed object.
|
resolved.configuration = configuration
|
||||||
// Projected mutation checks cannot detect that alias, so detach at publication.
|
// Publication detaches equal-valued validator replacements, preserving the
|
||||||
published, ownershipIssue := cloneConfigurationState(schema, reflect.ValueOf(configuration), defaultProjectionLimits(), true)
|
// validated secret state, then finalizes metadata and sensitivity.
|
||||||
if ownershipIssue != nil {
|
return publishLoadResult(schema, options.Definition, resolved, secretResolution.references, &accumulator)
|
||||||
accumulator.add(diagnosticFromDecodeIssue(decodeIssueFromProjection(ownershipIssue)))
|
|
||||||
return nil, accumulator.loadError()
|
|
||||||
}
|
|
||||||
configuration = published.Interface().(T)
|
|
||||||
finalizeMetadataPaths(&metadata, final, applications, decoded.baseline, decoded.final)
|
|
||||||
secrets := classifySecretPaths(schema, options.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(secretResolution.references),
|
|
||||||
}, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
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}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 == "_configkit" {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
func checkVersion(rule *VersionRule, root *document.Node) []Diagnostic {
|
|
||||||
if rule == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
version := Version{}
|
|
||||||
node, present := documentAtPath(root, rule.Path)
|
|
||||||
if present {
|
|
||||||
version.Present = true
|
|
||||||
switch node.Kind {
|
|
||||||
case document.Null:
|
|
||||||
version.Null = true
|
|
||||||
case document.String:
|
|
||||||
version.Text = node.Text
|
|
||||||
default:
|
|
||||||
location, _ := nearestDocumentLocation(root, rule.Path)
|
|
||||||
primary := documentLocationPointer(location)
|
|
||||||
return []Diagnostic{{
|
|
||||||
Code: CodeVersionType, Severity: SeverityError, Path: rule.Path.clone(),
|
|
||||||
Message: "schema version must be a string or null", Primary: primary,
|
|
||||||
}}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
state := &validationState{
|
|
||||||
active: true, errorCode: CodeVersionError, warningCode: CodeVersionWarning,
|
|
||||||
invalidPath: CodeVersionError, document: root,
|
|
||||||
}
|
|
||||||
validation := &Validation{state: state}
|
|
||||||
err := rule.Check(version, validation)
|
|
||||||
state.active = false
|
|
||||||
if err != nil && !hasErrors(state.diagnostics) {
|
|
||||||
location, _ := nearestDocumentLocation(root, rule.Path)
|
|
||||||
state.diagnostics = append(state.diagnostics, Diagnostic{
|
|
||||||
Code: CodeVersionHookFailed, Severity: SeverityError, Path: rule.Path.clone(),
|
|
||||||
Message: "schema-version check failed", Primary: documentLocationPointer(location),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
return canonicalDiagnostics(state.diagnostics)
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
func documentLocationPointer(location document.Location) *Location {
|
|
||||||
if !locationKnown(location) {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &Location{SourceID: location.SourceID, Line: location.Line, Column: location.Column}
|
|
||||||
}
|
|
||||||
|
|
||||||
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),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func hasErrors(diagnostics []Diagnostic) bool {
|
|
||||||
for _, diagnostic := range diagnostics {
|
|
||||||
if diagnostic.Severity == SeverityError {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
}
|
||||||
|
|||||||
88
load_input.go
Normal file
88
load_input.go
Normal 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
|
||||||
|
}
|
||||||
@@ -159,23 +159,6 @@ func collectFieldInfo(node *document.Node, path Path, decoded decodedConfigurati
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func indexDocument(node *document.Node, path Path, index map[string]*document.Node) {
|
|
||||||
if node == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
index[path.String()] = node
|
|
||||||
switch node.Kind {
|
|
||||||
case document.Mapping:
|
|
||||||
for _, entry := range node.Mapping {
|
|
||||||
indexDocument(entry.Value, path.Key(entry.Key), index)
|
|
||||||
}
|
|
||||||
case document.Sequence:
|
|
||||||
for position, child := range node.Sequence {
|
|
||||||
indexDocument(child, path.Index(position), index)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func cloneMetadata(metadata Metadata) Metadata {
|
func cloneMetadata(metadata Metadata) Metadata {
|
||||||
if metadata.fields == nil && metadata.history == nil {
|
if metadata.fields == nil && metadata.history == nil {
|
||||||
return Metadata{}
|
return Metadata{}
|
||||||
|
|||||||
119
load_result.go
Normal file
119
load_result.go
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
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())
|
||||||
|
}
|
||||||
|
context := decodeContext{}
|
||||||
|
if definition.Version != nil {
|
||||||
|
context.versionPath = &definition.Version.Path
|
||||||
|
}
|
||||||
|
decoded, decodeIssues := decodeConfigurationWithContext(schema, defaults, input.document, context)
|
||||||
|
if len(decodeIssues) != 0 {
|
||||||
|
diagnostics := make([]Diagnostic, len(decodeIssues))
|
||||||
|
for i, issue := range decodeIssues {
|
||||||
|
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),
|
||||||
|
}
|
||||||
|
}
|
||||||
317
load_test.go
317
load_test.go
@@ -8,6 +8,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"sync/atomic"
|
"sync/atomic"
|
||||||
"testing"
|
"testing"
|
||||||
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestLoadSingleSourceKindsAndDefaultsOnly(t *testing.T) {
|
func TestLoadSingleSourceKindsAndDefaultsOnly(t *testing.T) {
|
||||||
@@ -72,7 +73,9 @@ func TestLoadVersionStatesWarningsAndSafeFailures(t *testing.T) {
|
|||||||
{name: "no source absent", source: NoSource(), want: Version{}},
|
{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: "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: "string", source: Bytes("string.yml", []byte("schema_version: app.v1\n")), want: Version{Present: true, Text: "app.v1"}, wantLine: 1},
|
||||||
{name: "integer", source: Bytes("integer.yml", []byte("schema_version: 1\n")), wantErr: CodeVersionType},
|
{name: "integer", source: Bytes("integer.yml", []byte("schema_version: 1\n")), want: Version{Present: true, Text: "1"}, wantLine: 1},
|
||||||
|
{name: "boolean", source: Bytes("boolean.yml", []byte("schema_version: true\n")), wantErr: CodeVersionType},
|
||||||
|
{name: "sequence", source: Bytes("sequence.yml", []byte("schema_version: []\n")), wantErr: CodeVersionType},
|
||||||
{name: "returned error", source: Bytes("return.yml", []byte("{}\n")), wantErr: CodeVersionHookFailed, returnOnly: true},
|
{name: "returned error", source: Bytes("return.yml", []byte("{}\n")), wantErr: CodeVersionHookFailed, returnOnly: true},
|
||||||
}
|
}
|
||||||
for _, test := range tests {
|
for _, test := range tests {
|
||||||
@@ -103,7 +106,7 @@ func TestLoadVersionStatesWarningsAndSafeFailures(t *testing.T) {
|
|||||||
t.Fatalf("diagnostics = %#v", diagnostics)
|
t.Fatalf("diagnostics = %#v", diagnostics)
|
||||||
}
|
}
|
||||||
if test.wantErr == CodeVersionType && called {
|
if test.wantErr == CodeVersionType && called {
|
||||||
t.Fatal("checker ran for a non-string version")
|
t.Fatal("checker ran for an unsupported version kind")
|
||||||
}
|
}
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -124,6 +127,312 @@ func TestLoadVersionStatesWarningsAndSafeFailures(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLoadDecodesIntegerVersionOnlyAtConfiguredStringPath(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
type stringConfig struct {
|
||||||
|
Version string `yaml:"version"`
|
||||||
|
}
|
||||||
|
type pointerConfig struct {
|
||||||
|
Version *string `yaml:"version"`
|
||||||
|
}
|
||||||
|
versionPath := Keys("version")
|
||||||
|
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
source string
|
||||||
|
load func(Source, *VersionRule) (string, error)
|
||||||
|
}{
|
||||||
|
{name: "integer into string", source: "version: 4\n", load: func(source Source, rule *VersionRule) (string, error) {
|
||||||
|
result, err := Load(source, Options[stringConfig]{Definition: Definition[stringConfig]{Version: rule}})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return result.Config.Version, nil
|
||||||
|
}},
|
||||||
|
{name: "string into string", source: "version: \"4\"\n", load: func(source Source, rule *VersionRule) (string, error) {
|
||||||
|
result, err := Load(source, Options[stringConfig]{Definition: Definition[stringConfig]{Version: rule}})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
return result.Config.Version, nil
|
||||||
|
}},
|
||||||
|
{name: "integer into pointer", source: "version: 4\n", load: func(source Source, rule *VersionRule) (string, error) {
|
||||||
|
result, err := Load(source, Options[pointerConfig]{Definition: Definition[pointerConfig]{Version: rule}})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if result.Config.Version == nil {
|
||||||
|
t.Fatal("version pointer is nil")
|
||||||
|
}
|
||||||
|
return *result.Config.Version, nil
|
||||||
|
}},
|
||||||
|
{name: "string into pointer", source: "version: \"4\"\n", load: func(source Source, rule *VersionRule) (string, error) {
|
||||||
|
result, err := Load(source, Options[pointerConfig]{Definition: Definition[pointerConfig]{Version: rule}})
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if result.Config.Version == nil {
|
||||||
|
t.Fatal("version pointer is nil")
|
||||||
|
}
|
||||||
|
return *result.Config.Version, nil
|
||||||
|
}},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
var checked Version
|
||||||
|
rule := &VersionRule{Path: versionPath, Check: func(version Version, _ *Validation) error {
|
||||||
|
checked = version
|
||||||
|
return nil
|
||||||
|
}}
|
||||||
|
got, err := test.load(Bytes("version.yml", []byte(test.source)), rule)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got != "4" || checked != (Version{Present: true, Text: "4"}) {
|
||||||
|
t.Fatalf("value = %q, checked = %#v", got, checked)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if result, err := Load(Bytes("version.yml", []byte("version: 4\n")), Options[stringConfig]{}); result != nil {
|
||||||
|
t.Fatalf("load without rule = %#v, %v", result, err)
|
||||||
|
} else {
|
||||||
|
var loadError *LoadError
|
||||||
|
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != CodeDecodeType {
|
||||||
|
t.Fatalf("load without rule error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
type unrelatedConfig struct {
|
||||||
|
Version string `yaml:"version"`
|
||||||
|
Label string `yaml:"label"`
|
||||||
|
}
|
||||||
|
rule := &VersionRule{Path: versionPath, Check: func(Version, *Validation) error { return nil }}
|
||||||
|
if result, err := Load(Bytes("version.yml", []byte("version: 4\nlabel: 5\n")), Options[unrelatedConfig]{Definition: Definition[unrelatedConfig]{Version: rule}}); result != nil {
|
||||||
|
t.Fatalf("unrelated coercion = %#v, %v", result, err)
|
||||||
|
} else {
|
||||||
|
var loadError *LoadError
|
||||||
|
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != CodeDecodeType || loadError.Diagnostics()[0].Path.String() != "/label" {
|
||||||
|
t.Fatalf("unrelated coercion error = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadVersionScalarAndPathBoundaries(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
type config struct {
|
||||||
|
Version string `yaml:"version"`
|
||||||
|
}
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
value string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{name: "zero", value: "0", want: "0"},
|
||||||
|
{name: "negative", value: "-7", want: "-7"},
|
||||||
|
{name: "negative zero", value: "-0", want: "-0"},
|
||||||
|
{name: "beyond int64", value: "123456789012345678901234567890", want: "123456789012345678901234567890"},
|
||||||
|
{name: "empty string", value: `""`, want: ""},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
var checked Version
|
||||||
|
rule := &VersionRule{Path: Keys("version"), Check: func(version Version, _ *Validation) error {
|
||||||
|
checked = version
|
||||||
|
return nil
|
||||||
|
}}
|
||||||
|
result, err := Load(Bytes("version.yml", []byte("version: "+test.value+"\n")), Options[config]{Definition: Definition[config]{Version: rule}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result.Config.Version != test.want || checked.Text != test.want || !checked.Present || checked.Null {
|
||||||
|
t.Fatalf("configuration = %#v, checked = %#v", result.Config, checked)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
value string
|
||||||
|
}{
|
||||||
|
{name: "fraction", value: "4.0"},
|
||||||
|
{name: "exponent", value: "4e0"},
|
||||||
|
{name: "boolean", value: "true"},
|
||||||
|
{name: "mapping", value: "{}"},
|
||||||
|
{name: "sequence", value: "[]"},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
called := false
|
||||||
|
rule := &VersionRule{Path: Keys("version"), Check: func(Version, *Validation) error {
|
||||||
|
called = true
|
||||||
|
return nil
|
||||||
|
}}
|
||||||
|
result, err := Load(Bytes("version.yml", []byte("version: "+test.value+"\n")), Options[config]{Definition: Definition[config]{Version: rule}})
|
||||||
|
if result != nil || called {
|
||||||
|
t.Fatalf("result = %#v, checker called = %v", result, called)
|
||||||
|
}
|
||||||
|
requireDiagnostic(t, err, CodeVersionType, "/version")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
type namedVersion string
|
||||||
|
type nested struct {
|
||||||
|
Versions map[string]**namedVersion `yaml:"versions"`
|
||||||
|
Sibling namedVersion `yaml:"sibling"`
|
||||||
|
}
|
||||||
|
parsed, err := ParsePath("/versions/0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
rule := &VersionRule{Path: parsed, Check: func(version Version, _ *Validation) error {
|
||||||
|
if version.Text != "4" {
|
||||||
|
t.Errorf("version = %#v", version)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}}
|
||||||
|
result, err := Load(Bytes("nested.yml", []byte("versions:\n \"0\": 4\nsibling: quoted\n")), Options[nested]{Definition: Definition[nested]{Version: rule}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result.Config.Versions["0"] == nil || *result.Config.Versions["0"] == nil || **result.Config.Versions["0"] != "4" || result.Config.Sibling != "quoted" {
|
||||||
|
t.Fatalf("nested configuration = %#v", result.Config)
|
||||||
|
}
|
||||||
|
type sequenceConfig struct {
|
||||||
|
Versions []namedVersion `yaml:"versions"`
|
||||||
|
}
|
||||||
|
sequenceResult, err := Load(Bytes("sequence.yml", []byte("versions: [6]\n")), Options[sequenceConfig]{Definition: Definition[sequenceConfig]{Version: &VersionRule{Path: parsed, Check: func(Version, *Validation) error { return nil }}}})
|
||||||
|
if err != nil || len(sequenceResult.Config.Versions) != 1 || sequenceResult.Config.Versions[0] != "6" {
|
||||||
|
t.Fatalf("sequence configuration = %#v, %v", sequenceResult, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
type escaped struct {
|
||||||
|
Versions map[string]namedVersion `yaml:"versions"`
|
||||||
|
}
|
||||||
|
escapedPath, err := ParsePath("/versions/a~1b")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
escapedResult, err := Load(Bytes("escaped.yml", []byte("versions:\n a/b: 9\n")), Options[escaped]{Definition: Definition[escaped]{Version: &VersionRule{Path: escapedPath, Check: func(Version, *Validation) error { return nil }}}})
|
||||||
|
if err != nil || escapedResult.Config.Versions["a/b"] != "9" {
|
||||||
|
t.Fatalf("escaped configuration = %#v, %v", escapedResult, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
siblingRule := &VersionRule{Path: Keys("versions", "target"), Check: func(Version, *Validation) error { return nil }}
|
||||||
|
if result, err := Load(Bytes("sibling.yml", []byte("versions:\n target: 4\n other: 5\n")), Options[escaped]{Definition: Definition[escaped]{Version: siblingRule}}); result != nil {
|
||||||
|
t.Fatalf("same-type sibling result = %#v, %v", result, err)
|
||||||
|
} else {
|
||||||
|
requireDiagnostic(t, err, CodeDecodeType, "/versions/other")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadVersionDestinationTypesKeepOrdinaryDecoding(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
check := func(Version, *Validation) error { return nil }
|
||||||
|
t.Run("integer destination", func(t *testing.T) {
|
||||||
|
type config struct {
|
||||||
|
Version int8 `yaml:"version"`
|
||||||
|
}
|
||||||
|
result, err := Load(Bytes("version.yml", []byte("version: 4\n")), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: check}}})
|
||||||
|
if err != nil || result.Config.Version != 4 {
|
||||||
|
t.Fatalf("result = %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
result, err = Load(Bytes("version.yml", []byte("version: 999\n")), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: check}}})
|
||||||
|
if result != nil {
|
||||||
|
t.Fatalf("range result = %#v", result)
|
||||||
|
}
|
||||||
|
requireDiagnostic(t, err, CodeDecodeRange, "/version")
|
||||||
|
result, err = Load(Bytes("version.yml", []byte("version: \"4\"\n")), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: check}}})
|
||||||
|
if result != nil {
|
||||||
|
t.Fatalf("quoted integer result = %#v", result)
|
||||||
|
}
|
||||||
|
requireDiagnostic(t, err, CodeDecodeType, "/version")
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("dynamic destination", func(t *testing.T) {
|
||||||
|
type config struct {
|
||||||
|
Version any `yaml:"version"`
|
||||||
|
}
|
||||||
|
result, err := Load(Bytes("version.yml", []byte("version: 4\n")), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: check}}})
|
||||||
|
if err != nil || result.Config.Version != int64(4) {
|
||||||
|
t.Fatalf("result = %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
result, err = Load(Bytes("version.yml", []byte("version: 123456789012345678901234567890\n")), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: check}}})
|
||||||
|
if result != nil {
|
||||||
|
t.Fatalf("range result = %#v", result)
|
||||||
|
}
|
||||||
|
requireDiagnostic(t, err, CodeDecodeRange, "/version")
|
||||||
|
})
|
||||||
|
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
load func() error
|
||||||
|
}{
|
||||||
|
{name: "text codec", load: func() error {
|
||||||
|
type config struct {
|
||||||
|
Version validatedText `yaml:"version"`
|
||||||
|
}
|
||||||
|
_, err := Load(Bytes("version.yml", []byte("version: 4\n")), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: check}}})
|
||||||
|
return err
|
||||||
|
}},
|
||||||
|
{name: "duration", load: func() error {
|
||||||
|
type config struct {
|
||||||
|
Version time.Duration `yaml:"version"`
|
||||||
|
}
|
||||||
|
_, err := Load(Bytes("version.yml", []byte("version: 4\n")), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: check}}})
|
||||||
|
return err
|
||||||
|
}},
|
||||||
|
{name: "secret", load: func() error {
|
||||||
|
type config struct {
|
||||||
|
Version Secret `yaml:"version"`
|
||||||
|
}
|
||||||
|
_, err := Load(Bytes("version.yml", []byte("version: 4\n")), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: Keys("version"), Check: check}}})
|
||||||
|
return err
|
||||||
|
}},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
requireDiagnostic(t, test.load(), CodeDecodeType, "/version")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadVersionPolicyStopsLaterHooks(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
type config struct {
|
||||||
|
Version string `yaml:"version"`
|
||||||
|
}
|
||||||
|
var defaultsCalled, resolutionCalled, validationCalled bool
|
||||||
|
rule := &VersionRule{Path: Keys("version"), Check: func(version Version, validation *Validation) error {
|
||||||
|
if version.Text != "4" {
|
||||||
|
t.Errorf("version = %#v", version)
|
||||||
|
}
|
||||||
|
validation.Error(Keys("version"), "unsupported configuration version")
|
||||||
|
return nil
|
||||||
|
}}
|
||||||
|
result, err := Load(Bytes("version.yml", []byte("version: 4\n")), Options[config]{Definition: Definition[config]{
|
||||||
|
Defaults: func() config { defaultsCalled = true; return config{} },
|
||||||
|
Version: rule,
|
||||||
|
Resolve: func(*config, *Resolution) error { resolutionCalled = true; return nil },
|
||||||
|
Validate: func(*config, *Validation) { validationCalled = true },
|
||||||
|
}})
|
||||||
|
if result != nil || defaultsCalled || resolutionCalled || validationCalled {
|
||||||
|
t.Fatalf("result = %#v, hooks = %v/%v/%v", result, defaultsCalled, resolutionCalled, validationCalled)
|
||||||
|
}
|
||||||
|
requireDiagnostic(t, err, CodeVersionError, "/version")
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireDiagnostic(t *testing.T, err error, code Code, path string) {
|
||||||
|
t.Helper()
|
||||||
|
var loadError *LoadError
|
||||||
|
if !errors.As(err, &loadError) {
|
||||||
|
t.Fatalf("error = %T %v", err, err)
|
||||||
|
}
|
||||||
|
diagnostics := loadError.Diagnostics()
|
||||||
|
if len(diagnostics) != 1 || diagnostics[0].Code != code || diagnostics[0].Path.String() != path || diagnostics[0].Primary == nil {
|
||||||
|
t.Fatalf("diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestLoadVersionReportSuppressesReturnedErrorTextAndDefaults(t *testing.T) {
|
func TestLoadVersionReportSuppressesReturnedErrorTextAndDefaults(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
@@ -240,7 +549,7 @@ func TestLoadRejectsUnavailableOptionsAndDisabledDirectives(t *testing.T) {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
result, err := Load[config](Bytes("directive.yml", []byte("nested:\n _configkit: {}\n")))
|
result, err := Load[config](Bytes("directive.yml", []byte("nested:\n configkit: {}\n")))
|
||||||
if result != nil || err == nil {
|
if result != nil || err == nil {
|
||||||
t.Fatalf("result, err = %#v, %v", result, err)
|
t.Fatalf("result, err = %#v, %v", result, err)
|
||||||
}
|
}
|
||||||
@@ -249,7 +558,7 @@ func TestLoadRejectsUnavailableOptionsAndDisabledDirectives(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
diagnostic := loadError.Diagnostics()[0]
|
diagnostic := loadError.Diagnostics()[0]
|
||||||
if diagnostic.Code != CodeImportDisabled || diagnostic.Path.String() != "/nested/_configkit" || diagnostic.Primary == nil || diagnostic.Primary.Line != 2 {
|
if diagnostic.Code != CodeImportDisabled || diagnostic.Path.String() != "/nested/configkit" || diagnostic.Primary == nil || diagnostic.Primary.Line != 2 {
|
||||||
t.Fatalf("diagnostic = %#v", diagnostic)
|
t.Fatalf("diagnostic = %#v", diagnostic)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
55
metadata.go
55
metadata.go
@@ -1,6 +1,10 @@
|
|||||||
package configkit
|
package configkit
|
||||||
|
|
||||||
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
import (
|
||||||
|
"sort"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
|
)
|
||||||
|
|
||||||
// Metadata provides detached effective presence/origin and chronological history
|
// Metadata provides detached effective presence/origin and chronological history
|
||||||
// queries for a load. Its zero value contains no paths.
|
// queries for a load. Its zero value contains no paths.
|
||||||
@@ -50,6 +54,32 @@ func (m Metadata) History(path Path) []Change {
|
|||||||
return cloned
|
return cloned
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HistoricalPaths returns the paths observed during loading, including effective
|
||||||
|
// paths and paths removed by overlays or recorded resolution. It includes default
|
||||||
|
// paths and the root, but not paths that never existed. The zero Metadata returns
|
||||||
|
// an empty slice.
|
||||||
|
//
|
||||||
|
// Paths are detached, unique by canonical JSON Pointer, and sorted by that
|
||||||
|
// spelling. Like ParsePath results, numeric tokens are resolved contextually;
|
||||||
|
// one pointer can identify a mapping key in an earlier layer and a sequence index
|
||||||
|
// in a later one. Use History to inspect definitions and Lookup to check whether
|
||||||
|
// a path remains effective. Enumeration exposes no configuration values and does
|
||||||
|
// not perform another load or resolve secrets.
|
||||||
|
func (m Metadata) HistoricalPaths() []Path {
|
||||||
|
pointers := make([]string, 0, len(m.historicalPaths))
|
||||||
|
for pointer := range m.historicalPaths {
|
||||||
|
pointers = append(pointers, pointer)
|
||||||
|
}
|
||||||
|
sort.Strings(pointers)
|
||||||
|
paths := make([]Path, 0, len(pointers))
|
||||||
|
for _, pointer := range pointers {
|
||||||
|
// The index contains only canonical pointers produced by Path.String.
|
||||||
|
path, _ := ParsePath(pointer)
|
||||||
|
paths = append(paths, path)
|
||||||
|
}
|
||||||
|
return paths
|
||||||
|
}
|
||||||
|
|
||||||
// Textual pointers are lookup keys, but explicit key/index kinds remain part of
|
// Textual pointers are lookup keys, but explicit key/index kinds remain part of
|
||||||
// the query. Candidate paths are constructed from actual document containers.
|
// the query. Candidate paths are constructed from actual document containers.
|
||||||
func matchesPathShape(query, candidate Path) bool {
|
func matchesPathShape(query, candidate Path) bool {
|
||||||
@@ -85,8 +115,8 @@ func (m Metadata) hasHistoricalPath(path Path) bool {
|
|||||||
func finalizeMetadataPaths(m *Metadata, final *document.Node, applications []historyApplication, earlier ...*document.Node) {
|
func finalizeMetadataPaths(m *Metadata, final *document.Node, applications []historyApplication, earlier ...*document.Node) {
|
||||||
m.effectivePaths = make(map[string]Path)
|
m.effectivePaths = make(map[string]Path)
|
||||||
m.historicalPaths = make(map[string][]Path)
|
m.historicalPaths = make(map[string][]Path)
|
||||||
walkDocumentPaths(final, Path{}, func(path Path) { m.effectivePaths[path.String()] = path.clone() })
|
walkDocument(final, Path{}, func(path Path, _ *document.Node) { m.effectivePaths[path.String()] = path.clone() })
|
||||||
add := func(path Path) {
|
add := func(path Path, _ *document.Node) {
|
||||||
pointer := path.String()
|
pointer := path.String()
|
||||||
for _, prior := range m.historicalPaths[pointer] {
|
for _, prior := range m.historicalPaths[pointer] {
|
||||||
if matchesPathShape(path, prior) {
|
if matchesPathShape(path, prior) {
|
||||||
@@ -95,9 +125,9 @@ func finalizeMetadataPaths(m *Metadata, final *document.Node, applications []his
|
|||||||
}
|
}
|
||||||
m.historicalPaths[pointer] = append(m.historicalPaths[pointer], path.clone())
|
m.historicalPaths[pointer] = append(m.historicalPaths[pointer], path.clone())
|
||||||
}
|
}
|
||||||
walkDocumentPaths(final, Path{}, add)
|
walkDocument(final, Path{}, add)
|
||||||
for _, tree := range earlier {
|
for _, tree := range earlier {
|
||||||
walkDocumentPaths(tree, Path{}, add)
|
walkDocument(tree, Path{}, add)
|
||||||
}
|
}
|
||||||
for _, application := range applications {
|
for _, application := range applications {
|
||||||
// Overrides have mapping-only prefixes; parsed tokens are keys in this layer.
|
// Overrides have mapping-only prefixes; parsed tokens are keys in this layer.
|
||||||
@@ -105,19 +135,6 @@ func finalizeMetadataPaths(m *Metadata, final *document.Node, applications []his
|
|||||||
for _, segment := range application.path.segments {
|
for _, segment := range application.path.segments {
|
||||||
prefix = prefix.Key(segment.key)
|
prefix = prefix.Key(segment.key)
|
||||||
}
|
}
|
||||||
walkDocumentPaths(application.value, prefix, add)
|
walkDocument(application.value, prefix, add)
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func walkDocumentPaths(node *document.Node, path Path, visit func(Path)) {
|
|
||||||
if node == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
visit(path)
|
|
||||||
for _, entry := range node.Mapping {
|
|
||||||
walkDocumentPaths(entry.Value, path.Key(entry.Key), visit)
|
|
||||||
}
|
|
||||||
for i, child := range node.Sequence {
|
|
||||||
walkDocumentPaths(child, path.Index(i), visit)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,95 @@ package configkit
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"reflect"
|
||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
func TestMetadataHistoricalPaths(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
result, err := Load[map[string]any](Bytes("base", []byte("retired: {child: 1}\nshape: {'0': base}\n'a/b~': {'': 2}\n")), Options[map[string]any]{
|
||||||
|
Overrides: []Override{
|
||||||
|
{Path: Keys("retired"), Value: nil, Flag: "--retired"},
|
||||||
|
{Path: Keys("shape"), Value: []any{"replacement"}, Flag: "--shape"},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
metadata := result.Metadata()
|
||||||
|
paths := metadata.HistoricalPaths()
|
||||||
|
var got []string
|
||||||
|
for _, path := range paths {
|
||||||
|
got = append(got, path.String())
|
||||||
|
if len(metadata.History(path)) == 0 {
|
||||||
|
t.Fatalf("enumerated path %s has no history", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
want := []string{"", "/a~1b~0", "/a~1b~0/", "/retired", "/retired/child", "/shape", "/shape/0"}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("historical paths = %v, want %v", got, want)
|
||||||
|
}
|
||||||
|
if _, ok := metadata.Lookup(paths[4]); ok {
|
||||||
|
t.Fatal("removed descendant remains effective")
|
||||||
|
}
|
||||||
|
if _, ok := metadata.Lookup(paths[6]); !ok {
|
||||||
|
t.Fatal("enumerated numeric path cannot query the new sequence shape")
|
||||||
|
}
|
||||||
|
// Returned slices and nested paths must not alias retained metadata.
|
||||||
|
paths[1].segments[0].key = "mutated"
|
||||||
|
paths[2] = Keys("replacement")
|
||||||
|
if again := metadata.HistoricalPaths(); again[1].String() != want[1] || again[2].String() != want[2] {
|
||||||
|
t.Fatal("enumeration returned aliased paths")
|
||||||
|
}
|
||||||
|
if len((Metadata{}).HistoricalPaths()) != 0 {
|
||||||
|
t.Fatal("zero metadata contains paths")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestHistoricalPathsExposeHiddenBaseOwnershipWithoutResolvingBaseSecrets(t *testing.T) {
|
||||||
|
t.Setenv("CONFIGKIT_HISTORY_UNUSED_PROD_KEY", "")
|
||||||
|
t.Setenv("CONFIGKIT_HISTORY_TEST_KEY", "synthetic-test-key")
|
||||||
|
type config struct {
|
||||||
|
Credential *Secret `yaml:"credential"`
|
||||||
|
Definitions map[string]map[string]string `yaml:"definitions"`
|
||||||
|
}
|
||||||
|
for _, credential := range []string{"CONFIGKIT_HISTORY_TEST_KEY", "null"} {
|
||||||
|
t.Run(credential, func(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
root := writeImportFile(t, dir, "main.yml", "configkit:\n imports: [{path: first.yml}, {path: second.yml}]\ncredential: CONFIGKIT_HISTORY_UNUSED_PROD_KEY\n")
|
||||||
|
writeImportFile(t, dir, "first.yml", "definitions: {recap: {prompt: recap}}\n")
|
||||||
|
writeImportFile(t, dir, "second.yml", "definitions: {recap: {output: recap.md}}\n")
|
||||||
|
writeImportFile(t, dir, "profiles/testing.yml", "definitions: null\ncredential: "+credential+"\n")
|
||||||
|
result, err := Load[config](File(root), Options[config]{Imports: true, Profile: "testing"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
metadata := result.Metadata()
|
||||||
|
found := false
|
||||||
|
for _, path := range metadata.HistoricalPaths() {
|
||||||
|
if path.String() != "/definitions/recap" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
found = true
|
||||||
|
owners := map[string]bool{}
|
||||||
|
for _, change := range metadata.History(path) {
|
||||||
|
if change.Origin.Layer == LayerBase {
|
||||||
|
for _, location := range change.Origin.Locations {
|
||||||
|
owners[location.SourceID] = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(owners) != 2 {
|
||||||
|
t.Fatalf("hidden declaration owners = %v", owners)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !found {
|
||||||
|
t.Fatal("hidden base declaration was not discoverable")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestMetadataQueriesReturnDetachedRecords(t *testing.T) {
|
func TestMetadataQueriesReturnDetachedRecords(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
41
options.go
Normal file
41
options.go
Normal 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}
|
||||||
|
}
|
||||||
15
overlay.go
15
overlay.go
@@ -10,8 +10,6 @@ import (
|
|||||||
type overlayRecord struct {
|
type overlayRecord struct {
|
||||||
change Change
|
change Change
|
||||||
displaced []displacedDefinition
|
displaced []displacedDefinition
|
||||||
before *document.Node
|
|
||||||
after *document.Node
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type displacedDefinition struct {
|
type displacedDefinition struct {
|
||||||
@@ -26,15 +24,7 @@ func overlayDocuments(base *composedDocument, overlay *document.Node) (*composed
|
|||||||
Message: "profile overlay requires mapping document roots",
|
Message: "profile overlay requires mapping document roots",
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
result := &composedDocument{
|
result := cloneComposedDocument(base)
|
||||||
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,
|
|
||||||
}
|
|
||||||
mergeOverlayOrigin(result.origins, Path{}, overlay.Location, LayerProfile)
|
mergeOverlayOrigin(result.origins, Path{}, overlay.Location, LayerProfile)
|
||||||
mergeOverlayMappings(result, result.root, overlay, Path{}, LayerProfile, true)
|
mergeOverlayMappings(result, result.root, overlay, Path{}, LayerProfile, true)
|
||||||
result.applications = append(result.applications, historyApplication{layer: LayerProfile, value: overlay})
|
result.applications = append(result.applications, historyApplication{layer: LayerProfile, value: overlay})
|
||||||
@@ -73,7 +63,6 @@ func mergeOverlayMappings(result *composedDocument, target, overlay *document.No
|
|||||||
Previous: previousOrigins(previous),
|
Previous: previousOrigins(previous),
|
||||||
},
|
},
|
||||||
displaced: displaced,
|
displaced: displaced,
|
||||||
before: document.Clone(existing), after: document.Clone(incoming.Value),
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -161,8 +150,6 @@ func cloneOverlayRecords(records []overlayRecord) []overlayRecord {
|
|||||||
cloned := make([]overlayRecord, len(records))
|
cloned := make([]overlayRecord, len(records))
|
||||||
for index, record := range records {
|
for index, record := range records {
|
||||||
cloned[index].change = cloneChange(record.change)
|
cloned[index].change = cloneChange(record.change)
|
||||||
cloned[index].before = document.Clone(record.before)
|
|
||||||
cloned[index].after = document.Clone(record.after)
|
|
||||||
cloned[index].displaced = make([]displacedDefinition, len(record.displaced))
|
cloned[index].displaced = make([]displacedDefinition, len(record.displaced))
|
||||||
for displacedIndex, displaced := range record.displaced {
|
for displacedIndex, displaced := range record.displaced {
|
||||||
cloned[index].displaced[displacedIndex] = displacedDefinition{
|
cloned[index].displaced[displacedIndex] = displacedDefinition{
|
||||||
|
|||||||
247
override.go
247
override.go
@@ -1,9 +1,6 @@
|
|||||||
package configkit
|
package configkit
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"math"
|
|
||||||
"reflect"
|
|
||||||
"slices"
|
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"unicode"
|
"unicode"
|
||||||
@@ -22,17 +19,7 @@ type preparedOverride struct {
|
|||||||
nodes int
|
nodes int
|
||||||
}
|
}
|
||||||
|
|
||||||
type overrideValueBuilder struct {
|
func prepareOverrides(base *composedDocument, sources []SourceInfo, overrides []Override, rule *VersionRule, limits importLimits) ([]preparedOverride, []SourceInfo, []Diagnostic) {
|
||||||
sourceID string
|
|
||||||
active map[valueVisit]bool
|
|
||||||
bytes int64
|
|
||||||
nodes int
|
|
||||||
}
|
|
||||||
|
|
||||||
func applyOverrides(base *composedDocument, sources []SourceInfo, overrides []Override, rule *VersionRule, limits importLimits) (*composedDocument, []SourceInfo, []Diagnostic) {
|
|
||||||
if len(overrides) == 0 {
|
|
||||||
return base, cloneSourceInfos(sources), nil
|
|
||||||
}
|
|
||||||
if len(overrides) > maxOverrides || len(sources)+len(overrides) > limits.sources {
|
if len(overrides) > maxOverrides || len(sources)+len(overrides) > limits.sources {
|
||||||
return nil, cloneSourceInfos(sources), []Diagnostic{{
|
return nil, cloneSourceInfos(sources), []Diagnostic{{
|
||||||
Code: CodeOverrideLimitExceeded, Severity: SeverityError,
|
Code: CodeOverrideLimitExceeded, Severity: SeverityError,
|
||||||
@@ -147,13 +134,7 @@ func applyOverrides(base *composedDocument, sources []SourceInfo, overrides []Ov
|
|||||||
return nil, allSources, canonicalDiagnostics(diagnostics)
|
return nil, allSources, canonicalDiagnostics(diagnostics)
|
||||||
}
|
}
|
||||||
|
|
||||||
result := cloneComposedDocument(base)
|
return prepared, allSources, nil
|
||||||
for _, override := range prepared {
|
|
||||||
applyPreparedOverride(result, override)
|
|
||||||
}
|
|
||||||
result.inputBytes += usedBytes
|
|
||||||
result.inputNodes += usedNodes
|
|
||||||
return result, allSources, nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func validOverrideLabel(label string) bool {
|
func validOverrideLabel(label string) bool {
|
||||||
@@ -207,7 +188,7 @@ func overlappingOverride(previous []Override, valid []bool, path Path) int {
|
|||||||
|
|
||||||
func pathContainsDirective(path Path) bool {
|
func pathContainsDirective(path Path) bool {
|
||||||
for _, segment := range path.segments {
|
for _, segment := range path.segments {
|
||||||
if segment.key == "_configkit" {
|
if segment.key == directiveKey {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -224,7 +205,7 @@ func overrideDirectivePath(node *document.Node, path Path) (Path, bool) {
|
|||||||
if node.Kind == document.Mapping {
|
if node.Kind == document.Mapping {
|
||||||
for _, entry := range node.Mapping {
|
for _, entry := range node.Mapping {
|
||||||
child := path.Key(entry.Key)
|
child := path.Key(entry.Key)
|
||||||
if entry.Key == "_configkit" {
|
if entry.Key == directiveKey {
|
||||||
return child, true
|
return child, true
|
||||||
}
|
}
|
||||||
if found, ok := overrideDirectivePath(entry.Value, child); ok {
|
if found, ok := overrideDirectivePath(entry.Value, child); ok {
|
||||||
@@ -240,223 +221,3 @@ func overrideDirectivePath(node *document.Node, path Path) (Path, bool) {
|
|||||||
}
|
}
|
||||||
return Path{}, false
|
return Path{}, false
|
||||||
}
|
}
|
||||||
|
|
||||||
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}
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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
|
|
||||||
var before *document.Node
|
|
||||||
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
|
|
||||||
before = document.Clone(existing)
|
|
||||||
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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
position = documentEntryIndex(current.Mapping, segment.key)
|
|
||||||
result.overlays = append(result.overlays, overlayRecord{change: Change{
|
|
||||||
Path: path.clone(), Origin: overlayOrigin(LayerCLI, location), Previous: previousOrigins(previous),
|
|
||||||
}, displaced: displaced, before: before, after: document.Clone(current.Mapping[position].Value)})
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|||||||
100
override_apply.go
Normal file
100
override_apply.go
Normal 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
|
||||||
|
}
|
||||||
@@ -30,7 +30,7 @@ type overrideLifecycleConfig struct {
|
|||||||
|
|
||||||
func TestOverridesRunAtHighestInputPrecedenceBeforeResolutionAndValidation(t *testing.T) {
|
func TestOverridesRunAtHighestInputPrecedenceBeforeResolutionAndValidation(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", "_configkit:\n imports: [{path: base.yml}]\nversion: v1\n")
|
mainPath := writeImportFile(t, directory, "main.yml", "configkit:\n imports: [{path: base.yml}]\nversion: v1\n")
|
||||||
writeImportFile(t, directory, "base.yml", `settings:
|
writeImportFile(t, directory, "base.yml", `settings:
|
||||||
target: base
|
target: base
|
||||||
count: 4
|
count: 4
|
||||||
@@ -163,7 +163,7 @@ func TestOverrideValidationRejectsPathsOverlapValuesAndProtectedTargets(t *testi
|
|||||||
Nested map[string]any `yaml:"nested"`
|
Nested map[string]any `yaml:"nested"`
|
||||||
Unknown string `yaml:"unknown"`
|
Unknown string `yaml:"unknown"`
|
||||||
}
|
}
|
||||||
base := Bytes("override-errors.yml", []byte("version: v1\nscalar: value\nitems: [one]\nnested: {}\n"))
|
base := Bytes("override-errors.yml", []byte("version: 4\nscalar: value\nitems: [one]\nnested: {}\n"))
|
||||||
version := &VersionRule{Path: Keys("version"), Check: func(Version, *Validation) error { return nil }}
|
version := &VersionRule{Path: Keys("version"), Check: func(Version, *Validation) error { return nil }}
|
||||||
cycle := map[string]any{}
|
cycle := map[string]any{}
|
||||||
cycle["self"] = cycle
|
cycle["self"] = cycle
|
||||||
@@ -187,8 +187,8 @@ func TestOverrideValidationRejectsPathsOverlapValuesAndProtectedTargets(t *testi
|
|||||||
{name: "nonfinite", overrides: []Override{{Path: Keys("scalar"), Value: math.Inf(1), Flag: "--scalar"}}, code: CodeOverrideInvalidValue},
|
{name: "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: "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: "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 path", overrides: []Override{{Path: Keys("configkit", "x"), Value: true, Flag: "--directive"}}, code: CodeOverrideDirective},
|
||||||
{name: "directive value", overrides: []Override{{Path: Keys("nested"), Value: map[string]any{"_configkit": map[string]any{}}, Flag: "--nested"}}, code: CodeOverrideDirective},
|
{name: "directive 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", 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: "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},
|
{name: "label", overrides: []Override{{Path: Keys("scalar"), Value: "x", Flag: "\nCANARY"}}, code: CodeOverrideInvalidLabel},
|
||||||
@@ -213,7 +213,7 @@ func TestOverrideCannotReplaceSchemaVersionAncestor(t *testing.T) {
|
|||||||
type config struct {
|
type config struct {
|
||||||
Schema schemaConfig `yaml:"schema"`
|
Schema schemaConfig `yaml:"schema"`
|
||||||
}
|
}
|
||||||
_, err := Load(Bytes("version.yml", []byte("schema: {version: v1}\n")), Options[config]{
|
_, err := Load(Bytes("version.yml", []byte("schema: {version: 4}\n")), Options[config]{
|
||||||
Definition: Definition[config]{Version: &VersionRule{Path: Keys("schema", "version"), Check: func(Version, *Validation) error { return nil }}},
|
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"}},
|
Overrides: []Override{{Path: Keys("schema"), Value: map[string]any{"other": "value"}, Flag: "--schema"}},
|
||||||
})
|
})
|
||||||
|
|||||||
150
override_value.go
Normal file
150
override_value.go
Normal 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}
|
||||||
|
}
|
||||||
37
parse.go
37
parse.go
@@ -33,6 +33,21 @@ type parsedSource struct {
|
|||||||
bytes int64
|
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) {
|
func parseSource(source Source, sourceID string, limits parseLimits) (parsedSource, []Diagnostic) {
|
||||||
read, diagnostics := readSource(source, sourceID, limits.source)
|
read, diagnostics := readSource(source, sourceID, limits.source)
|
||||||
if len(diagnostics) != 0 || read.info == nil {
|
if len(diagnostics) != 0 || read.info == nil {
|
||||||
@@ -41,27 +56,7 @@ func parseSource(source Source, sourceID string, limits parseLimits) (parsedSour
|
|||||||
|
|
||||||
root, issues := yamladapter.Parse(bytes.NewReader(read.data), sourceID, limits.document)
|
root, issues := yamladapter.Parse(bytes.NewReader(read.data), sourceID, limits.document)
|
||||||
if len(issues) != 0 {
|
if len(issues) != 0 {
|
||||||
diagnostics := make([]Diagnostic, len(issues))
|
return parsedSource{source: read.info, bytes: int64(len(read.data))}, parserDiagnostics(sourceID, issues)
|
||||||
for i, issue := range issues {
|
|
||||||
diagnostics[i] = 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[i].Related = append(diagnostics[i].Related, Location{
|
|
||||||
SourceID: related.SourceID,
|
|
||||||
Line: related.Line,
|
|
||||||
Column: related.Column,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return parsedSource{source: read.info, bytes: int64(len(read.data))}, canonicalDiagnostics(diagnostics)
|
|
||||||
}
|
}
|
||||||
return parsedSource{document: root, source: read.info, bytes: int64(len(read.data))}, nil
|
return parsedSource{document: root, source: read.info, bytes: int64(len(read.data))}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
83
profile.go
83
profile.go
@@ -3,14 +3,12 @@ package configkit
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"errors"
|
"errors"
|
||||||
"io"
|
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
|
||||||
yamladapter "gitea.maximumdirect.net/eric/ConfigKit/internal/yaml"
|
yamladapter "gitea.maximumdirect.net/eric/ConfigKit/internal/yaml"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -68,9 +66,14 @@ func loadProfileDocument(source Source, context *FilesystemContext, main parsedS
|
|||||||
value.Primary = nil
|
value.Primary = nil
|
||||||
return nil, cloneSourceInfos(sources), []Diagnostic{value}
|
return nil, cloneSourceInfos(sources), []Diagnostic{value}
|
||||||
}
|
}
|
||||||
|
rootClosed := false
|
||||||
|
defer func() {
|
||||||
|
if !rootClosed {
|
||||||
|
_ = root.Close()
|
||||||
|
}
|
||||||
|
}()
|
||||||
relative, err := filepath.Rel(rootPath, canonical)
|
relative, err := filepath.Rel(rootPath, canonical)
|
||||||
if err != nil || filepath.IsAbs(relative) || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
|
if err != nil || filepath.IsAbs(relative) || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
|
||||||
_ = root.Close()
|
|
||||||
return nil, cloneSourceInfos(sources), []Diagnostic{importDiagnostic(CodeProfileOutsideRoot, "selected profile could not be addressed inside the configuration root", nil)}
|
return nil, cloneSourceInfos(sources), []Diagnostic{importDiagnostic(CodeProfileOutsideRoot, "selected profile could not be addressed inside the configuration root", nil)}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -80,32 +83,29 @@ func loadProfileDocument(source Source, context *FilesystemContext, main parsedS
|
|||||||
sources = append(cloneSourceInfos(sources), profileSource)
|
sources = append(cloneSourceInfos(sources), profileSource)
|
||||||
data, diagnostic := readProfileFile(root, relative, sourceID, limits.bytes-base.inputBytes)
|
data, diagnostic := readProfileFile(root, relative, sourceID, limits.bytes-base.inputBytes)
|
||||||
if diagnostic != nil {
|
if diagnostic != nil {
|
||||||
_ = root.Close()
|
|
||||||
return nil, sources, []Diagnostic{*diagnostic}
|
return nil, sources, []Diagnostic{*diagnostic}
|
||||||
}
|
}
|
||||||
profileRoot, issues := yamladapter.Parse(bytes.NewReader(data), sourceID, yamladapter.Limits{Depth: maxDocumentDepth, Nodes: maxDocumentNodes})
|
profileRoot, issues := yamladapter.Parse(bytes.NewReader(data), sourceID, yamladapter.Limits{Depth: maxDocumentDepth, Nodes: maxDocumentNodes})
|
||||||
if len(issues) != 0 {
|
if len(issues) != 0 {
|
||||||
_ = root.Close()
|
return nil, sources, parserDiagnostics(sourceID, issues)
|
||||||
return nil, sources, importParseDiagnostics(sourceID, issues)
|
|
||||||
}
|
}
|
||||||
nodeCount := countInputNodes(profileRoot)
|
nodeCount := countInputNodes(profileRoot)
|
||||||
if base.inputNodes+nodeCount > limits.nodes {
|
if base.inputNodes+nodeCount > limits.nodes {
|
||||||
_ = root.Close()
|
|
||||||
return nil, sources, []Diagnostic{importDiagnostic(CodeProfileLimitExceeded, "selected profile exceeds the aggregate node limit", profileLocation)}
|
return nil, sources, []Diagnostic{importDiagnostic(CodeProfileLimitExceeded, "selected profile exceeds the aggregate node limit", profileLocation)}
|
||||||
}
|
}
|
||||||
if diagnostics := profileDirectiveDiagnostics(profileRoot, Path{}); len(diagnostics) != 0 {
|
if diagnostics := profileDirectiveDiagnostics(profileRoot, Path{}); len(diagnostics) != 0 {
|
||||||
_ = root.Close()
|
|
||||||
return nil, sources, diagnostics
|
return nil, sources, diagnostics
|
||||||
}
|
}
|
||||||
if versionNode := fragmentVersionClaim(profileRoot, rule); versionNode != nil {
|
if versionNode := fragmentVersionClaim(profileRoot, rule); versionNode != nil {
|
||||||
location := publicLocation(versionNode.Location)
|
location := publicLocation(versionNode.Location)
|
||||||
_ = root.Close()
|
|
||||||
return nil, sources, []Diagnostic{{
|
return nil, sources, []Diagnostic{{
|
||||||
Code: CodeProfileVersion, Severity: SeverityError, Path: rule.Path.clone(),
|
Code: CodeProfileVersion, Severity: SeverityError, Path: rule.Path.clone(),
|
||||||
Message: "profiles cannot contribute to or replace the schema-version path", Primary: &location,
|
Message: "profiles cannot contribute to or replace the schema-version path", Primary: &location,
|
||||||
}}
|
}}
|
||||||
}
|
}
|
||||||
if err := root.Close(); err != nil {
|
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)}
|
return nil, sources, []Diagnostic{importDiagnostic(CodeImportRead, "configuration root could not be closed after reading the selected profile", nil)}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,67 +122,10 @@ func readProfileFile(root *os.Root, relative, sourceID string, remaining int64)
|
|||||||
if limit < 0 {
|
if limit < 0 {
|
||||||
limit = 0
|
limit = 0
|
||||||
}
|
}
|
||||||
preflight, err := root.Stat(relative)
|
data, failure := readRegularFile(relative, root.Stat, root.Open, limit)
|
||||||
if err != nil {
|
if failure != nil {
|
||||||
value := classifySourceError(err, sourceID)
|
value := sourceReadDiagnostic(failure, sourceID, CodeProfileLimitExceeded, "selected profile exceeds a source or aggregate byte limit")
|
||||||
return nil, &value
|
|
||||||
}
|
|
||||||
if !preflight.Mode().IsRegular() {
|
|
||||||
value := sourceDiagnostic(CodeSourceNotRegular, sourceID, "source is not a regular file")
|
|
||||||
return nil, &value
|
|
||||||
}
|
|
||||||
file, err := root.Open(relative)
|
|
||||||
if err != nil {
|
|
||||||
value := classifySourceError(err, sourceID)
|
|
||||||
return nil, &value
|
|
||||||
}
|
|
||||||
defer func() { _ = file.Close() }()
|
|
||||||
info, err := file.Stat()
|
|
||||||
if err != nil {
|
|
||||||
value := classifySourceError(err, sourceID)
|
|
||||||
return nil, &value
|
|
||||||
}
|
|
||||||
if !info.Mode().IsRegular() {
|
|
||||||
value := sourceDiagnostic(CodeSourceNotRegular, sourceID, "source is not a regular file")
|
|
||||||
return nil, &value
|
|
||||||
}
|
|
||||||
if info.Size() > limit {
|
|
||||||
value := importDiagnostic(CodeProfileLimitExceeded, "selected profile exceeds a source or aggregate byte limit", &Location{SourceID: sourceID})
|
|
||||||
return nil, &value
|
|
||||||
}
|
|
||||||
data, err := io.ReadAll(io.LimitReader(file, limit+1))
|
|
||||||
if err != nil {
|
|
||||||
value := classifySourceError(err, sourceID)
|
|
||||||
return nil, &value
|
|
||||||
}
|
|
||||||
if int64(len(data)) > limit {
|
|
||||||
value := importDiagnostic(CodeProfileLimitExceeded, "selected profile exceeds a source or aggregate byte limit", &Location{SourceID: sourceID})
|
|
||||||
return nil, &value
|
return nil, &value
|
||||||
}
|
}
|
||||||
return data, nil
|
return data, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func profileDirectiveDiagnostics(node *document.Node, current Path) []Diagnostic {
|
|
||||||
if node.Kind == document.Mapping {
|
|
||||||
for _, entry := range node.Mapping {
|
|
||||||
childPath := current.Key(entry.Key)
|
|
||||||
if entry.Key == "_configkit" {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ type profileTestConfig struct {
|
|||||||
|
|
||||||
func TestLoadAppliesSelectedProfileAfterBaseAndBeforeDefaults(t *testing.T) {
|
func TestLoadAppliesSelectedProfileAfterBaseAndBeforeDefaults(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", `_configkit:
|
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||||
imports: [{path: base.yml}]
|
imports: [{path: base.yml}]
|
||||||
settings:
|
settings:
|
||||||
kept: main
|
kept: main
|
||||||
@@ -123,7 +123,7 @@ func TestLoadProfileSelectionUsesOneFixedYAMLFile(t *testing.T) {
|
|||||||
|
|
||||||
func TestLoadProfileRejectsDirectivesVersionsAndBaseAliases(t *testing.T) {
|
func TestLoadProfileRejectsDirectivesVersionsAndBaseAliases(t *testing.T) {
|
||||||
versionRule := &VersionRule{Path: Keys("schema", "version"), Check: func(version Version, _ *Validation) error {
|
versionRule := &VersionRule{Path: Keys("schema", "version"), Check: func(version Version, _ *Validation) error {
|
||||||
if !version.Present || version.Text != "v1" {
|
if !version.Present || version.Text != "4" {
|
||||||
return errors.New("unexpected version")
|
return errors.New("unexpected version")
|
||||||
}
|
}
|
||||||
return nil
|
return nil
|
||||||
@@ -133,14 +133,14 @@ func TestLoadProfileRejectsDirectivesVersionsAndBaseAliases(t *testing.T) {
|
|||||||
profile string
|
profile string
|
||||||
code Code
|
code Code
|
||||||
}{
|
}{
|
||||||
{name: "root directive", profile: "_configkit: {}\n", code: CodeProfileDirective},
|
{name: "root directive", profile: "configkit: {}\n", code: CodeProfileDirective},
|
||||||
{name: "nested directive", profile: "settings: {_configkit: {}}\n", code: CodeProfileDirective},
|
{name: "nested directive", profile: "settings: {configkit: {}}\n", code: CodeProfileDirective},
|
||||||
{name: "equal version", profile: "schema: {version: v1}\n", code: CodeProfileVersion},
|
{name: "equal integer version", profile: "schema: {version: 4}\n", code: CodeProfileVersion},
|
||||||
{name: "ancestor scalar", profile: "schema: null\n", code: CodeProfileVersion},
|
{name: "ancestor scalar", profile: "schema: null\n", code: CodeProfileVersion},
|
||||||
} {
|
} {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", "schema: {version: v1}\n")
|
mainPath := writeImportFile(t, directory, "main.yml", "schema: {version: 4}\n")
|
||||||
writeImportFile(t, directory, "profiles/testing.yml", test.profile)
|
writeImportFile(t, directory, "profiles/testing.yml", test.profile)
|
||||||
requireProfileLoadCode(t, File(mainPath), Options[profileTestConfig]{Profile: "testing", Definition: Definition[profileTestConfig]{Version: versionRule}}, test.code)
|
requireProfileLoadCode(t, File(mainPath), Options[profileTestConfig]{Profile: "testing", Definition: Definition[profileTestConfig]{Version: versionRule}}, test.code)
|
||||||
})
|
})
|
||||||
@@ -148,7 +148,7 @@ func TestLoadProfileRejectsDirectivesVersionsAndBaseAliases(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("mapping sibling is allowed", func(t *testing.T) {
|
t.Run("mapping sibling is allowed", func(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", "schema: {version: v1}\n")
|
mainPath := writeImportFile(t, directory, "main.yml", "schema: {version: 4}\n")
|
||||||
writeImportFile(t, directory, "profiles/testing.yml", "schema: {other: allowed}\n")
|
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}})
|
result, err := Load[profileTestConfig](File(mainPath), Options[profileTestConfig]{Profile: "testing", Definition: Definition[profileTestConfig]{Version: versionRule}})
|
||||||
if err != nil || result.Config.Schema["other"] != "allowed" {
|
if err != nil || result.Config.Schema["other"] != "allowed" {
|
||||||
@@ -158,7 +158,7 @@ func TestLoadProfileRejectsDirectivesVersionsAndBaseAliases(t *testing.T) {
|
|||||||
|
|
||||||
t.Run("profile aliases imported base", func(t *testing.T) {
|
t.Run("profile aliases imported base", func(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", "_configkit: {imports: [{path: profiles/testing.yml}]}\n")
|
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: profiles/testing.yml}]}\n")
|
||||||
writeImportFile(t, directory, "profiles/testing.yml", "settings: {changed: value}\n")
|
writeImportFile(t, directory, "profiles/testing.yml", "settings: {changed: value}\n")
|
||||||
requireProfileLoadCode(t, File(mainPath), Options[profileTestConfig]{Imports: true, Profile: "testing"}, CodeProfileRepeated)
|
requireProfileLoadCode(t, File(mainPath), Options[profileTestConfig]{Imports: true, Profile: "testing"}, CodeProfileRepeated)
|
||||||
})
|
})
|
||||||
|
|||||||
378
projection.go
378
projection.go
@@ -1,6 +1,7 @@
|
|||||||
package configkit
|
package configkit
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"cmp"
|
||||||
"encoding"
|
"encoding"
|
||||||
"math"
|
"math"
|
||||||
"reflect"
|
"reflect"
|
||||||
@@ -12,35 +13,6 @@ import (
|
|||||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
)
|
)
|
||||||
|
|
||||||
type projectionLimits struct {
|
|
||||||
depth int
|
|
||||||
nodes int
|
|
||||||
}
|
|
||||||
|
|
||||||
func defaultProjectionLimits() projectionLimits {
|
|
||||||
return projectionLimits{depth: maxDocumentDepth, nodes: maxDocumentNodes}
|
|
||||||
}
|
|
||||||
|
|
||||||
type projectionIssue struct {
|
|
||||||
code Code
|
|
||||||
path Path
|
|
||||||
message string
|
|
||||||
}
|
|
||||||
|
|
||||||
type valueVisit struct {
|
|
||||||
typeOf reflect.Type
|
|
||||||
kind reflect.Kind
|
|
||||||
ptr uintptr
|
|
||||||
}
|
|
||||||
|
|
||||||
type valueOperation struct {
|
|
||||||
preserveSecrets bool
|
|
||||||
schema *schemaDescriptor
|
|
||||||
limits projectionLimits
|
|
||||||
active map[valueVisit]bool
|
|
||||||
nodes int
|
|
||||||
}
|
|
||||||
|
|
||||||
func projectConfiguration(schema *schemaDescriptor, value reflect.Value, limits projectionLimits) (*document.Node, *projectionIssue) {
|
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 {
|
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"}
|
return nil, &projectionIssue{code: CodeSchemaUnsupportedType, message: "configuration value does not match its schema"}
|
||||||
@@ -49,19 +21,6 @@ func projectConfiguration(schema *schemaDescriptor, value reflect.Value, limits
|
|||||||
return operation.project(value, schema.root, Path{}, 0)
|
return operation.project(value, schema.root, Path{}, 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
func cloneConfiguration(schema *schemaDescriptor, value reflect.Value, limits projectionLimits) (reflect.Value, *projectionIssue) {
|
|
||||||
return cloneConfigurationState(schema, value, limits, false)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Publication copies preserve only already-validated private reference state.
|
|
||||||
func cloneConfigurationState(schema *schemaDescriptor, value reflect.Value, limits projectionLimits, preserveSecrets bool) (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), preserveSecrets: preserveSecrets}
|
|
||||||
return operation.clone(value, schema.root, Path{}, 0)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (o *valueOperation) project(value reflect.Value, descriptor *typeDescriptor, path Path, depth int) (*document.Node, *projectionIssue) {
|
func (o *valueOperation) project(value reflect.Value, descriptor *typeDescriptor, path Path, depth int) (*document.Node, *projectionIssue) {
|
||||||
if issue := o.checkDepth(path, depth); issue != nil {
|
if issue := o.checkDepth(path, depth); issue != nil {
|
||||||
return nil, issue
|
return nil, issue
|
||||||
@@ -149,7 +108,7 @@ func (o *valueOperation) project(value reflect.Value, descriptor *typeDescriptor
|
|||||||
result.Mapping = make([]document.Entry, 0, value.Len())
|
result.Mapping = make([]document.Entry, 0, value.Len())
|
||||||
keys := value.MapKeys()
|
keys := value.MapKeys()
|
||||||
slices.SortFunc(keys, func(left, right reflect.Value) int {
|
slices.SortFunc(keys, func(left, right reflect.Value) int {
|
||||||
return cmpString(left.String(), right.String())
|
return cmp.Compare(left.String(), right.String())
|
||||||
})
|
})
|
||||||
for _, key := range keys {
|
for _, key := range keys {
|
||||||
childPath := path.Key(key.String())
|
childPath := path.Key(key.String())
|
||||||
@@ -258,171 +217,6 @@ func (o *valueOperation) project(value reflect.Value, descriptor *typeDescriptor
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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.preserveSecrets {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func (o *valueOperation) dynamicDescriptor(current reflect.Type, path Path) (*typeDescriptor, *projectionIssue) {
|
|
||||||
if current == secretType {
|
|
||||||
return nil, &projectionIssue{code: CodeSchemaDynamicType, path: path.clone(), message: "dynamic configuration values cannot establish secret-reference semantics"}
|
|
||||||
}
|
|
||||||
builder := schemaBuilder{cache: o.schema.types, building: make(map[reflect.Type]bool)}
|
|
||||||
descriptor, issue := builder.describe(current, path)
|
|
||||||
if issue != nil {
|
|
||||||
return nil, &projectionIssue{code: CodeSchemaDynamicType, path: path.clone(), message: "dynamic configuration value has an unsupported type"}
|
|
||||||
}
|
|
||||||
if descriptorContainsSecret(descriptor, make(map[*typeDescriptor]bool)) {
|
|
||||||
return nil, &projectionIssue{code: CodeSchemaDynamicType, path: path.clone(), message: "dynamic configuration values cannot establish secret-reference semantics"}
|
|
||||||
}
|
|
||||||
return descriptor, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func descriptorContainsSecret(descriptor *typeDescriptor, visited map[*typeDescriptor]bool) bool {
|
|
||||||
if descriptor == nil || visited[descriptor] {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
if descriptor.kind == descriptorSecret {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
visited[descriptor] = true
|
|
||||||
if descriptorContainsSecret(descriptor.element, visited) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
for _, member := range descriptor.members {
|
|
||||||
if descriptorContainsSecret(member.descriptor, visited) {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func (o *valueOperation) node(kind document.Kind, path Path, depth int) (*document.Node, *projectionIssue) {
|
func (o *valueOperation) node(kind document.Kind, path Path, depth int) (*document.Node, *projectionIssue) {
|
||||||
if issue := o.checkDepth(path, depth); issue != nil {
|
if issue := o.checkDepth(path, depth); issue != nil {
|
||||||
return nil, issue
|
return nil, issue
|
||||||
@@ -433,64 +227,6 @@ func (o *valueOperation) node(kind document.Kind, path Path, depth int) (*docume
|
|||||||
return &document.Node{Kind: kind}, nil
|
return &document.Node{Kind: kind}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func (o *valueOperation) charge(path Path) *projectionIssue {
|
|
||||||
o.nodes++
|
|
||||||
if o.limits.nodes >= 0 && o.nodes > o.limits.nodes {
|
|
||||||
return &projectionIssue{code: CodeSchemaProjectionLimit, path: path.clone(), message: "configuration projection exceeds the node limit"}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (o *valueOperation) checkDepth(path Path, depth int) *projectionIssue {
|
|
||||||
if depth > o.limits.depth {
|
|
||||||
return &projectionIssue{code: CodeSchemaProjectionLimit, path: path.clone(), message: "configuration projection exceeds the depth limit"}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func (o *valueOperation) enter(value reflect.Value, path Path) (func(), *projectionIssue) {
|
|
||||||
visit, ok := visitFor(value)
|
|
||||||
if !ok {
|
|
||||||
return func() {}, nil
|
|
||||||
}
|
|
||||||
if o.active[visit] {
|
|
||||||
return nil, &projectionIssue{code: CodeSchemaCycle, path: path.clone(), message: "configuration values must not contain cycles"}
|
|
||||||
}
|
|
||||||
o.active[visit] = true
|
|
||||||
return func() { delete(o.active, visit) }, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
func visitFor(value reflect.Value) (valueVisit, bool) {
|
|
||||||
if isNilValue(value) {
|
|
||||||
return valueVisit{}, false
|
|
||||||
}
|
|
||||||
visit := valueVisit{typeOf: value.Type(), kind: value.Kind()}
|
|
||||||
switch value.Kind() {
|
|
||||||
case reflect.Pointer, reflect.Slice:
|
|
||||||
if value.Kind() == reflect.Slice && value.Len() == 0 {
|
|
||||||
return valueVisit{}, false
|
|
||||||
}
|
|
||||||
visit.ptr = value.Pointer()
|
|
||||||
case reflect.Map:
|
|
||||||
if value.Len() == 0 {
|
|
||||||
return valueVisit{}, false
|
|
||||||
}
|
|
||||||
visit.ptr = uintptr(value.UnsafePointer())
|
|
||||||
default:
|
|
||||||
return valueVisit{}, false
|
|
||||||
}
|
|
||||||
return visit, visit.ptr != 0
|
|
||||||
}
|
|
||||||
|
|
||||||
func isNilValue(value reflect.Value) bool {
|
|
||||||
switch value.Kind() {
|
|
||||||
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
|
|
||||||
return value.IsNil()
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func marshalCanonicalText(value reflect.Value, path Path) (string, *projectionIssue) {
|
func marshalCanonicalText(value reflect.Value, path Path) (string, *projectionIssue) {
|
||||||
var marshaler encoding.TextMarshaler
|
var marshaler encoding.TextMarshaler
|
||||||
if value.CanInterface() {
|
if value.CanInterface() {
|
||||||
@@ -510,113 +246,3 @@ func marshalCanonicalText(value reflect.Value, path Path) (string, *projectionIs
|
|||||||
}
|
}
|
||||||
return string(text), nil
|
return string(text), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
func cmpString(left, right string) int {
|
|
||||||
switch {
|
|
||||||
case left < right:
|
|
||||||
return -1
|
|
||||||
case left > right:
|
|
||||||
return 1
|
|
||||||
default:
|
|
||||||
return 0
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
type documentDeltaKind uint8
|
|
||||||
|
|
||||||
const (
|
|
||||||
documentAdded documentDeltaKind = iota + 1
|
|
||||||
documentRemoved
|
|
||||||
documentReplaced
|
|
||||||
)
|
|
||||||
|
|
||||||
type documentDelta struct {
|
|
||||||
path Path
|
|
||||||
kind documentDeltaKind
|
|
||||||
}
|
|
||||||
|
|
||||||
func compareDocumentTrees(before, after *document.Node) []documentDelta {
|
|
||||||
var deltas []documentDelta
|
|
||||||
compareDocumentNode(before, after, Path{}, &deltas)
|
|
||||||
slices.SortFunc(deltas, func(left, right documentDelta) int {
|
|
||||||
if order := cmpString(left.path.String(), right.path.String()); order != 0 {
|
|
||||||
return order
|
|
||||||
}
|
|
||||||
return int(left.kind) - int(right.kind)
|
|
||||||
})
|
|
||||||
return deltas
|
|
||||||
}
|
|
||||||
|
|
||||||
func compareDocumentNode(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 {
|
|
||||||
compareDocumentNode(beforeEntries[key], afterEntries[key], path.Key(key), deltas)
|
|
||||||
}
|
|
||||||
case document.Sequence:
|
|
||||||
if !documentNodesEqual(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})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func documentNodesEqual(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 !documentNodesEqual(leftMap[entry.Key], entry.Value) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for i := range left.Sequence {
|
|
||||||
if !documentNodesEqual(left.Sequence[i], right.Sequence[i]) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -328,7 +328,7 @@ func TestCompareDocumentTreesTracksSemanticChanges(t *testing.T) {
|
|||||||
if issue != nil {
|
if issue != nil {
|
||||||
t.Fatal(issue.message)
|
t.Fatal(issue.message)
|
||||||
}
|
}
|
||||||
deltas := compareDocumentTrees(before, after)
|
deltas := compareConfigurationValues(before, after)
|
||||||
want := []struct {
|
want := []struct {
|
||||||
path string
|
path string
|
||||||
kind documentDeltaKind
|
kind documentDeltaKind
|
||||||
@@ -352,7 +352,7 @@ func TestCompareDocumentTreesTracksSemanticChanges(t *testing.T) {
|
|||||||
t.Fatal("sequence comparison created index correspondence")
|
t.Fatal("sequence comparison created index correspondence")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if equal := compareDocumentTrees(after, document.Clone(after)); len(equal) != 0 {
|
if equal := compareConfigurationValues(after, document.Clone(after)); len(equal) != 0 {
|
||||||
t.Fatalf("equal trees changed = %#v", equal)
|
t.Fatalf("equal trees changed = %#v", equal)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,7 +27,7 @@ type layeredConfig struct {
|
|||||||
|
|
||||||
func TestLayeredProvenanceAgreesWithResolvedSnapshot(t *testing.T) {
|
func TestLayeredProvenanceAgreesWithResolvedSnapshot(t *testing.T) {
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeImportFile(t, directory, "main.yml", `_configkit:
|
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||||
imports: [{path: imported.yml}]
|
imports: [{path: imported.yml}]
|
||||||
shared: {equal: same}
|
shared: {equal: same}
|
||||||
values: [1, 2]
|
values: [1, 2]
|
||||||
@@ -138,7 +138,7 @@ empty: {}
|
|||||||
t.Fatal(issue)
|
t.Fatal(issue)
|
||||||
}
|
}
|
||||||
projected, projectionIssue := projectConfiguration(schema, reflect.ValueOf(result.Config), defaultProjectionLimits())
|
projected, projectionIssue := projectConfiguration(schema, reflect.ValueOf(result.Config), defaultProjectionLimits())
|
||||||
if projectionIssue != nil || !documentNodesEqual(projected, result.final) {
|
if projectionIssue != nil || !configurationValuesEqual(projected, result.final) {
|
||||||
t.Fatalf("typed projection and private snapshot disagree: %#v", projectionIssue)
|
t.Fatalf("typed projection and private snapshot disagree: %#v", projectionIssue)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -9,91 +9,99 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestReleaseCheckerShellHandlesFilenamesAndStopsOnFailure(t *testing.T) {
|
func TestCheckScriptsHandleFilenamesAndStopOnFailure(t *testing.T) {
|
||||||
script, err := os.ReadFile("scripts/release-check.sh")
|
for _, scriptName := range []string{"release-check.sh", "check.sh"} {
|
||||||
if err != nil {
|
t.Run(scriptName, func(t *testing.T) {
|
||||||
t.Fatal(err)
|
script, err := os.ReadFile("scripts/" + scriptName)
|
||||||
}
|
|
||||||
for _, fail := range []bool{false, true} {
|
|
||||||
name := "success"
|
|
||||||
if fail {
|
|
||||||
name = "failed-go-check"
|
|
||||||
}
|
|
||||||
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", "release-check.sh"), script, 0700)
|
|
||||||
write(filepath.Join(root, "go.mod"), []byte("module example.invalid/check\n"), 0600)
|
|
||||||
write(filepath.Join(root, "go.sum"), nil, 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)
|
|
||||||
}
|
|
||||||
// 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"}, {"-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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
cmd := exec.Command("/bin/bash", filepath.Join(root, "scripts", "release-check.sh"))
|
|
||||||
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 fail {
|
|
||||||
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
parts := bytes.Split(arguments, []byte{0})
|
for _, name := range []string{"success", "failed-go-check", "untracked-whitespace"} {
|
||||||
for _, file := range files {
|
fail := name != "success"
|
||||||
found := false
|
t.Run(name, func(t *testing.T) {
|
||||||
for _, arg := range parts {
|
root := t.TempDir()
|
||||||
if string(arg) == file {
|
bin := t.TempDir()
|
||||||
found = true
|
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) {
|
||||||
if !found {
|
t.Helper()
|
||||||
t.Fatalf("filename was split: %q", file)
|
if err := os.WriteFile(path, data, mode); err != nil {
|
||||||
}
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
invoked, err := os.ReadFile(calls)
|
}
|
||||||
if err != nil {
|
write(filepath.Join(root, "scripts", scriptName), script, 0700)
|
||||||
t.Fatal(err)
|
write(filepath.Join(root, "go.mod"), []byte("module example.invalid/check\n"), 0600)
|
||||||
}
|
write(filepath.Join(root, "go.sum"), nil, 0600)
|
||||||
if fail && strings.Contains(string(invoked), "vet ./...") {
|
write(filepath.Join(root, "obsolete.go"), []byte("package obsolete\n"), 0600)
|
||||||
t.Fatal("checker continued after failed test")
|
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")
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -241,7 +241,7 @@ func runResolution[T any](configuration *T, resolver func(*T, *Resolution) error
|
|||||||
state.addDiagnostic(CodeResolveInvalidValue, issue.path, "application resolution produced an invalid configuration value")
|
state.addDiagnostic(CodeResolveInvalidValue, issue.path, "application resolution produced an invalid configuration value")
|
||||||
return resolutionResult{diagnostics: canonicalDiagnostics(state.diagnostics)}
|
return resolutionResult{diagnostics: canonicalDiagnostics(state.diagnostics)}
|
||||||
}
|
}
|
||||||
deltas := compareDocumentTrees(decoded.final, after)
|
deltas := compareConfigurationValues(decoded.final, after)
|
||||||
ordered := orderedResolutionRecords(state.records)
|
ordered := orderedResolutionRecords(state.records)
|
||||||
for _, delta := range deltas {
|
for _, delta := range deltas {
|
||||||
if coveringResolutionRecord(ordered, delta.path) == nil {
|
if coveringResolutionRecord(ordered, delta.path) == nil {
|
||||||
@@ -249,16 +249,15 @@ func runResolution[T any](configuration *T, resolver func(*T, *Resolution) error
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, record := range ordered {
|
for _, record := range ordered {
|
||||||
beforeNode, beforePresent := documentAtPath(decoded.final, record.path)
|
_, beforePresent := documentAtPath(decoded.final, record.path)
|
||||||
afterNode, afterPresent := documentAtPath(after, record.path)
|
afterNode, afterPresent := documentAtPath(after, record.path)
|
||||||
if !beforePresent && !afterPresent {
|
if !beforePresent && !afterPresent {
|
||||||
state.addDiagnostic(CodeResolveInvalidRecord, record.path, "resolution record does not identify an existing or removed value")
|
state.addDiagnostic(CodeResolveInvalidRecord, record.path, "resolution record does not identify an existing or removed value")
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if record.helperExpected != nil && (!afterPresent || !documentNodesEqual(record.helperExpected, afterNode)) {
|
if record.helperExpected != nil && (!afterPresent || !configurationValuesEqual(record.helperExpected, afterNode)) {
|
||||||
state.addDiagnostic(CodeResolveInvalidTarget, record.path, "resolution helper assignment does not match its declared configuration path")
|
state.addDiagnostic(CodeResolveInvalidTarget, record.path, "resolution helper assignment does not match its declared configuration path")
|
||||||
}
|
}
|
||||||
_ = beforeNode
|
|
||||||
}
|
}
|
||||||
if hasErrors(state.diagnostics) {
|
if hasErrors(state.diagnostics) {
|
||||||
return resolutionResult{diagnostics: canonicalDiagnostics(state.diagnostics)}
|
return resolutionResult{diagnostics: canonicalDiagnostics(state.diagnostics)}
|
||||||
@@ -343,17 +342,3 @@ func clonePaths(paths []Path) []Path {
|
|||||||
}
|
}
|
||||||
return cloned
|
return cloned
|
||||||
}
|
}
|
||||||
|
|
||||||
func cloneResolutionSnapshots(snapshots []resolutionSnapshot) []resolutionSnapshot {
|
|
||||||
if snapshots == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
cloned := make([]resolutionSnapshot, len(snapshots))
|
|
||||||
for index, snapshot := range snapshots {
|
|
||||||
cloned[index] = resolutionSnapshot{
|
|
||||||
change: cloneChange(snapshot.change),
|
|
||||||
before: document.Clone(snapshot.before), after: document.Clone(snapshot.after),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return cloned
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -434,5 +434,5 @@ func schemaIndex(segment pathSegment) (int, bool) {
|
|||||||
return 0, false
|
return 0, false
|
||||||
}
|
}
|
||||||
index, err := strconv.Atoi(segment.key)
|
index, err := strconv.Atoi(segment.key)
|
||||||
return index, err == nil && index >= 0
|
return index, err == nil && index >= 0 && strconv.Itoa(index) == segment.key
|
||||||
}
|
}
|
||||||
|
|||||||
41
scripts/check.sh
Executable file
41
scripts/check.sh
Executable file
@@ -0,0 +1,41 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
set -euo pipefail
|
||||||
|
|
||||||
|
repository_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
|
||||||
|
cd "$repository_dir"
|
||||||
|
|
||||||
|
# Use the installed toolchain unless the caller explicitly selects another.
|
||||||
|
# Toolchains and dependencies must already be available locally.
|
||||||
|
export GOTOOLCHAIN=${GOTOOLCHAIN:-local}
|
||||||
|
export GOPROXY=off
|
||||||
|
go version
|
||||||
|
|
||||||
|
go_files=()
|
||||||
|
while IFS= read -r -d '' go_file; do
|
||||||
|
if [[ -f "$go_file" ]]; then
|
||||||
|
go_files+=("$go_file")
|
||||||
|
fi
|
||||||
|
done < <(git ls-files --cached --others --exclude-standard -z -- '*.go')
|
||||||
|
if ((${#go_files[@]})); then
|
||||||
|
unformatted=$(gofmt -l -- "${go_files[@]}")
|
||||||
|
if [[ -n "$unformatted" ]]; then
|
||||||
|
printf 'gofmt required for:\n%s\n' "$unformatted" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
|
||||||
|
git diff --check HEAD --
|
||||||
|
while IFS= read -r -d '' candidate_file; do
|
||||||
|
# --no-index returns 1 for differing content even when whitespace is clean;
|
||||||
|
# whitespace errors return 2. Preserve other failures as well.
|
||||||
|
git diff --no-index --check -- /dev/null "$candidate_file" || {
|
||||||
|
check_status=$?
|
||||||
|
if ((check_status != 1)); then
|
||||||
|
exit "$check_status"
|
||||||
|
fi
|
||||||
|
}
|
||||||
|
done < <(git ls-files --others --exclude-standard -z)
|
||||||
|
|
||||||
|
go test ./...
|
||||||
|
go vet ./...
|
||||||
|
go build ./...
|
||||||
@@ -25,8 +25,13 @@ bash -n scripts/release-check.sh
|
|||||||
|
|
||||||
temporary_dir=$(mktemp -d)
|
temporary_dir=$(mktemp -d)
|
||||||
trap 'rm -rf -- "$temporary_dir"' EXIT
|
trap 'rm -rf -- "$temporary_dir"' EXIT
|
||||||
git ls-files --cached --others --exclude-standard -z |
|
candidate_manifest=$temporary_dir/candidate-files
|
||||||
tar --null --files-from=- --create --file=- |
|
while IFS= read -r -d '' candidate_file; do
|
||||||
|
if [[ -e "$candidate_file" || -L "$candidate_file" ]]; then
|
||||||
|
printf '%s\0' "$candidate_file" >>"$candidate_manifest"
|
||||||
|
fi
|
||||||
|
done < <(git ls-files --cached --others --exclude-standard -z)
|
||||||
|
tar --null --files-from="$candidate_manifest" --create --file=- |
|
||||||
tar --extract --file=- --directory="$temporary_dir"
|
tar --extract --file=- --directory="$temporary_dir"
|
||||||
(
|
(
|
||||||
cd "$temporary_dir"
|
cd "$temporary_dir"
|
||||||
@@ -37,7 +42,9 @@ diff -u go.sum "$temporary_dir/go.sum"
|
|||||||
|
|
||||||
go_files=()
|
go_files=()
|
||||||
while IFS= read -r -d '' go_file; do
|
while IFS= read -r -d '' go_file; do
|
||||||
go_files+=("$go_file")
|
if [[ -f "$go_file" ]]; then
|
||||||
|
go_files+=("$go_file")
|
||||||
|
fi
|
||||||
done < <(git ls-files --cached --others --exclude-standard -z -- '*.go')
|
done < <(git ls-files --cached --others --exclude-standard -z -- '*.go')
|
||||||
if ((${#go_files[@]})); then
|
if ((${#go_files[@]})); then
|
||||||
unformatted=$(gofmt -l -- "${go_files[@]}")
|
unformatted=$(gofmt -l -- "${go_files[@]}")
|
||||||
@@ -49,6 +56,9 @@ fi
|
|||||||
|
|
||||||
git diff --check HEAD --
|
git diff --check HEAD --
|
||||||
while IFS= read -r -d '' candidate_file; do
|
while IFS= read -r -d '' candidate_file; do
|
||||||
|
if [[ ! -f "$candidate_file" ]]; then
|
||||||
|
continue
|
||||||
|
fi
|
||||||
case "$candidate_file" in
|
case "$candidate_file" in
|
||||||
*.go|*.md|*.sh|go.mod|go.sum|LICENSE|.gitignore|AGENTS.md)
|
*.go|*.md|*.sh|go.mod|go.sum|LICENSE|.gitignore|AGENTS.md)
|
||||||
if LC_ALL=C grep -nE '[[:blank:]]+$' -- "$candidate_file"; then
|
if LC_ALL=C grep -nE '[[:blank:]]+$' -- "$candidate_file"; then
|
||||||
|
|||||||
15
secret.go
15
secret.go
@@ -144,21 +144,10 @@ func schemaSecretRoot(schema *schemaDescriptor, path Path) (Path, bool) {
|
|||||||
// is equal. Validators must preserve this inspection metadata as well as values.
|
// is equal. Validators must preserve this inspection metadata as well as values.
|
||||||
func projectedSensitivity(root *document.Node) map[string]Path {
|
func projectedSensitivity(root *document.Node) map[string]Path {
|
||||||
result := make(map[string]Path)
|
result := make(map[string]Path)
|
||||||
var visit func(*document.Node, Path)
|
walkDocument(root, Path{}, func(path Path, node *document.Node) {
|
||||||
visit = func(node *document.Node, path Path) {
|
|
||||||
if node == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
if node.Sensitive {
|
if node.Sensitive {
|
||||||
result[path.String()] = path.clone()
|
result[path.String()] = path.clone()
|
||||||
}
|
}
|
||||||
for _, entry := range node.Mapping {
|
})
|
||||||
visit(entry.Value, path.Key(entry.Key))
|
|
||||||
}
|
|
||||||
for i, child := range node.Sequence {
|
|
||||||
visit(child, path.Index(i))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
visit(root, Path{})
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package configkit
|
package configkit
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"cmp"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
@@ -139,7 +140,7 @@ func resolveSecretValue(value reflect.Value, descriptor *typeDescriptor, path Pa
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
keys := value.MapKeys()
|
keys := value.MapKeys()
|
||||||
slices.SortFunc(keys, func(left, right reflect.Value) int { return cmpString(left.String(), right.String()) })
|
slices.SortFunc(keys, func(left, right reflect.Value) int { return cmp.Compare(left.String(), right.String()) })
|
||||||
for _, key := range keys {
|
for _, key := range keys {
|
||||||
entry := reflect.New(value.Type().Elem()).Elem()
|
entry := reflect.New(value.Type().Elem()).Elem()
|
||||||
entry.Set(value.MapIndex(key))
|
entry.Set(value.MapIndex(key))
|
||||||
@@ -167,7 +168,7 @@ func secretDiagnostic(code Code, path Path, message string, input *document.Node
|
|||||||
func changedSecretStates(root reflect.Value, descriptor *typeDescriptor, lookups map[string]secretLookupResult) []Path {
|
func changedSecretStates(root reflect.Value, descriptor *typeDescriptor, lookups map[string]secretLookupResult) []Path {
|
||||||
var changed []Path
|
var changed []Path
|
||||||
collectChangedSecretStates(root, descriptor, Path{}, lookups, &changed)
|
collectChangedSecretStates(root, descriptor, Path{}, lookups, &changed)
|
||||||
slices.SortFunc(changed, func(left, right Path) int { return cmpString(left.String(), right.String()) })
|
slices.SortFunc(changed, func(left, right Path) int { return cmp.Compare(left.String(), right.String()) })
|
||||||
return changed
|
return changed
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -198,7 +199,7 @@ func collectChangedSecretStates(value reflect.Value, descriptor *typeDescriptor,
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
keys := value.MapKeys()
|
keys := value.MapKeys()
|
||||||
slices.SortFunc(keys, func(left, right reflect.Value) int { return cmpString(left.String(), right.String()) })
|
slices.SortFunc(keys, func(left, right reflect.Value) int { return cmp.Compare(left.String(), right.String()) })
|
||||||
for _, key := range keys {
|
for _, key := range keys {
|
||||||
collectChangedSecretStates(value.MapIndex(key), descriptor.element, path.Key(key.String()), lookups, changed)
|
collectChangedSecretStates(value.MapIndex(key), descriptor.element, path.Key(key.String()), lookups, changed)
|
||||||
}
|
}
|
||||||
@@ -211,3 +212,14 @@ func collectChangedSecretStates(value reflect.Value, descriptor *typeDescriptor,
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func cloneSecretReferences(values map[string]string) map[string]string {
|
||||||
|
if values == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
cloned := make(map[string]string, len(values))
|
||||||
|
for path, name := range values {
|
||||||
|
cloned[path] = name
|
||||||
|
}
|
||||||
|
return cloned
|
||||||
|
}
|
||||||
|
|||||||
63
snapshot.go
Normal file
63
snapshot.go
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
package configkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"slices"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||||
|
yamladapter "gitea.maximumdirect.net/eric/ConfigKit/internal/yaml"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Snapshot projection is the shared redaction boundary for effective YAML
|
||||||
|
// and explanation values. It borrows frozen trees and returns a detached tree.
|
||||||
|
func valueSnapshot(root *document.Node, path Path, secrets map[string]bool) ValueSnapshot {
|
||||||
|
node, present := documentAtPath(root, path)
|
||||||
|
if !present {
|
||||||
|
return ValueSnapshot{}
|
||||||
|
}
|
||||||
|
safe, redacted := safeSnapshotDocument(node, secrets, path)
|
||||||
|
var output bytes.Buffer
|
||||||
|
if err := yamladapter.Write(&output, safe); err != nil {
|
||||||
|
return ValueSnapshot{Present: true, Redacted: true, YAML: "'[REDACTED]'\n"}
|
||||||
|
}
|
||||||
|
return ValueSnapshot{Present: true, Redacted: redacted, YAML: output.String()}
|
||||||
|
}
|
||||||
|
|
||||||
|
func safeSnapshotDocument(node *document.Node, secrets map[string]bool, path Path) (*document.Node, bool) {
|
||||||
|
if node == nil {
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
if pathIsSecret(secrets, path) {
|
||||||
|
return &document.Node{Kind: document.String, Text: "[REDACTED]"}, true
|
||||||
|
}
|
||||||
|
result := *node
|
||||||
|
redacted := false
|
||||||
|
switch node.Kind {
|
||||||
|
case document.Mapping:
|
||||||
|
result.Mapping = make([]document.Entry, len(node.Mapping))
|
||||||
|
copy(result.Mapping, node.Mapping)
|
||||||
|
sortDocumentMapping(result.Mapping)
|
||||||
|
for index := range result.Mapping {
|
||||||
|
entry := &result.Mapping[index]
|
||||||
|
entry.Value, redacted = safeSnapshotChild(entry.Value, secrets, path.Key(entry.Key), redacted)
|
||||||
|
}
|
||||||
|
case document.Sequence:
|
||||||
|
result.Sequence = make([]*document.Node, len(node.Sequence))
|
||||||
|
for index, child := range node.Sequence {
|
||||||
|
result.Sequence[index], redacted = safeSnapshotChild(child, secrets, path.Index(index), redacted)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return &result, redacted
|
||||||
|
}
|
||||||
|
|
||||||
|
func safeSnapshotChild(node *document.Node, secrets map[string]bool, path Path, prior bool) (*document.Node, bool) {
|
||||||
|
safe, redacted := safeSnapshotDocument(node, secrets, path)
|
||||||
|
return safe, prior || redacted
|
||||||
|
}
|
||||||
|
|
||||||
|
func sortDocumentMapping(entries []document.Entry) {
|
||||||
|
slices.SortFunc(entries, func(left, right document.Entry) int {
|
||||||
|
return strings.Compare(left.Key, right.Key)
|
||||||
|
})
|
||||||
|
}
|
||||||
47
source.go
47
source.go
@@ -125,10 +125,7 @@ func readSource(source Source, sourceID string, limits sourceLimits) (sourceRead
|
|||||||
}
|
}
|
||||||
info := &SourceInfo{ID: sourceID, Label: source.label, Layer: LayerBase}
|
info := &SourceInfo{ID: sourceID, Label: source.label, Layer: LayerBase}
|
||||||
|
|
||||||
var (
|
var input io.Reader
|
||||||
input io.Reader
|
|
||||||
close func() error
|
|
||||||
)
|
|
||||||
switch source.kind {
|
switch source.kind {
|
||||||
case sourceFile:
|
case sourceFile:
|
||||||
absolute, err := filepath.Abs(source.path)
|
absolute, err := filepath.Abs(source.path)
|
||||||
@@ -141,32 +138,11 @@ func readSource(source Source, sourceID string, limits sourceLimits) (sourceRead
|
|||||||
return sourceRead{info: info}, []Diagnostic{classifySourceError(err, sourceID)}
|
return sourceRead{info: info}, []Diagnostic{classifySourceError(err, sourceID)}
|
||||||
}
|
}
|
||||||
info.Path = canonical
|
info.Path = canonical
|
||||||
preflight, err := os.Stat(canonical)
|
data, failure := readRegularFile(canonical, os.Stat, os.Open, limit)
|
||||||
if err != nil {
|
if failure != nil {
|
||||||
return sourceRead{info: info}, []Diagnostic{classifySourceError(err, sourceID)}
|
return sourceRead{info: info}, []Diagnostic{sourceReadDiagnostic(failure, sourceID, CodeSourceLimitExceeded, "source exceeds the configured byte limit")}
|
||||||
}
|
}
|
||||||
if !preflight.Mode().IsRegular() {
|
return sourceRead{data: bytes.Clone(data), info: info}, nil
|
||||||
return sourceRead{info: info}, []Diagnostic{sourceDiagnostic(CodeSourceNotRegular, sourceID, "source is not a regular file")}
|
|
||||||
}
|
|
||||||
file, err := os.Open(canonical)
|
|
||||||
if err != nil {
|
|
||||||
return sourceRead{info: info}, []Diagnostic{classifySourceError(err, sourceID)}
|
|
||||||
}
|
|
||||||
close = file.Close
|
|
||||||
stat, err := file.Stat()
|
|
||||||
if err != nil {
|
|
||||||
_ = file.Close()
|
|
||||||
return sourceRead{info: info}, []Diagnostic{classifySourceError(err, sourceID)}
|
|
||||||
}
|
|
||||||
if !stat.Mode().IsRegular() {
|
|
||||||
_ = file.Close()
|
|
||||||
return sourceRead{info: info}, []Diagnostic{sourceDiagnostic(CodeSourceNotRegular, sourceID, "source is not a regular file")}
|
|
||||||
}
|
|
||||||
if stat.Size() > limit {
|
|
||||||
_ = file.Close()
|
|
||||||
return sourceRead{info: info}, []Diagnostic{sourceDiagnostic(CodeSourceLimitExceeded, sourceID, "source exceeds the configured byte limit")}
|
|
||||||
}
|
|
||||||
input = file
|
|
||||||
case sourceBytes:
|
case sourceBytes:
|
||||||
input = bytes.NewReader(source.data)
|
input = bytes.NewReader(source.data)
|
||||||
case sourceReader:
|
case sourceReader:
|
||||||
@@ -177,16 +153,9 @@ func readSource(source Source, sourceID string, limits sourceLimits) (sourceRead
|
|||||||
default:
|
default:
|
||||||
return sourceRead{info: info}, []Diagnostic{sourceDiagnostic(CodeSourceInvalid, sourceID, "source is invalid")}
|
return sourceRead{info: info}, []Diagnostic{sourceDiagnostic(CodeSourceInvalid, sourceID, "source is invalid")}
|
||||||
}
|
}
|
||||||
if close != nil {
|
data, failure := readBoundedSource(input, limit)
|
||||||
defer func() { _ = close() }()
|
if failure != nil {
|
||||||
}
|
return sourceRead{info: info}, []Diagnostic{sourceReadDiagnostic(failure, sourceID, CodeSourceLimitExceeded, "source exceeds the configured byte limit")}
|
||||||
|
|
||||||
data, err := io.ReadAll(io.LimitReader(input, limit+1))
|
|
||||||
if err != nil {
|
|
||||||
return sourceRead{info: info}, []Diagnostic{classifySourceError(err, sourceID)}
|
|
||||||
}
|
|
||||||
if int64(len(data)) > limit {
|
|
||||||
return sourceRead{info: info}, []Diagnostic{sourceDiagnostic(CodeSourceLimitExceeded, sourceID, "source exceeds the configured byte limit")}
|
|
||||||
}
|
}
|
||||||
return sourceRead{data: bytes.Clone(data), info: info}, nil
|
return sourceRead{data: bytes.Clone(data), info: info}, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -43,7 +43,7 @@ func TestNonregularSourcesDoNotBlock(t *testing.T) {
|
|||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
input := "value: 1\n"
|
input := "value: 1\n"
|
||||||
if mode == "import" {
|
if mode == "import" {
|
||||||
input = "_configkit:\n imports:\n - path: pipe.yml\n"
|
input = "configkit:\n imports:\n - path: pipe.yml\n"
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(filepath.Join(root, "config.yml"), []byte(input), 0600); err != nil {
|
if err := os.WriteFile(filepath.Join(root, "config.yml"), []byte(input), 0600); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
|
|||||||
75
source_file.go
Normal file
75
source_file.go
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
package configkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
)
|
||||||
|
|
||||||
|
type sourceReadFailureKind uint8
|
||||||
|
|
||||||
|
const (
|
||||||
|
sourceReadIO sourceReadFailureKind = iota
|
||||||
|
sourceReadNotRegular
|
||||||
|
sourceReadLimit
|
||||||
|
)
|
||||||
|
|
||||||
|
// sourceReadFailure never escapes the private source boundary. missingTarget is
|
||||||
|
// set only for preflight/open failures; a later missing-file error is not optional.
|
||||||
|
type sourceReadFailure struct {
|
||||||
|
kind sourceReadFailureKind
|
||||||
|
cause error
|
||||||
|
missingTarget bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// readRegularFile borrows stat/open operations so root-bound callers retain their
|
||||||
|
// containment policy. Preflight rejects FIFOs before open can block. The opened
|
||||||
|
// handle is checked again and always closed; read-only close errors are ignored.
|
||||||
|
func readRegularFile(name string, stat func(string) (fs.FileInfo, error), open func(string) (*os.File, error), limit int64) ([]byte, *sourceReadFailure) {
|
||||||
|
info, err := stat(name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, &sourceReadFailure{cause: err, missingTarget: errors.Is(err, fs.ErrNotExist)}
|
||||||
|
}
|
||||||
|
if !info.Mode().IsRegular() {
|
||||||
|
return nil, &sourceReadFailure{kind: sourceReadNotRegular}
|
||||||
|
}
|
||||||
|
file, err := open(name)
|
||||||
|
if err != nil {
|
||||||
|
return nil, &sourceReadFailure{cause: err, missingTarget: errors.Is(err, fs.ErrNotExist)}
|
||||||
|
}
|
||||||
|
defer func() { _ = file.Close() }()
|
||||||
|
info, err = file.Stat()
|
||||||
|
if err != nil {
|
||||||
|
return nil, &sourceReadFailure{cause: err}
|
||||||
|
}
|
||||||
|
if !info.Mode().IsRegular() {
|
||||||
|
return nil, &sourceReadFailure{kind: sourceReadNotRegular}
|
||||||
|
}
|
||||||
|
if info.Size() > limit {
|
||||||
|
return nil, &sourceReadFailure{kind: sourceReadLimit}
|
||||||
|
}
|
||||||
|
return readBoundedSource(file, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
func readBoundedSource(input io.Reader, limit int64) ([]byte, *sourceReadFailure) {
|
||||||
|
data, err := io.ReadAll(io.LimitReader(input, limit+1))
|
||||||
|
if err != nil {
|
||||||
|
return nil, &sourceReadFailure{cause: err}
|
||||||
|
}
|
||||||
|
if int64(len(data)) > limit {
|
||||||
|
return nil, &sourceReadFailure{kind: sourceReadLimit}
|
||||||
|
}
|
||||||
|
return data, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func sourceReadDiagnostic(failure *sourceReadFailure, sourceID string, limitCode Code, limitMessage string) Diagnostic {
|
||||||
|
switch failure.kind {
|
||||||
|
case sourceReadNotRegular:
|
||||||
|
return sourceDiagnostic(CodeSourceNotRegular, sourceID, "source is not a regular file")
|
||||||
|
case sourceReadLimit:
|
||||||
|
return sourceDiagnostic(limitCode, sourceID, limitMessage)
|
||||||
|
default:
|
||||||
|
return classifySourceError(failure.cause, sourceID)
|
||||||
|
}
|
||||||
|
}
|
||||||
48
source_file_test.go
Normal file
48
source_file_test.go
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
package configkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Files disappearing between filesystem operations are not deterministic with
|
||||||
|
// real concurrent deletion. Exercise the private seam that controls optionality.
|
||||||
|
func TestRegularFileMissingFailurePhases(t *testing.T) {
|
||||||
|
name := filepath.Join(t.TempDir(), "input.yml")
|
||||||
|
if err := os.WriteFile(name, []byte("{}"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
stat func(string) (fs.FileInfo, error)
|
||||||
|
open func(string) (*os.File, error)
|
||||||
|
missing bool
|
||||||
|
}{
|
||||||
|
{"preflight", func(string) (fs.FileInfo, error) { return nil, fs.ErrNotExist }, os.Open, true},
|
||||||
|
{"open", os.Stat, func(string) (*os.File, error) { return nil, fs.ErrNotExist }, true},
|
||||||
|
{"post-open stat", os.Stat, func(name string) (*os.File, error) {
|
||||||
|
file, err := os.Open(name)
|
||||||
|
if err == nil {
|
||||||
|
err = file.Close()
|
||||||
|
}
|
||||||
|
return file, err
|
||||||
|
}, false},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
data, failure := readRegularFile(name, test.stat, test.open, 16)
|
||||||
|
if data != nil || failure == nil || failure.kind != sourceReadIO || failure.missingTarget != test.missing {
|
||||||
|
t.Fatalf("data = %v, failure = %#v", data, failure)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
_, failure := readBoundedSource(missingDuringRead{}, 16)
|
||||||
|
if failure == nil || failure.kind != sourceReadIO || failure.missingTarget {
|
||||||
|
t.Fatalf("late read failure = %#v", failure)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type missingDuringRead struct{}
|
||||||
|
|
||||||
|
func (missingDuringRead) Read([]byte) (int, error) { return 0, fs.ErrNotExist }
|
||||||
@@ -109,21 +109,7 @@ func runValidation[T any](configuration *T, validator func(*T, *Validation), sch
|
|||||||
state.diagnostics = append(state.diagnostics, validationMutationDiagnostic(issue.path))
|
state.diagnostics = append(state.diagnostics, validationMutationDiagnostic(issue.path))
|
||||||
return canonicalDiagnostics(state.diagnostics)
|
return canonicalDiagnostics(state.diagnostics)
|
||||||
}
|
}
|
||||||
for _, delta := range compareDocumentTrees(before, after) {
|
state.diagnostics = append(state.diagnostics, validationValueMutations(before, after)...)
|
||||||
state.diagnostics = append(state.diagnostics, validationMutationDiagnostic(delta.path))
|
|
||||||
}
|
|
||||||
beforeSensitivity := projectedSensitivity(before)
|
|
||||||
afterSensitivity := projectedSensitivity(after)
|
|
||||||
for pointer, path := range beforeSensitivity {
|
|
||||||
if _, exists := afterSensitivity[pointer]; !exists {
|
|
||||||
state.diagnostics = append(state.diagnostics, validationMutationDiagnostic(path))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for pointer, path := range afterSensitivity {
|
|
||||||
if _, exists := beforeSensitivity[pointer]; !exists {
|
|
||||||
state.diagnostics = append(state.diagnostics, validationMutationDiagnostic(path))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for _, path := range changedSecretStates(reflect.ValueOf(*configuration), schema.root, lookups) {
|
for _, path := range changedSecretStates(reflect.ValueOf(*configuration), schema.root, lookups) {
|
||||||
state.diagnostics = append(state.diagnostics, validationMutationDiagnostic(path))
|
state.diagnostics = append(state.diagnostics, validationMutationDiagnostic(path))
|
||||||
}
|
}
|
||||||
@@ -178,3 +164,85 @@ func nearestDocumentLocation(root *document.Node, path Path) (document.Location,
|
|||||||
func locationKnown(location document.Location) bool {
|
func locationKnown(location document.Location) bool {
|
||||||
return location.SourceID != "" || location.Line != 0 || location.Column != 0
|
return location.SourceID != "" || location.Line != 0 || location.Column != 0
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// validationValueMutations combines value and sensitivity changes; resolved
|
||||||
|
// secret state is audited separately because it never enters projected trees.
|
||||||
|
func validationValueMutations(before, after *document.Node) []Diagnostic {
|
||||||
|
var diagnostics []Diagnostic
|
||||||
|
for _, delta := range compareConfigurationValues(before, after) {
|
||||||
|
diagnostics = append(diagnostics, validationMutationDiagnostic(delta.path))
|
||||||
|
}
|
||||||
|
beforeSensitivity := projectedSensitivity(before)
|
||||||
|
afterSensitivity := projectedSensitivity(after)
|
||||||
|
for pointer, path := range beforeSensitivity {
|
||||||
|
if _, exists := afterSensitivity[pointer]; !exists {
|
||||||
|
diagnostics = append(diagnostics, validationMutationDiagnostic(path))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for pointer, path := range afterSensitivity {
|
||||||
|
if _, exists := beforeSensitivity[pointer]; !exists {
|
||||||
|
diagnostics = append(diagnostics, validationMutationDiagnostic(path))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return diagnostics
|
||||||
|
}
|
||||||
|
|
||||||
|
func checkVersion(rule *VersionRule, root *document.Node) []Diagnostic {
|
||||||
|
if rule == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
version := Version{}
|
||||||
|
node, present := documentAtPath(root, rule.Path)
|
||||||
|
if present {
|
||||||
|
version.Present = true
|
||||||
|
switch node.Kind {
|
||||||
|
case document.Null:
|
||||||
|
version.Null = true
|
||||||
|
case document.String:
|
||||||
|
version.Text = node.Text
|
||||||
|
case document.Number:
|
||||||
|
if isIntegerVersion(node) {
|
||||||
|
version.Text = node.Text
|
||||||
|
break
|
||||||
|
}
|
||||||
|
fallthrough
|
||||||
|
default:
|
||||||
|
location, _ := nearestDocumentLocation(root, rule.Path)
|
||||||
|
primary := documentLocationPointer(location)
|
||||||
|
return []Diagnostic{{
|
||||||
|
Code: CodeVersionType, Severity: SeverityError, Path: rule.Path.clone(),
|
||||||
|
Message: "schema version must be a string, integer, or null", Primary: primary,
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
state := &validationState{
|
||||||
|
active: true, errorCode: CodeVersionError, warningCode: CodeVersionWarning,
|
||||||
|
invalidPath: CodeVersionError, document: root,
|
||||||
|
}
|
||||||
|
validation := &Validation{state: state}
|
||||||
|
err := rule.Check(version, validation)
|
||||||
|
state.active = false
|
||||||
|
if err != nil && !hasErrors(state.diagnostics) {
|
||||||
|
location, _ := nearestDocumentLocation(root, rule.Path)
|
||||||
|
state.diagnostics = append(state.diagnostics, Diagnostic{
|
||||||
|
Code: CodeVersionHookFailed, Severity: SeverityError, Path: rule.Path.clone(),
|
||||||
|
Message: "schema-version check failed", Primary: documentLocationPointer(location),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return canonicalDiagnostics(state.diagnostics)
|
||||||
|
}
|
||||||
|
|
||||||
|
func isIntegerVersion(node *document.Node) bool {
|
||||||
|
return node != nil && node.Kind == document.Number && integerPattern.MatchString(node.Text)
|
||||||
|
}
|
||||||
|
|
||||||
|
func pathMatches(configured *Path, current Path) bool {
|
||||||
|
return configured != nil && current.valid() && matchesPathShape(*configured, current)
|
||||||
|
}
|
||||||
|
|
||||||
|
func documentLocationPointer(location document.Location) *Location {
|
||||||
|
if !locationKnown(location) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &Location{SourceID: location.SourceID, Line: location.Line, Column: location.Column}
|
||||||
|
}
|
||||||
|
|||||||
126
value_operation.go
Normal file
126
value_operation.go
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
package configkit
|
||||||
|
|
||||||
|
import "reflect"
|
||||||
|
|
||||||
|
type projectionLimits struct {
|
||||||
|
depth int
|
||||||
|
nodes int
|
||||||
|
}
|
||||||
|
|
||||||
|
func defaultProjectionLimits() projectionLimits {
|
||||||
|
return projectionLimits{depth: maxDocumentDepth, nodes: maxDocumentNodes}
|
||||||
|
}
|
||||||
|
|
||||||
|
type projectionIssue struct {
|
||||||
|
code Code
|
||||||
|
path Path
|
||||||
|
message string
|
||||||
|
}
|
||||||
|
|
||||||
|
type valueVisit struct {
|
||||||
|
typeOf reflect.Type
|
||||||
|
kind reflect.Kind
|
||||||
|
ptr uintptr
|
||||||
|
}
|
||||||
|
|
||||||
|
// valueOperation owns one traversal's cycle and budget state and reuses the
|
||||||
|
// current load's descriptor cache for dynamic values.
|
||||||
|
type valueOperation struct {
|
||||||
|
cloneMode secretCloneMode
|
||||||
|
schema *schemaDescriptor
|
||||||
|
limits projectionLimits
|
||||||
|
active map[valueVisit]bool
|
||||||
|
nodes int
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *valueOperation) dynamicDescriptor(current reflect.Type, path Path) (*typeDescriptor, *projectionIssue) {
|
||||||
|
if current == secretType {
|
||||||
|
return nil, &projectionIssue{code: CodeSchemaDynamicType, path: path.clone(), message: "dynamic configuration values cannot establish secret-reference semantics"}
|
||||||
|
}
|
||||||
|
builder := schemaBuilder{cache: o.schema.types, building: make(map[reflect.Type]bool)}
|
||||||
|
descriptor, issue := builder.describe(current, path)
|
||||||
|
if issue != nil {
|
||||||
|
return nil, &projectionIssue{code: CodeSchemaDynamicType, path: path.clone(), message: "dynamic configuration value has an unsupported type"}
|
||||||
|
}
|
||||||
|
if descriptorContainsSecret(descriptor, make(map[*typeDescriptor]bool)) {
|
||||||
|
return nil, &projectionIssue{code: CodeSchemaDynamicType, path: path.clone(), message: "dynamic configuration values cannot establish secret-reference semantics"}
|
||||||
|
}
|
||||||
|
return descriptor, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func descriptorContainsSecret(descriptor *typeDescriptor, visited map[*typeDescriptor]bool) bool {
|
||||||
|
if descriptor == nil || visited[descriptor] {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if descriptor.kind == descriptorSecret {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
visited[descriptor] = true
|
||||||
|
if descriptorContainsSecret(descriptor.element, visited) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
for _, member := range descriptor.members {
|
||||||
|
if descriptorContainsSecret(member.descriptor, visited) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *valueOperation) charge(path Path) *projectionIssue {
|
||||||
|
o.nodes++
|
||||||
|
if o.limits.nodes >= 0 && o.nodes > o.limits.nodes {
|
||||||
|
return &projectionIssue{code: CodeSchemaProjectionLimit, path: path.clone(), message: "configuration projection exceeds the node limit"}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *valueOperation) checkDepth(path Path, depth int) *projectionIssue {
|
||||||
|
if depth > o.limits.depth {
|
||||||
|
return &projectionIssue{code: CodeSchemaProjectionLimit, path: path.clone(), message: "configuration projection exceeds the depth limit"}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (o *valueOperation) enter(value reflect.Value, path Path) (func(), *projectionIssue) {
|
||||||
|
visit, ok := visitFor(value)
|
||||||
|
if !ok {
|
||||||
|
return func() {}, nil
|
||||||
|
}
|
||||||
|
if o.active[visit] {
|
||||||
|
return nil, &projectionIssue{code: CodeSchemaCycle, path: path.clone(), message: "configuration values must not contain cycles"}
|
||||||
|
}
|
||||||
|
o.active[visit] = true
|
||||||
|
return func() { delete(o.active, visit) }, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func visitFor(value reflect.Value) (valueVisit, bool) {
|
||||||
|
if isNilValue(value) {
|
||||||
|
return valueVisit{}, false
|
||||||
|
}
|
||||||
|
visit := valueVisit{typeOf: value.Type(), kind: value.Kind()}
|
||||||
|
switch value.Kind() {
|
||||||
|
case reflect.Pointer, reflect.Slice:
|
||||||
|
if value.Kind() == reflect.Slice && value.Len() == 0 {
|
||||||
|
return valueVisit{}, false
|
||||||
|
}
|
||||||
|
visit.ptr = value.Pointer()
|
||||||
|
case reflect.Map:
|
||||||
|
if value.Len() == 0 {
|
||||||
|
return valueVisit{}, false
|
||||||
|
}
|
||||||
|
visit.ptr = uintptr(value.UnsafePointer())
|
||||||
|
default:
|
||||||
|
return valueVisit{}, false
|
||||||
|
}
|
||||||
|
return visit, visit.ptr != 0
|
||||||
|
}
|
||||||
|
|
||||||
|
func isNilValue(value reflect.Value) bool {
|
||||||
|
switch value.Kind() {
|
||||||
|
case reflect.Chan, reflect.Func, reflect.Interface, reflect.Map, reflect.Pointer, reflect.Slice:
|
||||||
|
return value.IsNil()
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
90
version_path_test.go
Normal file
90
version_path_test.go
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
package configkit
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestVersionPathsShareCanonicalSequenceSemantics(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
type config struct {
|
||||||
|
Versions []string `yaml:"versions"`
|
||||||
|
}
|
||||||
|
for _, token := range []string{"0", "+0", "-0", "00", "+1"} {
|
||||||
|
for _, value := range []string{"4", "'4'"} {
|
||||||
|
t.Run(token+"/"+value, func(t *testing.T) {
|
||||||
|
path, err := ParsePath("/versions/" + token)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var checked Version
|
||||||
|
result, err := Load[config](Bytes("version.yml", []byte("versions: ["+value+"]\n")), Options[config]{Definition: Definition[config]{
|
||||||
|
Version: &VersionRule{Path: path, Check: func(v Version, _ *Validation) error { checked = v; return nil }},
|
||||||
|
}})
|
||||||
|
want := Version{}
|
||||||
|
if token == "0" {
|
||||||
|
want = Version{Present: true, Text: "4"}
|
||||||
|
}
|
||||||
|
if checked != want {
|
||||||
|
t.Fatalf("checked = %#v, want %#v", checked, want)
|
||||||
|
}
|
||||||
|
if token != "0" && value == "4" {
|
||||||
|
requireDiagnostic(t, err, CodeDecodeType, "/versions/0")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil || result.Config.Versions[0] != "4" {
|
||||||
|
t.Fatalf("result = %#v, err = %v", result, err)
|
||||||
|
}
|
||||||
|
_, found := result.Metadata().Lookup(path)
|
||||||
|
if found != (token == "0") {
|
||||||
|
t.Fatalf("metadata lookup = %v", found)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestVersionPathsPreserveLiteralMappingKeysAndSegmentIntent(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
for _, key := range []string{"0", "+0", "-0", "00", "a/b~"} {
|
||||||
|
t.Run(key, func(t *testing.T) {
|
||||||
|
path, err := ParsePath(Keys(key).String())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
result, err := Load[map[string]string](Bytes("version.yml", []byte("'"+key+"': 4\n")), Options[map[string]string]{Definition: Definition[map[string]string]{
|
||||||
|
Version: &VersionRule{Path: path, Check: func(v Version, _ *Validation) error {
|
||||||
|
if !v.Present || v.Text != "4" {
|
||||||
|
t.Errorf("checked = %#v", v)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
}})
|
||||||
|
if err != nil || result.Config[key] != "4" {
|
||||||
|
t.Fatalf("result = %#v, err = %v", result, err)
|
||||||
|
}
|
||||||
|
if _, ok := result.Metadata().Lookup(path); !ok {
|
||||||
|
t.Fatal("literal key missing from metadata")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
path Path
|
||||||
|
input string
|
||||||
|
}{
|
||||||
|
{"key cannot address sequence", Keys("versions", "0"), "versions: [4]\n"},
|
||||||
|
{"index cannot address mapping", Keys("versions").Index(0), "versions: {'0': 4}\n"},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
type config struct {
|
||||||
|
Versions any `yaml:"versions"`
|
||||||
|
}
|
||||||
|
_, err := Load[config](Bytes("version.yml", []byte(test.input)), Options[config]{Definition: Definition[config]{Version: &VersionRule{Path: test.path, Check: func(v Version, validation *Validation) error {
|
||||||
|
if v.Present {
|
||||||
|
t.Errorf("wrong segment kind found version: %#v", v)
|
||||||
|
}
|
||||||
|
validation.Error(Keys("versions"), "version absent")
|
||||||
|
return nil
|
||||||
|
}}}})
|
||||||
|
requireDiagnostic(t, err, CodeVersionError, "/versions")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user