Compare commits
317 Commits
v0.6.0
...
d898cb193d
| Author | SHA1 | Date | |
|---|---|---|---|
| d898cb193d | |||
| 557e006a4a | |||
| 3fd83b6b2b | |||
| 0fbce5d141 | |||
| ac4ce242a2 | |||
| fa6e876ab5 | |||
| c5275df1fc | |||
| f404168900 | |||
| 729c6fb5d5 | |||
| d314a269d5 | |||
| 2c3eb3308b | |||
| 6bc625b68f | |||
| b915dd776a | |||
| 9a64a65b6c | |||
| 6158e9db3b | |||
| 0d149e51f0 | |||
| bd51b9c3f2 | |||
| d05426d8a2 | |||
| 3f0efcb4ff | |||
| 19f5c2a2d5 | |||
| 455a4a355d | |||
| 8ab1568352 | |||
| 8a99e61070 | |||
| de7513d488 | |||
| 26771dc03e | |||
| fbaaaf18cd | |||
| c9574ef3c1 | |||
| 5959537d1a | |||
| 0369111d30 | |||
| 2fedb0307f | |||
| 087050fa25 | |||
| 1ac1f8bc9e | |||
| 4dc3e70877 | |||
| f64497cb13 | |||
| 68cf5c489b | |||
| e50fee2ca5 | |||
| eec214944c | |||
| 4f2c113237 | |||
| 2d93a5c703 | |||
| 2f6c1eca00 | |||
| 8642337966 | |||
| 53ce6c63d8 | |||
| 2f0900535d | |||
| 4748313105 | |||
| e2634c8815 | |||
| 424c89780e | |||
| 92309ddcc3 | |||
| 7ff204bf3d | |||
| b3ec860fac | |||
| 435dea102f | |||
| ea46001f78 | |||
| a0e5693d07 | |||
| 5c5f2af6cc | |||
| 8253993005 | |||
| d0848290da | |||
| e94a0642ff | |||
| 571d063675 | |||
| d0a605afbb | |||
| 7a83704f43 | |||
| ac5bf29ddc | |||
| cce8356b8e | |||
| b746dcc609 | |||
| 9c32b8d3b5 | |||
| c73daf891d | |||
| ae038a5165 | |||
| 367bbaaa1b | |||
| fafc28421f | |||
| 1a673753b9 | |||
| 988e49bb09 | |||
| 5bb4f364b7 | |||
| af84107905 | |||
| f94dd9b961 | |||
| 146e7e56d9 | |||
| d2e20a304a | |||
| 8d40983271 | |||
| 25cf7ce05c | |||
| 57edb8ec19 | |||
| 42663cf07c | |||
| 085b57eeb6 | |||
| dcf0379e02 | |||
| 8ecfedc91b | |||
| f7624ae92a | |||
| a035df9845 | |||
| e6598810e0 | |||
| f8cbb2ed87 | |||
| af80ccb90c | |||
| 0be7cdb00e | |||
| e01aeca761 | |||
| d4133f23f5 | |||
| 5af71a7533 | |||
| 41b5dff69b | |||
| ecfb78d84d | |||
| 041c4cff02 | |||
| 23f22d8982 | |||
| 0023617a07 | |||
| 33a51606da | |||
| 268fd03579 | |||
| 9d4d2e2a17 | |||
| 36ff9170b7 | |||
| 9e75496664 | |||
| 80d8c1dc82 | |||
| acd9e9a9fa | |||
| 89a9684f0d | |||
| 9a7027a04c | |||
| dfeaf90320 | |||
| d03e56398b | |||
| 628fc53d2c | |||
| 5fec16a768 | |||
| f6b02dec03 | |||
| 7b04dc6a50 | |||
| 12fdf0d331 | |||
| b202dd187f | |||
| 0314e587b6 | |||
| 9d7635d1e4 | |||
| 298e70b552 | |||
| 64c51647f0 | |||
| bc6b977585 | |||
| d06aae5461 | |||
| 4f941f8ba4 | |||
| 9a49833339 | |||
| 749cf6868c | |||
| a5f0b92ee9 | |||
| 6ec23b2de1 | |||
| 3298eb916c | |||
| 40430e787c | |||
| f434fa159f | |||
| 8ddf73ede3 | |||
| 844a15a3cb | |||
| 58f4c39d6f | |||
| 8a54cecd1d | |||
| 0e430e8077 | |||
| 26caa6024e | |||
| 8691a93ebf | |||
| c563f80934 | |||
| 27797d2392 | |||
| 6a4e57e5d3 | |||
| 29f94d89e1 | |||
| 12f6045da0 | |||
| 6eb80b06e3 | |||
| 86feb6d755 | |||
| 8bf49f6ea6 | |||
| f3d0fe6411 | |||
| cba3a0efb0 | |||
| b58cd75c98 | |||
| c530a989df | |||
| 2baf14d9a5 | |||
| 6903c9e51a | |||
| 31e10af9cb | |||
| 0d841d9d73 | |||
| 4bcefbc9d4 | |||
| 91db99b967 | |||
| 5747ec49c6 | |||
| d3a714a1cb | |||
| 7f8ad81065 | |||
| 75da54150f | |||
| 38fb315415 | |||
| 1c79ec3aa1 | |||
| 01e1b2fc2d | |||
| 15943e233c | |||
| f6a6b0184b | |||
| 898035d83a | |||
| de3666aed3 | |||
| c00811da2e | |||
| 55abcf8340 | |||
| 9d7c79cf0b | |||
| 24131617c7 | |||
| 05b67cbfcf | |||
| e433988962 | |||
| 05a28d1178 | |||
| 1e5bffc0e6 | |||
| f5a689b578 | |||
| e4abff4164 | |||
| 1a8f1cc3a5 | |||
| 9b99463187 | |||
| f69fa24b40 | |||
| 77d97296ea | |||
| 032679bf34 | |||
| 55a59de6db | |||
| a0d13962bd | |||
| 5f33e1e90c | |||
| b750b0fec6 | |||
| b498e5bee5 | |||
| ba577429e6 | |||
| 014b99504b | |||
| 263dd2f1bd | |||
| af43d6f438 | |||
| a4cbd6390e | |||
| 5ed0f55970 | |||
| 91ab37a39d | |||
| fbe9ae6d8b | |||
| 1aa121df71 | |||
| 6e40c166d6 | |||
| a56d4691ff | |||
| 2718fbb8fe | |||
| 2d52421919 | |||
| 167fcd475f | |||
| aa93aeda3f | |||
| 94381a67fd | |||
| c26b34147b | |||
| c9f9c98fbe | |||
| f6bf4454b0 | |||
| a7ca2c59a2 | |||
| 10c14aa794 | |||
| e9774b252b | |||
| 19fe621df1 | |||
| 82b289f4fe | |||
| 137769f3f1 | |||
| ef28eabaf8 | |||
| 008d2ba9c8 | |||
| 8e3a0892d4 | |||
| 023e428b9c | |||
| 527a38daf1 | |||
| 30bcd8eeb6 | |||
| 884215c403 | |||
| 8a89806e44 | |||
| 26720b98f0 | |||
| 4633a1b8a2 | |||
| 87986a12d2 | |||
| 0858af1029 | |||
| acddce8e2f | |||
| 77c5d9482a | |||
| adef16ccdd | |||
| c19825620c | |||
| 190431740a | |||
| 25cf6d32b6 | |||
| 1a04bb3835 | |||
| 1cf3b0d78f | |||
| 66dced0823 | |||
| b1b5163cc3 | |||
| bb9bde799a | |||
| dc7a70811a | |||
| ff212885c4 | |||
| 90c2d7e409 | |||
| 60e6e2697f | |||
| 20511773fc | |||
| 31fb6a9bd1 | |||
| 103e10639a | |||
| 97c5a0390a | |||
| 8b547765f0 | |||
| 5215cf5c4f | |||
| e66c37161c | |||
| e4270c9b14 | |||
| e69ad0f32d | |||
| 0a2246bdbe | |||
| e5a6df7af7 | |||
| 25cb3c47e7 | |||
| 020d72084d | |||
| 358b28c421 | |||
| b0550af6a3 | |||
| 43c994a32a | |||
| 1e47ee21cd | |||
| fed591183b | |||
| 1f95cf905a | |||
| 15f5b591b9 | |||
| 184f29c7af | |||
| 813d6a38c9 | |||
| 23ae700194 | |||
| 49218a836b | |||
| 2fcf892324 | |||
| 8645cab79a | |||
| 70c95bf660 | |||
| d9f08ff719 | |||
| 2c91e41d10 | |||
| e3860036f8 | |||
| b3fc6a5950 | |||
| a858a066e4 | |||
| 10edd95d4d | |||
| 08e03f6a38 | |||
| a20ba0af27 | |||
| 6458dbd3aa | |||
| 43e55dfa85 | |||
| 430985c629 | |||
| 4635ef08d5 | |||
| 49c0c6d908 | |||
| a8beb3e620 | |||
| 934c1022e0 | |||
| 7248a8bd95 | |||
| b2db3233b2 | |||
| edb6d05ec1 | |||
| 315f464dfc | |||
| a029a2a754 | |||
| 945e111c4e | |||
| 2d8df22dd1 | |||
| 53601e68ed | |||
| 751787ec24 | |||
| f50122d9f8 | |||
| c71faf2065 | |||
| 2f5d57ce1d | |||
| a1c557603f | |||
| a2fab66aba | |||
| 2f1f7f4b8a | |||
| 667a536ae7 | |||
| ea55ab43ce | |||
| d3d468a8b4 | |||
| 6353598bd6 | |||
| 3c2d7facb3 | |||
| 6fffde4af8 | |||
| 9e04a397c4 | |||
| c54a9bf51e | |||
| 8f3396d6f0 | |||
| 1c2610c2c0 | |||
| 92025d65cb | |||
| a4b5a6840a | |||
| 2ebdfb539b | |||
| a37e6895e1 | |||
| 8ae542b3a3 | |||
| 174efb65f6 | |||
| 756149beaa | |||
| e5dddef204 | |||
| ade5d6a273 | |||
| c20ea4b916 | |||
| 2e17e365a1 | |||
| fcf914a2ab | |||
| 3697a51124 | |||
| 9194c5b52a | |||
| e82b1c9054 | |||
| 6f59cdd237 |
63
README.md
63
README.md
@@ -9,7 +9,7 @@ ConfigKit's simple `Load` accepts one strict YAML source (or explicit
|
||||
defaults-only input). `LoadLayered` additionally combines explicit lower-priority
|
||||
source groups with an independently selected main input. Sources can be host
|
||||
files, native `fs.FS` files, labeled bytes or readers, or snapshotted mappings.
|
||||
Enabled path and glob imports compose strictly within their group; precedence
|
||||
Enabled explicit imports compose strictly within their group; precedence
|
||||
between groups combines mappings recursively. Both entry points return a complete,
|
||||
validated configuration with detached metadata and safe diagnostics.
|
||||
Applications may perform audited defaulting and normalization through a resolver,
|
||||
@@ -17,10 +17,38 @@ then report domain errors and warnings through read-only semantic validation. Se
|
||||
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
|
||||
CLI override records apply after inheritance and typed decoding, before application
|
||||
resolution. Value explanation is available through
|
||||
detached structured records and deterministic redacted output. Built-in
|
||||
`extends` relationships let a mapping inherit from a named sibling during the
|
||||
same load, retaining explicit presence and source/derivation history.
|
||||
same load, retaining explicit presence and source/derivation history. The root
|
||||
`Diff` API compares frozen effective values from two successful same-type loads;
|
||||
the [configuration contract](docs/config.md#effective-configuration-diffs) and
|
||||
[consumer guide](docs/consumers/README.md#effective-configuration-diffs) describe
|
||||
its safe changed-region records and maintained workflows.
|
||||
|
||||
Sources can also be explicitly placed as sequence items. Configuration
|
||||
declarations and application registrations can give selected sequences scalar
|
||||
identities and choose how they combine across precedence and inheritance.
|
||||
Strict peers combine distinct records, while higher-priority collections can
|
||||
replace, append, or upsert whole records. See the
|
||||
[sequence contract](docs/config.md#strict-document-composition),
|
||||
[maintained catalog examples](docs/consumers/README.md#identified-prompt-sequences),
|
||||
and [ADR 0017](docs/adr/0017-collection-sequence-policy.md).
|
||||
|
||||
Imports are also explicit: a source may name files, search declared directories,
|
||||
or select files with path globs. Applications can set load-wide import defaults
|
||||
while keeping declaration-specific exceptions. Files are acquired in authored
|
||||
declaration order; dependency-ready composition uses a shared stable scheduler.
|
||||
ConfigKit does not search conventional application paths implicitly. See the
|
||||
[import contract](docs/config.md#explicit-imports), [consumer examples](docs/consumers/README.md),
|
||||
and [ADR 0015](docs/adr/0015-shared-dependency-ready-ordering.md) and
|
||||
[ADR 0016](docs/adr/0016-independent-import-controls.md).
|
||||
|
||||
Main-file discovery is explicit too: applications choose and order `Search`
|
||||
descriptions and pass `SearchConfig` to the existing load APIs. See the
|
||||
[discovery contract](docs/config.md#configuration-discovery), [consumer guide](docs/consumers/README.md#explicit-main-file-discovery),
|
||||
and [ADR 0019](docs/adr/0019-explicit-configuration-discovery.md).
|
||||
|
||||
```go
|
||||
type Config struct {
|
||||
@@ -56,20 +84,29 @@ mappings combine, while overlapping non-mapping definitions are errors. Between
|
||||
precedence tiers, mappings combine recursively, retaining omitted lower-priority
|
||||
children; other values replace lower-priority values. The main document and its
|
||||
imports form one strictly composed tier. Explicit underlay groups sit below it
|
||||
and above typed defaults; a selected overlay and explicit CLI overrides sit
|
||||
above it. The [configuration contract](docs/config.md#strict-document-composition)
|
||||
owns the detailed combination rules and input-specific constraints.
|
||||
and above typed defaults; a selected overlay applies before inheritance, while
|
||||
explicit CLI overrides apply after decoding and before application resolution.
|
||||
The [configuration contract](docs/config.md#strict-document-composition) owns
|
||||
the detailed combination rules and input-specific constraints. Declared or
|
||||
registered collections combine distinct identified records within strict tiers
|
||||
and use their selected operation at precedence boundaries; other sequences keep
|
||||
their strict-conflict and whole-value-replacement behavior.
|
||||
|
||||
The [architecture goals](docs/policy/architecture.md#success-criteria) explain
|
||||
these priorities and how genuine new needs can justify extending the shared
|
||||
model. The [deferred scope](docs/roadmap/deferred.md) records deliberate exclusions.
|
||||
model. The [future feature list](docs/roadmap/future.md) preserves candidates for
|
||||
consideration in future releases.
|
||||
|
||||
The [configuration contract](docs/config.md) describes implemented behavior. The
|
||||
[consumer adoption guide](docs/consumers/README.md) provides maintained simple
|
||||
native-filesystem, layered, and sibling-inheritance examples alongside the migration shapes.
|
||||
See the [development guide](docs/development.md) for repository orientation and the
|
||||
[release procedure](docs/release.md) for maintainer-only candidate and publication
|
||||
steps.
|
||||
The [configuration contract](docs/config.md) describes implemented behavior,
|
||||
including [input destinations](docs/config.md#input-destinations). The
|
||||
[consumer adoption guide](docs/consumers/README.md) provides maintained simple,
|
||||
layered, mounted-collection, and identified-prompt examples alongside the
|
||||
migration shapes. Input placement decisions are recorded in
|
||||
[ADR 0011](docs/adr/0011-input-destinations.md) and sequence placement and
|
||||
composition in [ADR 0014](docs/adr/0014-sequence-placement-and-composition.md).
|
||||
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 code is licensed under the [LGPL v3.0 or later](COPYRIGHT).
|
||||
|
||||
|
||||
@@ -43,10 +43,78 @@ func toEngineLocations(values []Location) []engine.Location {
|
||||
return result
|
||||
}
|
||||
func fromEngineSourceInfo(value engine.SourceInfo) SourceInfo {
|
||||
return SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Layer: Layer(value.Layer), Kind: SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex, ImportedFrom: fromEngineLocationPointer(value.ImportedFrom)}
|
||||
return SourceInfo{
|
||||
ID: value.ID, Label: value.Label, Path: value.Path, Destination: fromEnginePath(value.Destination),
|
||||
PlacementMode: PlacementMode(engine.SourceInfoPlacementModeForFacade(value)),
|
||||
DestinationUnresolved: engine.SourceInfoDestinationUnresolvedForFacade(value),
|
||||
Layer: Layer(value.Layer), Kind: SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex,
|
||||
ImportedFrom: fromEngineLocationPointer(value.ImportedFrom), VersionCheck: fromEngineSourceVersionCheck(value.VersionCheck),
|
||||
ImportSelections: fromEngineImportSelections(engine.SourceInfoImportSelectionsForFacade(value)),
|
||||
}
|
||||
}
|
||||
func toEngineSourceInfo(value SourceInfo) engine.SourceInfo {
|
||||
return engine.SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Layer: engine.Layer(value.Layer), Kind: engine.SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex, ImportedFrom: toEngineLocationPointer(value.ImportedFrom)}
|
||||
func fromEngineImportSelections(values []engine.ImportSelectionForFacade) []ImportSelection {
|
||||
if values == nil {
|
||||
return nil
|
||||
}
|
||||
result := make([]ImportSelection, len(values))
|
||||
for index, value := range values {
|
||||
result[index] = ImportSelection{
|
||||
Path: fromEnginePath(value.Path), Location: fromEngineLocation(value.Location),
|
||||
Selector: ImportSelector(value.Selector), Outcome: ImportSelectionOutcome(value.Outcome),
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func toEngineOverrides(values []Override) []engine.Override {
|
||||
if values == nil {
|
||||
return nil
|
||||
}
|
||||
result := make([]engine.Override, len(values))
|
||||
for index, value := range values {
|
||||
result[index] = engine.Override{
|
||||
Path: toEnginePath(value.Path), Value: value.Value, Flag: value.Flag,
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func toEngineImportDefaults(value ImportDefaults) engine.ImportDefaults {
|
||||
return engine.ImportDefaults{
|
||||
Required: cloneImportDefaultBool(value.Required),
|
||||
Follow: cloneImportDefaultBool(value.Follow),
|
||||
Hidden: cloneImportDefaultBool(value.Hidden),
|
||||
Recursive: cloneImportDefaultBool(value.Recursive),
|
||||
Pattern: cloneImportDefaultStrings(value.Pattern),
|
||||
}
|
||||
}
|
||||
|
||||
func cloneImportDefaultBool(value *bool) *bool {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := *value
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func cloneImportDefaultStrings(values []string) []string {
|
||||
if values == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := make([]string, len(values))
|
||||
copy(cloned, values)
|
||||
return cloned
|
||||
}
|
||||
|
||||
func fromEngineSourceVersionCheck(value *engine.SourceVersionCheck) *SourceVersionCheck {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return &SourceVersionCheck{
|
||||
Path: fromEnginePath(value.Path),
|
||||
Version: Version{Present: value.Version.Present, Null: value.Version.Null, Text: value.Version.Text},
|
||||
Location: fromEngineLocationPointer(value.Location), Redacted: value.Redacted,
|
||||
}
|
||||
}
|
||||
func fromEngineOrigin(value engine.Origin) Origin {
|
||||
result := Origin{Layer: Layer(value.Layer), Locations: fromEngineLocations(value.Locations), Operation: ChangeKind(value.Operation)}
|
||||
@@ -61,19 +129,6 @@ func fromEngineOrigin(value engine.Origin) Origin {
|
||||
}
|
||||
return result
|
||||
}
|
||||
func toEngineOrigin(value Origin) engine.Origin {
|
||||
result := engine.Origin{Layer: engine.Layer(value.Layer), Locations: toEngineLocations(value.Locations), Operation: engine.ChangeKind(value.Operation)}
|
||||
if value.Inheritance != nil {
|
||||
result.Inheritance = make([]engine.InheritanceStep, len(value.Inheritance))
|
||||
for index, step := range value.Inheritance {
|
||||
result.Inheritance[index] = engine.InheritanceStep{
|
||||
From: toEnginePath(step.From), To: toEnginePath(step.To), Reference: toEnginePath(step.Reference),
|
||||
Locations: toEngineLocations(step.Locations),
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
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)}
|
||||
}
|
||||
|
||||
53
diff.go
Normal file
53
diff.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package configkit
|
||||
|
||||
import "gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
|
||||
|
||||
// Difference describes one canonical changed region between two successful
|
||||
// loads. Left is the earlier value and Right is the later value. Each snapshot
|
||||
// is detached; Present distinguishes absence from an explicit null, and
|
||||
// Redacted reports whether combined sensitivity affected that side. Mappings
|
||||
// are compared recursively, while sequences are reported as atomic values.
|
||||
// Records are ordered by canonical JSON pointer. HiddenChanged indicates that
|
||||
// a value concealed by combined sensitivity changed, without revealing which
|
||||
// concealed member changed.
|
||||
type Difference struct {
|
||||
Path Path
|
||||
Left ValueSnapshot
|
||||
Right ValueSnapshot
|
||||
HiddenChanged bool
|
||||
}
|
||||
|
||||
// Diff compares the frozen effective values of two successful loads of the
|
||||
// same configuration type. It does not reread sources, environment variables,
|
||||
// or rerun defaults or resolvers. Equal values produce no records even when
|
||||
// their sources, defaults, or resolution histories differ. Credential contents
|
||||
// are not compared: typed secret references compare by reference name only.
|
||||
//
|
||||
// Both results must be non-nil successful loads with inspection state; nil or
|
||||
// zero results return an error and no records. Load errors are returned by
|
||||
// Load, so callers must handle those before calling Diff. Snapshots are safe
|
||||
// for presentation but are not a complete reloadable configuration.
|
||||
func Diff[T any](left, right *Result[T]) ([]Difference, error) {
|
||||
var leftInspection, rightInspection *engine.Inspection
|
||||
if left != nil {
|
||||
leftInspection = left.inspection
|
||||
}
|
||||
if right != nil {
|
||||
rightInspection = right.inspection
|
||||
}
|
||||
|
||||
values, err := engine.Diff(leftInspection, rightInspection)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
differences := make([]Difference, len(values))
|
||||
for index, value := range values {
|
||||
differences[index] = Difference{
|
||||
Path: fromEnginePath(value.Path),
|
||||
Left: fromEngineValueSnapshot(value.Left),
|
||||
Right: fromEngineValueSnapshot(value.Right),
|
||||
HiddenChanged: value.HiddenChanged,
|
||||
}
|
||||
}
|
||||
return differences, nil
|
||||
}
|
||||
90
discovery.go
Normal file
90
discovery.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package configkit
|
||||
|
||||
import "gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
|
||||
|
||||
// Search is an immutable ordered description of possible host-file inputs.
|
||||
// It is consumed only when passed to SearchConfig and used by a load.
|
||||
type Search struct{ value engine.Search }
|
||||
|
||||
// FilenameGroup selects filenames in order for a directory search.
|
||||
// Construct groups with NamedFilenames, GenericFilenames, or CustomFilenames.
|
||||
type FilenameGroup struct{ value engine.FilenameGroup }
|
||||
|
||||
// SearchOptions controls whether a search must select a file.
|
||||
type SearchOptions struct {
|
||||
// Required reports a missing-source error if the complete search is exhausted.
|
||||
Required bool
|
||||
}
|
||||
|
||||
// DirectorySearchOptions selects the filename groups used by SearchDirectory.
|
||||
type DirectorySearchOptions struct {
|
||||
// Filenames concatenates groups in order. A nil slice uses Named then Generic;
|
||||
// a non-nil empty slice selects no filenames.
|
||||
Filenames []FilenameGroup
|
||||
}
|
||||
|
||||
// NamedFilenames selects <application>.yml followed by <application>.yaml.
|
||||
func NamedFilenames() FilenameGroup { return FilenameGroup{value: engine.NamedFilenames()} }
|
||||
|
||||
// GenericFilenames selects config.yml, config.yaml, main.yml, and main.yaml.
|
||||
func GenericFilenames() FilenameGroup { return FilenameGroup{value: engine.GenericFilenames()} }
|
||||
|
||||
// CustomFilenames selects literal filename components in the supplied order.
|
||||
// Names cannot be empty or contain a directory component.
|
||||
func CustomFilenames(names ...string) FilenameGroup {
|
||||
return FilenameGroup{value: engine.CustomFilenames(names...)}
|
||||
}
|
||||
|
||||
// SearchDirectory describes a search within directory. Omitted options or a nil
|
||||
// Filenames slice uses Named then Generic groups. Explicitly empty Filenames
|
||||
// selects no names and does not resolve the directory base.
|
||||
func SearchDirectory(directory string, supplied ...DirectorySearchOptions) Search {
|
||||
options := make([]engine.DirectorySearchOptions, len(supplied))
|
||||
for index, option := range supplied {
|
||||
if option.Filenames == nil {
|
||||
continue
|
||||
}
|
||||
options[index].Filenames = make([]engine.FilenameGroup, len(option.Filenames))
|
||||
for groupIndex, group := range option.Filenames {
|
||||
options[index].Filenames[groupIndex] = group.value
|
||||
}
|
||||
}
|
||||
return Search{value: engine.SearchDirectory(directory, options...)}
|
||||
}
|
||||
|
||||
// Paths describes literal host-file candidates in the supplied order.
|
||||
func Paths(paths ...string) Search { return Search{value: engine.Paths(paths...)} }
|
||||
|
||||
// Searches concatenates descriptions in order and snapshots their entries.
|
||||
func Searches(searches ...Search) Search {
|
||||
values := make([]engine.Search, len(searches))
|
||||
for index, search := range searches {
|
||||
values[index] = search.value
|
||||
}
|
||||
return Search{value: engine.Searches(values...)}
|
||||
}
|
||||
|
||||
// UnixSystemDirectories describes the conventional Unix system directories.
|
||||
// Loading on an unsupported platform fails if this preset is reached.
|
||||
func UnixSystemDirectories() Search { return Search{value: engine.UnixSystemDirectories()} }
|
||||
|
||||
// UserConfigDirectory describes searches rooted at the Go user-config directory.
|
||||
func UserConfigDirectory() Search { return Search{value: engine.UserConfigDirectory()} }
|
||||
|
||||
// HomeDirectory describes searches rooted at the Go home directory.
|
||||
func HomeDirectory() Search { return Search{value: engine.HomeDirectory()} }
|
||||
|
||||
// WorkingDirectory describes application-named files in the current directory.
|
||||
func WorkingDirectory() Search { return Search{value: engine.WorkingDirectory()} }
|
||||
|
||||
// SearchConfig creates a source that selects the first matching host file when
|
||||
// consumed by Load or LoadLayered. Without Required, exhausting the search uses
|
||||
// the absent-input lifecycle. Description and option errors are reported while
|
||||
// loading.
|
||||
func SearchConfig(application string, search Search, supplied ...SearchOptions) Source {
|
||||
options := make([]engine.SearchOptions, len(supplied))
|
||||
for index, option := range supplied {
|
||||
options[index] = engine.SearchOptions{Required: option.Required}
|
||||
}
|
||||
return Source{value: engine.SearchConfig(application, search.value, options...)}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
# ADR 0005: Record explicit CLI overrides and safe value explanation
|
||||
|
||||
Status: accepted
|
||||
Status: Partially superseded by
|
||||
[ADR 0018](0018-cli-after-structural-resolution.md) for override-before-decoding
|
||||
ordering. The remaining ownership and safe explanation decisions remain accepted.
|
||||
|
||||
Date: 2026-09-06
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
## Status
|
||||
|
||||
Proposed draft for review. Completed planning and remediation records are
|
||||
Proposed draft for review; its whole-sequence precedence replacement rule is
|
||||
superseded by [ADR 0017](0017-collection-sequence-policy.md). The remainder
|
||||
retains its proposed status. Completed planning and remediation records are
|
||||
preserved in Git history at `b604aa1` under `docs/roadmap/`; the
|
||||
[configuration contract](../config.md#layered-loading) owns current behavior.
|
||||
|
||||
|
||||
@@ -2,8 +2,14 @@
|
||||
|
||||
## Status
|
||||
|
||||
Accepted and implemented. The [configuration contract](../config.md#sibling-inheritance)
|
||||
owns current behavior. The completed implementation plan is preserved in Git
|
||||
Partially superseded by [ADR 0017](0017-collection-sequence-policy.md) for
|
||||
sequence combination and the addition of identified-sequence sibling scopes,
|
||||
and [ADR 0018](0018-cli-after-structural-resolution.md) for CLI-before-inheritance
|
||||
ordering. Existing mapping-sibling scopes and the remaining ownership and
|
||||
inspection decisions remain accepted.
|
||||
|
||||
The [configuration contract](../config.md#sibling-inheritance) owns implemented
|
||||
behavior. The completed implementation plan is preserved in Git
|
||||
history at `83c59ea`.
|
||||
|
||||
## Date
|
||||
|
||||
99
docs/adr/0010-effective-configuration-diff.md
Normal file
99
docs/adr/0010-effective-configuration-diff.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# ADR 0010: Compare Frozen Effective Configuration Snapshots
|
||||
|
||||
## Status
|
||||
|
||||
Accepted and implemented. The
|
||||
[configuration contract](../config.md#effective-configuration-diffs) owns current
|
||||
behavior. Completed planning and remediation records are preserved in Git
|
||||
history at `1c2610c` under `docs/roadmap/`.
|
||||
|
||||
## Date
|
||||
|
||||
2026-10-09
|
||||
|
||||
## Context
|
||||
|
||||
Applications need to inspect how two completed configurations differ, including
|
||||
the effective impact of an overlay after defaults and application resolution.
|
||||
ConfigKit already retains canonical typed inspection snapshots, semantic paths,
|
||||
presence information, and sensitivity classification for successful loads.
|
||||
|
||||
Extending this inspection model lets applications share configuration comparison
|
||||
and safe disclosure while keeping their presentation and domain interpretation.
|
||||
The operation should follow ConfigKit's existing equality, ownership, and secret
|
||||
boundaries so consumers can reason about loading and inspection consistently.
|
||||
|
||||
## Decision
|
||||
|
||||
Provide structured effective-value comparison between successful results of the
|
||||
same configuration type. Compare their retained canonical inspection snapshots
|
||||
through the root-owned public facade. Each comparison uses the completed load
|
||||
state and returns detached records with a semantic path and safe left and right
|
||||
value snapshots.
|
||||
|
||||
Use canonical value equality and the existing structural comparison rules.
|
||||
Mappings compare recursively, sequences report atomic changes, and scalar or
|
||||
kind replacements report the affected region. Presence distinguishes additions
|
||||
and removals from explicit null. Equal effective values produce an empty diff;
|
||||
source attribution, explicit-input status, and history remain separately
|
||||
inspectable through each result. Canonical pointer ordering makes reporting
|
||||
deterministic.
|
||||
|
||||
Detect changes before masking values. Apply sensitivity from both loads to both
|
||||
returned sides, including historical and dependency-derived classification.
|
||||
Preserve public content where the shapes permit corresponding sensitive regions
|
||||
to be identified. When a structural replacement prevents safe correspondence,
|
||||
mask the complete reported replacement region on both present sides. Absence
|
||||
retains its own representation.
|
||||
|
||||
Keep masking and hidden-change information distinct. Each record indicates
|
||||
whether an actual change is withheld by redaction, allowing applications to
|
||||
display a changed-sensitive-value indicator even when safe snapshots render
|
||||
identically. Public-only changes alongside unchanged masked content retain that
|
||||
distinction.
|
||||
|
||||
Typed secret references participate through their configured environment names.
|
||||
Resolved credential bytes remain behind the existing explicit secret accessor.
|
||||
The comparison uses the same frozen inspection boundary as effective rendering
|
||||
and explanation.
|
||||
|
||||
ConfigKit owns comparison and paired safe disclosure. Applications own labels,
|
||||
layout, and rendering of the returned changed regions. Reusing the existing
|
||||
snapshot representation and engine boundary keeps the extension consistent with
|
||||
the library's established inspection model.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Compare input text or separately rendered YAML.** Supports familiar textual
|
||||
displays, but input text precedes composition and resolution, and independently
|
||||
redacted output can hide changes or apply different disclosure rules. Canonical
|
||||
comparison with paired masking represents the completed configuration.
|
||||
- **Leave comparison to applications using returned Go values.** Offers direct
|
||||
access to application types, but duplicates structural and redaction machinery
|
||||
and observes caller mutation. Retained inspection snapshots preserve the
|
||||
completed load's values and established ownership boundary.
|
||||
- **Align sequence elements and return context-rich rendering data.** Enables
|
||||
finer-grained displays, but requires additional correspondence and disclosure
|
||||
rules. Atomic sequence records and existing value snapshots satisfy the
|
||||
selected changed-region workflows with a smaller public model.
|
||||
- **Compare resolved credentials or retain credential-derived tokens.** Could
|
||||
detect rotation behind an unchanged reference, but expands retained sensitive
|
||||
state and introduces a separate trusted comparison mechanism. Reference-name
|
||||
comparison preserves the existing inspection boundary.
|
||||
|
||||
## Consequences
|
||||
|
||||
Applications can compare independently loaded configurations using ConfigKit's
|
||||
shared equality, presence, and disclosure rules. Existing composition and load
|
||||
semantics continue to determine the values being compared. Caller mutation of a
|
||||
returned configuration leaves its retained comparison snapshot unchanged.
|
||||
|
||||
Paired redaction can conceal public content within a structurally incompatible
|
||||
replacement. The changed region and hidden-change indicator still communicate
|
||||
its effective impact. Credential rotation behind an unchanged environment name
|
||||
produces equal reference values for comparison.
|
||||
|
||||
The implementation requires operation-local disclosure preparation and focused
|
||||
coverage for presence, shape changes, historical sensitivity, and detached
|
||||
results. Applications retain control of presentation; changed-region records
|
||||
supply the safe data needed for that responsibility.
|
||||
74
docs/adr/0011-input-destinations.md
Normal file
74
docs/adr/0011-input-destinations.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# ADR 0011: Place Configuration Inputs at Mapping Destinations
|
||||
|
||||
## Status
|
||||
|
||||
Accepted and implemented. Current placement behavior is owned by the
|
||||
[configuration contract](../config.md#input-destinations). The accepted
|
||||
relative-import extension is recorded in
|
||||
[ADR 0012](0012-relative-import-destinations.md). The completed roadmap, consumer
|
||||
request, and review records are preserved at `e4270c9` under `docs/roadmap/` in
|
||||
Git history; the original input-destination implementation plan remains at
|
||||
`315f464:docs/roadmap/implementation.md`.
|
||||
|
||||
## Date
|
||||
|
||||
2026-10-14
|
||||
|
||||
## Context
|
||||
|
||||
Consumers assembling collections of independently authored configuration
|
||||
fragments need each source to contribute beneath a selected application path.
|
||||
Requiring consumers to rewrite source documents or merge completed values would
|
||||
separate placement from ConfigKit's existing composition, precedence, source
|
||||
identity, and inspection behavior. Placement should remain part of the ordinary
|
||||
loading lifecycle and preserve the root-owned public facade.
|
||||
|
||||
## Decision
|
||||
|
||||
Add an optional `Destination Path` to root-owned `Input` and expose the effective
|
||||
destination on existing `SourceInfo` records. The empty path means the existing
|
||||
application root. A non-empty destination is a sequence of literal mapping keys;
|
||||
explicit sequence-index segments are invalid. Existing reserved structural keys
|
||||
remain reserved, while keys that merely contain their spelling remain ordinary
|
||||
keys.
|
||||
|
||||
Acquire and interpret each source using its original physical identity and
|
||||
source-local directives, then place its application body before strict
|
||||
composition and the existing final lifecycle. Imported application bodies
|
||||
inherit the input destination. Placement applies to main and underlay inputs;
|
||||
ordinary root loading, selected overlays, and CLI inputs retain root placement.
|
||||
The main-owned version rule continues to check its effective application path
|
||||
at its existing lifecycle point.
|
||||
|
||||
Placement preserves authored node locations and source identity. Introduced
|
||||
mapping structure belongs to the contributing source and uses that document's
|
||||
root location as its source-coordinate fallback. A present empty mapping
|
||||
materializes its destination; an absent `NoSource` contributes nothing. The
|
||||
result continues through the existing strict composition, overlay, inheritance,
|
||||
typed loading, diagnostics, provenance, history, sensitivity, and resource
|
||||
context mechanisms.
|
||||
|
||||
The added exported fields preserve keyed and zero-value struct use. Callers
|
||||
using unkeyed composite literals for `Input` or `SourceInfo` must migrate to
|
||||
keyed literals. Treat this feature as minor-release work; select the version and
|
||||
document the compatibility impact during separately authorized release
|
||||
preparation, consistent with the release policy.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Rewrite or merge sources in consumers.** This makes each consumer reproduce
|
||||
placement, strict composition, source attribution, and inspection semantics.
|
||||
- **Add a separate loading entry point or public wrapper type.** This creates a
|
||||
parallel API for a behavior that belongs to existing per-input loading.
|
||||
- **Load each fragment as a complete application before placement.** This runs
|
||||
defaults, decoding, resolution, and validation on incomplete fragments rather
|
||||
than once on the assembled application.
|
||||
|
||||
## Consequences
|
||||
|
||||
Consumers can assemble contributions at effective mapping paths in one ordinary
|
||||
layered load and inspect each source's placement through existing source
|
||||
records. Paths, conflicts, and later application operations use the placed
|
||||
structure while source coordinates and relative-resource contexts continue to
|
||||
identify their original sources. Public field additions require release-note
|
||||
guidance for callers using unkeyed literals.
|
||||
78
docs/adr/0012-relative-import-destinations.md
Normal file
78
docs/adr/0012-relative-import-destinations.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# ADR 0012: Place Imported Configuration at Relative Destinations
|
||||
|
||||
## Status
|
||||
|
||||
Accepted and implemented. The
|
||||
[configuration contract](../config.md#input-destinations) owns current behavior.
|
||||
The completed roadmap and review records are preserved at `e4270c9` under
|
||||
`docs/roadmap/` in Git history; the relative-import implementation plan remains
|
||||
at `d9f08ff:docs/roadmap/implementation.md`.
|
||||
|
||||
## Date
|
||||
|
||||
2026-10-14
|
||||
|
||||
## Context
|
||||
|
||||
ConfigKit can place an input document at a literal mapping path while retaining
|
||||
source identity, composition, and inspection in its normal loading lifecycle.
|
||||
An importing document can currently bring in other documents, but those
|
||||
contributions inherit only the importing input's destination. Consumers that
|
||||
assemble a collection therefore cannot declare the layout of imported
|
||||
fragments alongside the import itself without rewriting source documents or
|
||||
performing another merge outside ConfigKit.
|
||||
|
||||
The extension should reuse input placement and the existing import lifecycle.
|
||||
Logical application placement must remain independent from physical path
|
||||
resolution, source identity, precedence, and resource ownership.
|
||||
|
||||
## Decision
|
||||
|
||||
Add an optional `destination` field to each YAML import declaration. Its value
|
||||
is a sequence of strings representing literal mapping-key segments. Omission
|
||||
and an empty sequence add no segments. A non-empty destination appends to the
|
||||
effective destination of the immediately importing document; nested imports
|
||||
continue from the resulting child destination. Every match of one glob
|
||||
declaration receives the same computed destination.
|
||||
|
||||
Use the existing input-destination validation and reserved-key rules. Do not
|
||||
interpret destination segments as filesystem paths, separators, sequence
|
||||
indices, or root-reset instructions. Physical import paths continue to resolve
|
||||
from their declaring source under existing containment rules.
|
||||
|
||||
Imports remain in their existing strict-composition group. A destination does
|
||||
not grant precedence, bypass cycle or repeated-source rules, or change source
|
||||
identity. Existing placement, version ownership, source records, diagnostics,
|
||||
resource contexts, inheritance, history, and inspection mechanisms continue to
|
||||
govern the placed contributions. No public Go entry point or independent
|
||||
specification-header behavior is introduced.
|
||||
|
||||
This is additive YAML functionality and is minor-release work. Version
|
||||
selection, release notes, roadmap retirement, and publication remain separate
|
||||
release-preparation work.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
- **Require complete application-root destinations on every import.** This
|
||||
avoids inherited context but repeats collection prefixes in nested source
|
||||
declarations and does not express placement relative to the importing
|
||||
document.
|
||||
- **Encode destinations as dotted or slash-delimited strings.** This introduces
|
||||
separator and escaping rules even though existing paths already represent
|
||||
literal mapping keys as segments.
|
||||
- **Rewrite or merge imported documents in consumers.** This duplicates source
|
||||
placement, strict composition, attribution, and inspection behavior outside
|
||||
ConfigKit's loading lifecycle.
|
||||
- **Add independent specification headers or collection-specific validation.**
|
||||
These are separate design questions and are not required to declare relative
|
||||
placement.
|
||||
|
||||
## Consequences
|
||||
|
||||
Import declarations can describe the logical placement of their contributions
|
||||
while preserving the physical source and existing composition lifecycle.
|
||||
Placement is immediate-parent-relative, so nested imports accumulate their
|
||||
declared segments and sibling declarations remain independent. The feature is
|
||||
implemented behind the existing public loading surface; detailed validation,
|
||||
diagnostic, accounting, and inspection requirements are tracked by the
|
||||
implementation roadmap.
|
||||
92
docs/adr/0013-shared-source-version-checks.md
Normal file
92
docs/adr/0013-shared-source-version-checks.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# ADR 0013: Check Source Versions with One Shared Policy
|
||||
|
||||
## Status
|
||||
|
||||
Accepted and implemented. The
|
||||
[configuration contract](../config.md#shared-source-version-checks) owns current
|
||||
behavior. The completed roadmap, implementation plan, consumer request, and
|
||||
resolved review findings are preserved at `e4270c9` under `docs/roadmap/` in
|
||||
Git history; the [development guide](../development.md#baseline-validation)
|
||||
provides retrieval examples.
|
||||
|
||||
## Date
|
||||
|
||||
2026-10-15
|
||||
|
||||
## Context
|
||||
|
||||
Applications that assemble one configuration from independently authored
|
||||
documents need a way to require that each contributing document declares a
|
||||
version accepted by the application's existing `Definition.Version` checker.
|
||||
Checking only the eventual composed value lets another source hide a rejected
|
||||
declaration, while separate consumer-side parsing duplicates ConfigKit's source
|
||||
handling and diagnostics.
|
||||
|
||||
The extension must preserve one application-owned acceptance policy and the
|
||||
existing loading lifecycle. Source declarations are compatibility evidence, not
|
||||
independent fragment schemas or values to compare for equality. Existing
|
||||
composition, placement, precedence, source identity, diagnostics, and
|
||||
sensitivity rules remain authoritative.
|
||||
|
||||
## Decision
|
||||
|
||||
Provide a load-wide source-version mode for ordinary and layered loading. The
|
||||
zero-value mode preserves current behavior. The enabled modes check declarations
|
||||
when present or require them, using the existing `Definition.Version` path and
|
||||
checker. Interpret the configured path relative to each participating source
|
||||
before placement or composition. Different declarations may be accepted by the
|
||||
same checker; ConfigKit adds no equality, SemVer, or expected-version rule.
|
||||
|
||||
Apply the selected policy to the actual main document, direct underlays,
|
||||
imports, and selected overlays. Missing non-main declarations are skipped in
|
||||
if-present mode and rejected in required mode. Existing main absence behavior
|
||||
remains, with required mode additionally rejecting an absent declaration on an
|
||||
actual main document. `NoSource`, unmatched optional inputs, synthetic field
|
||||
operations, and empty groups do not become documents that need declarations.
|
||||
|
||||
Reuse the existing version vocabulary and validation boundary: absence, null,
|
||||
exact strings, and strict decimal integers retain their established meanings;
|
||||
unsupported present values fail version-type validation. Warnings remain
|
||||
nonfatal, and source declarations do not replace final application decoding or
|
||||
domain validation.
|
||||
|
||||
After an accepted non-main declaration is checked, consume exactly that node
|
||||
before its remaining application content is composed or overlaid. Preserve the
|
||||
main declaration and its value semantics. Keep the declaration, source-local
|
||||
path, physical location, and checked absence as detached source evidence,
|
||||
separate from effective application values and history. Apply established
|
||||
sensitivity rules to that evidence, including original mounted paths and
|
||||
ancestors; retain existing safe failure disclosure.
|
||||
|
||||
In enabled modes, the main declaration remains source-local while its protected
|
||||
application path is derived from the main input's destination. This distinction
|
||||
does not change default-mode path interpretation or weaken protection of
|
||||
remaining payload and synthetic operations.
|
||||
|
||||
Public option-field additions and the enabled-mode interpretation for mounted
|
||||
main inputs are compatibility considerations for separately authorized minor-
|
||||
release preparation. This decision does not select a release version or
|
||||
authorize release notes or publication.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
- **Check only the final composed value.** Later sources can hide an earlier
|
||||
rejected declaration, so this does not validate every contributing document.
|
||||
- **Let consumers parse and check each source.** This duplicates acquisition,
|
||||
source attribution, safe diagnostics, and lifecycle coordination in every
|
||||
application.
|
||||
- **Require declarations to be equal or impose a universal version grammar.**
|
||||
Consumers already own the acceptance policy; ConfigKit need not invent an
|
||||
expected value or a domain-specific compatibility rule.
|
||||
- **Keep accepted non-main declarations in application payload.** A selected
|
||||
source-local declaration is metadata for this policy and cannot also remain
|
||||
an ordinary field at the same path without ambiguity.
|
||||
|
||||
## Consequences
|
||||
|
||||
Consumers can apply one existing version policy across contributing documents
|
||||
and select gradual or required adoption per load. Accepted non-main headers no
|
||||
longer participate in application composition; their inspectable evidence is
|
||||
kept separate and subject to sensitivity classification. Default callers retain
|
||||
current behavior. The implementation plan records the concrete API names,
|
||||
integration order, and validation evidence.
|
||||
81
docs/adr/0014-sequence-placement-and-composition.md
Normal file
81
docs/adr/0014-sequence-placement-and-composition.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# ADR 0014: Place and Compose Sequence Items Explicitly
|
||||
|
||||
## Status
|
||||
|
||||
Partially superseded by [ADR 0017](0017-collection-sequence-policy.md) for
|
||||
identity ownership and sequence combination. Explicit source placement remains
|
||||
accepted.
|
||||
|
||||
The [configuration contract](../config.md#strict-document-composition)
|
||||
defines implemented behavior, and the [consumer guide](../consumers/README.md#identified-prompt-sequences)
|
||||
shows the maintained example. The completed roadmap, implementation plan, and
|
||||
resolved remediation record are preserved in Git history at `008d2ba` under
|
||||
`docs/roadmap/`; see the [development guide](../development.md) for retrieval
|
||||
commands.
|
||||
|
||||
## Date
|
||||
|
||||
2026-10-16
|
||||
|
||||
## Context
|
||||
|
||||
ConfigKit strictly composes independent documents by recursively combining
|
||||
mappings and rejecting other overlapping values. This makes two independently
|
||||
authored sequence fragments conflict, even when their records are distinct.
|
||||
Precedence assembly has a different purpose: a higher-priority present sequence
|
||||
replaces the lower-priority sequence as one value. Those existing rules are
|
||||
predictable, but they do not express the separate operation of collecting
|
||||
independent records while rejecting duplicate identities.
|
||||
|
||||
Likewise, mapping destinations place a source beneath literal mapping keys, but
|
||||
there is no explicit way to state that a source mapping represents one item in a
|
||||
sequence. Inferring that intent from document shape or fields would make source
|
||||
meaning implicit and change existing placement behavior.
|
||||
|
||||
## Decision
|
||||
|
||||
Keep mapping placement as the zero-value behavior and add an explicit item
|
||||
placement choice for a source whose mapping should become one element at a
|
||||
non-root sequence destination. Item placement provides structure only; it does
|
||||
not infer or synthesize identity values.
|
||||
|
||||
Allow a definition to register literal mapping paths and one literal identity
|
||||
field for sequences at those paths. Within each strict composition tier, a
|
||||
registered sequence accepts mapping records with a present string identity,
|
||||
appends distinct records in contribution order, and rejects repeated exact
|
||||
identity strings. Identical registrations coalesce; conflicting identity fields
|
||||
for the same path are invalid. Registration does not recursively select paths
|
||||
inside sequence records.
|
||||
|
||||
Keep precedence assembly unchanged. A present higher-priority sequence replaces
|
||||
the lower-priority value wholesale, including at a registered path. Registration
|
||||
changes strict composition only; it does not turn precedence into append or
|
||||
identity-based merging.
|
||||
|
||||
This decision preserves ConfigKit's distinction between independent
|
||||
same-priority contributions and intentional precedence. Explicit source
|
||||
placement and definition-owned composition policy make the two required choices
|
||||
visible while keeping the existing zero-value load behavior.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
- **Infer item placement from a source mapping or identity field.** Shape-based
|
||||
inference would make ordinary mapping placement depend on document contents
|
||||
and would leave the intended destination ambiguous.
|
||||
- **Append all overlapping sequences during strict composition.** This would
|
||||
change existing conflict semantics at unregistered paths and would not detect
|
||||
duplicate records.
|
||||
- **Merge matching records recursively by identity.** This would add a second
|
||||
recursive merge rule with different conflict and precedence behavior from
|
||||
ConfigKit's existing mapping composition and overlay operations.
|
||||
- **Use identity to merge sequences across precedence tiers.** This would
|
||||
change established whole-value replacement semantics and conflate strict
|
||||
composition with precedence.
|
||||
|
||||
## Consequences
|
||||
|
||||
Consumers can explicitly place mappings as sequence items and opt selected
|
||||
sequences into duplicate-rejecting strict composition. Unregistered sequences
|
||||
retain their current strict-conflict behavior, and higher-priority sequences
|
||||
continue to replace lower-priority ones. The implementation plan owns the exact
|
||||
API, YAML syntax, diagnostics, and implementation status.
|
||||
68
docs/adr/0015-shared-dependency-ready-ordering.md
Normal file
68
docs/adr/0015-shared-dependency-ready-ordering.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# ADR 0015: Share Dependency-Ready Ordering Across Imports and Inheritance
|
||||
|
||||
## Status
|
||||
|
||||
Accepted. Implemented behavior is described in the
|
||||
[configuration contract](../config.md#explicit-imports) and
|
||||
[internal import overview](../internal/imports.md).
|
||||
|
||||
## Date
|
||||
|
||||
2026-10-19
|
||||
|
||||
## Context
|
||||
|
||||
Imports and sibling inheritance both resolve relationships between configuration
|
||||
contributions. Their existing resolvers use different traversal rules, making
|
||||
ordering depend on which mechanism expresses a dependency. Ordering affects
|
||||
sequence contributions, provenance, and diagnostics, so a consistent rule is
|
||||
part of ConfigKit's configuration model.
|
||||
|
||||
The resolvers also serve distinct purposes. Imports strictly compose sources;
|
||||
inheritance overlays a child on its resolved parent after source assembly.
|
||||
Item-local contributions and nested inheritance introduce scope boundaries that
|
||||
resolution must preserve.
|
||||
|
||||
## Decision
|
||||
|
||||
ConfigKit owns one shared dependency-ready scheduler for imports and sibling
|
||||
inheritance. Both resolvers use Kahn's algorithm with a stable ordering key,
|
||||
selecting the smallest currently eligible node after its dependencies have
|
||||
completed. The shared scheduler owns eligibility, deterministic tie-breaking,
|
||||
and dependency-cycle identification. Each resolver owns construction of its
|
||||
dependency relationships and application of its domain operations.
|
||||
|
||||
Apply the common scheduling rule within the scopes where dependencies are
|
||||
available. Complete item-local contributions before item placement and resolve
|
||||
enclosing inheritance before relationships in the resulting nested scopes.
|
||||
Source discovery supplies the import graph; the scheduler determines resolution
|
||||
order.
|
||||
|
||||
Strict composition, inheritance overlay, and precedence tiers retain their
|
||||
existing ownership and meanings under [ADR 0001](0001-separate-composition-and-overlay.md).
|
||||
The lifecycle and scope decisions in
|
||||
[ADR 0009](0009-sibling-inheritance.md) continue to govern inheritance. Import
|
||||
admission retains its cycle and repeated-source rules.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
- **Deterministic depth-first postorder.** Keeps dependency branches together,
|
||||
but gives traversal branch selection an additional role in ordering.
|
||||
- **Dependency rounds.** Groups nodes by readiness rounds, but delays newly
|
||||
eligible nodes until the current round finishes.
|
||||
- **Descending longest-path depth.** Prioritizes the furthest dependencies,
|
||||
but adds distance from the root as an ordering policy.
|
||||
- **Separate resolver algorithms.** Allows local specialization, but leaves
|
||||
consumers and maintainers with different rules for analogous dependencies.
|
||||
|
||||
## Consequences
|
||||
|
||||
Both resolvers follow one deterministic ordering model and share responsibility
|
||||
for maintaining that model. Resolver-specific scopes, combination semantics,
|
||||
and source-admission rules remain explicit at their integration boundaries.
|
||||
|
||||
Independent branches may interleave as nodes become eligible. Adopting this
|
||||
rule changes existing traversal-dependent sequence positions and reporting
|
||||
order, requiring coordinated review of source assembly, provenance, and
|
||||
inheritance behavior. The configuration contract owns the implemented ordering
|
||||
details.
|
||||
83
docs/adr/0016-independent-import-controls.md
Normal file
83
docs/adr/0016-independent-import-controls.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# ADR 0016: Separate Import Selection, Traversal, and Presence Requirements
|
||||
|
||||
## Status
|
||||
|
||||
Accepted. Implemented behavior is described in the
|
||||
[configuration contract](../config.md#explicit-imports), the
|
||||
[public API declarations](../../lifecycle.go), and the
|
||||
[internal import overview](../internal/imports.md).
|
||||
|
||||
## Date
|
||||
|
||||
2026-10-19
|
||||
|
||||
## Context
|
||||
|
||||
Consumers assemble configurations from named files and collections whose layout
|
||||
varies between installations. Simple imports should require little setup, while
|
||||
larger collections need control over discovery, recursion, filtering, and
|
||||
required contributions. Combining those choices implicitly makes declarations
|
||||
harder to interpret and pushes source-selection work into applications.
|
||||
|
||||
Discovery can also reach a candidate through overlapping searches or encounter
|
||||
different names for one canonical source. Selection and source admission need
|
||||
distinct identities to support useful search unions while preserving ConfigKit's
|
||||
existing source-integrity rules.
|
||||
|
||||
## Decision
|
||||
|
||||
ConfigKit owns import discovery and selection within its source-loading
|
||||
lifecycle. Support explicit files, directory searches, and path globs as
|
||||
distinct selection forms. Keep traversal, filename filtering, and presence
|
||||
requirements independently expressible. Discovery determines the selected
|
||||
contributions; the shared scheduler in
|
||||
[ADR 0015](0015-shared-dependency-ready-ordering.md) determines their resolution
|
||||
order.
|
||||
|
||||
Use optional-by-default imports with explicit requiredness controls. Selected
|
||||
sources remain subject to ConfigKit's validity, containment, resource, and
|
||||
composition contracts. This applies the library's
|
||||
[simplicity and defaults principles](../policy/architecture.md#simplicity-defaults-and-explicit-controls)
|
||||
to import absence while retaining meaningful errors for invalid contributions.
|
||||
|
||||
Applications own their selected import defaults. Authored declaration values
|
||||
take precedence over application defaults, which take precedence over library
|
||||
defaults. Each declaration resolves applicable controls independently,
|
||||
including declarations in imported sources. This gives applications a shared
|
||||
baseline while preserving explicit choices in individual declarations.
|
||||
|
||||
Keep declaration-local candidate identity separate from canonical source
|
||||
identity. Search unions coalesce repeated candidate paths within a source
|
||||
namespace before symlink resolution; filename filtering uses the selected or
|
||||
discovered name. Source admission continues to use canonical identity and the
|
||||
existing repeated-source rules. Distinct aliases retain their separate
|
||||
inclusion routes.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
- **Use path globs for all discovery.** Offers one selection form, but makes
|
||||
collection traversal depend on path-pattern structure and complicates
|
||||
independently controlling recursion and filename filtering.
|
||||
- **Offer only explicit files and directory searches.** Reduces the selector
|
||||
vocabulary, but moves wildcard directory selection into repeated declarations
|
||||
or application-side discovery.
|
||||
- **Require every import by default.** Detects absent contributions immediately,
|
||||
but requires repeated opt-outs for installations with optional components.
|
||||
- **Make application settings override authored declarations.** Provides central
|
||||
enforcement, but prevents declarations from expressing local exceptions.
|
||||
- **Deduplicate selections globally by canonical source identity.** Coalesces
|
||||
aliases and separate inclusion routes, but changes the existing repeated-source
|
||||
contract and obscures independently authored contributions.
|
||||
|
||||
## Consequences
|
||||
|
||||
Consumers can express simple imports and more selective collection searches
|
||||
through the same loading lifecycle. Applications choose defaults and validate
|
||||
their required effective configuration; ConfigKit owns consistent selection,
|
||||
source admission, composition, and inspection.
|
||||
|
||||
Optional absence can conceal misspelled selectors, so requiredness and
|
||||
inspection provide explicit ways to detect and explain missing contributions.
|
||||
Declaration-local deduplication requires the loader to preserve candidate
|
||||
identity alongside canonical source identity. The configuration and public API
|
||||
contracts own the implemented behavior.
|
||||
143
docs/adr/0017-collection-sequence-policy.md
Normal file
143
docs/adr/0017-collection-sequence-policy.md
Normal file
@@ -0,0 +1,143 @@
|
||||
# ADR 0017: Define Identity and Merge Rules for Each Collection
|
||||
|
||||
## Status
|
||||
|
||||
Accepted.
|
||||
|
||||
This supersedes the decisions about who declares identity and how
|
||||
sequences combine in [ADR 0014](0014-sequence-placement-and-composition.md),
|
||||
and the rule that inheritance always replaces sequences in
|
||||
[ADR 0009](0009-sibling-inheritance.md). It also extends that ADR's sibling
|
||||
scopes to identified sequences. Existing mapping-sibling scopes and source
|
||||
placement continue to apply. It supersedes the whole-sequence
|
||||
precedence replacement rule in the proposed
|
||||
[ADR 0007](0007-source-backed-underlays.md).
|
||||
|
||||
## Date
|
||||
|
||||
2026-10-22
|
||||
|
||||
## Context
|
||||
|
||||
ConfigKit can load separate files as records in a sequence. Applications can
|
||||
register an identity key so records with different identities combine and
|
||||
duplicates fail. However, a higher configuration layer or an inheriting mapping
|
||||
currently replaces the whole sequence. Applications that need to extend a
|
||||
catalog across layers must write their own merging code and keep source and
|
||||
history information consistent with its results.
|
||||
|
||||
Configuration authors need to choose how records are identified and whether a
|
||||
layer adds records, updates records, or replaces the collection. Applications
|
||||
also need a way to require unique identities in the final loaded configuration.
|
||||
These rules must work consistently for imported records, inline sequences,
|
||||
overlays, and inheritance.
|
||||
|
||||
Records in a sequence may also share settings. Authors need to reuse those
|
||||
settings through inheritance while keeping each record's own identity.
|
||||
|
||||
## Decision
|
||||
|
||||
ConfigKit manages identity and merge rules for each collection during loading.
|
||||
Configuration files can declare those rules for imported and inline sequences,
|
||||
including sequences in selected overlays. Applications can require a particular
|
||||
identity definition, and configuration declarations must agree with it. Source
|
||||
placement determines where each contribution enters the configuration.
|
||||
|
||||
Keep the distinction between contributions at the same priority and overrides
|
||||
from a higher layer, as established in
|
||||
[ADR 0001](0001-separate-composition-and-overlay.md). Within one tier, records
|
||||
with distinct identities combine; duplicate identities and conflicting
|
||||
declarations fail. Determine the applicable rules before combining those
|
||||
contributions, including any identity established in lower tiers.
|
||||
|
||||
Across layers, support three operations: replace the whole sequence, append
|
||||
records while rejecting duplicate identities, or replace whole records by
|
||||
identity (`upsert`). Source layering and sibling inheritance use the same
|
||||
operations. For inheritance, the rules at the inheriting destination determine
|
||||
which operation applies.
|
||||
|
||||
An identified sequence is also a sibling scope for inheritance. A record may
|
||||
inherit from one other record in the same sequence, selected by its complete
|
||||
identity. Each record supplies its own identity before inheritance. The child
|
||||
keeps that identity and combines its settings with the parent's other fields,
|
||||
using the existing mapping and collection rules. Parents remain ordinary records
|
||||
subject to normal validation.
|
||||
|
||||
Resolve these relationships after source assembly, using the same dependency
|
||||
ordering as mapping-sibling inheritance. Resolve nested relationships at each
|
||||
inheriting destination. Preserve the assembled sequence order. Whole-record
|
||||
upsert keeps its existing meaning; inheritance then uses the records that remain.
|
||||
Typed defaults, CLI overrides, and application resolution follow the lifecycle
|
||||
in [ADR 0018](0018-cli-after-structural-resolution.md).
|
||||
|
||||
An explicitly selected operation remains in effect at its destination until a
|
||||
higher layer changes it. When no explicit operation applies, use upsert if an
|
||||
identity definition is available; otherwise replace the whole sequence. Authors
|
||||
can declare identity and an operation before supplying any records. Keep source
|
||||
acquisition order separate from layer priority and the dependency ordering
|
||||
established by [ADR 0015](0015-shared-dependency-ready-ordering.md).
|
||||
|
||||
Rules declared inside a record belong to that record. Retaining the record
|
||||
retains its local rules; replacing it discards them. Replacing a whole collection
|
||||
can discard its previous YAML identity definition, while application requirements
|
||||
still apply. Inheritance can copy collection identity, but the inheriting
|
||||
destination chooses its operation. Application registrations continue to govern
|
||||
the paths where they were registered. The same ownership rules apply to
|
||||
declarations made before a sequence exists.
|
||||
|
||||
Check YAML-declared identity when combining collections during source assembly
|
||||
and inheritance. Application registrations also require the final loaded
|
||||
sequences to satisfy their identity rules, including after typed defaults, CLI
|
||||
overrides, and application resolution. This guarantee checks sequences that
|
||||
exist; the application schema and validation decide whether a collection must
|
||||
exist and which value types are allowed. The guarantee applies when loading
|
||||
completes, rather than to every intermediate callback state or changes made by
|
||||
the caller afterward.
|
||||
|
||||
Preserve explicit presence, source attribution, history, sensitivity, and
|
||||
resource context through these operations, following
|
||||
[ADR 0003](0003-preserve-presence-and-resolution-history.md). Typed defaults,
|
||||
CLI assignments, and application resolution keep their own rules for changing
|
||||
values. The public API provides registration and inspection; the implementation
|
||||
chooses how to store and track collection rules internally.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
- **Keep identity application-owned and always replace sequences across layers.**
|
||||
Keeps the current design small, but reusable catalogs require application
|
||||
changes or custom merging code.
|
||||
- **Put merge rules on each import.** Keeps the rules beside the imported source,
|
||||
but inline sequences and overlays need a separate way to declare them. It also
|
||||
makes rules for a collection spread across several imports harder to reconcile.
|
||||
- **Merge individual fields of records with matching identities.** Allows partial
|
||||
record updates, but adds rules for field conflicts and retained values.
|
||||
Replacing the whole record makes the update's scope clear.
|
||||
- **Keep inheritance limited to mapping siblings.** Keeps parent lookup simpler,
|
||||
but authors of sequence records must duplicate shared settings or applications
|
||||
must implement inheritance themselves. Collection identity provides a way to
|
||||
select a parent within the existing loading process.
|
||||
- **Check every YAML identity again at the end of loading.** Gives YAML declarations
|
||||
authority over values changed by defaults, CLI assignments, and application
|
||||
resolution. This extends the lifetime of those declarations; application
|
||||
registration provides that final guarantee when needed.
|
||||
|
||||
## Consequences
|
||||
|
||||
Consumers can build and customize catalogs in one load while retaining useful
|
||||
inspection information. Configuration authors choose how collections combine
|
||||
within application requirements. Duplicate identities among contributions at
|
||||
the same priority remain errors.
|
||||
|
||||
Existing registered collections use upsert across layers by default unless an
|
||||
explicit operation selects otherwise. Correct loading now requires tracking
|
||||
which declarations still apply as records are replaced, copied, or moved to new
|
||||
positions. Lower-tier identity must be available before combining higher-tier
|
||||
contributions.
|
||||
|
||||
Record inheritance also requires parent lookup by identity and checking for
|
||||
missing parents and cycles. Copied settings retain the same source, history,
|
||||
and sensitivity information as mapping-sibling inheritance.
|
||||
|
||||
The [configuration contract](../config.md#strict-document-composition) defines
|
||||
declaration syntax, identity comparison, omission and empty-value behavior, and
|
||||
diagnostics for the implemented feature.
|
||||
84
docs/adr/0018-cli-after-structural-resolution.md
Normal file
84
docs/adr/0018-cli-after-structural-resolution.md
Normal file
@@ -0,0 +1,84 @@
|
||||
# ADR 0018: Apply CLI Overrides After Inheritance and Decoding
|
||||
|
||||
## Status
|
||||
|
||||
Accepted.
|
||||
|
||||
This supersedes the rule that CLI overrides apply before decoding in
|
||||
[ADR 0005](0005-explicit-overrides-and-safe-explanation.md) and the
|
||||
rule that they apply before inheritance in [ADR 0009](0009-sibling-inheritance.md).
|
||||
Their remaining ownership, inheritance scope, and safe inspection decisions
|
||||
continue to apply.
|
||||
|
||||
## Date
|
||||
|
||||
2026-10-22
|
||||
|
||||
## Context
|
||||
|
||||
Explicit command-line values are the operator's highest-priority input. If they
|
||||
apply before inheritance, a sequence supplied as a replacement could gain
|
||||
inherited records afterward. Its final contents would then differ from the
|
||||
operator's assignment.
|
||||
|
||||
The application also needs these values when it fills missing settings,
|
||||
normalizes values, or computes derived settings. ConfigKit needs a clear loading
|
||||
order that preserves both CLI precedence and the application's role in resolving
|
||||
the final configuration.
|
||||
|
||||
## Decision
|
||||
|
||||
ConfigKit uses this loading order:
|
||||
|
||||
1. Combine sources within tiers and apply precedence between layers.
|
||||
2. Resolve sibling inheritance, apply typed defaults, and decode the configuration
|
||||
into the application's types.
|
||||
3. Apply explicit CLI overrides and check them against the application schema.
|
||||
4. Run application resolution using the overridden configuration.
|
||||
5. Look up effective secret references, validate the final configuration, and
|
||||
return it.
|
||||
|
||||
CLI overrides are the highest-priority input to application resolution. A CLI
|
||||
sequence replaces the whole sequence; mappings combine recursively. Collection
|
||||
merge rules apply during the earlier source and inheritance steps. Inheritance
|
||||
and typed defaulting do not run again after CLI overrides.
|
||||
|
||||
Keep track of which values were explicitly supplied and which CLI input supplied
|
||||
them. Applications continue to parse command-line arguments; ConfigKit applies
|
||||
and validates the overrides and makes their effects inspectable. Reject
|
||||
structural directives in override paths and values, so instructions such as
|
||||
inheritance relationships remain part of source processing.
|
||||
|
||||
Application resolution keeps its existing rules for defaults and recorded
|
||||
changes under [ADR 0003](0003-preserve-presence-and-resolution-history.md).
|
||||
It receives CLI values together with their presence and source information.
|
||||
Defaults that only fill missing settings preserve explicit CLI values.
|
||||
Application normalization and derived-value calculations can still change them,
|
||||
with those changes recorded. Secret lookup and final validation use the resulting
|
||||
configuration.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
- **Apply CLI overrides before inheritance.** Preserves existing ordering, but
|
||||
later merging needs special handling to preserve CLI replacement intent while
|
||||
still combining ordinary source contributions.
|
||||
- **Apply CLI overrides after inheritance but before typed decoding.** Allows an
|
||||
override to fix a source value that would otherwise fail decoding, but leaves
|
||||
typed defaulting after the operator's assignment. Completing defaulting and
|
||||
decoding first gives CLI overrides an already resolved configuration to modify.
|
||||
- **Apply CLI overrides after application resolution.** Applies assignments after
|
||||
the application's changes, but derived settings still reflect earlier values
|
||||
unless resolution runs again.
|
||||
|
||||
## Consequences
|
||||
|
||||
CLI overrides change a configuration whose inheritance and typed defaults are
|
||||
already resolved. The application can then derive settings from the operator's
|
||||
values. The final values, source information, history, and sensitivity handling
|
||||
must remain consistent throughout loading.
|
||||
|
||||
CLI input can no longer add or cancel inheritance relationships or bypass an
|
||||
earlier error in source assembly or inheritance. It also cannot fix a value that
|
||||
fails the earlier decoding step. Semantic validation still runs after overrides
|
||||
and application resolution. The [configuration contract](../config.md#explicit-cli-overrides)
|
||||
and [consumer guidance](../consumers/README.md) describe this behavior.
|
||||
68
docs/adr/0019-explicit-configuration-discovery.md
Normal file
68
docs/adr/0019-explicit-configuration-discovery.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# ADR 0019: Keep Configuration Discovery Explicit and Inside Loading
|
||||
|
||||
## Status
|
||||
|
||||
Accepted.
|
||||
|
||||
## Date
|
||||
|
||||
2026-10-24
|
||||
|
||||
## Context
|
||||
|
||||
Applications often need a small ordered convention for choosing a main
|
||||
configuration file. Keeping that choice entirely in each application repeats
|
||||
path selection and file-loading plumbing. Moving the convention into ConfigKit
|
||||
without an explicit declaration would make ordinary loads search paths that a
|
||||
consumer did not choose, obscuring both precedence and filesystem access.
|
||||
|
||||
ConfigKit already owns synchronous source acquisition, parsing, composition,
|
||||
decoding, validation, and inspection. A selected host file can therefore enter
|
||||
that lifecycle as the existing file source, preserving its source identity,
|
||||
resource context, and diagnostics. Optional exhaustion can use the same absent
|
||||
main lifecycle as an explicitly absent source.
|
||||
|
||||
## Decision
|
||||
|
||||
Consumers choose and order the main-file conventions they want to use. They may
|
||||
describe literal paths, directory filename groups, and supported directory
|
||||
presets, and may mark the complete search as required. Search descriptions are
|
||||
explicit inputs to loading; no preset or convention is activated implicitly.
|
||||
|
||||
During synchronous loading, ConfigKit resolves the description to one real host
|
||||
file or to absence before parsing. The selected file follows the existing source
|
||||
acquisition and processing rules. A selected-file access, parse, version,
|
||||
composition, decode, or validation failure is terminal and does not resume the
|
||||
search. Optional exhaustion shares the absent-main lifecycle; required
|
||||
exhaustion reports a missing-source diagnostic. No candidate-attempt history is
|
||||
added to public metadata.
|
||||
|
||||
This keeps convention choice with applications while moving repeated selection
|
||||
and loading plumbing into the library's established lifecycle. It preserves
|
||||
explicit control, ordinary source behavior, and complete-on-return ownership.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
- **Keep all search and fallback loading in each application.** This preserves
|
||||
consumer ownership but repeats the file-selection and load coordination that
|
||||
ConfigKit already owns, and risks divergent handling of selected-file errors.
|
||||
- **Search conventional locations implicitly during every load.** This reduces
|
||||
setup, but activates filesystem access and precedence the consumer did not
|
||||
select and makes ordinary source behavior less predictable.
|
||||
- **Return candidate paths for the application to load one by one.** This keeps
|
||||
selection outside the load lifecycle and encourages repeated complete loads,
|
||||
potentially rerunning defaults, callbacks, and validation during fallback.
|
||||
|
||||
## Consequences
|
||||
|
||||
The public search description must be immutable and root-owned, with private
|
||||
engine conversion and selection. The contract documents candidate ordering,
|
||||
selection, exhaustion, context, and provenance; the API declaration documents
|
||||
the public symbols. Dynamic bases and file availability are observed when each
|
||||
load consumes the description. Applications remain responsible for selecting
|
||||
conventions and for their schema and domain requirements.
|
||||
|
||||
The [configuration contract](../config.md) owns implemented behavior, and the
|
||||
[component overview](../internal/overview.md) describes implementation ownership.
|
||||
The completed implementation plan and remediation record are preserved in Git
|
||||
history at `0369111` under `docs/roadmap/`.
|
||||
796
docs/config.md
796
docs/config.md
@@ -12,6 +12,7 @@ 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. |
|
||||
| `SearchConfig(application, search, options...)` | Creates an explicit, reusable search source. Loading selects one host file or follows the absent-input lifecycle when an optional search is exhausted. |
|
||||
| `NewFilesystem(label, filesystem).File(name)` | Borrows a caller-owned `fs.FS` under a safe nonempty display label. `name` must be a non-root slash-relative `fs.ValidPath`. Each load reopens the selected regular file; copies of the `Filesystem` preserve namespace identity. Separate constructors create separate namespaces even for the same label or filesystem. |
|
||||
| `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. |
|
||||
@@ -28,6 +29,79 @@ Keys are sorted for stable provenance, integer decimal spelling stays exact, and
|
||||
cycles or unsupported types are rejected without exposing values. Mapping input
|
||||
uses the same directive and version rules as YAML, not CLI override restrictions.
|
||||
|
||||
### Configuration discovery
|
||||
|
||||
Discovery is opt-in. Construct `Search` from literal `Paths`, directory searches,
|
||||
or the supported directory presets, then pass it to `SearchConfig`. Search
|
||||
descriptions are immutable and reusable; each load resolves dynamic directories
|
||||
and checks current candidate files again. `Searches` is ordered concatenation.
|
||||
It preserves the supplied description order and does not deduplicate candidates.
|
||||
|
||||
`NamedFilenames` selects `<application>.yml`, then `<application>.yaml`.
|
||||
`GenericFilenames` selects `config.yml`, `config.yaml`, `main.yml`, then
|
||||
`main.yaml`. `SearchDirectory` defaults to `[Named, Generic]` when options are
|
||||
omitted or `Filenames` is nil. A non-nil empty filename list contributes no
|
||||
candidates and does not resolve its directory. `CustomFilenames` supplies
|
||||
literal single filename components; it does not expand application-name
|
||||
placeholders. An application slug is required only when a selected directory or
|
||||
filename group uses it. Literal custom paths are not expanded for `~`, variables,
|
||||
or wildcards; relative paths are resolved from the working directory at load time.
|
||||
The zero `Search`, `Searches()`, `Paths()`, and an empty custom filename group
|
||||
contribute no candidates. An explicitly supplied empty path or filename is
|
||||
invalid. Slugs and custom filenames must be usable single host filename
|
||||
components; ConfigKit preserves their case and spelling.
|
||||
|
||||
Search expansion is ordered concatenation. For each directory search, ConfigKit
|
||||
tries its filename groups and names before advancing to the next directory.
|
||||
When multiple directories use the same filename list, candidates follow the
|
||||
directory-first Cartesian product: every filename in one directory is tried
|
||||
before the next directory. Explicit file candidates stay at their declared
|
||||
positions among directory searches.
|
||||
|
||||
The presets expand to these ordered directories and filename groups:
|
||||
|
||||
| Preset | Directory, in order | Filename groups |
|
||||
| --- | --- | --- |
|
||||
| `UnixSystemDirectories` | `/usr/local/etc/<application>`, `/etc/<application>` | Named, then Generic |
|
||||
| `UnixSystemDirectories` | `/etc` | Named only |
|
||||
| `UserConfigDirectory` | Go user-config directory / `<application>` | Named, then Generic |
|
||||
| `UserConfigDirectory` | Go user-config directory | Named only |
|
||||
| `HomeDirectory` | Go home directory / `.<application>` | Named, then Generic |
|
||||
| `HomeDirectory` | Go home directory | Named only |
|
||||
| `WorkingDirectory` | Current working directory | Named only |
|
||||
|
||||
The Unix system preset is supported on aix, android, darwin, dragonfly, freebsd,
|
||||
hurd, illumos, ios, linux, netbsd, openbsd, and solaris; reaching it on another
|
||||
platform is an option error. Other presets use Go's user-config, home, and
|
||||
working-directory helpers. A reached
|
||||
directory-helper error terminates selection. Bases for later searches are not
|
||||
resolved after an earlier candidate is selected. Missing candidates continue;
|
||||
the first existing candidate is selected and then follows ordinary file loading
|
||||
rules. Errors while acquiring or processing a selected file are terminal and do
|
||||
not resume the search.
|
||||
|
||||
Searches are optional by default. Exhaustion uses the same absent-main lifecycle
|
||||
as `NoSource()`: there is no main document, source record, source ID, or explicit
|
||||
presence contribution. Imports may be enabled but there are no main declarations
|
||||
to traverse. Defaults, version checks, overlays, CLI overrides, resolution, and
|
||||
validation retain their normal lifecycle. `SearchOptions{Required: true}` makes
|
||||
exhaustion an error; it does not change candidate or selected-file error handling.
|
||||
|
||||
For an ordinary active overlay after optional exhaustion, provide an explicit
|
||||
filesystem context. A host context with only `Root` is sufficient; ConfigKit
|
||||
loads the overlay at its normal root-relative location and does not infer a root
|
||||
from attempted search directories. The same root-only context can accompany a
|
||||
search that selects a file: the selected file determines its base and must be
|
||||
inside `Root`. A selected host file rejects an explicit `BaseDir` or native
|
||||
filesystem context. Invalid context fails without resuming selection. Layered
|
||||
overlays keep their independent `OverlayFilesystem` requirement.
|
||||
|
||||
When a search selects a file, inspection reports that actual file with ordinary
|
||||
source IDs, paths, placement, metadata origins, and resource context. Missing
|
||||
candidates create no records. `SearchConfig` can be used as the main source of
|
||||
ordinary or layered loading, including an underlay input, and does not add a new
|
||||
precedence tier.
|
||||
|
||||
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
|
||||
@@ -52,10 +126,11 @@ preserves already validated secret state without rereading the environment.
|
||||
The current lifecycle is:
|
||||
|
||||
```text
|
||||
validate options → parse the selected source → traverse/compose enabled imports
|
||||
validate options → parse the selected source → extract source controls and
|
||||
traverse/strictly compose enabled imports
|
||||
→ build schema → check the optional application version → apply a selected overlay
|
||||
→ apply explicit CLI overrides → expand sibling inheritance
|
||||
→ create and clone defaults → strictly decode
|
||||
→ expand sibling inheritance
|
||||
→ create and clone defaults → strictly decode → apply explicit CLI overrides
|
||||
→ recorded application resolution
|
||||
→ resolve typed secret references → semantic validation → classify secrets
|
||||
→ snapshot configuration and metadata
|
||||
@@ -67,13 +142,24 @@ ID `source-1`; enabled imports continue in depth-first order as `source-2` onwar
|
||||
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.
|
||||
`Options` accepts the application definition, ordinary-source `Destination` and
|
||||
`PlacementMode`, explicit imports, one selected overlay, filesystem
|
||||
context, explicit CLI overrides, and shared source-version controls. The definition
|
||||
may register sequence identities through `Definition.SequenceIdentities`. Its
|
||||
zero-valued placement mode places the source mapping
|
||||
at the destination; `sequence-item` explicitly wraps the mapping as one item at a
|
||||
non-root destination. Unknown modes, invalid destinations, and invalid or
|
||||
conflicting sequence registrations fail before source acquisition. Overlay and
|
||||
CLI coordinates remain rooted at the application root. Ordinary `Load` recognizes
|
||||
source-root collection declarations whether or not imports are enabled; an
|
||||
`imports` declaration still requires imports to be enabled. `NoSource` may enable
|
||||
imports, but it has no main declarations to traverse. It accepts an explicit
|
||||
filesystem context: a host context requires an absolute existing `Root` and may
|
||||
omit `BaseDir` to use that root as its internal import base; a native context
|
||||
requires its namespace, `BaseDir`, and `Root`. Supplied context is validated even
|
||||
when imports are disabled. An active ordinary overlay requires this explicit
|
||||
context. `NoSource` accepts overrides and remains absent rather than contributing
|
||||
an empty document.
|
||||
|
||||
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
|
||||
@@ -123,62 +209,148 @@ nothing. Underlay groups are listed from lowest to highest priority. Repeating
|
||||
a file within one group remains an import/repetition error; the same reusable
|
||||
source may be selected again in another tier.
|
||||
|
||||
ConfigKit acquires and composes the main input first, constructs the schema,
|
||||
and checks the optional application version from the main tier only. Underlay
|
||||
roots cannot claim that version path (`underlay.version`); imported fragments
|
||||
retain `import.version` protection. It then acquires and composes underlay
|
||||
groups in order, recursively combines the groups, and applies the main tier,
|
||||
one selected overlay, and explicit CLI overrides. Scalar, sequence, and null
|
||||
definitions replace lower values between tiers; mappings combine recursively.
|
||||
Syntax and strict-composition errors inside a tier cannot be masked by a
|
||||
higher-priority tier. Defaults, typed decoding, resolution, secret lookup,
|
||||
validation, and publication run once on the final assembled input.
|
||||
ConfigKit acquires the main input first and constructs the schema. It then
|
||||
acquires and strictly composes underlay groups from lowest to highest priority,
|
||||
passing each group's accumulated lower collection policy into the next group.
|
||||
Each group is applied once as a precedence tier. The main tier is then composed
|
||||
with the accumulated underlay policy, and the optional application version is
|
||||
checked against that composed main tier only. Underlay roots cannot claim that
|
||||
version path (`underlay.version`); imported fragments retain `import.version`
|
||||
protection. ConfigKit then applies the selected overlay and resolves inheritance.
|
||||
Scalar, sequence, and null definitions replace lower values between tiers unless
|
||||
a collection policy defines the sequence operation; mappings combine recursively.
|
||||
After successful defaults and typed decoding, CLI overrides are applied to that
|
||||
typed configuration before application resolution. Syntax, strict-composition,
|
||||
inheritance, and decoding errors cannot be masked by a CLI override. Resolution,
|
||||
secret lookup, validation, and publication run once on the resulting values.
|
||||
|
||||
An active layered overlay requires `LayeredOptions.OverlayFilesystem` explicitly,
|
||||
independent of the main source's namespace. Its name and root-relative lookup
|
||||
follow the selected-overlay rules below; inactive overlays ignore the context.
|
||||
The same path in a different filesystem namespace is not a repeated file.
|
||||
Ordinary `Load` continues to infer its selected-overlay context from its source.
|
||||
Ordinary `Load` infers overlay context from sources that provide one; with
|
||||
`NoSource`, it requires the explicit `Options.Filesystem` context.
|
||||
|
||||
Source IDs follow acquisition order—main and its depth-first imports, then each
|
||||
underlay tier's inputs and depth-first imports, then overlay and CLI—even though
|
||||
history follows precedence order. Source `Layer` describes precedence while
|
||||
history follows precedence order. Default main-version checking happens after
|
||||
underlay composition because the main tier inherits the accumulated collection
|
||||
policy. Enabled per-source version checks retain acquisition order and source
|
||||
local headers. Source `Layer` describes precedence while
|
||||
`Kind` describes acquisition. Underlay records use `LayerUnderlay` and a
|
||||
one-based `UnderlayIndex` giving their original tier position; all other records
|
||||
have index zero. Explicit empty groups and `NoSource` consume no source ID.
|
||||
Byte, source, node, and glob-entry budgets are shared across the load, while
|
||||
the existing per-source and import-depth bounds still apply.
|
||||
|
||||
When a source mapping is explicitly placed as an item, its effective destination
|
||||
is the sequence container. After assembly assigns an index, nested source
|
||||
destinations are projected through that item. Physical source locations and
|
||||
resource contexts continue to identify the declaring source.
|
||||
|
||||
#### Input destinations
|
||||
|
||||
`Input.Destination` places the source's application mapping beneath a path
|
||||
relative to the configuration root. Its zero or empty value means root, so
|
||||
existing inputs keep their behavior. Main inputs and underlay inputs use the
|
||||
same destination model. Destination segments are literal mapping keys: dots,
|
||||
slashes, and numeric-looking text are not split or interpreted as sequence
|
||||
indexes. Explicit index segments and paths that introduce the reserved
|
||||
`configkit` or mapping-valued `extends` structure are invalid input options and
|
||||
produce a structured `LoadError` before source acquisition.
|
||||
|
||||
Source-root `configkit.imports` directives are processed before placement. An
|
||||
import without an explicit destination inherits the effective destination of
|
||||
its importing document, including through nested imports. An import may instead
|
||||
declare a relative destination as a sequence of literal mapping keys; its keys
|
||||
append to the importing document's effective destination. Omission and `[]`
|
||||
add no keys. Nested imports continue relative to their immediate importing
|
||||
document, each declaration is independent of its siblings, and all matches of
|
||||
one glob receive the same resulting destination. This logical placement does
|
||||
not change how the physical import path resolves from its declaring file.
|
||||
|
||||
A valid destination on `NoSource()` contributes nothing; an actual empty
|
||||
mapping materializes its destination containers. Once placed, contributions
|
||||
follow the same strict composition within a tier and recursive precedence
|
||||
behavior described above. A selected overlay and CLI overrides remain rooted at
|
||||
the application root and are not moved beneath the main input's destination.
|
||||
|
||||
`SourceInfo.Destination` reports each acquired source's effective mapping or
|
||||
sequence-container path, including imports. `PlacementMode` reports how that
|
||||
source was placed; mapping is the zero value, and CLI/overlay sources report
|
||||
mapping unless item placement was applied. `DestinationUnresolved` is true only
|
||||
when an enclosing item index needed to name the source's destination has not yet
|
||||
been assigned. In that case `Destination` reports the nearest resolved enclosing
|
||||
collection container and omits the unresolved local suffix. A source's own item
|
||||
placement still has an exact destination at its sequence container. Completed
|
||||
loads project nested source destinations through their owning items. Callback and
|
||||
failed-load snapshots preserve the evidence available at that point; a fallback
|
||||
snapshot remains unresolved, while a later exact snapshot is false. Returned
|
||||
source records and paths are detached. Placement does not change source IDs,
|
||||
acquisition identity, physical source locations, or passive resource contexts.
|
||||
Introduced ancestor mappings use the contributing document's root location as a
|
||||
source fallback; authored content retains its original source coordinates.
|
||||
|
||||
The maintained [layered consumer example](consumers/README.md#native-filesystem-and-layered-input)
|
||||
shows a main host file, two underlay tiers, embedded defaults, a mapping source,
|
||||
and explicit resource access without a second configuration pass.
|
||||
The accepted rationale for input placement and its relative-import extension
|
||||
is recorded in [ADR 0011](adr/0011-input-destinations.md) and
|
||||
[ADR 0012](adr/0012-relative-import-destinations.md).
|
||||
|
||||
### Sibling inheritance
|
||||
|
||||
Any input mapping may declare `extends: name` to inherit from the mapping named
|
||||
`name` among its immediate siblings. Names are exact keys, including the empty
|
||||
string; lookup does not search ancestor or unrelated scopes. The document root
|
||||
and a sequence-element mapping have no sibling namespace for their own string
|
||||
relationship, though mappings nested inside them can have one. An `extends`
|
||||
string; lookup does not search ancestor or unrelated scopes. A mapping inside a
|
||||
sequence can also participate in record inheritance when that sequence has an
|
||||
effective identity declaration from `configkit.sequences` or an application
|
||||
registration. In that case, `extends` is a mapping containing exactly the
|
||||
sequence's identity fields, with scalar values; lookup is limited to that final
|
||||
sequence. The document root and a sequence-element mapping without an effective
|
||||
identity declaration have no sibling namespace for non-null string
|
||||
relationships, though mappings nested inside them can have one. An `extends`
|
||||
value of null cancels a relationship without looking up a parent. Other value
|
||||
types, missing or non-mapping parents, and cycles fail the load with value-free
|
||||
diagnostics.
|
||||
types, incomplete or extra reference fields, missing parents, and cycles fail
|
||||
the load with value-free diagnostics.
|
||||
|
||||
Within each mapping sibling scope or identified sequence, direct definitions
|
||||
resolve by dependency readiness. A parent is resolved before a child; among
|
||||
currently eligible definitions, the lexically smallest canonical semantic
|
||||
pointer is selected, including newly eligible definitions as soon as their
|
||||
parent completes. This ordering governs inheritance operations and their
|
||||
history, controls, and transfers. It does not reorder mappings or sequence
|
||||
records in the resulting configuration.
|
||||
|
||||
`extends` is reserved structural input across ordinary and layered loads, every
|
||||
source kind, imports, selected overlays, and explicit CLI overrides. It is
|
||||
consumed before typed decoding and need not appear in the application's Go
|
||||
types. Applications previously using `extends` as an ordinary YAML field must
|
||||
rename that key and its field. No collection registration or keyword setup is
|
||||
required.
|
||||
required for mapping-sibling relationships; identified-sequence relationships
|
||||
require effective identity fields for that collection.
|
||||
|
||||
ConfigKit completes strict composition within tiers and precedence assembly
|
||||
between tiers before resolving relationships. An omitted `extends` retains a
|
||||
lower-priority relationship; `extends: null` cancels it while preserving the
|
||||
child's own settings. Superseded references are historical evidence, not active
|
||||
relationships to validate. Each child recursively overlays its own settings
|
||||
over its resolved parent: explicit zero, false, empty string, sequence, and
|
||||
null values replace inherited values, while mappings combine recursively.
|
||||
The child retains its key. All effective relationships resolve, including
|
||||
between tiers before resolving relationships. Only records that survive that
|
||||
assembly participate in identified-sequence lookup: an upserted parent supplies
|
||||
its final record, a replaced parent is unavailable, and a higher replacement
|
||||
without `extends` supersedes a lower relationship. An omitted `extends` retains
|
||||
a lower-priority mapping-sibling relationship; an identified record's effective
|
||||
directive depends on the record that survives assembly. `extends: null` cancels
|
||||
a relationship while preserving the child's own settings. Superseded references
|
||||
are historical evidence, not active relationships to validate. Each child
|
||||
recursively overlays its own settings over its resolved parent: explicit zero,
|
||||
false, empty string, and
|
||||
null values replace inherited values, while mappings combine recursively. For
|
||||
identified records, the child's identity fields and their origins remain its
|
||||
own; parent identity fields do not contribute to the payload.
|
||||
Sequences at collection destinations use the shared collection operation with
|
||||
the parent as lower input and the child as higher input. The destination's
|
||||
explicit mode applies; otherwise a copied identity selects upsert and an
|
||||
unkeyed destination replaces. A parent's explicit mode does not move to a
|
||||
different destination. Sequences outside collection destinations remain atomic
|
||||
replacements.
|
||||
All effective relationships resolve, including
|
||||
entries the application may never select for execution. Partial parent
|
||||
definitions undergo normal typed decoding and application-owned validation,
|
||||
not a special structural exemption.
|
||||
@@ -269,6 +441,67 @@ 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.
|
||||
|
||||
#### Shared source-version checks
|
||||
|
||||
`Options.SourceVersionMode` and `LayeredOptions.SourceVersionMode` select how
|
||||
one `Definition.Version` policy applies across source documents. The zero value
|
||||
`SourceVersionModeDefault` preserves the existing main-only check at its
|
||||
effective path and existing protected-path behavior. `SourceVersionModeIfPresent`
|
||||
checks each participating document that declares a version and permits absent
|
||||
non-main declarations. `SourceVersionModeRequired` rejects a participating
|
||||
document without a declaration. Enabled modes require a usable `VersionRule`
|
||||
and checker; an unknown mode or unusable rule is an options error before source
|
||||
access or callbacks.
|
||||
|
||||
In enabled modes, `Definition.Version.Path` is interpreted in each acquired
|
||||
document's own coordinates, before placement and composition. The main document,
|
||||
direct underlay documents, imported documents (including transitive and glob
|
||||
matches), and the selected overlay participate. `NoSource`, absent optional
|
||||
imports, empty underlay groups, defaults, resolution output, inheritance, and
|
||||
synthetic CLI overrides are not documents to check. An absent declaration in an
|
||||
actual main document still reaches the checker in if-present mode; required mode
|
||||
rejects it first. With `NoSource`, the existing single absent-main checker
|
||||
behavior remains in both enabled modes. If-present omissions in non-main
|
||||
documents skip the checker. Explicit null is present and reaches the checker;
|
||||
unsupported selected values produce `version.type`. No equality with the main
|
||||
declaration or non-null rule is imposed. Acceptance establishes only that the
|
||||
declaration is accepted by the consumer's checker; it does not establish that
|
||||
each fragment conforms to a complete application schema. Final assembled
|
||||
decoding and application validation still run once.
|
||||
|
||||
A header that is absent below a structural control follows the same omission
|
||||
policy. Actual header/control overlap is rejected, and ordinary structural
|
||||
validation and main-version ownership protection continue independently.
|
||||
|
||||
Accepted non-main declarations are consumed from their owned payload before
|
||||
composition; their authored ancestors remain, even if empty. The main
|
||||
declaration remains in the application. Ordinary release metadata should use a
|
||||
different path. Enabled main protection uses the main input's effective path:
|
||||
its `Destination` prefixed to the source-local `VersionRule.Path`. This protects
|
||||
the remaining payload against imports, underlays, inheritance, and CLI
|
||||
overrides. Thus a mounted main looks up a local header while ownership checks
|
||||
protect its destination-prefixed application path. Main checker diagnostics and
|
||||
declaration locations use source-local coordinates; ordinary payload
|
||||
diagnostics continue to use effective paths. Default-mode diagnostics are
|
||||
unchanged.
|
||||
|
||||
`SourceInfo.VersionCheck` is nil when enabled declaration processing did not
|
||||
occur. Otherwise it records a source-local `Path`, the observed `Version` state
|
||||
(`Present`, `Null`, and, when disclosed, exact `Text`), an actual or nearest
|
||||
available `Location`, and whether text was redacted. A non-nil record with
|
||||
`Present == false` records observed absence; it does not say the checker ran or
|
||||
accepted a declaration. Absent evidence still identifies the full requested
|
||||
header path, while its location identifies the nearest available source node.
|
||||
During callbacks and in failed-load source records,
|
||||
text is conservatively withheld because final classification may not be
|
||||
available. Successful final source records reflect completed sensitivity
|
||||
classification at the declaration's mounted path and ancestors, including
|
||||
historical classification. Redaction empties `Text` while retaining presence
|
||||
and null state. Returned copies are detached; evidence does not reopen inputs.
|
||||
Consult [ADR 0013](adr/0013-shared-source-version-checks.md) for the accepted
|
||||
decision and the [development guide](development.md#baseline-validation) for
|
||||
the completed implementation and remediation records in Git history.
|
||||
|
||||
### Layered metadata
|
||||
|
||||
`Result.Metadata` describes every final canonical path. Queries retain explicit
|
||||
@@ -299,15 +532,22 @@ 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.
|
||||
|
||||
Strict composition assigns contributed records their assembled sequence positions
|
||||
in history. Across tiers, history follows the selected
|
||||
[collection operation](#strict-document-composition), including append and
|
||||
whole-record upsert. Incoming contributions use their assigned positions, and
|
||||
replaced payload remains available as historical evidence.
|
||||
|
||||
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
|
||||
whose locations follow [scoped assembly order](#explicit-imports), not source-record
|
||||
acquisition 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.
|
||||
removed. Source records and IDs retain [acquisition order](#explicit-imports),
|
||||
including the underlay ordering described in [layered loading](#layered-loading).
|
||||
|
||||
Inheritance adds no acquired source or precedence layer. `Origin.Inheritance`
|
||||
lists concrete transfers from contributor paths through destinations, with
|
||||
@@ -370,29 +610,34 @@ historical path. Its effective value and each chronological before/after value a
|
||||
`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. Structural inheritance events appear after source replay
|
||||
and before typed reconciliation, including consumed or canceled `extends` input
|
||||
and equal-valued child replacements. Overlapping parent/child resolution records
|
||||
application-default or Go-zero baseline, followed by relevant underlay and main-tier
|
||||
contributions in precedence order, the selected overlay, and structural inheritance
|
||||
events. Typed defaulting and decoding reconcile that source history before supplied
|
||||
CLI records and audited net resolution operations. Within each strict tier, replay follows
|
||||
[scoped assembly order](#explicit-imports), not source-record acquisition order.
|
||||
Inheritance events include consumed or canceled `extends` input and equal-valued
|
||||
child replacements. 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`.
|
||||
writes remain visible. When structural replay differs from the pre-CLI typed input,
|
||||
a `ChangeDecoding` event records retained defaults and canonical decoding. CLI
|
||||
contributions then retain their raw input representation while effective and
|
||||
resolution values use the typed projection. 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.
|
||||
queries reconstruct each source or override contribution. A sequence snapshot
|
||||
represents the collection at that point in replay, and sequence diffs remain
|
||||
atomic. Descendant queries describe their presence and values before and after
|
||||
containing sequence operations. Combination follows the
|
||||
[collection operation tables](#strict-document-composition); upsert replaces
|
||||
matching records in full. 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
|
||||
@@ -423,6 +668,46 @@ an explicit `LoadSecretsDir` bootstrap, then use `WriteDiagnostics`,
|
||||
ConfigKit does not infer bootstrap outcomes from a secret reference and does not
|
||||
bundle these independent reports into a command framework.
|
||||
|
||||
## Effective configuration diffs
|
||||
|
||||
`configkit.Diff(left, right)` compares two completed successful loads of the same
|
||||
configuration type. `left` is the earlier state and `right` is the later state.
|
||||
Applications load both sides with the sources and options they choose; to inspect
|
||||
an overlay's impact, load the same base once without the overlay and once with it
|
||||
selected. The [consumer guide](consumers/README.md#effective-configuration-diffs)
|
||||
links to maintained examples. The public API returns safe changed-region records;
|
||||
applications own their presentation.
|
||||
|
||||
Comparison uses the frozen canonical effective inspection values, not mutable
|
||||
`Result.Config`, source text, provenance, or history. It does not reopen sources,
|
||||
reread environment variables, or rerun defaults or callbacks. Equal effective
|
||||
values produce no records even when sources, explicit-input status, defaults, or
|
||||
resolution histories differ; for example, explicit zero and defaulted zero are
|
||||
equal. Mappings compare recursively, sequences compare atomically, and scalar,
|
||||
kind, or null/container replacements report their affected region. Records use
|
||||
the existing comparator's canonical pointer ordering. `Present=false` with empty
|
||||
YAML means absence; explicit null is present and renders as `null`.
|
||||
|
||||
Before either side is returned, sensitivity from both loads is applied to both
|
||||
snapshots, including historical and dependency-derived classification. A
|
||||
classification on either side protects the corresponding values on both sides.
|
||||
`Redacted` reports masking, while `HiddenChanged` reports an actual concealed
|
||||
value or presence change; it is not implied merely by redaction. A structural
|
||||
replacement that prevents safe correspondence masks the complete reported
|
||||
region, and an absent side remains absent. Record membership establishes that a
|
||||
change occurred even when safe snapshots render identically. Literal
|
||||
marker-looking input is ordinary input; consumers use the record fields rather
|
||||
than interpreting marker text.
|
||||
|
||||
Typed secret references contribute their configured environment names to the
|
||||
frozen comparison, never resolved credential bytes. Credential rotation behind
|
||||
an unchanged reference is therefore not detected. The records contain changed
|
||||
regions only, not paired full-document context; independently calling
|
||||
`WriteEffective` on each result does not create cross-protected context. Nil or
|
||||
uninitialized results return an error with no records. A failed `Load` retains
|
||||
its normal nil-result/`LoadError` behavior, which callers handle before invoking
|
||||
`Diff`.
|
||||
|
||||
## Typed secret references
|
||||
|
||||
Credential-bearing fields use the opaque `Secret` type and store an environment
|
||||
@@ -439,13 +724,15 @@ 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.
|
||||
ConfigKit resolves only the effective names after CLI assignment and recorded
|
||||
application resolution, and before semantic validation. CLI overrides may supply
|
||||
or replace a typed secret reference; lookup uses that effective name and retains
|
||||
its effective source location for diagnostics. 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
|
||||
@@ -509,18 +796,19 @@ 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
|
||||
An optional `Definition.Resolve` callback runs after successful defaults, typed
|
||||
decoding, and explicit CLI assignment. 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.
|
||||
post-decode entry state, including explicit CLI overrides, 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
|
||||
@@ -595,8 +883,11 @@ 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.
|
||||
Ordinary `Load` applies strict document composition to its parsed source scope;
|
||||
`Options.Imports` adds imported documents as peers in that scope. Application
|
||||
defaults do not participate. With imports disabled, the single source still has
|
||||
its controls extracted and any declared or registered collection identity is
|
||||
validated, but there are no imported peers to combine.
|
||||
|
||||
| Existing node | Incoming node | Result |
|
||||
| --- | --- | --- |
|
||||
@@ -606,20 +897,148 @@ Application defaults do not participate in this operation.
|
||||
| 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.
|
||||
Every non-mapping overlap is a conflict, even when values are equal, except for
|
||||
sequence/sequence contributions at a destination with effective identity from an
|
||||
application registration, a current YAML `key` declaration, or retained lower
|
||||
policy. Nulls remain atomic conflicts.
|
||||
Sequences without effective identity conflict when repeated and are never joined
|
||||
by index. Disjoint entries under shared mappings compose, making stable keyed
|
||||
mappings the natural representation for independently contributed entries.
|
||||
|
||||
`Definition.SequenceIdentities` contains registrations created by
|
||||
`SequenceIdentityAt` or `SequenceIdentityMatching`. The exact form accepts a
|
||||
mapping-only `Path`; the matching form accepts literal-key and any-mapping-key
|
||||
segments created by `SequenceLiteralKey` and `SequenceAnyMappingKey`. A literal
|
||||
`"*"` key is distinct from the wildcard constructor. Parsed numeric path tokens
|
||||
remain literal mapping keys; explicit index segments and reserved `configkit` or
|
||||
mapping-valued `extends` segments are invalid. The root selector is harmless under
|
||||
the mapping-root schema. Selector segments consume one mapping level and match a
|
||||
complete destination; an any-mapping-key segment matches exactly one key, not an
|
||||
arbitrary prefix or suffix. Each registration requires one or more literal,
|
||||
direct field names; compound field sets are normalized as sets, and field names
|
||||
are not otherwise normalized. The same field-set and scalar-equality rules apply
|
||||
to YAML `key` declarations.
|
||||
|
||||
At each selected path, records must provide every effective identity field as a
|
||||
scalar string, number, boolean, or explicit null. Field names form a set, so
|
||||
order and repetition do not affect compound identity. The type-tagged tuple
|
||||
preserves category distinctions: strings compare exactly, including empty
|
||||
strings; numbers, booleans, and null remain distinct categories. Finite numbers
|
||||
compare by exact mathematical value, so `1`, `1.0`, and `1e0` are equal, as are
|
||||
`0` and `-0`, without floating-point rounding. Distinct tuples append in source
|
||||
and list order; duplicate tuples conflict even when records are otherwise equal
|
||||
or disjoint. Malformed records produce `compose.invalid`; duplicate identities
|
||||
produce `compose.conflict`, with paths and source locations identifying a
|
||||
duplicate without exposing identity values. Missing paths and non-sequence values
|
||||
do not fail by themselves. Equivalent overlapping registrations coalesce.
|
||||
Incompatible overlaps and invalid registrations produce `options.invalid` before
|
||||
source acquisition. Unused and root registrations are allowed and have no effect.
|
||||
|
||||
Registrations provide identity for strict composition, sequence operations,
|
||||
inheritance lookup, and final validation. Dynamic selectors apply at concrete
|
||||
strict-composition, inheritance, and final-result destinations without requiring
|
||||
the application to enumerate mapping keys. Each strict tier has independent
|
||||
duplicate-identity state among its peers; layered precedence and inheritance
|
||||
then apply the collection operation described below.
|
||||
|
||||
Final registration checks run in both ordinary and layered loads after effective
|
||||
secret lookup and before application semantic validation. They examine actual
|
||||
final typed sequences, including values introduced by defaults, CLI, or recorded
|
||||
resolution; they do not require path existence or sequence shape. Diagnostics
|
||||
use concrete collection and item paths and available source locations, without
|
||||
exposing identity or credential values. The guarantee ends when loading publishes
|
||||
successfully and does not cover later caller mutations.
|
||||
YAML-only identity is enforced during structural composition, precedence, and
|
||||
identified inheritance; it carries no final-result guarantee after typed
|
||||
defaults, CLI, or application resolution. Applications that need that guarantee
|
||||
register the identity constraint.
|
||||
The [maintained catalog examples](consumers/README.md#identified-prompt-sequences)
|
||||
show strict peers, precedence operations, inheritance, dynamic destinations, and
|
||||
the distinction between YAML-only declarations and application registrations.
|
||||
When an enabled source-version mode is selected, declarations are checked in each
|
||||
source's own coordinates before placement and composition. Accepted non-main
|
||||
headers are consumed before strict composition and cannot supply a registered
|
||||
record identity. Final typed decoding and application validation still run on the
|
||||
completed configuration.
|
||||
|
||||
Ordinary sources, layered inputs, and selected overlays may declare collection
|
||||
behavior under `configkit.sequences` at the document root. Each entry has a
|
||||
required literal `destination` and may specify `key` as one string or a nonempty
|
||||
list of strings, plus a `mode` of `replace`, `append`, or `upsert`. These
|
||||
controls are removed before application decoding. A declaration destination is
|
||||
relative to that source's placement, so a mounted fragment's declaration is
|
||||
mounted with its values. Declarations from strict peers must agree on identity
|
||||
fields and on any explicitly supplied mode; omission expresses no mode
|
||||
preference. They apply even when the declaring source has no value at that
|
||||
destination: a key validates an existing retained sequence or remains pending
|
||||
until records arrive, while a mode-only declaration affects later combinations
|
||||
without recombining earlier tiers. A key supplies the identity used by `append`
|
||||
and `upsert` and is checked against any application registration.
|
||||
|
||||
Strict peers at an identified destination first combine distinct records in
|
||||
source and list order. Duplicate identities among peers or within one
|
||||
contribution fail under every mode. After strict composition, precedence and
|
||||
inheritance use one operation for the assembled incoming collection:
|
||||
|
||||
| Mode | Behavior at source-precedence and inheritance boundaries |
|
||||
| --- | --- |
|
||||
| `replace` | Replace the complete lower sequence with the assembled higher sequence. |
|
||||
| `append` | Retain lower records and append higher records; duplicate identities fail. |
|
||||
| `upsert` | Replace matching records in full at their lower positions, retain unmatched lower records, and append new identities. |
|
||||
|
||||
Upsert replaces the complete matched record; it does not recursively merge
|
||||
record fields. Append and upsert require identity. With no explicit mode in the
|
||||
current tier, an explicit lower mode persists; if none exists, effective identity
|
||||
selects upsert and its absence selects replace. Inferred modes do not persist.
|
||||
Explicit modes persist at their destination until changed by a higher explicit
|
||||
mode, independently of YAML identity lifetime. A declaration alone does not
|
||||
clear or recombine a collection.
|
||||
|
||||
The presence and value kind at a precedence or inheritance boundary determine
|
||||
whether a collection changes:
|
||||
|
||||
| Higher contribution | `replace` | `append` / `upsert` |
|
||||
| --- | --- | --- |
|
||||
| Absent | Leave the lower value unchanged. | Leave the lower value unchanged. |
|
||||
| Explicit `[]` | Clear the sequence. | Contribute no records. |
|
||||
| Explicit null or another non-sequence value | Replace the lower value, subject to application schema. | Error; the operation requires a sequence. |
|
||||
|
||||
An absent lower value can be initialized by an incoming sequence under any mode.
|
||||
A present lower null or other non-sequence value is not absence: append and
|
||||
upsert cannot combine with it. An empty sequence still requires a valid mode and
|
||||
identity where applicable. Null used as an identity component inside a record is
|
||||
distinct from null used as the collection value.
|
||||
|
||||
Declarations govern ordinary strict source composition, including loads with
|
||||
imports disabled, and each strict scope in layered loading. Before composing a
|
||||
higher tier's peers, ConfigKit makes applicable lower identity and explicit mode
|
||||
available; after peer composition, the tier applies its resolved operation
|
||||
against the lower collection. Selected overlays use the same collection policy
|
||||
after the base source graph is composed. During mapping-sibling inheritance, a
|
||||
parent's YAML identity can follow its collection to the child destination, while
|
||||
the parent's explicit operation stays at the parent destination. The child
|
||||
destination selects its operation; absent one, effective identity implies
|
||||
upsert and its absence implies replace. Whole-sequence replacement discards
|
||||
lower YAML identity and uses the replacing scope's key declaration, if any;
|
||||
without one, the collection is unkeyed unless an application registration still
|
||||
supplies identity. Replacement discards policies owned by removed records, while
|
||||
an independently declared explicit mode and application registration remain
|
||||
applicable. Item-local
|
||||
policies follow their owning records: retaining a record retains its rules, while
|
||||
whole-record upsert replacement discards the displaced record's rules. Matching
|
||||
identity or reusing an index does not transfer them. These policies do not change
|
||||
CLI behavior: CLI sequence assignments replace whole sequences after decoding.
|
||||
A root `configkit.imports` declaration still requires `Options.Imports`;
|
||||
`NoSource` has no declaration scope.
|
||||
|
||||
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.
|
||||
base origin. A conflict reports the first claim in
|
||||
[scoped assembly order](#explicit-imports) as its primary location and every
|
||||
remaining claim as related locations in the same order. Assembly order never
|
||||
authorizes replacement. Composition clones the resulting tree and its ordered
|
||||
claim records rather than mutating or aliasing an input document.
|
||||
|
||||
## Selected overlays
|
||||
|
||||
@@ -630,13 +1049,21 @@ Every other name resolves only to `<configuration-root>/overlays/<name>.yml`, wi
|
||||
no extension fallback, case normalization, inheritance between overlay files,
|
||||
or overlay stack. A selected missing overlay is an error.
|
||||
|
||||
With `NoSource`, the main input stays absent and the explicit filesystem context
|
||||
supplies the overlay root. The overlay is recorded as the first acquired source
|
||||
when no other source exists. Host contexts may use the root-only form described
|
||||
under [single-source loading](#single-source-loading); native contexts retain
|
||||
their complete namespace, base, and root requirements.
|
||||
|
||||
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:
|
||||
Collection destinations follow the
|
||||
[collection operation and presence rules](#strict-document-composition) above.
|
||||
The following table describes ordinary overlay behavior elsewhere:
|
||||
|
||||
| Existing lower node | Incoming overlay node | Result |
|
||||
| --- | --- | --- |
|
||||
@@ -645,18 +1072,24 @@ Overlay is intentionally distinct from strict composition:
|
||||
| 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.
|
||||
Under these ordinary rules, scalars, sequences, and null are atomic. 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.
|
||||
An overlay may contain only root `configkit.sequences` declarations; overlay
|
||||
imports and nested directives are rejected. Sequence declarations are removed
|
||||
before application decoding. At declared or registered sequence destinations,
|
||||
`replace`, `append`, and `upsert` use the same identity and policy rules as source
|
||||
composition. Other overlay sequences remain atomic replacements. An overlay may
|
||||
not 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
|
||||
|
||||
@@ -668,10 +1101,16 @@ 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`.
|
||||
Overrides are prepared after source assembly and inheritance, then assigned after
|
||||
typed defaults and decoding and before application resolution. The order is:
|
||||
defaults and decoded sources < CLI < application resolution. A CLI mapping
|
||||
recursively overlays the decoded mapping; a CLI sequence replaces the complete
|
||||
decoded sequence regardless of source collection operations. Inheritance and
|
||||
typed defaulting do not run again after CLI assignment. Earlier parsing,
|
||||
composition, inheritance, and decoding failures remain errors even if an override
|
||||
would replace the failing value. Recorded resolution, typed secret lookup, and
|
||||
semantic validation use the effective post-override values. 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
|
||||
@@ -682,7 +1121,8 @@ false, and zero remain intentional; empty sequences replace whole lists; and map
|
||||
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
|
||||
All records are detached and validated before any is applied. They cannot create
|
||||
`configkit` or `extends` controls. 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.
|
||||
@@ -699,8 +1139,12 @@ 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.
|
||||
Override origins are explicit CLI origins. Resolution receives CLI values with
|
||||
their explicit-presence and source information. Absence-based defaults preserve
|
||||
those values, while recorded normalization and derivation may change them. History
|
||||
retains replaced source definitions and records equal-value replacements as well
|
||||
as structural changes. Secret lookup and validation use post-resolution values
|
||||
with their effective source locations.
|
||||
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
|
||||
@@ -708,12 +1152,22 @@ 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`.
|
||||
Imports are opt-in. With `Options.Imports` enabled, a document may use its root
|
||||
`configkit` mapping for `imports` and `sequences` controls. Ordinary `Load` may use
|
||||
`sequences` without enabling imports, but an `imports` field still requires
|
||||
`Options.Imports`. ConfigKit removes recognized root controls before application
|
||||
decoding. Each imports sequence item is a mapping with exactly one `path`,
|
||||
`dir`, or `glob` selector. `path` and `dir` accept a string or a list of strings;
|
||||
`glob` accepts one path pattern string. The optional `required` boolean controls
|
||||
whether an empty selection or a missing named path is an error. Its default is
|
||||
false, so missing imports are absent unless `required: true` is specified. The
|
||||
removed `optional` field is invalid. `destination` is an optional sequence of
|
||||
strings. Every destination string is one literal mapping key and follows the
|
||||
input-destination validation rules above; quote numeric-looking keys so YAML
|
||||
reads them as strings. Null, scalar destinations, and non-string members are
|
||||
`import.invalid`, as are all other unsupported shapes and fields. A `configkit`
|
||||
key below the root is invalid. With imports disabled, root imports and nested
|
||||
`configkit` keys remain `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
|
||||
@@ -722,25 +1176,118 @@ 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.
|
||||
URLs are invalid. Each path resolves from its declaring file's canonical directory.
|
||||
An empty `path: []` selects nothing; it succeeds even when `required: true` because
|
||||
there are no named targets to be missing. A nonempty required path list verifies
|
||||
every named target, including targets later excluded from selection.
|
||||
|
||||
Directory declarations search the listed literal directory roots. Missing roots
|
||||
contribute no candidates; supplied inaccessible or invalid roots fail. `pattern`
|
||||
accepts one basename pattern or a list, matching filenames only. Omission selects
|
||||
all filenames; an explicit empty list selects none. `exclude` accepts one basename
|
||||
pattern or a list and removes matching final candidates. Patterns use Go's
|
||||
case-sensitive `path.Match` grammar. `recursive` enables descendant traversal;
|
||||
`depth` is valid only when recursion is effective, with direct files at depth 1 and
|
||||
depth 0 or omission meaning unlimited traversal subject to library budgets.
|
||||
`follow` controls discovered symlink entries and `hidden` controls discovered
|
||||
names beginning with a dot. Both default to true. Explicit roots retain their
|
||||
normal access behavior.
|
||||
|
||||
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.
|
||||
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. Literal components are explicit path components;
|
||||
`follow` and `hidden` filter only wildcard-discovered entries. `exclude` filters
|
||||
final candidate basenames.
|
||||
|
||||
`Options.ImportDefaults` and `LayeredOptions.ImportDefaults` provide one
|
||||
application-owned load-wide policy for enabled imports in the main source,
|
||||
underlays, and transitive imports; they do not apply to selected overlays or
|
||||
discover sources by themselves. Each applicable field resolves independently:
|
||||
an explicit declaration value wins, then the application default, then the
|
||||
library default (`required: false`, `follow: true`, `hidden: true`,
|
||||
`recursive: false`, and unrestricted filenames). A declaration's `pattern`
|
||||
replaces rather than extends the default pattern. Nil `Pattern` leaves directory
|
||||
filenames unrestricted; a non-nil empty slice selects none. Defaults do not make
|
||||
a field applicable to selectors that do not support it, and load calls snapshot
|
||||
the supplied pointers and slice before source acquisition.
|
||||
|
||||
A source's `configkit.sequences` declarations apply independently of import
|
||||
enablement; a `configkit.imports` declaration still requires `Options.Imports`.
|
||||
Sequence identities can be supplied either by these YAML declarations or by
|
||||
application registrations. The [strict composition contract](#strict-document-composition)
|
||||
defines their agreement, operation, and lifetime rules.
|
||||
|
||||
Acquisition visits declarations in authored order and each declaration's selected
|
||||
paths in namespace-path lexical order, preserving source-record and version-callback
|
||||
order. Ordinary mapping contributions in a main tier are assembled dependency-first:
|
||||
ConfigKit selects the lexically smallest stable source key among the contributions
|
||||
whose imports are complete. Independent branches may interleave, and the main body
|
||||
may be assembled after its imports. Sequence-item contributions in that tier are
|
||||
nodes in the same schedule; each selected item first assembles its own strict
|
||||
mapping scope, with helpers before the bodies that import them. Those local scopes
|
||||
do not use application collection policies and are not flattened into the outer
|
||||
schedule. Item positions and diagnostic paths follow the resulting assembly order.
|
||||
Each underlay group likewise acquires its inputs before dependency-ready assembly
|
||||
in one strict scope. Independent groups remain separate precedence tiers; at a
|
||||
declared or registered destination, the assembled higher sequence uses the
|
||||
resolved collection operation against the lower sequence. Other sequences retain
|
||||
whole-value replacement between tiers. Assembly order never permits replacement.
|
||||
|
||||
Acquisition order and resolution order serve different purposes: acquisition
|
||||
assigns source records and callback order, while the shared scheduler determines
|
||||
composition order among currently dependency-ready contributions. Inheritance
|
||||
uses the same eligibility rule within each destination-local sibling scope; it
|
||||
does not reorder the authored output mapping. The private namespace and stable-key
|
||||
construction is described in the [import implementation overview](internal/imports.md).
|
||||
|
||||
An empty selection is allowed by default. `required: true` makes an empty directory
|
||||
or glob selection fail with `import.missing`; it also makes missing named paths
|
||||
fail. Requiredness does not suppress invalid paths, containment failures,
|
||||
directories selected as files, permissions, parse errors, or composition errors.
|
||||
|
||||
For inspection, `SourceInfo.ImportSelections` records one outcome for each
|
||||
completed optional declaration whose entire selected set is empty. The record is
|
||||
attached to its declaring source, keeps the declaration path local to that source,
|
||||
identifies the declaration mapping (including its `SourceID`) and selector, and
|
||||
uses outcome `none`. Records remain in declaration order and are available
|
||||
through the existing result, load
|
||||
error, and callback source snapshots. They do not enumerate candidates, explain
|
||||
why a path was absent, or describe partial omissions. Empty `path: []` succeeds
|
||||
regardless of requiredness: it produces a `none` record when effectively optional,
|
||||
and no record when effectively required. Snapshots detach the record paths and
|
||||
slices.
|
||||
|
||||
Each declaration may include `placement: mapping` or
|
||||
`placement: sequence-item`. Omission means `mapping` for that declaration,
|
||||
including every match of a glob; placement is not inherited. Sequence-item
|
||||
placement requires a nonempty effective destination and wraps the imported
|
||||
mapping as one element of a sequence there. It does not split source sequences
|
||||
or infer identity fields. Repeated sequences combine only when the destination
|
||||
has an applicable YAML identity declaration or application registration; source
|
||||
placement remains independent of collection identity and operation. Sequence
|
||||
records retain source locations through placement and history; indexes describe
|
||||
their assembled positions while source coordinates continue to identify the
|
||||
declaring files.
|
||||
Header/version policy retains its existing owner and timing. Application
|
||||
registrations also enforce identity on the final effective typed configuration;
|
||||
YAML-only declarations are enforced at structural combination and inheritance
|
||||
boundaries. The [configuration contract](#strict-document-composition) defines
|
||||
these validation boundaries.
|
||||
|
||||
An item import first opens a local mapping scope. Its body and ordinary helper
|
||||
imports compose within that record. An omitted helper destination or
|
||||
`destination: []` places the helper at the record root; other destinations are
|
||||
relative to the immediate importing source. A helper may supply fields such as
|
||||
the record's `id`. An explicit nested `sequence-item` import creates a sequence
|
||||
inside the record, and its placement is independent of the enclosing item.
|
||||
At the record root, an item import must name a nonempty nested destination.
|
||||
Physical paths continue to resolve from the file that declares each import.
|
||||
An actual empty mapping contributes an empty item, while an unmatched declaration
|
||||
with default or explicit `required: false` contributes nothing.
|
||||
|
||||
For host files, the configuration root defaults to the selected file's canonical
|
||||
directory. A host file may supply `FilesystemContext.Root` to widen that root but
|
||||
@@ -759,8 +1306,13 @@ 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. Native filesystems use slash-path operations
|
||||
and their own symlink behavior; imports stay in their selected namespace and root.
|
||||
distinct paths remain distinct sources. Native filesystems use slash-path
|
||||
operations. Where the supplied `fs.FS` implements `fs.ReadLinkFS`, ConfigKit uses
|
||||
that capability to inspect and resolve discovered links; otherwise it follows
|
||||
the filesystem's ordinary `fs.FS` behavior. ConfigKit does not require every
|
||||
native filesystem to expose link inspection, and native filesystem behavior is
|
||||
not a general sandbox guarantee. Imports stay in their selected namespace and
|
||||
root.
|
||||
|
||||
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
|
||||
|
||||
@@ -6,6 +6,30 @@ applications. Consumers still own their configuration structs, defaults, semanti
|
||||
validation, schema-version policy, CLI parsing, runtime matching and conversion,
|
||||
resource checks, remote acquisition, and service initialization.
|
||||
|
||||
## Choosing a loading entry point
|
||||
|
||||
- Use [`Load`](../../lifecycle.go) with one source for an ordinary configuration.
|
||||
[`SearchConfig`](../../discovery.go) is also a source, so its explicit
|
||||
discovery result can be passed to `Load` or used as the main input to
|
||||
[`LoadLayered`](../../layered.go).
|
||||
- Use `LoadLayered` when inputs must be composed in underlay groups before the
|
||||
main source. Each [`Input`](../../layered.go) owns its source, filesystem,
|
||||
destination, and import choice. The overlay filesystem context is selected
|
||||
independently in [`LayeredOptions`](../../layered.go).
|
||||
- Load-wide rules belong to [`Options`](../../lifecycle.go) or
|
||||
[`LayeredOptions`](../../layered.go): the application definition (including
|
||||
version policy), import defaults, and explicit overrides. These do not turn
|
||||
on imports or discovery by themselves. See the
|
||||
[configuration contract](../config.md#layered-loading) and
|
||||
[discovery contract](../config.md#configuration-discovery) for exact rules.
|
||||
|
||||
The root [README example](../../README.md) shows the small `Load` path.
|
||||
[`layered.go`](../../examples/consumers/layered.go) demonstrates the bundled
|
||||
file and layered cases. For import-focused adoption, see the
|
||||
[prompt catalog](../../examples/consumers/prompt_catalog.go); for explicit
|
||||
discovery composed with sequences, see the
|
||||
[sequence catalog](../../examples/consumers/sequence_catalog.go).
|
||||
|
||||
The maintained [`examples/consumers`](../../examples/consumers) package demonstrates
|
||||
sanitized shapes using only ConfigKit and the Go standard library:
|
||||
|
||||
@@ -17,7 +41,11 @@ sanitized shapes using only ConfigKit and the Go standard library:
|
||||
| Seriatim-like | Independent strict ordered speaker and autocorrect rule files | First-match and ordered replacement algorithms; no central application configuration file |
|
||||
| Bundled file | Simple native `fs.FS` source through ordinary `Load` | Selection and lifetime of the application-owned embedded filesystem |
|
||||
| Layered catalog | Embedded defaults with an import, a mapping underlay, an independent host main file, explicit false/zero input, and source-relative resource access | Domain validation and opening/interpreting the template resource |
|
||||
| Mounted collections | Entry-declared relative destinations, nested metadata/prompt/profile imports, local inheritance, and a version-only higher-tier override | Collection IDs, profile meaning, and opening/interpreting prompt resources |
|
||||
| Named presets | One-load sibling inheritance, explicit false, nested destination resolution, and source/derivation inspection | Choosing and executing a preset |
|
||||
| Effective configuration diff | Same-type frozen effective comparisons for two selected files or base-versus-overlay impact | Source/options selection and consumer-owned safe presentation |
|
||||
| Shared source-version checks | One `Definition.Version` policy for accepted declarations across participating source documents, with optional or required declarations and sanitized evidence | Choosing which declarations the application accepts; final assembled schema and domain validation |
|
||||
| Sequence catalogs | Declared collection operations, record inheritance, typed CLI and resolution, inspection, exact and wildcard application registrations | Catalog meaning and final domain validation |
|
||||
|
||||
The YAML under [`testdata`](../../examples/consumers/testdata) is loaded by the
|
||||
executable examples and ordinary tests. It contains no private application data,
|
||||
@@ -51,6 +79,70 @@ both without redefining their syntax. ConfigKit overlays are configuration overl
|
||||
they do not select or replace an application's LLM, model, pipeline, report, or
|
||||
execution profile concepts.
|
||||
|
||||
## Explicit main-file discovery
|
||||
|
||||
Applications choose the search conventions and their precedence, then pass the
|
||||
resulting `SearchConfig` source to the same `Load` or `LoadLayered` entry point.
|
||||
For example, the maintained sequence catalog puts a caller-selected file before
|
||||
a custom directory fallback while keeping its underlay and overlay choices
|
||||
independent. See [`sequence_catalog.go`](../../examples/consumers/sequence_catalog.go)
|
||||
and its [behavior tests](../../examples/consumers/sequence_catalog_test.go), plus
|
||||
the [working-directory executable example](../../examples/consumers/discovery_test.go).
|
||||
Together, the root-owned [discovery API](../../discovery.go) and [configuration
|
||||
discovery contract](../config.md#configuration-discovery) define ordered
|
||||
concatenation, directory-first Cartesian expansion, optional exhaustion, and
|
||||
selected-file behavior. [ADR 0019](../adr/0019-explicit-configuration-discovery.md)
|
||||
records why discovery is explicitly selected.
|
||||
|
||||
## Import defaults and selection inspection
|
||||
|
||||
`Options.ImportDefaults` and `LayeredOptions.ImportDefaults` apply one
|
||||
load-wide policy to enabled main, underlay, and transitive imports. Each field
|
||||
is resolved independently: a declaration's explicit value wins, then the
|
||||
application default, then the library default. `Required`, `Follow`, `Hidden`,
|
||||
and `Recursive` use nil pointers for unspecified values. `Pattern` applies to
|
||||
directory searches: nil leaves filenames unrestricted, while a non-nil empty
|
||||
slice selects no files. A declaration's own `pattern` replaces the application
|
||||
default. The defaults do not enable imports or change selected-overlay behavior.
|
||||
|
||||
`Result.Sources`, `LoadError.Sources`, and `Resolution.Source` expose each
|
||||
declaring source's `ImportSelections`. These records identify the source-local
|
||||
declaration path, its authored location and selector, and the `none` outcome;
|
||||
they do not include candidate names or a guessed reason for absence. Returned
|
||||
records are detached snapshots. The [configuration contract](../config.md#explicit-imports)
|
||||
owns selector applicability and exact absence semantics.
|
||||
|
||||
## Shared source-version checks
|
||||
|
||||
[`LoadVersionedService`](../../examples/consumers/source_version.go) demonstrates
|
||||
if-present and required modes with one `Definition.Version` rule across a mounted
|
||||
main input and an underlay. The example reads only sanitized
|
||||
`SourceInfo.VersionCheck` fields; its executable test is in
|
||||
[`layered_test.go`](../../examples/consumers/layered_test.go). The
|
||||
[configuration contract](../config.md#shared-source-version-checks) defines
|
||||
participation, source-local lookup and diagnostics, non-main header consumption,
|
||||
main-path protection, default behavior, and evidence disclosure. Accepted
|
||||
declarations do not replace final assembled decoding or application validation.
|
||||
|
||||
## Effective configuration diffs
|
||||
|
||||
[`CompareFiles`](../../examples/consumers/diff.go) loads two caller-selected files
|
||||
with one application definition, handles each load error, calls `configkit.Diff`,
|
||||
and presents the safe records through consumer-owned `FormatDifferences`.
|
||||
[`CompareRolloutOverlay`](../../examples/consumers/diff.go) loads the same base
|
||||
once without an overlay and once with the selected overlay, so omitted lower
|
||||
priority values remain present. Its resolver records a derived endpoint URL after
|
||||
overlay application, and its nested typed secret reference is classified before
|
||||
the resulting records are presented.
|
||||
|
||||
The maintained [behavior tests and executable examples](../../examples/consumers/diff_test.go)
|
||||
use temporary files and disposable secret values. Presenters should use
|
||||
`ValueSnapshot.Present` for absence, record membership for change, and
|
||||
`HiddenChanged` for a withheld change indicator. Do not parse the redaction marker
|
||||
or add separately rendered full contexts that lack combined sensitivity. The
|
||||
[configuration contract](../config.md#effective-configuration-diffs) owns shared
|
||||
equality, granularity, and disclosure semantics.
|
||||
|
||||
## Named sibling inheritance
|
||||
|
||||
[`LoadPresets`](../../examples/consumers/inheritance.go) loads the maintained
|
||||
@@ -88,6 +180,79 @@ and may fail independently of the completed configuration load. The
|
||||
[configuration contract](../config.md#source-resource-contexts) owns exact
|
||||
lifetime and precedence rules.
|
||||
|
||||
[`LoadPromptBundle`](../../examples/consumers/layered.go) is the ordinary-root
|
||||
counterpart: its entry opts into imports through `Load`, imports metadata at the
|
||||
root, and places a prompt beneath `prompts` using YAML. The executable example
|
||||
and behavior test are in
|
||||
[`layered_test.go`](../../examples/consumers/layered_test.go).
|
||||
|
||||
## Input destinations and mounted collections
|
||||
|
||||
Main and underlay inputs use the same destination field. An import without a
|
||||
`destination` inherits its declaring document's effective destination; an
|
||||
explicit sequence of key strings appends a relative suffix. Nested imports
|
||||
append to their immediate parent's effective destination, while filesystem
|
||||
paths remain relative to the declaring file. Destinations are application
|
||||
mapping paths, independent of filesystem context. The configuration contract
|
||||
owns the complete behavior and validation rules.
|
||||
|
||||
The small [`Example_inputDestination`](../../examples/consumers/destinations_test.go)
|
||||
mounts one mapping source beneath literal collection/prompt keys and loads a
|
||||
second input without a destination to show that root placement remains the
|
||||
default. Consumers pass paths, not filenames or namespace strings for ConfigKit
|
||||
to interpret.
|
||||
|
||||
[`LoadCollectionBundle`](../../examples/consumers/destinations.go) assembles an
|
||||
embedded collection manifest mounted beneath a literal collection path. The
|
||||
entry's YAML places nested metadata imports at the collection root and prompt
|
||||
and profile peers beneath their own keys. A higher-priority input changes only
|
||||
one prompt's ordinary `version` field. The main document alone supplies the
|
||||
application schema version. A prompt's local `extends` relationship resolves
|
||||
after all tiers, so `custom` inherits the final `brief` content and fields while
|
||||
retaining its own profile selection.
|
||||
|
||||
The resolver follows each effective `content_file` origin to its declaring
|
||||
source context. This also works for `custom`'s inherited resource. The
|
||||
application-owned [`ReadCollectionPromptContent`](../../examples/consumers/destinations.go)
|
||||
demonstrates the same lookup after loading; ConfigKit does not open or interpret
|
||||
the resource. [`destinations_test.go`](../../examples/consumers/destinations_test.go)
|
||||
checks source destinations and authored coordinates, presence, inherited values,
|
||||
relative resources, and strict peer conflicts. The
|
||||
[configuration contract](../config.md#input-destinations) owns shared placement,
|
||||
import, source-location, and ownership behavior.
|
||||
|
||||
## Identified prompt sequences
|
||||
|
||||
[`LoadPromptCatalog`](../../examples/consumers/prompt_catalog.go) shows the
|
||||
smaller import-focused adoption path: a registered `prompts.id` identity,
|
||||
directory and glob selection, a helper import that supplies an item's ID, and
|
||||
resource lookup through the effective source context. Its executable examples
|
||||
and tests are in
|
||||
[`prompt_catalog_test.go`](../../examples/consumers/prompt_catalog_test.go), with
|
||||
complete [catalog](../../examples/consumers/testdata/prompt-catalog/config.yml)
|
||||
and [required-file](../../examples/consumers/testdata/prompt-catalog/simple.yml)
|
||||
fixtures.
|
||||
|
||||
[`LoadSequenceCatalog`](../../examples/consumers/sequence_catalog.go) integrates
|
||||
an underlay declaration, imported main fragments, identified-record inheritance,
|
||||
selected overlay `append`, `upsert`, and `replace`, a typed CLI override, and
|
||||
recorded application resolution. Its tests and runnable example demonstrate
|
||||
stable output order, whole-record upsert, provenance/history inspection,
|
||||
resource lookup for inherited records, and secret-safe explanation in
|
||||
[`sequence_catalog_test.go`](../../examples/consumers/sequence_catalog_test.go).
|
||||
[`LoadNamespacedMapCatalog`](../../examples/consumers/sequence_catalog.go) adds
|
||||
an exact root registration and a wildcard namespace registration, showing that
|
||||
each concrete sequence has an independent identity scope. The same tests show
|
||||
that a YAML identity declaration without an application registration does not
|
||||
create final-result identity enforcement.
|
||||
|
||||
The examples keep prompt meaning and domain checks in the consuming application.
|
||||
The [configuration contract](../config.md#strict-document-composition) owns
|
||||
declaration, identity, operation, ownership, and final-validation semantics;
|
||||
[sibling inheritance](../config.md#sibling-inheritance) defines record
|
||||
relationships, and the [CLI contract](../config.md#explicit-cli-overrides) owns
|
||||
override ordering.
|
||||
|
||||
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-
|
||||
@@ -167,7 +332,9 @@ focused behavioral owner:
|
||||
| Canonical module objects and dynamic option handling | WeatherReporter- and Notarius-shaped examples and migration tests |
|
||||
| `LoadSecretsDir`, bootstrap reporting, typed lookup, CLI explanation | `Example_weatherStartup` |
|
||||
| Native filesystem, grouped underlays, programmatic mapping, retained resource context | `Example_nativeFilesystem`, `Example_layeredCatalog`, and their focused consumer tests |
|
||||
| Literal input destinations, root-default compatibility, and mounted peer collections | `Example_inputDestination`, `LoadCollectionBundle`, and `destinations_test.go` |
|
||||
| Named sibling inheritance, presence, and derivation | `Example_inheritedPresets` and `TestPresetCatalogOneLoadAndDerivation` |
|
||||
| Effective configuration diffs and consumer-owned presentation | `ExampleDiff`, `ExampleCompareFiles`, and `ExampleCompareRolloutOverlay` |
|
||||
| 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
|
||||
|
||||
@@ -39,9 +39,11 @@ public behavior.
|
||||
| Task | Read |
|
||||
| --- | --- |
|
||||
| Repository orientation or component responsibility | [Component overview](internal/overview.md) |
|
||||
| Input destinations | [Configuration contract](config.md#input-destinations), [ADR 0011](adr/0011-input-destinations.md), and the destination tests below |
|
||||
| Shared source-version checks | [Configuration contract](config.md#shared-source-version-checks), [ADR 0013](adr/0013-shared-source-version-checks.md), and the version/source-version tests below |
|
||||
| Documentation changes | The owning document |
|
||||
| Licensing, dependencies, attribution, or release-document changes | [Project grant](../COPYRIGHT) and [attribution](oss/attribution.md); release changes also follow the release procedure |
|
||||
| Architecture decisions or future work | [Deferred scope](roadmap/deferred.md) and relevant ADRs listed below |
|
||||
| Architecture decisions or future work | [Future features](roadmap/future.md) and relevant ADRs listed below |
|
||||
| Code and test changes | 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 procedure](release.md) |
|
||||
@@ -49,11 +51,19 @@ public behavior.
|
||||
Accepted decisions:
|
||||
|
||||
- [Separate composition and overlay](adr/0001-separate-composition-and-overlay.md)
|
||||
- [Check source versions with one shared policy](adr/0013-shared-source-version-checks.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)
|
||||
- [Compare frozen effective configuration snapshots](adr/0010-effective-configuration-diff.md)
|
||||
- [Place inputs at literal application mapping paths](adr/0011-input-destinations.md)
|
||||
- [Share dependency-ready ordering](adr/0015-shared-dependency-ready-ordering.md)
|
||||
- [Separate import selection controls](adr/0016-independent-import-controls.md)
|
||||
- [Define identity and merge rules for each collection](adr/0017-collection-sequence-policy.md)
|
||||
- [Apply CLI overrides after inheritance and decoding](adr/0018-cli-after-structural-resolution.md)
|
||||
- [Keep main-file discovery explicit and composable](adr/0019-explicit-configuration-discovery.md)
|
||||
|
||||
For cross-cutting changes, follow every applicable row. Completed implementation
|
||||
and remediation plans are preserved in Git history at `cb5b435`; use current
|
||||
@@ -103,7 +113,86 @@ git show 8e91a2b:docs/roadmap/startup-benchmark.md
|
||||
```
|
||||
|
||||
Remaining optimization candidates belong to
|
||||
[deferred scope](roadmap/deferred.md#deferred-performance-experiments).
|
||||
[future performance experiments](roadmap/future.md#3-performance-experiments).
|
||||
|
||||
The completed effective-configuration diff roadmap, implementation plan, and
|
||||
resolved review findings are preserved in Git history at `1c2610c` under
|
||||
`docs/roadmap/`. The
|
||||
[configuration contract](config.md#effective-configuration-diffs) owns current
|
||||
comparison and disclosure behavior, and
|
||||
[ADR 0010](adr/0010-effective-configuration-diff.md) records the decision.
|
||||
|
||||
The completed input-destination, relative-import-destination, and shared
|
||||
source-version roadmaps, associated consumer request, implementation plan, and
|
||||
resolved review findings are preserved in Git history at `e4270c9` under
|
||||
`docs/roadmap/`. For example:
|
||||
|
||||
```sh
|
||||
git show e4270c9:docs/roadmap/implementation.md
|
||||
git show e4270c9:docs/roadmap/remediation.md
|
||||
```
|
||||
|
||||
Current behavior belongs to the configuration contract's
|
||||
[input destinations](config.md#input-destinations),
|
||||
[imports](config.md#explicit-imports), and
|
||||
[shared source-version checks](config.md#shared-source-version-checks) sections.
|
||||
Explicit sequence-item placement, collection operations, identity registration,
|
||||
and final registered-identity validation are owned by the
|
||||
[strict composition](config.md#strict-document-composition) section;
|
||||
[imports](config.md#explicit-imports) owns acquisition and scheduling. The
|
||||
[maintained sequence catalog](consumers/README.md#identified-prompt-sequences)
|
||||
shows the consumer boundary, and [ADR 0017](adr/0017-collection-sequence-policy.md)
|
||||
records the collection decision.
|
||||
|
||||
The completed sequence-composition roadmap, implementation plan, and resolved
|
||||
remediation record are preserved in Git history at `008d2ba` under
|
||||
`docs/roadmap/`. For example:
|
||||
|
||||
```sh
|
||||
git show 008d2ba:docs/roadmap/sequence-composition.md
|
||||
git show 008d2ba:docs/roadmap/implementation.md
|
||||
git show 008d2ba:docs/roadmap/remediation.md
|
||||
```
|
||||
|
||||
The completed import-discovery roadmap, implementation plan, and resolved
|
||||
remediation record are preserved in Git history at `5747ec4` under
|
||||
`docs/roadmap/`. For example:
|
||||
|
||||
```sh
|
||||
git show 5747ec4:docs/roadmap/imports.md
|
||||
git show 5747ec4:docs/roadmap/implementation.md
|
||||
git show 5747ec4:docs/roadmap/remediation.md
|
||||
```
|
||||
|
||||
The subsequent import-integration audit and completed remediation record are
|
||||
preserved at `6903c9e:docs/roadmap/remediation.md`; retrieve them with
|
||||
`git show 6903c9e:docs/roadmap/remediation.md`.
|
||||
|
||||
The completed sequence-identity/collection-policy roadmap and implementation
|
||||
plan are preserved in Git history at `d0a605a`:
|
||||
|
||||
```sh
|
||||
git show d0a605a:docs/roadmap/sequences.md
|
||||
git show d0a605a:docs/roadmap/implementation.md
|
||||
```
|
||||
|
||||
Current behavior belongs to the configuration contract's
|
||||
[strict composition](config.md#strict-document-composition),
|
||||
[sibling inheritance](config.md#sibling-inheritance), and
|
||||
[CLI overrides](config.md#explicit-cli-overrides) sections.
|
||||
The completed remediation record, including the algorithm-audit findings, is
|
||||
preserved at `ea46001:docs/roadmap/remediation.md`.
|
||||
|
||||
The completed configuration-discovery roadmap, implementation plan, and
|
||||
remediation record are preserved in Git history at `0369111`, for example
|
||||
`git show 0369111:docs/roadmap/remediation.md`. Current behavior belongs to
|
||||
[configuration discovery](config.md#configuration-discovery); the public
|
||||
[discovery declarations](../discovery.go) own the Go API.
|
||||
|
||||
The [consolidation roadmap](roadmap/consolidation.md) owns the current refactoring
|
||||
proposal and the associated complexity-audit findings.
|
||||
|
||||
Potential enhancements are tracked in [future features](roadmap/future.md).
|
||||
|
||||
Install the Go version required by `go.mod` or a compatible newer toolchain.
|
||||
Acquire declared dependencies explicitly before entering an offline
|
||||
@@ -151,18 +240,25 @@ code changes. Use the component overview to find the corresponding implementatio
|
||||
| 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'` |
|
||||
| File/reader access, imports, globs, containment | [Import internals](internal/imports.md), [source acquisition](../internal/engine/source_test.go), [FIFO](../internal/engine/source_fifo_test.go), [import traversal](../internal/engine/imports_test.go), [import evidence](../internal/engine/import_evidence_test.go), [relative destinations](../internal/engine/destination_import_test.go), [globs](../internal/engine/glob_test.go) | `go test ./internal/engine -run 'Source\|Import\|Glob\|Filesystem\|Nonregular\|RegularFile'` |
|
||||
| Explicit main-file discovery and absent-input loading | [Candidate selection](../internal/engine/discovery_test.go), [load integration](../internal/engine/discovery_load_test.go), [public facade](../facade_discovery_test.go), and maintained [catalog](../examples/consumers/sequence_catalog_test.go) and [preset](../examples/consumers/discovery_test.go) examples | `go test ./internal/engine -run 'Discovery|Search'`, `go test . -run 'Search'`, and `go test ./examples/consumers -run 'WorkingDirectoryPreset|SequenceCatalog'` |
|
||||
| Import defaults, empty-selection evidence, and dependency-ready ordering | [Defaults and facade evidence](../facade_import_defaults_test.go), [engine import evidence](../internal/engine/import_evidence_test.go), [scheduler](../internal/engine/dependency_order_test.go), [scheduling integrations](../internal/engine/imports_test.go) | `go test ./internal/engine -run 'Import|ScheduleDependencies|Inheritance'`, `go test . -run 'ImportDefaults|FacadeImport'`, and `go test ./examples/consumers` |
|
||||
| Native filesystem and mapping sources | [Native acquisition](../internal/engine/native_filesystem_test.go), [filesystem facade](../facade_filesystem_test.go), [mapping facade](../facade_mapping_test.go) | `go test ./internal/engine -run 'NativeFilesystem'` and `go test . -run 'TestFacade'` |
|
||||
| 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'` |
|
||||
| Sibling inheritance, provenance, history, sensitivity | [Expansion](../internal/engine/inheritance_test.go), [destination scopes](../internal/engine/inheritance_walk_test.go), [origins](../internal/engine/inheritance_origin_test.go), [history](../internal/engine/inheritance_history_test.go), [secrets](../internal/engine/inheritance_secret_test.go), [public loading](../facade_inheritance_test.go) | `go test ./internal/engine -run 'Inheritance\|Inherited'` and `go test . -run '^TestFacadeInheritance\|^TestFacadeInheritedSecrets'` |
|
||||
| Sequence identity, declarations, and operations | [Identity](../internal/engine/sequence_identity_test.go), [registrations](../internal/engine/sequence_registration_test.go), [declarations](../internal/engine/sequence_declaration_test.go), [combination](../internal/engine/sequence_combine_test.go), [final facade validation](../facade_final_sequence_identity_test.go), and [consumer catalog](../examples/consumers/sequence_catalog_test.go) | `go test ./internal/engine -run 'Sequence\|Collection'`, `go test . -run 'SequenceIdentity\|SequenceRegistration'`, and `go test ./examples/consumers` |
|
||||
| Sibling inheritance, provenance, history, sensitivity | [Implementation flow](internal/overview.md#implementation-flow), [expansion](../internal/engine/inheritance_test.go), [destination scopes](../internal/engine/inheritance_walk_test.go), [origins](../internal/engine/inheritance_origin_test.go), [history](../internal/engine/inheritance_history_test.go), [secrets](../internal/engine/inheritance_secret_test.go), [public loading](../facade_inheritance_test.go) | `go test ./internal/engine -run 'Inheritance\|Inherited'` and `go test . -run '^TestFacadeInheritance\|^TestFacadeInheritedSecrets'` |
|
||||
| Layered loading and source equivalence | [Grouped lifecycle](../internal/engine/load_request_test.go), [layered facade](../facade_layered_test.go), [equivalence](../facade_equivalence_test.go) | `go test ./internal/engine -run '^TestGrouped'`, `go test . -run '^TestFacadeLayered'`, and `go test . -run '^TestEquivalentSourceForms'` |
|
||||
| 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'` |
|
||||
| Input and relative-import destinations, mounted sources, and source ownership | [Destination engine tests](../internal/engine/destination_test.go), [tier tests](../internal/engine/destination_tiers_test.go), [import traversal and version tests](../internal/engine/destination_import_test.go), [accounting tests](../internal/engine/destination_accounting_test.go), [lifecycle inspection tests](../internal/engine/destination_import_lifecycle_test.go), [public source/error tests](../facade_destination_test.go), [resource contexts](../facade_resource_context_test.go), and [consumer examples](../examples/consumers/destinations_test.go) | `go test ./internal/engine -run 'Destination|Import|Glob|Mounted'`, `go test . -run 'Destination|Import|SourceContext|FacadeMounted'`, and `go test ./examples/consumers` |
|
||||
| Shared source-version modes and evidence | [Engine source-version tests](../internal/engine/source_version_test.go), [public facade tests](../facade_source_version_mode_test.go), and [consumer example tests](../examples/consumers/layered_test.go) | `go test ./internal/engine -run 'SourceVersion'`, `go test . -run 'SourceVersion'`, and `go test ./examples/consumers` |
|
||||
| Schema, defaults, projection, cloning | [Implementation flow](internal/overview.md#implementation-flow), [schema](../internal/engine/schema_test.go), [decoding](../internal/engine/decode_test.go), [projection and typed cloning](../internal/engine/projection_test.go), [deferred clone review](roadmap/future.md#4-clone-traversal-review) | `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'` |
|
||||
| Passive resource contexts | [Engine contexts](../internal/engine/resource_context_test.go), [public contexts](../facade_resource_context_test.go) | `go test ./internal/engine -run 'Resource'` and `go test . -run 'SourceContext'` |
|
||||
| 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), [native and layered examples](../examples/consumers/layered_test.go), [API examples](../example_test.go) | `go test ./examples/consumers` and `go test . -run '^Example'` |
|
||||
| Effective configuration diff | [Engine comparison and masking tests](../internal/engine/diff_test.go), [engine operation tests](../internal/engine/diff_operation_test.go), [root facade tests](../facade_diff_test.go), and [consumer examples](../examples/consumers/diff_test.go) | `go test ./internal/engine -run 'Diff|Explain|History|Secret|Independent'`, `go test . -run 'Diff|FacadeInspection|PublicFacadeBoundary|DependencyImportBoundary'`, `go test ./examples/consumers`, and `go test . -run '^ExampleDiff$'` |
|
||||
| Consumer examples and migration fixtures | [Consumer tests](../examples/consumers/consumers_test.go), [native, layered, and ordinary relative-import examples](../examples/consumers/layered_test.go), [mounted collection examples](../examples/consumers/destinations_test.go), [API examples](../example_test.go) | `go test ./examples/consumers` and `go test . -run '^Example'` |
|
||||
| Assembly and layered scaling evidence | [Assembly benchmarks](../internal/engine/assembly_bench_test.go), [layered benchmarks](../internal/engine/underlay_bench_test.go) | `go test ./internal/engine -run '^$' -bench '^BenchmarkUnderlay' -benchmem`; repeat with `^BenchmarkCompose` and `^BenchmarkOverlay` |
|
||||
| Identified-sequence scaling evidence | [Mixed-upsert benchmark](../internal/engine/sequence_combine_bench_test.go) | `go test ./internal/engine -run '^$' -bench '^BenchmarkSequenceMixedUpsert$' -benchmem` |
|
||||
| Inheritance scaling evidence | [Expansion benchmarks](../internal/engine/inheritance_bench_test.go) | `go test ./internal/engine -run '^$' -bench '^BenchmarkInheritance' -benchmem` |
|
||||
| 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'` |
|
||||
| Development check behavior | [Development checker test](../development_check_test.go) | `go test . -run '^TestDevelopmentCheckHandlesFilenamesAndStopsOnFailure$'` |
|
||||
|
||||
98
docs/internal/imports.md
Normal file
98
docs/internal/imports.md
Normal file
@@ -0,0 +1,98 @@
|
||||
# Import Discovery and Scheduling Internals
|
||||
|
||||
The [configuration contract](../config.md#explicit-imports) owns selector syntax,
|
||||
defaults, requiredness, placement, inspection evidence, and observable ordering.
|
||||
This note records implementation flow and private identities for maintainers.
|
||||
|
||||
## Discovery and acquisition
|
||||
|
||||
Each source's root directive is parsed into presence-aware declarations and
|
||||
resolved against load-wide defaults. `acquisition.go` owns shared acquisition
|
||||
policy, load accounting, and per-tier accounting; `import_acquisition.go` owns
|
||||
reading candidate bytes, declaration traversal, and import admission. Candidate
|
||||
selection in `import_selection.go` preserves a normalized
|
||||
namespace path separately from canonical access identity: overlapping roots
|
||||
coalesce the same candidate spelling in that namespace, while aliases reached
|
||||
through distinct spellings retain separate inclusion routes for admission.
|
||||
Directory searches walk each distinct normalized root once, apply all filename
|
||||
patterns to discovered names, and form a set union of matching candidates across
|
||||
overlapping roots. Exclusions are applied as set difference after discovery and
|
||||
candidate normalization. They do not suppress discovery or resource errors.
|
||||
|
||||
Directory traversal tracks canonical directories on the active traversal path.
|
||||
It adds an identity when descending and removes it when the frame is popped, so
|
||||
only a back-edge to an active directory is a traversal cycle; a later alias is
|
||||
not mistaken for one. This is separate from import dependency-cycle detection.
|
||||
For host sources, discovery uses rooted `Lstat` and canonical containment.
|
||||
`filesystem_context.go` owns resolved access facts and live access setup; the
|
||||
acquisition operation closes its live handles before composition. Native sources
|
||||
use `fs.ReadLinkFS` inspection when supplied; otherwise traversal follows the
|
||||
capabilities and behavior of the provided `fs.FS`, without claiming a general
|
||||
sandbox guarantee. `source_context.go` derives passive resource contexts from
|
||||
resolved sources for later caller-owned reads; those records do not retain live
|
||||
acquisition handles.
|
||||
|
||||
Acquisition remains authored: declarations are visited in source order and each
|
||||
selected candidate set in namespace-path lexical order. This preserves source
|
||||
records, IDs, source-local version checks, and callback ordering. The acquired
|
||||
inclusion graph is retained separately from composition scopes so cycles that
|
||||
cross item boundaries remain detectable. Source discovery does not itself choose
|
||||
sequence assembly order.
|
||||
|
||||
## Stable keys and scheduling
|
||||
|
||||
The shared scheduler compares immutable tuples component by component using Go's
|
||||
bytewise string order; if one tuple is a prefix of another, the shorter tuple
|
||||
sorts first. The import key is
|
||||
`(namespace-kind, namespace-anchor, resource-kind, resource-path)`:
|
||||
|
||||
- Namespace kind is `host` or `native`. Host resource paths are canonical
|
||||
absolute paths rendered with slash separators; native paths retain their
|
||||
namespace-relative identity.
|
||||
- Anchors come from explicit input positions, not discovery order: main is
|
||||
`(0,0)` and underlay/input positions are `(group+1,input+1)`, each encoded as
|
||||
fixed-width decimal. Host accesses in one scheduled tier share its host anchor.
|
||||
A native filesystem namespace uses its earliest explicit selecting position
|
||||
in that tier. Filesystem tokens distinguish namespace equality, not sort order,
|
||||
so separately constructed filesystems with the same label remain distinct.
|
||||
- Resource kind is `file` or `synthetic`. A synthetic root uses its explicit
|
||||
input anchor and an empty resource path rather than a display label or token
|
||||
address.
|
||||
|
||||
Inheritance keys are
|
||||
`("definition", scope-pointer, definition-pointer)`, where both pointers use
|
||||
canonical escaped semantic path spelling. Source IDs are not part of the key.
|
||||
|
||||
Both integrations use the same Kahn scheduler: it deduplicates dependency edges,
|
||||
keeps currently ready nodes in a min-heap, and selects the smallest ready key
|
||||
after every completion. Its scheduling cost is `O(E + V log V)` time and
|
||||
`O(V + E)` additional memory, excluding key construction, tuple copying, and
|
||||
tuple comparisons. On a stalled graph, deterministic DFS over the residual graph
|
||||
returns a closed cycle rotated to its smallest key; blocked dependents are not
|
||||
reported as cycle members.
|
||||
|
||||
Import composition schedules a main tier or one underlay group as a single
|
||||
strict-scope graph. Applicable collection declarations are gathered for the
|
||||
scope before peer composition, including identity inherited from lower tiers.
|
||||
Identified peer sequences then combine distinct records in assembled source and
|
||||
list order; after the strict scope completes, layered assembly applies the
|
||||
resolved collection operation against the lower value, as defined by the
|
||||
[configuration contract](../config.md#strict-document-composition). Item
|
||||
contributions own nested local strict-mapping scopes; their helpers complete
|
||||
before the item is placed in its outer scope. Inheritance uses the same
|
||||
ready-node rule inside each sibling mapping scope, while preserving the authored
|
||||
mapping order in its output.
|
||||
These scopes do not flatten into one global graph, and independent precedence
|
||||
tiers remain separate.
|
||||
|
||||
The owners are [`acquisition.go`](../../internal/engine/acquisition.go),
|
||||
[`import_selection.go`](../../internal/engine/import_selection.go),
|
||||
[`import_acquisition.go`](../../internal/engine/import_acquisition.go),
|
||||
[`directory_walk.go`](../../internal/engine/directory_walk.go),
|
||||
[`import_graph.go`](../../internal/engine/import_graph.go),
|
||||
[`import_projection.go`](../../internal/engine/import_projection.go),
|
||||
[`dependency_order.go`](../../internal/engine/dependency_order.go), and
|
||||
[`inheritance_schedule.go`](../../internal/engine/inheritance_schedule.go).
|
||||
Their focused behavior is covered by import-selection, acquisition, scheduler,
|
||||
and scheduling integration tests under `internal/engine`; see the
|
||||
[development test map](../development.md#task-to-test-map).
|
||||
@@ -7,20 +7,39 @@ private state; public signatures and fields do not expose engine types.
|
||||
|
||||
Implemented component owners:
|
||||
|
||||
- `internal/engine` owns host and native-filesystem source acquisition,
|
||||
grouped strict composition, recursive precedence assembly, and destination-local
|
||||
sibling inheritance before typed decoding. It also owns schema
|
||||
- `internal/engine` owns host and native-filesystem source acquisition and
|
||||
explicitly selected configuration discovery. A search resolves once during
|
||||
shared acquisition; the selected concrete file or absent-input state then
|
||||
follows the existing composition, metadata, and resource-context lifecycle.
|
||||
It also owns grouped strict composition, recursive precedence assembly,
|
||||
per-input mapping and sequence-item placement, YAML collection declarations
|
||||
and operations for identified sequences, and application identity registrations for strict
|
||||
composition, inheritance, and final validation. It applies the selected
|
||||
collection operation at precedence boundaries and owns dependency-ready,
|
||||
destination-local inheritance before typed decoding. CLI assignment follows
|
||||
inheritance and decoding, before application resolution. The engine also owns
|
||||
shared source-version checking with sanitized declaration evidence.
|
||||
Explicit import discovery supports declared paths, directory searches, and
|
||||
globs; load-wide defaults and source-local empty-selection evidence cross the
|
||||
root facade without changing acquisition order. Import and inheritance
|
||||
resolution share dependency-ready scheduling within their composition scopes.
|
||||
It retains each source's effective placement alongside physical source
|
||||
identity and owns schema
|
||||
inspection, lifecycle coordination, callback auditing,
|
||||
provenance/history, passive source-context retention, secret resolution and
|
||||
bootstrap, and safe inspection rendering. It never imports the root package.
|
||||
bootstrap, safe inspection rendering, and effective-value comparison over
|
||||
frozen inspection snapshots. It never imports the root package.
|
||||
- Root `Source`, `Filesystem`, `Input`, `Underlay`, `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.
|
||||
- Root conversion files explicitly cross the path, record, callback, secret,
|
||||
search-description, and error boundaries. There are no public aliases,
|
||||
anonymous engine embeddings, or public engine bridge methods.
|
||||
- Root `Diff` and `Difference` provide the public comparison boundary and
|
||||
convert detached engine records to root-owned paths and snapshots; consumer
|
||||
examples own presentation of the safe changed regions.
|
||||
- `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, inheritance, decoding,
|
||||
@@ -37,10 +56,89 @@ 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
|
||||
semantics, including
|
||||
[configuration discovery](../config.md#configuration-discovery),
|
||||
[input destinations](../config.md#input-destinations),
|
||||
[shared source-version checks](../config.md#shared-source-version-checks), and
|
||||
[effective configuration diffs](../config.md#effective-configuration-diffs).
|
||||
The [strict composition contract](../config.md#strict-document-composition)
|
||||
owns sequence declarations, identity, operations, ownership, and final
|
||||
validation; [CLI overrides](../config.md#explicit-cli-overrides) own override
|
||||
ordering. The [consumer guide](../consumers/README.md#identified-prompt-sequences)
|
||||
links maintained workflow examples.
|
||||
The [consumer adoption guide](../consumers/README.md) owns application guidance.
|
||||
The [architecture policy](../policy/architecture.md) owns contributor
|
||||
boundaries; [ADR 0006](../adr/0006-public-facade.md) records the facade decision
|
||||
and [ADR 0010](../adr/0010-effective-configuration-diff.md) records the diff
|
||||
decision; [ADR 0011](../adr/0011-input-destinations.md) records the placement
|
||||
decision. See the [development guide](../development.md) for validation commands
|
||||
and [release procedure](../release.md) for source-candidate gates. [ADR
|
||||
0009](../adr/0009-sibling-inheritance.md) records why inheritance is a built-in
|
||||
post-assembly structural operation.
|
||||
post-assembly structural operation. [ADR 0012](../adr/0012-relative-import-destinations.md)
|
||||
records the relative-import extension; [ADR 0013](../adr/0013-shared-source-version-checks.md)
|
||||
records the source-version decision.
|
||||
[ADR 0014](../adr/0014-sequence-placement-and-composition.md) records explicit
|
||||
sequence placement and composition. [ADR 0015](../adr/0015-shared-dependency-ready-ordering.md)
|
||||
records the shared dependency-ready ordering decision; [ADR
|
||||
0016](../adr/0016-independent-import-controls.md) records the independent
|
||||
selection controls. [ADR 0017](../adr/0017-collection-sequence-policy.md) and
|
||||
[ADR 0018](../adr/0018-cli-after-structural-resolution.md) record collection
|
||||
operations and CLI ordering. [ADR 0019](../adr/0019-explicit-configuration-discovery.md)
|
||||
records explicit main-file discovery. The [import internals](imports.md)
|
||||
document candidate and namespace identities, discovery and scheduling, and the
|
||||
bounded algorithms.
|
||||
|
||||
## Implementation flow
|
||||
|
||||
The root facade converts caller-owned values into engine-owned requests. The
|
||||
ordinary and grouped entry points share a normalized `loadRequest`: see
|
||||
[`lifecycle.go`](../../lifecycle.go), [`layered.go`](../../layered.go), and
|
||||
[`load_request.go`](../../internal/engine/load_request.go). Load-wide definition,
|
||||
version, default-import, and override policy stays distinct from each input's
|
||||
source, filesystem, destination, and import settings.
|
||||
|
||||
[`acquisition.go`](../../internal/engine/acquisition.go) defines acquisition
|
||||
policy and load-wide and per-tier accounting state. Each input is acquired under
|
||||
that policy; grouped requests retain acquired underlay scopes until their tier is
|
||||
composed in [`load_request.go`](../../internal/engine/load_request.go). Live
|
||||
filesystem access handles are confined to acquisition and closed there. Resolved
|
||||
filesystem facts used to authorize access are separate from passively retained
|
||||
resource contexts: see
|
||||
[`filesystem_context.go`](../../internal/engine/filesystem_context.go) and
|
||||
[`source_context.go`](../../internal/engine/source_context.go).
|
||||
|
||||
Import responsibilities are split across candidate selection and path
|
||||
normalization in [`import_selection.go`](../../internal/engine/import_selection.go),
|
||||
source reading and traversal admission in
|
||||
[`import_acquisition.go`](../../internal/engine/import_acquisition.go),
|
||||
directory walking in [`directory_walk.go`](../../internal/engine/directory_walk.go),
|
||||
dependency graph scheduling in [`import_graph.go`](../../internal/engine/import_graph.go),
|
||||
and projection of acquired evidence into final source coordinates in
|
||||
[`import_projection.go`](../../internal/engine/import_projection.go). The
|
||||
[import internals](imports.md) page describes the boundaries and identities.
|
||||
|
||||
After acquisition handles are closed, grouped composition and inheritance run on
|
||||
the retained ordered documents. `inheritance.go` coordinates traversal and
|
||||
[`inheritance_schedule.go`](../../internal/engine/inheritance_schedule.go) applies
|
||||
dependency-ready templates;
|
||||
[`inheritance_prepare.go`](../../internal/engine/inheritance_prepare.go)
|
||||
prepares child and parent inputs,
|
||||
[`inheritance_combine.go`](../../internal/engine/inheritance_combine.go)
|
||||
returns the combined value with its origin, policy, sequence, and diagnostic
|
||||
evidence, and [`inheritance_finalize.go`](../../internal/engine/inheritance_finalize.go)
|
||||
accounts for and finalizes the result. Projection and schema work have separate
|
||||
operation owners: [`value_operation.go`](../../internal/engine/value_operation.go)
|
||||
holds per-operation state, [`projection.go`](../../internal/engine/projection.go)
|
||||
builds projected values, [`schema.go`](../../internal/engine/schema.go) owns
|
||||
descriptor caching and type policy, and
|
||||
[`schema_fields.go`](../../internal/engine/schema_fields.go) owns ordinary and
|
||||
inline field assembly. [`decode.go`](../../internal/engine/decode.go) consumes
|
||||
those descriptors for typed decoding.
|
||||
|
||||
Behavioral ownership follows the same handoffs: imports and grouped lifetimes are
|
||||
covered by `imports_test.go`, `load_request_test.go`, and focused source,
|
||||
discovery, and destination tests; inheritance by the inheritance test files;
|
||||
reflection behavior by `schema_test.go`, `projection_test.go`, and
|
||||
`decode_test.go`. The [development test map](../development.md#task-to-test-map)
|
||||
links focused commands. Typed clone extraction remains deferred in
|
||||
[`future.md`](../roadmap/future.md#4-clone-traversal-review).
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Base Policy Version:** 1.3
|
||||
|
||||
**Last Updated**: 2026-09-29
|
||||
**Last Updated**: 2026-10-22
|
||||
|
||||
**Upstream Source:** [Standard Repository Policies](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/architecture.md)
|
||||
|
||||
@@ -19,8 +19,8 @@ to ADRs for decision rationale.
|
||||
ConfigKit is an opinionated Go library for consistent YAML configuration loading
|
||||
and resolution across consuming applications. 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.
|
||||
implemented behavior, and the [future feature list](../roadmap/future.md) preserves
|
||||
candidates for future consideration.
|
||||
|
||||
## Adopted Constraints And Implemented Structure
|
||||
|
||||
@@ -32,6 +32,12 @@ structure as implemented when its changes land.
|
||||
Constraints must reflect deliberately selected requirements and decisions.
|
||||
Do not promote incidental implementation choices into architectural invariants.
|
||||
|
||||
The implemented collection-policy and CLI-ordering constraints below reflect
|
||||
accepted [ADR 0017](../adr/0017-collection-sequence-policy.md) and
|
||||
[ADR 0018](../adr/0018-cli-after-structural-resolution.md). The configuration
|
||||
contract owns their current behavior; these architectural constraints describe
|
||||
the system boundaries and guarantees that guide changes to that behavior.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
ConfigKit prioritizes a coherent configuration model: consistency and
|
||||
@@ -91,17 +97,82 @@ boundaries, and keep simple consumers independent of optional composition
|
||||
features. The [consumer adoption guide](../consumers/README.md) demonstrates the
|
||||
implemented surface against representative simple and complex shapes.
|
||||
|
||||
## Simplicity, Defaults, And Explicit Controls
|
||||
|
||||
These principles apply throughout ConfigKit: to configuration syntax, the public
|
||||
Go API, defaults, and feature design. Evaluate their effects on both configuration
|
||||
authors and consuming applications.
|
||||
|
||||
**Keep straightforward workflows straightforward.** Common configuration
|
||||
workflows should require little configuration text and minimal API setup.
|
||||
Support more demanding workflows through explicit, composable options, without
|
||||
requiring simple consumers to understand or configure those options.
|
||||
|
||||
**Prefer simple, permissive defaults with explicit controls.** Choose predictable
|
||||
defaults that minimize required setup and accommodate valid configurations
|
||||
without unnecessary restrictions. Provide selectable options for workflows that
|
||||
need additional capabilities, narrower behavior, or stronger requirements.
|
||||
|
||||
Measure simplicity by what consumers must understand and maintain, including
|
||||
configuration text and application-side plumbing. Concise syntax is useful, but
|
||||
hidden inference and interacting meanings can make a shorter declaration harder
|
||||
to understand. Prefer explicitness where it makes behavior more predictable.
|
||||
A terse configuration format that requires substantial application-side
|
||||
infrastructure does not satisfy the simplicity principle.
|
||||
|
||||
Define what permissiveness means for each behavior. Allowing an optional
|
||||
contribution to be absent or accepting valid inputs without an unnecessary
|
||||
filename restriction can reduce required setup. Invalid declarations, ambiguous
|
||||
composition, and unreadable selected sources must still produce meaningful
|
||||
errors under the applicable contracts. Permissiveness does not authorize silent
|
||||
recovery from invalid supplied configuration or weaken configuration integrity.
|
||||
|
||||
Options should express coherent choices with understandable purposes and fit
|
||||
ConfigKit's shared configuration model. Support powerful controls when they
|
||||
serve concrete needs; these principles do not require a switch for every
|
||||
implementation behavior or conceivable consumer preference.
|
||||
|
||||
Defaults require judgment when simplicity and permissiveness pull in different
|
||||
directions. Broader discovery can accommodate more layouts while increasing the
|
||||
implicit search scope. Evaluate such tradeoffs explicitly; these principles do
|
||||
not mechanically select the broadest behavior for every option. Changes to
|
||||
existing defaults remain subject to compatibility review, and proposed behavior
|
||||
remains in the roadmap until implemented.
|
||||
|
||||
## Responsibility Boundaries
|
||||
|
||||
ConfigKit owns configuration input processing and the coordination of decoding,
|
||||
resolution, validation, and inspection. Applications own their configuration
|
||||
types, field meanings, defaults, normalization, semantic validation, and schema
|
||||
version policy. Applications also own main-file search conventions, CLI wiring,
|
||||
remote acquisition, runtime initialization, and resource checks.
|
||||
version policy. Applications choose and order their main-file search conventions;
|
||||
ConfigKit provides explicitly selected search descriptions that resolve during
|
||||
loading. Applications also own CLI wiring, remote acquisition, runtime
|
||||
initialization, and resource checks. See the [configuration discovery
|
||||
contract](../config.md#configuration-discovery) and [ADR
|
||||
0019](../adr/0019-explicit-configuration-discovery.md).
|
||||
|
||||
The engine applies each input's mapping destination within the existing source
|
||||
assembly, applies immediate-parent-relative YAML destinations to imports, and
|
||||
retains each effective placement with source identity and inspection records;
|
||||
the root facade owns the public fields and conversions. The
|
||||
[configuration contract](../config.md#input-destinations) defines the observable
|
||||
placement behavior. [ADR 0011](../adr/0011-input-destinations.md) records input
|
||||
placement, and [ADR 0012](../adr/0012-relative-import-destinations.md) records
|
||||
the import extension.
|
||||
|
||||
Library code must not depend on consuming applications or initialize their
|
||||
runtime resources. It must not perform implicit network access, discover
|
||||
application-specific paths, or change process-global state during ordinary `Load`.
|
||||
runtime resources. It must not perform implicit network access, implicitly
|
||||
discover application-specific paths, or change process-global state during
|
||||
ordinary `Load`.
|
||||
Import discovery is available only through declarations in selected sources:
|
||||
explicit `path`, `dir`, and `glob` selectors may search their declared scope, but
|
||||
ConfigKit does not search conventional application paths unless the application
|
||||
explicitly supplies a discovery description, and it never discovers a main
|
||||
configuration file implicitly. Imports and sibling inheritance share
|
||||
dependency-ready scheduling within their existing scopes; acquisition order and
|
||||
precedence-tier boundaries remain distinct. The [configuration contract](../config.md#explicit-imports)
|
||||
owns observable behavior; [ADR 0015](../adr/0015-shared-dependency-ready-ordering.md)
|
||||
and [ADR 0016](../adr/0016-independent-import-controls.md) record the decisions.
|
||||
The explicit secrets-directory bootstrap is a narrow, opt-in exception: applications
|
||||
invoke it during startup, and it reports writes, skips and partial failures. It
|
||||
must never run implicitly as part of configuration loading.
|
||||
@@ -124,6 +195,29 @@ The [configuration contract](../config.md#completion-and-resource-lifetime) owns
|
||||
the precise completion and ownership behavior. The proposed decision record is
|
||||
[ADR 0008](../adr/0008-complete-on-return-loading.md).
|
||||
|
||||
### Accepted Processing Order
|
||||
|
||||
Complete source composition and precedence, including selected overlays, before
|
||||
sibling inheritance. Then apply typed defaults and decode the configuration.
|
||||
Apply explicit CLI overrides to that configuration and check their types before
|
||||
running application resolution. Effective secret lookup and final validation
|
||||
follow resolution, before publication. [ADR 0018](../adr/0018-cli-after-structural-resolution.md)
|
||||
records this ordering.
|
||||
|
||||
CLI overrides are the highest-priority input to application resolution. Supplied
|
||||
sequences replace whole sequences, and mappings combine recursively. Inheritance
|
||||
and typed defaulting do not run again after CLI application. CLI paths and values
|
||||
must not introduce structural directives; earlier source, inheritance, and
|
||||
decoding failures remain errors even if a CLI assignment would replace the value.
|
||||
Semantic validation assesses the final configuration after overrides and resolution.
|
||||
|
||||
Applications own flag parsing and domain resolution. ConfigKit owns override
|
||||
application, validation, and source attribution. Resolution receives CLI values
|
||||
with their explicit-presence and source information. Absence-based defaults
|
||||
preserve explicit values; recorded normalization and derivation can still change
|
||||
them. Typed defaults and application resolution retain their own assignment
|
||||
semantics, independently of collection operations during source assembly.
|
||||
|
||||
## Public API And Dependency Direction
|
||||
|
||||
Keep one small, coherent public surface for the supported lifecycle. Simple
|
||||
@@ -152,31 +246,44 @@ framework on this library.
|
||||
|
||||
## Configuration Integrity
|
||||
|
||||
Within a configuration tier, independent contributions use strict composition:
|
||||
shared mappings combine recursively, while overlapping non-mapping definitions
|
||||
are conflicts, including equal values. Between precedence tiers, intentional
|
||||
overlay combines mappings recursively, retains omitted lower-priority children,
|
||||
and replaces other present values according to the existing overlay rules.
|
||||
Tier precedence is explicit; traversal order within a tier establishes stable
|
||||
reporting, not permission to override. The distinction and its rationale are
|
||||
recorded in [ADR 0001](../adr/0001-separate-composition-and-overlay.md).
|
||||
Within a configuration tier, independent contributions use strict composition.
|
||||
Shared mappings combine recursively. Sequences with an applicable identity
|
||||
combine distinct records and reject duplicate identities, including equal
|
||||
records. Other overlapping non-mapping values remain conflicts, including equal
|
||||
values. Conflicting identity or operation declarations among peers also fail.
|
||||
Traversal order establishes stable reporting and record order; it does not grant
|
||||
precedence to peers. [ADR 0001](../adr/0001-separate-composition-and-overlay.md)
|
||||
records the distinction between strict composition and intentional overrides.
|
||||
|
||||
The main document and its imports form a strictly composed tier. Explicit
|
||||
underlay groups compose strictly within each group, then combine recursively
|
||||
from lowest to highest beneath the main tier. Source acquisition kind does not
|
||||
determine its precedence role. Input-specific constraints, including CLI path
|
||||
rules and opt-in version ownership, remain part of the configuration contract. Consistent combination
|
||||
semantics do not require identical acquisition or lifetime behavior from every
|
||||
source kind.
|
||||
underlay groups compose strictly within each group, then combine from lowest to
|
||||
highest beneath the main tier. Between tiers, mappings combine recursively and
|
||||
retain omitted lower-priority children. Sequence values follow collection policy;
|
||||
other present values retain the established replacement rules. Source acquisition
|
||||
kind does not determine precedence. Input-specific constraints, including version
|
||||
ownership and path protections, remain part of the configuration contract.
|
||||
Consistent combination semantics do not require identical acquisition or lifetime
|
||||
behavior from every source kind.
|
||||
|
||||
After source precedence is complete, built-in sibling `extends` relationships
|
||||
resolve structurally in the same parser-independent representation, using the
|
||||
existing recursive overlay rules. This adds neither an acquired source nor a
|
||||
consumer registration mechanism. Enclosing relationships precede nested
|
||||
destination-local resolution; typed defaults, application resolution, and
|
||||
secret lookup retain their existing lifecycle roles. The [configuration
|
||||
contract](../config.md#sibling-inheritance) owns the exact syntax and presence
|
||||
rules, and [ADR 0009](../adr/0009-sibling-inheritance.md) records the rationale.
|
||||
Built-in sibling `extends` relationships resolve after source precedence in the
|
||||
parser-independent document representation. Preserve existing mapping-sibling
|
||||
scopes and allow an identified sequence to provide a sibling scope for its
|
||||
records. A record may inherit from one parent in that sequence, selected by
|
||||
complete identity. The child supplies and keeps its own identity while inheriting
|
||||
the parent's other settings. Parents remain ordinary records subject to normal
|
||||
validation.
|
||||
|
||||
Use the shared dependency ordering and preserve assembled sequence order.
|
||||
Resolve enclosing relationships before nested destination-local relationships.
|
||||
Mappings combine recursively; sequences use the inheriting collection's policy.
|
||||
Whole-record upsert replaces records during combination; explicit inheritance
|
||||
uses the records that remain. Inheritance remains built-in and creates no
|
||||
acquired source.
|
||||
[ADR 0009](../adr/0009-sibling-inheritance.md) records the
|
||||
original scope and continuing ownership decisions, with sequence combination
|
||||
and identified-sequence inheritance defined by
|
||||
[ADR 0017](../adr/0017-collection-sequence-policy.md) and CLI ordering by
|
||||
[ADR 0018](../adr/0018-cli-after-structural-resolution.md).
|
||||
|
||||
ConfigKit must define supported YAML semantics explicitly rather than inherit
|
||||
parser defaults. Processing order and diagnostics must be deterministic for the
|
||||
@@ -185,13 +292,14 @@ configuration. Do not run application hooks on invalid typed configuration.
|
||||
|
||||
User-controlled precedence is application defaults, ordered underlays, composed
|
||||
main input, selected 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.
|
||||
This priority order is distinct from processing order: typed defaults are applied
|
||||
at decoding, and application resolution follows CLI input under the lifecycle
|
||||
rules above.
|
||||
|
||||
Preserve node-based composition until the effective input is ready for typed
|
||||
decoding. Preserve presence and source information through processing. Ordinary Go zero
|
||||
values must not be used to infer whether input was absent. Final configuration,
|
||||
Preserve node-based composition through source assembly and inheritance until
|
||||
typed decoding. Preserve presence and source information through later CLI and
|
||||
application-resolution changes as well. Ordinary Go zero 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 are part of the implemented contract,
|
||||
@@ -201,6 +309,79 @@ See [ADR 0005](../adr/0005-explicit-overrides-and-safe-explanation.md).
|
||||
Detailed syntax and operation tables belong to the
|
||||
[current configuration contract](../config.md).
|
||||
|
||||
The frozen inspection boundary also supports comparing completed same-type
|
||||
results without reopening sources or rerunning the load lifecycle. The
|
||||
[configuration contract](../config.md#effective-configuration-diffs) owns
|
||||
comparison and disclosure behavior, the [component overview](../internal/overview.md)
|
||||
owns implementation responsibility, and [ADR 0010](../adr/0010-effective-configuration-diff.md)
|
||||
records the accepted decision.
|
||||
|
||||
## Collection Identity And Combination
|
||||
|
||||
ConfigKit owns a common collection policy for imported records, inline sequences,
|
||||
selected overlays, and sibling inheritance. Configuration authors may declare
|
||||
identity and combination rules; applications may register authoritative identity
|
||||
constraints. Configuration declarations must agree with application constraints.
|
||||
Keep source placement explicit and separate from policy, retaining the placement
|
||||
decision in [ADR 0014](../adr/0014-sequence-placement-and-composition.md).
|
||||
[ADR 0017](../adr/0017-collection-sequence-policy.md) owns the rationale for
|
||||
collection policy and its relationship to application authority.
|
||||
|
||||
Gather declarations for the relevant destination and source or item-local scope
|
||||
before strict composition. Applicable identity from accumulated lower tiers must
|
||||
be available when composing higher-tier peers. Higher-tier declarations cannot
|
||||
repair invalid lower-tier composition. Preserve the distinction between source
|
||||
acquisition, dependency-ready scheduling within scopes, and precedence order.
|
||||
|
||||
Strictly compose each tier's contributions before applying one operation for the
|
||||
collection against its lower value. Support whole-sequence replacement,
|
||||
duplicate-rejecting append, and whole-record replacement by identity (`upsert`).
|
||||
Append and upsert require identity; do not infer it from record fields or position.
|
||||
Duplicate identities among peers fail under every mode. Source precedence and
|
||||
inheritance use the same operations and deterministic ordering rules. Whole-record
|
||||
upsert replaces the matching record's fields together; recursive record merging
|
||||
and unkeyed sequence concatenation are outside this operation model.
|
||||
|
||||
An explicit mode persists at its applicable destination until a higher explicit
|
||||
mode changes it. With no explicit mode, effective identity selects upsert and
|
||||
its absence selects whole-sequence replacement. Inferred defaults do not become
|
||||
persistent instructions. The inheriting destination chooses its operation; copying
|
||||
a parent collection does not automatically copy its mode to that destination.
|
||||
|
||||
Track YAML identity and explicit modes according to their separate lifetimes.
|
||||
Actual whole-value replacement discards lower YAML identity while preserving
|
||||
application constraints and any new identity declaration. Item-local policies
|
||||
belong to their records: retaining a record retains its rules, and discarding it
|
||||
discards those rules. Policies independently applicable at a destination remain
|
||||
in effect. Inheritance can copy YAML identity subject to agreement and replacement
|
||||
rules; application registrations continue to constrain their registered paths.
|
||||
Numeric positions alone do not establish policy ownership.
|
||||
|
||||
Allow declarations without accompanying records. A key declaration validates a
|
||||
retained sequence immediately or remains pending in its owning scope when no
|
||||
sequence exists. A mode-only declaration affects later combinations without
|
||||
recombining earlier tiers. Neither declaration alone clears a collection, and
|
||||
declaring replace alone does not authorize changing a retained collection's
|
||||
identity. These rules do not independently impose path-existence or shape
|
||||
requirements.
|
||||
|
||||
Enforce YAML identity during source composition, precedence, and identified
|
||||
inheritance operations. Application registrations additionally constrain final
|
||||
effective sequences after defaults, CLI overrides, and application resolution.
|
||||
The application schema and validation own existence, nullability, and allowed
|
||||
shape. The final identity guarantee covers actual sequences on successful load;
|
||||
it does not cover every intermediate callback state or later caller mutations.
|
||||
YAML-only declarations carry no such final guarantee through defaults, CLI
|
||||
assignments, or application resolution.
|
||||
|
||||
Preserve source attribution, presence, history, sensitivity, and resource context
|
||||
as records survive, move, or are replaced. Identity diagnostics use paths and
|
||||
available origin information without exposing identity values. The guarantee
|
||||
requires no new mutation-history system or exact reconstruction of causes for
|
||||
records supplied by defaults or callbacks. Exact identity equality, declaration
|
||||
syntax, presence cases, and operation tables belong in the
|
||||
[configuration contract](../config.md#strict-document-composition).
|
||||
|
||||
## Ownership And Confidentiality
|
||||
|
||||
Returned configuration and metadata must not share mutable state across loads.
|
||||
@@ -227,7 +408,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 [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.
|
||||
Record significant decisions to exclude capabilities, and their rationale, in
|
||||
ADRs. Significant boundary changes require an ADR and corresponding
|
||||
policy/contract updates; implementation convenience alone is not a reason to
|
||||
broaden the public API.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Base Policy Version:** 1.11
|
||||
|
||||
**Last Updated**: 2026-09-28
|
||||
**Last Updated**: 2026-10-16
|
||||
|
||||
**Upstream Source:** [Standard Repository Policies](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/documentation.md)
|
||||
|
||||
@@ -38,9 +38,10 @@ roadmap owns implementation status and sequencing until the decision is
|
||||
implemented. Current architecture, user, operator, integration, and internal
|
||||
documentation are updated when the behavior lands.
|
||||
|
||||
`docs/roadmap/deferred.md` owns deliberate exclusions; task-specific plans
|
||||
own execution order and progress. Completed plans retire during release
|
||||
preparation.
|
||||
`docs/roadmap/future.md` owns potential enhancements and their open questions;
|
||||
task-specific plans own execution order and progress. Record significant
|
||||
architectural exclusions and their rationale in ADRs. Do not maintain a roadmap
|
||||
inventory of rejected proposals. Completed plans retire during release preparation.
|
||||
|
||||
### Audience And Detail
|
||||
|
||||
@@ -91,7 +92,7 @@ secret values.
|
||||
| Inventory of implemented internal packages | `docs/internal/overview.md` | Implemented packages and components, responsibilities, and links to focused internal docs. | Normative architecture, contributor reading policy, external contracts. |
|
||||
| Behavior and architecture of internal packages | Other files under `docs/internal/` | Implementation flow, collaborators, state transitions, package-local guarantees and failures, and relevant tests. | Global architecture invariants, configuration definitions and defaults, external schemas, operator procedures. |
|
||||
| Architectural decision records | `docs/adr/` | Significant decisions, context, alternatives, rationale, consequences, and supersession history. | Current behavior reference, implementation status, task sequencing. |
|
||||
| Deferred scope | `docs/roadmap/deferred.md` | Deliberate exclusions and conditions for reconsideration. | Task execution and current behavior. |
|
||||
| Potential future features | `docs/roadmap/future.md` | Candidate enhancements and open questions for future consideration. | Rejected proposals, architectural decisions, task execution, and current behavior. |
|
||||
| Future implementation plans | Task-specific files under `docs/roadmap/` | Unfinished work, sequencing, acceptance, and progress; completed plans retire during release preparation. | Implemented behavior and ADR rationale. |
|
||||
| Release-note history | `docs/releases/` | One checked-in summary per source release under the procedure; the note at its immutable tag is the release record. | Current commands, behavior, contracts, and compatibility definitions. |
|
||||
| Complete examples | `examples/` and executable Go examples | Maintained copyable artifacts and Go API examples. | Configuration reference, symbol contracts, and prose explanation. |
|
||||
@@ -156,8 +157,10 @@ Use sequentially numbered ADR filenames such as
|
||||
|
||||
Treat the decision content of an accepted ADR as immutable. When a decision
|
||||
changes, create a new ADR and update the earlier ADR's status to superseded.
|
||||
Rejected architectural alternatives belong in the ADR; rejected product ideas
|
||||
belong in the roadmap. Routine choices need no ADR.
|
||||
Rejected architectural alternatives and significant architectural exclusions
|
||||
belong in ADRs. Roadmaps preserve plausible future work, not rejected product
|
||||
ideas. Routine choices and rejected suggestions need no ADR unless they
|
||||
represent a significant architectural decision.
|
||||
|
||||
## Maintenance
|
||||
|
||||
|
||||
@@ -82,7 +82,7 @@ go list -m gitea.maximumdirect.net/eric/ReleaseKit
|
||||
"$releasekit_bin" version
|
||||
```
|
||||
|
||||
The module-selected executable is ReleaseKit v0.3.0. Use the built executable
|
||||
The module-selected executable is ReleaseKit v0.4.1. Use the built executable
|
||||
for publication to preserve its exit status and publication-state diagnostics.
|
||||
`go run` maps a nonzero child exit status to 1. ReleaseKit's child Go commands
|
||||
use the `go` found on `PATH` with `GOTOOLCHAIN=local`; keep the selected
|
||||
|
||||
77
docs/releases/v0.10.0.md
Normal file
77
docs/releases/v0.10.0.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# ConfigKit v0.10.0
|
||||
|
||||
This minor release adds explicit, composable discovery of a configuration file.
|
||||
Applications choose the search conventions and their order; ConfigKit selects one
|
||||
file during loading and returns the final configuration with its normal metadata.
|
||||
Optional searches that find no file share the expanded `NoSource()` lifecycle.
|
||||
|
||||
## Changes
|
||||
|
||||
- Add `SearchConfig`, immutable `Search` and `FilenameGroup` descriptions, and
|
||||
search-wide requiredness through `SearchOptions`. Compose explicit `Paths`,
|
||||
custom `SearchDirectory` descriptions, and built-in Unix system, user-config,
|
||||
home, or working-directory presets with `Searches`. See the
|
||||
[public declarations](../../discovery.go) and
|
||||
[discovery contract](../config.md#configuration-discovery).
|
||||
- Expand searches in supplied order, trying a directory's filename groups before
|
||||
moving to the next directory. Named and generic groups provide conventional
|
||||
YAML filenames; custom groups provide literal names. Directory searches
|
||||
distinguish omitted filename selection from an explicitly empty selection.
|
||||
Constructors perform no filesystem access, and each load observes current
|
||||
directory bases and file availability.
|
||||
- Select the first existing candidate. Access errors and failures while loading
|
||||
the selected file terminate the load rather than trying another configuration.
|
||||
Selected files retain physical source identity, placement, provenance, imports,
|
||||
and relative-resource context. Discovery works with ordinary loading and
|
||||
layered inputs, including underlays, without adding a precedence tier.
|
||||
- Permit enabled imports with an absent main input: there are no main import
|
||||
declarations to traverse, while actual underlay sources retain normal imports.
|
||||
Support ordinary selected overlays with an absent main and explicit filesystem
|
||||
context, including a root-only host context. Layered overlays retain their
|
||||
independent `OverlayFilesystem` requirement. See
|
||||
[single-source loading](../config.md#single-source-loading) and
|
||||
[layered loading](../config.md#layered-loading).
|
||||
- Preserve ordinary file-context behavior after discovery and include the
|
||||
failing candidate's file identity in access diagnostics. Use Go's
|
||||
platform-aware Windows component rules and native drive-root-relative path
|
||||
resolution. Isolate user-config tests according to the host Go conventions.
|
||||
|
||||
## Compatibility and upgrade
|
||||
|
||||
Existing explicit source constructors and loading APIs remain available.
|
||||
`File(path)` remains an exclusive selection that fails for a missing file;
|
||||
discovery is activated only by explicitly supplying `SearchConfig`.
|
||||
|
||||
Searches are optional by default. Set `SearchOptions{Required: true}` when the
|
||||
application must find a file. An exhausted optional search contributes no main
|
||||
document or source record; application version checks, defaults, resolution,
|
||||
and validation still run under their existing rules and may reject incomplete
|
||||
configuration. A selected overlay is still required to exist.
|
||||
|
||||
The former `NoSource()` rejections of enabled imports and explicit filesystem
|
||||
context are relaxed. An active ordinary overlay with no main file now loads
|
||||
when explicit context is supplied. Applications that relied on those combinations
|
||||
being rejected should review their option handling. Selected host files derive
|
||||
their own base directory; do not supply an explicit `BaseDir` or native filesystem
|
||||
context for a search that selects a host file.
|
||||
|
||||
Applications may replace their own fallback-loading loops with the new search
|
||||
descriptions. The maintained [consumer examples](../consumers/README.md#explicit-main-file-discovery)
|
||||
show simple preset selection and custom-first discovery alongside existing
|
||||
configuration tiers. Composition, collection operations, inheritance, CLI
|
||||
precedence, and the complete-on-return ownership model retain their existing
|
||||
semantics.
|
||||
|
||||
Select this version and rebuild:
|
||||
|
||||
```sh
|
||||
go get gitea.maximumdirect.net/eric/ConfigKit@v0.10.0
|
||||
```
|
||||
|
||||
The consumer minimum remains Go 1.26.0. Release validation uses Go 1.26.10.
|
||||
The YAML dependency remains `go.yaml.in/yaml/v4 v4.0.0-rc.6`, and the maintainer
|
||||
tool remains ReleaseKit v0.4.1. Linux amd64 and arm64 remain release-validated
|
||||
targets; macOS remains best-effort. Publication follows the existing
|
||||
[release procedure](../release.md).
|
||||
|
||||
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.10.0/COPYRIGHT) | [LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.10.0/LICENSE) | [ATTRIBUTION](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.10.0/docs/oss/attribution.md)
|
||||
@@ -30,7 +30,7 @@ adopts ReleaseKit for source publication, and raises the consumer Go minimum to
|
||||
successful-tag-push completion point remain unchanged; CI is not a gate.
|
||||
- Adopt release policy 1.6, distinguishing the consumer Go minimum from the
|
||||
validation toolchain. Retire completed planning and measurement records into
|
||||
Git history while preserving [deferred work](../roadmap/deferred.md).
|
||||
Git history while preserving [performance candidates](../roadmap/future.md#3-performance-experiments).
|
||||
|
||||
## Compatibility and upgrade
|
||||
|
||||
|
||||
58
docs/releases/v0.7.0.md
Normal file
58
docs/releases/v0.7.0.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# ConfigKit v0.7.0
|
||||
|
||||
This minor release adds structured comparison of completed configurations.
|
||||
Applications can inspect changes between two configuration files or the
|
||||
effective impact of a selected overlay, with ConfigKit handling comparison and
|
||||
safe value disclosure while applications own presentation.
|
||||
|
||||
## Changes
|
||||
|
||||
- Add `Diff[T](left, right *Result[T]) ([]Difference, error)` through the public
|
||||
facade. It compares frozen effective inspection values from successful loads,
|
||||
including defaults and recorded application resolution, rather than source
|
||||
text or later mutations of `Result.Config`.
|
||||
- Return detached changed-region records containing a semantic path, safe left
|
||||
and right `ValueSnapshot` values, and `HiddenChanged`. Mappings compare
|
||||
recursively, sequences report atomic changes, and records follow canonical
|
||||
pointer order. Presence distinguishes additions and removals from explicit
|
||||
null; equal effective values produce no records regardless of provenance or
|
||||
loading history.
|
||||
- Protect both snapshots with sensitivity from both loads, including historical
|
||||
and dependency-derived classification. Compatible regions retain public
|
||||
siblings; incompatible structural replacements use complete-region masking
|
||||
where required. `HiddenChanged` identifies actual changes withheld by masking,
|
||||
rather than merely indicating that a snapshot contains redacted content.
|
||||
- Preserve visible public scalar changes when only historical sensitive
|
||||
descendants remain, including additions and removals. Historical explanation
|
||||
redaction and masking of incompatible present shapes remain intact.
|
||||
- Add maintained file-comparison and base-versus-overlay examples with
|
||||
consumer-owned formatting, plus an executable public API example. See the
|
||||
[consumer guide](../consumers/README.md#effective-configuration-diffs) and
|
||||
[configuration contract](../config.md#effective-configuration-diffs).
|
||||
|
||||
## Compatibility and upgrade
|
||||
|
||||
No application-code or configuration migration is required from v0.6.0.
|
||||
Adopting the new API is optional. Loading, composition, inheritance, provenance,
|
||||
existing inspection, and secret-handling contracts remain unchanged.
|
||||
|
||||
Load both configurations successfully before calling `Diff`; nil or
|
||||
uninitialized results return an error with no records. Comparison does not
|
||||
reopen sources, rerun callbacks, or reread environment variables. Typed secret
|
||||
references compare by configured reference name, not resolved credential bytes,
|
||||
so credential rotation behind an unchanged name is not reported. Returned
|
||||
snapshots describe changed regions, not cross-protected full-document context.
|
||||
|
||||
Select this version and rebuild:
|
||||
|
||||
```sh
|
||||
go get gitea.maximumdirect.net/eric/ConfigKit@v0.7.0
|
||||
```
|
||||
|
||||
The consumer minimum remains Go 1.26.0. The YAML dependency remains
|
||||
`go.yaml.in/yaml/v4 v4.0.0-rc.6`, and the maintainer tool dependency remains
|
||||
ReleaseKit v0.3.0. Linux amd64 and arm64 remain release-validated targets;
|
||||
macOS remains best-effort. Publication follows the existing
|
||||
[release procedure](../release.md).
|
||||
|
||||
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.7.0/COPYRIGHT) | [LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.7.0/LICENSE) | [ATTRIBUTION](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.7.0/docs/oss/attribution.md)
|
||||
22
docs/releases/v0.7.1.md
Normal file
22
docs/releases/v0.7.1.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# ConfigKit v0.7.1
|
||||
|
||||
This patch updates the maintainer release tool to ReleaseKit v0.4.1. ConfigKit's
|
||||
runtime behavior and public API are unchanged.
|
||||
|
||||
## Compatibility and upgrade
|
||||
|
||||
No application-code or configuration migration is required from v0.7.0. The
|
||||
consumer minimum remains Go 1.26.0, and the YAML dependency is unchanged.
|
||||
|
||||
Select this version and rebuild:
|
||||
|
||||
```sh
|
||||
go get gitea.maximumdirect.net/eric/ConfigKit@v0.7.1
|
||||
```
|
||||
|
||||
ReleaseKit v0.4.1 is a maintainer tool dependency and does not change consumer
|
||||
configuration or runtime behavior. Linux amd64 and arm64 remain
|
||||
release-validated targets; macOS remains best-effort. Publication follows the
|
||||
existing [release procedure](../release.md).
|
||||
|
||||
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.7.1/COPYRIGHT) | [LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.7.1/LICENSE) | [ATTRIBUTION](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.7.1/docs/oss/attribution.md)
|
||||
70
docs/releases/v0.8.0.md
Normal file
70
docs/releases/v0.8.0.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# ConfigKit v0.8.0
|
||||
|
||||
This minor release adds explicit input placement, relative import destinations,
|
||||
shared source-version checking, and opt-in composition of identified sequence
|
||||
records. Applications can assemble independently authored configuration fragments
|
||||
into one final configuration while retaining source locations, provenance,
|
||||
history, and sensitivity information.
|
||||
|
||||
## Changes
|
||||
|
||||
- Add `Destination` to ordinary load options and layered inputs. A source mapping
|
||||
can contribute beneath literal application keys without being rewritten by the
|
||||
consumer. Imports inherit that destination or append a relative YAML
|
||||
`destination`. Physical import paths and resource contexts remain relative to
|
||||
their declaring sources. See [input destinations](../config.md#input-destinations),
|
||||
[import syntax](../config.md#explicit-imports), and the
|
||||
[mounted collection example](../consumers/README.md#input-destinations-and-mounted-collections).
|
||||
- Add `SourceVersionModeIfPresent` and `SourceVersionModeRequired` to apply one
|
||||
application-owned version checker across main, underlay, imported, and selected
|
||||
overlay documents. Enabled modes check source-local declarations and consume
|
||||
accepted non-main headers before composition. Detached, sensitivity-aware
|
||||
evidence is available through `SourceInfo.VersionCheck`. Acceptance remains
|
||||
application policy; ConfigKit does not require declarations to equal the main
|
||||
version. See [shared source-version checks](../config.md#shared-source-version-checks).
|
||||
- Add explicit `PlacementModeSequenceItem` placement for ordinary and layered
|
||||
inputs, and `placement: sequence-item` for path and glob imports. A source
|
||||
mapping becomes one item at its destination; ordinary helper imports compose
|
||||
within that item and can supply its identity. See
|
||||
[imports](../config.md#explicit-imports).
|
||||
- Add `Definition.SequenceCompositions` registrations for strict composition at
|
||||
selected literal paths using one string identity field. Distinct records append
|
||||
in contribution order, while duplicate identities conflict. Higher-priority
|
||||
sequences still replace lower-priority sequences wholesale. Registration does
|
||||
not add identity-based overlays or final identity validation after defaults and
|
||||
application resolution. See the [composition contract](../config.md#strict-document-composition)
|
||||
and [maintained prompt catalog](../consumers/README.md#identified-prompt-sequences).
|
||||
- Extend source inspection with destination and placement evidence, including
|
||||
whether a nested destination is unresolved in an intermediate or failed-load
|
||||
snapshot. Preserve source-specific history, consumed-header sensitivity, and
|
||||
explanations of descendants removed by sequence replacement as records are
|
||||
assembled. Source locations continue to identify authored coordinates.
|
||||
|
||||
## Compatibility and upgrade
|
||||
|
||||
The new behavior is opt-in: zero-valued destinations retain root placement,
|
||||
mapping placement remains the default, unregistered sequences retain strict
|
||||
conflict behavior, and the default source-version mode preserves the existing
|
||||
main-only version policy. Recursive mapping precedence, whole-sequence
|
||||
replacement between tiers, and application-owned final validation remain intact.
|
||||
|
||||
Public option, definition, input, and source-information structs gain fields.
|
||||
Consumers using unkeyed literals for affected structs must switch to keyed
|
||||
literals or update them. `SourceInfo` now contains a `Path` and is no longer
|
||||
comparable with `==` or usable directly as a map key; compare the relevant fields
|
||||
or select an application-appropriate key instead. Existing keyed literals need
|
||||
no changes to retain the previous loading behavior.
|
||||
|
||||
Select this version and rebuild:
|
||||
|
||||
```sh
|
||||
go get gitea.maximumdirect.net/eric/ConfigKit@v0.8.0
|
||||
```
|
||||
|
||||
The consumer minimum remains Go 1.26.0. The YAML dependency remains
|
||||
`go.yaml.in/yaml/v4 v4.0.0-rc.6`, and the maintainer tool dependency remains
|
||||
ReleaseKit v0.4.1. Linux amd64 and arm64 remain release-validated targets;
|
||||
macOS remains best-effort. Publication follows the existing
|
||||
[release procedure](../release.md).
|
||||
|
||||
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.8.0/COPYRIGHT) | [LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.8.0/LICENSE) | [ATTRIBUTION](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.8.0/docs/oss/attribution.md)
|
||||
111
docs/releases/v0.9.0.md
Normal file
111
docs/releases/v0.9.0.md
Normal file
@@ -0,0 +1,111 @@
|
||||
# ConfigKit v0.9.0
|
||||
|
||||
This minor release expands import selection, adds author-declared collection
|
||||
identity and operations, and supports inheritance between identified records.
|
||||
It also moves CLI assignments after inheritance and typed decoding. These changes
|
||||
include breaking API and configuration behavior changes from v0.8.0; review the
|
||||
migration guidance below before upgrading.
|
||||
|
||||
## Changes
|
||||
|
||||
- Extend imports with named-file and directory lists, directory filename patterns,
|
||||
exclusions, recursion and depth controls, and explicit hidden-file and symlink
|
||||
discovery controls. Add `ImportDefaults` to ordinary and layered options, with
|
||||
authored declarations taking precedence over application defaults. Imports are
|
||||
optional by default. `SourceInfo.ImportSelections` exposes completed optional
|
||||
declarations that selected no sources. See the
|
||||
[import contract](../config.md#explicit-imports) and
|
||||
[consumer guidance](../consumers/README.md#import-defaults-and-selection-inspection).
|
||||
- Use shared dependency-ready ordering for import composition and sibling
|
||||
inheritance. Dependencies complete before dependents, with deterministic
|
||||
selection among currently eligible contributions. Source acquisition and
|
||||
callback order remain separate from assembly order. See the
|
||||
[ordering decision](../adr/0015-shared-dependency-ready-ordering.md).
|
||||
- Add root `configkit.sequences` declarations for collection identity and
|
||||
`replace`, `append`, or `upsert` operations, including in selected overlays.
|
||||
Strict peers combine distinct identities and reject duplicates; precedence
|
||||
and inheritance apply the destination's collection operation. Upsert replaces
|
||||
a whole matching record at its retained position. Identity supports compound
|
||||
field sets and scalar strings, numbers, booleans, and null. See the
|
||||
[collection contract](../config.md#strict-document-composition).
|
||||
- Add `Definition.SequenceIdentities`, exact `SequenceIdentityAt` registrations,
|
||||
and dynamic `SequenceIdentityMatching` selectors. Application registrations
|
||||
govern structural collection operations and check existing matching final
|
||||
typed sequences, including values from defaults, CLI assignments, and
|
||||
application resolution. YAML-only declarations govern structural operations
|
||||
without adding that final-result check.
|
||||
- Support record inheritance within an identified sequence using an `extends`
|
||||
mapping containing the parent's complete identity. The child retains its own
|
||||
identity, relationships use surviving assembled records, and output sequence
|
||||
order is preserved. See [sibling inheritance](../config.md#sibling-inheritance)
|
||||
and the maintained [sequence catalog](../consumers/README.md#identified-prompt-sequences).
|
||||
- Apply explicit CLI overrides to the decoded configuration before application
|
||||
resolution. Mappings retain omitted fields and their presence metadata;
|
||||
sequences replace whole lists independently of collection operations. See the
|
||||
[CLI contract](../config.md#explicit-cli-overrides).
|
||||
- Correct native import alias and contained-symlink handling, source-version
|
||||
path projection, and provenance/history through collection movement,
|
||||
replacement, inheritance, and CLI assignment. Reduce repeated scans,
|
||||
accumulated policy copying, and sparse-projection storage during construction.
|
||||
|
||||
## Compatibility and upgrade
|
||||
|
||||
This pre-v1 minor release intentionally changes the v0.8.0 contracts:
|
||||
|
||||
1. **Migrate import requiredness.** The YAML `optional` field is removed and is
|
||||
rejected. Replace `optional: true` with `required: false`; omission is equivalent
|
||||
when the application does not default imports to required. Replace
|
||||
`optional: false` with `required: true`. Imports that previously relied on
|
||||
implicit requiredness now need `required: true`, or an application
|
||||
`ImportDefaults.Required` default set to true. Review discovery filters:
|
||||
`follow` and `hidden` default to true, and directory searches select all
|
||||
filenames unless a pattern is supplied.
|
||||
2. **Replace sequence registrations.** `SequenceComposition` and
|
||||
`Definition.SequenceCompositions` are removed without compatibility aliases.
|
||||
For a previous registration at `prompts` using `id`, use:
|
||||
|
||||
```go
|
||||
rules.SequenceIdentities = []configkit.SequenceIdentity{
|
||||
configkit.SequenceIdentityAt(configkit.Keys("prompts"), "id"),
|
||||
}
|
||||
```
|
||||
|
||||
These registrations now also enforce identity in final typed sequences. Fix
|
||||
missing or duplicate identities introduced by defaults, CLI assignments, or
|
||||
application resolution. Registration does not require a collection to exist
|
||||
or determine its allowed shape; schema and application validation retain
|
||||
those responsibilities.
|
||||
3. **Review collection precedence and ordering.** An identified collection now
|
||||
defaults to upsert across layers and inheritance when no explicit mode
|
||||
applies, rather than unconditional whole-sequence replacement. Declare
|
||||
`mode: replace` at the relevant destination where replacement is intended.
|
||||
With append or upsert, `[]` contributes no records and does not clear the
|
||||
collection; null is invalid. Use `mode: replace` with `[]` to clear it.
|
||||
An explicit mode persists at its destination until changed; a parent's mode
|
||||
does not transfer to a different inheriting destination. Unidentified
|
||||
sequences retain strict peer conflicts and whole-value precedence
|
||||
replacement. Dependency-ready assembly can change sequence positions and
|
||||
diagnostic or history order compared with the former traversal order.
|
||||
4. **Review CLI recovery and inheritance assumptions.** CLI overrides can no
|
||||
longer repair values that fail earlier decoding, bypass source or inheritance
|
||||
errors, or add or cancel inheritance relationships. Supply structural
|
||||
directives in source configuration. Application resolution still receives
|
||||
explicit CLI values and may record subsequent normalization or derivation.
|
||||
5. **Update affected Go literals and diagnostic handling.** Use keyed literals
|
||||
for expanded option and source-information structs. Collection validation adds
|
||||
`CodeSequenceInvalid` (`sequence.invalid`) and `CodeSequenceConflict`
|
||||
(`sequence.conflict`); update diagnostic handling where applicable.
|
||||
|
||||
Select this version and rebuild:
|
||||
|
||||
```sh
|
||||
go get gitea.maximumdirect.net/eric/ConfigKit@v0.9.0
|
||||
```
|
||||
|
||||
The consumer minimum remains Go 1.26.0. The YAML dependency remains
|
||||
`go.yaml.in/yaml/v4 v4.0.0-rc.6`, and the maintainer tool dependency remains
|
||||
ReleaseKit v0.4.1. Linux amd64 and arm64 remain release-validated targets;
|
||||
macOS remains best-effort. Publication follows the existing
|
||||
[release procedure](../release.md).
|
||||
|
||||
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.9.0/COPYRIGHT) | [LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.9.0/LICENSE) | [ATTRIBUTION](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.9.0/docs/oss/attribution.md)
|
||||
481
docs/roadmap/consolidation.md
Normal file
481
docs/roadmap/consolidation.md
Normal file
@@ -0,0 +1,481 @@
|
||||
# ConfigKit Consolidation Toward 1.0
|
||||
|
||||
Status: selected target state; implementation pending. This document defines the
|
||||
refactor's scope and rationale; it does not authorize a release. The scope
|
||||
questions are settled. The [implementation plan](implementation.md) owns the
|
||||
concrete steps, sequencing, and implementation progress.
|
||||
|
||||
## Purpose
|
||||
|
||||
Consolidate the machinery introduced by recent import, collection, inheritance,
|
||||
and discovery work so ConfigKit is easier to understand, maintain, and adopt
|
||||
before its public contracts become stable. Success means fewer independently
|
||||
maintained paths through the same behavior, clearer state ownership, and concise
|
||||
consumer workflows. Reducing a function's line count is useful only when it
|
||||
serves those outcomes.
|
||||
|
||||
The review considered public API consolidation as well as internal extraction.
|
||||
The settled public direction retains the existing simple and layered loading APIs,
|
||||
consolidates their private translation and acquisition machinery, and removes
|
||||
unused reverse inspection conversions. No public signature or configuration
|
||||
semantic change is planned in this effort. This is an assessment of the reviewed
|
||||
consumer workflows, not a requirement to preserve every beta API in perpetuity.
|
||||
|
||||
## Evidence And Baseline
|
||||
|
||||
Reviewed worktree: `c9574ef`, immediately after the `v0.10.0` release at `5959537`.
|
||||
This document replaces the earlier `complexity.md` roadmap and `remediation.md`
|
||||
audit record. Their recommendations and relevant evidence are incorporated here;
|
||||
the original documents remain available in Git history:
|
||||
|
||||
```sh
|
||||
git show fbaaaf1:docs/roadmap/complexity.md
|
||||
git show fbaaaf1:docs/roadmap/remediation.md
|
||||
```
|
||||
|
||||
The earlier roadmap reviewed `7a83704f43ef9870db33661231cdff5d6ee502f7`, before
|
||||
discovery. The audit reviewed `5959537d1adc2516e7f7e8881df72b707ec48c3f`, including
|
||||
implemented discovery. The consolidation review checked their recommendations
|
||||
against that newer baseline and also considered the public API.
|
||||
|
||||
This review inspected the current public load/input/discovery declarations and
|
||||
maintained consumer examples, the shared request executor and acquisition flow,
|
||||
import parsing and selection, inheritance execution, reflective projection and
|
||||
cloning, schema field construction, and filesystem-context derivation. Graph
|
||||
caller traces and text-reference checks confirmed the unused reverse conversion
|
||||
chain. This was source review, not a new runtime correctness or performance audit.
|
||||
Neither this review nor the audit established a runtime correctness defect.
|
||||
|
||||
### Audit Measurements
|
||||
|
||||
The audit refreshed the codebase-memory graph and used function size, cognitive
|
||||
complexity, branch counts, and parameter counts to rank candidates for source
|
||||
inspection. It indexed 901 non-test functions/methods and 928 test-file
|
||||
declarations. Its fast index excludes examples, documentation, and scripts; a
|
||||
tracked-file inventory covered those separately. Standalone `gocyclo`, `gocognit`,
|
||||
`scc`, and `cloc` tools were unavailable.
|
||||
|
||||
These are the graph tool's reported measurements at the audited revision.
|
||||
Function spans include comments and blank lines. The scores support relative
|
||||
triage; they are not standardized acceptance thresholds or evidence of a defect.
|
||||
|
||||
| Function | Source | Lines | Cognitive score |
|
||||
| --- | --- | ---: | ---: |
|
||||
| `valueOperation.project` | [projection.go](../../internal/engine/projection.go) | 195 | 174 |
|
||||
| `parseImportDeclarationSpec` | [import_spec.go](../../internal/engine/import_spec.go) | 127 | 129 |
|
||||
| `valueOperation.clone` | [clone.go](../../internal/engine/clone.go) | 130 | 104 |
|
||||
| `schemaBuilder.describeFields` | [schema.go](../../internal/engine/schema.go) | 99 | 100 |
|
||||
| `assembleBaseInputTrackedForRole` | [load_input.go](../../internal/engine/load_input.go) | 188 | 96 |
|
||||
| `selectDirectoryImportCandidates` | [import_selection.go](../../internal/engine/import_selection.go) | 154 | 96 |
|
||||
| `valueBuilder.build` | [value.go](../../internal/engine/value.go) | 104 | 93 |
|
||||
| `decodeValue` | [decode.go](../../internal/engine/decode.go) | 115 | 85 |
|
||||
| `applyInheritanceScopeTemplates` | [inheritance_schedule.go](../../internal/engine/inheritance_schedule.go) | 219 | 75 |
|
||||
| `contextsForAcquiredSources` | [source_context.go](../../internal/engine/source_context.go) | 102 | 72 |
|
||||
|
||||
At that revision, the engine contained 88 production Go files and approximately
|
||||
20,144 physical lines; the root facade contained 16 files and 1,861 lines.
|
||||
`imports.go` was the largest production file at 1,195 lines. These measurements
|
||||
locate concentrated responsibilities. They do not establish a need for more
|
||||
packages or make the smaller document/YAML visitors and explicit facade
|
||||
conversions maintenance defects.
|
||||
|
||||
### Findings And Recommended Treatment
|
||||
|
||||
The finding numbers below preserve the audit's identifiers. The selected work
|
||||
remains unimplemented; the cloning portion of finding 3 is deferred to future
|
||||
work. Retiring the original documents does not mark their findings implemented.
|
||||
|
||||
| Audit finding | Maintenance priority | Assessment and recommended treatment |
|
||||
| --- | --- | --- |
|
||||
| 1. Import entry points and responsibilities | Higher | Main refactor. Include shared acquisition normalization, import-default ownership, declaration parsing, and directory admission. |
|
||||
| 2. Shared inheritance executor | Higher | Main refactor. Expose meaningful phases and explicit combination results while retaining one executor. |
|
||||
| 3. Projection and cloning | Higher for projection; cloning deferred | Main refactor for projection. Clone traversal review is tracked separately in [future.md](future.md#4-clone-traversal-review). |
|
||||
| 4. Schema field discovery and inline assembly | Medium | Include a focused refactor coordinated with projection. Keep one schema builder and its two descriptor views. |
|
||||
| 5. Resolved filesystem context | Medium | Include alongside acquisition work. Reuse resolved facts without merging resource lifetimes or changing validation timing. |
|
||||
| 6. Unused reverse inspection conversions | Small, independently reviewable cleanup | Include in the root facade and engine. Retain useful forward-conversion and detached-result coverage. |
|
||||
|
||||
Findings 1–3 confirm the earlier roadmap's main opportunities; findings 4–6 add
|
||||
focused work. The following sections own their rationale and intended changes.
|
||||
Implementation and validation will establish completion of the selected work;
|
||||
the deferred clone review retains its separate disposition.
|
||||
|
||||
Completed correctness and algorithmic remediations remain part of the baseline,
|
||||
including sequence records preserved at `ea46001:docs/roadmap/remediation.md`
|
||||
and discovery records preserved at `0369111:docs/roadmap/remediation.md`.
|
||||
Item-scope application matching and inheritance policy localization are examples
|
||||
of completed corrections that overlap this work and whose regressions should
|
||||
remain protected.
|
||||
|
||||
The [architecture policy](../policy/architecture.md),
|
||||
[configuration contract](../config.md), and exported declaration comments own
|
||||
existing constraints and behavior. The
|
||||
[component overview](../internal/overview.md) and
|
||||
[import internals](../internal/imports.md) locate implementation responsibilities.
|
||||
|
||||
## Consumer-Facing Target
|
||||
|
||||
### Keep Simple Loading Simple And Advanced Inputs Explicit
|
||||
|
||||
The current APIs already provide progressive setup:
|
||||
|
||||
- A simple consumer calls `Load[Config](File(path))`.
|
||||
- A consumer with defaults, imports, or resolution supplies `Options[Config]`.
|
||||
- Explicit discovery produces an ordinary `Source`, so it uses the same loading
|
||||
call and lifecycle.
|
||||
- A layered consumer supplies an `Input` and `LayeredOptions[Config]`, with
|
||||
`Underlay.Inputs` distinguishing strict peers from precedence tiers.
|
||||
|
||||
The two loading functions already converge on `loadRequest` and
|
||||
`executeLoadRequest`. Their public differences have meaning: ordinary options
|
||||
include the one input's controls; layered loading has per-input controls and a
|
||||
separate overlay filesystem. Ordinary overlay context follows its selected
|
||||
source, whereas layered overlay context is independently specified. These are
|
||||
observable conventions, not interchangeable forwarding spellings.
|
||||
|
||||
**Decision (OQ-1, settled):** retain `Load`, `LoadLayered`, `Options`, `LayeredOptions`,
|
||||
`Input`, and `Underlay`. Remove repeated translation and option reconstruction
|
||||
behind them. Keep common application rules in `Definition`; do not require
|
||||
applications to build a runtime loader or manually coordinate phases.
|
||||
|
||||
A single public request type could place main input, underlays, and common
|
||||
options together. It would reduce duplicated option fields and make promotion
|
||||
from ordinary to layered loading more uniform. A convenience entry could preserve
|
||||
the simplest call. Such a design would still need to resolve the existing
|
||||
overlay-context differences and demonstrate a clearer consumer model. The
|
||||
maintained examples do not show enough consumer benefit to justify that redesign
|
||||
in this effort. Retaining the current APIs accepts some duplicated public option
|
||||
fields and setup changes when adopting underlays; internal consolidation and
|
||||
clearer guidance address the observed maintenance needs. The decision is based on
|
||||
the resulting API, rather than the amount of refactoring needed to achieve it.
|
||||
|
||||
### Preserve Useful Convenience And Ownership Boundaries
|
||||
|
||||
| Surface reviewed | Recommended public end state |
|
||||
| --- | --- |
|
||||
| `Source`, `Filesystem`, `Search`, filename groups and presets | Retain typed, reusable source/search descriptions. Discovery selects a concrete source during loading; applications do not loop over complete loads. |
|
||||
| `FilesystemContext` | Retain the public description and current host/native rules. Consolidate its resolved private representation; do not expose acquisition handles or engine context records. |
|
||||
| `SequenceIdentityAt` and `SequenceIdentityMatching` | Retain the exact-path convenience and dynamic selector form over the common registration model. Both serve distinct consumer shapes. |
|
||||
| `Definition`, resolution and validation callbacks | Retain application-owned domain rules inside one recorded lifecycle. Preserve the distinction between mutable recorded resolution and read-only validation. |
|
||||
| Results, metadata, explanations, source context and diff | Retain detached, safe inspection with consumer-owned presentation. Remove unused inverse conversions without removing supported queries. |
|
||||
| Root wrappers and private conversions | Retain root-owned public types and explicit ownership conversion under ADR 0006. Consolidate repeated real conversions and delete unused ones. |
|
||||
|
||||
The consumer-facing work is to make the ordinary path easier to find and show
|
||||
how additional features compose, using the existing maintained examples. Update
|
||||
the adoption guide with a concise choice between simple loading, discovery, and
|
||||
layered loading, and show which options belong to an input versus a whole load.
|
||||
Keep exact behavior in its canonical owners. Examples should make application
|
||||
rules recognizable and avoid requiring consumers to recreate generic merging,
|
||||
presence, or provenance machinery.
|
||||
|
||||
Use the current simple README call, bundled-file and layered catalog examples,
|
||||
the import-focused prompt catalog, and the discovered sequence catalog to assess
|
||||
the end state. The latter exercises imports, underlays, independent overlay
|
||||
context, CLI assignment, and recorded resolution in one workflow. A high number
|
||||
of options in that example does not by itself justify a new abstraction.
|
||||
|
||||
## Internal Target
|
||||
|
||||
Keep the root facade, cohesive `internal/engine`, ordered document model, and
|
||||
private YAML adapter. Use ordinary private records, functions, and focused files
|
||||
to express responsibilities. Existing mapping combination, sequence combination,
|
||||
dependency scheduling, schema descriptors, and traversal accounting remain shared.
|
||||
|
||||
`executeLoadRequest` stays the visible lifecycle coordinator. It should show
|
||||
acquisition, assembly, inheritance, decoding/CLI assignment, resolution, secret
|
||||
processing, validation, and publication in their established order. Extract a
|
||||
phase only when it gives that work a clear input, output, or ownership boundary.
|
||||
Do not replace the visible lifecycle with a registry of configurable stages.
|
||||
|
||||
### 1. Normalize Loading Inputs And Consolidate Import Acquisition
|
||||
|
||||
The current executor constructs an engine `Options[T]` from `loadRequest`, and
|
||||
underlay acquisition constructs `Options[struct{}]` to call the same input
|
||||
assembler. `assembleBaseInputTrackedForRole` then combines discovery, directive
|
||||
processing, version rules, imports, placement, accounting, and contexts. Import
|
||||
entry points carry long positional lists through several forwarding variants.
|
||||
This repeats the representation of information already normalized for a load.
|
||||
|
||||
In [imports.go](../../internal/engine/imports.go), the
|
||||
`loadImportedDocumentsTrackedWithVersion`,
|
||||
`loadImportedDocumentsTrackedWithProcessing`, and
|
||||
`loadImportedDocumentsTrackedWithProcessingDeferred` variants have corresponding
|
||||
`loadImportedWithAccess...` variants. Both final signatures carry 13 parameters.
|
||||
Placement, processing results, sequence policy, accounting, repetition tracking,
|
||||
namespace identity, and deferred assembly must stay aligned through the chain.
|
||||
The same file's `importTraversal.visitPath` combines source-admission decisions
|
||||
in 174 lines, alongside acquisition, pending-item assembly, and evidence work.
|
||||
|
||||
Introduce cohesive private acquisition inputs and explicit state ownership,
|
||||
reusing `loadRequest`, `loadInput`, `acquisitionScope`, and `importTraversal`
|
||||
where they fit. Translate ordinary and layered public declarations into the
|
||||
shared loading model once. Acquisition should receive the source, input role,
|
||||
placement, context, and policies it needs without a fabricated application type
|
||||
or a reconstructed broad options object. Small adapters may preserve meaningful
|
||||
entry operations while callers migrate; remove obsolete forwarding variants
|
||||
within the completed refactor.
|
||||
|
||||
Separate immutable policy, mutable processing state, and results. Move import
|
||||
defaults out of `sourceVersionState` and into the load/acquisition policy owner.
|
||||
Version state continues to own version processing, projected effective version
|
||||
location, evidence, and diagnostics. Local and effective version rules have
|
||||
different meanings and must remain distinguishable.
|
||||
|
||||
The state boundaries are especially important:
|
||||
|
||||
| Lifetime or scope | State and responsibility |
|
||||
| --- | --- |
|
||||
| Whole load | Aggregate resource accounting, application definition/registrations, import defaults, shared version evidence and diagnostics. |
|
||||
| One strict tier | Repetition tracking shared by that tier's inputs, namespace scheduling anchors, and its inclusion/composition graph relationship. A later tier may reuse a file. |
|
||||
| Active import traversal | Active routes and inclusion edges, filesystem access ownership, and cycle detection, including edges crossing item-scope boundaries. |
|
||||
| One composition scope | Peer contributions, pending collection declarations, item-local helpers, and deferred item placement. Nested scopes do not become separate precedence tiers. |
|
||||
| Retained result | Source records, effective destinations, origins, history, and passive resource-context descriptions. |
|
||||
|
||||
Keep repetition tracking tier-scoped. In the current code,
|
||||
`acquireUnderlayGroupScopes` creates a fresh visited map per group. Preserve valid
|
||||
reuse across tiers while keeping shared aggregate limits. Namespace equality,
|
||||
candidate spelling, and stable scheduling keys also serve different purposes.
|
||||
|
||||
Give acquisition, already-acquired scope assembly, and evidence projection
|
||||
focused homes. Keep discovery at the acquisition boundary: resolve a search once
|
||||
per input consumption, then process the concrete file or absence. Preserve
|
||||
source IDs and source-local version processing in acquisition order, even when
|
||||
composition uses a different dependency-ready order. The layered main is acquired
|
||||
before underlays; lower-tier identity can still inform its later strict assembly.
|
||||
|
||||
Within `parseImportDeclarationSpec`, extract boolean/collection/placement field
|
||||
handling and selector-value parsing where it clarifies responsibilities. Keep
|
||||
field presence, authored error locations, selector applicability, and the order
|
||||
of consequential validation visible. Within `selectDirectoryImportCandidates` and
|
||||
`importTraversal.visitPath`, separate admission and descent decisions from
|
||||
traversal orchestration where they represent distinct work. Retain the existing
|
||||
root deduplication, candidate union, exclusions, sorting, and active-path cycle
|
||||
rules. No shared abstraction should conflate literal first-match main discovery
|
||||
with multi-file import selection.
|
||||
|
||||
### 2. Share Resolved Filesystem Facts Without Sharing Live Handles
|
||||
|
||||
`importFilesystem`, `nativeFilesystemAccess`, and `contextsForAcquiredSources`
|
||||
derive overlapping root/base/namespace facts. The latter reconstructs context
|
||||
from combinations of source kind and optional fields, including absent input.
|
||||
Discovery and independent layered overlays make these combinations consequential.
|
||||
Context construction is used by ordinary acquisition and both selected-overlay
|
||||
paths, so those callers need to use the same applicable derivation rules.
|
||||
|
||||
Use a small engine-owned resolved-context description for the facts actually
|
||||
needed by acquisition and passive views: namespace identity/kind, resolved root,
|
||||
source base, and selected source path when present. Preserve the distinction
|
||||
between host paths and native slash paths. Reuse already resolved facts where
|
||||
available instead of repeatedly deriving them from the original options.
|
||||
|
||||
Resolution and validation can occur at different existing lifecycle points.
|
||||
The record must not force every source to perform all filesystem checks early.
|
||||
Context descriptions, library-owned acquisition handles, and returned passive
|
||||
resource views have different lifetimes. Closing acquisition must remain
|
||||
explicit on success and failure; retaining context must not retain an open root
|
||||
handle or assume ownership of a borrowed `fs.FS`.
|
||||
|
||||
Cover selected and absent discovery outcomes, root-only absent host context,
|
||||
native sources, in-memory inputs with explicit context, and independently rooted
|
||||
overlays. Passive context remains associated with the source that contributed
|
||||
the value, including retained underlay and inherited values. Use the acquired
|
||||
source's canonical identity where available. Preserve containment failures,
|
||||
source associations, deferred search bases, and terminal selected-file errors.
|
||||
|
||||
Acquisition and context handling use the same private handoff rather than
|
||||
maintaining parallel models for overlapping callers. The description does not
|
||||
replace the public `FilesystemContext` or `ResourceContext` contracts.
|
||||
|
||||
### 3. Make Inheritance Combination Results Explicit
|
||||
|
||||
Keep the mapping and identified-record adapters and one shared executor.
|
||||
`applyInheritanceScopeTemplates` currently prepares templates, mutates values
|
||||
through merge callbacks, projects policy and origins, records replacements and
|
||||
replay evidence, and enforces budgets in one body. The callbacks mutate several
|
||||
surrounding result collections, making a combination difficult to review alone.
|
||||
|
||||
Give the executor named operations for child/control preparation, parent payload
|
||||
and policy preparation, combination, evidence construction, and retained-node
|
||||
accounting. Use a small private result for outputs of one combination where it
|
||||
replaces unrelated surrounding mutations. Value, policy, origin, replacement,
|
||||
and sequence-position evidence should come from the same combination outcome;
|
||||
do not rerun a merge to reconstruct its history.
|
||||
|
||||
Keep borrowed authored templates separate from owned resolved payloads and from
|
||||
retained history snapshots. Parent identity exclusion and child identity/origin
|
||||
preservation must apply consistently to values and history. Preserve nested
|
||||
unresolved instructions for destination-local evaluation, policy retirement on
|
||||
actual replacement, cancellation, and source-version movement through sequences.
|
||||
|
||||
The shared Kahn scheduler with stable keys remains unchanged. Resolve enclosing
|
||||
relationships before nested relationships and retain assembled record order.
|
||||
Preserve expansion limits and retained-history accounting for shared nodes;
|
||||
extraction must not charge shared evidence twice or make an unbounded snapshot.
|
||||
|
||||
### 4. Separate Projection Handlers And Clarify Schema Assembly
|
||||
|
||||
Keep `projectConfiguration` as the schema-checked entry and `valueOperation` as
|
||||
the operation-state owner. Move substantial struct, map, and sequence projection
|
||||
into focused handlers. Keep pointer/dynamic dispatch and scalar representation
|
||||
choices visible. All handlers share the same cycle, depth, and node state.
|
||||
|
||||
Struct projection needs particular care: physical members drive traversal,
|
||||
flattened fields drive name lookup and sensitivity, and an inline container is
|
||||
not retained as an extra output node. Preserve declaration order, deterministic
|
||||
map order, nil/empty distinctions, collision diagnostics, codec failures, and
|
||||
secret classification. Scalar projection retains the existing number, duration,
|
||||
text-codec, and secret-reference representations. Keep cycle entries active
|
||||
through child traversal and balance their release on every exit.
|
||||
|
||||
In `schemaBuilder.describeFields`, separate field discovery and tag parsing from
|
||||
inline struct assembly, inline map assembly, and ordinary field registration.
|
||||
Retain one builder/cache and its recursion detection. Make the paired updates to
|
||||
`members`, flattened `fields`, `fieldByName`, and `inlineMap` easy to follow.
|
||||
Keep index-path construction and inherited secret flags intact. These descriptor
|
||||
views are both needed; combining them into one collection would shift complexity
|
||||
into decoding and projection.
|
||||
Keep pointer-chain and recursive-inline decisions explicit, preserve supported
|
||||
field/tag forms and ambiguity paths, and retain cache/building-state behavior.
|
||||
|
||||
Clone traversal review and cleanup are deferred to
|
||||
[future.md](future.md#4-clone-traversal-review). This effort preserves cloning's
|
||||
existing behavior while changing the shared schema and projection machinery.
|
||||
Keep projection's document-building semantics distinct from typed copying.
|
||||
The mostly explicit dispatch in `decodeValue` and `valueBuilder.build` does not
|
||||
warrant a generic reflection visitor solely because it has a high complexity
|
||||
score.
|
||||
|
||||
### 5. Remove Unused Inspection Conversions And Consolidate Real Adapters
|
||||
|
||||
Remove `toEngineSourceInfo`, `toEngineOrigin`, `toEngineSourceVersionCheck`, and
|
||||
`toEngineImportSelections` from [conversion.go](../../conversion.go), along with
|
||||
the engine adapters used only by that chain.
|
||||
At the reviewed revision, the three callers of `toEngineSourceInfo` are tests;
|
||||
`toEngineOrigin` has none. `SourceInfoWithPlacement`,
|
||||
`SourceInfoWithImportSelections`, `importSelectorFromString`, and
|
||||
`importSelectionOutcomeFromString` in [records.go](../../internal/engine/records.go)
|
||||
have no production role beyond that reverse chain. Consumers receive inspection
|
||||
records; production facade operations do not send those records back to the
|
||||
engine. The unused inverse path adds another ownership mapping to maintain as
|
||||
inspection evidence grows. Recheck callers at the implementation baseline before
|
||||
removal.
|
||||
|
||||
Rewrite the affected [record tests](../../records_test.go) around real
|
||||
engine-to-public conversion and detached public inspection. Preserve useful
|
||||
placement, version evidence, import-selection, and copy-isolation assertions.
|
||||
Avoid manufacturing replacement production setters solely to keep an old
|
||||
round-trip fixture working. Existing facade loads already provide genuine sources
|
||||
and inspection records.
|
||||
|
||||
Retain reverse conversions that serve real inputs: `toEngineDiagnostics`, for
|
||||
example, supports `WriteDiagnostics`. Consolidate repeated override/input
|
||||
conversion in `toEngineOptions` and `toEngineLayeredOptions` where semantics
|
||||
match. Keep explicit root-to-engine ownership conversion and the public-boundary
|
||||
checks. A reflective transport framework or public engine aliases would obscure
|
||||
the boundary rather than simplify it.
|
||||
|
||||
## Scope, Compatibility, And Policy Consequences
|
||||
|
||||
The proposed work spans private engine code, private root-facade conversions,
|
||||
affected tests, consumer guidance, and implementation documentation. Under the
|
||||
settled answer to [OQ-1](#oq-1-should-the-public-loading-apis-change-in-this-effort),
|
||||
preserve public types/signatures and the behavior owned by the current contracts,
|
||||
including:
|
||||
|
||||
- Acquisition versus composition ordering, strict scopes versus precedence
|
||||
tiers, identity ownership, placement, and source-version protection.
|
||||
- Discovery exhaustion and error behavior, source lifetime, passive resource
|
||||
contexts, and host/native namespace distinctions.
|
||||
- Defaults, post-decode CLI assignment, recorded resolution, secret handling,
|
||||
final registered-identity enforcement, and semantic validation.
|
||||
- Explicit presence, provenance/history, sensitivity, safe diagnostics and diff,
|
||||
frozen inspection, and detached publication/query results.
|
||||
- Contracted diagnostic codes, paths, coordinates, ordering, and resource limits.
|
||||
|
||||
Do not turn incidental helper order, exact private representations, numerical
|
||||
complexity scores, or allocation counts into new guarantees. Keep the existing
|
||||
algorithmic remediations effective; watch for repeated whole-tree traversals,
|
||||
policy scans, or copies introduced by helper extraction.
|
||||
|
||||
The recommendation fits the existing architecture policy and ADRs 0006,
|
||||
0015, 0017, 0018, and 0019. It needs no new architectural ownership decision or
|
||||
policy revision merely to introduce private helpers. Update the component
|
||||
overview and import internals when code moves. Accepted ADR decision content
|
||||
remains unchanged. A public loading-model redesign is outside this effort's
|
||||
settled scope.
|
||||
|
||||
Pre-1.0 public changes are possible under the [release policy](../policy/release.md),
|
||||
but they must have a consumer benefit and documented impact. This draft does not
|
||||
propose a parallel compatibility API or temporary migration scaffold. It also
|
||||
does not make refactoring completion a new release gate or establish that 1.0
|
||||
is otherwise ready. Future performance experiments remain in
|
||||
[future.md](future.md#3-performance-experiments); no benchmark campaign was run
|
||||
or is required to claim readability improvements.
|
||||
|
||||
## Validation And Completion
|
||||
|
||||
Use the [development guide](../development.md) and
|
||||
[testing policy](../policy/testing.md) for focused checks and the ordinary full
|
||||
development check. Preserve existing behavior tests; rewrite tests coupled only
|
||||
to removed plumbing. Add a test when a consequential handoff risk lacks coverage,
|
||||
not for every new helper.
|
||||
|
||||
| Work area | Existing protection and review focus |
|
||||
| --- | --- |
|
||||
| Acquisition and imports | Selection, defaults, scheduling, relative/item placement, source-version evidence, aggregate limits, repetition within versus across tiers, and ordinary/layered equivalence. |
|
||||
| Resolved contexts | Host/native access, discovery selected/absent outcomes, terminal context failures, source-relative resources, independent overlay context, and handle closure. |
|
||||
| Inheritance | Mapping and record inheritance, nested destination-local evaluation, child identity, policy lifetime, version movement, history ordering, sensitivity, and retained-node budgets. |
|
||||
| Projection/schema | Inline fields, recursive schemas, dynamic values, nil/empty values, cycles and limits, text codecs, secret states, callback ownership, and independent loads; preserve existing cloning behavior where shared descriptors/state are affected. |
|
||||
| Facade and consumers | Forward conversion, detached records, supported API boundaries, source-form equivalence, maintained simple/catalog/discovery examples, and safe explanation/diff. |
|
||||
|
||||
Examples of established boundaries include
|
||||
[source-form equivalence](../../facade_equivalence_test.go),
|
||||
[discovery integration](../../facade_discovery_test.go),
|
||||
[final identity validation](../../facade_final_sequence_identity_test.go),
|
||||
[inheritance history](../../internal/engine/inheritance_history_test.go), and
|
||||
[projection and cloning](../../internal/engine/projection_test.go).
|
||||
Use applicable race checks when changing ownership or shared state. Reuse checks
|
||||
for unchanged content and keep benchmark work separate unless new evidence
|
||||
justifies it and it is authorized.
|
||||
|
||||
Completion requires reviewable evidence that:
|
||||
|
||||
- Simple and advanced consumer examples remain concise and valid, with no new
|
||||
public concepts required for existing workflows.
|
||||
- Both load entry points reach one normalized internal model; acquisition no
|
||||
longer reconstructs broad options or uses version state to transport defaults.
|
||||
- Import, context, inheritance, and reflection responsibilities have identifiable
|
||||
owners and explicit handoffs without duplicate semantic implementations.
|
||||
- Unused reverse conversions and their exclusive adapters are removed, with
|
||||
useful inspection ownership coverage retained.
|
||||
- Contracted values, presence, provenance/history, diagnostics, sensitivity,
|
||||
resource lifetime, and algorithmic corrections remain protected by appropriate
|
||||
tests and source review.
|
||||
- The reviewed scope is accounted for against all six audit findings, with clone
|
||||
traversal review deferred to future work. Completed portions have validation
|
||||
evidence rather than being closed because code moved.
|
||||
- Consumer guidance, contributor references, and internal documentation describe
|
||||
the resulting implementation. Current contracts continue to own behavior.
|
||||
|
||||
The [implementation plan](implementation.md) defines private records, file owners,
|
||||
and stage boundaries. No open scope questions remain, and no additional feature
|
||||
semantics are needed to carry out the selected refactors.
|
||||
|
||||
## Settled Decisions
|
||||
|
||||
### OQ-1: Should The Public Loading APIs Change In This Effort?
|
||||
|
||||
**Settled:** retain `Load`/`Options`, `LoadLayered`/`LayeredOptions`, `Input`, and
|
||||
`Underlay`, including their existing overlay-context behavior. Consolidate their
|
||||
private inputs, conversions, and acquisition machinery, and improve consumer
|
||||
guidance. The [consumer-facing target](#consumer-facing-target) records the
|
||||
rationale and tradeoffs. This decision governs the consolidation effort; it does
|
||||
not establish a perpetual restriction on future API design.
|
||||
|
||||
### OQ-2: How Much Clone Cleanup Should This Effort Commit To?
|
||||
|
||||
**Settled:** defer clone traversal review and any resulting cleanup to
|
||||
[future.md](future.md#4-clone-traversal-review). Neither the review nor clone
|
||||
refactoring is part of this consolidation effort. Projection and schema
|
||||
refactoring remain in scope, including protection of existing cloning behavior
|
||||
where they share descriptors or operation state. The future review can conclude
|
||||
that no clone edits are warranted; its goal is demonstrated maintenance benefit.
|
||||
@@ -1,51 +0,0 @@
|
||||
# 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 following remain deliberately excluded:
|
||||
|
||||
- automatic `conf.d` discovery;
|
||||
- remote imports, arbitrary providers, and additional configuration formats;
|
||||
- custom merge strategies, sequence merging, reset operators, multiple-parent
|
||||
inheritance, and cross-scope references;
|
||||
- named 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.
|
||||
|
||||
Built-in sibling inheritance using `extends` is implemented as described in the
|
||||
[configuration contract](../config.md#sibling-inheritance). It is distinct from
|
||||
inheritance between selected overlay files, which remains excluded above.
|
||||
|
||||
## Deferred performance experiments
|
||||
|
||||
The completed performance audit left these internal optimization candidates for
|
||||
separate investigation:
|
||||
|
||||
- branch-local explanation replay instead of the current whole-tree replay;
|
||||
- redesigning the private metadata-copy handoff while preserving detached public
|
||||
records;
|
||||
- caching schema descriptors across loads; and
|
||||
- indexing complex contributor-trace membership while preserving its equality
|
||||
rules and compact linked traces.
|
||||
|
||||
These are not committed implementation work. Reconsider them with evidence that
|
||||
the benefit justifies the ownership, retained-memory, or equivalence risks. The
|
||||
completed audit and implementation evidence are preserved in Git history at
|
||||
`8e91a2b` under `docs/roadmap/`.
|
||||
118
docs/roadmap/future.md
Normal file
118
docs/roadmap/future.md
Normal file
@@ -0,0 +1,118 @@
|
||||
# Potential Future Features
|
||||
|
||||
This document lists potential enhancements for consideration in future ConfigKit
|
||||
releases. Entries are not accepted contracts, implementation plans, or release
|
||||
commitments. Adopting a candidate requires an explicit design decision and a
|
||||
separate plan. Significant architectural exclusions and their rationale belong
|
||||
in ADRs; this document does not maintain a list of rejected proposals.
|
||||
|
||||
## 1. Recursive body merging for identified records
|
||||
|
||||
Status: deferred. The implemented
|
||||
[collection policy](../config.md#strict-document-composition) uses `upsert` to
|
||||
replace a complete matched record. Recursive merging of fields inside matching
|
||||
records remains a separate candidate and would revisit the whole-record update
|
||||
boundary selected by [ADR 0017](../adr/0017-collection-sequence-policy.md).
|
||||
|
||||
Consider this only in response to a concrete consumer need. A proposal would need
|
||||
to define conflicts for overlapping body fields, removal of omitted fields,
|
||||
presence and ordering, and how provenance, history, sensitivity, and resource
|
||||
context follow partially retained records. Any change requires a new decision and
|
||||
implementation plan; this entry does not alter current upsert behavior.
|
||||
|
||||
## 2. Required version declarations for selected sources
|
||||
|
||||
Status: deferred. PromptKit requested a way to require version declarations on
|
||||
a selected subset of source files while permitting other fragments to omit them.
|
||||
|
||||
### Motivation and proposed behavior
|
||||
|
||||
PromptKit could require each collection's root file (for example,
|
||||
`collection.yml` or `meta.yml`, with naming still undecided) to declare an
|
||||
accepted specification version. Other files would use the existing
|
||||
[check-when-present behavior](../config.md#shared-source-version-checks).
|
||||
This would enforce a rule that otherwise remains a documentation convention.
|
||||
|
||||
All declarations would use the same application-owned `Definition.Version`
|
||||
path and acceptance policy. The proposal concerns where declarations are
|
||||
required; it does not introduce independently versioned collections or subtrees.
|
||||
A root declaration would not prove that headerless descendants declare a
|
||||
compatible version or replace final configuration validation.
|
||||
|
||||
### Questions to resolve before implementation
|
||||
|
||||
- **Selection API:** should callers mark explicit inputs, select sources by
|
||||
path or predicate, or annotate imports? An input-level requirement is simpler,
|
||||
but cannot by itself select a root reached only through an import.
|
||||
- **Scope:** does a requirement apply only to the selected document or also to
|
||||
its imports? Applying it only to that document best fits PromptKit's request;
|
||||
imported fragments could still declare versions checked by the shared policy.
|
||||
- **Mode interaction:** how should selection interact with disabled source
|
||||
checks? Require an enabled mode, or activate checking for selected sources?
|
||||
Selection would strengthen check-when-present behavior and add no requirement
|
||||
beyond the load-wide required mode.
|
||||
- **Source handling and evidence:** how should selection work across file,
|
||||
reader, byte, and mapping inputs, glob matches, and repeated imports? Define
|
||||
behavior for absent optional inputs and preserve source-local diagnostics,
|
||||
inspection evidence, and existing sensitivity handling.
|
||||
|
||||
## 3. Performance experiments
|
||||
|
||||
Status: deferred pending evidence. The completed performance audit identified
|
||||
four internal optimization candidates for separate investigation:
|
||||
|
||||
- Branch-local explanation replay instead of the current whole-tree replay.
|
||||
- Redesigning the private metadata-copy handoff while preserving detached public
|
||||
records.
|
||||
- Caching schema descriptors across loads.
|
||||
- Indexing complex contributor-trace membership while preserving its equality
|
||||
rules and compact linked traces.
|
||||
|
||||
These are experiments, not committed implementation work or promised speedups.
|
||||
Reconsider them with evidence that the benefit justifies the ownership,
|
||||
retained-memory, or equivalence risks. The completed audit and implementation
|
||||
evidence are preserved in Git history at `8e91a2b` under `docs/roadmap/`.
|
||||
|
||||
### Questions to resolve before implementation
|
||||
|
||||
- Which representative workloads show enough remaining cost to justify each
|
||||
optimization, and what measurements would establish a useful improvement?
|
||||
- Can narrower replay and membership indexes preserve explanation results,
|
||||
ordering, provenance, and sensitivity behavior?
|
||||
- Can metadata handoff changes preserve detached public records and ownership
|
||||
across loads without introducing aliasing?
|
||||
- What cache lifetime and concurrency design would make schema reuse worthwhile
|
||||
without excessive retained memory?
|
||||
|
||||
## 4. Clone traversal review
|
||||
|
||||
Status: deferred. Review typed configuration cloning after the projection and
|
||||
schema consolidation work, when the resulting structure can inform the review.
|
||||
This is future maintenance work, not a prerequisite for completing consolidation.
|
||||
|
||||
The complexity audit identified `valueOperation.clone` in
|
||||
[clone.go](../../internal/engine/clone.go) as a candidate for cleanup. Much of its
|
||||
size is straightforward type dispatch. Focused handlers may clarify allocation,
|
||||
recursive copying, cycle tracking, secret-state handling, or text-codec
|
||||
reconstruction, but extraction may also add indirection without making the code
|
||||
easier to understand.
|
||||
|
||||
When this work is taken up, assess the traversal and record whether changes are
|
||||
worthwhile; a conclusion that no refactor is needed is valid. Make focused
|
||||
changes only where the maintenance benefit justifies the code movement. Keep
|
||||
cloning's typed-copy semantics distinct from projection's document-building
|
||||
semantics. Preserve detached values, nil/empty distinctions, physical inline
|
||||
fields, cycle/depth checks, and unresolved versus validated secret handling.
|
||||
Reuse existing ownership, codec, secret, and traversal tests, adding coverage for
|
||||
consequential gaps. No performance improvement is assumed.
|
||||
|
||||
### Questions to resolve before implementation
|
||||
|
||||
- After projection and schema consolidation, which clone branches still mix
|
||||
responsibilities enough to benefit from extraction?
|
||||
- Would a proposed helper keep allocation, recursive copying, and cycle cleanup
|
||||
easier to follow together, or merely move a short loop out of sight?
|
||||
- Are any small shared operations semantically identical across traversals, or
|
||||
would sharing require special cases that obscure their different purposes?
|
||||
- Which ownership or special-value risks, if any, lack sufficient existing test
|
||||
coverage for the proposed changes?
|
||||
783
docs/roadmap/implementation.md
Normal file
783
docs/roadmap/implementation.md
Normal file
@@ -0,0 +1,783 @@
|
||||
# Consolidation Implementation Plan
|
||||
|
||||
Status: complete. Stages 1 through 20 are complete.
|
||||
The target executor is a Luna coding agent working through one end-to-end sprint.
|
||||
|
||||
## Authority, Baseline, And Execution
|
||||
|
||||
The [consolidation roadmap](consolidation.md) owns the target state, rationale,
|
||||
and settled scope. Start from `8a99e6107001c69117a41dc37bc6ea09d2910af9` plus the
|
||||
commit introducing this plan. Production code is unchanged from the audited
|
||||
`5959537d1adc2516e7f7e8881df72b707ec48c3f`; intervening commits consolidated the
|
||||
planning documents and settled the public-API and clone decisions.
|
||||
|
||||
Read [development.md](../development.md), [agents.md](../policy/agents.md),
|
||||
[architecture.md](../policy/architecture.md), [testing.md](../policy/testing.md),
|
||||
and [documentation.md](../policy/documentation.md) as applicable. Use the code
|
||||
graph for discovery and confirm source at the implementation baseline. Current
|
||||
public comments and [config.md](../config.md) define behavior; this plan does not
|
||||
add public obligations.
|
||||
|
||||
Implement only the selected consolidation. Retain the public `Load`,
|
||||
`LoadLayered`, options, inputs, callbacks, source/search descriptions, inspection,
|
||||
and sequence-registration APIs. Keep the root-to-engine-to-document/YAML
|
||||
dependency direction. Clone traversal review/refactoring remains deferred under
|
||||
[future.md](future.md#4-clone-traversal-review). Changes to shared descriptors must
|
||||
preserve existing cloning behavior. This sprint does not publish a release,
|
||||
update dependencies/toolchain requirements, change configuration semantics, or
|
||||
introduce complexity/performance gates.
|
||||
|
||||
Stages are bounded work assignments, not independently shippable commits.
|
||||
Intermediate stages may leave callers, tests, or compilation unfinished until a
|
||||
later stage. Proceed directly through those dependencies; do not build temporary
|
||||
compatibility layers solely to keep each stage compiling. Update affected tests
|
||||
with their callers, and run the focused checks below when that portion builds.
|
||||
Record any deferred verification accurately. The completed sprint must compile,
|
||||
pass its checks, and leave no superseded plumbing. Commit completed, reviewable
|
||||
units under the agent policy; stage boundaries do not require separate commits.
|
||||
|
||||
Preserve existing meaningful validation/processing order, diagnostics, ownership,
|
||||
and limits. Private helper order and representation are not new contracts. Do
|
||||
not add rejection rules, error recovery, caches, new merge strategies, another
|
||||
pipeline framework, or tests for incidental helper choreography. A correctness
|
||||
issue discovered during the refactor needs identifiable regression evidence and
|
||||
an explicit explanation, rather than an unexplained expectation change.
|
||||
|
||||
## Chosen Private Design
|
||||
|
||||
Use the existing `loadRequest[T]` as the normalized complete-load model and
|
||||
`loadInput` for each selected input. Keep generic application definitions and
|
||||
the final lifecycle in `executeLoadRequest[T]`; acquisition is non-generic.
|
||||
The following records are ordinary engine-private data, not extension points.
|
||||
Use these names to make migrations concrete; their fields describe the selected
|
||||
ownership model, not a requirement to duplicate data already available there.
|
||||
|
||||
| Record | Fields and ownership |
|
||||
| --- | --- |
|
||||
| `acquisitionPolicy` | Read-only operation snapshot of `importDefaults`, `sequencePolicy`, and `importLimits`. Snapshot caller-owned defaults at entry, preserving nil versus empty. |
|
||||
| `acquisitionState` | Borrowed pointers to the load's `importAccounting` and `sourceVersionState`. One instance per load; version state no longer transports import defaults. |
|
||||
| `tierAcquisitionState` | The tier's visited-resource map and shared host namespace token. Underlay groups each own a fresh instance. Existing group graph aggregation and namespace anchors retain their current owners. |
|
||||
| `inputAcquisitionRequest` | `input loadInput`, source-version role, root-directive context, applicable protection rule, deferred-assembly choice, and `ordinaryOverlay string`. The last field carries only the ordinary main input's overlay-dependent validation/context timing; it is empty for underlays and layered main. |
|
||||
| `importAcquisitionRequest` | The input request, parsed main source, and already computed source-version/directive processing result. It preserves the distinction between source-local processing and application-path protection. |
|
||||
| `resolvedFilesystemContext` | `native *nativeNamespace` (nil means host), `hostToken *namespaceToken` where supplied, and `rootPath`, `basePath`, `mainPath` strings. Native facts use slash-relative paths; host facts use the existing canonical host paths. The record contains no open root/file or acquisition access object. |
|
||||
|
||||
The main private entry is
|
||||
`acquireInput(request inputAcquisitionRequest, policy acquisitionPolicy, state *acquisitionState, tier *tierAcquisitionState) (assembledInput, []Diagnostic)`.
|
||||
Import acquisition uses the corresponding named request with the same borrowed
|
||||
policy/state/tier owners. An access-based worker receives a resolved filesystem
|
||||
description and an explicitly owned `*filesystemAccess`, rather than more
|
||||
positional paths and independent optional policy parameters. Retain
|
||||
`assembledInput`, `acquisitionScope`, and `acquiredContribution` as the structural
|
||||
handoffs; add an optional resolved-context field to `assembledInput` for reuse.
|
||||
|
||||
`importTraversal` borrows the policy and state references. Active routes, stack,
|
||||
current item scope/prefix/fallback, and the acquired inclusion graph remain
|
||||
traversal-local. Nested item traversal shares the active graph and tier repetition
|
||||
state; it does not create another tier. Reusing a file in a later tier remains
|
||||
valid. Do not collapse namespace identity, lexical candidate spelling, and stable
|
||||
scheduling keys into one key.
|
||||
|
||||
Context facts are obtained at the existing points where those facts are needed.
|
||||
There is no global eager context-validation pass or persistent live-handle owner.
|
||||
Directory checks, canonicalization, handle opening/closing, and passive view
|
||||
creation remain distinguishable. Reusing a description does not authorize
|
||||
earlier checks that bypass an existing earlier error or implicit loading activity
|
||||
after publication. Consumer-initiated passive filesystem operations retain their
|
||||
existing contract.
|
||||
|
||||
For inheritance, retain both scope adapters and one executor. Use private
|
||||
prepared-child/prepared-parent records for combination inputs, then an
|
||||
`inheritanceTemplateResult` containing the resolved value, origins, local policy,
|
||||
policy templates, and the controls/transfers/replacements/operations produced by
|
||||
that template. Sequence position evidence lives in the existing operation
|
||||
records. Merge callbacks mutate that local result, not the enclosing executor's
|
||||
accumulator. The executor appends the result and applies existing accounting.
|
||||
|
||||
For reflection, keep `valueOperation` and `schemaBuilder` as their existing state
|
||||
owners. Extract substantial value-kind and inline-field operations as methods;
|
||||
do not introduce a generic reflection visitor or merge the two schema views.
|
||||
|
||||
## Validation Groups
|
||||
|
||||
Use the installed compatible Go toolchain and already acquired dependencies as
|
||||
described in the development guide. Commands run from the repository root with
|
||||
`GOTOOLCHAIN=local` and `GOPROXY=off`. These are development checks for this
|
||||
refactor, not additions to the release procedure.
|
||||
|
||||
| Group | Commands and purpose |
|
||||
| --- | --- |
|
||||
| F: facade cleanup | `go test . -run 'SourceInfo|SourceVersionCheck|ImportSelection|Facade|PublicFacadeBoundary|DependencyImportBoundary'` checks useful forward evidence and the existing public boundary. |
|
||||
| A: acquisition integration | `go test ./internal/engine -run 'Load|Grouped|Source|Search|Discovery|Import|Glob|Filesystem|Native|Destination|Overlay|Resource|Version|Sequence|Collection'`; `go test . -run 'Facade|EquivalentSourceForms|Search|SourceContext|ImportDefaults|SourceVersion'`; `go test ./examples/consumers`. Covers normalization, ordinary/layered acquisition, selection, contexts, scopes, and consumer workflows. |
|
||||
| I: inheritance | `go test ./internal/engine -run 'Inheritance|Inherited|Sequence|Collection|Version|History|Explain|Sensitivity|Secret'`; `go test . -run 'Inheritance|Inherited|SequenceIdentity|FinalSequence'`. Covers shared adapters and their evidence. |
|
||||
| R: reflection | `go test ./internal/engine -run 'Schema|Decode|Projection|Clone|Inline|CompareDocument|Callback|Ownership|Independent|Secret'`. Covers descriptors, projection, shared cloning behavior, and ownership. |
|
||||
| Final | `./scripts/check.sh` followed by `go test -race ./...`. The first owns formatting, whitespace, ordinary tests/examples/doc links, vet, and build; race validation is appropriate because state and ownership handoffs change throughout this sprint. |
|
||||
|
||||
Run F after Stage 2, A after Stages 9 and 13, I after Stage 16, and R after
|
||||
Stage 18 when buildable. If earlier migrations still prevent compilation, repair
|
||||
them at the next integration point and run the deferred group there. Run the
|
||||
final checks once on the finished code/documentation; repeat only for subsequent
|
||||
changes affecting their evidence. No benchmark campaign, timing threshold,
|
||||
traversal-count test, extended fuzz campaign, CI wait, or manual platform check is
|
||||
required by this plan. Add narrow behavior tests only for consequential gaps
|
||||
identified below that existing tests do not already cover.
|
||||
|
||||
## Stage 1 — Establish The Starting Map (Complete)
|
||||
|
||||
**Work:**
|
||||
|
||||
1. Confirm the revision/worktree and preserve unrelated work. Read the roadmap's
|
||||
settled decisions and the selected owners above.
|
||||
2. Map the current callers of `selectOptions`, `assembleBaseInput...`, both
|
||||
`loadImported...` ladders, `contextsForAcquiredSources`, the selected-overlay
|
||||
loaders, and `applyInheritanceScopeTemplates`. Inspect test callers as well
|
||||
as production callers; some old entry variants exist mainly for tests.
|
||||
3. Identify the existing behavioral owners for repeated resources within/across
|
||||
tiers, enabled/disabled version checking, selected/absent searches, ordinary
|
||||
versus independent layered overlays, item-local helper scopes, inherited
|
||||
identity/history, inline fields, and detached records.
|
||||
4. Keep brief working notes or implementation progress in this plan. Reuse
|
||||
existing validation evidence where applicable; establish an implicated
|
||||
baseline only when needed to distinguish a pre-existing failure. Do not
|
||||
generate another broad audit or blanket characterization-test suite.
|
||||
|
||||
**Finish:** a concrete caller/coverage map and any actual coverage gaps. No
|
||||
production change or empty commit is needed for orientation alone.
|
||||
|
||||
Progress notes (2026-10-25): the worktree was clean at `8ab1568`, immediately
|
||||
after the planning baseline `8a99e61`; the audited production baseline
|
||||
`5959537` is present. `selectOptions` is called by the ordinary and grouped
|
||||
load entry points. The `assembleBaseInput...` wrappers converge on
|
||||
`assembleBaseInputTrackedForRole`, reached from ordinary loading, layered main
|
||||
loading, and underlay-group acquisition. The tracked import wrappers converge
|
||||
on `loadImportedDocumentsTrackedWithProcessing`; production callers are input
|
||||
acquisition and underlay-group acquisition, while older convenience variants
|
||||
also have direct test callers. `contextsForAcquiredSources` is shared by ordinary
|
||||
loading, layered assembly, input acquisition, and overlay application. Selected
|
||||
overlay loading is reached from ordinary overlay application and layered input
|
||||
assembly. Both inheritance adapters, for ordinary and identified sequences,
|
||||
feed `applyInheritanceScopeTemplates`.
|
||||
|
||||
Existing behavioral owners cover repeated/cyclic resources and cross-tier reuse
|
||||
(`internal/engine/imports_test.go`, `facade_equivalence_test.go`); default and
|
||||
enabled source-version modes (`internal/engine/source_version_test.go`,
|
||||
`facade_source_version_mode_test.go`); selected/absent discovery
|
||||
(`internal/engine/discovery_load_test.go`, `facade_discovery_test.go`); ordinary
|
||||
and layered overlays (`internal/engine/selected_overlay_test.go`,
|
||||
`facade_layered_test.go`); item-local helper scopes (`internal/engine/imports_test.go`,
|
||||
`facade_import_lifecycle_test.go`); inheritance identity/history
|
||||
(`internal/engine/inheritance_identity_history_test.go`,
|
||||
`internal/engine/inheritance_history_test.go`); inline fields
|
||||
(`internal/engine/schema_test.go`, `internal/engine/projection_test.go`); and
|
||||
detached results and callback ownership (`internal/engine/metadata_test.go`,
|
||||
`internal/engine/callback_ownership_test.go`, `facade_diff_test.go`). No specific
|
||||
coverage gap surfaced in this map; later focused checks remain assigned in the
|
||||
validation groups above.
|
||||
|
||||
## Stage 2 — Remove Unused Reverse Bridges And Share Real Facade Conversion (Complete)
|
||||
|
||||
**Owners:** root `conversion.go`, `records_test.go`, `lifecycle.go`, `layered.go`,
|
||||
and engine `records.go`.
|
||||
|
||||
1. Reconfirm callers, then delete `toEngineSourceInfo`, `toEngineOrigin`,
|
||||
`toEngineSourceVersionCheck`, and `toEngineImportSelections`, plus
|
||||
`SourceInfoWithPlacement`, `SourceInfoWithImportSelections`,
|
||||
`importSelectorFromString`, and `importSelectionOutcomeFromString` when their
|
||||
only remaining purpose is the deleted chain.
|
||||
2. Rewrite the three reverse-round-trip record tests using genuine engine output
|
||||
and forward conversion/public inspection. Retain placement, version-evidence,
|
||||
selection-evidence, nil/empty, and detached-copy protection. Engine test
|
||||
fixtures can use existing loading APIs; do not add production fixture setters.
|
||||
3. Extract one root `toEngineOverrides` helper and use it from both option
|
||||
converters. Reuse `toEngineInput`, filesystem, path, definition, and default
|
||||
conversion where already shared. Preserve slice ownership and nil/empty
|
||||
behavior; do not make conversion interpret/validate input values.
|
||||
4. Retain reverse diagnostic conversions used by `WriteDiagnostics`, forward
|
||||
source/origin inspection conversion, and all public named types.
|
||||
|
||||
**Finish:** the unnecessary cross-package inverse interface is gone and real
|
||||
conversion is shared without a new transport layer. Run F.
|
||||
|
||||
Progress notes (2026-10-25): removed the unused reverse source-record bridge
|
||||
and engine setters, replaced synthetic round-trip fixtures with engine loads,
|
||||
and shared override conversion across ordinary and layered options. Validation
|
||||
group F passed with `GOTOOLCHAIN=local GOPROXY=off go test . -run
|
||||
'SourceInfo|SourceVersionCheck|ImportSelection|Facade|PublicFacadeBoundary|DependencyImportBoundary' -count=1`.
|
||||
|
||||
## Stage 3 — Normalize Preflight Without Fabricated Options (Complete)
|
||||
|
||||
**Owners:** engine `options.go`, ordinary entry in `load.go`, grouped entry in
|
||||
`load_request.go`, and their option tests.
|
||||
|
||||
1. Extract non-generic input normalization/validation over `loadInput`, returning
|
||||
the normalized input plus diagnostics. Pass the ordinary overlay name only
|
||||
where its context requirements apply. Keep discovery-description validation,
|
||||
placement checks, normalized destinations, and filesystem option checks at
|
||||
their existing pre-acquisition boundary.
|
||||
2. Separate load-wide import-default validation, version-rule/mode validation,
|
||||
and sequence-registration compilation from per-input checks. Share their
|
||||
mechanisms between entries without adding a second interpretation of options.
|
||||
3. Keep the public/engine entry arity handling and each entry's existing error
|
||||
classification. Ordinary loading still translates its one selected options
|
||||
value; layered loading validates main, all underlay inputs, and its independent
|
||||
overlay context in the existing meaningful order. Preserve canonical diagnostic
|
||||
accumulation and early returns, including invalid options before reads.
|
||||
4. Replace the grouped entry's constructed `Options[T]` values with direct
|
||||
normalization of `loadInput`; set the effective protection rule from the
|
||||
normalized main placement. Leave typed application behavior in the definition.
|
||||
5. Remove `importDefaultsValidated` and preflight-only forwarding machinery when
|
||||
callers migrate. Update tests through the entry/helper that owns the behavior,
|
||||
rather than keeping `selectOptions` solely as a test compatibility surface.
|
||||
|
||||
**Finish:** both entries produce validated `loadRequest[T]` values without using
|
||||
the ordinary options type to validate grouped inputs. Acquisition may still use
|
||||
old options until Stage 6; do not broaden this stage into that migration.
|
||||
|
||||
Progress notes (2026-10-25): added shared normalization over `loadInput`, moved
|
||||
load-wide defaults, source-version, and sequence-registration validation into
|
||||
shared settings preparation, and removed `selectOptions` plus its validation
|
||||
flag from grouped preflight. `GOTOOLCHAIN=local GOPROXY=off go test ./...` passed.
|
||||
|
||||
## Stage 4 — Express Input Acquisition With Non-Generic Requests (Complete)
|
||||
|
||||
**Owners:** new engine `acquisition.go` and existing `load_input.go`.
|
||||
|
||||
1. Define `acquisitionPolicy`, `acquisitionState`, `tierAcquisitionState`,
|
||||
`inputAcquisitionRequest`, and `importAcquisitionRequest` as selected above.
|
||||
Reuse existing enums, policy records, and accounting; introduce no new public
|
||||
controls or duplicate budget counter.
|
||||
2. Replace the generic `assembleBaseInputTrackedForRole` implementation with
|
||||
`acquireInput`. Translate its references to source, imports, filesystem,
|
||||
placement, role, directive context, version rule, policy, and deferred
|
||||
assembly into explicit request/policy/state fields.
|
||||
3. Keep its phases visible: search selection; parse and failed-source evidence;
|
||||
disabled-directive checks; source-local version/directive processing; import
|
||||
acquisition or single-input placement; accounting/repetition registration;
|
||||
context handoff. Small helpers may isolate version/directive preparation.
|
||||
4. Preserve discovery resolution once per input consumption and the acquired
|
||||
concrete source on both success and failure. Keep mapping diagnostic
|
||||
destination projection and the ordinary-overlay context deferral unchanged.
|
||||
5. Retain `assembledInput`'s existing structural/evidence fields. Do not merge
|
||||
parsed/local payloads with placed documents or eagerly compose deferred input.
|
||||
|
||||
**Finish:** the shared input-acquisition implementation no longer depends on
|
||||
`Options[T]` or an application type. Call-site and import-worker migration is
|
||||
completed by Stages 5–6; temporary non-compilation is acceptable.
|
||||
|
||||
Progress notes (2026-10-25): introduced the non-generic acquisition request and
|
||||
policy/state/tier records, moved the input-acquisition implementation into
|
||||
`acquireInput`, and added the resolved-context handoff field. The focused engine
|
||||
test command currently cannot compile because the old assembly callers in
|
||||
`load_input.go` and `load_request.go` are scheduled to migrate in Stage 6; test
|
||||
verification is deferred until that integration.
|
||||
|
||||
## Stage 5 — Consolidate Import Requests And Default Ownership (Complete)
|
||||
|
||||
**Owners:** engine import entry points, `importTraversal`, `source_version.go`,
|
||||
and tests that directly invoke the old import variants.
|
||||
|
||||
1. Replace the `loadImportedDocumentsTracked...` ladder with
|
||||
`acquireImportedInput` using `importAcquisitionRequest` and the shared
|
||||
policy/state/tier owners. Replace the access-based ladder with one worker
|
||||
receiving the same request plus explicit access/resolved facts. Keep only
|
||||
genuinely distinct convenience entries, not one wrapper per optional field.
|
||||
2. Move `importDefaults` out of `sourceVersionState`. Resolve declarations from
|
||||
`acquisitionPolicy.importDefaults`, including nested item scopes. Import
|
||||
defaults must work independently of whether a version rule/state is active.
|
||||
Preserve enabled version processing and its separate local/effective rules.
|
||||
3. Make traversal references to aggregate accounting, version evidence, tier
|
||||
repetition, and immutable policies point to their existing operation owners.
|
||||
Keep active-route tracking, scope switching, and inclusion edges local to
|
||||
the traversal. Do not allocate a fresh visited map inside a nested item scope.
|
||||
4. Preserve nil version-state behavior used by lower-level operations and the
|
||||
existing default processing branch. Replace optional positional/variadic
|
||||
policy passing with explicit inputs for meaningful operation distinctions.
|
||||
Private test entry spellings do not need compatibility wrappers; preserve
|
||||
their relevant behavioral cases through the resulting operation.
|
||||
5. Migrate direct import tests to named inputs. Small test-only constructors may
|
||||
supply default state/policy values when this reduces repetitive fixtures.
|
||||
Keep their behavior assertions intact.
|
||||
|
||||
**Finish:** the import request surface has named cohesive inputs; defaults no
|
||||
longer travel through version processing; no forwarding ladder remains merely
|
||||
to supply another nil/default positional parameter.
|
||||
|
||||
Progress notes (2026-10-25): replaced the import entry ladders with named
|
||||
requests and one access worker, moved defaults to acquisition policy, and
|
||||
retargeted direct import tests. The focused engine test command cannot compile
|
||||
yet because the remaining ordinary and grouped assembly callers are scheduled
|
||||
for Stage 6; verification is deferred until that integration. `git diff --check`
|
||||
passed.
|
||||
|
||||
## Stage 6 — Wire Ordinary And Grouped Lifecycle To Acquisition (Complete)
|
||||
|
||||
**Owners:** engine `load.go`, `load_request.go`, `load_input.go`, related tests.
|
||||
|
||||
1. Construct the load's acquisition policy and mutable state once in
|
||||
`executeLoadRequest`. Keep the definition and schema/callback lifecycle there.
|
||||
2. Call `acquireInput` for main using its ordinary/layered directive context,
|
||||
main version role, current protection rule, and deferred assembly. Preserve
|
||||
the existing schema-build and version-check timing, including the different
|
||||
default/enabled source-version paths.
|
||||
3. Have `acquireUnderlayGroupScopes` use the same load policy/accounting/version
|
||||
state and one fresh tier state per group. Remove `Options[struct{}]`
|
||||
construction. Preserve explicit namespace anchors and graph reanchoring.
|
||||
4. Main acquisition still precedes underlays. Strict assembly still follows
|
||||
the existing dependency-ready scopes and lower-tier identity information.
|
||||
Keep group-local repetition separate from load-wide limits, and retain the
|
||||
existing selected-overlay duplicate-source checks.
|
||||
5. Replace the ordinary `applyInputLayers` options argument with the precise
|
||||
main/overlay/protection/policy inputs it uses. A named overlay request will
|
||||
complete that migration in Stage 9. Remove remaining production
|
||||
`assembleBaseInput...` variants and the acquisition-only `sequencePolicy`,
|
||||
`deferAssembly`, and `directiveContext` fields from `Options`. Retain the
|
||||
placement, destination, and import-default fields used by facade translation.
|
||||
6. Retain real lower-level test/benchmark capabilities with direct request
|
||||
construction or test-only adapters. Do not delete meaningful source/limit
|
||||
cases to make removed helpers disappear.
|
||||
|
||||
**Finish:** no executor or underlay caller reconstructs broad/generic options
|
||||
for acquisition. The shared path is wired end to end; context and import-flow
|
||||
extraction continue in later stages.
|
||||
|
||||
Progress notes (2026-10-25): `executeLoadRequest` now creates one acquisition
|
||||
policy/state and acquires the main input through `acquireInput`; underlay groups
|
||||
reuse those load owners with a fresh tier state. Removed the generic base-input
|
||||
assembly variants and the acquisition-only fields from `Options`, and adapted
|
||||
direct tests to construct named requests. A search-source regression was caught
|
||||
and fixed during verification. The focused engine selection, facade selection,
|
||||
consumer example tests, and `GOTOOLCHAIN=local GOPROXY=off go test ./...` passed.
|
||||
|
||||
## Stage 7 — Separate Resolved Filesystem Facts From Access (Complete)
|
||||
|
||||
**Owners:** new engine `filesystem_context.go`, `filesystem.go`,
|
||||
`filesystem_access.go`.
|
||||
|
||||
1. Add `resolvedFilesystemContext` with the fields selected above. Reuse native
|
||||
namespace/token identity; do not compare arbitrary `fs.FS` values or equate
|
||||
equal labels with equal namespaces.
|
||||
2. Extract host and native fact derivation from `importFilesystem` and
|
||||
`nativeFilesystemAccess`. Prefer a selected `SourceInfo.Path` when it already
|
||||
supplies canonical source identity. Preserve source-derived file bases,
|
||||
explicit in-memory roots/bases, native slash paths, and the root-only absent
|
||||
host default.
|
||||
3. Keep separate functions for the directory checks and opening access that
|
||||
currently occur at each operation. A returned description does not mean
|
||||
every possible check has run. Do not add validation-state flags or an
|
||||
all-purpose eager resolver with stronger acceptance rules.
|
||||
4. Adapt the import worker to consume resolved facts and explicitly own its
|
||||
access until the existing acquisition handoff closes it. Keep close-error
|
||||
reporting and primary-error precedence unchanged. Native access borrows its
|
||||
caller filesystem and must not close it.
|
||||
5. Keep any old derivation adapters only while genuine callers migrate in
|
||||
Stages 8–9; remove them at the end of that migration.
|
||||
|
||||
**Finish:** common context facts have one private representation while validation
|
||||
timing and live resource ownership remain explicit.
|
||||
|
||||
Progress notes (2026-10-25): added shared host/native filesystem descriptions,
|
||||
separated directory validation and access creation, and migrated import
|
||||
acquisition to pass resolved facts alongside its owned access. The prior
|
||||
derivation adapters remain for the validator and overlay migration. Focused
|
||||
acquisition tests, `GOTOOLCHAIN=local GOPROXY=off go test ./...`, and
|
||||
`git diff --check` passed.
|
||||
|
||||
## Stage 8 — Carry Context Facts Through Input And Passive Views (Complete)
|
||||
|
||||
**Owners:** engine `load_input.go`, `source_context.go`, `load_request.go`, and
|
||||
the ordinary context backfill in `load.go`.
|
||||
|
||||
1. Add the optional resolved-context field to `assembledInput`. Populate it
|
||||
from import acquisition or the existing non-import context validation point.
|
||||
When context work is deferred for an ordinary overlay, leave it deferred.
|
||||
2. Replace the source-kind derivation branches of `contextsForAcquiredSources`
|
||||
with `contextsForResolvedSources`: consume resolved facts and source records,
|
||||
construct the existing rooted host/native passive view, and compute each
|
||||
source's relative base using the appropriate path rules.
|
||||
3. Share relative-base/containment calculation where applicable; retain each
|
||||
acquired source's own path and source-ID association. Do not substitute the
|
||||
first source record for the selected main file or attach all values to main.
|
||||
4. Migrate normal input acquisition, underlay contexts, and ordinary context
|
||||
backfill. Resolve only still-missing facts at the same lifecycle point as
|
||||
before. Retained descriptions/views never capture `filesystemAccess` or
|
||||
`*os.Root`; the existing passive `ResourceContext` contract stays unchanged.
|
||||
5. Retain failure evidence, root-only absent host context, discovered native
|
||||
outcomes, and deferred search-base behavior. Update focused context tests
|
||||
only where private function signatures changed.
|
||||
|
||||
**Finish:** ordinary/underlay acquisition and passive views use the common facts;
|
||||
selected-overlay callers are the remaining migration in Stage 9.
|
||||
|
||||
Progress notes (2026-10-25): input acquisition now retains resolved filesystem
|
||||
facts and builds passive contexts from those facts for ordinary and underlay
|
||||
sources. Migrated the ordinary backfill and reordered-source context test. The
|
||||
selected-overlay adapters remain for Stage 9. Focused context/acquisition tests,
|
||||
`GOTOOLCHAIN=local GOPROXY=off go test ./...`, and `git diff --check` passed.
|
||||
|
||||
## Stage 9 — Migrate Selected Overlays Without Merging Their Semantics (Complete)
|
||||
|
||||
**Owners:** engine `selected_overlay.go`, ordinary `applyInputLayers`, and
|
||||
`assembleLayeredInput`.
|
||||
|
||||
1. Define a private `selectedOverlayRequest` containing the overlay name,
|
||||
resolved filesystem description, protection rule, sequence policy, and
|
||||
existing limits/version state. Derive context at the caller's existing
|
||||
lifecycle point. Pass assembly and source evidence explicitly to the worker.
|
||||
2. Route ordinary overlays through the main source's context; route layered
|
||||
overlays through their independent `OverlayFilesystem`. Use named operation
|
||||
inputs instead of fabricating a `sourceBytes`/empty parsed-main pair merely
|
||||
to select a context branch. Preserve the actual absent-host root behavior.
|
||||
3. Return or retain the resolved description used by overlay acquisition and
|
||||
build overlay passive context from it. Reuse main facts only when that
|
||||
ordinary operation previously used the same root. Context facts do not share
|
||||
a live handle between the main import traversal and the later overlay read.
|
||||
4. Keep the production collection-aware overlay path. Migrate meaningful
|
||||
lower-level tests to that path with explicit policy/limits, retaining their
|
||||
budget, version, and failure assertions. Delete the test-only legacy branch
|
||||
selected by missing variadic policy and its exclusive adapters, rather than
|
||||
treating optional argument count as a contract. The baseline production entry
|
||||
points all supply the policy argument.
|
||||
5. Keep overlay source IDs, duplicate detection, version protection, directory
|
||||
validation, read/close errors, aggregate accounting, and post-read application
|
||||
order. Remove superseded context derivation and overlay-forwarding adapters.
|
||||
|
||||
**Finish:** all acquisition/context callers share facts and named inputs while
|
||||
ordinary and independent layered overlay semantics remain distinct. Run A when
|
||||
buildable; carry any genuine remaining migration dependencies to the next
|
||||
integration opportunity instead of adding scaffolding or stopping the sprint.
|
||||
|
||||
Progress notes (2026-10-25): introduced the named overlay request and migrated
|
||||
ordinary and independent layered overlay acquisition to resolved facts plus
|
||||
explicit access. Replaced the variadic compatibility branch with the existing
|
||||
collection-aware path, removed old context adapters, and retained overlay
|
||||
validation timing, source evidence, budgets, version protection, and close
|
||||
reporting. Validation group A, consumer examples, the full Go suite, and
|
||||
`git diff --check` passed.
|
||||
|
||||
## Stage 10 — Give Import Responsibilities Focused Files (Complete)
|
||||
|
||||
**Owners:** engine `imports.go` and new `import_acquisition.go`,
|
||||
`import_scope.go`, `import_projection.go`.
|
||||
|
||||
1. Put the request entry/access worker and traversal admission/orchestration in
|
||||
`import_acquisition.go`. Keep `imports.go` as the small common owner for
|
||||
limits, accounting/route/traversal data, and shared import diagnostics.
|
||||
2. Move `composeAcquiredInputWithPolicies`, `composeAcquiredScopeWithPolicy`,
|
||||
pending-item composition, placement reservation, and their item-scope
|
||||
assembly helpers into `import_scope.go`.
|
||||
3. Move nested-origin/policy/evidence projection, acquired source destinations,
|
||||
version-evidence projection, sequence-prefix helpers, and diagnostic-path
|
||||
projection into `import_projection.go`. Scheduling and graph identity remain
|
||||
with their existing graph/scheduler owners.
|
||||
4. Make the acquisition-to-closed-resources-to-assembly handoff visible. Keep
|
||||
evidence projection based on existing composition applications/position maps;
|
||||
file movement does not authorize another merge, full-tree replay, or scan of
|
||||
every unrelated policy for each record.
|
||||
|
||||
**Finish:** maintainers can locate acquisition, local strict assembly, and evidence
|
||||
work separately. Keep this stage structural; parse/admission extraction follows.
|
||||
|
||||
Progress notes (2026-10-25): moved request acquisition and traversal admission to
|
||||
`import_acquisition.go`, local strict composition and item-scope assembly to
|
||||
`import_scope.go`, and origin, destination, version-evidence, and diagnostic-path
|
||||
projection to `import_projection.go`. Shared limits, traversal data, and import
|
||||
diagnostics remain in `imports.go`. `./scripts/check.sh` passed.
|
||||
|
||||
## Stage 11 — Extract Import Declaration Parsing Operations (Complete)
|
||||
|
||||
**Owner:** engine `import_spec.go`.
|
||||
|
||||
1. Retain the outer mapping check, exactly-one-selector check, source-order field
|
||||
walk, applicability checks, and final selector parsing in
|
||||
`parseImportDeclarationSpec`.
|
||||
2. Extract boolean assignment, wildcard-set parsing/validation (shared by
|
||||
`pattern` and `exclude`), placement parsing, and selector-value parsing into
|
||||
ordinary private helpers. Reuse existing `parseStringSet`, authored-member
|
||||
sites, and destination/depth parsers.
|
||||
3. Preserve unspecified pointers and set flags, especially nil versus empty
|
||||
pattern lists. Keep error paths/locations tied to the authored selector or
|
||||
list member. Do not reorder consequential checks or replace the selector
|
||||
matrix with a configurable parser registry.
|
||||
|
||||
**Finish:** field decoding is readable without changing declaration syntax,
|
||||
selector rules, default resolution, or diagnostics. Keep behavior tests as the
|
||||
owner; no helper-by-helper assertion inventory is needed.
|
||||
|
||||
Progress notes (2026-10-25): extracted boolean assignment, shared wildcard set
|
||||
assignment, placement parsing, and selector-value parsing into private helpers.
|
||||
The declaration parser retains its validation ordering, authored member sites,
|
||||
and nil-versus-empty presence behavior. Import-focused tests and
|
||||
`./scripts/check.sh` passed.
|
||||
|
||||
## Stage 12 — Extract Directory Entry Admission And Descent (Complete)
|
||||
|
||||
**Owner:** engine `import_selection.go`, using existing directory/link helpers.
|
||||
|
||||
1. Keep root normalization/deduplication, root ordering, the explicit traversal
|
||||
stack, candidate union, sorting, exclusion, and required-empty decision visible
|
||||
in `selectDirectoryImportCandidates`.
|
||||
2. Extract coherent entry inspection and descent preparation. Return a small
|
||||
result identifying skip, admitted file, or directory frame to enter, plus a
|
||||
diagnostic when appropriate. Keep stack/active-directory mutation with the
|
||||
traversal so its lifetime is visible; do not invent a general visitor API.
|
||||
3. Preserve hidden/follow/depth behavior, distinct lexical candidates versus
|
||||
canonical access identity, resource-entry accounting, root-error handling,
|
||||
and active-path cycle detection. A later non-active alias remains eligible;
|
||||
exclusions still do not suppress discovery errors.
|
||||
4. Keep globs and first-match main-file discovery on their existing mechanisms.
|
||||
Share a helper only where its existing admission semantics actually coincide.
|
||||
|
||||
**Finish:** substantial entry/descent policy is separated from stack orchestration
|
||||
without changing the selection algorithm or adding repeated root walks.
|
||||
|
||||
Progress notes (2026-10-25): extracted entry inspection and file/directory
|
||||
admission into a result-returning helper. The selector retains root handling,
|
||||
stack and active-path mutation, candidate union, sorting, exclusions, and the
|
||||
required-empty decision. Directory-focused tests and `./scripts/check.sh` passed.
|
||||
|
||||
## Stage 13 — Extract Imported-Source Admission From Recursion (Complete)
|
||||
|
||||
**Owner:** engine `import_acquisition.go` after Stage 10.
|
||||
|
||||
1. Keep `importTraversal.visitPath` as the visible coordinator of containment,
|
||||
canonical identity, cycle/repetition checks, read/parse, directive/version
|
||||
processing, placement, and recursive declaration traversal.
|
||||
2. Extract source admission/read-and-process results into a private
|
||||
`admittedImport` carrying canonical resource/node identity, source record,
|
||||
processed payload/declarations, and processing evidence. Return an explicit
|
||||
optional-missing outcome; do not represent a read failure as absence.
|
||||
3. Preserve when source records are appended, rolled back for optional missing
|
||||
input, and retained for failures; preserve source IDs, accounting increments,
|
||||
version-callback ordering, and inclusion-edge creation on repetition/cycles.
|
||||
4. Pass the admitted record to mapping/item placement helpers rather than
|
||||
repeating its payload, declarations, and processing evidence as parallel
|
||||
arguments to `visitItemSource`.
|
||||
5. Factor route push/pop and item-scope save/restore only where it makes their
|
||||
shared lifetime clearer. Keep active graph state across item scopes and
|
||||
reserve placement nodes once. Item helpers complete before outer placement;
|
||||
they are strict local peers, not a precedence layer.
|
||||
|
||||
**Finish:** admission decisions and recursion have clear handoffs, with one
|
||||
read/process path and the original scheduling/evidence algorithms. Run A again
|
||||
because Stages 10–13 changed the import implementation after the first checkpoint.
|
||||
|
||||
Progress notes (2026-10-25): introduced an explicit admitted-import record and
|
||||
optional-missing outcome for source read/parse/version processing. `visitPath`
|
||||
retains containment, identity/cycle checks, graph updates, and recursive
|
||||
placement coordination; item placement receives the admitted record directly.
|
||||
Validation group A and `./scripts/check.sh` passed.
|
||||
|
||||
## Stage 14 — Extract Inheritance Child And Parent Preparation (Complete)
|
||||
|
||||
**Owners:** engine `inheritance_schedule.go`, new `inheritance_prepare.go`, and
|
||||
existing scope/policy helpers.
|
||||
|
||||
1. Extract preparation of the child's directive-free payload, direct origins,
|
||||
local policy templates, and optional control/control-origin evidence.
|
||||
2. Extract parent preparation: clone/project policies, lower them against the
|
||||
child's claims, exclude parent identity from the inherited payload, and
|
||||
prepare the separate parent history snapshot where currently required.
|
||||
Keep effective policy templates and transfer construction available to the
|
||||
combination result rather than reconstructing them afterward.
|
||||
3. Use focused prepared-child/prepared-parent records of these related values.
|
||||
Borrow authored/resolved parent inputs read-only; retain existing clone points
|
||||
for mutable payloads and history. Keep the current preliminary lower-bound
|
||||
budget decisions at the executor's existing point before materialization.
|
||||
4. Keep mapping and identified-sequence adapters, the stable Kahn scheduler, and
|
||||
the parentless/cancelled path semantics intact. Do not flatten nested directives
|
||||
before their destination-local evaluation.
|
||||
|
||||
**Finish:** the executor no longer embeds child/control and parent/policy
|
||||
preparation. Actual recursive combination/evidence moves in Stage 15.
|
||||
|
||||
Progress notes (2026-10-25): extracted child/control and parent/policy preparation
|
||||
into focused records in `inheritance_prepare.go`. The executor retains its
|
||||
preliminary budget checks, scheduler, and combination path, while history and
|
||||
transfer inputs stay available to combination. Engine inheritance coverage and
|
||||
facade inheritance tests passed.
|
||||
|
||||
## Stage 15 — Return One Inheritance Combination Result (Complete)
|
||||
|
||||
**Owners:** new engine `inheritance_combine.go`, `inheritance_collection.go`,
|
||||
and the shared executor.
|
||||
|
||||
1. Add `inheritanceTemplateResult` as described above and extract the existing
|
||||
parent/child combination into a helper receiving prepared inputs and the
|
||||
application policy. Parentless templates return the same result shape.
|
||||
2. Keep `mergeRecursiveMappings` and `combineSequence` as the combination
|
||||
mechanisms. Their callbacks update local origins/policy and local replacement
|
||||
evidence. Have `applyInheritanceSequenceOrigins` return its replacement slice
|
||||
instead of taking the whole enclosing scope result; append that slice to the
|
||||
local template result while retaining its updates to the supplied origin maps.
|
||||
3. Derive sequence history, transferred origins, retired/replaced policies,
|
||||
identity preservation, and the transfer operation from that same combination.
|
||||
Preserve `historyParent` and all policy/identity fields on the existing
|
||||
`inheritanceOperation`; do not rerun a merge or project values to recover them.
|
||||
4. Keep sequence validation, diagnostic canonicalization, location assignment,
|
||||
replacement order, and no-parent cloning behavior at their established
|
||||
semantic points. Keep child identity absent from inherited-parent history but
|
||||
present with its own origin in the effective child.
|
||||
|
||||
**Finish:** merge callbacks no longer mutate the executor accumulator. The
|
||||
resolved template and all evidence travel together for Stage 16 integration.
|
||||
|
||||
Progress notes (2026-10-25): added `inheritanceTemplateResult` and moved the
|
||||
parent/child merge and evidence construction into `combineInheritanceTemplate`.
|
||||
Callbacks now update the local origins, policies, and replacement evidence;
|
||||
sequence-origin handling returns replacement records to the template result.
|
||||
Parentless templates use the same result shape. Engine and facade inheritance
|
||||
tests passed.
|
||||
|
||||
## Stage 16 — Finish The Inheritance Executor And Accounting (Complete)
|
||||
|
||||
**Owner:** engine `inheritance_schedule.go` and focused finalization helpers.
|
||||
|
||||
1. Reduce the executor to preparation, budget precheck, combination, accumulation,
|
||||
and final rebuild. Append each template's control before its transfer/history
|
||||
in the same order as before, and update the indexed resolved value/origin/
|
||||
policy/template state once per template.
|
||||
2. Extract retained-history charging for one template's returned operations and
|
||||
replacements, reusing the same `retainedNodeCounter` across the scope as
|
||||
before. Preserve pointer-based shared-node accounting, lower-bound checks,
|
||||
scope-slot overhead, final expansion check, limits, and diagnostics.
|
||||
3. Extract final rebuilding and scope policy/origin projection where it clarifies
|
||||
the coordinator. Reuse `newInheritancePolicyIndex` once for scope localization;
|
||||
do not return to scanning all policy entries for every slot.
|
||||
4. Confirm both adapters and nested walking consume the same output. Retain
|
||||
source-version movement, sensitivity, independent snapshots, assembled order,
|
||||
cancellation, and policy retirement on actual replacement.
|
||||
|
||||
**Finish:** one readable executor coordinates explicit value/policy/evidence
|
||||
handoffs, with no parallel inheritance implementation. Run I.
|
||||
|
||||
Progress notes (2026-10-25): extracted per-template retained-history charging and
|
||||
scope rebuild/origin/policy projection into `inheritance_finalize.go`. The shared
|
||||
counter, budget checks, final expansion check, and one policy index remain intact.
|
||||
Both mapping and identified-sequence adapters still use the shared executor.
|
||||
Validation group I passed.
|
||||
|
||||
## Stage 17 — Extract Substantial Projection Handlers (Complete)
|
||||
|
||||
**Owners:** engine `projection.go`, optionally new `projection_collection.go`.
|
||||
|
||||
1. Keep `projectConfiguration`'s schema/type guard and one `valueOperation`.
|
||||
Keep `project`'s depth check, pointer/dynamic dispatch, and scalar conversion
|
||||
choices visible; delegate structs, maps, and sequences to named methods.
|
||||
2. Move struct member/inline flattening, collision checks, mapping-key charging,
|
||||
and flattened-field/inline-map secret classification into `projectStruct`.
|
||||
Preserve declaration order and the current decrement for a flattened
|
||||
container that is not retained in the output.
|
||||
3. Move deterministic sorted-map traversal into `projectMap`; keep its cycle
|
||||
entry active until all recursive children complete. Move slice/array
|
||||
construction into `projectSequence`, preserving nil-slice handling and the
|
||||
slice-only cycle guard.
|
||||
4. All recursive children call the same operation's dispatcher. Avoid duplicate
|
||||
node charging/depth increments or early cycle release introduced by extraction.
|
||||
Preserve float/text/duration/secret scalar handling and error paths.
|
||||
|
||||
**Finish:** dispatch and substantial handlers are separately readable, using the
|
||||
original operation state. Do not refactor `clone`, `decodeValue`, or
|
||||
`valueBuilder.build` as an additional cleanup project.
|
||||
|
||||
Progress notes (2026-10-25): delegated struct, map, and sequence projection to
|
||||
methods on the existing `valueOperation`. The dispatcher retains depth checks,
|
||||
pointer/dynamic handling, and scalar conversion; struct order, map sorting,
|
||||
flattened-node charging, nil slices, and cycle guards remain in their original
|
||||
paths. Validation group R passed.
|
||||
|
||||
## Stage 18 — Separate Schema Field Discovery And Assembly (Complete)
|
||||
|
||||
**Owner:** engine `schema.go`, optionally new `schema_fields.go`.
|
||||
|
||||
1. Keep `describeFields` as declaration-order enumeration, exported/ignored/tag
|
||||
decisions, and dispatch to ordinary or inline handling.
|
||||
2. Extract inline pointer-chain inspection and inline-struct/map assembly,
|
||||
retaining recursion/building-state checks and the existing codec-versus-
|
||||
structural distinction. Reuse the one builder/cache.
|
||||
3. Make ordinary registration and inline flattening visibly maintain both
|
||||
`members` and flattened `fields`/`fieldByName`/`inlineMap`. Preserve index
|
||||
paths, nested secret propagation, ambiguity paths, and the single-inline-map
|
||||
rule. Share registration helpers only for genuinely identical updates.
|
||||
4. Preserve descriptor reuse and the physical-member view used by typed cloning.
|
||||
Do not unify these views or add alternate field/tag policy. Existing schema,
|
||||
projection, decoding, and cloning expectations remain the behavior baseline.
|
||||
|
||||
Progress notes (2026-10-25): kept declaration-order enumeration, exported and
|
||||
ignored-field decisions, and tag parsing in `describeFields`; moved ordinary
|
||||
registration, inline pointer inspection, and inline struct/map assembly into
|
||||
focused helpers in `schema_fields.go`. Physical members remain distinct from
|
||||
flattened fields and inline-map metadata. Validation group R passed.
|
||||
|
||||
**Finish:** field discovery and descriptor assembly have explicit responsibilities
|
||||
without a new registration framework. Run R for both Stages 17–18.
|
||||
|
||||
## Stage 19 — Update Consumer And Internal Navigation (Complete)
|
||||
|
||||
**Owners:** `docs/consumers/README.md`, `docs/internal/overview.md`,
|
||||
`docs/internal/imports.md`, and affected links/test-owner entries in
|
||||
`docs/development.md`.
|
||||
|
||||
1. Add a concise consumer choice guide: ordinary `Load` for one selected source,
|
||||
explicit discovery as a source usable by either entry, and `LoadLayered` for
|
||||
underlay groups. Distinguish input-owned placement/import/filesystem choices
|
||||
from load-wide definition/default/version/override choices and independent
|
||||
layered overlay context. Link current API/config owners for exact rules.
|
||||
2. Link the existing simple README example, bundled-file/layered examples,
|
||||
import-focused prompt catalog, and discovered sequence catalog. Keep the
|
||||
README's simple path simple; do not duplicate complete examples or rewrite
|
||||
consumer domain rules to showcase new internal types.
|
||||
3. Update implementation ownership and flow for normalized requests, acquisition
|
||||
policy/state/tier lifetimes, resolved context versus live handles, import file
|
||||
owners, and explicit inheritance/projection/schema handoffs. Fix moved-symbol
|
||||
and behavioral-test navigation and stale comments affected by the refactor.
|
||||
4. Preserve public contract definitions and accepted ADR decisions. Keep deferred
|
||||
clone work in `future.md`. No policy change or new ADR is needed for the
|
||||
selected private refactor.
|
||||
|
||||
Progress notes (2026-10-25): added a consumer entry-point and ownership guide,
|
||||
linked the existing adoption examples, and documented normalized requests,
|
||||
acquisition/context lifetimes, import owners, and inheritance/projection/schema
|
||||
handoffs. Updated test navigation and retained clone review as deferred.
|
||||
Documentation links and repository boundaries passed.
|
||||
|
||||
**Finish:** current documentation describes implemented structure, with the
|
||||
roadmap/plan retaining future work and progress until release cleanup.
|
||||
|
||||
## Stage 20 — Verify And Close The Combined Sprint (Complete)
|
||||
|
||||
1. Search for obsolete import/input forwarding ladders, synthetic `Options`
|
||||
acquisition construction, `sourceVersionState.importDefaults`, discarded
|
||||
context derivation branches, unused inverse bridges, and temporary scaffolds.
|
||||
Remove leftovers along with exclusive test scaffolding. Preserve distinct
|
||||
operations and real diagnostic conversions; do not delete private code solely
|
||||
because it resembles another helper.
|
||||
2. Review the combined call flow: one normalization model, non-generic acquisition,
|
||||
load versus tier versus traversal state, library-owned acquisition handles
|
||||
closed before composition/publication, one combination outcome for inheritance
|
||||
evidence, and one state owner per reflective operation. Review for added whole-tree
|
||||
traversals, policy scans, or ownership copies without requiring measurements.
|
||||
3. Account for every selected finding using the table below. Confirm any new
|
||||
handoff coverage addresses a concrete gap. Existing correctness/algorithmic
|
||||
regressions, public-boundary tests, and meaningful assertions stay effective.
|
||||
4. Run the Final validation group, resolve failures, inspect the staged diff and
|
||||
workspace, and commit the completed remaining work with plain-English
|
||||
messages. Report changes, checks, limitations, and any unrelated work retained.
|
||||
Do not tag, push, publish, or update consumer repositories in this sprint.
|
||||
|
||||
Progress notes (2026-10-25): reviewed the combined request/acquisition/tier and
|
||||
traversal flow, the inheritance result handoff, and the shared reflection state
|
||||
owners. The named obsolete source-version field, synthetic acquisition-options
|
||||
construction, and inverse conversion symbols are absent; no additional cleanup
|
||||
was needed. The final development check and race-enabled full test suite passed.
|
||||
|
||||
| Audit finding | Completion evidence |
|
||||
| --- | --- |
|
||||
| 1: imports | Stages 3–6 and 10–13 remove options reconstruction/default coupling and forwarding ladders; acquisition/scope/evidence owners and admission operations are clear. A and final checks pass. |
|
||||
| 2: inheritance | Stages 14–16 return explicit template results, keep one executor, and preserve accounting and value/history/identity agreement. I and final checks pass. |
|
||||
| 3: projection | Stage 17 extracts substantial handlers sharing operation state. R and final checks pass. Clone review remains deferred, not implicitly completed. |
|
||||
| 4: schema | Stage 18 clarifies ordinary/inline assembly while preserving both descriptor views. R and final checks pass. |
|
||||
| 5: contexts | Stages 7–9 share resolved facts across input/import/overlay/passive contexts without retaining handles or changing validation timing. A and final checks pass. |
|
||||
| 6: facade | Stage 2 removes unused inverse conversions/adapters and shares real conversion. F and final boundary/consumer checks pass. |
|
||||
|
||||
**Finish:** the selected roadmap target is implemented, validated, documented,
|
||||
and reviewable as one complete sprint. No unresolved product/scope decisions are
|
||||
needed to begin these stages.
|
||||
77
docs/roadmap/remediation.md
Normal file
77
docs/roadmap/remediation.md
Normal file
@@ -0,0 +1,77 @@
|
||||
# Consolidation Review Findings
|
||||
|
||||
Reviewed revision: `557e006a4ab98b666735a71b3845418c702f7cec`.
|
||||
Comparison baseline: `8ab1568` (the committed implementation plan).
|
||||
|
||||
The review compared the [consolidation target](consolidation.md) and
|
||||
[implementation plan](implementation.md) with the completed sprint, including
|
||||
public/private conversions, preflight and acquisition, import selection and
|
||||
evidence, filesystem contexts, inheritance, projection, schema construction,
|
||||
changed tests, and documentation. The development check (`./scripts/check.sh`)
|
||||
and full race suite (`go test -race ./...`) passed with Go 1.26.10 on Linux.
|
||||
|
||||
## 1. Complete The Non-Import Resolved-Context Handoff
|
||||
|
||||
Status: open. Priority: medium. Category: implementation gap and maintainability.
|
||||
No user-facing correctness regression has been established for this finding.
|
||||
|
||||
**Target.** Roadmap internal target 2 and implementation Stages 7–9 select one
|
||||
resolved filesystem description shared by validation/acquisition and passive
|
||||
context construction. Stage 8 explicitly requires populating the description
|
||||
at the existing non-import validation point and resolving only facts that are
|
||||
still missing later.
|
||||
|
||||
**Evidence.** In [acquisition.go](../../internal/engine/acquisition.go),
|
||||
`acquireInput` calls `validateFilesystemContext` at line 181 for inputs without
|
||||
import traversal and with an applicable explicit context. That validator returns
|
||||
only a diagnostic. Its host/native branches derive a resolved description but
|
||||
discard it. At lines 220–226, acquisition sees `input.resolvedFilesystem == nil`
|
||||
and calls `resolveFilesystemContext` again before building passive views.
|
||||
|
||||
For example, an ordinary `Load` of a byte or mapping source with explicit host
|
||||
`FilesystemContext` and imports disabled resolves/canonicalizes the base and
|
||||
root during validation, then derives those same facts again for its source
|
||||
context. The retained description therefore does not complete the intended
|
||||
validation-to-context handoff.
|
||||
|
||||
Additionally, [filesystem.go](../../internal/engine/filesystem.go) still contains
|
||||
`validateAbsentHostFilesystemContext` (line 40), with its own root/base
|
||||
canonicalization, root-defaulting, and containment logic. That derivation
|
||||
duplicates `resolveAbsentFilesystemContext` in
|
||||
[filesystem_context.go](../../internal/engine/filesystem_context.go) (line 29).
|
||||
The two paths already maintain separate containment-diagnostic messages. Graph
|
||||
caller inspection confirms that the old validator is reachable from
|
||||
`acquireInput`; it is not unused migration scaffolding.
|
||||
|
||||
**Why address it.** The new model exists, but one important path still discards
|
||||
and reconstructs its results, while absent-host context derivation has two
|
||||
production implementations. This leaves part of audit finding 5 unresolved and
|
||||
preserves the drift risk the consolidation was intended to remove. This is a
|
||||
structural completion issue, not a claim of measured performance loss or a new
|
||||
single-lookup guarantee.
|
||||
|
||||
**Required revision.**
|
||||
|
||||
- Have non-import context resolution/validation return or populate the resolved
|
||||
description, and retain it on `assembledInput` at the existing validation
|
||||
point. Later passive-view construction and ordinary-overlay handling should
|
||||
reuse those applicable facts.
|
||||
- Route absent-host derivation through the common resolved-context owner and
|
||||
remove the duplicate derivation from the old validator. Keep the existing
|
||||
directory/access checks and close-error handling at their appropriate points.
|
||||
- Preserve deferred discovery/ordinary-overlay checks, root-only absent host
|
||||
context, host/native distinctions, diagnostics, and borrowed filesystem
|
||||
ownership. Independently required validation must not disappear merely to
|
||||
reduce filesystem calls.
|
||||
- Keep internal documentation aligned with the completed handoff. Describe
|
||||
aggregate accounting as load-wide and repetition/namespace state as
|
||||
tier-scoped; the new internal overview/import prose currently calls the latter
|
||||
per-tier accounting despite its distinct role.
|
||||
|
||||
**Verification.** Review the non-import call path to confirm that applicable
|
||||
resolved facts are carried forward and absent-host derivation has one owner.
|
||||
Reuse the existing host/native, discovery selected/absent, ordinary/layered
|
||||
overlay, and passive-resource-context tests. Add a focused behavioral regression
|
||||
only for a consequential gap exposed by the change; do not assert incidental
|
||||
canonicalization/stat call counts. Run the development check and applicable
|
||||
ownership race checks after remediation.
|
||||
@@ -19,6 +19,28 @@ func ExampleLoad() {
|
||||
// Output: {true 3} <nil>
|
||||
}
|
||||
|
||||
func ExampleLoad_sequenceDeclaration() {
|
||||
type Item struct {
|
||||
ID string `yaml:"id"`
|
||||
}
|
||||
type Config struct {
|
||||
Items []Item `yaml:"items"`
|
||||
}
|
||||
result, err := configkit.Load[Config](configkit.Bytes("catalog.yml", []byte(`configkit:
|
||||
sequences:
|
||||
- destination: [items]
|
||||
key: id
|
||||
items:
|
||||
- id: prompt
|
||||
`)))
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
fmt.Println(result.Config.Items[0].ID)
|
||||
// Output: prompt
|
||||
}
|
||||
|
||||
func ExampleNoSource() {
|
||||
type Config struct {
|
||||
Workers int `yaml:"workers"`
|
||||
@@ -30,6 +52,32 @@ func ExampleNoSource() {
|
||||
// Output: 4 0 <nil>
|
||||
}
|
||||
|
||||
func ExampleDiff() {
|
||||
type Config struct {
|
||||
Workers int `yaml:"workers"`
|
||||
}
|
||||
before, err := configkit.Load[Config](configkit.Mapping("before", map[string]any{"workers": 2}))
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
after, err := configkit.Load[Config](configkit.Mapping("after", map[string]any{"workers": 4}))
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
differences, err := configkit.Diff(before, after)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
for _, difference := range differences {
|
||||
fmt.Printf("%s: %s -> %s\n", difference.Path.String(),
|
||||
strings.TrimSpace(difference.Left.YAML), strings.TrimSpace(difference.Right.YAML))
|
||||
}
|
||||
// Output: /workers: 2 -> 4
|
||||
}
|
||||
|
||||
func ExampleValidation_Error() {
|
||||
type Config struct {
|
||||
First string `yaml:"first"`
|
||||
@@ -128,8 +176,15 @@ func ExampleOverride() {
|
||||
})
|
||||
}
|
||||
result, err := configkit.Load(configkit.NoSource(), configkit.Options[Config]{
|
||||
Definition: configkit.Definition[Config]{Defaults: func() Config { return Config{Workers: 4} }},
|
||||
Overrides: overrides,
|
||||
Definition: configkit.Definition[Config]{
|
||||
Defaults: func() Config { return Config{Workers: 4} },
|
||||
Resolve: func(configuration *Config, resolution *configkit.Resolution) error {
|
||||
configkit.Default(resolution, configkit.Keys("workers"), &configuration.Workers, 100,
|
||||
"preserve an explicitly supplied worker count")
|
||||
return nil
|
||||
},
|
||||
},
|
||||
Overrides: overrides,
|
||||
})
|
||||
fmt.Println(result.Config.Workers, result.Sources()[0].Label, err)
|
||||
// Output: 0 --workers <nil>
|
||||
|
||||
154
examples/consumers/destinations.go
Normal file
154
examples/consumers/destinations.go
Normal file
@@ -0,0 +1,154 @@
|
||||
package consumers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"path"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
// CollectionBundleConfig is a representative application-owned collection
|
||||
// shape. Collection and prompt version labels remain ordinary domain fields.
|
||||
type CollectionBundleConfig struct {
|
||||
Version string `yaml:"version"`
|
||||
Collections map[string]CollectionDefinition `yaml:"collections"`
|
||||
}
|
||||
|
||||
type CollectionDefinition struct {
|
||||
Name string `yaml:"name"`
|
||||
Version string `yaml:"version"`
|
||||
Description string `yaml:"description"`
|
||||
Prompts map[string]CollectionPrompt `yaml:"prompts"`
|
||||
Profiles map[string]CollectionProfile `yaml:"profiles"`
|
||||
}
|
||||
|
||||
type CollectionPrompt struct {
|
||||
Version string `yaml:"version"`
|
||||
DefaultProfile string `yaml:"default_profile"`
|
||||
ContentFile string `yaml:"content_file"`
|
||||
ResourceText string `yaml:"resource_text"`
|
||||
Messages []CollectionMessage `yaml:"messages"`
|
||||
}
|
||||
|
||||
type CollectionMessage struct {
|
||||
Role string `yaml:"role"`
|
||||
Content string `yaml:"content"`
|
||||
}
|
||||
|
||||
type CollectionProfile struct {
|
||||
Format string `yaml:"format"`
|
||||
Temperature float64 `yaml:"temperature"`
|
||||
}
|
||||
|
||||
// LoadCollectionBundle demonstrates one layered collection load. Its main
|
||||
// document owns the application schema version; the mounted entry declares
|
||||
// relative destinations for collection metadata, prompt/profile peers, and
|
||||
// their nested imports. The operator version override remains a separate tier.
|
||||
func LoadCollectionBundle() (*configkit.Result[CollectionBundleConfig], error) {
|
||||
files, err := fs.Sub(bundledFiles, "testdata/collection-bundle")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bundle := configkit.NewFilesystem("collection bundle", files)
|
||||
collectionDestination := configkit.Keys("collections", "weatherreporter")
|
||||
promptDestination := collectionDestination.Key("prompts")
|
||||
return configkit.LoadLayered(
|
||||
configkit.Input{Source: configkit.Bytes("application main", []byte("version: 1\n"))},
|
||||
configkit.LayeredOptions[CollectionBundleConfig]{
|
||||
Definition: configkit.Definition[CollectionBundleConfig]{
|
||||
Version: &configkit.VersionRule{Path: configkit.Keys("version"), Check: func(version configkit.Version, _ *configkit.Validation) error {
|
||||
if !version.Present || version.Text != "1" {
|
||||
return errors.New("unsupported application schema version")
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
Resolve: resolveCollectionResources,
|
||||
Validate: validateCollectionBundle,
|
||||
},
|
||||
Underlays: []configkit.Underlay{
|
||||
{Inputs: []configkit.Input{{
|
||||
Source: bundle.File("collections/weather/collection.yml"), Imports: true, Destination: collectionDestination,
|
||||
}}},
|
||||
{Inputs: []configkit.Input{{
|
||||
Source: bundle.File("operators/weather-prompt-version.yml"), Destination: promptDestination,
|
||||
}}},
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func resolveCollectionResources(configuration *CollectionBundleConfig, resolution *configkit.Resolution) error {
|
||||
for collectionID, collection := range configuration.Collections {
|
||||
for promptID, prompt := range collection.Prompts {
|
||||
if prompt.ContentFile == "" {
|
||||
continue
|
||||
}
|
||||
contentPath := configkit.Keys("collections", collectionID, "prompts", promptID, "content_file")
|
||||
info, found := resolution.InputInfo(contentPath)
|
||||
if !found || len(info.Origin.Locations) != 1 {
|
||||
return fmt.Errorf("prompt resource has no single declaring source")
|
||||
}
|
||||
resourceSource, found := resolution.Source(info.Origin.Locations[0].SourceID)
|
||||
if !found {
|
||||
return fmt.Errorf("prompt resource source is unavailable")
|
||||
}
|
||||
context, found := resolution.SourceContext(resourceSource.ID)
|
||||
if !found {
|
||||
return fmt.Errorf("prompt resource context is unavailable")
|
||||
}
|
||||
data, err := fs.ReadFile(context.FS, path.Join(context.BaseDir, prompt.ContentFile))
|
||||
if err != nil {
|
||||
return fmt.Errorf("read prompt resource: %w", err)
|
||||
}
|
||||
prompt.ResourceText = string(data)
|
||||
collection.Prompts[promptID] = prompt
|
||||
resolution.Record(
|
||||
configkit.Keys("collections", collectionID, "prompts", promptID, "resource_text"),
|
||||
configkit.ChangeNormalization, "read source-relative prompt content", contentPath,
|
||||
)
|
||||
}
|
||||
configuration.Collections[collectionID] = collection
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateCollectionBundle(configuration *CollectionBundleConfig, validation *configkit.Validation) {
|
||||
for collectionID, collection := range configuration.Collections {
|
||||
for promptID, prompt := range collection.Prompts {
|
||||
if prompt.DefaultProfile == "" {
|
||||
continue
|
||||
}
|
||||
if _, found := collection.Profiles[prompt.DefaultProfile]; !found {
|
||||
validation.Error(configkit.Keys("collections", collectionID, "prompts", promptID, "default_profile"), "must name a profile in this collection")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ReadCollectionPromptContent follows the effective content field's physical
|
||||
// source, including when it was inherited from a peer prompt.
|
||||
func ReadCollectionPromptContent(result *configkit.Result[CollectionBundleConfig], collectionID, promptID string) (string, error) {
|
||||
if result == nil {
|
||||
return "", errors.New("collection result is nil")
|
||||
}
|
||||
contentPath := configkit.Keys("collections", collectionID, "prompts", promptID, "content_file")
|
||||
info, found := result.Metadata().Lookup(contentPath)
|
||||
if !found || len(info.Origin.Locations) != 1 {
|
||||
return "", errors.New("prompt resource has no single declaring source")
|
||||
}
|
||||
context, found := result.SourceContext(info.Origin.Locations[0].SourceID)
|
||||
if !found {
|
||||
return "", errors.New("prompt resource context is unavailable")
|
||||
}
|
||||
prompt, found := result.Config.Collections[collectionID].Prompts[promptID]
|
||||
if !found {
|
||||
return "", errors.New("prompt is unavailable")
|
||||
}
|
||||
data, err := fs.ReadFile(context.FS, path.Join(context.BaseDir, prompt.ContentFile))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
137
examples/consumers/destinations_test.go
Normal file
137
examples/consumers/destinations_test.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package consumers_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/examples/consumers"
|
||||
)
|
||||
|
||||
func Example_inputDestination() {
|
||||
mounted, err := configkit.LoadLayered[map[string]any](configkit.Input{
|
||||
Source: configkit.Mapping("weather prompt", map[string]any{
|
||||
"summarize.alerts": map[string]any{"version": "1"},
|
||||
}),
|
||||
Destination: configkit.Keys("collections", "weather.reporter", "prompts"),
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
root, err := configkit.LoadLayered[map[string]any](configkit.Input{
|
||||
Source: configkit.Bytes("standalone", []byte("name: standalone\n")),
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
_, found := mounted.Config["collections"]
|
||||
fmt.Println(found, root.Config["name"])
|
||||
// Output: true standalone
|
||||
}
|
||||
|
||||
func TestCollectionBundleAssemblesMountedPeersAndResources(t *testing.T) {
|
||||
result, err := consumers.LoadCollectionBundle()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadCollectionBundle: %v", err)
|
||||
}
|
||||
collection := result.Config.Collections["weatherreporter"]
|
||||
if result.Config.Version != "1" || collection.Name != "Weather Reporter" || collection.Version != "2.4.0" {
|
||||
t.Fatalf("main and collection versions = %q, %#v", result.Config.Version, collection)
|
||||
}
|
||||
brief := collection.Prompts["brief"]
|
||||
custom := collection.Prompts["custom"]
|
||||
if brief.Version != "2" || brief.DefaultProfile != "compact" || brief.ContentFile != "welcome.txt" || len(brief.Messages) != 1 {
|
||||
t.Fatalf("version-only override replaced lower prompt fields: %#v", brief)
|
||||
}
|
||||
if custom.Version != "2" || custom.DefaultProfile != "weather" || custom.ContentFile != brief.ContentFile ||
|
||||
len(custom.Messages) != 1 || custom.ResourceText != "Welcome to the weather collection.\n" {
|
||||
t.Fatalf("inherited prompt = %#v", custom)
|
||||
}
|
||||
weatherProfile := collection.Profiles["weather"]
|
||||
if weatherProfile.Format != "text" || weatherProfile.Temperature != 0.4 {
|
||||
t.Fatalf("inherited profile = %#v", weatherProfile)
|
||||
}
|
||||
resource, err := consumers.ReadCollectionPromptContent(result, "weatherreporter", "custom")
|
||||
if err != nil || resource != custom.ResourceText {
|
||||
t.Fatalf("inherited prompt resource = %q, %v", resource, err)
|
||||
}
|
||||
|
||||
versionInfo, ok := result.Metadata().Lookup(configkit.Keys("version"))
|
||||
if !ok || !versionInfo.Explicit || versionInfo.Origin.Layer != configkit.LayerBase || len(versionInfo.Origin.Locations) != 1 {
|
||||
t.Fatalf("main-owned version metadata = %#v, %v", versionInfo, ok)
|
||||
}
|
||||
collectionVersionPath := configkit.Keys("collections", "weatherreporter", "version")
|
||||
collectionVersion, ok := result.Metadata().Lookup(collectionVersionPath)
|
||||
if !ok || !collectionVersion.Explicit || collectionVersion.Origin.Layer != configkit.LayerUnderlay || len(collectionVersion.Origin.Locations) != 1 {
|
||||
t.Fatalf("ordinary collection version metadata = %#v, %v", collectionVersion, ok)
|
||||
}
|
||||
var metadataSource configkit.SourceInfo
|
||||
var collectionEntrySource configkit.SourceInfo
|
||||
var versionSource configkit.SourceInfo
|
||||
var parentPromptSource configkit.SourceInfo
|
||||
var promptIndexSource configkit.SourceInfo
|
||||
var profileIndexSource configkit.SourceInfo
|
||||
var promptVersionSource configkit.SourceInfo
|
||||
for _, source := range result.Sources() {
|
||||
switch source.Path {
|
||||
case "collections/weather/collection.yml":
|
||||
collectionEntrySource = source
|
||||
case "collections/weather/metadata.yml":
|
||||
metadataSource = source
|
||||
case "collections/weather/version.yml":
|
||||
versionSource = source
|
||||
case "collections/weather/prompts/index.yml":
|
||||
promptIndexSource = source
|
||||
case "collections/weather/prompts/brief.yml":
|
||||
parentPromptSource = source
|
||||
case "collections/weather/profiles/index.yml":
|
||||
profileIndexSource = source
|
||||
case "operators/weather-prompt-version.yml":
|
||||
promptVersionSource = source
|
||||
}
|
||||
}
|
||||
collectionVersionLocation := collectionVersion.Origin.Locations[0]
|
||||
if collectionEntrySource.ID != "source-2" || collectionEntrySource.Destination.String() != "/collections/weatherreporter" ||
|
||||
metadataSource.ID == "" || metadataSource.Destination.String() != "/collections/weatherreporter" ||
|
||||
metadataSource.ImportedFrom == nil || metadataSource.ImportedFrom.SourceID != collectionEntrySource.ID ||
|
||||
versionSource.ID == "" || collectionVersionLocation.SourceID != versionSource.ID || versionSource.Destination.String() != "/collections/weatherreporter" ||
|
||||
versionSource.ImportedFrom == nil || versionSource.ImportedFrom.SourceID != metadataSource.ID || collectionVersionLocation.Line != 1 {
|
||||
t.Fatalf("nested collection version provenance = entry %#v, metadata %#v, version %#v, location %#v", collectionEntrySource, metadataSource, versionSource, collectionVersionLocation)
|
||||
}
|
||||
if promptIndexSource.ID == "" || promptIndexSource.Destination.String() != "/collections/weatherreporter/prompts" ||
|
||||
promptIndexSource.ImportedFrom == nil || promptIndexSource.ImportedFrom.SourceID != collectionEntrySource.ID ||
|
||||
profileIndexSource.ID == "" || profileIndexSource.Destination.String() != "/collections/weatherreporter/profiles" ||
|
||||
profileIndexSource.ImportedFrom == nil || profileIndexSource.ImportedFrom.SourceID != collectionEntrySource.ID {
|
||||
t.Fatalf("nested prompt/profile placement = prompt %#v profile %#v", promptIndexSource, profileIndexSource)
|
||||
}
|
||||
if promptVersionSource.ID == "" || promptVersionSource.Destination.String() != "/collections/weatherreporter/prompts" || promptVersionSource.UnderlayIndex != 2 {
|
||||
t.Fatalf("prompt version source = %#v", promptVersionSource)
|
||||
}
|
||||
contentPath := configkit.Keys("collections", "weatherreporter", "prompts", "custom", "content_file")
|
||||
contentInfo, ok := result.Metadata().Lookup(contentPath)
|
||||
if !ok || !contentInfo.Explicit || len(contentInfo.Origin.Inheritance) != 1 || len(contentInfo.Origin.Locations) != 1 {
|
||||
t.Fatalf("inherited content field metadata = %#v, %v", contentInfo, ok)
|
||||
}
|
||||
contentLocation := contentInfo.Origin.Locations[0]
|
||||
if parentPromptSource.ID == "" || contentLocation.SourceID != parentPromptSource.ID || contentLocation.Line != 4 ||
|
||||
parentPromptSource.Destination.String() != "/collections/weatherreporter/prompts" {
|
||||
t.Fatalf("inherited resource source coordinates = %#v, source %#v", contentLocation, parentPromptSource)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectionPeerPromptConflictsRemainStrict(t *testing.T) {
|
||||
destination := configkit.Keys("collections", "weatherreporter", "prompts")
|
||||
_, err := configkit.LoadLayered[map[string]any](configkit.Input{Source: configkit.NoSource()}, configkit.LayeredOptions[map[string]any]{
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{
|
||||
{Source: configkit.Bytes("peer-a", []byte("brief: {version: '1'}\n")), Destination: destination},
|
||||
{Source: configkit.Bytes("peer-b", []byte("brief: {version: '1'}\n")), Destination: destination},
|
||||
}}},
|
||||
})
|
||||
var loadError *configkit.LoadError
|
||||
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != configkit.CodeComposeConflict {
|
||||
t.Fatalf("duplicate peer prompt error = %v", err)
|
||||
}
|
||||
}
|
||||
136
examples/consumers/diff.go
Normal file
136
examples/consumers/diff.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package consumers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
// FileComparisonConfig is a small application-owned shape for comparing
|
||||
// caller-selected configuration files.
|
||||
type FileComparisonConfig struct {
|
||||
Name string `yaml:"name"`
|
||||
Entries map[string]any `yaml:"entries"`
|
||||
}
|
||||
|
||||
// CompareFiles loads two caller-selected files with the same application
|
||||
// definition, then formats only the safe records returned by configkit.Diff.
|
||||
func CompareFiles(leftPath, rightPath string) (string, error) {
|
||||
options := configkit.Options[FileComparisonConfig]{
|
||||
Definition: configkit.Definition[FileComparisonConfig]{
|
||||
Defaults: func() FileComparisonConfig {
|
||||
return FileComparisonConfig{Entries: map[string]any{}}
|
||||
},
|
||||
},
|
||||
}
|
||||
left, err := configkit.Load(configkit.File(leftPath), options)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("load left configuration: %w", err)
|
||||
}
|
||||
right, err := configkit.Load(configkit.File(rightPath), options)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("load right configuration: %w", err)
|
||||
}
|
||||
differences, err := configkit.Diff(left, right)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return FormatDifferences(differences), nil
|
||||
}
|
||||
|
||||
// RolloutConfig models a service endpoint whose URL is derived by the
|
||||
// application resolver and whose credential reference is classified.
|
||||
type RolloutConfig struct {
|
||||
Service RolloutService `yaml:"service"`
|
||||
}
|
||||
|
||||
// RolloutService contains endpoint settings and a classified credential.
|
||||
type RolloutService struct {
|
||||
Endpoint RolloutEndpoint `yaml:"endpoint"`
|
||||
Credential RolloutCredential `yaml:"credential"`
|
||||
}
|
||||
|
||||
// RolloutEndpoint is the effective destination used by the application.
|
||||
type RolloutEndpoint struct {
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
URL string `yaml:"url"`
|
||||
}
|
||||
|
||||
// RolloutCredential holds only a typed secret reference in configuration.
|
||||
type RolloutCredential struct {
|
||||
Token configkit.Secret `yaml:"token" configkit:"secret"`
|
||||
}
|
||||
|
||||
// LoadRollout loads a base file, optionally with one selected configuration
|
||||
// overlay. Resolution derives the effective URL after the overlay is applied.
|
||||
func LoadRollout(basePath, overlayName string) (*configkit.Result[RolloutConfig], error) {
|
||||
return configkit.Load(configkit.File(basePath), configkit.Options[RolloutConfig]{
|
||||
Overlay: overlayName,
|
||||
Definition: configkit.Definition[RolloutConfig]{
|
||||
Resolve: resolveRollout,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// CompareRolloutOverlay compares the effective base configuration with that
|
||||
// same file plus one selected overlay. It presents only safe diff records.
|
||||
func CompareRolloutOverlay(basePath, overlayName string) (string, error) {
|
||||
base, err := LoadRollout(basePath, "")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("load base configuration: %w", err)
|
||||
}
|
||||
withOverlay, err := LoadRollout(basePath, overlayName)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("load overlay configuration: %w", err)
|
||||
}
|
||||
differences, err := configkit.Diff(base, withOverlay)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return FormatDifferences(differences), nil
|
||||
}
|
||||
|
||||
func resolveRollout(configuration *RolloutConfig, resolution *configkit.Resolution) error {
|
||||
endpoint := &configuration.Service.Endpoint
|
||||
if endpoint.Host == "" || endpoint.Port <= 0 {
|
||||
return nil
|
||||
}
|
||||
url := fmt.Sprintf("https://%s:%d", endpoint.Host, endpoint.Port)
|
||||
if endpoint.URL != url {
|
||||
endpoint.URL = url
|
||||
resolution.Record(configkit.Keys("service", "endpoint", "url"),
|
||||
configkit.ChangeNormalization, "derived endpoint URL",
|
||||
configkit.Keys("service", "endpoint", "host"),
|
||||
configkit.Keys("service", "endpoint", "port"))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FormatDifferences is consumer-owned presentation. It distinguishes absence
|
||||
// through Present and reports withheld changes through HiddenChanged without
|
||||
// interpreting redaction marker text.
|
||||
func FormatDifferences(differences []configkit.Difference) string {
|
||||
if len(differences) == 0 {
|
||||
return "No effective changes.\n"
|
||||
}
|
||||
var output strings.Builder
|
||||
for _, difference := range differences {
|
||||
fmt.Fprintf(&output, "%s: left=%s right=%s", difference.Path.String(),
|
||||
formatSnapshot(difference.Left), formatSnapshot(difference.Right))
|
||||
if difference.HiddenChanged {
|
||||
output.WriteString(" hidden-change")
|
||||
}
|
||||
output.WriteByte('\n')
|
||||
}
|
||||
return output.String()
|
||||
}
|
||||
|
||||
func formatSnapshot(snapshot configkit.ValueSnapshot) string {
|
||||
if !snapshot.Present {
|
||||
return "<absent>"
|
||||
}
|
||||
return strconv.Quote(snapshot.YAML)
|
||||
}
|
||||
187
examples/consumers/diff_test.go
Normal file
187
examples/consumers/diff_test.go
Normal file
@@ -0,0 +1,187 @@
|
||||
package consumers_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/examples/consumers"
|
||||
)
|
||||
|
||||
func ExampleCompareFiles() {
|
||||
directory, err := os.MkdirTemp("", "configkit-diff-files-")
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
defer os.RemoveAll(directory)
|
||||
leftPath := filepath.Join(directory, "before.yml")
|
||||
rightPath := filepath.Join(directory, "after.yml")
|
||||
if err := os.WriteFile(leftPath, []byte("name: before\nentries: {}\n"), 0o600); err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(rightPath, []byte("name: after\nentries:\n added: null\n"), 0o600); err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
output, err := consumers.CompareFiles(leftPath, rightPath)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
fmt.Print(output)
|
||||
// Output:
|
||||
// /entries/added: left=<absent> right="null\n"
|
||||
// /name: left="before\n" right="after\n"
|
||||
}
|
||||
|
||||
func ExampleCompareRolloutOverlay() {
|
||||
const baseToken = "CONFIGKIT_DIFF_EXAMPLE_BASE_TOKEN"
|
||||
const overlayToken = "CONFIGKIT_DIFF_EXAMPLE_OVERLAY_TOKEN"
|
||||
defer preserveEnvironment(baseToken)()
|
||||
defer preserveEnvironment(overlayToken)()
|
||||
_ = os.Setenv(baseToken, "disposable-example-base-credential")
|
||||
_ = os.Setenv(overlayToken, "disposable-example-overlay-credential")
|
||||
|
||||
directory, err := os.MkdirTemp("", "configkit-diff-overlay-")
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
defer os.RemoveAll(directory)
|
||||
basePath := filepath.Join(directory, "config.yml")
|
||||
overlayPath := filepath.Join(directory, "overlays", "canary.yml")
|
||||
if err := os.MkdirAll(filepath.Dir(overlayPath), 0o700); err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
base := "service:\n endpoint:\n host: base.example\n port: 8443\n credential:\n token: " + baseToken + "\n"
|
||||
overlay := "service:\n endpoint:\n host: canary.example\n credential:\n token: " + overlayToken + "\n"
|
||||
if err := os.WriteFile(basePath, []byte(base), 0o600); err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(overlayPath, []byte(overlay), 0o600); err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
output, err := consumers.CompareRolloutOverlay(basePath, "canary")
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
fmt.Print(output)
|
||||
// Output:
|
||||
// /service/credential/token: left="'[REDACTED]'\n" right="'[REDACTED]'\n" hidden-change
|
||||
// /service/endpoint/host: left="base.example\n" right="canary.example\n"
|
||||
// /service/endpoint/url: left="https://base.example:8443\n" right="https://canary.example:8443\n"
|
||||
}
|
||||
|
||||
func TestCompareFilesUsesCallerSelectedSourcesAndSafePresence(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
leftPath := filepath.Join(directory, "before.yml")
|
||||
rightPath := filepath.Join(directory, "after.yml")
|
||||
writeConfiguration(t, leftPath, "name: before\nentries: {}\n")
|
||||
writeConfiguration(t, rightPath, "name: after\nentries:\n added: null\n")
|
||||
|
||||
output, err := consumers.CompareFiles(leftPath, rightPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := "/entries/added: left=<absent> right=\"null\\n\"\n" +
|
||||
"/name: left=\"before\\n\" right=\"after\\n\"\n"
|
||||
if output != want {
|
||||
t.Fatalf("comparison output = %q, want %q", output, want)
|
||||
}
|
||||
if again, err := consumers.CompareFiles(leftPath, rightPath); err != nil || again != output {
|
||||
t.Fatalf("repeat output = %q, %v", again, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareFilesReturnsEachLoadError(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
leftPath := filepath.Join(directory, "before.yml")
|
||||
rightPath := filepath.Join(directory, "after.yml")
|
||||
writeConfiguration(t, leftPath, "name: before\n")
|
||||
writeConfiguration(t, rightPath, "unknown: invalid\n")
|
||||
|
||||
output, err := consumers.CompareFiles(leftPath, rightPath)
|
||||
if err == nil || output != "" || !strings.Contains(err.Error(), "load right configuration") {
|
||||
t.Fatalf("right load failure = %q, %v", output, err)
|
||||
}
|
||||
if _, err := consumers.CompareFiles(filepath.Join(directory, "missing.yml"), rightPath); err == nil || !strings.Contains(err.Error(), "load left configuration") {
|
||||
t.Fatalf("left load failure = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareRolloutOverlayShowsResolvedSafeChangesAndRetainsSiblings(t *testing.T) {
|
||||
t.Setenv("CONFIGKIT_DIFF_BASE_TOKEN", "disposable-base-credential")
|
||||
t.Setenv("CONFIGKIT_DIFF_OVERLAY_TOKEN", "disposable-overlay-credential")
|
||||
directory := t.TempDir()
|
||||
basePath := filepath.Join(directory, "config.yml")
|
||||
overlayPath := filepath.Join(directory, "overlays", "canary.yml")
|
||||
if err := os.MkdirAll(filepath.Dir(overlayPath), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeConfiguration(t, basePath, "service:\n endpoint:\n host: base.example\n port: 8443\n credential:\n token: CONFIGKIT_DIFF_BASE_TOKEN\n")
|
||||
writeConfiguration(t, overlayPath, "service:\n endpoint:\n host: canary.example\n credential:\n token: CONFIGKIT_DIFF_OVERLAY_TOKEN\n")
|
||||
|
||||
base, err := consumers.LoadRollout(basePath, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
withOverlay, err := consumers.LoadRollout(basePath, "canary")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if base.Config.Service.Endpoint.Port != 8443 || withOverlay.Config.Service.Endpoint.Port != 8443 {
|
||||
t.Fatalf("lower-priority port was not retained: base=%#v overlay=%#v", base.Config.Service.Endpoint, withOverlay.Config.Service.Endpoint)
|
||||
}
|
||||
urlInfo, found := withOverlay.Metadata().Lookup(configkit.Keys("service", "endpoint", "url"))
|
||||
if !found || urlInfo.Origin.Layer != configkit.LayerResolution || withOverlay.Config.Service.Endpoint.URL != "https://canary.example:8443" {
|
||||
t.Fatalf("resolved endpoint = %#v, metadata=%#v, %v", withOverlay.Config.Service.Endpoint, urlInfo, found)
|
||||
}
|
||||
|
||||
differences, err := configkit.Diff(base, withOverlay)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(differences) != 3 {
|
||||
t.Fatalf("differences = %#v", differences)
|
||||
}
|
||||
wantPaths := []string{"/service/credential/token", "/service/endpoint/host", "/service/endpoint/url"}
|
||||
for index, difference := range differences {
|
||||
if got := difference.Path.String(); got != wantPaths[index] {
|
||||
t.Errorf("path[%d] = %q, want %q", index, got, wantPaths[index])
|
||||
}
|
||||
}
|
||||
secret := differences[0]
|
||||
if !secret.Left.Redacted || !secret.Right.Redacted || !secret.HiddenChanged || secret.Left.YAML != "'[REDACTED]'\n" || secret.Right.YAML != "'[REDACTED]'\n" {
|
||||
t.Fatalf("classified credential difference = %#v", secret)
|
||||
}
|
||||
if strings.Contains(consumers.FormatDifferences(differences), "disposable-") {
|
||||
t.Fatal("formatted differences exposed credential contents")
|
||||
}
|
||||
|
||||
output, err := consumers.CompareRolloutOverlay(basePath, "canary")
|
||||
if err != nil || output != consumers.FormatDifferences(differences) {
|
||||
t.Fatalf("formatted base/overlay comparison = %q, %v", output, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatDifferencesUsesRecordMembershipForNoChange(t *testing.T) {
|
||||
if got := consumers.FormatDifferences(nil); got != "No effective changes.\n" {
|
||||
t.Fatalf("no-change output = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func writeConfiguration(t *testing.T, filename, contents string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filename, []byte(contents), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
45
examples/consumers/discovery_test.go
Normal file
45
examples/consumers/discovery_test.go
Normal file
@@ -0,0 +1,45 @@
|
||||
package consumers_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
type workingDirectoryPresetConfig struct {
|
||||
Name string `yaml:"name"`
|
||||
}
|
||||
|
||||
func Example_workingDirectoryPreset() {
|
||||
original, err := os.Getwd()
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
defer func() { _ = os.Chdir(original) }()
|
||||
root, err := os.MkdirTemp("", "configkit-working-search-")
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
defer os.RemoveAll(root)
|
||||
if err := os.Chdir(root); err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "service.yml"), []byte("name: example\n"), 0o600); err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
result, err := configkit.Load[workingDirectoryPresetConfig](
|
||||
configkit.SearchConfig("service", configkit.WorkingDirectory()),
|
||||
)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
fmt.Println(result.Config.Name, filepath.Base(result.Sources()[0].Path))
|
||||
// Output: example service.yml
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
//go:embed testdata/native-source testdata/layered-bundle
|
||||
//go:embed testdata/native-source testdata/layered-bundle testdata/collection-bundle testdata/ordinary-imports testdata/source-version
|
||||
var bundledFiles embed.FS
|
||||
|
||||
// BundledConfig is a small configuration loaded directly from an embedded file.
|
||||
@@ -18,6 +18,30 @@ type BundledConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
}
|
||||
|
||||
type PromptBundleConfig struct {
|
||||
Name string `yaml:"name"`
|
||||
Version string `yaml:"version"`
|
||||
Description string `yaml:"description"`
|
||||
Prompts map[string]BundlePrompt `yaml:"prompts"`
|
||||
}
|
||||
|
||||
type BundlePrompt struct {
|
||||
Summary string `yaml:"summary"`
|
||||
}
|
||||
|
||||
// LoadPromptBundle demonstrates ordinary Load with opt-in imports whose
|
||||
// destinations are declared relative to the entry document.
|
||||
func LoadPromptBundle() (*configkit.Result[PromptBundleConfig], error) {
|
||||
files, err := fs.Sub(bundledFiles, "testdata/ordinary-imports")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return configkit.Load[PromptBundleConfig](
|
||||
configkit.NewFilesystem("prompt bundle", files).File("config.yml"),
|
||||
configkit.Options[PromptBundleConfig]{Imports: true},
|
||||
)
|
||||
}
|
||||
|
||||
// LoadBundled demonstrates a native filesystem source without layered options.
|
||||
func LoadBundled() (*configkit.Result[BundledConfig], error) {
|
||||
files, err := fs.Sub(bundledFiles, "testdata/native-source")
|
||||
|
||||
@@ -20,6 +20,34 @@ func Example_nativeFilesystem() {
|
||||
// Output: embedded false
|
||||
}
|
||||
|
||||
func Example_rootRelativeImports() {
|
||||
result, err := consumers.LoadPromptBundle()
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
source := result.Sources()[2]
|
||||
fmt.Println(result.Config.Name, result.Config.Version, result.Config.Prompts["brief"].Summary, source.Destination.String(), err)
|
||||
// Output: Root import example 1.0 A root-loaded prompt. /prompts <nil>
|
||||
}
|
||||
|
||||
func TestPromptBundleUsesRelativeImportDestinations(t *testing.T) {
|
||||
result, err := consumers.LoadPromptBundle()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Config.Name != "Root import example" || result.Config.Version != "1.0" || result.Config.Prompts["brief"].Summary != "A root-loaded prompt." {
|
||||
t.Fatalf("root import result = %#v", result.Config)
|
||||
}
|
||||
sources := result.Sources()
|
||||
if len(sources) != 3 || sources[0].Destination.String() != configkit.Keys().String() ||
|
||||
sources[1].Path != "metadata.yml" || sources[1].Destination.String() != configkit.Keys().String() ||
|
||||
sources[2].Path != "prompts/brief.yml" || sources[2].Destination.String() != "/prompts" ||
|
||||
sources[2].ImportedFrom == nil || sources[2].ImportedFrom.SourceID != sources[0].ID {
|
||||
t.Fatalf("root import sources = %#v", sources)
|
||||
}
|
||||
}
|
||||
|
||||
func Example_layeredCatalog() {
|
||||
result, err := consumers.LoadCatalog(fixture("layered-main", "config.yml"))
|
||||
if err != nil {
|
||||
@@ -32,6 +60,58 @@ func Example_layeredCatalog() {
|
||||
// Output: production false 0 bundle example Welcome from the bundle. <nil>
|
||||
}
|
||||
|
||||
func Example_sourceVersionAdoption() {
|
||||
result, err := consumers.LoadVersionedService(configkit.SourceVersionModeIfPresent)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
checks, redacted := 0, true
|
||||
for _, source := range result.Sources() {
|
||||
if check := source.VersionCheck; check != nil {
|
||||
checks++
|
||||
redacted = redacted && check.Redacted && check.Version.Text == ""
|
||||
}
|
||||
}
|
||||
required, requiredErr := consumers.LoadVersionedService(configkit.SourceVersionModeRequired)
|
||||
fmt.Println(result.Config.Service.Version, result.Config.Service.Metadata.Release,
|
||||
checks, redacted, required != nil && requiredErr == nil)
|
||||
// Output: service-v2 application-2026.10 2 true true
|
||||
}
|
||||
|
||||
func TestVersionedServiceModesAndMountedProtection(t *testing.T) {
|
||||
for _, mode := range []configkit.SourceVersionMode{
|
||||
configkit.SourceVersionModeIfPresent,
|
||||
configkit.SourceVersionModeRequired,
|
||||
} {
|
||||
result, err := consumers.LoadVersionedService(mode)
|
||||
if err != nil || result == nil || result.Config.Service.Version != "service-v2" ||
|
||||
result.Config.Service.Name != "example service" || result.Config.Service.Endpoint != "https://service.example.invalid" ||
|
||||
result.Config.Service.Metadata.Release != "application-2026.10" {
|
||||
t.Fatalf("versioned service mode %d = %#v, %v", mode, result, err)
|
||||
}
|
||||
sources := result.Sources()
|
||||
if len(sources) != 2 {
|
||||
t.Fatalf("versioned service sources = %#v", sources)
|
||||
}
|
||||
for _, source := range sources {
|
||||
check := source.VersionCheck
|
||||
if check == nil || check.Path.String() != "/version" || !check.Version.Present ||
|
||||
!check.Redacted || check.Version.Text != "" {
|
||||
t.Fatalf("safe source version evidence = %#v", sources)
|
||||
}
|
||||
}
|
||||
}
|
||||
_, err := consumers.LoadVersionedService(configkit.SourceVersionModeIfPresent,
|
||||
configkit.Override{Path: configkit.Keys("service", "version"), Value: "cli-v3", Flag: "--service-version"})
|
||||
var loadError *configkit.LoadError
|
||||
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 ||
|
||||
loadError.Diagnostics()[0].Code != configkit.CodeOverrideVersion ||
|
||||
loadError.Diagnostics()[0].Path.String() != "/service/version" {
|
||||
t.Fatalf("mounted main version override error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogRetainsPresenceAndSourceContext(t *testing.T) {
|
||||
result, err := consumers.LoadCatalog(fixture("layered-main", "config.yml"))
|
||||
if err != nil {
|
||||
|
||||
67
examples/consumers/prompt_catalog.go
Normal file
67
examples/consumers/prompt_catalog.go
Normal file
@@ -0,0 +1,67 @@
|
||||
package consumers
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
// PromptCatalogConfig is an application-owned prompt collection shape.
|
||||
type PromptCatalogConfig struct {
|
||||
Prompts []PromptRecord `yaml:"prompts"`
|
||||
}
|
||||
|
||||
// PromptRecord keeps its identity and message meaning in the consumer.
|
||||
type PromptRecord struct {
|
||||
ID string `yaml:"id"`
|
||||
Name string `yaml:"name"`
|
||||
Messages []string `yaml:"messages"`
|
||||
Resource string `yaml:"resource"`
|
||||
}
|
||||
|
||||
// LoadPromptCatalog discovers explicitly registered prompt records in the main
|
||||
// tier and replaces the lower-priority default collection as a whole.
|
||||
func LoadPromptCatalog(mainPath string) (*configkit.Result[PromptCatalogConfig], error) {
|
||||
return configkit.LoadLayered(configkit.Input{
|
||||
Source: configkit.File(mainPath),
|
||||
Imports: true,
|
||||
}, configkit.LayeredOptions[PromptCatalogConfig]{
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{
|
||||
Source: configkit.Bytes("default prompts", []byte("prompts:\n - id: default\n name: Default prompt\n messages: [Default message]\n")),
|
||||
}}}},
|
||||
ImportDefaults: configkit.ImportDefaults{
|
||||
Pattern: []string{"curated*.yml", "generated-*.yml"},
|
||||
},
|
||||
Definition: promptCatalogDefinition(),
|
||||
})
|
||||
}
|
||||
|
||||
// LoadRequiredPromptCatalog keeps the common one-file import shape small.
|
||||
func LoadRequiredPromptCatalog(mainPath string) (*configkit.Result[PromptCatalogConfig], error) {
|
||||
return configkit.Load(configkit.File(mainPath), configkit.Options[PromptCatalogConfig]{
|
||||
Imports: true, Definition: promptCatalogDefinition(),
|
||||
})
|
||||
}
|
||||
|
||||
func promptCatalogDefinition() configkit.Definition[PromptCatalogConfig] {
|
||||
return configkit.Definition[PromptCatalogConfig]{
|
||||
SequenceIdentities: []configkit.SequenceIdentity{
|
||||
configkit.SequenceIdentityAt(configkit.Keys("prompts"), "id"),
|
||||
},
|
||||
Validate: validatePromptCatalog,
|
||||
}
|
||||
}
|
||||
|
||||
func validatePromptCatalog(configuration *PromptCatalogConfig, validation *configkit.Validation) {
|
||||
for index, prompt := range configuration.Prompts {
|
||||
if strings.TrimSpace(prompt.Name) == "" {
|
||||
validation.Error(configkit.Keys("prompts").Index(index).Key("name"), "must not be empty")
|
||||
}
|
||||
if len(prompt.Messages) == 0 {
|
||||
validation.Error(configkit.Keys("prompts").Index(index).Key("messages"), "must contain at least one message")
|
||||
}
|
||||
}
|
||||
if len(configuration.Prompts) == 0 {
|
||||
validation.Error(configkit.Keys("prompts"), "must contain at least one prompt")
|
||||
}
|
||||
}
|
||||
135
examples/consumers/prompt_catalog_test.go
Normal file
135
examples/consumers/prompt_catalog_test.go
Normal file
@@ -0,0 +1,135 @@
|
||||
package consumers_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
pathpkg "path"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/examples/consumers"
|
||||
)
|
||||
|
||||
func Example_identifiedPromptCatalog() {
|
||||
result, err := consumers.LoadPromptCatalog(fixture("prompt-catalog", "config.yml"))
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
ids := make([]string, 0, len(result.Config.Prompts))
|
||||
for _, prompt := range result.Config.Prompts {
|
||||
ids = append(ids, prompt.ID)
|
||||
}
|
||||
emptySearches := 0
|
||||
for _, source := range result.Sources() {
|
||||
for _, selection := range source.ImportSelections {
|
||||
if selection.Selector == configkit.ImportSelectorDirectory && selection.Outcome == configkit.ImportSelectionNone {
|
||||
emptySearches++
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Println(ids, emptySearches, err)
|
||||
// Output: [weather curated welcome main] 1 <nil>
|
||||
}
|
||||
|
||||
func Example_requiredPromptCatalog() {
|
||||
result, err := consumers.LoadRequiredPromptCatalog(fixture("prompt-catalog", "simple.yml"))
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
fmt.Println(result.Config.Prompts[0].ID, err)
|
||||
// Output: required <nil>
|
||||
}
|
||||
|
||||
func TestPromptCatalogImportsRegisteredItemsAndReplacesDefaults(t *testing.T) {
|
||||
result, err := consumers.LoadPromptCatalog(fixture("prompt-catalog", "config.yml"))
|
||||
if err != nil {
|
||||
var loadError *configkit.LoadError
|
||||
if errors.As(err, &loadError) {
|
||||
t.Fatalf("load prompt catalog: %v (%#v)", err, loadError.Diagnostics())
|
||||
}
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.Config.Prompts) != 4 {
|
||||
t.Fatalf("prompt records = %#v", result.Config.Prompts)
|
||||
}
|
||||
ids := []string{"weather", "curated", "welcome", "main"}
|
||||
for index, id := range ids {
|
||||
if result.Config.Prompts[index].ID != id {
|
||||
t.Fatalf("prompt %d ID = %q, want %q", index, result.Config.Prompts[index].ID, id)
|
||||
}
|
||||
}
|
||||
for _, prompt := range result.Config.Prompts {
|
||||
if prompt.ID == "default" {
|
||||
t.Fatalf("lower-priority default survived main sequence replacement: %#v", result.Config.Prompts)
|
||||
}
|
||||
if prompt.ID == "ignored" {
|
||||
t.Fatalf("excluded generated prompt was selected: %#v", result.Config.Prompts)
|
||||
}
|
||||
}
|
||||
|
||||
var itemImports, helperImports int
|
||||
for _, source := range result.Sources() {
|
||||
switch filepath.Base(source.Path) {
|
||||
case "curated.yml", "weather-feature.yml", "generated-welcome.yml":
|
||||
if source.PlacementMode != configkit.PlacementModeSequenceItem || source.Destination.String() != "/prompts" {
|
||||
t.Fatalf("item source placement = %#v", source)
|
||||
}
|
||||
itemImports++
|
||||
case "curated-id.yml":
|
||||
if source.PlacementMode != configkit.PlacementModeMapping || source.ImportedFrom == nil {
|
||||
t.Fatalf("helper source = %#v", source)
|
||||
}
|
||||
helperImports++
|
||||
}
|
||||
}
|
||||
if itemImports != 3 || helperImports != 1 {
|
||||
t.Fatalf("item imports = %d, helper imports = %d, sources = %#v", itemImports, helperImports, result.Sources())
|
||||
}
|
||||
if result.Config.Prompts[1].ID != "curated" {
|
||||
t.Fatalf("helper did not provide prompt ID: %#v", result.Config.Prompts[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptCatalogMountsResourceFromScheduledPromptOrigin(t *testing.T) {
|
||||
result, err := consumers.LoadPromptCatalog(fixture("prompt-catalog", "config.yml"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Config.Prompts[2].ID != "welcome" || result.Config.Prompts[2].Resource != "welcome.txt" {
|
||||
t.Fatalf("scheduled resource prompt = %#v", result.Config.Prompts)
|
||||
}
|
||||
resourcePath := configkit.Keys("prompts").Index(2).Key("resource")
|
||||
info, ok := result.Metadata().Lookup(resourcePath)
|
||||
if !ok || len(info.Origin.Locations) != 1 {
|
||||
t.Fatalf("resource origin = %#v, %v", info, ok)
|
||||
}
|
||||
context, ok := result.SourceContext(info.Origin.Locations[0].SourceID)
|
||||
if !ok || context.FS == nil || filepath.Base(context.BaseDir) != "prompts" {
|
||||
t.Fatalf("resource source context = %#v, %v", context, ok)
|
||||
}
|
||||
contents, err := fs.ReadFile(context.FS, pathpkg.Join(context.BaseDir, result.Config.Prompts[2].Resource))
|
||||
if err != nil || string(contents) != "Welcome resource text.\n" {
|
||||
t.Fatalf("mounted resource = %q, %v", contents, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequiredPromptCatalogUsesOneRequiredFile(t *testing.T) {
|
||||
result, err := consumers.LoadRequiredPromptCatalog(fixture("prompt-catalog", "simple.yml"))
|
||||
if err != nil || len(result.Config.Prompts) != 1 || result.Config.Prompts[0].ID != "required" {
|
||||
t.Fatalf("simple required prompt = %#v, %v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptCatalogLeavesDomainValidationWithConsumer(t *testing.T) {
|
||||
_, err := consumers.LoadPromptCatalog(fixture("prompt-catalog", "invalid.yml"))
|
||||
var loadError *configkit.LoadError
|
||||
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 ||
|
||||
loadError.Diagnostics()[0].Code != configkit.CodeValidateError ||
|
||||
loadError.Diagnostics()[0].Path.String() != "/prompts/0/messages" {
|
||||
t.Fatalf("consumer domain validation error = %v", err)
|
||||
}
|
||||
}
|
||||
145
examples/consumers/sequence_catalog.go
Normal file
145
examples/consumers/sequence_catalog.go
Normal file
@@ -0,0 +1,145 @@
|
||||
package consumers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
// SequenceCatalogConfig is a source-backed prompt catalog with an application
|
||||
// supplied run label and a value derived from that label during resolution.
|
||||
type SequenceCatalogConfig struct {
|
||||
RunLabel string `yaml:"run_label"`
|
||||
ResolvedLabel string `yaml:"resolved_label"`
|
||||
Prompts []SequenceCatalogEntry `yaml:"prompts"`
|
||||
}
|
||||
|
||||
// SequenceCatalogEntry keeps prompt meaning, resources, and credentials in the
|
||||
// consuming application while ConfigKit handles collection assembly.
|
||||
type SequenceCatalogEntry struct {
|
||||
ID string `yaml:"id"`
|
||||
Name string `yaml:"name"`
|
||||
Description string `yaml:"description"`
|
||||
Messages []string `yaml:"messages"`
|
||||
Resource string `yaml:"resource"`
|
||||
Credential configkit.Secret `yaml:"credential"`
|
||||
}
|
||||
|
||||
// LoadSequenceCatalog searches its caller-selected main path before a custom
|
||||
// fallback directory, assembles imported fragments over a file-backed underlay,
|
||||
// applies one selected collection overlay, then resolves and validates once.
|
||||
func LoadSequenceCatalog(mainPath, overlayName, runLabel string) (*configkit.Result[SequenceCatalogConfig], error) {
|
||||
absoluteMain, err := filepath.Abs(mainPath)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
root := filepath.Dir(absoluteMain)
|
||||
filesystem := configkit.NewFilesystem("sequence catalog", os.DirFS(root))
|
||||
filesystemContext := &configkit.FilesystemContext{BaseDir: ".", Root: ".", FS: &filesystem}
|
||||
mainSearch := configkit.SearchConfig("", configkit.Searches(
|
||||
configkit.Paths(absoluteMain),
|
||||
configkit.SearchDirectory(filepath.Join(root, "fallback"), configkit.DirectorySearchOptions{
|
||||
Filenames: []configkit.FilenameGroup{configkit.CustomFilenames(filepath.Base(absoluteMain))},
|
||||
}),
|
||||
), configkit.SearchOptions{Required: true})
|
||||
definition := configkit.Definition[SequenceCatalogConfig]{
|
||||
SequenceIdentities: []configkit.SequenceIdentity{
|
||||
configkit.SequenceIdentityAt(configkit.Keys("prompts"), "id"),
|
||||
},
|
||||
Resolve: func(configuration *SequenceCatalogConfig, resolution *configkit.Resolution) error {
|
||||
configuration.ResolvedLabel = "for:" + configuration.RunLabel
|
||||
resolution.Record(configkit.Keys("resolved_label"), configkit.ChangeNormalization,
|
||||
"derive the resolved label from the selected run label", configkit.Keys("run_label"))
|
||||
return nil
|
||||
},
|
||||
Validate: validateSequenceCatalog,
|
||||
}
|
||||
return configkit.LoadLayered(configkit.Input{
|
||||
Source: mainSearch,
|
||||
Imports: true,
|
||||
Filesystem: &configkit.FilesystemContext{Root: root},
|
||||
}, configkit.LayeredOptions[SequenceCatalogConfig]{
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{
|
||||
Source: filesystem.File("underlay.yml"),
|
||||
}}}},
|
||||
Overlay: overlayName,
|
||||
OverlayFilesystem: filesystemContext,
|
||||
Overrides: []configkit.Override{{
|
||||
Path: configkit.Keys("run_label"), Value: runLabel, Flag: "--run-label",
|
||||
}},
|
||||
Definition: definition,
|
||||
})
|
||||
}
|
||||
|
||||
// ReadSequenceCatalogResource follows the final prompt field's source context,
|
||||
// including when record inheritance retained a resource from an underlay.
|
||||
func ReadSequenceCatalogResource(result *configkit.Result[SequenceCatalogConfig], promptID string) (string, error) {
|
||||
if result == nil {
|
||||
return "", errors.New("sequence catalog result is nil")
|
||||
}
|
||||
for index, prompt := range result.Config.Prompts {
|
||||
if prompt.ID != promptID {
|
||||
continue
|
||||
}
|
||||
resourcePath := configkit.Keys("prompts").Index(index).Key("resource")
|
||||
info, found := result.Metadata().Lookup(resourcePath)
|
||||
if !found || len(info.Origin.Locations) != 1 {
|
||||
return "", errors.New("prompt resource has no single declaring source")
|
||||
}
|
||||
context, found := result.SourceContext(info.Origin.Locations[0].SourceID)
|
||||
if !found {
|
||||
return "", errors.New("prompt resource source context is unavailable")
|
||||
}
|
||||
data, err := fs.ReadFile(context.FS, path.Join(context.BaseDir, prompt.Resource))
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("read prompt resource: %w", err)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
return "", fmt.Errorf("prompt %q is unavailable", promptID)
|
||||
}
|
||||
|
||||
func validateSequenceCatalog(configuration *SequenceCatalogConfig, validation *configkit.Validation) {
|
||||
for index, prompt := range configuration.Prompts {
|
||||
if strings.TrimSpace(prompt.Name) == "" {
|
||||
validation.Error(configkit.Keys("prompts").Index(index).Key("name"), "must not be empty")
|
||||
}
|
||||
if len(prompt.Messages) == 0 {
|
||||
validation.Error(configkit.Keys("prompts").Index(index).Key("messages"), "must contain at least one message")
|
||||
}
|
||||
}
|
||||
if len(configuration.Prompts) == 0 {
|
||||
validation.Error(configkit.Keys("prompts"), "must contain at least one prompt")
|
||||
}
|
||||
}
|
||||
|
||||
// NamespacedMapCatalogConfig demonstrates typed application maps whose
|
||||
// independent prompt sequences use root and dynamic namespace registrations.
|
||||
type NamespacedMapCatalogConfig struct {
|
||||
Prompts []map[string]any `yaml:"prompts"`
|
||||
Collections map[string]map[string]any `yaml:"collections"`
|
||||
}
|
||||
|
||||
// LoadNamespacedMapCatalog applies the same scalar identity constraint to one
|
||||
// root collection and each namespace's independently scoped collection.
|
||||
func LoadNamespacedMapCatalog(source configkit.Source) (*configkit.Result[NamespacedMapCatalogConfig], error) {
|
||||
selector := configkit.SequenceSelector{
|
||||
configkit.SequenceLiteralKey("collections"),
|
||||
configkit.SequenceAnyMappingKey(),
|
||||
configkit.SequenceLiteralKey("prompts"),
|
||||
}
|
||||
return configkit.Load(source, configkit.Options[NamespacedMapCatalogConfig]{
|
||||
Definition: configkit.Definition[NamespacedMapCatalogConfig]{
|
||||
SequenceIdentities: []configkit.SequenceIdentity{
|
||||
configkit.SequenceIdentityAt(configkit.Keys("prompts"), "id"),
|
||||
configkit.SequenceIdentityMatching(selector, "id"),
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
239
examples/consumers/sequence_catalog_test.go
Normal file
239
examples/consumers/sequence_catalog_test.go
Normal file
@@ -0,0 +1,239 @@
|
||||
package consumers_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/examples/consumers"
|
||||
)
|
||||
|
||||
func Example_sequenceCatalog() {
|
||||
const oldSecret = "CONFIGKIT_SEQUENCE_CATALOG_OLD_SECRET"
|
||||
const newSecret = "CONFIGKIT_SEQUENCE_CATALOG_NEW_SECRET"
|
||||
restoreOld, restoreNew := preserveEnvironment(oldSecret), preserveEnvironment(newSecret)
|
||||
defer restoreOld()
|
||||
defer restoreNew()
|
||||
_ = os.Setenv(oldSecret, "disposable-old-example-credential")
|
||||
_ = os.Setenv(newSecret, "disposable-new-example-credential")
|
||||
|
||||
result, err := consumers.LoadSequenceCatalog(fixture("sequence-catalog", "config.yml"), "upsert", "preview")
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
ids := make([]string, 0, len(result.Config.Prompts))
|
||||
for _, prompt := range result.Config.Prompts {
|
||||
ids = append(ids, prompt.ID)
|
||||
}
|
||||
shared := configkit.Keys("prompts").Index(1).Key("name")
|
||||
info, _ := result.Metadata().Lookup(shared)
|
||||
resource, resourceErr := consumers.ReadSequenceCatalogResource(result, "concise")
|
||||
fmt.Println(ids, result.Config.ResolvedLabel, info.Explicit, len(result.Metadata().History(shared)), resource, resourceErr == nil, err)
|
||||
// Output: [foundation shared welcome concise extra] for:preview true 2 Foundation resource text.
|
||||
// true <nil>
|
||||
}
|
||||
|
||||
func TestSequenceCatalogSelectedOverlayOperations(t *testing.T) {
|
||||
const oldSecret = "CONFIGKIT_SEQUENCE_CATALOG_OLD_SECRET"
|
||||
const newSecret = "CONFIGKIT_SEQUENCE_CATALOG_NEW_SECRET"
|
||||
t.Setenv(oldSecret, "sequence-catalog-old-canary")
|
||||
t.Setenv(newSecret, "sequence-catalog-new-canary")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
wantIDs []string
|
||||
shared *consumers.SequenceCatalogEntry
|
||||
}{
|
||||
{name: "append", wantIDs: []string{"foundation", "shared", "welcome", "concise", "extra"}},
|
||||
{name: "upsert", wantIDs: []string{"foundation", "shared", "welcome", "concise", "extra"}, shared: &consumers.SequenceCatalogEntry{ID: "shared", Name: "Shared replacement", Messages: []string{"Replacement message"}, Credential: configkit.SecretEnv(newSecret)}},
|
||||
{name: "replace", wantIDs: []string{"replacement", "last"}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
result, err := consumers.LoadSequenceCatalog(fixture("sequence-catalog", "config.yml"), test.name, "test")
|
||||
if err != nil {
|
||||
var loadError *configkit.LoadError
|
||||
if errors.As(err, &loadError) {
|
||||
t.Fatalf("load selected overlay: %v (%#v)", err, loadError.Diagnostics())
|
||||
}
|
||||
t.Fatal(err)
|
||||
}
|
||||
gotIDs := make([]string, 0, len(result.Config.Prompts))
|
||||
for _, prompt := range result.Config.Prompts {
|
||||
gotIDs = append(gotIDs, prompt.ID)
|
||||
}
|
||||
if strings.Join(gotIDs, ",") != strings.Join(test.wantIDs, ",") {
|
||||
t.Fatalf("prompt order = %v, want %v", gotIDs, test.wantIDs)
|
||||
}
|
||||
if test.shared == nil {
|
||||
return
|
||||
}
|
||||
shared := result.Config.Prompts[1]
|
||||
if shared.ID != test.shared.ID || shared.Name != test.shared.Name || len(shared.Messages) != 1 ||
|
||||
shared.Messages[0] != test.shared.Messages[0] || shared.Description != "" || shared.Resource != "" || shared.Credential.EnvName() != newSecret {
|
||||
t.Fatalf("upsert did not replace the complete matched record: %#v", shared)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSequenceCatalogInspectionTracksResourcesAndSecretHistory(t *testing.T) {
|
||||
const oldSecret = "CONFIGKIT_SEQUENCE_CATALOG_OLD_SECRET"
|
||||
const newSecret = "CONFIGKIT_SEQUENCE_CATALOG_NEW_SECRET"
|
||||
const oldCanary = "sequence-catalog-old-canary"
|
||||
const newCanary = "sequence-catalog-new-canary"
|
||||
t.Setenv(oldSecret, oldCanary)
|
||||
t.Setenv(newSecret, newCanary)
|
||||
|
||||
result, err := consumers.LoadSequenceCatalog(fixture("sequence-catalog", "config.yml"), "upsert", "history")
|
||||
if err != nil {
|
||||
var loadError *configkit.LoadError
|
||||
if errors.As(err, &loadError) {
|
||||
t.Fatalf("load sequence catalog: %v (%#v)", err, loadError.Diagnostics())
|
||||
}
|
||||
t.Fatal(err)
|
||||
}
|
||||
resourcePath := configkit.Keys("prompts").Index(3).Key("resource")
|
||||
resourceInfo, found := result.Metadata().Lookup(resourcePath)
|
||||
if !found || len(resourceInfo.Origin.Locations) != 1 || len(resourceInfo.Origin.Inheritance) != 1 {
|
||||
t.Fatalf("inherited resource inspection = %#v, %v", resourceInfo, found)
|
||||
}
|
||||
resource, err := consumers.ReadSequenceCatalogResource(result, "concise")
|
||||
if err != nil || resource != "Foundation resource text.\n" {
|
||||
t.Fatalf("resource from inherited source context = %q, %v", resource, err)
|
||||
}
|
||||
if len(result.Metadata().History(configkit.Keys("resolved_label"))) == 0 {
|
||||
t.Fatal("recorded CLI-driven resolution was absent from final inspection")
|
||||
}
|
||||
credentialPath := configkit.Keys("prompts").Index(1).Key("credential")
|
||||
credentialHistory := result.Metadata().History(credentialPath)
|
||||
if len(credentialHistory) == 0 {
|
||||
t.Fatal("upsert did not retain the replaced secret reference in history")
|
||||
}
|
||||
var explanation bytes.Buffer
|
||||
if err := result.WriteExplanation(&explanation, credentialPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(explanation.String(), oldCanary) || strings.Contains(explanation.String(), newCanary) {
|
||||
t.Fatalf("secret explanation exposed a credential value: %s", explanation.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestNamespacedTypedMapIdentityScopesAreIndependent(t *testing.T) {
|
||||
source := configkit.Bytes("namespaced maps", []byte(`
|
||||
prompts:
|
||||
- id: shared
|
||||
text: root prompt
|
||||
collections:
|
||||
team:
|
||||
prompts:
|
||||
- id: shared
|
||||
text: team prompt
|
||||
operations:
|
||||
prompts:
|
||||
- id: shared
|
||||
text: operations prompt
|
||||
`))
|
||||
result, err := consumers.LoadNamespacedMapCatalog(source)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Config.Prompts[0]["text"] != "root prompt" ||
|
||||
result.Config.Collections["team"]["prompts"].([]any)[0].(map[string]any)["text"] != "team prompt" ||
|
||||
result.Config.Collections["operations"]["prompts"].([]any)[0].(map[string]any)["text"] != "operations prompt" {
|
||||
t.Fatalf("typed map collections = %#v", result.Config)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNamespacedTypedMapIdentityFindsCollectionsIntroducedByInheritance(t *testing.T) {
|
||||
result, err := consumers.LoadNamespacedMapCatalog(configkit.Bytes("inherited namespace", []byte(`
|
||||
collections:
|
||||
source:
|
||||
prompts:
|
||||
- id: shared
|
||||
text: inherited prompt
|
||||
copied:
|
||||
extends: source
|
||||
`)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
copied := result.Config.Collections["copied"]["prompts"].([]any)
|
||||
if len(copied) != 1 || copied[0].(map[string]any)["text"] != "inherited prompt" {
|
||||
t.Fatalf("inherited typed-map collection = %#v", result.Config.Collections["copied"])
|
||||
}
|
||||
}
|
||||
|
||||
func Example_yamlDeclaredIdentityOnly() {
|
||||
result, err := configkit.Load(configkit.Bytes("YAML identity only", []byte(`
|
||||
configkit:
|
||||
sequences:
|
||||
- destination: [prompts]
|
||||
key: id
|
||||
prompts:
|
||||
- id: initial
|
||||
`)), configkit.Options[map[string]any]{
|
||||
Overrides: []configkit.Override{{
|
||||
Path: configkit.Keys("prompts"),
|
||||
Value: []any{
|
||||
map[string]any{"id": "same"},
|
||||
map[string]any{"id": "same"},
|
||||
},
|
||||
Flag: "--prompts",
|
||||
}},
|
||||
})
|
||||
fmt.Println(len(result.Config["prompts"].([]any)), err)
|
||||
// Output: 2 <nil>
|
||||
}
|
||||
|
||||
func TestYAMLDeclaredIdentityDoesNotRegisterFinalTypedConstraint(t *testing.T) {
|
||||
result, err := configkit.Load(configkit.Bytes("YAML identity only", []byte(`
|
||||
configkit:
|
||||
sequences:
|
||||
- destination: [prompts]
|
||||
key: id
|
||||
prompts:
|
||||
- id: initial
|
||||
`)), configkit.Options[map[string]any]{
|
||||
Overrides: []configkit.Override{{
|
||||
Path: configkit.Keys("prompts"),
|
||||
Value: []any{
|
||||
map[string]any{"id": "same"},
|
||||
map[string]any{"id": "same"},
|
||||
},
|
||||
Flag: "--prompts",
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("YAML-only identity incorrectly became an application constraint: %v", err)
|
||||
}
|
||||
prompts := result.Config["prompts"].([]any)
|
||||
if len(prompts) != 2 || prompts[0].(map[string]any)["id"] != prompts[1].(map[string]any)["id"] {
|
||||
t.Fatalf("CLI result = %#v", result.Config)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNamespacedTypedMapRejectsDuplicatesWithinOneCollection(t *testing.T) {
|
||||
_, err := consumers.LoadNamespacedMapCatalog(configkit.Bytes("duplicate namespace", []byte(`
|
||||
collections:
|
||||
team:
|
||||
prompts:
|
||||
- id: duplicate
|
||||
- id: duplicate
|
||||
`)))
|
||||
var loadError *configkit.LoadError
|
||||
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 ||
|
||||
loadError.Diagnostics()[0].Code != configkit.CodeComposeConflict ||
|
||||
loadError.Diagnostics()[0].Path.String() != "/collections/team/prompts" {
|
||||
diagnostics := []configkit.Diagnostic(nil)
|
||||
if loadError != nil {
|
||||
diagnostics = loadError.Diagnostics()
|
||||
}
|
||||
t.Fatalf("duplicate namespaced identity error = %v (%#v)", err, diagnostics)
|
||||
}
|
||||
}
|
||||
64
examples/consumers/source_version.go
Normal file
64
examples/consumers/source_version.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package consumers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
// VersionedServiceConfig demonstrates a mounted main input whose source-local
|
||||
// schema version is retained at the effective service path.
|
||||
type VersionedServiceConfig struct {
|
||||
Service struct {
|
||||
Version string `yaml:"version"`
|
||||
Name string `yaml:"name"`
|
||||
Endpoint string `yaml:"endpoint"`
|
||||
Metadata struct {
|
||||
Release string `yaml:"release"`
|
||||
} `yaml:"metadata"`
|
||||
} `yaml:"service"`
|
||||
}
|
||||
|
||||
// LoadVersionedService checks the main and underlay declarations with one
|
||||
// application policy. The local rule path is "version" for each source; the
|
||||
// main input's destination places its protected value at /service/version.
|
||||
// Optional CLI overrides are passed through to ConfigKit for normal protection.
|
||||
func LoadVersionedService(mode configkit.SourceVersionMode, overrides ...configkit.Override) (*configkit.Result[VersionedServiceConfig], error) {
|
||||
files, err := fs.Sub(bundledFiles, "testdata/source-version")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
main, err := fs.ReadFile(files, "main.yml")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
underlay, err := fs.ReadFile(files, "underlay.yml")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
definition := configkit.Definition[VersionedServiceConfig]{
|
||||
Version: &configkit.VersionRule{
|
||||
Path: configkit.Keys("version"),
|
||||
Check: func(version configkit.Version, _ *configkit.Validation) error {
|
||||
if !version.Present || version.Null || version.Text == "" {
|
||||
return errors.New("a nonempty source version is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
Secret: func(path configkit.Path) bool {
|
||||
return path.String() == "/service/version"
|
||||
},
|
||||
}
|
||||
return configkit.LoadLayered(configkit.Input{
|
||||
Source: configkit.Bytes("service main", main), Destination: configkit.Keys("service"),
|
||||
}, configkit.LayeredOptions[VersionedServiceConfig]{
|
||||
Definition: definition,
|
||||
SourceVersionMode: mode,
|
||||
Overrides: overrides,
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{
|
||||
Source: configkit.Bytes("service defaults", underlay), Destination: configkit.Keys("service"),
|
||||
}}}},
|
||||
})
|
||||
}
|
||||
9
examples/consumers/testdata/collection-bundle/collections/weather/collection.yml
vendored
Normal file
9
examples/consumers/testdata/collection-bundle/collections/weather/collection.yml
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
configkit:
|
||||
imports:
|
||||
- path: metadata.yml
|
||||
destination: []
|
||||
- path: prompts/index.yml
|
||||
destination: [prompts]
|
||||
- path: profiles/index.yml
|
||||
destination: [profiles]
|
||||
name: Weather Reporter
|
||||
4
examples/consumers/testdata/collection-bundle/collections/weather/metadata.yml
vendored
Normal file
4
examples/consumers/testdata/collection-bundle/collections/weather/metadata.yml
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
configkit:
|
||||
imports:
|
||||
- path: version.yml
|
||||
description: Imported collection metadata.
|
||||
3
examples/consumers/testdata/collection-bundle/collections/weather/profiles/compact.yml
vendored
Normal file
3
examples/consumers/testdata/collection-bundle/collections/weather/profiles/compact.yml
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
compact:
|
||||
format: text
|
||||
temperature: 0.2
|
||||
4
examples/consumers/testdata/collection-bundle/collections/weather/profiles/index.yml
vendored
Normal file
4
examples/consumers/testdata/collection-bundle/collections/weather/profiles/index.yml
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
configkit:
|
||||
imports:
|
||||
- path: compact.yml
|
||||
- path: weather.yml
|
||||
3
examples/consumers/testdata/collection-bundle/collections/weather/profiles/weather.yml
vendored
Normal file
3
examples/consumers/testdata/collection-bundle/collections/weather/profiles/weather.yml
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
weather:
|
||||
extends: compact
|
||||
temperature: 0.4
|
||||
7
examples/consumers/testdata/collection-bundle/collections/weather/prompts/brief.yml
vendored
Normal file
7
examples/consumers/testdata/collection-bundle/collections/weather/prompts/brief.yml
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
brief:
|
||||
version: "1"
|
||||
default_profile: compact
|
||||
content_file: welcome.txt
|
||||
messages:
|
||||
- role: user
|
||||
content: Write a concise weather summary.
|
||||
3
examples/consumers/testdata/collection-bundle/collections/weather/prompts/custom.yml
vendored
Normal file
3
examples/consumers/testdata/collection-bundle/collections/weather/prompts/custom.yml
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
custom:
|
||||
extends: brief
|
||||
default_profile: weather
|
||||
4
examples/consumers/testdata/collection-bundle/collections/weather/prompts/index.yml
vendored
Normal file
4
examples/consumers/testdata/collection-bundle/collections/weather/prompts/index.yml
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
configkit:
|
||||
imports:
|
||||
- path: brief.yml
|
||||
- path: custom.yml
|
||||
1
examples/consumers/testdata/collection-bundle/collections/weather/prompts/welcome.txt
vendored
Normal file
1
examples/consumers/testdata/collection-bundle/collections/weather/prompts/welcome.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
Welcome to the weather collection.
|
||||
1
examples/consumers/testdata/collection-bundle/collections/weather/version.yml
vendored
Normal file
1
examples/consumers/testdata/collection-bundle/collections/weather/version.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
version: "2.4.0"
|
||||
2
examples/consumers/testdata/collection-bundle/operators/weather-prompt-version.yml
vendored
Normal file
2
examples/consumers/testdata/collection-bundle/operators/weather-prompt-version.yml
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
brief:
|
||||
version: "2"
|
||||
6
examples/consumers/testdata/ordinary-imports/config.yml
vendored
Normal file
6
examples/consumers/testdata/ordinary-imports/config.yml
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
configkit:
|
||||
imports:
|
||||
- path: metadata.yml
|
||||
- path: prompts/brief.yml
|
||||
destination: [prompts]
|
||||
name: Root import example
|
||||
2
examples/consumers/testdata/ordinary-imports/metadata.yml
vendored
Normal file
2
examples/consumers/testdata/ordinary-imports/metadata.yml
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
version: "1.0"
|
||||
description: Metadata imported into the root document.
|
||||
2
examples/consumers/testdata/ordinary-imports/prompts/brief.yml
vendored
Normal file
2
examples/consumers/testdata/ordinary-imports/prompts/brief.yml
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
brief:
|
||||
summary: A root-loaded prompt.
|
||||
19
examples/consumers/testdata/prompt-catalog/config.yml
vendored
Normal file
19
examples/consumers/testdata/prompt-catalog/config.yml
vendored
Normal file
@@ -0,0 +1,19 @@
|
||||
configkit:
|
||||
sequences:
|
||||
- destination: [prompts]
|
||||
key: id
|
||||
mode: replace
|
||||
imports:
|
||||
- dir: prompts
|
||||
exclude: [generated-ignored.yml]
|
||||
destination: [prompts]
|
||||
placement: sequence-item
|
||||
- dir: featured
|
||||
pattern: [weather-*.yml]
|
||||
destination: [prompts]
|
||||
placement: sequence-item
|
||||
- dir: optional
|
||||
prompts:
|
||||
- id: main
|
||||
name: Main authored prompt
|
||||
messages: [Use the main prompt.]
|
||||
4
examples/consumers/testdata/prompt-catalog/featured/weather-feature.yml
vendored
Normal file
4
examples/consumers/testdata/prompt-catalog/featured/weather-feature.yml
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
id: weather
|
||||
name: Weather forecast
|
||||
messages:
|
||||
- Describe today's forecast.
|
||||
9
examples/consumers/testdata/prompt-catalog/invalid.yml
vendored
Normal file
9
examples/consumers/testdata/prompt-catalog/invalid.yml
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
configkit:
|
||||
sequences:
|
||||
- destination: [prompts]
|
||||
key: id
|
||||
mode: replace
|
||||
prompts:
|
||||
- id: invalid
|
||||
name: Invalid prompt
|
||||
messages: []
|
||||
1
examples/consumers/testdata/prompt-catalog/optional/ignored.txt
vendored
Normal file
1
examples/consumers/testdata/prompt-catalog/optional/ignored.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
This file does not match the application import defaults.
|
||||
6
examples/consumers/testdata/prompt-catalog/prompts/curated.yml
vendored
Normal file
6
examples/consumers/testdata/prompt-catalog/prompts/curated.yml
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
configkit:
|
||||
imports:
|
||||
- path: helpers/curated-id.yml
|
||||
name: Curated summary
|
||||
messages:
|
||||
- Summarize the supplied content.
|
||||
3
examples/consumers/testdata/prompt-catalog/prompts/generated-ignored.yml
vendored
Normal file
3
examples/consumers/testdata/prompt-catalog/prompts/generated-ignored.yml
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
id: ignored
|
||||
name: Excluded generated prompt
|
||||
messages: [This record is excluded.]
|
||||
5
examples/consumers/testdata/prompt-catalog/prompts/generated-welcome.yml
vendored
Normal file
5
examples/consumers/testdata/prompt-catalog/prompts/generated-welcome.yml
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
id: welcome
|
||||
name: Welcome
|
||||
messages:
|
||||
- Welcome the user.
|
||||
resource: welcome.txt
|
||||
1
examples/consumers/testdata/prompt-catalog/prompts/helpers/curated-id.yml
vendored
Normal file
1
examples/consumers/testdata/prompt-catalog/prompts/helpers/curated-id.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
id: curated
|
||||
3
examples/consumers/testdata/prompt-catalog/prompts/required.yml
vendored
Normal file
3
examples/consumers/testdata/prompt-catalog/prompts/required.yml
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
id: required
|
||||
name: Required prompt
|
||||
messages: [Use the required prompt.]
|
||||
1
examples/consumers/testdata/prompt-catalog/prompts/welcome.txt
vendored
Normal file
1
examples/consumers/testdata/prompt-catalog/prompts/welcome.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
Welcome resource text.
|
||||
7
examples/consumers/testdata/prompt-catalog/simple.yml
vendored
Normal file
7
examples/consumers/testdata/prompt-catalog/simple.yml
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
configkit:
|
||||
imports:
|
||||
- path: prompts/required.yml
|
||||
required: true
|
||||
destination: [prompts]
|
||||
placement: sequence-item
|
||||
prompts: []
|
||||
7
examples/consumers/testdata/sequence-catalog/config.yml
vendored
Normal file
7
examples/consumers/testdata/sequence-catalog/config.yml
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
run_label: source-default
|
||||
configkit:
|
||||
imports:
|
||||
- dir: fragments
|
||||
pattern: '*.yml'
|
||||
destination: [prompts]
|
||||
placement: sequence-item
|
||||
1
examples/consumers/testdata/sequence-catalog/foundation.txt
vendored
Normal file
1
examples/consumers/testdata/sequence-catalog/foundation.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
Foundation resource text.
|
||||
4
examples/consumers/testdata/sequence-catalog/fragments/01-welcome.yml
vendored
Normal file
4
examples/consumers/testdata/sequence-catalog/fragments/01-welcome.yml
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
id: welcome
|
||||
name: Welcome
|
||||
messages: [Welcome message]
|
||||
credential: CONFIGKIT_SEQUENCE_CATALOG_OLD_SECRET
|
||||
3
examples/consumers/testdata/sequence-catalog/fragments/02-concise.yml
vendored
Normal file
3
examples/consumers/testdata/sequence-catalog/fragments/02-concise.yml
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
id: concise
|
||||
extends: {id: foundation}
|
||||
name: Concise
|
||||
10
examples/consumers/testdata/sequence-catalog/overlays/append.yml
vendored
Normal file
10
examples/consumers/testdata/sequence-catalog/overlays/append.yml
vendored
Normal file
@@ -0,0 +1,10 @@
|
||||
configkit:
|
||||
sequences:
|
||||
- destination: [prompts]
|
||||
key: id
|
||||
mode: append
|
||||
prompts:
|
||||
- id: extra
|
||||
name: Extra
|
||||
messages: [Extra message]
|
||||
credential: CONFIGKIT_SEQUENCE_CATALOG_OLD_SECRET
|
||||
14
examples/consumers/testdata/sequence-catalog/overlays/replace.yml
vendored
Normal file
14
examples/consumers/testdata/sequence-catalog/overlays/replace.yml
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
configkit:
|
||||
sequences:
|
||||
- destination: [prompts]
|
||||
key: id
|
||||
mode: replace
|
||||
prompts:
|
||||
- id: replacement
|
||||
name: Replacement
|
||||
messages: [Replacement message]
|
||||
credential: CONFIGKIT_SEQUENCE_CATALOG_OLD_SECRET
|
||||
- id: last
|
||||
name: Last
|
||||
messages: [Last message]
|
||||
credential: CONFIGKIT_SEQUENCE_CATALOG_OLD_SECRET
|
||||
14
examples/consumers/testdata/sequence-catalog/overlays/upsert.yml
vendored
Normal file
14
examples/consumers/testdata/sequence-catalog/overlays/upsert.yml
vendored
Normal file
@@ -0,0 +1,14 @@
|
||||
configkit:
|
||||
sequences:
|
||||
- destination: [prompts]
|
||||
key: id
|
||||
mode: upsert
|
||||
prompts:
|
||||
- id: shared
|
||||
name: Shared replacement
|
||||
messages: [Replacement message]
|
||||
credential: CONFIGKIT_SEQUENCE_CATALOG_NEW_SECRET
|
||||
- id: extra
|
||||
name: Extra
|
||||
messages: [Extra message]
|
||||
credential: CONFIGKIT_SEQUENCE_CATALOG_OLD_SECRET
|
||||
18
examples/consumers/testdata/sequence-catalog/underlay.yml
vendored
Normal file
18
examples/consumers/testdata/sequence-catalog/underlay.yml
vendored
Normal file
@@ -0,0 +1,18 @@
|
||||
configkit:
|
||||
sequences:
|
||||
- destination: [prompts]
|
||||
key: id
|
||||
mode: append
|
||||
prompts:
|
||||
- id: foundation
|
||||
name: Foundation
|
||||
description: Shared foundation instruction
|
||||
messages: [Foundation message]
|
||||
resource: foundation.txt
|
||||
credential: CONFIGKIT_SEQUENCE_CATALOG_OLD_SECRET
|
||||
- id: shared
|
||||
name: Shared draft
|
||||
description: This field must not survive a whole-record replacement
|
||||
messages: [Draft message]
|
||||
resource: shared.txt
|
||||
credential: CONFIGKIT_SEQUENCE_CATALOG_OLD_SECRET
|
||||
4
examples/consumers/testdata/source-version/main.yml
vendored
Normal file
4
examples/consumers/testdata/source-version/main.yml
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
version: service-v2
|
||||
name: example service
|
||||
metadata:
|
||||
release: application-2026.10
|
||||
2
examples/consumers/testdata/source-version/underlay.yml
vendored
Normal file
2
examples/consumers/testdata/source-version/underlay.yml
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
version: catalog-v1
|
||||
endpoint: https://service.example.invalid
|
||||
705
facade_destination_test.go
Normal file
705
facade_destination_test.go
Normal file
@@ -0,0 +1,705 @@
|
||||
package configkit_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
func TestFacadeInputDestinationsAcrossSourceKinds(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
hostPath := filepath.Join(directory, "host.yml")
|
||||
if err := os.WriteFile(hostPath, []byte("value: host\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
native := configkit.NewFilesystem("native", fstest.MapFS{
|
||||
"main.yml": &fstest.MapFile{Data: []byte("value: native\n")},
|
||||
})
|
||||
const literalValuePath = "/collections/weather.reporter/profiles/a~1b/~0/0"
|
||||
destination := configkit.Keys("collections", "weather.reporter", "profiles", "a/b", "~", "0")
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
source configkit.Source
|
||||
want string
|
||||
kind configkit.SourceKind
|
||||
}{
|
||||
{name: "host file", source: configkit.File(hostPath), want: "host", kind: configkit.SourceKindFile},
|
||||
{name: "native filesystem", source: native.File("main.yml"), want: "native", kind: configkit.SourceKindFilesystem},
|
||||
{name: "bytes", source: configkit.Bytes("bytes", []byte("value: bytes\n")), want: "bytes", kind: configkit.SourceKindBytes},
|
||||
{name: "reader", source: configkit.Reader("reader", strings.NewReader("value: reader\n")), want: "reader", kind: configkit.SourceKindReader},
|
||||
{name: "mapping", source: configkit.Mapping("mapping", map[string]any{"value": "mapping"}), want: "mapping", kind: configkit.SourceKindMapping},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
result, err := configkit.LoadLayered[map[string]any](configkit.Input{Source: test.source, Destination: destination})
|
||||
if err != nil {
|
||||
t.Fatalf("LoadLayered: %v", err)
|
||||
}
|
||||
if got := facadeDestinationValue(t, result.Config, "collections", "weather.reporter", "profiles", "a/b", "~", "0", "value"); got != test.want {
|
||||
t.Fatalf("placed value = %#v, want %q", got, test.want)
|
||||
}
|
||||
sources := result.Sources()
|
||||
if len(sources) != 1 || sources[0].Kind != test.kind || sources[0].Destination.String() != destination.String() || destination.String() != literalValuePath {
|
||||
t.Fatalf("source placement = %#v, destination = %q", sources, destination.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeDestinationRootEmptyMappingNoSourceAndInvalidPaths(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
destination configkit.Path
|
||||
}{
|
||||
{name: "omitted destination"},
|
||||
{name: "explicit empty destination", destination: configkit.Path{}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
result, err := configkit.LoadLayered[map[string]any](configkit.Input{Source: configkit.Bytes("root", []byte("value: root\n")), Destination: test.destination})
|
||||
if err != nil {
|
||||
t.Fatalf("root load: %v", err)
|
||||
}
|
||||
if result.Config["value"] != "root" || len(result.Sources()) != 1 || result.Sources()[0].Destination.String() != "" {
|
||||
t.Fatalf("root placement = %#v, sources %#v", result.Config, result.Sources())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
emptyPath := configkit.Keys("collections", "empty")
|
||||
empty, err := configkit.LoadLayered[map[string]any](configkit.Input{
|
||||
Source: configkit.Mapping("empty", map[string]any{}), Destination: emptyPath,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("empty mapping load: %v", err)
|
||||
}
|
||||
if value := facadeDestinationMap(t, empty.Config, "collections")["empty"]; value == nil {
|
||||
t.Fatal("empty mapping did not materialize its destination")
|
||||
}
|
||||
if info, ok := empty.Metadata().Lookup(emptyPath); !ok || !info.Explicit {
|
||||
t.Fatalf("empty destination metadata = %#v, %v", info, ok)
|
||||
}
|
||||
|
||||
absentPath := configkit.Keys("collections", "absent")
|
||||
absent, err := configkit.LoadLayered[map[string]any](configkit.Input{Source: configkit.NoSource(), Destination: absentPath})
|
||||
if err != nil {
|
||||
t.Fatalf("NoSource with a destination: %v", err)
|
||||
}
|
||||
if len(absent.Sources()) != 0 {
|
||||
t.Fatalf("NoSource created source records: %#v", absent.Sources())
|
||||
}
|
||||
if _, ok := absent.Metadata().Lookup(absentPath); ok {
|
||||
t.Fatal("NoSource materialized its destination")
|
||||
}
|
||||
|
||||
reader := &destinationCountingReader{}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
source configkit.Source
|
||||
destination configkit.Path
|
||||
wantReads int
|
||||
}{
|
||||
{name: "index segment", source: configkit.Reader("unread", reader), destination: configkit.Keys("catalog").Index(0)},
|
||||
{name: "reserved configkit key", source: configkit.NoSource(), destination: configkit.Keys("configkit")},
|
||||
{name: "reserved extends key", source: configkit.NoSource(), destination: configkit.Keys("catalog", "extends")},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
_, err := configkit.LoadLayered[map[string]any](configkit.Input{Source: test.source, Destination: test.destination})
|
||||
loadError, ok := err.(*configkit.LoadError)
|
||||
if !ok || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != configkit.CodeOptionsInvalid {
|
||||
t.Fatalf("invalid destination error = %#v", err)
|
||||
}
|
||||
if reader.reads != test.wantReads {
|
||||
t.Fatalf("reader calls = %d, want %d", reader.reads, test.wantReads)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeImportedSourcesRetainInputDestination(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := filepath.Join(directory, "main.yml")
|
||||
fragmentPath := filepath.Join(directory, "fragment.yml")
|
||||
if err := os.WriteFile(mainPath, []byte("configkit: {imports: [{path: fragment.yml}]}\nmain: present\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(fragmentPath, []byte("imported: present\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
destination := configkit.Keys("collections", "weatherreporter")
|
||||
result, err := configkit.LoadLayered[map[string]any](configkit.Input{
|
||||
Source: configkit.File(mainPath), Imports: true, Destination: destination,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("mounted import load: %v", err)
|
||||
}
|
||||
collection := facadeDestinationMap(t, result.Config, "collections", "weatherreporter")
|
||||
if collection["main"] != "present" || collection["imported"] != "present" {
|
||||
t.Fatalf("mounted imported values = %#v", collection)
|
||||
}
|
||||
sources := result.Sources()
|
||||
if len(sources) != 2 || sources[0].Destination.String() != destination.String() || sources[1].Destination.String() != destination.String() {
|
||||
t.Fatalf("mounted import source destinations = %#v", sources)
|
||||
}
|
||||
if sources[1].ImportedFrom == nil {
|
||||
t.Fatalf("imported source lacks its authored directive: %#v", sources[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeRelativeImportDestinationsForRootAndMountedLoads(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
write := func(relative, contents string) string {
|
||||
t.Helper()
|
||||
filename := filepath.Join(directory, 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
|
||||
}
|
||||
|
||||
mainPath := write("main.yml", `configkit:
|
||||
imports:
|
||||
- path: metadata.yml
|
||||
destination: [metadata]
|
||||
- glob: prompts/*.yml
|
||||
destination: [prompts]
|
||||
- path: entries/main.yml
|
||||
destination: [profiles]
|
||||
- path: sibling.yml
|
||||
destination: [sibling]
|
||||
root: main
|
||||
`)
|
||||
write("metadata.yml", "name: imported\n")
|
||||
write("prompts/a.yml", "a: {name: alpha}\n")
|
||||
write("prompts/b.yml", "b: {name: beta}\n")
|
||||
write("entries/main.yml", `configkit:
|
||||
imports:
|
||||
- path: nested/details.yml
|
||||
destination: [details]
|
||||
name: profile
|
||||
`)
|
||||
write("entries/nested/details.yml", "summary: nested\n")
|
||||
write("sibling.yml", "name: separate\n")
|
||||
|
||||
root, err := configkit.Load(
|
||||
configkit.File(mainPath), configkit.Options[map[string]any]{Imports: true},
|
||||
)
|
||||
if err != nil {
|
||||
if loadError, ok := err.(*configkit.LoadError); ok {
|
||||
t.Fatalf("ordinary root Load: %v, diagnostics %#v", err, loadError.Diagnostics())
|
||||
}
|
||||
t.Fatalf("ordinary root Load: %v", err)
|
||||
}
|
||||
mountedDestination := configkit.Keys("collections", "weatherreporter")
|
||||
mounted, err := configkit.LoadLayered[map[string]any](configkit.Input{
|
||||
Source: configkit.File(mainPath), Imports: true, Destination: mountedDestination,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("mounted layered Load: %v", err)
|
||||
}
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
result *configkit.Result[map[string]any]
|
||||
prefix []string
|
||||
}{
|
||||
{name: "ordinary root", result: root},
|
||||
{name: "mounted entry", result: mounted, prefix: []string{"collections", "weatherreporter"}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
at := func(keys ...string) []string {
|
||||
return append(append([]string(nil), test.prefix...), keys...)
|
||||
}
|
||||
if got := facadeDestinationValue(t, test.result.Config, at("root")...); got != "main" {
|
||||
t.Fatalf("root value = %#v", got)
|
||||
}
|
||||
if got := facadeDestinationValue(t, test.result.Config, at("metadata", "name")...); got != "imported" {
|
||||
t.Fatalf("metadata value = %#v", got)
|
||||
}
|
||||
if got := facadeDestinationValue(t, test.result.Config, at("prompts", "a", "name")...); got != "alpha" {
|
||||
t.Fatalf("first glob value = %#v", got)
|
||||
}
|
||||
if got := facadeDestinationValue(t, test.result.Config, at("prompts", "b", "name")...); got != "beta" {
|
||||
t.Fatalf("second glob value = %#v", got)
|
||||
}
|
||||
if got := facadeDestinationValue(t, test.result.Config, at("profiles", "name")...); got != "profile" {
|
||||
t.Fatalf("entry value = %#v", got)
|
||||
}
|
||||
if got := facadeDestinationValue(t, test.result.Config, at("profiles", "details", "summary")...); got != "nested" {
|
||||
t.Fatalf("nested import value = %#v", got)
|
||||
}
|
||||
if got := facadeDestinationValue(t, test.result.Config, at("sibling", "name")...); got != "separate" {
|
||||
t.Fatalf("subsequent sibling value = %#v", got)
|
||||
}
|
||||
|
||||
wantPaths := []string{
|
||||
"main.yml", "metadata.yml", "a.yml", "b.yml", "main.yml", "details.yml", "sibling.yml",
|
||||
}
|
||||
wantPhysicalPaths := []string{
|
||||
mainPath,
|
||||
filepath.Join(directory, "metadata.yml"),
|
||||
filepath.Join(directory, "prompts", "a.yml"),
|
||||
filepath.Join(directory, "prompts", "b.yml"),
|
||||
filepath.Join(directory, "entries", "main.yml"),
|
||||
filepath.Join(directory, "entries", "nested", "details.yml"),
|
||||
filepath.Join(directory, "sibling.yml"),
|
||||
}
|
||||
wantImportParents := []string{"", "source-1", "source-1", "source-1", "source-1", "source-5", "source-1"}
|
||||
wantDestinations := []configkit.Path{configkit.Keys(test.prefix...)}
|
||||
for _, suffix := range [][]string{{"metadata"}, {"prompts"}, {"prompts"}, {"profiles"}, {"profiles", "details"}, {"sibling"}} {
|
||||
destination := append([]string(nil), test.prefix...)
|
||||
for _, segment := range suffix {
|
||||
destination = append(destination, segment)
|
||||
}
|
||||
wantDestinations = append(wantDestinations, configkit.Keys(destination...))
|
||||
}
|
||||
sources := test.result.Sources()
|
||||
if len(sources) != len(wantPaths) {
|
||||
t.Fatalf("sources = %#v, want %d", sources, len(wantPaths))
|
||||
}
|
||||
for index, source := range sources {
|
||||
if filepath.Base(source.Path) != wantPaths[index] || source.Path != wantPhysicalPaths[index] || source.Destination.String() != wantDestinations[index].String() {
|
||||
t.Errorf("source %d path/destination = (%q, %q), want physical path %q and destination %q", index, source.Path, source.Destination.String(), wantPhysicalPaths[index], wantDestinations[index].String())
|
||||
}
|
||||
if source.ID != fmt.Sprintf("source-%d", index+1) || source.Kind != configkit.SourceKindFile {
|
||||
t.Errorf("source %d identity/kind = (%q, %q)", index, source.ID, source.Kind)
|
||||
}
|
||||
if wantImportParents[index] == "" {
|
||||
if source.ImportedFrom != nil {
|
||||
t.Errorf("entry source unexpectedly has an import location: %#v", source.ImportedFrom)
|
||||
}
|
||||
} else if source.ImportedFrom == nil || source.ImportedFrom.SourceID != wantImportParents[index] || source.ImportedFrom.Line == 0 || source.ImportedFrom.Column == 0 {
|
||||
t.Errorf("source %d import location = %#v, want declaration in %s", index, source.ImportedFrom, wantImportParents[index])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeSameFragmentMountsAtSeparateCollectionDestinations(t *testing.T) {
|
||||
weatherDestination := configkit.Keys("collections", "weather", "prompts")
|
||||
homeDestination := configkit.Keys("collections", "homewatch", "prompts")
|
||||
result, err := configkit.LoadLayered(
|
||||
configkit.Input{Source: configkit.NoSource()},
|
||||
configkit.LayeredOptions[map[string]any]{Underlays: []configkit.Underlay{{Inputs: []configkit.Input{
|
||||
{Source: configkit.Bytes("weather", []byte("summarize.alerts: {title: weather}\n")), Destination: weatherDestination},
|
||||
{Source: configkit.Bytes("homewatch", []byte("summarize.alerts: {title: home}\n")), Destination: homeDestination},
|
||||
}}}},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadLayered: %v", err)
|
||||
}
|
||||
for _, test := range []struct {
|
||||
path []string
|
||||
want string
|
||||
}{
|
||||
{path: []string{"collections", "weather", "prompts", "summarize.alerts", "title"}, want: "weather"},
|
||||
{path: []string{"collections", "homewatch", "prompts", "summarize.alerts", "title"}, want: "home"},
|
||||
} {
|
||||
if got := facadeDestinationValue(t, result.Config, test.path...); got != test.want {
|
||||
t.Errorf("value at %v = %#v, want %q", test.path, got, test.want)
|
||||
}
|
||||
}
|
||||
sources := result.Sources()
|
||||
if len(sources) != 2 || sources[0].Destination.String() != weatherDestination.String() || sources[1].Destination.String() != homeDestination.String() {
|
||||
t.Fatalf("independent collection source placements = %#v", sources)
|
||||
}
|
||||
}
|
||||
|
||||
type destinationCountingReader struct{ reads int }
|
||||
|
||||
func (reader *destinationCountingReader) Read([]byte) (int, error) {
|
||||
reader.reads++
|
||||
return 0, io.EOF
|
||||
}
|
||||
|
||||
func TestFacadeMountedMainAndUnderlayInspectionResourcesAndDiff(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
filesystem := configkit.NewFilesystem("resources", fstest.MapFS{
|
||||
"assets/summary.md": &fstest.MapFile{Data: []byte("source-relative summary")},
|
||||
})
|
||||
context := &configkit.FilesystemContext{Root: ".", BaseDir: "assets", FS: &filesystem}
|
||||
destination := configkit.Keys("catalog", "entries")
|
||||
tokenPath := configkit.Keys("catalog", "entries", "item", "token")
|
||||
titlePath := configkit.Keys("catalog", "entries", "item", "title")
|
||||
var callbacks struct{ resolve, validate int }
|
||||
load := func(token string) *configkit.Result[map[string]any] {
|
||||
t.Helper()
|
||||
main := configkit.Input{
|
||||
Source: configkit.Bytes("main", []byte("item:\n title: main\n resource: summary.md\n token: "+token+"\n")),
|
||||
Destination: destination, Filesystem: context,
|
||||
}
|
||||
underlay := configkit.Input{
|
||||
Source: configkit.Bytes("underlay", []byte("item:\n title: lower\n retained: lower-value\n token: LOWER-TOKEN-CANARY\n")),
|
||||
Destination: destination,
|
||||
}
|
||||
result, err := configkit.LoadLayered(main, configkit.LayeredOptions[map[string]any]{
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{underlay}}},
|
||||
Definition: configkit.Definition[map[string]any]{
|
||||
Resolve: func(configuration *map[string]any, resolution *configkit.Resolution) error {
|
||||
callbacks.resolve++
|
||||
resourcePath := configkit.Keys("catalog", "entries", "item", "resource")
|
||||
input, ok := resolution.InputInfo(resourcePath)
|
||||
if !ok || len(input.Origin.Locations) != 1 {
|
||||
return fmt.Errorf("mounted resource input has no single source origin")
|
||||
}
|
||||
source, ok := resolution.Source(input.Origin.Locations[0].SourceID)
|
||||
if !ok || source.Destination.String() != destination.String() {
|
||||
return fmt.Errorf("resolution source did not retain its destination")
|
||||
}
|
||||
resourceContext, ok := resolution.SourceContext(source.ID)
|
||||
if !ok {
|
||||
return fmt.Errorf("resolution source context is unavailable")
|
||||
}
|
||||
data, err := fs.ReadFile(resourceContext.FS, path.Join(resourceContext.BaseDir, "summary.md"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("read source-relative resource: %w", err)
|
||||
}
|
||||
entry := facadeDestinationMap(t, facadeDestinationMap(t, facadeDestinationMap(t, *configuration, "catalog"), "entries"), "item")
|
||||
entry["resource_text"] = string(data)
|
||||
resolution.Record(configkit.Keys("catalog", "entries", "item", "resource_text"), configkit.ChangeNormalization, "read source-relative summary", resourcePath)
|
||||
return nil
|
||||
},
|
||||
Validate: func(*map[string]any, *configkit.Validation) { callbacks.validate++ },
|
||||
Secret: func(candidate configkit.Path) bool { return candidate.String() == tokenPath.String() },
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("mounted load: %v", err)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
first := load("FIRST-TOKEN-CANARY")
|
||||
second := load("SECOND-TOKEN-CANARY")
|
||||
if callbacks.resolve != 2 || callbacks.validate != 2 {
|
||||
t.Fatalf("lifecycle callbacks = %#v", callbacks)
|
||||
}
|
||||
for _, result := range []*configkit.Result[map[string]any]{first, second} {
|
||||
entry := facadeDestinationMap(t, facadeDestinationMap(t, facadeDestinationMap(t, result.Config, "catalog"), "entries"), "item")
|
||||
if entry["title"] != "main" || entry["retained"] != "lower-value" || entry["resource_text"] != "source-relative summary" {
|
||||
t.Fatalf("mounted main/underlay configuration = %#v", entry)
|
||||
}
|
||||
sources := result.Sources()
|
||||
if len(sources) != 2 || sources[0].Destination.String() != destination.String() || sources[1].Destination.String() != destination.String() {
|
||||
t.Fatalf("public source destinations = %#v", sources)
|
||||
}
|
||||
resource, ok := result.SourceContext(sources[0].ID)
|
||||
if !ok || resource.BaseDir != "assets" {
|
||||
t.Fatalf("completed source context = %#v, %v", resource, ok)
|
||||
}
|
||||
data, err := fs.ReadFile(resource.FS, path.Join(resource.BaseDir, "summary.md"))
|
||||
if err != nil || string(data) != "source-relative summary" {
|
||||
t.Fatalf("completed relative resource = %q, %v", data, err)
|
||||
}
|
||||
if info, ok := result.Metadata().Lookup(titlePath); !ok || info.Origin.Layer != configkit.LayerBase {
|
||||
t.Fatalf("mounted title metadata = %#v, %v", info, ok)
|
||||
}
|
||||
if history := result.Metadata().History(titlePath); len(history) < 2 {
|
||||
t.Fatalf("mounted title history = %#v", history)
|
||||
}
|
||||
if explanation, ok := result.Explain(tokenPath); !ok || !explanation.Effective.Redacted {
|
||||
t.Fatalf("mounted secret explanation = %#v, %v", explanation, ok)
|
||||
}
|
||||
var effective, explanation bytes.Buffer
|
||||
if err := result.WriteEffective(&effective); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := result.WriteExplanation(&explanation, tokenPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
output := effective.String() + explanation.String()
|
||||
for _, canary := range []string{"FIRST-TOKEN-CANARY", "SECOND-TOKEN-CANARY", "LOWER-TOKEN-CANARY"} {
|
||||
if strings.Contains(output, canary) {
|
||||
t.Fatalf("public inspection leaked %q: %s", canary, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mutating published records and query copies must not alter the retained result.
|
||||
returnedSources := first.Sources()
|
||||
returnedSources[0].Destination = configkit.Keys("mutated")
|
||||
if got := first.Sources()[0].Destination.String(); got != destination.String() {
|
||||
t.Fatalf("source destination changed after caller mutation: %q", got)
|
||||
}
|
||||
info, ok := first.Metadata().Lookup(titlePath)
|
||||
if !ok {
|
||||
t.Fatal("mounted metadata lookup failed")
|
||||
}
|
||||
info.Origin.Locations[0].SourceID = "mutated"
|
||||
if fresh, ok := first.Metadata().Lookup(titlePath); !ok || fresh.Origin.Locations[0].SourceID == "mutated" {
|
||||
t.Fatalf("metadata copy mutation escaped: %#v, %v", fresh, ok)
|
||||
}
|
||||
history := first.Metadata().History(titlePath)
|
||||
if len(history) == 0 {
|
||||
t.Fatal("mounted history is empty")
|
||||
}
|
||||
history[0].Path = configkit.Keys("mutated")
|
||||
if fresh := first.Metadata().History(titlePath); len(fresh) == 0 || fresh[0].Path.String() != titlePath.String() {
|
||||
t.Fatalf("history copy mutation escaped: %#v", fresh)
|
||||
}
|
||||
|
||||
differences, err := configkit.Diff(first, second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(differences) != 1 || differences[0].Path.String() != tokenPath.String() ||
|
||||
!differences[0].Left.Redacted || !differences[0].Right.Redacted ||
|
||||
strings.Contains(differences[0].Left.YAML+differences[0].Right.YAML, "TOKEN-CANARY") {
|
||||
t.Fatalf("public mounted secret diff = %#v", differences)
|
||||
}
|
||||
if callbacks.resolve != 2 || callbacks.validate != 2 {
|
||||
t.Fatalf("inspection reran lifecycle callbacks: %#v", callbacks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeFailedLoadExposesDetachedSourceDestination(t *testing.T) {
|
||||
type configuration struct {
|
||||
Tenant struct {
|
||||
Count int `yaml:"count"`
|
||||
} `yaml:"tenant"`
|
||||
}
|
||||
destination := configkit.Keys("tenant")
|
||||
_, err := configkit.LoadLayered[configuration](configkit.Input{
|
||||
Source: configkit.Bytes("invalid", []byte("count: not-an-integer\n")), Destination: destination,
|
||||
})
|
||||
loadError, ok := err.(*configkit.LoadError)
|
||||
if !ok {
|
||||
t.Fatalf("failed load error = %T %v", err, err)
|
||||
}
|
||||
sources := loadError.Sources()
|
||||
if len(sources) != 1 || sources[0].Destination.String() != destination.String() {
|
||||
t.Fatalf("failed source destinations = %#v", sources)
|
||||
}
|
||||
sources[0].Destination = configkit.Keys("caller-change")
|
||||
if fresh := loadError.Sources(); len(fresh) != 1 || fresh[0].Destination.String() != destination.String() {
|
||||
t.Fatalf("failed source destination was not detached: %#v", fresh)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeRelativeImportFailuresRetainSourceRecordsAndCoordinates(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
write := func(name, contents string) string {
|
||||
t.Helper()
|
||||
filename := filepath.Join(directory, name)
|
||||
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
|
||||
}
|
||||
|
||||
t.Run("imported parse failure retains effective source destination", func(t *testing.T) {
|
||||
mainPath := write("parse/main.yml", "configkit:\n imports:\n - path: child.yml\n destination: [tenant]\n")
|
||||
childPath := write("parse/child.yml", "count: [unterminated\n")
|
||||
_, err := configkit.LoadLayered[map[string]any](configkit.Input{
|
||||
Source: configkit.File(mainPath), Imports: true, Destination: configkit.Keys("mount"),
|
||||
})
|
||||
var loadError *configkit.LoadError
|
||||
if !errors.As(err, &loadError) {
|
||||
t.Fatalf("load error = %T %v", err, err)
|
||||
}
|
||||
diagnostics := loadError.Diagnostics()
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeYAMLInvalid || diagnostics[0].Primary == nil || diagnostics[0].Primary.SourceID != "source-2" {
|
||||
t.Fatalf("imported parse diagnostic = %#v", diagnostics)
|
||||
}
|
||||
sources := loadError.Sources()
|
||||
if len(sources) != 2 || sources[0].Destination.String() != "/mount" || sources[1].Destination.String() != "/mount/tenant" || sources[1].Path != childPath {
|
||||
t.Fatalf("imported parse failure sources = %#v", sources)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("directive failure stays source local", func(t *testing.T) {
|
||||
mainPath := write("directive/main.yml", "configkit:\n imports:\n - path: parent.yml\n destination: [tenant]\n")
|
||||
parentPath := write("directive/parent.yml", "configkit:\n imports:\n - path: details.yml\n destination: [3]\n")
|
||||
_, err := configkit.LoadLayered[map[string]any](configkit.Input{
|
||||
Source: configkit.File(mainPath), Imports: true, Destination: configkit.Keys("mount"),
|
||||
})
|
||||
var loadError *configkit.LoadError
|
||||
if !errors.As(err, &loadError) {
|
||||
t.Fatalf("load error = %T %v", err, err)
|
||||
}
|
||||
diagnostics := loadError.Diagnostics()
|
||||
wantPath := configkit.Keys("configkit", "imports").Index(0).Key("destination").Index(0)
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeImportInvalid || !reflect.DeepEqual(diagnostics[0].Path, wantPath) || diagnostics[0].Primary == nil || diagnostics[0].Primary.SourceID != "source-2" {
|
||||
t.Fatalf("import directive diagnostic = %#v", diagnostics)
|
||||
}
|
||||
sources := loadError.Sources()
|
||||
if len(sources) != 2 || sources[0].Destination.String() != "/mount" || sources[1].Destination.String() != "/mount/tenant" || sources[1].Path != parentPath {
|
||||
t.Fatalf("directive failure sources = %#v", sources)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("composition failure reports effective path and all sources", func(t *testing.T) {
|
||||
mainPath := write("compose/main.yml", "configkit:\n imports:\n - path: left.yml\n destination: [tenant]\n - path: right.yml\n destination: [tenant]\n")
|
||||
write("compose/left.yml", "value: same\n")
|
||||
write("compose/right.yml", "value: same\n")
|
||||
_, err := configkit.LoadLayered[map[string]any](configkit.Input{
|
||||
Source: configkit.File(mainPath), Imports: true, Destination: configkit.Keys("mount"),
|
||||
})
|
||||
var loadError *configkit.LoadError
|
||||
if !errors.As(err, &loadError) {
|
||||
t.Fatalf("load error = %T %v", err, err)
|
||||
}
|
||||
diagnostics := loadError.Diagnostics()
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeComposeConflict || diagnostics[0].Path.String() != "/mount/tenant/value" {
|
||||
t.Fatalf("composition diagnostic = %#v", diagnostics)
|
||||
}
|
||||
sources := loadError.Sources()
|
||||
if len(sources) != 3 || sources[0].Destination.String() != "/mount" || sources[1].Destination.String() != "/mount/tenant" || sources[2].Destination.String() != "/mount/tenant" {
|
||||
t.Fatalf("composition failure sources = %#v", sources)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("version failure reports effective path and imported source", func(t *testing.T) {
|
||||
mainPath := write("version/main.yml", "configkit:\n imports:\n - path: child.yml\n destination: [tenant]\n")
|
||||
childPath := write("version/child.yml", "version: forbidden\n")
|
||||
_, err := configkit.LoadLayered[map[string]any](configkit.Input{
|
||||
Source: configkit.File(mainPath), Imports: true, Destination: configkit.Keys("mount"),
|
||||
}, configkit.LayeredOptions[map[string]any]{
|
||||
Definition: configkit.Definition[map[string]any]{Version: &configkit.VersionRule{
|
||||
Path: configkit.Keys("mount", "tenant", "version"),
|
||||
Check: func(configkit.Version, *configkit.Validation) error { return nil },
|
||||
}},
|
||||
})
|
||||
var loadError *configkit.LoadError
|
||||
if !errors.As(err, &loadError) {
|
||||
t.Fatalf("load error = %T %v", err, err)
|
||||
}
|
||||
diagnostics := loadError.Diagnostics()
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeImportVersion || diagnostics[0].Path.String() != "/mount/tenant/version" || diagnostics[0].Primary == nil || diagnostics[0].Primary.SourceID != "source-2" {
|
||||
t.Fatalf("version diagnostic = %#v", diagnostics)
|
||||
}
|
||||
sources := loadError.Sources()
|
||||
if len(sources) != 2 || sources[0].Destination.String() != "/mount" || sources[1].Destination.String() != "/mount/tenant" || sources[1].Path != childPath {
|
||||
t.Fatalf("version failure sources = %#v", sources)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("typed validation reports effective path and authored location", func(t *testing.T) {
|
||||
type configuration struct {
|
||||
Mount struct {
|
||||
Tenant struct {
|
||||
Count int `yaml:"count"`
|
||||
} `yaml:"tenant"`
|
||||
} `yaml:"mount"`
|
||||
}
|
||||
mainPath := write("typed/main.yml", "configkit:\n imports:\n - path: child.yml\n destination: [tenant]\n")
|
||||
childPath := write("typed/child.yml", "count: invalid\n")
|
||||
_, err := configkit.LoadLayered[configuration](configkit.Input{
|
||||
Source: configkit.File(mainPath), Imports: true, Destination: configkit.Keys("mount"),
|
||||
})
|
||||
var loadError *configkit.LoadError
|
||||
if !errors.As(err, &loadError) {
|
||||
t.Fatalf("load error = %T %v", err, err)
|
||||
}
|
||||
diagnostics := loadError.Diagnostics()
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeDecodeType || diagnostics[0].Path.String() != "/mount/tenant/count" || diagnostics[0].Primary == nil || diagnostics[0].Primary.SourceID != "source-2" || diagnostics[0].Primary.Line != 1 {
|
||||
t.Fatalf("typed validation diagnostic = %#v", diagnostics)
|
||||
}
|
||||
sources := loadError.Sources()
|
||||
if len(sources) != 2 || sources[0].Destination.String() != "/mount" || sources[1].Destination.String() != "/mount/tenant" || sources[1].Path != childPath {
|
||||
t.Fatalf("typed validation sources = %#v", sources)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestFacadeDestinationMappingFailurePaths(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cycle := map[string]any{}
|
||||
cycle["self"] = cycle
|
||||
for _, fixture := range []struct {
|
||||
name string
|
||||
values map[string]any
|
||||
path func(configkit.Path) configkit.Path
|
||||
}{
|
||||
{
|
||||
name: "unsupported nested sequence value",
|
||||
values: map[string]any{"a/b": []any{map[string]any{"~": func() {}}}},
|
||||
path: func(base configkit.Path) configkit.Path { return base.Key("a/b").Index(0).Key("~") },
|
||||
},
|
||||
{
|
||||
name: "cyclic value",
|
||||
values: map[string]any{"item": cycle},
|
||||
path: func(base configkit.Path) configkit.Path { return base.Key("item").Key("self") },
|
||||
},
|
||||
{
|
||||
name: "constructor failure keeps source coordinates",
|
||||
path: func(configkit.Path) configkit.Path { return configkit.Path{} },
|
||||
},
|
||||
} {
|
||||
t.Run(fixture.name, func(t *testing.T) {
|
||||
for _, role := range []string{"root", "mounted main", "mounted underlay"} {
|
||||
t.Run(role, func(t *testing.T) {
|
||||
var destination configkit.Path
|
||||
if role != "root" {
|
||||
destination = configkit.Keys("catalog", "entries")
|
||||
}
|
||||
wantPath := fixture.path(destination)
|
||||
input := configkit.Input{Source: configkit.Mapping("invalid mapping", fixture.values), Destination: destination}
|
||||
var result *configkit.Result[map[string]any]
|
||||
var err error
|
||||
if role == "mounted underlay" {
|
||||
result, err = configkit.LoadLayered(configkit.Input{Source: configkit.NoSource()}, configkit.LayeredOptions[map[string]any]{
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{input}}},
|
||||
})
|
||||
} else {
|
||||
result, err = configkit.LoadLayered[map[string]any](input)
|
||||
}
|
||||
var loadError *configkit.LoadError
|
||||
if result != nil || !errors.As(err, &loadError) {
|
||||
t.Fatalf("failed load = %#v, %v", result, err)
|
||||
}
|
||||
diagnostics := loadError.Diagnostics()
|
||||
if len(diagnostics) != 1 {
|
||||
t.Fatalf("diagnostics = %#v", diagnostics)
|
||||
}
|
||||
diagnostic := diagnostics[0]
|
||||
if diagnostic.Code != configkit.CodeSourceInvalid || !reflect.DeepEqual(diagnostic.Path, wantPath) {
|
||||
t.Fatalf("diagnostic = %#v; want source.invalid at %q", diagnostic, wantPath.String())
|
||||
}
|
||||
if diagnostic.Primary == nil || *diagnostic.Primary != (configkit.Location{SourceID: "source-1"}) || len(diagnostic.Related) != 0 {
|
||||
t.Fatalf("mapping source locations = %#v, %#v", diagnostic.Primary, diagnostic.Related)
|
||||
}
|
||||
sources := loadError.Sources()
|
||||
if len(sources) != 1 || sources[0].ID != "source-1" || sources[0].Destination.String() != destination.String() {
|
||||
t.Fatalf("error sources = %#v", sources)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func facadeDestinationMap(t *testing.T, value map[string]any, keys ...string) map[string]any {
|
||||
t.Helper()
|
||||
for _, key := range keys {
|
||||
child, ok := value[key].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("%q = %#v, want mapping", key, value[key])
|
||||
}
|
||||
value = child
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func facadeDestinationValue(t *testing.T, value map[string]any, keys ...string) any {
|
||||
t.Helper()
|
||||
return facadeDestinationMap(t, value, keys[:len(keys)-1]...)[keys[len(keys)-1]]
|
||||
}
|
||||
327
facade_diff_test.go
Normal file
327
facade_diff_test.go
Normal file
@@ -0,0 +1,327 @@
|
||||
package configkit_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
type facadeDiffConfig struct {
|
||||
Value string `yaml:"value"`
|
||||
Count int `yaml:"count"`
|
||||
Items []string `yaml:"items"`
|
||||
Dynamic map[string]any `yaml:"dynamic"`
|
||||
}
|
||||
|
||||
type facadeDiffSecretConfig struct {
|
||||
Secret configkit.Secret `yaml:"secret"`
|
||||
}
|
||||
|
||||
func TestFacadeDiffKeepsPublicSequenceChangesWithHistoricalDescendants(t *testing.T) {
|
||||
type configuration struct {
|
||||
Items []any `yaml:"items"`
|
||||
}
|
||||
load := func(public string) *configkit.Result[configuration] {
|
||||
t.Helper()
|
||||
result, err := configkit.Load(configkit.Bytes("historical-sequence.yml", []byte("items: [{secret: disposable-canary}, "+public+"]\n")),
|
||||
configkit.Options[configuration]{Definition: configkit.Definition[configuration]{
|
||||
Secret: func(path configkit.Path) bool { return path.String() == "/items/0/secret" },
|
||||
Resolve: func(current *configuration, resolution *configkit.Resolution) error {
|
||||
current.Items[0] = "public scalar"
|
||||
resolution.Record(configkit.Keys("items"), configkit.ChangeNormalization, "normalize first item")
|
||||
return nil
|
||||
},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return result
|
||||
}
|
||||
left, right := load("1"), load("2")
|
||||
history, ok := left.Explain(configkit.Keys("items").Index(0).Key("secret"))
|
||||
if !ok || history.Effective.Present || len(history.History) == 0 {
|
||||
t.Fatalf("removed historical secret = %#v, known=%v", history, ok)
|
||||
}
|
||||
maskedHistory := false
|
||||
for _, change := range history.History {
|
||||
maskedHistory = maskedHistory || change.Before.Redacted || change.After.Redacted
|
||||
}
|
||||
if !maskedHistory {
|
||||
t.Fatal("historical sensitivity was not retained")
|
||||
}
|
||||
differences, err := configkit.Diff(left, right)
|
||||
if err != nil || len(differences) != 1 || differences[0].Path.String() != "/items" {
|
||||
t.Fatalf("sequence difference = %#v, %v", differences, err)
|
||||
}
|
||||
difference := differences[0]
|
||||
if !difference.Left.Present || !difference.Right.Present || difference.Left.Redacted || difference.Right.Redacted || difference.HiddenChanged {
|
||||
t.Fatalf("public sequence change was concealed: %#v", difference)
|
||||
}
|
||||
if difference.Left.YAML != "- public scalar\n- 1\n" || difference.Right.YAML != "- public scalar\n- 2\n" {
|
||||
t.Fatalf("public sequence snapshots = %#v", difference)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeDiffKeepsPublicScalarAcrossHistoricalAbsence(t *testing.T) {
|
||||
type configuration struct {
|
||||
Dynamic map[string]any `yaml:"dynamic"`
|
||||
}
|
||||
options := configkit.Options[configuration]{Definition: configkit.Definition[configuration]{
|
||||
Secret: func(path configkit.Path) bool { return path.String() == "/dynamic/entry/token" },
|
||||
Resolve: func(current *configuration, resolution *configkit.Resolution) error {
|
||||
delete(current.Dynamic, "entry")
|
||||
resolution.Record(configkit.Keys("dynamic", "entry"), configkit.ChangeNormalization, "remove entry")
|
||||
return nil
|
||||
},
|
||||
}}
|
||||
absent, err := configkit.Load(configkit.Bytes("removed-entry.yml", []byte("dynamic: {entry: {token: disposable-canary}}\n")), options)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
options.Definition.Resolve = nil
|
||||
present, err := configkit.Load(configkit.Bytes("public-entry.yml", []byte("dynamic: {entry: public}\n")), options)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
left, right *configkit.Result[configuration]
|
||||
leftPresent bool
|
||||
}{
|
||||
{name: "addition", left: absent, right: present},
|
||||
{name: "removal", left: present, right: absent, leftPresent: true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
differences, err := configkit.Diff(test.left, test.right)
|
||||
if err != nil || len(differences) != 1 || differences[0].Path.String() != "/dynamic/entry" {
|
||||
t.Fatalf("entry difference = %#v, %v", differences, err)
|
||||
}
|
||||
difference := differences[0]
|
||||
if difference.Left.Present != test.leftPresent || difference.Right.Present == test.leftPresent ||
|
||||
difference.Left.Redacted || difference.Right.Redacted || difference.HiddenChanged {
|
||||
t.Fatalf("public presence change was concealed: %#v", difference)
|
||||
}
|
||||
wantLeft, wantRight := "", "public\n"
|
||||
if test.leftPresent {
|
||||
wantLeft, wantRight = wantRight, wantLeft
|
||||
}
|
||||
if difference.Left.YAML != wantLeft || difference.Right.YAML != wantRight {
|
||||
t.Fatalf("entry snapshots = %#v", difference)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeDiffReturnsDetachedCanonicalRecords(t *testing.T) {
|
||||
left, err := configkit.Load[facadeDiffConfig](configkit.Bytes("left.yml", []byte("value: before\ncount: 0\nitems: [one, two]\ndynamic: {}\n")))
|
||||
if err != nil {
|
||||
t.Fatalf("load left: %v", err)
|
||||
}
|
||||
right, err := configkit.Load[facadeDiffConfig](configkit.Bytes("right.yml", []byte("value: after\ncount: 0\nitems: [one, three]\ndynamic:\n added: null\n")))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
differences, err := configkit.Diff(left, right)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantPaths := []string{"/dynamic/added", "/items", "/value"}
|
||||
if len(differences) != len(wantPaths) {
|
||||
t.Fatalf("differences = %#v", differences)
|
||||
}
|
||||
for index, difference := range differences {
|
||||
if got := difference.Path.String(); got != wantPaths[index] {
|
||||
t.Errorf("path[%d] = %q, want %q", index, got, wantPaths[index])
|
||||
}
|
||||
}
|
||||
if differences[0].Left.Present || !differences[0].Right.Present || differences[0].Right.YAML != "null\n" {
|
||||
t.Errorf("null/absence snapshots = %#v / %#v", differences[0].Left, differences[0].Right)
|
||||
}
|
||||
if differences[1].Left.YAML != "- one\n- two\n" || differences[1].Right.YAML != "- one\n- three\n" {
|
||||
t.Errorf("sequence snapshots = %#v / %#v", differences[1].Left, differences[1].Right)
|
||||
}
|
||||
|
||||
left.Config.Value = "caller mutation"
|
||||
left.Config.Items[0] = "caller mutation"
|
||||
right.Config.Dynamic["added"] = "caller mutation"
|
||||
differences[2].Left.YAML = "record mutation"
|
||||
differences[2].Path = configkit.Keys("rewritten")
|
||||
|
||||
again, err := configkit.Diff(left, right)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if again[2].Path.String() != "/value" || again[2].Left.YAML != "before\n" || again[1].Left.YAML != "- one\n- two\n" {
|
||||
t.Fatalf("repeat diff changed = %#v", again)
|
||||
}
|
||||
explanation, ok := left.Explain(configkit.Keys("value"))
|
||||
if !ok || explanation.Effective.YAML != "before\n" {
|
||||
t.Fatalf("frozen explanation = %#v, %v", explanation, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeDiffRejectsUnavailableResults(t *testing.T) {
|
||||
type config struct {
|
||||
Value string `yaml:"value"`
|
||||
}
|
||||
loaded, err := configkit.Load[config](configkit.Bytes("valid.yml", []byte("value: valid\n")))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var nilResult *configkit.Result[config]
|
||||
zeroResult := &configkit.Result[config]{Config: config{Value: "must not be inspected"}}
|
||||
for name, left := range map[string]*configkit.Result[config]{"nil": nilResult, "zero": zeroResult} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if records, err := configkit.Diff(left, loaded); err == nil || records != nil {
|
||||
t.Fatalf("Diff = %#v, %v; want nil records and error", records, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
if records, err := configkit.Diff(loaded, nilResult); err == nil || records != nil {
|
||||
t.Fatalf("right nil Diff = %#v, %v", records, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeDiffIgnoresSourceAndResolutionHistory(t *testing.T) {
|
||||
type config struct {
|
||||
Count int `yaml:"count"`
|
||||
}
|
||||
defaults := configkit.Options[config]{Definition: configkit.Definition[config]{
|
||||
Defaults: func() config { return config{Count: 7} },
|
||||
}}
|
||||
defaulted, err := configkit.Load[config](configkit.NoSource(), defaults)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
explicit, err := configkit.Load[config](configkit.Bytes("explicit.yml", []byte("count: 7\n")))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if differences, err := configkit.Diff(defaulted, explicit); err != nil || len(differences) != 0 {
|
||||
t.Fatalf("default/source difference = %#v, %v", differences, err)
|
||||
}
|
||||
|
||||
zeroDefault, err := configkit.Load[config](configkit.NoSource(), configkit.Options[config]{Definition: configkit.Definition[config]{Defaults: func() config { return config{} }}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
explicitZero, err := configkit.Load[config](configkit.Bytes("explicit-zero.yml", []byte("count: 0\n")))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if differences, err := configkit.Diff(zeroDefault, explicitZero); err != nil || len(differences) != 0 {
|
||||
t.Fatalf("explicit/defaulted zero difference = %#v, %v", differences, err)
|
||||
}
|
||||
|
||||
resolve := func(value string) configkit.Options[config] {
|
||||
return configkit.Options[config]{Definition: configkit.Definition[config]{Resolve: func(current *config, resolution *configkit.Resolution) error {
|
||||
current.Count = len(value) + 8
|
||||
resolution.Record(configkit.Keys("count"), configkit.ChangeNormalization, "set effective count")
|
||||
return nil
|
||||
}}}
|
||||
}
|
||||
resolvedA, err := configkit.Load[config](configkit.Bytes("a.yml", []byte("{}\n")), resolve("a"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resolvedB, err := configkit.Load[config](configkit.Bytes("b.yml", []byte("{}\n")), resolve("b"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if differences, err := configkit.Diff(resolvedA, resolvedB); err != nil || len(differences) != 0 {
|
||||
t.Fatalf("equal resolved values = %#v, %v", differences, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeDiffUsesFrozenFilesAndRunsCallbacksOnlyDuringLoad(t *testing.T) {
|
||||
type config struct {
|
||||
Value string `yaml:"value"`
|
||||
}
|
||||
calls := 0
|
||||
options := configkit.Options[config]{Definition: configkit.Definition[config]{Defaults: func() config {
|
||||
calls++
|
||||
return config{}
|
||||
}}}
|
||||
dir := t.TempDir()
|
||||
paths := []string{filepath.Join(dir, "left.yml"), filepath.Join(dir, "right.yml")}
|
||||
for index, contents := range []string{"value: left\n", "value: right\n"} {
|
||||
if err := os.WriteFile(paths[index], []byte(contents), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
left, err := configkit.Load[config](configkit.File(paths[0]), options)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
right, err := configkit.Load[config](configkit.File(paths[1]), options)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Fatalf("defaults calls after loads = %d", calls)
|
||||
}
|
||||
for _, path := range paths {
|
||||
if err := os.Remove(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
differences, err := configkit.Diff(left, right)
|
||||
if err != nil || len(differences) != 1 || differences[0].Path.String() != "/value" {
|
||||
t.Fatalf("Diff after removing files = %#v, %v", differences, err)
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Fatalf("defaults reran during Diff: %d calls", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeDiffCombinesSecretSensitivityAndLoadErrors(t *testing.T) {
|
||||
t.Setenv("FACADE_DIFF_SECRET", "credential-one")
|
||||
secretOptions := configkit.Options[facadeDiffSecretConfig]{Definition: configkit.Definition[facadeDiffSecretConfig]{Secret: func(path configkit.Path) bool {
|
||||
return path.String() == "/secret"
|
||||
}}}
|
||||
left, err := configkit.Load[facadeDiffSecretConfig](configkit.Bytes("left.yml", []byte("secret: FACADE_DIFF_SECRET\n")), secretOptions)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("FACADE_DIFF_SECRET", "credential-two")
|
||||
right, err := configkit.Load[facadeDiffSecretConfig](configkit.Bytes("right.yml", []byte("secret: FACADE_DIFF_SECRET\n")), secretOptions)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if differences, err := configkit.Diff(left, right); err != nil || len(differences) != 0 {
|
||||
t.Fatalf("credential rotation = %#v, %v", differences, err)
|
||||
}
|
||||
t.Setenv("OTHER_FACADE_DIFF_SECRET", "other-credential")
|
||||
otherReference, err := configkit.Load[facadeDiffSecretConfig](configkit.Bytes("other.yml", []byte("secret: OTHER_FACADE_DIFF_SECRET\n")), secretOptions)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
differences, err := configkit.Diff(left, otherReference)
|
||||
if err != nil || len(differences) != 1 || !differences[0].HiddenChanged || !differences[0].Left.Redacted || !differences[0].Right.Redacted {
|
||||
t.Fatalf("reference change = %#v, %v", differences, err)
|
||||
}
|
||||
if strings.Contains(differences[0].Left.YAML+differences[0].Right.YAML, "credential") {
|
||||
t.Fatalf("credential leaked: %#v", differences)
|
||||
}
|
||||
|
||||
invalidLeft, leftErr := configkit.Load[facadeDiffSecretConfig](configkit.Bytes("invalid-left.yml", []byte("unknown: true\n")))
|
||||
if invalidLeft != nil || leftErr == nil {
|
||||
t.Fatalf("invalid left load = %#v, %v", invalidLeft, leftErr)
|
||||
}
|
||||
var loadError *configkit.LoadError
|
||||
if !errors.As(leftErr, &loadError) {
|
||||
t.Fatalf("left load error = %T, want *configkit.LoadError", leftErr)
|
||||
}
|
||||
invalidRight, rightErr := configkit.Load[facadeDiffSecretConfig](configkit.Bytes("invalid-right.yml", []byte("unknown: true\n")))
|
||||
if invalidRight != nil || rightErr == nil {
|
||||
t.Fatalf("invalid right load = %#v, %v", invalidRight, rightErr)
|
||||
}
|
||||
if !errors.As(rightErr, &loadError) {
|
||||
t.Fatalf("right load error = %T, want *configkit.LoadError", rightErr)
|
||||
}
|
||||
}
|
||||
316
facade_discovery_test.go
Normal file
316
facade_discovery_test.go
Normal file
@@ -0,0 +1,316 @@
|
||||
package configkit
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
)
|
||||
|
||||
type facadeDiscoveryConfig struct {
|
||||
Value string `yaml:"value"`
|
||||
Settings map[string]string `yaml:"settings"`
|
||||
}
|
||||
|
||||
type facadeDiscoveryLifecycleConfig struct {
|
||||
Value string `yaml:"value"`
|
||||
Resolved string `yaml:"resolved"`
|
||||
}
|
||||
|
||||
type facadeDiscoveryPlacementConfig struct {
|
||||
Items []facadeDiscoveryPlacementItem `yaml:"items"`
|
||||
}
|
||||
|
||||
type facadeDiscoveryPlacementItem struct {
|
||||
Name string `yaml:"name"`
|
||||
}
|
||||
|
||||
func TestFacadeSearchPresetUsesCurrentWorkingDirectory(t *testing.T) {
|
||||
original, err := os.Getwd()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
root := t.TempDir()
|
||||
if err := os.Chdir(root); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
if err := os.Chdir(original); err != nil {
|
||||
t.Errorf("restore working directory: %v", err)
|
||||
}
|
||||
})
|
||||
writeFacadeImportFile(t, root, "myapp.yml", "value: working\n")
|
||||
|
||||
result, err := Load[facadeDiscoveryConfig](SearchConfig("myapp", WorkingDirectory()))
|
||||
if err != nil || result.Config.Value != "working" {
|
||||
t.Fatalf("working-directory search = %#v, %v", result, err)
|
||||
}
|
||||
if sources := result.Sources(); len(sources) != 1 || sources[0].Path != canonicalFacadePath(t, filepath.Join(root, "myapp.yml")) {
|
||||
t.Fatalf("working-directory sources = %#v", sources)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeSearchCustomGenericNamesAndEmptyFilenameSelection(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
filename := writeFacadeImportFile(t, root, "config.yml", "value: found\n")
|
||||
custom := SearchDirectory(root, DirectorySearchOptions{
|
||||
Filenames: []FilenameGroup{CustomFilenames("config.yml")},
|
||||
})
|
||||
result, err := Load[facadeDiscoveryConfig](SearchConfig("", custom))
|
||||
if err != nil || result.Config.Value != "found" {
|
||||
t.Fatalf("custom generic-only search with empty slug = %#v, %v", result, err)
|
||||
}
|
||||
if sources := result.Sources(); len(sources) != 1 || sources[0].Path != canonicalFacadePath(t, filename) {
|
||||
t.Fatalf("custom generic-only sources = %#v", sources)
|
||||
}
|
||||
|
||||
defaults := func() facadeDiscoveryConfig { return facadeDiscoveryConfig{Value: "default"} }
|
||||
empty := SearchDirectory(root, DirectorySearchOptions{Filenames: []FilenameGroup{}})
|
||||
absent, err := Load(SearchConfig("myapp", empty), Options[facadeDiscoveryConfig]{
|
||||
Definition: Definition[facadeDiscoveryConfig]{Defaults: defaults},
|
||||
})
|
||||
if err != nil || absent.Config.Value != "default" || len(absent.Sources()) != 0 {
|
||||
t.Fatalf("explicit empty filename selection = %#v, %v", absent, err)
|
||||
}
|
||||
omitted, err := Load[facadeDiscoveryConfig](SearchConfig("myapp", SearchDirectory(root)))
|
||||
if err != nil || omitted.Config.Value != "found" {
|
||||
t.Fatalf("omitted filename selection did not use defaults = %#v, %v", omitted, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeSearchOrderingRequirednessAndSnapshots(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
selectedPath := writeFacadeImportFile(t, root, "config.yml", "value: selected\n")
|
||||
laterPath := writeFacadeImportFile(t, root, "later.yml", "value: later\n")
|
||||
paths := []string{filepath.Join(root, "missing.yml")}
|
||||
names := []string{"config.yml"}
|
||||
groups := []FilenameGroup{CustomFilenames(names...)}
|
||||
directoryOptions := []DirectorySearchOptions{{Filenames: groups}}
|
||||
search := Searches(Paths(paths...), SearchDirectory(root, directoryOptions...), Paths(laterPath))
|
||||
source := SearchConfig("", search, SearchOptions{Required: true})
|
||||
|
||||
paths[0] = laterPath
|
||||
names[0] = "later.yml"
|
||||
groups[0] = CustomFilenames("later.yml")
|
||||
directoryOptions[0].Filenames[0] = CustomFilenames("later.yml")
|
||||
search = Paths(laterPath)
|
||||
|
||||
result, err := Load[facadeDiscoveryConfig](source)
|
||||
if err != nil || result.Config.Value != "selected" {
|
||||
t.Fatalf("ordered required search after caller mutation = %#v, %v", result, err)
|
||||
}
|
||||
if sources := result.Sources(); len(sources) != 1 || sources[0].Path != canonicalFacadePath(t, selectedPath) {
|
||||
t.Fatalf("ordered search sources = %#v", sources)
|
||||
}
|
||||
|
||||
_, err = Load[facadeDiscoveryConfig](SearchConfig("", Paths(filepath.Join(root, "missing.yml")), SearchOptions{Required: true}))
|
||||
var loadErr *LoadError
|
||||
if !errors.As(err, &loadErr) {
|
||||
t.Fatalf("required exhausted search error = %v, want *LoadError", err)
|
||||
}
|
||||
foundMissing := false
|
||||
for _, diagnostic := range loadErr.Diagnostics() {
|
||||
foundMissing = foundMissing || diagnostic.Code == CodeSourceMissing
|
||||
}
|
||||
if !foundMissing {
|
||||
t.Fatalf("required exhausted search diagnostics = %#v", loadErr.Diagnostics())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeSearchWorksForLayeredMainAndUnderlay(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
mainPath := writeFacadeImportFile(t, root, "main.yml", "settings: {main: value}\n")
|
||||
underlayPath := writeFacadeImportFile(t, root, "underlay/base.yml", "settings: {underlay: value}\n")
|
||||
result, err := LoadLayered(
|
||||
Input{Source: SearchConfig("", Paths(mainPath))},
|
||||
LayeredOptions[facadeDiscoveryConfig]{Underlays: []Underlay{{Inputs: []Input{{
|
||||
Source: SearchConfig("", SearchDirectory(filepath.Dir(underlayPath), DirectorySearchOptions{
|
||||
Filenames: []FilenameGroup{CustomFilenames("base.yml")},
|
||||
})),
|
||||
}}}}},
|
||||
)
|
||||
if err != nil || !reflect.DeepEqual(result.Config.Settings, map[string]string{"main": "value", "underlay": "value"}) {
|
||||
t.Fatalf("layered search = %#v, %v", result, err)
|
||||
}
|
||||
sources := result.Sources()
|
||||
if len(sources) != 2 || sources[0].Path != canonicalFacadePath(t, mainPath) || sources[0].Layer != LayerBase ||
|
||||
sources[1].Path != canonicalFacadePath(t, underlayPath) || sources[1].Layer != LayerUnderlay {
|
||||
t.Fatalf("layered search sources = %#v", sources)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeSearchSelectedAndAbsentUseRootOnlyContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
mainPath := writeFacadeImportFile(t, root, "main.yml", "value: main\n")
|
||||
writeFacadeImportFile(t, root, "overlays/testing.yml", "value: overlay\n")
|
||||
filesystem := &FilesystemContext{Root: root}
|
||||
options := Options[facadeDiscoveryConfig]{Overlay: "testing", Filesystem: filesystem}
|
||||
|
||||
selected, err := Load(SearchConfig("", Paths(mainPath)), options)
|
||||
if err != nil || selected.Config.Value != "overlay" {
|
||||
t.Fatalf("selected search with root-only context = %#v, %v", selected, err)
|
||||
}
|
||||
sources := selected.Sources()
|
||||
if len(sources) != 2 || sources[0].Path != canonicalFacadePath(t, mainPath) || sources[0].ID != "source-1" ||
|
||||
sources[1].ID != "source-2" || sources[1].Layer != LayerOverlay {
|
||||
t.Fatalf("selected search source evidence = %#v", sources)
|
||||
}
|
||||
mainContext, ok := selected.SourceContext("source-1")
|
||||
if !ok || mainContext.BaseDir != "." {
|
||||
t.Fatalf("selected file base context = %#v, %v", mainContext, ok)
|
||||
}
|
||||
|
||||
missing := SearchConfig("", Paths(filepath.Join(root, "missing.yml")))
|
||||
searchedAbsent, err := Load(missing, options)
|
||||
if err != nil {
|
||||
t.Fatalf("exhausted search with root-only overlay context: %v", err)
|
||||
}
|
||||
explicitAbsent, err := Load(NoSource(), options)
|
||||
if err != nil {
|
||||
t.Fatalf("NoSource with root-only overlay context: %v", err)
|
||||
}
|
||||
if !reflect.DeepEqual(searchedAbsent.Config, explicitAbsent.Config) ||
|
||||
!reflect.DeepEqual(searchedAbsent.Sources(), explicitAbsent.Sources()) {
|
||||
t.Fatalf("searched absence differs from NoSource: %#v / %#v", searchedAbsent, explicitAbsent)
|
||||
}
|
||||
left, leftOK := searchedAbsent.Metadata().Lookup(Keys("value"))
|
||||
right, rightOK := explicitAbsent.Metadata().Lookup(Keys("value"))
|
||||
if leftOK != rightOK || !reflect.DeepEqual(left, right) || !leftOK {
|
||||
t.Fatalf("searched absence metadata = %#v, %v; NoSource metadata = %#v, %v", left, leftOK, right, rightOK)
|
||||
}
|
||||
if sources := searchedAbsent.Sources(); len(sources) != 1 || sources[0].ID != "source-1" || sources[0].Layer != LayerOverlay {
|
||||
t.Fatalf("absent search created unexpected source evidence = %#v", sources)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeSearchRejectsSelectedContextWithoutFallback(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
inside := writeFacadeImportFile(t, root, "inside.yml", "value: fallback\n")
|
||||
outsideRoot := t.TempDir()
|
||||
outside := writeFacadeImportFile(t, outsideRoot, "outside.yml", "value: selected\n")
|
||||
search := SearchConfig("", Paths(outside, inside))
|
||||
|
||||
_, err := Load(search, Options[facadeDiscoveryConfig]{Filesystem: &FilesystemContext{Root: root}})
|
||||
var loadErr *LoadError
|
||||
if !errors.As(err, &loadErr) || !hasFacadeDiagnostic(loadErr, CodeImportOutsideRoot) {
|
||||
t.Fatalf("selected outside-root file error = %v, want containment failure", err)
|
||||
}
|
||||
|
||||
_, err = Load(search, Options[facadeDiscoveryConfig]{Filesystem: &FilesystemContext{BaseDir: root, Root: root}})
|
||||
if !errors.As(err, &loadErr) || !hasFacadeDiagnostic(loadErr, CodeOptionsInvalid) {
|
||||
t.Fatalf("selected file with explicit base error = %v, want option failure", err)
|
||||
}
|
||||
|
||||
native := NewFilesystem("native", fstest.MapFS{})
|
||||
_, err = Load(search, Options[facadeDiscoveryConfig]{Filesystem: &FilesystemContext{FS: &native, BaseDir: ".", Root: "."}})
|
||||
if !errors.As(err, &loadErr) || !hasFacadeDiagnostic(loadErr, CodeOptionsInvalid) {
|
||||
t.Fatalf("selected file with native context error = %v, want option failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeSearchRunsCallbacksOnceAndKeepsInspectionWithSelectedFile(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
selectedPath := writeFacadeImportFile(t, root, "selected.yml", "value: selected\n")
|
||||
writeFacadeImportFile(t, root, "later.yml", "value: later\n")
|
||||
defaultsCalls, resolveCalls, validateCalls := 0, 0, 0
|
||||
result, err := Load(SearchConfig("", Paths(selectedPath, filepath.Join(root, "later.yml"))), Options[facadeDiscoveryLifecycleConfig]{
|
||||
Definition: Definition[facadeDiscoveryLifecycleConfig]{
|
||||
Defaults: func() facadeDiscoveryLifecycleConfig {
|
||||
defaultsCalls++
|
||||
return facadeDiscoveryLifecycleConfig{}
|
||||
},
|
||||
Resolve: func(configuration *facadeDiscoveryLifecycleConfig, resolution *Resolution) error {
|
||||
resolveCalls++
|
||||
configuration.Resolved = "from:" + configuration.Value
|
||||
resolution.Record(Keys("resolved"), ChangeNormalization, "derive from selected value", Keys("value"))
|
||||
return nil
|
||||
},
|
||||
Validate: func(_ *facadeDiscoveryLifecycleConfig, _ *Validation) { validateCalls++ },
|
||||
},
|
||||
})
|
||||
if err != nil || result.Config.Value != "selected" || result.Config.Resolved != "from:selected" {
|
||||
t.Fatalf("search lifecycle result = %#v, %v", result, err)
|
||||
}
|
||||
if defaultsCalls != 1 || resolveCalls != 1 || validateCalls != 1 {
|
||||
t.Fatalf("search callbacks ran defaults=%d resolve=%d validate=%d times", defaultsCalls, resolveCalls, validateCalls)
|
||||
}
|
||||
sources := result.Sources()
|
||||
if len(sources) != 1 || sources[0].Path != canonicalFacadePath(t, selectedPath) || sources[0].ID != "source-1" {
|
||||
t.Fatalf("selected source evidence = %#v", sources)
|
||||
}
|
||||
valueInfo, found := result.Metadata().Lookup(Keys("value"))
|
||||
if !found || len(valueInfo.Origin.Locations) != 1 || valueInfo.Origin.Locations[0].SourceID != sources[0].ID {
|
||||
t.Fatalf("selected value metadata = %#v, %v", valueInfo, found)
|
||||
}
|
||||
var rendered strings.Builder
|
||||
if err := result.WriteEffective(&rendered); err != nil || !strings.Contains(rendered.String(), "value: selected") || strings.Contains(rendered.String(), "later") {
|
||||
t.Fatalf("effective output = %q, %v", rendered.String(), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeSearchSequenceItemAndAbsentInputPlacement(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := t.TempDir()
|
||||
selectedPath := writeFacadeImportFile(t, root, "item.yml", "name: selected\n")
|
||||
options := Options[facadeDiscoveryPlacementConfig]{
|
||||
Destination: Keys("items"),
|
||||
PlacementMode: PlacementModeSequenceItem,
|
||||
}
|
||||
selected, err := Load(SearchConfig("", Paths(selectedPath)), options)
|
||||
if err != nil || len(selected.Config.Items) != 1 || selected.Config.Items[0].Name != "selected" {
|
||||
t.Fatalf("selected sequence item = %#v, %v", selected, err)
|
||||
}
|
||||
if sources := selected.Sources(); len(sources) != 1 || sources[0].Destination.String() != "/items" || sources[0].PlacementMode != PlacementModeSequenceItem {
|
||||
t.Fatalf("selected sequence-item source = %#v", sources)
|
||||
}
|
||||
|
||||
defaults := func() facadeDiscoveryPlacementConfig {
|
||||
return facadeDiscoveryPlacementConfig{Items: []facadeDiscoveryPlacementItem{}}
|
||||
}
|
||||
absent, err := Load(SearchConfig("", Paths(filepath.Join(root, "missing.yml"))), Options[facadeDiscoveryPlacementConfig]{
|
||||
Definition: Definition[facadeDiscoveryPlacementConfig]{Defaults: defaults},
|
||||
Destination: options.Destination,
|
||||
PlacementMode: options.PlacementMode,
|
||||
})
|
||||
if err != nil || len(absent.Config.Items) != 0 || len(absent.Sources()) != 0 {
|
||||
t.Fatalf("absent search created a sequence item: %#v, %v", absent, err)
|
||||
}
|
||||
}
|
||||
|
||||
func hasFacadeDiagnostic(err *LoadError, code Code) bool {
|
||||
for _, diagnostic := range err.Diagnostics() {
|
||||
if diagnostic.Code == code {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func canonicalFacadePath(t *testing.T, path string) string {
|
||||
t.Helper()
|
||||
absolute, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
canonical, err := filepath.EvalSymlinks(absolute)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return canonical
|
||||
}
|
||||
249
facade_final_sequence_identity_test.go
Normal file
249
facade_final_sequence_identity_test.go
Normal file
@@ -0,0 +1,249 @@
|
||||
package configkit_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
func TestFinalSequenceIdentityTreatsLateExtendsFieldsAsPayload(t *testing.T) {
|
||||
for _, phase := range []string{"defaults", "resolution"} {
|
||||
for _, component := range []any{"application-data", nil} {
|
||||
for _, duplicate := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("%s/%v/duplicate=%t", phase, component, duplicate), func(t *testing.T) {
|
||||
items := []any{map[string]any{"extends": component}}
|
||||
if duplicate {
|
||||
items = append(items, map[string]any{"extends": component})
|
||||
}
|
||||
definition := configkit.Definition[map[string]any]{
|
||||
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("items"), "extends")},
|
||||
Defaults: func() map[string]any {
|
||||
if phase == "defaults" {
|
||||
return map[string]any{"items": items}
|
||||
}
|
||||
return map[string]any{}
|
||||
},
|
||||
}
|
||||
if phase == "resolution" {
|
||||
definition.Resolve = func(value *map[string]any, resolution *configkit.Resolution) error {
|
||||
(*value)["items"] = items
|
||||
resolution.Record(configkit.Keys("items"), configkit.ChangeNormalization, "supply application payload")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
loaded, err := configkit.Load(configkit.NoSource(), configkit.Options[map[string]any]{Definition: definition})
|
||||
if duplicate {
|
||||
loadErr := requireFacadeLoadError(t, err)
|
||||
diagnostics := loadErr.Diagnostics()
|
||||
if loaded != nil || len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeSequenceConflict || diagnostics[0].Path.String() != "/items" {
|
||||
t.Fatalf("duplicate effective payload diagnostics = %#v", diagnostics)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil || loaded == nil {
|
||||
t.Fatalf("ordinary effective identity was interpreted as a directive: %v", err)
|
||||
}
|
||||
value := loaded.Config["items"].([]any)[0].(map[string]any)
|
||||
if actual, present := value["extends"]; !present || actual != component {
|
||||
t.Fatalf("effective payload = %#v", value)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinalSequenceIdentityValidatesLateValues(t *testing.T) {
|
||||
identity := configkit.SequenceIdentityAt(configkit.Keys("items"), "id")
|
||||
for _, source := range []string{"defaults", "CLI", "resolution"} {
|
||||
for _, shape := range []string{"duplicate", "missing", "non-scalar"} {
|
||||
t.Run(source+"/"+shape, func(t *testing.T) {
|
||||
items := finalIdentityRecords(shape)
|
||||
definition := configkit.Definition[map[string]any]{
|
||||
SequenceIdentities: []configkit.SequenceIdentity{identity},
|
||||
Defaults: func() map[string]any {
|
||||
if source == "defaults" {
|
||||
return map[string]any{"items": items}
|
||||
}
|
||||
return map[string]any{}
|
||||
},
|
||||
}
|
||||
options := configkit.Options[map[string]any]{Definition: definition}
|
||||
switch source {
|
||||
case "CLI":
|
||||
options.Overrides = []configkit.Override{{Path: configkit.Keys("items"), Value: items, Flag: "--items"}}
|
||||
case "resolution":
|
||||
options.Definition.Resolve = func(value *map[string]any, resolution *configkit.Resolution) error {
|
||||
(*value)["items"] = items
|
||||
resolution.Record(configkit.Keys("items"), configkit.ChangeNormalization, "create effective records")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
result, err := configkit.Load(configkit.NoSource(), options)
|
||||
if result != nil {
|
||||
t.Fatalf("invalid final identities returned a result: %#v", result.Config)
|
||||
}
|
||||
loadErr := requireFacadeLoadError(t, err)
|
||||
if len(loadErr.Diagnostics()) != 1 {
|
||||
t.Fatalf("diagnostics = %#v", loadErr.Diagnostics())
|
||||
}
|
||||
diagnostic := loadErr.Diagnostics()[0]
|
||||
wantCode, wantPath := configkit.CodeSequenceInvalid, "/items/0/id"
|
||||
if shape == "duplicate" {
|
||||
wantCode, wantPath = configkit.CodeSequenceConflict, "/items"
|
||||
}
|
||||
if diagnostic.Code != wantCode || diagnostic.Path.String() != wantPath {
|
||||
t.Fatalf("diagnostic = %#v, want %s at %s", diagnostic, wantCode, wantPath)
|
||||
}
|
||||
if source == "CLI" && (diagnostic.Primary == nil || diagnostic.Primary.SourceID != "source-1") {
|
||||
t.Fatalf("CLI identity diagnostic lost its source: %#v", diagnostic)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinalSequenceIdentityMatchesLateDynamicNamespaces(t *testing.T) {
|
||||
selector := configkit.SequenceSelector{
|
||||
configkit.SequenceLiteralKey("collections"),
|
||||
configkit.SequenceAnyMappingKey(),
|
||||
configkit.SequenceLiteralKey("items"),
|
||||
}
|
||||
for _, source := range []string{"defaults", "CLI", "resolution"} {
|
||||
t.Run(source, func(t *testing.T) {
|
||||
name := map[string]string{"defaults": "team", "CLI": "cloud", "resolution": "orbit"}[source]
|
||||
path := configkit.Keys("collections", name, "items")
|
||||
items := finalIdentityRecords("duplicate")
|
||||
definition := configkit.Definition[map[string]any]{
|
||||
SequenceIdentities: []configkit.SequenceIdentity{
|
||||
configkit.SequenceIdentityMatching(selector, "id"),
|
||||
},
|
||||
Defaults: func() map[string]any {
|
||||
if source == "defaults" {
|
||||
return map[string]any{"collections": map[string]any{name: map[string]any{"items": items}}}
|
||||
}
|
||||
return map[string]any{}
|
||||
},
|
||||
}
|
||||
options := configkit.Options[map[string]any]{Definition: definition}
|
||||
switch source {
|
||||
case "CLI":
|
||||
options.Overrides = []configkit.Override{{Path: path, Value: items, Flag: "--items"}}
|
||||
case "resolution":
|
||||
options.Definition.Resolve = func(value *map[string]any, resolution *configkit.Resolution) error {
|
||||
(*value)["collections"] = map[string]any{name: map[string]any{"items": items}}
|
||||
resolution.Record(configkit.Keys("collections"), configkit.ChangeNormalization, "create namespaced records")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
result, err := configkit.Load(configkit.NoSource(), options)
|
||||
if result != nil {
|
||||
t.Fatalf("invalid dynamic identity returned a result: %#v", result.Config)
|
||||
}
|
||||
loadErr := requireFacadeLoadError(t, err)
|
||||
if diagnostics := loadErr.Diagnostics(); len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeSequenceConflict || diagnostics[0].Path.String() != path.String() {
|
||||
t.Fatalf("dynamic identity diagnostics = %#v", diagnostics)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinalSequenceIdentityAppliesToLayeredLoads(t *testing.T) {
|
||||
definition := configkit.Definition[map[string]any]{
|
||||
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("items"), "id")},
|
||||
Defaults: func() map[string]any {
|
||||
return map[string]any{"items": finalIdentityRecords("duplicate")}
|
||||
},
|
||||
}
|
||||
result, err := configkit.LoadLayered(configkit.Input{Source: configkit.NoSource()}, configkit.LayeredOptions[map[string]any]{Definition: definition})
|
||||
if result != nil {
|
||||
t.Fatalf("invalid layered final identities returned a result: %#v", result.Config)
|
||||
}
|
||||
loadErr := requireFacadeLoadError(t, err)
|
||||
if diagnostics := loadErr.Diagnostics(); len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeSequenceConflict {
|
||||
t.Fatalf("layered identity diagnostics = %#v", diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinalSequenceIdentityUsesTypedNumbersAndIgnoresUnmatchedShapes(t *testing.T) {
|
||||
definition := configkit.Definition[map[string]any]{
|
||||
SequenceIdentities: []configkit.SequenceIdentity{
|
||||
configkit.SequenceIdentityAt(configkit.Keys("items"), "id"),
|
||||
configkit.SequenceIdentityAt(configkit.Keys("unmatched"), "id"),
|
||||
},
|
||||
}
|
||||
_, err := configkit.Load(configkit.NoSource(), configkit.Options[map[string]any]{
|
||||
Definition: configkit.Definition[map[string]any]{
|
||||
SequenceIdentities: definition.SequenceIdentities,
|
||||
Defaults: func() map[string]any {
|
||||
return map[string]any{"items": []any{
|
||||
map[string]any{"id": int64(1)}, map[string]any{"id": float64(1)},
|
||||
}}
|
||||
},
|
||||
},
|
||||
})
|
||||
loadErr := requireFacadeLoadError(t, err)
|
||||
if diagnostics := loadErr.Diagnostics(); len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeSequenceConflict {
|
||||
t.Fatalf("typed numeric identity diagnostics = %#v", diagnostics)
|
||||
}
|
||||
|
||||
valid, err := configkit.Load(configkit.Mapping("valid", map[string]any{
|
||||
"items": []any{
|
||||
map[string]any{"id": nil}, map[string]any{"id": true},
|
||||
map[string]any{"id": int64(1)}, map[string]any{"id": "one"},
|
||||
},
|
||||
"other": []any{map[string]any{"id": "same"}, map[string]any{"id": "same"}},
|
||||
}), configkit.Options[map[string]any]{Definition: definition})
|
||||
if err != nil || valid == nil {
|
||||
t.Fatalf("valid scalar identities and unmatched duplicate sequence = %#v, %v", valid, err)
|
||||
}
|
||||
if _, err := configkit.Load(configkit.Mapping("other-shapes", map[string]any{"items": nil}), configkit.Options[map[string]any]{Definition: definition}); err != nil {
|
||||
t.Fatalf("null at a registered path was validated as an identity sequence: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinalSequenceIdentityDiagnosticDoesNotRevealResolvedSecrets(t *testing.T) {
|
||||
const canary = "FINAL-IDENTITY-CREDENTIAL-CANARY"
|
||||
const secretName = "CONFIGKIT_FINAL_IDENTITY_SECRET"
|
||||
t.Setenv(secretName, canary)
|
||||
type item struct {
|
||||
ID string `yaml:"id"`
|
||||
Token configkit.Secret `yaml:"token"`
|
||||
}
|
||||
type config struct {
|
||||
Items []item `yaml:"items"`
|
||||
}
|
||||
definition := configkit.Definition[config]{
|
||||
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("items"), "id")},
|
||||
Defaults: func() config {
|
||||
return config{Items: []item{
|
||||
{ID: "duplicate", Token: configkit.SecretEnv(secretName)},
|
||||
{ID: "duplicate", Token: configkit.SecretEnv(secretName)},
|
||||
}}
|
||||
},
|
||||
}
|
||||
result, err := configkit.Load(configkit.NoSource(), configkit.Options[config]{Definition: definition})
|
||||
if result != nil {
|
||||
t.Fatalf("duplicate identity returned a result: %#v", result.Config)
|
||||
}
|
||||
if err == nil || strings.Contains(err.Error(), canary) {
|
||||
t.Fatalf("identity diagnostic leaked a resolved secret: %v", err)
|
||||
}
|
||||
loadErr := requireFacadeLoadError(t, err)
|
||||
if diagnostics := loadErr.Diagnostics(); len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeSequenceConflict {
|
||||
t.Fatalf("secret-bearing identity diagnostics = %#v", diagnostics)
|
||||
}
|
||||
}
|
||||
|
||||
func finalIdentityRecords(shape string) []any {
|
||||
switch shape {
|
||||
case "duplicate":
|
||||
return []any{map[string]any{"id": "same"}, map[string]any{"id": "same"}}
|
||||
case "missing":
|
||||
return []any{map[string]any{"other": "value"}}
|
||||
default:
|
||||
return []any{map[string]any{"id": map[string]any{"nested": "value"}}}
|
||||
}
|
||||
}
|
||||
99
facade_import_alias_test.go
Normal file
99
facade_import_alias_test.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package configkit_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
func TestFacadeNativeDiscoveryPreservesAliasIdentity(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
for name, contents := range map[string]string{
|
||||
"data/item.yml": "configkit:\n imports:\n - path: helper.yml\nvalue: item\n",
|
||||
"data/helper.yml": "owner: target\nresource: resource.txt\n",
|
||||
"aliases/helper.yml": "owner: alias\nresource: resource.txt\n",
|
||||
"data/resource.txt": "target resource",
|
||||
"aliases/resource.txt": "alias resource",
|
||||
} {
|
||||
filename := filepath.Join(root, filepath.FromSlash(name))
|
||||
if err := os.MkdirAll(filepath.Dir(filename), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filename, []byte(contents), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for name, target := range map[string]string{"aliases/item.yml": "../data/item.yml", "linked": "data"} {
|
||||
if err := os.Symlink(target, filepath.Join(root, filepath.FromSlash(name))); err != nil {
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
}
|
||||
}
|
||||
filesystem := configkit.NewFilesystem("aliases", os.DirFS(root))
|
||||
type configuration struct {
|
||||
Value string `yaml:"value"`
|
||||
Owner string `yaml:"owner"`
|
||||
Resource string `yaml:"resource"`
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name, declaration, item, helper, owner, resource string
|
||||
}{
|
||||
{"file/path", "path: aliases/item.yml", "aliases/item.yml", "aliases/helper.yml", "alias", "alias resource"},
|
||||
{"file/dir", "dir: aliases\n pattern: item.yml", "aliases/item.yml", "aliases/helper.yml", "alias", "alias resource"},
|
||||
{"file/glob", "glob: 'aliases/ite*.yml'", "aliases/item.yml", "aliases/helper.yml", "alias", "alias resource"},
|
||||
{"directory/path", "path: linked/item.yml", "linked/item.yml", "linked/helper.yml", "target", "target resource"},
|
||||
{"directory/dir", "dir: linked\n pattern: item.yml", "linked/item.yml", "linked/helper.yml", "target", "target resource"},
|
||||
{"directory/glob", "glob: 'lin*/ite*.yml'", "linked/item.yml", "linked/helper.yml", "target", "target resource"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
result, err := configkit.Load(configkit.Bytes("main", []byte("configkit:\n imports:\n - "+test.declaration+"\n")), configkit.Options[configuration]{
|
||||
Imports: true, Filesystem: &configkit.FilesystemContext{FS: &filesystem, Root: ".", BaseDir: "."},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Config.Owner != test.owner || result.Config.Value != "item" {
|
||||
t.Fatalf("configuration = %#v", result.Config)
|
||||
}
|
||||
sources := result.Sources()
|
||||
if len(sources) != 3 || sources[1].Path != test.item || sources[2].Path != test.helper {
|
||||
t.Fatalf("source identities = %#v", sources)
|
||||
}
|
||||
for field, source := range map[string]configkit.SourceInfo{"value": sources[1], "resource": sources[2]} {
|
||||
info, ok := result.Metadata().Lookup(configkit.Keys(field))
|
||||
if !ok || len(info.Origin.Locations) != 1 || info.Origin.Locations[0].SourceID != source.ID {
|
||||
t.Fatalf("%s origin = %#v", field, info)
|
||||
}
|
||||
context, ok := result.SourceContext(source.ID)
|
||||
if !ok || context.BaseDir != path.Dir(source.Path) {
|
||||
t.Fatalf("%s context = %#v", field, context)
|
||||
}
|
||||
contents, err := fs.ReadFile(context.FS, path.Join(context.BaseDir, result.Config.Resource))
|
||||
if err != nil || string(contents) != test.resource {
|
||||
t.Fatalf("%s resource = %q, %v", field, contents, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
// The same native path is still a repeat when separately included. An alias
|
||||
// to a leaf file remains a different native namespace path, as for path imports.
|
||||
for _, selector := range []string{"path: aliases/item.yml", "dir: aliases\n pattern: item.yml", "glob: 'aliases/ite*.yml'"} {
|
||||
_, err := configkit.Load(configkit.Bytes("main", []byte("configkit:\n imports:\n - "+selector+"\n - path: aliases/item.yml\n")), configkit.Options[configuration]{
|
||||
Imports: true, Filesystem: &configkit.FilesystemContext{FS: &filesystem, Root: ".", BaseDir: "."},
|
||||
})
|
||||
var loadError *configkit.LoadError
|
||||
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != configkit.CodeImportRepeated {
|
||||
t.Fatalf("repeated source: %v", err)
|
||||
}
|
||||
_, err = configkit.Load(configkit.Bytes("main", []byte("configkit:\n imports:\n - "+selector+"\n - path: data/item.yml\n")), configkit.Options[configuration]{
|
||||
Imports: true, Filesystem: &configkit.FilesystemContext{FS: &filesystem, Root: ".", BaseDir: "."},
|
||||
})
|
||||
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != configkit.CodeComposeConflict {
|
||||
t.Fatalf("distinct native alias paths should reach composition: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
260
facade_import_defaults_test.go
Normal file
260
facade_import_defaults_test.go
Normal file
@@ -0,0 +1,260 @@
|
||||
package configkit
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
|
||||
)
|
||||
|
||||
func TestImportDepthChecksSuffixAfterSaturation(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
depth string
|
||||
valid bool
|
||||
}{
|
||||
{"999999999999999999999999.5", false},
|
||||
{"999999999999999999999999e1", false},
|
||||
{"999999999999999999999999", true},
|
||||
{"-0", true},
|
||||
} {
|
||||
t.Run(test.depth, func(t *testing.T) {
|
||||
main := writeFacadeImportFile(t, t.TempDir(), "main.yml", "value: true\nconfigkit:\n imports:\n - dir: absent\n recursive: true\n depth: "+test.depth+"\n")
|
||||
result, err := Load[map[string]any](File(main), Options[map[string]any]{Imports: true})
|
||||
if test.valid {
|
||||
if err != nil || result.Config["value"] != true {
|
||||
t.Fatalf("valid depth: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
var loadError *LoadError
|
||||
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 {
|
||||
t.Fatalf("invalid depth accepted: %v", err)
|
||||
}
|
||||
if diagnostic := loadError.Diagnostics()[0]; diagnostic.Code != CodeImportInvalid || diagnostic.Path.String() != "/configkit/imports/0/depth" {
|
||||
t.Fatalf("invalid depth diagnostic = %#v", diagnostic)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportDefaultsApplyToTransitiveAndUnderlayImports(t *testing.T) {
|
||||
type config struct {
|
||||
Main string `yaml:"main"`
|
||||
Transitive string `yaml:"transitive"`
|
||||
Exception string `yaml:"exception"`
|
||||
Underlay string `yaml:"underlay"`
|
||||
IgnoredDeep string `yaml:"ignored_deep"`
|
||||
}
|
||||
directory := t.TempDir()
|
||||
mainPath := writeFacadeImportFile(t, directory, "main.yml", `configkit:
|
||||
imports:
|
||||
- path: parent/parent.yml
|
||||
main: main
|
||||
`)
|
||||
writeFacadeImportFile(t, directory, "parent/parent.yml", `configkit:
|
||||
imports:
|
||||
- dir: generated
|
||||
- dir: explicit
|
||||
pattern: '*.yaml'
|
||||
recursive: false
|
||||
`)
|
||||
transitivePath := writeFacadeImportFile(t, directory, "parent/generated/deep/transitive.cfg", "transitive: selected\n")
|
||||
writeFacadeImportFile(t, directory, "parent/generated/ignored.yaml", "ignored_deep: wrong-pattern\n")
|
||||
explicitPath := writeFacadeImportFile(t, directory, "parent/explicit/exception.yaml", "exception: declaration\n")
|
||||
writeFacadeImportFile(t, directory, "parent/explicit/deep/ignored.yaml", "ignored_deep: wrong-depth\n")
|
||||
writeFacadeImportFile(t, directory, "parent/explicit/ignored.cfg", "ignored_deep: wrong-explicit-pattern\n")
|
||||
underlayPath := writeFacadeImportFile(t, directory, "underlay.yml", `configkit:
|
||||
imports:
|
||||
- dir: underlay-files
|
||||
`)
|
||||
underlayImportPath := writeFacadeImportFile(t, directory, "underlay-files/underlay.cfg", "underlay: selected\n")
|
||||
recursive := true
|
||||
loaded, err := LoadLayered(Input{Source: File(mainPath), Imports: true}, LayeredOptions[config]{
|
||||
ImportDefaults: ImportDefaults{Recursive: &recursive, Pattern: []string{"*.cfg"}},
|
||||
Underlays: []Underlay{{Inputs: []Input{{Source: File(underlayPath), Imports: true}}}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("LoadLayered: %v", err)
|
||||
}
|
||||
if loaded.Config.Main != "main" || loaded.Config.Transitive != "selected" || loaded.Config.Exception != "declaration" || loaded.Config.Underlay != "selected" || loaded.Config.IgnoredDeep != "" {
|
||||
t.Fatalf("configuration = %#v", loaded.Config)
|
||||
}
|
||||
paths := make(map[string]bool)
|
||||
for _, source := range loaded.Sources() {
|
||||
paths[source.Path] = true
|
||||
}
|
||||
for _, selected := range []string{transitivePath, explicitPath, underlayImportPath} {
|
||||
if !paths[canonicalFacadeImportPath(t, selected)] {
|
||||
t.Errorf("selected source %q missing from source records", selected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportDefaultsPreserveEmptyPatternAndRequiredness(t *testing.T) {
|
||||
type config struct {
|
||||
Value string `yaml:"value"`
|
||||
}
|
||||
directory := t.TempDir()
|
||||
mainPath := writeFacadeImportFile(t, directory, "main.yml", `configkit:
|
||||
imports:
|
||||
- dir: snippets
|
||||
`)
|
||||
writeFacadeImportFile(t, directory, "snippets/value.cfg", "value: imported\n")
|
||||
|
||||
unrestricted, err := Load(File(mainPath), Options[config]{Imports: true})
|
||||
if err != nil || unrestricted.Config.Value != "imported" {
|
||||
t.Fatalf("unspecified pattern = %#v, %v", unrestricted, err)
|
||||
}
|
||||
empty, err := Load(File(mainPath), Options[config]{Imports: true, ImportDefaults: ImportDefaults{Pattern: []string{}}})
|
||||
if err != nil || empty.Config.Value != "" {
|
||||
t.Fatalf("empty pattern = %#v, %v", empty, err)
|
||||
}
|
||||
emptySources := empty.Sources()
|
||||
if len(emptySources) != 1 || len(emptySources[0].ImportSelections) != 1 || emptySources[0].ImportSelections[0].Path.String() != "/configkit/imports/0" || emptySources[0].ImportSelections[0].Selector != ImportSelectorDirectory || emptySources[0].ImportSelections[0].Outcome != ImportSelectionNone {
|
||||
t.Fatalf("empty-pattern evidence = %#v", emptySources)
|
||||
}
|
||||
required := true
|
||||
_, err = Load(File(mainPath), Options[config]{Imports: true, ImportDefaults: ImportDefaults{Required: &required, Pattern: []string{}}})
|
||||
var loadError *LoadError
|
||||
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != CodeImportMissing {
|
||||
t.Fatalf("required empty selection error = %T %v", err, err)
|
||||
}
|
||||
if sources := loadError.Sources(); len(sources) != 1 || len(sources[0].ImportSelections) != 0 {
|
||||
t.Fatalf("required empty selection was recorded as optional: %#v", sources)
|
||||
}
|
||||
|
||||
recursiveMain := writeFacadeImportFile(t, directory, "recursive.yml", `configkit:
|
||||
imports:
|
||||
- dir: recursive-files
|
||||
`)
|
||||
writeFacadeImportFile(t, directory, "recursive-files/nested/value.cfg", "value: selected\n")
|
||||
recursive := true
|
||||
patterns := []string{"*.cfg"}
|
||||
engineOptions := toEngineOptions(Options[config]{Imports: true, ImportDefaults: ImportDefaults{Recursive: &recursive, Pattern: patterns}})
|
||||
recursive = false
|
||||
patterns[0] = "["
|
||||
loadedEngine, err := engine.Load(File(recursiveMain).toEngine(), secretBinding(), engineOptions)
|
||||
if err != nil || loadedEngine.Config.Value != "selected" {
|
||||
t.Fatalf("load after mutating caller defaults = %#v, %v", loadedEngine, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidImportDefaultsPreflightBeforeOrdinaryAndLayeredReads(t *testing.T) {
|
||||
type config struct {
|
||||
Value string `yaml:"value"`
|
||||
}
|
||||
for _, layered := range []bool{false, true} {
|
||||
input := strings.NewReader("value: read\n")
|
||||
underlayInput := strings.NewReader("value: underlay\n")
|
||||
defaultsCalled := false
|
||||
resolveCalled := false
|
||||
definition := Definition[config]{
|
||||
Defaults: func() config { defaultsCalled = true; return config{} },
|
||||
Resolve: func(*config, *Resolution) error { resolveCalled = true; return nil },
|
||||
}
|
||||
defaults := ImportDefaults{Pattern: []string{"["}}
|
||||
var err error
|
||||
if layered {
|
||||
_, err = LoadLayered(Input{Source: Reader("reader.yml", input)}, LayeredOptions[config]{
|
||||
Definition: definition, ImportDefaults: defaults,
|
||||
Underlays: []Underlay{{Inputs: []Input{{Source: Reader("underlay.yml", underlayInput)}}}},
|
||||
})
|
||||
} else {
|
||||
_, err = Load(Reader("reader.yml", input), Options[config]{Definition: definition, ImportDefaults: defaults})
|
||||
}
|
||||
var loadError *LoadError
|
||||
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != CodeOptionsInvalid {
|
||||
if loadError != nil {
|
||||
t.Fatalf("layered=%v diagnostics = %#v", layered, loadError.Diagnostics())
|
||||
}
|
||||
t.Fatalf("layered=%v error = %T %v", layered, err, err)
|
||||
}
|
||||
if input.Len() != len("value: read\n") || underlayInput.Len() != len("value: underlay\n") || defaultsCalled || resolveCalled {
|
||||
t.Fatalf("layered=%v consumed input or called application hooks: remaining=%d underlay=%d defaults=%v resolve=%v", layered, input.Len(), underlayInput.Len(), defaultsCalled, resolveCalled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicImportSelectionSnapshotsAreDetachedAcrossResultErrorAndResolution(t *testing.T) {
|
||||
type config struct {
|
||||
Value string `yaml:"value"`
|
||||
}
|
||||
directory := t.TempDir()
|
||||
mainPath := writeFacadeImportFile(t, directory, "main.yml", `configkit:
|
||||
imports:
|
||||
- dir: absent
|
||||
value: loaded
|
||||
`)
|
||||
callbackPath := ""
|
||||
loaded, err := Load(File(mainPath), Options[config]{Imports: true, Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error {
|
||||
first, ok := resolution.Source("source-1")
|
||||
if !ok || len(first.ImportSelections) != 1 {
|
||||
t.Fatalf("resolution source = %#v, %v", first, ok)
|
||||
}
|
||||
first.ImportSelections[0].Path = Keys("callback mutation")
|
||||
second, ok := resolution.Source("source-1")
|
||||
if !ok || second.ImportSelections[0].Path.String() != "/configkit/imports/0" {
|
||||
t.Fatalf("resolution snapshot changed after mutation: %#v", second)
|
||||
}
|
||||
callbackPath = second.ImportSelections[0].Path.String()
|
||||
return nil
|
||||
}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if callbackPath != "/configkit/imports/0" {
|
||||
t.Fatalf("callback path = %q", callbackPath)
|
||||
}
|
||||
sources := loaded.Sources()
|
||||
if len(sources) != 1 || len(sources[0].ImportSelections) != 1 || sources[0].ImportSelections[0].Location.SourceID != sources[0].ID || sources[0].ImportSelections[0].Selector != ImportSelectorDirectory {
|
||||
t.Fatalf("result source evidence = %#v", sources)
|
||||
}
|
||||
sources[0].ImportSelections[0].Path = Keys("result mutation")
|
||||
if got := loaded.Sources()[0].ImportSelections[0].Path.String(); got != "/configkit/imports/0" {
|
||||
t.Fatalf("result snapshot changed after mutation: %q", got)
|
||||
}
|
||||
|
||||
failurePath := writeFacadeImportFile(t, directory, "failure.yml", `configkit:
|
||||
imports:
|
||||
- dir: absent
|
||||
- path: required-missing.yml
|
||||
required: true
|
||||
`)
|
||||
_, err = Load(File(failurePath), Options[config]{Imports: true})
|
||||
var loadError *LoadError
|
||||
if !errors.As(err, &loadError) {
|
||||
t.Fatalf("failed load error = %T %v", err, err)
|
||||
}
|
||||
errorSources := loadError.Sources()
|
||||
if len(errorSources) != 1 || len(errorSources[0].ImportSelections) != 1 || errorSources[0].ImportSelections[0].Path.String() != "/configkit/imports/0" {
|
||||
t.Fatalf("error source evidence = %#v", errorSources)
|
||||
}
|
||||
errorSources[0].ImportSelections[0].Path = Keys("error mutation")
|
||||
if got := loadError.Sources()[0].ImportSelections[0].Path.String(); got != "/configkit/imports/0" {
|
||||
t.Fatalf("error snapshot changed after mutation: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func writeFacadeImportFile(t *testing.T, root, name, contents string) string {
|
||||
t.Helper()
|
||||
filename := filepath.Join(root, name)
|
||||
if err := os.MkdirAll(filepath.Dir(filename), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filename, []byte(contents), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return filename
|
||||
}
|
||||
|
||||
func canonicalFacadeImportPath(t *testing.T, filename string) string {
|
||||
t.Helper()
|
||||
canonical, err := filepath.EvalSymlinks(filename)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return filepath.ToSlash(canonical)
|
||||
}
|
||||
135
facade_import_lifecycle_test.go
Normal file
135
facade_import_lifecycle_test.go
Normal file
@@ -0,0 +1,135 @@
|
||||
package configkit
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"path"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
)
|
||||
|
||||
func TestDiscoveredItemHelpersPreserveNestedInheritedResourceEvidence(t *testing.T) {
|
||||
const canary = "PRIVATE-CONSUMED-HEADER-CANARY"
|
||||
filesystem := NewFilesystem("bundle", fstest.MapFS{
|
||||
"bundle/config/main.yml": {Data: []byte(`configkit:
|
||||
imports:
|
||||
- dir: records
|
||||
pattern: '*-item.yml'
|
||||
destination: [services, records]
|
||||
placement: sequence-item
|
||||
services:
|
||||
records:
|
||||
- id: authored
|
||||
version: authored
|
||||
`)},
|
||||
"bundle/config/records/a-item.yml": {Data: []byte(`configkit:
|
||||
imports:
|
||||
- path: helper.yml
|
||||
version: ` + canary + `
|
||||
entries:
|
||||
parent:
|
||||
resource: icon.txt
|
||||
child:
|
||||
extends: parent
|
||||
`)},
|
||||
"bundle/config/records/helper.yml": {Data: []byte("id: helper-supplied\n")},
|
||||
"bundle/config/records/icon.txt": {Data: []byte("icon payload")},
|
||||
})
|
||||
result, err := Load(filesystem.File("bundle/config/main.yml"), Options[map[string]any]{
|
||||
Imports: true,
|
||||
Filesystem: &FilesystemContext{Root: "bundle", FS: &filesystem},
|
||||
Definition: Definition[map[string]any]{
|
||||
Version: &VersionRule{Path: Keys("version"), Check: func(Version, *Validation) error { return nil }},
|
||||
SequenceIdentities: []SequenceIdentity{SequenceIdentityAt(Keys("services", "records"), "id")},
|
||||
Secret: func(candidate Path) bool {
|
||||
return candidate.String() == "/services/records/0/version"
|
||||
},
|
||||
},
|
||||
SourceVersionMode: SourceVersionModeIfPresent,
|
||||
})
|
||||
if err != nil {
|
||||
var loadError *LoadError
|
||||
if errors.As(err, &loadError) {
|
||||
t.Fatalf("load discovered item: %v; diagnostics: %#v", err, loadError.Diagnostics())
|
||||
}
|
||||
t.Fatalf("load discovered item: %v", err)
|
||||
}
|
||||
services := result.Config["services"].(map[string]any)
|
||||
records := services["records"].([]any)
|
||||
if len(records) != 2 || records[0].(map[string]any)["id"] != "helper-supplied" || records[0].(map[string]any)["version"] != nil || records[1].(map[string]any)["version"] != "authored" {
|
||||
t.Fatalf("nested records = %#v", records)
|
||||
}
|
||||
childPath := Keys("services", "records").Index(0).Key("entries").Key("child").Key("resource")
|
||||
child := records[0].(map[string]any)["entries"].(map[string]any)["child"].(map[string]any)
|
||||
if child["resource"] != "icon.txt" {
|
||||
t.Fatalf("inherited relative resource = %#v", child)
|
||||
}
|
||||
|
||||
sources := result.Sources()
|
||||
filesByID := make(map[string]string, len(sources))
|
||||
for _, source := range sources {
|
||||
filesByID[source.ID] = source.Path
|
||||
}
|
||||
var itemSource SourceInfo
|
||||
for _, source := range sources {
|
||||
if source.Path == "bundle/config/records/a-item.yml" {
|
||||
itemSource = source
|
||||
}
|
||||
}
|
||||
if itemSource.VersionCheck == nil || !itemSource.VersionCheck.Version.Present || !itemSource.VersionCheck.Redacted || itemSource.VersionCheck.Version.Text != "" {
|
||||
t.Fatalf("consumed header inspection = %#v", itemSource.VersionCheck)
|
||||
}
|
||||
idInfo, ok := result.Metadata().Lookup(Keys("services", "records").Index(0).Key("id"))
|
||||
if !ok || len(idInfo.Origin.Locations) != 1 || filesByID[idInfo.Origin.Locations[0].SourceID] != "bundle/config/records/helper.yml" {
|
||||
t.Fatalf("assigned helper ID origin = %#v", idInfo)
|
||||
}
|
||||
resourceInfo, ok := result.Metadata().Lookup(childPath)
|
||||
if !ok || len(resourceInfo.Origin.Locations) != 1 || filesByID[resourceInfo.Origin.Locations[0].SourceID] != "bundle/config/records/a-item.yml" {
|
||||
t.Fatalf("inherited resource metadata = %#v", resourceInfo)
|
||||
}
|
||||
explanation, ok := result.Explain(childPath)
|
||||
if !ok || explanation.Effective.YAML != "icon.txt\n" || len(explanation.Origin.Locations) != 1 || explanation.Origin.Locations[0].SourceID != resourceInfo.Origin.Locations[0].SourceID {
|
||||
t.Fatalf("inherited resource explanation = %#v", explanation)
|
||||
}
|
||||
context, ok := result.SourceContext(resourceInfo.Origin.Locations[0].SourceID)
|
||||
if !ok || context.FS == nil || context.BaseDir != "config/records" {
|
||||
t.Fatalf("inherited resource context = %#v, %v", context, ok)
|
||||
}
|
||||
contents, err := fs.ReadFile(context.FS, path.Join(context.BaseDir, child["resource"].(string)))
|
||||
if err != nil || string(contents) != "icon payload" {
|
||||
t.Fatalf("inherited resource read = %q, %v", contents, err)
|
||||
}
|
||||
|
||||
_, err = Load(filesystem.File("bundle/config/main.yml"), Options[map[string]any]{
|
||||
Imports: true,
|
||||
Filesystem: &FilesystemContext{Root: "bundle", FS: &filesystem},
|
||||
Definition: Definition[map[string]any]{
|
||||
Version: &VersionRule{Path: Keys("version"), Check: func(Version, *Validation) error { return nil }},
|
||||
SequenceIdentities: []SequenceIdentity{SequenceIdentityAt(Keys("services", "records"), "version")},
|
||||
Secret: func(candidate Path) bool {
|
||||
return candidate.String() == "/services/records/0/version"
|
||||
},
|
||||
},
|
||||
SourceVersionMode: SourceVersionModeIfPresent,
|
||||
})
|
||||
loadError, ok := err.(*LoadError)
|
||||
if !ok || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != CodeComposeInvalid {
|
||||
t.Fatalf("consumed header supplied an identity: %v", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), canary) {
|
||||
t.Fatalf("diagnostic exposed the consumed secret header: %v", err)
|
||||
}
|
||||
var failedItemSource SourceInfo
|
||||
foundFailedItem := false
|
||||
for _, source := range loadError.Sources() {
|
||||
if source.Path == "bundle/config/records/a-item.yml" {
|
||||
failedItemSource = source
|
||||
foundFailedItem = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundFailedItem || failedItemSource.VersionCheck == nil || failedItemSource.VersionCheck.Version.Text != "" || !failedItemSource.VersionCheck.Redacted {
|
||||
t.Fatalf("failed-load source inspection exposed or omitted header evidence: %#v", failedItemSource)
|
||||
}
|
||||
}
|
||||
119
facade_import_root_test.go
Normal file
119
facade_import_root_test.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package configkit_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
func TestFacadeDirectoryRootBelowContainedAbsoluteSymlink(t *testing.T) {
|
||||
root, err := filepath.EvalSymlinks(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parts := filepath.Join(root, "real", "parts")
|
||||
if err := os.MkdirAll(parts, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
partPath := filepath.Join(parts, "part.yml")
|
||||
if err := os.WriteFile(partPath, []byte("value: included\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(filepath.Join(root, "real"), filepath.Join(root, "absolute")); err != nil {
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
}
|
||||
for _, follow := range []bool{true, false} {
|
||||
t.Run(fmt.Sprintf("follow=%t", follow), func(t *testing.T) {
|
||||
mainPath := filepath.Join(root, "main.yml")
|
||||
contents := fmt.Sprintf("configkit:\n imports:\n - dir: absolute/parts\n pattern: '*.yml'\n follow: %t\n", follow)
|
||||
if err := os.WriteFile(mainPath, []byte(contents), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := configkit.Load[map[string]any](configkit.File(mainPath), configkit.Options[map[string]any]{Imports: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Config["value"] != "included" {
|
||||
t.Fatalf("configuration = %#v", result.Config)
|
||||
}
|
||||
if sources := result.Sources(); len(sources) != 2 || sources[1].Path != partPath {
|
||||
t.Fatalf("canonical imported source = %#v", sources)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeNativeDiscoveryWithNestedRoot(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
parts := filepath.Join(root, "nested/root/parts")
|
||||
if err := os.MkdirAll(parts, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(parts, "value.yml"), []byte("value: 1\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink("parts", filepath.Join(root, "nested/root/linked")); err != nil {
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
}
|
||||
filesystem := configkit.NewFilesystem("nested", os.DirFS(root))
|
||||
context := &configkit.FilesystemContext{FS: &filesystem, Root: "nested/root", BaseDir: "nested/root"}
|
||||
for _, selector := range []string{
|
||||
"dir: parts", "glob: 'parts/*.yml'", "dir: linked", "glob: 'lin*/*.yml'",
|
||||
} {
|
||||
t.Run(selector, func(t *testing.T) {
|
||||
result, err := configkit.Load(configkit.Bytes("main", []byte("configkit:\n imports:\n - "+selector+"\n")), configkit.Options[map[string]any]{Imports: true, Filesystem: context})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Config["value"] != int64(1) {
|
||||
t.Fatalf("configuration = %#v", result.Config)
|
||||
}
|
||||
})
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "nested/outside.yml"), []byte("value: outside\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink("../../outside.yml", filepath.Join(parts, "escape.yml")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, selector := range []string{"dir: parts", "glob: 'parts/*.yml'"} {
|
||||
_, err := configkit.Load(configkit.Bytes("main", []byte("configkit:\n imports:\n - "+selector+"\n")), configkit.Options[map[string]any]{Imports: true, Filesystem: context})
|
||||
var loadError *configkit.LoadError
|
||||
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != configkit.CodeImportOutsideRoot {
|
||||
t.Fatalf("escaping %s: %v", selector, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeDiscoveryAllowsFiniteLinkReuse(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.Mkdir(filepath.Join(root, "parts"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "parts/value.yml"), []byte("value: 1\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink("..", filepath.Join(root, "parts/up")); err != nil {
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
}
|
||||
filesystem := configkit.NewFilesystem("finite", os.DirFS(root))
|
||||
for _, native := range []bool{false, true} {
|
||||
for _, selector := range []string{"dir: parts/up/parts/up/parts\n pattern: '*.yml'", "glob: 'parts/up/parts/up/parts/*.yml'"} {
|
||||
context := &configkit.FilesystemContext{Root: root, BaseDir: root}
|
||||
if native {
|
||||
context = &configkit.FilesystemContext{FS: &filesystem, Root: ".", BaseDir: "."}
|
||||
}
|
||||
result, err := configkit.Load(configkit.Bytes("main", []byte("configkit:\n imports:\n - "+selector+"\n")), configkit.Options[map[string]any]{Imports: true, Filesystem: context})
|
||||
if err != nil {
|
||||
t.Fatalf("native=%v selector=%s: %v", native, selector, err)
|
||||
}
|
||||
if result.Config["value"] != int64(1) {
|
||||
t.Fatalf("configuration = %#v", result.Config)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -91,6 +91,214 @@ func TestFacadeInheritancePreservesPresenceOriginsAndHistory(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeIdentifiedInheritanceResolvesChainsAndNestedCollections(t *testing.T) {
|
||||
const secretName = "CONFIGKIT_IDENTIFIED_INHERITANCE_SECRET"
|
||||
const canary = "IDENTIFIED-INHERITANCE-SECRET-CANARY"
|
||||
t.Setenv(secretName, canary)
|
||||
type step struct {
|
||||
ID string `yaml:"id"`
|
||||
Value string `yaml:"value"`
|
||||
}
|
||||
type job struct {
|
||||
ID string `yaml:"id"`
|
||||
Name string `yaml:"name"`
|
||||
Credential configkit.Secret `yaml:"credential"`
|
||||
Resource string `yaml:"resource"`
|
||||
Steps []step `yaml:"steps"`
|
||||
Settings map[string]map[string]string `yaml:"settings"`
|
||||
}
|
||||
type configuration struct {
|
||||
Jobs []job `yaml:"jobs"`
|
||||
}
|
||||
input := []byte(`jobs:
|
||||
- id: child
|
||||
extends: {id: parent}
|
||||
name: child-name
|
||||
- id: parent
|
||||
extends: {id: base}
|
||||
- id: base
|
||||
name: base-name
|
||||
credential: CONFIGKIT_IDENTIFIED_INHERITANCE_SECRET
|
||||
resource: icon.txt
|
||||
steps:
|
||||
- id: base-step
|
||||
value: base-step-value
|
||||
- id: second-step
|
||||
value: second-step-value
|
||||
settings:
|
||||
primary: {value: base-setting}
|
||||
secondary: {extends: primary}
|
||||
`)
|
||||
filesystem := configkit.NewFilesystem("identified-bundle", fstest.MapFS{
|
||||
"config/identified.yml": &fstest.MapFile{Data: input},
|
||||
"config/icon.txt": &fstest.MapFile{Data: []byte("identified-parent-resource")},
|
||||
})
|
||||
registrations := []configkit.SequenceIdentity{
|
||||
configkit.SequenceIdentityAt(configkit.Keys("jobs"), "id"),
|
||||
}
|
||||
result, err := configkit.Load(filesystem.File("config/identified.yml"), configkit.Options[configuration]{
|
||||
Definition: configkit.Definition[configuration]{
|
||||
SequenceIdentities: registrations,
|
||||
Secret: func(path configkit.Path) bool {
|
||||
return path.String() == "/jobs/0/credential" || path.String() == "/jobs/1/credential" || path.String() == "/jobs/2/credential"
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
if loadError, ok := err.(*configkit.LoadError); ok {
|
||||
t.Fatalf("load identified inheritance: %v (%#v)", err, loadError.Diagnostics())
|
||||
}
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.Config.Jobs) != 3 || result.Config.Jobs[0].ID != "child" || result.Config.Jobs[1].ID != "parent" || result.Config.Jobs[2].ID != "base" {
|
||||
t.Fatalf("inherited job positions and identities = %#v", result.Config.Jobs)
|
||||
}
|
||||
child := result.Config.Jobs[0]
|
||||
if child.Name != "child-name" || child.Credential.Value() != canary || child.Resource != "icon.txt" || len(child.Steps) != 2 {
|
||||
t.Fatalf("inherited child = %#v", child)
|
||||
}
|
||||
if got := []string{child.Steps[0].ID, child.Steps[1].ID}; strings.Join(got, ",") != "base-step,second-step" {
|
||||
t.Fatalf("inherited nested steps = %v", got)
|
||||
}
|
||||
if child.Steps[0].Value != "base-step-value" || child.Steps[1].Value != "second-step-value" || child.Settings["secondary"]["value"] != "base-setting" {
|
||||
t.Fatalf("inherited nested collection and mapping = %#v", child)
|
||||
}
|
||||
credentialPath := configkit.Keys("jobs").Index(0).Key("credential")
|
||||
credentialInfo, found := result.Metadata().Lookup(credentialPath)
|
||||
if !found || len(credentialInfo.Origin.Inheritance) != 2 {
|
||||
t.Fatalf("inherited secret provenance = %#v, %v", credentialInfo, found)
|
||||
}
|
||||
resourceInfo, found := result.Metadata().Lookup(configkit.Keys("jobs").Index(0).Key("resource"))
|
||||
if !found || len(resourceInfo.Origin.Inheritance) != 2 || len(resourceInfo.Origin.Locations) != 1 {
|
||||
t.Fatalf("inherited resource provenance = %#v, %v", resourceInfo, found)
|
||||
}
|
||||
context, found := result.SourceContext(resourceInfo.Origin.Locations[0].SourceID)
|
||||
if !found || context.BaseDir != "config" || context.FS == nil {
|
||||
t.Fatalf("inherited resource context = %#v, %v", context, found)
|
||||
}
|
||||
resource, readErr := fs.ReadFile(context.FS, context.BaseDir+"/"+child.Resource)
|
||||
if readErr != nil || string(resource) != "identified-parent-resource" {
|
||||
t.Fatalf("inherited resource = %q, %v", resource, readErr)
|
||||
}
|
||||
resourceHistory, found := result.Explain(configkit.Keys("jobs").Index(0).Key("resource"))
|
||||
if !found || len(resourceHistory.History) == 0 {
|
||||
t.Fatalf("inherited resource history = %#v, %v", resourceHistory, found)
|
||||
}
|
||||
credentialHistory, found := result.Explain(credentialPath)
|
||||
if !found || !credentialHistory.Effective.Redacted {
|
||||
t.Fatalf("inherited secret explanation = %#v, %v", credentialHistory, found)
|
||||
}
|
||||
identityInfo, found := result.Metadata().Lookup(configkit.Keys("jobs").Index(0).Key("id"))
|
||||
if !found || !identityInfo.Explicit || len(identityInfo.Origin.Inheritance) != 0 ||
|
||||
len(identityInfo.Origin.Locations) != 1 {
|
||||
t.Fatalf("child identity origin and presence = %#v, %v", identityInfo, found)
|
||||
}
|
||||
if _, found := result.Metadata().Lookup(configkit.Keys("jobs").Index(0).Key("extends")); found {
|
||||
t.Fatal("identified record relationship remained in effective metadata")
|
||||
}
|
||||
var rendered bytes.Buffer
|
||||
if err := result.WriteEffective(&rendered); err != nil || strings.Contains(rendered.String(), canary) || strings.Contains(rendered.String(), "extends:") {
|
||||
t.Fatalf("effective inherited configuration = %q, %v", rendered.String(), err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeIdentifiedInheritanceFailureDoesNotRevealSecretCanary(t *testing.T) {
|
||||
const secretName = "CONFIGKIT_IDENTIFIED_INHERITANCE_FAILURE_SECRET"
|
||||
const canary = "IDENTIFIED-INHERITANCE-FAILURE-CANARY"
|
||||
t.Setenv(secretName, canary)
|
||||
type job struct {
|
||||
ID string `yaml:"id"`
|
||||
Credential configkit.Secret `yaml:"credential"`
|
||||
}
|
||||
type configuration struct {
|
||||
Jobs []job `yaml:"jobs"`
|
||||
}
|
||||
input := []byte(`jobs:
|
||||
- id: child
|
||||
extends: {id: missing}
|
||||
credential: CONFIGKIT_IDENTIFIED_INHERITANCE_FAILURE_SECRET
|
||||
`)
|
||||
_, err := configkit.Load(configkit.Bytes("identified-failure.yml", input), configkit.Options[configuration]{
|
||||
Definition: configkit.Definition[configuration]{
|
||||
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("jobs"), "id")},
|
||||
Secret: func(path configkit.Path) bool { return path.String() == "/jobs/0/credential" },
|
||||
},
|
||||
})
|
||||
if err == nil || strings.Contains(err.Error(), canary) {
|
||||
t.Fatalf("identified inheritance failure = %v; secret canary must remain undisclosed", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeLayeredIdentifiedInheritanceUsesSurvivingRecords(t *testing.T) {
|
||||
policy := func(mode string) string {
|
||||
return "configkit:\n sequences:\n - destination: [jobs]\n key: id\n mode: " + mode + "\n"
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
lower string
|
||||
higher string
|
||||
wantCode configkit.Code
|
||||
wantValue string
|
||||
wantSuccess bool
|
||||
}{
|
||||
{
|
||||
name: "upserted parent supplies its final payload",
|
||||
lower: policy("upsert") + "jobs: [{id: parent, value: lower}]\n",
|
||||
higher: policy("upsert") + "jobs: [{id: parent, value: higher}, {id: child, extends: {id: parent}}]\n",
|
||||
wantValue: "higher", wantSuccess: true,
|
||||
},
|
||||
{
|
||||
name: "replacement removes a lower parent before lookup",
|
||||
lower: policy("upsert") + "jobs: [{id: parent, value: lower}]\n",
|
||||
higher: policy("replace") + "jobs: [{id: child, extends: {id: parent}}]\n",
|
||||
wantCode: configkit.CodeInheritanceMissing,
|
||||
},
|
||||
{
|
||||
name: "upserted child without a directive replaces the invalid lower child",
|
||||
lower: policy("upsert") + "jobs: [{id: child, extends: {id: missing}, value: lower}]\n",
|
||||
higher: policy("upsert") + "jobs: [{id: child, value: higher}]\n",
|
||||
wantValue: "higher", wantSuccess: true,
|
||||
},
|
||||
{
|
||||
name: "self-reference cannot resolve through a lower predecessor",
|
||||
lower: policy("upsert") + "jobs: [{id: parent, value: lower}]\n",
|
||||
higher: policy("upsert") + "jobs: [{id: parent, extends: {id: parent}, value: higher}]\n",
|
||||
wantCode: configkit.CodeInheritanceCycle,
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
result, err := configkit.LoadLayered(configkit.Input{Source: configkit.Bytes("main.yml", []byte(test.higher))}, configkit.LayeredOptions[map[string]any]{
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.Bytes("lower.yml", []byte(test.lower))}}}},
|
||||
})
|
||||
if test.wantSuccess {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
jobs, ok := result.Config["jobs"].([]any)
|
||||
if !ok || len(jobs) == 0 {
|
||||
t.Fatalf("assembled jobs = %#v", result.Config["jobs"])
|
||||
}
|
||||
child, ok := jobs[len(jobs)-1].(map[string]any)
|
||||
if !ok || child["value"] != test.wantValue {
|
||||
t.Fatalf("surviving record = %#v, want inherited value %q", jobs[len(jobs)-1], test.wantValue)
|
||||
}
|
||||
return
|
||||
}
|
||||
loadError, ok := err.(*configkit.LoadError)
|
||||
if !ok {
|
||||
t.Fatalf("load error = %v, want code %s", err, test.wantCode)
|
||||
}
|
||||
for _, diagnostic := range loadError.Diagnostics() {
|
||||
if diagnostic.Code == test.wantCode {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("load diagnostics = %#v, want code %s", loadError.Diagnostics(), test.wantCode)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeInheritanceLayeredPrecedenceAndSourceContext(t *testing.T) {
|
||||
filesystem := configkit.NewFilesystem("bundle", fstest.MapFS{
|
||||
"config/main.yml": &fstest.MapFile{Data: []byte("entries: {child: {extends: parent}}\n")},
|
||||
@@ -206,27 +414,24 @@ func TestFacadeInheritanceFailureStopsLaterCallbacks(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeInheritanceResolvesAfterImportsAndCLIReferenceChange(t *testing.T) {
|
||||
func TestFacadeCLIOverridesCannotChangeInheritanceRelationship(t *testing.T) {
|
||||
filesystem := configkit.NewFilesystem("bundle", fstest.MapFS{
|
||||
"main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: parent.yml}]}\nentries: {child: {extends: first}}\n")},
|
||||
"parent.yml": &fstest.MapFile{Data: []byte("entries: {first: {count: 1}, second: {count: 2}}\n")},
|
||||
})
|
||||
result, err := configkit.Load(filesystem.File("main.yml"), configkit.Options[inheritedConfiguration]{
|
||||
_, err := configkit.Load(filesystem.File("main.yml"), configkit.Options[inheritedConfiguration]{
|
||||
Imports: true, Overrides: []configkit.Override{{Path: configkit.Keys("entries", "child", "extends"), Value: "second", Flag: "--extends"}},
|
||||
})
|
||||
if err != nil || result.Config.Entries["child"].Count != 2 {
|
||||
t.Fatalf("import and CLI reference = %#v, %v", result, err)
|
||||
}
|
||||
history := result.Metadata().History(configkit.Keys("entries", "child", "extends"))
|
||||
if len(history) < 3 || history[len(history)-1].Origin.Operation != configkit.ChangeInheritance {
|
||||
t.Fatalf("superseded reference history = %#v", history)
|
||||
loadError, ok := err.(*configkit.LoadError)
|
||||
if !ok || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != configkit.CodeOverrideDirective {
|
||||
t.Fatalf("CLI inheritance relationship change = %v", err)
|
||||
}
|
||||
conflicting := configkit.NewFilesystem("bundle", fstest.MapFS{
|
||||
"main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: parent.yml}]}\nentries: {child: {extends: first}, first: {count: 3}}\n")},
|
||||
"parent.yml": &fstest.MapFile{Data: []byte("entries: {first: {count: 1}}\n")},
|
||||
})
|
||||
_, err = configkit.Load[inheritedConfiguration](conflicting.File("main.yml"), configkit.Options[inheritedConfiguration]{Imports: true})
|
||||
loadError, ok := err.(*configkit.LoadError)
|
||||
loadError, ok = err.(*configkit.LoadError)
|
||||
if !ok || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != configkit.CodeComposeConflict {
|
||||
t.Fatalf("strict conflict before expansion = %v", err)
|
||||
}
|
||||
|
||||
69
facade_item_registration_scope_test.go
Normal file
69
facade_item_registration_scope_test.go
Normal file
@@ -0,0 +1,69 @@
|
||||
package configkit_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
func TestFacadeItemScopesDoNotReinterpretGlobalRegistrations(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, layered := range []bool{false, true} {
|
||||
for _, dynamic := range []bool{false, true} {
|
||||
for _, missingOuterID := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("layered=%t/dynamic=%t/missing-outer-id=%t", layered, dynamic, missingOuterID), func(t *testing.T) {
|
||||
payload := "prompts: [{text: helper}]\n"
|
||||
unused := configkit.SequenceIdentityAt(configkit.Keys("prompts"), "id")
|
||||
if dynamic {
|
||||
payload = "catalog: {local: {prompts: [{text: helper}]}}\n"
|
||||
unused = configkit.SequenceIdentityMatching(configkit.SequenceSelector{
|
||||
configkit.SequenceLiteralKey("catalog"), configkit.SequenceAnyMappingKey(), configkit.SequenceLiteralKey("prompts"),
|
||||
}, "id")
|
||||
}
|
||||
item := "configkit: {imports: [{path: helper.yml}, {path: child.yml, destination: [children], placement: sequence-item}]}\n"
|
||||
if !missingOuterID {
|
||||
item += "id: outer\n"
|
||||
}
|
||||
files := configkit.NewFilesystem("items", fstest.MapFS{
|
||||
"main.yml": {Data: []byte("configkit: {imports: [{path: item.yml, destination: [items], placement: sequence-item}]}\n")},
|
||||
"item.yml": {Data: []byte(item)}, "helper.yml": {Data: []byte(payload)},
|
||||
"child.yml": {Data: []byte("id: child\nconfigkit: {imports: [{path: child-helper.yml}]}\n")},
|
||||
"child-helper.yml": {Data: []byte(payload)},
|
||||
})
|
||||
definition := configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{
|
||||
unused, configkit.SequenceIdentityAt(configkit.Keys("items"), "id"),
|
||||
}}
|
||||
var result *configkit.Result[map[string]any]
|
||||
var err error
|
||||
if layered {
|
||||
result, err = configkit.LoadLayered(configkit.Input{Source: files.File("main.yml"), Imports: true}, configkit.LayeredOptions[map[string]any]{Definition: definition})
|
||||
} else {
|
||||
result, err = configkit.Load(files.File("main.yml"), configkit.Options[map[string]any]{Imports: true, Definition: definition})
|
||||
}
|
||||
if missingOuterID {
|
||||
failure := requireFacadeLoadError(t, err)
|
||||
if result != nil || len(failure.Diagnostics()) != 1 || failure.Diagnostics()[0].Code != configkit.CodeComposeInvalid || failure.Diagnostics()[0].Path.String() != "/items/0/id" {
|
||||
t.Fatalf("outer constraint diagnostics = %#v", failure.Diagnostics())
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, record := range []configkit.Path{configkit.Keys("items").Index(0), configkit.Keys("items").Index(0).Key("children").Index(0)} {
|
||||
path := record
|
||||
if dynamic {
|
||||
path = path.Key("catalog").Key("local")
|
||||
}
|
||||
info, found := result.Metadata().Lookup(path.Key("prompts").Index(0).Key("text"))
|
||||
if !found || !info.Explicit {
|
||||
t.Fatalf("nested helper payload lost at %s", path)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
64
facade_item_version_test.go
Normal file
64
facade_item_version_test.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package configkit_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
func TestFacadeItemDomainVersionsKeepEffectiveScope(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name, item, child, helper string
|
||||
underlay, mainItem bool
|
||||
}{
|
||||
{name: "item", item: "id: item\nversion: domain\n"},
|
||||
{name: "helper", item: "id: item\nconfigkit: {imports: [{path: helper.yml}]}\n", helper: "version: domain\n"},
|
||||
{name: "nested", item: "id: parent\nconfigkit: {imports: [{path: child.yml, destination: [children], placement: sequence-item}]}\n", child: "id: child\nversion: domain\n"},
|
||||
{name: "underlay", item: "id: item\nversion: domain\n", underlay: true},
|
||||
{name: "main-item", item: "id: parent\nconfigkit: {imports: [{path: child.yml, destination: [children], placement: sequence-item}]}\n", child: "id: child\nversion: domain\n", mainItem: true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
files := fstest.MapFS{
|
||||
"main.yml": {Data: []byte("version: 1\nconfigkit: {imports: [{path: item.yml, destination: [items], placement: sequence-item}]}\n")},
|
||||
"item.yml": {Data: []byte(test.item)},
|
||||
"child.yml": {Data: []byte(test.child)},
|
||||
"helper.yml": {Data: []byte(test.helper)},
|
||||
"underlay.yml": {Data: []byte("configkit: {imports: [{path: item.yml, destination: [items], placement: sequence-item}]}\n")},
|
||||
}
|
||||
filesystem := configkit.NewFilesystem("items", files)
|
||||
definition := configkit.Definition[map[string]any]{Version: &configkit.VersionRule{
|
||||
Path: configkit.Keys("version"), Check: func(configkit.Version, *configkit.Validation) error { return nil },
|
||||
}}
|
||||
var result *configkit.Result[map[string]any]
|
||||
var err error
|
||||
switch {
|
||||
case test.underlay:
|
||||
result, err = configkit.LoadLayered(configkit.Input{Source: configkit.Mapping("main", map[string]any{"version": "1"})}, configkit.LayeredOptions[map[string]any]{
|
||||
Definition: definition, Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: filesystem.File("underlay.yml"), Imports: true}}}},
|
||||
})
|
||||
case test.mainItem:
|
||||
result, err = configkit.Load(filesystem.File("item.yml"), configkit.Options[map[string]any]{
|
||||
Imports: true, Destination: configkit.Keys("items"), PlacementMode: configkit.PlacementModeSequenceItem, Definition: definition,
|
||||
})
|
||||
default:
|
||||
result, err = configkit.Load(filesystem.File("main.yml"), configkit.Options[map[string]any]{Imports: true, Definition: definition})
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
item := result.Config["items"].([]any)[0].(map[string]any)
|
||||
fieldPath := configkit.Keys("items").Index(0).Key("version")
|
||||
if test.child != "" {
|
||||
item = item["children"].([]any)[0].(map[string]any)
|
||||
fieldPath = configkit.Keys("items").Index(0).Key("children").Index(0).Key("version")
|
||||
}
|
||||
if item["version"] != "domain" {
|
||||
t.Fatalf("domain version = %#v", item)
|
||||
}
|
||||
if info, ok := result.Metadata().Lookup(fieldPath); !ok || !info.Explicit {
|
||||
t.Fatalf("version metadata = %#v, %v", info, ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,12 @@ package configkit_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
@@ -219,3 +221,77 @@ func TestFacadeSyntheticSourceContextsAndContextlessSources(t *testing.T) {
|
||||
t.Fatal("CLI source returned a context")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeRelativeImportDestinationsPreserveNativeResourceContexts(t *testing.T) {
|
||||
borrowed := &observedResourceFS{files: fstest.MapFS{
|
||||
"bundle/config/main.yml": &fstest.MapFile{Data: []byte("configkit:\n imports:\n - path: entries/parent.yml\n destination: [logical]\n")},
|
||||
"bundle/config/entries/parent.yml": &fstest.MapFile{Data: []byte("configkit:\n imports:\n - path: nested/details.yml\n destination: [details]\nresource: icon.txt\n")},
|
||||
"bundle/config/entries/nested/details.yml": &fstest.MapFile{Data: []byte("resource: detail.txt\n")},
|
||||
"bundle/config/entries/icon.txt": &fstest.MapFile{Data: []byte("parent-resource")},
|
||||
"bundle/config/entries/nested/detail.txt": &fstest.MapFile{Data: []byte("nested-resource")},
|
||||
}}
|
||||
namespace := configkit.NewFilesystem("bundle", borrowed)
|
||||
input := configkit.Input{
|
||||
Source: namespace.File("bundle/config/main.yml"), Imports: true,
|
||||
Filesystem: &configkit.FilesystemContext{Root: "bundle", FS: &namespace},
|
||||
}
|
||||
load := func() *configkit.Result[map[string]any] {
|
||||
t.Helper()
|
||||
result, err := configkit.LoadLayered[map[string]any](input)
|
||||
if err != nil {
|
||||
t.Fatalf("load relative native imports: %v", err)
|
||||
}
|
||||
return result
|
||||
}
|
||||
result := load()
|
||||
wantDestinations := []string{configkit.Keys().String(), "/logical", "/logical/details"}
|
||||
wantPaths := []string{"bundle/config/main.yml", "bundle/config/entries/parent.yml", "bundle/config/entries/nested/details.yml"}
|
||||
sources := result.Sources()
|
||||
if len(sources) != len(wantPaths) {
|
||||
t.Fatalf("sources = %#v", sources)
|
||||
}
|
||||
for index, source := range sources {
|
||||
if source.ID != fmt.Sprintf("source-%d", index+1) || source.Kind != configkit.SourceKindFilesystem || source.Path != wantPaths[index] || source.Destination.String() != wantDestinations[index] {
|
||||
t.Errorf("source %d = %#v", index, source)
|
||||
}
|
||||
if index == 0 {
|
||||
if source.ImportedFrom != nil {
|
||||
t.Errorf("entry source has import location %#v", source.ImportedFrom)
|
||||
}
|
||||
} else if source.ImportedFrom == nil || source.ImportedFrom.SourceID != fmt.Sprintf("source-%d", index) || source.ImportedFrom.Line == 0 || source.ImportedFrom.Column == 0 {
|
||||
t.Errorf("source %d import location = %#v", index, source.ImportedFrom)
|
||||
}
|
||||
}
|
||||
|
||||
for _, test := range []struct {
|
||||
field, effectivePath, wantBase, wantResource, wantContents string
|
||||
}{
|
||||
{"parent resource", "/logical/resource", "config/entries", "icon.txt", "parent-resource"},
|
||||
{"nested resource", "/logical/details/resource", "config/entries/nested", "detail.txt", "nested-resource"},
|
||||
} {
|
||||
info, ok := result.Metadata().Lookup(configkit.Keys(strings.Split(strings.Trim(test.effectivePath, "/"), "/")...))
|
||||
if !ok || len(info.Origin.Locations) != 1 {
|
||||
t.Fatalf("%s origin = %#v, %v", test.field, info, ok)
|
||||
}
|
||||
context, ok := result.SourceContext(info.Origin.Locations[0].SourceID)
|
||||
if !ok || context.BaseDir != test.wantBase || context.FS == nil {
|
||||
t.Fatalf("%s context = %#v, %v", test.field, context, ok)
|
||||
}
|
||||
contents, err := fs.ReadFile(context.FS, path.Join(context.BaseDir, test.wantResource))
|
||||
if err != nil || string(contents) != test.wantContents {
|
||||
t.Fatalf("%s opened resource = %q, %v", test.field, contents, err)
|
||||
}
|
||||
}
|
||||
|
||||
sources[1].Destination = configkit.Keys("caller", "mutation")
|
||||
for query := 0; query < 2; query++ {
|
||||
fresh := result.Sources()
|
||||
if fresh[1].Destination.String() != "/logical" {
|
||||
t.Fatalf("query %d source destination = %q", query, fresh[1].Destination.String())
|
||||
}
|
||||
}
|
||||
second := load()
|
||||
if !reflect.DeepEqual(second.Sources(), result.Sources()) {
|
||||
t.Fatalf("independent source records differ: %#v vs %#v", second.Sources(), result.Sources())
|
||||
}
|
||||
}
|
||||
|
||||
240
facade_sequence_identity_test.go
Normal file
240
facade_sequence_identity_test.go
Normal file
@@ -0,0 +1,240 @@
|
||||
package configkit_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
func TestFacadeSequenceIdentitySnapshotsSelectorsAndFields(t *testing.T) {
|
||||
fields := []string{"namespace", "id", "id"}
|
||||
selector := configkit.SequenceSelector{
|
||||
configkit.SequenceLiteralKey("collections"),
|
||||
configkit.SequenceAnyMappingKey(),
|
||||
configkit.SequenceLiteralKey("prompts"),
|
||||
}
|
||||
identity := configkit.SequenceIdentityMatching(selector, fields...)
|
||||
selector[1] = configkit.SequenceLiteralKey("other")
|
||||
fields[0] = "changed"
|
||||
definition := configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{identity}}
|
||||
|
||||
underlay := configkit.Mapping("underlay", map[string]any{"collections": map[string]any{
|
||||
"team": map[string]any{"prompts": []any{map[string]any{"namespace": "team", "id": "kept"}}},
|
||||
}})
|
||||
main := configkit.Mapping("main", map[string]any{"collections": map[string]any{
|
||||
"team": map[string]any{"prompts": []any{map[string]any{"namespace": "team", "id": "added"}}},
|
||||
}})
|
||||
loaded, err := configkit.LoadLayered(configkit.Input{Source: main}, configkit.LayeredOptions[map[string]any]{
|
||||
Definition: definition,
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: underlay}}}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("load after mutating constructor inputs: %v", err)
|
||||
}
|
||||
prompts := loaded.Config["collections"].(map[string]any)["team"].(map[string]any)["prompts"].([]any)
|
||||
if len(prompts) != 2 {
|
||||
t.Fatalf("registered compound identity did not append records: %#v", prompts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeSequenceIdentityUsesExactAndDynamicSelectors(t *testing.T) {
|
||||
dynamic := configkit.SequenceIdentityMatching(configkit.SequenceSelector{
|
||||
configkit.SequenceLiteralKey("collections"),
|
||||
configkit.SequenceAnyMappingKey(),
|
||||
configkit.SequenceLiteralKey("prompts"),
|
||||
}, "id")
|
||||
definition := configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{
|
||||
configkit.SequenceIdentityAt(configkit.Keys("prompts"), "namespace", "id"),
|
||||
dynamic,
|
||||
}}
|
||||
underlay := configkit.Mapping("underlay", map[string]any{
|
||||
"prompts": []any{map[string]any{"namespace": "alpha", "id": "same"}},
|
||||
"collections": map[string]any{
|
||||
"team": map[string]any{"prompts": []any{map[string]any{"id": "team-one"}}},
|
||||
"cloud": map[string]any{"prompts": []any{map[string]any{"id": "cloud-one"}}},
|
||||
},
|
||||
})
|
||||
main := configkit.Mapping("main", map[string]any{
|
||||
"prompts": []any{map[string]any{"namespace": "beta", "id": "same"}},
|
||||
"collections": map[string]any{
|
||||
"team": map[string]any{"prompts": []any{map[string]any{"id": "team-two"}}},
|
||||
"cloud": map[string]any{"prompts": []any{map[string]any{"id": "cloud-two"}}},
|
||||
},
|
||||
})
|
||||
loaded, err := configkit.LoadLayered(configkit.Input{Source: main}, configkit.LayeredOptions[map[string]any]{
|
||||
Definition: definition,
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: underlay}}}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("load mixed exact and dynamic registrations: %v", err)
|
||||
}
|
||||
rootRecords := loaded.Config["prompts"].([]any)
|
||||
if len(rootRecords) != 2 {
|
||||
t.Fatalf("compound root identities = %#v", rootRecords)
|
||||
}
|
||||
collections := loaded.Config["collections"].(map[string]any)
|
||||
for _, name := range []string{"team", "cloud"} {
|
||||
prompts := collections[name].(map[string]any)["prompts"].([]any)
|
||||
if len(prompts) != 2 {
|
||||
t.Errorf("dynamic prompts for %q = %#v", name, prompts)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeSequenceIdentityLiteralStarIsNotWildcard(t *testing.T) {
|
||||
definition := configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{
|
||||
configkit.SequenceIdentityMatching(configkit.SequenceSelector{
|
||||
configkit.SequenceLiteralKey("collections"),
|
||||
configkit.SequenceLiteralKey("*"),
|
||||
configkit.SequenceLiteralKey("prompts"),
|
||||
}, "id"),
|
||||
}}
|
||||
underlay := configkit.Mapping("underlay", map[string]any{"collections": map[string]any{
|
||||
"*": map[string]any{"prompts": []any{map[string]any{"id": "one"}}},
|
||||
"team": map[string]any{"prompts": []any{map[string]any{"id": "team-one"}}},
|
||||
}})
|
||||
main := configkit.Mapping("main", map[string]any{"collections": map[string]any{
|
||||
"*": map[string]any{"prompts": []any{map[string]any{"id": "two"}}},
|
||||
"team": map[string]any{"prompts": []any{map[string]any{"id": "team-two"}}},
|
||||
}})
|
||||
_, err := configkit.LoadLayered(configkit.Input{Source: configkit.Mapping("main", map[string]any{})}, configkit.LayeredOptions[map[string]any]{
|
||||
Definition: definition,
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: underlay}, {Source: main}}}},
|
||||
})
|
||||
loadErr := requireFacadeLoadError(t, err)
|
||||
if len(loadErr.Diagnostics()) == 0 || loadErr.Diagnostics()[0].Path.String() != "/collections/team/prompts" {
|
||||
t.Fatalf("literal star registration also matched another key: %#v", loadErr.Diagnostics())
|
||||
}
|
||||
|
||||
wildcard := configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{
|
||||
configkit.SequenceIdentityMatching(configkit.SequenceSelector{
|
||||
configkit.SequenceLiteralKey("collections"),
|
||||
configkit.SequenceAnyMappingKey(),
|
||||
configkit.SequenceLiteralKey("prompts"),
|
||||
}, "id"),
|
||||
}}
|
||||
loaded, err := configkit.LoadLayered(configkit.Input{Source: configkit.Mapping("main", map[string]any{})}, configkit.LayeredOptions[map[string]any]{
|
||||
Definition: wildcard,
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: underlay}, {Source: main}}}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("wildcard registration did not match mapping keys: %v", err)
|
||||
}
|
||||
for name, raw := range loaded.Config["collections"].(map[string]any) {
|
||||
if got := len(raw.(map[string]any)["prompts"].([]any)); got != 2 {
|
||||
t.Errorf("wildcard result for %q has %d records", name, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeSequenceIdentityRejectsInvalidDefinitionsBeforeReadingSource(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
identity configkit.SequenceIdentity
|
||||
wantPath string
|
||||
}{
|
||||
{
|
||||
name: "index path",
|
||||
identity: configkit.SequenceIdentityAt(configkit.Keys("records").Index(0), "id"),
|
||||
wantPath: "/records/0",
|
||||
},
|
||||
{
|
||||
name: "empty fields",
|
||||
identity: configkit.SequenceIdentityAt(configkit.Keys("records")),
|
||||
wantPath: "/records",
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
reader := strings.NewReader("records: []\n")
|
||||
_, err := configkit.Load(configkit.Reader("input.yml", reader), configkit.Options[map[string]any]{
|
||||
Definition: configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{test.identity}},
|
||||
})
|
||||
loadErr := requireFacadeLoadError(t, err)
|
||||
if len(loadErr.Diagnostics()) != 1 || loadErr.Diagnostics()[0].Code != configkit.CodeOptionsInvalid || loadErr.Diagnostics()[0].Path.String() != test.wantPath {
|
||||
t.Fatalf("definition diagnostics = %#v", loadErr.Diagnostics())
|
||||
}
|
||||
if reader.Len() == 0 {
|
||||
t.Fatal("source was read before registration validation")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeSequenceIdentityOverlapFailsBeforeSourceAccess(t *testing.T) {
|
||||
selector := configkit.SequenceSelector{configkit.SequenceLiteralKey("collections"), configkit.SequenceAnyMappingKey()}
|
||||
definition := configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{
|
||||
configkit.SequenceIdentityMatching(selector, "id"),
|
||||
configkit.SequenceIdentityMatching(configkit.SequenceSelector{
|
||||
configkit.SequenceLiteralKey("collections"), configkit.SequenceLiteralKey("team"),
|
||||
}, "slug"),
|
||||
}}
|
||||
reader := strings.NewReader("collections: {}\n")
|
||||
_, err := configkit.Load(configkit.Reader("input.yml", reader), configkit.Options[map[string]any]{Definition: definition})
|
||||
loadErr := requireFacadeLoadError(t, err)
|
||||
if len(loadErr.Diagnostics()) == 0 || loadErr.Diagnostics()[0].Code != configkit.CodeOptionsInvalid {
|
||||
t.Fatalf("overlap diagnostics = %#v", loadErr.Diagnostics())
|
||||
}
|
||||
if reader.Len() == 0 {
|
||||
t.Fatal("source was read before overlapping registrations were rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeSequenceIdentityConstraintsApplyAtInheritedDestinations(t *testing.T) {
|
||||
definition := configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{
|
||||
configkit.SequenceIdentityMatching(configkit.SequenceSelector{
|
||||
configkit.SequenceLiteralKey("entries"),
|
||||
configkit.SequenceLiteralKey("child"),
|
||||
configkit.SequenceLiteralKey("collections"),
|
||||
configkit.SequenceAnyMappingKey(),
|
||||
configkit.SequenceLiteralKey("prompts"),
|
||||
}, "id"),
|
||||
}}
|
||||
loaded, err := configkit.Load(configkit.Bytes("main", []byte(`jobs:
|
||||
entries:
|
||||
parent:
|
||||
collections:
|
||||
team:
|
||||
prompts: [{id: inherited, text: base}]
|
||||
child:
|
||||
extends: parent
|
||||
collections:
|
||||
team:
|
||||
prompts: [{id: added, text: child}]
|
||||
`)), configkit.Options[map[string]any]{Definition: definition})
|
||||
if err != nil {
|
||||
t.Fatalf("load with dynamic inherited destination: %v", err)
|
||||
}
|
||||
child := loaded.Config["entries"].(map[string]any)["child"].(map[string]any)
|
||||
prompts := child["collections"].(map[string]any)["team"].(map[string]any)["prompts"].([]any)
|
||||
if len(prompts) != 2 {
|
||||
t.Fatalf("inherited dynamic collection = %#v, want parent and child records", prompts)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeSequenceIdentityLoadsUseIndependentScopes(t *testing.T) {
|
||||
definition := configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{
|
||||
configkit.SequenceIdentityMatching(configkit.SequenceSelector{
|
||||
configkit.SequenceLiteralKey("collections"), configkit.SequenceAnyMappingKey(),
|
||||
}, "id"),
|
||||
}}
|
||||
for _, source := range []string{"first", "second"} {
|
||||
loaded, err := configkit.Load(configkit.Mapping(source, map[string]any{"collections": map[string]any{
|
||||
source: []any{map[string]any{"id": "reused"}},
|
||||
}}), configkit.Options[map[string]any]{Definition: definition})
|
||||
if err != nil || len(loaded.Config["collections"].(map[string]any)[source].([]any)) != 1 {
|
||||
t.Fatalf("independent load %q = %#v, %v", source, loaded, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func requireFacadeLoadError(t *testing.T, err error) *configkit.LoadError {
|
||||
t.Helper()
|
||||
var loadErr *configkit.LoadError
|
||||
if !errors.As(err, &loadErr) {
|
||||
t.Fatalf("error = %v, want *LoadError", err)
|
||||
}
|
||||
return loadErr
|
||||
}
|
||||
699
facade_sequence_test.go
Normal file
699
facade_sequence_test.go
Normal file
@@ -0,0 +1,699 @@
|
||||
package configkit_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
func TestFacadeNoSourcePreservesAbsenceWithSequencePolicy(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, defaults := range []bool{false, true} {
|
||||
for _, registered := range []bool{false, true} {
|
||||
definition := configkit.Definition[map[string]any]{}
|
||||
if defaults {
|
||||
definition.Defaults = func() map[string]any { return map[string]any{"fallback": true} }
|
||||
}
|
||||
if registered {
|
||||
definition.SequenceIdentities = []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("records"), "id")}
|
||||
}
|
||||
ordinary, err := configkit.Load(configkit.NoSource(), configkit.Options[map[string]any]{Definition: definition})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
layered, err := configkit.LoadLayered[map[string]any](configkit.Input{Source: configkit.NoSource()}, configkit.LayeredOptions[map[string]any]{Definition: definition})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(ordinary.Config, layered.Config) {
|
||||
t.Fatal("NoSource values differ between entry points")
|
||||
}
|
||||
for _, result := range []*configkit.Result[map[string]any]{ordinary, layered} {
|
||||
if len(result.Sources()) != 0 || result.Metadata().IsExplicit(configkit.Keys()) {
|
||||
t.Fatal("NoSource contributed an explicit root")
|
||||
}
|
||||
info, ok := result.Metadata().Lookup(configkit.Keys())
|
||||
if !ok || info.Origin.Layer != configkit.LayerDefault {
|
||||
t.Fatalf("NoSource root origin = %#v, %t", info, ok)
|
||||
}
|
||||
}
|
||||
empty, err := configkit.Load(configkit.Mapping("empty", map[string]any{}), configkit.Options[map[string]any]{Definition: definition})
|
||||
if err != nil || !empty.Metadata().IsExplicit(configkit.Keys()) {
|
||||
t.Fatal("real empty mapping lost its presence")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeItemHelperHistoryPreservesScheduledPlacement(t *testing.T) {
|
||||
t.Parallel()
|
||||
filesystem := configkit.NewFilesystem("history", fstest.MapFS{
|
||||
"main.yml": {Data: []byte("records: [{id: authored}]\nconfigkit: {imports: [{path: one.yml, destination: [records], placement: sequence-item}, {path: two.yml, destination: [records], placement: sequence-item}]}\n")},
|
||||
"one.yml": {Data: []byte("id: one\nconfigkit: {imports: [{path: helper-one.yml}, {path: child.yml, destination: [children], placement: sequence-item}]}\n")},
|
||||
"two.yml": {Data: []byte("id: two\nconfigkit: {imports: [{path: helper-two.yml}]}\n")},
|
||||
"helper-one.yml": {Data: []byte("text: first helper\n")},
|
||||
"helper-two.yml": {Data: []byte("text: second helper\n")},
|
||||
"child.yml": {Data: []byte("id: child\nconfigkit: {imports: [{path: child-helper.yml}]}\n")},
|
||||
"child-helper.yml": {Data: []byte("text: nested helper\n")},
|
||||
})
|
||||
result, err := configkit.Load(filesystem.File("main.yml"), configkit.Options[map[string]any]{
|
||||
Imports: true, Definition: configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("records"), "id")}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sourceFiles := make(map[string]string)
|
||||
for _, source := range result.Sources() {
|
||||
sourceFiles[source.ID] = source.Path
|
||||
}
|
||||
for _, test := range []struct {
|
||||
path configkit.Path
|
||||
file, text string
|
||||
}{
|
||||
{configkit.Keys("records").Index(0).Key("text"), "helper-one.yml", "first helper\n"},
|
||||
{configkit.Keys("records").Index(1).Key("text"), "helper-two.yml", "second helper\n"},
|
||||
{configkit.Keys("records").Index(0).Key("children").Index(0).Key("text"), "child-helper.yml", "nested helper\n"},
|
||||
} {
|
||||
explanation, ok := result.Explain(test.path)
|
||||
if !ok || explanation.Effective.YAML != test.text || len(explanation.History) != 1 {
|
||||
t.Fatalf("helper history at %s = %#v", test.path.String(), explanation)
|
||||
}
|
||||
locations := explanation.History[0].Origin.Locations
|
||||
if len(locations) != 1 || sourceFiles[locations[0].SourceID] != test.file {
|
||||
t.Fatalf("helper origin at %s = %#v", test.path.String(), locations)
|
||||
}
|
||||
info, ok := result.Metadata().Lookup(test.path)
|
||||
if !ok || len(info.Origin.Locations) != 1 || info.Origin.Locations[0].SourceID != locations[0].SourceID {
|
||||
t.Fatal("metadata and explanation disagree")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeRegisteredReplacementExplainsRemovedDescendants(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
upper []any
|
||||
path configkit.Path
|
||||
}{
|
||||
{"shorter", []any{map[string]any{"id": "main"}}, configkit.Keys("records").Index(1).Key("id")},
|
||||
{"empty", []any{}, configkit.Keys("records").Index(0).Key("id")},
|
||||
{"removed field", []any{map[string]any{"id": "main"}}, configkit.Keys("records").Index(0).Key("text")},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
mainYAML := "configkit:\n sequences:\n - destination: [records]\n key: id\n mode: replace\n"
|
||||
if len(test.upper) == 0 {
|
||||
mainYAML += "records: []\n"
|
||||
} else {
|
||||
mainYAML += "records: [{id: main}]\n"
|
||||
}
|
||||
result, err := configkit.LoadLayered[map[string]any](configkit.Input{Source: configkit.Bytes("main", []byte(mainYAML))}, configkit.LayeredOptions[map[string]any]{
|
||||
Definition: configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("records"), "id")}},
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.Mapping("lower", map[string]any{"records": []any{map[string]any{"id": "one", "text": "old"}, map[string]any{"id": "two"}}})}}}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
explanation, ok := result.Explain(test.path)
|
||||
if !ok || explanation.Effective.Present || len(explanation.History) < 2 {
|
||||
t.Fatalf("removal not explained: %#v", explanation)
|
||||
}
|
||||
last := explanation.History[len(explanation.History)-1]
|
||||
if !last.Before.Present || last.After.Present || last.Origin.Layer != configkit.LayerBase || len(last.Origin.Locations) != 1 || last.Origin.Locations[0].SourceID != result.Sources()[0].ID {
|
||||
t.Fatalf("wrong removal transition/origin: %#v", last)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeItemConflictDiagnosticsFollowEnclosingIndexes(t *testing.T) {
|
||||
t.Parallel()
|
||||
filesystem := configkit.NewFilesystem("conflicts", fstest.MapFS{
|
||||
"main.yml": {Data: []byte("records: [{id: authored}]\nconfigkit: {imports: [{path: good.yml, destination: [records], placement: sequence-item}, {path: bad.yml, destination: [records], placement: sequence-item}]}\n")},
|
||||
"group.yml": {Data: []byte("records: [{id: authored}]\nconfigkit: {imports: [{path: bad.yml, destination: [records], placement: sequence-item}]}\n")},
|
||||
"nested.yml": {Data: []byte("records: [{id: authored}]\nconfigkit: {imports: [{path: parent.yml, destination: [records], placement: sequence-item}]}\n")},
|
||||
"parent.yml": {Data: []byte("id: parent\nchildren: [{id: authored-child}]\nconfigkit: {imports: [{path: bad.yml, destination: [children], placement: sequence-item}]}\n")},
|
||||
"good.yml": {Data: []byte("id: good\n")},
|
||||
"bad.yml": {Data: []byte("id: bad\nconfigkit: {imports: [{path: helper.yml}]}\n")},
|
||||
"helper.yml": {Data: []byte("id: duplicate-field\n")},
|
||||
})
|
||||
definition := configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("records"), "id")}}
|
||||
for _, test := range []struct {
|
||||
name, file, path string
|
||||
grouped, itemMain bool
|
||||
}{
|
||||
{"preceding authored and imported items", "main.yml", "/records/0/id", false, false},
|
||||
{"nested item", "nested.yml", "/records/0/children/0/id", false, false},
|
||||
{"preceding grouped input", "group.yml", "/records/1/id", true, false},
|
||||
{"item main source", "bad.yml", "/records/0/id", false, true},
|
||||
{"grouped item source", "bad.yml", "/records/1/id", true, true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var err error
|
||||
input := configkit.Input{Source: filesystem.File(test.file), Imports: true}
|
||||
if test.itemMain {
|
||||
input.Destination = configkit.Keys("records")
|
||||
input.PlacementMode = configkit.PlacementModeSequenceItem
|
||||
}
|
||||
if test.grouped {
|
||||
_, err = configkit.LoadLayered[map[string]any](configkit.Input{Source: configkit.NoSource()}, configkit.LayeredOptions[map[string]any]{
|
||||
Definition: definition, Underlays: []configkit.Underlay{{Inputs: []configkit.Input{
|
||||
{Source: configkit.Mapping("prior", map[string]any{"records": []any{map[string]any{"id": "prior"}}})},
|
||||
input,
|
||||
}}},
|
||||
})
|
||||
} else {
|
||||
_, err = configkit.Load(input.Source, configkit.Options[map[string]any]{Definition: definition, Imports: true, Destination: input.Destination, PlacementMode: input.PlacementMode})
|
||||
}
|
||||
loadError, ok := err.(*configkit.LoadError)
|
||||
if !ok || len(loadError.Diagnostics()) != 1 {
|
||||
t.Fatalf("expected helper conflict: %v", err)
|
||||
}
|
||||
diagnostic := loadError.Diagnostics()[0]
|
||||
if diagnostic.Code != configkit.CodeComposeConflict || diagnostic.Path.String() != test.path {
|
||||
t.Fatalf("diagnostic = %s %s, want %s", diagnostic.Code, diagnostic.Path.String(), test.path)
|
||||
}
|
||||
files := make(map[string]string)
|
||||
for _, source := range loadError.Sources() {
|
||||
files[source.ID] = source.Path
|
||||
}
|
||||
locations := append([]configkit.Location(nil), diagnostic.Related...)
|
||||
if diagnostic.Primary != nil {
|
||||
locations = append(locations, *diagnostic.Primary)
|
||||
}
|
||||
gotFiles := make(map[string]bool, len(locations))
|
||||
for _, location := range locations {
|
||||
gotFiles[files[location.SourceID]] = location.Line == 1 && location.Column == 5
|
||||
}
|
||||
if len(locations) != 2 || !gotFiles["bad.yml"] || !gotFiles["helper.yml"] {
|
||||
t.Fatalf("conflict lost physical source locations: diagnostic=%#v sources=%#v", diagnostic, files)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeSequencePlacementControlsAcrossLoadEntryPoints(t *testing.T) {
|
||||
definition := configkit.Definition[map[string]any]{
|
||||
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("entries"), "id")},
|
||||
}
|
||||
ordinary, err := configkit.Load(configkit.Mapping("ordinary", map[string]any{"id": "one"}), configkit.Options[map[string]any]{
|
||||
Definition: definition, Destination: configkit.Keys("entries"), PlacementMode: configkit.PlacementModeSequenceItem,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ordinary item load: %v", err)
|
||||
}
|
||||
if got := ordinary.Config["entries"].([]any); len(got) != 1 || got[0].(map[string]any)["id"] != "one" {
|
||||
t.Fatalf("ordinary item placement = %#v", ordinary.Config)
|
||||
}
|
||||
if sources := ordinary.Sources(); len(sources) != 1 || sources[0].PlacementMode != configkit.PlacementModeSequenceItem || sources[0].Destination.String() != "/entries" || sources[0].DestinationUnresolved {
|
||||
t.Fatalf("ordinary item source = %#v", sources)
|
||||
}
|
||||
|
||||
layered, err := configkit.LoadLayered[map[string]any](configkit.Input{Source: configkit.NoSource()}, configkit.LayeredOptions[map[string]any]{
|
||||
Definition: definition,
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{
|
||||
{Source: configkit.Mapping("first", map[string]any{"id": "first"}), Destination: configkit.Keys("entries"), PlacementMode: configkit.PlacementModeSequenceItem},
|
||||
{Source: configkit.Mapping("second", map[string]any{"id": "second"}), Destination: configkit.Keys("entries"), PlacementMode: configkit.PlacementModeSequenceItem},
|
||||
}}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("layered item load: %v", err)
|
||||
}
|
||||
items := layered.Config["entries"].([]any)
|
||||
if len(items) != 2 || items[0].(map[string]any)["id"] != "first" || items[1].(map[string]any)["id"] != "second" {
|
||||
t.Fatalf("layered item placement = %#v", layered.Config)
|
||||
}
|
||||
for _, source := range layered.Sources() {
|
||||
if source.PlacementMode != configkit.PlacementModeSequenceItem || source.Destination.String() != "/entries" || source.DestinationUnresolved {
|
||||
t.Errorf("layered item source = %#v", source)
|
||||
}
|
||||
}
|
||||
|
||||
precedence, err := configkit.LoadLayered[map[string]any](configkit.Input{Source: configkit.Mapping("main", map[string]any{
|
||||
"entries": []any{map[string]any{"id": "main"}},
|
||||
})}, configkit.LayeredOptions[map[string]any]{
|
||||
Definition: definition,
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{
|
||||
Source: configkit.Mapping("lower", map[string]any{"id": "lower"}), Destination: configkit.Keys("entries"), PlacementMode: configkit.PlacementModeSequenceItem,
|
||||
}}}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("registered sequence precedence load: %v", err)
|
||||
}
|
||||
if got := precedence.Config["entries"].([]any); len(got) != 2 || got[0].(map[string]any)["id"] != "lower" || got[1].(map[string]any)["id"] != "main" {
|
||||
t.Fatalf("registered upsert did not retain the lower sequence: %#v", precedence.Config)
|
||||
}
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
load func(configkit.Source) error
|
||||
}{
|
||||
{name: "ordinary", load: func(source configkit.Source) error {
|
||||
_, loadErr := configkit.Load(source, configkit.Options[map[string]any]{PlacementMode: configkit.PlacementMode(9)})
|
||||
return loadErr
|
||||
}},
|
||||
{name: "layered", load: func(source configkit.Source) error {
|
||||
_, loadErr := configkit.LoadLayered[map[string]any](configkit.Input{Source: source, PlacementMode: configkit.PlacementMode(9)})
|
||||
return loadErr
|
||||
}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
reader := &destinationCountingReader{}
|
||||
err := test.load(configkit.Reader("unread", reader))
|
||||
loadError, ok := err.(*configkit.LoadError)
|
||||
if !ok || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != configkit.CodeOptionsInvalid || reader.reads != 0 {
|
||||
t.Fatalf("invalid placement result = %v, reads = %d", err, reader.reads)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeItemImportGlobComposesWithAuthoredSequence(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := filepath.Join(directory, "main.yml")
|
||||
if err := os.WriteFile(mainPath, []byte(`configkit:
|
||||
imports:
|
||||
- glob: records/*.yml
|
||||
destination: [items]
|
||||
placement: sequence-item
|
||||
items:
|
||||
- id: authored
|
||||
name: authored item
|
||||
`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, id := range map[string]string{"b.yml": "imported-b", "a.yml": "imported-a"} {
|
||||
if err := os.MkdirAll(filepath.Join(directory, "records"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(directory, "records", name), []byte("id: "+id+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
var callbackSources []configkit.SourceInfo
|
||||
var callbackContexts []configkit.ResourceContext
|
||||
result, err := configkit.Load(configkit.File(mainPath), configkit.Options[map[string]any]{
|
||||
Imports: true,
|
||||
Definition: configkit.Definition[map[string]any]{
|
||||
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("items"), "id")},
|
||||
Resolve: func(_ *map[string]any, resolution *configkit.Resolution) error {
|
||||
for _, id := range []string{"source-2", "source-3"} {
|
||||
source, ok := resolution.Source(id)
|
||||
if !ok {
|
||||
t.Errorf("resolver could not query %s", id)
|
||||
continue
|
||||
}
|
||||
context, found := resolution.SourceContext(id)
|
||||
if !found || context.FS == nil || context.BaseDir != "records" {
|
||||
t.Errorf("resolver context for %s = %#v, %v", id, context, found)
|
||||
}
|
||||
callbackSources = append(callbackSources, source)
|
||||
callbackContexts = append(callbackContexts, context)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("load item glob: %v", err)
|
||||
}
|
||||
items := result.Config["items"].([]any)
|
||||
wantIDs := []string{"imported-a", "imported-b", "authored"}
|
||||
for index, item := range items {
|
||||
if got := item.(map[string]any)["id"]; got != wantIDs[index] {
|
||||
t.Errorf("item %d ID = %#v, want %q", index, got, wantIDs[index])
|
||||
}
|
||||
}
|
||||
if len(callbackSources) != 2 || len(callbackContexts) != 2 {
|
||||
t.Fatalf("callback snapshots = %#v, contexts %#v", callbackSources, callbackContexts)
|
||||
}
|
||||
for index, source := range callbackSources {
|
||||
if source.PlacementMode != configkit.PlacementModeSequenceItem || source.Destination.String() != "/items" || source.DestinationUnresolved {
|
||||
t.Errorf("callback source = %#v", source)
|
||||
}
|
||||
if callbackContexts[index].FS == nil || callbackContexts[index].BaseDir != "records" {
|
||||
t.Errorf("callback context = %#v", callbackContexts[index])
|
||||
}
|
||||
}
|
||||
metadata := result.Metadata()
|
||||
for index := 0; index < 2; index++ {
|
||||
path := configkit.Keys("items").Index(index).Key("id")
|
||||
info, ok := metadata.Lookup(path)
|
||||
if !ok || !info.Explicit || len(info.Origin.Locations) != 1 || info.Origin.Locations[0].SourceID != []string{"source-2", "source-3"}[index] {
|
||||
t.Errorf("shifted item origin at %s = %#v, %v", path.String(), info, ok)
|
||||
}
|
||||
}
|
||||
if got := result.Sources(); len(got) != 3 || got[1].Destination.String() != "/items" || got[1].DestinationUnresolved {
|
||||
t.Fatalf("final item sources = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeItemDestinationFallbackEvidenceSurvivesFailures(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := filepath.Join(directory, "main.yml")
|
||||
helperPath := filepath.Join(directory, "helper.yml")
|
||||
if err := os.WriteFile(mainPath, []byte("configkit: {imports: [{path: helper.yml, destination: [metadata]}]}\nid: main\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(helperPath, []byte("value: [unterminated\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := configkit.Load(configkit.File(mainPath), configkit.Options[map[string]any]{
|
||||
Imports: true, Destination: configkit.Keys("items"), PlacementMode: configkit.PlacementModeSequenceItem,
|
||||
})
|
||||
loadError, ok := err.(*configkit.LoadError)
|
||||
if !ok {
|
||||
t.Fatalf("early failure = %v", err)
|
||||
}
|
||||
sources := loadError.Sources()
|
||||
if len(sources) != 2 || sources[0].Destination.String() != "/items" || sources[0].DestinationUnresolved ||
|
||||
sources[0].PlacementMode != configkit.PlacementModeSequenceItem ||
|
||||
sources[1].Destination.String() != "/items" || !sources[1].DestinationUnresolved ||
|
||||
sources[1].PlacementMode != configkit.PlacementModeMapping {
|
||||
t.Fatalf("early source snapshots = %#v", sources)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(helperPath, []byte("value: invalid-for-typed-schema\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
type item struct {
|
||||
ID string `yaml:"id"`
|
||||
Metadata struct {
|
||||
Value int `yaml:"value"`
|
||||
} `yaml:"metadata"`
|
||||
}
|
||||
type configuration struct {
|
||||
Items []item `yaml:"items"`
|
||||
}
|
||||
_, err = configkit.Load(configkit.File(mainPath), configkit.Options[configuration]{
|
||||
Imports: true, Destination: configkit.Keys("items"), PlacementMode: configkit.PlacementModeSequenceItem,
|
||||
})
|
||||
loadError, ok = err.(*configkit.LoadError)
|
||||
if !ok {
|
||||
t.Fatalf("post-placement failure = %v", err)
|
||||
}
|
||||
sources = loadError.Sources()
|
||||
if len(sources) != 2 || sources[0].Destination.String() != "/items" || sources[0].DestinationUnresolved ||
|
||||
sources[1].Destination.String() != "/items/0/metadata" || sources[1].DestinationUnresolved {
|
||||
t.Fatalf("post-placement source snapshots = %#v", sources)
|
||||
}
|
||||
firstSnapshot := sources[1]
|
||||
sources[1].Destination = configkit.Keys("mutated")
|
||||
if again := loadError.Sources(); len(again) != 2 || again[1].Destination.String() != firstSnapshot.Destination.String() || again[1].DestinationUnresolved {
|
||||
t.Fatalf("failure source snapshots were aliased: %#v", again)
|
||||
}
|
||||
|
||||
var callback configkit.SourceInfo
|
||||
_, err = configkit.Load(configkit.File(mainPath), configkit.Options[map[string]any]{
|
||||
Imports: true, Destination: configkit.Keys("items"), PlacementMode: configkit.PlacementModeSequenceItem,
|
||||
Definition: configkit.Definition[map[string]any]{Resolve: func(_ *map[string]any, resolution *configkit.Resolution) error {
|
||||
callback, _ = resolution.Source("source-2")
|
||||
return nil
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("successful item load: %v", err)
|
||||
}
|
||||
if callback.Destination.String() != "/items/0/metadata" || callback.DestinationUnresolved || callback.PlacementMode != configkit.PlacementModeMapping {
|
||||
t.Fatalf("callback source snapshot = %#v", callback)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeNestedItemFallbackUsesNearestResolvedContainer(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := filepath.Join(directory, "main.yml")
|
||||
childPath := filepath.Join(directory, "child.yml")
|
||||
helperPath := filepath.Join(directory, "helper.yml")
|
||||
if err := os.WriteFile(mainPath, []byte("configkit: {imports: [{path: child.yml, destination: [children], placement: sequence-item}]}\nid: parent\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(childPath, []byte("configkit: {imports: [{path: helper.yml, destination: [metadata]}]}\nid: child\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(helperPath, []byte("value: [unterminated\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := configkit.Load(configkit.File(mainPath), configkit.Options[map[string]any]{
|
||||
Imports: true, Destination: configkit.Keys("groups"), PlacementMode: configkit.PlacementModeSequenceItem,
|
||||
})
|
||||
loadError, ok := err.(*configkit.LoadError)
|
||||
if !ok {
|
||||
t.Fatalf("nested acquisition failure = %v", err)
|
||||
}
|
||||
sources := loadError.Sources()
|
||||
if len(sources) != 3 {
|
||||
t.Fatalf("nested source snapshots = %#v", sources)
|
||||
}
|
||||
if got := sources[0]; got.Destination.String() != "/groups" || got.DestinationUnresolved || got.PlacementMode != configkit.PlacementModeSequenceItem {
|
||||
t.Errorf("outer item snapshot = %#v", got)
|
||||
}
|
||||
if got := sources[1]; got.Destination.String() != "/groups" || !got.DestinationUnresolved || got.PlacementMode != configkit.PlacementModeSequenceItem {
|
||||
t.Errorf("nested item fallback = %#v", got)
|
||||
}
|
||||
if got := sources[2]; got.Destination.String() != "/groups" || !got.DestinationUnresolved || got.PlacementMode != configkit.PlacementModeMapping {
|
||||
t.Errorf("nested helper fallback = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeSequenceIdentityRegistrationIsDetached(t *testing.T) {
|
||||
fields := []string{"id"}
|
||||
path := configkit.Keys("records")
|
||||
registration := configkit.SequenceIdentityAt(path, fields...)
|
||||
fields[0] = "changed"
|
||||
definition := configkit.Definition[map[string]any]{
|
||||
SequenceIdentities: []configkit.SequenceIdentity{registration},
|
||||
Version: &configkit.VersionRule{
|
||||
Path: configkit.Keys("version"),
|
||||
Check: func(configkit.Version, *configkit.Validation) error {
|
||||
path = configkit.Keys("other")
|
||||
return nil
|
||||
},
|
||||
},
|
||||
}
|
||||
_, err := configkit.Load(configkit.Mapping("registration", map[string]any{
|
||||
"version": "1",
|
||||
"records": []any{map[string]any{"id": "same"}, map[string]any{"id": "same"}},
|
||||
}), configkit.Options[map[string]any]{
|
||||
Definition: definition, SourceVersionMode: configkit.SourceVersionModeIfPresent,
|
||||
})
|
||||
loadError, ok := err.(*configkit.LoadError)
|
||||
if !ok {
|
||||
t.Fatalf("duplicate registered identity = %v", err)
|
||||
}
|
||||
for _, diagnostic := range loadError.Diagnostics() {
|
||||
if diagnostic.Code == configkit.CodeComposeConflict {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("registration mutation changed the in-flight policy: %#v", loadError.Diagnostics())
|
||||
}
|
||||
|
||||
func TestFacadeSourceInfoPlacementCopiesAndZeroValues(t *testing.T) {
|
||||
zero := configkit.SourceInfo{}
|
||||
if zero.PlacementMode != configkit.PlacementModeMapping || zero.DestinationUnresolved {
|
||||
t.Fatalf("zero source info = %#v", zero)
|
||||
}
|
||||
loaded, err := configkit.Load[map[string]any](configkit.Mapping("mapping", map[string]any{"value": true}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sources := loaded.Sources()
|
||||
if len(sources) != 1 || sources[0].PlacementMode != configkit.PlacementModeMapping || sources[0].DestinationUnresolved {
|
||||
t.Fatalf("mapping source evidence = %#v", sources)
|
||||
}
|
||||
first := sources[0]
|
||||
first.Destination = configkit.Keys("caller")
|
||||
if again := loaded.Sources(); len(again) != 1 || again[0].Destination.String() != "" || again[0].DestinationUnresolved {
|
||||
t.Fatalf("source evidence changed after caller mutation: %#v", again)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayeredSequenceUpsertHistoryRetainsLowerTierRecords(t *testing.T) {
|
||||
definition := configkit.Definition[map[string]any]{
|
||||
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("entries"), "id")},
|
||||
}
|
||||
result, err := configkit.LoadLayered[map[string]any](configkit.Input{Source: configkit.Bytes("main", []byte("entries: [{id: main}]\n"))}, configkit.LayeredOptions[map[string]any]{
|
||||
Definition: definition,
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{
|
||||
Source: configkit.Bytes("lower", []byte("entries: [{id: lower}]\n")),
|
||||
}}}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for index, id := range []string{"lower", "main"} {
|
||||
path := configkit.Keys("entries").Index(index).Key("id")
|
||||
explanation, ok := result.Explain(path)
|
||||
if !ok || !explanation.Effective.Present || explanation.Effective.YAML != id+"\n" || len(explanation.History) == 0 {
|
||||
t.Fatalf("tier history at %s = %#v, %v", path.String(), explanation, ok)
|
||||
}
|
||||
if explanation.History[len(explanation.History)-1].After.YAML != id+"\n" {
|
||||
t.Fatalf("tier history at %s does not retain final record: %#v", path.String(), explanation.History)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeSequenceReplacementKeepsTypedAndHistoricalEvidenceAligned(t *testing.T) {
|
||||
type record struct {
|
||||
ID string `yaml:"id"`
|
||||
Count int `yaml:"count"`
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Empty string `yaml:"empty"`
|
||||
Nullable *string `yaml:"nullable"`
|
||||
Token string `yaml:"token"`
|
||||
Omitted string `yaml:"omitted"`
|
||||
}
|
||||
type configuration struct {
|
||||
Entries []record `yaml:"entries"`
|
||||
}
|
||||
|
||||
directory := t.TempDir()
|
||||
mainPath := filepath.Join(directory, "main.yml")
|
||||
underlayPath := filepath.Join(directory, "underlay.yml")
|
||||
if err := os.WriteFile(mainPath, []byte(`configkit:
|
||||
sequences:
|
||||
- destination: [entries]
|
||||
key: id
|
||||
mode: replace
|
||||
entries:
|
||||
- id: current
|
||||
count: 0
|
||||
enabled: false
|
||||
empty: ""
|
||||
nullable: null
|
||||
token: CURRENT-SECRET-CANARY
|
||||
`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(underlayPath, []byte(`configkit:
|
||||
imports:
|
||||
- glob: records/*.yml
|
||||
destination: [entries]
|
||||
placement: sequence-item
|
||||
`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
recordsDir := filepath.Join(directory, "records")
|
||||
if err := os.MkdirAll(recordsDir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, body := range map[string]string{
|
||||
"a.yml": "id: old-a\ntoken: OLD-SECRET-CANARY-A\n",
|
||||
"b.yml": "id: old-b\ntoken: OLD-SECRET-CANARY-B\n",
|
||||
} {
|
||||
if err := os.WriteFile(filepath.Join(recordsDir, name), []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
var callbackItems []configkit.SourceInfo
|
||||
var callbackContexts []configkit.ResourceContext
|
||||
result, err := configkit.LoadLayered(configkit.Input{Source: configkit.File(mainPath)}, configkit.LayeredOptions[configuration]{
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.File(underlayPath), Imports: true}}}},
|
||||
Definition: configkit.Definition[configuration]{
|
||||
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("entries"), "id")},
|
||||
Secret: func(path configkit.Path) bool {
|
||||
return path.String() == "/entries/0/token" || path.String() == "/entries/1/token"
|
||||
},
|
||||
Resolve: func(value *configuration, resolution *configkit.Resolution) error {
|
||||
for _, field := range []string{"count", "enabled", "empty", "nullable"} {
|
||||
path := configkit.Keys("entries").Index(0).Key(field)
|
||||
if !resolution.IsExplicit(path) {
|
||||
t.Errorf("resolver did not see explicit %s", path.String())
|
||||
}
|
||||
}
|
||||
if resolution.IsExplicit(configkit.Keys("entries").Index(0).Key("omitted")) {
|
||||
t.Error("resolver treated an omitted field as explicit")
|
||||
}
|
||||
info, found := resolution.InputInfo(configkit.Keys("entries").Index(0).Key("count"))
|
||||
if !found || info.Origin.Layer != configkit.LayerBase {
|
||||
t.Errorf("resolver current-record input info = %#v, %v", info, found)
|
||||
}
|
||||
for _, id := range []string{"source-3", "source-4"} {
|
||||
source, ok := resolution.Source(id)
|
||||
if !ok {
|
||||
t.Errorf("resolver could not query %s", id)
|
||||
continue
|
||||
}
|
||||
context, contextOK := resolution.SourceContext(id)
|
||||
if !contextOK || context.FS == nil || context.BaseDir != "records" {
|
||||
t.Errorf("resolver context for %s = %#v, %v", id, context, contextOK)
|
||||
}
|
||||
callbackItems = append(callbackItems, source)
|
||||
callbackContexts = append(callbackContexts, context)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
if loadError, ok := err.(*configkit.LoadError); ok {
|
||||
t.Fatalf("layered item replacement: %#v", loadError.Diagnostics())
|
||||
}
|
||||
t.Fatalf("layered item replacement: %v", err)
|
||||
}
|
||||
if len(result.Config.Entries) != 1 || result.Config.Entries[0].ID != "current" || result.Config.Entries[0].Count != 0 ||
|
||||
result.Config.Entries[0].Enabled || result.Config.Entries[0].Empty != "" || result.Config.Entries[0].Nullable != nil {
|
||||
t.Fatalf("typed replacement result = %#v", result.Config)
|
||||
}
|
||||
if len(callbackItems) != 2 || len(callbackContexts) != 2 {
|
||||
t.Fatalf("callback sources = %#v; contexts = %#v", callbackItems, callbackContexts)
|
||||
}
|
||||
for _, source := range callbackItems {
|
||||
if source.PlacementMode != configkit.PlacementModeSequenceItem || source.Destination.String() != "/entries" || source.DestinationUnresolved {
|
||||
t.Errorf("callback item source = %#v", source)
|
||||
}
|
||||
}
|
||||
metadata := result.Metadata()
|
||||
for _, field := range []string{"count", "enabled", "empty", "nullable"} {
|
||||
path := configkit.Keys("entries").Index(0).Key(field)
|
||||
if info, ok := metadata.Lookup(path); !ok || !info.Explicit || (field == "nullable") != info.Null {
|
||||
t.Errorf("explicit metadata at %s = %#v, %v", path.String(), info, ok)
|
||||
}
|
||||
}
|
||||
if info, ok := metadata.Lookup(configkit.Keys("entries").Index(0).Key("omitted")); ok && info.Explicit {
|
||||
t.Errorf("metadata marked an omitted zero-valued field explicit: %#v", info)
|
||||
}
|
||||
mainID := ""
|
||||
for _, source := range result.Sources() {
|
||||
if source.Path == mainPath {
|
||||
mainID = source.ID
|
||||
}
|
||||
}
|
||||
currentOrigin, ok := metadata.Lookup(configkit.Keys("entries").Index(0).Key("id"))
|
||||
if !ok || len(currentOrigin.Origin.Locations) != 1 || currentOrigin.Origin.Locations[0].SourceID != mainID || mainID == "" {
|
||||
t.Fatalf("replacement origin = %#v, main source %q", currentOrigin, mainID)
|
||||
}
|
||||
oldRecord, ok := result.Explain(configkit.Keys("entries").Index(1).Key("token"))
|
||||
if !ok || oldRecord.Effective.Present {
|
||||
t.Fatalf("removed appended item explanation = %#v, %v", oldRecord, ok)
|
||||
}
|
||||
maskedHistory := false
|
||||
for _, change := range oldRecord.History {
|
||||
maskedHistory = maskedHistory || change.Before.Redacted || change.After.Redacted
|
||||
}
|
||||
if !maskedHistory {
|
||||
t.Fatal("removed appended item lost historical sensitivity")
|
||||
}
|
||||
var effective bytes.Buffer
|
||||
if err := result.WriteEffective(&effective); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var explanation bytes.Buffer
|
||||
if err := result.WriteExplanation(&explanation, configkit.Keys("entries").Index(1).Key("token")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, output := range []string{effective.String(), explanation.String()} {
|
||||
for _, canary := range []string{"CURRENT-SECRET-CANARY", "OLD-SECRET-CANARY-A", "OLD-SECRET-CANARY-B"} {
|
||||
if strings.Contains(output, canary) {
|
||||
t.Fatalf("sensitive sequence value leaked: %s", output)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
197
facade_sequence_version_test.go
Normal file
197
facade_sequence_version_test.go
Normal file
@@ -0,0 +1,197 @@
|
||||
package configkit_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
type movedVersionItem struct {
|
||||
ID string `yaml:"id"`
|
||||
Version string `yaml:"version"`
|
||||
}
|
||||
|
||||
type movedVersionCatalog struct {
|
||||
Items []movedVersionItem `yaml:"items"`
|
||||
}
|
||||
|
||||
func TestMainVersionDecodingFollowsSequenceMovement(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, mounted := range []bool{false, true} {
|
||||
for _, matching := range []bool{false, true} {
|
||||
for _, version := range []string{"4", "'4'"} {
|
||||
t.Run(fmt.Sprintf("mounted=%t/matched=%t/%s", mounted, matching, version), func(t *testing.T) {
|
||||
body := "items: [{id: main, version: " + version + "}]\n"
|
||||
path := configkit.Keys("items").Index(0).Key("version")
|
||||
main := configkit.Input{}
|
||||
if mounted {
|
||||
body, path = "id: main\nversion: "+version+"\n", configkit.Keys("version")
|
||||
main.Destination, main.PlacementMode = configkit.Keys("items"), configkit.PlacementModeSequenceItem
|
||||
}
|
||||
main.Source = configkit.Bytes("main", []byte(body))
|
||||
keep := "{id: keep}"
|
||||
if mounted {
|
||||
keep = "{id: keep, version: ordinary}"
|
||||
}
|
||||
lower := "items: [" + keep + "]\n"
|
||||
if matching {
|
||||
lower = "items: [" + keep + ", {id: main}]\n"
|
||||
}
|
||||
calls := 0
|
||||
loaded, err := configkit.LoadLayered(main, configkit.LayeredOptions[movedVersionCatalog]{
|
||||
SourceVersionMode: configkit.SourceVersionModeIfPresent,
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.Bytes("lower", []byte(lower))}}}},
|
||||
Definition: configkit.Definition[movedVersionCatalog]{
|
||||
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("items"), "id")},
|
||||
Version: &configkit.VersionRule{Path: path, Check: func(version configkit.Version, _ *configkit.Validation) error {
|
||||
calls++
|
||||
if !version.Present || version.Text != "4" {
|
||||
t.Errorf("checked version = %#v", version)
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls != 1 || len(loaded.Config.Items) != 2 || loaded.Config.Items[1].ID != "main" || loaded.Config.Items[1].Version != "4" {
|
||||
t.Fatalf("moved main = %#v, checker calls = %d", loaded.Config, calls)
|
||||
}
|
||||
if mounted && loaded.Config.Items[0].Version != "ordinary" {
|
||||
t.Fatal("unrelated underlay payload was treated as the main declaration")
|
||||
}
|
||||
for _, source := range loaded.Sources() {
|
||||
if source.Layer == configkit.LayerBase && source.VersionCheck.Path.String() != path.String() {
|
||||
t.Fatal("effective movement changed source-local version evidence")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestOverlayVersionProtectionUsesAssignedRecordPositions(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, test := range []struct {
|
||||
name, payload string
|
||||
code configkit.Code
|
||||
}{
|
||||
{name: "change main header", payload: "[{id: main, version: changed}]", code: configkit.CodeOverlayVersion},
|
||||
{name: "remove main header", payload: "[{id: main}]", code: configkit.CodeOverlayVersion},
|
||||
{name: "other record version", payload: "[{id: keep, version: ordinary}]"},
|
||||
{name: "new record version", payload: "[{id: new, version: ordinary}]"},
|
||||
{name: "only main header gets integer conversion", payload: "[{id: new, version: 5}]", code: configkit.CodeDecodeType},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
files := configkit.NewFilesystem("catalog", fstest.MapFS{
|
||||
"overlays/test.yml": {Data: []byte("items: " + test.payload + "\n")},
|
||||
})
|
||||
calls := 0
|
||||
loaded, err := configkit.LoadLayered(configkit.Input{
|
||||
Source: configkit.Bytes("main", []byte("id: main\nversion: 4\n")), Destination: configkit.Keys("items"), PlacementMode: configkit.PlacementModeSequenceItem,
|
||||
}, configkit.LayeredOptions[movedVersionCatalog]{
|
||||
SourceVersionMode: configkit.SourceVersionModeIfPresent,
|
||||
Overlay: "test", OverlayFilesystem: &configkit.FilesystemContext{FS: &files, BaseDir: ".", Root: "."},
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.Bytes("lower", []byte("items: [{id: keep}]\n"))}}}},
|
||||
Definition: configkit.Definition[movedVersionCatalog]{
|
||||
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("items"), "id")},
|
||||
Version: &configkit.VersionRule{Path: configkit.Keys("version"), Check: func(configkit.Version, *configkit.Validation) error { calls++; return nil }},
|
||||
},
|
||||
})
|
||||
if calls != 1 {
|
||||
t.Fatalf("checker calls = %d", calls)
|
||||
}
|
||||
if test.code != "" {
|
||||
loadErr := requireFacadeLoadError(t, err)
|
||||
if loaded != nil || len(loadErr.Diagnostics()) != 1 || loadErr.Diagnostics()[0].Code != test.code {
|
||||
t.Fatalf("version diagnostics = %#v", loadErr.Diagnostics())
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded.Config.Items[1].Version != "4" {
|
||||
t.Fatal("unrelated overlay changed main header")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnderlayVersionProtectionUsesMainRecordPosition(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, imported := range []bool{false, true} {
|
||||
t.Run(fmt.Sprintf("imported=%t", imported), func(t *testing.T) {
|
||||
payload := "items: [{id: keep}, {id: main, version: forbidden}]\n"
|
||||
lower := configkit.Input{Source: configkit.Bytes("lower", []byte(payload))}
|
||||
want := configkit.CodeUnderlayVersion
|
||||
if imported {
|
||||
files := configkit.NewFilesystem("lower", fstest.MapFS{
|
||||
"main.yml": {Data: []byte("configkit: {imports: [{path: fragment.yml}]}\n")},
|
||||
"fragment.yml": {Data: []byte(payload)},
|
||||
})
|
||||
lower.Source, lower.Imports = files.File("main.yml"), true
|
||||
want = configkit.CodeImportVersion
|
||||
}
|
||||
calls := 0
|
||||
result, err := configkit.LoadLayered(configkit.Input{
|
||||
Source: configkit.Bytes("main", []byte("id: main\nversion: 4\n")),
|
||||
Destination: configkit.Keys("items"), PlacementMode: configkit.PlacementModeSequenceItem,
|
||||
}, configkit.LayeredOptions[movedVersionCatalog]{
|
||||
SourceVersionMode: configkit.SourceVersionModeIfPresent,
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{lower}}},
|
||||
Definition: configkit.Definition[movedVersionCatalog]{
|
||||
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("items"), "id")},
|
||||
Version: &configkit.VersionRule{Path: configkit.Keys("version"), Check: func(configkit.Version, *configkit.Validation) error { calls++; return nil }},
|
||||
},
|
||||
})
|
||||
failure := requireFacadeLoadError(t, err)
|
||||
if result != nil || calls != 1 || len(failure.Diagnostics()) != 1 || failure.Diagnostics()[0].Code != want || failure.Diagnostics()[0].Path.String() != "/items/1/version" {
|
||||
t.Fatalf("result=%#v calls=%d diagnostics=%#v", result, calls, failure.Diagnostics())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMainVersionFollowsInheritedSequenceMovement(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, parentID := range []string{"keep", "main"} {
|
||||
t.Run(parentID, func(t *testing.T) {
|
||||
body := "base: {items: [{id: " + parentID + ", version: ordinary}]}\nchild:\n extends: base\n items: [{id: main, version: 4}]\n"
|
||||
type config struct {
|
||||
Base, Child movedVersionCatalog
|
||||
}
|
||||
calls := 0
|
||||
result, err := configkit.Load(configkit.Bytes("main", []byte(body)), configkit.Options[config]{
|
||||
SourceVersionMode: configkit.SourceVersionModeIfPresent,
|
||||
Definition: configkit.Definition[config]{
|
||||
SequenceIdentities: []configkit.SequenceIdentity{
|
||||
configkit.SequenceIdentityAt(configkit.Keys("base", "items"), "id"),
|
||||
configkit.SequenceIdentityAt(configkit.Keys("child", "items"), "id"),
|
||||
},
|
||||
Version: &configkit.VersionRule{Path: configkit.Keys("child", "items").Index(0).Key("version"), Check: func(configkit.Version, *configkit.Validation) error { calls++; return nil }},
|
||||
},
|
||||
})
|
||||
if calls != 1 {
|
||||
t.Fatalf("checker calls = %d", calls)
|
||||
}
|
||||
if parentID == "main" {
|
||||
failure := requireFacadeLoadError(t, err)
|
||||
if result != nil || len(failure.Diagnostics()) != 1 || failure.Diagnostics()[0].Code != configkit.CodeInheritanceVersion {
|
||||
t.Fatalf("diagnostics = %#v", failure.Diagnostics())
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.Config.Child.Items) != 2 || result.Config.Child.Items[1].Version != "4" || result.Config.Child.Items[0].Version != "ordinary" {
|
||||
t.Fatalf("inherited items = %#v", result.Config.Child.Items)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
889
facade_source_version_mode_test.go
Normal file
889
facade_source_version_mode_test.go
Normal file
@@ -0,0 +1,889 @@
|
||||
package configkit_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
func TestFacadeSourceVersionEvidenceFollowsAssembledSequenceRanges(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, grouped := range []bool{false, true} {
|
||||
index := 0
|
||||
if grouped {
|
||||
index = 1
|
||||
}
|
||||
for _, sensitivePath := range []string{"", fmt.Sprintf("/records/%d/version", index), fmt.Sprintf("/records/%d", index)} {
|
||||
t.Run(fmt.Sprintf("grouped=%t/secret=%s", grouped, sensitivePath), func(t *testing.T) {
|
||||
definition := configkit.Definition[map[string]any]{
|
||||
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("records"), "id")},
|
||||
Version: &configkit.VersionRule{Path: configkit.Keys("records").Index(0).Key("version"), Check: func(configkit.Version, *configkit.Validation) error { return nil }},
|
||||
Secret: func(path configkit.Path) bool { return sensitivePath != "" && path.String() == sensitivePath },
|
||||
}
|
||||
first := "records: [{id: first, version: first-version}]\n"
|
||||
child := "records: [{id: child, version: PRIVATE-VERSION-CANARY}]\n"
|
||||
var result *configkit.Result[map[string]any]
|
||||
var err error
|
||||
if grouped {
|
||||
result, err = configkit.LoadLayered(configkit.Input{Source: configkit.Bytes("main", []byte(first))}, configkit.LayeredOptions[map[string]any]{
|
||||
Definition: definition, SourceVersionMode: configkit.SourceVersionModeRequired,
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{
|
||||
{Source: configkit.Bytes("first", []byte(first))}, {Source: configkit.Bytes("child", []byte(child))},
|
||||
}}},
|
||||
})
|
||||
} else {
|
||||
filesystem := configkit.NewFilesystem("versions", fstest.MapFS{
|
||||
"main.yml": {Data: []byte("configkit: {imports: [{path: child.yml}]}\n" + first)},
|
||||
"child.yml": {Data: []byte(child)},
|
||||
})
|
||||
result, err = configkit.Load(filesystem.File("main.yml"), configkit.Options[map[string]any]{
|
||||
Definition: definition, Imports: true, SourceVersionMode: configkit.SourceVersionModeRequired,
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !grouped {
|
||||
records, ok := result.Config["records"].([]any)
|
||||
if !ok || len(records) != 2 {
|
||||
t.Fatalf("scheduled records = %#v", result.Config["records"])
|
||||
}
|
||||
firstRecord, ok := records[0].(map[string]any)
|
||||
if !ok || firstRecord["id"] != "child" {
|
||||
t.Fatalf("scheduled records = %#v", result.Config["records"])
|
||||
}
|
||||
}
|
||||
sources := result.Sources()
|
||||
check := sources[len(sources)-1].VersionCheck
|
||||
if check == nil || check.Path.String() != "/records/0/version" || check.Location == nil || check.Location.SourceID != sources[len(sources)-1].ID {
|
||||
t.Fatalf("source-local evidence changed: %#v", check)
|
||||
}
|
||||
if sensitivePath != "" {
|
||||
if !check.Redacted || check.Version.Text != "" {
|
||||
t.Fatal("shifted version evidence was disclosed")
|
||||
}
|
||||
} else if check.Redacted || check.Version.Text != "PRIVATE-VERSION-CANARY" {
|
||||
t.Fatal("unclassified version evidence was lost")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeSourceVersionOwnershipAcceptsContextualIndexes(t *testing.T) {
|
||||
t.Parallel()
|
||||
parsed, err := configkit.ParsePath("/records/0/version")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, grouped := range []bool{false, true} {
|
||||
for _, path := range []configkit.Path{configkit.Keys("records").Index(0).Key("version"), parsed} {
|
||||
definition := configkit.Definition[map[string]any]{
|
||||
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("records"), "id")},
|
||||
Version: &configkit.VersionRule{Path: path, Check: func(configkit.Version, *configkit.Validation) error { return nil }},
|
||||
}
|
||||
first := "records: [{id: first, version: first}]\n"
|
||||
child := "records: [{id: child, version: child}]\n"
|
||||
var result *configkit.Result[map[string]any]
|
||||
if grouped {
|
||||
result, err = configkit.LoadLayered(configkit.Input{Source: configkit.Bytes("main", []byte(first))}, configkit.LayeredOptions[map[string]any]{
|
||||
Definition: definition, SourceVersionMode: configkit.SourceVersionModeRequired,
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.Bytes("lower", []byte(child))}}}},
|
||||
})
|
||||
} else {
|
||||
filesystem := configkit.NewFilesystem("versions", fstest.MapFS{"main.yml": {Data: []byte("configkit: {imports: [{path: child.yml}]}\n" + first)}, "child.yml": {Data: []byte(child)}})
|
||||
result, err = configkit.Load(filesystem.File("main.yml"), configkit.Options[map[string]any]{Definition: definition, SourceVersionMode: configkit.SourceVersionModeRequired, Imports: true})
|
||||
}
|
||||
if err != nil || result == nil {
|
||||
t.Fatalf("grouped=%t path=%#v: %v", grouped, path, err)
|
||||
}
|
||||
if len(result.Config["records"].([]any)) != 2 {
|
||||
t.Fatal("version path changed composition")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeItemMainKeepsDefaultVersionOwnership(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, layered := range []bool{false, true} {
|
||||
for _, protected := range []bool{true, false} {
|
||||
body, helper := "id: main\n", "version: imported-version\n"
|
||||
if !protected {
|
||||
body += "version: main-version\n"
|
||||
helper = "other: {version: permitted}\n"
|
||||
}
|
||||
filesystem := configkit.NewFilesystem("item-main", fstest.MapFS{
|
||||
"main.yml": {Data: []byte(body + "configkit: {imports: [{path: helper.yml}]}\n")}, "helper.yml": {Data: []byte(helper)},
|
||||
})
|
||||
checks := 0
|
||||
definition := configkit.Definition[map[string]any]{Version: &configkit.VersionRule{
|
||||
Path: configkit.Keys("records").Index(0).Key("version"),
|
||||
Check: func(version configkit.Version, _ *configkit.Validation) error {
|
||||
checks++
|
||||
if version.Text != "main-version" {
|
||||
t.Error("helper supplied main version")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}}
|
||||
var err error
|
||||
if layered {
|
||||
_, err = configkit.LoadLayered[map[string]any](configkit.Input{Source: filesystem.File("main.yml"), Imports: true, Destination: configkit.Keys("records"), PlacementMode: configkit.PlacementModeSequenceItem}, configkit.LayeredOptions[map[string]any]{Definition: definition})
|
||||
} else {
|
||||
_, err = configkit.Load(filesystem.File("main.yml"), configkit.Options[map[string]any]{Definition: definition, Imports: true, Destination: configkit.Keys("records"), PlacementMode: configkit.PlacementModeSequenceItem})
|
||||
}
|
||||
if protected {
|
||||
loadError, ok := err.(*configkit.LoadError)
|
||||
if !ok || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != configkit.CodeImportVersion || loadError.Diagnostics()[0].Path.String() != "/records/0/version" || checks != 0 {
|
||||
t.Fatalf("layered=%t ownership error = %v, checks=%d", layered, err, checks)
|
||||
}
|
||||
} else if err != nil || checks != 1 {
|
||||
t.Fatalf("unrelated helper version rejected: %v, checks=%d", err, checks)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type sourceVersionModeReader struct {
|
||||
*strings.Reader
|
||||
reads int
|
||||
}
|
||||
|
||||
func TestFacadeSourceVersionEvidenceRedactsCollidingShapes(t *testing.T) {
|
||||
t.Parallel()
|
||||
type header struct {
|
||||
Version string `yaml:"version"`
|
||||
}
|
||||
type secretHeader struct {
|
||||
Version string `yaml:"version" configkit:"secret"`
|
||||
}
|
||||
type configuration struct {
|
||||
Meta map[string]header `yaml:"meta"`
|
||||
Data struct {
|
||||
Meta map[string]secretHeader `yaml:"meta"`
|
||||
} `yaml:"data"`
|
||||
}
|
||||
path, err := configkit.ParsePath("/meta/0/version")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, reverse := range []bool{false, true} {
|
||||
groups := []configkit.Underlay{
|
||||
{Inputs: []configkit.Input{{
|
||||
Source: configkit.Bytes("mapping", []byte("meta: {\"0\": {version: PRIVATE-HEADER-CANARY}}\n")),
|
||||
Destination: configkit.Keys("data"),
|
||||
}}},
|
||||
{Inputs: []configkit.Input{{
|
||||
Source: configkit.Bytes("sequence", []byte("meta: [{version: another-header}]\n")),
|
||||
Destination: configkit.Keys("data"),
|
||||
}}},
|
||||
}
|
||||
if reverse {
|
||||
groups[0], groups[1] = groups[1], groups[0]
|
||||
}
|
||||
loaded, err := configkit.LoadLayered(configkit.Input{
|
||||
Source: configkit.Bytes("main", []byte("meta: {\"0\": {version: main}}\ndata: {meta: null}\n")),
|
||||
}, configkit.LayeredOptions[configuration]{
|
||||
Definition: configkit.Definition[configuration]{Version: &configkit.VersionRule{
|
||||
Path: path, Check: func(configkit.Version, *configkit.Validation) error { return nil },
|
||||
}},
|
||||
SourceVersionMode: configkit.SourceVersionModeRequired, Underlays: groups,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded.Config.Meta["0"].Version != "main" || loaded.Config.Data.Meta != nil {
|
||||
t.Fatalf("reverse=%v: final payload changed: %#v", reverse, loaded.Config)
|
||||
}
|
||||
foundMapping := false
|
||||
for _, source := range loaded.Sources() {
|
||||
if source.Label != "mapping" {
|
||||
continue
|
||||
}
|
||||
foundMapping = true
|
||||
if check := source.VersionCheck; check == nil || !check.Version.Present || !check.Redacted || check.Version.Text != "" {
|
||||
t.Errorf("reverse=%v: schema-tagged header was not redacted", reverse)
|
||||
}
|
||||
}
|
||||
if !foundMapping {
|
||||
t.Fatal("missing mapping source record")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeSourceVersionModesPreserveImportOptIn(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, mode := range []configkit.SourceVersionMode{configkit.SourceVersionModeIfPresent, configkit.SourceVersionModeRequired} {
|
||||
for _, underlay := range []bool{false, true} {
|
||||
bad := configkit.Bytes("disabled-imports", []byte("version: accepted\nconfigkit: {imports: [{path: missing.yml}]}\nvalue: visible\n"))
|
||||
definition := configkit.Definition[map[string]any]{Version: &configkit.VersionRule{
|
||||
Path: configkit.Keys("version"), Check: func(configkit.Version, *configkit.Validation) error { return nil },
|
||||
}}
|
||||
var err error
|
||||
var resultPresent bool
|
||||
wantSource := "source-1"
|
||||
if underlay {
|
||||
loaded, loadErr := configkit.LoadLayered(configkit.Input{Source: configkit.Bytes("main", []byte("version: main\n"))}, configkit.LayeredOptions[map[string]any]{
|
||||
Definition: definition, SourceVersionMode: mode,
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: bad}}}},
|
||||
})
|
||||
err, resultPresent, wantSource = loadErr, loaded != nil, "source-2"
|
||||
} else {
|
||||
loaded, loadErr := configkit.Load(bad, configkit.Options[map[string]any]{Definition: definition, SourceVersionMode: mode})
|
||||
err, resultPresent = loadErr, loaded != nil
|
||||
}
|
||||
var failure *configkit.LoadError
|
||||
if resultPresent || !errors.As(err, &failure) {
|
||||
t.Errorf("mode=%v underlay=%v: import opt-in did not fail safely", mode, underlay)
|
||||
continue
|
||||
}
|
||||
diagnostics := failure.Diagnostics()
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeImportDisabled || diagnostics[0].Path.String() != "/configkit" ||
|
||||
diagnostics[0].Primary == nil || diagnostics[0].Primary.SourceID != wantSource {
|
||||
t.Errorf("mode=%v underlay=%v: diagnostics=%#v", mode, underlay, diagnostics)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeSourceVersionAbsenceKeepsCompleteEvidencePath(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, test := range []struct {
|
||||
name, main, underlay string
|
||||
path configkit.Path
|
||||
}{
|
||||
{"missing member", "meta: {version: main}\n", "{}\n", configkit.Keys("meta", "version")},
|
||||
{"scalar obstruction", "meta: {version: main}\n", "meta: false\n", configkit.Keys("meta", "version")},
|
||||
{"unavailable index", "meta: [null, null, null, {version: main}]\n", "meta: []\n", configkit.Keys("meta").Index(3).Key("version")},
|
||||
} {
|
||||
for _, mode := range []configkit.SourceVersionMode{configkit.SourceVersionModeIfPresent, configkit.SourceVersionModeRequired} {
|
||||
var callbackEvidence *configkit.SourceVersionCheck
|
||||
loaded, err := configkit.LoadLayered(configkit.Input{Source: configkit.Bytes("main", []byte(test.main))}, configkit.LayeredOptions[map[string]any]{
|
||||
SourceVersionMode: mode,
|
||||
Definition: configkit.Definition[map[string]any]{
|
||||
Version: &configkit.VersionRule{Path: test.path, Check: func(configkit.Version, *configkit.Validation) error { return nil }},
|
||||
Resolve: func(_ *map[string]any, resolution *configkit.Resolution) error {
|
||||
source, found := resolution.Source("source-2")
|
||||
if !found {
|
||||
t.Fatal("missing callback source")
|
||||
}
|
||||
callbackEvidence = source.VersionCheck
|
||||
return nil
|
||||
},
|
||||
},
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{
|
||||
Source: configkit.Bytes("underlay", []byte(test.underlay)), Destination: configkit.Keys("under"),
|
||||
}}}},
|
||||
})
|
||||
var evidence *configkit.SourceVersionCheck
|
||||
if mode == configkit.SourceVersionModeRequired {
|
||||
var failure *configkit.LoadError
|
||||
if loaded != nil || !errors.As(err, &failure) {
|
||||
t.Fatalf("%s: required omission did not fail", test.name)
|
||||
}
|
||||
diagnostics := failure.Diagnostics()
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeVersionError || diagnostics[0].Path.String() != test.path.String() {
|
||||
t.Fatalf("%s: required diagnostics=%#v", test.name, diagnostics)
|
||||
}
|
||||
evidence = failure.Sources()[1].VersionCheck
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", test.name, err)
|
||||
}
|
||||
evidence = loaded.Sources()[1].VersionCheck
|
||||
if callbackEvidence == nil || callbackEvidence.Path.String() != test.path.String() {
|
||||
t.Errorf("%s: callback path is incomplete", test.name)
|
||||
}
|
||||
}
|
||||
if evidence == nil || evidence.Version.Present || evidence.Path.String() != test.path.String() ||
|
||||
evidence.Location == nil || evidence.Location.SourceID != "source-2" || evidence.Location.Line != 1 {
|
||||
t.Errorf("%s mode=%v: absence evidence=%#v", test.name, mode, evidence)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeSourceVersionMissingHeaderDoesNotOverlapControl(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
mode configkit.SourceVersionMode
|
||||
control string
|
||||
wantCode configkit.Code
|
||||
}{
|
||||
{"optional omission", configkit.SourceVersionModeIfPresent, "base", ""},
|
||||
{"required omission", configkit.SourceVersionModeRequired, "base", configkit.CodeVersionError},
|
||||
{"invalid control", configkit.SourceVersionModeIfPresent, "true", configkit.CodeInheritanceInvalid},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
main := configkit.Bytes("main", []byte("{}\n"))
|
||||
if test.mode == configkit.SourceVersionModeRequired {
|
||||
main = configkit.NoSource()
|
||||
}
|
||||
checks := 0
|
||||
path := configkit.Keys("entries", "child", "extends", "version")
|
||||
loaded, err := configkit.LoadLayered(configkit.Input{Source: main, Destination: configkit.Keys("main")}, configkit.LayeredOptions[map[string]any]{
|
||||
SourceVersionMode: test.mode,
|
||||
Definition: configkit.Definition[map[string]any]{Version: &configkit.VersionRule{
|
||||
Path: path, Check: func(version configkit.Version, _ *configkit.Validation) error {
|
||||
checks++
|
||||
if version.Present {
|
||||
t.Error("checker received a nonexistent declaration")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}},
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{
|
||||
Source: configkit.Bytes("underlay", []byte("entries:\n base: {value: good}\n child: {extends: "+test.control+"}\n")), Destination: configkit.Keys("under"),
|
||||
}}}},
|
||||
})
|
||||
if checks != 1 {
|
||||
t.Errorf("checker ran %d times, want absent main only", checks)
|
||||
}
|
||||
if test.wantCode != "" {
|
||||
var failure *configkit.LoadError
|
||||
if loaded != nil || !errors.As(err, &failure) {
|
||||
t.Fatal("invalid input did not fail safely")
|
||||
}
|
||||
if diagnostics := failure.Diagnostics(); len(diagnostics) != 1 || diagnostics[0].Code != test.wantCode {
|
||||
t.Fatalf("diagnostics=%#v", diagnostics)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
child := loaded.Config["under"].(map[string]any)["entries"].(map[string]any)["child"].(map[string]any)
|
||||
if child["value"] != "good" {
|
||||
t.Fatal("valid sibling inheritance was not applied")
|
||||
}
|
||||
check := loaded.Sources()[1].VersionCheck
|
||||
if check == nil || check.Version.Present || check.Path.String() != path.String() {
|
||||
t.Fatal("optional omission evidence was not retained")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (reader *sourceVersionModeReader) Read(data []byte) (int, error) {
|
||||
reader.reads++
|
||||
return reader.Reader.Read(data)
|
||||
}
|
||||
|
||||
func TestFacadeSourceVersionModesAcrossOrdinaryAndLayeredLoads(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var checked []configkit.Version
|
||||
definition := configkit.Definition[map[string]any]{Version: &configkit.VersionRule{
|
||||
Path: configkit.Keys("meta", "version"),
|
||||
Check: func(version configkit.Version, _ *configkit.Validation) error {
|
||||
checked = append(checked, version)
|
||||
return nil
|
||||
},
|
||||
}}
|
||||
ordinary, err := configkit.Load(configkit.Bytes("ordinary", []byte("meta: {version: ordinary-v}\n")), configkit.Options[map[string]any]{
|
||||
Definition: definition, SourceVersionMode: configkit.SourceVersionModeIfPresent,
|
||||
})
|
||||
if err != nil || ordinary == nil {
|
||||
t.Fatalf("ordinary enabled load = %#v, %v", ordinary, err)
|
||||
}
|
||||
if got := ordinary.Config["meta"].(map[string]any)["version"]; got != "ordinary-v" {
|
||||
t.Fatalf("main declaration was not retained: %#v", ordinary.Config)
|
||||
}
|
||||
|
||||
directory := t.TempDir()
|
||||
mainPath := filepath.Join(directory, "main.yml")
|
||||
if err := os.WriteFile(mainPath, []byte("meta: {version: main-v}\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
layered, err := configkit.LoadLayered(configkit.Input{Source: configkit.File(mainPath)}, configkit.LayeredOptions[map[string]any]{
|
||||
Definition: definition, SourceVersionMode: configkit.SourceVersionModeRequired,
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{
|
||||
Source: configkit.Mapping("underlay", map[string]any{
|
||||
"meta": map[string]any{"version": "underlay-v"}, "fallback": "present",
|
||||
}),
|
||||
Destination: configkit.Keys("defaults"),
|
||||
}}}},
|
||||
})
|
||||
if err != nil || layered == nil {
|
||||
t.Fatalf("layered enabled load = %#v, %v", layered, err)
|
||||
}
|
||||
defaults, ok := layered.Config["defaults"].(map[string]any)
|
||||
if !ok || defaults["fallback"] != "present" {
|
||||
t.Fatalf("layered underlay payload = %#v", layered.Config["defaults"])
|
||||
}
|
||||
if meta, ok := defaults["meta"].(map[string]any); !ok || len(meta) != 0 {
|
||||
t.Fatalf("accepted underlay declaration was not consumed: %#v", defaults["meta"])
|
||||
}
|
||||
if len(checked) != 3 {
|
||||
t.Fatalf("checker calls across separate mode uses = %#v", checked)
|
||||
}
|
||||
for _, want := range []string{"ordinary-v", "main-v", "underlay-v"} {
|
||||
found := false
|
||||
for _, version := range checked {
|
||||
found = found || version.Present && version.Text == want
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("checked versions %#v do not include %q", checked, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeSourceVersionModeZeroPreservesDefaultBehavior(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
checks := 0
|
||||
result, err := configkit.Load(configkit.Bytes("default", []byte("version: v1\n")), configkit.Options[map[string]any]{
|
||||
Definition: configkit.Definition[map[string]any]{Version: &configkit.VersionRule{
|
||||
Path: configkit.Keys("version"),
|
||||
Check: func(version configkit.Version, _ *configkit.Validation) error {
|
||||
checks++
|
||||
if version != (configkit.Version{Present: true, Text: "v1"}) {
|
||||
t.Errorf("default version = %#v", version)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}},
|
||||
})
|
||||
if err != nil || result == nil || result.Config["version"] != "v1" || checks != 1 {
|
||||
t.Fatalf("zero-mode load = %#v, %v; checks = %d", result, err, checks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeSourceVersionModeValidationPrecedesSourceReads(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
validDefinition := configkit.Definition[map[string]any]{Version: &configkit.VersionRule{
|
||||
Path: configkit.Keys("version"), Check: func(configkit.Version, *configkit.Validation) error { return nil },
|
||||
}}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
mode configkit.SourceVersionMode
|
||||
def configkit.Definition[map[string]any]
|
||||
layered bool
|
||||
}{
|
||||
{name: "unknown ordinary mode", mode: configkit.SourceVersionMode(255), def: validDefinition},
|
||||
{name: "ordinary mode requires checker", mode: configkit.SourceVersionModeIfPresent},
|
||||
{name: "unknown layered mode", mode: configkit.SourceVersionMode(255), def: validDefinition, layered: true},
|
||||
{name: "layered mode requires checker", mode: configkit.SourceVersionModeRequired, layered: true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
reader := &sourceVersionModeReader{Reader: strings.NewReader("version: v1\n")}
|
||||
source := configkit.Reader("version.yml", reader)
|
||||
var err error
|
||||
if test.layered {
|
||||
_, err = configkit.LoadLayered(configkit.Input{Source: source}, configkit.LayeredOptions[map[string]any]{
|
||||
Definition: test.def, SourceVersionMode: test.mode,
|
||||
})
|
||||
} else {
|
||||
_, err = configkit.Load(source, configkit.Options[map[string]any]{
|
||||
Definition: test.def, SourceVersionMode: test.mode,
|
||||
})
|
||||
}
|
||||
var loadError *configkit.LoadError
|
||||
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != configkit.CodeOptionsInvalid {
|
||||
t.Fatalf("invalid mode error = %#v", err)
|
||||
}
|
||||
if reader.reads != 0 {
|
||||
t.Fatalf("invalid options read source %d times", reader.reads)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeSourceVersionDefinitionCanBeReusedWithDifferentModes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
checks := 0
|
||||
definition := configkit.Definition[map[string]any]{Version: &configkit.VersionRule{
|
||||
Path: configkit.Keys("version"),
|
||||
Check: func(version configkit.Version, _ *configkit.Validation) error {
|
||||
checks++
|
||||
if version.Present {
|
||||
t.Errorf("missing declaration reached checker as %#v", version)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}}
|
||||
source := configkit.Bytes("reused", []byte("value: retained\n"))
|
||||
ifPresent, err := configkit.Load(source, configkit.Options[map[string]any]{
|
||||
Definition: definition, SourceVersionMode: configkit.SourceVersionModeIfPresent,
|
||||
})
|
||||
if err != nil || ifPresent == nil || checks != 1 {
|
||||
t.Fatalf("if-present load = %#v, %v; checks = %d", ifPresent, err, checks)
|
||||
}
|
||||
required, err := configkit.Load(source, configkit.Options[map[string]any]{
|
||||
Definition: definition, SourceVersionMode: configkit.SourceVersionModeRequired,
|
||||
})
|
||||
var loadError *configkit.LoadError
|
||||
if required != nil || !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != configkit.CodeVersionError {
|
||||
t.Fatalf("required load = %#v, %v", required, err)
|
||||
}
|
||||
if checks != 1 {
|
||||
t.Fatalf("required absence reached checker: calls = %d", checks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeSourceVersionEvidenceIsSanitizedAndDetached(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const canary = "PRIVATE-SOURCE-VERSION-CANARY"
|
||||
directory := t.TempDir()
|
||||
path := filepath.Join(directory, "main.yml")
|
||||
if err := os.WriteFile(path, []byte("version: "+canary+"\nvalue: visible\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var callbackSource configkit.SourceInfo
|
||||
var callbackContext configkit.ResourceContext
|
||||
definition := configkit.Definition[map[string]any]{
|
||||
Version: &configkit.VersionRule{Path: configkit.Keys("version"), Check: func(configkit.Version, *configkit.Validation) error { return nil }},
|
||||
Secret: func(candidate configkit.Path) bool { return candidate.String() == "/service/version" },
|
||||
Resolve: func(_ *map[string]any, resolution *configkit.Resolution) error {
|
||||
var found bool
|
||||
callbackSource, found = resolution.Source("source-1")
|
||||
if !found {
|
||||
t.Fatal("resolution callback could not query the acquired source")
|
||||
}
|
||||
if callbackSource.VersionCheck == nil || !callbackSource.VersionCheck.Redacted || callbackSource.VersionCheck.Version.Text != "" {
|
||||
t.Fatalf("callback source did not conservatively redact evidence: %#v", callbackSource.VersionCheck)
|
||||
}
|
||||
callbackContext, found = resolution.SourceContext("source-1")
|
||||
if !found || callbackContext.FS == nil || callbackContext.BaseDir != "." {
|
||||
t.Fatalf("callback source context = %#v, found=%v", callbackContext, found)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
loaded, err := configkit.LoadLayered(configkit.Input{Source: configkit.File(path), Destination: configkit.Keys("service")}, configkit.LayeredOptions[map[string]any]{
|
||||
Definition: definition, SourceVersionMode: configkit.SourceVersionModeIfPresent,
|
||||
})
|
||||
if err != nil || loaded == nil {
|
||||
t.Fatalf("LoadLayered = %#v, %v", loaded, err)
|
||||
}
|
||||
if callbackSource.VersionCheck == nil || !callbackSource.VersionCheck.Redacted || callbackSource.VersionCheck.Version.Text != "" {
|
||||
t.Fatalf("callback evidence lost its conservative snapshot: %#v", callbackSource.VersionCheck)
|
||||
}
|
||||
callbackSource.VersionCheck.Path = configkit.Keys("mutated-callback")
|
||||
callbackSource.VersionCheck.Version.Text = canary
|
||||
callbackSource.VersionCheck.Location.Line = 98
|
||||
finalSources := loaded.Sources()
|
||||
if len(finalSources) != 1 || finalSources[0].Path != path || finalSources[0].VersionCheck == nil {
|
||||
t.Fatalf("final sources = %#v", finalSources)
|
||||
}
|
||||
check := finalSources[0].VersionCheck
|
||||
if check.Path.String() != "/version" || check.Version != (configkit.Version{Present: true}) || !check.Redacted ||
|
||||
check.Location == nil || check.Location.SourceID != finalSources[0].ID || check.Location.Line != 1 {
|
||||
t.Fatalf("final classified evidence = %#v", check)
|
||||
}
|
||||
if err := os.Remove(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if callbackContext.FS == nil || callbackContext.BaseDir != "." {
|
||||
t.Fatalf("resource metadata changed after removing the source: %#v", callbackContext)
|
||||
}
|
||||
check.Path = configkit.Keys("mutated")
|
||||
check.Location.Line = 99
|
||||
check.Redacted = false
|
||||
check.Version.Text = "mutated"
|
||||
repeated := loaded.Sources()
|
||||
if len(repeated) != 1 || repeated[0].VersionCheck.Path.String() != "/version" ||
|
||||
repeated[0].VersionCheck.Location.Line != 1 || !repeated[0].VersionCheck.Redacted ||
|
||||
repeated[0].VersionCheck.Version.Text != "" {
|
||||
t.Fatalf("source evidence was aliased or re-read after source removal: %#v", repeated)
|
||||
}
|
||||
|
||||
publicText := "PUBLIC-SOURCE-VERSION"
|
||||
var publicCallback configkit.SourceInfo
|
||||
public, err := configkit.Load(configkit.Bytes("public", []byte("version: "+publicText+"\n")), configkit.Options[map[string]any]{
|
||||
SourceVersionMode: configkit.SourceVersionModeIfPresent,
|
||||
Definition: configkit.Definition[map[string]any]{
|
||||
Version: definition.Version,
|
||||
Resolve: func(_ *map[string]any, resolution *configkit.Resolution) error {
|
||||
publicCallback, _ = resolution.Source("source-1")
|
||||
return nil
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil || public == nil || len(public.Sources()) != 1 || public.Sources()[0].VersionCheck == nil {
|
||||
t.Fatalf("unclassified source evidence = %#v, %v", public, err)
|
||||
}
|
||||
if got := public.Sources()[0].VersionCheck; got.Redacted || got.Version.Text != publicText || !got.Version.Present {
|
||||
t.Fatalf("unclassified evidence = %#v", got)
|
||||
}
|
||||
if publicCallback.VersionCheck == nil || !publicCallback.VersionCheck.Redacted || publicCallback.VersionCheck.Version.Text != "" {
|
||||
t.Fatalf("unclassified callback evidence was not initially conservative: %#v", publicCallback.VersionCheck)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeSourceVersionEvidenceOnFailedLoadsAndSyntheticSources(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
definition := configkit.Definition[map[string]any]{Version: &configkit.VersionRule{
|
||||
Path: configkit.Keys("version"), Check: func(configkit.Version, *configkit.Validation) error { return nil },
|
||||
}}
|
||||
t.Run("early import acquisition failure", func(t *testing.T) {
|
||||
const canary = "EARLY-ACQUISITION-SOURCE-VERSION-CANARY"
|
||||
checked := 0
|
||||
earlyDefinition := configkit.Definition[map[string]any]{Version: &configkit.VersionRule{
|
||||
Path: configkit.Keys("version"), Check: func(configkit.Version, *configkit.Validation) error { checked++; return nil },
|
||||
}}
|
||||
path := filepath.Join(t.TempDir(), "main.yml")
|
||||
contents := "version: " + canary + "\nconfigkit:\n imports:\n - path: missing.yml\n required: true\n"
|
||||
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := configkit.LoadLayered(configkit.Input{Source: configkit.File(path), Imports: true}, configkit.LayeredOptions[map[string]any]{
|
||||
Definition: earlyDefinition, SourceVersionMode: configkit.SourceVersionModeIfPresent,
|
||||
})
|
||||
var failure *configkit.LoadError
|
||||
if !errors.As(err, &failure) || len(failure.Sources()) != 1 {
|
||||
t.Fatalf("early acquisition failure sources = %#v, error %v", failure, err)
|
||||
}
|
||||
if checked != 1 {
|
||||
t.Fatalf("main declaration checker calls = %d; error %v", checked, err)
|
||||
}
|
||||
check := failure.Sources()[0].VersionCheck
|
||||
if check == nil || !check.Redacted || !check.Version.Present || check.Version.Text != "" {
|
||||
t.Fatalf("early acquisition evidence was not conservatively redacted: %#v; sources %#v", check, failure.Sources())
|
||||
}
|
||||
if strings.Contains(err.Error(), canary) {
|
||||
t.Fatalf("early acquisition failure disclosed source-version canary: %v", err)
|
||||
}
|
||||
})
|
||||
_, err := configkit.Load(configkit.Bytes("malformed", []byte("version: true\n")), configkit.Options[map[string]any]{
|
||||
Definition: definition, SourceVersionMode: configkit.SourceVersionModeIfPresent,
|
||||
})
|
||||
var loadError *configkit.LoadError
|
||||
if !errors.As(err, &loadError) {
|
||||
t.Fatalf("malformed version error = %v", err)
|
||||
}
|
||||
sources := loadError.Sources()
|
||||
if len(sources) != 1 || sources[0].VersionCheck == nil || !sources[0].VersionCheck.Redacted ||
|
||||
!sources[0].VersionCheck.Version.Present || sources[0].VersionCheck.Version.Text != "" || sources[0].VersionCheck.Path.String() != "/version" {
|
||||
t.Fatalf("failed-load source evidence = %#v", sources)
|
||||
}
|
||||
sources[0].VersionCheck.Path = configkit.Keys("mutated-error")
|
||||
sources[0].VersionCheck.Version.Text = "mutated"
|
||||
sources[0].VersionCheck.Location.Line = 99
|
||||
if again := loadError.Sources()[0].VersionCheck; again.Path.String() != "/version" || again.Version.Text != "" || again.Location.Line == 99 {
|
||||
t.Fatalf("LoadError source evidence was aliased: %#v", again)
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
def configkit.Definition[map[string]any]
|
||||
canary string
|
||||
}{
|
||||
{
|
||||
name: "resolver failure",
|
||||
canary: "RESOLVER-SOURCE-VERSION-CANARY",
|
||||
def: configkit.Definition[map[string]any]{Version: definition.Version, Resolve: func(*map[string]any, *configkit.Resolution) error {
|
||||
return errors.New("resolver failed")
|
||||
}},
|
||||
},
|
||||
{
|
||||
name: "validator failure",
|
||||
canary: "VALIDATOR-SOURCE-VERSION-CANARY",
|
||||
def: configkit.Definition[map[string]any]{Version: definition.Version, Validate: func(_ *map[string]any, validation *configkit.Validation) {
|
||||
validation.Error(configkit.Keys("value"), "invalid value")
|
||||
}},
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
_, err := configkit.Load(configkit.Bytes("late-failure", []byte("version: "+test.canary+"\nvalue: configured\n")), configkit.Options[map[string]any]{
|
||||
Definition: test.def, SourceVersionMode: configkit.SourceVersionModeIfPresent,
|
||||
})
|
||||
var failure *configkit.LoadError
|
||||
if !errors.As(err, &failure) || len(failure.Sources()) != 1 {
|
||||
t.Fatalf("late failure sources = %#v, error %v", failure, err)
|
||||
}
|
||||
if strings.Contains(err.Error(), test.canary) {
|
||||
t.Fatalf("late failure disclosed source-version canary: %v", err)
|
||||
}
|
||||
check := failure.Sources()[0].VersionCheck
|
||||
if check == nil || !check.Redacted || !check.Version.Present || check.Version.Text != "" {
|
||||
t.Fatalf("late-failure evidence was not conservatively redacted: %#v", check)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
defaultResult, err := configkit.Load(configkit.Bytes("default", []byte("version: 7\n")), configkit.Options[map[string]any]{})
|
||||
if err != nil || defaultResult == nil || len(defaultResult.Sources()) != 1 || defaultResult.Sources()[0].VersionCheck != nil {
|
||||
t.Fatalf("default source version check = %#v, %v", defaultResult, err)
|
||||
}
|
||||
|
||||
withCLI, err := configkit.Load(configkit.Bytes("main", []byte("version: 8\nvalue: old\n")), configkit.Options[map[string]any]{
|
||||
Definition: definition, SourceVersionMode: configkit.SourceVersionModeIfPresent,
|
||||
Overrides: []configkit.Override{{Path: configkit.Keys("value"), Value: "new", Flag: "--value"}},
|
||||
})
|
||||
if err != nil || withCLI == nil {
|
||||
t.Fatalf("enabled load with CLI source = %#v, %v", withCLI, err)
|
||||
}
|
||||
sawCLI := false
|
||||
for _, source := range withCLI.Sources() {
|
||||
if source.Kind == configkit.SourceKindCLI && source.VersionCheck != nil {
|
||||
t.Fatalf("synthetic CLI source acquired declaration evidence: %#v", source)
|
||||
}
|
||||
sawCLI = sawCLI || source.Kind == configkit.SourceKindCLI
|
||||
}
|
||||
if !sawCLI {
|
||||
t.Fatalf("CLI source record missing: %#v", withCLI.Sources())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeSourceVersionEvidencePreservesAbsenceAndNull(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var checked []configkit.Version
|
||||
loaded, err := configkit.LoadLayered(configkit.Input{Source: configkit.Bytes("main", []byte("version: 17\n"))}, configkit.LayeredOptions[map[string]any]{
|
||||
SourceVersionMode: configkit.SourceVersionModeIfPresent,
|
||||
Definition: configkit.Definition[map[string]any]{Version: &configkit.VersionRule{
|
||||
Path: configkit.Keys("version"),
|
||||
Check: func(version configkit.Version, _ *configkit.Validation) error {
|
||||
checked = append(checked, version)
|
||||
return nil
|
||||
},
|
||||
}},
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{
|
||||
{Source: configkit.Bytes("absent", []byte("value: visible\n")), Destination: configkit.Keys("absent")},
|
||||
{Source: configkit.Bytes("null", []byte("version: null\nvalue: visible\n")), Destination: configkit.Keys("nullable")},
|
||||
}}},
|
||||
})
|
||||
if err != nil || loaded == nil {
|
||||
t.Fatalf("LoadLayered = %#v, %v", loaded, err)
|
||||
}
|
||||
if len(checked) != 2 || !checked[0].Present || checked[0].Text != "17" ||
|
||||
!checked[1].Present || !checked[1].Null {
|
||||
t.Fatalf("checker calls = %#v; absent contribution must skip while null is checked", checked)
|
||||
}
|
||||
sources := loaded.Sources()
|
||||
if len(sources) != 3 || sources[1].VersionCheck == nil || sources[2].VersionCheck == nil {
|
||||
t.Fatalf("source version evidence = %#v", sources)
|
||||
}
|
||||
absent := sources[1].VersionCheck
|
||||
if absent.Version.Present || absent.Path.String() != "/version" || absent.Location == nil || absent.Redacted {
|
||||
t.Fatalf("optional omission evidence = %#v", absent)
|
||||
}
|
||||
null := sources[2].VersionCheck
|
||||
if !null.Version.Present || !null.Version.Null || null.Version.Text != "" || null.Path.String() != "/version" {
|
||||
t.Fatalf("null declaration evidence = %#v", null)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeSourceVersionLifecycleKeepsConsumedHeadersOutOfPayloadHistory(t *testing.T) {
|
||||
type serviceConfig struct {
|
||||
Version uint64 `yaml:"version"`
|
||||
Count int `yaml:"count"`
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Nullable *string `yaml:"nullable"`
|
||||
Revision string `yaml:"revision"`
|
||||
Defaulted string `yaml:"defaulted"`
|
||||
Resolved string `yaml:"resolved"`
|
||||
Credential configkit.Secret `yaml:"credential"`
|
||||
}
|
||||
type configuration struct {
|
||||
Service serviceConfig `yaml:"service"`
|
||||
}
|
||||
t.Setenv("CONFIGKIT_LIFECYCLE_SECRET", "lifecycle-secret-value")
|
||||
var checked []configkit.Version
|
||||
var validationCalls int
|
||||
definition := configkit.Definition[configuration]{
|
||||
Defaults: func() configuration {
|
||||
return configuration{Service: serviceConfig{Defaulted: "from defaults"}}
|
||||
},
|
||||
Version: &configkit.VersionRule{
|
||||
Path: configkit.Keys("version"),
|
||||
Check: func(version configkit.Version, _ *configkit.Validation) error {
|
||||
checked = append(checked, version)
|
||||
return nil
|
||||
},
|
||||
},
|
||||
Resolve: func(current *configuration, resolution *configkit.Resolution) error {
|
||||
current.Service.Resolved = "resolved once"
|
||||
resolution.Record(configkit.Keys("service", "resolved"), configkit.ChangeNormalization, "derive resolved value")
|
||||
return nil
|
||||
},
|
||||
Validate: func(current *configuration, validation *configkit.Validation) {
|
||||
validationCalls++
|
||||
if current.Service.Version != 9007199254740993 || current.Service.Count != 0 || current.Service.Enabled ||
|
||||
current.Service.Nullable != nil || current.Service.Revision != "ordinary-payload" || current.Service.Defaulted != "from defaults" ||
|
||||
current.Service.Resolved != "resolved once" || current.Service.Credential.Value() != "lifecycle-secret-value" {
|
||||
validation.Error(configkit.Keys("service"), "assembled values did not survive source declaration processing")
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
load := func(header string) *configkit.Result[configuration] {
|
||||
t.Helper()
|
||||
result, err := configkit.LoadLayered(configkit.Input{
|
||||
Source: configkit.Bytes("main", []byte("version: 9007199254740993\n")), Destination: configkit.Keys("service"),
|
||||
}, configkit.LayeredOptions[configuration]{
|
||||
Definition: definition, SourceVersionMode: configkit.SourceVersionModeIfPresent,
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{
|
||||
Source: configkit.Bytes("underlay", []byte("version: "+header+"\ncount: 0\nenabled: false\nnullable: null\nrevision: ordinary-payload\ncredential: CONFIGKIT_LIFECYCLE_SECRET\n")),
|
||||
Destination: configkit.Keys("service"),
|
||||
}}}},
|
||||
})
|
||||
if err != nil || result == nil {
|
||||
var failure *configkit.LoadError
|
||||
var diagnostics []configkit.Diagnostic
|
||||
if errors.As(err, &failure) {
|
||||
diagnostics = failure.Diagnostics()
|
||||
}
|
||||
t.Fatalf("LoadLayered(%q) = %#v, %v; diagnostics %#v", header, result, err, diagnostics)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
first := load("underlay-release-a")
|
||||
second := load("underlay-release-b")
|
||||
if len(checked) != 4 || checked[0].Text != "9007199254740993" || checked[1].Text != "underlay-release-a" ||
|
||||
checked[2].Text != "9007199254740993" || checked[3].Text != "underlay-release-b" {
|
||||
t.Fatalf("source declarations checked = %#v", checked)
|
||||
}
|
||||
if validationCalls != 2 || first.Config.Service != second.Config.Service {
|
||||
t.Fatalf("final configs or validation count = %#v, %#v, %d", first.Config, second.Config, validationCalls)
|
||||
}
|
||||
if differences, err := configkit.Diff(first, second); err != nil || len(differences) != 0 {
|
||||
t.Fatalf("different consumed declarations created effective differences: %#v, %v", differences, err)
|
||||
}
|
||||
var firstEffective, secondEffective bytes.Buffer
|
||||
if err := first.WriteEffective(&firstEffective); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := second.WriteEffective(&secondEffective); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if firstEffective.String() != secondEffective.String() || strings.Contains(firstEffective.String(), "underlay-release") {
|
||||
t.Fatalf("consumed declarations affected effective rendering:\n%s\n%s", firstEffective.String(), secondEffective.String())
|
||||
}
|
||||
sourcesA, sourcesB := first.Sources(), second.Sources()
|
||||
if len(sourcesA) != 2 || len(sourcesB) != 2 || sourcesA[1].VersionCheck == nil || sourcesB[1].VersionCheck == nil ||
|
||||
sourcesA[1].VersionCheck.Version.Text != "underlay-release-a" || sourcesB[1].VersionCheck.Version.Text != "underlay-release-b" {
|
||||
t.Fatalf("sanitized evidence did not retain distinct accepted declarations: %#v / %#v", sourcesA, sourcesB)
|
||||
}
|
||||
versionExplanation, found := first.Explain(configkit.Keys("service", "version"))
|
||||
if !found {
|
||||
t.Fatal("retained main version missing from effective explanation")
|
||||
}
|
||||
underlayID := sourcesA[1].ID
|
||||
for _, location := range versionExplanation.Origin.Locations {
|
||||
if location.SourceID == underlayID {
|
||||
t.Fatalf("consumed underlay declaration entered effective origin: %#v", versionExplanation.Origin)
|
||||
}
|
||||
}
|
||||
for _, change := range versionExplanation.History {
|
||||
for _, location := range change.Origin.Locations {
|
||||
if location.SourceID == underlayID {
|
||||
t.Fatalf("consumed underlay declaration entered effective history: %#v", versionExplanation.History)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user