Compare commits
95 Commits
cc40880a22
...
v0.4.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 0adb94f59e | |||
| ee2bcbeed5 | |||
| 3883a5042d | |||
| c034b7e709 | |||
| a803799502 | |||
| 01b437e6ed | |||
| e6ced0d3d1 | |||
| 73ce6e9e31 | |||
| ae91f57bbe | |||
| 66e080ed34 | |||
| d8d9dbfe7f | |||
| aead20fae4 | |||
| c5a2ddee7a | |||
| 418fefdeef | |||
| 21ae2159f7 | |||
| b9425c9ae2 | |||
| fbe7b61c79 | |||
| 34380f62a5 | |||
| f2c650b5eb | |||
| 5760614834 | |||
| 88e86de03e | |||
| c98435ce8e | |||
| 6102b36adf | |||
| 7395f54513 | |||
| 5dc7e18d83 | |||
| 7f46c947dd | |||
| 550f92d7c6 | |||
| da307c9f49 | |||
| b8935e77bd | |||
| 05812a6ad3 | |||
| da02a19f2c | |||
| 9b4f9ac50e | |||
| f81def9365 | |||
| eb7de8e40a | |||
| 28e7fc5f69 | |||
| 715364b079 | |||
| b9e4dfac92 | |||
| cc98be8f0a | |||
| e20a0a0998 | |||
| 88f21d09c7 | |||
| 818d36f836 | |||
| 381430a24b | |||
| 8a62b42503 | |||
| 244104f84f | |||
| fd8a68d268 | |||
| 8702121fa4 | |||
| 6eb40c5492 | |||
| f5bbc2be5d | |||
| b3e7b62119 | |||
| cd730d5c99 | |||
| 55fd42921a | |||
| 4bd83639a3 | |||
| 994a9ea0e2 | |||
| cb5b4356aa | |||
| 22a03c9f4d | |||
| 6a1c7df4be | |||
| 75a5829132 | |||
| 1cc645f30f | |||
| 633c5cd519 | |||
| fc2b6c81a1 | |||
| a04d32c389 | |||
| 49e04442da | |||
| 874adaaa18 | |||
| 9b147bfd96 | |||
| fb2e2c5386 | |||
| 537d26e359 | |||
| 3e70ad1285 | |||
| be2ba9a20b | |||
| edd82e15b2 | |||
| 831c83a0b5 | |||
| f7ca150a7f | |||
| 9c94f2b70a | |||
| e0f9541f5f | |||
| f42ecc70c3 | |||
| 2836f825b6 | |||
| a8bfb8848d | |||
| 6ef240b1a0 | |||
| d4ec8607d1 | |||
| d6e32d8807 | |||
| 16f4212770 | |||
| 828fdd38eb | |||
| 26cece6d53 | |||
| ef0e6078f7 | |||
| 71a73bcfd8 | |||
| 9f04c09be3 | |||
| 3257b2124c | |||
| a0cd0977ee | |||
| 0da4260ad6 | |||
| cc41653bfd | |||
| 21e3f633bc | |||
| 15172294b5 | |||
| 2b65876a7d | |||
| 7c78da9cd2 | |||
| 14b10cb6a5 | |||
| 8e3cc40c78 |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -25,6 +25,9 @@ go.work.sum
|
||||
# env file
|
||||
.env
|
||||
|
||||
# Local codebase-memory tooling artifacts
|
||||
/.codebase-memory/
|
||||
|
||||
# ---> Linux
|
||||
*~
|
||||
|
||||
@@ -81,4 +84,3 @@ Temporary Items
|
||||
.history/
|
||||
|
||||
# Built Visual Studio Code Extensions
|
||||
*.vsix
|
||||
|
||||
@@ -1,2 +1,5 @@
|
||||
Please review `docs/development.md` for initial orientation in this repository
|
||||
and follow its task-specific reading guide.
|
||||
|
||||
Review and follow `docs/policy/agents.md` before recommending, planning, or
|
||||
implementing any changes to this repository.
|
||||
|
||||
20
COPYRIGHT
Normal file
20
COPYRIGHT
Normal file
@@ -0,0 +1,20 @@
|
||||
ConfigKit
|
||||
|
||||
Copyright (C) 2026 Eric Rakestraw
|
||||
|
||||
SPDX-License-Identifier: LGPL-3.0-or-later
|
||||
|
||||
This library is free software: you can redistribute it and/or modify it
|
||||
under the terms of the GNU Lesser General Public License as published by
|
||||
the Free Software Foundation, either version 3 of the License, or
|
||||
(at your option) any later version.
|
||||
|
||||
The GNU Lesser General Public License version 3 and its incorporated
|
||||
GNU General Public License version 3 text are provided in LICENSE.
|
||||
|
||||
This library is distributed in the hope that it will be useful,
|
||||
but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||
GNU Lesser General Public License for more details.
|
||||
|
||||
Third-party code and assets are subject to their respective license terms.
|
||||
46
README.md
46
README.md
@@ -2,9 +2,45 @@
|
||||
|
||||
An opinionated Go library for applications that use YAML configuration files.
|
||||
|
||||
ConfigKit is in pre-implementation. This repository currently contains its design
|
||||
and contributor policies; no usable Go library has been implemented yet.
|
||||
Linux is release-validated; macOS support is best-effort. See the
|
||||
[platform support policy](docs/policy/release.md#platform-support) for details.
|
||||
|
||||
The [initial target design](docs/roadmap/initial.md) describes planned behavior.
|
||||
The [implementation plan](docs/roadmap/implementation.md) owns the pending stages.
|
||||
See the [development guide](docs/development.md) for repository orientation.
|
||||
ConfigKit's initial loader accepts one strict YAML
|
||||
source (or explicit defaults-only input), optionally traverses explicit path and glob
|
||||
imports, strictly composes their disjoint definitions, applies typed defaults, and
|
||||
can overlay one selected fixed overlay and check an application schema version. It
|
||||
returns detached metadata and safe diagnostics.
|
||||
Applications may perform audited defaulting and normalization through a resolver,
|
||||
then report domain errors and warnings through read-only semantic validation. Secret
|
||||
tags and a path predicate drive deterministic redacted effective YAML. Typed secret
|
||||
references resolve environment names before validation, and explicit
|
||||
secrets-directory bootstrap is available as a separate startup operation. Explicit
|
||||
CLI override records apply above overlays. Value explanation is available through
|
||||
detached structured records and deterministic redacted output.
|
||||
|
||||
```go
|
||||
type Config struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Workers int `yaml:"workers"`
|
||||
}
|
||||
|
||||
func ReadConfig(path string) (Config, error) {
|
||||
result, err := configkit.Load[Config](configkit.File(path))
|
||||
if err != nil {
|
||||
return Config{}, err
|
||||
}
|
||||
return result.Config, nil
|
||||
}
|
||||
```
|
||||
|
||||
The [configuration contract](docs/config.md) describes implemented behavior. The
|
||||
[consumer adoption guide](docs/consumers/README.md) provides maintained Narratio-,
|
||||
Notarius-, WeatherReporter-, and Seriatim-shaped examples and migration guidance.
|
||||
The [deferred scope](docs/roadmap/deferred.md) records deliberate exclusions. See the
|
||||
[development guide](docs/development.md) for repository orientation and the
|
||||
[release procedure](docs/release.md) for maintainer-only candidate and publication
|
||||
steps.
|
||||
|
||||
ConfigKit is licensed under the [LGPL v3.0 or later](COPYRIGHT).
|
||||
|
||||
[Open Source attribution notices](docs/oss/attribution.md) are available.
|
||||
|
||||
133
conversion.go
Normal file
133
conversion.go
Normal 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}
|
||||
}
|
||||
57
diagnostic.go
Normal file
57
diagnostic.go
Normal file
@@ -0,0 +1,57 @@
|
||||
package configkit
|
||||
|
||||
import (
|
||||
"io"
|
||||
|
||||
"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{ value *engine.LoadError }
|
||||
|
||||
// Error implements error without rendering diagnostic messages or input values.
|
||||
func (e *LoadError) Error() string {
|
||||
if e == nil || e.value == nil {
|
||||
return "configkit: configuration load failed"
|
||||
}
|
||||
return e.value.Error()
|
||||
}
|
||||
|
||||
// Diagnostics returns a deep copy of the error's canonical diagnostics.
|
||||
func (e *LoadError) Diagnostics() []Diagnostic {
|
||||
if e == nil || e.value == nil {
|
||||
return nil
|
||||
}
|
||||
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 || e.value == nil {
|
||||
return nil
|
||||
}
|
||||
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 {
|
||||
return engine.WriteDiagnostics(output, toEngineDiagnostics(diagnostics))
|
||||
}
|
||||
9
doc.go
Normal file
9
doc.go
Normal file
@@ -0,0 +1,9 @@
|
||||
// Package configkit provides deterministic, application-owned configuration
|
||||
// loading for Go programs.
|
||||
//
|
||||
// Load supports file, byte, reader, and explicit no-input sources with strict YAML,
|
||||
// typed defaults, imports, overlays, 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
|
||||
@@ -20,8 +20,8 @@ Use distinct strict-composition and intentional-overlay operations. Composition
|
||||
rejects conflicting definitions; overlay provides explicit precedence. Keep the
|
||||
operations opinionated rather than adding arbitrary per-field merge strategies.
|
||||
The [architecture policy](../policy/architecture.md) owns the governing boundary;
|
||||
the [target design](../roadmap/initial.md#composition-and-overlay) owns the planned
|
||||
operation table until its current contract lands.
|
||||
the [current configuration contract](../config.md#strict-document-composition) owns
|
||||
the operation table and selected-profile overlay behavior.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
@@ -20,7 +20,8 @@ ConfigKit defines its supported YAML policy and keeps parser integration private
|
||||
Application types and diagnostics must not require parser-specific public types.
|
||||
Use a small internal boundary rather than a general parser/provider framework.
|
||||
The [architecture policy](../policy/architecture.md) owns this constraint; the
|
||||
[target design](../roadmap/initial.md#yaml-and-scalar-policy) owns planned syntax.
|
||||
[current YAML document](../config.md#yaml-documents) and
|
||||
[scalar](../config.md#scalars) contracts own the implemented syntax.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
@@ -22,8 +22,9 @@ values. Keep application defaults and normalization application-owned, but recor
|
||||
resolution changes so final inspection and metadata describe the actual resolved
|
||||
snapshot. Do not promise language-enforced immutability of exported Go values.
|
||||
The [architecture policy](../policy/architecture.md) owns these invariants; the
|
||||
[target design](../roadmap/initial.md#recorded-resolution-and-validation) owns the
|
||||
planned recording mechanism and ownership semantics.
|
||||
[current resolution contract](../config.md#recorded-application-resolution) and
|
||||
[ownership contract](../config.md#typed-projection-and-ownership) own the implemented
|
||||
recording mechanism and ownership semantics.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
@@ -21,8 +21,9 @@ Existing environment variables win even when empty. Report skipped files and
|
||||
partial failures without values. Keep bootstrap provenance separate from later
|
||||
lookup provenance rather than imply an unchanged environment between calls.
|
||||
General environment-variable configuration overlays are not on the roadmap.
|
||||
The [feature roadmap](../roadmap/initial.md#secret-references-and-explicit-bootstrap)
|
||||
owns the unimplemented contract; stages remain in the implementation plan.
|
||||
The [current reference contract](../config.md#typed-secret-references) and
|
||||
[bootstrap contract](../config.md#explicit-secrets-directory-bootstrap) own the
|
||||
implemented behavior.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
@@ -19,8 +19,9 @@ node tree before typed decoding, retaining the existing parser adapter boundary.
|
||||
Recorded domain resolution follows input selection. Provide safe structured
|
||||
historical values and an explanation renderer in the initial release, building
|
||||
on ADR 0003's presence and audited resolution decision. Applications own commands.
|
||||
The [feature roadmap](../roadmap/initial.md#cli-overrides) owns the exact override
|
||||
contract and [explanation contract](../roadmap/initial.md#historical-value-explanation).
|
||||
The [current override contract](../config.md#explicit-cli-overrides) and
|
||||
[explanation contract](../config.md#historical-value-explanation) own the implemented
|
||||
behavior.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
|
||||
61
docs/adr/0006-public-facade.md
Normal file
61
docs/adr/0006-public-facade.md
Normal 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.
|
||||
770
docs/config.md
Normal file
770
docs/config.md
Normal file
@@ -0,0 +1,770 @@
|
||||
# Configuration Input Contract
|
||||
|
||||
This document owns ConfigKit's currently implemented source-input, YAML syntax,
|
||||
schema, typed-decoding, loading, metadata, redaction, rendering, and ownership
|
||||
contracts. The parser, owned document representation, and decoder remain private
|
||||
behind the public loading lifecycle.
|
||||
|
||||
## Sources
|
||||
|
||||
ConfigKit exposes opaque sources through these constructors:
|
||||
|
||||
| Constructor | Current contract |
|
||||
| --- | --- |
|
||||
| `File(path)` | Requires a nonempty path, opens a regular file for each consumption, and closes only the library-owned file handle. Missing, inaccessible, unreadable, directory, and other nonregular inputs fail. |
|
||||
| `Bytes(label, data)` | Requires a nonempty, non-secret diagnostic label and snapshots bytes during construction. Later caller mutation cannot affect the source. |
|
||||
| `Reader(label, input)` | Requires a nonempty, non-secret label and non-nil reader. The reader is claimed once across all copies of the source, consumed but never closed, and cannot be reused. |
|
||||
| `NoSource()` | Selects defaults-only loading, creates no source record, and marks no paths explicit. |
|
||||
|
||||
The zero `Source` is invalid. Constructor argument failures are retained inside the
|
||||
opaque source and become safe structured diagnostics when the source is consumed.
|
||||
File sources can observe file changes between consumptions; byte sources cannot.
|
||||
|
||||
Each source is limited to 8 MiB. A load using imports or an active overlay is limited
|
||||
to 64 MiB across all accepted sources, 1,024 accepted sources, 64 active import
|
||||
edges, and 1,000,000 input nodes in aggregate. Glob expansion separately permits
|
||||
1,000,000 visited directory entries, including nonmatches. Reads request at most the
|
||||
remaining allowance plus one byte so excess data is rejected rather than silently
|
||||
truncated. Data returned together with a read error is not accepted as partial input.
|
||||
|
||||
## Single-source loading
|
||||
|
||||
`Load[T](source, options...)` accepts zero or one `Options[T]`. The configuration
|
||||
root `T` must satisfy the application schema contract below. With no options it
|
||||
starts from the Go zero value. A `Definition[T].Defaults` factory, when supplied,
|
||||
runs exactly once after successful source parsing, schema construction, optional
|
||||
schema-version checking, and selected-overlay loading. Its result is detached before
|
||||
input is applied. Resolver-provided mutable values are detached before secret lookup
|
||||
and validation, and a final detached copy prevents equal-valued aliases introduced
|
||||
by validators from escaping into the returned configuration. The publication copy
|
||||
preserves already validated secret state without rereading the environment.
|
||||
|
||||
The current lifecycle is:
|
||||
|
||||
```text
|
||||
validate options → parse the selected source → traverse/compose enabled imports
|
||||
→ build schema → check the optional application version → apply a selected overlay
|
||||
→ apply explicit CLI overrides → create and clone defaults → strictly decode
|
||||
→ recorded application resolution
|
||||
→ resolve typed secret references → semantic validation → classify secrets
|
||||
→ snapshot configuration and metadata
|
||||
```
|
||||
|
||||
`File`, `Bytes`, and `Reader` each contribute the first base source with deterministic
|
||||
ID `source-1`; enabled imports continue in depth-first order as `source-2` onward.
|
||||
`NoSource` skips parsing, supplies an absent version to a configured version checker,
|
||||
contributes no source, and continues through defaults, decode, and projection. A
|
||||
missing file remains an error; it is never interpreted as no input.
|
||||
|
||||
The current `Options` boundary accepts a defaults factory, `VersionRule`, resolver,
|
||||
semantic validator, explicit path and glob imports, one selected overlay, and
|
||||
filesystem context. It also accepts explicit CLI override records, static secret
|
||||
tags, and a dynamic path predicate. `NoSource` rejects imports, active overlays,
|
||||
and filesystem context as invalid combinations but accepts overrides. Input
|
||||
containing a reserved `configkit` mapping key is rejected while imports are
|
||||
disabled.
|
||||
|
||||
Every source, options, schema, version, defaults, decode, resolution, or validation
|
||||
failure returns a nil result and a `*LoadError`. Successful results contain caller-owned `Config`, detached
|
||||
`Metadata`, a defensive source list, and defensive nonfatal diagnostics. Mutating
|
||||
the returned configuration or inspection records cannot alter ConfigKit's private
|
||||
snapshot. ConfigKit performs no logging. `Result.WriteEffective` exposes only the
|
||||
frozen redacted projection.
|
||||
|
||||
### Application schema versions
|
||||
|
||||
`VersionRule` declares a semantic path and non-nil checker. Before defaults or typed
|
||||
decoding, the checker receives a `Version` preserving absent, explicit null, 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, overlay, 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
|
||||
nearest input locations. Reported errors fail loading. If the checker returns an
|
||||
error without reporting one, ConfigKit emits a generic `version.hook_failed`
|
||||
diagnostic and suppresses the returned error text. Warnings alone are retained on a
|
||||
successful result. Callback panics remain programming failures.
|
||||
|
||||
### Layered metadata
|
||||
|
||||
`Result.Metadata` describes every final canonical path. Queries retain explicit
|
||||
key/index distinctions using actual effective and historical container shapes;
|
||||
only parsed numeric tokens are resolved contextually. A descendant that never
|
||||
existed is not historical merely because an atomic ancestor was replaced. Exact surviving input paths
|
||||
are explicit and have a base or overlay origin; omitted paths have a default origin.
|
||||
An explicit parent mapping does not make omitted children explicit. Null state and
|
||||
available source coordinates are retained. `History` records the Go-zero or application
|
||||
default baseline followed by exact base or overlay definitions, including
|
||||
equal-valued input. Overlay replacement removes displaced descendants from effective
|
||||
metadata while retaining their base definition and the parent replacement operation
|
||||
in history. All metadata queries and returned nested records are detached.
|
||||
|
||||
`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, an overlay replacement, and a recorded resolution operation. A descendant
|
||||
query uses the ancestor's path for a whole-container or whole-sequence replacement;
|
||||
the descendant can therefore retain history after `Lookup` stops reporting it as
|
||||
effective. Equal-valued input is still an explicit base definition, while a derived
|
||||
value for absent input remains non-explicit.
|
||||
|
||||
A mapping assembled from the selected file and imports has one composite base origin
|
||||
whose locations retain source traversal order. A mapping also visited by an overlay
|
||||
has an overlay-layer composite origin with the lower contributing locations followed
|
||||
by the overlay location; an empty mapping is still an explicit container even though
|
||||
it contributes no children. Resolution origins are synthetic: they have layer
|
||||
`resolution`, no fabricated file location, and a `derived_default` or `normalization`
|
||||
operation. Reasons and dependency paths retain first occurrence order with duplicates
|
||||
removed. Source records remain ordered as selected source, depth-first imports, the
|
||||
selected overlay, then synthetic CLI records in supplied order.
|
||||
|
||||
### Secret classification and effective rendering
|
||||
|
||||
Secret classification combines `configkit:"secret"` field tags with the optional
|
||||
pure `Definition.Secret(Path) bool` predicate. Tags on concrete structs inside
|
||||
supported dynamic values are retained in typed projections, including superseded
|
||||
defaults and pre-resolution values. A tagged or predicate-classified
|
||||
container makes all descendants sensitive. ConfigKit evaluates final and historical
|
||||
paths, so replacing or deleting a definition does not erase its classification. The
|
||||
predicate can add sensitivity but cannot declassify a tagged path.
|
||||
|
||||
Recorded resolution dependencies propagate sensitivity to their derived target.
|
||||
Propagation reaches a fixed point, so record/map iteration order does not affect the
|
||||
result and dependency cycles terminate. A dependency on a classified descendant or
|
||||
on a container that contains one is treated conservatively as sensitive. Applications
|
||||
must classify secret-producing transformations whose dependencies are not recorded.
|
||||
|
||||
`Result.WriteEffective` writes the frozen resolved snapshot as deterministic redacted
|
||||
YAML. Mappings sort by decoded key string, sequences retain order, indentation is two
|
||||
spaces with noncompact sequence indentation, line endings are LF, the document has no
|
||||
start or end marker, and output ends with a newline. Nil pointers/maps/slices remain
|
||||
`null`; non-nil empty maps and sequences remain `{}` and `[]`. A secret root is
|
||||
replaced by the quoted string `'[REDACTED]'`. Redacted output is intended for
|
||||
inspection and is not promised to be reloadable.
|
||||
|
||||
Rendering uses only a safe adapter tree built from the already completed canonical
|
||||
typed projection. It does not invoke application YAML marshal hooks or text codecs;
|
||||
all text-codec projection has succeeded before `Load` returns. Caller mutation of
|
||||
`Result.Config` cannot change later output. Writer failures, including short writes,
|
||||
are returned and may leave partial redacted output; internal dumper failures are
|
||||
translated to a fixed safe error. There is no unredacted rendering option and
|
||||
ConfigKit performs no implicit logging.
|
||||
|
||||
`Result.Config` is the explicit trusted boundary and contains ordinary typed values,
|
||||
including secrets. Metadata records, diagnostics, and load errors contain no
|
||||
configuration values. Explanation snapshots deliberately expose non-secret
|
||||
configuration history after applying the redaction contract below. Applications
|
||||
must still keep source labels, resolution reasons, and diagnostic messages value-free
|
||||
because ConfigKit cannot infer and sanitize secrets embedded in arbitrary application
|
||||
prose.
|
||||
|
||||
### Historical-value explanation
|
||||
|
||||
`Result.Explain(path)` returns a detached `Explanation` for any known effective or
|
||||
historical path. Its effective value and each chronological before/after value are
|
||||
`ValueSnapshot` records containing deterministic YAML text rather than mutable Go or
|
||||
parser values. `Present=false` with empty YAML means absence or deletion; explicit
|
||||
null is present and renders as `null`. The history starts with the canonical typed
|
||||
application-default or Go-zero baseline, then includes only relevant base sources in
|
||||
traversal order, the selected overlay, supplied CLI records, and audited net
|
||||
resolution operations. Overlapping parent/child resolution records are rendered
|
||||
as one net subtree event with combined reasons and dependencies, rather than
|
||||
pretending that the recorded assignments ran in lexical order. Explicit equal
|
||||
writes remain visible. When input replay
|
||||
differs from the final typed input, a `ChangeDecoding` event records the transition
|
||||
before domain resolution. Its origin retains the decoded path's source/default
|
||||
attribution; its reason identifies static-default retention and canonical decoding.
|
||||
This built-in inspection operation is not accepted by `Resolution.Record`.
|
||||
|
||||
Base, overlay, and CLI history preserves the policy-validated input representation,
|
||||
including a superseded value that could not have decoded into the final Go field.
|
||||
Baseline, effective, and resolution values use canonical typed projection. A
|
||||
decoding event bridges replayed input and canonical typed values, so intermediate
|
||||
null/scalar replacements cannot incorrectly imply permanent default removal. Mapping
|
||||
queries reconstruct each source or override contribution, while sequences remain
|
||||
atomic: a descendant history identifies its presence before and after the containing
|
||||
sequence replacement without implying element-wise merging. Removed paths remain
|
||||
explainable; unknown and malformed paths return false.
|
||||
|
||||
Every snapshot applies the same historical tag, predicate, and recorded-dependency
|
||||
sensitivity closure as effective rendering before YAML is exposed. A redacted scalar
|
||||
is the quoted marker `'[REDACTED]'`, and a container snapshot has `Redacted=true` if
|
||||
it contains a masked descendant. Typed `Secret` snapshots contain only the configured
|
||||
environment name. `SecretReference` reports that name only for a successfully
|
||||
resolved effective reference, and is omitted when its path is classified. Credential
|
||||
values are never retained in explanation history.
|
||||
|
||||
`Result.WriteExplanation(output, path)` renders the safe structured result with the
|
||||
canonical JSON-pointer path, canonical-versus-input representation labels, effective
|
||||
source, composite locations, chronological values, resolution reasons and
|
||||
dependencies, and successful secret-reference lookup attribution. Control characters
|
||||
in paths, source identifiers and labels, reasons, dependencies, and reference names
|
||||
are escaped. An unknown path returns a fixed safe error before writing anything;
|
||||
writer errors and short writes are returned and may leave partial output. There is no
|
||||
unredacted mode.
|
||||
|
||||
Retained history consists of immutable source applications and audited subtree
|
||||
deltas, rather than a full tree copy per field. Shared nodes count once toward a
|
||||
4,000,000-node retained-history limit; exceeding it fails loading with
|
||||
`explain.limit_exceeded`. Replaying one requested path is performed on demand, and
|
||||
the returned YAML strings and nested records cannot mutate later explanations.
|
||||
|
||||
Application commands should render the separate `SecretsReport` immediately after
|
||||
an explicit `LoadSecretsDir` bootstrap, then use `WriteDiagnostics`,
|
||||
`WriteEffective`, or `WriteExplanation` on the later load result as appropriate.
|
||||
ConfigKit does not infer bootstrap outcomes from a secret reference and does not
|
||||
bundle these independent reports into a command framework.
|
||||
|
||||
## Typed secret references
|
||||
|
||||
Credential-bearing fields use the opaque `Secret` type and store an environment
|
||||
variable name in YAML, such as `api_key: OPENROUTER_API_KEY`. `SecretEnv(name)`
|
||||
constructs the same unresolved reference for application defaults or recorded
|
||||
resolution. Names must match `[A-Za-z_][A-Za-z0-9_]*`; they are public
|
||||
configuration and remain visible through `EnvName`, canonical projection, history,
|
||||
and effective output unless the path is separately classified for redaction.
|
||||
|
||||
A non-pointer `Secret` is required. An absent, null, or invalid reference, or an
|
||||
unset or empty environment value, fails with a safe `secret.*` diagnostic at the
|
||||
configuration path. `*Secret` is optional only when nil; every non-nil reference
|
||||
must resolve to a nonempty value. Secret references work in typed structs, maps,
|
||||
pointers, slices, and arrays. A dynamic `any` value does not establish secret
|
||||
semantics and cannot contain `Secret`; applications must use a typed slot.
|
||||
|
||||
ConfigKit resolves only the effective names after recorded application resolution
|
||||
and before semantic validation. Each unique name is read with `os.LookupEnv` once
|
||||
per load. Validation can inspect the credential through the deliberately named
|
||||
`Value()` accessor and distinguish success with `IsResolved()`. Later loads read
|
||||
the environment again, and cloned defaults are rebuilt from names so a previously
|
||||
resolved result cannot retain a stale credential. `Load` never writes to the
|
||||
environment.
|
||||
|
||||
Credential bytes are held only in the per-load lookup state and the trusted returned
|
||||
configuration, and are returned only by `Value`. Secret formatting uses a safe
|
||||
public-name representation, JSON marshals only the name, and projection, metadata,
|
||||
histories, diagnostics, effective output,
|
||||
and resolution snapshots never retain the credential. Validation remains read-only:
|
||||
in addition to ordinary projected-value comparison, ConfigKit checks the private
|
||||
resolved state against this load's lookup result. Validator mutation checks also
|
||||
reject changes to projected field sensitivity, even when a replacement dynamic
|
||||
concrete type preserves the same textual values. Applications must keep their
|
||||
validation messages value-free and must not include `Value()` in diagnostics.
|
||||
|
||||
For example, a typed pipeline field is declared as:
|
||||
|
||||
```go
|
||||
type PipelineConfig struct {
|
||||
APIKey configkit.Secret `yaml:"api_key"`
|
||||
}
|
||||
```
|
||||
|
||||
The executable `ExampleSecretEnv` demonstrates loading and inspecting the safe
|
||||
reference state without printing its value.
|
||||
|
||||
## Explicit secrets-directory bootstrap
|
||||
|
||||
`LoadSecretsDir(directory)` is a separate, opt-in startup operation. It is never
|
||||
called by `Load`, is not a YAML option, and does not participate in configuration
|
||||
validation. Applications choose the native directory path and should invoke the
|
||||
operation once before starting concurrent work. The selected path may itself be a
|
||||
symlink; ConfigKit resolves it to an existing canonical directory before examining
|
||||
its immediate entries.
|
||||
|
||||
The bootstrap enumerates at most 1,024 entries, including dot-prefixed entries.
|
||||
Entries whose names begin with `.` (files, directories, or symlinks, such as `.git`
|
||||
and `.gitignore`) are ignored before name or metadata validation, are never opened,
|
||||
and are omitted from the report. Remaining entries are sorted by filename; each
|
||||
must have an ASCII environment-variable name and be a regular, nonsymlink
|
||||
file. Metadata for all remaining entries is validated before file content is read
|
||||
or process state is changed. Existing environment variables win, including variables whose
|
||||
value is empty; their files are not opened. Each eligible file is limited to 1 MiB,
|
||||
and eligible content is limited to 8 MiB in total. ConfigKit removes exactly one
|
||||
terminal LF or CRLF, preserves all other whitespace, accepts an empty value, and
|
||||
rejects NUL bytes.
|
||||
|
||||
All eligible files are read successfully before application begins. Presence is
|
||||
checked again immediately before each `os.Setenv`: a variable populated since
|
||||
preflight is skipped. A write failure stops application without rollback, so prior
|
||||
writes remain and later eligible entries are `not_applied`. This operation is not
|
||||
atomic and does not provide compare-and-set semantics for concurrent environment
|
||||
mutation.
|
||||
|
||||
`SecretsReport.Entries` returns detached, filename-ordered `SecretFileResult`
|
||||
records with `loaded`, `skipped_existing`, `not_applied`, or `failed` status.
|
||||
`ExistingEmpty` distinguishes an empty existing winner. `SecretsReport.WriteTo`
|
||||
renders only quoted names, canonical paths, statuses, and that empty-state flag;
|
||||
it never retains or renders file contents. Failures return the non-nil partial
|
||||
report with a `*SecretsError` containing detached `secrets.*` diagnostics and no
|
||||
raw filesystem or environment error chain. Applications should retain the report
|
||||
alongside the later configuration result and explicitly present blocked and failed
|
||||
files when appropriate; ConfigKit stores no implicit global report.
|
||||
|
||||
### Recorded application resolution
|
||||
|
||||
An optional `Definition.Resolve` callback runs after successful typed decoding. It
|
||||
receives the working configuration and a per-load `Resolution` context. The context's
|
||||
`IsExplicit` query uses surviving input presence, so explicit false, zero, empty, and
|
||||
null are never confused with absence or static defaults.
|
||||
|
||||
`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 overlay, 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
|
||||
operation; a recorded container covers changes to its descendants. Records require
|
||||
a schema-valid path, valid kind, safe nonempty reason, and schema-valid dependency
|
||||
paths. Repeated records at one path are coalesced: reasons and dependencies retain
|
||||
their first occurrence, the last helper value is authoritative, and any normalization
|
||||
makes the combined operation a normalization.
|
||||
|
||||
ConfigKit projects the configuration before and after the callback. Every net change
|
||||
must have an exact or ancestor record. Helper-assigned values must agree with the
|
||||
final value at their declared path, which supports ordinary map-entry copy/writeback
|
||||
while detecting forgotten writeback or unrelated targets. Invalid paths, kinds,
|
||||
values, records, helper targets, and unrecorded mutations fail the load. Equal-valued
|
||||
derived assignments may still record application intent even when there is no
|
||||
structural change.
|
||||
|
||||
`Resolution.Error` and `Resolution.Warning` attach exact or nearest available input
|
||||
locations. Reported errors fail the load; warnings survive on a successful result. A
|
||||
returned resolver error adds one generic `resolve.hook_failed` error only when the
|
||||
callback did not report an error, and its text is never exposed. Callback panics are
|
||||
programming failures. Resolution and validation contexts are callback-scoped; using
|
||||
a nil, zero, or retained context outside its active callback panics with fixed,
|
||||
value-free text.
|
||||
|
||||
Successful records update effective origins and append detached metadata history
|
||||
without changing input explicitness. Removed paths leave effective metadata but
|
||||
retain history. ConfigKit privately snapshots each recorded before/after subtree for
|
||||
later safe explanation; caller mutations of `Result.Config` cannot alter those
|
||||
snapshots.
|
||||
|
||||
### Semantic validation
|
||||
|
||||
An optional `Definition.Validate` callback runs after successful resolution and
|
||||
receives the resolved configuration plus a per-load `Validation` context. It can
|
||||
collect multiple independent `validate.error` and `validate.warning` reports.
|
||||
Warnings continue the lifecycle and are returned on success; any error returns a nil
|
||||
result with the full safe diagnostic and source report.
|
||||
|
||||
Primary and related paths must be valid for the application schema. Omitted declared
|
||||
fields and missing string-map or dynamic keys are valid report targets; unknown
|
||||
struct fields and malformed paths produce `validate.invalid_path`. ConfigKit attaches
|
||||
the exact surviving input location when one exists, otherwise the nearest input
|
||||
container location, or no primary location for defaults-only synthetic state.
|
||||
|
||||
Validation is read-only. ConfigKit projects the configuration immediately before and
|
||||
after the callback and returns `validate.mutation` at every detected net-change path,
|
||||
including nested mutable state. A value that can no longer be projected is also a
|
||||
mutation failure. No mutated or otherwise invalid configuration is published.
|
||||
Callback panics remain programming failures, and retained contexts use the same
|
||||
fixed closed-context panic as version and resolution callbacks.
|
||||
|
||||
Only schema-bound exported state participates in loading, resolution audit,
|
||||
validation audit, metadata, and snapshots. Ignored or unexported ordinary fields are
|
||||
zero in returned detached values. Applications retain responsibility for domain
|
||||
rules and runtime initialization; validators should not start clients or depend on
|
||||
live services as a substitute for configuration validation.
|
||||
|
||||
## Strict document composition
|
||||
|
||||
ConfigKit strictly composes parsed base documents when `Options.Imports` is true.
|
||||
Application defaults do not participate in this operation.
|
||||
|
||||
| Existing node | Incoming node | Result |
|
||||
| --- | --- | --- |
|
||||
| Absent | Absent | Absent |
|
||||
| Absent | Present | Retain the incoming node |
|
||||
| Present | Absent | Retain the existing node |
|
||||
| Mapping | Mapping | Recurse by mapping key |
|
||||
| Any other pair of present nodes | Any other pair of present nodes | `compose.conflict` |
|
||||
|
||||
Every non-mapping overlap is a conflict, even when values are equal. Nulls and
|
||||
sequences are present atomic values, so they also conflict when repeated; sequences
|
||||
are never joined by index. Disjoint entries under shared mappings compose, making
|
||||
stable keyed mappings the natural representation for independently contributed
|
||||
entries.
|
||||
|
||||
An empty mapping contributes no child definitions. Two empty mappings compose, and
|
||||
an empty mapping neither clears nor conflicts with another mapping's children.
|
||||
Shared mapping containers retain all contributing value locations as their composite
|
||||
base origin. A conflict reports the first claim as its primary location and every
|
||||
remaining claim as related locations, in deterministic source and declaration
|
||||
traversal order. Traversal order never authorizes replacement. Composition clones
|
||||
the resulting tree and its ordered claim records rather than mutating or aliasing an
|
||||
input document.
|
||||
|
||||
## Selected overlays
|
||||
|
||||
`Options.Overlay` selects at most one overlay by name. Names must match
|
||||
`[a-zA-Z0-9_-]+`; they are not paths. The empty name and exact lowercase name
|
||||
`default` select no overlay, and `overlays/default.yml` is never loaded implicitly.
|
||||
Every other name resolves only to `<configuration-root>/overlays/<name>.yml`, with
|
||||
no extension fallback, case normalization, inheritance, or overlay stack. A selected
|
||||
missing overlay is an error.
|
||||
|
||||
The overlay is a separately policy-validated YAML mapping. It is loaded after the
|
||||
base import graph is composed and its schema version is checked, but before defaults
|
||||
and typed decoding. Overlay bytes, nodes, and source records count toward the same
|
||||
aggregate limits as base inputs. Its canonical path must remain within the configured
|
||||
root and must not duplicate a file already present in the base graph.
|
||||
|
||||
Overlay is intentionally distinct from strict composition:
|
||||
|
||||
| Existing lower node | Incoming overlay node | Result |
|
||||
| --- | --- | --- |
|
||||
| Absent | Value | Retain the overlay value |
|
||||
| Value | Absent | Retain the lower value |
|
||||
| Mapping | Mapping | Recurse by mapping key |
|
||||
| Any other pair of present nodes | Any other pair of present nodes | Replace the entire lower node |
|
||||
|
||||
Scalars, sequences, and null are atomic. Thus an overlay sequence replaces rather
|
||||
than extends a base sequence, `[]` clears it, null is an explicit replacement, and
|
||||
cross-kind replacement is allowed when the final application schema accepts it.
|
||||
Replacing a subtree removes all displaced descendants from effective presence and
|
||||
origin data; history retains the displaced definitions and identifies the parent
|
||||
replacement. Surviving overlay paths have overlay origins, while recursively shared
|
||||
mapping containers retain their contributing locations.
|
||||
|
||||
An overlay 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. Overlays cannot mask a
|
||||
base parse, composition, or version failure because those checks run first.
|
||||
|
||||
## Explicit CLI overrides
|
||||
|
||||
`Options.Overrides` accepts only records the application knows were explicitly set
|
||||
by its CLI parser. Each `Override` supplies a non-root mapping `Path`, a detached
|
||||
`Value`, and a required safe display `Flag` such as `--workers`; ConfigKit has no CLI
|
||||
parser dependency. Parser defaults must not be submitted as overrides, and `Flag`
|
||||
must never contain the raw argument or configured value. General environment
|
||||
variable overlays are an explicit non-goal: environment access is reserved for
|
||||
typed secret references.
|
||||
|
||||
Overrides apply after the selected overlay and before typed defaults/decoding, so
|
||||
the input precedence is application defaults < composed base < selected overlay <
|
||||
CLI. Recorded resolution, typed secret lookup, and semantic validation follow the
|
||||
effective override tree. Overrides also work with `NoSource`.
|
||||
|
||||
Values are converted directly into ConfigKit's owned tree without YAML parsing or
|
||||
string coercion. Supported values are `nil`, built-in strings and booleans, all
|
||||
built-in signed and unsigned integer widths except `uintptr`, finite `float32` and
|
||||
`float64`, and recursive `[]any`/`map[string]any`. Strings stay strings; duration and
|
||||
secret fields therefore receive string values. Nil is explicit null; empty strings,
|
||||
false, and zero remain intentional; empty sequences replace whole lists; and mapping
|
||||
values recursively overlay existing mappings. Map keys are sorted canonically and
|
||||
sequence order is preserved.
|
||||
|
||||
All records are detached and validated before any is applied. Root and explicit
|
||||
sequence-index paths are invalid; parsed numeric tokens remain mapping keys. A path
|
||||
may create missing mapping ancestors but cannot traverse an existing scalar, null,
|
||||
or sequence. Duplicate and ancestor/descendant-overlapping records are rejected.
|
||||
Neither paths nor values may create `configkit`, and no record may overlap the
|
||||
configured schema-version path. Unknown fields and effective type mismatches remain
|
||||
ordinary strict-decoder errors.
|
||||
|
||||
At most 1,024 records are accepted, and each record creates one synthetic CLI
|
||||
source after file/overlay sources in supplied order. Override sources share the
|
||||
1,024-source ceiling. A record has an 8 MiB string-byte limit; override strings and
|
||||
source bytes share the 64 MiB aggregate limit. Synthetic mapping keys and values
|
||||
count toward the existing 1,000,000-node and depth-256 ceilings. Cycles, named or
|
||||
unsupported types, nonfinite floats, and limit excesses fail with safe `override.*`
|
||||
diagnostics. Diagnostics and provenance identify the safe `Flag` source, never raw
|
||||
arguments or values.
|
||||
|
||||
Override origins are explicit CLI origins. History retains replaced base/overlay
|
||||
definitions and records equal-value replacements as well as structural changes.
|
||||
Caller-owned maps and slices are snapshotted during loading and must not be mutated
|
||||
concurrently with the call. The executable `ExampleOverride` shows translating a
|
||||
parser's explicit-set state into records while preserving an explicitly supplied
|
||||
zero.
|
||||
|
||||
## Explicit imports
|
||||
|
||||
Imports are opt-in. With `Options.Imports` enabled, a document may reserve its root
|
||||
`configkit` mapping for an `imports` sequence. ConfigKit removes that mapping before
|
||||
application decoding. Each sequence item is a mapping with exactly one nonempty
|
||||
string `path` or `glob` plus an optional boolean `optional`; all other shapes and
|
||||
fields are `import.invalid`. A `configkit` key below the root is invalid. With
|
||||
imports disabled, any occurrence remains `import.disabled`.
|
||||
|
||||
The directive key is `configkit`, not the `_configkit` spelling used in v0.1.0.
|
||||
There is no legacy alias: `_configkit` is ordinary application data, subject to
|
||||
normal schema validation, and does not trigger imports. When upgrading, rename
|
||||
directive keys in root files and imported fragments, and rename any application
|
||||
fields previously named `configkit` to avoid the newly reserved key.
|
||||
|
||||
Path declarations use slash-separated relative paths. Absolute paths and network
|
||||
URLs are invalid. Each path resolves from its declaring file's canonical directory,
|
||||
and files are traversed depth-first in declaration order: a file contributes its own
|
||||
body before its imports. Order stabilizes source records and diagnostics but never
|
||||
permits replacement. Required missing paths fail; `optional: true` suppresses only
|
||||
target nonexistence, not invalid paths, containment failures, directories,
|
||||
permissions, parse errors, or composition errors.
|
||||
|
||||
Glob declarations use the slash-based Go `path.Match` grammar: `*`, `?`, character
|
||||
classes, and backslash escapes are supported, while recursive `**` syntax is invalid.
|
||||
Backslashes escape pattern characters rather than acting as directory separators.
|
||||
ConfigKit validates the complete pattern before filesystem traversal, walks only
|
||||
directories selected by preceding segments, and reads wildcard directories in
|
||||
bounded batches. Every examined entry consumes the separate visit budget, including
|
||||
nonmatches. Matches are sorted lexicographically by slash-relative spelling within
|
||||
each declaration before depth-first source traversal. A required zero-match glob is
|
||||
`import.missing`; `optional: true` suppresses only zero matches. Invalid patterns,
|
||||
walk failures, matched directories, invalid documents, escapes, and repeated targets
|
||||
remain errors. Final candidates are bounded by the remaining source limit before
|
||||
their contents are accumulated.
|
||||
|
||||
The configuration root defaults to a selected file's canonical directory. A file
|
||||
source may supply `FilesystemContext.Root` to widen that root but may not supply
|
||||
`BaseDir`. Whenever byte and reader sources supply filesystem context, they must
|
||||
provide both `BaseDir` and `Root` as absolute existing directories; their diagnostic
|
||||
labels, including URI-looking labels, never imply filesystem paths. The base and
|
||||
selected file must be inside the canonical root. Nonregular targets are rejected before opening to avoid blocking on FIFOs;
|
||||
post-open regular-file checks remain in place under the stable-input assumption.
|
||||
ConfigKit canonicalizes symlinks,
|
||||
checks containment with relative paths, and opens the canonical root-relative target
|
||||
through a library-owned root handle. Contained relative or absolute symlink targets
|
||||
are accepted; escapes are rejected. Canonical paths define identity, so hard links at
|
||||
distinct paths remain distinct sources.
|
||||
|
||||
An active-stack repeat is `import.cycle` and reports the complete cycle's import
|
||||
sites. A previously visited canonical path is `import.repeated` and reports both
|
||||
inclusion routes, including diamonds and aliases. Each accepted file is read once.
|
||||
Imported fragments may contain their own root directives, but may not claim the
|
||||
application's schema-version path or replace one of its ancestors; the version must
|
||||
come from the selected source. No directory is scanned implicitly, including one
|
||||
named `conf.d`, and ConfigKit performs no remote fetching.
|
||||
|
||||
## YAML documents
|
||||
|
||||
The private YAML boundary currently accepts exactly one nonempty document whose root
|
||||
is a mapping. Comments and block or flow collections are supported. It rejects:
|
||||
|
||||
- empty input or an empty document;
|
||||
- every additional document, including an empty one;
|
||||
- duplicate decoded mapping keys;
|
||||
- non-string mapping keys;
|
||||
- explicit tags, anchors, aliases, and merge keys;
|
||||
- a quoted or plain mapping key equal to `<<`; and
|
||||
- input exceeding the fixed depth or node limits.
|
||||
|
||||
Mapping key comparison is exact: it performs no case folding or Unicode
|
||||
normalization. The private document tree retains declaration order, exact validated
|
||||
number spelling, source identifiers, and separate key/value line and column
|
||||
locations. It contains no upstream parser nodes or types.
|
||||
|
||||
Root depth is zero. Input depth is limited to 256 and each parsed input tree to
|
||||
1,000,000 nodes, counting mapping keys as input nodes. The upstream parser also has
|
||||
a private calibrated depth guard; passing that guard never bypasses ConfigKit's tree
|
||||
limits.
|
||||
|
||||
## Scalars
|
||||
|
||||
Scalar meaning is determined by ConfigKit rather than upstream coercion:
|
||||
|
||||
| Input form | Meaning |
|
||||
| --- | --- |
|
||||
| Quoted, literal, or folded scalar | String |
|
||||
| Plain `true` or `false` | Boolean |
|
||||
| Plain `null`, `~`, or an empty mapping value | Null |
|
||||
| JSON decimal number | Number, retaining its exact spelling |
|
||||
| Date/time-looking or other ordinary plain text | String |
|
||||
|
||||
JSON numbers permit an optional minus, a zero or nonzero decimal integer without a
|
||||
leading zero, an optional fraction with at least one digit, and an optional decimal
|
||||
exponent. Hexadecimal, octal, digit separators, leading plus, leading/trailing-dot
|
||||
forms, incomplete exponents, and non-finite values are rejected. Legacy boolean
|
||||
words (`yes`, `no`, `on`, and `off`) and non-lowercase variants of the reserved
|
||||
boolean/null words are rejected unless quoted.
|
||||
|
||||
Mapping keys use the same scalar classification and must classify as strings. Quote
|
||||
keys that otherwise classify as a boolean, number, or null.
|
||||
|
||||
## Application schema binding
|
||||
|
||||
ConfigKit builds a private reflection descriptor graph for each schema operation;
|
||||
it keeps no global mutable descriptor cache. A configuration root must be a struct
|
||||
or string-keyed map. Nested supported shapes are:
|
||||
|
||||
- booleans, strings, signed and unsigned integers, and finite floating-point types;
|
||||
- `time.Duration`, typed `Secret` references, and paired
|
||||
`encoding.TextMarshaler`/`TextUnmarshaler` scalars;
|
||||
- pointers, structs, string-keyed maps, slices, fixed arrays, and `any`; and
|
||||
- recursive type definitions whose eventual application values are acyclic.
|
||||
|
||||
Named scalar types retain their Go identity. `uintptr`, complex numbers, functions,
|
||||
channels, unsafe pointers, non-string map keys, and nonempty interfaces are not
|
||||
configuration types. A custom text scalar must provide both directions on its value
|
||||
or pointer method set. Building a descriptor only inspects method sets; it never
|
||||
executes application methods or parser-specific hooks.
|
||||
|
||||
Exported struct fields use an explicit `yaml` name or the lowercase Go field name.
|
||||
Unexported fields and `yaml:"-"` are ignored. `omitempty` is accepted but does not
|
||||
change binding or projection. An explicit `yaml:",inline"` flattens a struct
|
||||
or pointer-to-struct, or designates at most one string-keyed inline map for otherwise
|
||||
unmatched names. Ambiguous names, named inline fields, multiple inline maps, and
|
||||
unsupported or malformed tag options are schema errors. Inline pointers must
|
||||
terminate in a struct; pointer-to-map and recursive pointer-only inline declarations
|
||||
are rejected before the defaults factory runs. Embedded fields are ordinary
|
||||
named fields unless explicitly inlined. `configkit:"secret"` is the only current
|
||||
ConfigKit field tag and marks its bound field or container for secret classification
|
||||
and redacted inspection.
|
||||
|
||||
Schema path traversal uses these same bindings. Explicit key segments address
|
||||
mappings, explicit index segments address sequences, and parsed numeric pointer
|
||||
tokens become indices only in a sequence. Sequence indices 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.
|
||||
|
||||
Descriptors are reused by typed decoding, projection, and cloning so those operations
|
||||
cannot acquire different field bindings.
|
||||
|
||||
### Canonical module options
|
||||
|
||||
Applications with a known module catalog represent each configured item as an object
|
||||
with an explicit module identifier and an options mapping. Ordinary typed structs,
|
||||
including a small union of optional option fields, use the same strict binding above;
|
||||
application validation decides which fields and combinations apply to each module.
|
||||
String shorthand, a library module registry, and custom structural decoder hooks are
|
||||
not supported alternatives.
|
||||
|
||||
When a runtime-selected catalog genuinely requires dynamic options,
|
||||
`map[string]any` retains ConfigKit's canonical dynamic scalars. In particular,
|
||||
integers are `int64`; application validation owns allowed keys, value constraints,
|
||||
range checks, and conversion to runtime `int` or catalog objects. ConfigKit does not
|
||||
provide generic subtree conversion, and consumers must not add a YAML marshal/decode
|
||||
round trip to exercise supported behavior. The
|
||||
[consumer adoption guide](consumers/README.md) contains compiled typed and dynamic
|
||||
examples.
|
||||
|
||||
## Defaults and typed decoding
|
||||
|
||||
The private decoder starts from an independently owned clone of the application's
|
||||
typed defaults and applies only explicitly present input nodes. Defaults therefore
|
||||
never become explicit, while equal-valued input remains explicit. Successful decode
|
||||
produces detached typed state, baseline and final canonical trees, an input-presence
|
||||
index, and default/input origins for the later public loading lifecycle.
|
||||
|
||||
| Configured input | Effect on the typed baseline |
|
||||
| --- | --- |
|
||||
| Absent field | Retain the default |
|
||||
| Explicit false, zero, or empty string | Replace the default |
|
||||
| Partial mapping or string-keyed map | Recurse and retain omitted default children or entries |
|
||||
| Empty mapping | Retain existing mapping children |
|
||||
| Sequence, including an empty sequence | Replace the entire sequence |
|
||||
| Pointer object | Recurse into a non-nil default; otherwise allocate an object whose omitted children have ordinary Go zero values |
|
||||
| Allowed null | Replace the value with nil and suppress its default |
|
||||
|
||||
Null is allowed only for pointers, maps, slices, and dynamic `any` values. It is an
|
||||
error for scalars, structs, and arrays. New map entries and sequence elements begin
|
||||
from their Go zero values; ConfigKit does not invent entry-level defaults.
|
||||
|
||||
Decoding is strict at every typed depth. Unknown struct fields, type mismatches,
|
||||
invalid nulls, numeric overflow, and arrays with the wrong length are errors at the
|
||||
original semantic path and source coordinates. String-keyed maps accept arbitrary
|
||||
keys but preserve the strict schema of their values; inline maps accept unmatched
|
||||
keys according to their element descriptor. Multiple independent field failures are
|
||||
collected without publishing the partially decoded object.
|
||||
|
||||
Booleans and strings are not 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
|
||||
range and finiteness checks. Named scalar types retain their identity. Durations
|
||||
decode only from strings accepted by `time.ParseDuration`; integers are rejected.
|
||||
Paired text scalars decode only from strings through `encoding.TextUnmarshaler`, and
|
||||
codec failures are classified without exposing application error text or input
|
||||
values.
|
||||
|
||||
This decoder is available only through `Load`; ConfigKit exposes no generic document
|
||||
conversion API.
|
||||
|
||||
## Typed projection and ownership
|
||||
|
||||
ConfigKit can internally project a schema-valid application value directly into its
|
||||
owned canonical tree without YAML serialization. Projection includes every bound
|
||||
field, including ordinary zero values; `omitempty` has no effect. Struct fields keep
|
||||
binding order, dynamic map keys sort by decoded string, and sequences keep their
|
||||
order. Integers retain exact base-10 values, finite floats use their destination
|
||||
precision, durations use `time.Duration.String`, and paired text scalars use their
|
||||
canonical marshaled text.
|
||||
|
||||
Nil pointers, maps, slices, and dynamic values project as null. Non-nil empty maps
|
||||
and slices remain empty containers, preserving the distinction. Recursive acyclic
|
||||
objects are supported. Cyclic values, non-finite floats, unsupported dynamic concrete
|
||||
types, invalid codec output, and depth/node-limit violations fail with safe schema
|
||||
classifications that omit codec error text.
|
||||
|
||||
The same descriptor graph creates detached typed copies. Pointers, maps, slices,
|
||||
arrays, nested dynamic values, and shared references are recursively copied; shared
|
||||
pointer identity is not preserved as configuration meaning. Paired text scalars are
|
||||
rebuilt by unmarshaling their canonical text into fresh values, detaching private
|
||||
backing storage. Ignored and unexported struct fields are zeroed rather than copied.
|
||||
|
||||
Private structural comparison operates on canonical trees. Mapping additions,
|
||||
removals, and descendant replacements retain semantic paths. A sequence is atomic:
|
||||
any sequence change reports the sequence path and never invents correspondence
|
||||
between old and new indices. Cross-kind and null/container changes replace the
|
||||
affected path.
|
||||
|
||||
These mechanisms support decoding, resolution auditing, metadata,
|
||||
and rendering. They are not a public generic conversion or cloning API.
|
||||
|
||||
## Safe failures
|
||||
|
||||
Source failures use stable `source.*` codes and YAML failures use stable `yaml.*`
|
||||
codes declared by the root package. Diagnostics contain safe classifications,
|
||||
semantic paths when applicable, and available source coordinates. Raw parser,
|
||||
conversion, reader-error, and input text is not forwarded. Source labels and
|
||||
diagnostic messages are application-visible and therefore must never contain
|
||||
secrets; terminal control characters are escaped by `WriteDiagnostics`.
|
||||
132
docs/consumers/README.md
Normal file
132
docs/consumers/README.md
Normal file
@@ -0,0 +1,132 @@
|
||||
# Consumer Adoption Guide
|
||||
|
||||
ConfigKit removes parser setup, strict decoding, presence bookkeeping, import and
|
||||
selected-overlay traversal, precedence, provenance, secret lookup, and safe inspection from
|
||||
applications. Consumers still own their configuration structs, defaults, semantic
|
||||
validation, schema-version policy, CLI parsing, runtime matching and conversion,
|
||||
resource checks, remote acquisition, and service initialization.
|
||||
|
||||
The maintained [`examples/consumers`](../../examples/consumers) package demonstrates
|
||||
four sanitized shapes using only ConfigKit and the Go standard library:
|
||||
|
||||
| Shape | Demonstrated fit | Application code deliberately retained |
|
||||
| --- | --- | --- |
|
||||
| Narratio-like | Fresh defaults, explicit partial imports, artifact maps, recorded inherited defaults, normalization, and source ownership | Artifact meaning and the separate `model_profile` domain field |
|
||||
| Notarius-like | Canonical object bindings, a string-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 |
|
||||
|
||||
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
|
||||
features appear only when the application needs them:
|
||||
|
||||
```go
|
||||
result, err := configkit.Load[Config](configkit.File(path), configkit.Options[Config]{
|
||||
Definition: rules,
|
||||
Imports: useDeclaredImports,
|
||||
Overlay: selectedConfigOverlay,
|
||||
Overrides: explicitlySetFlags,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return start(result.Config)
|
||||
```
|
||||
|
||||
Simple consumers use neither imports nor selected overlays. A complex consumer can opt into
|
||||
both without redefining their syntax. ConfigKit overlays are configuration overlays;
|
||||
they do not select or replace an application's LLM, model, pipeline, report, or
|
||||
execution profile concepts.
|
||||
|
||||
Known module catalogs should use canonical object entries with a module identifier
|
||||
and typed options. A small union struct is sufficient when the available option
|
||||
fields are known; validation selects which fields apply to each module. Runtime-
|
||||
selected catalogs may retain `map[string]any`, whose integers are `int64`; applications
|
||||
validate keys and ranges and convert only at their runtime boundary. Neither shape
|
||||
needs YAML reserialization, a parser hook, a ConfigKit registry, or a generic subtree
|
||||
conversion API.
|
||||
|
||||
## 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:
|
||||
|
||||
- Explicit empty strings, lists, zeroes, false values, and nulls remain intentional.
|
||||
ConfigKit accepts an empty list structurally; WeatherReporter-shaped validation
|
||||
rejects an explicitly or programmatically supplied empty module/template list as a
|
||||
domain rule. Loaded and constructed values use the same validator.
|
||||
- Canonical module and object bindings replace string shorthand. Unknown fields and
|
||||
incompatible option shapes fail at their semantic paths.
|
||||
- Schema versions 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
|
||||
tags/scalars, trailing documents, and unknown typed fields. Diagnostics expose
|
||||
stable classifications and safe paths, not copied parser text.
|
||||
- General environment-variable configuration overlays retire. Application CLI
|
||||
parsers translate only explicitly set configuration flags into `Override` records.
|
||||
Separate invocation state remains outside configuration.
|
||||
- Credential fields migrate to environment-name `Secret` references. Applications
|
||||
explicitly call `LoadSecretsDir` before `Load` when they choose file bootstrap;
|
||||
blocked empty variables remain blocked, and ordinary `Load` never changes the
|
||||
process environment.
|
||||
|
||||
### Selected-overlay migration
|
||||
|
||||
To adopt the current selected-overlay API, rename `Options.Profile`,
|
||||
`LayerProfile`, and `CodeProfile*` references to their `Overlay` equivalents; move
|
||||
selected files from `profiles/<name>.yml` to `overlays/<name>.yml`; update any
|
||||
handling of the `overlay` layer and `overlay.*` diagnostic strings; then rebuild.
|
||||
The [configuration contract](../config.md#selected-overlays) is the canonical
|
||||
behavioral reference. Application-owned profile concepts, such as `model_profile`,
|
||||
are unchanged, and any CLI flag rename remains the application's decision.
|
||||
|
||||
These examples are migration evidence, not completed changes to Narratio, Notarius,
|
||||
WeatherReporter, or Seriatim. Updating those repositories, their deployed YAML, CLI
|
||||
wiring, runtime catalogs, and operational documentation remains separate downstream
|
||||
work.
|
||||
|
||||
## Maintained API examples
|
||||
|
||||
Every nontrivial initial API sketch has a compiled example or a
|
||||
focused behavioral owner:
|
||||
|
||||
| Roadmap surface | Maintained owner |
|
||||
| --- | --- |
|
||||
| `Load`, `NoSource`, validation, versions, resolution, `SecretEnv`, overrides, and explanation | Executable examples in [`example_test.go`](../../example_test.go) |
|
||||
| Source/import/selected-overlay pipeline rules and artifact defaults | Narratio-shaped executable example and fixture |
|
||||
| Canonical module objects and dynamic option handling | WeatherReporter- and Notarius-shaped examples and migration tests |
|
||||
| `LoadSecretsDir`, bootstrap reporting, typed lookup, CLI explanation | `Example_weatherStartup` |
|
||||
| Results, metadata, diagnostics, effective output, and writer failures | Focused root-package load, provenance, rendering, and explanation tests |
|
||||
|
||||
The package-level import-boundary test covers the consumer examples too: supported
|
||||
usage names no YAML dependency, parser type, parser option, or parser error chain.
|
||||
@@ -1,16 +1,18 @@
|
||||
# Development
|
||||
|
||||
This is the contributor entry point for ConfigKit. Read the task-specific owners
|
||||
below before making changes. The repository is in pre-implementation: it has no
|
||||
Go module, packages, tests, or executable examples yet. The
|
||||
[component overview](internal/overview.md) owns the implementation inventory.
|
||||
Start here when contributing to ConfigKit. Read the task-specific owners below
|
||||
before making changes. The [component overview](internal/overview.md) maps
|
||||
implementation responsibilities; the [configuration contract](config.md) owns
|
||||
public behavior.
|
||||
|
||||
## Initial orientation
|
||||
|
||||
1. Inspect the working tree and preserve unrelated changes.
|
||||
Agents must read and follow the [agent policy](policy/agents.md) before
|
||||
recommending, planning, or implementing changes.
|
||||
2. Read the architecture policy for code or design work.
|
||||
3. Read the policy, contract, and internal documents applicable to the task.
|
||||
4. Inspect relevant implementation and tests where they exist before changing them.
|
||||
4. Inspect the relevant implementation and behavioral tests before changing them.
|
||||
|
||||
## Task-specific reading guide
|
||||
|
||||
@@ -18,43 +20,113 @@ Go module, packages, tests, or executable examples yet. The
|
||||
| --- | --- |
|
||||
| Repository orientation or component responsibility | [Architecture policy](policy/architecture.md) and [component overview](internal/overview.md) |
|
||||
| Documentation changes | [Documentation policy](policy/documentation.md) and the owning document |
|
||||
| Architecture decisions or future work | [Documentation policy](policy/documentation.md), [initial target design](roadmap/initial.md), and relevant ADRs listed below |
|
||||
| Code and test changes | [Architecture policy](policy/architecture.md), [testing policy](policy/testing.md), and owning contracts/tests as they are introduced |
|
||||
| Examples or copyable assets | The owning implemented contract and related maintained examples, once available |
|
||||
| Release preparation | [Release policy](policy/release.md) and [release-readiness gaps](roadmap/release-readiness.md); the operational runbook will be added before release |
|
||||
| Licensing, dependencies, attribution, or release-document changes | [OSS policy](policy/oss.md), [project grant](../COPYRIGHT), and [attribution](oss/attribution.md); release changes also follow the release policy and procedure |
|
||||
| Architecture decisions or future work | [Documentation policy](policy/documentation.md), [deferred scope](roadmap/deferred.md), and relevant ADRs listed below |
|
||||
| Code and test changes | [Architecture policy](policy/architecture.md), [testing policy](policy/testing.md), and the owning contract and tests from the map below |
|
||||
| Examples or copyable assets | The owning implemented contract and [consumer adoption guide](consumers/README.md) |
|
||||
| Release preparation | [Release policy](policy/release.md) and [release procedure](release.md) |
|
||||
|
||||
Accepted decisions:
|
||||
|
||||
- [Separate composition and overlay](adr/0001-separate-composition-and-overlay.md)
|
||||
- [Own YAML semantics behind a private parser boundary](adr/0002-own-yaml-semantics.md)
|
||||
- [Preserve presence and record application resolution](adr/0003-preserve-presence-and-resolution-history.md)
|
||||
|
||||
- [Secret references and explicit bootstrap](adr/0004-secret-references-and-explicit-bootstrap.md)
|
||||
- [Explicit overrides and safe explanation](adr/0005-explicit-overrides-and-safe-explanation.md)
|
||||
- [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; all remain pending until implementation evidence is recorded.
|
||||
For cross-cutting changes, follow every applicable row. Completed implementation
|
||||
and remediation plans are preserved in Git history at `cb5b435`; use current
|
||||
contracts and the component overview for ongoing work. For example:
|
||||
|
||||
```sh
|
||||
git show cb5b435:docs/roadmap/remediation.md
|
||||
```
|
||||
|
||||
Release-candidate, publication, and verification work follows the operational
|
||||
[release procedure](release.md).
|
||||
|
||||
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
|
||||
|
||||
For the current documentation-only repository:
|
||||
The completed overlay rename plan and release-workflow simplification record are
|
||||
preserved in Git history at `73ce6e9` under `docs/roadmap/`. Current overlay
|
||||
behavior belongs to the [configuration contract](config.md#selected-overlays),
|
||||
and release operations belong to the [release procedure](release.md).
|
||||
|
||||
Install Go 1.25.0 or a supported patched/newer Go toolchain. Acquire declared
|
||||
dependencies explicitly before entering an offline development or validation
|
||||
environment:
|
||||
|
||||
```sh
|
||||
git diff --check
|
||||
go mod download
|
||||
```
|
||||
|
||||
Also inspect new untracked documents, verify changed local links and heading
|
||||
fragments, and review paths, examples, ownership, and current-versus-planned
|
||||
claims. The command above does not check links or untracked content.
|
||||
Run the everyday check with Bash 3.2 or newer and Git:
|
||||
|
||||
Go validation is not runnable until a module and packages exist. The scaffolding
|
||||
change must establish and verify library test, vet, and build commands here,
|
||||
including their prerequisites. Do not use an application binary build as the
|
||||
library baseline. Add executable-example and other checks when those artifacts
|
||||
are introduced; do not document nonexistent tools as operational.
|
||||
```sh
|
||||
./scripts/check.sh
|
||||
```
|
||||
|
||||
Once code exists, use focused checks while iterating and validation proportionate
|
||||
to the change under the testing policy. Documentation-only work does not require
|
||||
Go checks unless it changes behavior-bearing examples or commands they validate.
|
||||
Release validation is a separate, stricter gate governed by release policy.
|
||||
[The script](../scripts/check.sh) runs from the repository root regardless of the
|
||||
invocation directory. It checks Go formatting without rewriting files, checks
|
||||
staged/unstaged and untracked whitespace, then runs `go test ./...`,
|
||||
`go vet ./...`, and `go build ./...`, stopping on the first failure. It uses the
|
||||
installed Go toolchain by default, honors an explicit `GOTOOLCHAIN`, and disables
|
||||
dependency downloads. Acquire dependencies and any selected toolchain beforehand.
|
||||
Format reported Go files with `gofmt -w <files>`.
|
||||
|
||||
The default suite includes executable examples, maintained consumer fixtures,
|
||||
local documentation-link checks, and the dependency import-boundary check.
|
||||
It must stay deterministic and runnable without network access or credentials.
|
||||
Still inspect new/untracked content and review paths, examples, ownership, and
|
||||
current-versus-planned claims; automated checks do not replace content review.
|
||||
|
||||
Use focused checks while iterating, with validation proportionate to the change
|
||||
under the testing policy. Documentation-only work normally needs the local-link
|
||||
check below and whitespace review; behavior-bearing examples and commands also
|
||||
need execution. Use `go test -race ./...` for ownership or concurrency changes.
|
||||
The [release checker](release.md#4-validate-the-source-candidate) performs one
|
||||
minimum-toolchain race-suite run, vet, module hygiene, and Linux cross-builds.
|
||||
It already covers examples, links, and package boundaries; do not additionally
|
||||
run the everyday checker just to repeat those checks. CI never gates a release.
|
||||
|
||||
## 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, selected overlays, CLI precedence | [Composition](../internal/engine/compose_test.go), [overlay](../internal/engine/overlay_test.go), [selected overlays](../internal/engine/selected_overlay_test.go), [overrides](../internal/engine/override_test.go) | `go test ./internal/engine -run 'Compose\|Overlay\|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
|
||||
|
||||
Run the bounded fuzz targets independently from the repository root. These exact
|
||||
commands keep each campaign short and constrain concurrent workers:
|
||||
|
||||
```sh
|
||||
go test ./internal/engine -run='^$' -fuzz='^FuzzParsePath$' -fuzztime=30s -parallel=2
|
||||
go test ./internal/yaml -run='^$' -fuzz='^FuzzParseDocument$' -fuzztime=30s -parallel=2
|
||||
go test ./internal/engine -run='^$' -fuzz='^FuzzCompositionAndOverlay$' -fuzztime=30s -parallel=2
|
||||
```
|
||||
|
||||
418
docs/internal/extraction.md
Normal file
418
docs/internal/extraction.md
Normal file
@@ -0,0 +1,418 @@
|
||||
# Source Extraction Inventory
|
||||
|
||||
This inventory records reusable implementation evidence from sibling applications.
|
||||
Those checkouts are review inputs only: ConfigKit does not import them, and its
|
||||
build and tests must not require them. ConfigKit's current contracts and roadmap
|
||||
govern every adaptation.
|
||||
|
||||
## Inspected revisions
|
||||
|
||||
| Application | Revision | Worktree | Role |
|
||||
| --- | --- | --- | --- |
|
||||
| Narratio | `b68435128be1e24b94fc7ec259d6e9093669fd99` | Clean `main` tracking `origin/main` | Primary parsing, composition, import, profile, presence, defaults, resolution, and inspection source |
|
||||
| WeatherReporter | `5d306a202471e10fe98923a1df27d0b0fce5c666` | Clean `main` tracking `origin/main` | Secrets bootstrap and simple-consumer/defaults/options evidence |
|
||||
| Notarius | `77ae132e5609916da40dcd4fcc749c000a63cbac` | Clean `main` tracking `origin/main` | Canonical binding, string-version, and dynamic-option migration evidence |
|
||||
| Seriatim | `0fc92f364316c874d366b00cdc884a33e1900b25` | Clean `main` tracking `origin/main` | Independent ordered speaker/autocorrect rule evidence |
|
||||
|
||||
These revisions were inspected on 2026-09-06. Paths below are relative to each
|
||||
application repository. Record later source revisions and adaptations here when
|
||||
the owning implementation work extracts a symbol or case.
|
||||
|
||||
## Adaptation rules
|
||||
|
||||
- Copy and generalize only the smallest applicable mechanism or behavioral case.
|
||||
- Replace upstream YAML nodes, application types, commands, logging, digests, and
|
||||
provider registries with ConfigKit-owned boundaries.
|
||||
- Preserve strictness, deterministic behavior, and useful failure coverage while
|
||||
changing expectations that conflict with ConfigKit's accepted contracts.
|
||||
- Use sanitized local fixtures only. Audio, transcripts, credentials, private
|
||||
configuration, and whole application package trees are application-only.
|
||||
- Record the exact copied/generalized symbols and tests in the owning section when
|
||||
extraction occurs. Assignment prose records the original work boundary; dated
|
||||
implementation records state what was actually generalized.
|
||||
|
||||
## Narratio evidence
|
||||
|
||||
### Source parsing and strict input
|
||||
|
||||
Assigned to the YAML/source implementation:
|
||||
|
||||
- Production: `internal/config/load.go` functions `decodeStrictYAML`,
|
||||
`decodeStrictYAMLFromReader`, and `LoadSessionBytesWithOptions`; and
|
||||
`internal/config/composition.go` functions `parseCompositionDocument`,
|
||||
`parseCompositionBytes`, and `buildCompositionNode`.
|
||||
- Cases: `TestLoadersRejectTrailingYAMLDocuments`,
|
||||
`TestStrictYAMLRejectsParserExposedEmptyTrailingDocument`, and
|
||||
`TestStrictYAMLAcceptsSingleDocumentAndRejectsUnknownFields` in
|
||||
`internal/config/loader_strict_test.go`; plus duplicate-key, alias, non-mapping,
|
||||
malformed-input, scalar spelling, presence, declaration-order, and source-label
|
||||
coverage in `TestParseCompositionDocumentRetainsPresenceOwnershipAndDeclarationOrder`
|
||||
and `TestParseCompositionDocumentRejectsAmbiguousOrMalformedYAML`.
|
||||
- Preserve: labeled reader/byte input, strict unknown-field intent, rejection of
|
||||
trailing documents (including an empty second document), duplicate detection,
|
||||
and source-aware failures.
|
||||
- Adapt: translate v4 parser nodes into ConfigKit's private document tree, apply
|
||||
ConfigKit's scalar grammar and limits, and replace raw parser errors with safe
|
||||
diagnostics. Narratio's application decoders and YAML node types are not copied.
|
||||
|
||||
Implementation record (2026-09-06): the labeled byte/reader ownership and strict
|
||||
single-document cases above were generalized into `source.go`, `parse.go`, and
|
||||
`internal/yaml`. No production line was copied verbatim. ConfigKit adds byte and
|
||||
tree ceilings, shared atomic reader claims, regular-file checks, exact numeric
|
||||
lexemes, parser-depth calibration, ConfigKit-owned locations, safe diagnostic
|
||||
codes, and an AST import-boundary regression. Application typed decoding, parser
|
||||
errors, YAML node types, and session-specific loaders were not retained.
|
||||
|
||||
### Strict composition
|
||||
|
||||
Assigned to document composition:
|
||||
|
||||
- Production: `mergeAdditiveComposition`, `mergeAdditiveCompositions`,
|
||||
`validateAdditiveClaims`, `appendCompositionClaims`, `mergeAdditiveNodes`,
|
||||
`newCompositionConflict`, and cloning/path/source helpers in
|
||||
`internal/config/composition.go`.
|
||||
- Cases: `TestMergeAdditiveCompositionJoinsOnlyDisjointMappings`,
|
||||
`TestMergeAdditiveCompositionRejectsEveryDuplicateClass`, and
|
||||
`TestMergeAdditiveCompositionReportsAllClaimingSources`.
|
||||
- Preserve: recursive joining of disjoint mappings, collisions for every overlapping
|
||||
non-mapping value (including equal values and sequences), all claimant sources,
|
||||
deterministic traversal, and nonmutation of inputs.
|
||||
- Adapt: empty mappings follow ConfigKit's no-child operation rule; paths use the
|
||||
ConfigKit semantic path model; both precise locations and composite origins are
|
||||
retained. Default values never participate in strict composition.
|
||||
|
||||
Implementation record (2026-09-06): `composeDocuments`, its pre-order claim
|
||||
collection, conflict construction, and recursive mapping join in `compose.go` adapt
|
||||
the assigned production symbols. `TestComposeDocumentsJoinsDisjointMappingsAndTracksClaims`,
|
||||
`TestComposeDocumentsRejectsEveryPresentNonMappingOverlap`, and
|
||||
`TestComposeDocumentsReportsAllClaimantsAtExactLocations` adapt the assigned cases.
|
||||
The intentional empty-mapping difference is covered separately: empty mappings retain
|
||||
container provenance but contribute no child claims. No other semantic deviation
|
||||
from the extracted additive composer was required.
|
||||
|
||||
### Imports
|
||||
|
||||
Assigned to explicit import traversal and glob handling:
|
||||
|
||||
- Production: `loadPipelineCompositionSources`,
|
||||
`splitPipelineCompositionEnvelope`, `loadPipelineImports`, and related ownership
|
||||
structures in `internal/config/pipeline_composition.go`.
|
||||
- Cases: `TestSplitPipelineCompositionEnvelopePreservesInputAndDeclarationOrder`,
|
||||
`TestSplitPipelineCompositionEnvelopeRejectsMalformedShapesWithoutMutation`,
|
||||
`TestLoadPipelineCompositionImportsDisjointFieldsAndTracksOwnership`,
|
||||
`TestLoadPipelineCompositionMergesDisjointKeyedEntries`,
|
||||
`TestLoadPipelineCompositionRejectsBaseConflictsWithAllSources`,
|
||||
`TestLoadPipelineCompositionRejectsUnsafeOrInvalidImports`,
|
||||
`TestLoadPipelineCompositionRejectsSameFileAliasesAndImportedComposition`,
|
||||
`TestLoadPipelineCompositionReportsImportedParseAndSchemaSources`, and
|
||||
`TestLoadPipelineCompositionKeepsRelativePathsRootBased`.
|
||||
- Preserve: explicit declarations, declaration-order traversal, disjoint
|
||||
composition, conflict attribution, malformed/unreadable target failures, directive
|
||||
removal, and imported parse/schema source labels.
|
||||
- Adapt: replace the root-only `composition` envelope with recursive
|
||||
`_configkit.imports`; add path and glob declarations, bounded depth-first graph
|
||||
traversal, canonical containment, cycle/diamond routes, and fixed source IDs.
|
||||
Canonical paths—not `os.SameFile`—define identity, so hard links remain distinct.
|
||||
|
||||
Path-import implementation record (2026-09-06): `splitImportDirectives`,
|
||||
`parseImportDeclaration` (now in `directives.go`), `importFilesystem` (now in
|
||||
`filesystem.go`), and `importTraversal.visitPath`/`loadImportedDocuments` in
|
||||
`imports.go` adapt the assigned envelope, loading, source,
|
||||
and failure mechanics. The corresponding ConfigKit tests adapt the assigned
|
||||
declaration-order, disjoint composition, keyed-entry, all-source conflict, unsafe
|
||||
target, alias, imported parse/schema, and relative-resolution cases. ConfigKit adds
|
||||
recursive bounded DFS, `os.OpenRoot` confinement, canonical symlink identity,
|
||||
cycle/diamond route locations, optional-path semantics, aggregate budgets, and
|
||||
fragment version-placement enforcement. That checkpoint parsed glob syntax behind an
|
||||
explicit unsupported gate that the subsequent glob implementation removed; hard
|
||||
links intentionally replace Narratio's same-file rejection with path identity.
|
||||
|
||||
Glob-import implementation record (2026-09-06): ConfigKit retains the extracted
|
||||
declaration order, import failures, composition, and identity cases while adding the
|
||||
new `expandImportGlob`, `readGlobMatches`, and pattern-validation mechanisms in
|
||||
`glob.go`. Focused cases cover lexical ordering, multiple declarations, overlap,
|
||||
directories, hard links, contained and escaping symlinks, unmatched declarations,
|
||||
malformed and recursive patterns, escaped metacharacters, character classes,
|
||||
permission failures, explicit `conf.d` use, and entry/candidate budgets. This work
|
||||
has no Narratio production equivalent; it uses the selected standard-library
|
||||
`path.Match` and bounded `File.ReadDir` adaptation rather than its flat loader.
|
||||
|
||||
### Upstream profiles and ConfigKit selected overlays
|
||||
|
||||
The following upstream symbols and case names retain their original profile
|
||||
terminology. ConfigKit adapts their selected-overlay behavior without renaming the
|
||||
upstream evidence:
|
||||
|
||||
- Production: `mergeOverlayComposition`, `mergeOverlayNodes`,
|
||||
`selectPipelineProfile`, `loadPipelineProfileOverlays`, and
|
||||
`loadedProfileOverlay`.
|
||||
- Cases: `TestLoadPipelineProfilesSelectDefaultAndExplicitOverlay`,
|
||||
`TestLoadPipelineProfileSelectionErrors`,
|
||||
`TestPipelineProfileNamesRejectControlCharacters`,
|
||||
`TestLoadPipelineProfilesValidateEveryDeclaredOverlay`,
|
||||
`TestLoadPipelineProfileOverlaySemantics`,
|
||||
`TestLoadPipelineProfileRejectsNullAndKindChanges`, and
|
||||
`TestLoadPipelineProfilePairResolvesIndependentEffectivePipelines`.
|
||||
- Preserve: recursive mapping overlay, atomic scalar/sequence replacement,
|
||||
deterministic selection failures, source ownership, and independent results.
|
||||
- Adapt: load only the selected fixed overlay path; lowercase `default` means no
|
||||
selection. ConfigKit permits null and cross-kind replacement and does not support
|
||||
declared defaults, inheritance, arbitrary overlay paths, or validating every
|
||||
unselected overlay.
|
||||
|
||||
Implementation record (2026-09-06, updated 2026-09-23): `overlayDocuments`,
|
||||
`mergeOverlayMappings`, `loadSelectedOverlayDocument`, and
|
||||
`overlayDirectiveDiagnostics` adapt the assigned merge, selection, loading, and
|
||||
ownership mechanics. `TestOverlayDocumentsRecursesMappingsAndReplacesAtomicSubtrees`,
|
||||
`TestLoadAppliesSelectedOverlayAfterBaseAndBeforeDefaults`, selected-overlay
|
||||
selection/error, directive/version, containment, and budget cases adapt the assigned
|
||||
tests. ConfigKit
|
||||
uses exactly one fixed selected `.yml` source, permits null and cross-kind replacement,
|
||||
retains displaced descendant history, rejects any overlay directive or version-path
|
||||
contribution, and counts the overlay in aggregate budgets. Narratio's declared
|
||||
default, multi-overlay validation, inheritance-like application conventions, and
|
||||
null/kind restrictions were not retained.
|
||||
|
||||
### Effective inspection and provenance
|
||||
|
||||
Assigned to layered metadata, redaction, rendering, and explanation:
|
||||
|
||||
- Production: `EffectivePipelineValues`, `EffectivePipelineSources`,
|
||||
`MarshalEffectivePipeline`, `effectivePipelineFieldPaths`,
|
||||
`effectivePipelineOwners`, and composition `semanticRecords`/
|
||||
`compactSemanticRecords`.
|
||||
- Cases: `TestCompositionCanonicalOutputsAreDeterministic`,
|
||||
`TestEffectivePipelineValuesIgnoreEquivalentSourceLayout`,
|
||||
`TestConfigValidateAndShowPipelineOnlyAreSideEffectFree`,
|
||||
`TestConfigSourcesReportsStableOwnership`,
|
||||
`TestConfigSourcesReportsCanonicalFamilyAndPublishOrigins`,
|
||||
`TestConfigSourcesPreservesProfileOwnershipThroughFamilyExpansion`, and
|
||||
`TestConfigSourcesReportsRootImportProfileAndDefaultOwnership`.
|
||||
- Preserve: deterministic effective projection, stable logical paths, source
|
||||
ownership, side-effect-free inspection, and domain-attribution evidence.
|
||||
- Adapt: project directly from ConfigKit-owned typed values without YAML round trips;
|
||||
add line/column locations, composite origins, complete operation history, detached
|
||||
records, and redaction. Narratio commands, diff/digest APIs, generated artifact
|
||||
attribution, campaign logic, and party expansion remain application-owned.
|
||||
|
||||
Layered-provenance implementation record (2026-09-06): ConfigKit integrated its
|
||||
previously extracted source claims, overlay displacement records, typed baseline,
|
||||
and audited resolution snapshots in `load_metadata.go`. The representative
|
||||
`TestLayeredProvenanceAgreesWithResolvedSnapshot` adapts Narratio's stable ownership,
|
||||
profile ownership, deterministic effective projection, and side-effect-free
|
||||
inspection cases across one default/import/overlay/resolution workflow. ConfigKit
|
||||
adds chronological descendant history for parent and whole-sequence operations,
|
||||
composite empty-mapping origins, canonical resolution reasons/dependencies, private
|
||||
snapshot agreement, defensive nested copies, and concurrent load isolation. Value
|
||||
That checkpoint deliberately left rendering, redaction, explanation, commands,
|
||||
generated attribution, diffs, and digests outside its scope.
|
||||
|
||||
Effective-rendering implementation record (2026-09-06): ConfigKit adapts Narratio's
|
||||
deterministic effective-output and side-effect-free inspection cases in
|
||||
`classifySecretPaths`, `redactedDocument`, `Result.WriteEffective`, and the private
|
||||
`internal/yaml.Write` adapter. Tests cover stable sorted mappings, preserved sequence
|
||||
order, explicit indentation/line/document formatting, tagged containers, dynamic
|
||||
current and historical paths, fixed-point dependency cycles, nil/empty distinctions,
|
||||
resolved values, text-scalar snapshot reuse, application YAML-hook isolation,
|
||||
non-disclosure through failures and record formatting, caller mutation, and writer
|
||||
failures. Unlike Narratio's marshal/reparse path, ConfigKit renders only its owned
|
||||
frozen tree and substitutes redaction before the upstream dumper boundary. Historical
|
||||
value explanation and secret references were implemented at their later owning
|
||||
boundaries and are exercised by the consumer startup example below.
|
||||
|
||||
### Defaults, presence, and domain resolution
|
||||
|
||||
Assigned across schema, decoding, resolution, validation, and consumer examples:
|
||||
|
||||
- Production: `NormalizeConfig.UnmarshalYAML`, `outputPathWasSet`,
|
||||
`applyPipelineDefaults` and its component default helpers, `Resolve`,
|
||||
`ResolveLoadedPipelineCampaign`, `mergeCampaignSession`, `selectStableInput`, and
|
||||
application-supplied byte loading in `internal/app/config_loader.go`.
|
||||
- Cases: `TestNormalizeLoadAndValidate`,
|
||||
`TestRepresentativeLoadDefaultsAndValidation`,
|
||||
`TestMarshalEffectivePipelineRendersStablePublicConfiguration`,
|
||||
`TestPipelineCampaignRegistryStrictDecode`,
|
||||
`TestCampaignSessionMergeFillsStableInputs`,
|
||||
`TestCampaignSessionMergeSessionOverridesStableInputs`,
|
||||
`TestLoadWithSessionOptionsCarriesExplicitProfilePresence`, and the maintained
|
||||
extraction-example coverage in `TestMaintainedExtractionExamplesPreservePublishedContracts`.
|
||||
- Preserve: fresh defaults, explicit-presence-sensitive domain decisions, strict
|
||||
nested types, stable input selection, application-supplied labeled bytes, and
|
||||
validation after resolution.
|
||||
- Adapt: presence comes from metadata rather than custom YAML hooks or pointer/zero
|
||||
guesses; explicit empty and null values are retained; defaults are cloned and
|
||||
application-owned; every domain mutation is recorded. Campaign/session fallback,
|
||||
search paths, fetching, storage, and concrete default values remain in Narratio.
|
||||
|
||||
Implementation record (2026-09-06): ConfigKit generalized Narratio's fresh-default,
|
||||
strict nested-type, and explicit-presence cases into `decode.go` and
|
||||
`decode_test.go`. The decoder applies the owned document tree over a detached typed
|
||||
baseline, treats mappings recursively and sequences atomically, preserves explicit
|
||||
false/zero/empty/null, rejects unknown nested fields, and returns private presence
|
||||
and default/input origin artifacts. Unlike Narratio's custom YAML hooks and
|
||||
pointer/zero inference, presence comes directly from the parsed tree and equal-valued
|
||||
input remains explicit. ConfigKit also adds destination-width numeric checks,
|
||||
dynamic `int64`/`float64`, exact arrays, duration strings, paired text codecs, safe
|
||||
multi-error collection, and repeated-result isolation. Resolution, validation,
|
||||
consumer defaults, and all campaign/session algorithms remain assigned to later
|
||||
work.
|
||||
|
||||
Lifecycle integration record (2026-09-06): the public single-source `Load` boundary
|
||||
combines the already extracted labeled-source and strict-decoding mechanisms with
|
||||
Narratio's fresh-default and presence expectations. ConfigKit adds an application-
|
||||
defined pre-decode string-version check, defaults-only `NoSource` operation,
|
||||
detached base/default metadata and source reports, and explicit gates for every
|
||||
later lifecycle option. No application loader or production line was copied.
|
||||
Narratio's resolution, validation, import/profile, campaign/session, and command
|
||||
behavior remains assigned to its later owning work.
|
||||
|
||||
Resolution implementation record (2026-09-06): ConfigKit adapted the intent of
|
||||
Narratio's `Resolve`, `applyPipelineDefaults`, component default helpers,
|
||||
`mergeCampaignSession`, and `selectStableInput` into a generic recorded resolver in
|
||||
`resolution.go`. The synthetic timeout/artifact cases preserve explicit-input
|
||||
decisions, derived dependencies, normalization, and map copy/writeback without
|
||||
copying campaign/session algorithms or concrete defaults. ConfigKit adds structural
|
||||
mutation auditing, container coverage, helper/path consistency checks, equal-value
|
||||
intent, coalesced repeated writes, private before/after snapshots, deterministic
|
||||
record ordering, and safe hook diagnostics. Semantic validation remains assigned to
|
||||
the next lifecycle work.
|
||||
|
||||
Validation implementation record (2026-09-06): ConfigKit generalized Narratio's
|
||||
`TestNormalizeLoadAndValidate`, `TestRepresentativeLoadDefaultsAndValidation`,
|
||||
strict registry decoding, and maintained extraction-example expectations into the
|
||||
post-resolution `Validation` lifecycle and synthetic consumer examples. ConfigKit
|
||||
keeps domain rules application-owned, resolves schema-valid missing/dynamic report
|
||||
paths to nearest input locations, collects independent warnings/errors, and audits
|
||||
the full typed projection to reject validator mutation. Live resources, registries,
|
||||
campaign/session logic, commands, and application runtime initialization were not
|
||||
extracted.
|
||||
|
||||
### Secrets bootstrap
|
||||
|
||||
Assigned to the explicit bootstrap operation:
|
||||
|
||||
- Production: `loadSecretsFromConfigWithDependencies`, `validateSecretDirectory`,
|
||||
`validateSecretFile`, `readAPIKeyFile`, and
|
||||
`internal/fileops/read_bounded.go` function `ReadFileBounded`.
|
||||
- Cases: `TestLoadSecretsFromConfigLoadsValidFiles`,
|
||||
`TestLoadSecretsFromConfigPreservesExistingEnv`,
|
||||
`TestLoadSecretsFromConfigRelativeDirUsesCWD`,
|
||||
`TestLoadSecretsFromConfigMissingDirFails`,
|
||||
`TestLoadSecretsFromConfigUnreadableValidEntryFails`,
|
||||
`TestLoadSecretsFromConfigRejectsUnsafeModes`,
|
||||
`TestLoadSecretsFromConfigRejectsNonRegularAndOversizedEntries`, and
|
||||
`TestLoadSecretsFromConfigRejectsSymlinkAndAncestorReplacement`.
|
||||
- Preserve: bounded reads, valid environment names, existing variables (including
|
||||
empty values) winning, regular-file checks, symlink rejection, and failure cases.
|
||||
- Adapt: use ConfigKit's deterministic report/preflight/apply state machine and
|
||||
fixed budgets. Logging, configuration-driven implicit invocation, private-mode
|
||||
conventions, and application API-key plumbing remain in Narratio.
|
||||
|
||||
Implementation record (2026-09-06): `LoadSecretsDir`, `readSecretsEntries`,
|
||||
`readSecretValue`, and the private environment/file seams in `secrets.go` adapt
|
||||
Narratio's bounded-read and preserve-existing mechanisms at revision
|
||||
`b68435128be1e24b94fc7ec259d6e9093669fd99`. The corresponding tests retain its
|
||||
valid-file, existing-variable (including empty), missing/permission/read,
|
||||
nonregular, symlink, and byte-limit cases. ConfigKit validates invalid entries
|
||||
rather than skipping them, permits ordinary deployment permission modes, resolves
|
||||
the selected directory symlink, performs complete preflight before mutation, and
|
||||
emits per-file safe records without application configuration or logging.
|
||||
|
||||
## WeatherReporter evidence
|
||||
|
||||
### Secrets bootstrap
|
||||
|
||||
Assigned to the explicit bootstrap operation:
|
||||
|
||||
- Production: `loadSecrets`, `applySecrets`, `restoreEnvironment`, `secretValue`,
|
||||
and `environmentValue` in `internal/config/secrets.go`.
|
||||
- Cases: `TestLoadSecretsDisabledLeavesEnvironmentUnchanged`,
|
||||
`TestLoadSecretsOverwritesExistingEnvironment`,
|
||||
`TestLoadSecretsTrimsOneTrailingLineEnding`,
|
||||
`TestLoadSecretsRejectsInvalidDirectoryEntries`,
|
||||
`TestLoadSecretsRejectsMissingDirectory`,
|
||||
`TestLoadFileSecretDirectoryFailureLeavesEnvironmentUnchanged`,
|
||||
`TestLoadFileValidationFailureLeavesSecretEnvironmentUnset`, and
|
||||
`TestApplySecretsRollsBackOnEnvironmentFailure`.
|
||||
- Preserve: separate read/preflight and apply mechanisms, one-newline handling,
|
||||
invalid-name/type/read coverage, no writes after preflight failure, and controlled
|
||||
environment-write failure coverage.
|
||||
- Adapt: existing variables—including empty ones—are skipped, never overwritten;
|
||||
application failures produce an accurate partial report and no rollback. Skip
|
||||
entries are not read. Bootstrap is explicitly invoked and remains independent of
|
||||
YAML loading and semantic validation.
|
||||
|
||||
Implementation record (2026-09-06): the same `secrets.go` state machine adapts
|
||||
WeatherReporter's `loadSecrets`/`applySecrets` separation, environment-name check,
|
||||
regular-file rejection, and exact one-LF/CRLF removal at revision
|
||||
`5d306a202471e10fe98923a1df27d0b0fce5c666`. Tests adapt its newline matrix,
|
||||
invalid-entry and preflight-no-write cases, and controlled application failure.
|
||||
The overwrite/rollback expectation is intentionally replaced by existing-winner,
|
||||
presence-recheck, and partial-progress statuses; no configuration loader invokes
|
||||
the bootstrap.
|
||||
|
||||
### Defaults, explicit presence, and module options
|
||||
|
||||
Assigned to consumer-shaped examples and migration checks:
|
||||
|
||||
- Production: `Load`, `mergeFile`, `normalizeReportModules`,
|
||||
`traverseReportModules`, `normalizeModuleOptions`, and `decodeKnownOptions`.
|
||||
- Cases: `TestDefaults`, `TestOutputDirectoryLoading`,
|
||||
`TestOutputDirectoryValidationIsConsistentForLoadedAndConstructedConfigs`,
|
||||
`TestLoadMinimalExampleConfig`,
|
||||
`TestReportDistributorPathTemplatesSetTracksExplicitEmptyList`,
|
||||
`TestValidateReportModuleKeysWithoutMutatingOptions`,
|
||||
`TestReportModuleOverridesNormalizesConstructedOptionsWithoutMutatingConfig`,
|
||||
`TestReportModuleOverridesCanonicalizesTypedPointerOptions`,
|
||||
`TestReportDistributorPathOverridesConsistentForLoadedAndConstructedConfig`,
|
||||
`TestReportModuleValidationConsistentForLoadedAndConstructedConfig`,
|
||||
`TestConstructedReportOverridesUseExportedFields`, and
|
||||
`TestConstructedExplicitEmptyReportOverridesAreRejected`.
|
||||
- Preserve: defaults-only use, consistent loaded/constructed outcomes, explicit
|
||||
empty-list domain rejection, typed module-specific options, normalization without
|
||||
mutating caller input, and unknown-field rejection.
|
||||
- Adapt: ConfigKit permits empty lists structurally and exposes their presence to
|
||||
application validation. Object bindings replace shorthand, explicitly set flags
|
||||
become CLI override records, and general environment overlays retire. Report
|
||||
semantics, registries, rendering, and module option validation remain application
|
||||
responsibilities.
|
||||
|
||||
Consumer-example implementation record (2026-09-06): at Narratio revision
|
||||
`b68435128be1e24b94fc7ec259d6e9093669fd99`, the maintained consumer cases adapt
|
||||
`TestMaintainedExtractionExamplesPreservePublishedContracts`,
|
||||
`TestConfigValidateAndShowPipelineOnlyAreSideEffectFree`, and the stable source-
|
||||
ownership tests into a sanitized partial-import/artifact fixture. ConfigKit replaces
|
||||
application composition envelopes and command output with `_configkit.imports`,
|
||||
typed defaults, recorded resolution, metadata, and explanation. Artifact meaning,
|
||||
model/pipeline profiles, commands, and generated-domain attribution remain in the
|
||||
example application layer; no production function was copied.
|
||||
|
||||
At WeatherReporter revision `5d306a202471e10fe98923a1df27d0b0fce5c666`,
|
||||
`TestDefaults`, explicit-empty-list cases, loaded/constructed consistency cases, and
|
||||
nonmutating module-option normalization were adapted into the WeatherReporter-shaped
|
||||
fixtures and tests in [`examples/consumers`](../../examples/consumers). ConfigKit's
|
||||
detached defaults replace source mutation, canonical module objects replace string
|
||||
shorthand, ordinary validation retains empty-list and option compatibility rules,
|
||||
and explicit `Override` records replace external configuration-field overlays. The
|
||||
startup example composes the separately implemented bootstrap report, typed secret
|
||||
lookup, and explanation APIs without copying WeatherReporter's registries or
|
||||
rendering/runtime modules.
|
||||
|
||||
Notarius revision `77ae132e5609916da40dcd4fcc749c000a63cbac` supplied
|
||||
binding/version/dynamic-option migration evidence only. The local example uses an
|
||||
ordinary canonical object, a string `VersionRule`, ConfigKit's `int64` dynamic
|
||||
integer convention, and application range checking; it does not copy a catalog or
|
||||
environment overlay. Seriatim revision
|
||||
`0fc92f364316c874d366b00cdc884a33e1900b25` supplied ordered rule-file evidence.
|
||||
The local examples load speaker and autocorrect files independently and keep their
|
||||
matching/replacement algorithms outside ConfigKit. Neither source is a build or test
|
||||
dependency.
|
||||
|
||||
## Application-only material
|
||||
|
||||
Do not extract application commands, filesystem discovery, logging, runtime
|
||||
initialization, remote clients, LLM/provider setup, audio/transcript fixtures,
|
||||
configuration digests, report rendering, campaign/session algorithms, or consumer
|
||||
resource validation into ConfigKit. Maintained consumer examples use only small
|
||||
synthetic types and fixtures to prove that these responsibilities remain
|
||||
straightforward.
|
||||
@@ -1,10 +1,39 @@
|
||||
# Internal Component Overview
|
||||
|
||||
ConfigKit has no implemented Go packages or components yet. This document will
|
||||
inventory components and link to their current contracts and focused internal
|
||||
documentation as they are introduced.
|
||||
ConfigKit exposes 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.
|
||||
|
||||
The [architecture policy](../policy/architecture.md) owns binding boundaries and
|
||||
invariants. The [initial target design](../roadmap/initial.md) describes planned
|
||||
behavior; it is not an inventory of implemented features. See the
|
||||
[development guide](../development.md) for contributor orientation and validation.
|
||||
Implemented component owners:
|
||||
|
||||
- `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`.
|
||||
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
28
docs/oss/attribution.md
Normal file
28
docs/oss/attribution.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Open Source Attribution
|
||||
|
||||
Component notices follow the [OSS policy](../policy/oss.md). ConfigKit's own
|
||||
grant is in [COPYRIGHT](../../COPYRIGHT); dependency notices do not change it.
|
||||
|
||||
## go-yaml v4
|
||||
|
||||
Version: `go.yaml.in/yaml/v4 v4.0.0-rc.6`.
|
||||
|
||||
Copyright 2011-2019 Canonical Ltd
|
||||
|
||||
Copyright 2025 The go-yaml Project Contributors
|
||||
|
||||
Copyright 2006-2010 Kirill Simonov
|
||||
|
||||
SPDX-License-Identifier: Apache-2.0 AND MIT
|
||||
|
||||
License and notices: [Apache 2.0](licenses/Apache-2.0.txt) |
|
||||
[MIT](licenses/MIT.txt) |
|
||||
[upstream NOTICE](https://github.com/yaml/go-yaml/blob/1c17b9cee72bf81ae73a8be50fbd7dd3a9985125/NOTICE) |
|
||||
[upstream LICENSE](https://github.com/yaml/go-yaml/blob/1c17b9cee72bf81ae73a8be50fbd7dd3a9985125/LICENSE)
|
||||
|
||||
Source: [go-yaml](https://github.com/yaml/go-yaml/tree/1c17b9cee72bf81ae73a8be50fbd7dd3a9985125).
|
||||
|
||||
The upstream NOTICE identifies the libyaml-derived portion as MIT-licensed and
|
||||
the remaining project files as Apache-2.0-licensed; these are not alternative
|
||||
licenses for the entire module. The statements above preserve the NOTICE's
|
||||
wording. The module declares no additional Go module requirements.
|
||||
202
docs/oss/licenses/Apache-2.0.txt
Normal file
202
docs/oss/licenses/Apache-2.0.txt
Normal file
@@ -0,0 +1,202 @@
|
||||
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
18
docs/oss/licenses/MIT.txt
Normal file
18
docs/oss/licenses/MIT.txt
Normal file
@@ -0,0 +1,18 @@
|
||||
MIT License
|
||||
|
||||
Copyright (c) <year> <copyright holders>
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy of this software and
|
||||
associated documentation files (the "Software"), to deal in the Software without restriction, including
|
||||
without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the
|
||||
following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all copies or substantial
|
||||
portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT
|
||||
LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO
|
||||
EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
|
||||
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE
|
||||
USE OR OTHER DEALINGS IN THE SOFTWARE.
|
||||
91
docs/policy/agents.md
Normal file
91
docs/policy/agents.md
Normal file
@@ -0,0 +1,91 @@
|
||||
# Agent Policy
|
||||
|
||||
**Base Policy Version:** 1.1
|
||||
|
||||
**Last Updated**: 2026-09-23
|
||||
|
||||
## Purpose
|
||||
|
||||
Agents working in this repository must read and adhere to the following house rules.
|
||||
|
||||
## Implementation Discipline
|
||||
|
||||
**KEY PRINCIPLES:** Write the simplest idiomatic code that satisfies the user's
|
||||
request. Do not implicitly strengthen requirements, restrictions, or guarantees
|
||||
beyond what the user actually requested.
|
||||
|
||||
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.
|
||||
|
||||
**Do not invent guarantees.** Do not strengthen public contracts, guarantees,
|
||||
or invariants beyond those expressly requested by the user.
|
||||
|
||||
**Use the standard library.** For example: in almost every case, the best
|
||||
approach to copying files is to use the standard library. Writing custom code to
|
||||
implement power-loss protection at the application level, or to guard against
|
||||
bad actors with privileged and concurrent access to the host's filesystem,
|
||||
is wasteful, unnecessary, and not idiomatic. Stick to the simple, standard, and
|
||||
idiomatic approach unless another path is expressly requested by the user.
|
||||
|
||||
**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.
|
||||
|
||||
## Git Commit Policy
|
||||
|
||||
By default, create a commit after completing each coherent unit of work, using a
|
||||
concise, plain-English message describing what changed. This policy is standing
|
||||
authorization to create those local commits; a separate user request is not
|
||||
required. An explicit user instruction to leave work uncommitted takes precedence.
|
||||
|
||||
A unit of work is a focused, reviewable outcome, including its relevant tests and
|
||||
documentation. Do not commit after every intermediate edit or create empty commits.
|
||||
Complete the appropriate validation before committing. If work is incomplete or
|
||||
validation is blocked, report that state rather than presenting it as completed.
|
||||
|
||||
Before committing:
|
||||
|
||||
1. Run `git status --short` and review the relevant diff, including any staged
|
||||
changes. Identify changes that predate the current unit of work.
|
||||
2. Exclude secrets, local environment files, generated junk, and unrelated changes.
|
||||
3. Stage only the files or hunks belonging to the completed unit. Use `git add .`
|
||||
only when every included change belongs to that unit. Do not disturb unrelated
|
||||
staged or unstaged work.
|
||||
4. Review `git diff --staged` and run appropriate checks for the change. Do not
|
||||
repeat checks already passed for unchanged content merely to create a commit.
|
||||
5. Review `git log --oneline -n 10` and use
|
||||
`git commit -m "<concise plain-English commit message>"`.
|
||||
6. Report the commit identifier and any remaining uncommitted work.
|
||||
|
||||
If the unit cannot be safely separated from pre-existing work, leave it uncommitted
|
||||
and explain the overlap rather than silently including that work. If a commit
|
||||
fails, preserve the changes and report the failure; do not bypass hooks.
|
||||
|
||||
Default commit authorization does not authorize pushing, publishing, amending,
|
||||
resetting, reverting, or rewriting history. Those actions require a separate user
|
||||
instruction. Do not use `git commit -a` or `git commit --no-verify` to bypass the
|
||||
staging and validation discipline above.
|
||||
@@ -7,10 +7,10 @@
|
||||
## Purpose and applicability
|
||||
|
||||
ConfigKit is an opinionated Go library for consistent YAML configuration loading
|
||||
and resolution across consuming applications. This policy governs contributions
|
||||
now; it does not claim that the target features are implemented. The
|
||||
[component overview](../internal/overview.md) owns implementation inventory and
|
||||
[initial roadmap](../roadmap/initial.md) owns unimplemented target behavior.
|
||||
and resolution across consuming applications. This policy governs contributions.
|
||||
The [component overview](../internal/overview.md) owns implementation inventory, the
|
||||
[configuration contract](../config.md) owns implemented behavior, and the
|
||||
[deferred scope](../roadmap/deferred.md) preserves deferred scope.
|
||||
|
||||
## Success criteria
|
||||
|
||||
@@ -48,8 +48,8 @@ existing primitives cannot accommodate it cleanly, and show how the extension
|
||||
preserves a coherent model. Application-specific interpretation and validation
|
||||
remain appropriate. Reuse Narratio's applicable infrastructure under these
|
||||
boundaries, and keep simple consumers independent of optional composition
|
||||
features. The [consumer API design](../roadmap/initial.md#consumer-api-design)
|
||||
describes the proposed surface; this policy does not claim it is implemented.
|
||||
features. The [consumer adoption guide](../consumers/README.md) demonstrates the
|
||||
implemented surface against representative simple and complex shapes.
|
||||
|
||||
## Responsibility boundaries
|
||||
|
||||
@@ -71,11 +71,23 @@ network provider into the library.
|
||||
## Public API and dependency direction
|
||||
|
||||
Keep one small, coherent public surface for the supported lifecycle. Simple
|
||||
single-document consumers must not need import, profile, or provider machinery.
|
||||
single-document consumers must not need import, overlay, or provider machinery.
|
||||
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
|
||||
@@ -94,7 +106,7 @@ same inputs and options. Invalid input must not produce a successful usable
|
||||
configuration. Do not run application hooks on invalid typed configuration.
|
||||
|
||||
User-controlled precedence is application defaults, composed base, selected
|
||||
profile, then explicitly supplied CLI overrides. Applications own flag parsing;
|
||||
overlay, then explicitly supplied CLI overrides. Applications own flag parsing;
|
||||
ConfigKit owns overlay semantics and provenance. Domain resolution follows these
|
||||
inputs and must remain recorded, not become a hidden override channel.
|
||||
|
||||
@@ -103,12 +115,12 @@ decoding. Preserve presence and source information through processing. Ordinary
|
||||
values must not be used to infer whether input was absent. Final configuration,
|
||||
metadata, and effective inspection must agree, including application resolution
|
||||
changes; see [ADR 0003](../adr/0003-preserve-presence-and-resolution-history.md).
|
||||
Safe historical values and an explanation renderer belong to the initial target,
|
||||
Safe historical values and an explanation renderer are part of the implemented contract,
|
||||
including superseded/deleted paths and effective source attribution. Inspection
|
||||
must agree with the final typed snapshot and apply historical sensitivity rules.
|
||||
See [ADR 0005](../adr/0005-explicit-overrides-and-safe-explanation.md).
|
||||
Detailed syntax and operation tables belong to the target roadmap until their
|
||||
current configuration contract is implemented.
|
||||
Detailed syntax and operation tables belong to the
|
||||
[current configuration contract](../config.md).
|
||||
|
||||
## Ownership and confidentiality
|
||||
|
||||
@@ -136,6 +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 roadmap owns the complete deferred-feature inventory. Significant boundary
|
||||
The [deferred scope](../roadmap/deferred.md#explicit-non-goals-and-deferred-scope)
|
||||
owns the deferred-feature inventory. Significant boundary
|
||||
changes require an ADR and corresponding policy/contract updates; implementation
|
||||
convenience alone is not a reason to broaden the public API.
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
# Documentation Policy
|
||||
|
||||
**Base Policy Version:** 1.0
|
||||
**Base Policy Version:** 1.1
|
||||
|
||||
**Last Updated:** 2026-09-06
|
||||
**Last Updated:** 2026-09-26
|
||||
|
||||
Licensing ownership incorporates the upstream documentation policy v1.5;
|
||||
unrelated policy revisions remain outside this adoption.
|
||||
|
||||
## Purpose
|
||||
|
||||
@@ -28,8 +31,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,36 +59,47 @@ 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 |
|
||||
| Agent rules | `docs/policy/agents.md` | Implementation discipline, proportionality, rules against inventing contracts, and local commit authorization; not application contracts or architecture |
|
||||
| Documentation rules | `docs/policy/documentation.md` | Ownership and document lifecycle |
|
||||
| Testing rules | `docs/policy/testing.md` | Risk-based sufficiency, test boundaries, and maintenance principles |
|
||||
| Release rules | `docs/policy/release.md` | Compatibility, integrity, gates, immutability, and exceptions |
|
||||
| Release procedure | `docs/release.md`, when operational | Exact maintainer commands, prerequisites, supported targets, and verification |
|
||||
| Licensing and attribution rules | `docs/policy/oss.md` | Project licensing conventions, attribution format, notice links, discoverability, and maintenance |
|
||||
| Project grant | `COPYRIGHT` | Project identity, holders and years, SPDX expression, grant, disclaimer, and third-party statement |
|
||||
| Project license texts | `LICENSE` | Complete selected license and incorporated companion text |
|
||||
| Upstream attribution | `docs/oss/attribution.md` | Component names, copyright statements, license identification, and source/license/notice links |
|
||||
| Standard dependency license texts | `docs/oss/licenses/` | Complete verbatim texts, not project-specific upstream notices |
|
||||
| Release procedure | `docs/release.md` | Exact maintainer commands, prerequisites, supported targets, and verification |
|
||||
| Exported Go API | Go documentation beside declarations | Symbol contracts, errors, ownership, and lifecycle API behavior |
|
||||
| YAML and resolution semantics | `docs/config.md`, as behavior lands | Syntax, composition, precedence, conversion, secret references/bootstrap, and explanation semantics; Go reference docs link here for shared rules |
|
||||
| YAML and resolution semantics | `docs/config.md` | Syntax, composition, precedence, conversion, secret references/bootstrap, and explanation semantics; Go reference docs link here for shared rules |
|
||||
| Consumer walkthroughs | `docs/consumers/`, when useful | Task-oriented usage and consumer responsibilities, not duplicate API reference |
|
||||
| Component inventory | `docs/internal/overview.md` | Implemented components and links to their owners |
|
||||
| Component mechanics | Other `docs/internal/` files, when needed | Detailed implementation behavior and change recipes |
|
||||
| Architectural decisions | `docs/adr/` | Context, decisions, alternatives, consequences, and supersession history |
|
||||
| Initial target | `docs/roadmap/initial.md` | Unimplemented end-state and deferred feature scope |
|
||||
| Implementation plan | `docs/roadmap/implementation.md` | Stages, dependencies, acceptance gates, and progress |
|
||||
| Release readiness gaps | `docs/roadmap/release-readiness.md` | Outstanding release prerequisites and closure evidence |
|
||||
| Deferred scope | `docs/roadmap/deferred.md` | Deliberate exclusions and the conditions for reconsidering them |
|
||||
| Future implementation plans | Task-specific files under `docs/roadmap/` | Unfinished work, task order, acceptance gates, and progress; completed plans retire to Git history |
|
||||
| Historical release notes | `docs/releases/` | Version-specific historical summaries required by release policy |
|
||||
| Complete examples | `examples/` and executable Go examples | Maintained artifacts; prose links instead of duplicating full examples |
|
||||
|
||||
A listed future owner is not a requirement to create an empty file now. Create
|
||||
API, consumer, example, and component documents when the corresponding behavior
|
||||
A listed owner is not a requirement to create an empty file before it has useful
|
||||
content. Create API, consumer, example, and component documents when the corresponding behavior
|
||||
exists and there is useful content to own. Link only to existing files; use plain
|
||||
code paths for planned files. Release prerequisites have the explicit temporary
|
||||
exception recorded in the release policy.
|
||||
code paths for planned files. Unvalidated candidate and publication work has the
|
||||
explicit temporary exception recorded in the release policy.
|
||||
|
||||
ConfigKit has no owned HTTP service, application CLI, or runtime operations
|
||||
interface. Do not create those reference documents for consuming applications.
|
||||
|
||||
## Boundary rules
|
||||
|
||||
The [OSS policy](oss.md) owns licensing and attribution conventions. COPYRIGHT
|
||||
owns the project grant, LICENSE owns its complete texts, and attribution entries
|
||||
own component-specific information. README and new release documents provide
|
||||
navigation; the release procedure owns concrete release URLs and publication
|
||||
mechanics. Do not duplicate notice lists in those documents.
|
||||
|
||||
Architecture owns governing constraints; ADRs explain why; contracts describe
|
||||
implemented semantics; internal documentation explains how. A roadmap may link
|
||||
to all of them but must not become a second current reference.
|
||||
|
||||
170
docs/policy/oss.md
Normal file
170
docs/policy/oss.md
Normal file
@@ -0,0 +1,170 @@
|
||||
# Open Source Licensing And Attribution Policy
|
||||
|
||||
**Base Policy Version:** 1.2
|
||||
|
||||
**Last Updated**: 2026-09-26
|
||||
|
||||
## ConfigKit applicability
|
||||
|
||||
ConfigKit's selected grant is LGPL-3.0-or-later, recorded in
|
||||
[COPYRIGHT](../../COPYRIGHT), with the complete LGPLv3 and incorporated GPLv3
|
||||
texts in [LICENSE](../../LICENSE). Adoption does not change that grant.
|
||||
The [release procedure](../release.md) owns the Gitea source host, source-only
|
||||
Go module distribution, and annotated-tag publication mechanism. No separate
|
||||
hosted download page or license asset is required for this source-only channel.
|
||||
|
||||
## Purpose And Scope
|
||||
|
||||
This policy defines project licensing conventions and a repeatable House Format
|
||||
for upstream attribution and license notices. It covers the project's own code
|
||||
and upstream components incorporated into or distributed with the project,
|
||||
including relevant transitive dependencies and
|
||||
copied code or assets. Treatment follows the component's terms and actual use,
|
||||
rather than its classification as a direct dependency.
|
||||
|
||||
The baseline is a publicly available source repository with releases published
|
||||
alongside it. The format is independent of language, forge, and build system.
|
||||
It does not require attribution for every development tool, establish license
|
||||
compatibility, or resolve every obligation arising from use or distribution.
|
||||
|
||||
## Canonical Locations
|
||||
|
||||
The [documentation policy](documentation.md) assigns ownership:
|
||||
|
||||
- `COPYRIGHT` contains the project name, copyright statements, SPDX expression,
|
||||
license grant, warranty disclaimer, and statement about third-party material.
|
||||
- `LICENSE` contains the complete, unmodified project license text, including
|
||||
incorporated companion text. Dependency attribution does not relicense the project.
|
||||
- `docs/oss/attribution.md` contains the canonical component entries.
|
||||
- `docs/oss/licenses/` contains complete, verbatim standard license texts,
|
||||
including companion texts where applicable.
|
||||
|
||||
Use recognizable license filenames, such as `Apache-2.0.txt` or `LGPL-3.0.txt`.
|
||||
A shared standard text may serve multiple entries. Keep explanations outside
|
||||
license texts and preserve distinct versions and exceptions.
|
||||
|
||||
## Project Licensing
|
||||
|
||||
Unless the author specifies otherwise, use **GPL version 3 or later**
|
||||
(`GPL-3.0-or-later`) for applications and **LGPL version 3 or later**
|
||||
(`LGPL-3.0-or-later`) for libraries. An author's explicit choice takes precedence:
|
||||
for example, a one-off script may use MIT. Such a choice requires no exception
|
||||
rationale, additional approval, or compensating controls under this policy.
|
||||
|
||||
These defaults guide new license selection. Adopting this policy does not
|
||||
relicense existing code or override third-party terms. Preserve an existing
|
||||
license unless the author deliberately changes it. For mixed-purpose projects,
|
||||
follow the author's selected scope rather than inferring different licenses for
|
||||
individual packages.
|
||||
|
||||
Keep project name, years, and holders accurate in COPYRIGHT; do not assume the
|
||||
maintainer owns every contribution or advance years automatically. For another
|
||||
selected license, adapt the grant, SPDX expression, disclaimer, and text
|
||||
references together.
|
||||
|
||||
For GPLv3 projects, copy the complete GPLv3 text into `LICENSE`. For LGPLv3
|
||||
projects, place the complete LGPLv3 text followed by the complete GPLv3 text in
|
||||
`LICENSE`; the LGPL incorporates the GPL terms. Keep the texts unmodified and
|
||||
place project-specific statements in `COPYRIGHT`. For the default grants, the
|
||||
grant and SPDX expression in `COPYRIGHT` must expressly identify `or-later`;
|
||||
follow the author’s choice if a different grant is selected.
|
||||
|
||||
For other licenses, provide their complete text and any required project-specific
|
||||
fields; the standard reference collection remains unchanged. Keep `COPYRIGHT`,
|
||||
`LICENSE`, SPDX expressions, and README descriptions consistent with the actual
|
||||
grant. This convention introduces no blanket requirement for per-file headers;
|
||||
preserve notices or add them where the applicable terms require them.
|
||||
|
||||
## Upstream Attribution House Format
|
||||
|
||||
Each component entry has five fields:
|
||||
|
||||
1. **Project name**, normally as the entry heading.
|
||||
2. **Upstream copyright statement or statements**, preserving their wording.
|
||||
3. **SPDX license identifier or expression**, when applicable.
|
||||
4. **License and notice links**, covering the applicable standard texts and
|
||||
upstream project-specific material.
|
||||
5. **Source repository link**, identifying the source corresponding to the
|
||||
imported version.
|
||||
|
||||
The [go-yaml entry](../oss/attribution.md#go-yaml-v4) demonstrates this format
|
||||
with the pinned module's actual statements and links.
|
||||
|
||||
These are five fields, not a line limit. Include multiple statements and links
|
||||
where needed. Omit the SPDX field when no applicable identifier exists; identify
|
||||
nonstandard terms accurately through the linked text. Preserve licensing
|
||||
alternatives and exceptions, and record a selected option when relevant. Do not
|
||||
invent an identifier or silently resolve an unsettled licensing choice.
|
||||
|
||||
## License Texts And Upstream Links
|
||||
|
||||
Link standard license texts to their local copies. Link project-specific
|
||||
notices to the upstream copies instead of storing them locally. Every upstream
|
||||
repository or notice link must identify the exact commit corresponding to the
|
||||
imported version, including the selected source revision for copied material.
|
||||
Do not use moving branches or version tags as substitutes for that commit.
|
||||
|
||||
Classify material by its contents, not its filename. An upstream `LICENSE`,
|
||||
`COPYING`, or similar file may contain project-specific notices or exceptions
|
||||
in addition to standard wording. Link both the local standard text and the
|
||||
commit-specific upstream file when needed. Make companion license texts directly
|
||||
reachable from the entry rather than leaving readers to infer their location.
|
||||
|
||||
For project-specific license and attribution material, this policy accepts
|
||||
direct links to the applicable upstream texts as satisfying reproduction,
|
||||
inclusion, or copy requirements. This is the adopted house interpretation.
|
||||
Agents must apply it without adding local-copy requirements or seeking repeated
|
||||
approval solely because a license uses those terms.
|
||||
|
||||
This interpretation concerns notice reproduction. Concrete obligations such as
|
||||
marking modified files or supplying corresponding source receive their own
|
||||
applicable treatment. Preserve required notices already present in incorporated
|
||||
source files; the central attribution entry does not authorize stripping them.
|
||||
|
||||
## Discoverability
|
||||
|
||||
End the project's README with two friendly statements linking to its actual
|
||||
grant in `COPYRIGHT` and the attribution document. ConfigKit's [README](../../README.md)
|
||||
uses "ConfigKit is licensed under the LGPL v3.0 or later" and "Open Source
|
||||
attribution notices are available", linking the license name to COPYRIGHT and
|
||||
the attribution phrase to `docs/oss/attribution.md` relative to the repository root.
|
||||
|
||||
Each new checked-in release document and its hosted download page must end
|
||||
with links whose visible text is `COPYRIGHT | LICENSE | ATTRIBUTION`. Use absolute
|
||||
URLs to those files at the release's immutable tag or corresponding immutable source
|
||||
revision so the links work in both locations. This release navigation is distinct
|
||||
from upstream component links, which must use the imported commit.
|
||||
|
||||
Reuse the checked-in release document as the hosted release body where supported.
|
||||
The release procedure owns concrete URLs and publication mechanics for delivering
|
||||
the links through the selected channel. Do not maintain duplicate notice lists.
|
||||
These conventions do not by themselves require separate downloadable license
|
||||
assets, source bundles, or a different binary package format.
|
||||
|
||||
## Maintenance And Exceptions
|
||||
|
||||
Update project licensing statements together when the author changes the grant
|
||||
or its scope. Update upstream notices in the change that adds, removes, upgrades,
|
||||
copies, or changes the distribution of a relevant component. Check the selected source's license
|
||||
and notices rather than assuming they are unchanged. Update affected entries,
|
||||
commit links, and texts; retain historical versions through Git.
|
||||
|
||||
Check affected statements, faithful standard-text copies, and link destinations
|
||||
as part of ordinary change review. For a future release tag, check intended file
|
||||
paths against the candidate source; the tag need not resolve remotely before
|
||||
publication. Do not add publication-time network checks, CI polling, recurring
|
||||
legal sign-offs, mandatory scanners, or a full software bill of materials merely
|
||||
to implement this policy.
|
||||
|
||||
Use the House Format routinely. Raise an exception only for a concrete obligation
|
||||
or unresolved rights question it does not address, including any arising from a
|
||||
particular distribution channel. Record the issue and selected treatment in the
|
||||
adopted policy or affected attribution entry as appropriate. Resolve the issue
|
||||
before the affected use or distribution without reopening settled choices.
|
||||
Common ownership of projects is neither required nor a blanket exception.
|
||||
|
||||
Apply the formatting convention prospectively. Do not rewrite published tags,
|
||||
notes, binaries, or checksums to retrofit it. Address actual omissions deliberately
|
||||
under the applicable terms and the [release correction policy](release.md#immutability-and-correction).
|
||||
Ordinary attribution maintenance and release links are preparation work, not new
|
||||
release approval gates.
|
||||
@@ -1,8 +1,11 @@
|
||||
# Release Policy
|
||||
|
||||
**Base Policy Version:** 0.1.0
|
||||
**Base Policy Version:** 1.0
|
||||
|
||||
**Last Updated:** 2026-09-06
|
||||
**Last Updated:** 2026-09-26
|
||||
|
||||
The licensing-footer requirement incorporates upstream release policy v1.2;
|
||||
other release-workflow changes from that version are not adopted here.
|
||||
|
||||
## Purpose
|
||||
|
||||
@@ -21,7 +24,8 @@ ConfigKit adopts a source-only Go module release model. The source tag and
|
||||
version-matched release note are the declared deliverables. Standalone binaries,
|
||||
container images, and separate binary/package-registry publication are not
|
||||
required. This does not waive source compilation, module hygiene, testing, or
|
||||
consumer-facing module retrieval and build verification.
|
||||
local target-build validation. Post-publication consumer retrieval is optional,
|
||||
as documented under Policy Exceptions.
|
||||
|
||||
The release procedure must define the module path, authoritative remote,
|
||||
supported Go versions and operating-system/architecture targets, prerequisite
|
||||
@@ -35,6 +39,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:
|
||||
@@ -60,6 +78,10 @@ responsibility is documented. Automation must not silently change the source
|
||||
candidate, move a published tag, replace a published deliverable, or repair a
|
||||
release in place.
|
||||
|
||||
A successful push of the intended annotated tag completes the source-only release.
|
||||
Never wait for CI or use its status as a readiness or completion gate. Optional
|
||||
post-publication checks must not delay reporting success.
|
||||
|
||||
## Versioning And Compatibility
|
||||
|
||||
Stable releases use Semantic Versioning in the form `vMAJOR.MINOR.PATCH`, with
|
||||
@@ -116,40 +138,36 @@ canonical documentation for exact behavior. Every affected canonical document
|
||||
must be updated in the same candidate; a release note must not substitute for
|
||||
current documentation or defer required documentation changes.
|
||||
|
||||
Each new release note must end with the release navigation defined by the
|
||||
[OSS policy](oss.md#discoverability). Preserve those links on a hosted download
|
||||
page if one is used. The release procedure owns URL construction and channel
|
||||
treatment. This is ordinary preparation, not a new network check or approval
|
||||
gate; historical release notes are unchanged.
|
||||
|
||||
## Candidate Preparation And Validation
|
||||
|
||||
Each repository must provide one documented release-check command or script
|
||||
that exercises the complete set of automated source-candidate gates. The
|
||||
repository-specific procedure must state all additional manual gates.
|
||||
ConfigKit provides one local release checker. Run it once against the final
|
||||
committed candidate using one maintained patch of the minimum supported Go line
|
||||
on Linux. It checks module hygiene, formatting and whitespace, the full offline
|
||||
suite with the race detector, static analysis, and CGO-disabled Linux amd64/arm64
|
||||
builds. Examples, documentation links, consumer fixtures, and package boundaries
|
||||
are covered by that suite, not separate repeated runs. Native package compilation
|
||||
is covered by the test run. A second toolchain is optional and non-gating.
|
||||
|
||||
The required gates are:
|
||||
Commit the release note and associated changes before release validation. Record
|
||||
the exact validated commit; revalidate if the candidate changes. Do not require
|
||||
both pre-commit and post-commit full checker runs for unchanged content.
|
||||
Do not publish uncommitted or unvalidated content. Local check failures still
|
||||
block publication. Skipped test cases are reported, not separate human sign-off
|
||||
requirements.
|
||||
|
||||
- dependency or module hygiene appropriate to the language and ecosystem;
|
||||
- the complete deterministic, offline default test suite;
|
||||
- race, concurrency, or equivalent safety checks where the implementation can
|
||||
be meaningfully checked for those risks;
|
||||
- static analysis and linting used by the repository;
|
||||
- production builds for every supported target that can be built in the
|
||||
release environment;
|
||||
- formatting and whitespace checks;
|
||||
- validation of maintained examples, configuration, schemas, generated
|
||||
sources, and documentation commands where applicable;
|
||||
- review of changed local documentation links;
|
||||
- removal of obsolete roadmap documents under `docs/roadmap/` that describe
|
||||
completed work included in the release;
|
||||
- review for unintended files, generated output, credentials, private data,
|
||||
and unrelated changes; and
|
||||
- any additional security, compatibility, migration, packaging, or platform
|
||||
checks required by the product.
|
||||
|
||||
Unavailable tooling or an inapplicable check must be addressed explicitly in
|
||||
the adopted policy or release procedure; it must not disappear silently from
|
||||
the candidate gates.
|
||||
|
||||
Run the complete checker while preparing the candidate. Commit the release
|
||||
note and all associated code, tests, documentation, and configuration, then
|
||||
run the complete checker again against the exact commit intended for release.
|
||||
Do not publish a release from uncommitted or unvalidated content.
|
||||
Compatibility review, release-note accuracy, removal of completed roadmaps, and
|
||||
review for unintended files, credentials, private data, and unrelated changes
|
||||
are ordinary preparation. The executing agent may perform them without routine
|
||||
human approval or separate manual evidence records. Ask the maintainer only for
|
||||
genuine unresolved decisions or missing authority. A clean existing checkout is
|
||||
sufficient; a dedicated clone is optional. Dependency and toolchain update
|
||||
research belongs to maintenance or relevant changes, not every release.
|
||||
|
||||
## Publication Guards
|
||||
|
||||
@@ -164,7 +182,8 @@ verify all of the following:
|
||||
- the authoritative remote branch points to that same commit;
|
||||
- the matching release note exists in that commit and has the required title
|
||||
and sections;
|
||||
- all required approvals and automated gates succeeded for that commit; and
|
||||
- release publication is authorized and the local automated gates succeeded for
|
||||
that commit; and
|
||||
- no local or remote tag already uses the selected version.
|
||||
|
||||
Create the version tag against the guarded commit and verify its target before
|
||||
@@ -205,34 +224,48 @@ order:
|
||||
1. maintainer prerequisites and required access;
|
||||
2. version selection and compatibility review;
|
||||
3. release-note creation;
|
||||
4. candidate preparation and the complete validation command;
|
||||
5. candidate commit, push, and exact commit recording;
|
||||
4. candidate preparation and commit;
|
||||
5. one local validation pass, exact candidate recording, and push;
|
||||
6. publication guards;
|
||||
7. exact tag and deliverable publication commands;
|
||||
8. authoritative remote and consumer-facing verification; and
|
||||
8. completion on successful tag push and optional post-publication checks; and
|
||||
9. failure handling and correction.
|
||||
|
||||
The procedure should use copyable, fail-fast commands where practical. It must
|
||||
name the primary branch, authoritative remote, tag form, published
|
||||
deliverables, supported build targets, release automation responsibilities,
|
||||
and consumer verification path. It must link to this policy instead of
|
||||
and optional consumer verification path. It must link to this policy instead of
|
||||
restating or redefining its requirements.
|
||||
|
||||
## Policy Exceptions
|
||||
|
||||
### Temporary bootstrap deferral
|
||||
Any exception to this policy must be explicitly documented in this section,
|
||||
including the repository constraint requiring it, its rationale, scope, and
|
||||
compensating controls. Customizing release mechanics must not silently weaken
|
||||
the policy's integrity, validation, documentation, verification, or immutability
|
||||
requirements.
|
||||
|
||||
The release procedure (`docs/release.md`) and complete release-check command are
|
||||
not yet present because ConfigKit has no Go module or implementation to validate.
|
||||
Their creation is temporarily deferred during initial development. This exception
|
||||
permits implementation work; it does not permit publishing a release without
|
||||
any gate required above.
|
||||
For ConfigKit's source-only releases, the maintainer accepts successful publication
|
||||
of the intended tag as sufficient completion evidence. This is an explicit
|
||||
exception to the shared standard's mandatory post-publication verification:
|
||||
authoritative tag read-back and fresh consumer retrieval are optional and
|
||||
non-gating. CI status and downstream retrieval availability must not delay release
|
||||
completion. Compensating controls are local validation of the exact candidate,
|
||||
automated pre-publication guards, matching remote main, verification of the local
|
||||
annotated tag target, explicit ref pushes, and immutable published versions.
|
||||
|
||||
Compensating controls: no release may be published until the
|
||||
[release-readiness gaps](../roadmap/release-readiness.md) are closed, the runbook
|
||||
and checker exist, and the exact candidate passes all applicable gates. The
|
||||
maintainer preparing the first release owns closure. Remove this temporary
|
||||
exception when readiness is verified. Any other exception requires a documented repository constraint, rationale,
|
||||
compensating controls, scope, and closure condition here; none is currently
|
||||
adopted. Do not silently weaken the base integrity, validation, documentation,
|
||||
verification, or immutability guarantees.
|
||||
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.
|
||||
|
||||
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.
|
||||
|
||||
Candidate selection and guarded publication remain mandatory; post-publication
|
||||
verification is optional in the [release procedure](../release.md).
|
||||
|
||||
@@ -39,7 +39,7 @@ boundaries. No credential bytes may appear in inspection or failure fixtures'
|
||||
expected output; use disposable canaries to prove non-disclosure.
|
||||
|
||||
Baseline commands belong in the [development guide](../development.md). Release
|
||||
gates belong in the [release policy](release.md) and its eventual runbook. This
|
||||
gates belong in the [release policy](release.md) and [maintainer runbook](../release.md). This
|
||||
policy does not imply that those tools or tests already exist.
|
||||
|
||||
## Every test has a cost
|
||||
|
||||
252
docs/release.md
Normal file
252
docs/release.md
Normal file
@@ -0,0 +1,252 @@
|
||||
# ConfigKit Release Procedure
|
||||
|
||||
This is the maintainer runbook for ConfigKit's [release policy](policy/release.md).
|
||||
ConfigKit publishes one source-only Go module: the immutable source tag and the
|
||||
matching checked-in release note are the deliverables. There is no application
|
||||
binary, container image, generated package, or separate registry publication.
|
||||
|
||||
## Release identity and support
|
||||
|
||||
- Module: `gitea.maximumdirect.net/eric/ConfigKit`
|
||||
- Primary branch: `main`
|
||||
- Authoritative remote: `origin`, expected to fetch from
|
||||
`https://gitea.maximumdirect.net/eric/ConfigKit.git`
|
||||
- Tags: stable Semantic Versions such as `v0.1.0`; prerelease tags are not supported
|
||||
- Minimum Go language/toolchain line: Go 1.25, release-validated with one maintained
|
||||
patch of that line; newer-toolchain testing is optional and non-gating
|
||||
- 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 local Linux checker includes native race tests and CGO-disabled cross-builds.
|
||||
Its output reports skipped filesystem cases; no separate evidence record or
|
||||
human sign-off is required. Native macOS checks and Darwin cross-builds are
|
||||
optional and do not gate publication.
|
||||
|
||||
CI is never a release gate. Do not wait for it, poll its status, or delay reporting
|
||||
completion because it is pending, unavailable, or failed.
|
||||
|
||||
## 1. Prepare access and tools
|
||||
|
||||
The release maintainer owns this procedure. Use a clean checkout (a dedicated clone is optional) with Git, Bash 3.2 or newer,
|
||||
a maintained Go 1.25 patch, and the compiler needed for native race tests. The checker supports the stock macOS
|
||||
Bash; its filename enumeration is null-delimited, including names with whitespace. Configure private-host Git and Go module
|
||||
access through the normal credential manager before starting. Keep tokens, passwords,
|
||||
SSH material, and environment dumps outside the repository and command transcript.
|
||||
Verify access without displaying credentials:
|
||||
|
||||
```sh
|
||||
git remote get-url origin
|
||||
git ls-remote origin HEAD
|
||||
```
|
||||
|
||||
The remote URL must equal the authoritative URL above. Acquire the selected
|
||||
toolchain and dependencies explicitly. For example, with the maintained patch
|
||||
used when this procedure was simplified:
|
||||
|
||||
```sh
|
||||
export CONFIGKIT_BASELINE_TOOLCHAIN=go1.25.14
|
||||
GOTOOLCHAIN="$CONFIGKIT_BASELINE_TOOLCHAIN" go version
|
||||
GOTOOLCHAIN="$CONFIGKIT_BASELINE_TOOLCHAIN" go mod download
|
||||
```
|
||||
|
||||
Keep toolchains maintained, but do not require latest-version discovery for each
|
||||
release. The checker sets `GOPROXY=off` and fails if the cache is incomplete.
|
||||
Toolchain and YAML-dependency update research belongs to periodic maintenance or
|
||||
relevant dependency changes. No renewed approval of an unchanged pin is required.
|
||||
|
||||
Use Bash with `set -euo pipefail` for the command blocks below so any failed gate
|
||||
stops the sequence. Publication requires user authorization; the agent policy's
|
||||
local-commit authorization is not permission to push or release.
|
||||
|
||||
## 2. Select the version and review compatibility
|
||||
|
||||
Choose an unused stable version and compare the candidate with the previous tag.
|
||||
For the first release, review the whole public surface and current contracts.
|
||||
|
||||
```sh
|
||||
release_version=v0.1.0
|
||||
printf '%s\n' "$release_version" | grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'
|
||||
if git show-ref --verify --quiet "refs/tags/$release_version"; then exit 1; fi
|
||||
if git ls-remote --exit-code --tags origin "refs/tags/$release_version" >/dev/null; then
|
||||
printf 'remote tag already exists: %s\n' "$release_version" >&2
|
||||
exit 1
|
||||
else
|
||||
test $? -eq 2
|
||||
fi
|
||||
git log --oneline --decorate --no-merges
|
||||
```
|
||||
|
||||
Review exported Go signatures and ownership, YAML and scalar behavior, precedence,
|
||||
diagnostic codes, minimum Go version, and platform support. Before `v1.0.0`, any
|
||||
intentional incompatible minor-release change must be explained with consumer action;
|
||||
patch releases must remain compatible within their minor line. Review the maintained
|
||||
consumer examples and the migration guide even when signatures did not change.
|
||||
|
||||
When dependencies change, review parser behavior and migration impact as part of
|
||||
that change. Keep Compatibility accurate about the dependency pin, but do not
|
||||
perform an external version survey for every patch release.
|
||||
|
||||
## 3. Write the release note
|
||||
|
||||
Create `docs/releases/$release_version.md` before selecting the candidate. It must
|
||||
begin with `# ConfigKit $release_version` and contain `Summary`, `Compatibility`,
|
||||
`Upgrade`, and `Changes` sections. Use “No action required” where appropriate. Link
|
||||
to current canonical contracts instead of copying their definitions. Do not create a
|
||||
release note until there is a real candidate and reviewed content.
|
||||
|
||||
End each new release note with the footer required by the
|
||||
[OSS policy](policy/oss.md#discoverability). For the selected version, use these
|
||||
absolute Gitea URLs (replace every `VERSION` with the complete intended tag):
|
||||
|
||||
```markdown
|
||||
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/VERSION/COPYRIGHT) | [LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/VERSION/LICENSE) | [ATTRIBUTION](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/VERSION/docs/oss/attribution.md)
|
||||
```
|
||||
|
||||
Confirm the three target files exist in the candidate and the version is correct
|
||||
during ordinary note review. The future tag need not resolve remotely yet; do
|
||||
not add publication-time network checks. ConfigKit publishes source tags with
|
||||
checked-in notes, not separate hosted download pages. If a hosted release page
|
||||
is used later, reuse the checked-in note as its body and retain this footer.
|
||||
Do not retrofit published notes or add separate downloadable license assets.
|
||||
|
||||
## 4. Validate the source candidate
|
||||
|
||||
Before release validation, finish compatibility review, inspect the diff and file
|
||||
list for unintended content or credentials, retire completed roadmaps, repair
|
||||
links, and review the release note. These are ordinary preparation tasks the
|
||||
executing agent may perform, not separate human approvals or evidence records.
|
||||
Ask the maintainer only for an unresolved product decision or missing authority.
|
||||
|
||||
Commit the candidate, staging only its intended changes, then run the checker
|
||||
once against that clean commit. Do not run both everyday and release checkers to
|
||||
repeat the same gates.
|
||||
|
||||
```sh
|
||||
git status --short
|
||||
# Stage the specific files belonging to the candidate, then review:
|
||||
git diff --staged
|
||||
git commit --message "Prepare ConfigKit $release_version"
|
||||
test -z "$(git status --porcelain=v1 --untracked-files=all)"
|
||||
candidate_commit=$(git rev-parse HEAD)
|
||||
./scripts/release-check.sh
|
||||
test "$(git rev-parse HEAD)" = "$candidate_commit"
|
||||
test -z "$(git status --porcelain=v1 --untracked-files=all)"
|
||||
printf '%s\n' "$candidate_commit" > "$(git rev-parse --git-path configkit-release-candidate)"
|
||||
```
|
||||
|
||||
If the final candidate is already committed, omit the staging and commit commands;
|
||||
do not create an empty commit. The checker validates module manifests in a
|
||||
disposable copy, formatting, whitespace, the full offline suite once with the race
|
||||
detector and verbose skip reporting, vet once, and CGO-disabled Linux amd64/arm64
|
||||
builds. The suite includes examples, consumer fixtures, documentation links, and
|
||||
package boundaries; no separate reruns are required. No additional linter is
|
||||
claimed. Sibling-consumer links are checked when those checkouts exist and are
|
||||
otherwise inapplicable in an isolated checkout.
|
||||
|
||||
A changed candidate requires validation again. Do not reuse a recorded candidate
|
||||
from an older revision. The record is written only after successful local checks.
|
||||
|
||||
## 5. Prepare publication
|
||||
|
||||
The following guarded sequence pushes only `main` and the selected tag. Local
|
||||
validation failures block it; CI does not. Keep the selected `release_version`
|
||||
from step 2 in the same shell. If starting a new shell, set it explicitly again.
|
||||
|
||||
## 6. Run publication guards
|
||||
|
||||
Immediately before tagging, fetch the authoritative branch and tags, restore the
|
||||
recorded variables if this is a new shell, and execute every guard:
|
||||
|
||||
```sh
|
||||
candidate_commit=$(cat "$(git rev-parse --git-path configkit-release-candidate)")
|
||||
git fetch --prune origin main
|
||||
git fetch --tags origin
|
||||
test "$(git branch --show-current)" = main
|
||||
test -z "$(git status --porcelain=v1 --untracked-files=all)"
|
||||
test "$(git rev-parse HEAD)" = "$candidate_commit"
|
||||
test "$(git remote get-url origin)" = 'https://gitea.maximumdirect.net/eric/ConfigKit.git'
|
||||
printf '%s\n' "$release_version" | grep -Eq '^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$'
|
||||
test -f "docs/releases/$release_version.md"
|
||||
test "$(sed -n '1p' "docs/releases/$release_version.md")" = "# ConfigKit $release_version"
|
||||
for heading in Summary Compatibility Upgrade Changes; do
|
||||
grep -Fxq "## $heading" "docs/releases/$release_version.md"
|
||||
done
|
||||
if git show-ref --verify --quiet "refs/tags/$release_version"; then exit 1; fi
|
||||
if git ls-remote --exit-code --tags origin "refs/tags/$release_version" >/dev/null; then
|
||||
printf 'remote tag already exists: %s\n' "$release_version" >&2
|
||||
exit 1
|
||||
else
|
||||
test $? -eq 2
|
||||
fi
|
||||
git push origin HEAD:refs/heads/main
|
||||
git fetch origin main
|
||||
test "$(git rev-parse refs/remotes/origin/main)" = "$candidate_commit"
|
||||
test "$(git rev-parse HEAD)" = "$candidate_commit"
|
||||
test -z "$(git status --porcelain=v1 --untracked-files=all)"
|
||||
```
|
||||
|
||||
The recorded local validation must apply to exactly `$candidate_commit`. No
|
||||
separate human sign-off, CI status, or manual evidence document is required.
|
||||
|
||||
## 7. Tag and publish the exact ref
|
||||
|
||||
Create one annotated tag at the guarded commit, verify its peeled target, and push
|
||||
only that ref. Do not use `git push --tags`.
|
||||
|
||||
```sh
|
||||
git tag --annotate "$release_version" --message "ConfigKit $release_version" "$candidate_commit"
|
||||
test "$(git rev-list -n 1 "$release_version")" = "$candidate_commit"
|
||||
git push origin "refs/tags/$release_version:refs/tags/$release_version"
|
||||
```
|
||||
|
||||
A successful push of the intended tag completes the release. Report success now;
|
||||
do not wait for CI, module propagation, or any post-publication check. No Gitea
|
||||
release object or binary upload is required.
|
||||
|
||||
## 8. Optional post-publication checks
|
||||
|
||||
Only when useful or requested, read back the remote tag or retrieve the exact
|
||||
version into a new module without a local `replace` directive. These checks are
|
||||
optional and non-gating; their failure or unavailability does not delay release
|
||||
completion. Do not poll CI or wait for module propagation. Private-host credentials
|
||||
must already be configured. Example direct-origin verification:
|
||||
|
||||
```sh
|
||||
remote_tag_commit=$(git ls-remote origin "refs/tags/$release_version^{}" | awk '{print $1}')
|
||||
test "$remote_tag_commit" = "$candidate_commit"
|
||||
consumer_dir=$(mktemp -d)
|
||||
(
|
||||
cd "$consumer_dir"
|
||||
go mod init example.invalid/configkit-release-check
|
||||
GOPRIVATE=gitea.maximumdirect.net GOPROXY=direct go get "gitea.maximumdirect.net/eric/ConfigKit@$release_version"
|
||||
cat > main.go <<'EOF'
|
||||
package main
|
||||
|
||||
import configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
|
||||
func main() {
|
||||
_ = configkit.NoSource()
|
||||
}
|
||||
EOF
|
||||
gofmt -w main.go
|
||||
go test ./...
|
||||
go build ./...
|
||||
go list -m -f '{{.Path}} {{.Version}}' gitea.maximumdirect.net/eric/ConfigKit
|
||||
)
|
||||
rm -rf -- "$consumer_dir"
|
||||
```
|
||||
|
||||
If performed, the final `go list` output should show the exact module path and
|
||||
`$release_version`. Report optional verification separately from publication success.
|
||||
|
||||
## 9. Handle failures without replacement
|
||||
|
||||
Before publication, fix and commit the candidate, validate it, record the new
|
||||
exact candidate, and repeat the guarded publication sequence. An unpublished local tag may be deleted only after verifying it
|
||||
was never pushed or used by another release channel.
|
||||
|
||||
After publication, never move, delete, or recreate the tag and never replace its
|
||||
release note. Correct the defect on `main`, choose a new semantically appropriate
|
||||
version, and repeat this procedure. A supported advisory or deprecation mechanism
|
||||
may warn consumers about the old version but cannot change its contents.
|
||||
72
docs/releases/v0.1.0.md
Normal file
72
docs/releases/v0.1.0.md
Normal file
@@ -0,0 +1,72 @@
|
||||
# ConfigKit v0.1.0
|
||||
|
||||
This is the first source release of ConfigKit, an opinionated Go library for strict,
|
||||
explainable YAML configuration loading.
|
||||
|
||||
## Summary
|
||||
|
||||
ConfigKit provides one typed loading lifecycle for file, byte, reader, and
|
||||
defaults-only inputs. It supports bounded explicit imports, strict composition,
|
||||
selected profiles, explicit CLI overrides, application-owned defaults and recorded
|
||||
resolution, semantic validation, provenance, redacted effective output, and safe
|
||||
historical explanation.
|
||||
|
||||
Typed environment-name secret references and an explicit secrets-directory bootstrap
|
||||
keep credential acquisition separate from ordinary loading. Maintained consumer
|
||||
examples demonstrate simple and composed application shapes without exposing the
|
||||
private YAML parser dependency.
|
||||
|
||||
See the [configuration contract](../config.md) and
|
||||
[consumer adoption guide](../consumers/README.md) for current behavior and examples.
|
||||
|
||||
## Compatibility
|
||||
|
||||
This is the initial public release, so there is no earlier ConfigKit release to
|
||||
upgrade from. The module requires Go 1.25 or newer and supports Linux and macOS on
|
||||
amd64 and arm64. Candidate validation uses patched Go 1.25 and Go 1.26 toolchains;
|
||||
native Linux and macOS evidence is required in addition to cross-builds.
|
||||
|
||||
Configuration intentionally uses a strict YAML subset. Duplicate keys, aliases,
|
||||
anchors, merge keys, unsupported tags and scalar forms, trailing documents, and
|
||||
unknown typed fields are rejected. Composition rejects overlapping non-mapping
|
||||
definitions; replacement occurs only through selected profiles, explicit CLI
|
||||
overrides, or recorded application resolution.
|
||||
|
||||
The private parser dependency remains explicitly pinned to
|
||||
`go.yaml.in/yaml/v4 v4.0.0-rc.6`, which was still the newest available v4 release when
|
||||
the candidate was prepared. No upstream YAML declarations are part of ConfigKit's
|
||||
public API.
|
||||
|
||||
## Upgrade
|
||||
|
||||
No action is required for an earlier ConfigKit version because this is the first
|
||||
release. New consumers can add the module with:
|
||||
|
||||
```sh
|
||||
go get gitea.maximumdirect.net/eric/ConfigKit@v0.1.0
|
||||
```
|
||||
|
||||
The authoritative host may require its normal private-module credentials. Consumers
|
||||
adapting existing application configuration should review the intentional migration
|
||||
differences in the [consumer adoption guide](../consumers/README.md), particularly
|
||||
strict YAML, string schema versions, explicit empty/null behavior, canonical module
|
||||
objects, `int64` dynamic integers, explicit CLI records, and typed secret references.
|
||||
|
||||
## Changes
|
||||
|
||||
- Added opaque file, byte, reader, and defaults-only sources with fixed resource
|
||||
limits and safe structured diagnostics.
|
||||
- Added an owned strict YAML model, typed schema binding, detached defaults and
|
||||
results, canonical projection, and private parser isolation.
|
||||
- Added explicit path/glob imports, deterministic strict composition, canonical
|
||||
source identity and containment, and selected profile overlays.
|
||||
- Added layered presence, origins, chronological history, version checks, recorded
|
||||
defaulting and normalization, and read-only semantic validation.
|
||||
- Added secret classification, deterministic redacted effective YAML, typed
|
||||
environment references, and bounded explicit secrets-directory bootstrap reports.
|
||||
- Added parser-neutral explicit CLI overrides and safe structured/human historical
|
||||
value explanations, including removed and superseded values.
|
||||
- Added maintained Narratio-, Notarius-, WeatherReporter-, and Seriatim-shaped
|
||||
examples and migration coverage using only ConfigKit and the standard library.
|
||||
- Added race and bounded fuzz coverage plus a two-toolchain, offline, cross-platform
|
||||
source-release checker and maintainer runbook.
|
||||
43
docs/releases/v0.1.1.md
Normal file
43
docs/releases/v0.1.1.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# ConfigKit v0.1.1
|
||||
|
||||
This release corrects the spelling of ConfigKit's reserved configuration key.
|
||||
|
||||
## Summary
|
||||
|
||||
The reserved directive key is now `configkit` instead of `_configkit`. Import
|
||||
declarations continue to use the same `imports` sequence and path/glob syntax.
|
||||
See the [configuration contract](../config.md#explicit-imports).
|
||||
|
||||
## Compatibility
|
||||
|
||||
This is an intentional configuration compatibility change, approved as a one-time
|
||||
[patch-release exception](../policy/release.md#policy-exceptions) because there are
|
||||
no current consumers. `_configkit` is no longer a directive or a reserved key; it
|
||||
is ordinary application data and is subject to normal schema validation. There is
|
||||
no legacy alias. `configkit` is now reserved, including in nested mappings, profiles,
|
||||
and override paths and values.
|
||||
|
||||
The Go API, diagnostic codes, Go minimum version, and supported platforms are
|
||||
unchanged. The private YAML dependency remains pinned to
|
||||
`go.yaml.in/yaml/v4 v4.0.0-rc.6`, confirmed as the newest available v4 release
|
||||
during candidate preparation.
|
||||
|
||||
## Upgrade
|
||||
|
||||
Rename `_configkit` directive keys to `configkit` in root configuration files and
|
||||
imported fragments. Rename any application-owned `configkit` fields to avoid the
|
||||
newly reserved key. Update diagnostic-path expectations to the new spelling.
|
||||
Files retaining `_configkit` will not load their declared imports.
|
||||
|
||||
Consumers can select this release with:
|
||||
|
||||
```sh
|
||||
go get gitea.maximumdirect.net/eric/ConfigKit@v0.1.1
|
||||
```
|
||||
|
||||
## Changes
|
||||
|
||||
- Renamed the directive key consistently across imports, profiles, and overrides.
|
||||
- Updated current configuration documentation and the composed consumer fixture.
|
||||
- Updated behavioral tests and added coverage proving the old spelling is not an
|
||||
import alias.
|
||||
50
docs/releases/v0.1.2.md
Normal file
50
docs/releases/v0.1.2.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# ConfigKit v0.1.2
|
||||
|
||||
This release adds safe enumeration of effective and removed metadata paths.
|
||||
|
||||
## Summary
|
||||
|
||||
`Metadata.HistoricalPaths` returns the semantic paths observed during a load,
|
||||
including paths removed by profiles, CLI overrides, or recorded resolution. This
|
||||
allows applications to discover hidden declarations and inspect their ownership
|
||||
through the existing `Metadata.History` API without reloading configuration or
|
||||
resolving unselected credentials.
|
||||
|
||||
See the [layered metadata contract](../config.md#layered-metadata) for current
|
||||
behavior.
|
||||
|
||||
## Compatibility
|
||||
|
||||
This is a backward-compatible API addition. Existing APIs, configuration syntax,
|
||||
diagnostic codes, minimum Go version, and supported platforms are unchanged.
|
||||
Existing consumers do not need to change their code or configuration.
|
||||
|
||||
Returned paths are detached, unique by canonical JSON Pointer spelling, and sorted
|
||||
by that spelling. The enumeration includes defaults and the root, exposes no
|
||||
configuration values, and performs no I/O or secret resolution.
|
||||
|
||||
The private YAML dependency remains pinned to
|
||||
`go.yaml.in/yaml/v4 v4.0.0-rc.6`, confirmed as the newest available v4 release during
|
||||
candidate preparation.
|
||||
|
||||
## Upgrade
|
||||
|
||||
No action required. Consumers that want historical path discovery can update with:
|
||||
|
||||
```sh
|
||||
go get gitea.maximumdirect.net/eric/ConfigKit@v0.1.2
|
||||
```
|
||||
|
||||
Then iterate over `result.Metadata().HistoricalPaths()` and use `History` for
|
||||
chronological definitions or `Lookup` to determine whether each path remains
|
||||
effective.
|
||||
|
||||
## Changes
|
||||
|
||||
- Added `Metadata.HistoricalPaths` with detached, deterministic path enumeration.
|
||||
- Included effective, default, and removed paths while excluding paths that never
|
||||
existed.
|
||||
- Added coverage for parent removal, mapping-to-sequence shape replacement, escaped
|
||||
mapping keys, detached results, zero metadata, composite base ownership, and
|
||||
avoidance of superseded secret lookup.
|
||||
- Documented historical path enumeration in the current configuration contract.
|
||||
58
docs/releases/v0.2.0.md
Normal file
58
docs/releases/v0.2.0.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# ConfigKit v0.2.0
|
||||
|
||||
This release adds integer schema-version input and consistent canonical sequence
|
||||
path handling.
|
||||
|
||||
## Summary
|
||||
|
||||
Applications can retain YAML such as `version: 4` while storing and checking their
|
||||
version as a Go string. The exception applies only to the path designated by
|
||||
`VersionRule`; unrelated fields keep strict decoding. See the
|
||||
[configuration contract](../config.md#application-schema-versions).
|
||||
|
||||
## Compatibility
|
||||
|
||||
This minor release intentionally changes integer schema-version input from a
|
||||
`version.type` rejection to invoking the application checker with its decimal text.
|
||||
Plain string destinations at that path now accept it. Public Go API shapes,
|
||||
minimum Go version, and supported platforms are unchanged.
|
||||
|
||||
Parsed sequence indices now require canonical nonnegative decimal spelling.
|
||||
Signed tokens such as `+0` and `-0`, previously accepted by some traversal paths,
|
||||
no longer identify sequence elements. They remain valid literal mapping keys.
|
||||
Version checking, decoding, and metadata matching now agree on this boundary.
|
||||
|
||||
The private YAML dependency remains pinned to `go.yaml.in/yaml/v4 v4.0.0-rc.6`,
|
||||
confirmed as the newest available v4 release during candidate preparation. Linux
|
||||
candidate checks use Go 1.25.5 and Go 1.26.7. The maintainer confirmed native macOS
|
||||
checks at `244104f` and approved carrying that result across the documentation-only
|
||||
candidate changes under the [recorded exception](../policy/release.md#policy-exceptions).
|
||||
|
||||
## Upgrade
|
||||
|
||||
```sh
|
||||
go get gitea.maximumdirect.net/eric/ConfigKit@v0.2.0
|
||||
```
|
||||
|
||||
Existing string schema versions need no configuration changes. Applications using
|
||||
integer YAML versions can retain them and use a plain string field plus a
|
||||
`VersionRule`. Review the application checker: it now receives integer identifiers
|
||||
as text, and remains responsible for rejecting unsupported versions. Fractions,
|
||||
exponents, booleans, and containers remain invalid version kinds.
|
||||
|
||||
Replace signed sequence-index tokens in configured paths with canonical indices
|
||||
(for example, `/versions/+0` becomes `/versions/0`). Do not change signed mapping
|
||||
keys. Literal integers elsewhere still do not decode into strings, and numeric
|
||||
destinations retain their existing range checks.
|
||||
|
||||
## Changes
|
||||
|
||||
- Added load-local, path-scoped integer-to-string schema-version decoding without
|
||||
mutating original input or introducing general scalar coercion.
|
||||
- Preserved numeric historical input, string effective output, decoding
|
||||
reconciliation, source attribution, and secret redaction.
|
||||
- Unified sequence path interpretation and added regression coverage for signed
|
||||
tokens, literal mapping keys, and explicit key/index distinctions.
|
||||
- Updated maintained Notarius-shaped fixtures and migration guidance for quoted
|
||||
and integer version forms.
|
||||
- Retired completed implementation and remediation roadmaps to Git history.
|
||||
70
docs/releases/v0.3.0.md
Normal file
70
docs/releases/v0.3.0.md
Normal 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.
|
||||
43
docs/releases/v0.3.1.md
Normal file
43
docs/releases/v0.3.1.md
Normal file
@@ -0,0 +1,43 @@
|
||||
# ConfigKit v0.3.1
|
||||
|
||||
This release fixes secrets-directory bootstrap when the directory contains Git
|
||||
metadata or other dot-prefixed entries.
|
||||
|
||||
## Summary
|
||||
|
||||
`LoadSecretsDir` now ignores dot-prefixed files, directories, and symlinks,
|
||||
including `.git`, `.gitignore`, and `.DS_Store`. These entries no longer prevent
|
||||
otherwise valid secrets from loading and are omitted from the bootstrap report.
|
||||
See the [bootstrap contract](../config.md#explicit-secrets-directory-bootstrap).
|
||||
|
||||
## Compatibility
|
||||
|
||||
This is a backward-compatible bug fix for previously rejected directory contents.
|
||||
There are no Go API signature changes or migrations for previously accepted
|
||||
inputs. Validation of non-hidden entries, existing-environment precedence, and
|
||||
the existing enumeration and content limits remain unchanged. Hidden entries
|
||||
still count toward the directory enumeration limit.
|
||||
|
||||
The minimum Go version remains 1.25. Linux remains release-validated and macOS
|
||||
remains best-effort. The explicitly pinned YAML dependency remains
|
||||
`go.yaml.in/yaml/v4 v4.0.0-rc.6`; reassessment found no newer published version.
|
||||
Retaining this prerelease pin avoids unrelated parser changes in this patch.
|
||||
|
||||
## Upgrade
|
||||
|
||||
Update the module dependency and rebuild the consuming application:
|
||||
|
||||
```sh
|
||||
go get gitea.maximumdirect.net/eric/ConfigKit@v0.3.1
|
||||
```
|
||||
|
||||
No configuration migration is required. A secrets directory can retain its
|
||||
dot-prefixed metadata without treating those entries as secret candidates.
|
||||
|
||||
## Changes
|
||||
|
||||
- Filter hidden entries before filename and metadata validation and reporting.
|
||||
- Cover hidden-only and mixed directories, including Git metadata and a dangling
|
||||
hidden symlink, with regression tests.
|
||||
- Update public API comments and the canonical bootstrap contract.
|
||||
- Stop tracking local codebase-memory tooling artifacts and ignore their directory.
|
||||
56
docs/releases/v0.4.0.md
Normal file
56
docs/releases/v0.4.0.md
Normal file
@@ -0,0 +1,56 @@
|
||||
# ConfigKit v0.4.0
|
||||
|
||||
This release renames selected configuration profiles to overlays, intentionally
|
||||
breaking the previous naming contract without compatibility aliases or fallback.
|
||||
|
||||
## Summary
|
||||
|
||||
Selected configuration now uses `Options.Overlay` and `overlays/<name>.yml`.
|
||||
Selection, merge rules, input precedence, and lifecycle behavior are unchanged.
|
||||
See the [selected-overlay contract](../config.md#selected-overlays).
|
||||
|
||||
## Compatibility
|
||||
|
||||
This is a breaking minor release before v1.0.0. Four surfaces change:
|
||||
|
||||
- Go API: `Options.Profile` becomes `Options.Overlay`, `LayerProfile` becomes
|
||||
`LayerOverlay`, and all seven `CodeProfile*` constants become `CodeOverlay*`.
|
||||
- Selected files: `profiles/<name>.yml` becomes `overlays/<name>.yml`; there is
|
||||
no legacy-directory fallback.
|
||||
- Provenance: the layer string changes from `profile` to `overlay`.
|
||||
- Structured diagnostics: the `profile.*` code prefix becomes `overlay.*`.
|
||||
|
||||
The old Go identifiers are removed. Application-owned concepts such as
|
||||
`ModelProfile` and `model_profile` are unchanged. Ordinary explicit imports from
|
||||
a directory named `profiles` remain valid base inputs.
|
||||
|
||||
The minimum Go version remains 1.25. Linux remains release-validated and macOS
|
||||
remains best-effort. The YAML dependency remains pinned to
|
||||
`go.yaml.in/yaml/v4 v4.0.0-rc.6`; this release does not change parser dependencies.
|
||||
|
||||
## Upgrade
|
||||
|
||||
Update the dependency:
|
||||
|
||||
```sh
|
||||
go get gitea.maximumdirect.net/eric/ConfigKit@v0.4.0
|
||||
```
|
||||
|
||||
Rename the affected Go identifiers, move selected files into `overlays/`, update
|
||||
any handling of provenance-layer and diagnostic-code strings, and rebuild.
|
||||
The maintained example helper is now `LoadNarratioOverlay` rather than
|
||||
`LoadNarratioProfile`. Application CLI flag names remain application-owned;
|
||||
ConfigKit does not require renaming domain-specific profile concepts.
|
||||
See the [migration guide](../consumers/README.md#selected-overlay-migration).
|
||||
|
||||
## Changes
|
||||
|
||||
- Rename the selected feature consistently across the public facade, internal
|
||||
engine, diagnostics, source labels, examples, and current documentation.
|
||||
- Add regression coverage for the new directory, absence of legacy fallback,
|
||||
public values, explanations, and preservation of application profile fields.
|
||||
- Align contributor policies with the current shared standard.
|
||||
- Simplify release validation to one local minimum-toolchain check. CI is not a
|
||||
release gate; successful publication of the intended tag completes the
|
||||
source-only release. See the [release procedure](../release.md).
|
||||
- Retire completed overlay and release-workflow planning documents to Git history.
|
||||
42
docs/releases/v0.4.1.md
Normal file
42
docs/releases/v0.4.1.md
Normal file
@@ -0,0 +1,42 @@
|
||||
# ConfigKit v0.4.1
|
||||
|
||||
This release formalizes ConfigKit's licensing and upstream attribution
|
||||
documentation without changing library behavior.
|
||||
|
||||
## Summary
|
||||
|
||||
ConfigKit now includes an explicit LGPL-3.0-or-later grant in COPYRIGHT,
|
||||
complete LGPLv3 and incorporated GPLv3 license texts, and centralized upstream
|
||||
attribution. README and release guidance provide navigation to those materials.
|
||||
|
||||
## Compatibility
|
||||
|
||||
No Go code, public API, configuration semantics, diagnostic behavior, or
|
||||
dependencies changed from v0.4.0. The minimum Go version remains 1.25, Linux
|
||||
remains release-validated, and macOS remains best-effort. The YAML dependency
|
||||
remains `go.yaml.in/yaml/v4 v4.0.0-rc.6`.
|
||||
|
||||
The project grant is explicitly LGPL-3.0-or-later. Third-party material remains
|
||||
subject to its respective terms, recorded in the attribution document below.
|
||||
Published earlier releases are unchanged.
|
||||
|
||||
## Upgrade
|
||||
|
||||
Update the module dependency and rebuild to select this release:
|
||||
|
||||
```sh
|
||||
go get gitea.maximumdirect.net/eric/ConfigKit@v0.4.1
|
||||
```
|
||||
|
||||
No application-code or configuration migration is required from v0.4.0.
|
||||
|
||||
## Changes
|
||||
|
||||
- Add the project COPYRIGHT statement and complete standard project license texts.
|
||||
- Document go-yaml attribution with exact upstream revision links and local
|
||||
Apache-2.0 and MIT license texts.
|
||||
- Adopt the house OSS policy and connect licensing ownership and contributor guidance.
|
||||
- Add license and attribution navigation to README and future release documents,
|
||||
without adding release gates or changing publication mechanics.
|
||||
|
||||
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.4.1/COPYRIGHT) | [LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.4.1/LICENSE) | [ATTRIBUTION](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.4.1/docs/oss/attribution.md)
|
||||
28
docs/roadmap/deferred.md
Normal file
28
docs/roadmap/deferred.md
Normal file
@@ -0,0 +1,28 @@
|
||||
# Deferred Scope
|
||||
|
||||
This document owns intentionally excluded features, not a committed delivery plan.
|
||||
The [architecture policy](../policy/architecture.md) governs changes to these
|
||||
boundaries. Implemented behavior belongs in the [configuration contract](../config.md)
|
||||
and consumer adoption in the [consumer guide](../consumers/README.md).
|
||||
|
||||
## Explicit non-goals and deferred scope
|
||||
|
||||
General environment-variable configuration overlays are not supported. Typed secret
|
||||
references and explicit directory bootstrap are the environment convention. Literal
|
||||
credential configuration and implicit secret-directory discovery are unsupported.
|
||||
|
||||
The initial release deliberately excludes:
|
||||
|
||||
- automatic `conf.d` discovery;
|
||||
- remote imports, arbitrary providers, and additional configuration formats;
|
||||
- custom merge strategies, sequence merging, reset operators, and overlay inheritance;
|
||||
- parser-specific or custom structural-decoding extension APIs;
|
||||
- generic dynamic-subtree conversion and module-registry APIs;
|
||||
- automatic schema migrations;
|
||||
- hot reload, runtime resource replacement, and remote resource validation;
|
||||
- a bundled CLI framework or application commands; and
|
||||
- configuration diffs and fingerprints.
|
||||
|
||||
Applications may build their own validate, effective-value, source, bootstrap, and
|
||||
explanation commands around the library. Broadening these boundaries requires
|
||||
concrete consumer evidence, compatibility review, and an explicit design change.
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -1,25 +0,0 @@
|
||||
# Release Readiness
|
||||
|
||||
## Status and ownership
|
||||
|
||||
ConfigKit is not release-ready. This document tracks outstanding release
|
||||
prerequisites under the [temporary bootstrap exception](../policy/release.md#temporary-bootstrap-deferral).
|
||||
It is not the library implementation plan and does not assign implementation
|
||||
stages. The maintainer preparing the first release owns closure.
|
||||
|
||||
## Outstanding prerequisites
|
||||
|
||||
| Gap | Evidence required to close |
|
||||
| --- | --- |
|
||||
| No Go module or declared support matrix | Established module path, supported Go versions and platform targets documented in the release procedure |
|
||||
| No release runbook | `docs/release.md` exists with the prerequisites, commands, publication guards, and verification required by release policy |
|
||||
| No complete release checker | A documented command exists and exercises every applicable automated candidate gate; manual gates and inapplicable checks are explicit |
|
||||
| No consumer verification procedure | Runbook verifies retrieval of the exact released module version and a representative consumer build without local replacement directives |
|
||||
| No verified secret/precedence/explanation contracts | Current contracts and passing bootstrap skip/partial-failure, secret isolation, CLI precedence, and safe historical-value regressions; representative consumer examples |
|
||||
| No validated release candidate | Version-matched release note, current contracts/examples, and complete checks against the exact committed candidate |
|
||||
|
||||
No release can proceed while these prerequisites remain open. Close items only
|
||||
on evidence from actual files and checks, not proposed commands. Record closure
|
||||
evidence here while this roadmap remains active. Once all gaps are closed,
|
||||
remove the bootstrap exception and retire this document in favor of the
|
||||
operational runbook and normal release records.
|
||||
151
documentation_test.go
Normal file
151
documentation_test.go
Normal file
@@ -0,0 +1,151 @@
|
||||
package configkit
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"testing"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
var markdownLinkPattern = regexp.MustCompile(`(?m)(?:^|[[:space:]])\[[^\]]*\]\(([^)[:space:]]+)`)
|
||||
|
||||
func TestLocalDocumentationLinks(t *testing.T) {
|
||||
repository, err := filepath.Abs(".")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var failures []string
|
||||
err = filepath.WalkDir(repository, func(path string, entry os.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if entry.IsDir() {
|
||||
if entry.Name() == ".git" {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if filepath.Ext(path) != ".md" {
|
||||
return nil
|
||||
}
|
||||
content, readErr := os.ReadFile(path)
|
||||
if readErr != nil {
|
||||
return readErr
|
||||
}
|
||||
for _, match := range markdownLinkPattern.FindAllStringSubmatch(string(content), -1) {
|
||||
target := strings.Trim(match[1], "<>")
|
||||
if strings.Contains(target, "://") || strings.HasPrefix(target, "mailto:") {
|
||||
continue
|
||||
}
|
||||
filePart, fragment, _ := strings.Cut(target, "#")
|
||||
decoded, decodeErr := url.PathUnescape(filePart)
|
||||
if decodeErr != nil {
|
||||
failures = append(failures, fmt.Sprintf("%s: invalid escaped link %q", relativePath(repository, path), target))
|
||||
continue
|
||||
}
|
||||
linked := path
|
||||
if decoded != "" {
|
||||
linked = filepath.Clean(filepath.Join(filepath.Dir(path), filepath.FromSlash(decoded)))
|
||||
}
|
||||
_, statErr := os.Stat(linked)
|
||||
outside := linked != repository && !strings.HasPrefix(linked, repository+string(filepath.Separator))
|
||||
if os.IsNotExist(statErr) && outside && optionalSiblingSource(repository, linked) {
|
||||
continue // Recorded sibling-source links are optional in isolated clones.
|
||||
}
|
||||
if statErr != nil {
|
||||
failures = append(failures, fmt.Sprintf("%s: missing local link %q", relativePath(repository, path), target))
|
||||
continue
|
||||
}
|
||||
if fragment != "" && filepath.Ext(linked) == ".md" && !markdownHasFragment(linked, fragment) {
|
||||
failures = append(failures, fmt.Sprintf("%s: missing fragment in %q", relativePath(repository, path), target))
|
||||
}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(failures) != 0 {
|
||||
t.Fatalf("documentation link failures:\n%s", strings.Join(failures, "\n"))
|
||||
}
|
||||
}
|
||||
|
||||
func optionalSiblingSource(repository, linked string) bool {
|
||||
relative, err := filepath.Rel(filepath.Dir(repository), linked)
|
||||
if err != nil || relative == "." || filepath.IsAbs(relative) {
|
||||
return false
|
||||
}
|
||||
parts := strings.Split(filepath.ToSlash(relative), "/")
|
||||
if len(parts) == 0 {
|
||||
return false
|
||||
}
|
||||
switch strings.ToLower(parts[0]) {
|
||||
case "narratio", "notarius", "weatherreporter", "seriatim":
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func markdownHasFragment(path, wanted string) bool {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
defer file.Close()
|
||||
wanted, err = url.PathUnescape(wanted)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
seen := make(map[string]int)
|
||||
scanner := bufio.NewScanner(file)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if !strings.HasPrefix(line, "#") {
|
||||
continue
|
||||
}
|
||||
heading := strings.TrimSpace(strings.TrimLeft(line, "#"))
|
||||
base := markdownFragment(heading)
|
||||
count := seen[base]
|
||||
seen[base]++
|
||||
fragment := base
|
||||
if count > 0 {
|
||||
fragment = fmt.Sprintf("%s-%d", base, count)
|
||||
}
|
||||
if fragment == wanted {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func markdownFragment(heading string) string {
|
||||
var result strings.Builder
|
||||
previousSpace := false
|
||||
for _, character := range strings.ToLower(heading) {
|
||||
switch {
|
||||
case unicode.IsLetter(character), unicode.IsDigit(character), character == '_', character == '-':
|
||||
result.WriteRune(character)
|
||||
previousSpace = false
|
||||
case unicode.IsSpace(character):
|
||||
if result.Len() > 0 && !previousSpace {
|
||||
result.WriteByte('-')
|
||||
previousSpace = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return strings.TrimRight(result.String(), "-")
|
||||
}
|
||||
|
||||
func relativePath(root, path string) string {
|
||||
relative, err := filepath.Rel(root, path)
|
||||
if err != nil {
|
||||
return path
|
||||
}
|
||||
return filepath.ToSlash(relative)
|
||||
}
|
||||
150
example_test.go
Normal file
150
example_test.go
Normal file
@@ -0,0 +1,150 @@
|
||||
package configkit_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
func ExampleLoad() {
|
||||
type Config struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Workers int `yaml:"workers"`
|
||||
}
|
||||
result, err := configkit.Load[Config](configkit.Bytes("example.yml", []byte("enabled: true\nworkers: 3\n")))
|
||||
fmt.Println(result.Config, err)
|
||||
// Output: {true 3} <nil>
|
||||
}
|
||||
|
||||
func ExampleNoSource() {
|
||||
type Config struct {
|
||||
Workers int `yaml:"workers"`
|
||||
}
|
||||
result, err := configkit.Load(configkit.NoSource(), configkit.Options[Config]{
|
||||
Definition: configkit.Definition[Config]{Defaults: func() Config { return Config{Workers: 4} }},
|
||||
})
|
||||
fmt.Println(result.Config.Workers, len(result.Sources()), err)
|
||||
// Output: 4 0 <nil>
|
||||
}
|
||||
|
||||
func ExampleValidation_Error() {
|
||||
type Config struct {
|
||||
First string `yaml:"first"`
|
||||
Second string `yaml:"second"`
|
||||
}
|
||||
_, err := configkit.Load(configkit.Bytes("duplicates.yml", []byte("first: same\nsecond: same\n")), configkit.Options[Config]{
|
||||
Definition: configkit.Definition[Config]{Validate: func(configuration *Config, validation *configkit.Validation) {
|
||||
if configuration.First == configuration.Second {
|
||||
validation.Error(configkit.Keys("second"), "must differ from first", configkit.Keys("first"))
|
||||
}
|
||||
}},
|
||||
})
|
||||
var loadError *configkit.LoadError
|
||||
errors.As(err, &loadError)
|
||||
diagnostic := loadError.Diagnostics()[0]
|
||||
fmt.Println(diagnostic.Code, diagnostic.Path, len(diagnostic.Related))
|
||||
// Output: validate.error /second 1
|
||||
}
|
||||
|
||||
func ExampleVersionRule() {
|
||||
type Config struct {
|
||||
Version string `yaml:"version"`
|
||||
}
|
||||
_, err := configkit.Load(configkit.Bytes("version.yml", []byte("version: old\n")), configkit.Options[Config]{
|
||||
Definition: configkit.Definition[Config]{Version: &configkit.VersionRule{
|
||||
Path: configkit.Keys("version"),
|
||||
Check: func(version configkit.Version, validation *configkit.Validation) error {
|
||||
if version.Text != "v1" {
|
||||
validation.Error(configkit.Keys("version"), "unsupported version; migrate using the application guide")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}},
|
||||
})
|
||||
var loadError *configkit.LoadError
|
||||
errors.As(err, &loadError)
|
||||
fmt.Println(loadError.Diagnostics()[0].Message)
|
||||
// Output: unsupported version; migrate using the application guide
|
||||
}
|
||||
|
||||
func ExampleResolution() {
|
||||
type Config struct {
|
||||
Input int `yaml:"input"`
|
||||
Derived int `yaml:"derived"`
|
||||
}
|
||||
var order []string
|
||||
result, err := configkit.Load(configkit.Bytes("resolved.yml", []byte("input: 3\n")), configkit.Options[Config]{
|
||||
Definition: configkit.Definition[Config]{
|
||||
Resolve: func(configuration *Config, resolution *configkit.Resolution) error {
|
||||
order = append(order, "resolve")
|
||||
configkit.Default(resolution, configkit.Keys("derived"), &configuration.Derived,
|
||||
configuration.Input*2, "derived from input", configkit.Keys("input"))
|
||||
return nil
|
||||
},
|
||||
Validate: func(configuration *Config, validation *configkit.Validation) {
|
||||
order = append(order, "validate")
|
||||
if configuration.Derived < configuration.Input {
|
||||
validation.Error(configkit.Keys("derived"), "must not be less than input")
|
||||
}
|
||||
},
|
||||
},
|
||||
})
|
||||
fmt.Println(strings.Join(order, " then "), result.Config.Derived, err)
|
||||
// Output: resolve then validate 6 <nil>
|
||||
}
|
||||
|
||||
func ExampleSecretEnv() {
|
||||
const name = "CONFIGKIT_EXAMPLE_API_KEY"
|
||||
previous, present := os.LookupEnv(name)
|
||||
_ = os.Setenv(name, "disposable-example-value")
|
||||
defer func() {
|
||||
if present {
|
||||
_ = os.Setenv(name, previous)
|
||||
} else {
|
||||
_ = os.Unsetenv(name)
|
||||
}
|
||||
}()
|
||||
|
||||
type Config struct {
|
||||
APIKey configkit.Secret `yaml:"api_key"`
|
||||
}
|
||||
result, err := configkit.Load[Config](configkit.Bytes("secret.yml", []byte("api_key: "+name+"\n")))
|
||||
fmt.Println(result.Config.APIKey.EnvName(), result.Config.APIKey.IsResolved(), err)
|
||||
// Output: CONFIGKIT_EXAMPLE_API_KEY true <nil>
|
||||
}
|
||||
|
||||
func ExampleOverride() {
|
||||
type Config struct {
|
||||
Workers int `yaml:"workers"`
|
||||
}
|
||||
workers, workersWasSet := 0, true // Supplied by the application's CLI parser.
|
||||
var overrides []configkit.Override
|
||||
if workersWasSet {
|
||||
overrides = append(overrides, configkit.Override{
|
||||
Path: configkit.Keys("workers"), Value: workers, Flag: "--workers",
|
||||
})
|
||||
}
|
||||
result, err := configkit.Load(configkit.NoSource(), configkit.Options[Config]{
|
||||
Definition: configkit.Definition[Config]{Defaults: func() Config { return Config{Workers: 4} }},
|
||||
Overrides: overrides,
|
||||
})
|
||||
fmt.Println(result.Config.Workers, result.Sources()[0].Label, err)
|
||||
// Output: 0 --workers <nil>
|
||||
}
|
||||
|
||||
func ExampleResult_Explain() {
|
||||
type Config struct {
|
||||
Workers int `yaml:"workers"`
|
||||
}
|
||||
result, err := configkit.Load(configkit.Bytes("config.yml", []byte("workers: 8\n")), configkit.Options[Config]{
|
||||
Definition: configkit.Definition[Config]{Defaults: func() Config { return Config{Workers: 4} }},
|
||||
})
|
||||
explanation, ok := result.Explain(configkit.Keys("workers"))
|
||||
fmt.Printf("%s -> %s known=%t error=%v\n",
|
||||
strings.TrimSpace(explanation.History[0].After.YAML),
|
||||
strings.TrimSpace(explanation.Effective.YAML), ok, err)
|
||||
// Output: 4 -> 8 known=true error=<nil>
|
||||
}
|
||||
395
examples/consumers/consumers.go
Normal file
395
examples/consumers/consumers.go
Normal file
@@ -0,0 +1,395 @@
|
||||
// Package consumers contains small, maintained application shapes that exercise
|
||||
// ConfigKit without making consumer domain policy part of the library API.
|
||||
package consumers
|
||||
|
||||
import (
|
||||
"math"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
// Artifact is a Narratio-shaped generated output declaration.
|
||||
type Artifact struct {
|
||||
Output string `yaml:"output"`
|
||||
Timeout time.Duration `yaml:"timeout"`
|
||||
}
|
||||
|
||||
// NarratioConfig demonstrates application defaults, partial imports, and
|
||||
// application-owned model-profile and artifact semantics.
|
||||
type NarratioConfig struct {
|
||||
PollInterval time.Duration `yaml:"poll_interval"`
|
||||
Timeout time.Duration `yaml:"timeout"`
|
||||
ModelProfile string `yaml:"model_profile"`
|
||||
Artifacts map[string]Artifact `yaml:"artifacts"`
|
||||
}
|
||||
|
||||
// LoadNarratio loads the maintained import fixture with Narratio-shaped rules.
|
||||
func LoadNarratio(path string) (*configkit.Result[NarratioConfig], error) {
|
||||
return LoadNarratioOverlay(path, "")
|
||||
}
|
||||
|
||||
// LoadNarratioOverlay selects a ConfigKit configuration overlay independently
|
||||
// from the application's ModelProfile field.
|
||||
func LoadNarratioOverlay(path, overlay string) (*configkit.Result[NarratioConfig], error) {
|
||||
return configkit.Load(configkit.File(path), configkit.Options[NarratioConfig]{
|
||||
Imports: true, Overlay: overlay,
|
||||
Definition: configkit.Definition[NarratioConfig]{
|
||||
Defaults: func() NarratioConfig { return NarratioConfig{PollInterval: time.Minute} },
|
||||
Resolve: resolveNarratio,
|
||||
Validate: validateNarratio,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func resolveNarratio(configuration *NarratioConfig, resolution *configkit.Resolution) error {
|
||||
configkit.Default(resolution, configkit.Keys("timeout"), &configuration.Timeout,
|
||||
configuration.PollInterval/10, "derived from poll interval", configkit.Keys("poll_interval"))
|
||||
for name, artifact := range configuration.Artifacts {
|
||||
path := configkit.Keys("artifacts", name)
|
||||
configkit.Default(resolution, path.Key("timeout"), &artifact.Timeout,
|
||||
configuration.Timeout, "inherited pipeline timeout", configkit.Keys("timeout"))
|
||||
configkit.Normalize(resolution, path.Key("output"), &artifact.Output,
|
||||
strings.TrimSpace(artifact.Output), "trimmed artifact output")
|
||||
configuration.Artifacts[name] = artifact
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateNarratio(configuration *NarratioConfig, validation *configkit.Validation) {
|
||||
if configuration.PollInterval <= 0 {
|
||||
validation.Error(configkit.Keys("poll_interval"), "must be positive")
|
||||
}
|
||||
if configuration.Timeout <= 0 {
|
||||
validation.Error(configkit.Keys("timeout"), "must be positive")
|
||||
}
|
||||
for name, artifact := range configuration.Artifacts {
|
||||
path := configkit.Keys("artifacts", name)
|
||||
if artifact.Output == "" {
|
||||
validation.Error(path.Key("output"), "must be nonempty")
|
||||
}
|
||||
if artifact.Timeout <= 0 {
|
||||
validation.Error(path.Key("timeout"), "must be positive")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ObjectBinding is a Notarius-shaped canonical object binding. Options remain
|
||||
// dynamic because the application catalog selects their schema at runtime.
|
||||
type ObjectBinding struct {
|
||||
Object string `yaml:"object"`
|
||||
Options map[string]any `yaml:"options"`
|
||||
}
|
||||
|
||||
// NotariusConfig demonstrates string-backed versions and dynamic catalog options.
|
||||
type NotariusConfig struct {
|
||||
Version string `yaml:"version"`
|
||||
Bindings []ObjectBinding `yaml:"bindings"`
|
||||
}
|
||||
|
||||
// LoadNotarius loads a Notarius-shaped configuration.
|
||||
func LoadNotarius(path string) (*configkit.Result[NotariusConfig], error) {
|
||||
return configkit.Load(configkit.File(path), configkit.Options[NotariusConfig]{
|
||||
Definition: configkit.Definition[NotariusConfig]{
|
||||
Version: &configkit.VersionRule{Path: configkit.Keys("version"), Check: requireVersion("4")},
|
||||
Validate: validateNotarius,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func requireVersion(expected string) func(configkit.Version, *configkit.Validation) error {
|
||||
return func(version configkit.Version, validation *configkit.Validation) error {
|
||||
if !version.Present || version.Null || version.Text != expected {
|
||||
validation.Error(configkit.Keys("version"), "unsupported configuration version")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func validateNotarius(configuration *NotariusConfig, validation *configkit.Validation) {
|
||||
for index, binding := range configuration.Bindings {
|
||||
path := configkit.Keys("bindings").Index(index)
|
||||
if binding.Object != "summary" && binding.Object != "registry" {
|
||||
validation.Error(path.Key("object"), "unsupported object binding")
|
||||
}
|
||||
for key, value := range binding.Options {
|
||||
optionPath := path.Key("options").Key(key)
|
||||
switch key {
|
||||
case "window_units":
|
||||
units, ok := value.(int64)
|
||||
if !ok || units < 1 {
|
||||
validation.Error(optionPath, "must be a positive integer")
|
||||
}
|
||||
case "format":
|
||||
if _, ok := value.(string); !ok {
|
||||
validation.Error(optionPath, "must be a string")
|
||||
}
|
||||
default:
|
||||
validation.Error(optionPath, "unsupported object option")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// NotariusWindow converts ConfigKit's portable dynamic integer only at the
|
||||
// application runtime boundary.
|
||||
func NotariusWindow(configuration NotariusConfig, binding int) (int, bool) {
|
||||
if binding < 0 || binding >= len(configuration.Bindings) {
|
||||
return 0, false
|
||||
}
|
||||
value, ok := configuration.Bindings[binding].Options["window_units"].(int64)
|
||||
if !ok || value < 1 || (strconv.IntSize == 32 && value > math.MaxInt32) {
|
||||
return 0, false
|
||||
}
|
||||
return int(value), true
|
||||
}
|
||||
|
||||
// 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"`
|
||||
Sections []string `yaml:"sections"`
|
||||
}
|
||||
|
||||
// ModuleSpec uses the canonical object form instead of a string shorthand.
|
||||
type ModuleSpec struct {
|
||||
Module string `yaml:"module"`
|
||||
Options ModuleOptions `yaml:"options"`
|
||||
}
|
||||
|
||||
// ReportConfig keeps report-specific lists application-owned.
|
||||
type ReportConfig struct {
|
||||
Modules []ModuleSpec `yaml:"modules"`
|
||||
PathTemplates []string `yaml:"path_templates"`
|
||||
}
|
||||
|
||||
// WeatherConfig demonstrates simple defaults, reports, module options, and an
|
||||
// optional typed secret reference.
|
||||
type WeatherConfig struct {
|
||||
Units string `yaml:"units"`
|
||||
Reports map[string]ReportConfig `yaml:"reports"`
|
||||
Token *configkit.Secret `yaml:"token"`
|
||||
}
|
||||
|
||||
// LoadWeather loads a WeatherReporter-shaped source and explicit CLI records.
|
||||
func LoadWeather(source configkit.Source, overrides ...configkit.Override) (*configkit.Result[WeatherConfig], error) {
|
||||
return configkit.Load(source, configkit.Options[WeatherConfig]{
|
||||
Overrides: overrides,
|
||||
Definition: weatherDefinition(func() WeatherConfig {
|
||||
return WeatherConfig{Units: "metric"}
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
// LoadConstructedWeather runs the same rules over a detached constructed value.
|
||||
func LoadConstructedWeather(configuration WeatherConfig) (*configkit.Result[WeatherConfig], error) {
|
||||
return configkit.Load(configkit.NoSource(), configkit.Options[WeatherConfig]{
|
||||
Definition: weatherDefinition(func() WeatherConfig { return configuration }),
|
||||
})
|
||||
}
|
||||
|
||||
func weatherDefinition(defaults func() WeatherConfig) configkit.Definition[WeatherConfig] {
|
||||
return configkit.Definition[WeatherConfig]{
|
||||
Defaults: defaults,
|
||||
Resolve: func(configuration *WeatherConfig, resolution *configkit.Resolution) error {
|
||||
for reportName, report := range configuration.Reports {
|
||||
var modules []ModuleSpec
|
||||
if report.Modules != nil {
|
||||
modules = append(make([]ModuleSpec, 0, len(report.Modules)), report.Modules...)
|
||||
}
|
||||
for index, module := range modules {
|
||||
if module.Options.Sections != nil {
|
||||
sections := append([]string(nil), module.Options.Sections...)
|
||||
for position := range sections {
|
||||
sections[position] = strings.TrimSpace(sections[position])
|
||||
}
|
||||
module.Options.Sections = sections
|
||||
}
|
||||
modules[index] = module
|
||||
}
|
||||
configkit.Normalize(resolution, configkit.Keys("reports", reportName, "modules"),
|
||||
&report.Modules, modules, "normalized module options")
|
||||
configuration.Reports[reportName] = report
|
||||
}
|
||||
return nil
|
||||
},
|
||||
Validate: validateWeather,
|
||||
}
|
||||
}
|
||||
|
||||
func validateWeather(configuration *WeatherConfig, validation *configkit.Validation) {
|
||||
if configuration.Units != "metric" && configuration.Units != "us" {
|
||||
validation.Error(configkit.Keys("units"), "must be metric or us")
|
||||
}
|
||||
for reportName, report := range configuration.Reports {
|
||||
path := configkit.Keys("reports", reportName)
|
||||
if report.Modules != nil && len(report.Modules) == 0 {
|
||||
validation.Error(path.Key("modules"), "must contain at least one module")
|
||||
}
|
||||
if report.PathTemplates != nil && len(report.PathTemplates) == 0 {
|
||||
validation.Error(path.Key("path_templates"), "must contain at least one template")
|
||||
}
|
||||
for index, module := range report.Modules {
|
||||
modulePath := path.Key("modules").Index(index)
|
||||
switch module.Module {
|
||||
case "chunker":
|
||||
if module.Options.WindowUnits == nil || *module.Options.WindowUnits < 1 {
|
||||
validation.Error(modulePath.Key("options").Key("window_units"), "must be a positive integer")
|
||||
}
|
||||
if module.Options.Sections != nil {
|
||||
validation.Error(modulePath.Key("options").Key("sections"), "not supported by this module")
|
||||
}
|
||||
case "discussion":
|
||||
if module.Options.WindowUnits != nil {
|
||||
validation.Error(modulePath.Key("options").Key("window_units"), "not supported by this module")
|
||||
}
|
||||
if len(module.Options.Sections) == 0 {
|
||||
validation.Error(modulePath.Key("options").Key("sections"), "must contain at least one section")
|
||||
}
|
||||
default:
|
||||
validation.Error(modulePath.Key("module"), "unsupported module")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SpeakerRule and SpeakerRules preserve declaration order for application matching.
|
||||
type SpeakerRule struct {
|
||||
Prefix string `yaml:"prefix"`
|
||||
Name string `yaml:"name"`
|
||||
}
|
||||
|
||||
type SpeakerRules struct {
|
||||
Rules []SpeakerRule `yaml:"rules"`
|
||||
}
|
||||
|
||||
// AutocorrectRule and AutocorrectRules preserve replacement order.
|
||||
type AutocorrectRule struct {
|
||||
Find string `yaml:"find"`
|
||||
Replace string `yaml:"replace"`
|
||||
}
|
||||
|
||||
type AutocorrectRules struct {
|
||||
Rules []AutocorrectRule `yaml:"rules"`
|
||||
}
|
||||
|
||||
// LoadSpeakerRules loads one independent Seriatim-shaped rule file.
|
||||
func LoadSpeakerRules(path string) (*configkit.Result[SpeakerRules], error) {
|
||||
return configkit.Load(configkit.File(path), configkit.Options[SpeakerRules]{
|
||||
Definition: configkit.Definition[SpeakerRules]{Validate: validateSpeakerRules},
|
||||
})
|
||||
}
|
||||
|
||||
// LoadAutocorrectRules loads another independent ordered rule file.
|
||||
func LoadAutocorrectRules(path string) (*configkit.Result[AutocorrectRules], error) {
|
||||
return configkit.Load(configkit.File(path), configkit.Options[AutocorrectRules]{
|
||||
Definition: configkit.Definition[AutocorrectRules]{Validate: validateAutocorrectRules},
|
||||
})
|
||||
}
|
||||
|
||||
func validateSpeakerRules(configuration *SpeakerRules, validation *configkit.Validation) {
|
||||
seen := make(map[string]int)
|
||||
for index, rule := range configuration.Rules {
|
||||
path := configkit.Keys("rules").Index(index)
|
||||
if rule.Prefix == "" {
|
||||
validation.Error(path.Key("prefix"), "must be nonempty")
|
||||
}
|
||||
if previous, ok := seen[rule.Prefix]; ok {
|
||||
validation.Error(path.Key("prefix"), "prefix must be unique",
|
||||
configkit.Keys("rules").Index(previous).Key("prefix"))
|
||||
}
|
||||
seen[rule.Prefix] = index
|
||||
}
|
||||
}
|
||||
|
||||
func validateAutocorrectRules(configuration *AutocorrectRules, validation *configkit.Validation) {
|
||||
for index, rule := range configuration.Rules {
|
||||
path := configkit.Keys("rules").Index(index)
|
||||
if rule.Find == "" {
|
||||
validation.Error(path.Key("find"), "must be nonempty")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// SpeakerName is application runtime matching, not configuration infrastructure.
|
||||
func SpeakerName(rules SpeakerRules, input string) (string, bool) {
|
||||
for _, rule := range rules.Rules {
|
||||
if strings.HasPrefix(input, rule.Prefix) {
|
||||
return rule.Name, true
|
||||
}
|
||||
}
|
||||
return "", false
|
||||
}
|
||||
|
||||
// ApplyAutocorrect is application runtime replacement in declared order.
|
||||
func ApplyAutocorrect(rules AutocorrectRules, input string) string {
|
||||
for _, rule := range rules.Rules {
|
||||
input = strings.ReplaceAll(input, rule.Find, rule.Replace)
|
||||
}
|
||||
return input
|
||||
}
|
||||
353
examples/consumers/consumers_test.go
Normal file
353
examples/consumers/consumers_test.go
Normal file
@@ -0,0 +1,353 @@
|
||||
package consumers_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/examples/consumers"
|
||||
)
|
||||
|
||||
func Example_narratio() {
|
||||
result, err := consumers.LoadNarratio(fixture("narratio", "pipeline.yml"))
|
||||
artifact := result.Config.Artifacts["transcript"]
|
||||
fmt.Println(result.Config.ModelProfile, artifact.Output, result.Config.Timeout, artifact.Timeout, err)
|
||||
// Output: concise transcript.txt 6s 6s <nil>
|
||||
}
|
||||
|
||||
func Example_notarius() {
|
||||
result, err := consumers.LoadNotarius(fixture("notarius", "config.yml"))
|
||||
window, ok := consumers.NotariusWindow(result.Config, 0)
|
||||
fmt.Println(result.Config.Version, result.Config.Bindings[0].Object, window, ok, err)
|
||||
// Output: 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() {
|
||||
defaults, defaultsErr := consumers.LoadWeather(configkit.NoSource())
|
||||
result, err := consumers.LoadWeather(configkit.File(fixture("weather", "config.yml")))
|
||||
discussion := result.Config.Reports["daily"].Modules[1]
|
||||
fmt.Println(defaults.Config.Units, result.Config.Units, discussion.Options.Sections[0], defaultsErr, err)
|
||||
// Output: metric us short_term <nil> <nil>
|
||||
}
|
||||
|
||||
func Example_seriatim() {
|
||||
speakers, speakerErr := consumers.LoadSpeakerRules(fixture("seriatim", "speakers.yml"))
|
||||
autocorrect, autocorrectErr := consumers.LoadAutocorrectRules(fixture("seriatim", "autocorrect.yml"))
|
||||
name, found := consumers.SpeakerName(speakers.Config, "Guest: teh event occured")
|
||||
text := consumers.ApplyAutocorrect(autocorrect.Config, "teh event occured")
|
||||
fmt.Println(name, found, text, speakerErr, autocorrectErr)
|
||||
// Output: visitor true the event occurred <nil> <nil>
|
||||
}
|
||||
|
||||
func Example_weatherStartup() {
|
||||
const blockedName = "CONFIGKIT_WEATHER_EXAMPLE_BLOCKED"
|
||||
const tokenName = "CONFIGKIT_WEATHER_EXAMPLE_TOKEN"
|
||||
restoreBlocked := preserveEnvironment(blockedName)
|
||||
restoreToken := preserveEnvironment(tokenName)
|
||||
defer restoreBlocked()
|
||||
defer restoreToken()
|
||||
_ = os.Setenv(blockedName, "")
|
||||
_ = os.Unsetenv(tokenName)
|
||||
|
||||
directory, _ := os.MkdirTemp("", "configkit-weather-example-")
|
||||
defer os.RemoveAll(directory)
|
||||
_ = os.WriteFile(filepath.Join(directory, blockedName), []byte("unused-example-value\n"), 0o600)
|
||||
_ = os.WriteFile(filepath.Join(directory, tokenName), []byte("disposable-example-credential\n"), 0o600)
|
||||
report, bootstrapErr := configkit.LoadSecretsDir(directory)
|
||||
var reportOutput bytes.Buffer
|
||||
_, reportWriteErr := report.WriteTo(&reportOutput)
|
||||
|
||||
result, loadErr := consumers.LoadWeather(configkit.Bytes("weather.yml", []byte("units: metric\ntoken: "+tokenName+"\n")),
|
||||
configkit.Override{Path: configkit.Keys("units"), Value: "us", Flag: "--units"})
|
||||
var explanation bytes.Buffer
|
||||
explainErr := result.WriteExplanation(&explanation, configkit.Keys("units"))
|
||||
blocked, loaded := secretStatus(report, blockedName), secretStatus(report, tokenName)
|
||||
value, present := os.LookupEnv(tokenName)
|
||||
fmt.Printf("blocked=%s loaded=%s report=%t reference=%s explained=%t unchanged=%t errors=%t\n",
|
||||
blocked, loaded, reportOutput.Len() > 0, result.Config.Token.EnvName(),
|
||||
strings.Contains(explanation.String(), "--units"), present && value == "disposable-example-credential",
|
||||
bootstrapErr != nil || reportWriteErr != nil || loadErr != nil || explainErr != nil)
|
||||
// Output: blocked=skipped_existing loaded=loaded report=true reference=CONFIGKIT_WEATHER_EXAMPLE_TOKEN explained=true unchanged=true errors=false
|
||||
}
|
||||
|
||||
func TestMaintainedConsumerFixturesPreserveDomainOutcomes(t *testing.T) {
|
||||
narratio, err := consumers.LoadNarratio(fixture("narratio", "pipeline.yml"))
|
||||
if err != nil || narratio.Config.Artifacts["transcript"].Output != "transcript.txt" ||
|
||||
narratio.Config.ModelProfile != "concise" || len(narratio.Sources()) != 2 {
|
||||
t.Fatalf("Narratio shape = %#v, %v", narratio, err)
|
||||
}
|
||||
if origin, ok := narratio.Metadata().Lookup(configkit.Keys("artifacts", "transcript", "output")); !ok || origin.Origin.Layer != configkit.LayerResolution {
|
||||
t.Fatalf("artifact origin = %#v, %v", origin, ok)
|
||||
}
|
||||
overlaid, err := consumers.LoadNarratioOverlay(fixture("narratio", "pipeline.yml"), "testing")
|
||||
if err != nil || overlaid.Config.PollInterval.String() != "2m0s" ||
|
||||
overlaid.Config.ModelProfile != "concise" || len(overlaid.Sources()) != 3 {
|
||||
t.Fatalf("configuration overlay and application model profile = %#v, %v", overlaid, err)
|
||||
}
|
||||
if source := overlaid.Sources()[2]; source.Label != "overlays/testing.yml" || source.Layer != configkit.LayerOverlay {
|
||||
t.Fatalf("Narratio overlay source = %#v", source)
|
||||
}
|
||||
|
||||
notarius, err := consumers.LoadNotarius(fixture("notarius", "config.yml"))
|
||||
window, converted := consumers.NotariusWindow(notarius.Config, 0)
|
||||
if err != nil || !converted || window != 12 {
|
||||
t.Fatalf("Notarius shape = %#v, %v, %v", notarius, converted, err)
|
||||
}
|
||||
if _, ok := notarius.Config.Bindings[0].Options["window_units"].(int64); !ok {
|
||||
t.Fatalf("dynamic integer type = %T", notarius.Config.Bindings[0].Options["window_units"])
|
||||
}
|
||||
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" {
|
||||
t.Fatalf("WeatherReporter shape = %#v, %v", weather, err)
|
||||
}
|
||||
|
||||
speakers, err := consumers.LoadSpeakerRules(fixture("seriatim", "speakers.yml"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
name, ok := consumers.SpeakerName(speakers.Config, "Host: introduction")
|
||||
if !ok || name != "narrator" || speakers.Config.Rules[0].Prefix != "Host:" {
|
||||
t.Fatalf("ordered speaker rules = %#v, %q, %v", speakers.Config.Rules, name, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrationFixturesFailWithStableCodesAndPaths(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
load func() error
|
||||
code configkit.Code
|
||||
path string
|
||||
}{
|
||||
{
|
||||
name: "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",
|
||||
},
|
||||
{
|
||||
name: "module shorthand",
|
||||
load: func() error {
|
||||
_, err := consumers.LoadWeather(configkit.File(fixture("weather", "shorthand-module.yml")))
|
||||
return err
|
||||
},
|
||||
code: configkit.CodeDecodeType, path: "/reports/daily/modules/0",
|
||||
},
|
||||
{
|
||||
name: "explicit empty modules",
|
||||
load: func() error {
|
||||
_, err := consumers.LoadWeather(configkit.File(fixture("weather", "empty-modules.yml")))
|
||||
return err
|
||||
},
|
||||
code: configkit.CodeValidateError, path: "/reports/daily/modules",
|
||||
},
|
||||
{
|
||||
name: "explicit empty path templates",
|
||||
load: func() error {
|
||||
_, err := consumers.LoadWeather(configkit.File(fixture("weather", "empty-paths.yml")))
|
||||
return err
|
||||
},
|
||||
code: configkit.CodeValidateError, path: "/reports/daily/path_templates",
|
||||
},
|
||||
{
|
||||
name: "explicit null required option",
|
||||
load: func() error {
|
||||
_, err := consumers.LoadWeather(configkit.File(fixture("weather", "null-window.yml")))
|
||||
return err
|
||||
},
|
||||
code: configkit.CodeValidateError, path: "/reports/daily/modules/0/options/window_units",
|
||||
},
|
||||
{
|
||||
name: "strict unknown rule field",
|
||||
load: func() error {
|
||||
_, err := consumers.LoadSpeakerRules(fixture("seriatim", "unknown-field.yml"))
|
||||
return err
|
||||
},
|
||||
code: configkit.CodeDecodeUnknownField, path: "/rules/0/color",
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var loadError *configkit.LoadError
|
||||
if err := test.load(); !errors.As(err, &loadError) {
|
||||
t.Fatalf("error = %T %v", err, err)
|
||||
}
|
||||
diagnostics := loadError.Diagnostics()
|
||||
if len(diagnostics) == 0 || diagnostics[0].Code != test.code || diagnostics[0].Path.String() != test.path {
|
||||
t.Fatalf("diagnostics = %#v", diagnostics)
|
||||
}
|
||||
if strings.Contains(diagnostics[0].Message, "wrong-before-overlay") {
|
||||
t.Fatalf("diagnostic exposed raw parser input: %#v", diagnostics[0])
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestWeatherLoadedAndConstructedRulesAgreeWithoutMutation(t *testing.T) {
|
||||
loaded, err := consumers.LoadWeather(configkit.File(fixture("weather", "config.yml")))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reconstructed, err := consumers.LoadConstructedWeather(loaded.Config)
|
||||
if err != nil || !reflect.DeepEqual(reconstructed.Config, loaded.Config) {
|
||||
t.Fatalf("loaded/constructed outcomes differ: %#v, %v", reconstructed, err)
|
||||
}
|
||||
constructed := consumers.WeatherConfig{Units: loaded.Config.Units, Reports: map[string]consumers.ReportConfig{}}
|
||||
constructed.Reports["daily"] = consumers.ReportConfig{
|
||||
Modules: []consumers.ModuleSpec{{Module: "discussion", Options: consumers.ModuleOptions{Sections: []string{" short_term "}}}},
|
||||
PathTemplates: []string{"daily/{date}.md"},
|
||||
}
|
||||
wantInput := constructed.Reports["daily"].Modules[0].Options.Sections[0]
|
||||
result, err := consumers.LoadConstructedWeather(constructed)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := constructed.Reports["daily"].Modules[0].Options.Sections[0]; got != wantInput {
|
||||
t.Fatalf("constructed options mutated from %q to %q", wantInput, got)
|
||||
}
|
||||
if result.Config.Reports["daily"].Modules[0].Options.Sections[0] != "short_term" {
|
||||
t.Fatalf("normalized result = %#v", result.Config.Reports["daily"])
|
||||
}
|
||||
|
||||
empty := consumers.WeatherConfig{Units: "metric", Reports: map[string]consumers.ReportConfig{
|
||||
"daily": {Modules: []consumers.ModuleSpec{}},
|
||||
}}
|
||||
_, err = consumers.LoadConstructedWeather(empty)
|
||||
var loadError *configkit.LoadError
|
||||
if !errors.As(err, &loadError) || loadError.Diagnostics()[0].Code != configkit.CodeValidateError {
|
||||
t.Fatalf("constructed empty modules error = %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(empty.Reports["daily"].Modules, []consumers.ModuleSpec{}) {
|
||||
t.Fatal("constructed empty module input was mutated")
|
||||
}
|
||||
}
|
||||
|
||||
func 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() {
|
||||
if present {
|
||||
_ = os.Setenv(name, value)
|
||||
} else {
|
||||
_ = os.Unsetenv(name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func secretStatus(report *configkit.SecretsReport, name string) configkit.SecretFileStatus {
|
||||
for _, entry := range report.Entries() {
|
||||
if entry.Name == name {
|
||||
return entry.Status
|
||||
}
|
||||
}
|
||||
return "missing"
|
||||
}
|
||||
3
examples/consumers/testdata/narratio/artifacts.yml
vendored
Normal file
3
examples/consumers/testdata/narratio/artifacts.yml
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
artifacts:
|
||||
transcript:
|
||||
output: ' transcript.txt '
|
||||
1
examples/consumers/testdata/narratio/overlays/testing.yml
vendored
Normal file
1
examples/consumers/testdata/narratio/overlays/testing.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
poll_interval: 2m
|
||||
5
examples/consumers/testdata/narratio/pipeline.yml
vendored
Normal file
5
examples/consumers/testdata/narratio/pipeline.yml
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
configkit:
|
||||
imports:
|
||||
- path: artifacts.yml
|
||||
poll_interval: 1m
|
||||
model_profile: concise
|
||||
1
examples/consumers/testdata/notarius-paths/declarations/path.yml
vendored
Normal file
1
examples/consumers/testdata/notarius-paths/declarations/path.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
reference: ../assets/imported.txt
|
||||
1
examples/consumers/testdata/notarius-paths/defaults.yml
vendored
Normal file
1
examples/consumers/testdata/notarius-paths/defaults.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
sensitive: PATH_REFERENCE_SECRET_CANARY
|
||||
4
examples/consumers/testdata/notarius-paths/main.yml
vendored
Normal file
4
examples/consumers/testdata/notarius-paths/main.yml
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
configkit:
|
||||
imports:
|
||||
- path: declarations/path.yml
|
||||
sensitive: PATH_REFERENCE_SECRET_CANARY
|
||||
2
examples/consumers/testdata/notarius-paths/symlink-target.yml
vendored
Normal file
2
examples/consumers/testdata/notarius-paths/symlink-target.yml
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
reference: ../assets/target.txt
|
||||
sensitive: PATH_REFERENCE_SECRET_CANARY
|
||||
6
examples/consumers/testdata/notarius/config.yml
vendored
Normal file
6
examples/consumers/testdata/notarius/config.yml
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
version: "4"
|
||||
bindings:
|
||||
- object: summary
|
||||
options:
|
||||
window_units: 12
|
||||
format: markdown
|
||||
6
examples/consumers/testdata/notarius/integer-version.yml
vendored
Normal file
6
examples/consumers/testdata/notarius/integer-version.yml
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
version: 4
|
||||
bindings:
|
||||
- object: summary
|
||||
options:
|
||||
window_units: 12
|
||||
format: markdown
|
||||
2
examples/consumers/testdata/notarius/noninteger-version.yml
vendored
Normal file
2
examples/consumers/testdata/notarius/noninteger-version.yml
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
version: 4.0
|
||||
bindings: []
|
||||
2
examples/consumers/testdata/notarius/unsupported-version.yml
vendored
Normal file
2
examples/consumers/testdata/notarius/unsupported-version.yml
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
version: 5
|
||||
bindings: []
|
||||
5
examples/consumers/testdata/seriatim/autocorrect.yml
vendored
Normal file
5
examples/consumers/testdata/seriatim/autocorrect.yml
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
rules:
|
||||
- find: teh
|
||||
replace: the
|
||||
- find: occured
|
||||
replace: occurred
|
||||
5
examples/consumers/testdata/seriatim/speakers.yml
vendored
Normal file
5
examples/consumers/testdata/seriatim/speakers.yml
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
rules:
|
||||
- prefix: 'Host:'
|
||||
name: narrator
|
||||
- prefix: 'Guest:'
|
||||
name: visitor
|
||||
4
examples/consumers/testdata/seriatim/unknown-field.yml
vendored
Normal file
4
examples/consumers/testdata/seriatim/unknown-field.yml
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
rules:
|
||||
- prefix: 'Host:'
|
||||
name: narrator
|
||||
color: blue
|
||||
11
examples/consumers/testdata/weather/config.yml
vendored
Normal file
11
examples/consumers/testdata/weather/config.yml
vendored
Normal file
@@ -0,0 +1,11 @@
|
||||
units: us
|
||||
reports:
|
||||
daily:
|
||||
modules:
|
||||
- module: chunker
|
||||
options:
|
||||
window_units: 12
|
||||
- module: discussion
|
||||
options:
|
||||
sections: [' short_term ']
|
||||
path_templates: ['daily/{date}.md']
|
||||
3
examples/consumers/testdata/weather/empty-modules.yml
vendored
Normal file
3
examples/consumers/testdata/weather/empty-modules.yml
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
reports:
|
||||
daily:
|
||||
modules: []
|
||||
3
examples/consumers/testdata/weather/empty-paths.yml
vendored
Normal file
3
examples/consumers/testdata/weather/empty-paths.yml
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
reports:
|
||||
daily:
|
||||
path_templates: []
|
||||
6
examples/consumers/testdata/weather/null-window.yml
vendored
Normal file
6
examples/consumers/testdata/weather/null-window.yml
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
reports:
|
||||
daily:
|
||||
modules:
|
||||
- module: chunker
|
||||
options:
|
||||
window_units: null
|
||||
3
examples/consumers/testdata/weather/shorthand-module.yml
vendored
Normal file
3
examples/consumers/testdata/weather/shorthand-module.yml
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
reports:
|
||||
daily:
|
||||
modules: [chunker]
|
||||
272
facade_candidate_test.go
Normal file
272
facade_candidate_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
85
facade_compatibility_test.go
Normal file
85
facade_compatibility_test.go
Normal 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
51
facade_context_test.go
Normal 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
52
facade_internal_test.go
Normal 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
66
facade_version_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
5
go.mod
Normal file
5
go.mod
Normal file
@@ -0,0 +1,5 @@
|
||||
module gitea.maximumdirect.net/eric/ConfigKit
|
||||
|
||||
go 1.25.0
|
||||
|
||||
require go.yaml.in/yaml/v4 v4.0.0-rc.6
|
||||
2
go.sum
Normal file
2
go.sum
Normal file
@@ -0,0 +1,2 @@
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=
|
||||
go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
||||
59
import_boundary_test.go
Normal file
59
import_boundary_test.go
Normal file
@@ -0,0 +1,59 @@
|
||||
package configkit
|
||||
|
||||
import (
|
||||
"go/parser"
|
||||
"go/token"
|
||||
"io/fs"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDependencyImportBoundary(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
err := filepath.WalkDir(".", func(path string, entry fs.DirEntry, walkErr error) error {
|
||||
if walkErr != nil {
|
||||
return walkErr
|
||||
}
|
||||
if entry.IsDir() {
|
||||
if path == ".git" {
|
||||
return filepath.SkipDir
|
||||
}
|
||||
return nil
|
||||
}
|
||||
if filepath.Ext(path) != ".go" {
|
||||
return nil
|
||||
}
|
||||
file, err := parser.ParseFile(token.NewFileSet(), path, nil, parser.ImportsOnly)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, imported := range file.Imports {
|
||||
importPath, err := strconv.Unquote(imported.Path.Value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if strings.HasPrefix(importPath, "go.yaml.in/yaml/") && !strings.HasPrefix(filepath.ToSlash(path), "internal/yaml/") {
|
||||
t.Errorf("%s imports private YAML dependency %s", path, importPath)
|
||||
}
|
||||
if strings.HasPrefix(importPath, "github.com/google/go-cmp/") && !strings.HasSuffix(path, "_test.go") {
|
||||
t.Errorf("%s imports test-only dependency %s", path, importPath)
|
||||
}
|
||||
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
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
70
inspection.go
Normal file
70
inspection.go
Normal 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))
|
||||
}
|
||||
65
internal/document/document.go
Normal file
65
internal/document/document.go
Normal file
@@ -0,0 +1,65 @@
|
||||
// Package document defines ConfigKit's parser-independent configuration tree.
|
||||
package document
|
||||
|
||||
// Kind identifies a configuration node category.
|
||||
type Kind uint8
|
||||
|
||||
const (
|
||||
Mapping Kind = iota + 1
|
||||
Sequence
|
||||
String
|
||||
Boolean
|
||||
Number
|
||||
Null
|
||||
)
|
||||
|
||||
// Location identifies a source coordinate. Root depth and unavailable coordinates
|
||||
// are represented independently by callers; line and column are one-based when set.
|
||||
type Location struct {
|
||||
SourceID string
|
||||
Line int
|
||||
Column int
|
||||
}
|
||||
|
||||
// Entry is an ordered mapping entry with separate key and value locations.
|
||||
type Entry struct {
|
||||
Key string
|
||||
KeyLocation Location
|
||||
ValueLocation Location
|
||||
Value *Node
|
||||
}
|
||||
|
||||
// Node is ConfigKit's owned configuration representation. Number Text retains its
|
||||
// exact validated decimal spelling; Boolean Text is "true" or "false".
|
||||
type Node struct {
|
||||
// Sensitive preserves field classification from concrete typed projections.
|
||||
// It is metadata, not part of the configuration value or emitted YAML.
|
||||
Sensitive bool
|
||||
Kind Kind
|
||||
Location Location
|
||||
Text string
|
||||
Mapping []Entry
|
||||
Sequence []*Node
|
||||
}
|
||||
|
||||
// Clone returns a detached copy of a node tree.
|
||||
func Clone(node *Node) *Node {
|
||||
if node == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := *node
|
||||
if node.Mapping != nil {
|
||||
cloned.Mapping = make([]Entry, len(node.Mapping))
|
||||
for i, entry := range node.Mapping {
|
||||
cloned.Mapping[i] = entry
|
||||
cloned.Mapping[i].Value = Clone(entry.Value)
|
||||
}
|
||||
}
|
||||
if node.Sequence != nil {
|
||||
cloned.Sequence = make([]*Node, len(node.Sequence))
|
||||
for i, child := range node.Sequence {
|
||||
cloned.Sequence[i] = Clone(child)
|
||||
}
|
||||
}
|
||||
return &cloned
|
||||
}
|
||||
52
internal/engine/callback_ownership_test.go
Normal file
52
internal/engine/callback_ownership_test.go
Normal 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
159
internal/engine/clone.go
Normal 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
|
||||
}
|
||||
}
|
||||
190
internal/engine/compose.go
Normal file
190
internal/engine/compose.go
Normal file
@@ -0,0 +1,190 @@
|
||||
package engine
|
||||
|
||||
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
|
||||
// composedDocument is the private handoff between source assembly and later
|
||||
// precedence layers. Claims remain ordered by source and declaration traversal;
|
||||
// origins retain every source that contributes to a composite mapping.
|
||||
type composedDocument struct {
|
||||
root *document.Node
|
||||
claims []compositionClaim
|
||||
origins map[string]Origin
|
||||
overlays []overlayRecord
|
||||
applications []historyApplication
|
||||
inputBytes int64
|
||||
inputNodes int
|
||||
}
|
||||
|
||||
// cloneComposedDocument detaches mutable composition and provenance state;
|
||||
// history application trees are immutable and retain their existing sharing.
|
||||
func cloneComposedDocument(base *composedDocument) *composedDocument {
|
||||
return &composedDocument{
|
||||
root: document.Clone(base.root), claims: cloneCompositionClaims(base.claims),
|
||||
origins: cloneOriginIndex(base.origins), overlays: cloneOverlayRecords(base.overlays),
|
||||
applications: cloneHistoryApplications(base.applications),
|
||||
inputBytes: base.inputBytes, inputNodes: base.inputNodes,
|
||||
}
|
||||
}
|
||||
|
||||
type compositionClaim struct {
|
||||
path Path
|
||||
kind document.Kind
|
||||
location Location
|
||||
}
|
||||
|
||||
type compositionClaims struct {
|
||||
ordered []compositionClaim
|
||||
byPath map[string][]compositionClaim
|
||||
}
|
||||
|
||||
// composeDocuments recursively joins disjoint mapping entries. Source order
|
||||
// controls only output traversal and diagnostics: every non-mapping overlap is
|
||||
// rejected before a result is assembled.
|
||||
func composeDocuments(inputs ...*document.Node) (*composedDocument, []Diagnostic) {
|
||||
if len(inputs) == 0 {
|
||||
return nil, []Diagnostic{compositionInvalidDiagnostic("strict composition requires at least one document", nil)}
|
||||
}
|
||||
|
||||
collected := compositionClaims{byPath: make(map[string][]compositionClaim)}
|
||||
for _, input := range inputs {
|
||||
if input == nil || input.Kind != document.Mapping {
|
||||
var location *Location
|
||||
if input != nil {
|
||||
converted := publicLocation(input.Location)
|
||||
location = &converted
|
||||
}
|
||||
return nil, []Diagnostic{compositionInvalidDiagnostic("strict composition requires mapping document roots", location)}
|
||||
}
|
||||
collected.append(input, Path{})
|
||||
}
|
||||
|
||||
for _, claim := range collected.ordered {
|
||||
claims := collected.byPath[claim.path.String()]
|
||||
if len(claims) < 2 || allMappingClaims(claims) {
|
||||
continue
|
||||
}
|
||||
return nil, []Diagnostic{compositionConflictDiagnostic(claim.path, claims)}
|
||||
}
|
||||
|
||||
root := document.Clone(inputs[0])
|
||||
for _, input := range inputs[1:] {
|
||||
mergeDisjointMappings(root, input)
|
||||
}
|
||||
applications := make([]historyApplication, 0, len(inputs))
|
||||
for _, input := range inputs {
|
||||
applications = append(applications, historyApplication{layer: LayerBase, value: input})
|
||||
}
|
||||
return &composedDocument{
|
||||
root: root,
|
||||
claims: cloneCompositionClaims(collected.ordered),
|
||||
origins: compositionOrigins(collected.ordered),
|
||||
applications: applications,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *compositionClaims) append(node *document.Node, path Path) {
|
||||
claim := compositionClaim{
|
||||
path: path.clone(),
|
||||
kind: node.Kind,
|
||||
location: publicLocation(node.Location),
|
||||
}
|
||||
c.ordered = append(c.ordered, claim)
|
||||
pointer := path.String()
|
||||
c.byPath[pointer] = append(c.byPath[pointer], claim)
|
||||
|
||||
if node.Kind != document.Mapping {
|
||||
return
|
||||
}
|
||||
for _, entry := range node.Mapping {
|
||||
c.append(entry.Value, path.Key(entry.Key))
|
||||
}
|
||||
}
|
||||
|
||||
func allMappingClaims(claims []compositionClaim) bool {
|
||||
for _, claim := range claims {
|
||||
if claim.kind != document.Mapping {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func mergeDisjointMappings(target, incoming *document.Node) {
|
||||
for _, incomingEntry := range incoming.Mapping {
|
||||
index := documentEntryIndex(target.Mapping, incomingEntry.Key)
|
||||
if index < 0 {
|
||||
cloned := incomingEntry
|
||||
cloned.Value = document.Clone(incomingEntry.Value)
|
||||
target.Mapping = append(target.Mapping, cloned)
|
||||
continue
|
||||
}
|
||||
// Claim validation guarantees that the only remaining overlap is a
|
||||
// mapping pair. Empty mappings naturally contribute no child entries.
|
||||
mergeDisjointMappings(target.Mapping[index].Value, incomingEntry.Value)
|
||||
}
|
||||
}
|
||||
|
||||
func documentEntryIndex(entries []document.Entry, key string) int {
|
||||
for index := range entries {
|
||||
if entries[index].Key == key {
|
||||
return index
|
||||
}
|
||||
}
|
||||
return -1
|
||||
}
|
||||
|
||||
func compositionOrigins(claims []compositionClaim) map[string]Origin {
|
||||
origins := make(map[string]Origin, len(claims))
|
||||
for _, claim := range claims {
|
||||
pointer := claim.path.String()
|
||||
origin := origins[pointer]
|
||||
origin.Layer = LayerBase
|
||||
origin.Locations = append(origin.Locations, claim.location)
|
||||
origins[pointer] = origin
|
||||
}
|
||||
return origins
|
||||
}
|
||||
|
||||
func applyCompositionOrigins(decoded *decodedConfiguration, origins map[string]Origin) {
|
||||
for pointer, origin := range origins {
|
||||
if _, present := decoded.presence[pointer]; present {
|
||||
decoded.origins[pointer] = cloneOrigin(origin)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cloneCompositionClaims(claims []compositionClaim) []compositionClaim {
|
||||
cloned := make([]compositionClaim, len(claims))
|
||||
for index, claim := range claims {
|
||||
cloned[index] = claim
|
||||
cloned[index].path = claim.path.clone()
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func compositionInvalidDiagnostic(message string, location *Location) Diagnostic {
|
||||
return Diagnostic{
|
||||
Code: CodeComposeInvalid, Severity: SeverityError, Message: message,
|
||||
Primary: cloneLocation(location),
|
||||
}
|
||||
}
|
||||
|
||||
func compositionConflictDiagnostic(path Path, claims []compositionClaim) Diagnostic {
|
||||
primary := claims[0].location
|
||||
related := make([]Location, 0, len(claims)-1)
|
||||
for _, claim := range claims[1:] {
|
||||
related = append(related, claim.location)
|
||||
}
|
||||
return Diagnostic{
|
||||
Code: CodeComposeConflict,
|
||||
Severity: SeverityError,
|
||||
Path: path.clone(),
|
||||
Message: "configuration path is defined by multiple sources",
|
||||
Primary: &primary,
|
||||
Related: related,
|
||||
}
|
||||
}
|
||||
|
||||
func publicLocation(location document.Location) Location {
|
||||
return Location{SourceID: location.SourceID, Line: location.Line, Column: location.Column}
|
||||
}
|
||||
194
internal/engine/compose_test.go
Normal file
194
internal/engine/compose_test.go
Normal file
@@ -0,0 +1,194 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
)
|
||||
|
||||
func TestComposeDocumentsJoinsDisjointMappingsAndTracksClaims(t *testing.T) {
|
||||
base := parseCompositionDocument(t, "source-1", `services:
|
||||
api:
|
||||
enabled: true
|
||||
empty: {}
|
||||
`)
|
||||
fragment := parseCompositionDocument(t, "source-2", `services:
|
||||
worker:
|
||||
enabled: false
|
||||
values: [one, two]
|
||||
`)
|
||||
baseBefore := document.Clone(base)
|
||||
fragmentBefore := document.Clone(fragment)
|
||||
|
||||
composed, diagnostics := composeDocuments(base, fragment)
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("composeDocuments() diagnostics = %#v", diagnostics)
|
||||
}
|
||||
for _, path := range []Path{
|
||||
Keys("services", "api", "enabled"),
|
||||
Keys("services", "worker", "enabled"),
|
||||
Keys("empty"),
|
||||
Keys("values"),
|
||||
} {
|
||||
if _, ok := documentAtPath(composed.root, path); !ok {
|
||||
t.Errorf("composed document is missing %s", path.String())
|
||||
}
|
||||
}
|
||||
|
||||
serviceOrigin := composed.origins[Keys("services").String()]
|
||||
wantLocations := []Location{
|
||||
{SourceID: "source-1", Line: 2, Column: 3},
|
||||
{SourceID: "source-2", Line: 2, Column: 3},
|
||||
}
|
||||
if !reflect.DeepEqual(serviceOrigin, Origin{Layer: LayerBase, Locations: wantLocations}) {
|
||||
t.Fatalf("services origin = %#v, want composite origin", serviceOrigin)
|
||||
}
|
||||
wantClaimPaths := []string{"", "/services", "/services/api", "/services/api/enabled", "/empty", "", "/services", "/services/worker", "/services/worker/enabled", "/values"}
|
||||
gotClaimPaths := make([]string, len(composed.claims))
|
||||
for index, claim := range composed.claims {
|
||||
gotClaimPaths[index] = claim.path.String()
|
||||
}
|
||||
if !reflect.DeepEqual(gotClaimPaths, wantClaimPaths) {
|
||||
t.Fatalf("claim paths = %#v, want %#v", gotClaimPaths, wantClaimPaths)
|
||||
}
|
||||
reversed, reversedDiagnostics := composeDocuments(fragment, base)
|
||||
if len(reversedDiagnostics) != 0 {
|
||||
t.Fatalf("reversed composeDocuments() diagnostics = %#v", reversedDiagnostics)
|
||||
}
|
||||
if deltas := compareConfigurationValues(composed.root, reversed.root); len(deltas) != 0 {
|
||||
t.Fatalf("input order changed effective values: %#v", deltas)
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(base, baseBefore) || !reflect.DeepEqual(fragment, fragmentBefore) {
|
||||
t.Fatal("composition mutated an input document")
|
||||
}
|
||||
composed.root.Mapping[0].Value.Mapping[0].Value.Mapping[0].Value.Text = "changed"
|
||||
if !reflect.DeepEqual(base, baseBefore) || !reflect.DeepEqual(fragment, fragmentBefore) {
|
||||
t.Fatal("composed document aliases an input document")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeDocumentsRejectsEveryPresentNonMappingOverlap(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
left string
|
||||
right string
|
||||
path string
|
||||
}{
|
||||
{name: "equal scalar", left: "value: true\n", right: "value: true\n", path: "/value"},
|
||||
{name: "different scalar", left: "value: true\n", right: "value: false\n", path: "/value"},
|
||||
{name: "null", left: "value: null\n", right: "value: null\n", path: "/value"},
|
||||
{name: "sequence", left: "value: [one]\n", right: "value: [two]\n", path: "/value"},
|
||||
{name: "mapping and scalar", left: "value:\n nested: true\n", right: "value: text\n", path: "/value"},
|
||||
{name: "scalar and mapping", left: "value: text\n", right: "value:\n nested: true\n", path: "/value"},
|
||||
{name: "nested keyed entry", left: "items:\n shared:\n left: 1\n", right: "items:\n shared:\n left: 2\n", path: "/items/shared/left"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
left := parseCompositionDocument(t, "source-1", test.left)
|
||||
right := parseCompositionDocument(t, "source-2", test.right)
|
||||
_, diagnostics := composeDocuments(left, right)
|
||||
assertCompositionConflict(t, diagnostics, test.path, []string{"source-1", "source-2"})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeDocumentsReportsAllClaimantsAtExactLocations(t *testing.T) {
|
||||
first := parseCompositionDocument(t, "source-1", "group:\n value: 1\n")
|
||||
second := parseCompositionDocument(t, "source-2", "group:\n value: 2\n")
|
||||
third := parseCompositionDocument(t, "source-3", "group:\n value: 3\n")
|
||||
|
||||
_, diagnostics := composeDocuments(first, second, third)
|
||||
assertCompositionConflict(t, diagnostics, "/group/value", []string{"source-1", "source-2", "source-3"})
|
||||
locations := append([]Location{*diagnostics[0].Primary}, diagnostics[0].Related...)
|
||||
for _, location := range locations {
|
||||
if location.Line != 2 || location.Column != 10 {
|
||||
t.Fatalf("claim location = %#v, want line 2 column 10", location)
|
||||
}
|
||||
}
|
||||
|
||||
_, diagnostics = composeDocuments(
|
||||
parseCompositionDocument(t, "source-1", "group:\n left: 1\n"),
|
||||
parseCompositionDocument(t, "source-2", "group:\n right: 2\n"),
|
||||
parseCompositionDocument(t, "source-3", "group: blocked\n"),
|
||||
)
|
||||
assertCompositionConflict(t, diagnostics, "/group", []string{"source-1", "source-2", "source-3"})
|
||||
}
|
||||
|
||||
func TestComposeDocumentsTreatsEmptyMappingsAsChildlessContributions(t *testing.T) {
|
||||
empty, diagnostics := composeDocuments(
|
||||
parseCompositionDocument(t, "source-1", "{}\n"),
|
||||
parseCompositionDocument(t, "source-2", "{}\n"),
|
||||
)
|
||||
if len(diagnostics) != 0 || len(empty.root.Mapping) != 0 || len(empty.origins[""].Locations) != 2 {
|
||||
t.Fatalf("empty root composition = (%#v, %#v)", empty, diagnostics)
|
||||
}
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
left string
|
||||
right string
|
||||
}{
|
||||
{name: "two empty mappings", left: "value: {}\n", right: "value: {}\n"},
|
||||
{name: "empty then populated", left: "value: {}\n", right: "value: {nested: true}\n"},
|
||||
{name: "populated then empty", left: "value: {nested: true}\n", right: "value: {}\n"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
composed, diagnostics := composeDocuments(
|
||||
parseCompositionDocument(t, "source-1", test.left),
|
||||
parseCompositionDocument(t, "source-2", test.right),
|
||||
)
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("composeDocuments() diagnostics = %#v", diagnostics)
|
||||
}
|
||||
if origin := composed.origins["/value"]; len(origin.Locations) != 2 {
|
||||
t.Fatalf("value origin = %#v, want both mapping locations", origin)
|
||||
}
|
||||
if test.name != "two empty mappings" {
|
||||
if _, ok := documentAtPath(composed.root, Keys("value", "nested")); !ok {
|
||||
t.Fatal("populated mapping contribution was not retained")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeDocumentsRejectsInvalidBoundaries(t *testing.T) {
|
||||
if composed, diagnostics := composeDocuments(); composed != nil || len(diagnostics) != 1 || diagnostics[0].Code != CodeComposeInvalid {
|
||||
t.Fatalf("empty composition = (%#v, %#v)", composed, diagnostics)
|
||||
}
|
||||
nonMapping := &document.Node{Kind: document.String, Location: document.Location{SourceID: "source-1", Line: 4, Column: 2}}
|
||||
if composed, diagnostics := composeDocuments(nonMapping); composed != nil || len(diagnostics) != 1 || diagnostics[0].Primary == nil || diagnostics[0].Primary.Line != 4 {
|
||||
t.Fatalf("non-mapping composition = (%#v, %#v)", composed, diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func parseCompositionDocument(t *testing.T, sourceID, input string) *document.Node {
|
||||
t.Helper()
|
||||
parsed, diagnostics := parseSource(Bytes(sourceID, []byte(input)), sourceID, defaultParseLimits())
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("parse diagnostics = %#v", diagnostics)
|
||||
}
|
||||
return parsed.document
|
||||
}
|
||||
|
||||
func assertCompositionConflict(t *testing.T, diagnostics []Diagnostic, path string, sources []string) {
|
||||
t.Helper()
|
||||
if len(diagnostics) != 1 {
|
||||
t.Fatalf("diagnostics = %#v, want one conflict", diagnostics)
|
||||
}
|
||||
diagnostic := diagnostics[0]
|
||||
if diagnostic.Code != CodeComposeConflict || diagnostic.Path.String() != path || diagnostic.Primary == nil {
|
||||
t.Fatalf("diagnostic = %#v, want compose conflict at %s", diagnostic, path)
|
||||
}
|
||||
locations := append([]Location{*diagnostic.Primary}, diagnostic.Related...)
|
||||
gotSources := make([]string, len(locations))
|
||||
for index, location := range locations {
|
||||
gotSources[index] = location.SourceID
|
||||
}
|
||||
if !reflect.DeepEqual(gotSources, sources) {
|
||||
t.Fatalf("claim sources = %#v, want %#v", gotSources, sources)
|
||||
}
|
||||
}
|
||||
68
internal/engine/composition_fuzz_test.go
Normal file
68
internal/engine/composition_fuzz_test.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
yamladapter "gitea.maximumdirect.net/eric/ConfigKit/internal/yaml"
|
||||
)
|
||||
|
||||
func FuzzCompositionAndOverlay(f *testing.F) {
|
||||
for _, seed := range [][2]string{
|
||||
{"{}\n", "{}\n"},
|
||||
{"left: {value: one}\n", "right: [two]\n"},
|
||||
{"shared: {left: one}\n", "shared: {right: two}\n"},
|
||||
{"value: one\n", "value: two\n"},
|
||||
} {
|
||||
f.Add([]byte(seed[0]), []byte(seed[1]))
|
||||
}
|
||||
|
||||
limits := yamladapter.Limits{Depth: 12, Nodes: 256, ParserDepth: 24}
|
||||
f.Fuzz(func(t *testing.T, leftInput, rightInput []byte) {
|
||||
if len(leftInput) > 4<<10 || len(rightInput) > 4<<10 {
|
||||
return
|
||||
}
|
||||
left, leftIssues := yamladapter.Parse(bytes.NewReader(leftInput), "left", limits)
|
||||
right, rightIssues := yamladapter.Parse(bytes.NewReader(rightInput), "right", limits)
|
||||
if len(leftIssues) != 0 || len(rightIssues) != 0 {
|
||||
return
|
||||
}
|
||||
leftBefore, rightBefore := document.Clone(left), document.Clone(right)
|
||||
|
||||
composed, diagnostics := composeDocuments(left, right)
|
||||
if !reflect.DeepEqual(left, leftBefore) || !reflect.DeepEqual(right, rightBefore) {
|
||||
t.Fatal("composition mutated an input document")
|
||||
}
|
||||
if len(diagnostics) == 0 {
|
||||
reversed, reversedDiagnostics := composeDocuments(right, left)
|
||||
if len(reversedDiagnostics) != 0 {
|
||||
t.Fatalf("reversed composition failed: %#v", reversedDiagnostics)
|
||||
}
|
||||
if differences := compareConfigurationValues(composed.root, reversed.root); len(differences) != 0 {
|
||||
t.Fatalf("composition order changed effective values: %#v", differences)
|
||||
}
|
||||
composed.root.Mapping = append(composed.root.Mapping, document.Entry{Key: "fuzz-detachment"})
|
||||
if !reflect.DeepEqual(left, leftBefore) || !reflect.DeepEqual(right, rightBefore) {
|
||||
t.Fatal("composition result aliases an input document")
|
||||
}
|
||||
}
|
||||
|
||||
base, baseDiagnostics := composeDocuments(left)
|
||||
if len(baseDiagnostics) != 0 {
|
||||
t.Fatalf("single accepted mapping did not compose: %#v", baseDiagnostics)
|
||||
}
|
||||
overlaid, overlayDiagnostics := overlayDocuments(base, right)
|
||||
if len(overlayDiagnostics) != 0 {
|
||||
t.Fatalf("accepted mappings did not overlay: %#v", overlayDiagnostics)
|
||||
}
|
||||
if !reflect.DeepEqual(left, leftBefore) || !reflect.DeepEqual(right, rightBefore) {
|
||||
t.Fatal("overlay mutated an input document")
|
||||
}
|
||||
overlaid.root.Mapping = append(overlaid.root.Mapping, document.Entry{Key: "fuzz-detachment"})
|
||||
if !reflect.DeepEqual(left, leftBefore) || !reflect.DeepEqual(right, rightBefore) {
|
||||
t.Fatal("overlay result aliases an input document")
|
||||
}
|
||||
})
|
||||
}
|
||||
470
internal/engine/decode.go
Normal file
470
internal/engine/decode.go
Normal file
@@ -0,0 +1,470 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"encoding"
|
||||
"math"
|
||||
"reflect"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
)
|
||||
|
||||
var integerPattern = regexp.MustCompile(`^-?(0|[1-9][0-9]*)$`)
|
||||
var unsignedIntegerPattern = regexp.MustCompile(`^(0|[1-9][0-9]*)$`)
|
||||
|
||||
type decodeIssue struct {
|
||||
code Code
|
||||
path Path
|
||||
message string
|
||||
location document.Location
|
||||
}
|
||||
|
||||
type inputPresence struct {
|
||||
null bool
|
||||
location document.Location
|
||||
}
|
||||
|
||||
type decodedConfiguration struct {
|
||||
value reflect.Value
|
||||
presence map[string]inputPresence
|
||||
origins map[string]Origin
|
||||
baseline *document.Node
|
||||
final *document.Node
|
||||
}
|
||||
|
||||
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"}}
|
||||
}
|
||||
baseline, projectionIssue := projectConfiguration(schema, defaults, defaultProjectionLimits())
|
||||
if projectionIssue != nil {
|
||||
return decodedConfiguration{}, []decodeIssue{decodeIssueFromProjection(projectionIssue)}
|
||||
}
|
||||
working, cloneIssue := cloneConfiguration(schema, defaults, defaultProjectionLimits())
|
||||
if cloneIssue != nil {
|
||||
return decodedConfiguration{}, []decodeIssue{decodeIssueFromProjection(cloneIssue)}
|
||||
}
|
||||
|
||||
presence := make(map[string]inputPresence)
|
||||
if input != nil {
|
||||
collectPresence(input, Path{}, presence)
|
||||
decoded, issues := decodeValue(input, working, schema.root, Path{}, schema, context)
|
||||
if len(issues) != 0 {
|
||||
return decodedConfiguration{}, issues
|
||||
}
|
||||
working = decoded
|
||||
}
|
||||
final, projectionIssue := projectConfiguration(schema, working, defaultProjectionLimits())
|
||||
if projectionIssue != nil {
|
||||
return decodedConfiguration{}, []decodeIssue{decodeIssueFromProjection(projectionIssue)}
|
||||
}
|
||||
origins := make(map[string]Origin)
|
||||
collectOrigins(final, Path{}, presence, origins)
|
||||
return decodedConfiguration{
|
||||
value: working,
|
||||
presence: presence,
|
||||
origins: origins,
|
||||
baseline: baseline,
|
||||
final: final,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func decodeValue(node *document.Node, base reflect.Value, descriptor *typeDescriptor, path Path, schema *schemaDescriptor, context decodeContext) (reflect.Value, []decodeIssue) {
|
||||
if node.Kind == document.Null {
|
||||
switch descriptor.kind {
|
||||
case descriptorPointer, descriptorMap, descriptorSlice, descriptorDynamic:
|
||||
return reflect.Zero(descriptor.typeOf), nil
|
||||
case descriptorSecret:
|
||||
return reflect.Value{}, []decodeIssue{newDecodeIssue(CodeSecretRequired, path, node, "required secret reference cannot be null")}
|
||||
default:
|
||||
return reflect.Value{}, []decodeIssue{newDecodeIssue(CodeDecodeNull, path, node, "null is not allowed for this configuration type")}
|
||||
}
|
||||
}
|
||||
if descriptor.kind == descriptorPointer {
|
||||
var elementBase reflect.Value
|
||||
if base.IsNil() {
|
||||
elementBase = reflect.Zero(descriptor.typeOf.Elem())
|
||||
} else {
|
||||
elementBase = base.Elem()
|
||||
}
|
||||
decoded, issues := decodeValue(node, elementBase, descriptor.element, path, schema, context)
|
||||
if len(issues) != 0 {
|
||||
return reflect.Value{}, issues
|
||||
}
|
||||
result := reflect.New(descriptor.typeOf.Elem())
|
||||
result.Elem().Set(decoded)
|
||||
return result, nil
|
||||
}
|
||||
|
||||
switch descriptor.kind {
|
||||
case descriptorStruct:
|
||||
return decodeStruct(node, base, descriptor, path, schema, context)
|
||||
case descriptorMap:
|
||||
return decodeMap(node, base, descriptor, path, schema, context)
|
||||
case descriptorSlice:
|
||||
return decodeSlice(node, descriptor, path, schema, context)
|
||||
case descriptorArray:
|
||||
return decodeArray(node, descriptor, path, schema, context)
|
||||
case descriptorDynamic:
|
||||
return decodeDynamic(node, base, path, schema, context)
|
||||
case descriptorString:
|
||||
if node.Kind != document.String && !(pathMatches(context.versionPath, path) && isIntegerVersion(node)) {
|
||||
return typeMismatch(path, node, "string")
|
||||
}
|
||||
result := reflect.New(descriptor.typeOf).Elem()
|
||||
result.SetString(node.Text)
|
||||
return result, nil
|
||||
case descriptorBoolean:
|
||||
if node.Kind != document.Boolean {
|
||||
return typeMismatch(path, node, "boolean")
|
||||
}
|
||||
result := reflect.New(descriptor.typeOf).Elem()
|
||||
result.SetBool(node.Text == "true")
|
||||
return result, nil
|
||||
case descriptorSigned:
|
||||
if node.Kind != document.Number || !integerPattern.MatchString(node.Text) {
|
||||
return typeMismatch(path, node, "integer")
|
||||
}
|
||||
value, err := strconv.ParseInt(node.Text, 10, descriptor.typeOf.Bits())
|
||||
if err != nil {
|
||||
return rangeFailure(path, node, "integer")
|
||||
}
|
||||
result := reflect.New(descriptor.typeOf).Elem()
|
||||
result.SetInt(value)
|
||||
return result, nil
|
||||
case descriptorUnsigned:
|
||||
if node.Kind != document.Number || !unsignedIntegerPattern.MatchString(node.Text) {
|
||||
return typeMismatch(path, node, "unsigned integer")
|
||||
}
|
||||
value, err := strconv.ParseUint(node.Text, 10, descriptor.typeOf.Bits())
|
||||
if err != nil {
|
||||
return rangeFailure(path, node, "unsigned integer")
|
||||
}
|
||||
result := reflect.New(descriptor.typeOf).Elem()
|
||||
result.SetUint(value)
|
||||
return result, nil
|
||||
case descriptorFloat:
|
||||
if node.Kind != document.Number {
|
||||
return typeMismatch(path, node, "number")
|
||||
}
|
||||
value, err := strconv.ParseFloat(node.Text, descriptor.typeOf.Bits())
|
||||
if err != nil || math.IsNaN(value) || math.IsInf(value, 0) {
|
||||
return rangeFailure(path, node, "number")
|
||||
}
|
||||
result := reflect.New(descriptor.typeOf).Elem()
|
||||
result.SetFloat(value)
|
||||
return result, nil
|
||||
case descriptorDuration:
|
||||
if node.Kind != document.String {
|
||||
return typeMismatch(path, node, "duration string")
|
||||
}
|
||||
value, err := time.ParseDuration(node.Text)
|
||||
if err != nil {
|
||||
return reflect.Value{}, []decodeIssue{newDecodeIssue(CodeDecodeDuration, path, node, "duration string is invalid")}
|
||||
}
|
||||
result := reflect.New(descriptor.typeOf).Elem()
|
||||
result.SetInt(int64(value))
|
||||
return result, nil
|
||||
case descriptorSecret:
|
||||
if node.Kind != document.String {
|
||||
return typeMismatch(path, node, "secret environment name")
|
||||
}
|
||||
return schema.secrets.Make(SecretState{Name: node.Text, Specified: true}), nil
|
||||
case descriptorText:
|
||||
if node.Kind != document.String {
|
||||
return typeMismatch(path, node, "text string")
|
||||
}
|
||||
result := reflect.New(descriptor.typeOf).Elem()
|
||||
unmarshaler := result.Addr().Interface().(encoding.TextUnmarshaler)
|
||||
if err := unmarshaler.UnmarshalText([]byte(node.Text)); err != nil {
|
||||
return reflect.Value{}, []decodeIssue{newDecodeIssue(CodeDecodeText, path, node, "text value is invalid for its configuration type")}
|
||||
}
|
||||
return result, nil
|
||||
default:
|
||||
return reflect.Value{}, []decodeIssue{newDecodeIssue(CodeSchemaUnsupportedType, path, node, "configuration type is not supported")}
|
||||
}
|
||||
}
|
||||
|
||||
func decodeStruct(node *document.Node, base reflect.Value, descriptor *typeDescriptor, path Path, schema *schemaDescriptor, context decodeContext) (reflect.Value, []decodeIssue) {
|
||||
if node.Kind != document.Mapping {
|
||||
return typeMismatch(path, node, "mapping")
|
||||
}
|
||||
result := reflect.New(descriptor.typeOf).Elem()
|
||||
result.Set(base)
|
||||
var issues []decodeIssue
|
||||
for _, entry := range node.Mapping {
|
||||
childPath := path.Key(entry.Key)
|
||||
if position, ok := descriptor.fieldByName[entry.Key]; ok {
|
||||
field := descriptor.fields[position]
|
||||
target, ok := fieldForDecode(result, field.index, true)
|
||||
if !ok {
|
||||
issues = append(issues, decodeIssue{code: CodeSchemaUnsupportedType, path: childPath, message: "configuration field could not be addressed", location: entry.ValueLocation})
|
||||
continue
|
||||
}
|
||||
decoded, fieldIssues := decodeValue(entry.Value, target, field.descriptor, childPath, schema, context)
|
||||
if len(fieldIssues) != 0 {
|
||||
issues = append(issues, fieldIssues...)
|
||||
continue
|
||||
}
|
||||
target.Set(decoded)
|
||||
continue
|
||||
}
|
||||
if descriptor.inlineMap != nil {
|
||||
if issue := decodeInlineMapEntry(result, *descriptor.inlineMap, entry, childPath, schema, context); issue != nil {
|
||||
issues = append(issues, issue...)
|
||||
}
|
||||
continue
|
||||
}
|
||||
issues = append(issues, decodeIssue{code: CodeDecodeUnknownField, path: childPath, message: "configuration field is not declared", location: entry.KeyLocation})
|
||||
}
|
||||
return result, issues
|
||||
}
|
||||
|
||||
func decodeInlineMapEntry(target reflect.Value, field fieldDescriptor, entry document.Entry, path Path, schema *schemaDescriptor, 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}}
|
||||
}
|
||||
if mapValue.IsNil() {
|
||||
mapValue.Set(reflect.MakeMap(mapValue.Type()))
|
||||
}
|
||||
key := reflect.New(mapValue.Type().Key()).Elem()
|
||||
key.SetString(entry.Key)
|
||||
base := mapValue.MapIndex(key)
|
||||
if !base.IsValid() {
|
||||
base = reflect.Zero(mapValue.Type().Elem())
|
||||
}
|
||||
decoded, issues := decodeValue(entry.Value, base, field.descriptor.element, path, schema, 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, context decodeContext) (reflect.Value, []decodeIssue) {
|
||||
if node.Kind != document.Mapping {
|
||||
return typeMismatch(path, node, "mapping")
|
||||
}
|
||||
result := base
|
||||
if result.IsNil() {
|
||||
result = reflect.MakeMap(descriptor.typeOf)
|
||||
}
|
||||
var issues []decodeIssue
|
||||
for _, entry := range node.Mapping {
|
||||
key := reflect.New(descriptor.typeOf.Key()).Elem()
|
||||
key.SetString(entry.Key)
|
||||
entryBase := result.MapIndex(key)
|
||||
if !entryBase.IsValid() {
|
||||
entryBase = reflect.Zero(descriptor.typeOf.Elem())
|
||||
}
|
||||
decoded, entryIssues := decodeValue(entry.Value, entryBase, descriptor.element, path.Key(entry.Key), schema, context)
|
||||
if len(entryIssues) != 0 {
|
||||
issues = append(issues, entryIssues...)
|
||||
continue
|
||||
}
|
||||
result.SetMapIndex(key, decoded)
|
||||
}
|
||||
return result, issues
|
||||
}
|
||||
|
||||
func decodeSlice(node *document.Node, descriptor *typeDescriptor, path Path, schema *schemaDescriptor, 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, context)
|
||||
if len(childIssues) != 0 {
|
||||
issues = append(issues, childIssues...)
|
||||
continue
|
||||
}
|
||||
result.Index(i).Set(decoded)
|
||||
}
|
||||
return result, issues
|
||||
}
|
||||
|
||||
func decodeArray(node *document.Node, descriptor *typeDescriptor, path Path, schema *schemaDescriptor, context decodeContext) (reflect.Value, []decodeIssue) {
|
||||
if node.Kind != document.Sequence {
|
||||
return typeMismatch(path, node, "sequence")
|
||||
}
|
||||
if len(node.Sequence) != descriptor.length {
|
||||
return reflect.Value{}, []decodeIssue{newDecodeIssue(CodeDecodeArrayLength, path, node, "configuration array has the wrong length")}
|
||||
}
|
||||
result := reflect.New(descriptor.typeOf).Elem()
|
||||
var issues []decodeIssue
|
||||
for i, child := range node.Sequence {
|
||||
decoded, childIssues := decodeValue(child, reflect.Zero(descriptor.typeOf.Elem()), descriptor.element, path.Index(i), schema, context)
|
||||
if len(childIssues) != 0 {
|
||||
issues = append(issues, childIssues...)
|
||||
continue
|
||||
}
|
||||
result.Index(i).Set(decoded)
|
||||
}
|
||||
return result, issues
|
||||
}
|
||||
|
||||
func decodeDynamic(node *document.Node, base reflect.Value, path Path, schema *schemaDescriptor, 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, context)
|
||||
if len(issues) != 0 {
|
||||
return reflect.Value{}, issues
|
||||
}
|
||||
result := reflect.New(base.Type()).Elem()
|
||||
result.Set(decoded)
|
||||
return result, nil
|
||||
}
|
||||
}
|
||||
var value any
|
||||
switch node.Kind {
|
||||
case document.Null:
|
||||
return reflect.Zero(base.Type()), nil
|
||||
case document.String:
|
||||
value = node.Text
|
||||
case document.Boolean:
|
||||
value = node.Text == "true"
|
||||
case document.Number:
|
||||
if integerPattern.MatchString(node.Text) {
|
||||
parsed, err := strconv.ParseInt(node.Text, 10, 64)
|
||||
if err != nil {
|
||||
return rangeFailure(path, node, "dynamic integer")
|
||||
}
|
||||
value = parsed
|
||||
} else {
|
||||
parsed, err := strconv.ParseFloat(node.Text, 64)
|
||||
if err != nil || math.IsNaN(parsed) || math.IsInf(parsed, 0) {
|
||||
return rangeFailure(path, node, "dynamic number")
|
||||
}
|
||||
value = parsed
|
||||
}
|
||||
case document.Sequence:
|
||||
sequence := make([]any, len(node.Sequence))
|
||||
var issues []decodeIssue
|
||||
for i, child := range node.Sequence {
|
||||
decoded, childIssues := decodeDynamic(child, reflect.Zero(reflect.TypeFor[any]()), path.Index(i), schema, context)
|
||||
if len(childIssues) != 0 {
|
||||
issues = append(issues, childIssues...)
|
||||
continue
|
||||
}
|
||||
sequence[i] = decoded.Interface()
|
||||
}
|
||||
if len(issues) != 0 {
|
||||
return reflect.Value{}, issues
|
||||
}
|
||||
value = sequence
|
||||
case document.Mapping:
|
||||
mapping := make(map[string]any, len(node.Mapping))
|
||||
var issues []decodeIssue
|
||||
for _, entry := range node.Mapping {
|
||||
decoded, childIssues := decodeDynamic(entry.Value, reflect.Zero(reflect.TypeFor[any]()), path.Key(entry.Key), schema, context)
|
||||
if len(childIssues) != 0 {
|
||||
issues = append(issues, childIssues...)
|
||||
continue
|
||||
}
|
||||
mapping[entry.Key] = decoded.Interface()
|
||||
}
|
||||
if len(issues) != 0 {
|
||||
return reflect.Value{}, issues
|
||||
}
|
||||
value = mapping
|
||||
default:
|
||||
return reflect.Value{}, []decodeIssue{newDecodeIssue(CodeDecodeType, path, node, "dynamic configuration value has an unsupported kind")}
|
||||
}
|
||||
result := reflect.New(base.Type()).Elem()
|
||||
if value != nil {
|
||||
result.Set(reflect.ValueOf(value))
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func acceptsMapping(descriptor *typeDescriptor) bool {
|
||||
for descriptor != nil && descriptor.kind == descriptorPointer {
|
||||
descriptor = descriptor.element
|
||||
}
|
||||
return descriptor != nil && (descriptor.kind == descriptorStruct || descriptor.kind == descriptorMap || descriptor.kind == descriptorDynamic)
|
||||
}
|
||||
|
||||
func fieldForDecode(root reflect.Value, index []int, allocate bool) (reflect.Value, bool) {
|
||||
current := root
|
||||
for position, fieldIndex := range index {
|
||||
if current.Kind() != reflect.Struct {
|
||||
return reflect.Value{}, false
|
||||
}
|
||||
current = current.Field(fieldIndex)
|
||||
if position == len(index)-1 {
|
||||
return current, current.CanSet()
|
||||
}
|
||||
for current.Kind() == reflect.Pointer {
|
||||
if current.IsNil() {
|
||||
if !allocate || !current.CanSet() {
|
||||
return reflect.Value{}, false
|
||||
}
|
||||
current.Set(reflect.New(current.Type().Elem()))
|
||||
}
|
||||
current = current.Elem()
|
||||
}
|
||||
}
|
||||
return reflect.Value{}, false
|
||||
}
|
||||
|
||||
func collectPresence(node *document.Node, path Path, presence map[string]inputPresence) {
|
||||
presence[path.String()] = inputPresence{null: node.Kind == document.Null, location: node.Location}
|
||||
switch node.Kind {
|
||||
case document.Mapping:
|
||||
for _, entry := range node.Mapping {
|
||||
collectPresence(entry.Value, path.Key(entry.Key), presence)
|
||||
}
|
||||
case document.Sequence:
|
||||
for i, child := range node.Sequence {
|
||||
collectPresence(child, path.Index(i), presence)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func collectOrigins(node *document.Node, path Path, presence map[string]inputPresence, origins map[string]Origin) {
|
||||
if input, ok := presence[path.String()]; ok {
|
||||
origins[path.String()] = Origin{Layer: LayerBase, Locations: []Location{{SourceID: input.location.SourceID, Line: input.location.Line, Column: input.location.Column}}}
|
||||
} else {
|
||||
origins[path.String()] = Origin{Layer: LayerDefault}
|
||||
}
|
||||
switch node.Kind {
|
||||
case document.Mapping:
|
||||
for _, entry := range node.Mapping {
|
||||
collectOrigins(entry.Value, path.Key(entry.Key), presence, origins)
|
||||
}
|
||||
case document.Sequence:
|
||||
for i, child := range node.Sequence {
|
||||
collectOrigins(child, path.Index(i), presence, origins)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func decodeIssueFromProjection(issue *projectionIssue) decodeIssue {
|
||||
return decodeIssue{code: issue.code, path: issue.path.clone(), message: issue.message}
|
||||
}
|
||||
|
||||
func newDecodeIssue(code Code, path Path, node *document.Node, message string) decodeIssue {
|
||||
return decodeIssue{code: code, path: path.clone(), message: message, location: node.Location}
|
||||
}
|
||||
|
||||
func typeMismatch(path Path, node *document.Node, expected string) (reflect.Value, []decodeIssue) {
|
||||
return reflect.Value{}, []decodeIssue{newDecodeIssue(CodeDecodeType, path, node, "configuration value must be a "+expected)}
|
||||
}
|
||||
|
||||
func rangeFailure(path Path, node *document.Node, expected string) (reflect.Value, []decodeIssue) {
|
||||
return reflect.Value{}, []decodeIssue{newDecodeIssue(CodeDecodeRange, path, node, expected+" is outside the supported range")}
|
||||
}
|
||||
312
internal/engine/decode_test.go
Normal file
312
internal/engine/decode_test.go
Normal file
@@ -0,0 +1,312 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"math"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
)
|
||||
|
||||
type namedSmall int8
|
||||
|
||||
type validatedText string
|
||||
|
||||
func (value validatedText) MarshalText() ([]byte, error) {
|
||||
return []byte(value), nil
|
||||
}
|
||||
|
||||
func (value *validatedText) UnmarshalText(text []byte) error {
|
||||
if string(text) == "bad-secret-canary" {
|
||||
return errors.New("CANARY_TEXT_ERROR: bad-secret-canary")
|
||||
}
|
||||
*value = validatedText(text)
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestDecodeAppliesInputOverDetachedDefaults(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type nested struct {
|
||||
A string `yaml:"a"`
|
||||
B string `yaml:"b"`
|
||||
}
|
||||
type config struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Workers int `yaml:"workers"`
|
||||
Label string `yaml:"label"`
|
||||
Nested nested `yaml:"nested"`
|
||||
EmptyMapping nested `yaml:"empty_mapping"`
|
||||
Objects map[string]nested `yaml:"objects"`
|
||||
Sequence []string `yaml:"sequence"`
|
||||
Replacement []string `yaml:"replacement"`
|
||||
Pointer *nested `yaml:"pointer"`
|
||||
NilPointer *nested `yaml:"nil_pointer"`
|
||||
Nullable map[string]string `yaml:"nullable"`
|
||||
Dynamic any `yaml:"dynamic"`
|
||||
DynamicShape any `yaml:"dynamic_shape"`
|
||||
DynamicNull any `yaml:"dynamic_null"`
|
||||
Fixed [2]int `yaml:"fixed"`
|
||||
Small namedSmall `yaml:"small"`
|
||||
Delay time.Duration `yaml:"delay"`
|
||||
Custom validatedText `yaml:"custom"`
|
||||
}
|
||||
defaults := config{
|
||||
Enabled: true,
|
||||
Workers: 4,
|
||||
Label: "default",
|
||||
Nested: nested{A: "keep", B: "before"},
|
||||
EmptyMapping: nested{A: "keep-empty", B: "keep-empty"},
|
||||
Objects: map[string]nested{
|
||||
"existing": {A: "keep-map", B: "before"},
|
||||
},
|
||||
Sequence: []string{"default"},
|
||||
Replacement: []string{"default"},
|
||||
Pointer: &nested{A: "keep-pointer", B: "before"},
|
||||
Nullable: map[string]string{"default": "value"},
|
||||
Dynamic: map[string]any{"keep": "default", "count": int64(4)},
|
||||
DynamicShape: nested{A: "keep-dynamic", B: "before"},
|
||||
DynamicNull: "default",
|
||||
Fixed: [2]int{1, 2},
|
||||
Small: 7,
|
||||
Delay: time.Second,
|
||||
Custom: "default",
|
||||
}
|
||||
input := `
|
||||
enabled: false
|
||||
workers: 0
|
||||
label: ""
|
||||
nested: {b: changed}
|
||||
empty_mapping: {}
|
||||
objects:
|
||||
existing: {b: changed}
|
||||
new: {b: added}
|
||||
sequence: []
|
||||
replacement: [one, two]
|
||||
pointer: {b: changed}
|
||||
nil_pointer: {b: added}
|
||||
nullable: null
|
||||
dynamic: {count: 0, fraction: 1.5}
|
||||
dynamic_shape: {b: changed}
|
||||
dynamic_null: null
|
||||
fixed: [3, 4]
|
||||
small: -8
|
||||
delay: 1.5s
|
||||
custom: accepted
|
||||
`
|
||||
schema := mustSchemaFor[config](t)
|
||||
documentRoot := parseDocumentForDecode(t, input)
|
||||
decoded, issues := decodeConfiguration(schema, reflect.ValueOf(defaults), documentRoot)
|
||||
if len(issues) != 0 {
|
||||
t.Fatalf("decode issues = %#v", issues)
|
||||
}
|
||||
got := decoded.value.Interface().(config)
|
||||
if got.Enabled || got.Workers != 0 || got.Label != "" {
|
||||
t.Fatalf("explicit zero values lost: %#v", got)
|
||||
}
|
||||
if got.Nested != (nested{A: "keep", B: "changed"}) || got.EmptyMapping != defaults.EmptyMapping {
|
||||
t.Fatalf("mapping defaults = %#v / %#v", got.Nested, got.EmptyMapping)
|
||||
}
|
||||
if got.Objects["existing"] != (nested{A: "keep-map", B: "changed"}) || got.Objects["new"] != (nested{B: "added"}) {
|
||||
t.Fatalf("map defaults = %#v", got.Objects)
|
||||
}
|
||||
if got.Sequence == nil || len(got.Sequence) != 0 || !reflect.DeepEqual(got.Replacement, []string{"one", "two"}) {
|
||||
t.Fatalf("sequence replacement = %#v / %#v", got.Sequence, got.Replacement)
|
||||
}
|
||||
if got.Pointer == defaults.Pointer || *got.Pointer != (nested{A: "keep-pointer", B: "changed"}) || got.NilPointer == nil || *got.NilPointer != (nested{B: "added"}) {
|
||||
t.Fatalf("pointer defaults = %#v / %#v", got.Pointer, got.NilPointer)
|
||||
}
|
||||
if got.Nullable != nil || got.DynamicNull != nil {
|
||||
t.Fatalf("null values = %#v / %#v", got.Nullable, got.DynamicNull)
|
||||
}
|
||||
dynamic := got.Dynamic.(map[string]any)
|
||||
if dynamic["keep"] != "default" || dynamic["count"] != int64(0) || dynamic["fraction"] != float64(1.5) {
|
||||
t.Fatalf("dynamic mapping = %#v", dynamic)
|
||||
}
|
||||
if got.DynamicShape != (nested{A: "keep-dynamic", B: "changed"}) {
|
||||
t.Fatalf("dynamic concrete mapping = %#v", got.DynamicShape)
|
||||
}
|
||||
if got.Fixed != [2]int{3, 4} || got.Small != -8 || got.Delay != 1500*time.Millisecond || got.Custom != "accepted" {
|
||||
t.Fatalf("scalar decoding = %#v", got)
|
||||
}
|
||||
|
||||
for _, path := range []Path{Keys(), Keys("enabled"), Keys("nested"), Keys("nested", "b"), Keys("empty_mapping")} {
|
||||
if _, ok := decoded.presence[path.String()]; !ok {
|
||||
t.Errorf("explicit path %q missing", path.String())
|
||||
}
|
||||
}
|
||||
if _, ok := decoded.presence[Keys("nested", "a").String()]; ok {
|
||||
t.Fatal("parent mapping made omitted child explicit")
|
||||
}
|
||||
if decoded.origins[Keys("nested", "a").String()].Layer != LayerDefault || decoded.origins[Keys("nested", "b").String()].Layer != LayerBase {
|
||||
t.Fatalf("origins = %#v", decoded.origins)
|
||||
}
|
||||
if decoded.origins[Keys("nested", "b").String()].Locations[0].Line == 0 {
|
||||
t.Fatal("input origin lost source coordinates")
|
||||
}
|
||||
|
||||
defaults.Objects["existing"] = nested{A: "mutated"}
|
||||
defaults.Pointer.B = "mutated"
|
||||
defaults.Sequence[0] = "mutated"
|
||||
if got.Objects["existing"].A != "keep-map" || got.Pointer.B != "changed" || len(got.Sequence) != 0 {
|
||||
t.Fatal("decoded value aliases defaults")
|
||||
}
|
||||
got.Pointer.B = "caller mutation"
|
||||
if mappingValue(t, mappingValue(t, decoded.final, "pointer"), "b").Text != "changed" {
|
||||
t.Fatal("final snapshot aliases returned typed value")
|
||||
}
|
||||
if mappingValue(t, mappingValue(t, decoded.baseline, "nested"), "b").Text != "before" {
|
||||
t.Fatal("baseline snapshot was mutated by decode")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeDefaultsOnlyAndRepeatedResultsAreIndependent(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type config struct {
|
||||
Values map[string][]string `yaml:"values"`
|
||||
}
|
||||
defaults := config{Values: map[string][]string{"one": {"a"}}}
|
||||
schema := mustSchemaFor[config](t)
|
||||
first, issues := decodeConfiguration(schema, reflect.ValueOf(defaults), nil)
|
||||
if len(issues) != 0 {
|
||||
t.Fatal(issues)
|
||||
}
|
||||
second, issues := decodeConfiguration(schema, reflect.ValueOf(defaults), nil)
|
||||
if len(issues) != 0 {
|
||||
t.Fatal(issues)
|
||||
}
|
||||
firstValue := first.value.Interface().(config)
|
||||
secondValue := second.value.Interface().(config)
|
||||
firstValue.Values["one"][0] = "changed"
|
||||
if secondValue.Values["one"][0] != "a" || defaults.Values["one"][0] != "a" {
|
||||
t.Fatal("repeated defaults-only results share aliases")
|
||||
}
|
||||
if len(first.presence) != 0 || first.origins[Keys("values", "one").String()].Layer != LayerDefault {
|
||||
t.Fatalf("defaults-only metadata = %#v / %#v", first.presence, first.origins)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeCollectsIndependentTypeAndShapeFailures(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type nested struct {
|
||||
Known int `yaml:"known"`
|
||||
}
|
||||
type config struct {
|
||||
Small int8 `yaml:"small"`
|
||||
Unsigned uint8 `yaml:"unsigned"`
|
||||
Number float32 `yaml:"number"`
|
||||
Text string `yaml:"text"`
|
||||
Flag bool `yaml:"flag"`
|
||||
Delay time.Duration `yaml:"delay"`
|
||||
Fixed [2]int `yaml:"fixed"`
|
||||
Nested nested `yaml:"nested"`
|
||||
Custom validatedText `yaml:"custom"`
|
||||
Dynamic any `yaml:"dynamic"`
|
||||
}
|
||||
input := `
|
||||
small: 128
|
||||
unsigned: -1
|
||||
number: 1e999
|
||||
text: 3
|
||||
flag: "true"
|
||||
delay: 10
|
||||
fixed: [1]
|
||||
nested: {first_unknown: 1, second_unknown: 2}
|
||||
custom: bad-secret-canary
|
||||
dynamic: 9223372036854775808
|
||||
`
|
||||
schema := mustSchemaFor[config](t)
|
||||
_, issues := decodeConfiguration(schema, reflect.ValueOf(config{Custom: "ok"}), parseDocumentForDecode(t, input))
|
||||
if len(issues) != 11 {
|
||||
t.Fatalf("issues = %d: %#v", len(issues), issues)
|
||||
}
|
||||
want := map[string]Code{
|
||||
"/small": CodeDecodeRange,
|
||||
"/unsigned": CodeDecodeType,
|
||||
"/number": CodeDecodeRange,
|
||||
"/text": CodeDecodeType,
|
||||
"/flag": CodeDecodeType,
|
||||
"/delay": CodeDecodeType,
|
||||
"/fixed": CodeDecodeArrayLength,
|
||||
"/nested/first_unknown": CodeDecodeUnknownField,
|
||||
"/nested/second_unknown": CodeDecodeUnknownField,
|
||||
"/custom": CodeDecodeText,
|
||||
"/dynamic": CodeDecodeRange,
|
||||
}
|
||||
for _, issue := range issues {
|
||||
if wantCode, ok := want[issue.path.String()]; !ok || wantCode != issue.code {
|
||||
t.Errorf("unexpected issue %#v", issue)
|
||||
}
|
||||
if issue.location.Line == 0 {
|
||||
t.Errorf("issue lost input location: %#v", issue)
|
||||
}
|
||||
if strings.Contains(issue.message, "CANARY") || strings.Contains(issue.message, "bad-secret") {
|
||||
t.Errorf("issue exposed value: %#v", issue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeNullabilityAndNestedMapStrictness(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type nested struct {
|
||||
Value int `yaml:"value"`
|
||||
}
|
||||
type config struct {
|
||||
Scalar int `yaml:"scalar"`
|
||||
Object nested `yaml:"object"`
|
||||
Array [1]int `yaml:"array"`
|
||||
Map map[string]nested `yaml:"map"`
|
||||
}
|
||||
input := "scalar: null\nobject: null\narray: null\nmap:\n item: {unknown: 1}\n"
|
||||
_, issues := decodeConfiguration(mustSchemaFor[config](t), reflect.ValueOf(config{}), parseDocumentForDecode(t, input))
|
||||
want := map[string]Code{
|
||||
"/scalar": CodeDecodeNull,
|
||||
"/object": CodeDecodeNull,
|
||||
"/array": CodeDecodeNull,
|
||||
"/map/item/unknown": CodeDecodeUnknownField,
|
||||
}
|
||||
if len(issues) != len(want) {
|
||||
t.Fatalf("issues = %#v", issues)
|
||||
}
|
||||
for _, issue := range issues {
|
||||
if want[issue.path.String()] != issue.code {
|
||||
t.Errorf("issue = %#v", issue)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDecodeRejectsInvalidDefaultsBeforeApplyingInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type config struct {
|
||||
Name string `yaml:"name"`
|
||||
Extras map[string]any `yaml:",inline"`
|
||||
Float float64 `yaml:"float"`
|
||||
}
|
||||
schema := mustSchemaFor[config](t)
|
||||
for name, defaults := range map[string]config{
|
||||
"inline collision": {Name: "declared", Extras: map[string]any{"name": "duplicate"}},
|
||||
"non-finite": {Float: math.NaN()},
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, issues := decodeConfiguration(schema, reflect.ValueOf(defaults), parseDocumentForDecode(t, "{}\n")); len(issues) != 1 || !strings.HasPrefix(string(issues[0].code), "schema.") {
|
||||
t.Fatalf("issues = %#v", issues)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func parseDocumentForDecode(t *testing.T, input string) *document.Node {
|
||||
t.Helper()
|
||||
parsed, diagnostics := parseSource(Bytes("decode.yml", []byte(input)), "source-1", defaultParseLimits())
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("parse diagnostics = %#v", diagnostics)
|
||||
}
|
||||
return parsed.document
|
||||
}
|
||||
248
internal/engine/diagnostic.go
Normal file
248
internal/engine/diagnostic.go
Normal 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
|
||||
}
|
||||
143
internal/engine/diagnostic_test.go
Normal file
143
internal/engine/diagnostic_test.go
Normal file
@@ -0,0 +1,143 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestDiagnosticsAreCanonicalAndDetached(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
diagnostics := []Diagnostic{
|
||||
{
|
||||
Code: "decode.type",
|
||||
Severity: SeverityError,
|
||||
Path: Keys("later"),
|
||||
Message: "later",
|
||||
Primary: &Location{SourceID: "source-10", Line: 1, Column: 1},
|
||||
},
|
||||
{
|
||||
Code: "schema.invalid",
|
||||
Severity: SeverityError,
|
||||
Path: Keys("root"),
|
||||
Message: "source-less",
|
||||
},
|
||||
{
|
||||
Code: "decode.type",
|
||||
Severity: SeverityError,
|
||||
Path: Keys("earlier"),
|
||||
Message: "earlier",
|
||||
Primary: &Location{SourceID: "source-2", Line: 3, Column: 4},
|
||||
Related: []Location{
|
||||
{SourceID: "source-10", Line: 1, Column: 1},
|
||||
{SourceID: "source-2", Line: 9, Column: 1},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
var forward bytes.Buffer
|
||||
if err := WriteDiagnostics(&forward, diagnostics); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reversed := []Diagnostic{diagnostics[2], diagnostics[1], diagnostics[0]}
|
||||
var backward bytes.Buffer
|
||||
if err := WriteDiagnostics(&backward, reversed); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if forward.String() != backward.String() {
|
||||
t.Fatalf("rendering depends on submission order:\n%s\n---\n%s", forward.String(), backward.String())
|
||||
}
|
||||
lines := strings.Split(forward.String(), "\n")
|
||||
if !strings.Contains(lines[0], "source-less") || !strings.Contains(lines[1], "source-2:3:4") {
|
||||
t.Fatalf("unexpected canonical order:\n%s", forward.String())
|
||||
}
|
||||
if !strings.Contains(lines[2], "source-2:9:1") || !strings.Contains(lines[3], "source-10:1:1") {
|
||||
t.Fatalf("related locations not canonical:\n%s", forward.String())
|
||||
}
|
||||
|
||||
accumulator := diagnosticAccumulator{
|
||||
sources: []SourceInfo{{
|
||||
ID: "source-1",
|
||||
Label: "config.yml",
|
||||
ImportedFrom: &Location{SourceID: "source-0", Line: 1},
|
||||
}},
|
||||
}
|
||||
accumulator.add(diagnostics...)
|
||||
loadErr := accumulator.loadError()
|
||||
var found *LoadError
|
||||
if !errors.As(error(loadErr), &found) || found != loadErr {
|
||||
t.Fatal("errors.As did not find LoadError")
|
||||
}
|
||||
|
||||
first := loadErr.Diagnostics()
|
||||
first[1].Primary.SourceID = "mutated"
|
||||
first[1].Path.segments[0].key = "mutated"
|
||||
first[1].Related[0].SourceID = "mutated"
|
||||
second := loadErr.Diagnostics()
|
||||
if second[1].Primary.SourceID != "source-2" || second[1].Path.String() != "/earlier" || second[1].Related[0].SourceID != "source-2" {
|
||||
t.Fatal("Diagnostics returned aliased records")
|
||||
}
|
||||
sources := loadErr.Sources()
|
||||
sources[0].ImportedFrom.SourceID = "mutated"
|
||||
if loadErr.Sources()[0].ImportedFrom.SourceID != "source-0" {
|
||||
t.Fatal("Sources returned aliased records")
|
||||
}
|
||||
if got := loadErr.Error(); got != "configkit: configuration load failed with 3 errors" {
|
||||
t.Fatalf("Error() = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteDiagnosticsEscapesControls(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
diagnostic := Diagnostic{
|
||||
Code: "validate.error",
|
||||
Severity: SeverityError,
|
||||
Path: Keys("line\nbreak"),
|
||||
Message: "message\r\nnext",
|
||||
Primary: &Location{SourceID: "source\tlabel", Line: 1},
|
||||
}
|
||||
var output bytes.Buffer
|
||||
if err := WriteDiagnostics(&output, []Diagnostic{diagnostic}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := output.String()
|
||||
if strings.Count(got, "\n") != 1 {
|
||||
t.Fatalf("control characters created output lines: %q", got)
|
||||
}
|
||||
for _, escaped := range []string{`line\nbreak`, `message\r\nnext`, `source\tlabel`} {
|
||||
if !strings.Contains(got, escaped) {
|
||||
t.Errorf("output %q does not contain %q", got, escaped)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteDiagnosticsPropagatesWriterFailures(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
diagnostics := []Diagnostic{{Severity: SeverityError, Message: "safe"}}
|
||||
want := errors.New("write failed")
|
||||
if err := WriteDiagnostics(errorWriter{err: want}, diagnostics); !errors.Is(err, want) {
|
||||
t.Fatalf("error writer returned %v", err)
|
||||
}
|
||||
if err := WriteDiagnostics(shortWriter{}, diagnostics); !errors.Is(err, io.ErrShortWrite) {
|
||||
t.Fatalf("short writer returned %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
type errorWriter struct {
|
||||
err error
|
||||
}
|
||||
|
||||
func (w errorWriter) Write(p []byte) (int, error) {
|
||||
return min(3, len(p)), w.err
|
||||
}
|
||||
|
||||
type shortWriter struct{}
|
||||
|
||||
func (shortWriter) Write(p []byte) (int, error) {
|
||||
return max(0, len(p)-1), nil
|
||||
}
|
||||
229
internal/engine/directives.go
Normal file
229
internal/engine/directives.go
Normal 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 overlayDirectiveDiagnostics(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: CodeOverlayDirective, Severity: SeverityError, Path: childPath,
|
||||
Message: "overlays cannot contain configuration directives", Primary: &location,
|
||||
}}
|
||||
}
|
||||
if diagnostics := overlayDirectiveDiagnostics(entry.Value, childPath); len(diagnostics) != 0 {
|
||||
return diagnostics
|
||||
}
|
||||
}
|
||||
} else if node.Kind == document.Sequence {
|
||||
for index, child := range node.Sequence {
|
||||
if diagnostics := overlayDirectiveDiagnostics(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
|
||||
}
|
||||
110
internal/engine/document_compare.go
Normal file
110
internal/engine/document_compare.go
Normal 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
|
||||
}
|
||||
86
internal/engine/document_walk.go
Normal file
86
internal/engine/document_walk.go
Normal 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
122
internal/engine/explain.go
Normal 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
|
||||
}
|
||||
123
internal/engine/explain_render.go
Normal file
123
internal/engine/explain_render.go
Normal 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 == LayerOverlay || 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')
|
||||
}
|
||||
}
|
||||
}
|
||||
522
internal/engine/explain_test.go
Normal file
522
internal/engine/explain_test.go
Normal file
@@ -0,0 +1,522 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
)
|
||||
|
||||
type explanationConcurrency struct {
|
||||
Transcription struct {
|
||||
Concurrency int `yaml:"concurrency"`
|
||||
} `yaml:"transcription"`
|
||||
}
|
||||
|
||||
func TestExplainShowsDefaultBaseAndEffectiveOverlayValue(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "config.yml", "configkit:\n imports: [{path: transcription.yml}]\n")
|
||||
writeImportFile(t, directory, "transcription.yml", "transcription:\n concurrency: 8\n")
|
||||
writeImportFile(t, directory, filepath.Join("overlays", "testing.yml"), "transcription:\n concurrency: 1\n")
|
||||
result, err := testLoad[explanationConcurrency](File(mainPath), Options[explanationConcurrency]{
|
||||
Imports: true, Overlay: "testing",
|
||||
Definition: Definition[explanationConcurrency]{Defaults: func() explanationConcurrency {
|
||||
value := explanationConcurrency{}
|
||||
value.Transcription.Concurrency = 4
|
||||
return value
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
explanation, ok := result.Explain(Keys("transcription", "concurrency"))
|
||||
if !ok {
|
||||
t.Fatal("known path was not explained")
|
||||
}
|
||||
if got := explanationValues(explanation); !reflect.DeepEqual(got, []string{"4\n", "8\n", "1\n"}) {
|
||||
t.Fatalf("history values = %#v", got)
|
||||
}
|
||||
if explanation.Effective.YAML != "1\n" || explanation.Origin.Layer != LayerOverlay ||
|
||||
len(explanation.Origin.Locations) != 1 || explanation.Origin.Locations[0].SourceID != "source-3" ||
|
||||
explanation.Origin.Locations[0].Line != 2 {
|
||||
t.Fatalf("effective explanation = %#v", explanation)
|
||||
}
|
||||
var rendered bytes.Buffer
|
||||
if err := result.WriteExplanation(&rendered, Keys("transcription", "concurrency")); err != nil ||
|
||||
!strings.Contains(rendered.String(), `layer="overlay"`) ||
|
||||
!strings.Contains(rendered.String(), `label="overlays/testing.yml"`) {
|
||||
t.Fatalf("rendered explanation = %v\n%s", err, rendered.String())
|
||||
}
|
||||
}
|
||||
|
||||
type explanationConfig struct {
|
||||
Concurrency int `yaml:"concurrency"`
|
||||
Equal string `yaml:"equal"`
|
||||
Limit int `yaml:"limit"`
|
||||
Items []string `yaml:"items"`
|
||||
Labels map[string]string `yaml:"labels"`
|
||||
Dynamic any `yaml:"dynamic"`
|
||||
Maybe *string `yaml:"maybe"`
|
||||
Empty []string `yaml:"empty"`
|
||||
Credential Secret `yaml:"credential"`
|
||||
Classified Secret `yaml:"classified"`
|
||||
DerivedA string `yaml:"derived_a"`
|
||||
DerivedB string `yaml:"derived_b"`
|
||||
}
|
||||
|
||||
func TestExplainReplaysCLIResolutionContainersAndSafeHistoricalValues(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "config.yml", `concurrency: 8
|
||||
equal: same
|
||||
limit: wrong-before-overlay
|
||||
items: [base, removed]
|
||||
labels: {base: retained}
|
||||
dynamic: {retired: HISTORICAL_EXPLAIN_CANARY, public: visible}
|
||||
credential: APP_SECRET
|
||||
classified: APP_SECRET
|
||||
`)
|
||||
writeImportFile(t, directory, filepath.Join("overlays", "testing.yml"), `concurrency: 1
|
||||
equal: same
|
||||
limit: 7
|
||||
items: [overlay]
|
||||
labels: {overlay: retained}
|
||||
dynamic: selected
|
||||
`)
|
||||
result, err := testLoadWithSecretLookup(File(mainPath), func(name string) (string, bool) {
|
||||
if name != "APP_SECRET" {
|
||||
t.Fatalf("secret lookup = %q", name)
|
||||
}
|
||||
return "RESOLVED_EXPLAIN_CANARY", true
|
||||
}, Options[explanationConfig]{
|
||||
Overlay: "testing",
|
||||
Overrides: []Override{
|
||||
{Path: Keys("concurrency"), Value: 2, Flag: "--concurrency"},
|
||||
{Path: Keys("equal"), Value: "same", Flag: "--equal"},
|
||||
{Path: Keys("items"), Value: []any{"cli", "restored"}, Flag: "--items"},
|
||||
{Path: Keys("labels"), Value: map[string]any{"cli": "retained"}, Flag: "--label"},
|
||||
},
|
||||
Definition: Definition[explanationConfig]{
|
||||
Defaults: func() explanationConfig {
|
||||
return explanationConfig{
|
||||
Concurrency: 4, Equal: "same", Labels: map[string]string{"default": "retained"},
|
||||
Maybe: nil, Empty: []string{}, Credential: SecretEnv("APP_SECRET"), Classified: SecretEnv("APP_SECRET"),
|
||||
}
|
||||
},
|
||||
Secret: func(path Path) bool {
|
||||
return path.String() == "/dynamic/retired" || path.String() == "/classified"
|
||||
},
|
||||
Resolve: func(configuration *explanationConfig, resolution *Resolution) error {
|
||||
Normalize(resolution, Keys("concurrency"), &configuration.Concurrency, 3,
|
||||
"normalized\nconcurrency", Keys("labels"))
|
||||
Normalize(resolution, Keys("equal"), &configuration.Equal, "same", "record equal value")
|
||||
Normalize(resolution, Keys("derived_a"), &configuration.DerivedA, "DERIVED_EXPLAIN_A",
|
||||
"derived cycle a", Keys("derived_b"))
|
||||
Normalize(resolution, Keys("derived_b"), &configuration.DerivedB, "DERIVED_EXPLAIN_B",
|
||||
"derived cycle b", Keys("derived_a"), Keys("dynamic"))
|
||||
return nil
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
concurrency, ok := result.Explain(Keys("concurrency"))
|
||||
if !ok {
|
||||
t.Fatal("concurrency was not explained")
|
||||
}
|
||||
if got := explanationValues(concurrency); !reflect.DeepEqual(got, []string{"4\n", "8\n", "1\n", "2\n", "3\n"}) {
|
||||
t.Fatalf("concurrency history = %#v", got)
|
||||
}
|
||||
if got := historyValueLayers(concurrency); !reflect.DeepEqual(got, []Layer{LayerDefault, LayerBase, LayerOverlay, LayerCLI, LayerResolution}) {
|
||||
t.Fatalf("concurrency layers = %#v", got)
|
||||
}
|
||||
if concurrency.History[4].Before.YAML != "2\n" || concurrency.History[4].Reasons[0] != "normalized\nconcurrency" ||
|
||||
concurrency.History[4].Dependencies[0].String() != "/labels" {
|
||||
t.Fatalf("resolution history = %#v", concurrency.History[4])
|
||||
}
|
||||
|
||||
equal, _ := result.Explain(Keys("equal"))
|
||||
if len(equal.History) != 5 {
|
||||
t.Fatalf("equal writes were lost: %#v", equal.History)
|
||||
}
|
||||
for _, change := range equal.History[1:] {
|
||||
if change.Before.YAML != "same\n" || change.After.YAML != "same\n" {
|
||||
t.Fatalf("equal replacement = %#v", change)
|
||||
}
|
||||
}
|
||||
|
||||
limit, _ := result.Explain(Keys("limit"))
|
||||
if got := explanationValues(limit); !reflect.DeepEqual(got, []string{"0\n", "wrong-before-overlay\n", "7\n"}) {
|
||||
t.Fatalf("overridden invalid input was not retained: %#v", got)
|
||||
}
|
||||
removed, ok := result.Explain(Keys("dynamic", "retired"))
|
||||
if !ok || removed.Effective.Present || len(removed.History) < 2 {
|
||||
t.Fatalf("removed path = %#v, %v", removed, ok)
|
||||
}
|
||||
for _, change := range removed.History {
|
||||
if strings.Contains(change.Before.YAML+change.After.YAML, "HISTORICAL_EXPLAIN_CANARY") {
|
||||
t.Fatalf("historical secret escaped redaction: %#v", change)
|
||||
}
|
||||
}
|
||||
if !removed.History[0].After.Redacted || removed.History[len(removed.History)-1].After.Present {
|
||||
t.Fatalf("removed secret history = %#v", removed.History)
|
||||
}
|
||||
dynamic, _ := result.Explain(Keys("dynamic"))
|
||||
if len(dynamic.History) < 3 || !dynamic.History[1].After.Redacted ||
|
||||
strings.Contains(dynamic.History[1].After.YAML, "HISTORICAL_EXPLAIN_CANARY") {
|
||||
t.Fatalf("container redaction history = %#v", dynamic.History)
|
||||
}
|
||||
|
||||
items, _ := result.Explain(Keys("items"))
|
||||
if len(items.History) != 4 || !strings.Contains(items.History[1].After.YAML, "removed") ||
|
||||
strings.Contains(items.History[2].After.YAML, "removed") || !strings.Contains(items.History[3].After.YAML, "restored") {
|
||||
t.Fatalf("atomic sequence history = %#v", items.History)
|
||||
}
|
||||
removedIndex, ok := result.Explain(Keys("items").Index(1))
|
||||
if !ok || !removedIndex.History[1].Before.Present || removedIndex.History[1].After.Present ||
|
||||
!removedIndex.History[2].After.Present {
|
||||
t.Fatalf("sequence descendant history = %#v", removedIndex.History)
|
||||
}
|
||||
labels, _ := result.Explain(Keys("labels"))
|
||||
if len(labels.History) != 4 || !strings.Contains(labels.Effective.YAML, "default: retained") ||
|
||||
!strings.Contains(labels.Effective.YAML, "overlay: retained") || !strings.Contains(labels.Effective.YAML, "cli: retained") {
|
||||
t.Fatalf("composite mapping history = %#v", labels)
|
||||
}
|
||||
if labels.Origin.Layer != LayerCLI || len(labels.Origin.Locations) != 3 {
|
||||
t.Fatalf("composite effective origin = %#v", labels.Origin)
|
||||
}
|
||||
|
||||
maybe, _ := result.Explain(Keys("maybe"))
|
||||
empty, _ := result.Explain(Keys("empty"))
|
||||
if !maybe.Effective.Present || maybe.Effective.YAML != "null\n" || !empty.Effective.Present || empty.Effective.YAML != "[]\n" {
|
||||
t.Fatalf("null/empty snapshots = maybe %#v empty %#v", maybe.Effective, empty.Effective)
|
||||
}
|
||||
credential, _ := result.Explain(Keys("credential"))
|
||||
if credential.SecretReference == nil || credential.SecretReference.Name != "APP_SECRET" || credential.Effective.YAML != "APP_SECRET\n" {
|
||||
t.Fatalf("secret reference = %#v", credential)
|
||||
}
|
||||
classified, _ := result.Explain(Keys("classified"))
|
||||
if classified.SecretReference != nil || !classified.Effective.Redacted ||
|
||||
strings.Contains(classified.Effective.YAML, "APP_SECRET") {
|
||||
t.Fatalf("classified secret reference = %#v", classified)
|
||||
}
|
||||
var derivedExplanations []Explanation
|
||||
for _, path := range []Path{Keys("derived_a"), Keys("derived_b")} {
|
||||
derived, _ := result.Explain(path)
|
||||
if !derived.Effective.Redacted || strings.Contains(derived.Effective.YAML, "DERIVED_EXPLAIN") {
|
||||
t.Fatalf("dependency closure at %s = %#v", path.String(), derived)
|
||||
}
|
||||
derivedExplanations = append(derivedExplanations, derived)
|
||||
}
|
||||
|
||||
formatted := fmt.Sprintf("%v %#v %v", removed, credential, derivedExplanations)
|
||||
encoded, marshalErr := json.Marshal(append([]Explanation{removed, credential, classified}, derivedExplanations...))
|
||||
if marshalErr != nil || strings.Contains(formatted+string(encoded), "RESOLVED_EXPLAIN_CANARY") ||
|
||||
strings.Contains(formatted+string(encoded), "HISTORICAL_EXPLAIN_CANARY") ||
|
||||
strings.Contains(formatted+string(encoded), "DERIVED_EXPLAIN") {
|
||||
t.Fatalf("public records disclosed a canary: %s %s", formatted, encoded)
|
||||
}
|
||||
|
||||
concurrency.Path.segments[0].key = "mutated"
|
||||
concurrency.History[4].Reasons[0] = "mutated"
|
||||
concurrency.History[4].Dependencies[0].segments[0].key = "mutated"
|
||||
result.Config.Concurrency = 99
|
||||
again, _ := result.Explain(Keys("concurrency"))
|
||||
if again.Path.String() != "/concurrency" || again.Effective.YAML != "3\n" ||
|
||||
again.History[4].Reasons[0] != "normalized\nconcurrency" ||
|
||||
again.History[4].Dependencies[0].String() != "/labels" {
|
||||
t.Fatalf("returned explanation aliased internal history: %#v", again)
|
||||
}
|
||||
|
||||
var first, second bytes.Buffer
|
||||
if err := result.WriteExplanation(&first, Keys("concurrency")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := result.WriteExplanation(&second, Keys("concurrency")); err != nil || first.String() != second.String() {
|
||||
t.Fatalf("nondeterministic rendering: %v\n%s\n%s", err, first.String(), second.String())
|
||||
}
|
||||
if strings.Contains(first.String(), "\nconcurrency\"") || !strings.Contains(first.String(), `"normalized\nconcurrency"`) {
|
||||
t.Fatalf("reason controls were not escaped: %q", first.String())
|
||||
}
|
||||
unknownOutput := bytes.NewBufferString("unchanged")
|
||||
if err := result.WriteExplanation(unknownOutput, Keys("unknown")); !errors.Is(err, errExplanationUnknownPath) || unknownOutput.String() != "unchanged" {
|
||||
t.Fatalf("unknown writer = %q, %v", unknownOutput.String(), err)
|
||||
}
|
||||
if _, ok := result.Explain(Keys("items").Index(-1)); ok {
|
||||
t.Fatal("malformed path was explained")
|
||||
}
|
||||
wantWriteError := errors.New("write failed")
|
||||
if err := result.WriteExplanation(errorWriter{err: wantWriteError}, Keys("concurrency")); !errors.Is(err, wantWriteError) {
|
||||
t.Fatalf("writer error = %v", err)
|
||||
}
|
||||
if err := result.WriteExplanation(shortWriter{}, Keys("concurrency")); !errors.Is(err, io.ErrShortWrite) {
|
||||
t.Fatalf("short writer error = %v", err)
|
||||
}
|
||||
var invalidInput bytes.Buffer
|
||||
if err := result.WriteExplanation(&invalidInput, Keys("limit")); err != nil ||
|
||||
!strings.Contains(invalidInput.String(), "policy-validated input") ||
|
||||
strings.Contains(invalidInput.String(), "wrong-before-overlay\n source: layer=\"base\"\n before (canonical typed)") {
|
||||
t.Fatalf("historical representation rendering = %v\n%s", err, invalidInput.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestSecretsBootstrapLoadAndExplanationRemainSeparateAndSafe(t *testing.T) {
|
||||
const name = "CONFIGKIT_EXPLAIN_BOOTSTRAP_TEST"
|
||||
previous, present := os.LookupEnv(name)
|
||||
if err := os.Unsetenv(name); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if present {
|
||||
_ = os.Setenv(name, previous)
|
||||
} else {
|
||||
_ = os.Unsetenv(name)
|
||||
}
|
||||
})
|
||||
directory := t.TempDir()
|
||||
writeSecretTestFile(t, directory, name, "BOOTSTRAP_EXPLAIN_CANARY\n")
|
||||
report, err := LoadSecretsDir(directory)
|
||||
if err != nil || len(report.Entries()) != 1 || report.Entries()[0].Status != SecretFileLoaded {
|
||||
t.Fatalf("bootstrap = %#v, %v", report, err)
|
||||
}
|
||||
type config struct {
|
||||
Credential Secret `yaml:"credential"`
|
||||
}
|
||||
result, err := testLoad[config](Bytes("config.yml", []byte("credential: "+name+"\n")))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var output bytes.Buffer
|
||||
if err := result.WriteExplanation(&output, Keys("credential")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(output.String(), name) || strings.Contains(output.String(), "BOOTSTRAP_EXPLAIN_CANARY") {
|
||||
t.Fatalf("explanation mixed bootstrap value into reference report: %s", output.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestHistoryNodeBudgetCountsSharedTreesOnce(t *testing.T) {
|
||||
leaf := &document.Node{Kind: document.String, Text: "value"}
|
||||
root := &document.Node{Kind: document.Mapping, Mapping: []document.Entry{{Key: "value", Value: leaf}}}
|
||||
applications := []historyApplication{{layer: LayerBase, value: root}}
|
||||
if !historyNodesWithin(2, root, root, root, applications, nil) {
|
||||
t.Fatal("structurally shared history was counted more than once")
|
||||
}
|
||||
other := document.Clone(root)
|
||||
if historyNodesWithin(3, root, other, root, applications, nil) {
|
||||
t.Fatal("distinct retained nodes did not consume the budget")
|
||||
}
|
||||
}
|
||||
|
||||
func explanationValues(explanation Explanation) []string {
|
||||
values := make([]string, len(explanation.History))
|
||||
for index, change := range explanation.History {
|
||||
values[index] = change.After.YAML
|
||||
}
|
||||
return values
|
||||
}
|
||||
|
||||
func historyValueLayers(explanation Explanation) []Layer {
|
||||
layers := make([]Layer, len(explanation.History))
|
||||
for index, change := range explanation.History {
|
||||
layers[index] = change.Origin.Layer
|
||||
}
|
||||
return layers
|
||||
}
|
||||
|
||||
func TestExplanationReconcilesDefaultsAfterInputReplacement(t *testing.T) {
|
||||
type cfg struct {
|
||||
M map[string]int `yaml:"m"`
|
||||
}
|
||||
for _, base := range []string{"null", "replaced-text"} {
|
||||
for _, cli := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("%s/cli=%v", base, cli), func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
main := writeImportFile(t, root, "main.yml", "m: "+base+"\n")
|
||||
opts := Options[cfg]{Definition: Definition[cfg]{Defaults: func() cfg { return cfg{M: map[string]int{"a": 1}} }}}
|
||||
if cli {
|
||||
opts.Overrides = []Override{{Path: Keys("m"), Value: map[string]any{"b": 2}, Flag: "--m"}}
|
||||
} else {
|
||||
writeImportFile(t, root, "overlays/p.yml", "m: {b: 2}\n")
|
||||
opts.Overlay = "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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
121
internal/engine/filesystem.go
Normal file
121
internal/engine/filesystem.go
Normal 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))
|
||||
}
|
||||
205
internal/engine/glob.go
Normal file
205
internal/engine/glob.go
Normal file
@@ -0,0 +1,205 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"slices"
|
||||
"strings"
|
||||
)
|
||||
|
||||
const globReadBatch = 128
|
||||
|
||||
type globCandidate struct {
|
||||
absolute string
|
||||
relative string
|
||||
}
|
||||
|
||||
func (s *importTraversal) expandImportGlob(basePath string, declaration importDeclaration) ([]string, []Diagnostic) {
|
||||
location := declaration.location
|
||||
segments, valid := validGlobPattern(declaration.glob)
|
||||
if !valid {
|
||||
return nil, []Diagnostic{importDiagnostic(CodeImportInvalid, "import glob pattern is invalid or uses recursive ** syntax", &location)}
|
||||
}
|
||||
|
||||
candidates := []globCandidate{{absolute: basePath}}
|
||||
for index, segment := range segments {
|
||||
last := index == len(segments)-1
|
||||
literal, patterned := globLiteralSegment(segment)
|
||||
var next []globCandidate
|
||||
for _, candidate := range candidates {
|
||||
var names []string
|
||||
if patterned {
|
||||
matched, diagnostics := s.readGlobMatches(candidate.absolute, segment, location)
|
||||
if len(diagnostics) != 0 {
|
||||
return nil, diagnostics
|
||||
}
|
||||
names = matched
|
||||
} else {
|
||||
names = []string{literal}
|
||||
}
|
||||
for _, name := range names {
|
||||
absolute := filepath.Join(candidate.absolute, filepath.FromSlash(name))
|
||||
relative := path.Join(candidate.relative, name)
|
||||
if !containedPath(s.rootPath, filepath.Clean(absolute)) {
|
||||
return nil, []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "glob traversal is outside the configuration root", &location)}
|
||||
}
|
||||
canonical, err := canonicalExistingPath(absolute)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) && !patterned {
|
||||
continue
|
||||
}
|
||||
return nil, []Diagnostic{globFilesystemDiagnostic(err, "glob match could not be canonicalized", location)}
|
||||
}
|
||||
if !containedPath(s.rootPath, canonical) {
|
||||
return nil, []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "glob traversal resolves outside the configuration root", &location)}
|
||||
}
|
||||
if last {
|
||||
next = append(next, globCandidate{absolute: canonical, relative: relative})
|
||||
if len(s.sources)+len(next) > s.limits.sources {
|
||||
return nil, []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "glob matches exceed the remaining source-count limit", &location)}
|
||||
}
|
||||
continue
|
||||
}
|
||||
info, err := os.Stat(canonical)
|
||||
if err != nil {
|
||||
return nil, []Diagnostic{globFilesystemDiagnostic(err, "glob traversal target could not be inspected", location)}
|
||||
}
|
||||
if info.IsDir() {
|
||||
next = append(next, globCandidate{absolute: canonical, relative: relative})
|
||||
}
|
||||
}
|
||||
}
|
||||
candidates = next
|
||||
if len(candidates) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
}
|
||||
|
||||
matches := make([]string, len(candidates))
|
||||
for index, candidate := range candidates {
|
||||
matches[index] = candidate.relative
|
||||
}
|
||||
slices.Sort(matches)
|
||||
return matches, nil
|
||||
}
|
||||
|
||||
func (s *importTraversal) readGlobMatches(directory, pattern string, location Location) ([]string, []Diagnostic) {
|
||||
canonical, err := canonicalExistingPath(directory)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, []Diagnostic{globFilesystemDiagnostic(err, "glob directory could not be canonicalized", location)}
|
||||
}
|
||||
if !containedPath(s.rootPath, canonical) {
|
||||
return nil, []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "glob directory resolves outside the configuration root", &location)}
|
||||
}
|
||||
relative, err := filepath.Rel(s.rootPath, canonical)
|
||||
if err != nil || filepath.IsAbs(relative) || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
|
||||
return nil, []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "glob directory could not be addressed inside the configuration root", &location)}
|
||||
}
|
||||
directoryFile, err := s.root.Open(relative)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, []Diagnostic{globFilesystemDiagnostic(err, "glob directory could not be opened", location)}
|
||||
}
|
||||
|
||||
var matches []string
|
||||
for {
|
||||
entries, readErr := directoryFile.ReadDir(globReadBatch)
|
||||
for _, entry := range entries {
|
||||
s.usedEntries++
|
||||
if s.usedEntries > s.limits.entries {
|
||||
_ = directoryFile.Close()
|
||||
return nil, []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "glob traversal exceeds the directory-entry visit limit", &location)}
|
||||
}
|
||||
matched, matchErr := path.Match(pattern, entry.Name())
|
||||
if matchErr != nil {
|
||||
_ = directoryFile.Close()
|
||||
return nil, []Diagnostic{importDiagnostic(CodeImportInvalid, "import glob pattern is invalid", &location)}
|
||||
}
|
||||
if matched {
|
||||
matches = append(matches, entry.Name())
|
||||
}
|
||||
}
|
||||
if errors.Is(readErr, io.EOF) {
|
||||
break
|
||||
}
|
||||
if readErr != nil {
|
||||
_ = directoryFile.Close()
|
||||
return nil, []Diagnostic{globFilesystemDiagnostic(readErr, "glob directory could not be read", location)}
|
||||
}
|
||||
}
|
||||
if err := directoryFile.Close(); err != nil {
|
||||
return nil, []Diagnostic{globFilesystemDiagnostic(err, "glob directory could not be closed", location)}
|
||||
}
|
||||
slices.Sort(matches)
|
||||
return matches, nil
|
||||
}
|
||||
|
||||
func validGlobPattern(pattern string) ([]string, bool) {
|
||||
if !validImportPath(pattern) {
|
||||
return nil, false
|
||||
}
|
||||
segments := strings.Split(pattern, "/")
|
||||
for _, segment := range segments {
|
||||
if segment == "" || hasRecursiveStars(segment) {
|
||||
return nil, false
|
||||
}
|
||||
if _, err := path.Match(segment, ""); err != nil {
|
||||
return nil, false
|
||||
}
|
||||
}
|
||||
return segments, true
|
||||
}
|
||||
|
||||
func hasRecursiveStars(segment string) bool {
|
||||
inClass := false
|
||||
for index := 0; index < len(segment); index++ {
|
||||
switch segment[index] {
|
||||
case '\\':
|
||||
index++
|
||||
case '[':
|
||||
inClass = true
|
||||
case ']':
|
||||
inClass = false
|
||||
case '*':
|
||||
if !inClass && index+1 < len(segment) && segment[index+1] == '*' {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func globLiteralSegment(segment string) (string, bool) {
|
||||
var literal strings.Builder
|
||||
for index := 0; index < len(segment); index++ {
|
||||
switch segment[index] {
|
||||
case '*', '?', '[':
|
||||
return "", true
|
||||
case '\\':
|
||||
index++
|
||||
if index < len(segment) {
|
||||
literal.WriteByte(segment[index])
|
||||
}
|
||||
default:
|
||||
literal.WriteByte(segment[index])
|
||||
}
|
||||
}
|
||||
return literal.String(), false
|
||||
}
|
||||
|
||||
func globFilesystemDiagnostic(err error, message string, location Location) Diagnostic {
|
||||
code := CodeImportRead
|
||||
if errors.Is(err, fs.ErrPermission) {
|
||||
code = CodeImportPermission
|
||||
}
|
||||
return importDiagnostic(code, message, &location)
|
||||
}
|
||||
273
internal/engine/glob_test.go
Normal file
273
internal/engine/glob_test.go
Normal file
@@ -0,0 +1,273 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLoadGlobImportsUseLexicalDeclarationOrderAndNestedDFS(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||
imports:
|
||||
- glob: fragments/*.yml
|
||||
- path: after.yml
|
||||
items: {root: root}
|
||||
`)
|
||||
aPath := writeImportFile(t, directory, "fragments/a.yml", `configkit:
|
||||
imports: [{path: nested/child.yml}]
|
||||
items: {alpha: alpha}
|
||||
`)
|
||||
childPath := writeImportFile(t, directory, "fragments/nested/child.yml", "items: {child: child}\n")
|
||||
bPath := writeImportFile(t, directory, "fragments/b.yml", "items: {beta: beta}\n")
|
||||
afterPath := writeImportFile(t, directory, "after.yml", "items: {after: after}\n")
|
||||
|
||||
var previous []SourceInfo
|
||||
for run := 0; run < 2; run++ {
|
||||
result, err := testLoad[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantItems := map[string]string{"root": "root", "alpha": "alpha", "child": "child", "beta": "beta", "after": "after"}
|
||||
if !reflect.DeepEqual(result.Config.Items, wantItems) {
|
||||
t.Fatalf("items = %#v, want %#v", result.Config.Items, wantItems)
|
||||
}
|
||||
sources := result.Sources()
|
||||
wantPaths := []string{
|
||||
canonicalTestPath(t, mainPath), canonicalTestPath(t, aPath),
|
||||
canonicalTestPath(t, childPath), canonicalTestPath(t, bPath),
|
||||
canonicalTestPath(t, afterPath),
|
||||
}
|
||||
gotPaths := make([]string, len(sources))
|
||||
for index, source := range sources {
|
||||
gotPaths[index] = source.Path
|
||||
}
|
||||
if !reflect.DeepEqual(gotPaths, wantPaths) {
|
||||
t.Fatalf("source paths = %#v, want %#v", gotPaths, wantPaths)
|
||||
}
|
||||
if run != 0 && !reflect.DeepEqual(sources, previous) {
|
||||
t.Fatalf("repeated traversal changed sources: %#v != %#v", sources, previous)
|
||||
}
|
||||
previous = sources
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadGlobImportsSupportEscapesClassesAndExplicitConfD(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||
imports:
|
||||
- glob: 'conf.d/literal\*.yml'
|
||||
- glob: 'conf.d/item-[ab].yml'
|
||||
- glob: 'conf.d/question?.yml'
|
||||
items: {root: root}
|
||||
`)
|
||||
writeImportFile(t, directory, "conf.d/literal*.yml", "items: {literal: literal}\n")
|
||||
writeImportFile(t, directory, "conf.d/item-b.yml", "items: {b: b}\n")
|
||||
writeImportFile(t, directory, "conf.d/item-a.yml", "items: {a: a}\n")
|
||||
writeImportFile(t, directory, "conf.d/question1.yml", "items: {question: question}\n")
|
||||
|
||||
result, err := testLoad[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if want := map[string]string{"root": "root", "literal": "literal", "a": "a", "b": "b", "question": "question"}; !reflect.DeepEqual(result.Config.Items, want) {
|
||||
t.Fatalf("items = %#v, want %#v", result.Config.Items, want)
|
||||
}
|
||||
|
||||
ignoredPath := writeImportFile(t, directory, "conf.d/not-discovered.yml", "unknown: value\n")
|
||||
_ = ignoredPath
|
||||
plainPath := writeImportFile(t, directory, "plain.yml", "items: {root: only}\n")
|
||||
plain, err := 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)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadGlobImportsRejectInvalidAndUnmatchedPatterns(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
pattern string
|
||||
code Code
|
||||
}{
|
||||
{name: "bad class", pattern: "empty/[", code: CodeImportInvalid},
|
||||
{name: "recursive star segment", pattern: "empty/**/value.yml", code: CodeImportInvalid},
|
||||
{name: "recursive adjacent stars", pattern: "empty/value**.yml", code: CodeImportInvalid},
|
||||
{name: "trailing separator", pattern: "empty/", code: CodeImportInvalid},
|
||||
{name: "absolute", pattern: "/tmp/*.yml", code: CodeImportInvalid},
|
||||
{name: "URL", pattern: "https://example.invalid/*.yml", code: CodeImportInvalid},
|
||||
{name: "missing parent escape", pattern: "../missing/*.yml", code: CodeImportOutsideRoot},
|
||||
{name: "unmatched", pattern: "empty/*.yml", code: CodeImportMissing},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
if err := os.Mkdir(filepath.Join(directory, "empty"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mainPath := writeGlobRoot(t, directory, test.pattern, false)
|
||||
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, test.code)
|
||||
})
|
||||
}
|
||||
|
||||
directory := t.TempDir()
|
||||
if err := os.Mkdir(filepath.Join(directory, "empty"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mainPath := writeGlobRoot(t, directory, "empty/*.yml", true)
|
||||
result, err := testLoad[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
|
||||
if err != nil || len(result.Sources()) != 1 {
|
||||
t.Fatalf("optional unmatched glob = %#v, %v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadGlobImportsPreserveIdentityAndFileRules(t *testing.T) {
|
||||
t.Run("overlapping declarations", func(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
writeImportFile(t, directory, "conf/value.yml", "items: {value: value}\n")
|
||||
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||
imports:
|
||||
- glob: conf/*.yml
|
||||
- glob: conf/value.*
|
||||
`)
|
||||
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportRepeated)
|
||||
})
|
||||
|
||||
t.Run("matched directory", func(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
if err := os.MkdirAll(filepath.Join(directory, "conf/matched.yml"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
mainPath := writeGlobRoot(t, directory, "conf/*.yml", true)
|
||||
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeSourceNotRegular)
|
||||
})
|
||||
|
||||
t.Run("hard links stay distinct", func(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
one := writeImportFile(t, directory, "conf/one.yml", "items: {same: value}\n")
|
||||
if err := os.Link(one, filepath.Join(directory, "conf/two.yml")); err != nil {
|
||||
t.Skipf("hard links unavailable: %v", err)
|
||||
}
|
||||
mainPath := writeGlobRoot(t, directory, "conf/*.yml", false)
|
||||
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeComposeConflict)
|
||||
})
|
||||
}
|
||||
|
||||
func TestLoadGlobImportsConfineSymlinkDirectories(t *testing.T) {
|
||||
if runtime.GOOS != "linux" && runtime.GOOS != "darwin" {
|
||||
t.Skip("initial filesystem contract covers Linux and macOS")
|
||||
}
|
||||
t.Run("contained absolute target", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
target := filepath.Join(root, "target")
|
||||
writeImportFile(t, target, "value.yml", "items: {linked: linked}\n")
|
||||
if err := os.Symlink(target, filepath.Join(root, "linked")); err != nil {
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
}
|
||||
mainPath := writeGlobRoot(t, root, "linked/*.yml", false)
|
||||
result, err := 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)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("escaping target", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
outside := t.TempDir()
|
||||
writeImportFile(t, outside, "value.yml", "items: {escaped: escaped}\n")
|
||||
if err := os.Symlink(outside, filepath.Join(root, "linked")); err != nil {
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
}
|
||||
mainPath := writeGlobRoot(t, root, "linked/*.yml", true)
|
||||
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportOutsideRoot)
|
||||
})
|
||||
}
|
||||
|
||||
func TestOptionalGlobDoesNotSuppressDirectoryPermissionFailure(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
locked := filepath.Join(directory, "locked")
|
||||
if err := os.Mkdir(locked, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chmod(locked, 0); err != nil {
|
||||
t.Skipf("cannot create unreadable directory fixture: %v", err)
|
||||
}
|
||||
defer func() { _ = os.Chmod(locked, 0o700) }()
|
||||
if opened, err := os.Open(locked); err == nil {
|
||||
_ = opened.Close()
|
||||
t.Skip("filesystem does not enforce the unreadable-directory fixture")
|
||||
}
|
||||
mainPath := writeGlobRoot(t, directory, "locked/*.yml", true)
|
||||
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportPermission)
|
||||
}
|
||||
|
||||
func TestGlobTraversalBoundsNonmatchesAndAcceptedCandidates(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
for _, name := range []string{"one.txt", "two.txt", "three.txt"} {
|
||||
writeImportFile(t, directory, "many/"+name, "ignored\n")
|
||||
}
|
||||
mainPath := writeGlobRoot(t, directory, "many/*.yml", true)
|
||||
main := parseImportMain(t, mainPath)
|
||||
limits := defaultImportLimits()
|
||||
limits.entries = 2
|
||||
_, _, diagnostics := loadImportedDocuments(File(mainPath), nil, main, nil, limits)
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportLimitExceeded {
|
||||
t.Fatalf("entry-budget diagnostics = %#v", diagnostics)
|
||||
}
|
||||
|
||||
for _, name := range []string{"a.yml", "b.yml"} {
|
||||
writeImportFile(t, directory, "matches/"+name, "items: {"+name[:1]+": value}\n")
|
||||
}
|
||||
mainPath = writeGlobRoot(t, directory, "matches/*.yml", false)
|
||||
main = parseImportMain(t, mainPath)
|
||||
limits = defaultImportLimits()
|
||||
limits.sources = 2
|
||||
_, _, diagnostics = loadImportedDocuments(File(mainPath), nil, main, nil, limits)
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportLimitExceeded {
|
||||
t.Fatalf("candidate-budget diagnostics = %#v", diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFilesystemContextIsValidatedWithoutImportsOrActiveOverlay(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "items: {root: root}\n")
|
||||
result, err := testLoad[importTestConfig](File(mainPath), Options[importTestConfig]{
|
||||
Overlay: "default", Filesystem: &FilesystemContext{Root: directory},
|
||||
})
|
||||
if err != nil || result.Config.Items["root"] != "root" {
|
||||
t.Fatalf("file filesystem context = %#v, %v", result, err)
|
||||
}
|
||||
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Filesystem: &FilesystemContext{BaseDir: directory, Root: directory}}, CodeOptionsInvalid)
|
||||
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Filesystem: &FilesystemContext{Root: filepath.Join(directory, "missing")}}, CodeImportInvalid)
|
||||
|
||||
input := Bytes("memory.yml", []byte("items: {root: memory}\n"))
|
||||
result, err = 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 := testLoad[importTestConfig](NoSource(), Options[importTestConfig]{Overlay: "default"})
|
||||
if err != nil || defaultsOnly == nil {
|
||||
t.Fatalf("default overlay with NoSource = %#v, %v", defaultsOnly, err)
|
||||
}
|
||||
|
||||
if runtime.GOOS == "linux" || runtime.GOOS == "darwin" {
|
||||
selectedLink := filepath.Join(directory, "selected-link.yml")
|
||||
if err := os.Symlink(mainPath, selectedLink); err == nil {
|
||||
linked, err := 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func writeGlobRoot(t *testing.T, directory, pattern string, optional bool) string {
|
||||
t.Helper()
|
||||
optionalLine := ""
|
||||
if optional {
|
||||
optionalLine = "\n optional: true"
|
||||
}
|
||||
return writeImportFile(t, directory, "main.yml", "configkit:\n imports:\n - glob: '"+pattern+"'"+optionalLine+"\n")
|
||||
}
|
||||
276
internal/engine/history.go
Normal file
276
internal/engine/history.go
Normal 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(¤t.Mapping[position].Value, incoming)
|
||||
}
|
||||
return
|
||||
}
|
||||
if position < 0 {
|
||||
child := &document.Node{Kind: document.Mapping}
|
||||
current.Mapping = append(current.Mapping, document.Entry{Key: segment.key, Value: child})
|
||||
current = child
|
||||
} else {
|
||||
current = current.Mapping[position].Value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Resolution records describe one before/final transformation, not assignment
|
||||
// order. A parent already carries its descendants' final values; merge overlapping
|
||||
// intent into that event rather than replay a child assignment a second time.
|
||||
func coalescedResolutionSnapshots(snapshots []resolutionSnapshot) []resolutionSnapshot {
|
||||
groups := make([]resolutionSnapshot, 0, len(snapshots))
|
||||
for _, snapshot := range snapshots {
|
||||
merged := false
|
||||
for i := range groups {
|
||||
if !pathContains(groups[i].change.Path, snapshot.change.Path) {
|
||||
continue
|
||||
}
|
||||
for _, reason := range snapshot.change.Reasons {
|
||||
groups[i].change.Reasons = appendUniqueString(groups[i].change.Reasons, reason)
|
||||
}
|
||||
for _, dependency := range snapshot.change.Dependencies {
|
||||
groups[i].change.Dependencies = appendUniquePath(groups[i].change.Dependencies, dependency)
|
||||
}
|
||||
if snapshot.change.Origin.Operation == ChangeNormalization {
|
||||
groups[i].change.Origin.Operation = ChangeNormalization
|
||||
}
|
||||
merged = true
|
||||
break
|
||||
}
|
||||
if !merged {
|
||||
snapshot.change = cloneChange(snapshot.change)
|
||||
groups = append(groups, snapshot)
|
||||
}
|
||||
}
|
||||
return groups
|
||||
}
|
||||
|
||||
func replaceHistoryPath(root **document.Node, path Path, replacement *document.Node) {
|
||||
if root == nil || *root == nil {
|
||||
return
|
||||
}
|
||||
if len(path.segments) == 0 {
|
||||
*root = document.Clone(replacement)
|
||||
return
|
||||
}
|
||||
current := *root
|
||||
for index, segment := range path.segments {
|
||||
last := index == len(path.segments)-1
|
||||
switch current.Kind {
|
||||
case document.Mapping:
|
||||
if segment.kind == pathSegmentIndex {
|
||||
return
|
||||
}
|
||||
position := documentEntryIndex(current.Mapping, segment.key)
|
||||
if position < 0 {
|
||||
if replacement == nil {
|
||||
return
|
||||
}
|
||||
child := &document.Node{Kind: document.Mapping}
|
||||
if last {
|
||||
child = document.Clone(replacement)
|
||||
}
|
||||
current.Mapping = append(current.Mapping, document.Entry{Key: segment.key, Value: child})
|
||||
if last {
|
||||
return
|
||||
}
|
||||
current = child
|
||||
continue
|
||||
}
|
||||
if last {
|
||||
if replacement == nil {
|
||||
current.Mapping = append(current.Mapping[:position], current.Mapping[position+1:]...)
|
||||
} else {
|
||||
current.Mapping[position].Value = document.Clone(replacement)
|
||||
}
|
||||
return
|
||||
}
|
||||
current = current.Mapping[position].Value
|
||||
case document.Sequence:
|
||||
position, ok := schemaIndex(segment)
|
||||
if !ok || position >= len(current.Sequence) {
|
||||
return
|
||||
}
|
||||
if last {
|
||||
if replacement != nil {
|
||||
current.Sequence[position] = document.Clone(replacement)
|
||||
}
|
||||
return
|
||||
}
|
||||
current = current.Sequence[position]
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func historyNodesWithinLimit(baseline, input, final *document.Node, applications []historyApplication, resolutions []resolutionSnapshot) bool {
|
||||
return historyNodesWithin(historyNodeLimit, baseline, input, final, applications, resolutions)
|
||||
}
|
||||
|
||||
func historyNodesWithin(limit int, baseline, input, final *document.Node, applications []historyApplication, resolutions []resolutionSnapshot) bool {
|
||||
seen := make(map[*document.Node]bool)
|
||||
count := 0
|
||||
var visit func(*document.Node) bool
|
||||
visit = func(node *document.Node) bool {
|
||||
if node == nil || seen[node] {
|
||||
return true
|
||||
}
|
||||
seen[node] = true
|
||||
count++
|
||||
if count > limit {
|
||||
return false
|
||||
}
|
||||
for _, entry := range node.Mapping {
|
||||
if !visit(entry.Value) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, child := range node.Sequence {
|
||||
if !visit(child) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
if !visit(baseline) || !visit(input) || !visit(final) {
|
||||
return false
|
||||
}
|
||||
for _, application := range applications {
|
||||
if !visit(application.value) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
for _, resolution := range resolutions {
|
||||
if !visit(resolution.before) || !visit(resolution.after) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
324
internal/engine/imports.go
Normal file
324
internal/engine/imports.go
Normal 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,
|
||||
}
|
||||
}
|
||||
488
internal/engine/imports_test.go
Normal file
488
internal/engine/imports_test.go
Normal file
@@ -0,0 +1,488 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"runtime"
|
||||
"strconv"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type importTestConfig struct {
|
||||
Settings struct {
|
||||
Root string `yaml:"root"`
|
||||
First string `yaml:"first"`
|
||||
Nested string `yaml:"nested"`
|
||||
Second string `yaml:"second"`
|
||||
} `yaml:"settings"`
|
||||
Version string `yaml:"version"`
|
||||
Items map[string]string `yaml:"items"`
|
||||
}
|
||||
|
||||
func TestLoadFormerDirectiveKeyIsApplicationData(t *testing.T) {
|
||||
type config struct {
|
||||
Legacy struct {
|
||||
Imports []struct {
|
||||
Path string `yaml:"path"`
|
||||
} `yaml:"imports"`
|
||||
} `yaml:"_configkit"`
|
||||
}
|
||||
for _, imports := range []bool{false, true} {
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "_configkit:\n imports:\n - path: nonexistent.yml\n")
|
||||
result, err := 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:
|
||||
imports:
|
||||
- path: first.yml
|
||||
- path: second.yml
|
||||
settings:
|
||||
root: root
|
||||
`)
|
||||
firstPath := writeImportFile(t, directory, "first.yml", `configkit:
|
||||
imports:
|
||||
- path: nested/third.yml
|
||||
settings:
|
||||
first: first
|
||||
`)
|
||||
thirdPath := writeImportFile(t, directory, "nested/third.yml", "settings:\n nested: nested\n")
|
||||
secondPath := writeImportFile(t, directory, "second.yml", "settings:\n second: second\n")
|
||||
|
||||
result, err := testLoad[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := result.Config.Settings; got.Root != "root" || got.First != "first" || got.Nested != "nested" || got.Second != "second" {
|
||||
t.Fatalf("composed settings = %#v", got)
|
||||
}
|
||||
sources := result.Sources()
|
||||
wantPaths := []string{canonicalTestPath(t, mainPath), canonicalTestPath(t, firstPath), canonicalTestPath(t, thirdPath), canonicalTestPath(t, secondPath)}
|
||||
gotPaths := make([]string, len(sources))
|
||||
for index, source := range sources {
|
||||
gotPaths[index] = source.Path
|
||||
if source.ID != "source-"+strconv.Itoa(index+1) {
|
||||
t.Errorf("source %d ID = %q", index, source.ID)
|
||||
}
|
||||
if index > 0 && source.ImportedFrom == nil {
|
||||
t.Errorf("source %d has no import location", index)
|
||||
}
|
||||
}
|
||||
if !reflect.DeepEqual(gotPaths, wantPaths) {
|
||||
t.Fatalf("source paths = %#v, want depth-first %#v", gotPaths, wantPaths)
|
||||
}
|
||||
info, ok := result.Metadata().Lookup(Keys("settings"))
|
||||
if !ok || !info.Explicit || len(info.Origin.Locations) != 4 {
|
||||
t.Fatalf("settings metadata = %#v, %v", info, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadImportsFromProfilesDirectoryAsBaseInput(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: profiles/shared.yml}]}\n")
|
||||
writeImportFile(t, directory, "profiles/shared.yml", "settings: {root: imported}\n")
|
||||
|
||||
result, err := testLoad[importTestConfig](File(mainPath), Options[importTestConfig]{Imports: true})
|
||||
if err != nil || result.Config.Settings.Root != "imported" || len(result.Sources()) != 2 ||
|
||||
result.Sources()[1].Layer != LayerBase {
|
||||
t.Fatalf("profiles import = %#v, %v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadRejectsImportConflictsAndFragmentVersions(t *testing.T) {
|
||||
t.Run("all duplicate claimants", func(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||
imports:
|
||||
- path: one.yml
|
||||
- path: two.yml
|
||||
- path: three.yml
|
||||
`)
|
||||
for index, name := range []string{"one.yml", "two.yml", "three.yml"} {
|
||||
writeImportFile(t, directory, name, "settings:\n root: "+string(rune('a'+index))+"\n")
|
||||
}
|
||||
loadError := requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeComposeConflict)
|
||||
diagnostic := loadError.Diagnostics()[0]
|
||||
if diagnostic.Path.String() != "/settings/root" || diagnostic.Primary == nil || len(diagnostic.Related) != 2 {
|
||||
t.Fatalf("conflict diagnostic = %#v", diagnostic)
|
||||
}
|
||||
})
|
||||
|
||||
versionRule := &VersionRule{Path: Keys("settings", "version"), Check: func(Version, *Validation) error { return nil }}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
fragment string
|
||||
}{
|
||||
{name: "exact 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")
|
||||
writeImportFile(t, directory, "fragment.yml", test.fragment)
|
||||
loadError := requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true, Definition: Definition[importTestConfig]{Version: versionRule}}, CodeImportVersion)
|
||||
diagnostic := loadError.Diagnostics()[0]
|
||||
if diagnostic.Path.String() != "/settings/version" || diagnostic.Primary == nil || diagnostic.Primary.SourceID != "source-2" {
|
||||
t.Fatalf("version placement diagnostic = %#v", diagnostic)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadImportDirectiveValidationAndTemporaryGlobGate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
code Code
|
||||
}{
|
||||
{name: "directive kind", body: "configkit: []\n", code: CodeImportInvalid},
|
||||
{name: "unknown directive field", body: "configkit: {unknown: true}\n", code: CodeImportInvalid},
|
||||
{name: "imports kind", body: "configkit: {imports: {}}\n", code: CodeImportInvalid},
|
||||
{name: "declaration kind", body: "configkit: {imports: [file.yml]}\n", code: CodeImportInvalid},
|
||||
{name: "missing target", body: "configkit: {imports: [{optional: true}]}\n", code: CodeImportInvalid},
|
||||
{name: "two targets", body: "configkit: {imports: [{path: a.yml, glob: '*.yml'}]}\n", code: CodeImportInvalid},
|
||||
{name: "unknown declaration field", body: "configkit: {imports: [{path: a.yml, extra: true}]}\n", code: CodeImportInvalid},
|
||||
{name: "optional kind", body: "configkit: {imports: [{path: a.yml, optional: 'yes'}]}\n", code: CodeImportInvalid},
|
||||
{name: "nested directive", body: "nested: {configkit: {}}\n", code: CodeImportInvalid},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", test.body)
|
||||
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, test.code)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadImportMissingContainmentAndRegularFileRules(t *testing.T) {
|
||||
t.Run("optional missing", func(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||
imports:
|
||||
- path: missing.yml
|
||||
optional: true
|
||||
settings: {root: retained}
|
||||
`)
|
||||
result, err := 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)
|
||||
}
|
||||
})
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
setup func(*testing.T, string)
|
||||
code Code
|
||||
}{
|
||||
{name: "required missing", path: "missing.yml", code: CodeImportMissing},
|
||||
{name: "parent traversal", path: "../outside.yml", setup: func(t *testing.T, directory string) {
|
||||
writeImportFile(t, filepath.Dir(directory), "outside.yml", "settings: {root: outside}\n")
|
||||
}, code: CodeImportOutsideRoot},
|
||||
{name: "directory", path: "directory", setup: func(t *testing.T, directory string) {
|
||||
if err := os.Mkdir(filepath.Join(directory, "directory"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}, code: CodeSourceNotRegular},
|
||||
{name: "absolute", path: "/tmp/configkit.yml", code: CodeImportInvalid},
|
||||
{name: "network URL", path: "https://example.invalid/config.yml", code: CodeImportInvalid},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
if test.setup != nil {
|
||||
test.setup(t, directory)
|
||||
}
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "configkit:\n imports:\n - path: \""+test.path+"\"\n")
|
||||
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, test.code)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadImportIdentityReportsCyclesAndRepeatedRoutes(t *testing.T) {
|
||||
t.Run("cycle", func(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "configkit:\n imports: [{path: a.yml}]\n")
|
||||
writeImportFile(t, directory, "a.yml", "configkit:\n imports: [{path: b.yml}]\n")
|
||||
writeImportFile(t, directory, "b.yml", "configkit:\n imports: [{path: main.yml}]\n")
|
||||
loadError := requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportCycle)
|
||||
diagnostic := loadError.Diagnostics()[0]
|
||||
if diagnostic.Primary == nil || len(diagnostic.Related) != 2 || !strings.Contains(diagnostic.Message, "main.yml") || !strings.Contains(diagnostic.Message, "a.yml") || !strings.Contains(diagnostic.Message, "b.yml") {
|
||||
t.Fatalf("cycle diagnostic = %#v", diagnostic)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("diamond", func(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "configkit:\n imports: [{path: left.yml}, {path: right.yml}]\n")
|
||||
writeImportFile(t, directory, "left.yml", "configkit:\n imports: [{path: shared.yml}]\n")
|
||||
writeImportFile(t, directory, "right.yml", "configkit:\n imports: [{path: shared.yml}]\n")
|
||||
writeImportFile(t, directory, "shared.yml", "settings: {root: shared}\n")
|
||||
loadError := requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportRepeated)
|
||||
diagnostic := loadError.Diagnostics()[0]
|
||||
if diagnostic.Primary == nil || len(diagnostic.Related) < 3 {
|
||||
t.Fatalf("repeated-route diagnostic = %#v", diagnostic)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("symlink aliases", func(t *testing.T) {
|
||||
if runtime.GOOS != "linux" && runtime.GOOS != "darwin" {
|
||||
t.Skip("initial filesystem contract covers Linux and macOS")
|
||||
}
|
||||
directory := t.TempDir()
|
||||
writeImportFile(t, directory, "target.yml", "settings: {root: target}\n")
|
||||
if err := os.Symlink("target.yml", filepath.Join(directory, "first-link.yml")); err != nil {
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
}
|
||||
if err := os.Symlink("target.yml", filepath.Join(directory, "second-link.yml")); err != nil {
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
}
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: first-link.yml}, {path: second-link.yml}]}\n")
|
||||
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportRepeated)
|
||||
})
|
||||
}
|
||||
|
||||
func TestLoadImportsCanonicalSymlinksAndKeepsHardLinksDistinct(t *testing.T) {
|
||||
if runtime.GOOS != "linux" && runtime.GOOS != "darwin" {
|
||||
t.Skip("initial filesystem contract covers Linux and macOS")
|
||||
}
|
||||
t.Run("contained absolute symlink", func(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
target := writeImportFile(t, directory, "target.yml", "settings: {first: linked}\n")
|
||||
if err := os.Symlink(target, filepath.Join(directory, "link.yml")); err != nil {
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
}
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: link.yml}]}\n")
|
||||
result, err := 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)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("symlink escape", func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
outside := t.TempDir()
|
||||
target := writeImportFile(t, outside, "outside.yml", "settings: {root: outside}\n")
|
||||
if err := os.Symlink(target, filepath.Join(root, "escape.yml")); err != nil {
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
}
|
||||
mainPath := writeImportFile(t, root, "main.yml", "configkit: {imports: [{path: escape.yml}]}\n")
|
||||
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportOutsideRoot)
|
||||
})
|
||||
|
||||
t.Run("hard links are path identities", func(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
one := writeImportFile(t, directory, "one.yml", "settings: {first: one}\n")
|
||||
if err := os.Link(one, filepath.Join(directory, "two.yml")); err != nil {
|
||||
t.Skipf("hard links unavailable: %v", err)
|
||||
}
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: one.yml}, {path: two.yml}]}\n")
|
||||
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeComposeConflict)
|
||||
})
|
||||
|
||||
t.Run("canonicalization failure", func(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
if err := os.Symlink("loop.yml", filepath.Join(directory, "loop.yml")); err != nil {
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
}
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: loop.yml}]}\n")
|
||||
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportInvalid)
|
||||
})
|
||||
}
|
||||
|
||||
func TestLoadByteAndReaderImportsRequireExplicitFilesystemContext(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
writeImportFile(t, directory, "fragment.yml", "settings: {first: imported}\n")
|
||||
input := []byte("configkit: {imports: [{path: fragment.yml}]}\n")
|
||||
context := &FilesystemContext{BaseDir: directory, Root: directory}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
source func() Source
|
||||
}{
|
||||
{name: "bytes URI label", source: func() Source { return Bytes("s3://bucket/main.yml", input) }},
|
||||
{name: "reader URI label", source: func() Source { return Reader("https://example.invalid/main.yml", strings.NewReader(string(input))) }},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
result, err := 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
requireLoadCode(t, Bytes("memory.yml", input), Options[importTestConfig]{Imports: true}, CodeOptionsInvalid)
|
||||
requireLoadCode(t, Bytes("memory.yml", input), Options[importTestConfig]{Imports: true, Filesystem: &FilesystemContext{BaseDir: "relative", Root: directory}}, CodeOptionsInvalid)
|
||||
requireLoadCode(t, Bytes("memory.yml", input), Options[importTestConfig]{Imports: true, Filesystem: &FilesystemContext{BaseDir: directory, Root: t.TempDir()}}, CodeImportOutsideRoot)
|
||||
}
|
||||
|
||||
func TestFileImportRootCanContainTheSelectedFileDirectory(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
mainPath := writeImportFile(t, root, "configs/main.yml", "configkit: {imports: [{path: ../shared.yml}]}\n")
|
||||
writeImportFile(t, root, "shared.yml", "settings: {root: shared}\n")
|
||||
result, err := testLoad[importTestConfig](File(mainPath), Options[importTestConfig]{
|
||||
Imports: true, Filesystem: &FilesystemContext{Root: root},
|
||||
})
|
||||
if err != nil || result.Config.Settings.Root != "shared" {
|
||||
t.Fatalf("explicit-root result = %#v, %v", result, err)
|
||||
}
|
||||
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true, Filesystem: &FilesystemContext{BaseDir: filepath.Dir(mainPath), Root: root}}, CodeOptionsInvalid)
|
||||
}
|
||||
|
||||
func TestImportedParseAndDecodeDiagnosticsKeepTheirSource(t *testing.T) {
|
||||
t.Run("parse", func(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: broken.yml}]}\n")
|
||||
writeImportFile(t, directory, "broken.yml", "settings: [\n")
|
||||
loadError := requireAnyLoadError(t, File(mainPath), Options[importTestConfig]{Imports: true})
|
||||
diagnostics := loadError.Diagnostics()
|
||||
if diagnostics[0].Code != CodeYAMLInvalid || diagnostics[0].Primary == nil || diagnostics[0].Primary.SourceID != "source-2" || len(loadError.Sources()) != 2 {
|
||||
t.Fatalf("parse failure = diagnostics %#v, sources %#v", diagnostics, loadError.Sources())
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("decode", func(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: unknown.yml}]}\n")
|
||||
writeImportFile(t, directory, "unknown.yml", "unknown: value\n")
|
||||
loadError := requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeDecodeUnknownField)
|
||||
diagnostic := loadError.Diagnostics()[0]
|
||||
if diagnostic.Primary == nil || diagnostic.Primary.SourceID != "source-2" {
|
||||
t.Fatalf("decode diagnostic = %#v", diagnostic)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestOptionalImportDoesNotSuppressUnsafeOrUnreadableTargets(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "outside.yml", "configkit: {imports: [{path: ../missing.yml, optional: true}]}\n")
|
||||
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeImportOutsideRoot)
|
||||
|
||||
locked := writeImportFile(t, directory, "locked.yml", "settings: {root: locked}\n")
|
||||
if err := os.Chmod(locked, 0); err != nil {
|
||||
t.Skipf("cannot create unreadable fixture: %v", err)
|
||||
}
|
||||
defer func() { _ = os.Chmod(locked, 0o600) }()
|
||||
if file, err := os.Open(locked); err == nil {
|
||||
_ = file.Close()
|
||||
t.Skip("filesystem does not enforce the unreadable-file fixture")
|
||||
}
|
||||
mainPath = writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: locked.yml, optional: true}]}\n")
|
||||
requireLoadCode(t, File(mainPath), Options[importTestConfig]{Imports: true}, CodeSourcePermission)
|
||||
}
|
||||
|
||||
func TestImportTraversalEnforcesPrivateBudgets(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: one.yml}, {path: two.yml}]}\n")
|
||||
writeImportFile(t, directory, "one.yml", "settings: {first: one}\n")
|
||||
writeImportFile(t, directory, "two.yml", "settings: {second: two}\n")
|
||||
main := parseImportMain(t, mainPath)
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
limits importLimits
|
||||
}{
|
||||
{name: "source count", limits: importLimits{depth: 8, sources: 2, bytes: maxTotalBytes, nodes: maxDocumentNodes}},
|
||||
{name: "aggregate bytes", limits: importLimits{depth: 8, sources: 8, bytes: main.bytes + 1, nodes: maxDocumentNodes}},
|
||||
{name: "aggregate nodes", limits: importLimits{depth: 8, sources: 8, bytes: maxTotalBytes, nodes: countInputNodes(main.document) + 1}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
_, _, diagnostics := loadImportedDocuments(File(mainPath), nil, main, nil, test.limits)
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportLimitExceeded {
|
||||
t.Fatalf("budget diagnostics = %#v", diagnostics)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
writeImportFile(t, directory, "one.yml", "configkit: {imports: [{path: nested.yml}]}\n")
|
||||
writeImportFile(t, directory, "nested.yml", "settings: {nested: nested}\n")
|
||||
main = parseImportMain(t, mainPath)
|
||||
_, _, diagnostics := loadImportedDocuments(File(mainPath), nil, main, nil, importLimits{depth: 1, sources: 8, bytes: maxTotalBytes, nodes: maxDocumentNodes})
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportLimitExceeded {
|
||||
t.Fatalf("depth diagnostics = %#v", diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportTraversalAcceptsExactPrivateBudgets(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: imported.yml}]}\n")
|
||||
importedPath := writeImportFile(t, directory, "imported.yml", "settings: {first: imported}\n")
|
||||
main := parseImportMain(t, mainPath)
|
||||
imported := parseImportMain(t, importedPath)
|
||||
data, err := os.ReadFile(importedPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
limits := importLimits{
|
||||
depth: 1,
|
||||
sources: 2,
|
||||
bytes: main.bytes + int64(len(data)),
|
||||
nodes: countInputNodes(main.document) + countInputNodes(imported.document),
|
||||
}
|
||||
composed, sources, diagnostics := loadImportedDocuments(File(mainPath), nil, main, nil, limits)
|
||||
if len(diagnostics) != 0 || composed == nil || len(sources) != 2 {
|
||||
t.Fatalf("exact-limit composition = (%#v, %#v, %#v)", composed, sources, diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func writeImportFile(t *testing.T, root, relative, contents string) string {
|
||||
t.Helper()
|
||||
filename := filepath.Join(root, filepath.FromSlash(relative))
|
||||
if err := os.MkdirAll(filepath.Dir(filename), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filename, []byte(contents), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return filename
|
||||
}
|
||||
|
||||
func canonicalTestPath(t *testing.T, filename string) string {
|
||||
t.Helper()
|
||||
canonical, err := canonicalExistingPath(filename)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return canonical
|
||||
}
|
||||
|
||||
func requireLoadCode(t *testing.T, source Source, options Options[importTestConfig], code Code) *LoadError {
|
||||
t.Helper()
|
||||
loadError := requireAnyLoadError(t, source, options)
|
||||
diagnostics := loadError.Diagnostics()
|
||||
if len(diagnostics) == 0 || diagnostics[0].Code != code {
|
||||
t.Fatalf("diagnostics = %#v, want %s", diagnostics, code)
|
||||
}
|
||||
return loadError
|
||||
}
|
||||
|
||||
func requireAnyLoadError(t *testing.T, source Source, options Options[importTestConfig]) *LoadError {
|
||||
t.Helper()
|
||||
result, err := testLoad[importTestConfig](source, options)
|
||||
if result != nil || err == nil {
|
||||
t.Fatalf("result, error = %#v, %v", result, err)
|
||||
}
|
||||
var loadError *LoadError
|
||||
if !errors.As(err, &loadError) {
|
||||
t.Fatalf("error type = %T", err)
|
||||
}
|
||||
return loadError
|
||||
}
|
||||
|
||||
func parseImportMain(t *testing.T, filename string) parsedSource {
|
||||
t.Helper()
|
||||
parsed, diagnostics := parseSource(File(filename), "source-1", defaultParseLimits())
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("parse diagnostics = %#v", diagnostics)
|
||||
}
|
||||
return parsed
|
||||
}
|
||||
139
internal/engine/independence_test.go
Normal file
139
internal/engine/independence_test.go
Normal file
@@ -0,0 +1,139 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type independentText string
|
||||
|
||||
func (value independentText) MarshalText() ([]byte, error) {
|
||||
return []byte(value), nil
|
||||
}
|
||||
|
||||
func (value *independentText) UnmarshalText(text []byte) error {
|
||||
*value = independentText(text)
|
||||
return nil
|
||||
}
|
||||
|
||||
type independentConfig struct {
|
||||
Labels map[string]string `yaml:"labels"`
|
||||
Items []string `yaml:"items"`
|
||||
Custom independentText `yaml:"custom"`
|
||||
}
|
||||
|
||||
func TestParallelLoadsShareRulesButNotMutableValues(t *testing.T) {
|
||||
definition := Definition[independentConfig]{
|
||||
Defaults: func() independentConfig {
|
||||
return independentConfig{
|
||||
Labels: map[string]string{"default": "kept"},
|
||||
Items: []string{"default"},
|
||||
Custom: "default",
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
const count = 32
|
||||
results := make([]*testResult[independentConfig], count)
|
||||
errorsByIndex := make([]error, count)
|
||||
var group sync.WaitGroup
|
||||
for index := range count {
|
||||
group.Add(1)
|
||||
go func(index int) {
|
||||
defer group.Done()
|
||||
input := fmt.Sprintf("custom: value-%d\n", index)
|
||||
results[index], errorsByIndex[index] = testLoad(Bytes("parallel.yml", []byte(input)), Options[independentConfig]{Definition: definition})
|
||||
}(index)
|
||||
}
|
||||
group.Wait()
|
||||
|
||||
for index, err := range errorsByIndex {
|
||||
if err != nil {
|
||||
t.Fatalf("testLoad(%d) failed: %v", index, err)
|
||||
}
|
||||
}
|
||||
results[0].Config.Labels["default"] = "mutated"
|
||||
results[0].Config.Items[0] = "mutated"
|
||||
results[0].Config.Custom = "mutated"
|
||||
for index := 1; index < count; index++ {
|
||||
if results[index].Config.Labels["default"] != "kept" || results[index].Config.Items[0] != "default" || results[index].Config.Custom != independentText(fmt.Sprintf("value-%d", index)) {
|
||||
t.Fatalf("result %d shares mutable state: %#v", index, results[index].Config)
|
||||
}
|
||||
}
|
||||
var effective bytes.Buffer
|
||||
if err := results[0].WriteEffective(&effective); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(effective.String(), "mutated") || !strings.Contains(effective.String(), "default: kept") || !strings.Contains(effective.String(), "value-0") {
|
||||
t.Fatalf("frozen effective snapshot = %q", effective.String())
|
||||
}
|
||||
}
|
||||
|
||||
func 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
|
||||
type config struct {
|
||||
Value string `yaml:"value"`
|
||||
}
|
||||
|
||||
start := make(chan struct{})
|
||||
errorsFound := make(chan error, 2)
|
||||
var group sync.WaitGroup
|
||||
for _, candidate := range []Source{source, copied} {
|
||||
group.Add(1)
|
||||
go func(candidate Source) {
|
||||
defer group.Done()
|
||||
<-start
|
||||
_, err := testLoad[config](candidate)
|
||||
errorsFound <- err
|
||||
}(candidate)
|
||||
}
|
||||
close(start)
|
||||
group.Wait()
|
||||
close(errorsFound)
|
||||
|
||||
successes, used := 0, 0
|
||||
for err := range errorsFound {
|
||||
if err == nil {
|
||||
successes++
|
||||
continue
|
||||
}
|
||||
var loadError *LoadError
|
||||
if errors.As(err, &loadError) {
|
||||
diagnostics := loadError.Diagnostics()
|
||||
if len(diagnostics) == 1 && diagnostics[0].Code == CodeSourceReaderUsed {
|
||||
used++
|
||||
}
|
||||
}
|
||||
}
|
||||
if successes != 1 || used != 1 {
|
||||
t.Fatalf("successes=%d reader-used=%d", successes, used)
|
||||
}
|
||||
}
|
||||
190
internal/engine/load.go
Normal file
190
internal/engine/load.go
Normal 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; Overlay selects one fixed overlay;
|
||||
// Overrides contains only values explicitly supplied by an application CLI.
|
||||
type Options[T any] struct {
|
||||
Definition Definition[T]
|
||||
Imports bool
|
||||
Overlay string
|
||||
Filesystem *FilesystemContext
|
||||
Overrides []Override
|
||||
}
|
||||
|
||||
// FilesystemContext supplies filesystem roots for imports and overlays.
|
||||
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 overlay. 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)
|
||||
}
|
||||
88
internal/engine/load_input.go
Normal file
88
internal/engine/load_input.go
Normal 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 && !activeOverlay(options.Overlay) {
|
||||
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 !activeOverlay(options.Overlay) && len(options.Overrides) == 0 {
|
||||
return input, nil
|
||||
}
|
||||
composition, diagnostics := compositionForInput(input)
|
||||
if len(diagnostics) != 0 {
|
||||
return input, diagnostics
|
||||
}
|
||||
if activeOverlay(options.Overlay) {
|
||||
composition, input.sources, diagnostics = loadSelectedOverlayDocument(
|
||||
source, options.Filesystem, input.parsed, composition, input.sources,
|
||||
options.Overlay, 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 overlays 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
|
||||
}
|
||||
330
internal/engine/load_metadata.go
Normal file
330
internal/engine/load_metadata.go
Normal file
@@ -0,0 +1,330 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"slices"
|
||||
|
||||
"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),
|
||||
history: make(map[string][]Change),
|
||||
}
|
||||
baseline := make(map[string]*document.Node)
|
||||
indexDocument(decoded.baseline, Path{}, baseline)
|
||||
for pointer := range baseline {
|
||||
path, err := ParsePath(pointer)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
metadata.history[pointer] = append(metadata.history[pointer], Change{
|
||||
Path: path, Origin: Origin{Layer: LayerDefault},
|
||||
})
|
||||
}
|
||||
for pointer := range decoded.presence {
|
||||
path, err := ParsePath(pointer)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
origin := cloneOrigin(decoded.origins[pointer])
|
||||
change := Change{Path: path, Origin: origin}
|
||||
if _, ok := baseline[pointer]; ok {
|
||||
change.Previous = []Origin{{Layer: LayerDefault}}
|
||||
}
|
||||
metadata.history[pointer] = append(metadata.history[pointer], change)
|
||||
}
|
||||
collectFieldInfo(decoded.final, Path{}, decoded, metadata.fields)
|
||||
return metadata
|
||||
}
|
||||
|
||||
func applyOverlayMetadata(metadata *Metadata, records []overlayRecord) {
|
||||
baseline := make(map[string][]Change, len(metadata.history))
|
||||
for pointer, history := range metadata.history {
|
||||
for _, change := range history {
|
||||
if change.Origin.Layer == LayerDefault {
|
||||
baseline[pointer] = append(baseline[pointer], cloneChange(change))
|
||||
}
|
||||
}
|
||||
}
|
||||
events := make(map[string][]Change)
|
||||
for _, record := range records {
|
||||
exact := false
|
||||
for _, displaced := range record.displaced {
|
||||
pointer := displaced.path.String()
|
||||
appendInputOriginEvent(events, baseline, pointer, displaced.path, displaced.origin)
|
||||
appendInputChangeEvent(events, baseline, pointer, record.change)
|
||||
if pointer == record.change.Path.String() {
|
||||
exact = true
|
||||
}
|
||||
}
|
||||
if !exact {
|
||||
pointer := record.change.Path.String()
|
||||
for _, previous := range record.change.Previous {
|
||||
appendInputOriginEvent(events, baseline, pointer, record.change.Path, previous)
|
||||
}
|
||||
appendInputChangeEvent(events, baseline, pointer, record.change)
|
||||
}
|
||||
}
|
||||
for pointer, info := range metadata.fields {
|
||||
if !info.Explicit {
|
||||
continue
|
||||
}
|
||||
path, err := ParsePath(pointer)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
if history := events[pointer]; len(history) != 0 && history[len(history)-1].Origin.Layer == info.Origin.Layer && history[len(history)-1].Origin.Operation == info.Origin.Operation {
|
||||
continue
|
||||
}
|
||||
appendInputOriginEvent(events, baseline, pointer, path, info.Origin)
|
||||
}
|
||||
metadata.history = make(map[string][]Change, len(baseline)+len(events))
|
||||
for pointer, history := range baseline {
|
||||
metadata.history[pointer] = cloneChanges(history)
|
||||
}
|
||||
for pointer, history := range events {
|
||||
metadata.history[pointer] = append(metadata.history[pointer], cloneChanges(history)...)
|
||||
}
|
||||
}
|
||||
|
||||
func appendInputOriginEvent(events map[string][]Change, baseline map[string][]Change, pointer string, path Path, origin Origin) {
|
||||
if origin.Layer == "" {
|
||||
return
|
||||
}
|
||||
history := events[pointer]
|
||||
if len(history) != 0 && originContainedBy(history[len(history)-1].Origin, origin) {
|
||||
return
|
||||
}
|
||||
appendInputChangeEvent(events, baseline, pointer, Change{Path: path.clone(), Origin: cloneOrigin(origin)})
|
||||
}
|
||||
|
||||
func appendInputChangeEvent(events map[string][]Change, baseline map[string][]Change, pointer string, value Change) {
|
||||
change := cloneChange(value)
|
||||
history := events[pointer]
|
||||
if len(history) != 0 {
|
||||
change.Previous = []Origin{cloneOrigin(history[len(history)-1].Origin)}
|
||||
} else if defaults := baseline[pointer]; len(defaults) != 0 {
|
||||
change.Previous = []Origin{cloneOrigin(defaults[len(defaults)-1].Origin)}
|
||||
} else {
|
||||
change.Previous = nil
|
||||
}
|
||||
events[pointer] = append(history, change)
|
||||
}
|
||||
|
||||
func originContainedBy(candidate, container Origin) bool {
|
||||
if candidate.Layer != container.Layer || candidate.Operation != container.Operation {
|
||||
return false
|
||||
}
|
||||
for _, location := range candidate.Locations {
|
||||
found := false
|
||||
for _, available := range container.Locations {
|
||||
if location == available {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func cloneChanges(changes []Change) []Change {
|
||||
cloned := make([]Change, len(changes))
|
||||
for index, change := range changes {
|
||||
cloned[index] = cloneChange(change)
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func collectFieldInfo(node *document.Node, path Path, decoded decodedConfiguration, fields map[string]FieldInfo) {
|
||||
pointer := path.String()
|
||||
_, explicit := decoded.presence[pointer]
|
||||
fields[pointer] = FieldInfo{
|
||||
Explicit: explicit,
|
||||
Null: node.Kind == document.Null,
|
||||
Origin: cloneOrigin(decoded.origins[pointer]),
|
||||
}
|
||||
switch node.Kind {
|
||||
case document.Mapping:
|
||||
for _, entry := range node.Mapping {
|
||||
collectFieldInfo(entry.Value, path.Key(entry.Key), decoded, fields)
|
||||
}
|
||||
case document.Sequence:
|
||||
for index, child := range node.Sequence {
|
||||
collectFieldInfo(child, path.Index(index), decoded, fields)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func cloneMetadata(metadata Metadata) Metadata {
|
||||
if metadata.fields == nil && metadata.history == nil {
|
||||
return Metadata{}
|
||||
}
|
||||
cloned := Metadata{
|
||||
fields: make(map[string]FieldInfo, len(metadata.fields)),
|
||||
history: make(map[string][]Change, len(metadata.history)),
|
||||
}
|
||||
cloned.effectivePaths = make(map[string]Path, len(metadata.effectivePaths))
|
||||
for pointer, path := range metadata.effectivePaths {
|
||||
cloned.effectivePaths[pointer] = path.clone()
|
||||
}
|
||||
cloned.historicalPaths = make(map[string][]Path, len(metadata.historicalPaths))
|
||||
for pointer, paths := range metadata.historicalPaths {
|
||||
cloned.historicalPaths[pointer] = clonePaths(paths)
|
||||
}
|
||||
for path, info := range metadata.fields {
|
||||
cloned.fields[path] = cloneFieldInfo(info)
|
||||
}
|
||||
for path, changes := range metadata.history {
|
||||
clonedChanges := make([]Change, len(changes))
|
||||
for index, change := range changes {
|
||||
clonedChanges[index] = cloneChange(change)
|
||||
}
|
||||
cloned.history[path] = clonedChanges
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func applyResolutionMetadata(metadata *Metadata, resolved resolutionResult) {
|
||||
for _, snapshot := range resolved.snapshots {
|
||||
pointer := snapshot.change.Path.String()
|
||||
change := cloneChange(snapshot.change)
|
||||
if info, ok := metadata.fields[pointer]; ok {
|
||||
change.Previous = []Origin{cloneOrigin(info.Origin)}
|
||||
info.Origin = cloneOrigin(change.Origin)
|
||||
metadata.fields[pointer] = info
|
||||
}
|
||||
metadata.history[pointer] = append(metadata.history[pointer], change)
|
||||
}
|
||||
appendResolutionDescendantHistory(metadata, resolved)
|
||||
for _, delta := range resolved.deltas {
|
||||
record := coveringResolutionSnapshot(resolved.snapshots, delta.path)
|
||||
if record == nil {
|
||||
continue
|
||||
}
|
||||
for pointer, info := range metadata.fields {
|
||||
path, err := ParsePath(pointer)
|
||||
if err != nil || !pathContains(delta.path, path) {
|
||||
continue
|
||||
}
|
||||
info.Origin = cloneOrigin(record.change.Origin)
|
||||
metadata.fields[pointer] = info
|
||||
}
|
||||
}
|
||||
refreshResolutionFields(metadata.fields, resolved.final, resolved.snapshots)
|
||||
}
|
||||
|
||||
func appendResolutionDescendantHistory(metadata *Metadata, resolved resolutionResult) {
|
||||
finalPaths := make(map[string]*document.Node)
|
||||
indexDocument(resolved.final, Path{}, finalPaths)
|
||||
for _, delta := range resolved.deltas {
|
||||
record := coveringResolutionSnapshot(resolved.snapshots, delta.path)
|
||||
if record == nil {
|
||||
continue
|
||||
}
|
||||
affected := make(map[string]bool)
|
||||
for pointer := range metadata.history {
|
||||
if path, err := ParsePath(pointer); err == nil && pathContains(delta.path, path) {
|
||||
affected[pointer] = true
|
||||
}
|
||||
}
|
||||
for pointer := range finalPaths {
|
||||
if path, err := ParsePath(pointer); err == nil && pathContains(delta.path, path) {
|
||||
affected[pointer] = true
|
||||
}
|
||||
}
|
||||
pointers := make([]string, 0, len(affected))
|
||||
for pointer := range affected {
|
||||
if pointer != record.change.Path.String() {
|
||||
pointers = append(pointers, pointer)
|
||||
}
|
||||
}
|
||||
slices.Sort(pointers)
|
||||
for _, pointer := range pointers {
|
||||
change := cloneChange(record.change)
|
||||
if info, ok := metadata.fields[pointer]; ok {
|
||||
change.Previous = []Origin{cloneOrigin(info.Origin)}
|
||||
}
|
||||
metadata.history[pointer] = append(metadata.history[pointer], change)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func coveringResolutionSnapshot(snapshots []resolutionSnapshot, path Path) *resolutionSnapshot {
|
||||
var covering *resolutionSnapshot
|
||||
for index := range snapshots {
|
||||
snapshot := &snapshots[index]
|
||||
if pathContains(snapshot.change.Path, path) && (covering == nil || len(snapshot.change.Path.segments) > len(covering.change.Path.segments)) {
|
||||
covering = snapshot
|
||||
}
|
||||
}
|
||||
return covering
|
||||
}
|
||||
|
||||
func refreshResolutionFields(fields map[string]FieldInfo, final *document.Node, snapshots []resolutionSnapshot) {
|
||||
finalPaths := make(map[string]*document.Node)
|
||||
indexDocument(final, Path{}, finalPaths)
|
||||
for pointer := range fields {
|
||||
if _, ok := finalPaths[pointer]; !ok {
|
||||
delete(fields, pointer)
|
||||
}
|
||||
}
|
||||
for pointer, node := range finalPaths {
|
||||
if info, ok := fields[pointer]; ok {
|
||||
info.Null = node.Kind == document.Null
|
||||
fields[pointer] = info
|
||||
continue
|
||||
}
|
||||
origin := Origin{Layer: LayerResolution}
|
||||
if path, err := ParsePath(pointer); err == nil {
|
||||
if covering := coveringResolutionSnapshot(snapshots, path); covering != nil {
|
||||
origin = cloneOrigin(covering.change.Origin)
|
||||
}
|
||||
}
|
||||
fields[pointer] = FieldInfo{Null: node.Kind == document.Null, Origin: origin}
|
||||
}
|
||||
}
|
||||
120
internal/engine/load_result.go
Normal file
120
internal/engine/load_result.go
Normal 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),
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user