56 Commits

Author SHA1 Message Date
34380f62a5 Prepare ConfigKit v0.3.0 2026-09-12 18:35:51 +00:00
f2c650b5eb Preserve closed callback failures through the facade 2026-09-12 18:33:11 +00:00
5760614834 Make macOS support best effort 2026-09-12 18:30:59 +00:00
88e86de03e Repair development guide formatting 2026-09-12 17:26:12 +00:00
c98435ce8e Restore public API reference documentation 2026-09-12 17:24:44 +00:00
6102b36adf Check resolved types at the public boundary 2026-09-12 17:23:47 +00:00
7395f54513 Preserve missing version checker validation 2026-09-12 17:20:40 +00:00
5dc7e18d83 Audit the public facade boundary 2026-09-12 16:26:44 +00:00
7f46c947dd Move facade to the root package 2026-09-12 16:21:17 +00:00
550f92d7c6 Complete facade inspection and secrets bootstrap 2026-09-12 16:19:22 +00:00
da307c9f49 Add facade lifecycle wrappers 2026-09-12 16:16:11 +00:00
b8935e77bd Separate engine loading from inspection state 2026-09-12 16:12:01 +00:00
05812a6ad3 Add public facade value wrappers 2026-09-12 16:07:38 +00:00
da02a19f2c Bind engine secret handling to facade types 2026-09-12 16:04:31 +00:00
9b4f9ac50e Add internal engine relocation candidate 2026-09-12 15:58:06 +00:00
f81def9365 Record public facade boundary 2026-09-12 15:56:11 +00:00
eb7de8e40a Plan the public facade refactor 2026-09-12 15:16:52 +00:00
28e7fc5f69 Avoid copying unused resolution history 2026-09-12 13:51:04 +00:00
715364b079 Complete resolution provenance readiness audit 2026-09-12 13:25:38 +00:00
b9e4dfac92 Demonstrate source-aware path normalization 2026-09-12 13:21:21 +00:00
cc98be8f0a Verify layered resolution provenance 2026-09-12 13:17:22 +00:00
e20a0a0998 Expose resolution input provenance 2026-09-12 13:13:07 +00:00
88f21d09c7 Prepare resolution entry provenance view 2026-09-12 13:10:48 +00:00
818d36f836 Clarify implementation discipline and provenance feature scope 2026-09-12 13:05:00 +00:00
381430a24b Plan source origins during resolution 2026-09-12 12:52:32 +00:00
8a62b42503 Prepare ConfigKit v0.2.0 2026-09-12 01:33:17 +00:00
244104f84f Unify canonical sequence index semantics for version decoding 2026-09-12 01:31:54 +00:00
fd8a68d268 Complete integer schema version consumer readiness 2026-09-12 00:33:49 +00:00
8702121fa4 Verify integer version provenance and protections 2026-09-12 00:30:32 +00:00
6eb40c5492 Cover integer version decoding boundaries 2026-09-12 00:28:35 +00:00
f5bbc2be5d Support integer schema versions through typed decoding 2026-09-12 00:25:54 +00:00
b3e7b62119 Prepare ConfigKit v0.1.2 2026-09-07 13:02:22 +00:00
cd730d5c99 Prepare ConfigKit v0.1.1 2026-09-07 03:06:14 +00:00
55fd42921a Prepare ConfigKit v0.1.0 2026-09-07 01:52:38 +00:00
4bd83639a3 Retire completed roadmaps and preserve release gates 2026-09-07 01:42:57 +00:00
994a9ea0e2 Add an everyday check command and contributor test map 2026-09-07 01:40:39 +00:00
cb5b4356aa Complete ownership documentation and integration validation 2026-09-07 01:19:00 +00:00
22a03c9f4d Separate explanation replay snapshots and rendering 2026-09-07 01:14:17 +00:00
6a1c7df4be Extract explicit configuration loading phases 2026-09-07 01:13:20 +00:00
75a5829132 Clarify filesystem directive and override ownership 2026-09-07 01:10:21 +00:00
1cc645f30f Separate projection cloning and value comparison 2026-09-07 01:08:43 +00:00
633c5cd519 Share semantic document traversal helpers 2026-09-07 01:07:02 +00:00
fc2b6c81a1 Share bounded regular file read mechanics 2026-09-07 01:06:35 +00:00
a04d32c389 Centralize parser diagnostic translation 2026-09-07 01:04:08 +00:00
49e04442da Remove unused overlay value snapshots 2026-09-07 01:02:27 +00:00
874adaaa18 Document results of a code maintainability audit for an upcoming implementation sprint 2026-09-06 19:58:18 -05:00
9b147bfd96 Reject validator changes to secret classification 2026-09-06 19:20:04 -05:00
fb2e2c5386 Run release checks with the stock macOS Bash 2026-09-06 19:16:00 -05:00
537d26e359 Check canonical source paths correctly on macOS 2026-09-06 19:13:16 -05:00
3e70ad1285 Reject unsupported inline pointer schemas safely 2026-09-06 19:12:19 -05:00
be2ba9a20b Validate metadata queries against semantic path shapes 2026-09-06 19:11:30 -05:00
edd82e15b2 Replay added configuration paths in explanations 2026-09-06 19:08:29 -05:00
831c83a0b5 Explain defaults restored during final decoding 2026-09-06 19:07:03 -05:00
f7ca150a7f Detach callback values before publishing configuration 2026-09-06 19:05:28 -05:00
9c94f2b70a Reject nonregular configuration files before opening them 2026-09-06 19:04:04 -05:00
e0f9541f5f Preserve secret tags in dynamic configuration values 2026-09-06 19:03:00 -05:00
134 changed files with 9873 additions and 6727 deletions

3
.codebase-memory/.gitattributes vendored Normal file
View File

@@ -0,0 +1,3 @@
# Auto-generated by codebase-memory-mcp
# Prevent merge conflicts on compressed artifact
graph.db.zst merge=ours binary

View File

@@ -0,0 +1,11 @@
{
"schema_version": 2,
"commit": "88e86de03ebd87038b0c98b164865f2c3b82a94c",
"indexed_at": "2026-09-12T18:30:58Z",
"project": "home-eric-Workspace-ConfigKit",
"nodes": 1641,
"edges": 7961,
"original_size": 8126464,
"compressed_size": 1350795,
"compression_level": 3
}

Binary file not shown.

View File

@@ -1,2 +1,35 @@
Please review `docs/development.md` for initial orientation in this repository
and follow its task-specific reading guide.
## Implementation Discipline
Simplify the mechanism, not the user's choices.
**Keep implementation machinery proportionate.** Use straightforward,
idiomatic code and introduce only the machinery needed to support the requested
behavior and flexibility. Optimize for conceptual simplicity and
maintainability, not for the fewest lines, fewest options, or narrowest product
surface.
**Do not invent restrictions.** Do not reject, prohibit, constrain, cap,
harden, or otherwise narrow behavior unless required by the user, an explicit
repository contract, or an unavoidable correctness constraint. Preserve
operator and caller choice where policy has not been established. If an
additional restriction appears advisable, present it as a decision rather than
silently implementing it.
A correctness constraint must follow from the requested behavior or an
established invariant, not from the convenience of the chosen implementation.
Explain the specific failure it prevents and why a less restrictive
implementation cannot satisfy the contract.
**An implementation detail is not repository policy.** Tests and documentation
must not elevate incidental behavior into a durable guarantee or restriction
unless that behavior was expressly selected as part of the contract. Existing
explicit contracts remain authoritative; changing or weakening one requires a
deliberate decision rather than being inferred from this principle.
Agent-authored plans, tests, documentation, and prior implementations are not
evidence that an additional restriction was deliberately approved. Identify the
authorizing requirement or decision; when that authority is unclear, surface
the ambiguity rather than treating repetition as approval.

View File

@@ -2,6 +2,9 @@
An opinionated Go library for applications that use YAML configuration files.
Linux is release-validated; macOS support is best-effort. See the
[platform support policy](docs/policy/release.md#platform-support) for details.
ConfigKit's initial loader accepts one strict YAML
source (or explicit defaults-only input), optionally traverses explicit path and glob
imports, strictly composes their disjoint definitions, applies typed defaults, and
@@ -33,9 +36,7 @@ func ReadConfig(path string) (Config, error) {
The [configuration contract](docs/config.md) describes implemented behavior. The
[consumer adoption guide](docs/consumers/README.md) provides maintained Narratio-,
Notarius-, WeatherReporter-, and Seriatim-shaped examples and migration guidance.
The [initial target record](docs/roadmap/initial.md) links implemented feature owners
and preserves deliberately deferred scope. The [implementation plan](docs/roadmap/implementation.md)
records completion evidence until release-candidate preparation retires it. See the
The [deferred scope](docs/roadmap/deferred.md) records deliberate exclusions. See the
[development guide](docs/development.md) for repository orientation and the
[release procedure](docs/release.md) for maintainer-only candidate and publication
steps.

133
conversion.go Normal file
View File

@@ -0,0 +1,133 @@
package configkit
import "gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
func fromEngineLocation(value engine.Location) Location {
return Location{SourceID: value.SourceID, Line: value.Line, Column: value.Column}
}
func toEngineLocation(value Location) engine.Location {
return engine.Location{SourceID: value.SourceID, Line: value.Line, Column: value.Column}
}
func fromEngineLocationPointer(value *engine.Location) *Location {
if value == nil {
return nil
}
result := fromEngineLocation(*value)
return &result
}
func toEngineLocationPointer(value *Location) *engine.Location {
if value == nil {
return nil
}
result := toEngineLocation(*value)
return &result
}
func fromEngineLocations(values []engine.Location) []Location {
if values == nil {
return nil
}
result := make([]Location, len(values))
for i := range values {
result[i] = fromEngineLocation(values[i])
}
return result
}
func toEngineLocations(values []Location) []engine.Location {
if values == nil {
return nil
}
result := make([]engine.Location, len(values))
for i := range values {
result[i] = toEngineLocation(values[i])
}
return result
}
func fromEngineSourceInfo(value engine.SourceInfo) SourceInfo {
return SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Layer: Layer(value.Layer), ImportedFrom: fromEngineLocationPointer(value.ImportedFrom)}
}
func toEngineSourceInfo(value SourceInfo) engine.SourceInfo {
return engine.SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Layer: engine.Layer(value.Layer), ImportedFrom: toEngineLocationPointer(value.ImportedFrom)}
}
func fromEngineOrigin(value engine.Origin) Origin {
return Origin{Layer: Layer(value.Layer), Locations: fromEngineLocations(value.Locations), Operation: ChangeKind(value.Operation)}
}
func toEngineOrigin(value Origin) engine.Origin {
return engine.Origin{Layer: engine.Layer(value.Layer), Locations: toEngineLocations(value.Locations), Operation: engine.ChangeKind(value.Operation)}
}
func fromEngineDiagnostic(value engine.Diagnostic) Diagnostic {
return Diagnostic{Code: Code(value.Code), Severity: Severity(value.Severity), Path: fromEnginePath(value.Path), Message: value.Message, Primary: fromEngineLocationPointer(value.Primary), Related: fromEngineLocations(value.Related)}
}
func toEngineDiagnostic(value Diagnostic) engine.Diagnostic {
return engine.Diagnostic{Code: engine.Code(value.Code), Severity: engine.Severity(value.Severity), Path: toEnginePath(value.Path), Message: value.Message, Primary: toEngineLocationPointer(value.Primary), Related: toEngineLocations(value.Related)}
}
func toEngineDiagnostics(values []Diagnostic) []engine.Diagnostic {
if values == nil {
return nil
}
result := make([]engine.Diagnostic, len(values))
for i := range values {
result[i] = toEngineDiagnostic(values[i])
}
return result
}
func fromEngineFieldInfo(value engine.FieldInfo) FieldInfo {
return FieldInfo{Explicit: value.Explicit, Null: value.Null, Origin: fromEngineOrigin(value.Origin)}
}
func fromEngineChange(value engine.Change) Change {
result := Change{Path: fromEnginePath(value.Path), Origin: fromEngineOrigin(value.Origin), Reasons: append([]string(nil), value.Reasons...)}
if value.Previous != nil {
result.Previous = make([]Origin, len(value.Previous))
for i := range value.Previous {
result.Previous[i] = fromEngineOrigin(value.Previous[i])
}
}
if value.Dependencies != nil {
result.Dependencies = make([]Path, len(value.Dependencies))
for i := range value.Dependencies {
result.Dependencies[i] = fromEnginePath(value.Dependencies[i])
}
}
return result
}
func fromEngineValueSnapshot(value engine.ValueSnapshot) ValueSnapshot {
return ValueSnapshot{Present: value.Present, Redacted: value.Redacted, YAML: value.YAML}
}
func fromEngineValueChange(value engine.ValueChange) ValueChange {
result := ValueChange{
Before: fromEngineValueSnapshot(value.Before),
After: fromEngineValueSnapshot(value.After),
Origin: fromEngineOrigin(value.Origin),
Reasons: append([]string(nil), value.Reasons...),
}
if value.Dependencies != nil {
result.Dependencies = make([]Path, len(value.Dependencies))
for index := range value.Dependencies {
result.Dependencies[index] = fromEnginePath(value.Dependencies[index])
}
}
return result
}
func fromEngineExplanation(value engine.Explanation) Explanation {
result := Explanation{
Path: fromEnginePath(value.Path),
Effective: fromEngineValueSnapshot(value.Effective),
Origin: fromEngineOrigin(value.Origin),
}
if value.History != nil {
result.History = make([]ValueChange, len(value.History))
for index := range value.History {
result.History[index] = fromEngineValueChange(value.History[index])
}
}
if value.SecretReference != nil {
result.SecretReference = &SecretReferenceInfo{Name: value.SecretReference.Name}
}
return result
}
func fromEngineSecretFileResult(value engine.SecretFileResult) SecretFileResult {
return SecretFileResult{Name: value.Name, Path: value.Path, Status: SecretFileStatus(value.Status), ExistingEmpty: value.ExistingEmpty}
}

View File

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

11
doc.go
View File

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

View File

@@ -0,0 +1,61 @@
# ADR 0006: Keep the public facade root-owned
## Status
Accepted and implemented on 2026-09-12.
## Date
2026-09-12
## Context
The root `configkit` package formerly combined the supported consumer API with
loading, decoding, inspection, and rendering machinery. That made ordinary
consumer navigation harder and risked exposing implementation details through
future public declarations. Moving files alone would not preserve the identity,
method sets, callbacks, ownership rules, or reflection-sensitive behavior of the
root's public types.
## Decision
Keep `configkit` as the sole supported consumer import path and keep all public
named types defined there. Lifecycle machinery lives in a cohesive
`internal/engine`; the root facade holds private engine state and forwards
deliberately selected methods. The dependency direction is root to engine and
its supporting internal packages. Internal production packages do not import the
root package.
The facade will use root-owned wrappers and explicit private conversions. It
will not use public aliases to engine types, anonymous embedding that promotes
engine methods, public engine types in signatures or fields, or public bridge
methods. Existing document and YAML boundaries remain intact.
The migration used a temporary internal candidate facade while the unchanged root
remained the only production entry point. That duplication was a bounded
relocation mechanism, not a second supported package, feature path, or runtime
selection mechanism; the final cutover removed it and the old root implementation.
The [architecture policy](../policy/architecture.md#public-api-and-dependency-direction)
owns the binding boundary. The [component overview](../internal/overview.md)
describes the implemented layout; the [development guide](../development.md)
locates the completed roadmap and implementation history.
## Alternatives considered
- Alias root types to engine types: reduces forwarding, but makes the engine's
exported method set and type ownership part of the consumer API.
- Keep all machinery in the root package: avoids migration work, but leaves the
public boundary difficult to navigate and easy to expand accidentally.
- Introduce a generic adapter or registration framework: could reduce some
handwritten conversions, but adds machinery and obscures the ownership rules
this boundary needs to preserve.
## Consequences
The refactor carries explicit forwarding and conversion work, especially for
callbacks, detached records, source ownership, frozen inspection, and the
exact-type secret bridge. Public compatibility must be verified by behavior and
external-package evidence rather than inferred from package names. Internal
exports remain implementation surfaces, and the completed architecture will
have one engine and one root facade.

View File

@@ -34,7 +34,10 @@ root `T` must satisfy the application schema contract below. With no options it
starts from the Go zero value. A `Definition[T].Defaults` factory, when supplied,
runs exactly once after successful source parsing, schema construction, optional
schema-version checking, and selected-profile loading. Its result is detached before
input is applied.
input is applied. Resolver-provided mutable values are detached before secret lookup
and validation, and a final detached copy prevents equal-valued aliases introduced
by validators from escaping into the returned configuration. The publication copy
preserves already validated secret state without rereading the environment.
The current lifecycle is:
@@ -58,7 +61,7 @@ semantic validator, explicit path and glob imports, one selected profile, and
filesystem context. It also accepts explicit CLI override records, static secret
tags, and a dynamic path predicate. `NoSource` rejects imports, active profiles,
and filesystem context as invalid combinations but accepts overrides. Input
containing a reserved `_configkit` mapping key is rejected while imports are
containing a reserved `configkit` mapping key is rejected while imports are
disabled.
Every source, options, schema, version, defaults, decode, resolution, or validation
@@ -71,9 +74,27 @@ frozen redacted projection.
### Application schema versions
`VersionRule` declares a semantic path and non-nil checker. Before defaults or typed
decoding, the checker receives a `Version` preserving absent, explicit null, or
string input. Any other input kind is a `version.type` error and the checker does not
run. ConfigKit does not interpret the text or impose a universal version path.
decoding, the checker receives a `Version` preserving absent, explicit null, string,
or strict decimal-integer input. Strings and integers supply their exact spelling in
`Version.Text`; ConfigKit does not impose a machine-integer range. Fractional or
exponent numbers, booleans, mappings, and sequences produce `version.type`, and the
checker does not run. ConfigKit does not interpret the text or impose a universal
version path.
Integer versions use the strict grammar `^-?(0|[1-9][0-9]*)$`. Their spelling is
preserved exactly, including `-0`, and application policy decides which identifiers
are supported. A numeric version has no built-in `int64` range limit. Existing parser
policy still rejects leading zeroes, leading plus signs, hexadecimal notation, and
other non-JSON scalar forms before version checking.
At exactly the configured semantic path, accepted integer input can decode into a
plain Go string or named string without a text codec, including pointer wrappers.
This is a load-local exception: siblings and unrelated fields, dynamic values,
integer and float destinations, durations, `Secret`, and text codecs retain ordinary
strict decoding. The retained source and history stay numeric while typed projection
and effective YAML contain the resulting string; explanation records this through
the normal decoding reconciliation event. Fragment, profile, and CLI ownership
protections apply before this conversion.
The checker reports safe, value-free errors and warnings through `Validation.Error`
and `Validation.Warning`; ConfigKit resolves primary and related paths to exact or
@@ -84,7 +105,10 @@ successful result. Callback panics remain programming failures.
### Layered metadata
`Result.Metadata` describes every final canonical path. Exact surviving input paths
`Result.Metadata` describes every final canonical path. Queries retain explicit
key/index distinctions using actual effective and historical container shapes;
only parsed numeric tokens are resolved contextually. A descendant that never
existed is not historical merely because an atomic ancestor was replaced. Exact surviving input paths
are explicit and have a base or profile origin; omitted paths have a default origin.
An explicit parent mapping does not make omitted children explicit. Null state and
available source coordinates are retained. `History` records the Go-zero or application
@@ -93,6 +117,13 @@ equal-valued input. Profile replacement removes displaced descendants from effec
metadata while retaining their base definition and the parent replacement operation
in history. All metadata queries and returned nested records are detached.
`Metadata.HistoricalPaths` enumerates observed paths, including removed paths,
without reloading input or resolving unselected credentials. Consumers can use
it with `History` to validate ownership of declarations hidden by an overlay;
domain ownership rules remain application responsibilities. The method's Go
documentation in [metadata.go](../metadata.go) defines enumeration and path-ordering
details.
History is chronological: a path can have a synthetic default record, its exact base
definition, a profile replacement, and a recorded resolution operation. A descendant
query uses the ancestor's path for a whole-container or whole-sequence replacement;
@@ -113,7 +144,9 @@ selected profile, then synthetic CLI records in supplied order.
### Secret classification and effective rendering
Secret classification combines `configkit:"secret"` field tags with the optional
pure `Definition.Secret(Path) bool` predicate. A tagged or predicate-classified
pure `Definition.Secret(Path) bool` predicate. Tags on concrete structs inside
supported dynamic values are retained in typed projections, including superseded
defaults and pre-resolution values. A tagged or predicate-classified
container makes all descendants sensitive. ConfigKit evaluates final and historical
paths, so replacing or deleting a definition does not erase its classification. The
predicate can add sensitivity but cannot declassify a tagged path.
@@ -157,11 +190,20 @@ parser values. `Present=false` with empty YAML means absence or deletion; explic
null is present and renders as `null`. The history starts with the canonical typed
application-default or Go-zero baseline, then includes only relevant base sources in
traversal order, the selected profile, supplied CLI records, and audited net
resolution operations. Explicit equal writes remain visible.
resolution operations. Overlapping parent/child resolution records are rendered
as one net subtree event with combined reasons and dependencies, rather than
pretending that the recorded assignments ran in lexical order. Explicit equal
writes remain visible. When input replay
differs from the final typed input, a `ChangeDecoding` event records the transition
before domain resolution. Its origin retains the decoded path's source/default
attribution; its reason identifies static-default retention and canonical decoding.
This built-in inspection operation is not accepted by `Resolution.Record`.
Base, profile, and CLI history preserves the policy-validated input representation,
including a superseded value that could not have decoded into the final Go field.
Baseline, effective, and resolution values use canonical typed projection. Mapping
Baseline, effective, and resolution values use canonical typed projection. A
decoding event bridges replayed input and canonical typed values, so intermediate
null/scalar replacements cannot incorrectly imply permanent default removal. Mapping
queries reconstruct each source or override contribution, while sequences remain
atomic: a descendant history identifies its presence before and after the containing
sequence replacement without implying element-wise merging. Removed paths remain
@@ -226,7 +268,9 @@ public-name representation, JSON marshals only the name, and projection, metadat
histories, diagnostics, effective output,
and resolution snapshots never retain the credential. Validation remains read-only:
in addition to ordinary projected-value comparison, ConfigKit checks the private
resolved state against this load's lookup result. Applications must keep their
resolved state against this load's lookup result. Validator mutation checks also
reject changes to projected field sensitivity, even when a replacement dynamic
concrete type preserves the same textual values. Applications must keep their
validation messages value-free and must not include `Value()` in diagnostics.
For example, a typed pipeline field is declared as:
@@ -282,6 +326,27 @@ receives the working configuration and a per-load `Resolution` context. The cont
`IsExplicit` query uses surviving input presence, so explicit false, zero, empty, and
null are never confused with absence or static defaults.
`Resolution.InputInfo(path)` returns detached effective metadata from the fixed
decoded entry state, before application resolution runs. It includes retained static
defaults, preserves explicit null, and uses the same exact semantic path matching as
`Metadata.Lookup`; missing, malformed, and key/index-shape-mismatched paths return
false without diagnostics. Its `Explicit` value agrees with `IsExplicit` for every
found entry path. Resolution changes, additions, removals, and moves do not alter
this entry view.
`Resolution.Source(id)` returns a detached record for an already acquired source in
the same load: the main source, imports, selected profile, and supplied CLI records
where applicable. Empty and unknown IDs return false. It never reloads input, reads
the filesystem, resolves symlinks, or looks up credentials. A source path exists
only when the acquired source has one; defaults and byte, reader, and CLI sources do
not acquire an invented file path. Applications select a base directory from exact
origin locations and these source records according to their own policy.
Both queries are callback-scoped like the rest of `Resolution`: nil, zero, or
retained contexts panic with the fixed closed-context failure outside the callback.
Returned field and source records are detached and remain usable after callback
exit; changing them cannot affect later queries, results, or another load.
`Default` assigns and records a derived value only when its path is not explicit.
`Normalize` always assigns and records. For transformations performed directly by
application code, `Resolution.Record` declares a derived-default or normalization
@@ -400,7 +465,7 @@ origin data; history retains the displaced definitions and identifies the parent
replacement. Surviving profile paths have profile origins, while recursively shared
mapping containers retain their contributing locations.
A profile may not contain `_configkit` anywhere, contribute the configured schema
A profile may not contain `configkit` anywhere, contribute the configured schema
version, or replace an ancestor of that version with a scalar or null. These rules
apply even if the supplied version text would be unchanged. Profiles cannot mask a
base parse, composition, or version failure because those checks run first.
@@ -433,7 +498,7 @@ All records are detached and validated before any is applied. Root and explicit
sequence-index paths are invalid; parsed numeric tokens remain mapping keys. A path
may create missing mapping ancestors but cannot traverse an existing scalar, null,
or sequence. Duplicate and ancestor/descendant-overlapping records are rejected.
Neither paths nor values may create `_configkit`, and no record may overlap the
Neither paths nor values may create `configkit`, and no record may overlap the
configured schema-version path. Unknown fields and effective type mismatches remain
ordinary strict-decoder errors.
@@ -456,12 +521,18 @@ zero.
## Explicit imports
Imports are opt-in. With `Options.Imports` enabled, a document may reserve its root
`_configkit` mapping for an `imports` sequence. ConfigKit removes that mapping before
`configkit` mapping for an `imports` sequence. ConfigKit removes that mapping before
application decoding. Each sequence item is a mapping with exactly one nonempty
string `path` or `glob` plus an optional boolean `optional`; all other shapes and
fields are `import.invalid`. A `_configkit` key below the root is invalid. With
fields are `import.invalid`. A `configkit` key below the root is invalid. With
imports disabled, any occurrence remains `import.disabled`.
The directive key is `configkit`, not the `_configkit` spelling used in v0.1.0.
There is no legacy alias: `_configkit` is ordinary application data, subject to
normal schema validation, and does not trigger imports. When upgrading, rename
directive keys in root files and imported fragments, and rename any application
fields previously named `configkit` to avoid the newly reserved key.
Path declarations use slash-separated relative paths. Absolute paths and network
URLs are invalid. Each path resolves from its declaring file's canonical directory,
and files are traversed depth-first in declaration order: a file contributes its own
@@ -488,7 +559,9 @@ source may supply `FilesystemContext.Root` to widen that root but may not supply
`BaseDir`. Whenever byte and reader sources supply filesystem context, they must
provide both `BaseDir` and `Root` as absolute existing directories; their diagnostic
labels, including URI-looking labels, never imply filesystem paths. The base and
selected file must be inside the canonical root. ConfigKit canonicalizes symlinks,
selected file must be inside the canonical root. Nonregular targets are rejected before opening to avoid blocking on FIFOs;
post-open regular-file checks remain in place under the stable-input assumption.
ConfigKit canonicalizes symlinks,
checks containment with relative paths, and opens the canonical root-relative target
through a library-owned root handle. Contained relative or absolute symlink targets
are accepted; escapes are rejected. Canonical paths define identity, so hard links at
@@ -570,15 +643,19 @@ Unexported fields and `yaml:"-"` are ignored. `omitempty` is accepted but does n
change binding or projection. An explicit `yaml:",inline"` flattens a struct
or pointer-to-struct, or designates at most one string-keyed inline map for otherwise
unmatched names. Ambiguous names, named inline fields, multiple inline maps, and
unsupported or malformed tag options are schema errors. Embedded fields are ordinary
unsupported or malformed tag options are schema errors. Inline pointers must
terminate in a struct; pointer-to-map and recursive pointer-only inline declarations
are rejected before the defaults factory runs. Embedded fields are ordinary
named fields unless explicitly inlined. `configkit:"secret"` is the only current
ConfigKit field tag and marks its bound field or container for secret classification
and redacted inspection.
Schema path traversal uses these same bindings. Explicit key segments address
mappings, explicit index segments address sequences, and parsed numeric pointer
tokens become indices only in a sequence. Sequence indices cannot have leading
zeroes; fixed-array indices are bounds checked. Dynamic `any` and inline-map entries
tokens become indices only in a sequence. Sequence indices must use canonical
nonnegative decimal spelling: no signs or leading zeroes except `0` itself.
Tokens such as `+0` and `-0` remain literal keys in mappings but do not identify
sequence elements. Fixed-array indices are bounds checked. Dynamic `any` and inline-map entries
remain schema-valid for application-defined descendants, but dynamic values cannot
introduce typed secret references.
@@ -632,7 +709,11 @@ keys but preserve the strict schema of their values; inline maps accept unmatche
keys according to their element descriptor. Multiple independent field failures are
collected without publishing the partially decoded object.
Booleans and strings are not coerced. Integer destinations require integer syntax
Booleans and strings are not generally coerced. At exactly a configured
`VersionRule.Path`, strict decimal-integer input may decode into a plain `string`, a
named string without a text codec, or pointer wrappers around those types. The
exception is load-local and does not apply to unrelated fields or loads without a
version rule. Integer destinations require integer syntax
and a value in the destination type's range. Float destinations accept finite JSON
numbers representable by their destination precision. Dynamic numbers become
`int64` for integer syntax and `float64` for fractional or exponent syntax, with

View File

@@ -12,7 +12,7 @@ four sanitized shapes using only ConfigKit and the Go standard library:
| Shape | Demonstrated fit | Application code deliberately retained |
| --- | --- | --- |
| Narratio-like | Fresh defaults, explicit partial imports, artifact maps, recorded inherited defaults, normalization, and source ownership | Artifact meaning and the separate `model_profile` domain field |
| Notarius-like | Canonical object bindings, a string schema version, strict dynamic options, and `int64` dynamic integers | Catalog selection, allowed option keys, range-checked conversion to runtime `int` |
| Notarius-like | Canonical object bindings, a string-backed schema version accepting string or integer YAML, strict dynamic options, and `int64` dynamic integers | Catalog selection, allowed option keys, range-checked conversion to runtime `int` |
| WeatherReporter-like | Defaults-only loading, report maps, canonical module objects, typed option unions, explicit CLI records, and typed secret startup | Report/module compatibility, rejection of explicitly empty domain lists, CLI parsing, and startup ordering |
| Seriatim-like | Independent strict ordered speaker and autocorrect rule files | First-match and ordered replacement algorithms; no central application configuration file |
@@ -20,6 +20,11 @@ The YAML under [`testdata`](../../examples/consumers/testdata) is loaded by the
executable examples and ordinary tests. It contains no private application data,
credentials, network calls, or dependency on a sibling checkout.
The Notarius-shaped fixtures deliberately load both `version: 4` and
`version: "4"` into the same string-backed application field. Its version checker
still owns the supported identifier; fractional numeric input and unsupported
integer identifiers remain negative examples.
## Happy path
An application defines ordinary types and passes one explicit source. Optional
@@ -51,6 +56,24 @@ validate keys and ranges and convert only at their runtime boundary. Neither sha
needs YAML reserialization, a parser hook, a ConfigKit registry, or a generic subtree
conversion API.
## Source-aware application path policy
[`LoadNotariusPaths`](../../examples/consumers/consumers.go) is the maintained
path-bearing example. Its application resolver uses `Resolution.InputInfo` and
`Resolution.Source` before normalizing: relative CLI input uses an explicitly
supplied invocation directory, retained defaults use the canonical main source,
and a file-authored scalar uses its one declaring source. Absolute paths remain
unchanged. Its executable test covers the matching effective YAML and explanation
history, source-file ownership, and a symlink opened through an alias; canonical
target-file directories, rather than the alias directory or process working
directory, are the selected bases.
The selected example policy leaves a relative value unchanged and emits a warning
when a CLI base is absent or file provenance is missing or composite. That is an
application choice, not a ConfigKit fallback: consumers can instead reject it or
choose another explicit base. The example intentionally performs neither target
existence checks nor confinement checks.
## Intentional migration changes
The examples preserve domain meaning while adopting one configuration convention:
@@ -61,8 +84,9 @@ The examples preserve domain meaning while adopting one configuration convention
domain rule. Loaded and constructed values use the same validator.
- Canonical module and object bindings replace string shorthand. Unknown fields and
incompatible option shapes fail at their semantic paths.
- Schema versions are strings. Consumers with integer versions migrate their files
instead of enabling another scalar convention.
- Schema versions remain string-backed in application types. A configured
`VersionRule` accepts string or strict decimal-integer YAML at its exact path and
supplies the spelling to both the checker and a plain string destination.
- Integers inside dynamic options are portable `int64` values. Runtime `int`
conversion is explicit and range checked.
- Strict YAML intentionally rejects duplicate keys, aliases, merge keys, unsupported

View File

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

View File

@@ -115,8 +115,9 @@ Assigned to explicit import traversal and glob handling:
Canonical paths—not `os.SameFile`—define identity, so hard links remain distinct.
Path-import implementation record (2026-09-06): `splitImportDirectives`,
`parseImportDeclaration`, `importFilesystem`, `importTraversal.visitPath`, and
`loadImportedDocuments` in `imports.go` adapt the assigned envelope, loading, source,
`parseImportDeclaration` (now in `directives.go`), `importFilesystem` (now in
`filesystem.go`), and `importTraversal.visitPath`/`loadImportedDocuments` in
`imports.go` adapt the assigned envelope, loading, source,
and failure mechanics. The corresponding ConfigKit tests adapt the assigned
declaration-order, disjoint composition, keyed-entry, all-source conflict, unsafe
target, alias, imported parse/schema, and relative-resolution cases. ConfigKit adds

View File

@@ -1,68 +1,39 @@
# Internal Component Overview
ConfigKit currently contains the root `configkit` package, which establishes the
module and package documentation boundary. Its implemented public primitives are:
ConfigKit exposes one supported root `configkit` package. The root owns public
named types, declaration comments, constructors, and explicit conversion/wrapper
code. It delegates loading and frozen inspection to `internal/engine` through
private state; public signatures and fields do not expose engine types.
- immutable semantic `Path` construction and strict JSON Pointer parsing;
- detached provenance and diagnostic record types with empty `Metadata` queries;
- canonical, control-safe diagnostic rendering and structured `LoadError` access;
- opaque file, byte, reader, and no-input source construction and bounded reads;
- a parser-independent ordered tree in `internal/document`;
- the sole upstream YAML integration in `internal/yaml`, which translates validated
input into that owned tree and exposes no parser types outside its boundary;
- a private strict composer in `compose.go` that joins only disjoint document-tree
definitions, retains ordered claims and composite mapping origins, reports every
conflicting location, and never aliases an input tree;
- fixed selected-profile loading in `profile.go` and recursive, non-aliasing overlay
with displacement records in `overlay.go`;
- detached parser-neutral CLI override validation and synthetic-source application
in `override.go`, sharing overlay, source, byte, node, and depth policies;
- a per-operation private reflection descriptor graph in `schema.go` for supported
types, YAML field binding, inlining, secret tags, recursion, and semantic paths;
- descriptor-driven canonical projection, detached typed cloning, cycle/limit
enforcement, and structural comparison in `projection.go`;
- strict schema-driven decoding over detached defaults in `decode.go`, including
mapping/sequence semantics, null and numeric policy, safe codec errors, presence,
baseline/final trees, and initial default/input origins;
- public lifecycle coordination in `load.go`, bounded canonical import DFS in
`imports.go`, pattern-directed glob expansion in `glob.go`, version-report contexts
in `validation.go`, and detached layered metadata/history assembly in
`load_metadata.go`;
- recorded application defaulting and normalization in `resolution.go`, including
explicitness queries, helper verification, structural mutation audit, coalesced
intent, private before/after snapshots, and resolution metadata integration;
- post-resolution schema-aware report collection and validator mutation detection in
`validation.go`, completing the public callback lifecycle after composed input;
- opaque typed environment references and per-load name resolution in
`secret_reference.go`, including credential-free projection/cloning and private
validator-state auditing;
- explicit bounded startup environment bootstrap and safe partial reporting in
`secrets.go`;
- fixed-point tag/predicate/dependency sensitivity classification in `secret.go`;
- frozen-snapshot redaction and canonical effective output in `render.go`;
- bounded immutable input applications, safe historical replay, detached explanation
records, and deterministic human rendering in `explain.go`, with the
only upstream dumper integration isolated in `internal/yaml/write.go`;
- a separate maintained `examples/consumers` package with four sanitized consumer
shapes, fixtures, domain validators, and runtime-boundary functions; and
- a fail-fast source-candidate checker in `scripts/release-check.sh`, with local
documentation-link and public import-boundary gates owned by root-package tests.
Implemented component owners:
The [configuration contract](../config.md) owns implemented input, schema, decoding,
loading lifecycle, path imports, composition, profile/CLI overlay, version checking,
recorded resolution, typed secret references/bootstrap, semantic validation,
metadata, secret classification, redacted effective rendering, historical
explanation, and ownership behavior.
The [consumer adoption guide](../consumers/README.md) owns current application-fit
and migration guidance.
- `internal/engine` owns source acquisition, composition, schema inspection,
decoding, lifecycle coordination, callback auditing, provenance/history,
secret resolution and bootstrap, and safe inspection rendering. It never
imports the root package.
- Root `Source`, `Path`, `Secret`, records, lifecycle callbacks, results, and
bootstrap reports are root-defined wrappers or records. Callback wrappers pass
only live engine context during a callback; `Result.Config` is detached and its
private inspection remains frozen independently.
- Root conversion files explicitly cross the path, record, callback, secret, and
error boundaries. There are no public aliases, anonymous engine embeddings, or
public engine bridge methods.
- `internal/document` owns the parser-independent ordered tree and cloning.
`internal/yaml` is the sole upstream YAML adapter and parser/dumper boundary.
- Engine mechanism tests own source, composition, decoding, callback, provenance,
secret, history, rendering, and bootstrap behavior. Root tests own public API
compatibility, facade integration, documentation links, and dependency/public
boundary checks. Consumer examples remain under `examples/consumers`.
The source-extraction inventory records the application foundations used by the
implementation:
Dependency direction is root to engine to document/YAML support. The root may
import only `internal/engine` among project internal production packages; engine
and its supporting packages do not import root. The root boundary test enforces
these constraints and rejects exported aliases, fields, or signatures involving
internal types.
- [Extraction inventory](extraction.md)
The [architecture policy](../policy/architecture.md) owns binding boundaries and
invariants. The [initial target record](../roadmap/initial.md) links current feature
owners and preserves deferred scope; it is not an implementation inventory. See the
[development guide](../development.md) for contributor orientation and validation
and the [release procedure](../release.md) for source-candidate gates.
The [configuration contract](../config.md) owns implemented YAML and lifecycle
semantics. The [consumer adoption guide](../consumers/README.md) owns application
guidance. The [architecture policy](../policy/architecture.md) owns contributor
boundaries, and [ADR 0006](../adr/0006-public-facade.md) records the facade
decision. See the [development guide](../development.md) for validation commands
and [release procedure](../release.md) for source-candidate gates.

View File

@@ -10,7 +10,7 @@ ConfigKit is an opinionated Go library for consistent YAML configuration loading
and resolution across consuming applications. This policy governs contributions.
The [component overview](../internal/overview.md) owns implementation inventory, the
[configuration contract](../config.md) owns implemented behavior, and the
[initial target record](../roadmap/initial.md) preserves deferred scope.
[deferred scope](../roadmap/deferred.md) preserves deferred scope.
## Success criteria
@@ -76,6 +76,18 @@ Do not expose parser-specific nodes, errors, or extension hooks through the
public API. Parser integration belongs behind a private boundary, as recorded
in [ADR 0002](../adr/0002-own-yaml-semantics.md).
The root `configkit` package is the sole supported consumer import path. Public
named types stay root-owned and may hold private delegated implementation state;
their exported signatures, fields, and method sets must not expose internal
types or acquire internal methods through anonymous embedding. Root production
code may depend inward on a cohesive engine and its supporting internal packages,
but internal production packages must not import the root. Explicit forwarding
and local conversion are acceptable where they preserve type identity,
ownership, callback lifetime, and inspection behavior. The accepted root-wrapper
boundary and its migration rationale are recorded in
[ADR 0006](../adr/0006-public-facade.md); the implemented layout is described in
the [component overview](../internal/overview.md).
Separate input access, document processing, composition, decoding, metadata, and
rendering responsibilities without prescribing an interface or package for each.
Dependencies must preserve those boundaries; package splits and abstractions
@@ -136,7 +148,7 @@ for safe messages supplied by their hooks.
Do not introduce automatic directory discovery, network providers, arbitrary
merge strategies, additional configuration languages, or application runtime
management without concrete consumer evidence and an explicit design decision.
The [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
changes require an ADR and corresponding policy/contract updates; implementation
convenience alone is not a reason to broaden the public API.

View File

@@ -28,8 +28,8 @@ semantics, defaults, paths, diagnostics, or compatibility guarantees.
implementation; execution status belongs in roadmaps.
- Roadmaps own unimplemented targets, deferred work, and execution status.
The initial target and implementation plan have separate owners. Do not put
staged steps or progress in `docs/roadmap/initial.md`. `docs/roadmap/implementation.md` owns those details.
Future implementation plans own their task order, acceptance gates, and progress;
`docs/roadmap/deferred.md` owns deliberate exclusions, not execution instructions.
As behavior lands, move its normative definition into the current contract and
replace the roadmap definition with a link. Retire completed roadmap material
as part of release preparation; ADRs preserve significant decision history.
@@ -56,7 +56,7 @@ handling, not secret values.
| Topic | Owner | Boundary |
| --- | --- | --- |
| Product orientation | `README.md` | Current status, purpose, navigation, and a minimal working example once available |
| Product orientation | `README.md` | Current status, purpose, navigation, and a minimal working example |
| Contributor entry point | `docs/development.md` | Reading guide and actual baseline validation commands |
| Architecture rules | `docs/policy/architecture.md` | Responsibilities, dependency constraints, invariants, and scope boundaries |
| Documentation rules | `docs/policy/documentation.md` | Ownership and document lifecycle |
@@ -64,14 +64,13 @@ handling, not secret values.
| 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 |
| Exported Go API | Go documentation beside declarations | Symbol contracts, errors, ownership, and lifecycle API behavior |
| YAML and resolution semantics | `docs/config.md`, as behavior lands | Syntax, composition, precedence, conversion, secret references/bootstrap, and explanation semantics; Go reference docs link here for shared rules |
| YAML and resolution semantics | `docs/config.md` | Syntax, composition, precedence, conversion, secret references/bootstrap, and explanation semantics; Go reference docs link here for shared rules |
| Consumer walkthroughs | `docs/consumers/`, when useful | Task-oriented usage and consumer responsibilities, not duplicate API reference |
| Component inventory | `docs/internal/overview.md` | Implemented components and links to their owners |
| Component mechanics | Other `docs/internal/` files, when needed | Detailed implementation behavior and change recipes |
| Architectural decisions | `docs/adr/` | Context, decisions, alternatives, consequences, and supersession history |
| Initial target record | `docs/roadmap/initial.md` | Implemented-feature owner links and deferred feature scope until roadmap retirement |
| Implementation plan | `docs/roadmap/implementation.md` | Stages, dependencies, acceptance gates, and progress |
| Release readiness evidence | `docs/roadmap/release-readiness.md` | Closed preparation gaps and outstanding candidate/publication evidence until roadmap retirement |
| Deferred scope | `docs/roadmap/deferred.md` | Deliberate exclusions and the conditions for reconsidering them |
| Future implementation plans | Task-specific files under `docs/roadmap/` | Unfinished work, task order, acceptance gates, and progress; completed plans retire to Git history |
| Historical release notes | `docs/releases/` | Version-specific historical summaries required by release policy |
| Complete examples | `examples/` and executable Go examples | Maintained artifacts; prose links instead of duplicating full examples |

View File

@@ -2,7 +2,7 @@
**Base Policy Version:** 0.1.0
**Last Updated:** 2026-09-06
**Last Updated:** 2026-09-12
## Purpose
@@ -35,6 +35,20 @@ can still break consumers. Human diagnostic wording is stable only where a
contract expressly promises it. Minimum Go version and platform support changes
also require an explicit compatibility assessment.
### Platform support
Linux is the release-validated platform. macOS has best-effort support: keeping
it working remains desirable, but neither native macOS validation nor Darwin
cross-builds are release gates. Missing macOS evidence or a macOS-specific failure
does not by itself block a release. Voluntary checks and fixes are welcome; there
is no guarantee that each release has been validated on macOS.
The [release procedure](../release.md#release-identity-and-support) owns target
architectures and required Linux checks. This support change applies to upcoming
releases; historical release notes and their validation records remain unchanged.
The v0.3.0 release note must explain the change in Compatibility, with version
selection following this policy's existing compatibility rules.
## Release Model
A release consists of:
@@ -220,23 +234,18 @@ restating or redefining its requirements.
## Policy Exceptions
### Temporary bootstrap deferral
For v0.1.1 only, the maintainer approved renaming the reserved `_configkit` key to
`configkit` as an error-correcting point release because there are no current
consumers. This intentionally changes the v0.1.0 configuration contract without a
minor-version increment. The release note must explain the renamed directive,
the lack of a legacy alias, and the newly reserved application key. This exception
does not apply to subsequent releases or waive any validation or publication gate.
The release procedure and automated source-candidate checker now exist, but no exact
release candidate, version-matched release note, native macOS execution evidence, or
post-publication module retrieval has been validated. Initial implementation may be
completed without fabricating those later facts. This exception does not authorize
publication or waive any candidate, platform, remote, or consumer-verification gate.
For v0.2.0 only, the maintainer accepted the successful native macOS check at
`244104f` for the final candidate, provided subsequent changes are documentation
only. The final candidate still requires the complete Linux release checker.
This exception waives only repeating the macOS check after those documentation
changes; it does not waive other publication or verification gates.
Compensating controls: the [release procedure](../release.md) keeps every unvalidated
step as an explicit maintainer gate, and the
[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.
Candidate selection, publication, and post-publication verification remain
mandatory steps in the [release procedure](../release.md).

View File

@@ -14,16 +14,20 @@ binary, container image, generated package, or separate registry publication.
- Tags: stable Semantic Versions such as `v0.1.0`; prerelease tags are not supported
- Minimum Go language/toolchain line: Go 1.25, tested with a supported patched
Go 1.25 toolchain and one newer supported toolchain
- Build targets: Linux and macOS on amd64 and arm64
- Release-gated build targets: Linux on amd64 and arm64
- Best-effort targets: macOS on amd64 and arm64, under the
[platform support policy](policy/release.md#platform-support)
The automated checker cross-builds all four targets. Before release, the maintainer
must also obtain native test, race, symlink, and permission-fixture evidence on Linux
and macOS; cross-compilation is not native execution evidence.
The automated checker cross-builds the Linux targets. Before release, the maintainer
must also obtain native test, race, symlink, and permission-fixture evidence on Linux;
cross-compilation is not native execution evidence. Native macOS checks and Darwin
cross-builds are optional and do not gate publication.
## 1. Prepare access and tools
The release maintainer owns this procedure. Work in a dedicated clone with Git, Bash,
and two supported Go toolchains available. Configure private-host Git and Go module
The release maintainer owns this procedure. Work in a dedicated clone with Git, Bash 3.2 or newer,
and two supported Go toolchains available. The checker supports the stock macOS
Bash; its filename enumeration is null-delimited, including names with whitespace. Configure private-host Git and Go module
access through the normal credential manager before starting. Keep tokens, passwords,
SSH material, and environment dumps outside the repository and command transcript.
Verify access without displaying credentials:
@@ -96,10 +100,10 @@ release note until there is a real candidate and reviewed content.
## 4. Validate the source candidate
Run the complete checker from the repository root after dependencies have been
Run the complete checker on Linux from the repository root after dependencies have been
acquired. It validates module manifests in a disposable copy, both supported Go
toolchains, offline default tests, the native race detector, vet, native builds,
CGO-disabled cross-builds, formatting and whitespace (including untracked files),
CGO-disabled Linux cross-builds, formatting and whitespace (including untracked files),
examples, package isolation, and local documentation links.
```sh
@@ -119,9 +123,9 @@ Complete these manual gates and record their evidence with the candidate:
- The maintainer inspects `git status --short`, `git diff`, and the candidate file
list for credentials, private data, generated output, unintended files, and
unrelated changes.
- The maintainer records successful native checker runs on Linux and macOS, including
filesystem capability tests. If either host is unavailable, release remains
blocked unless platform support is formally narrowed in current documentation.
- The maintainer records a successful native checker run on Linux, including
filesystem capability tests. Missing required Linux evidence blocks release;
no macOS run, sign-off, or cross-build result is required.
- The maintainer removes completed, obsolete documents under `docs/roadmap/`, repairs
inbound links, and reruns the checker. ADR rationale remains historical.
- The maintainer reviews the exact version-matched release note for accuracy and

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

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

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

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

50
docs/releases/v0.1.2.md Normal file
View File

@@ -0,0 +1,50 @@
# ConfigKit v0.1.2
This release adds safe enumeration of effective and removed metadata paths.
## Summary
`Metadata.HistoricalPaths` returns the semantic paths observed during a load,
including paths removed by profiles, CLI overrides, or recorded resolution. This
allows applications to discover hidden declarations and inspect their ownership
through the existing `Metadata.History` API without reloading configuration or
resolving unselected credentials.
See the [layered metadata contract](../config.md#layered-metadata) for current
behavior.
## Compatibility
This is a backward-compatible API addition. Existing APIs, configuration syntax,
diagnostic codes, minimum Go version, and supported platforms are unchanged.
Existing consumers do not need to change their code or configuration.
Returned paths are detached, unique by canonical JSON Pointer spelling, and sorted
by that spelling. The enumeration includes defaults and the root, exposes no
configuration values, and performs no I/O or secret resolution.
The private YAML dependency remains pinned to
`go.yaml.in/yaml/v4 v4.0.0-rc.6`, confirmed as the newest available v4 release during
candidate preparation.
## Upgrade
No action required. Consumers that want historical path discovery can update with:
```sh
go get gitea.maximumdirect.net/eric/ConfigKit@v0.1.2
```
Then iterate over `result.Metadata().HistoricalPaths()` and use `History` for
chronological definitions or `Lookup` to determine whether each path remains
effective.
## Changes
- Added `Metadata.HistoricalPaths` with detached, deterministic path enumeration.
- Included effective, default, and removed paths while excluding paths that never
existed.
- Added coverage for parent removal, mapping-to-sequence shape replacement, escaped
mapping keys, detached results, zero metadata, composite base ownership, and
avoidance of superseded secret lookup.
- Documented historical path enumeration in the current configuration contract.

58
docs/releases/v0.2.0.md Normal file
View File

@@ -0,0 +1,58 @@
# ConfigKit v0.2.0
This release adds integer schema-version input and consistent canonical sequence
path handling.
## Summary
Applications can retain YAML such as `version: 4` while storing and checking their
version as a Go string. The exception applies only to the path designated by
`VersionRule`; unrelated fields keep strict decoding. See the
[configuration contract](../config.md#application-schema-versions).
## Compatibility
This minor release intentionally changes integer schema-version input from a
`version.type` rejection to invoking the application checker with its decimal text.
Plain string destinations at that path now accept it. Public Go API shapes,
minimum Go version, and supported platforms are unchanged.
Parsed sequence indices now require canonical nonnegative decimal spelling.
Signed tokens such as `+0` and `-0`, previously accepted by some traversal paths,
no longer identify sequence elements. They remain valid literal mapping keys.
Version checking, decoding, and metadata matching now agree on this boundary.
The private YAML dependency remains pinned to `go.yaml.in/yaml/v4 v4.0.0-rc.6`,
confirmed as the newest available v4 release during candidate preparation. Linux
candidate checks use Go 1.25.5 and Go 1.26.7. The maintainer confirmed native macOS
checks at `244104f` and approved carrying that result across the documentation-only
candidate changes under the [recorded exception](../policy/release.md#policy-exceptions).
## Upgrade
```sh
go get gitea.maximumdirect.net/eric/ConfigKit@v0.2.0
```
Existing string schema versions need no configuration changes. Applications using
integer YAML versions can retain them and use a plain string field plus a
`VersionRule`. Review the application checker: it now receives integer identifiers
as text, and remains responsible for rejecting unsupported versions. Fractions,
exponents, booleans, and containers remain invalid version kinds.
Replace signed sequence-index tokens in configured paths with canonical indices
(for example, `/versions/+0` becomes `/versions/0`). Do not change signed mapping
keys. Literal integers elsewhere still do not decode into strings, and numeric
destinations retain their existing range checks.
## Changes
- Added load-local, path-scoped integer-to-string schema-version decoding without
mutating original input or introducing general scalar coercion.
- Preserved numeric historical input, string effective output, decoding
reconciliation, source attribution, and secret redaction.
- Unified sequence path interpretation and added regression coverage for signed
tokens, literal mapping keys, and explicit key/index distinctions.
- Updated maintained Notarius-shaped fixtures and migration guidance for quoted
and integer version forms.
- Retired completed implementation and remediation roadmaps to Git history.

70
docs/releases/v0.3.0.md Normal file
View File

@@ -0,0 +1,70 @@
# ConfigKit v0.3.0
This release adds source provenance during application resolution, separates the
public API from its internal engine, and moves macOS to best-effort support.
## Summary
Resolvers can query fixed entry-state metadata and already-acquired sources using
`Resolution.InputInfo` and `Resolution.Source`. This supports application-owned
decisions such as resolving relative paths against their declaring configuration
files. See the [resolution contract](../config.md#recorded-application-resolution)
and the maintained [source-aware path example](../consumers/README.md#source-aware-application-path-policy).
The root `configkit` package now owns public types and explicit wrappers around a
single private engine. Internal changes no longer automatically expand public
method sets through shared type aliases. See the [component overview](../internal/overview.md)
and [facade decision](../adr/0006-public-facade.md).
## Compatibility
Existing consumer imports, exported API signatures, configuration syntax,
precedence, secret handling, callback lifetime, and ownership behavior are
preserved. The two resolution query methods are additive and available only
during an active resolution callback; returned records remain detached. The
library supplies provenance, not a new filesystem normalization policy.
macOS on amd64 and arm64 is now best-effort rather than release-validated.
Neither native macOS evidence nor Darwin cross-builds are required for publication,
and macOS-specific failures do not by themselves block a release. Linux on amd64
and arm64 retains release-gated builds and native Linux validation. See the
[platform support policy](../policy/release.md#platform-support).
The module still requires Go 1.25 or newer. 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 preparation. It is still a prerelease dependency, isolated behind
the existing private parser boundary; no dependency upgrade is included.
Linux candidate validation uses Go 1.25.14 and Go 1.26.8, including the complete
release checker, race tests, consumer examples, filesystem fixtures, and Linux
cross-builds. No native macOS validation is claimed for this candidate.
## Upgrade
```sh
go get gitea.maximumdirect.net/eric/ConfigKit@v0.3.0
```
No code or configuration migration is required for the facade refactor. Adopt the
new provenance queries only if your resolver needs them. Applications retain
ownership of relative-path bases and fallback/error behavior.
macOS consumers should account for the reduced validation commitment and perform
their own application checks when upgrading if macOS behavior is important to
their deployment.
## Changes
- Added callback-scoped, fixed entry metadata and source lookup without additional
filesystem access or credential resolution.
- Added a maintained Notarius-shaped path-normalization example with provenance,
frozen effective output, and explanation coverage.
- Moved loading, schema, resolution, provenance/history, secret, and rendering
machinery into one internal engine while keeping public named types root-owned.
- Added exact-type secret and callback adapters, with regression coverage for
missing version checkers and nil/zero closed-context failures.
- Added type-aware public-boundary checks covering aliases, collections, generic
constraints, and exported method sets; restored public reference documentation.
- Removed macOS release gates and retained required Linux validation.
- Retired completed feature, implementation, and remediation documents to Git
history and repaired navigation to current contract and component owners.

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

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

File diff suppressed because it is too large Load Diff

View File

@@ -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.

View File

@@ -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.

View File

@@ -4,6 +4,7 @@ package consumers
import (
"math"
"path/filepath"
"strconv"
"strings"
"time"
@@ -83,7 +84,7 @@ type ObjectBinding struct {
Options map[string]any `yaml:"options"`
}
// NotariusConfig demonstrates string versions and dynamic catalog options.
// NotariusConfig demonstrates string-backed versions and dynamic catalog options.
type NotariusConfig struct {
Version string `yaml:"version"`
Bindings []ObjectBinding `yaml:"bindings"`
@@ -93,7 +94,7 @@ type NotariusConfig struct {
func LoadNotarius(path string) (*configkit.Result[NotariusConfig], error) {
return configkit.Load(configkit.File(path), configkit.Options[NotariusConfig]{
Definition: configkit.Definition[NotariusConfig]{
Version: &configkit.VersionRule{Path: configkit.Keys("version"), Check: requireVersion("v1")},
Version: &configkit.VersionRule{Path: configkit.Keys("version"), Check: requireVersion("4")},
Validate: validateNotarius,
},
})
@@ -146,6 +147,69 @@ func NotariusWindow(configuration NotariusConfig, binding int) (int, bool) {
return int(value), true
}
// NotariusPathConfig demonstrates application-owned interpretation of a
// path-valued configuration field. ConfigKit supplies provenance; this example
// selects its own relative-path bases.
type NotariusPathConfig struct {
Reference string `yaml:"reference"`
Sensitive string `yaml:"sensitive"`
}
// LoadNotariusPaths loads the path-bearing Notarius-shaped example. InvocationDir
// is the application-supplied base for relative CLI overrides; it is never read
// from the process working directory.
func LoadNotariusPaths(path, invocationDir string, overrides ...configkit.Override) (*configkit.Result[NotariusPathConfig], error) {
return configkit.Load(configkit.File(path), configkit.Options[NotariusPathConfig]{
Imports: true, Overrides: overrides,
Definition: configkit.Definition[NotariusPathConfig]{
Defaults: func() NotariusPathConfig { return NotariusPathConfig{Reference: "defaults/reference.txt"} },
Resolve: func(configuration *NotariusPathConfig, resolution *configkit.Resolution) error {
resolveNotariusReference(configuration, resolution, invocationDir)
return nil
},
Secret: func(path configkit.Path) bool { return path.String() == "/sensitive" },
},
})
}
func resolveNotariusReference(configuration *NotariusPathConfig, resolution *configkit.Resolution, invocationDir string) {
path := configkit.Keys("reference")
if configuration.Reference == "" || filepath.IsAbs(configuration.Reference) {
return
}
info, found := resolution.InputInfo(path)
if !found {
resolution.Warning(path, "relative reference has no entry provenance")
return
}
base := ""
if info.Origin.Layer == configkit.LayerCLI {
base = invocationDir
if base == "" {
resolution.Warning(path, "relative CLI reference has no invocation directory")
return
}
} else {
sourceID := "source-1" // ConfigKit's primary-source identity convention.
if info.Origin.Layer != configkit.LayerDefault {
if len(info.Origin.Locations) != 1 {
resolution.Warning(path, "relative reference has no unambiguous declaring source")
return
}
sourceID = info.Origin.Locations[0].SourceID
}
source, exists := resolution.Source(sourceID)
if !exists || source.Path == "" {
resolution.Warning(path, "relative reference has no declaring file")
return
}
base = filepath.Dir(source.Path)
}
configkit.Normalize(resolution, path, &configuration.Reference,
filepath.Join(base, configuration.Reference), "resolved relative reference from entry source")
}
// ModuleOptions is a WeatherReporter-shaped union of known module option fields.
type ModuleOptions struct {
WindowUnits *int `yaml:"window_units"`

View File

@@ -26,7 +26,13 @@ func Example_notarius() {
result, err := consumers.LoadNotarius(fixture("notarius", "config.yml"))
window, ok := consumers.NotariusWindow(result.Config, 0)
fmt.Println(result.Config.Version, result.Config.Bindings[0].Object, window, ok, err)
// Output: v1 summary 12 true <nil>
// Output: 4 summary 12 true <nil>
}
func Example_notariusPaths() {
result, err := consumers.LoadNotariusPaths(fixture("notarius-paths", "main.yml"), "/invocation")
fmt.Println(filepath.Base(result.Config.Reference), err)
// Output: imported.txt <nil>
}
func Example_weatherReporter() {
@@ -100,6 +106,10 @@ func TestMaintainedConsumerFixturesPreserveDomainOutcomes(t *testing.T) {
if _, ok := notarius.Config.Bindings[0].Options["window_units"].(int64); !ok {
t.Fatalf("dynamic integer type = %T", notarius.Config.Bindings[0].Options["window_units"])
}
integerNotarius, err := consumers.LoadNotarius(fixture("notarius", "integer-version.yml"))
if err != nil || integerNotarius.Config.Version != notarius.Config.Version || integerNotarius.Config.Version != "4" {
t.Fatalf("Notarius version forms = %#v / %#v, %v", notarius.Config, integerNotarius, err)
}
weather, err := consumers.LoadWeather(configkit.File(fixture("weather", "config.yml")))
if err != nil || weather.Config.Reports["daily"].Modules[1].Options.Sections[0] != "short_term" {
@@ -124,8 +134,19 @@ func TestMigrationFixturesFailWithStableCodesAndPaths(t *testing.T) {
path string
}{
{
name: "integer schema version",
load: func() error { _, err := consumers.LoadNotarius(fixture("notarius", "integer-version.yml")); return err },
name: "unsupported application version",
load: func() error {
_, err := consumers.LoadNotarius(fixture("notarius", "unsupported-version.yml"))
return err
},
code: configkit.CodeVersionError, path: "/version",
},
{
name: "non-integer numeric version",
load: func() error {
_, err := consumers.LoadNotarius(fixture("notarius", "noninteger-version.yml"))
return err
},
code: configkit.CodeVersionType, path: "/version",
},
{
@@ -225,12 +246,89 @@ func TestWeatherLoadedAndConstructedRulesAgreeWithoutMutation(t *testing.T) {
}
}
func TestNotariusPathExampleUsesEntryProvenance(t *testing.T) {
main := fixture("notarius-paths", "main.yml")
declared := fixture("notarius-paths", "declarations", "path.yml")
declaredCanonical := canonicalFixture(t, declared)
wantImported := filepath.Join(filepath.Dir(declaredCanonical), "..", "assets", "imported.txt")
result, err := consumers.LoadNotariusPaths(main, filepath.Join(t.TempDir(), "invocation"))
if err != nil || result.Config.Reference != wantImported {
t.Fatalf("imported reference = %#v, %v", result, err)
}
var effective bytes.Buffer
if err := result.WriteEffective(&effective); err != nil || !strings.Contains(effective.String(), wantImported) ||
strings.Contains(effective.String(), "PATH_REFERENCE_SECRET_CANARY") {
t.Fatalf("effective output = %q, %v", effective.String(), err)
}
explanation, found := result.Explain(configkit.Keys("reference"))
if !found || len(explanation.History) == 0 || explanation.History[len(explanation.History)-1].Before.YAML != "../assets/imported.txt\n" ||
len(explanation.History[len(explanation.History)-1].Reasons) != 1 || explanation.History[len(explanation.History)-1].Reasons[0] != "resolved relative reference from entry source" {
t.Fatalf("reference explanation = %#v, %v", explanation, found)
}
sensitive, found := result.Explain(configkit.Keys("sensitive"))
var rendered bytes.Buffer
writeErr := result.WriteExplanation(&rendered, configkit.Keys("sensitive"))
if !found || !sensitive.Effective.Redacted || writeErr != nil ||
strings.Contains(fmt.Sprintf("%#v %s", sensitive, rendered.String()), "PATH_REFERENCE_SECRET_CANARY") {
t.Fatalf("sensitive inspection = %#v, %q, %v", sensitive, rendered.String(), writeErr)
}
defaults := fixture("notarius-paths", "defaults.yml")
defaultCanonical := canonicalFixture(t, defaults)
defaultResult, err := consumers.LoadNotariusPaths(defaults, filepath.Join(t.TempDir(), "invocation"))
wantDefault := filepath.Join(filepath.Dir(defaultCanonical), "defaults", "reference.txt")
if err != nil || defaultResult.Config.Reference != wantDefault {
t.Fatalf("default reference = %#v, %v", defaultResult, err)
}
invocation := filepath.Join(t.TempDir(), "caller")
cliResult, err := consumers.LoadNotariusPaths(main, invocation,
configkit.Override{Path: configkit.Keys("reference"), Value: "cli/reference.txt", Flag: "--reference"})
wantCLI := filepath.Join(invocation, "cli", "reference.txt")
if err != nil || cliResult.Config.Reference != wantCLI {
t.Fatalf("CLI reference = %#v, %v", cliResult, err)
}
missingBase, err := consumers.LoadNotariusPaths(main, "",
configkit.Override{Path: configkit.Keys("reference"), Value: "cli/reference.txt", Flag: "--reference"})
if err != nil || missingBase.Config.Reference != "cli/reference.txt" || len(missingBase.Diagnostics()) != 1 ||
missingBase.Diagnostics()[0].Code != configkit.CodeResolveWarning {
t.Fatalf("missing CLI base = %#v, %v", missingBase, err)
}
absolute := filepath.Join(t.TempDir(), "absolute.txt")
absoluteResult, err := consumers.LoadNotariusPaths(main, invocation,
configkit.Override{Path: configkit.Keys("reference"), Value: absolute, Flag: "--reference"})
if err != nil || absoluteResult.Config.Reference != absolute {
t.Fatalf("absolute reference = %#v, %v", absoluteResult, err)
}
target := fixture("notarius-paths", "symlink-target.yml")
link := filepath.Join(t.TempDir(), "linked-config.yml")
if err := os.Symlink(target, link); err != nil {
t.Skipf("symlink fixture unavailable: %v", err)
}
symlinkResult, err := consumers.LoadNotariusPaths(link, invocation)
targetCanonical := canonicalFixture(t, target)
wantSymlink := filepath.Join(filepath.Dir(targetCanonical), "..", "assets", "target.txt")
if err != nil || symlinkResult.Config.Reference != wantSymlink {
t.Fatalf("symlink reference = %#v, %v", symlinkResult, err)
}
}
func fixture(parts ...string) string {
_, file, _, _ := runtime.Caller(0)
values := append([]string{filepath.Dir(file), "testdata"}, parts...)
return filepath.Join(values...)
}
func canonicalFixture(t *testing.T, path string) string {
t.Helper()
canonical, err := filepath.EvalSymlinks(path)
if err != nil {
t.Fatal(err)
}
return canonical
}
func preserveEnvironment(name string) func() {
value, present := os.LookupEnv(name)
return func() {

View File

@@ -1,4 +1,4 @@
_configkit:
configkit:
imports:
- path: artifacts.yml
poll_interval: 1m

View File

@@ -0,0 +1 @@
reference: ../assets/imported.txt

View File

@@ -0,0 +1 @@
sensitive: PATH_REFERENCE_SECRET_CANARY

View File

@@ -0,0 +1,4 @@
configkit:
imports:
- path: declarations/path.yml
sensitive: PATH_REFERENCE_SECRET_CANARY

View File

@@ -0,0 +1,2 @@
reference: ../assets/target.txt
sensitive: PATH_REFERENCE_SECRET_CANARY

View File

@@ -1,4 +1,4 @@
version: v1
version: "4"
bindings:
- object: summary
options:

View File

@@ -1,2 +1,6 @@
version: 1
bindings: []
version: 4
bindings:
- object: summary
options:
window_units: 12
format: markdown

View File

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

View File

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

View File

@@ -1,507 +0,0 @@
package configkit
import (
"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.
// Present distinguishes absence or deletion from an explicit null value.
type ValueSnapshot struct {
Present bool
Redacted bool
YAML string
}
// ValueChange describes one explicit definition or audited domain operation.
type ValueChange struct {
Before ValueSnapshot
After ValueSnapshot
Origin Origin
Reasons []string
Dependencies []Path
}
// SecretReferenceInfo describes a successfully resolved effective reference.
// Name is the environment-variable name, never its value.
type SecretReferenceInfo struct {
Name string
}
// Explanation is a detached safe account of one effective or historical path.
type Explanation struct {
Path Path
Effective ValueSnapshot
Origin Origin
History []ValueChange
SecretReference *SecretReferenceInfo
}
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
// historical path. Unknown and malformed paths return false.
func (r *Result[T]) Explain(path Path) (Explanation, bool) {
if r == nil || r.final == nil || r.baseline == nil || !path.valid() {
return Explanation{}, false
}
explanation := Explanation{Path: path.clone(), Effective: valueSnapshot(r.final, path, r.secrets)}
if info, ok := r.metadata.Lookup(path); ok {
explanation.Origin = cloneOrigin(info.Origin)
}
working := document.Clone(r.baseline)
if _, present := documentAtPath(working, path); present {
explanation.History = append(explanation.History, ValueChange{
After: valueSnapshot(working, path, r.secrets), Origin: Origin{Layer: LayerDefault},
})
}
for _, application := range r.applications {
operationPath, relevant := applicationOperationPath(application, path)
var before ValueSnapshot
if relevant {
before = valueSnapshot(working, path, r.secrets)
}
applyHistoryApplication(&working, application)
if !relevant {
continue
}
explanation.History = append(explanation.History, ValueChange{
Before: before,
After: valueSnapshot(working, path, r.secrets),
Origin: historyApplicationOrigin(application, operationPath),
})
}
working = document.Clone(r.input)
for _, snapshot := range r.resolution {
relevant := pathsIntersect(snapshot.change.Path, path)
var before ValueSnapshot
if relevant {
before = valueSnapshot(working, path, r.secrets)
}
replaceHistoryPath(&working, snapshot.change.Path, snapshot.after)
if !relevant {
continue
}
explanation.History = append(explanation.History, ValueChange{
Before: before,
After: valueSnapshot(working, path, r.secrets),
Origin: cloneOrigin(snapshot.change.Origin),
Reasons: append([]string(nil), snapshot.change.Reasons...),
Dependencies: clonePaths(snapshot.change.Dependencies),
})
}
if !explanation.Effective.Present && len(explanation.History) == 0 {
return Explanation{}, false
}
if name, ok := r.secretReferences[path.String()]; ok && !pathIsSecret(r.secrets, path) {
explanation.SecretReference = &SecretReferenceInfo{Name: name}
}
return explanation, true
}
func applicationOperationPath(application historyApplication, query Path) (Path, bool) {
if !application.path.valid() || application.value == nil {
return Path{}, false
}
if application.path.String() != "" {
if pathsIntersect(application.path, query) {
return application.path.clone(), true
}
return Path{}, false
}
if len(query.segments) == 0 {
return Path{}, true
}
for length := 1; length <= len(query.segments); length++ {
prefix := Path{segments: clonePathSegments(query.segments[:length], 0)}
node, present := documentAtPath(application.value, prefix)
if !present {
return Path{}, false
}
if length == len(query.segments) || node.Kind != document.Mapping {
return prefix, true
}
}
return Path{}, false
}
func historyApplicationOrigin(application historyApplication, operationPath Path) Origin {
if application.origin.Layer != "" {
return cloneOrigin(application.origin)
}
origin := Origin{Layer: application.layer}
relative, ok := relativeHistoryPath(application.path, operationPath)
if !ok {
return origin
}
if node, present := documentAtPath(application.value, relative); present {
origin.Locations = []Location{publicLocation(node.Location)}
}
return origin
}
func relativeHistoryPath(root, path Path) (Path, bool) {
if !pathContains(root, path) {
return Path{}, false
}
return Path{segments: clonePathSegments(path.segments[len(root.segments):], 0)}, true
}
func pathsIntersect(left, right Path) bool {
return pathContains(left, right) || pathContains(right, left)
}
func applyHistoryApplication(root **document.Node, application historyApplication) {
if application.path.String() == "" {
mergeHistoryDocument(root, application.value)
return
}
mergeHistoryAtPath(root, application.path, application.value)
}
func mergeHistoryDocument(target **document.Node, incoming *document.Node) {
if incoming == nil {
return
}
if *target == nil || (*target).Kind != document.Mapping || incoming.Kind != document.Mapping {
*target = document.Clone(incoming)
return
}
for _, entry := range incoming.Mapping {
position := documentEntryIndex((*target).Mapping, entry.Key)
if position < 0 {
cloned := entry
cloned.Value = document.Clone(entry.Value)
(*target).Mapping = append((*target).Mapping, cloned)
continue
}
mergeHistoryDocument(&(*target).Mapping[position].Value, entry.Value)
}
}
func mergeHistoryAtPath(root **document.Node, path Path, incoming *document.Node) {
if root == nil || *root == nil || len(path.segments) == 0 {
mergeHistoryDocument(root, incoming)
return
}
current := *root
for index, segment := range path.segments {
if current.Kind != document.Mapping || segment.kind == pathSegmentIndex {
return
}
position := documentEntryIndex(current.Mapping, segment.key)
if index == len(path.segments)-1 {
if position < 0 {
current.Mapping = append(current.Mapping, document.Entry{Key: segment.key, Value: document.Clone(incoming)})
} else {
mergeHistoryDocument(&current.Mapping[position].Value, incoming)
}
return
}
if position < 0 {
child := &document.Node{Kind: document.Mapping}
current.Mapping = append(current.Mapping, document.Entry{Key: segment.key, Value: child})
current = child
} else {
current = current.Mapping[position].Value
}
}
}
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 {
return
}
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.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')
}
}
}

272
facade_candidate_test.go Normal file
View File

@@ -0,0 +1,272 @@
package configkit_test
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
"os"
"reflect"
"strings"
"testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
var (
_ error = (*configkit.LoadError)(nil)
_ fmt.Formatter = configkit.Secret{}
_ fmt.Stringer = configkit.Path{}
_ json.Marshaler = configkit.Secret{}
)
func TestFacadeValuesRemainRootOwned(t *testing.T) {
t.Parallel()
const packagePath = "gitea.maximumdirect.net/eric/ConfigKit"
for _, candidate := range []reflect.Type{
reflect.TypeFor[configkit.Path](), reflect.TypeFor[configkit.Source](),
reflect.TypeFor[configkit.Secret](), reflect.TypeFor[configkit.Diagnostic](),
reflect.TypeFor[configkit.LoadError](), reflect.TypeFor[configkit.Metadata](),
reflect.TypeFor[configkit.Resolution](), reflect.TypeFor[configkit.Validation](),
reflect.TypeFor[configkit.Version](), reflect.TypeFor[configkit.VersionRule](),
reflect.TypeFor[configkit.FilesystemContext](), reflect.TypeFor[configkit.Override](),
reflect.TypeFor[configkit.Definition[struct{}]](), reflect.TypeFor[configkit.Options[struct{}]](),
reflect.TypeFor[configkit.Result[struct{}]](),
reflect.TypeFor[configkit.ValueSnapshot](), reflect.TypeFor[configkit.ValueChange](),
reflect.TypeFor[configkit.SecretReferenceInfo](), reflect.TypeFor[configkit.Explanation](),
reflect.TypeFor[configkit.SecretFileResult](), reflect.TypeFor[configkit.SecretsReport](),
reflect.TypeFor[configkit.SecretsError](),
} {
if candidate.PkgPath() != packagePath {
t.Fatalf("type %s belongs to %q", candidate, candidate.PkgPath())
}
}
secret := configkit.SecretEnv("SAFE_NAME")
formatted := fmt.Sprintf("%#v", secret)
encoded, err := json.Marshal(secret)
if formatted == "" || err != nil || string(encoded) != `"SAFE_NAME"` {
t.Fatalf("secret rendering = %q, %s, %v", formatted, encoded, err)
}
if got := configkit.Keys("a/b~").Index(0).String(); got != "/a~1b~0/0" {
t.Fatalf("path = %q", got)
}
if got := (&configkit.LoadError{}).Error(); got != "configkit: configuration load failed" {
t.Fatalf("zero LoadError = %q", got)
}
}
func TestFacadeInspectionUsesFrozenState(t *testing.T) {
t.Setenv("FACADE_INSPECTION_SECRET", "credential")
type configuration struct {
Value string `yaml:"value"`
Secret configkit.Secret `yaml:"secret"`
}
result, err := configkit.Load[configuration](configkit.Bytes("inspection.yml", []byte("value: original\nsecret: FACADE_INSPECTION_SECRET\n")))
if err != nil {
t.Fatal(err)
}
if !result.Config.Secret.IsResolved() || result.Config.Secret.Value() != "credential" {
t.Fatalf("secret = %#v", result.Config.Secret)
}
explanation, ok := result.Explain(configkit.Keys("value"))
if !ok || explanation.Effective.YAML != "original\n" {
t.Fatalf("explanation = %#v, %v", explanation, ok)
}
explanation.Effective.YAML = "mutated\n"
result.Config.Value = "mutated"
var effective bytes.Buffer
if err := result.WriteEffective(&effective); err != nil || !strings.Contains(effective.String(), "value: original\n") || strings.Contains(effective.String(), "credential") {
t.Fatalf("effective = %q, %v", effective.String(), err)
}
again, ok := result.Explain(configkit.Keys("value"))
if !ok || again.Effective.YAML != "original\n" {
t.Fatalf("inspection changed = %#v, %v", again, ok)
}
secret, ok := result.Explain(configkit.Keys("secret"))
if !ok || secret.SecretReference == nil || secret.SecretReference.Name != "FACADE_INSPECTION_SECRET" {
t.Fatalf("secret explanation = %#v, %v", secret, ok)
}
var explanationOutput bytes.Buffer
if err := result.WriteExplanation(&explanationOutput, configkit.Keys("value")); err != nil || !strings.Contains(explanationOutput.String(), `path: "/value"`) {
t.Fatalf("explanation output = %q, %v", explanationOutput.String(), err)
}
unknown := bytes.NewBufferString("unchanged")
if err := result.WriteExplanation(unknown, configkit.Keys("unknown")); err == nil || unknown.String() != "unchanged" {
t.Fatalf("unknown explanation = %q, %v", unknown.String(), err)
}
var nilResult *configkit.Result[configuration]
if _, ok := nilResult.Explain(configkit.Keys("value")); ok {
t.Fatal("nil result explained a path")
}
if err := nilResult.WriteEffective(io.Discard); err == nil {
t.Fatal("nil result rendered effective configuration")
}
}
func TestFacadeSecretsBootstrapUsesFacadeRecords(t *testing.T) {
const (
loadedName = "FACADE_BOOTSTRAP_LOADED"
existingName = "FACADE_BOOTSTRAP_EXISTING"
)
restore := func(name string) {
value, present := os.LookupEnv(name)
t.Cleanup(func() {
if present {
_ = os.Setenv(name, value)
} else {
_ = os.Unsetenv(name)
}
})
}
restore(loadedName)
restore(existingName)
if err := os.Unsetenv(loadedName); err != nil {
t.Fatal(err)
}
if err := os.Setenv(existingName, ""); err != nil {
t.Fatal(err)
}
directory := t.TempDir()
if err := os.WriteFile(directory+"/"+loadedName, []byte("bootstrap\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(directory+"/"+existingName, []byte("ignored\n"), 0o600); err != nil {
t.Fatal(err)
}
report, err := configkit.LoadSecretsDir(directory)
if err != nil {
t.Fatal(err)
}
entries := report.Entries()
if len(entries) != 2 || os.Getenv(loadedName) != "bootstrap" {
t.Fatalf("entries/environment = %#v / %q", entries, os.Getenv(loadedName))
}
status := map[string]configkit.SecretFileResult{}
for _, entry := range entries {
status[entry.Name] = entry
}
if status[loadedName].Status != configkit.SecretFileLoaded || status[existingName].Status != configkit.SecretFileSkippedExisting || !status[existingName].ExistingEmpty {
t.Fatalf("statuses = %#v", status)
}
entries[0].Name = "mutated"
if report.Entries()[0].Name == "mutated" {
t.Fatal("report entries share mutable state")
}
var rendered bytes.Buffer
if _, err := report.WriteTo(&rendered); err != nil || strings.Contains(rendered.String(), "bootstrap") || strings.Contains(rendered.String(), "ignored") {
t.Fatalf("report rendering = %q, %v", rendered.String(), err)
}
failedReport, err := configkit.LoadSecretsDir("")
var secretsError *configkit.SecretsError
if failedReport == nil || !errors.As(err, &secretsError) || len(secretsError.Diagnostics()) != 1 || secretsError.Diagnostics()[0].Code != configkit.CodeSecretsInvalidPath {
t.Fatalf("bootstrap failure = %#v / %#v", failedReport, err)
}
}
func TestFacadeLoadBridgesLifecycleCallbacks(t *testing.T) {
t.Setenv("FACADE_SECRET_NAME", "credential")
type configuration struct {
Version string `yaml:"version"`
Input int `yaml:"input"`
Derived int `yaml:"derived"`
Secret configkit.Secret `yaml:"secret"`
Labels map[string]string `yaml:"labels"`
}
var retainedResolution *configkit.Resolution
var retainedValidation *configkit.Validation
versionCalls, defaultsCalls, resolveCalls, validateCalls := 0, 0, 0, 0
definition := configkit.Definition[configuration]{
Version: &configkit.VersionRule{Path: configkit.Keys("version"), Check: func(version configkit.Version, validation *configkit.Validation) error {
versionCalls++
retainedValidation = validation
if !version.Present || version.Text != "1" {
validation.Error(configkit.Keys("version"), "unsupported version")
}
return nil
}},
Defaults: func() configuration {
defaultsCalls++
return configuration{Derived: 1, Labels: map[string]string{"default": "kept"}}
},
Resolve: func(configuration *configuration, resolution *configkit.Resolution) error {
resolveCalls++
retainedResolution = resolution
if !resolution.IsExplicit(configkit.Keys("input")) {
resolution.Error(configkit.Keys("input"), "input is required")
}
input, ok := resolution.InputInfo(configkit.Keys("input"))
if !ok || !input.Explicit {
resolution.Error(configkit.Keys("input"), "missing input provenance")
}
source, ok := resolution.Source("source-1")
if !ok || source.Label != "facade.yml" {
resolution.Error(configkit.Keys("input"), "missing source")
}
configkit.Default(resolution, configkit.Keys("derived"), &configuration.Derived, configuration.Input*2, "derived from input", configkit.Keys("input"))
return nil
},
Validate: func(configuration *configuration, validation *configkit.Validation) {
validateCalls++
retainedValidation = validation
if !configuration.Secret.IsResolved() || configuration.Secret.Value() != "credential" {
validation.Error(configkit.Keys("secret"), "secret did not resolve")
}
},
}
if versionCalls != 0 || defaultsCalls != 0 || resolveCalls != 0 || validateCalls != 0 {
t.Fatal("callbacks ran while constructing options")
}
result, err := configkit.Load(configkit.Bytes("facade.yml", []byte("version: 1\ninput: 3\nsecret: FACADE_SECRET_NAME\n")), configkit.Options[configuration]{Definition: definition})
if err != nil {
t.Fatal(err)
}
if versionCalls != 1 || defaultsCalls != 1 || resolveCalls != 1 || validateCalls != 1 || result.Config.Derived != 6 {
t.Fatalf("callback counts/configuration = %d/%d/%d/%d %#v", versionCalls, defaultsCalls, resolveCalls, validateCalls, result.Config)
}
derived, ok := result.Metadata().Lookup(configkit.Keys("derived"))
if !ok || derived.Explicit || derived.Origin.Operation != configkit.ChangeDerivedDefault {
t.Fatalf("derived metadata = %#v, %v", derived, ok)
}
if sources := result.Sources(); len(sources) != 1 || sources[0].Label != "facade.yml" {
t.Fatalf("sources = %#v", sources)
}
if result.Diagnostics() != nil {
t.Fatalf("diagnostics = %#v", result.Diagnostics())
}
for _, context := range []func(){
func() { retainedResolution.IsExplicit(configkit.Keys("input")) },
func() { retainedValidation.Warning(configkit.Keys("input"), "too late") },
} {
func() {
defer func() {
if recover() == nil {
t.Fatal("retained callback context remained active")
}
}()
context()
}()
}
}
func TestFacadeLoadReturnsFacadeLoadError(t *testing.T) {
t.Parallel()
type configuration struct {
Value int `yaml:"value"`
}
result, err := configkit.Load[configuration](configkit.Bytes("invalid.yml", []byte("value: wrong\n")))
if result != nil || err == nil {
t.Fatalf("result, err = %#v, %v", result, err)
}
var loadError *configkit.LoadError
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != configkit.CodeDecodeType {
t.Fatalf("facade error = %#v", err)
}
}

View File

@@ -0,0 +1,85 @@
package configkit_test
import (
"encoding/json"
"errors"
"fmt"
"io"
"reflect"
"testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
var (
_ error = (*configkit.LoadError)(nil)
_ fmt.Formatter = configkit.Secret{}
_ fmt.Stringer = configkit.Path{}
_ json.Marshaler = configkit.Secret{}
_ io.WriterTo = (*configkit.SecretsReport)(nil)
)
type facadeCompatibilityConfig struct {
Value string `yaml:"value"`
Derived string `yaml:"derived"`
}
func TestFacadeCompatibility(t *testing.T) {
t.Parallel()
const packagePath = "gitea.maximumdirect.net/eric/ConfigKit"
for _, candidate := range []struct {
name string
type_ reflect.Type
}{
{"Path", reflect.TypeFor[configkit.Path]()},
{"Source", reflect.TypeFor[configkit.Source]()},
{"Secret", reflect.TypeFor[configkit.Secret]()},
{"Definition", reflect.TypeFor[configkit.Definition[facadeCompatibilityConfig]]()},
{"Options", reflect.TypeFor[configkit.Options[facadeCompatibilityConfig]]()},
{"Result", reflect.TypeFor[configkit.Result[facadeCompatibilityConfig]]()},
} {
if candidate.type_.PkgPath() != packagePath {
t.Errorf("%s belongs to %q, want %q", candidate.name, candidate.type_.PkgPath(), packagePath)
}
}
constructed := configkit.Options[facadeCompatibilityConfig]{
Definition: configkit.Definition[facadeCompatibilityConfig]{},
Filesystem: &configkit.FilesystemContext{BaseDir: "configs", Root: "configs"},
Overrides: []configkit.Override{{
Path: configkit.Keys("value"),
Value: "override",
Flag: "--value",
}},
}
if got := constructed.Overrides[0].Path.String(); got != "/value" {
t.Fatalf("constructed override path = %q, want /value", got)
}
publicResult := configkit.Result[facadeCompatibilityConfig]{Config: facadeCompatibilityConfig{Value: "constructed"}}
if publicResult.Config.Value != "constructed" {
t.Fatal("constructed result lost its public configuration")
}
result, err := configkit.Load(configkit.Bytes("facade.yml", []byte("value: input\n")), configkit.Options[facadeCompatibilityConfig]{
Definition: configkit.Definition[facadeCompatibilityConfig]{
Resolve: func(configuration *facadeCompatibilityConfig, resolution *configkit.Resolution) error {
configkit.Default(resolution, configkit.Keys("derived"), &configuration.Derived, configuration.Value+" default", "derive value")
configkit.Normalize(resolution, configkit.Keys("value"), &configuration.Value, "normalized "+configuration.Value, "normalize value")
return nil
},
},
})
if err != nil {
t.Fatalf("Load() error = %v", err)
}
if result.Config != (facadeCompatibilityConfig{Value: "normalized input", Derived: "input default"}) {
t.Fatalf("Load() Config = %#v", result.Config)
}
_, err = configkit.Load[facadeCompatibilityConfig](configkit.Bytes("invalid.yml", []byte("value: [\n")))
var loadError *configkit.LoadError
if !errors.As(err, &loadError) {
t.Fatalf("Load() error = %T, want *configkit.LoadError", err)
}
}

51
facade_context_test.go Normal file
View File

@@ -0,0 +1,51 @@
package configkit_test
import (
"testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
func TestFacadeInactiveContextsPreserveClosedFailure(t *testing.T) {
t.Parallel()
for _, state := range []struct {
name string
resolution *configkit.Resolution
validation *configkit.Validation
}{
{name: "nil"},
{name: "zero", resolution: &configkit.Resolution{}, validation: &configkit.Validation{}},
} {
t.Run(state.name, func(t *testing.T) {
path := configkit.Keys("value")
var target int
for _, operation := range []struct {
name string
call func()
}{
{"IsExplicit", func() { state.resolution.IsExplicit(path) }},
{"InputInfo", func() { state.resolution.InputInfo(path) }},
{"Source", func() { state.resolution.Source("source") }},
{"Record", func() { state.resolution.Record(path, configkit.ChangeNormalization, "reason") }},
{"Resolution.Error", func() { state.resolution.Error(path, "message") }},
{"Resolution.Warning", func() { state.resolution.Warning(path, "message") }},
{"Default", func() { configkit.Default(state.resolution, path, &target, 1, "reason") }},
{"Normalize", func() { configkit.Normalize(state.resolution, path, &target, 1, "reason") }},
{"Validation.Error", func() { state.validation.Error(path, "message") }},
{"Validation.Warning", func() { state.validation.Warning(path, "message") }},
} {
t.Run(operation.name, func(t *testing.T) {
defer func() {
if got := recover(); got != "configkit: callback context is no longer active" {
t.Errorf("panic = %v; want fixed closed-context failure", got)
}
if target != 0 {
t.Error("inactive helper changed target")
}
}()
operation.call()
})
}
})
}
}

52
facade_internal_test.go Normal file
View File

@@ -0,0 +1,52 @@
package configkit
import (
"errors"
"reflect"
"strings"
"testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
)
func TestWrappedSourcesPreserveEngineOwnership(t *testing.T) {
t.Parallel()
type config struct {
Value string `yaml:"value"`
}
data := []byte("value: retained\n")
source := Bytes("snapshot.yml", data)
data[7] = 'X'
result, err := engine.Load[config](source.toEngine(), engine.SecretBinding{})
if err != nil || result.Config.Value != "retained" {
t.Fatalf("snapshotted source = %#v, %v", result, err)
}
readerSource := Reader("reader.yml", strings.NewReader("value: once\n"))
first := readerSource
if _, err := engine.Load[config](first.toEngine(), engine.SecretBinding{}); err != nil {
t.Fatal(err)
}
_, err = engine.Load[config](readerSource.toEngine(), engine.SecretBinding{})
var loadError *engine.LoadError
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != engine.CodeSourceReaderUsed {
t.Fatalf("copied reader source error = %#v", err)
}
}
func TestWrappedPathsRetainEngineSegmentIntent(t *testing.T) {
t.Parallel()
parsed, err := ParsePath("/0")
if err != nil {
t.Fatal(err)
}
keyed, indexed := Keys("0"), Path{}.Index(0)
if parsed.String() != "/0" || keyed.String() != "/0" || indexed.String() != "/0" {
t.Fatalf("path spellings = %q, %q, %q", parsed, keyed, indexed)
}
if reflect.DeepEqual(parsed.value, keyed.value) || reflect.DeepEqual(parsed.value, indexed.value) || reflect.DeepEqual(keyed.value, indexed.value) {
t.Fatal("path wrappers collapsed parsed, mapping-key, and index segments")
}
}

66
facade_version_test.go Normal file
View File

@@ -0,0 +1,66 @@
package configkit_test
import (
"errors"
"strings"
"testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
type versionCountingReader struct {
*strings.Reader
reads int
}
func (r *versionCountingReader) Read(data []byte) (int, error) {
r.reads++
return r.Reader.Read(data)
}
func TestFacadeRejectsMissingVersionCheckerBeforeReading(t *testing.T) {
t.Parallel()
type configuration struct {
Version string `yaml:"version"`
}
reader := &versionCountingReader{Reader: strings.NewReader("version: v1\n")}
source := configkit.Reader("version.yml", reader)
var defaults, resolutions, validations, checks int
options := configkit.Options[configuration]{Definition: configkit.Definition[configuration]{
Version: &configkit.VersionRule{Path: configkit.Keys("version")},
Defaults: func() configuration { defaults++; return configuration{} },
Resolve: func(*configuration, *configkit.Resolution) error { resolutions++; return nil },
Validate: func(*configuration, *configkit.Validation) { validations++ },
}}
result, err := configkit.Load(source, options)
var loadError *configkit.LoadError
if result != nil || !errors.As(err, &loadError) {
t.Fatalf("missing checker: result = %v, error = %v", result, err)
}
diagnostics := loadError.Diagnostics()
if len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeOptionsInvalid {
t.Fatalf("diagnostics = %v", diagnostics)
}
if reader.reads != 0 || defaults != 0 || resolutions != 0 || validations != 0 {
t.Fatalf("invalid options performed work: reads=%d defaults=%d resolve=%d validate=%d", reader.reads, defaults, resolutions, validations)
}
options.Definition.Version.Check = func(version configkit.Version, validation *configkit.Validation) error {
checks++
if version != (configkit.Version{Present: true, Text: "v1"}) {
t.Errorf("version = %#v", version)
}
validation.Warning(configkit.Keys("version"), "version accepted")
return nil
}
result, err = configkit.Load(source, options)
if err != nil || result == nil || result.Config.Version != "v1" {
t.Fatalf("reuse with checker: result = %v, error = %v", result, err)
}
if checks != 1 || defaults != 1 || resolutions != 1 || validations != 1 {
t.Fatalf("callback counts: check=%d defaults=%d resolve=%d validate=%d", checks, defaults, resolutions, validations)
}
diagnostics = result.Diagnostics()
if len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeVersionWarning {
t.Fatalf("version callback diagnostics = %v", diagnostics)
}
}

View File

@@ -41,6 +41,15 @@ func TestDependencyImportBoundary(t *testing.T) {
if strings.HasPrefix(importPath, "github.com/google/go-cmp/") && !strings.HasSuffix(path, "_test.go") {
t.Errorf("%s imports test-only dependency %s", path, importPath)
}
production := !strings.HasSuffix(path, "_test.go")
rootProduction := production && filepath.Dir(path) == "."
internalProduction := production && strings.HasPrefix(filepath.ToSlash(path), "internal/")
if rootProduction && strings.HasPrefix(importPath, "gitea.maximumdirect.net/eric/ConfigKit/internal/") && importPath != "gitea.maximumdirect.net/eric/ConfigKit/internal/engine" {
t.Errorf("%s bypasses the engine boundary through %s", path, importPath)
}
if internalProduction && importPath == "gitea.maximumdirect.net/eric/ConfigKit" {
t.Errorf("%s imports the public root package", path)
}
}
return nil
})

View File

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

70
inspection.go Normal file
View File

@@ -0,0 +1,70 @@
package configkit
import (
"io"
"gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
)
// ValueSnapshot is a detached, deterministic YAML representation of one value.
// Present distinguishes absence or deletion from an explicit null value.
type ValueSnapshot struct {
Present bool
Redacted bool
YAML string
}
// ValueChange describes one explicit definition or audited domain operation.
type ValueChange struct {
Before ValueSnapshot
After ValueSnapshot
Origin Origin
Reasons []string
Dependencies []Path
}
// SecretReferenceInfo describes a successfully resolved effective reference.
// Name is the environment-variable name, never its value.
type SecretReferenceInfo struct {
Name string
}
// Explanation is a detached safe account of one effective or historical path.
type Explanation struct {
Path Path
Effective ValueSnapshot
Origin Origin
History []ValueChange
SecretReference *SecretReferenceInfo
}
// Explain returns safe effective and chronological values for a known final or
// historical path. Unknown and malformed paths return false.
func (r *Result[T]) Explain(path Path) (Explanation, bool) {
if r == nil {
return Explanation{}, false
}
value, ok := r.inspection.Explain(toEnginePath(path))
return fromEngineExplanation(value), ok
}
// WriteEffective writes deterministic redacted YAML from the frozen resolved
// snapshot. The output is intended for inspection and is not promised reloadable.
// Writer errors, including short writes, are returned and may leave partial output.
func (r *Result[T]) WriteEffective(output io.Writer) error {
if r == nil {
var inspection *engine.Inspection
return inspection.WriteEffective(output)
}
return r.inspection.WriteEffective(output)
}
// 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 {
var inspection *engine.Inspection
return inspection.WriteExplanation(output, toEnginePath(path))
}
return r.inspection.WriteExplanation(output, toEnginePath(path))
}

View File

@@ -32,11 +32,14 @@ type Entry struct {
// Node is ConfigKit's owned configuration representation. Number Text retains its
// exact validated decimal spelling; Boolean Text is "true" or "false".
type Node struct {
Kind Kind
Location Location
Text string
Mapping []Entry
Sequence []*Node
// Sensitive preserves field classification from concrete typed projections.
// It is metadata, not part of the configuration value or emitted YAML.
Sensitive bool
Kind Kind
Location Location
Text string
Mapping []Entry
Sequence []*Node
}
// Clone returns a detached copy of a node tree.

View File

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

159
internal/engine/clone.go Normal file
View File

@@ -0,0 +1,159 @@
package engine
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 := o.schema.secrets.Read(value)
if o.cloneMode == cloneValidatedSecrets {
result.Set(value)
} else if reference.Specified {
result.Set(o.schema.secrets.Make(SecretState{Name: reference.Name, Specified: true}))
}
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
}
}

View File

@@ -1,4 +1,4 @@
package configkit
package engine
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
@@ -15,6 +15,17 @@ type composedDocument struct {
inputNodes int
}
// cloneComposedDocument detaches mutable composition and provenance state;
// history application trees are immutable and retain their existing sharing.
func cloneComposedDocument(base *composedDocument) *composedDocument {
return &composedDocument{
root: document.Clone(base.root), claims: cloneCompositionClaims(base.claims),
origins: cloneOriginIndex(base.origins), overlays: cloneOverlayRecords(base.overlays),
applications: cloneHistoryApplications(base.applications),
inputBytes: base.inputBytes, inputNodes: base.inputNodes,
}
}
type compositionClaim struct {
path Path
kind document.Kind

View File

@@ -1,4 +1,4 @@
package configkit
package engine
import (
"reflect"
@@ -56,7 +56,7 @@ values: [one, two]
if len(reversedDiagnostics) != 0 {
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)
}

View File

@@ -1,4 +1,4 @@
package configkit
package engine
import (
"bytes"
@@ -40,7 +40,7 @@ func FuzzCompositionAndOverlay(f *testing.F) {
if len(reversedDiagnostics) != 0 {
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)
}
composed.root.Mapping = append(composed.root.Mapping, document.Entry{Key: "fuzz-detachment"})

View File

@@ -1,4 +1,4 @@
package configkit
package engine
import (
"encoding"
@@ -34,7 +34,15 @@ type decodedConfiguration struct {
final *document.Node
}
type decodeContext struct {
versionPath *Path
}
func decodeConfiguration(schema *schemaDescriptor, defaults reflect.Value, input *document.Node) (decodedConfiguration, []decodeIssue) {
return decodeConfigurationWithContext(schema, defaults, input, decodeContext{})
}
func decodeConfigurationWithContext(schema *schemaDescriptor, defaults reflect.Value, input *document.Node, context decodeContext) (decodedConfiguration, []decodeIssue) {
if schema == nil || schema.root == nil || !defaults.IsValid() || defaults.Type() != schema.root.typeOf {
return decodedConfiguration{}, []decodeIssue{{code: CodeSchemaUnsupportedType, message: "configuration defaults do not match the schema"}}
}
@@ -50,7 +58,7 @@ func decodeConfiguration(schema *schemaDescriptor, defaults reflect.Value, input
presence := make(map[string]inputPresence)
if input != nil {
collectPresence(input, Path{}, presence)
decoded, issues := decodeValue(input, working, schema.root, Path{}, schema)
decoded, issues := decodeValue(input, working, schema.root, Path{}, schema, context)
if len(issues) != 0 {
return decodedConfiguration{}, issues
}
@@ -71,7 +79,7 @@ func decodeConfiguration(schema *schemaDescriptor, defaults reflect.Value, input
}, nil
}
func decodeValue(node *document.Node, base reflect.Value, descriptor *typeDescriptor, path Path, schema *schemaDescriptor) (reflect.Value, []decodeIssue) {
func decodeValue(node *document.Node, base reflect.Value, descriptor *typeDescriptor, path Path, schema *schemaDescriptor, context decodeContext) (reflect.Value, []decodeIssue) {
if node.Kind == document.Null {
switch descriptor.kind {
case descriptorPointer, descriptorMap, descriptorSlice, descriptorDynamic:
@@ -89,7 +97,7 @@ func decodeValue(node *document.Node, base reflect.Value, descriptor *typeDescri
} else {
elementBase = base.Elem()
}
decoded, issues := decodeValue(node, elementBase, descriptor.element, path, schema)
decoded, issues := decodeValue(node, elementBase, descriptor.element, path, schema, context)
if len(issues) != 0 {
return reflect.Value{}, issues
}
@@ -100,17 +108,17 @@ func decodeValue(node *document.Node, base reflect.Value, descriptor *typeDescri
switch descriptor.kind {
case descriptorStruct:
return decodeStruct(node, base, descriptor, path, schema)
return decodeStruct(node, base, descriptor, path, schema, context)
case descriptorMap:
return decodeMap(node, base, descriptor, path, schema)
return decodeMap(node, base, descriptor, path, schema, context)
case descriptorSlice:
return decodeSlice(node, descriptor, path, schema)
return decodeSlice(node, descriptor, path, schema, context)
case descriptorArray:
return decodeArray(node, descriptor, path, schema)
return decodeArray(node, descriptor, path, schema, context)
case descriptorDynamic:
return decodeDynamic(node, base, path, schema)
return decodeDynamic(node, base, path, schema, context)
case descriptorString:
if node.Kind != document.String {
if node.Kind != document.String && !(pathMatches(context.versionPath, path) && isIntegerVersion(node)) {
return typeMismatch(path, node, "string")
}
result := reflect.New(descriptor.typeOf).Elem()
@@ -171,7 +179,7 @@ func decodeValue(node *document.Node, base reflect.Value, descriptor *typeDescri
if node.Kind != document.String {
return typeMismatch(path, node, "secret environment name")
}
return reflect.ValueOf(SecretEnv(node.Text)), nil
return schema.secrets.Make(SecretState{Name: node.Text, Specified: true}), nil
case descriptorText:
if node.Kind != document.String {
return typeMismatch(path, node, "text string")
@@ -187,7 +195,7 @@ func decodeValue(node *document.Node, base reflect.Value, descriptor *typeDescri
}
}
func decodeStruct(node *document.Node, base reflect.Value, descriptor *typeDescriptor, path Path, schema *schemaDescriptor) (reflect.Value, []decodeIssue) {
func decodeStruct(node *document.Node, base reflect.Value, descriptor *typeDescriptor, path Path, schema *schemaDescriptor, context decodeContext) (reflect.Value, []decodeIssue) {
if node.Kind != document.Mapping {
return typeMismatch(path, node, "mapping")
}
@@ -203,7 +211,7 @@ func decodeStruct(node *document.Node, base reflect.Value, descriptor *typeDescr
issues = append(issues, decodeIssue{code: CodeSchemaUnsupportedType, path: childPath, message: "configuration field could not be addressed", location: entry.ValueLocation})
continue
}
decoded, fieldIssues := decodeValue(entry.Value, target, field.descriptor, childPath, schema)
decoded, fieldIssues := decodeValue(entry.Value, target, field.descriptor, childPath, schema, context)
if len(fieldIssues) != 0 {
issues = append(issues, fieldIssues...)
continue
@@ -212,7 +220,7 @@ func decodeStruct(node *document.Node, base reflect.Value, descriptor *typeDescr
continue
}
if descriptor.inlineMap != nil {
if issue := decodeInlineMapEntry(result, *descriptor.inlineMap, entry, childPath, schema); issue != nil {
if issue := decodeInlineMapEntry(result, *descriptor.inlineMap, entry, childPath, schema, context); issue != nil {
issues = append(issues, issue...)
}
continue
@@ -222,7 +230,7 @@ func decodeStruct(node *document.Node, base reflect.Value, descriptor *typeDescr
return result, issues
}
func decodeInlineMapEntry(target reflect.Value, field fieldDescriptor, entry document.Entry, path Path, schema *schemaDescriptor) []decodeIssue {
func decodeInlineMapEntry(target reflect.Value, field fieldDescriptor, entry document.Entry, path Path, schema *schemaDescriptor, context decodeContext) []decodeIssue {
mapValue, ok := fieldForDecode(target, field.index, true)
if !ok || mapValue.Kind() != reflect.Map {
return []decodeIssue{{code: CodeSchemaInline, path: path, message: "inline map could not be addressed", location: entry.KeyLocation}}
@@ -236,14 +244,14 @@ func decodeInlineMapEntry(target reflect.Value, field fieldDescriptor, entry doc
if !base.IsValid() {
base = reflect.Zero(mapValue.Type().Elem())
}
decoded, issues := decodeValue(entry.Value, base, field.descriptor.element, path, schema)
decoded, issues := decodeValue(entry.Value, base, field.descriptor.element, path, schema, context)
if len(issues) == 0 {
mapValue.SetMapIndex(key, decoded)
}
return issues
}
func decodeMap(node *document.Node, base reflect.Value, descriptor *typeDescriptor, path Path, schema *schemaDescriptor) (reflect.Value, []decodeIssue) {
func decodeMap(node *document.Node, base reflect.Value, descriptor *typeDescriptor, path Path, schema *schemaDescriptor, context decodeContext) (reflect.Value, []decodeIssue) {
if node.Kind != document.Mapping {
return typeMismatch(path, node, "mapping")
}
@@ -259,7 +267,7 @@ func decodeMap(node *document.Node, base reflect.Value, descriptor *typeDescript
if !entryBase.IsValid() {
entryBase = reflect.Zero(descriptor.typeOf.Elem())
}
decoded, entryIssues := decodeValue(entry.Value, entryBase, descriptor.element, path.Key(entry.Key), schema)
decoded, entryIssues := decodeValue(entry.Value, entryBase, descriptor.element, path.Key(entry.Key), schema, context)
if len(entryIssues) != 0 {
issues = append(issues, entryIssues...)
continue
@@ -269,14 +277,14 @@ func decodeMap(node *document.Node, base reflect.Value, descriptor *typeDescript
return result, issues
}
func decodeSlice(node *document.Node, descriptor *typeDescriptor, path Path, schema *schemaDescriptor) (reflect.Value, []decodeIssue) {
func decodeSlice(node *document.Node, descriptor *typeDescriptor, path Path, schema *schemaDescriptor, context decodeContext) (reflect.Value, []decodeIssue) {
if node.Kind != document.Sequence {
return typeMismatch(path, node, "sequence")
}
result := reflect.MakeSlice(descriptor.typeOf, len(node.Sequence), len(node.Sequence))
var issues []decodeIssue
for i, child := range node.Sequence {
decoded, childIssues := decodeValue(child, reflect.Zero(descriptor.typeOf.Elem()), descriptor.element, path.Index(i), schema)
decoded, childIssues := decodeValue(child, reflect.Zero(descriptor.typeOf.Elem()), descriptor.element, path.Index(i), schema, context)
if len(childIssues) != 0 {
issues = append(issues, childIssues...)
continue
@@ -286,7 +294,7 @@ func decodeSlice(node *document.Node, descriptor *typeDescriptor, path Path, sch
return result, issues
}
func decodeArray(node *document.Node, descriptor *typeDescriptor, path Path, schema *schemaDescriptor) (reflect.Value, []decodeIssue) {
func decodeArray(node *document.Node, descriptor *typeDescriptor, path Path, schema *schemaDescriptor, context decodeContext) (reflect.Value, []decodeIssue) {
if node.Kind != document.Sequence {
return typeMismatch(path, node, "sequence")
}
@@ -296,7 +304,7 @@ func decodeArray(node *document.Node, descriptor *typeDescriptor, path Path, sch
result := reflect.New(descriptor.typeOf).Elem()
var issues []decodeIssue
for i, child := range node.Sequence {
decoded, childIssues := decodeValue(child, reflect.Zero(descriptor.typeOf.Elem()), descriptor.element, path.Index(i), schema)
decoded, childIssues := decodeValue(child, reflect.Zero(descriptor.typeOf.Elem()), descriptor.element, path.Index(i), schema, context)
if len(childIssues) != 0 {
issues = append(issues, childIssues...)
continue
@@ -306,13 +314,13 @@ func decodeArray(node *document.Node, descriptor *typeDescriptor, path Path, sch
return result, issues
}
func decodeDynamic(node *document.Node, base reflect.Value, path Path, schema *schemaDescriptor) (reflect.Value, []decodeIssue) {
func decodeDynamic(node *document.Node, base reflect.Value, path Path, schema *schemaDescriptor, context decodeContext) (reflect.Value, []decodeIssue) {
if node.Kind == document.Mapping && base.IsValid() && !base.IsNil() {
concrete := base.Elem()
operation := valueOperation{schema: schema}
descriptor, issue := operation.dynamicDescriptor(concrete.Type(), path)
if issue == nil && acceptsMapping(descriptor) {
decoded, issues := decodeValue(node, concrete, descriptor, path, schema)
decoded, issues := decodeValue(node, concrete, descriptor, path, schema, context)
if len(issues) != 0 {
return reflect.Value{}, issues
}
@@ -347,7 +355,7 @@ func decodeDynamic(node *document.Node, base reflect.Value, path Path, schema *s
sequence := make([]any, len(node.Sequence))
var issues []decodeIssue
for i, child := range node.Sequence {
decoded, childIssues := decodeDynamic(child, reflect.Zero(reflect.TypeFor[any]()), path.Index(i), schema)
decoded, childIssues := decodeDynamic(child, reflect.Zero(reflect.TypeFor[any]()), path.Index(i), schema, context)
if len(childIssues) != 0 {
issues = append(issues, childIssues...)
continue
@@ -362,7 +370,7 @@ func decodeDynamic(node *document.Node, base reflect.Value, path Path, schema *s
mapping := make(map[string]any, len(node.Mapping))
var issues []decodeIssue
for _, entry := range node.Mapping {
decoded, childIssues := decodeDynamic(entry.Value, reflect.Zero(reflect.TypeFor[any]()), path.Key(entry.Key), schema)
decoded, childIssues := decodeDynamic(entry.Value, reflect.Zero(reflect.TypeFor[any]()), path.Key(entry.Key), schema, context)
if len(childIssues) != 0 {
issues = append(issues, childIssues...)
continue

View File

@@ -1,4 +1,4 @@
package configkit
package engine
import (
"errors"

View File

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

View File

@@ -1,4 +1,4 @@
package configkit
package engine
import (
"bytes"

View File

@@ -0,0 +1,229 @@
package engine
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
}

View File

@@ -0,0 +1,110 @@
package engine
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
}

View File

@@ -0,0 +1,86 @@
package engine
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
}

122
internal/engine/explain.go Normal file
View File

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

View File

@@ -0,0 +1,123 @@
package engine
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 *Inspection) 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')
}
}
}

View File

@@ -1,4 +1,4 @@
package configkit
package engine
import (
"bytes"
@@ -23,10 +23,10 @@ type explanationConcurrency struct {
func TestExplainShowsDefaultBaseAndEffectiveProfileValue(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "config.yml", "_configkit:\n imports: [{path: transcription.yml}]\n")
mainPath := writeImportFile(t, directory, "config.yml", "configkit:\n imports: [{path: transcription.yml}]\n")
writeImportFile(t, directory, "transcription.yml", "transcription:\n concurrency: 8\n")
writeImportFile(t, directory, filepath.Join("profiles", "testing.yml"), "transcription:\n concurrency: 1\n")
result, err := Load[explanationConcurrency](File(mainPath), Options[explanationConcurrency]{
result, err := testLoad[explanationConcurrency](File(mainPath), Options[explanationConcurrency]{
Imports: true, Profile: "testing",
Definition: Definition[explanationConcurrency]{Defaults: func() explanationConcurrency {
value := explanationConcurrency{}
@@ -84,7 +84,7 @@ items: [profile]
labels: {profile: retained}
dynamic: selected
`)
result, err := loadWithSecretLookup(File(mainPath), func(name string) (string, bool) {
result, err := testLoadWithSecretLookup(File(mainPath), func(name string) (string, bool) {
if name != "APP_SECRET" {
t.Fatalf("secret lookup = %q", name)
}
@@ -285,7 +285,7 @@ func TestSecretsBootstrapLoadAndExplanationRemainSeparateAndSafe(t *testing.T) {
type config struct {
Credential Secret `yaml:"credential"`
}
result, err := Load[config](Bytes("config.yml", []byte("credential: "+name+"\n")))
result, err := testLoad[config](Bytes("config.yml", []byte("credential: "+name+"\n")))
if err != nil {
t.Fatal(err)
}
@@ -326,3 +326,191 @@ func historyValueLayers(explanation Explanation) []Layer {
}
return layers
}
func TestExplanationReconcilesDefaultsAfterInputReplacement(t *testing.T) {
type cfg struct {
M map[string]int `yaml:"m"`
}
for _, base := range []string{"null", "replaced-text"} {
for _, cli := range []bool{false, true} {
t.Run(fmt.Sprintf("%s/cli=%v", base, cli), func(t *testing.T) {
root := t.TempDir()
main := writeImportFile(t, root, "main.yml", "m: "+base+"\n")
opts := Options[cfg]{Definition: Definition[cfg]{Defaults: func() cfg { return cfg{M: map[string]int{"a": 1}} }}}
if cli {
opts.Overrides = []Override{{Path: Keys("m"), Value: map[string]any{"b": 2}, Flag: "--m"}}
} else {
writeImportFile(t, root, "profiles/p.yml", "m: {b: 2}\n")
opts.Profile = "p"
}
result, err := testLoad(File(main), opts)
if err != nil {
t.Fatal(err)
}
for _, path := range []Path{Keys("m"), Keys("m", "a")} {
explanation, ok := result.Explain(path)
if !ok {
t.Fatal("missing explanation")
}
last := explanation.History[len(explanation.History)-1]
if last.After != explanation.Effective || last.Origin.Operation != ChangeDecoding {
t.Fatalf("history does not reconcile final value: %#v", explanation)
}
info, _ := result.Metadata().Lookup(path)
if last.Origin.Layer != info.Origin.Layer {
t.Fatal("decoding lost effective origin")
}
}
})
}
}
}
func 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 := testLoad(Bytes("version.yml", []byte("version: 4\n")), options)
if err != nil {
t.Fatal(err)
}
second, err := testLoad(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 := testLoadWithSecretLookup(Bytes("version.yml", []byte("version: 987654321\n")), func(string) (string, bool) {
lookups++
return "CREDENTIAL_CANARY", true
}, Options[config]{Definition: Definition[config]{
Version: &VersionRule{Path: Keys("version"), Check: func(Version, *Validation) error { return nil }},
Secret: func(path Path) bool { return path.String() == "/version" },
}})
if err != nil || result.Config.Version != "987654321" || lookups != 0 {
t.Fatalf("result = %#v, lookups = %d, err = %v", result, lookups, err)
}
var effective strings.Builder
if err := result.WriteEffective(&effective); err != nil {
t.Fatal(err)
}
explanation, ok := result.Explain(Keys("version"))
formatted := effective.String() + fmt.Sprintf("%#v %#v", explanation, result.Metadata().History(Keys("version")))
if !ok || !explanation.Effective.Redacted || strings.Contains(formatted, "987654321") || strings.Contains(formatted, "CREDENTIAL_CANARY") {
t.Fatalf("sensitive inspection = %q / %#v", effective.String(), explanation)
}
}
func TestExplanationReplaysResolutionAdditionsAndOverlappingIntent(t *testing.T) {
type cfg struct {
M map[string]any `yaml:"m"`
}
for _, overlap := range []bool{false, true} {
t.Run(fmt.Sprint(overlap), func(t *testing.T) {
result, err := testLoad(Bytes("base", []byte("m: {old: 1}\n")), Options[cfg]{Definition: Definition[cfg]{Resolve: func(c *cfg, r *Resolution) error {
c.M["new"] = map[string]any{"child": int64(7)}
delete(c.M, "old")
r.Record(Keys("m", "new"), ChangeDerivedDefault, "add nested", Keys("m", "old"))
r.Record(Keys("m", "old"), ChangeNormalization, "remove old")
if overlap {
r.Record(Keys("m"), ChangeNormalization, "update container")
}
return nil
}}})
if err != nil {
t.Fatal(err)
}
for _, path := range []Path{Keys("m"), Keys("m", "new"), Keys("m", "new", "child"), Keys("m", "old")} {
x, ok := result.Explain(path)
if !ok {
t.Fatal("missing known history")
}
var count int
for _, v := range x.History {
if v.Origin.Layer == LayerResolution {
count++
if overlap && len(v.Reasons) != 3 {
t.Fatal("lost overlapping intent")
}
}
}
if overlap && count != 1 {
t.Fatalf("overlapping records applied %d times", count)
}
last := x.History[len(x.History)-1]
if last.After != x.Effective {
t.Fatalf("incorrect replay for %s: %#v", path, x)
}
}
})
}
}

View File

@@ -0,0 +1,121 @@
package engine
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))
}

View File

@@ -1,4 +1,4 @@
package configkit
package engine
import (
"errors"

View File

@@ -1,4 +1,4 @@
package configkit
package engine
import (
"os"
@@ -10,13 +10,13 @@ import (
func TestLoadGlobImportsUseLexicalDeclarationOrderAndNestedDFS(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", `_configkit:
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
imports:
- glob: fragments/*.yml
- path: after.yml
items: {root: root}
`)
aPath := writeImportFile(t, directory, "fragments/a.yml", `_configkit:
aPath := writeImportFile(t, directory, "fragments/a.yml", `configkit:
imports: [{path: nested/child.yml}]
items: {alpha: alpha}
`)
@@ -26,7 +26,7 @@ items: {alpha: alpha}
var previous []SourceInfo
for run := 0; run < 2; run++ {
result, err := Load[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
result, err := testLoad[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
if err != nil {
t.Fatal(err)
}
@@ -56,7 +56,7 @@ items: {alpha: alpha}
func TestLoadGlobImportsSupportEscapesClassesAndExplicitConfD(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", `_configkit:
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
imports:
- glob: 'conf.d/literal\*.yml'
- glob: 'conf.d/item-[ab].yml'
@@ -68,7 +68,7 @@ items: {root: root}
writeImportFile(t, directory, "conf.d/item-a.yml", "items: {a: a}\n")
writeImportFile(t, directory, "conf.d/question1.yml", "items: {question: question}\n")
result, err := Load[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
result, err := testLoad[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
if err != nil {
t.Fatal(err)
}
@@ -79,7 +79,7 @@ items: {root: root}
ignoredPath := writeImportFile(t, directory, "conf.d/not-discovered.yml", "unknown: value\n")
_ = ignoredPath
plainPath := writeImportFile(t, directory, "plain.yml", "items: {root: only}\n")
plain, err := Load[importTestConfig](File(plainPath), Options[importTestConfig]{Imports: true})
plain, err := testLoad[importTestConfig](File(plainPath), Options[importTestConfig]{Imports: true})
if err != nil || len(plain.Sources()) != 1 || plain.Config.Items["root"] != "only" {
t.Fatalf("implicit discovery result = %#v, %v", plain, err)
}
@@ -116,7 +116,7 @@ func TestLoadGlobImportsRejectInvalidAndUnmatchedPatterns(t *testing.T) {
t.Fatal(err)
}
mainPath := writeGlobRoot(t, directory, "empty/*.yml", true)
result, err := Load[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
result, err := testLoad[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
if err != nil || len(result.Sources()) != 1 {
t.Fatalf("optional unmatched glob = %#v, %v", result, err)
}
@@ -126,7 +126,7 @@ func TestLoadGlobImportsPreserveIdentityAndFileRules(t *testing.T) {
t.Run("overlapping declarations", func(t *testing.T) {
directory := t.TempDir()
writeImportFile(t, directory, "conf/value.yml", "items: {value: value}\n")
mainPath := writeImportFile(t, directory, "main.yml", `_configkit:
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
imports:
- glob: conf/*.yml
- glob: conf/value.*
@@ -166,7 +166,7 @@ func TestLoadGlobImportsConfineSymlinkDirectories(t *testing.T) {
t.Skipf("symlinks unavailable: %v", err)
}
mainPath := writeGlobRoot(t, root, "linked/*.yml", false)
result, err := Load[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
result, err := testLoad[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
if err != nil || result.Config.Items["linked"] != "linked" {
t.Fatalf("contained directory symlink = %#v, %v", result, err)
}
@@ -232,7 +232,7 @@ func TestGlobTraversalBoundsNonmatchesAndAcceptedCandidates(t *testing.T) {
func TestFilesystemContextIsValidatedWithoutImportsOrActiveProfile(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "items: {root: root}\n")
result, err := Load[importTestConfig](File(mainPath), Options[importTestConfig]{
result, err := testLoad[importTestConfig](File(mainPath), Options[importTestConfig]{
Profile: "default", Filesystem: &FilesystemContext{Root: directory},
})
if err != nil || result.Config.Items["root"] != "root" {
@@ -242,12 +242,12 @@ func TestFilesystemContextIsValidatedWithoutImportsOrActiveProfile(t *testing.T)
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Filesystem: &FilesystemContext{Root: filepath.Join(directory, "missing")}}, CodeImportInvalid)
input := Bytes("memory.yml", []byte("items: {root: memory}\n"))
result, err = Load[importTestConfig](input, Options[importTestConfig]{Filesystem: &FilesystemContext{BaseDir: directory, Root: directory}})
result, err = testLoad[importTestConfig](input, Options[importTestConfig]{Filesystem: &FilesystemContext{BaseDir: directory, Root: directory}})
if err != nil || result.Config.Items["root"] != "memory" {
t.Fatalf("byte filesystem context = %#v, %v", result, err)
}
requireLoadCode(t, Bytes("memory.yml", []byte("{}\n")), Options[importTestConfig]{Filesystem: &FilesystemContext{BaseDir: directory}}, CodeOptionsInvalid)
defaultsOnly, err := Load[importTestConfig](NoSource(), Options[importTestConfig]{Profile: "default"})
defaultsOnly, err := testLoad[importTestConfig](NoSource(), Options[importTestConfig]{Profile: "default"})
if err != nil || defaultsOnly == nil {
t.Fatalf("default profile with NoSource = %#v, %v", defaultsOnly, err)
}
@@ -255,7 +255,7 @@ func TestFilesystemContextIsValidatedWithoutImportsOrActiveProfile(t *testing.T)
if runtime.GOOS == "linux" || runtime.GOOS == "darwin" {
selectedLink := filepath.Join(directory, "selected-link.yml")
if err := os.Symlink(mainPath, selectedLink); err == nil {
linked, err := Load[importTestConfig](File(selectedLink), Options[importTestConfig]{Filesystem: &FilesystemContext{Root: directory}})
linked, err := testLoad[importTestConfig](File(selectedLink), Options[importTestConfig]{Filesystem: &FilesystemContext{Root: directory}})
if err != nil || linked.Sources()[0].Path != canonicalTestPath(t, mainPath) {
t.Fatalf("selected symlink identity = %#v, %v", linked, err)
}
@@ -269,5 +269,5 @@ func writeGlobRoot(t *testing.T, directory, pattern string, optional bool) strin
if optional {
optionalLine = "\n optional: true"
}
return writeImportFile(t, directory, "main.yml", "_configkit:\n imports:\n - glob: '"+pattern+"'"+optionalLine+"\n")
return writeImportFile(t, directory, "main.yml", "configkit:\n imports:\n - glob: '"+pattern+"'"+optionalLine+"\n")
}

276
internal/engine/history.go Normal file
View File

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

324
internal/engine/imports.go Normal file
View File

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

View File

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

View File

@@ -1,4 +1,4 @@
package configkit
package engine
import (
"bytes"
@@ -38,7 +38,7 @@ func TestParallelLoadsShareRulesButNotMutableValues(t *testing.T) {
}
const count = 32
results := make([]*Result[independentConfig], count)
results := make([]*testResult[independentConfig], count)
errorsByIndex := make([]error, count)
var group sync.WaitGroup
for index := range count {
@@ -46,14 +46,14 @@ func TestParallelLoadsShareRulesButNotMutableValues(t *testing.T) {
go func(index int) {
defer group.Done()
input := fmt.Sprintf("custom: value-%d\n", index)
results[index], errorsByIndex[index] = Load(Bytes("parallel.yml", []byte(input)), Options[independentConfig]{Definition: definition})
results[index], errorsByIndex[index] = testLoad(Bytes("parallel.yml", []byte(input)), Options[independentConfig]{Definition: definition})
}(index)
}
group.Wait()
for index, err := range errorsByIndex {
if err != nil {
t.Fatalf("Load(%d) failed: %v", index, err)
t.Fatalf("testLoad(%d) failed: %v", index, err)
}
}
results[0].Config.Labels["default"] = "mutated"
@@ -73,6 +73,29 @@ func TestParallelLoadsShareRulesButNotMutableValues(t *testing.T) {
}
}
func TestLoadedConfigurationIsSeparateFromInspection(t *testing.T) {
type configuration struct {
Value string `yaml:"value"`
}
loaded, err := Load[configuration](Bytes("config.yml", []byte("value: original\n")), testSecretBinding())
if err != nil {
t.Fatal(err)
}
if loaded.Inspection == nil {
t.Fatal("inspection is nil")
}
loaded.Config.Value = "mutated"
var effective bytes.Buffer
if err := loaded.Inspection.WriteEffective(&effective); err != nil {
t.Fatal(err)
}
if effective.String() != "value: original\n" {
t.Fatalf("inspection changed with configuration: %q", effective.String())
}
}
func TestConcurrentLoadsOfCopiedReaderSourceHaveOneClaim(t *testing.T) {
source := Reader("shared", strings.NewReader("value: one\n"))
copied := source
@@ -88,7 +111,7 @@ func TestConcurrentLoadsOfCopiedReaderSourceHaveOneClaim(t *testing.T) {
go func(candidate Source) {
defer group.Done()
<-start
_, err := Load[config](candidate)
_, err := testLoad[config](candidate)
errorsFound <- err
}(candidate)
}

190
internal/engine/load.go Normal file
View File

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

View File

@@ -0,0 +1,88 @@
package engine
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
}

View File

@@ -1,4 +1,4 @@
package configkit
package engine
import (
"slices"
@@ -6,6 +6,47 @@ import (
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
// resolutionInput is the immutable entry-state provenance available to a
// resolution callback. It owns detached metadata and source records so later
// resolution updates cannot change the entry view.
type resolutionInput struct {
metadata Metadata
sources map[string]SourceInfo
}
func newResolutionInput(metadata Metadata, final *document.Node, sources []SourceInfo) resolutionInput {
frozen := Metadata{
fields: make(map[string]FieldInfo, len(metadata.fields)),
effectivePaths: effectivePathIndex(final),
}
for pointer, info := range metadata.fields {
frozen.fields[pointer] = cloneFieldInfo(info)
}
indexed := make(map[string]SourceInfo, len(sources))
for _, source := range sources {
if source.ID == "" {
panic("configkit: source identity is missing")
}
if _, exists := indexed[source.ID]; exists {
panic("configkit: source identity is duplicated")
}
indexed[source.ID] = cloneSourceInfo(source)
}
return resolutionInput{metadata: frozen, sources: indexed}
}
func (input resolutionInput) lookup(path Path) (FieldInfo, bool) {
return input.metadata.Lookup(path)
}
func (input resolutionInput) source(id string) (SourceInfo, bool) {
source, ok := input.sources[id]
if !ok {
return SourceInfo{}, false
}
return cloneSourceInfo(source), true
}
func metadataFromDecoded(decoded decodedConfiguration) Metadata {
metadata := Metadata{
fields: make(map[string]FieldInfo),
@@ -159,23 +200,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 {
if metadata.fields == nil && metadata.history == nil {
return Metadata{}
@@ -184,6 +208,14 @@ func cloneMetadata(metadata Metadata) Metadata {
fields: make(map[string]FieldInfo, len(metadata.fields)),
history: make(map[string][]Change, len(metadata.history)),
}
cloned.effectivePaths = make(map[string]Path, len(metadata.effectivePaths))
for pointer, path := range metadata.effectivePaths {
cloned.effectivePaths[pointer] = path.clone()
}
cloned.historicalPaths = make(map[string][]Path, len(metadata.historicalPaths))
for pointer, paths := range metadata.historicalPaths {
cloned.historicalPaths[pointer] = clonePaths(paths)
}
for path, info := range metadata.fields {
cloned.fields[path] = cloneFieldInfo(info)
}

View File

@@ -0,0 +1,120 @@
package engine
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 {
entry := newResolutionInput(metadata, decoded.final, input.sources)
resolved := runResolution(&configuration, definition.Resolve, schema, decoded, input.document, entry)
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) (*Loaded[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)
inspection := &Inspection{
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),
}
return &Loaded[T]{Config: configuration, Inspection: inspection}, 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),
}
}

View File

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

149
internal/engine/metadata.go Normal file
View File

@@ -0,0 +1,149 @@
package engine
import (
"sort"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
// Metadata provides detached effective presence/origin and chronological history
// queries for a load. Its zero value contains no paths.
type Metadata struct {
effectivePaths map[string]Path
historicalPaths map[string][]Path
fields map[string]FieldInfo
history map[string][]Change
}
// Lookup returns information for an effective path. Unknown and invalid paths
// return false.
func (m Metadata) Lookup(path Path) (FieldInfo, bool) {
if !path.valid() || !matchesPathShape(path, m.effectivePaths[path.String()]) {
return FieldInfo{}, false
}
info, ok := m.fields[path.String()]
if !ok {
return FieldInfo{}, false
}
return cloneFieldInfo(info), true
}
// IsExplicit reports whether a surviving input contribution explicitly defined
// path.
func (m Metadata) IsExplicit(path Path) bool {
info, ok := m.Lookup(path)
return ok && info.Explicit
}
// History returns detached definition and resolution records for path. A record's
// Path can be an ancestor when that container operation replaced or removed path.
// Removed historical paths can have history even though Lookup returns false.
// Unknown and invalid paths return nil.
func (m Metadata) History(path Path) []Change {
if !m.hasHistoricalPath(path) {
return nil
}
history, ok := m.history[path.String()]
if !ok {
return nil
}
cloned := make([]Change, len(history))
for i, change := range history {
cloned[i] = cloneChange(change)
}
return cloned
}
// HistoricalPaths returns the paths observed during loading, including effective
// paths and paths removed by overlays or recorded resolution. It includes default
// paths and the root, but not paths that never existed. The zero Metadata returns
// an empty slice.
//
// Paths are detached, unique by canonical JSON Pointer, and sorted by that
// spelling. Like ParsePath results, numeric tokens are resolved contextually;
// one pointer can identify a mapping key in an earlier layer and a sequence index
// in a later one. Use History to inspect definitions and Lookup to check whether
// a path remains effective. Enumeration exposes no configuration values and does
// not perform another load or resolve secrets.
func (m Metadata) HistoricalPaths() []Path {
pointers := make([]string, 0, len(m.historicalPaths))
for pointer := range m.historicalPaths {
pointers = append(pointers, pointer)
}
sort.Strings(pointers)
paths := make([]Path, 0, len(pointers))
for _, pointer := range pointers {
// The index contains only canonical pointers produced by Path.String.
path, _ := ParsePath(pointer)
paths = append(paths, path)
}
return paths
}
// Textual pointers are lookup keys, but explicit key/index kinds remain part of
// the query. Candidate paths are constructed from actual document containers.
func matchesPathShape(query, candidate Path) bool {
if !query.valid() || query.String() != candidate.String() || len(query.segments) != len(candidate.segments) {
return false
}
for i, segment := range query.segments {
actual := candidate.segments[i]
if actual.kind == pathSegmentIndex {
index, ok := schemaIndex(segment)
if !ok || index != actual.index {
return false
}
} else if segment.kind == pathSegmentIndex {
return false
}
}
return true
}
func (m Metadata) hasHistoricalPath(path Path) bool {
if !path.valid() {
return false
}
for _, candidate := range m.historicalPaths[path.String()] {
if matchesPathShape(path, candidate) {
return true
}
}
return false
}
func finalizeMetadataPaths(m *Metadata, final *document.Node, applications []historyApplication, earlier ...*document.Node) {
m.effectivePaths = effectivePathIndex(final)
m.historicalPaths = make(map[string][]Path)
add := func(path Path, _ *document.Node) {
pointer := path.String()
for _, prior := range m.historicalPaths[pointer] {
if matchesPathShape(path, prior) {
return
}
}
m.historicalPaths[pointer] = append(m.historicalPaths[pointer], path.clone())
}
walkDocument(final, Path{}, add)
for _, tree := range earlier {
walkDocument(tree, Path{}, add)
}
for _, application := range applications {
// Overrides have mapping-only prefixes; parsed tokens are keys in this layer.
prefix := Path{}
for _, segment := range application.path.segments {
prefix = prefix.Key(segment.key)
}
walkDocument(application.value, prefix, add)
}
}
// effectivePathIndex records document-derived paths with their contextual segment
// kinds. Callers retain no aliases to the walked tree or its paths.
func effectivePathIndex(final *document.Node) map[string]Path {
paths := make(map[string]Path)
walkDocument(final, Path{}, func(path Path, _ *document.Node) {
paths[path.String()] = path.clone()
})
return paths
}

View File

@@ -0,0 +1,274 @@
package engine
import (
"bytes"
"reflect"
"testing"
)
func TestMetadataHistoricalPaths(t *testing.T) {
t.Parallel()
result, err := testLoad[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 := testLoad[config](File(root), Options[config]{Imports: true, Profile: "testing"})
if err != nil {
t.Fatal(err)
}
metadata := result.Metadata()
found := false
for _, path := range metadata.HistoricalPaths() {
if path.String() != "/definitions/recap" {
continue
}
found = true
owners := map[string]bool{}
for _, change := range metadata.History(path) {
if change.Origin.Layer == LayerBase {
for _, location := range change.Origin.Locations {
owners[location.SourceID] = true
}
}
}
if len(owners) != 2 {
t.Fatalf("hidden declaration owners = %v", owners)
}
}
if !found {
t.Fatal("hidden base declaration was not discoverable")
}
})
}
}
func TestMetadataQueriesReturnDetachedRecords(t *testing.T) {
t.Parallel()
path := Keys("service", "workers")
metadata := Metadata{
effectivePaths: map[string]Path{path.String(): path},
historicalPaths: map[string][]Path{path.String(): {path}},
fields: map[string]FieldInfo{
path.String(): {
Explicit: true,
Origin: Origin{
Layer: LayerBase,
Locations: []Location{{SourceID: "source-1", Line: 2, Column: 3}},
},
},
},
history: map[string][]Change{
path.String(): {{
Path: path,
Origin: Origin{Layer: LayerResolution, Locations: []Location{{SourceID: "source-1"}}},
Previous: []Origin{{Layer: LayerDefault, Locations: []Location{{SourceID: "default"}}}},
Reasons: []string{"bounded worker count"},
Dependencies: []Path{Keys("limits")},
}},
},
}
info, ok := metadata.Lookup(path)
if !ok || !info.Explicit || !metadata.IsExplicit(path) {
t.Fatalf("Lookup() = %#v, %v", info, ok)
}
info.Origin.Locations[0].SourceID = "mutated"
again, _ := metadata.Lookup(path)
if again.Origin.Locations[0].SourceID != "source-1" {
t.Fatal("Lookup returned aliased locations")
}
history := metadata.History(path)
history[0].Reasons[0] = "mutated"
history[0].Previous[0].Locations[0].SourceID = "mutated"
history[0].Dependencies[0].segments[0].key = "mutated"
againHistory := metadata.History(path)
if againHistory[0].Reasons[0] != "bounded worker count" ||
againHistory[0].Previous[0].Locations[0].SourceID != "default" ||
againHistory[0].Dependencies[0].String() != "/limits" {
t.Fatal("History returned aliased nested records")
}
missing := Keys("missing")
if _, ok := metadata.Lookup(missing); ok || metadata.IsExplicit(missing) || metadata.History(missing) != nil {
t.Fatal("unknown path produced metadata")
}
if _, ok := metadata.Lookup(Path{}.Index(-1)); ok {
t.Fatal("invalid path produced metadata")
}
}
func TestResolutionInputKeepsDetachedEntryMetadata(t *testing.T) {
t.Parallel()
type configuration struct {
Default string `yaml:"default"`
Items []string `yaml:"items"`
Values map[string]string `yaml:"values"`
Nullable *string `yaml:"nullable"`
}
schema := mustSchemaFor[configuration](t)
document := parseDocumentForDecode(t, "items: [first]\nvalues: {'0': key, 'a/b~': escaped}\nnullable: null\n")
decoded, issues := decodeConfiguration(schema, reflect.ValueOf(configuration{Default: "retained"}), document)
if len(issues) != 0 {
t.Fatalf("decode issues = %#v", issues)
}
metadata := metadataFromDecoded(decoded)
input := newResolutionInput(metadata, decoded.final, []SourceInfo{{
ID: "source-1", Label: "decode.yml", Layer: LayerBase,
ImportedFrom: &Location{SourceID: "parent", Line: 1, Column: 1},
}})
parsedItem, err := ParsePath("/items/0")
if err != nil {
t.Fatal(err)
}
parsedKey, err := ParsePath("/values/0")
if err != nil {
t.Fatal(err)
}
for _, path := range []Path{Path{}, Keys("default"), Keys("nullable"), parsedItem, parsedKey, Keys("values", "a/b~")} {
if _, ok := input.lookup(path); !ok {
t.Fatalf("entry path %s is missing", path)
}
}
defaultInfo, _ := input.lookup(Keys("default"))
nullInfo, _ := input.lookup(Keys("nullable"))
if defaultInfo.Explicit || !nullInfo.Explicit || !nullInfo.Null {
t.Fatalf("default = %#v, nullable = %#v", defaultInfo, nullInfo)
}
for _, path := range []Path{
Keys("default", "child"),
Keys("items", "0"),
Keys("values").Index(0),
Keys("items").Index(1),
} {
if _, ok := input.lookup(path); ok {
t.Fatalf("entry lookup accepted %s", path)
}
}
item, _ := input.lookup(parsedItem)
item.Origin.Locations[0].SourceID = "mutated"
source, ok := input.source("source-1")
if !ok {
t.Fatal("source is missing")
}
source.ImportedFrom.SourceID = "mutated"
configurationAtEntry := decoded.value.Interface().(configuration)
resolved := runResolution(&configurationAtEntry, func(value *configuration, resolution *Resolution) error {
value.Items[0] = "normalized"
resolution.Record(Keys("items"), ChangeNormalization, "normalized item")
return nil
}, schema, decoded, document, input)
if len(resolved.diagnostics) != 0 {
t.Fatalf("resolution diagnostics = %#v", resolved.diagnostics)
}
applyResolutionMetadata(&metadata, resolved)
metadata.effectivePaths = effectivePathIndex(resolved.final)
again, ok := input.lookup(parsedItem)
if !ok || again.Origin.Locations[0].SourceID != "source-1" || again.Origin.Operation != "" {
t.Fatalf("entry metadata changed after resolution = %#v, %v", again, ok)
}
againSource, ok := input.source("source-1")
if !ok || againSource.ImportedFrom == nil || againSource.ImportedFrom.SourceID != "parent" {
t.Fatalf("entry source changed = %#v, %v", againSource, ok)
}
if _, ok := input.source(""); ok {
t.Fatal("empty source identity resolved")
}
}
func TestQueriesRespectActualHistoricalAndEffectiveContainers(t *testing.T) {
result, err := testLoad[map[string]any](Bytes("base", []byte("items: [1]\nm: {'0': 2}\nx: 3\nretired: {child: 4}\n")), Options[map[string]any]{Overrides: []Override{{Path: Keys("retired"), Value: "removed", Flag: "--retired"}}})
if err != nil {
t.Fatal(err)
}
for _, path := range []Path{Keys("items", "0"), Keys("m").Index(0), Keys("x", "missing"), Keys("items").Index(8)} {
if _, ok := result.Metadata().Lookup(path); ok {
t.Fatalf("invalid lookup %s", path)
}
if result.Metadata().History(path) != nil {
t.Fatalf("invalid history %s", path)
}
if _, ok := result.Explain(path); ok {
t.Fatalf("invalid explanation %s", path)
}
var out bytes.Buffer
if err := result.WriteExplanation(&out, path); err == nil || out.Len() != 0 {
t.Fatalf("invalid path wrote output: %s", path)
}
}
for _, pointer := range []string{"/items/0", "/m/0"} {
path, _ := ParsePath(pointer)
if _, ok := result.Metadata().Lookup(path); !ok {
t.Fatal("parsed path lost lookup")
}
if _, ok := result.Explain(path); !ok {
t.Fatal("parsed path lost explanation")
}
}
retired := Keys("retired", "child")
if _, ok := result.Metadata().Lookup(retired); ok {
t.Fatal("removed path remains effective")
}
if len(result.Metadata().History(retired)) == 0 {
t.Fatal("removed history missing")
}
x, ok := result.Explain(retired)
if !ok || x.Effective.Present || len(x.History) == 0 {
t.Fatal("removed explanation missing")
}
}

View File

@@ -0,0 +1,41 @@
package engine
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}
}

View File

@@ -1,4 +1,4 @@
package configkit
package engine
import (
"slices"
@@ -10,8 +10,6 @@ import (
type overlayRecord struct {
change Change
displaced []displacedDefinition
before *document.Node
after *document.Node
}
type displacedDefinition struct {
@@ -26,15 +24,7 @@ func overlayDocuments(base *composedDocument, overlay *document.Node) (*composed
Message: "profile overlay requires mapping document roots",
}}
}
result := &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,
}
result := cloneComposedDocument(base)
mergeOverlayOrigin(result.origins, Path{}, overlay.Location, LayerProfile)
mergeOverlayMappings(result, result.root, overlay, Path{}, LayerProfile, true)
result.applications = append(result.applications, historyApplication{layer: LayerProfile, value: overlay})
@@ -73,7 +63,6 @@ func mergeOverlayMappings(result *composedDocument, target, overlay *document.No
Previous: previousOrigins(previous),
},
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))
for index, record := range records {
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))
for displacedIndex, displaced := range record.displaced {
cloned[index].displaced[displacedIndex] = displacedDefinition{

View File

@@ -1,4 +1,4 @@
package configkit
package engine
import (
"reflect"

223
internal/engine/override.go Normal file
View File

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

View File

@@ -0,0 +1,100 @@
package engine
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
}

View File

@@ -1,4 +1,4 @@
package configkit
package engine
import (
"errors"
@@ -30,7 +30,7 @@ type overrideLifecycleConfig struct {
func TestOverridesRunAtHighestInputPrecedenceBeforeResolutionAndValidation(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "_configkit:\n imports: [{path: base.yml}]\nversion: v1\n")
mainPath := writeImportFile(t, directory, "main.yml", "configkit:\n imports: [{path: base.yml}]\nversion: v1\n")
writeImportFile(t, directory, "base.yml", `settings:
target: base
count: 4
@@ -56,7 +56,7 @@ func TestOverridesRunAtHighestInputPrecedenceBeforeResolutionAndValidation(t *te
{Path: Keys("settings", "labels"), Value: map[string]any{"cli": "selected"}, Flag: "--label"},
{Path: Keys("settings", "api_key"), Value: "CLI_SECRET", Flag: "--api-key-env"},
}
result, err := loadWithSecretLookup(File(mainPath), func(name string) (string, bool) {
result, err := testLoadWithSecretLookup(File(mainPath), func(name string) (string, bool) {
if name != "CLI_SECRET" {
t.Fatalf("looked up superseded secret name %q", name)
}
@@ -83,7 +83,7 @@ func TestOverridesRunAtHighestInputPrecedenceBeforeResolutionAndValidation(t *te
},
})
if err != nil {
t.Fatalf("loadWithSecretLookup() error = %v", err)
t.Fatalf("testLoadWithSecretLookup() error = %v", err)
}
want := overrideLifecycleSettings{
Target: "cli", Labels: map[string]string{"base": "retained", "profile": "retained", "cli": "selected"},
@@ -123,7 +123,7 @@ func TestOverridesSupportDefaultsOnlyMapMergeListReplacementAndNumericKeys(t *te
"int": int8(1), "uint": uint16(2), "float": float32(3.5), "bool": false,
"null": nil, "list": []any{"value"}, "map": map[string]any{"key": "value"},
}
result, err := Load(NoSource(), Options[config]{
result, err := testLoad(NoSource(), Options[config]{
Definition: Definition[config]{Defaults: func() config {
return config{Labels: map[string]string{"kept": "default"}, Items: []int{9}}
}},
@@ -163,7 +163,7 @@ func TestOverrideValidationRejectsPathsOverlapValuesAndProtectedTargets(t *testi
Nested map[string]any `yaml:"nested"`
Unknown string `yaml:"unknown"`
}
base := Bytes("override-errors.yml", []byte("version: v1\nscalar: value\nitems: [one]\nnested: {}\n"))
base := Bytes("override-errors.yml", []byte("version: 4\nscalar: value\nitems: [one]\nnested: {}\n"))
version := &VersionRule{Path: Keys("version"), Check: func(Version, *Validation) error { return nil }}
cycle := map[string]any{}
cycle["self"] = cycle
@@ -187,14 +187,14 @@ func TestOverrideValidationRejectsPathsOverlapValuesAndProtectedTargets(t *testi
{name: "nonfinite", overrides: []Override{{Path: Keys("scalar"), Value: math.Inf(1), Flag: "--scalar"}}, code: CodeOverrideInvalidValue},
{name: "depth", overrides: []Override{{Path: Keys("nested"), Value: deep, Flag: "--nested"}}, code: CodeOverrideLimitExceeded},
{name: "record bytes", overrides: []Override{{Path: Keys("scalar"), Value: strings.Repeat("x", maxSourceBytes+1), Flag: "--scalar"}}, code: CodeOverrideLimitExceeded},
{name: "directive path", overrides: []Override{{Path: Keys("_configkit", "x"), Value: true, Flag: "--directive"}}, code: CodeOverrideDirective},
{name: "directive value", overrides: []Override{{Path: Keys("nested"), Value: map[string]any{"_configkit": map[string]any{}}, Flag: "--nested"}}, code: CodeOverrideDirective},
{name: "directive path", overrides: []Override{{Path: Keys("configkit", "x"), Value: true, Flag: "--directive"}}, code: CodeOverrideDirective},
{name: "directive value", overrides: []Override{{Path: Keys("nested"), Value: map[string]any{"configkit": map[string]any{}}, Flag: "--nested"}}, code: CodeOverrideDirective},
{name: "version", overrides: []Override{{Path: Keys("version"), Value: "v2", Flag: "--version"}}, code: CodeOverrideVersion},
{name: "version ancestor", overrides: []Override{{Path: Keys("version"), Value: map[string]any{"child": "v2"}, Flag: "--version"}}, code: CodeOverrideVersion},
{name: "label", overrides: []Override{{Path: Keys("scalar"), Value: "x", Flag: "\nCANARY"}}, code: CodeOverrideInvalidLabel},
} {
t.Run(test.name, func(t *testing.T) {
result, err := Load(base, Options[config]{Definition: Definition[config]{Version: version}, Overrides: test.overrides})
result, err := testLoad(base, Options[config]{Definition: Definition[config]{Version: version}, Overrides: test.overrides})
if result != nil {
t.Fatalf("result = %#v, want nil", result)
}
@@ -213,7 +213,7 @@ func TestOverrideCannotReplaceSchemaVersionAncestor(t *testing.T) {
type config struct {
Schema schemaConfig `yaml:"schema"`
}
_, err := Load(Bytes("version.yml", []byte("schema: {version: v1}\n")), Options[config]{
_, err := testLoad(Bytes("version.yml", []byte("schema: {version: 4}\n")), Options[config]{
Definition: Definition[config]{Version: &VersionRule{Path: Keys("schema", "version"), Check: func(Version, *Validation) error { return nil }}},
Overrides: []Override{{Path: Keys("schema"), Value: map[string]any{"other": "value"}, Flag: "--schema"}},
})
@@ -224,7 +224,7 @@ func TestOverrideDecodeFailureUsesFlagSourceWithoutRawValue(t *testing.T) {
type config struct {
Count int `yaml:"count"`
}
result, err := Load(Bytes("base.yml", []byte("count: 1\n")), Options[config]{Overrides: []Override{{
result, err := testLoad(Bytes("base.yml", []byte("count: 1\n")), Options[config]{Overrides: []Override{{
Path: Keys("missing"), Value: "RAW-ARG-CANARY", Flag: "--missing",
}}})
if result != nil {
@@ -249,7 +249,7 @@ func TestOverrideTypeMismatchUsesNormalStrictDecode(t *testing.T) {
type config struct {
Count int `yaml:"count"`
}
_, err := Load(Bytes("base.yml", []byte("count: 1\n")), Options[config]{Overrides: []Override{{
_, err := testLoad(Bytes("base.yml", []byte("count: 1\n")), Options[config]{Overrides: []Override{{
Path: Keys("count"), Value: "not-an-integer", Flag: "--count",
}}})
loadError := requireSecretLoadCode(t, err, CodeDecodeType)
@@ -262,7 +262,7 @@ func TestOverrideEqualReplacementStillAppearsInHistory(t *testing.T) {
type config struct {
Value string `yaml:"value"`
}
result, err := Load(Bytes("equal.yml", []byte("value: same\n")), Options[config]{Overrides: []Override{{Path: Keys("value"), Value: "same", Flag: "--value"}}})
result, err := testLoad(Bytes("equal.yml", []byte("value: same\n")), Options[config]{Overrides: []Override{{Path: Keys("value"), Value: "same", Flag: "--value"}}})
if err != nil {
t.Fatal(err)
}

View File

@@ -0,0 +1,150 @@
package engine
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}
}

View File

@@ -1,4 +1,4 @@
package configkit
package engine
import (
"bytes"
@@ -33,6 +33,21 @@ type parsedSource struct {
bytes int64
}
// parserDiagnostics translates adapter issues into detached, ordered public records.
func parserDiagnostics(sourceID string, issues []yamladapter.Issue) []Diagnostic {
diagnostics := make([]Diagnostic, len(issues))
for index, issue := range issues {
diagnostics[index] = Diagnostic{
Code: Code(issue.Code), Severity: SeverityError, Message: issue.Message,
Primary: &Location{SourceID: sourceID, Line: issue.Line, Column: issue.Column},
}
for _, related := range issue.Related {
diagnostics[index].Related = append(diagnostics[index].Related, publicLocation(related))
}
}
return canonicalDiagnostics(diagnostics)
}
func parseSource(source Source, sourceID string, limits parseLimits) (parsedSource, []Diagnostic) {
read, diagnostics := readSource(source, sourceID, limits.source)
if len(diagnostics) != 0 || read.info == nil {
@@ -41,27 +56,7 @@ func parseSource(source Source, sourceID string, limits parseLimits) (parsedSour
root, issues := yamladapter.Parse(bytes.NewReader(read.data), sourceID, limits.document)
if len(issues) != 0 {
diagnostics := make([]Diagnostic, len(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{source: read.info, bytes: int64(len(read.data))}, parserDiagnostics(sourceID, issues)
}
return parsedSource{document: root, source: read.info, bytes: int64(len(read.data))}, nil
}

View File

@@ -1,4 +1,4 @@
package configkit
package engine
import (
"strings"

148
internal/engine/path.go Normal file
View File

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

View File

@@ -1,4 +1,4 @@
package configkit
package engine
import "testing"

View File

@@ -1,4 +1,4 @@
package configkit
package engine
import "testing"

View File

@@ -1,16 +1,14 @@
package configkit
package engine
import (
"bytes"
"errors"
"io"
"io/fs"
"os"
"path/filepath"
"strconv"
"strings"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
yamladapter "gitea.maximumdirect.net/eric/ConfigKit/internal/yaml"
)
@@ -68,9 +66,14 @@ func loadProfileDocument(source Source, context *FilesystemContext, main parsedS
value.Primary = nil
return nil, cloneSourceInfos(sources), []Diagnostic{value}
}
rootClosed := false
defer func() {
if !rootClosed {
_ = root.Close()
}
}()
relative, err := filepath.Rel(rootPath, canonical)
if err != nil || filepath.IsAbs(relative) || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
_ = root.Close()
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)
data, diagnostic := readProfileFile(root, relative, sourceID, limits.bytes-base.inputBytes)
if diagnostic != nil {
_ = root.Close()
return nil, sources, []Diagnostic{*diagnostic}
}
profileRoot, issues := yamladapter.Parse(bytes.NewReader(data), sourceID, yamladapter.Limits{Depth: maxDocumentDepth, Nodes: maxDocumentNodes})
if len(issues) != 0 {
_ = root.Close()
return nil, sources, importParseDiagnostics(sourceID, issues)
return nil, sources, parserDiagnostics(sourceID, issues)
}
nodeCount := countInputNodes(profileRoot)
if base.inputNodes+nodeCount > limits.nodes {
_ = root.Close()
return nil, sources, []Diagnostic{importDiagnostic(CodeProfileLimitExceeded, "selected profile exceeds the aggregate node limit", profileLocation)}
}
if diagnostics := profileDirectiveDiagnostics(profileRoot, Path{}); len(diagnostics) != 0 {
_ = root.Close()
return nil, sources, diagnostics
}
if versionNode := fragmentVersionClaim(profileRoot, rule); versionNode != nil {
location := publicLocation(versionNode.Location)
_ = root.Close()
return nil, sources, []Diagnostic{{
Code: CodeProfileVersion, Severity: SeverityError, Path: rule.Path.clone(),
Message: "profiles cannot contribute to or replace the schema-version path", Primary: &location,
}}
}
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)}
}
@@ -122,58 +122,10 @@ func readProfileFile(root *os.Root, relative, sourceID string, remaining int64)
if limit < 0 {
limit = 0
}
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})
data, failure := readRegularFile(relative, root.Stat, root.Open, limit)
if failure != nil {
value := sourceReadDiagnostic(failure, sourceID, CodeProfileLimitExceeded, "selected profile exceeds a source or aggregate byte limit")
return nil, &value
}
return data, nil
}
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
}

View File

@@ -1,4 +1,4 @@
package configkit
package engine
import (
"errors"
@@ -23,7 +23,7 @@ type profileTestConfig struct {
func TestLoadAppliesSelectedProfileAfterBaseAndBeforeDefaults(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", `_configkit:
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
imports: [{path: base.yml}]
settings:
kept: main
@@ -40,7 +40,7 @@ nullable: null
value: scalar
`)
result, err := Load[profileTestConfig](File(mainPath), Options[profileTestConfig]{
result, err := testLoad[profileTestConfig](File(mainPath), Options[profileTestConfig]{
Imports: true,
Profile: "testing",
Definition: Definition[profileTestConfig]{Defaults: func() profileTestConfig {
@@ -101,12 +101,12 @@ func TestLoadProfileSelectionUsesOneFixedYAMLFile(t *testing.T) {
writeImportFile(t, directory, "profiles/default.yml", "settings: {changed: forbidden}\n")
for _, name := range []string{"", "default"} {
result, err := Load[profileTestConfig](File(mainPath), Options[profileTestConfig]{Profile: name})
result, err := testLoad[profileTestConfig](File(mainPath), Options[profileTestConfig]{Profile: name})
if err != nil || result.Config.Settings.Changed != "" || len(result.Sources()) != 1 {
t.Fatalf("inactive profile %q = %#v, %v", name, result, err)
}
}
selected, err := Load[profileTestConfig](File(mainPath), Options[profileTestConfig]{Profile: "testing"})
selected, err := testLoad[profileTestConfig](File(mainPath), Options[profileTestConfig]{Profile: "testing"})
if err != nil || selected.Config.Settings.Changed != "selected" || len(selected.Sources()) != 2 {
t.Fatalf("selected profile = %#v, %v", selected, err)
}
@@ -123,7 +123,7 @@ func TestLoadProfileSelectionUsesOneFixedYAMLFile(t *testing.T) {
func TestLoadProfileRejectsDirectivesVersionsAndBaseAliases(t *testing.T) {
versionRule := &VersionRule{Path: Keys("schema", "version"), Check: func(version Version, _ *Validation) error {
if !version.Present || version.Text != "v1" {
if !version.Present || version.Text != "4" {
return errors.New("unexpected version")
}
return nil
@@ -133,14 +133,14 @@ func TestLoadProfileRejectsDirectivesVersionsAndBaseAliases(t *testing.T) {
profile string
code Code
}{
{name: "root directive", profile: "_configkit: {}\n", code: CodeProfileDirective},
{name: "nested directive", profile: "settings: {_configkit: {}}\n", code: CodeProfileDirective},
{name: "equal version", profile: "schema: {version: v1}\n", code: CodeProfileVersion},
{name: "root directive", profile: "configkit: {}\n", code: CodeProfileDirective},
{name: "nested directive", profile: "settings: {configkit: {}}\n", code: CodeProfileDirective},
{name: "equal integer version", profile: "schema: {version: 4}\n", code: CodeProfileVersion},
{name: "ancestor scalar", profile: "schema: null\n", code: CodeProfileVersion},
} {
t.Run(test.name, func(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "schema: {version: v1}\n")
mainPath := writeImportFile(t, directory, "main.yml", "schema: {version: 4}\n")
writeImportFile(t, directory, "profiles/testing.yml", test.profile)
requireProfileLoadCode(t, File(mainPath), Options[profileTestConfig]{Profile: "testing", Definition: Definition[profileTestConfig]{Version: versionRule}}, test.code)
})
@@ -148,9 +148,9 @@ func TestLoadProfileRejectsDirectivesVersionsAndBaseAliases(t *testing.T) {
t.Run("mapping sibling is allowed", func(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "schema: {version: v1}\n")
mainPath := writeImportFile(t, directory, "main.yml", "schema: {version: 4}\n")
writeImportFile(t, directory, "profiles/testing.yml", "schema: {other: allowed}\n")
result, err := Load[profileTestConfig](File(mainPath), Options[profileTestConfig]{Profile: "testing", Definition: Definition[profileTestConfig]{Version: versionRule}})
result, err := testLoad[profileTestConfig](File(mainPath), Options[profileTestConfig]{Profile: "testing", Definition: Definition[profileTestConfig]{Version: versionRule}})
if err != nil || result.Config.Schema["other"] != "allowed" {
t.Fatalf("mapping sibling = %#v, %v", result, err)
}
@@ -158,7 +158,7 @@ func TestLoadProfileRejectsDirectivesVersionsAndBaseAliases(t *testing.T) {
t.Run("profile aliases imported base", func(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "_configkit: {imports: [{path: profiles/testing.yml}]}\n")
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: profiles/testing.yml}]}\n")
writeImportFile(t, directory, "profiles/testing.yml", "settings: {changed: value}\n")
requireProfileLoadCode(t, File(mainPath), Options[profileTestConfig]{Imports: true, Profile: "testing"}, CodeProfileRepeated)
})
@@ -201,7 +201,7 @@ func TestLoadProfileKeepsBaseFailuresAndFilesystemContainment(t *testing.T) {
t.Run("byte source context", func(t *testing.T) {
directory := t.TempDir()
writeImportFile(t, directory, "profiles/testing.yml", "settings: {changed: selected}\n")
result, err := Load[profileTestConfig](Bytes("s3://bucket/main.yml", []byte("{}\n")), Options[profileTestConfig]{
result, err := testLoad[profileTestConfig](Bytes("s3://bucket/main.yml", []byte("{}\n")), Options[profileTestConfig]{
Profile: "testing", Filesystem: &FilesystemContext{BaseDir: directory, Root: directory},
})
if err != nil || result.Config.Settings.Changed != "selected" {
@@ -251,7 +251,7 @@ func TestLoadProfileCountsAgainstAggregateBudgets(t *testing.T) {
func requireProfileLoadCode(t *testing.T, source Source, options Options[profileTestConfig], code Code) *LoadError {
t.Helper()
result, err := Load[profileTestConfig](source, options)
result, err := testLoad[profileTestConfig](source, options)
if result != nil || err == nil {
t.Fatalf("result, error = %#v, %v", result, err)
}

View File

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

View File

@@ -1,4 +1,4 @@
package configkit
package engine
import (
"errors"
@@ -328,7 +328,7 @@ func TestCompareDocumentTreesTracksSemanticChanges(t *testing.T) {
if issue != nil {
t.Fatal(issue.message)
}
deltas := compareDocumentTrees(before, after)
deltas := compareConfigurationValues(before, after)
want := []struct {
path string
kind documentDeltaKind
@@ -352,16 +352,16 @@ func TestCompareDocumentTreesTracksSemanticChanges(t *testing.T) {
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)
}
}
func mustSchemaFor[T any](t *testing.T) *schemaDescriptor {
t.Helper()
schema, issue := buildSchema(reflect.TypeFor[T]())
schema, issue := testBuildSchema(reflect.TypeFor[T]())
if issue != nil {
t.Fatalf("buildSchema() issue = %#v", issue)
t.Fatalf("testBuildSchema() issue = %#v", issue)
}
return schema
}

View File

@@ -0,0 +1,510 @@
package engine
import (
"errors"
"reflect"
"strings"
"sync"
"testing"
)
type layeredItem struct {
Value string `yaml:"value"`
}
type layeredConfig struct {
Shared struct {
Equal string `yaml:"equal"`
Imported string `yaml:"imported"`
Profile string `yaml:"profile"`
} `yaml:"shared"`
Values []int `yaml:"values"`
Items map[string]layeredItem `yaml:"items"`
Value any `yaml:"value"`
Nullable *string `yaml:"nullable"`
Derived int `yaml:"derived"`
Empty map[string]string `yaml:"empty"`
}
func TestLayeredProvenanceAgreesWithResolvedSnapshot(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
imports: [{path: imported.yml}]
shared: {equal: same}
values: [1, 2]
items:
keep: {value: 'yes'}
remove: {value: 'no'}
value: {child: base}
nullable: base
empty: {}
`)
importPath := writeImportFile(t, directory, "imported.yml", "shared: {imported: base}\n")
profilePath := writeImportFile(t, directory, "profiles/testing.yml", `shared: {profile: selected}
values: [3, 4]
value: selected
nullable: null
empty: {}
`)
defaultNullable := "default"
definition := Definition[layeredConfig]{
Defaults: func() layeredConfig {
defaults := layeredConfig{
Values: []int{0, 0}, Value: map[string]any{"child": "default"},
Nullable: &defaultNullable, Empty: map[string]string{"retained": "default"},
}
defaults.Shared.Equal = "same"
return defaults
},
Resolve: func(configuration *layeredConfig, resolution *Resolution) error {
Normalize(resolution, Keys("values"), &configuration.Values, []int{5},
"bounded values", Keys("shared", "imported"), Keys("shared", "imported"))
Normalize(resolution, Keys("values"), &configuration.Values, []int{5},
"bounded values", Keys("shared", "imported"))
delete(configuration.Items, "remove")
resolution.Record(Keys("items"), ChangeNormalization, "removed disabled items")
Default(resolution, Keys("derived"), &configuration.Derived, 7,
"derived from equal", Keys("shared", "equal"), Keys("shared", "equal"))
resolution.Warning(Keys("values"), "values were bounded", Keys("shared", "imported"))
return nil
},
}
result, err := testLoad[layeredConfig](File(mainPath), Options[layeredConfig]{
Imports: true, Profile: "testing", Definition: definition,
})
if err != nil {
var loadError *LoadError
_ = errors.As(err, &loadError)
t.Fatalf("%v: %#v", err, loadError.Diagnostics())
}
wantSources := []string{canonicalTestPath(t, mainPath), canonicalTestPath(t, importPath), canonicalTestPath(t, profilePath)}
sources := result.Sources()
gotSources := make([]string, len(sources))
for index, source := range sources {
gotSources[index] = source.Path
}
if !reflect.DeepEqual(gotSources, wantSources) || sources[2].Layer != LayerProfile {
t.Fatalf("sources = %#v", sources)
}
if result.Config.Shared.Equal != "same" || result.Config.Shared.Imported != "base" || result.Config.Shared.Profile != "selected" ||
!reflect.DeepEqual(result.Config.Values, []int{5}) || result.Config.Nullable != nil || result.Config.Derived != 7 ||
result.Config.Empty["retained"] != "default" {
t.Fatalf("configuration = %#v", result.Config)
}
metadata := result.Metadata()
equal, _ := metadata.Lookup(Keys("shared", "equal"))
derived, _ := metadata.Lookup(Keys("derived"))
nullable, _ := metadata.Lookup(Keys("nullable"))
empty, _ := metadata.Lookup(Keys("empty"))
firstValue, _ := metadata.Lookup(Keys("values").Index(0))
if !equal.Explicit || equal.Origin.Layer != LayerBase || derived.Explicit || derived.Origin.Operation != ChangeDerivedDefault ||
!nullable.Explicit || !nullable.Null || nullable.Origin.Layer != LayerProfile ||
!empty.Explicit || empty.Origin.Layer != LayerProfile || len(empty.Origin.Locations) != 2 ||
!firstValue.Explicit || firstValue.Origin.Operation != ChangeNormalization {
t.Fatalf("metadata = equal %#v derived %#v nullable %#v empty %#v value %#v", equal, derived, nullable, empty, firstValue)
}
if _, ok := metadata.Lookup(Keys("value", "child")); ok {
t.Fatal("profile-removed child remains effective")
}
if _, ok := metadata.Lookup(Keys("values").Index(1)); ok {
t.Fatal("resolution-removed sequence index remains effective")
}
if _, ok := metadata.Lookup(Keys("items", "remove", "value")); ok {
t.Fatal("resolution-removed mapping child remains effective")
}
assertHistoryLayersAndPaths(t, metadata.History(Keys("values").Index(1)),
[]Layer{LayerDefault, LayerBase, LayerProfile, LayerResolution},
[]string{"/values/1", "/values/1", "/values", "/values"})
assertHistoryLayersAndPaths(t, metadata.History(Keys("value", "child")),
[]Layer{LayerDefault, LayerBase, LayerProfile},
[]string{"/value/child", "/value/child", "/value"})
removedHistory := metadata.History(Keys("items", "remove", "value"))
assertHistoryLayersAndPaths(t, removedHistory,
[]Layer{LayerBase, LayerResolution},
[]string{"/items/remove/value", "/items"})
if !reflect.DeepEqual(removedHistory[1].Reasons, []string{"removed disabled items"}) {
t.Fatalf("removed history = %#v", removedHistory)
}
valuesHistory := metadata.History(Keys("values").Index(0))
lastValue := valuesHistory[len(valuesHistory)-1]
if !reflect.DeepEqual(lastValue.Reasons, []string{"bounded values"}) || len(lastValue.Dependencies) != 1 || lastValue.Dependencies[0].String() != "/shared/imported" {
t.Fatalf("canonical resolution history = %#v", lastValue)
}
schema, issue := testBuildSchema(reflect.TypeFor[layeredConfig]())
if issue != nil {
t.Fatal(issue)
}
projected, projectionIssue := projectConfiguration(schema, reflect.ValueOf(result.Config), defaultProjectionLimits())
if projectionIssue != nil || !configurationValuesEqual(projected, result.final) {
t.Fatalf("typed projection and private snapshot disagree: %#v", projectionIssue)
}
metadata.history[Keys("values").Index(0).String()][0].Origin.Layer = "mutated"
sources[1].ImportedFrom.SourceID = "mutated"
diagnostics := result.Diagnostics()
diagnostics[0].Related[0].SourceID = "mutated"
result.Config.Values[0] = 99
freshHistory := result.Metadata().History(Keys("values").Index(0))
if freshHistory[0].Origin.Layer == "mutated" || result.Sources()[1].ImportedFrom.SourceID == "mutated" ||
result.Diagnostics()[0].Related[0].SourceID == "mutated" {
t.Fatal("returned inspection records alias private state")
}
snapshotValue, ok := documentAtPath(result.final, Keys("values").Index(0))
if !ok || snapshotValue.Text != "5" {
t.Fatal("caller configuration mutation changed the private final snapshot")
}
}
func TestSelectedProfileLoadsRemainIndependentConcurrently(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", "values: [1, 2]\n")
writeImportFile(t, directory, "profiles/testing.yml", "values: [3, 4]\n")
options := Options[layeredConfig]{Profile: "testing"}
const loads = 16
results := make(chan *testResult[layeredConfig], loads)
errors := make(chan error, loads)
var group sync.WaitGroup
for index := 0; index < loads; index++ {
group.Add(1)
go func() {
defer group.Done()
result, err := testLoad[layeredConfig](File(mainPath), options)
if err != nil {
errors <- err
return
}
results <- result
}()
}
group.Wait()
close(results)
close(errors)
for err := range errors {
t.Fatal(err)
}
var collected []*testResult[layeredConfig]
for result := range results {
collected = append(collected, result)
}
if len(collected) != loads {
t.Fatalf("completed loads = %d", len(collected))
}
collected[0].Config.Values[0] = 99
collected[0].metadata.fields[Keys("values").String()] = FieldInfo{}
for _, result := range collected[1:] {
if !reflect.DeepEqual(result.Config.Values, []int{3, 4}) {
t.Fatalf("concurrent result contaminated = %#v", result.Config.Values)
}
if info, ok := result.Metadata().Lookup(Keys("values")); !ok || info.Origin.Layer != LayerProfile {
t.Fatalf("concurrent metadata contaminated = %#v, %v", info, ok)
}
}
}
func TestResolutionInputKeepsLayeredOriginsAndSources(t *testing.T) {
directory := t.TempDir()
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
imports: [{path: first.yml}, {path: second.yml}]
main: main
equal: same
cli_equal: same
composite: {main: main}
`)
firstPath := writeImportFile(t, directory, "first.yml", `imported: imported
sequence: [first, second]
composite: {first: first}
`)
secondPath := writeImportFile(t, directory, "second.yml", "composite: {second: second}\n")
profilePath := writeImportFile(t, directory, "profiles/testing.yml", "equal: same\nprofile: profile\n")
compositeSources := map[string]bool{
canonicalTestPath(t, mainPath): true,
canonicalTestPath(t, firstPath): true,
canonicalTestPath(t, secondPath): true,
}
type config struct {
Main string `yaml:"main"`
Imported string `yaml:"imported"`
Sequence []string `yaml:"sequence"`
Composite map[string]string `yaml:"composite"`
Equal string `yaml:"equal"`
Profile string `yaml:"profile"`
CLIEqual string `yaml:"cli_equal"`
}
var callbackErr error
result, err := testLoad[config](File(mainPath), Options[config]{
Imports: true, Profile: "testing", Overrides: []Override{{Path: Keys("cli_equal"), Value: "same", Flag: "--cli-equal"}},
Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error {
check := func(ok bool, message string) {
if !ok && callbackErr == nil {
callbackErr = errors.New(message)
}
}
fileOwner := func(path Path, want string) FieldInfo {
info, found := resolution.InputInfo(path)
check(found && len(info.Origin.Locations) == 1, "scalar has no exact origin")
if !found || len(info.Origin.Locations) != 1 {
return FieldInfo{}
}
source, exists := resolution.Source(info.Origin.Locations[0].SourceID)
check(exists && source.Path == want, "scalar source does not match its declaration")
return info
}
fileOwner(Keys("main"), canonicalTestPath(t, mainPath))
fileOwner(Keys("imported"), canonicalTestPath(t, firstPath))
fileOwner(Keys("sequence").Index(1), canonicalTestPath(t, firstPath))
profile := fileOwner(Keys("equal"), canonicalTestPath(t, profilePath))
fileOwner(Keys("profile"), canonicalTestPath(t, profilePath))
check(profile.Origin.Layer == LayerProfile, "profile replacement retained base ownership")
cli, found := resolution.InputInfo(Keys("cli_equal"))
check(found && cli.Origin.Layer == LayerCLI && len(cli.Origin.Locations) == 1, "CLI replacement lost ownership")
if found && len(cli.Origin.Locations) == 1 {
source, exists := resolution.Source(cli.Origin.Locations[0].SourceID)
check(exists && source.Layer == LayerCLI && source.Path == "", "CLI source has a file path or is missing")
}
composite, found := resolution.InputInfo(Keys("composite"))
check(found && len(composite.Origin.Locations) == 3, "composite mapping did not retain every contributor")
for _, location := range composite.Origin.Locations {
source, exists := resolution.Source(location.SourceID)
check(exists && compositeSources[source.Path], "composite location has no expected source record")
delete(compositeSources, source.Path)
}
check(len(compositeSources) == 0, "composite sources are incomplete")
imported, _ := resolution.InputInfo(Keys("imported"))
importedSourceID := imported.Origin.Locations[0].SourceID
imported.Origin.Locations[0].SourceID = "mutated"
importSource, _ := resolution.Source(importedSourceID)
check(importSource.ImportedFrom != nil, "import source is missing its import location")
if importSource.ImportedFrom != nil {
importSource.ImportedFrom.SourceID = "mutated"
}
again, _ := resolution.InputInfo(Keys("imported"))
againSource, _ := resolution.Source(importedSourceID)
check(again.Origin.Locations[0].SourceID == importedSourceID, "input record is aliased")
check(againSource.ImportedFrom != nil && againSource.ImportedFrom.SourceID == "source-1", "source record is aliased")
return callbackErr
}},
})
if callbackErr != nil {
t.Fatal(callbackErr)
}
if err != nil {
t.Fatal(err)
}
if result.Config.Main != "main" || result.Config.Imported != "imported" || !reflect.DeepEqual(result.Config.Sequence, []string{"first", "second"}) ||
result.Config.Equal != "same" || result.Config.Profile != "profile" || result.Config.CLIEqual != "same" {
t.Fatalf("configuration = %#v", result.Config)
}
resultField, _ := result.Metadata().Lookup(Keys("imported"))
resultSource := result.Sources()[1]
if resultField.Origin.Locations[0].SourceID != "source-2" || resultSource.ImportedFrom == nil || resultSource.ImportedFrom.SourceID != "source-1" {
t.Fatal("callback record mutation changed the completed result")
}
}
func TestResolutionInputUsesEntryShapesAcrossTypedAndDynamicValues(t *testing.T) {
t.Parallel()
type Embedded struct {
Name string `yaml:"name"`
}
type config struct {
Embedded `yaml:",inline"`
Mappings map[string]string `yaml:"mappings"`
Array [2]string `yaml:"array"`
Dynamic any `yaml:"dynamic"`
}
var callbackErr error
_, err := testLoad[config](Bytes("shapes.yml", []byte("name: inlined\nmappings: {'-0': key, 'a/b~': escaped}\narray: [zero, one]\ndynamic: {nested: [dynamic]}\n")), Options[config]{Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error {
check := func(path Path, want bool) {
_, found := resolution.InputInfo(path)
if found != want && callbackErr == nil {
callbackErr = errors.New("entry shape lookup disagrees with its document container")
}
}
parsedMapping, _ := ParsePath("/mappings/-0")
escapedMapping, _ := ParsePath("/mappings/a~1b~0")
check(Keys("name"), true)
check(parsedMapping, true)
check(escapedMapping, true)
check(Keys("mappings").Index(0), false)
check(Keys("array").Index(1), true)
check(Keys("array", "1"), false)
check(Keys("array").Index(2), false)
check(Keys("dynamic", "nested").Index(0), true)
check(Keys("dynamic", "nested", "0"), false)
negative, _ := ParsePath("/array/-1")
check(negative, false)
return callbackErr
}}})
if callbackErr != nil {
t.Fatal(callbackErr)
}
if err != nil {
t.Fatal(err)
}
}
func TestResolutionInputDistinguishesNonFileRootsAndImportedFiles(t *testing.T) {
directory := t.TempDir()
importPath := writeImportFile(t, directory, "fragment.yml", "imported: file\n")
type config struct {
Root string `yaml:"root"`
Imported string `yaml:"imported"`
Default string `yaml:"default"`
}
input := "configkit: {imports: [{path: fragment.yml}]}\nroot: memory\n"
context := &FilesystemContext{BaseDir: directory, Root: directory}
for _, test := range []struct {
name string
source func() Source
}{
{name: "bytes", source: func() Source { return Bytes("memory://config.yml", []byte(input)) }},
{name: "reader", source: func() Source { return Reader("memory://config.yml", strings.NewReader(input)) }},
} {
t.Run(test.name, func(t *testing.T) {
var callbackErr error
_, err := testLoad[config](test.source(), Options[config]{Imports: true, Filesystem: context, Definition: Definition[config]{
Defaults: func() config { return config{Default: "retained"} },
Resolve: func(_ *config, resolution *Resolution) error {
root, _ := resolution.InputInfo(Keys("root"))
imported, _ := resolution.InputInfo(Keys("imported"))
defaulted, _ := resolution.InputInfo(Keys("default"))
rootSource, rootFound := resolution.Source(root.Origin.Locations[0].SourceID)
importSource, importFound := resolution.Source(imported.Origin.Locations[0].SourceID)
if !rootFound || rootSource.Path != "" || !importFound || importSource.Path != canonicalTestPath(t, importPath) || defaulted.Explicit {
callbackErr = errors.New("non-file root or imported file provenance is wrong")
}
if _, found := resolution.Source("unknown"); found {
callbackErr = errors.New("unknown source was found")
}
return callbackErr
},
}})
if callbackErr != nil {
t.Fatal(callbackErr)
}
if err != nil {
t.Fatal(err)
}
})
}
var callbackErr error
_, err := testLoad[config](Bytes("memory://standalone.yml", []byte("root: memory\n")), Options[config]{Definition: Definition[config]{
Defaults: func() config { return config{Default: "retained"} },
Resolve: func(_ *config, resolution *Resolution) error {
root, rootFound := resolution.InputInfo(Keys("root"))
defaulted, defaultFound := resolution.InputInfo(Keys("default"))
source, sourceFound := resolution.Source(root.Origin.Locations[0].SourceID)
if !rootFound || !defaultFound || !sourceFound || source.Path != "" || defaulted.Explicit {
callbackErr = errors.New("standalone non-file provenance is wrong")
}
return callbackErr
},
}})
if callbackErr != nil {
t.Fatal(callbackErr)
}
if err != nil {
t.Fatal(err)
}
}
func TestResolutionInputRemainsFixedAcrossRecordedMutations(t *testing.T) {
t.Parallel()
type config struct {
Existing string `yaml:"existing"`
Values map[string]string `yaml:"values"`
}
var callbackErr error
result, err := testLoad[config](Bytes("mutations.yml", []byte("existing: original\nvalues: {original: move}\n")), Options[config]{Definition: Definition[config]{Resolve: func(value *config, resolution *Resolution) error {
original, originalFound := resolution.InputInfo(Keys("values", "original"))
_, destinationFound := resolution.InputInfo(Keys("values", "destination"))
existing, existingFound := resolution.InputInfo(Keys("existing"))
Normalize(resolution, Keys("existing"), &value.Existing, "normalized", "normalized existing value")
value.Values["destination"] = value.Values["original"]
delete(value.Values, "original")
resolution.Record(Keys("values"), ChangeNormalization, "moved configured value")
againOriginal, againOriginalFound := resolution.InputInfo(Keys("values", "original"))
againDestination, againDestinationFound := resolution.InputInfo(Keys("values", "destination"))
againExisting, againExistingFound := resolution.InputInfo(Keys("existing"))
if !originalFound || destinationFound || !existingFound || !againOriginalFound || againDestinationFound || !againExistingFound ||
!reflect.DeepEqual(original, againOriginal) || !reflect.DeepEqual(existing, againExisting) || !reflect.DeepEqual(againDestination, FieldInfo{}) {
callbackErr = errors.New("entry view changed during resolution")
}
return callbackErr
}}})
if callbackErr != nil {
t.Fatal(callbackErr)
}
if err != nil {
t.Fatal(err)
}
if result.Config.Existing != "normalized" || result.Config.Values["destination"] != "move" {
t.Fatalf("configuration = %#v", result.Config)
}
if _, found := result.Metadata().Lookup(Keys("values", "original")); found {
t.Fatal("final metadata retained a removed path")
}
destination, found := result.Metadata().Lookup(Keys("values", "destination"))
existing, existingFound := result.Metadata().Lookup(Keys("existing"))
if !found || destination.Origin.Layer != LayerResolution || !existingFound || existing.Origin.Layer != LayerResolution {
t.Fatalf("final metadata = destination %#v existing %#v", destination, existing)
}
}
func TestResolutionInputQueriesRemainIndependentAcrossLoads(t *testing.T) {
t.Parallel()
type config struct {
Value string `yaml:"value"`
}
const loads = 16
failures := make(chan error, loads)
var group sync.WaitGroup
for index := 0; index < loads; index++ {
group.Add(1)
go func() {
defer group.Done()
_, err := testLoad[config](Bytes("parallel.yml", []byte("value: stable\n")), Options[config]{Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error {
info, found := resolution.InputInfo(Keys("value"))
source, sourceFound := resolution.Source(info.Origin.Locations[0].SourceID)
if !found || !sourceFound || source.ID != "source-1" {
return errors.New("independent entry lookup failed")
}
info.Origin.Locations[0].SourceID = "mutated"
source.Label = "mutated"
return nil
}}})
if err != nil {
failures <- err
}
}()
}
group.Wait()
close(failures)
for err := range failures {
t.Fatal(err)
}
}
func assertHistoryLayersAndPaths(t *testing.T, history []Change, layers []Layer, paths []string) {
t.Helper()
if len(history) != len(layers) || len(history) != len(paths) {
t.Fatalf("history = %#v", history)
}
for index, change := range history {
if change.Origin.Layer != layers[index] || change.Path.String() != paths[index] {
t.Fatalf("history[%d] = %#v", index, change)
}
}
}

286
internal/engine/records.go Normal file
View File

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

View File

@@ -1,4 +1,4 @@
package configkit
package engine
import (
"errors"
@@ -14,7 +14,7 @@ var errEffectiveUnavailable = errors.New("configkit: effective configuration is
// WriteEffective writes deterministic redacted YAML from the frozen resolved
// snapshot. The output is intended for inspection and is not promised reloadable.
// Writer errors, including short writes, are returned and may leave partial output.
func (r *Result[T]) WriteEffective(output io.Writer) error {
func (r *Inspection) WriteEffective(output io.Writer) error {
if r == nil || r.final == nil || output == nil {
return errEffectiveUnavailable
}

View File

@@ -1,4 +1,4 @@
package configkit
package engine
import (
"bytes"
@@ -85,7 +85,7 @@ sequence: [second, first]
return nil
},
}
result, err := Load[renderConfig](File(mainPath), Options[renderConfig]{Definition: definition, Profile: "testing"})
result, err := testLoad[renderConfig](File(mainPath), Options[renderConfig]{Definition: definition, Profile: "testing"})
if err != nil {
t.Fatal(err)
}
@@ -139,7 +139,7 @@ sequence: [second, first]
if err := result.WriteEffective(shortWriter{}); !errors.Is(err, io.ErrShortWrite) {
t.Fatalf("short writer error = %v", err)
}
var nilResult *Result[renderConfig]
var nilResult *testResult[renderConfig]
if err := nilResult.WriteEffective(io.Discard); !errors.Is(err, errEffectiveUnavailable) {
t.Fatalf("nil result error = %v", err)
}
@@ -160,7 +160,7 @@ func TestSecretValuesStayOutOfLoadFailuresAndWarnings(t *testing.T) {
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
test.definition.Secret = func(path Path) bool { return path.String() == "/credentials" }
result, err := Load[renderConfig](Bytes("secret-failure.yml", []byte(test.input)), Options[renderConfig]{Definition: test.definition})
result, err := testLoad[renderConfig](Bytes("secret-failure.yml", []byte(test.input)), Options[renderConfig]{Definition: test.definition})
if result != nil || err == nil {
t.Fatalf("result, error = %#v, %v", result, err)
}
@@ -175,3 +175,76 @@ func TestSecretValuesStayOutOfLoadFailuresAndWarnings(t *testing.T) {
})
}
}
func TestDynamicConcreteTagsRemainSecretThroughHistory(t *testing.T) {
type sensitive struct {
Token string `yaml:"token" configkit:"secret"`
}
type cfg struct {
Payload any `yaml:"payload"`
Derived string `yaml:"derived"`
}
for _, change := range []string{"retain", "replace", "delete", "introduce"} {
t.Run(change, func(t *testing.T) {
const canary = "DYNAMIC_TAG_CANARY"
definition := Definition[cfg]{Defaults: func() cfg { return cfg{Payload: map[string]any{"nested": []any{sensitive{Token: canary}}}} }}
path := Keys("payload", "nested").Index(0).Key("token")
if change == "introduce" {
definition.Defaults = nil
}
definition.Resolve = func(c *cfg, r *Resolution) error {
if change == "introduce" {
Normalize(r, Keys("payload"), &c.Payload, any(map[string]any{"nested": []any{sensitive{Token: canary}}}), "create")
}
if change == "replace" {
Normalize(r, Keys("payload"), &c.Payload, any("public"), "replace")
}
if change == "delete" {
Normalize(r, Keys("payload"), &c.Payload, any(nil), "remove")
}
Normalize(r, Keys("derived"), &c.Derived, canary, "derive", path)
return nil
}
result, err := testLoad(NoSource(), Options[cfg]{Definition: definition})
if err != nil {
t.Fatal(err)
}
var output bytes.Buffer
if err := result.WriteEffective(&output); err != nil {
t.Fatal(err)
}
for _, query := range []Path{Keys(), path, Keys("derived")} {
explanation, _ := result.Explain(query)
output.WriteString(fmt.Sprintf("%#v", explanation))
}
if strings.Contains(output.String(), canary) {
t.Fatal("dynamic secret escaped inspection")
}
if !result.secrets[path.String()] || !result.secrets["/derived"] {
t.Fatal("dynamic tag or dependency classification missing")
}
})
}
}
func TestValidatorCannotChangeDynamicSensitivityWithoutChangingText(t *testing.T) {
type sensitive struct {
Token string `yaml:"token" configkit:"secret"`
}
type cfg struct {
Payload any `yaml:"payload"`
}
_, err := testLoad(Bytes("base", []byte("payload: {token: disposable}\n")), Options[cfg]{Definition: Definition[cfg]{Validate: func(c *cfg, v *Validation) {
c.Payload = sensitive{Token: c.Payload.(map[string]any)["token"].(string)}
}}})
var failure *LoadError
if !errors.As(err, &failure) {
t.Fatal("validator changed inspection sensitivity without a mutation error")
}
for _, d := range failure.Diagnostics() {
if d.Code == CodeValidateMutation {
return
}
}
t.Fatal("missing validation mutation diagnostic")
}

View File

@@ -1,4 +1,4 @@
package configkit
package engine
import (
"reflect"
@@ -15,6 +15,7 @@ type resolutionState struct {
schema *schemaDescriptor
presence map[string]inputPresence
document *document.Node
input resolutionInput
records map[string]*resolutionRecord
diagnostics []Diagnostic
}
@@ -50,6 +51,27 @@ func (r *Resolution) IsExplicit(path Path) bool {
return explicit
}
// InputInfo returns detached entry-state metadata for path. The entry state is
// the decoded configuration, including retained static defaults, immediately
// before resolution starts. It is fixed while resolution records mutate the
// working configuration; unknown or shape-mismatched paths return false.
//
// InputInfo is valid only during the resolution callback. See the configuration
// contract for source and provenance semantics.
func (r *Resolution) InputInfo(path Path) (FieldInfo, bool) {
return r.activeState().input.lookup(path)
}
// Source returns a detached record for a source acquired by this load. Empty or
// unknown IDs return false and do not perform input, filesystem, or credential
// access. A source path is present only when that acquired source has one.
//
// Source is valid only during the resolution callback. See the configuration
// contract for source and provenance semantics.
func (r *Resolution) Source(id string) (SourceInfo, bool) {
return r.activeState().input.source(id)
}
// Record declares the intent for a resolver mutation. A container path covers
// mutations to its descendants.
func (r *Resolution) Record(path Path, kind ChangeKind, reason string, dependencies ...Path) {
@@ -221,9 +243,10 @@ func (state *resolutionState) addDiagnostic(code Code, path Path, message string
state.diagnostics = append(state.diagnostics, diagnostic)
}
func runResolution[T any](configuration *T, resolver func(*T, *Resolution) error, schema *schemaDescriptor, decoded decodedConfiguration, input *document.Node) resolutionResult {
func runResolution[T any](configuration *T, resolver func(*T, *Resolution) error, schema *schemaDescriptor, decoded decodedConfiguration, input *document.Node, entry resolutionInput) resolutionResult {
state := &resolutionState{
active: true, schema: schema, presence: decoded.presence, document: input,
input: entry,
records: make(map[string]*resolutionRecord),
}
context := &Resolution{state: state}
@@ -241,7 +264,7 @@ func runResolution[T any](configuration *T, resolver func(*T, *Resolution) error
state.addDiagnostic(CodeResolveInvalidValue, issue.path, "application resolution produced an invalid configuration value")
return resolutionResult{diagnostics: canonicalDiagnostics(state.diagnostics)}
}
deltas := compareDocumentTrees(decoded.final, after)
deltas := compareConfigurationValues(decoded.final, after)
ordered := orderedResolutionRecords(state.records)
for _, delta := range deltas {
if coveringResolutionRecord(ordered, delta.path) == nil {
@@ -249,16 +272,15 @@ func runResolution[T any](configuration *T, resolver func(*T, *Resolution) error
}
}
for _, record := range ordered {
beforeNode, beforePresent := documentAtPath(decoded.final, record.path)
_, beforePresent := documentAtPath(decoded.final, record.path)
afterNode, afterPresent := documentAtPath(after, record.path)
if !beforePresent && !afterPresent {
state.addDiagnostic(CodeResolveInvalidRecord, record.path, "resolution record does not identify an existing or removed value")
continue
}
if record.helperExpected != nil && (!afterPresent || !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")
}
_ = beforeNode
}
if hasErrors(state.diagnostics) {
return resolutionResult{diagnostics: canonicalDiagnostics(state.diagnostics)}
@@ -343,17 +365,3 @@ func clonePaths(paths []Path) []Path {
}
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
}

View File

@@ -1,4 +1,4 @@
package configkit
package engine
import (
"errors"
@@ -41,7 +41,7 @@ func TestLoadRunsRecordedResolutionWithMapWriteback(t *testing.T) {
},
}
input := "poll_interval: 1m\nartifacts:\n second: {output: ' second '}\n first: {output: ' first '}\n"
result, err := Load(Bytes("resolution.yml", []byte(input)), Options[config]{Definition: definition})
result, err := testLoad(Bytes("resolution.yml", []byte(input)), Options[config]{Definition: definition})
if err != nil {
t.Fatal(err)
}
@@ -94,7 +94,7 @@ func TestResolutionPreservesExplicitNullAndZeroWhileReplacingAbsentBaseline(t *t
Absent int `yaml:"absent"`
}
five := 5
result, err := Load(Bytes("explicit.yml", []byte("optional: null\ncount: 0\n")), Options[config]{Definition: Definition[config]{
result, err := testLoad(Bytes("explicit.yml", []byte("optional: null\ncount: 0\n")), Options[config]{Definition: Definition[config]{
Defaults: func() config { return config{Optional: &five, Count: 5, Absent: 5} },
Resolve: func(configuration *config, resolution *Resolution) error {
if !resolution.IsExplicit(Keys("optional")) || !resolution.IsExplicit(Keys("count")) || resolution.IsExplicit(Keys("absent")) {
@@ -126,7 +126,7 @@ func TestResolutionCoalescesEqualAndRepeatedHelperIntent(t *testing.T) {
Value int `yaml:"value"`
Other int `yaml:"other"`
}
result, err := Load(NoSource(), Options[config]{Definition: Definition[config]{
result, err := testLoad(NoSource(), Options[config]{Definition: Definition[config]{
Defaults: func() config { return config{Value: 5} },
Resolve: func(configuration *config, resolution *Resolution) error {
Default(resolution, Keys("value"), &configuration.Value, 5, "equal derived intent", Keys("other"))
@@ -157,7 +157,7 @@ func TestResolutionContainerRecordCoversSubtreeRemovalAndSnapshotsAreDetached(t
type config struct {
Items map[string]item `yaml:"items"`
}
result, err := Load(Bytes("remove.yml", []byte("items:\n keep: {value: 'yes'}\n remove: {value: 'no'}\n")), Options[config]{Definition: Definition[config]{
result, err := testLoad(Bytes("remove.yml", []byte("items:\n keep: {value: 'yes'}\n remove: {value: 'no'}\n")), Options[config]{Definition: Definition[config]{
Resolve: func(configuration *config, resolution *Resolution) error {
delete(configuration.Items, "remove")
resolution.Record(Keys("items"), ChangeNormalization, "removed disabled entries")
@@ -188,7 +188,7 @@ func TestResolutionSequenceReplacementUpdatesDescendantOrigins(t *testing.T) {
type config struct {
Values []int `yaml:"values"`
}
result, err := Load(Bytes("sequence.yml", []byte("values: [1, 2]\n")), Options[config]{Definition: Definition[config]{
result, err := testLoad(Bytes("sequence.yml", []byte("values: [1, 2]\n")), Options[config]{Definition: Definition[config]{
Resolve: func(configuration *config, resolution *Resolution) error {
Normalize(resolution, Keys("values"), &configuration.Values, []int{3}, "replaced values")
return nil
@@ -237,7 +237,7 @@ func TestResolutionRejectsUnrecordedAndMismatchedMutations(t *testing.T) {
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
result, err := Load(Bytes("invalid-resolution.yml", []byte("value: 1\n")), Options[config]{Definition: Definition[config]{Resolve: test.resolve}})
result, err := testLoad(Bytes("invalid-resolution.yml", []byte("value: 1\n")), Options[config]{Definition: Definition[config]{Resolve: test.resolve}})
assertLoadDiagnostic(t, result, err, test.code)
})
}
@@ -269,7 +269,7 @@ func TestResolutionRejectsInvalidProjectedValuesAndSuppressesReturnedErrors(t *t
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
result, err := Load(Bytes("resolution-errors.yml", []byte("value: 1\nnumber: 1\n")), Options[config]{Definition: Definition[config]{Resolve: test.resolve}})
result, err := testLoad(Bytes("resolution-errors.yml", []byte("value: 1\nnumber: 1\n")), Options[config]{Definition: Definition[config]{Resolve: test.resolve}})
loadError := assertLoadDiagnostic(t, result, err, test.code)
for _, diagnostic := range loadError.Diagnostics() {
if strings.Contains(diagnostic.Message, "CANARY") {
@@ -280,6 +280,83 @@ func TestResolutionRejectsInvalidProjectedValuesAndSuppressesReturnedErrors(t *t
}
}
func TestResolutionEntryQueriesExposeDetachedLoadInput(t *testing.T) {
t.Parallel()
type config struct {
Declared string `yaml:"declared"`
Default string `yaml:"default"`
Optional *string `yaml:"optional"`
}
filename := writeImportFile(t, t.TempDir(), "config.yml", "declared: file\noptional: null\n")
var savedField FieldInfo
var savedSource SourceInfo
result, err := testLoad[config](File(filename), Options[config]{Definition: Definition[config]{
Defaults: func() config { return config{Default: "retained"} },
Resolve: func(_ *config, resolution *Resolution) error {
for _, path := range []Path{Keys("declared"), Keys("default"), Keys("optional")} {
info, found := resolution.InputInfo(path)
if !found || info.Explicit != resolution.IsExplicit(path) {
return errors.New("entry metadata does not agree with explicitness")
}
}
defaultInfo, _ := resolution.InputInfo(Keys("default"))
optionalInfo, _ := resolution.InputInfo(Keys("optional"))
if defaultInfo.Explicit || defaultInfo.Origin.Layer != LayerDefault || !optionalInfo.Explicit || !optionalInfo.Null {
return errors.New("entry metadata lost default or null state")
}
if _, found := resolution.InputInfo(Keys("missing")); found {
return errors.New("missing entry path was found")
}
declared, _ := resolution.InputInfo(Keys("declared"))
if len(declared.Origin.Locations) != 1 {
return errors.New("declared source location is missing")
}
source, found := resolution.Source(declared.Origin.Locations[0].SourceID)
if !found || source.Path != canonicalTestPath(t, filename) || source.ID != "source-1" {
return errors.New("declared source record is missing")
}
if _, found := resolution.Source(""); found {
return errors.New("empty source identity was found")
}
if _, found := resolution.Source("unknown"); found {
return errors.New("unknown source identity was found")
}
savedField, savedSource = declared, source
return nil
},
}})
if err != nil {
t.Fatal(err)
}
if result.Config.Declared != "file" || result.Config.Default != "retained" || result.Config.Optional != nil {
t.Fatalf("configuration = %#v", result.Config)
}
savedField.Origin.Locations[0].SourceID = "mutated"
savedSource.Label = "mutated"
field, _ := result.Metadata().Lookup(Keys("declared"))
if field.Origin.Locations[0].SourceID != "source-1" || result.Sources()[0].Label == "mutated" {
t.Fatal("saved resolution records alias a completed result")
}
noSource, err := testLoad[config](NoSource(), Options[config]{Definition: Definition[config]{
Defaults: func() config { return config{Default: "retained"} },
Resolve: func(_ *config, resolution *Resolution) error {
info, found := resolution.InputInfo(Keys("default"))
if !found || info.Explicit || resolution.IsExplicit(Keys("default")) {
return errors.New("defaults-only entry metadata is wrong")
}
if _, found := resolution.Source("source-1"); found {
return errors.New("defaults-only load invented a source")
}
return nil
},
}})
if err != nil || noSource.Config.Default != "retained" {
t.Fatalf("defaults-only load = %#v, %v", noSource, err)
}
}
func TestResolutionContextPanicsAfterCallback(t *testing.T) {
t.Parallel()
@@ -287,7 +364,7 @@ func TestResolutionContextPanicsAfterCallback(t *testing.T) {
Value int `yaml:"value"`
}
var retained *Resolution
if _, err := Load(NoSource(), Options[config]{Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error {
if _, err := testLoad(NoSource(), Options[config]{Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error {
retained = resolution
return nil
}}}); err != nil {
@@ -301,6 +378,45 @@ func TestResolutionContextPanicsAfterCallback(t *testing.T) {
retained.IsExplicit(Keys("value"))
}
func TestResolutionEntryQueriesRequireAnActiveCallback(t *testing.T) {
t.Parallel()
type config struct {
Value int `yaml:"value"`
}
requireClosed := func(t *testing.T, call func()) {
t.Helper()
defer func() {
if recovered := recover(); recovered != closedCallbackContextMessage {
t.Fatalf("panic = %#v", recovered)
}
}()
call()
}
requireClosed(t, func() { (*Resolution)(nil).InputInfo(Keys("value")) })
requireClosed(t, func() { (&Resolution{}).Source("source-1") })
var successful *Resolution
if _, err := testLoad(NoSource(), Options[config]{Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error {
successful = resolution
return nil
}}}); err != nil {
t.Fatal(err)
}
requireClosed(t, func() { successful.InputInfo(Keys("value")) })
requireClosed(t, func() { successful.Source("source-1") })
var failed *Resolution
if _, err := testLoad(NoSource(), Options[config]{Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error {
failed = resolution
return errors.New("resolver failed")
}}}); err == nil {
t.Fatal("failed resolver succeeded")
}
requireClosed(t, func() { failed.InputInfo(Keys("value")) })
requireClosed(t, func() { failed.Source("source-1") })
}
func TestValidationContextPanicsAfterVersionCallback(t *testing.T) {
t.Parallel()
@@ -308,7 +424,7 @@ func TestValidationContextPanicsAfterVersionCallback(t *testing.T) {
Version string `yaml:"version"`
}
var retained *Validation
if _, err := Load(Bytes("version-context.yml", []byte("version: one\n")), Options[config]{Definition: Definition[config]{
if _, err := testLoad(Bytes("version-context.yml", []byte("version: one\n")), Options[config]{Definition: Definition[config]{
Version: &VersionRule{Path: Keys("version"), Check: func(_ Version, validation *Validation) error {
retained = validation
return nil
@@ -324,7 +440,7 @@ func TestValidationContextPanicsAfterVersionCallback(t *testing.T) {
retained.Warning(Keys("version"), "too late")
}
func assertLoadDiagnostic[T any](t *testing.T, result *Result[T], err error, code Code) *LoadError {
func assertLoadDiagnostic[T any](t *testing.T, result *testResult[T], err error, code Code) *LoadError {
t.Helper()
if result != nil || err == nil {
t.Fatalf("result, err = %#v, %v", result, err)

View File

@@ -1,4 +1,4 @@
package configkit
package engine
import (
"encoding"
@@ -29,8 +29,9 @@ const (
)
type schemaDescriptor struct {
root *typeDescriptor
types map[reflect.Type]*typeDescriptor
root *typeDescriptor
types map[reflect.Type]*typeDescriptor
secrets SecretBinding
}
type typeDescriptor struct {
@@ -65,20 +66,39 @@ type schemaIssue struct {
message string
}
// SecretState is the private operation state for one exact-type secret value.
// It is supplied by the facade and never enters inspection records.
type SecretState struct {
Name string
Value string
Specified bool
Resolved bool
}
// SecretBinding adapts the facade-owned exact secret type for one load.
// Bindings are operation data: callers must supply the type and conversions
// together, and a binding never becomes package-global state.
type SecretBinding struct {
Type reflect.Type
Read func(reflect.Value) SecretState
Make func(SecretState) reflect.Value
}
var (
durationType = reflect.TypeFor[time.Duration]()
secretType = reflect.TypeFor[Secret]()
textMarshalerType = reflect.TypeFor[encoding.TextMarshaler]()
textUnmarshalerType = reflect.TypeFor[encoding.TextUnmarshaler]()
)
func buildSchema(root reflect.Type) (*schemaDescriptor, *schemaIssue) {
func buildSchema(root reflect.Type, secrets SecretBinding) (*schemaDescriptor, *schemaIssue) {
secrets.validate()
if root == nil || (root.Kind() != reflect.Struct && !isStringMap(root)) {
return nil, &schemaIssue{code: CodeSchemaRoot, message: "configuration root must be a struct or string-keyed map"}
}
builder := schemaBuilder{
cache: make(map[reflect.Type]*typeDescriptor),
building: make(map[reflect.Type]bool),
secrets: secrets,
}
descriptor, issue := builder.describe(root, Path{})
if issue != nil {
@@ -87,12 +107,13 @@ func buildSchema(root reflect.Type) (*schemaDescriptor, *schemaIssue) {
if descriptor.kind != descriptorStruct && descriptor.kind != descriptorMap {
return nil, &schemaIssue{code: CodeSchemaRoot, message: "configuration root must be a structural struct or string-keyed map"}
}
return &schemaDescriptor{root: descriptor, types: builder.cache}, nil
return &schemaDescriptor{root: descriptor, types: builder.cache, secrets: secrets}, nil
}
type schemaBuilder struct {
cache map[reflect.Type]*typeDescriptor
building map[reflect.Type]bool
secrets SecretBinding
}
func (b *schemaBuilder) describe(current reflect.Type, path Path) (*typeDescriptor, *schemaIssue) {
@@ -122,7 +143,7 @@ func (b *schemaBuilder) describe(current reflect.Type, path Path) (*typeDescript
descriptor.kind = descriptorDuration
return descriptor, nil
}
if current == secretType {
if b.secrets.Type != nil && current == b.secrets.Type {
descriptor.kind = descriptorSecret
return descriptor, nil
}
@@ -189,6 +210,15 @@ func (b *schemaBuilder) describe(current reflect.Type, path Path) (*typeDescript
return descriptor, nil
}
func (binding SecretBinding) validate() {
if binding.Type == nil {
return
}
if binding.Read == nil || binding.Make == nil {
panic("configkit engine: incomplete secret binding")
}
}
func (b *schemaBuilder) describeFields(descriptor *typeDescriptor, current reflect.Type, path Path) *schemaIssue {
for i := 0; i < current.NumField(); i++ {
field := current.Field(i)
@@ -212,9 +242,17 @@ func (b *schemaBuilder) describeFields(descriptor *typeDescriptor, current refle
return &schemaIssue{code: CodeSchemaTag, path: path.clone(), message: "inline field cannot declare a YAML name"}
}
inlineType := field.Type
seenPointers := make(map[reflect.Type]bool)
for inlineType.Kind() == reflect.Pointer {
if seenPointers[inlineType] {
return &schemaIssue{code: CodeSchemaInline, path: path.clone(), message: "inline pointer chain must terminate in a struct"}
}
seenPointers[inlineType] = true
inlineType = inlineType.Elem()
}
if inlineType.Kind() == reflect.Map && field.Type.Kind() == reflect.Pointer {
return &schemaIssue{code: CodeSchemaInline, path: path.clone(), message: "inline maps cannot be pointers"}
}
switch inlineType.Kind() {
case reflect.Struct:
if b.building[inlineType] {
@@ -426,5 +464,5 @@ func schemaIndex(segment pathSegment) (int, bool) {
return 0, false
}
index, err := strconv.Atoi(segment.key)
return index, err == nil && index >= 0
return index, err == nil && index >= 0 && strconv.Itoa(index) == segment.key
}

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