Compare commits
211 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c1c7e87f2c | |||
| 99098d2974 | |||
| 3e50388b73 | |||
| 22603f0e5b | |||
| 5f001cf597 | |||
| 47547bc63e | |||
| ce6005ca2d | |||
| ef420c943b | |||
| f0198f865d | |||
| 367fe4120f | |||
| 37ea670747 | |||
| 68de431bb9 | |||
| 720df98a5a | |||
| 81c27bb9cd | |||
| 9a08438769 | |||
| a05bb94a21 | |||
| d2608e8d56 | |||
| 18af2d2130 | |||
| bc3fc91296 | |||
| 08ea01921b | |||
| 761dd0a4e0 | |||
| b93cbb0639 | |||
| e47960d377 | |||
| f0a4bf9bb2 | |||
| 0a136063da | |||
| ae0c94d3c6 | |||
| c51bea9b4f | |||
| 132b9ec659 | |||
| c0bc5e2b73 | |||
| daa0cf9e88 | |||
| 2ba61d5c7c | |||
| e36740313d | |||
| 1960304f6f | |||
| 26ea24958c | |||
| 191015e289 | |||
| 7e53863adb | |||
| f7044cf695 | |||
| c2b3cb32da | |||
| 43ac34d10e | |||
| 795cc2f379 | |||
| e878ce51ff | |||
| ebf98ffc62 | |||
| 34c3241dae | |||
| 25382a112b | |||
| 95495ae7fc | |||
| 27be6bab23 | |||
| b066090db4 | |||
| 1d8438b635 | |||
| 3b10f97424 | |||
| c296eba6c1 | |||
| 0799e1d18f | |||
| 151b56e7ec | |||
| 4549fbaeef | |||
| c61597d137 | |||
| d120fe907b | |||
| 7f004f67ad | |||
| c0a0c753f9 | |||
| 40b00da175 | |||
| 1cee127300 | |||
| 9adcf14966 | |||
| 6b7a960d11 | |||
| 128bf475c3 | |||
| c88aac990b | |||
| 48c50e49d7 | |||
| 7ed4629c8d | |||
| c63f4ff687 | |||
| 68ec552b52 | |||
| e5475ea7f8 | |||
| cf9e2fd677 | |||
| eb87a10e1c | |||
| 54bdc24865 | |||
| ad5b859337 | |||
| 1fd0adc571 | |||
| 5b03ca6278 | |||
| b9f41d698e | |||
| fb8d56cfef | |||
| b27fe0598a | |||
| 74bff9704e | |||
| 1e69abc88c | |||
| 6c60f02b5a | |||
| d211537873 | |||
| 6db441893d | |||
| e74c26a4b3 | |||
| 08bb3a20c6 | |||
| b3bb57eda0 | |||
| 94d1e333d4 | |||
| ff10d03978 | |||
| b4477a236a | |||
| d818b08556 | |||
| 587f407a32 | |||
| 335bd7bc31 | |||
| 331195202e | |||
| 21c9bc0ad0 | |||
| 68400ffdfb | |||
| 5d5d51ab11 | |||
| 69bd9ead53 | |||
| 2ff588eb64 | |||
| c96197c417 | |||
| 24ab623cb2 | |||
| 1ce147c9f6 | |||
| 9833ca3aa3 | |||
| eafbd32489 | |||
| 9ac0053590 | |||
| 791cf16136 | |||
| 31ae30372e | |||
| 7ee9e92ef5 | |||
| 556b10b2cd | |||
| 07e4ae5d92 | |||
| 7548fa101a | |||
| 197567177e | |||
| 7330107389 | |||
| ad6d3748d9 | |||
| 1ee508ba02 | |||
| e9155fced6 | |||
| 14c83a6515 | |||
| 3ebb848ead | |||
| 4289f9ff7f | |||
| a050986916 | |||
| fb71421956 | |||
| b281adb751 | |||
| 1448f9b19d | |||
| 731149fc6d | |||
| 9ff873f54c | |||
| 3f389662e9 | |||
| 906ca34b6d | |||
| 17216a40f3 | |||
| 896c1cde08 | |||
| 91769e9f9b | |||
| 98c8b7c842 | |||
| 1954052631 | |||
| 3ffaef7ede | |||
| 6a1447c327 | |||
| 12fd0a7191 | |||
| 8155465322 | |||
| 408dd13c24 | |||
| 738c8a9cc7 | |||
| 4b4e15fc58 | |||
| 76521f7cd8 | |||
| cacc558288 | |||
| 231ae66857 | |||
| da74d4190d | |||
| 11f7154874 | |||
| 3828b117e8 | |||
| 2419e63717 | |||
| a893c7716c | |||
| 28ef9b6200 | |||
| 0dd16aa8a9 | |||
| f6d54a094a | |||
| f9ec66e867 | |||
| be5cdeb502 | |||
| 1f64673d0d | |||
| 01b08e76a3 | |||
| 453da684d7 | |||
| fb6c254d38 | |||
| a71413ce67 | |||
| d66338a1c5 | |||
| 643f43ce6a | |||
| c25cf39d19 | |||
| 7d18c93339 | |||
| b1d89810ca | |||
| bb5540b62e | |||
| 80cf23e710 | |||
| ac48557535 | |||
| b4af900c10 | |||
| f132cd6468 | |||
| 75e209eb3b | |||
| 1dd94a3cc6 | |||
| d172a5e46a | |||
| 3a1b6ff355 | |||
| 68383f34de | |||
| 549fca239d | |||
| 883244936c | |||
| a8ae2e9d5e | |||
| f18582106b | |||
| d8bf6abc2b | |||
| 4bf940c579 | |||
| 4fd3bee986 | |||
| 6f6130fd12 | |||
| 3122e2f32d | |||
| e4a5ecb4be | |||
| 826062aa17 | |||
| 08f2a8f3e9 | |||
| 12d350a292 | |||
| 4fcff5f453 | |||
| 247a12bc50 | |||
| 119b42c810 | |||
| 0f9ecf12ed | |||
| 7bf2f0c687 | |||
| 8aba43da38 | |||
| 877c497325 | |||
| 08a8c25542 | |||
| ea27f17f5c | |||
| ae3408a096 | |||
| deb9aa5c69 | |||
| 2736ddfbf2 | |||
| 7889ebddfb | |||
| 4a5316b7fc | |||
| e1e94eeec7 | |||
| 176e9a5d90 | |||
| 23c1976ef1 | |||
| aab79d9508 | |||
| ecd5d5d460 | |||
| b1c278b38b | |||
| eb5a82f1f3 | |||
| b30c9f167d | |||
| 5925066d35 | |||
| 0cca633a67 | |||
| a80273433e | |||
| 320d0c5f43 | |||
| 5098b56655 | |||
| 47944d22d1 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -28,6 +28,9 @@ go.work.sum
|
|||||||
# Local codebase-memory tooling artifacts
|
# Local codebase-memory tooling artifacts
|
||||||
/.codebase-memory/
|
/.codebase-memory/
|
||||||
|
|
||||||
|
# Local release credentials and tooling
|
||||||
|
/.releasekit/
|
||||||
|
|
||||||
# ---> Linux
|
# ---> Linux
|
||||||
*~
|
*~
|
||||||
|
|
||||||
|
|||||||
@@ -18,8 +18,3 @@ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|||||||
GNU Lesser General Public License for more details.
|
GNU Lesser General Public License for more details.
|
||||||
|
|
||||||
Third-party code and assets are subject to their respective license terms.
|
Third-party code and assets are subject to their respective license terms.
|
||||||
|
|
||||||
Policy documents adapted from Standard Repository Policies, the root AGENTS.md
|
|
||||||
orientation, and the Policy Reading Guide in docs/development.md remain licensed
|
|
||||||
under CC-BY-SA-4.0. See docs/oss/attribution.md for the affected material,
|
|
||||||
upstream credit, and license links.
|
|
||||||
|
|||||||
137
README.md
137
README.md
@@ -1,56 +1,18 @@
|
|||||||
# ConfigKit
|
# ConfigKit
|
||||||
|
|
||||||
An opinionated Go library for applications that use YAML configuration files.
|
ConfigKit loads YAML into your application's Go configuration type, combines the
|
||||||
|
inputs you choose, and lets you inspect where the final values came from. You
|
||||||
|
supply the types, defaults, and application rules; ConfigKit handles loading,
|
||||||
|
combination, strict decoding, and inspection.
|
||||||
|
|
||||||
Linux is release-validated; macOS support is best-effort. See the
|
Linux is release-validated; macOS support is best-effort. See the
|
||||||
[platform support policy](docs/policy/release.md#platform-support) for details.
|
[platform support policy](docs/policy/release.md#platform-support).
|
||||||
|
|
||||||
ConfigKit's simple `Load` accepts one strict YAML source (or explicit
|
## Load a configuration file
|
||||||
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 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,
|
|
||||||
then report domain errors and warnings through read-only semantic validation. Secret
|
|
||||||
tags and a path predicate drive deterministic redacted effective YAML. Typed secret
|
|
||||||
references resolve environment names before validation, and explicit
|
|
||||||
secrets-directory bootstrap is available as a separate startup operation. Explicit
|
|
||||||
CLI override records apply 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. 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
|
```go
|
||||||
|
import "go.maximumdirect.net/configkit"
|
||||||
|
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Enabled bool `yaml:"enabled"`
|
Enabled bool `yaml:"enabled"`
|
||||||
Workers int `yaml:"workers"`
|
Workers int `yaml:"workers"`
|
||||||
@@ -65,48 +27,57 @@ func ReadConfig(path string) (Config, error) {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Existing consumers should follow the
|
||||||
|
[module-path migration guidance](docs/consumers/README.md#vanity-module-path-migration).
|
||||||
|
|
||||||
|
## Add the features you need
|
||||||
|
|
||||||
|
Start with one file. Optional features use the same loading and inspection model:
|
||||||
|
|
||||||
|
- [Choose inputs](docs/consumers/README.md#choosing-a-loading-entry-point): read
|
||||||
|
files, an application-owned `fs.FS`, bytes, a reader, or a Go mapping. Use
|
||||||
|
explicit search descriptions when the application should look in several places.
|
||||||
|
- [Supply lower-priority settings](docs/consumers/README.md#native-filesystem-and-layered-input):
|
||||||
|
use `LoadLayered` for bundled defaults that application configuration can override.
|
||||||
|
- [Combine configuration](docs/config.md#explicit-imports): add declared imports,
|
||||||
|
select one overlay, or pass values explicitly set by your CLI parser.
|
||||||
|
- [Reuse settings](docs/config.md#sibling-inheritance): let one named entry inherit
|
||||||
|
from a sibling. [Identified lists](docs/config.md#strict-document-composition)
|
||||||
|
also support record inheritance and replace, append, or upsert operations.
|
||||||
|
- [Apply application rules](docs/config.md#recorded-application-resolution): derive
|
||||||
|
or normalize values in a resolver, then check the final configuration in a validator.
|
||||||
|
- [Handle credentials](docs/config.md#typed-secret-references): use environment-name
|
||||||
|
references and choose which configuration fields to hide in inspection output.
|
||||||
|
- [Inspect a load](docs/config.md#layered-metadata): examine sources, presence,
|
||||||
|
history, safe YAML output, or [changes between two loads](docs/config.md#effective-configuration-diffs).
|
||||||
|
|
||||||
|
The [consumer guide](docs/consumers/README.md) walks through these tasks and links
|
||||||
|
to maintained examples. The [configuration reference](docs/config.md) defines the
|
||||||
|
exact YAML, combination, and lifecycle rules.
|
||||||
|
|
||||||
## Design philosophy
|
## Design philosophy
|
||||||
|
|
||||||
ConfigKit favors one coherent, predictable configuration model over flexibility
|
ConfigKit favors consistent, predictable rules across applications. A load returns
|
||||||
for reproducing each consumer's conventions. Its goal is to let applications
|
the final configuration and metadata that distinguish supplied values such as
|
||||||
load once, inspect where final values came from, and distinguish explicit values
|
`false` and `0` from omissions. Applications keep their domain rules and runtime
|
||||||
from omissions with minimal configuration bookkeeping. Applications retain their
|
initialization.
|
||||||
domain rules and execution responsibilities.
|
|
||||||
|
|
||||||
Loading is synchronous and complete on return. Applications normally load once
|
Within one layer, independent contributions combine strictly: conflicting
|
||||||
near startup and then use the final configuration and metadata; ConfigKit stays
|
values are errors. Across layers, higher-priority settings can replace
|
||||||
inactive until explicitly called again. Applications may initiate additional
|
lower-priority settings, while mappings combine by key. Identified lists have
|
||||||
loads as needed. See the [loading lifecycle](docs/config.md#completion-and-resource-lifetime)
|
their declared combination rules. See the [worked combination
|
||||||
for completion and ownership details.
|
examples](docs/config.md#strict-document-composition) for the details and
|
||||||
|
exceptions.
|
||||||
|
|
||||||
Within a configuration tier, independent contributions compose strictly: shared
|
Loading finishes before the call returns. ConfigKit then stays inactive until you
|
||||||
mappings combine, while overlapping non-mapping definitions are errors. Between
|
call it again; inspection uses the saved result. See
|
||||||
precedence tiers, mappings combine recursively, retaining omitted lower-priority
|
[completion and resource lifetime](docs/config.md#completion-and-resource-lifetime).
|
||||||
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 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
|
The [architecture goals](docs/policy/architecture.md#success-criteria) and
|
||||||
these priorities and how genuine new needs can justify extending the shared
|
[ADRs](docs/adr) explain design decisions, including shared import and inheritance
|
||||||
model. The [future feature list](docs/roadmap/future.md) preserves candidates for
|
ordering. [Future work](docs/roadmap/future.md) lists possible extensions.
|
||||||
consideration in future releases.
|
Contributors can start with the [development guide](docs/development.md);
|
||||||
|
release maintainers use the [release procedure](docs/release.md).
|
||||||
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).
|
ConfigKit code is licensed under the [LGPL v3.0 or later](COPYRIGHT).
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package configkit
|
package configkit
|
||||||
|
|
||||||
import "gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
|
import "go.maximumdirect.net/configkit/internal/engine"
|
||||||
|
|
||||||
func fromEngineLocation(value engine.Location) Location {
|
func fromEngineLocation(value engine.Location) Location {
|
||||||
return Location{SourceID: value.SourceID, Line: value.Line, Column: value.Column}
|
return Location{SourceID: value.SourceID, Line: value.Line, Column: value.Column}
|
||||||
@@ -46,21 +46,21 @@ func fromEngineSourceInfo(value engine.SourceInfo) SourceInfo {
|
|||||||
return SourceInfo{
|
return SourceInfo{
|
||||||
ID: value.ID, Label: value.Label, Path: value.Path, Destination: fromEnginePath(value.Destination),
|
ID: value.ID, Label: value.Label, Path: value.Path, Destination: fromEnginePath(value.Destination),
|
||||||
PlacementMode: PlacementMode(engine.SourceInfoPlacementModeForFacade(value)),
|
PlacementMode: PlacementMode(engine.SourceInfoPlacementModeForFacade(value)),
|
||||||
DestinationUnresolved: engine.SourceInfoDestinationUnresolvedForFacade(value),
|
DestinationIsFallback: engine.SourceInfoDestinationUnresolvedForFacade(value),
|
||||||
Layer: Layer(value.Layer), Kind: SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex,
|
Layer: Layer(value.Layer), Kind: SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex,
|
||||||
ImportedFrom: fromEngineLocationPointer(value.ImportedFrom), VersionCheck: fromEngineSourceVersionCheck(value.VersionCheck),
|
ImportedFrom: fromEngineLocationPointer(value.ImportedFrom), VersionEvidence: fromEngineSourceVersionEvidence(value.VersionCheck),
|
||||||
ImportSelections: fromEngineImportSelections(engine.SourceInfoImportSelectionsForFacade(value)),
|
EmptyImportSelections: fromEngineEmptyImportSelections(engine.SourceInfoImportSelectionsForFacade(value)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
func fromEngineImportSelections(values []engine.ImportSelectionForFacade) []ImportSelection {
|
func fromEngineEmptyImportSelections(values []engine.ImportSelectionForFacade) []EmptyImportSelection {
|
||||||
if values == nil {
|
if values == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
result := make([]ImportSelection, len(values))
|
result := make([]EmptyImportSelection, len(values))
|
||||||
for index, value := range values {
|
for index, value := range values {
|
||||||
result[index] = ImportSelection{
|
result[index] = EmptyImportSelection{
|
||||||
Path: fromEnginePath(value.Path), Location: fromEngineLocation(value.Location),
|
Path: fromEnginePath(value.Path), Location: fromEngineLocation(value.Location),
|
||||||
Selector: ImportSelector(value.Selector), Outcome: ImportSelectionOutcome(value.Outcome),
|
Selector: ImportSelector(value.Selector),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
@@ -81,36 +81,19 @@ func toEngineOverrides(values []Override) []engine.Override {
|
|||||||
|
|
||||||
func toEngineImportDefaults(value ImportDefaults) engine.ImportDefaults {
|
func toEngineImportDefaults(value ImportDefaults) engine.ImportDefaults {
|
||||||
return engine.ImportDefaults{
|
return engine.ImportDefaults{
|
||||||
Required: cloneImportDefaultBool(value.Required),
|
Required: value.Required,
|
||||||
Follow: cloneImportDefaultBool(value.Follow),
|
Follow: value.FollowSymlinks,
|
||||||
Hidden: cloneImportDefaultBool(value.Hidden),
|
Hidden: value.IncludeHidden,
|
||||||
Recursive: cloneImportDefaultBool(value.Recursive),
|
Recursive: value.Recursive,
|
||||||
Pattern: cloneImportDefaultStrings(value.Pattern),
|
Pattern: value.Pattern,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func cloneImportDefaultBool(value *bool) *bool {
|
func fromEngineSourceVersionEvidence(value *engine.SourceVersionCheck) *SourceVersionEvidence {
|
||||||
if value == nil {
|
if value == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
cloned := *value
|
return &SourceVersionEvidence{
|
||||||
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),
|
Path: fromEnginePath(value.Path),
|
||||||
Version: Version{Present: value.Version.Present, Null: value.Version.Null, Text: value.Version.Text},
|
Version: Version{Present: value.Version.Present, Null: value.Version.Null, Text: value.Version.Text},
|
||||||
Location: fromEngineLocationPointer(value.Location), Redacted: value.Redacted,
|
Location: fromEngineLocationPointer(value.Location), Redacted: value.Redacted,
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ package configkit
|
|||||||
import (
|
import (
|
||||||
"io"
|
"io"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
|
"go.maximumdirect.net/configkit/internal/engine"
|
||||||
)
|
)
|
||||||
|
|
||||||
// LoadError reports safe structured diagnostics for a failed configuration load.
|
// LoadError reports safe structured diagnostics for a failed configuration load.
|
||||||
@@ -50,6 +50,21 @@ func (e *LoadError) Sources() []SourceInfo {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Source returns a detached record from the sanitized source evidence retained
|
||||||
|
// before loading failed, including placement evidence available at that point.
|
||||||
|
// IDs are local to the failed load. It does not acquire sources or access the
|
||||||
|
// filesystem. Empty or unknown IDs return false.
|
||||||
|
func (e *LoadError) Source(id string) (SourceInfo, bool) {
|
||||||
|
if e == nil || e.value == nil {
|
||||||
|
return SourceInfo{}, false
|
||||||
|
}
|
||||||
|
value, ok := e.value.Source(id)
|
||||||
|
if !ok {
|
||||||
|
return SourceInfo{}, false
|
||||||
|
}
|
||||||
|
return fromEngineSourceInfo(value), true
|
||||||
|
}
|
||||||
|
|
||||||
// WriteDiagnostics writes diagnostics in canonical order. It escapes terminal
|
// WriteDiagnostics writes diagnostics in canonical order. It escapes terminal
|
||||||
// control characters and propagates writer errors, including short writes.
|
// control characters and propagates writer errors, including short writes.
|
||||||
func WriteDiagnostics(output io.Writer, diagnostics []Diagnostic) error {
|
func WriteDiagnostics(output io.Writer, diagnostics []Diagnostic) error {
|
||||||
|
|||||||
22
diff.go
22
diff.go
@@ -1,15 +1,14 @@
|
|||||||
package configkit
|
package configkit
|
||||||
|
|
||||||
import "gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
|
import "go.maximumdirect.net/configkit/internal/engine"
|
||||||
|
|
||||||
// Difference describes one canonical changed region between two successful
|
// Difference describes one changed value or subtree between two successful loads.
|
||||||
// loads. Left is the earlier value and Right is the later value. Each snapshot
|
// Left is the earlier value and Right is the later value. Snapshots are copies;
|
||||||
// is detached; Present distinguishes absence from an explicit null, and
|
// Present distinguishes absence from explicit null. Redacted reports hiding
|
||||||
// Redacted reports whether combined sensitivity affected that side. Mappings
|
// based on sensitivity from either load. Mappings compare by key; a changed
|
||||||
// are compared recursively, while sequences are reported as atomic values.
|
// sequence is reported as a whole value. Records are ordered by canonical JSON
|
||||||
// Records are ordered by canonical JSON pointer. HiddenChanged indicates that
|
// pointer. HiddenChanged reports an actual hidden change, without identifying
|
||||||
// a value concealed by combined sensitivity changed, without revealing which
|
// the hidden member. Redaction alone does not set it.
|
||||||
// concealed member changed.
|
|
||||||
type Difference struct {
|
type Difference struct {
|
||||||
Path Path
|
Path Path
|
||||||
Left ValueSnapshot
|
Left ValueSnapshot
|
||||||
@@ -17,8 +16,9 @@ type Difference struct {
|
|||||||
HiddenChanged bool
|
HiddenChanged bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// Diff compares the frozen effective values of two successful loads of the
|
// Diff compares the saved final values of two successful loads of the same
|
||||||
// same configuration type. It does not reread sources, environment variables,
|
// configuration type, independently of later changes to Result.Config.
|
||||||
|
// It does not reread sources, environment variables,
|
||||||
// or rerun defaults or resolvers. Equal values produce no records even when
|
// or rerun defaults or resolvers. Equal values produce no records even when
|
||||||
// their sources, defaults, or resolution histories differ. Credential contents
|
// their sources, defaults, or resolution histories differ. Credential contents
|
||||||
// are not compared: typed secret references compare by reference name only.
|
// are not compared: typed secret references compare by reference name only.
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package configkit
|
package configkit
|
||||||
|
|
||||||
import "gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
|
import "go.maximumdirect.net/configkit/internal/engine"
|
||||||
|
|
||||||
// Search is an immutable ordered description of possible host-file inputs.
|
// Search is an immutable ordered description of possible host-file inputs.
|
||||||
// It is consumed only when passed to SearchConfig and used by a load.
|
// It is consumed only when passed to SearchConfig and used by a load.
|
||||||
@@ -52,8 +52,8 @@ func SearchDirectory(directory string, supplied ...DirectorySearchOptions) Searc
|
|||||||
return Search{value: engine.SearchDirectory(directory, options...)}
|
return Search{value: engine.SearchDirectory(directory, options...)}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Paths describes literal host-file candidates in the supplied order.
|
// SearchPaths describes literal host-file candidates in the supplied order.
|
||||||
func Paths(paths ...string) Search { return Search{value: engine.Paths(paths...)} }
|
func SearchPaths(paths ...string) Search { return Search{value: engine.Paths(paths...)} }
|
||||||
|
|
||||||
// Searches concatenates descriptions in order and snapshots their entries.
|
// Searches concatenates descriptions in order and snapshots their entries.
|
||||||
func Searches(searches ...Search) Search {
|
func Searches(searches ...Search) Search {
|
||||||
|
|||||||
22
doc.go
22
doc.go
@@ -1,9 +1,17 @@
|
|||||||
// Package configkit provides deterministic, application-owned configuration
|
// Package configkit loads YAML into an application's Go configuration type and
|
||||||
// loading for Go programs.
|
// lets the application inspect where the final values came from.
|
||||||
//
|
//
|
||||||
// Load supports file, byte, reader, and explicit no-input sources with strict YAML,
|
// Start with Load and a File, Bytes, Reader, Mapping, or Filesystem.File source.
|
||||||
// typed defaults, imports, overlays, CLI overrides, schema-version checks, detached
|
// Use NoSource for deliberately absent input or SearchConfig for an explicitly
|
||||||
// metadata, recorded application resolution, typed secret references, safe effective
|
// chosen search. Applications supply types, defaults, version rules, and callbacks
|
||||||
// rendering, read-only semantic validation, and structured load errors. The
|
// for deriving and validating values. LoadLayered adds lower-priority input groups.
|
||||||
// configuration contract is documented in docs/config.md.
|
//
|
||||||
|
// A successful load returns the final configuration and saved inspection state.
|
||||||
|
// Metadata tracks supplied values, including false and zero; explanations retain
|
||||||
|
// safe earlier values. WriteEffective and Diff use the saved state even if the
|
||||||
|
// application later changes Result.Config. Secret references resolve credentials
|
||||||
|
// during loading, while inspection follows the configured hiding rules.
|
||||||
|
//
|
||||||
|
// The consumer guide is in docs/consumers/README.md. The exact YAML, combination,
|
||||||
|
// lifecycle, and ownership rules are documented in docs/config.md.
|
||||||
package configkit
|
package configkit
|
||||||
|
|||||||
@@ -10,6 +10,9 @@ request, and review records are preserved at `e4270c9` under `docs/roadmap/` in
|
|||||||
Git history; the original input-destination implementation plan remains at
|
Git history; the original input-destination implementation plan remains at
|
||||||
`315f464:docs/roadmap/implementation.md`.
|
`315f464:docs/roadmap/implementation.md`.
|
||||||
|
|
||||||
|
[ADR 0020](0020-document-root-controls.md) amends the scope of the control-name
|
||||||
|
reservation. The other placement decisions in this record remain in force.
|
||||||
|
|
||||||
## Date
|
## Date
|
||||||
|
|
||||||
2026-10-14
|
2026-10-14
|
||||||
|
|||||||
61
docs/adr/0020-document-root-controls.md
Normal file
61
docs/adr/0020-document-root-controls.md
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
# ADR 0020: Scope Configuration Controls to Document Roots
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted.
|
||||||
|
|
||||||
|
## Date
|
||||||
|
|
||||||
|
2026-11-01
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
ConfigKit interprets configuration controls during source acquisition, before
|
||||||
|
placing and composing source payloads. Reserving the control name throughout
|
||||||
|
application data restricts paths unrelated to source-document controls. The
|
||||||
|
source-local position of a control and the effective application path of a
|
||||||
|
value are different coordinates, even when their path text is identical.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Use `config` as the single control mapping name at the root of each acquired
|
||||||
|
source document. Interpret each source's controls before placing its payload,
|
||||||
|
using the existing context-specific grammar and import opt-in. A source retains
|
||||||
|
its own document root regardless of its eventual destination, including when
|
||||||
|
mounted below another value or as a sequence record.
|
||||||
|
|
||||||
|
Below a source root, `config` is ordinary application data, including in
|
||||||
|
sequence records and selected overlays. Control-looking children in such data
|
||||||
|
do not activate controls. Application-root `config` data produced by placement,
|
||||||
|
defaults, CLI assignment, or resolution is also ordinary data. Placement and
|
||||||
|
later lifecycle operations do not create another source-document parsing pass.
|
||||||
|
|
||||||
|
The former spellings `configkit` and `_configkit` are ordinary application keys
|
||||||
|
at any depth, subject to normal schema and structural rules; they are not
|
||||||
|
control aliases. Source-version checks protect actual source-local controls,
|
||||||
|
not an application path merely because its spelling matches. Existing `extends`
|
||||||
|
inheritance semantics and restrictions remain in force.
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
- **Reserve the control name throughout application data.** This conflates
|
||||||
|
source-document syntax with application paths and prevents ordinary fields
|
||||||
|
unrelated to control interpretation.
|
||||||
|
- **Recognize controls again after placement or later operations.** This makes
|
||||||
|
application data acquire control semantics according to its destination and
|
||||||
|
introduces a second interpretation pass.
|
||||||
|
- **Support multiple or configurable control spellings.** This makes control
|
||||||
|
interpretation ambiguous and expands the configuration contract.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
Control evidence uses source-local coordinates while assembled values use
|
||||||
|
application coordinates. Existing source identities and contexts distinguish
|
||||||
|
these uses without a new inspection-record format. Consumers must migrate
|
||||||
|
genuine old control blocks and account for source-root application fields
|
||||||
|
named `config`; ordinary nested and assembled root fields remain available.
|
||||||
|
|
||||||
|
The completed feature roadmap and implementation plan are preserved in Git
|
||||||
|
history at `c63f4ff` under `docs/roadmap/`, for example
|
||||||
|
`git show c63f4ff:docs/roadmap/implementation.md`.
|
||||||
|
The [configuration contract](../config.md) owns implemented behavior.
|
||||||
65
docs/adr/0021-sequence-resolution-correspondence.md
Normal file
65
docs/adr/0021-sequence-resolution-correspondence.md
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
# ADR 0021: Match Registered Records During Resolution Inspection
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted.
|
||||||
|
|
||||||
|
## Date
|
||||||
|
|
||||||
|
2026-11-02
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Application resolution can change fields within sequences. Treating every
|
||||||
|
sequence change as atomic prevents a field record from covering an individual
|
||||||
|
edit. Registered collection identities also provide a way to relate a final
|
||||||
|
record to its entry value when positions change, so inspection can retain the
|
||||||
|
correct source evidence without confusing it with a prior index occupant.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Compare equal-length ordinary lists recursively by position during resolution
|
||||||
|
auditing. A length change requires coverage of the list or an ancestor. For a
|
||||||
|
collection with an applicable application identity registration, match entry and
|
||||||
|
final records only when both endpoints have valid, unique identities. A change
|
||||||
|
in identity order or membership requires list or ancestor coverage, while
|
||||||
|
surviving records retain their matched entry evidence. When matching is not
|
||||||
|
available, audit a changed list as a whole without introducing an early identity
|
||||||
|
failure. Final registration validation remains at its existing boundary.
|
||||||
|
|
||||||
|
Match only the resolution-entry and final snapshots, within the same registered
|
||||||
|
destination. Identity changes are removals and additions, not inferred renames.
|
||||||
|
Records and helpers address final positions; entry queries remain fixed to entry
|
||||||
|
coordinates, and helpers do not follow later moves automatically. A surviving
|
||||||
|
record takes precedence at a reused position; collection inspection retains
|
||||||
|
removed records' evidence, and absent historical positions remain inspectable.
|
||||||
|
New records do not inherit a previous occupant's history or explicitness.
|
||||||
|
|
||||||
|
Preserve recorded operations, source coordinates, sensitivity, and safe detached
|
||||||
|
inspection. Resolution comparison is separate from public effective diff, which
|
||||||
|
continues to present sequence changes atomically.
|
||||||
|
|
||||||
|
This extends resolution inspection under [ADR 0003](0003-preserve-presence-and-resolution-history.md)
|
||||||
|
while preserving the diff decision in [ADR 0010](0010-effective-configuration-diff.md),
|
||||||
|
source combination and identity rules in [ADR 0017](0017-collection-sequence-policy.md),
|
||||||
|
and lifecycle ordering in [ADR 0018](0018-cli-after-structural-resolution.md).
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
- **Keep all sequence changes atomic during resolution.** This forces whole-list
|
||||||
|
records for independent field edits and does not identify a moved record's
|
||||||
|
earlier evidence.
|
||||||
|
- **Infer identities for every list.** Similar values or conventional field
|
||||||
|
names do not establish an application identity and could attach unrelated
|
||||||
|
history to a final record.
|
||||||
|
- **Reconstruct every intermediate callback mutation.** This requires a new
|
||||||
|
mutation history and implies an ordering that endpoint snapshots cannot prove.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
Resolution auditing and inspection must share entry-to-final correspondence,
|
||||||
|
including explicit unmatched records, while preserving historical coordinates
|
||||||
|
and conservative sensitivity masking. Source assembly and public path types do
|
||||||
|
not change. The completed implementation plan is preserved in Git history at
|
||||||
|
`7e53863:docs/roadmap/implementation.md`; the
|
||||||
|
[configuration contract](../config.md) owns implemented behavior.
|
||||||
47
docs/adr/0022-main-relative-context.md
Normal file
47
docs/adr/0022-main-relative-context.md
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
# ADR 0022: Bind Companion Contexts After Main Selection
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted.
|
||||||
|
|
||||||
|
## Date
|
||||||
|
|
||||||
|
2026-11-09
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Applications choose a main-file search and may also declare companion imports
|
||||||
|
and a selected overlay. The main directory is only known after discovery, so
|
||||||
|
supplying every dependent context before loading duplicates selection or requires
|
||||||
|
a preliminary load. ADR 0019 keeps main discovery explicit and inside loading.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Extend layered loading to bind application-declared non-file underlays to the
|
||||||
|
resolved main context after acquisition. An explicit underlay context overrides
|
||||||
|
the binding, and an explicit fallback or require-base option controls absence.
|
||||||
|
Selected layered overlays choose an explicit context, the resolved main context,
|
||||||
|
or a fallback in that order. Derived and fallback lookup use the context base;
|
||||||
|
the inherited root continues to bound containment. Ordinary and explicit
|
||||||
|
overlay lookup retain their established root-relative behavior.
|
||||||
|
|
||||||
|
Applications remain responsible for choosing searches, import declarations,
|
||||||
|
and overlay names. Selection failures are terminal; no second search or
|
||||||
|
application context callback is introduced.
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
- Require applications to discover the main file separately. This repeats
|
||||||
|
discovery and may run a complete load more than once.
|
||||||
|
- Implicitly scan conventional companion directories. This would impose
|
||||||
|
application-specific conventions without an explicit declaration.
|
||||||
|
- Add only a relative file constructor. This would not cover mapping or bytes
|
||||||
|
sources with existing import, placement, and composition rules.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
The load owns the resolved binding and keeps namespace, containment, source
|
||||||
|
identity, and handle lifetime in the existing acquisition lifecycle. A missing
|
||||||
|
base skips optional imports while preserving the declaring payload; required
|
||||||
|
imports and require-base retain explicit failures. The configuration contract
|
||||||
|
and public declarations describe the observable rules.
|
||||||
971
docs/config.md
971
docs/config.md
File diff suppressed because it is too large
Load Diff
@@ -1,21 +1,46 @@
|
|||||||
# Consumer Adoption Guide
|
# Consumer Adoption Guide
|
||||||
|
|
||||||
ConfigKit removes parser setup, strict decoding, presence bookkeeping, import and
|
ConfigKit loads configuration into your application's Go types. You provide
|
||||||
selected-overlay traversal, precedence, provenance, secret lookup, and safe inspection from
|
those types, defaults, and application rules. ConfigKit reads and combines the
|
||||||
applications. Consumers still own their configuration structs, defaults, semantic
|
selected inputs, checks their types, and returns values with source information.
|
||||||
validation, schema-version policy, CLI parsing, runtime matching and conversion,
|
Your application still owns CLI parsing, resource reads, remote acquisition, and
|
||||||
resource checks, remote acquisition, and service initialization.
|
runtime initialization.
|
||||||
|
|
||||||
|
Import `"go.maximumdirect.net/configkit"`; the package name is `configkit`.
|
||||||
|
|
||||||
|
Document controls now use root `config` in every source. Rename genuine old
|
||||||
|
`configkit` or `_configkit` control blocks and update stored declaration paths
|
||||||
|
from `/configkit/...` to `/config/...`. Old blocks become ordinary data and a
|
||||||
|
permissive schema can accept them without executing imports. Move, rename, or
|
||||||
|
separately mount application fields previously authored as source-root `config`.
|
||||||
|
Nested and assembled root `config` values remain ordinary application data; the
|
||||||
|
[mounted-root example](../../examples/consumers/config_controls_test.go) shows
|
||||||
|
the boundary. See the [configuration contract](../config.md#declaration-and-selection-rules)
|
||||||
|
for the complete rules.
|
||||||
|
Start with one file, then choose the tasks your application needs:
|
||||||
|
|
||||||
|
| Task | Start here |
|
||||||
|
| --- | --- |
|
||||||
|
| Load one file | [Happy path](#happy-path) and the [README example](../../README.md#load-a-configuration-file) |
|
||||||
|
| Supply fixed defaults and check application rules | [Defaults and application rules](#defaults-and-application-rules) |
|
||||||
|
| Add bundled defaults beneath application settings | [Native filesystem and layered input](#native-filesystem-and-layered-input) |
|
||||||
|
| Select files or an overlay | [Discovery](#explicit-main-file-discovery), [import defaults](#import-defaults-and-selection-inspection), and the [overlay reference](../config.md#selected-overlays) |
|
||||||
|
| Reuse settings or combine record lists | [Inheritance](#named-sibling-inheritance) and [identified sequences](#identified-prompt-sequences) |
|
||||||
|
| Handle credentials | [Typed-secret reference](../config.md#typed-secret-references) and the [startup example](../../examples/consumers/consumers_test.go) |
|
||||||
|
| Inspect sources, earlier values, or changes | [Inspect a completed load](#inspect-a-completed-load) and [diffs](#effective-configuration-diffs) |
|
||||||
|
| Upgrade an existing consumer | [Migration guidance](#intentional-migration-changes) |
|
||||||
|
|
||||||
## Choosing a loading entry point
|
## Choosing a loading entry point
|
||||||
|
|
||||||
- Use [`Load`](../../lifecycle.go) with one source for an ordinary configuration.
|
- Use [`Load`](../../lifecycle.go) with one source for an ordinary configuration.
|
||||||
[`SearchConfig`](../../discovery.go) is also a source, so its explicit
|
[`SearchConfig`](../../discovery.go) is also a source; pass it to `Load` or use
|
||||||
discovery result can be passed to `Load` or used as the main input to
|
it as the main input to [`LoadLayered`](../../layered.go).
|
||||||
[`LoadLayered`](../../layered.go).
|
- Use `LoadLayered` when several inputs should supply lower-priority defaults
|
||||||
- Use `LoadLayered` when inputs must be composed in underlay groups before the
|
beneath the main source. Each underlay group combines its inputs strictly.
|
||||||
main source. Each [`Input`](../../layered.go) owns its source, filesystem,
|
Each [`Input`](../../layered.go) owns its source, filesystem, destination, and
|
||||||
destination, and import choice. The overlay filesystem context is selected
|
import choice. Set `Input.MainRelative` on a companion underlay to use the
|
||||||
independently in [`LayeredOptions`](../../layered.go).
|
main input's filesystem context. Overlay context selection belongs to
|
||||||
|
[`LayeredOptions`](../../layered.go).
|
||||||
- Load-wide rules belong to [`Options`](../../lifecycle.go) or
|
- Load-wide rules belong to [`Options`](../../lifecycle.go) or
|
||||||
[`LayeredOptions`](../../layered.go): the application definition (including
|
[`LayeredOptions`](../../layered.go): the application definition (including
|
||||||
version policy), import defaults, and explicit overrides. These do not turn
|
version policy), import defaults, and explicit overrides. These do not turn
|
||||||
@@ -29,69 +54,71 @@ file and layered cases. For import-focused adoption, see the
|
|||||||
[prompt catalog](../../examples/consumers/prompt_catalog.go); for explicit
|
[prompt catalog](../../examples/consumers/prompt_catalog.go); for explicit
|
||||||
discovery composed with sequences, see the
|
discovery composed with sequences, see the
|
||||||
[sequence catalog](../../examples/consumers/sequence_catalog.go).
|
[sequence catalog](../../examples/consumers/sequence_catalog.go).
|
||||||
|
For discovered main files with companion imports and an overlay, see the
|
||||||
The maintained [`examples/consumers`](../../examples/consumers) package demonstrates
|
[main-relative example](../../examples/consumers/main_relative.go).
|
||||||
sanitized shapes using only ConfigKit and the Go standard library:
|
|
||||||
|
|
||||||
| Shape | Demonstrated fit | Application code deliberately retained |
|
|
||||||
| --- | --- | --- |
|
|
||||||
| Narratio-like | Fresh defaults, explicit partial imports, artifact maps, recorded inherited defaults, normalization, and source ownership | Artifact meaning and the separate `model_profile` domain field |
|
|
||||||
| Notarius-like | Canonical object bindings, a string-backed schema version accepting string or integer YAML, strict dynamic options, and `int64` dynamic integers | Catalog selection, allowed option keys, range-checked conversion to runtime `int` |
|
|
||||||
| WeatherReporter-like | Defaults-only loading, report maps, canonical module objects, typed option unions, explicit CLI records, and typed secret startup | Report/module compatibility, rejection of explicitly empty domain lists, CLI parsing, and startup ordering |
|
|
||||||
| Seriatim-like | Independent strict ordered speaker and autocorrect rule files | First-match and ordered replacement algorithms; no central application configuration file |
|
|
||||||
| 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,
|
|
||||||
credentials, network calls, or dependency on a sibling checkout.
|
|
||||||
|
|
||||||
The Notarius-shaped fixtures deliberately load both `version: 4` and
|
|
||||||
`version: "4"` into the same string-backed application field. Its version checker
|
|
||||||
still owns the supported identifier; fractional numeric input and unsupported
|
|
||||||
integer identifiers remain negative examples.
|
|
||||||
|
|
||||||
## Happy path
|
## Happy path
|
||||||
|
|
||||||
An application defines ordinary types and passes one explicit source. Optional
|
Define the Go configuration type, choose one source, and call `Load`. Handle a
|
||||||
features appear only when the application needs them:
|
load error before starting the application with `result.Config`. The
|
||||||
|
[README example](../../README.md#load-a-configuration-file) shows this complete
|
||||||
|
minimal loading function. An explicitly missing file is an error; use `NoSource`
|
||||||
|
when you deliberately want defaults without a main document.
|
||||||
|
|
||||||
|
### Defaults and application rules
|
||||||
|
|
||||||
|
Using the README's `Config` type, an application can add reusable rules:
|
||||||
|
|
||||||
```go
|
```go
|
||||||
result, err := configkit.Load[Config](configkit.File(path), configkit.Options[Config]{
|
definition := configkit.Definition[Config]{
|
||||||
Definition: rules,
|
Defaults: func() Config { return Config{Enabled: true, Workers: 2} },
|
||||||
Imports: useDeclaredImports,
|
Validate: func(value *Config, validation *configkit.Validation) {
|
||||||
Overlay: selectedConfigOverlay,
|
if value.Workers < 0 {
|
||||||
Overrides: explicitlySetFlags,
|
validation.Error(configkit.Keys("workers"), "must be nonnegative")
|
||||||
})
|
}
|
||||||
if err != nil {
|
},
|
||||||
return err
|
|
||||||
}
|
}
|
||||||
return start(result.Config)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Simple consumers use neither imports nor selected overlays. A complex consumer can opt into
|
Pass this definition in `Options[Config]{Definition: definition}` when calling
|
||||||
both without redefining their syntax. ConfigKit overlays are configuration overlays;
|
`Load`. Supplied `enabled: false` or `workers: 0` replaces those defaults.
|
||||||
they do not select or replace an application's LLM, model, pipeline, report, or
|
Use `Resolve` when a value must be derived or normalized before validation, and
|
||||||
execution profile concepts.
|
record that change with the provided helpers. The
|
||||||
|
[callback reference](../config.md#recorded-application-resolution) compares the
|
||||||
|
helpers; [executable examples](../../example_test.go) show full definitions.
|
||||||
|
|
||||||
|
### Inspect a completed load
|
||||||
|
|
||||||
|
Keep the result when you need to explain configuration. Use
|
||||||
|
`result.Metadata().Lookup(configkit.Keys("workers"))` to inspect whether input
|
||||||
|
supplied the value and which source or application step supplied it. Follow an
|
||||||
|
origin's source ID with `result.Source(id)` when you need its source record.
|
||||||
|
|
||||||
|
Use `Explain` for earlier values, `WriteExplanation` for a readable explanation,
|
||||||
|
and `WriteEffective` for safe YAML describing the final saved state. These
|
||||||
|
operations describe the completed load even if your application later changes
|
||||||
|
`result.Config`. The [inspection reference](../config.md#layered-metadata)
|
||||||
|
explains returned values, copies, and hidden information.
|
||||||
|
|
||||||
|
Add imports, a selected overlay, or explicitly set CLI values through options
|
||||||
|
only when needed. An overlay changes configuration; choosing an LLM, report, or
|
||||||
|
execution profile remains application logic.
|
||||||
|
|
||||||
## Explicit main-file discovery
|
## Explicit main-file discovery
|
||||||
|
|
||||||
Applications choose the search conventions and their precedence, then pass the
|
Applications choose the search conventions and their precedence, then pass the
|
||||||
resulting `SearchConfig` source to the same `Load` or `LoadLayered` entry point.
|
resulting `SearchConfig` source to the same `Load` or `LoadLayered` entry
|
||||||
For example, the maintained sequence catalog puts a caller-selected file before
|
point. For example, the maintained sequence catalog puts a caller-selected
|
||||||
a custom directory fallback while keeping its underlay and overlay choices
|
file before a custom directory fallback while keeping its underlay and overlay
|
||||||
independent. See [`sequence_catalog.go`](../../examples/consumers/sequence_catalog.go)
|
choices independent. See
|
||||||
and its [behavior tests](../../examples/consumers/sequence_catalog_test.go), plus
|
[`sequence_catalog.go`](../../examples/consumers/sequence_catalog.go) and its
|
||||||
the [working-directory executable example](../../examples/consumers/discovery_test.go).
|
[behavior tests](../../examples/consumers/sequence_catalog_test.go), plus the
|
||||||
Together, the root-owned [discovery API](../../discovery.go) and [configuration
|
[working-directory executable
|
||||||
discovery contract](../config.md#configuration-discovery) define ordered
|
example](../../examples/consumers/discovery_test.go). Together, the [discovery
|
||||||
concatenation, directory-first Cartesian expansion, optional exhaustion, and
|
API](../../discovery.go) and [configuration discovery
|
||||||
selected-file behavior. [ADR 0019](../adr/0019-explicit-configuration-discovery.md)
|
contract](../config.md#configuration-discovery) explain the order of attempted
|
||||||
|
files, what happens when no file is found, and why a selected-file failure
|
||||||
|
stops the search. [ADR 0019](../adr/0019-explicit-configuration-discovery.md)
|
||||||
records why discovery is explicitly selected.
|
records why discovery is explicitly selected.
|
||||||
|
|
||||||
## Import defaults and selection inspection
|
## Import defaults and selection inspection
|
||||||
@@ -99,15 +126,20 @@ records why discovery is explicitly selected.
|
|||||||
`Options.ImportDefaults` and `LayeredOptions.ImportDefaults` apply one
|
`Options.ImportDefaults` and `LayeredOptions.ImportDefaults` apply one
|
||||||
load-wide policy to enabled main, underlay, and transitive imports. Each field
|
load-wide policy to enabled main, underlay, and transitive imports. Each field
|
||||||
is resolved independently: a declaration's explicit value wins, then the
|
is resolved independently: a declaration's explicit value wins, then the
|
||||||
application default, then the library default. `Required`, `Follow`, `Hidden`,
|
application default, then the library default. `Required`, `FollowSymlinks`,
|
||||||
and `Recursive` use nil pointers for unspecified values. `Pattern` applies to
|
`IncludeHidden`, and `Recursive` use nil pointers for unspecified values.
|
||||||
|
`Pattern` applies to
|
||||||
directory searches: nil leaves filenames unrestricted, while a non-nil empty
|
directory searches: nil leaves filenames unrestricted, while a non-nil empty
|
||||||
slice selects no files. A declaration's own `pattern` replaces the application
|
slice selects no files. A declaration's own `pattern` replaces the application
|
||||||
default. The defaults do not enable imports or change selected-overlay behavior.
|
default. The defaults do not enable imports or change selected-overlay behavior.
|
||||||
|
The Go fields `FollowSymlinks` and `IncludeHidden` correspond to the unchanged
|
||||||
|
YAML keys `follow` and `hidden`.
|
||||||
|
|
||||||
`Result.Sources`, `LoadError.Sources`, and `Resolution.Source` expose each
|
`Result.Sources`, `LoadError.Sources`, and `Resolution.Source` expose each
|
||||||
declaring source's `ImportSelections`. These records identify the source-local
|
declaring source's `EmptyImportSelections`. `Result.Source(id)` and
|
||||||
declaration path, its authored location and selector, and the `none` outcome;
|
`LoadError.Source(id)` retrieve one retained record directly when its load-local
|
||||||
|
ID is known. These records identify the source-local declaration path, its
|
||||||
|
authored location and selector;
|
||||||
they do not include candidate names or a guessed reason for absence. Returned
|
they do not include candidate names or a guessed reason for absence. Returned
|
||||||
records are detached snapshots. The [configuration contract](../config.md#explicit-imports)
|
records are detached snapshots. The [configuration contract](../config.md#explicit-imports)
|
||||||
owns selector applicability and exact absence semantics.
|
owns selector applicability and exact absence semantics.
|
||||||
@@ -117,7 +149,7 @@ owns selector applicability and exact absence semantics.
|
|||||||
[`LoadVersionedService`](../../examples/consumers/source_version.go) demonstrates
|
[`LoadVersionedService`](../../examples/consumers/source_version.go) demonstrates
|
||||||
if-present and required modes with one `Definition.Version` rule across a mounted
|
if-present and required modes with one `Definition.Version` rule across a mounted
|
||||||
main input and an underlay. The example reads only sanitized
|
main input and an underlay. The example reads only sanitized
|
||||||
`SourceInfo.VersionCheck` fields; its executable test is in
|
`SourceInfo.VersionEvidence` fields; its executable test is in
|
||||||
[`layered_test.go`](../../examples/consumers/layered_test.go). The
|
[`layered_test.go`](../../examples/consumers/layered_test.go). The
|
||||||
[configuration contract](../config.md#shared-source-version-checks) defines
|
[configuration contract](../config.md#shared-source-version-checks) defines
|
||||||
participation, source-local lookup and diagnostics, non-main header consumption,
|
participation, source-local lookup and diagnostics, non-main header consumption,
|
||||||
@@ -135,13 +167,14 @@ priority values remain present. Its resolver records a derived endpoint URL afte
|
|||||||
overlay application, and its nested typed secret reference is classified before
|
overlay application, and its nested typed secret reference is classified before
|
||||||
the resulting records are presented.
|
the resulting records are presented.
|
||||||
|
|
||||||
The maintained [behavior tests and executable examples](../../examples/consumers/diff_test.go)
|
The maintained [behavior tests and executable
|
||||||
use temporary files and disposable secret values. Presenters should use
|
examples](../../examples/consumers/diff_test.go) use temporary files and
|
||||||
`ValueSnapshot.Present` for absence, record membership for change, and
|
disposable secret values. Presenters should use `ValueSnapshot.Present` for
|
||||||
`HiddenChanged` for a withheld change indicator. Do not parse the redaction marker
|
absence, record membership for change, and `HiddenChanged` for a withheld
|
||||||
or add separately rendered full contexts that lack combined sensitivity. The
|
change indicator. Do not parse the redaction marker or add separately rendered
|
||||||
[configuration contract](../config.md#effective-configuration-diffs) owns shared
|
full contexts that lack combined sensitivity. The [configuration
|
||||||
equality, granularity, and disclosure semantics.
|
contract](../config.md#effective-configuration-diffs) owns shared equality,
|
||||||
|
granularity, and disclosure semantics.
|
||||||
|
|
||||||
## Named sibling inheritance
|
## Named sibling inheritance
|
||||||
|
|
||||||
@@ -264,14 +297,13 @@ conversion API.
|
|||||||
## Source-aware application path policy
|
## Source-aware application path policy
|
||||||
|
|
||||||
[`LoadNotariusPaths`](../../examples/consumers/consumers.go) is the maintained
|
[`LoadNotariusPaths`](../../examples/consumers/consumers.go) is the maintained
|
||||||
path-bearing example. Its application resolver uses `Resolution.InputInfo` and
|
path-bearing example. Its application resolver uses `Resolution.EntryInfo` and
|
||||||
`Resolution.Source` before normalizing: relative CLI input uses an explicitly
|
`Resolution.Source` before normalizing: relative CLI input uses an explicitly
|
||||||
supplied invocation directory, retained defaults use the canonical main source,
|
supplied invocation directory, retained defaults use the canonical main source,
|
||||||
and a file-authored scalar uses its one declaring source. Absolute paths remain
|
and a file-authored scalar uses its one declaring source. Absolute paths remain
|
||||||
unchanged. Its executable test covers the matching effective YAML and explanation
|
unchanged. Its executable test checks the resulting YAML and explanation
|
||||||
history, source-file ownership, and a symlink opened through an alias; canonical
|
history. When the configuration filename is a symlink, this example uses the
|
||||||
target-file directories, rather than the alias directory or process working
|
actual target file's directory as the base for file-authored paths.
|
||||||
directory, are the selected bases.
|
|
||||||
|
|
||||||
The selected example policy leaves a relative value unchanged and emits a warning
|
The selected example policy leaves a relative value unchanged and emits a warning
|
||||||
when a CLI base is absent or file provenance is missing or composite. That is an
|
when a CLI base is absent or file provenance is missing or composite. That is an
|
||||||
@@ -279,9 +311,77 @@ application choice, not a ConfigKit fallback: consumers can instead reject it or
|
|||||||
choose another explicit base. The example intentionally performs neither target
|
choose another explicit base. The example intentionally performs neither target
|
||||||
existence checks nor confinement checks.
|
existence checks nor confinement checks.
|
||||||
|
|
||||||
|
### Normalizing paths inside a list
|
||||||
|
|
||||||
|
Suppose `/srv/catalog/resources.yml` supplies this configuration, and the
|
||||||
|
application interprets file-authored relative paths against their declaring
|
||||||
|
file's directory:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
resources:
|
||||||
|
- name: reports
|
||||||
|
path: ./reports
|
||||||
|
- name: invoices
|
||||||
|
path: ./invoices
|
||||||
|
```
|
||||||
|
|
||||||
|
Apply the same source-aware policy as `LoadNotariusPaths` to each `path` field
|
||||||
|
while the list remains in its entry order:
|
||||||
|
|
||||||
|
1. Build the field's semantic path, such as
|
||||||
|
`configkit.Keys("resources").Index(0).Key("path")`.
|
||||||
|
2. Query `Resolution.EntryInfo` at that path and use `Resolution.Source` to
|
||||||
|
identify its declaring file. Choose the base under the application's policy;
|
||||||
|
defaults, CLI values, and missing or composite provenance need their own
|
||||||
|
handling, as in the maintained example above.
|
||||||
|
3. Resolve the relative value against that base and use `Normalize` to assign
|
||||||
|
and record the individual field. For a mutation already performed by the
|
||||||
|
application, use `RecordNormalization` instead.
|
||||||
|
4. Continue without reordering the list. Later resource access uses the resolved
|
||||||
|
value rather than trying to recover a base directory from its effective origin.
|
||||||
|
|
||||||
|
For these file-authored values, the resulting configuration is:
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
resources:
|
||||||
|
- name: reports
|
||||||
|
path: /srv/catalog/reports
|
||||||
|
- name: invoices
|
||||||
|
path: /srv/catalog/invoices
|
||||||
|
```
|
||||||
|
|
||||||
|
Each changed `path` has a resolution origin, while its history retains the
|
||||||
|
earlier relative value and source evidence. Unchanged fields such as `name`
|
||||||
|
retain their source origins. Field-level recording avoids attributing the whole
|
||||||
|
list to the path-normalization operation; it does not preserve a file origin on
|
||||||
|
the normalized fields themselves.
|
||||||
|
|
||||||
|
If the application also restructures the list during resolution, a whole-list
|
||||||
|
record covers the structural change. For a collection registered with `name`
|
||||||
|
as its identity, history follows each surviving record through a reorder, but
|
||||||
|
descendants can acquire synthetic resolution origins without source locations.
|
||||||
|
Historical source evidence and effective origins answer different questions;
|
||||||
|
identity-aware history does not automatically select a resource base for a
|
||||||
|
derived value. Resolve source-relative values before moving records, and account
|
||||||
|
for final positions when recording helper targets. `EntryInfo` continues to use
|
||||||
|
entry positions even after a move.
|
||||||
|
|
||||||
|
See [resolution recording](../config.md#resolution-recording-and-failure-rules)
|
||||||
|
for helper and coverage behavior, and
|
||||||
|
[origins and historical paths](../config.md#presence-origins-and-historical-paths)
|
||||||
|
for the distinction between resolution origins and retained source evidence.
|
||||||
|
|
||||||
## Intentional migration changes
|
## Intentional migration changes
|
||||||
|
|
||||||
The examples preserve domain meaning while adopting one configuration convention:
|
When upgrading, start with the changes relevant to your dependency and inputs:
|
||||||
|
|
||||||
|
1. Update the module requirement and Go imports together as described below.
|
||||||
|
2. Replace old public names using the old/new table.
|
||||||
|
3. Update any layer or diagnostic-code strings stored or compared by your code.
|
||||||
|
4. Review YAML and startup changes that apply to your application, then rebuild
|
||||||
|
and run its configuration tests.
|
||||||
|
|
||||||
|
The maintained examples illustrate these configuration changes:
|
||||||
|
|
||||||
- Explicit empty strings, lists, zeroes, false values, and nulls remain intentional.
|
- Explicit empty strings, lists, zeroes, false values, and nulls remain intentional.
|
||||||
ConfigKit accepts an empty list structurally; WeatherReporter-shaped validation
|
ConfigKit accepts an empty list structurally; WeatherReporter-shaped validation
|
||||||
@@ -301,10 +401,70 @@ The examples preserve domain meaning while adopting one configuration convention
|
|||||||
parsers translate only explicitly set configuration flags into `Override` records.
|
parsers translate only explicitly set configuration flags into `Override` records.
|
||||||
Separate invocation state remains outside configuration.
|
Separate invocation state remains outside configuration.
|
||||||
- Credential fields migrate to environment-name `Secret` references. Applications
|
- Credential fields migrate to environment-name `Secret` references. Applications
|
||||||
explicitly call `LoadSecretsDir` before `Load` when they choose file bootstrap;
|
explicitly call `LoadSecretsIntoEnvironment` before `Load` when they choose
|
||||||
|
file bootstrap;
|
||||||
blocked empty variables remain blocked, and ordinary `Load` never changes the
|
blocked empty variables remain blocked, and ordinary `Load` never changes the
|
||||||
process environment.
|
process environment.
|
||||||
|
|
||||||
|
### Vanity module path migration
|
||||||
|
|
||||||
|
ConfigKit's module identifier changed from
|
||||||
|
`gitea.maximumdirect.net/eric/ConfigKit` to `go.maximumdirect.net/configkit` before
|
||||||
|
v1.0.0. Update the dependency requirement in your application's `go.mod` and its
|
||||||
|
Go imports together, selecting a revision or release that declares the new module
|
||||||
|
path. The package name remains `configkit`; the configuration model and public
|
||||||
|
symbol names are unchanged by this identity migration.
|
||||||
|
|
||||||
|
Go treats the old and new import paths as different packages, with different
|
||||||
|
named types. For example, if another library returns a ConfigKit `Path` or
|
||||||
|
`Secret` using the old import, your application cannot use it as the new
|
||||||
|
package's type just by changing a module requirement. Coordinate migration
|
||||||
|
with dependencies that pass ConfigKit values to or from your application. The
|
||||||
|
maintained examples use the new path, but other repositories must manage their
|
||||||
|
own migrations.
|
||||||
|
|
||||||
|
Historical tags keep their original module declarations and contents. Older
|
||||||
|
releases such as v0.10.2 still use the old path and cannot be installed under the
|
||||||
|
vanity identifier simply because discovery can find their repository. A matching
|
||||||
|
migrated revision is needed; no old-path compatibility module is supplied here.
|
||||||
|
|
||||||
|
### Pre-v1 public API cleanup
|
||||||
|
|
||||||
|
The pre-v1 API cleanup replaces these names outright; it does not retain
|
||||||
|
compatibility aliases:
|
||||||
|
|
||||||
|
| Previous API | Current API |
|
||||||
|
| --- | --- |
|
||||||
|
| `Resolution.InputInfo(path)` | `Resolution.EntryInfo(path)` |
|
||||||
|
| `Definition.Secret` | `Definition.Sensitive` |
|
||||||
|
| `SourceVersionModeDefault` | `SourceVersionModeMainOnly` |
|
||||||
|
| `SourceVersionCheck` | `SourceVersionEvidence` |
|
||||||
|
| `SourceInfo.VersionCheck` | `SourceInfo.VersionEvidence` |
|
||||||
|
| `ImportSelection` | `EmptyImportSelection` |
|
||||||
|
| `SourceInfo.ImportSelections` | `SourceInfo.EmptyImportSelections` |
|
||||||
|
| `SourceInfo.DestinationUnresolved` | `SourceInfo.DestinationIsFallback` |
|
||||||
|
| `ImportDefaults.Follow` / `.Hidden` | `FollowSymlinks` / `IncludeHidden` |
|
||||||
|
| `Paths(paths...)` | `SearchPaths(paths...)` |
|
||||||
|
| `LoadSecretsDir(directory)` | `LoadSecretsIntoEnvironment(directory)` |
|
||||||
|
| `LayerBase` | `LayerMain` |
|
||||||
|
| `CodeExplainLimitExceeded` | `CodeHistoryLimitExceeded` |
|
||||||
|
| `CodeResolveInvalidKind` / `CodeOptionsUnavailable` | Removed; no replacement |
|
||||||
|
| `ImportSelection.Outcome`, `ImportSelectionOutcome`, `ImportSelectionNone` | Removed; empty-selection records no longer carry an outcome |
|
||||||
|
| `Resolution.Record(path, ChangeDerivedDefault, reason, deps...)` | `Resolution.RecordDerivedDefault(path, reason, deps...)` |
|
||||||
|
| `Resolution.Record(path, ChangeNormalization, reason, deps...)` | `Resolution.RecordNormalization(path, reason, deps...)` |
|
||||||
|
| Enumerating `Result.Sources()` / `LoadError.Sources()` to find one ID | `Result.Source(id)` / `LoadError.Source(id)` |
|
||||||
|
| Provenance string `"base"` | `"main"` |
|
||||||
|
| History diagnostic string `"explain.limit_exceeded"` | `"history.limit_exceeded"` |
|
||||||
|
|
||||||
|
The YAML import keys `follow` and `hidden`, the `configkit:"secret"` tag, and
|
||||||
|
typed `Secret` references are unchanged. If your application compares or
|
||||||
|
stores layer names or diagnostic codes as strings, update those strings
|
||||||
|
alongside Go references. This repository guidance does not claim that
|
||||||
|
downstream applications have migrated and does not select a release version
|
||||||
|
for this breaking pre-v1 change. `ChangeKind` and its inspection constants
|
||||||
|
remain; use `Default` and `Normalize` when the helper should both assign and
|
||||||
|
record a value.
|
||||||
|
|
||||||
### Selected-overlay migration
|
### Selected-overlay migration
|
||||||
|
|
||||||
To adopt the current selected-overlay API, rename `Options.Profile`,
|
To adopt the current selected-overlay API, rename `Options.Profile`,
|
||||||
@@ -315,10 +475,40 @@ The [configuration contract](../config.md#selected-overlays) is the canonical
|
|||||||
behavioral reference. Application-owned profile concepts, such as `model_profile`,
|
behavioral reference. Application-owned profile concepts, such as `model_profile`,
|
||||||
are unchanged, and any CLI flag rename remains the application's decision.
|
are unchanged, and any CLI flag rename remains the application's decision.
|
||||||
|
|
||||||
These examples are migration evidence, not completed changes to Narratio, Notarius,
|
### Background on the examples
|
||||||
WeatherReporter, or Seriatim. Updating those repositories, their deployed YAML, CLI
|
|
||||||
wiring, runtime catalogs, and operational documentation remains separate downstream
|
These examples demonstrate migration shapes. They do not claim that Narratio,
|
||||||
work.
|
Notarius, WeatherReporter, or Seriatim has been migrated. Updating those
|
||||||
|
repositories, their deployed YAML, CLI wiring, runtime catalogs, and
|
||||||
|
operational documentation remains separate downstream work.
|
||||||
|
|
||||||
|
## Example background
|
||||||
|
|
||||||
|
The maintained [`examples/consumers`](../../examples/consumers) package demonstrates
|
||||||
|
sanitized shapes using only ConfigKit and the Go standard library:
|
||||||
|
|
||||||
|
| Shape | Demonstrated fit | Application code deliberately retained |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Artifact defaults and normalization (Narratio-like) | Fresh defaults, explicit partial imports, artifact maps, recorded inherited defaults, normalization, and source ownership | Artifact meaning and the separate `model_profile` domain field |
|
||||||
|
| Versions and dynamic options (Notarius-like) | Canonical object bindings, a string-backed schema version accepting string or integer YAML, strict dynamic options, and `int64` dynamic integers | Catalog selection, allowed option keys, range-checked conversion to runtime `int` |
|
||||||
|
| Module validation and credential startup (WeatherReporter-like) | Defaults-only loading, report maps, canonical module objects, typed option unions, explicit CLI records, and typed secret startup | Report/module compatibility, rejection of explicitly empty domain lists, CLI parsing, and startup ordering |
|
||||||
|
| Independent ordered rule files (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,
|
||||||
|
credentials, network calls, or dependency on a sibling checkout.
|
||||||
|
|
||||||
|
The Notarius-shaped fixtures deliberately load both `version: 4` and
|
||||||
|
`version: "4"` into the same string-backed application field. Its version checker
|
||||||
|
still owns the supported identifier; fractional numeric input and unsupported
|
||||||
|
integer identifiers remain negative examples.
|
||||||
|
|
||||||
## Maintained API examples
|
## Maintained API examples
|
||||||
|
|
||||||
@@ -330,7 +520,7 @@ focused behavioral owner:
|
|||||||
| `Load`, `NoSource`, validation, versions, resolution, `SecretEnv`, overrides, and explanation | Executable examples in [`example_test.go`](../../example_test.go) |
|
| `Load`, `NoSource`, validation, versions, resolution, `SecretEnv`, overrides, and explanation | Executable examples in [`example_test.go`](../../example_test.go) |
|
||||||
| Source/import/selected-overlay pipeline rules and artifact defaults | Narratio-shaped executable example and fixture |
|
| Source/import/selected-overlay pipeline rules and artifact defaults | Narratio-shaped executable example and fixture |
|
||||||
| Canonical module objects and dynamic option handling | WeatherReporter- and Notarius-shaped examples and migration tests |
|
| Canonical module objects and dynamic option handling | WeatherReporter- and Notarius-shaped examples and migration tests |
|
||||||
| `LoadSecretsDir`, bootstrap reporting, typed lookup, CLI explanation | `Example_weatherStartup` |
|
| `LoadSecretsIntoEnvironment`, 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 |
|
| 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` |
|
| 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` |
|
| Named sibling inheritance, presence, and derivation | `Example_inheritedPresets` and `TestPresetCatalogOneLoadAndDerivation` |
|
||||||
|
|||||||
@@ -64,6 +64,9 @@ Accepted decisions:
|
|||||||
- [Define identity and merge rules for each collection](adr/0017-collection-sequence-policy.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)
|
- [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)
|
- [Keep main-file discovery explicit and composable](adr/0019-explicit-configuration-discovery.md)
|
||||||
|
- [Scope configuration controls to document roots](adr/0020-document-root-controls.md)
|
||||||
|
- [Match registered records during resolution inspection](adr/0021-sequence-resolution-correspondence.md)
|
||||||
|
- [Bind companion contexts after main selection](adr/0022-main-relative-context.md)
|
||||||
|
|
||||||
For cross-cutting changes, follow every applicable row. Completed implementation
|
For cross-cutting changes, follow every applicable row. Completed implementation
|
||||||
and remediation plans are preserved in Git history at `cb5b435`; use current
|
and remediation plans are preserved in Git history at `cb5b435`; use current
|
||||||
@@ -91,6 +94,25 @@ and current facade ownership belongs to the [component overview](internal/overvi
|
|||||||
|
|
||||||
## Baseline validation
|
## Baseline validation
|
||||||
|
|
||||||
|
The completed main-relative companion roadmap, Audita request, implementation
|
||||||
|
plan, and remediation report are preserved in Git history at `47547bc` under
|
||||||
|
`docs/roadmap/`. The [layered loading contract](config.md#layered-loading)
|
||||||
|
describes current behavior, and [ADR 0022](adr/0022-main-relative-context.md)
|
||||||
|
records the context binding decision.
|
||||||
|
|
||||||
|
The completed field-level sequence resolution roadmap, implementation plan, and
|
||||||
|
resolved audit findings are preserved in Git history at `7e53863` under
|
||||||
|
`docs/roadmap/`. For example:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git show 7e53863:docs/roadmap/implementation.md
|
||||||
|
git show 7e53863:docs/roadmap/remediation.md
|
||||||
|
```
|
||||||
|
|
||||||
|
The [configuration contract](config.md#recorded-application-resolution) owns
|
||||||
|
current resolution behavior, and [ADR 0021](adr/0021-sequence-resolution-correspondence.md)
|
||||||
|
records the correspondence decision.
|
||||||
|
|
||||||
The completed filesystem-source and underlay roadmap, original feature request,
|
The completed filesystem-source and underlay roadmap, original feature request,
|
||||||
implementation plan, and resolved remediation record are preserved in Git history
|
implementation plan, and resolved remediation record are preserved in Git history
|
||||||
at `b604aa1` under `docs/roadmap/`. Current behavior belongs to the
|
at `b604aa1` under `docs/roadmap/`. Current behavior belongs to the
|
||||||
@@ -199,6 +221,32 @@ git show 34cbc0d:docs/roadmap/remediation.md
|
|||||||
```
|
```
|
||||||
|
|
||||||
Potential enhancements are tracked in [future features](roadmap/future.md).
|
Potential enhancements are tracked in [future features](roadmap/future.md).
|
||||||
|
The completed audit-remediation, internal-boundary simplification, shared-history,
|
||||||
|
and public API cleanup roadmaps, reviews, and resolved findings are preserved at
|
||||||
|
`68400ff` under `docs/roadmap/`. For example:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git show 68400ff:docs/roadmap/api.md
|
||||||
|
git show 68400ff:docs/roadmap/remediation.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Their earlier completed implementation plans are preserved at
|
||||||
|
`bb5540b:docs/roadmap/implementation.md` (audit remediation),
|
||||||
|
`f6d54a0:docs/roadmap/implementation.md` (internal-boundary simplification), and
|
||||||
|
`07e4ae5:docs/roadmap/implementation.md` (shared history). The plan at
|
||||||
|
`68400ff:docs/roadmap/implementation.md` records the public API cleanup.
|
||||||
|
Current behavior and migration information belong to the
|
||||||
|
[configuration contract](config.md), public declaration comments, and
|
||||||
|
[consumer guide](consumers/README.md#pre-v1-public-api-cleanup).
|
||||||
|
|
||||||
|
The consolidated audit, including
|
||||||
|
findings, evidence, and settled scope decisions, is preserved at
|
||||||
|
`453da68:docs/roadmap/audit.md`; the original nine reports are preserved at
|
||||||
|
`ecd5d5d` under `docs/roadmap/`. Retrieve the consolidated record with:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git show 453da68:docs/roadmap/audit.md
|
||||||
|
```
|
||||||
|
|
||||||
Install the Go version required by `go.mod` or a compatible newer toolchain.
|
Install the Go version required by `go.mod` or a compatible newer toolchain.
|
||||||
Acquire declared dependencies explicitly before entering an offline
|
Acquire declared dependencies explicitly before entering an offline
|
||||||
@@ -223,15 +271,19 @@ dependency downloads. Acquire dependencies and any selected toolchain beforehand
|
|||||||
Format reported Go files with `gofmt -w <files>`.
|
Format reported Go files with `gofmt -w <files>`.
|
||||||
|
|
||||||
The default suite includes executable examples, maintained consumer fixtures,
|
The default suite includes executable examples, maintained consumer fixtures,
|
||||||
local documentation-link checks, and the dependency import-boundary check.
|
and the dependency/public boundary checks.
|
||||||
It must stay deterministic and runnable without network access or credentials.
|
It must stay deterministic and runnable without network access or credentials.
|
||||||
Still inspect new/untracked content and review paths, examples, ownership, and
|
Still inspect new/untracked content and review paths, examples, ownership, and
|
||||||
current-versus-planned claims; automated checks do not replace content review.
|
current-versus-planned claims; automated checks do not replace content review.
|
||||||
|
|
||||||
Use focused checks while iterating, with validation proportionate to the change
|
Use focused checks while iterating, with validation proportionate to the change
|
||||||
under the testing policy. Documentation-only work normally needs the local-link
|
under the testing policy. Documentation-only work normally needs review of
|
||||||
check below and whitespace review; behavior-bearing examples and commands also
|
affected links and whitespace; behavior-bearing examples and commands also
|
||||||
need execution. Use `go test -race ./...` for ownership or concurrency changes.
|
need execution. Automated Markdown link checking runs against the committed
|
||||||
|
candidate through ReleaseKit, not during ordinary Go tests or the everyday
|
||||||
|
checker. See the [release procedure](release.md#4-publish-the-committed-candidate)
|
||||||
|
for the selected checks and optional diagnostic command. Use
|
||||||
|
`go test -race ./...` for ownership or concurrency changes.
|
||||||
The [release procedure](release.md#4-publish-the-committed-candidate) performs
|
The [release procedure](release.md#4-publish-the-committed-candidate) performs
|
||||||
its selected validation through one `publish` invocation. The everyday checker
|
its selected validation through one `publish` invocation. The everyday checker
|
||||||
remains a development gate and is not a separate release prerequisite. CI never
|
remains a development gate and is not a separate release prerequisite. CI never
|
||||||
@@ -247,28 +299,28 @@ code changes. Use the component overview to find the corresponding implementatio
|
|||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| 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'` |
|
| 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 | [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'` |
|
| 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'` |
|
| 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` |
|
| 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'` |
|
| 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'` |
|
| 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'` |
|
||||||
| 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` |
|
| 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'` |
|
| 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'` |
|
| 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'` |
|
||||||
| 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` |
|
| 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` |
|
| 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) | `go test ./internal/engine -run 'Schema\|Decode\|Projection\|Clone\|Inline\|CompareDocument'` |
|
| 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) | `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'` |
|
| 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'` |
|
||||||
|
| Resolution sequence matching and inspection | [Correspondence](../internal/engine/resolution_correspondence_test.go), [auditing and history](../internal/engine/resolution_compare_test.go), [public integration](../facade_resolution_sequences_test.go) | `go test ./internal/engine -run 'Resolution\|History\|Explain\|Sensitivity'` and `go test . -run 'Resolution\|SequenceIdentity\|Diff'` |
|
||||||
| 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'` |
|
| 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'` |
|
| 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'` |
|
| 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'` |
|
||||||
| 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$'` |
|
| 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'` |
|
| 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` |
|
| 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` |
|
| 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` |
|
| 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'` |
|
| Dependency and public API isolation | [Import boundary](../import_boundary_test.go), [public boundary](../public_boundary_test.go) | `go test . -run 'TestDependencyImportBoundary\|TestPublicFacadeBoundary'` |
|
||||||
| Development check behavior | [Development checker test](../development_check_test.go) | `go test . -run '^TestDevelopmentCheckHandlesFilenamesAndStopsOnFailure$'` |
|
| Development check behavior | [Development checker test](../development_check_test.go) | `go test . -run '^TestDevelopmentCheckHandlesFilenamesAndStopsOnFailure$'` |
|
||||||
| Release profile wiring | [Release profile test](../releasekit_profile_test.go) | `go test . -run '^TestReleaseKitProfile$'` |
|
|
||||||
|
|
||||||
## Fuzz validation
|
## Fuzz validation
|
||||||
|
|
||||||
|
|||||||
@@ -20,6 +20,13 @@ patterns to discovered names, and form a set union of matching candidates across
|
|||||||
overlapping roots. Exclusions are applied as set difference after discovery and
|
overlapping roots. Exclusions are applied as set difference after discovery and
|
||||||
candidate normalization. They do not suppress discovery or resource errors.
|
candidate normalization. They do not suppress discovery or resource errors.
|
||||||
|
|
||||||
|
Layered assembly passes the resolved main context to underlay acquisition.
|
||||||
|
Derived inputs retain the main's filesystem location and namespace. The
|
||||||
|
receiving tier supplies its own host token and tracks repeated sources.
|
||||||
|
Contextless bindings prepare declaration policies and assemble the declaring
|
||||||
|
payload without opening filesystem access or recording selection evidence.
|
||||||
|
Selected contexts use the existing acquisition and handle lifecycle.
|
||||||
|
|
||||||
Directory traversal tracks canonical directories on the active traversal path.
|
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
|
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
|
only a back-edge to an active directory is a traversal cycle; a later alias is
|
||||||
@@ -33,6 +40,26 @@ sandbox guarantee. `source_context.go` derives passive resource contexts from
|
|||||||
resolved sources for later caller-owned reads; those records do not retain live
|
resolved sources for later caller-owned reads; those records do not retain live
|
||||||
acquisition handles.
|
acquisition handles.
|
||||||
|
|
||||||
|
Host and native link bindings share one iterative link-frame evaluator. It
|
||||||
|
tracks active links for cycle detection and reuses successfully completed link
|
||||||
|
targets while resolving the remaining path components; each binding retains its
|
||||||
|
own namespace and target-containment rules. Directory reads distinguish an
|
||||||
|
initial-open failure from failures after opening: only an initial missing
|
||||||
|
directory can be treated as optional, while enumeration and close failures
|
||||||
|
remain selection errors, including errors from the standard `fs.ReadDir`
|
||||||
|
fallback.
|
||||||
|
|
||||||
|
Glob selection keeps traversal coordination in `selectGlobImportCandidates`:
|
||||||
|
it advances literal and patterned segments, reads directories, unions and sorts
|
||||||
|
candidates, validates final targets, then applies exclusions and requiredness.
|
||||||
|
For each patterned directory entry, `prepareGlobEntry` handles matching and
|
||||||
|
hidden filtering, containment, target and link inspection, intermediate-directory
|
||||||
|
eligibility, host/native access-path selection, and candidate identity and label.
|
||||||
|
It returns a candidate, an ordinary skip, or a diagnostic without mutating the
|
||||||
|
selector's candidate set or traversal state. Directory entry preparation remains
|
||||||
|
separate because its matching, absence, recursion, and traversal-state rules
|
||||||
|
differ.
|
||||||
|
|
||||||
Acquisition remains authored: declarations are visited in source order and each
|
Acquisition remains authored: declarations are visited in source order and each
|
||||||
selected candidate set in namespace-path lexical order. This preserves source
|
selected candidate set in namespace-path lexical order. This preserves source
|
||||||
records, IDs, source-local version checks, and callback ordering. The acquired
|
records, IDs, source-local version checks, and callback ordering. The acquired
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ Implemented component owners:
|
|||||||
explicitly selected configuration discovery. A search resolves once during
|
explicitly selected configuration discovery. A search resolves once during
|
||||||
shared acquisition; the selected concrete file or absent-input state then
|
shared acquisition; the selected concrete file or absent-input state then
|
||||||
follows the existing composition, metadata, and resource-context lifecycle.
|
follows the existing composition, metadata, and resource-context lifecycle.
|
||||||
It also owns grouped strict composition, recursive precedence assembly,
|
It also owns main-relative context selection, grouped strict composition, recursive precedence assembly,
|
||||||
per-input mapping and sequence-item placement, YAML collection declarations
|
per-input mapping and sequence-item placement, YAML collection declarations
|
||||||
and operations for identified sequences, and application identity registrations for strict
|
and operations for identified sequences, and application identity registrations for strict
|
||||||
composition, inheritance, and final validation. It applies the selected
|
composition, inheritance, and final validation. It applies the selected
|
||||||
@@ -20,14 +20,19 @@ Implemented component owners:
|
|||||||
inheritance and decoding, before application resolution. The engine also owns
|
inheritance and decoding, before application resolution. The engine also owns
|
||||||
shared source-version checking with sanitized declaration evidence.
|
shared source-version checking with sanitized declaration evidence.
|
||||||
Explicit import discovery supports declared paths, directory searches, and
|
Explicit import discovery supports declared paths, directory searches, and
|
||||||
globs; load-wide defaults and source-local empty-selection evidence cross the
|
globs; root-owned defaults are converted to the engine's single defaults input
|
||||||
root facade without changing acquisition order. Import and inheritance
|
and snapshotted during load preflight before acquisition, while source-local
|
||||||
resolution share dependency-ready scheduling within their composition scopes.
|
empty-selection evidence crosses the facade without changing acquisition
|
||||||
|
order. The root constructs placement directly on engine options and inputs
|
||||||
|
using explicit enum conversion; public types and fields remain root-owned.
|
||||||
|
Import and inheritance resolution share dependency-ready scheduling within
|
||||||
|
their composition scopes.
|
||||||
It retains each source's effective placement alongside physical source
|
It retains each source's effective placement alongside physical source
|
||||||
identity and owns schema
|
identity and owns schema
|
||||||
inspection, lifecycle coordination, callback auditing,
|
inspection, lifecycle coordination, callback auditing,
|
||||||
provenance/history, passive source-context retention, secret resolution and
|
provenance/history, passive source-context retention, secret resolution and
|
||||||
bootstrap, safe inspection rendering, and effective-value comparison over
|
bootstrap, operation-local secret failure-location attribution, safe
|
||||||
|
inspection rendering, and effective-value comparison over
|
||||||
frozen inspection snapshots. It never imports the root package.
|
frozen inspection snapshots. It never imports the root package.
|
||||||
- Root `Source`, `Filesystem`, `Input`, `Underlay`, `Path`, `Secret`, records,
|
- Root `Source`, `Filesystem`, `Input`, `Underlay`, `Path`, `Secret`, records,
|
||||||
lifecycle callbacks, results, and bootstrap reports are root-defined wrappers
|
lifecycle callbacks, results, and bootstrap reports are root-defined wrappers
|
||||||
@@ -45,7 +50,7 @@ Implemented component owners:
|
|||||||
- Engine mechanism tests own source, composition, inheritance, decoding,
|
- Engine mechanism tests own source, composition, inheritance, decoding,
|
||||||
callback, provenance,
|
callback, provenance,
|
||||||
source-context, secret, history, rendering, and bootstrap behavior. Root tests
|
source-context, secret, history, rendering, and bootstrap behavior. Root tests
|
||||||
own public API compatibility, facade integration, documentation links, and
|
own public API compatibility, facade integration, and
|
||||||
dependency/public boundary checks. Consumer examples remain under
|
dependency/public boundary checks. Consumer examples remain under
|
||||||
`examples/consumers`.
|
`examples/consumers`.
|
||||||
|
|
||||||
@@ -90,8 +95,14 @@ bounded algorithms.
|
|||||||
|
|
||||||
## Implementation flow
|
## Implementation flow
|
||||||
|
|
||||||
The root facade converts caller-owned values into engine-owned requests. The
|
The root facade converts caller-owned values into engine-owned requests. Its
|
||||||
ordinary and grouped entry points share a normalized `loadRequest`: see
|
`ImportDefaults` conversion maps fields without taking ownership; engine load
|
||||||
|
preflight snapshots the defaults once before source acquisition or callbacks.
|
||||||
|
Ordinary and grouped loading then carry that read-only value through their
|
||||||
|
normalized `loadRequest` and acquisition policy, while declaration resolution
|
||||||
|
copies values needed by its independently owned effective policy. Placement is
|
||||||
|
constructed directly on the engine's options and input records after explicit
|
||||||
|
root-to-engine enum conversion. See
|
||||||
[`lifecycle.go`](../../lifecycle.go), [`layered.go`](../../layered.go), and
|
[`lifecycle.go`](../../lifecycle.go), [`layered.go`](../../layered.go), and
|
||||||
[`load_request.go`](../../internal/engine/load_request.go). Load-wide definition,
|
[`load_request.go`](../../internal/engine/load_request.go). Load-wide definition,
|
||||||
version, default-import, and override policy stays distinct from each input's
|
version, default-import, and override policy stays distinct from each input's
|
||||||
@@ -99,9 +110,14 @@ source, filesystem, destination, and import settings.
|
|||||||
|
|
||||||
[`acquisition.go`](../../internal/engine/acquisition.go) defines acquisition
|
[`acquisition.go`](../../internal/engine/acquisition.go) defines acquisition
|
||||||
policy, load-wide accounting, and tier-scoped repetition/namespace state. Each
|
policy, load-wide accounting, and tier-scoped repetition/namespace state. Each
|
||||||
input is acquired under that policy; grouped requests retain acquired underlay
|
input is acquired under that policy. Source-root controls are extracted from
|
||||||
|
each acquired tree before placement; assembled application data is not parsed
|
||||||
|
again for controls. Grouped requests retain acquired underlay
|
||||||
scopes until their tier is composed in
|
scopes until their tier is composed in
|
||||||
[`load_request.go`](../../internal/engine/load_request.go). Live
|
[`load_request.go`](../../internal/engine/load_request.go). Completed tiers then
|
||||||
|
share one assembly handoff that applies collection declarations and projects
|
||||||
|
only that tier's source/version evidence; the caller keeps main-version
|
||||||
|
protection and its projection explicit. Live
|
||||||
filesystem access handles are confined to acquisition and closed there. Resolved
|
filesystem access handles are confined to acquisition and closed there. Resolved
|
||||||
filesystem facts used to authorize access are separate from passively retained
|
filesystem facts used to authorize access are separate from passively retained
|
||||||
resource contexts. Non-import validation retains those facts on `assembledInput`
|
resource contexts. Non-import validation retains those facts on `assembledInput`
|
||||||
@@ -137,6 +153,30 @@ descriptor caching and type policy, and
|
|||||||
inline field assembly. [`decode.go`](../../internal/engine/decode.go) consumes
|
inline field assembly. [`decode.go`](../../internal/engine/decode.go) consumes
|
||||||
those descriptors for typed decoding.
|
those descriptors for typed decoding.
|
||||||
|
|
||||||
|
History and inspection use one private evidence owner. The
|
||||||
|
[`history_model.go`](../../internal/engine/history_model.go) builder receives
|
||||||
|
baseline, source-composition, inheritance, typed-checkpoint, and CLI evidence
|
||||||
|
in [`preparePostDecodeOverrideContext`](../../internal/engine/override_context.go),
|
||||||
|
before application resolution begins. The callback retains its separate fixed
|
||||||
|
entry view; it does not read a builder that continues to change. After successful
|
||||||
|
resolution auditing, [`resolveLoadInput`](../../internal/engine/load_result.go)
|
||||||
|
adds the original resolution records and their net replay groups. At the existing
|
||||||
|
resolution boundary, [`resolution_correspondence.go`](../../internal/engine/resolution_correspondence.go)
|
||||||
|
matches eligible registered records and the shared history retains their paired
|
||||||
|
coordinates for selection and safe replay. At the existing
|
||||||
|
pre-secret admission boundary, [`prepareInspectionHistory`](../../internal/engine/load_result.go)
|
||||||
|
freezes that builder and counts the retained document roots before secret lookup
|
||||||
|
and validation.
|
||||||
|
|
||||||
|
The frozen model is shared by `Metadata` and `Inspection`. Its shape-aware
|
||||||
|
selection in [`history_select.go`](../../internal/engine/history_select.go)
|
||||||
|
drives metadata history, explanation, and historical-path membership; the model
|
||||||
|
also supplies historical diagnostic locations and sensitivity/dependency
|
||||||
|
evidence. `Inspection` replays selected payloads to produce detached, safely
|
||||||
|
rendered values, while metadata queries return detached attribution records.
|
||||||
|
History and explanation behavior is covered by focused `history_*_test.go`
|
||||||
|
tests alongside metadata, secret, inheritance, and facade integration tests.
|
||||||
|
|
||||||
Behavioral ownership follows the same handoffs: imports and grouped lifetimes are
|
Behavioral ownership follows the same handoffs: imports and grouped lifetimes are
|
||||||
covered by `imports_test.go`, `load_request_test.go`, and focused source,
|
covered by `imports_test.go`, `load_request_test.go`, and focused source,
|
||||||
discovery, and destination tests; inheritance by the inheritance test files;
|
discovery, and destination tests; inheritance by the inheritance test files;
|
||||||
@@ -144,3 +184,9 @@ reflection behavior by `schema_test.go`, `projection_test.go`, and
|
|||||||
`decode_test.go`. The [development test map](../development.md#task-to-test-map)
|
`decode_test.go`. The [development test map](../development.md#task-to-test-map)
|
||||||
links focused commands. [`clone.go`](../../internal/engine/clone.go) owns typed
|
links focused commands. [`clone.go`](../../internal/engine/clone.go) owns typed
|
||||||
copying, using the shared descriptors and per-operation traversal helpers.
|
copying, using the shared descriptors and per-operation traversal helpers.
|
||||||
|
|
||||||
|
Each schema operation builds its private reflection descriptor graph with
|
||||||
|
operation-local caching; there is no global mutable descriptor cache. Decoding,
|
||||||
|
projection, and cloning reuse these descriptors so field bindings agree. The
|
||||||
|
public reference describes the resulting type, output, and ownership behavior;
|
||||||
|
this descriptor storage is an implementation detail.
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
|
|
||||||
## Standard Repository Policies
|
## Standard Repository Policies
|
||||||
|
|
||||||
Material: `docs/policy/release.md`, adapted from
|
Material: `docs/policy/documentation.md`, `docs/policy/oss.md`, and
|
||||||
`e833faddf120c789a9b45348edaf75720de16348`; `docs/policy/meta.md`, `docs/policy/oss.md`,
|
`docs/policy/release.md`, adapted from
|
||||||
`docs/policy/documentation.md`, `docs/policy/agents.md`,
|
`b339ad0427a50e43a68b932a6a9f4669cc0f6252`; `docs/policy/meta.md`, `docs/policy/agents.md`,
|
||||||
`docs/policy/architecture.md`, `docs/policy/testing.md`; root `AGENTS.md`; and the
|
`docs/policy/architecture.md`, `docs/policy/testing.md`; root `AGENTS.md`; and the
|
||||||
Policy Reading Guide in `docs/development.md`, adapted from
|
Policy Reading Guide in `docs/development.md`, adapted from
|
||||||
`d84a38ebab26f048cafbe59a2246700c35d29843` for this repository.
|
`d84a38ebab26f048cafbe59a2246700c35d29843` for this repository.
|
||||||
@@ -16,12 +16,12 @@ SPDX-License-Identifier: CC-BY-SA-4.0
|
|||||||
License and notices: [CC BY-SA 4.0](licenses/CC-BY-SA-4.0.txt) |
|
License and notices: [CC BY-SA 4.0](licenses/CC-BY-SA-4.0.txt) |
|
||||||
[COPYRIGHT](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/COPYRIGHT) |
|
[COPYRIGHT](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/COPYRIGHT) |
|
||||||
[LICENSE](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/LICENSE) |
|
[LICENSE](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/LICENSE) |
|
||||||
[COPYRIGHT](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/COPYRIGHT) |
|
[COPYRIGHT](https://gitea.maximumdirect.net/eric/policy/src/commit/b339ad0427a50e43a68b932a6a9f4669cc0f6252/COPYRIGHT) |
|
||||||
[LICENSE](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/LICENSE).
|
[LICENSE](https://gitea.maximumdirect.net/eric/policy/src/commit/b339ad0427a50e43a68b932a6a9f4669cc0f6252/LICENSE).
|
||||||
|
|
||||||
[Source](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843).
|
[Source](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843).
|
||||||
|
|
||||||
[Source](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348).
|
[Source](https://gitea.maximumdirect.net/eric/policy/src/commit/b339ad0427a50e43a68b932a6a9f4669cc0f6252).
|
||||||
|
|
||||||
## go-yaml v4
|
## go-yaml v4
|
||||||
|
|
||||||
|
|||||||
@@ -151,6 +151,11 @@ initialization, and resource checks. See the [configuration discovery
|
|||||||
contract](../config.md#configuration-discovery) and [ADR
|
contract](../config.md#configuration-discovery) and [ADR
|
||||||
0019](../adr/0019-explicit-configuration-discovery.md).
|
0019](../adr/0019-explicit-configuration-discovery.md).
|
||||||
|
|
||||||
|
The layered loader may bind application-selected dependent locations after main
|
||||||
|
selection. Applications still choose search conventions and must explicitly
|
||||||
|
select an overlay. [ADR 0022](../adr/0022-main-relative-context.md) records
|
||||||
|
the deferred context decision.
|
||||||
|
|
||||||
The engine applies each input's mapping destination within the existing source
|
The engine applies each input's mapping destination within the existing source
|
||||||
assembly, applies immediate-parent-relative YAML destinations to imports, and
|
assembly, applies immediate-parent-relative YAML destinations to imports, and
|
||||||
retains each effective placement with source identity and inspection records;
|
retains each effective placement with source identity and inspection records;
|
||||||
@@ -207,7 +212,9 @@ records this ordering.
|
|||||||
CLI overrides are the highest-priority input to application resolution. Supplied
|
CLI overrides are the highest-priority input to application resolution. Supplied
|
||||||
sequences replace whole sequences, and mappings combine recursively. Inheritance
|
sequences replace whole sequences, and mappings combine recursively. Inheritance
|
||||||
and typed defaulting do not run again after CLI application. CLI paths and values
|
and typed defaulting do not run again after CLI application. CLI paths and values
|
||||||
must not introduce structural directives; earlier source, inheritance, and
|
must not introduce inheritance controls; source-document controls are interpreted
|
||||||
|
only at acquisition, before placement, and later application `config` data is
|
||||||
|
not reinterpreted. Earlier source, inheritance, and
|
||||||
decoding failures remain errors even if a CLI assignment would replace the value.
|
decoding failures remain errors even if a CLI assignment would replace the value.
|
||||||
Semantic validation assesses the final configuration after overrides and resolution.
|
Semantic validation assesses the final configuration after overrides and resolution.
|
||||||
|
|
||||||
@@ -374,6 +381,11 @@ it does not cover every intermediate callback state or later caller mutations.
|
|||||||
YAML-only declarations carry no such final guarantee through defaults, CLI
|
YAML-only declarations carry no such final guarantee through defaults, CLI
|
||||||
assignments, or application resolution.
|
assignments, or application resolution.
|
||||||
|
|
||||||
|
For resolution auditing and inspection, eligible registered collections match
|
||||||
|
entry and final records by identity within each destination. Structural changes
|
||||||
|
still require container coverage, and this correspondence does not change source
|
||||||
|
composition or move final validation earlier. Public diff remains sequence-atomic.
|
||||||
|
|
||||||
Preserve source attribution, presence, history, sensitivity, and resource context
|
Preserve source attribution, presence, history, sensitivity, and resource context
|
||||||
as records survive, move, or are replaced. Identity diagnostics use paths and
|
as records survive, move, or are replaced. Identity diagnostics use paths and
|
||||||
available origin information without exposing identity values. The guarantee
|
available origin information without exposing identity values. The guarantee
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# Documentation Policy
|
# Documentation Policy
|
||||||
|
|
||||||
**Base Policy Version:** 1.11
|
**Base Policy Version:** 1.12
|
||||||
|
|
||||||
**Last Updated**: 2026-10-16
|
**Last Updated**: 2026-11-09
|
||||||
|
|
||||||
**Upstream Source:** [Standard Repository Policies](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/documentation.md)
|
**Upstream Source:** [Standard Repository Policies](https://gitea.maximumdirect.net/eric/policy/src/commit/b339ad0427a50e43a68b932a6a9f4669cc0f6252/docs/policy/documentation.md)
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
@@ -91,7 +91,7 @@ secret values.
|
|||||||
| Consumer guidance for the public Go API | `docs/consumers/` | Task-oriented public Go API usage, minimal examples, and consumer responsibilities. | Go symbol contracts, configuration semantics, and internal implementation detail. |
|
| Consumer guidance for the public Go API | `docs/consumers/` | Task-oriented public Go API usage, minimal examples, and consumer responsibilities. | Go symbol contracts, configuration semantics, and internal implementation detail. |
|
||||||
| 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. |
|
| 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. |
|
| 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. |
|
| Architectural decision records | `docs/adr/` | Records of significant architectural decisions and supporting rationale. | Ordinary design choices, current behavior reference, implementation status, task sequencing. |
|
||||||
| Potential future features | `docs/roadmap/future.md` | Candidate enhancements and open questions for future consideration. | Rejected proposals, architectural decisions, 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. |
|
| 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. |
|
| 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. |
|
||||||
@@ -157,10 +157,16 @@ Use sequentially numbered ADR filenames such as
|
|||||||
|
|
||||||
Treat the decision content of an accepted ADR as immutable. When a decision
|
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.
|
changes, create a new ADR and update the earlier ADR's status to superseded.
|
||||||
|
|
||||||
|
ADRs should be reserved for major architectural decisions, such as ownership
|
||||||
|
boundaries, large-scale architectural features, or invariants that rise to
|
||||||
|
the level of architectural significance. Routine choices, implementation
|
||||||
|
decisions, and rejected suggestions do not warrant an ADR unless they
|
||||||
|
otherwise represent a significant architectural decision.
|
||||||
|
|
||||||
Rejected architectural alternatives and significant architectural exclusions
|
Rejected architectural alternatives and significant architectural exclusions
|
||||||
belong in ADRs. Roadmaps preserve plausible future work, not rejected product
|
belong in ADRs. Roadmaps preserve plausible future work, not rejected product
|
||||||
ideas. Routine choices and rejected suggestions need no ADR unless they
|
ideas.
|
||||||
represent a significant architectural decision.
|
|
||||||
|
|
||||||
## Maintenance
|
## Maintenance
|
||||||
|
|
||||||
|
|||||||
@@ -1,18 +1,19 @@
|
|||||||
# Open Source Licensing And Attribution Policy
|
# Open Source Licensing And Attribution Policy
|
||||||
|
|
||||||
**Base Policy Version:** 1.8
|
**Base Policy Version:** 1.9
|
||||||
|
|
||||||
**Last Updated**: 2026-09-28
|
**Last Updated**: 2026-11-09
|
||||||
|
|
||||||
**Upstream Source:** [Standard Repository Policies](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/oss.md)
|
**Upstream Source:** [Standard Repository Policies](https://gitea.maximumdirect.net/eric/policy/src/commit/b339ad0427a50e43a68b932a6a9f4669cc0f6252/docs/policy/oss.md)
|
||||||
|
|
||||||
## ConfigKit Applicability
|
## ConfigKit Applicability
|
||||||
|
|
||||||
ConfigKit's selected grant is LGPL-3.0-or-later, recorded in
|
ConfigKit's selected grant is LGPL-3.0-or-later, recorded in
|
||||||
[COPYRIGHT](../../COPYRIGHT), with the complete LGPLv3 and incorporated GPLv3
|
[COPYRIGHT](../../COPYRIGHT), with the complete LGPLv3 and incorporated GPLv3
|
||||||
texts in [LICENSE](../../LICENSE). The [release procedure](../release.md)
|
texts in [LICENSE](../../LICENSE). The [release procedure](../release.md)
|
||||||
owns source-only Go module publication through Gitea. No separate hosted release
|
owns source-only Go module publication and, beginning with v1.0.0, local delivery
|
||||||
object or license asset is required for this channel.
|
of a Gitea release page using the complete committed release note. That page
|
||||||
|
retains the note's licensing footer; separate license assets are not required.
|
||||||
|
|
||||||
## Purpose And Scope
|
## Purpose And Scope
|
||||||
|
|
||||||
@@ -73,8 +74,9 @@ individual packages.
|
|||||||
|
|
||||||
Use the upstream [application COPYRIGHT example](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/examples/licensing/application/COPYRIGHT)
|
Use the upstream [application COPYRIGHT example](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/examples/licensing/application/COPYRIGHT)
|
||||||
or [library COPYRIGHT example](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/examples/licensing/library/COPYRIGHT)
|
or [library COPYRIGHT example](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/examples/licensing/library/COPYRIGHT)
|
||||||
to create the project’s root `COPYRIGHT` file.
|
to create the project’s root `COPYRIGHT` file. Use the examples as provided,
|
||||||
Use verified project names, contribution years, and copyright holders.
|
without discretionary modification, unless otherwise instructed. Substitute
|
||||||
|
verified project names, contribution years, and copyright holders.
|
||||||
For another selected license, adapt the grant, SPDX expression, disclaimer, and
|
For another selected license, adapt the grant, SPDX expression, disclaimer, and
|
||||||
text references together to reflect the author's selected grant.
|
text references together to reflect the author's selected grant.
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
# Release Policy
|
# Release Policy
|
||||||
|
|
||||||
**Base Policy Version:** 1.6
|
**Base Policy Version:** 1.7
|
||||||
|
|
||||||
**Last Updated**: 2026-10-09
|
**Last Updated**: 2026-11-09
|
||||||
|
|
||||||
**Upstream Source:** [Standard Repository Policies](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/docs/policy/release.md)
|
**Upstream Source:** [Standard Repository Policies](https://gitea.maximumdirect.net/eric/policy/src/commit/b339ad0427a50e43a68b932a6a9f4669cc0f6252/docs/policy/release.md)
|
||||||
|
|
||||||
## Purpose
|
## Purpose
|
||||||
|
|
||||||
@@ -23,9 +23,10 @@ policy and link to it rather than introduce additional release requirements.
|
|||||||
|
|
||||||
ConfigKit publishes a source-only Go module. Its declared deliverables are the
|
ConfigKit publishes a source-only Go module. Its declared deliverables are the
|
||||||
immutable annotated source tag and version-matched checked-in release note.
|
immutable annotated source tag and version-matched checked-in release note.
|
||||||
Standalone binaries, container images, hosted release objects, and separate
|
Beginning with v1.0.0, maintainers also deliver a Gitea release page containing
|
||||||
registry publication are not required. Additional channels require a deliberate
|
that complete note after source publication. Standalone binaries, container
|
||||||
update to the policy and procedure.
|
images, and separate registry publication are not required. Further channels
|
||||||
|
require a deliberate update to the policy and procedure.
|
||||||
|
|
||||||
The Go API, documented YAML and resolution semantics, and structured diagnostic
|
The Go API, documented YAML and resolution semantics, and structured diagnostic
|
||||||
contracts participate in compatibility review. A signature-preserving change
|
contracts participate in compatibility review. A signature-preserving change
|
||||||
@@ -80,9 +81,13 @@ the tagged source. The procedure must identify those deliverables and the
|
|||||||
responsibility of their automation. Published deliverables must be traceable
|
responsibility of their automation. Published deliverables must be traceable
|
||||||
to that source and identify the same version.
|
to that source and identify the same version.
|
||||||
|
|
||||||
ConfigKit currently declares no additional deliverables. Confirmed publication
|
ConfigKit's hosted release page is delivered locally through ReleaseKit after
|
||||||
of the intended source tag is the completion point. CI, macOS validation,
|
confirmed source publication, using the tagged note and preserving its licensing
|
||||||
hosted delivery, and consumer retrieval are outside the release gates.
|
footer. Hosted delivery has a separately reported outcome and may be retried
|
||||||
|
against the same immutable tag without republishing it. Confirmed publication
|
||||||
|
of the intended source tag remains the source-release completion point. CI,
|
||||||
|
macOS validation, hosted-service availability, delivery credentials, and consumer
|
||||||
|
retrieval are outside the source-publication gates.
|
||||||
|
|
||||||
## Versioning And Compatibility
|
## Versioning And Compatibility
|
||||||
|
|
||||||
@@ -105,6 +110,10 @@ For releases at or after `v1.0.0`:
|
|||||||
minimum as described below; and
|
minimum as described below; and
|
||||||
- a patch release may make backward-compatible fixes.
|
- a patch release may make backward-compatible fixes.
|
||||||
|
|
||||||
|
In this context, "backward-compatible" means only that pre-existing, valid
|
||||||
|
configurations will continue to work following the release. Behavior need not
|
||||||
|
remain strictly identical.
|
||||||
|
|
||||||
Before `v1.0.0`, a minor release may change a documented public, operational,
|
Before `v1.0.0`, a minor release may change a documented public, operational,
|
||||||
configuration, data, integration, or compatibility contract when the release
|
configuration, data, integration, or compatibility contract when the release
|
||||||
note explains the impact and required consumer action. A patch release must
|
note explains the impact and required consumer action. A patch release must
|
||||||
@@ -176,8 +185,8 @@ ConfigKit uses the complete checked-in note as the annotated tag message.
|
|||||||
Include the release footer defined by the [OSS policy](oss.md#discoverability)
|
Include the release footer defined by the [OSS policy](oss.md#discoverability)
|
||||||
in each new release document. The release procedure defines the concrete URLs
|
in each new release document. The release procedure defines the concrete URLs
|
||||||
and publication mechanics. Prepare these links with the release note; they do
|
and publication mechanics. Prepare these links with the release note; they do
|
||||||
not add a network check or approval gate. If a hosted download page is used in
|
not add a network check or approval gate. ConfigKit's hosted release page reuses
|
||||||
a future channel, preserve the footer there.
|
the complete committed note and its footer.
|
||||||
|
|
||||||
Handle version selection, release-note content, and retirement of completed
|
Handle version selection, release-note content, and retirement of completed
|
||||||
roadmaps during preparation. Preserve planned and deferred work and repair
|
roadmaps during preparation. Preserve planned and deferred work and repair
|
||||||
@@ -208,8 +217,8 @@ test or CI run required before it.
|
|||||||
|
|
||||||
The selected profile runs the complete deterministic, offline default suite
|
The selected profile runs the complete deterministic, offline default suite
|
||||||
once with the race detector and test-time vet disabled. The suite already
|
once with the race detector and test-time vet disabled. The suite already
|
||||||
includes examples, consumer fixtures, documentation links, and package
|
includes examples, consumer fixtures, and package boundaries. One full vet
|
||||||
boundaries. One full vet pass adds static checks beyond the test-time subset.
|
pass adds static checks beyond the test-time subset.
|
||||||
CGO-disabled Linux amd64 and arm64 package compilation covers supported
|
CGO-disabled Linux amd64 and arm64 package compilation covers supported
|
||||||
configurations distinct from native race compilation. macOS and newer-toolchain
|
configurations distinct from native race compilation. macOS and newer-toolchain
|
||||||
checks are optional.
|
checks are optional.
|
||||||
@@ -221,6 +230,22 @@ expanded platform matrices solely because they are possible. Retain such
|
|||||||
checks when their distinct protection justifies their cost. A one-time migration
|
checks when their distinct protection justifies their cost. A one-time migration
|
||||||
check must not silently become a permanent release requirement.
|
check must not silently become a permanent release requirement.
|
||||||
|
|
||||||
|
ConfigKit selects ReleaseKit's targeted forbidden-file check to keep the local
|
||||||
|
delivery token path out of the committed candidate. The profile owns the path;
|
||||||
|
the check does not inspect file contents or Git history. Hosted credential
|
||||||
|
preflight belongs to delivery and adds no source-publication gate.
|
||||||
|
|
||||||
|
ConfigKit delegates committed Markdown link validation to ReleaseKit rather
|
||||||
|
than maintaining a documentation-link test in the Go suite. Links outside the
|
||||||
|
checkout remain out of scope. This moves automated link feedback to candidate
|
||||||
|
validation; ordinary development review still covers edited links.
|
||||||
|
|
||||||
|
ConfigKit also selects ReleaseKit's local-replacement check for committed module
|
||||||
|
declarations. Filesystem replacements can make local validation succeed with
|
||||||
|
dependencies unavailable to consumers of the published module. Versioned module
|
||||||
|
replacements remain permitted. This check does not validate remote dependency
|
||||||
|
availability or require consumer retrieval.
|
||||||
|
|
||||||
ReleaseKit runs its selected Go commands with readonly module mode and
|
ReleaseKit runs its selected Go commands with readonly module mode and
|
||||||
`GOPROXY=off`; acquire dependencies and build the tool before candidate
|
`GOPROXY=off`; acquire dependencies and build the tool before candidate
|
||||||
validation. Formatting and whitespace remain ordinary development checks in
|
validation. Formatting and whitespace remain ordinary development checks in
|
||||||
|
|||||||
295
docs/release.md
295
docs/release.md
@@ -1,16 +1,25 @@
|
|||||||
# ConfigKit Release Procedure
|
# ConfigKit Release Procedure
|
||||||
|
|
||||||
This is the maintainer runbook for ConfigKit's [release policy](policy/release.md).
|
This is the maintainer runbook for ConfigKit's [release policy](policy/release.md).
|
||||||
ConfigKit publishes a source-only Go module. The immutable annotated tag and
|
A release publishes the Go source under a new Git tag, with a matching release
|
||||||
matching checked-in release note are the deliverables; there is no binary,
|
note committed in the repository. The tag is annotated: it includes the release
|
||||||
container image, generated package, or separate registry publication.
|
note text and points to the released commit. Published tags and contents are
|
||||||
|
immutable. There is no binary, container image, generated package, or separate
|
||||||
|
registry publication. Beginning with v1.0.0, maintainers also deliver a Gitea
|
||||||
|
release page containing the complete checked-in note after source publication.
|
||||||
|
Source publication and hosted delivery have separate outcomes.
|
||||||
|
|
||||||
|
A **candidate** is the committed revision you intend to release. The
|
||||||
|
**authoritative remote** is the repository where ConfigKit publishes releases;
|
||||||
|
its exact push URL is listed below. The **consumer minimum** is the oldest Go
|
||||||
|
version supported for applications using ConfigKit.
|
||||||
|
|
||||||
## Release identity and support
|
## Release identity and support
|
||||||
|
|
||||||
- Module: `gitea.maximumdirect.net/eric/ConfigKit`
|
- Module: `go.maximumdirect.net/configkit`
|
||||||
- Primary branch: `main`
|
- Primary branch: `main`
|
||||||
- Authoritative remote: `origin`
|
- Authoritative remote: `origin`
|
||||||
- Authoritative push URL: `https://gitea.maximumdirect.net/eric/ConfigKit.git`
|
- Authoritative push URL: `ssh://git@forge.maximumdirect.net:22222/base-layer/configkit.git`
|
||||||
- Tags: stable Semantic Versions such as `v0.5.0`; no prereleases
|
- Tags: stable Semantic Versions such as `v0.5.0`; no prereleases
|
||||||
- Minimum Go version: the first stable patch of the selected Go series, or a
|
- Minimum Go version: the first stable patch of the selected Go series, or a
|
||||||
higher patch when required by a dependency or specific correctness fix, as
|
higher patch when required by a dependency or specific correctness fix, as
|
||||||
@@ -20,10 +29,16 @@ container image, generated package, or separate registry publication.
|
|||||||
- Best-effort targets: macOS amd64 and arm64, under the
|
- Best-effort targets: macOS amd64 and arm64, under the
|
||||||
[platform support policy](policy/release.md#platform-support)
|
[platform support policy](policy/release.md#platform-support)
|
||||||
|
|
||||||
Release validation runs one offline race-enabled Go suite, one full vet pass,
|
Release validation runs the offline Go test suite once with the race detector,
|
||||||
and CGO-disabled package compilation for both Linux targets. macOS and CI are
|
then a full vet pass and package compilation for both Linux targets with cgo
|
||||||
|
disabled (`CGO_ENABLED=0`), followed by the selected committed-file checks
|
||||||
|
described in [publication](#4-publish-the-committed-candidate). macOS and CI are
|
||||||
not release gates; do not wait for or poll CI.
|
not release gates; do not wait for or poll CI.
|
||||||
|
|
||||||
|
The vanity module path is the name applications put in Go imports. Source/tag
|
||||||
|
browser links and public Go retrieval use the Forge HTTPS repository
|
||||||
|
independently of SSH publication.
|
||||||
|
|
||||||
## 1. Select Go and prepare the tool
|
## 1. Select Go and prepare the tool
|
||||||
|
|
||||||
Use a clean checkout with Git, Bash 3.2 or newer, and a native compiler for race
|
Use a clean checkout with Git, Bash 3.2 or newer, and a native compiler for race
|
||||||
@@ -33,10 +48,10 @@ For a patch release, retain the series recorded in `go.mod` and leave its consum
|
|||||||
minimum unchanged. Follow the
|
minimum unchanged. Follow the
|
||||||
[toolchain selection policy](policy/release.md#configkit-toolchain-selection).
|
[toolchain selection policy](policy/release.md#configkit-toolchain-selection).
|
||||||
|
|
||||||
Put the selected series' latest locally available compatible patch first on
|
Choose the latest compatible patch installed locally in that Go series. Put
|
||||||
`PATH`, ensuring it meets `go.mod`'s minimum. The same `go` executable must build
|
its `go` executable first on `PATH` and ensure it meets the minimum in
|
||||||
ReleaseKit and run its child validation commands. Set local toolchain selection
|
`go.mod`. The same `go` executable must build ReleaseKit and run its child
|
||||||
and verify it:
|
validation commands. Set local toolchain selection and verify it:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
export GOTOOLCHAIN=local
|
export GOTOOLCHAIN=local
|
||||||
@@ -61,8 +76,17 @@ use that required version instead and document the reason. When the series is
|
|||||||
unchanged, leave the minimum unchanged unless a minor or major release
|
unchanged, leave the minimum unchanged unless a minor or major release
|
||||||
deliberately changes it under the
|
deliberately changes it under the
|
||||||
[consumer minimum policy](policy/release.md#consumer-minimum-and-release-toolchain).
|
[consumer minimum policy](policy/release.md#consumer-minimum-and-release-toolchain).
|
||||||
For example, validating with Go 1.26.8 leaves `go 1.26.0` unchanged. There is no
|
For example:
|
||||||
separate requirement to validate with Go 1.26.0.
|
|
||||||
|
| Release situation | Go used for validation | Consumer minimum |
|
||||||
|
| --- | --- | --- |
|
||||||
|
| Patch release in the 1.26 series | Latest compatible patch installed locally, such as 1.26.8 | Keep the existing `go.mod` minimum unchanged. |
|
||||||
|
| Minor/major release staying in 1.26 | Latest compatible patch installed locally | Normally retain the minimum; any deliberate change follows the linked policy. |
|
||||||
|
| Minor/major release adopting a newer installed stable series | Latest compatible patch installed locally in that series | Normally declare that series' initial stable patch, or the higher patch required by a dependency or identified fix. |
|
||||||
|
|
||||||
|
Using Go 1.26.8 for validation can therefore leave `go 1.26.0` unchanged. Users
|
||||||
|
need the declared minimum, while maintainers validate with their selected patch.
|
||||||
|
There is no separate requirement to validate with Go 1.26.0.
|
||||||
|
|
||||||
Prepare dependencies whether or not the minimum changes:
|
Prepare dependencies whether or not the minimum changes:
|
||||||
|
|
||||||
@@ -70,28 +94,35 @@ Prepare dependencies whether or not the minimum changes:
|
|||||||
go mod download
|
go mod download
|
||||||
```
|
```
|
||||||
|
|
||||||
Build the tool selected by `go.mod` into temporary storage outside the checkout.
|
Run the module-selected ReleaseKit tool from the repository root. Go builds
|
||||||
Ensure `$TMPDIR` names a system temporary location, not the repository. These
|
and caches the executable; no temporary executable or launcher is needed.
|
||||||
commands never install another ReleaseKit version:
|
These commands use the version recorded in `go.mod`:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
releasekit_tmp=$(mktemp -d "${TMPDIR:-/tmp}/configkit-releasekit.XXXXXX")
|
go list -m go.maximumdirect.net/releasekit
|
||||||
releasekit_bin="$releasekit_tmp/releasekit"
|
go tool releasekit version
|
||||||
go build -o "$releasekit_bin" gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit
|
|
||||||
go list -m gitea.maximumdirect.net/eric/ReleaseKit
|
|
||||||
"$releasekit_bin" version
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The module-selected executable is ReleaseKit v0.4.1. Use the built executable
|
This repository selects ReleaseKit v0.8.0. `go tool releasekit` preserves the
|
||||||
for publication to preserve its exit status and publication-state diagnostics.
|
tool's exit status and publication report; `go run` maps a nonzero child exit
|
||||||
`go run` maps a nonzero child exit status to 1. ReleaseKit's child Go commands
|
status to 1. ReleaseKit's child Go commands
|
||||||
use the `go` found on `PATH` with `GOTOOLCHAIN=local`; keep the selected
|
use the `go` found on `PATH` with `GOTOOLCHAIN=local`; keep the selected
|
||||||
executable first on `PATH` through the publication command.
|
executable first on `PATH` through the publication command.
|
||||||
|
|
||||||
|
For hosted delivery, save the Gitea API token in the ignored local file
|
||||||
|
`.releasekit/gitea-token` with owner-only permissions. The profile uses it when
|
||||||
|
`RELEASEKIT_GITEA_TOKEN` is unset or blank; a nonblank environment value takes
|
||||||
|
precedence. The file path resolves from the repository root, and ReleaseKit
|
||||||
|
trims surrounding whitespace from its contents. Keep credentials out of release
|
||||||
|
notes, diagnostic labels, command arguments, and commits. The selected
|
||||||
|
`forbidden_files` check also rejects a committed token at that exact path;
|
||||||
|
it does not scan other files or history. Source publication uses Git
|
||||||
|
authentication and does not read or require this token.
|
||||||
|
|
||||||
Verify access to the authoritative remote without displaying credentials:
|
Verify access to the authoritative remote without displaying credentials:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
git ls-remote https://gitea.maximumdirect.net/eric/ConfigKit.git HEAD refs/heads/main
|
git ls-remote ssh://git@forge.maximumdirect.net:22222/base-layer/configkit.git HEAD refs/heads/main
|
||||||
```
|
```
|
||||||
|
|
||||||
Publication requires explicit authorization. Local implementation commits do
|
Publication requires explicit authorization. Local implementation commits do
|
||||||
@@ -99,10 +130,10 @@ not authorize a push or release.
|
|||||||
|
|
||||||
## 2. Prepare and commit the candidate
|
## 2. Prepare and commit the candidate
|
||||||
|
|
||||||
Choose an unused stable version locally and on the authoritative remote. Compare
|
Choose a stable version that has not been used locally or on the publication
|
||||||
the candidate with the previous tag and review exported Go signatures and
|
remote. Compare the candidate with the previous tag. Review public Go APIs and
|
||||||
ownership, YAML and resolution semantics, diagnostic contracts, minimum Go
|
value ownership, YAML and resolution rules, diagnostic contracts, the minimum
|
||||||
version, and platform support. Review maintained consumer examples and the
|
Go version, and platform support. Review maintained consumer examples and the
|
||||||
migration guidance. Explain any consumer-minimum increase and required consumer
|
migration guidance. Explain any consumer-minimum increase and required consumer
|
||||||
action under the
|
action under the
|
||||||
[consumer minimum policy](policy/release.md#consumer-minimum-and-release-toolchain).
|
[consumer minimum policy](policy/release.md#consumer-minimum-and-release-toolchain).
|
||||||
@@ -111,7 +142,7 @@ Create `docs/releases/<version>.md`. Its first line must be exactly
|
|||||||
`# ConfigKit <version>`, and it must end with the license and attribution footer:
|
`# ConfigKit <version>`, and it must end with the license and attribution footer:
|
||||||
|
|
||||||
```markdown
|
```markdown
|
||||||
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/VERSION/COPYRIGHT) | [LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/VERSION/LICENSE) | [ATTRIBUTION](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/VERSION/docs/oss/attribution.md)
|
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/configkit/src/tag/VERSION/COPYRIGHT) | [LICENSE](https://forge.maximumdirect.net/base-layer/configkit/src/tag/VERSION/LICENSE) | [ATTRIBUTION](https://forge.maximumdirect.net/base-layer/configkit/src/tag/VERSION/docs/oss/attribution.md)
|
||||||
```
|
```
|
||||||
|
|
||||||
Replace `VERSION` with the full tag and confirm the three target files exist in
|
Replace `VERSION` with the full tag and confirm the three target files exist in
|
||||||
@@ -127,7 +158,7 @@ Push only that committed candidate to the literal authoritative URL. This does
|
|||||||
not follow local tags:
|
not follow local tags:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
git push --no-follow-tags https://gitea.maximumdirect.net/eric/ConfigKit.git HEAD:refs/heads/main
|
git push --no-follow-tags ssh://git@forge.maximumdirect.net:22222/base-layer/configkit.git HEAD:refs/heads/main
|
||||||
```
|
```
|
||||||
|
|
||||||
ReleaseKit requires authoritative `main` to equal this candidate before it
|
ReleaseKit requires authoritative `main` to equal this candidate before it
|
||||||
@@ -139,82 +170,184 @@ candidate to `main`, and invoke validation again.
|
|||||||
|
|
||||||
## 4. Publish the committed candidate
|
## 4. Publish the committed candidate
|
||||||
|
|
||||||
With the authorized candidate already on authoritative `main`, invoke the
|
Once publication is authorized and the candidate is on the publication remote's
|
||||||
module-selected native executable exactly once:
|
`main`, run the module-selected ReleaseKit tool once to validate and publish it:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
"$releasekit_bin" publish vMAJOR.MINOR.PATCH
|
go tool releasekit publish vMAJOR.MINOR.PATCH
|
||||||
```
|
```
|
||||||
|
|
||||||
Replace the placeholder with the selected version. ReleaseKit checks the
|
Replace the placeholder with the selected version. ReleaseKit checks the
|
||||||
effective push URL against `expected_push_url`, verifies the candidate on
|
push URL Git will actually use, after URL rewrites, against `expected_push_url`.
|
||||||
|
It verifies the candidate on
|
||||||
`main`, and checks the release-note H1 for the product and version. It then
|
`main`, and checks the release-note H1 for the product and version. It then
|
||||||
runs the selected race-enabled standard suite once with test-time vet disabled,
|
runs the selected race-enabled standard suite once with test-time vet disabled,
|
||||||
one full vet pass, and CGO-disabled package compilation for Linux amd64 and
|
one full vet pass, and CGO-disabled package compilation for Linux amd64 and
|
||||||
arm64. It publishes only the annotated tag for that candidate. Confirmed
|
arm64. It also checks the committed candidate for the forbidden token path,
|
||||||
|
filesystem replacements in module declarations, and local Markdown destinations
|
||||||
|
and supported heading fragments. Markdown links outside the checkout are
|
||||||
|
reported as out of scope without failing validation. These file checks inspect
|
||||||
|
the committed candidate, not uncommitted or ignored working-tree content.
|
||||||
|
|
||||||
|
It publishes only the annotated tag for that candidate. Confirmed
|
||||||
publication of the intended tag completes the release. The tag points to the
|
publication of the intended tag completes the release. The tag points to the
|
||||||
captured commit and uses the complete checked-in note as its annotation message.
|
captured commit and uses the complete checked-in note as its annotation message.
|
||||||
The optional `check`
|
Use the optional `check` command for diagnosis when useful. It validates without
|
||||||
command is available for diagnosis but is not a separate required pass:
|
publishing and is not required before `publish`, which performs its own checks:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
"$releasekit_bin" check vMAJOR.MINOR.PATCH
|
go tool releasekit check vMAJOR.MINOR.PATCH
|
||||||
```
|
```
|
||||||
|
|
||||||
Status 0 confirms publication, status 1 reports a known publication failure or
|
For `publish`, interpret the program's exit status as follows:
|
||||||
conflict, status 2 reports usage, profile, or version errors, and status 3 means
|
|
||||||
the push outcome remains unknown. Preserve both the native exit status and its
|
| Status | Meaning | Next action |
|
||||||
diagnostics.
|
| --- | --- | --- |
|
||||||
|
| `0` | Publication succeeded. | The source release is complete. Continue with hosted delivery. |
|
||||||
|
| `1` | A known publication failure or conflict was reported. | Read the report and correct the stated problem. Preserve any published tag. |
|
||||||
|
| `2` | The command, profile, or version is invalid. | Correct the command or configuration before trying again. |
|
||||||
|
| `3` | The tag push outcome is still unknown. | Follow the recovery procedure below before retrying or declaring success. |
|
||||||
|
|
||||||
|
Keep both the program's exit status and its diagnostic report. Using `go run`
|
||||||
|
would lose the distinction between its nonzero exit statuses.
|
||||||
|
|
||||||
The command does not push or repair `main`, wait for CI, or publish binaries or
|
The command does not push or repair `main`, wait for CI, or publish binaries or
|
||||||
a hosted release page. A successful intended-tag publication does not require
|
a hosted release page. A successful intended-tag publication does not require
|
||||||
remote read-back or consumer retrieval; those checks remain optional.
|
remote read-back or consumer retrieval; those checks remain optional.
|
||||||
|
|
||||||
## 5. Recover from a failed or uncertain tag push
|
## 5. Deliver the hosted release page
|
||||||
|
|
||||||
The executable reports the publication state, phase, version, candidate commit,
|
After source publication succeeds, run local hosted delivery from the repository
|
||||||
tag-object ID, effective endpoint, and error on standard error. It performs one
|
root. Keep the worktree clean and `HEAD` at the published tag's commit. Use the
|
||||||
bounded authoritative reconciliation after a failed tag push. Exit status 3
|
module-selected tool as in step 1. For the first release using this workflow:
|
||||||
means the tag outcome remains unknown; do not blindly retry or move a tag.
|
|
||||||
|
```sh
|
||||||
For an uncertain result, use the exact endpoint, candidate commit, version, and
|
go tool releasekit deliver v1.0.0
|
||||||
tag-object ID from the report. Copy the captured endpoint into the variable
|
```
|
||||||
below using normal shell quoting. Check that Git still resolves it to itself,
|
|
||||||
then query the authoritative refs once:
|
For subsequent releases, substitute the selected version. The profile selects
|
||||||
|
Gitea at `https://forge.maximumdirect.net`, owner `base-layer`, repository
|
||||||
|
`configkit`. With no build targets, delivery creates or accepts a stable,
|
||||||
|
non-draft release page containing the complete committed note, including its
|
||||||
|
licensing footer. It creates no binary assets, receipts, or checksum manifest.
|
||||||
|
An existing page with matching content is accepted; conflicting content is not
|
||||||
|
overwritten.
|
||||||
|
|
||||||
|
Delivery automatically performs an authenticated, read-only repository preflight
|
||||||
|
before hosted writes. A separate preflight before source publication is not a
|
||||||
|
release gate. Delivery does not rerun candidate validation, publish a tag, or
|
||||||
|
wait for CI. Hosted-service availability and delivery credentials do not gate
|
||||||
|
source publication. Report source-publication success and hosted-delivery status
|
||||||
|
separately.
|
||||||
|
|
||||||
|
| Delivery status | Meaning and next action |
|
||||||
|
| --- | --- |
|
||||||
|
| `0` | Delivery is complete; report the hosted release URL. |
|
||||||
|
| `1` | Preflight was unavailable, or delivery failed, conflicted, or made partial progress. Read the reported state and correct the cause; investigate conflicts before retrying. |
|
||||||
|
| `2` | Usage, profile, credential loading, or preflight configuration/access failed. Correct the reported problem before retrying delivery. |
|
||||||
|
| `3` | A hosted write has an unresolved outcome. Inspect the hosted release and resolve that state before retrying. |
|
||||||
|
|
||||||
|
A delivery problem leaves the successfully published source tag intact. Retry
|
||||||
|
delivery with the same immutable version after correcting the cause and
|
||||||
|
resolving any uncertain write. For a later retry, use a clean checkout of the
|
||||||
|
original tag, fetching that tag if needed. Use that checkout's module-selected
|
||||||
|
CLI, prepare its dependencies and tool as in step 1 without changing the tagged
|
||||||
|
module files, and make the token available there. For example, with the tag
|
||||||
|
available locally:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
release_version=v1.0.0
|
||||||
|
git switch --detach "$release_version"
|
||||||
|
```
|
||||||
|
|
||||||
|
After preparing the tool in that tagged checkout:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go tool releasekit deliver "$release_version"
|
||||||
|
```
|
||||||
|
|
||||||
|
Do not rerun `publish`, move a tag, or replace a hosted page to repair delivery.
|
||||||
|
If published source or content needs correction, prepare a new appropriate
|
||||||
|
version through the normal release procedure.
|
||||||
|
|
||||||
|
## 6. Recover from a failed or uncertain tag push
|
||||||
|
|
||||||
|
ReleaseKit attempts to save publication evidence before pushing a tag and
|
||||||
|
reports the absolute record path, or a recording warning, separately from the
|
||||||
|
publication outcome. A recording failure does not undo a successful publication
|
||||||
|
or change its exit status. Confirmed publication still completes the source
|
||||||
|
release; hosted delivery remains a separate operation.
|
||||||
|
|
||||||
|
Records default to a private store under the repository's Git common directory,
|
||||||
|
shared by linked worktrees. `publish --record-dir DIR` can select another
|
||||||
|
location; relative paths resolve from the source repository root. Records can
|
||||||
|
contain exact endpoints, including credentials. Keep them private and remove
|
||||||
|
them when their recovery value expires. There is no new tracked storage,
|
||||||
|
credential-preflight gate, or required recording-success check.
|
||||||
|
|
||||||
|
After a failed push, ReleaseKit makes one bounded observation of the original
|
||||||
|
publication endpoint. If the outcome remains unknown, use the explicitly saved
|
||||||
|
record rather than rerunning publication. Copy the recovery command printed by
|
||||||
|
ReleaseKit, or use the module-selected tool:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
publication_record='/absolute/path/from/the/publication/report.json'
|
||||||
|
go tool releasekit reconcile --record "$publication_record"
|
||||||
|
```
|
||||||
|
|
||||||
|
If the path was lost, discover local attempts without contacting a remote:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go tool releasekit publication-attempts
|
||||||
|
```
|
||||||
|
|
||||||
|
Select the record for the intended attempt from its reported version, candidate,
|
||||||
|
and tag evidence; discovery does not automatically choose the latest attempt.
|
||||||
|
For an alternate store, supply `publication-attempts --record-dir DIR`.
|
||||||
|
Both commands support optional `--json` output. Treat saved records as private
|
||||||
|
regardless of the CLI's credential-redacted diagnostic output.
|
||||||
|
|
||||||
|
Reconciliation observes the captured endpoint and exact tag object using current
|
||||||
|
supported Git transport settings. It does not load the current profile, require
|
||||||
|
the original `HEAD`, rerun candidate validation, change tags, or contact Gitea
|
||||||
|
hosted delivery. It needs no clean candidate checkout. Use ReleaseKit v0.8.0 or
|
||||||
|
another CLI supporting the saved record format, including format 2 where exact
|
||||||
|
non-UTF-8 endpoint/path bytes were retained.
|
||||||
|
|
||||||
|
| Reconciliation status | Meaning and next action |
|
||||||
|
| --- | --- |
|
||||||
|
| `0` | The intended tag object is published. The source release is complete; continue with hosted delivery from its clean tagged checkout. |
|
||||||
|
| `1` | The tag is currently absent or conflicting. Read the reported state. Investigate conflicts; absence alone does not prove that the version was never published. |
|
||||||
|
| `2` | The record cannot be read/decoded, or the invocation is invalid. Correct the path, access, CLI compatibility, or command. |
|
||||||
|
| `3` | Observation remains unresolved, including insufficient evidence or cancellation. Correct the transport or other reported cause before another explicit observation; do not blindly republish. |
|
||||||
|
|
||||||
|
These are recovery actions after failure, not read-back requirements after a
|
||||||
|
successful push. A failed observation leaves the result unresolved. Do not poll
|
||||||
|
CI or move a tag to settle uncertainty.
|
||||||
|
|
||||||
|
When no usable record exists (for example, storage failed or an older CLI made
|
||||||
|
the attempt), use the captured endpoint and exact tag-object ID from the original
|
||||||
|
report for a manual authoritative observation. Checking only the peeled commit
|
||||||
|
would not establish that the intended annotated tag object arrived. Do not
|
||||||
|
substitute current profile or local tag state for missing original evidence.
|
||||||
|
|
||||||
|
Only after establishing that a retained local tag was never published or used
|
||||||
|
by another release channel may it be removed before retrying publication:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
authoritative='COPY_CAPTURED_ENDPOINT'
|
|
||||||
release_version='vMAJOR.MINOR.PATCH'
|
release_version='vMAJOR.MINOR.PATCH'
|
||||||
test "$(git ls-remote --get-url -- "$authoritative")" = "$authoritative" &&
|
|
||||||
git ls-remote --refs -- "$authoritative" refs/heads/main "refs/tags/$release_version"
|
|
||||||
```
|
|
||||||
|
|
||||||
A failed URL-equality check or remote query leaves the outcome unresolved.
|
|
||||||
Correct transport configuration before continuing; a rewritten destination
|
|
||||||
cannot establish absence at the captured endpoint.
|
|
||||||
|
|
||||||
Compare the `main` object ID with the reported candidate commit and the tag
|
|
||||||
object ID with the reported tag-object ID. `--refs` returns the annotated tag
|
|
||||||
object itself, not merely its peeled commit. If the tag object matches, the
|
|
||||||
release is published. If the query fails, the result is conflicting, or the
|
|
||||||
endpoint or candidate differs, stop and resolve the state before retrying. If
|
|
||||||
the endpoint is reachable, the tag is confirmed absent, and `main` still equals
|
|
||||||
the reported candidate, remove a retained local tag only after confirming it
|
|
||||||
was not published or used by another channel; then rerun `publish`, which
|
|
||||||
revalidates the candidate:
|
|
||||||
|
|
||||||
```sh
|
|
||||||
git tag -d "$release_version"
|
git tag -d "$release_version"
|
||||||
"$releasekit_bin" publish "$release_version"
|
go tool releasekit publish "$release_version"
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Replace the placeholder with the original attempt's version.
|
||||||
|
The next `publish` revalidates the candidate and publication guards. A conflicting
|
||||||
|
published tag must never be moved or reused. A defect in published source
|
||||||
|
requires a corrected commit, a new appropriate version, and a matching note.
|
||||||
A validation failure after the candidate reached `main` leaves an untagged
|
A validation failure after the candidate reached `main` leaves an untagged
|
||||||
candidate there. Correct it with a new commit rather than rewriting or rolling
|
candidate there; correct it in a new commit rather than rewriting `main`.
|
||||||
back `main`. A conflicting published tag must never be moved or reused. A
|
|
||||||
defect in published source requires a corrected commit, a new semantically
|
|
||||||
appropriate version, and a matching release note.
|
|
||||||
|
|
||||||
## 6. Optional consumer verification
|
## 7. Optional consumer verification
|
||||||
|
|
||||||
When useful or requested, read back the remote tag or retrieve the exact
|
When useful or requested, read back the remote tag or retrieve the exact
|
||||||
version into a new module without a local `replace` directive. These checks are
|
version into a new module without a local `replace` directive. These checks are
|
||||||
@@ -228,8 +361,8 @@ consumer_dir=$(mktemp -d)
|
|||||||
(
|
(
|
||||||
cd "$consumer_dir"
|
cd "$consumer_dir"
|
||||||
go mod init example.invalid/configkit-release-check
|
go mod init example.invalid/configkit-release-check
|
||||||
GOPRIVATE=gitea.maximumdirect.net GOPROXY=direct go get "gitea.maximumdirect.net/eric/ConfigKit@$release_version"
|
GOWORK=off GOPROXY=direct go get "go.maximumdirect.net/configkit@$release_version"
|
||||||
go list -m -f '{{.Path}} {{.Version}}' gitea.maximumdirect.net/eric/ConfigKit
|
GOWORK=off go list -m -f '{{.Path}} {{.Version}}' go.maximumdirect.net/configkit
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
70
docs/releases/v0.10.2.md
Normal file
70
docs/releases/v0.10.2.md
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
# ConfigKit v0.10.2
|
||||||
|
|
||||||
|
This patch release fixes edge-case loading failures, resource cleanup, and limit
|
||||||
|
accounting, and simplifies ConfigKit's internal loading machinery. Public APIs
|
||||||
|
and documented configuration semantics remain unchanged from v0.10.1.
|
||||||
|
|
||||||
|
## Fixes
|
||||||
|
|
||||||
|
- Terminate pointer-only recursive type traversal safely during decoding and
|
||||||
|
schema-path resolution, preserving nullable values and supported recursive
|
||||||
|
structures. Roll back failed dynamic descriptor construction so subsequent
|
||||||
|
operations do not reuse incomplete state or panic.
|
||||||
|
- Accept finite, non-cyclic slice views that share backing storage while
|
||||||
|
retaining cycle detection and detached output.
|
||||||
|
- Deactivate resolution, validation, and version-check contexts when callbacks
|
||||||
|
panic, and close library-owned import roots during panic unwinding. The
|
||||||
|
original application panic continues to propagate.
|
||||||
|
- Report directory enumeration and close failures instead of treating them as
|
||||||
|
optional absence, including failures reported as not-exist after a successful
|
||||||
|
open. Genuinely missing optional directories retain their existing behavior.
|
||||||
|
- Reuse completed symlink dependencies during host and native import discovery,
|
||||||
|
avoiding exponential repeated expansion of finite link graphs while retaining
|
||||||
|
cycle detection and containment checks.
|
||||||
|
- Check existing projection budgets before bulk container allocation and account
|
||||||
|
for the complete retained inspection-history graph, including replay and
|
||||||
|
separately copied inheritance evidence.
|
||||||
|
- Apply the documented append/upsert sequence requirements to non-sequence
|
||||||
|
contributions at retained collection-policy destinations.
|
||||||
|
|
||||||
|
## Internal improvements
|
||||||
|
|
||||||
|
- Index collection-policy retirement and prepare sparse incoming policy changes
|
||||||
|
instead of repeatedly scanning or copying unrelated retained policy.
|
||||||
|
- Reuse successful dynamic-type eligibility checks within an operation and index
|
||||||
|
tier-history lookups, compound identity restoration, and secret-failure source
|
||||||
|
attribution to avoid repeated scans.
|
||||||
|
- Remove unused snapshots, evidence, helpers, fields, and test-only production
|
||||||
|
paths. Clarify acquisition handoffs and separate collection preparation,
|
||||||
|
application, and finalization while sharing sequence-history construction.
|
||||||
|
- Separate patterned glob entry preparation from traversal. Consolidate engine
|
||||||
|
import defaults, snapshot them during load preflight, and construct placement
|
||||||
|
directly across the facade while keeping public types root-owned.
|
||||||
|
- Add regression coverage for the corrected failures, public historical paths,
|
||||||
|
defaults snapshots, and resource ownership. Strengthen fuzz detachment checks
|
||||||
|
and replace redundant or implementation-coupled assertions.
|
||||||
|
- Update release tooling from ReleaseKit v0.4.1 to v0.4.2.
|
||||||
|
|
||||||
|
These changes remove redundant work and improve scaling in the identified paths;
|
||||||
|
no end-to-end performance improvement has been quantified.
|
||||||
|
|
||||||
|
## Compatibility and upgrade
|
||||||
|
|
||||||
|
No consumer API or configuration-format migration is required. The
|
||||||
|
[configuration contract](../config.md) remains authoritative.
|
||||||
|
|
||||||
|
Loads that previously succeeded despite directory acquisition failures, invalid
|
||||||
|
append/upsert contributions, or incomplete retained-history accounting may now
|
||||||
|
return the documented structured errors. These corrections enforce existing
|
||||||
|
requirements and limits rather than introducing new ones.
|
||||||
|
|
||||||
|
Select this version and rebuild:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get gitea.maximumdirect.net/eric/ConfigKit@v0.10.2
|
||||||
|
```
|
||||||
|
|
||||||
|
The consumer minimum remains Go 1.26.0. The YAML dependency and platform support
|
||||||
|
are unchanged. Publication follows the existing [release procedure](../release.md).
|
||||||
|
|
||||||
|
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.10.2/COPYRIGHT) | [LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.10.2/LICENSE) | [ATTRIBUTION](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.10.2/docs/oss/attribution.md)
|
||||||
144
docs/releases/v1.0.0.md
Normal file
144
docs/releases/v1.0.0.md
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
# ConfigKit v1.0.0
|
||||||
|
|
||||||
|
ConfigKit's first stable release completes the public API cleanup, moves the Go
|
||||||
|
module to `go.maximumdirect.net/configkit`, and scopes configuration controls to
|
||||||
|
the root of each source document. It also corrects presence, history, and
|
||||||
|
sensitivity reporting and adds direct source lookup for successful and failed
|
||||||
|
loads.
|
||||||
|
|
||||||
|
This release includes intentional breaking changes from v0.10.2. The public Go
|
||||||
|
API, documented configuration and resolution semantics, and structured diagnostic
|
||||||
|
contracts now follow the [stable-release compatibility policy](../policy/release.md#versioning-and-compatibility).
|
||||||
|
That policy includes the existing allowance for minor releases to raise the Go
|
||||||
|
consumer minimum.
|
||||||
|
|
||||||
|
## Upgrade from v0.10.2
|
||||||
|
|
||||||
|
### Module path
|
||||||
|
|
||||||
|
Update the dependency requirement and Go imports from
|
||||||
|
`gitea.maximumdirect.net/eric/ConfigKit` to `go.maximumdirect.net/configkit`,
|
||||||
|
selecting v1.0.0. The package name remains `configkit`.
|
||||||
|
|
||||||
|
Go treats the two import paths as different packages with different named types.
|
||||||
|
Coordinate the change with dependencies that exchange ConfigKit values with your
|
||||||
|
application. Earlier tags retain their original module declarations; no old-path
|
||||||
|
compatibility module is provided. See the
|
||||||
|
[module migration guide](../consumers/README.md#vanity-module-path-migration).
|
||||||
|
|
||||||
|
### Source-document controls
|
||||||
|
|
||||||
|
Rename genuine source-root `configkit` control blocks to `config` in main files,
|
||||||
|
imported fragments, underlays, selected overlays, and programmatic mapping inputs.
|
||||||
|
The recognized controls remain `imports` and `sequences`, subject to their
|
||||||
|
existing context-specific rules and import opt-in.
|
||||||
|
|
||||||
|
The control name is reserved only at the root of each acquired document. ConfigKit
|
||||||
|
extracts controls before placing that document's payload. Nested `config` keys,
|
||||||
|
including those in sequence records, are ordinary application data. Application-root
|
||||||
|
`config` data produced by placement, defaults, CLI overrides, or resolution is
|
||||||
|
also ordinary data and is not interpreted again as controls.
|
||||||
|
|
||||||
|
Review source-root application fields already named `config`: move, rename, or
|
||||||
|
separately mount that data. The former names `configkit` and `_configkit` are now
|
||||||
|
ordinary application keys at every depth, with no control aliases. A permissive
|
||||||
|
schema can therefore accept an unmigrated control block as data without executing
|
||||||
|
its imports. Stored source-local declaration paths change from `/configkit/...`
|
||||||
|
to `/config/...`.
|
||||||
|
|
||||||
|
Source-version checks distinguish actual source-local controls from application
|
||||||
|
paths with the same spelling. Existing inheritance rules remain in place. See
|
||||||
|
[imports and control scope](../config.md#declaration-and-selection-rules) and
|
||||||
|
[the directive migration guidance](../config.md#earlier-directive-spelling).
|
||||||
|
|
||||||
|
### Public Go API
|
||||||
|
|
||||||
|
The following names replace their pre-v1 counterparts outright, without
|
||||||
|
compatibility aliases:
|
||||||
|
|
||||||
|
| Previous API | v1.0.0 API |
|
||||||
|
| --- | --- |
|
||||||
|
| `Resolution.InputInfo(path)` | `Resolution.EntryInfo(path)` |
|
||||||
|
| `Definition.Secret` | `Definition.Sensitive` |
|
||||||
|
| `SourceVersionModeDefault` | `SourceVersionModeMainOnly` |
|
||||||
|
| `SourceVersionCheck` | `SourceVersionEvidence` |
|
||||||
|
| `SourceInfo.VersionCheck` | `SourceInfo.VersionEvidence` |
|
||||||
|
| `ImportSelection` | `EmptyImportSelection` |
|
||||||
|
| `SourceInfo.ImportSelections` | `SourceInfo.EmptyImportSelections` |
|
||||||
|
| `SourceInfo.DestinationUnresolved` | `SourceInfo.DestinationIsFallback` |
|
||||||
|
| `ImportDefaults.Follow` / `.Hidden` | `FollowSymlinks` / `IncludeHidden` |
|
||||||
|
| `Paths(paths...)` | `SearchPaths(paths...)` |
|
||||||
|
| `LoadSecretsDir(directory)` | `LoadSecretsIntoEnvironment(directory)` |
|
||||||
|
| `LayerBase` | `LayerMain` |
|
||||||
|
| `CodeExplainLimitExceeded` | `CodeHistoryLimitExceeded` |
|
||||||
|
| `Resolution.Record(path, ChangeDerivedDefault, reason, deps...)` | `Resolution.RecordDerivedDefault(path, reason, deps...)` |
|
||||||
|
| `Resolution.Record(path, ChangeNormalization, reason, deps...)` | `Resolution.RecordNormalization(path, reason, deps...)` |
|
||||||
|
|
||||||
|
The recording methods describe mutations the application has already performed;
|
||||||
|
they do not assign values. `Default` and `Normalize` remain the helpers that both
|
||||||
|
assign and record. `EntryInfo` names the fixed resolution-entry metadata view,
|
||||||
|
including defaults and CLI assignments. Version evidence records what was
|
||||||
|
observed, not proof of checker execution or acceptance.
|
||||||
|
|
||||||
|
The redundant `ImportSelection.Outcome`, `ImportSelectionOutcome`, and
|
||||||
|
`ImportSelectionNone` are removed. Empty-selection records retain their selector,
|
||||||
|
path, and location. The obsolete diagnostic constants `CodeResolveInvalidKind`
|
||||||
|
and `CodeOptionsUnavailable` are also removed without replacements.
|
||||||
|
|
||||||
|
Update stored or compared strings as well as Go symbols:
|
||||||
|
|
||||||
|
- The main provenance layer changes from `"base"` to `"main"`.
|
||||||
|
- The retained-history limit diagnostic changes from
|
||||||
|
`"explain.limit_exceeded"` to `"history.limit_exceeded"`.
|
||||||
|
|
||||||
|
The YAML import options `follow` and `hidden`, the `configkit:"secret"` Go tag,
|
||||||
|
and typed `Secret` references retain their existing spelling and behavior. See
|
||||||
|
the [API migration guide](../consumers/README.md#pre-v1-public-api-cleanup) for
|
||||||
|
the consolidated mapping and usage guidance. Rebuild and run your application's
|
||||||
|
configuration tests after migrating its imports, API calls, and source controls.
|
||||||
|
|
||||||
|
## Source inspection
|
||||||
|
|
||||||
|
`Result.Source(id)` and `LoadError.Source(id)` return a detached `SourceInfo`
|
||||||
|
record and a found flag for an already acquired source. They complement
|
||||||
|
`Sources()` and the existing callback-scoped `Resolution.Source(id)`. Empty or
|
||||||
|
unknown IDs return false. Lookup does not reopen inputs or resolve credentials;
|
||||||
|
failed loads retain the same partial, conservatively redacted evidence as their
|
||||||
|
source inventory. See [results and failures](../config.md#results-and-failures).
|
||||||
|
|
||||||
|
## Correctness and internal improvements
|
||||||
|
|
||||||
|
- Resolution presence queries distinguish numeric mapping keys from sequence
|
||||||
|
indexes using the configuration's entry shape. A later shape change no longer
|
||||||
|
transfers input explicitness to a different semantic path.
|
||||||
|
- Metadata history and value explanations now use one shared internal history
|
||||||
|
model. This corrects attribution across shape changes, preserves container
|
||||||
|
origins, and avoids duplicate intermediate source-history records.
|
||||||
|
- Final dynamically typed secret fields retain their sensitivity classification
|
||||||
|
in inspection output. Parsed dependency paths are deduplicated without losing
|
||||||
|
distinctions needed for sensitivity propagation.
|
||||||
|
- Composition-conflict diagnostics preserve claimant order through structured
|
||||||
|
reporting and rendering, consistent with scoped assembly order.
|
||||||
|
- History selection and explanation use indexes and reuse prepared selections
|
||||||
|
and sensitivity dependencies to reduce repeated traversal. Retained-history
|
||||||
|
accounting follows the shared model, and secret resolution and validation share
|
||||||
|
traversal machinery.
|
||||||
|
|
||||||
|
These changes preserve the documented loading and inspection rules apart from
|
||||||
|
the explicit migrations above. No end-to-end performance improvement is claimed.
|
||||||
|
The [configuration contract](../config.md) and public Go declaration comments
|
||||||
|
remain the behavior references.
|
||||||
|
|
||||||
|
## Requirements and release delivery
|
||||||
|
|
||||||
|
The consumer minimum rises from Go 1.26.0 to Go 1.27.0. Consumers must upgrade
|
||||||
|
their Go toolchain before adopting this release. The YAML dependency is unchanged.
|
||||||
|
Linux amd64 and arm64 remain the release-validated targets; macOS support remains
|
||||||
|
best-effort under the [platform support policy](../policy/release.md#platform-support).
|
||||||
|
|
||||||
|
Release tooling moves to `go.maximumdirect.net/releasekit` v0.6.0. Beginning with
|
||||||
|
this release, the source tag and checked-in note are accompanied by a Gitea release
|
||||||
|
page containing the complete note and licensing footer. Source publication and
|
||||||
|
hosted delivery remain separate outcomes under the [release procedure](../release.md).
|
||||||
|
|
||||||
|
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.0.0/COPYRIGHT) | [LICENSE](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.0.0/LICENSE) | [ATTRIBUTION](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.0.0/docs/oss/attribution.md)
|
||||||
79
docs/releases/v1.1.0.md
Normal file
79
docs/releases/v1.1.0.md
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
# ConfigKit v1.1.0
|
||||||
|
|
||||||
|
ConfigKit now accepts field-level resolution records inside lists and follows
|
||||||
|
registered records across position changes when reporting their provenance and
|
||||||
|
history. This release also standardizes more release validation on ReleaseKit.
|
||||||
|
|
||||||
|
## Field-level resolution in lists
|
||||||
|
|
||||||
|
Applications can normalize or derive a field inside a list and record that
|
||||||
|
field directly. For example, changing `/resources/0/path` can be covered by
|
||||||
|
`RecordNormalization` at that path, without recording the entire `/resources`
|
||||||
|
list when no other changes require coverage.
|
||||||
|
|
||||||
|
For ordinary lists of equal length, resolution auditing compares corresponding
|
||||||
|
positions recursively. Positions are addresses; ConfigKit does not infer an
|
||||||
|
element's identity from similar values. A length change still requires a record
|
||||||
|
for the list or an ancestor.
|
||||||
|
|
||||||
|
For collections registered through `Definition.SequenceIdentities`, ConfigKit
|
||||||
|
matches records between the resolution-entry and final snapshots when both
|
||||||
|
endpoints have valid, unique identities. Field edits can be recorded individually.
|
||||||
|
Changes to identity order or membership still require list or ancestor coverage;
|
||||||
|
changing an identity is treated as removal and addition. When matching is
|
||||||
|
unavailable, a changed registered collection is audited as a whole. Existing
|
||||||
|
final identity validation remains in place, allowing resolution to repair entry
|
||||||
|
values.
|
||||||
|
|
||||||
|
See [recorded application resolution](../config.md#recorded-application-resolution)
|
||||||
|
for the complete rules.
|
||||||
|
|
||||||
|
## Matched history and inspection
|
||||||
|
|
||||||
|
For eligible registered collections, inspection at a surviving record's final
|
||||||
|
position follows its own entry evidence, including earlier values, input
|
||||||
|
explicitness, and source attribution. A record no longer inherits the evidence
|
||||||
|
of a different record that previously occupied its final position. Original
|
||||||
|
source coordinates remain intact, while reported operation paths follow the
|
||||||
|
matched final record.
|
||||||
|
|
||||||
|
Collection inspection retains removed records' evidence. Removed fields of
|
||||||
|
surviving moved records remain available through metadata history and
|
||||||
|
explanation, including deep inherited fields. Sensitivity propagation protects
|
||||||
|
matched and removed evidence and recorded dependencies; detached before/after
|
||||||
|
snapshots remain conservatively masked.
|
||||||
|
|
||||||
|
Correspondence lookup, sensitivity propagation, and resolution-change ordering
|
||||||
|
reuse indexes, a work queue, and prepared pointer strings to avoid repeated
|
||||||
|
prefix construction, full-map passes, and formatting during sorting. No
|
||||||
|
end-to-end performance improvement is claimed.
|
||||||
|
|
||||||
|
## Compatibility
|
||||||
|
|
||||||
|
This is a backward-compatible extension of resolution auditing and inspection.
|
||||||
|
Existing callbacks that record a whole list remain supported. Records and helper
|
||||||
|
targets for surviving values refer to final positions; helpers do not follow
|
||||||
|
later moves automatically. `Resolution.EntryInfo` and `Resolution.IsExplicit`
|
||||||
|
retain their fixed entry-snapshot meaning.
|
||||||
|
|
||||||
|
Public effective-configuration diffs continue to report list changes atomically.
|
||||||
|
Source composition, collection merge operations, configuration syntax, and public
|
||||||
|
path addressing are unchanged. No new public Go symbols are required.
|
||||||
|
|
||||||
|
The consumer minimum remains Go 1.27.0, and the YAML dependency is unchanged.
|
||||||
|
Linux amd64 and arm64 remain release-validated; macOS remains best-effort.
|
||||||
|
|
||||||
|
## Release tooling
|
||||||
|
|
||||||
|
ReleaseKit advances from v0.6.0 to v0.8.0. The runbook uses the module-selected
|
||||||
|
`go tool releasekit` command and documents saved publication attempts and
|
||||||
|
reconciliation after failed or uncertain tag pushes.
|
||||||
|
|
||||||
|
ReleaseKit now checks committed Markdown links and rejects filesystem dependency
|
||||||
|
replacements during candidate validation. The custom documentation-link Go test
|
||||||
|
is removed, so automated link feedback occurs during candidate validation rather
|
||||||
|
than ordinary Go tests. Source publication and hosted Gitea delivery remain
|
||||||
|
separate outcomes; CI remains outside release gates. See the
|
||||||
|
[release procedure](../release.md).
|
||||||
|
|
||||||
|
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.1.0/COPYRIGHT) | [LICENSE](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.1.0/LICENSE) | [ATTRIBUTION](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.1.0/docs/oss/attribution.md)
|
||||||
54
docs/releases/v1.2.0.md
Normal file
54
docs/releases/v1.2.0.md
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
# ConfigKit v1.2.0
|
||||||
|
|
||||||
|
`LoadLayered` can now use the resolved main configuration's filesystem context
|
||||||
|
to find companion imports and the selected overlay in a single load. For example,
|
||||||
|
an application that discovers `/srv/campaign/config.yml` can import fragments
|
||||||
|
from `/srv/campaign/prompts/` and apply
|
||||||
|
`/srv/campaign/overlays/production.yml`, regardless of its working directory.
|
||||||
|
|
||||||
|
## Main-relative companion inputs
|
||||||
|
|
||||||
|
Set `Input.MainRelative` to `MainRelative()` on a non-file underlay to use the
|
||||||
|
main input's resolved context. Enable imports separately and declare the files
|
||||||
|
or directories to load. A concrete `Input.Filesystem` takes precedence over the
|
||||||
|
binding. The derived context keeps the main input's filesystem namespace and
|
||||||
|
containment root, with lookup starting at its resolved base directory.
|
||||||
|
|
||||||
|
When the main supplies no usable context, the companion can use a
|
||||||
|
`MainRelativeContext.Fallback`. Otherwise, optional imports are skipped and
|
||||||
|
the companion's own configuration still contributes. Required imports that
|
||||||
|
need targets fail, and `RequireBase` requires a usable context even when imports
|
||||||
|
are disabled. Skipped imports produce no filesystem search evidence.
|
||||||
|
|
||||||
|
The consumer example combines embedded defaults, operator imports, and a selected
|
||||||
|
overlay. [Example](../../examples/consumers/main_relative.go) ·
|
||||||
|
[Layered loading contract](../config.md#layered-loading)
|
||||||
|
|
||||||
|
## Overlay lookup and filesystem checks
|
||||||
|
|
||||||
|
`LoadLayered` now looks for a selected overlay using `OverlayFilesystem`, the
|
||||||
|
resolved main context, or `OverlayFallback`, in that order. Derived and fallback
|
||||||
|
lookups start at the context's base directory. Explicit overlay contexts and
|
||||||
|
ordinary `Load` use their existing root-relative lookup. A selected overlay
|
||||||
|
must exist in the chosen context.
|
||||||
|
|
||||||
|
Native overlays resolve symlink targets when the filesystem exposes link
|
||||||
|
inspection. This catches targets outside the configured root and aliases of
|
||||||
|
already acquired files in the same namespace. Derived standalone host contexts
|
||||||
|
now check root access the same way explicit contexts do. [Overlay contract](../config.md#selected-overlays)
|
||||||
|
|
||||||
|
## Upgrade notes
|
||||||
|
|
||||||
|
Use keyed Go literals when constructing `Input` and `LayeredOptions`. Both
|
||||||
|
types gained fields, so existing positional literals need updating. The minimum
|
||||||
|
consumer Go version is 1.27.0.
|
||||||
|
|
||||||
|
Internally, imports with and without a filesystem base share root placement
|
||||||
|
and accounting. Directory import policies reuse default patterns, and companions
|
||||||
|
in the same tier share one derived context.
|
||||||
|
|
||||||
|
The repository also adopted upstream documentation, licensing, and release
|
||||||
|
policy updates, including clearer guidance on when to write an ADR and what
|
||||||
|
compatibility means for existing configurations. [Repository policies](../policy/meta.md)
|
||||||
|
|
||||||
|
[COPYRIGHT](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.2.0/COPYRIGHT) | [LICENSE](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.2.0/LICENSE) | [ATTRIBUTION](https://forge.maximumdirect.net/base-layer/configkit/src/tag/v1.2.0/docs/oss/attribution.md)
|
||||||
@@ -1,9 +1,9 @@
|
|||||||
# Potential Future Features
|
# Potential Future Work
|
||||||
|
|
||||||
This document lists potential enhancements for consideration in future ConfigKit
|
This document lists potential enhancements and maintenance work for consideration
|
||||||
releases. Entries are not accepted contracts, implementation plans, or release
|
in future ConfigKit releases. Entries are not accepted contracts, implementation
|
||||||
commitments. Adopting a candidate requires an explicit design decision and a
|
plans, or release commitments. Adopting a candidate requires an explicit design
|
||||||
separate plan. Significant architectural exclusions and their rationale belong
|
decision and a separate plan. Significant architectural exclusions and their rationale belong
|
||||||
in ADRs; this document does not maintain a list of rejected proposals.
|
in ADRs; this document does not maintain a list of rejected proposals.
|
||||||
|
|
||||||
## 1. Recursive body merging for identified records
|
## 1. Recursive body merging for identified records
|
||||||
@@ -83,3 +83,48 @@ evidence are preserved in Git history at `8e91a2b` under `docs/roadmap/`.
|
|||||||
across loads without introducing aliasing?
|
across loads without introducing aliasing?
|
||||||
- What cache lifetime and concurrency design would make schema reuse worthwhile
|
- What cache lifetime and concurrency design would make schema reuse worthwhile
|
||||||
without excessive retained memory?
|
without excessive retained memory?
|
||||||
|
|
||||||
|
## 4. Public path comparison
|
||||||
|
|
||||||
|
Status: deferred pending a concrete consumer need. This preserves A2 from the
|
||||||
|
completed API review at `68400ff:docs/roadmap/review.md`.
|
||||||
|
|
||||||
|
`Path.String()` is a JSON Pointer spelling, not a complete semantic identity:
|
||||||
|
an explicit numeric mapping key and a sequence index can share it. The public
|
||||||
|
type is opaque and currently non-comparable.
|
||||||
|
|
||||||
|
If consumers need comparison, consider a small exact-representation operation
|
||||||
|
rather than exposing mutable segments or introducing a pattern language. A
|
||||||
|
proposal must distinguish exact segment representation, including parsed versus
|
||||||
|
explicit segments and invalid state, from resolution against a document. Pointer
|
||||||
|
string equality must not be presented as semantic equality.
|
||||||
|
|
||||||
|
Keeping `Path` solely as an opaque query/building value remains an alternative.
|
||||||
|
No current contract changes or implementation commitment follow from this entry;
|
||||||
|
a comparison operation can be added compatibly later.
|
||||||
|
|
||||||
|
## 5. Single-file named overlays
|
||||||
|
|
||||||
|
Status: deferred. Consider supporting a single `overlays.yml` containing named
|
||||||
|
overlay bodies alongside the existing
|
||||||
|
[selected-overlay directory convention](../config.md#selected-overlays).
|
||||||
|
This would let small applications keep their overlays in one file. Planning was
|
||||||
|
tabled because the added semantics outweighed the immediate convenience; the
|
||||||
|
draft is preserved at `3e50388:docs/roadmap/overlays.md`.
|
||||||
|
|
||||||
|
The draft selected a direct name-to-body mapping, strict peer composition when
|
||||||
|
both storage forms contribute to the selected name, and file-level version
|
||||||
|
checks using the application's existing rule and mode. These provide a starting
|
||||||
|
point for reconsideration, not an implementation commitment.
|
||||||
|
|
||||||
|
### Questions to resolve before implementation
|
||||||
|
|
||||||
|
- Where should collection controls live: within each named body or at catalog
|
||||||
|
level, and how should that interact with document-root control ownership?
|
||||||
|
- How should configured version metadata paths coexist with overlay names,
|
||||||
|
including nested paths and modes that do not check overlay versions?
|
||||||
|
- Which semantic checks should apply to unselected bodies?
|
||||||
|
- Should `.yaml` also be recognized, and how should multiple matching files
|
||||||
|
interact?
|
||||||
|
- How should catalog lookup be enabled without disrupting successful loads that
|
||||||
|
previously ignored an adjacent `overlays.yml`?
|
||||||
|
|||||||
@@ -1,151 +0,0 @@
|
|||||||
package configkit
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bufio"
|
|
||||||
"fmt"
|
|
||||||
"net/url"
|
|
||||||
"os"
|
|
||||||
"path/filepath"
|
|
||||||
"regexp"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
"unicode"
|
|
||||||
)
|
|
||||||
|
|
||||||
var markdownLinkPattern = regexp.MustCompile(`(?m)(?:^|[[:space:]])\[[^\]]*\]\(([^)[:space:]]+)`)
|
|
||||||
|
|
||||||
func TestLocalDocumentationLinks(t *testing.T) {
|
|
||||||
repository, err := filepath.Abs(".")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
var failures []string
|
|
||||||
err = filepath.WalkDir(repository, func(path string, entry os.DirEntry, walkErr error) error {
|
|
||||||
if walkErr != nil {
|
|
||||||
return walkErr
|
|
||||||
}
|
|
||||||
if entry.IsDir() {
|
|
||||||
if entry.Name() == ".git" {
|
|
||||||
return filepath.SkipDir
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
if filepath.Ext(path) != ".md" {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
content, readErr := os.ReadFile(path)
|
|
||||||
if readErr != nil {
|
|
||||||
return readErr
|
|
||||||
}
|
|
||||||
for _, match := range markdownLinkPattern.FindAllStringSubmatch(string(content), -1) {
|
|
||||||
target := strings.Trim(match[1], "<>")
|
|
||||||
if strings.Contains(target, "://") || strings.HasPrefix(target, "mailto:") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
filePart, fragment, _ := strings.Cut(target, "#")
|
|
||||||
decoded, decodeErr := url.PathUnescape(filePart)
|
|
||||||
if decodeErr != nil {
|
|
||||||
failures = append(failures, fmt.Sprintf("%s: invalid escaped link %q", relativePath(repository, path), target))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
linked := path
|
|
||||||
if decoded != "" {
|
|
||||||
linked = filepath.Clean(filepath.Join(filepath.Dir(path), filepath.FromSlash(decoded)))
|
|
||||||
}
|
|
||||||
_, statErr := os.Stat(linked)
|
|
||||||
outside := linked != repository && !strings.HasPrefix(linked, repository+string(filepath.Separator))
|
|
||||||
if os.IsNotExist(statErr) && outside && optionalSiblingSource(repository, linked) {
|
|
||||||
continue // Recorded sibling-source links are optional in isolated clones.
|
|
||||||
}
|
|
||||||
if statErr != nil {
|
|
||||||
failures = append(failures, fmt.Sprintf("%s: missing local link %q", relativePath(repository, path), target))
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
if fragment != "" && filepath.Ext(linked) == ".md" && !markdownHasFragment(linked, fragment) {
|
|
||||||
failures = append(failures, fmt.Sprintf("%s: missing fragment in %q", relativePath(repository, path), target))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
if len(failures) != 0 {
|
|
||||||
t.Fatalf("documentation link failures:\n%s", strings.Join(failures, "\n"))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func optionalSiblingSource(repository, linked string) bool {
|
|
||||||
relative, err := filepath.Rel(filepath.Dir(repository), linked)
|
|
||||||
if err != nil || relative == "." || filepath.IsAbs(relative) {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
parts := strings.Split(filepath.ToSlash(relative), "/")
|
|
||||||
if len(parts) == 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
switch strings.ToLower(parts[0]) {
|
|
||||||
case "narratio", "notarius", "weatherreporter", "seriatim":
|
|
||||||
return true
|
|
||||||
default:
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func markdownHasFragment(path, wanted string) bool {
|
|
||||||
file, err := os.Open(path)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
defer file.Close()
|
|
||||||
wanted, err = url.PathUnescape(wanted)
|
|
||||||
if err != nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
seen := make(map[string]int)
|
|
||||||
scanner := bufio.NewScanner(file)
|
|
||||||
for scanner.Scan() {
|
|
||||||
line := strings.TrimSpace(scanner.Text())
|
|
||||||
if !strings.HasPrefix(line, "#") {
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
heading := strings.TrimSpace(strings.TrimLeft(line, "#"))
|
|
||||||
base := markdownFragment(heading)
|
|
||||||
count := seen[base]
|
|
||||||
seen[base]++
|
|
||||||
fragment := base
|
|
||||||
if count > 0 {
|
|
||||||
fragment = fmt.Sprintf("%s-%d", base, count)
|
|
||||||
}
|
|
||||||
if fragment == wanted {
|
|
||||||
return true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
func markdownFragment(heading string) string {
|
|
||||||
var result strings.Builder
|
|
||||||
previousSpace := false
|
|
||||||
for _, character := range strings.ToLower(heading) {
|
|
||||||
switch {
|
|
||||||
case unicode.IsLetter(character), unicode.IsDigit(character), character == '_', character == '-':
|
|
||||||
result.WriteRune(character)
|
|
||||||
previousSpace = false
|
|
||||||
case unicode.IsSpace(character):
|
|
||||||
if result.Len() > 0 && !previousSpace {
|
|
||||||
result.WriteByte('-')
|
|
||||||
previousSpace = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return strings.TrimRight(result.String(), "-")
|
|
||||||
}
|
|
||||||
|
|
||||||
func relativePath(root, path string) string {
|
|
||||||
relative, err := filepath.Rel(root, path)
|
|
||||||
if err != nil {
|
|
||||||
return path
|
|
||||||
}
|
|
||||||
return filepath.ToSlash(relative)
|
|
||||||
}
|
|
||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "go.maximumdirect.net/configkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
func ExampleLoad() {
|
func ExampleLoad() {
|
||||||
@@ -26,7 +26,7 @@ func ExampleLoad_sequenceDeclaration() {
|
|||||||
type Config struct {
|
type Config struct {
|
||||||
Items []Item `yaml:"items"`
|
Items []Item `yaml:"items"`
|
||||||
}
|
}
|
||||||
result, err := configkit.Load[Config](configkit.Bytes("catalog.yml", []byte(`configkit:
|
result, err := configkit.Load[Config](configkit.Bytes("catalog.yml", []byte(`config:
|
||||||
sequences:
|
sequences:
|
||||||
- destination: [items]
|
- destination: [items]
|
||||||
key: id
|
key: id
|
||||||
@@ -186,7 +186,8 @@ func ExampleOverride() {
|
|||||||
},
|
},
|
||||||
Overrides: overrides,
|
Overrides: overrides,
|
||||||
})
|
})
|
||||||
fmt.Println(result.Config.Workers, result.Sources()[0].Label, err)
|
source, _ := result.Source("source-1")
|
||||||
|
fmt.Println(result.Config.Workers, source.Label, err)
|
||||||
// Output: 0 --workers <nil>
|
// Output: 0 --workers <nil>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
26
examples/consumers/config_controls_test.go
Normal file
26
examples/consumers/config_controls_test.go
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
package consumers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing/fstest"
|
||||||
|
|
||||||
|
configkit "go.maximumdirect.net/configkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Example_documentRootConfig() {
|
||||||
|
type app struct {
|
||||||
|
Config struct {
|
||||||
|
Timeout string `yaml:"timeout"`
|
||||||
|
} `yaml:"config"`
|
||||||
|
}
|
||||||
|
files := configkit.NewFilesystem("bundle", fstest.MapFS{
|
||||||
|
"main.yml": {Data: []byte("config: {imports: [{path: settings.yml, destination: [config]}]}\n")},
|
||||||
|
"settings.yml": {Data: []byte("timeout: 10s\n")},
|
||||||
|
})
|
||||||
|
result, err := configkit.Load[app](files.File("main.yml"), configkit.Options[app]{Imports: true})
|
||||||
|
if err != nil {
|
||||||
|
panic(err)
|
||||||
|
}
|
||||||
|
fmt.Println(result.Config.Config.Timeout)
|
||||||
|
// Output: 10s
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "go.maximumdirect.net/configkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Artifact is a Narratio-shaped generated output declaration.
|
// Artifact is a Narratio-shaped generated output declaration.
|
||||||
@@ -167,7 +167,7 @@ func LoadNotariusPaths(path, invocationDir string, overrides ...configkit.Overri
|
|||||||
resolveNotariusReference(configuration, resolution, invocationDir)
|
resolveNotariusReference(configuration, resolution, invocationDir)
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
Secret: func(path configkit.Path) bool { return path.String() == "/sensitive" },
|
Sensitive: func(path configkit.Path) bool { return path.String() == "/sensitive" },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -177,7 +177,7 @@ func resolveNotariusReference(configuration *NotariusPathConfig, resolution *con
|
|||||||
if configuration.Reference == "" || filepath.IsAbs(configuration.Reference) {
|
if configuration.Reference == "" || filepath.IsAbs(configuration.Reference) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
info, found := resolution.InputInfo(path)
|
info, found := resolution.EntryInfo(path)
|
||||||
if !found {
|
if !found {
|
||||||
resolution.Warning(path, "relative reference has no entry provenance")
|
resolution.Warning(path, "relative reference has no entry provenance")
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -11,8 +11,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "go.maximumdirect.net/configkit"
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit/examples/consumers"
|
"go.maximumdirect.net/configkit/examples/consumers"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Example_narratio() {
|
func Example_narratio() {
|
||||||
@@ -82,7 +82,7 @@ func Example_weatherStartup() {
|
|||||||
defer os.RemoveAll(directory)
|
defer os.RemoveAll(directory)
|
||||||
_ = os.WriteFile(filepath.Join(directory, blockedName), []byte("unused-example-value\n"), 0o600)
|
_ = os.WriteFile(filepath.Join(directory, blockedName), []byte("unused-example-value\n"), 0o600)
|
||||||
_ = os.WriteFile(filepath.Join(directory, tokenName), []byte("disposable-example-credential\n"), 0o600)
|
_ = os.WriteFile(filepath.Join(directory, tokenName), []byte("disposable-example-credential\n"), 0o600)
|
||||||
report, bootstrapErr := configkit.LoadSecretsDir(directory)
|
report, bootstrapErr := configkit.LoadSecretsIntoEnvironment(directory)
|
||||||
var reportOutput bytes.Buffer
|
var reportOutput bytes.Buffer
|
||||||
_, reportWriteErr := report.WriteTo(&reportOutput)
|
_, reportWriteErr := report.WriteTo(&reportOutput)
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"io/fs"
|
"io/fs"
|
||||||
"path"
|
"path"
|
||||||
|
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "go.maximumdirect.net/configkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
// CollectionBundleConfig is a representative application-owned collection
|
// CollectionBundleConfig is a representative application-owned collection
|
||||||
@@ -86,7 +86,7 @@ func resolveCollectionResources(configuration *CollectionBundleConfig, resolutio
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
contentPath := configkit.Keys("collections", collectionID, "prompts", promptID, "content_file")
|
contentPath := configkit.Keys("collections", collectionID, "prompts", promptID, "content_file")
|
||||||
info, found := resolution.InputInfo(contentPath)
|
info, found := resolution.EntryInfo(contentPath)
|
||||||
if !found || len(info.Origin.Locations) != 1 {
|
if !found || len(info.Origin.Locations) != 1 {
|
||||||
return fmt.Errorf("prompt resource has no single declaring source")
|
return fmt.Errorf("prompt resource has no single declaring source")
|
||||||
}
|
}
|
||||||
@@ -104,9 +104,9 @@ func resolveCollectionResources(configuration *CollectionBundleConfig, resolutio
|
|||||||
}
|
}
|
||||||
prompt.ResourceText = string(data)
|
prompt.ResourceText = string(data)
|
||||||
collection.Prompts[promptID] = prompt
|
collection.Prompts[promptID] = prompt
|
||||||
resolution.Record(
|
resolution.RecordNormalization(
|
||||||
configkit.Keys("collections", collectionID, "prompts", promptID, "resource_text"),
|
configkit.Keys("collections", collectionID, "prompts", promptID, "resource_text"),
|
||||||
configkit.ChangeNormalization, "read source-relative prompt content", contentPath,
|
"read source-relative prompt content", contentPath,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
configuration.Collections[collectionID] = collection
|
configuration.Collections[collectionID] = collection
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "go.maximumdirect.net/configkit"
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit/examples/consumers"
|
"go.maximumdirect.net/configkit/examples/consumers"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Example_inputDestination() {
|
func Example_inputDestination() {
|
||||||
@@ -60,7 +60,7 @@ func TestCollectionBundleAssemblesMountedPeersAndResources(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
versionInfo, ok := result.Metadata().Lookup(configkit.Keys("version"))
|
versionInfo, ok := result.Metadata().Lookup(configkit.Keys("version"))
|
||||||
if !ok || !versionInfo.Explicit || versionInfo.Origin.Layer != configkit.LayerBase || len(versionInfo.Origin.Locations) != 1 {
|
if !ok || !versionInfo.Explicit || versionInfo.Origin.Layer != configkit.LayerMain || len(versionInfo.Origin.Locations) != 1 {
|
||||||
t.Fatalf("main-owned version metadata = %#v, %v", versionInfo, ok)
|
t.Fatalf("main-owned version metadata = %#v, %v", versionInfo, ok)
|
||||||
}
|
}
|
||||||
collectionVersionPath := configkit.Keys("collections", "weatherreporter", "version")
|
collectionVersionPath := configkit.Keys("collections", "weatherreporter", "version")
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "go.maximumdirect.net/configkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
// FileComparisonConfig is a small application-owned shape for comparing
|
// FileComparisonConfig is a small application-owned shape for comparing
|
||||||
@@ -101,8 +101,8 @@ func resolveRollout(configuration *RolloutConfig, resolution *configkit.Resoluti
|
|||||||
url := fmt.Sprintf("https://%s:%d", endpoint.Host, endpoint.Port)
|
url := fmt.Sprintf("https://%s:%d", endpoint.Host, endpoint.Port)
|
||||||
if endpoint.URL != url {
|
if endpoint.URL != url {
|
||||||
endpoint.URL = url
|
endpoint.URL = url
|
||||||
resolution.Record(configkit.Keys("service", "endpoint", "url"),
|
resolution.RecordNormalization(configkit.Keys("service", "endpoint", "url"),
|
||||||
configkit.ChangeNormalization, "derived endpoint URL",
|
"derived endpoint URL",
|
||||||
configkit.Keys("service", "endpoint", "host"),
|
configkit.Keys("service", "endpoint", "host"),
|
||||||
configkit.Keys("service", "endpoint", "port"))
|
configkit.Keys("service", "endpoint", "port"))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,8 +7,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "go.maximumdirect.net/configkit"
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit/examples/consumers"
|
"go.maximumdirect.net/configkit/examples/consumers"
|
||||||
)
|
)
|
||||||
|
|
||||||
func ExampleCompareFiles() {
|
func ExampleCompareFiles() {
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "go.maximumdirect.net/configkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
type workingDirectoryPresetConfig struct {
|
type workingDirectoryPresetConfig struct {
|
||||||
@@ -40,6 +40,7 @@ func Example_workingDirectoryPreset() {
|
|||||||
fmt.Println(err)
|
fmt.Println(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
fmt.Println(result.Config.Name, filepath.Base(result.Sources()[0].Path))
|
source, _ := result.Source("source-1")
|
||||||
|
fmt.Println(result.Config.Name, filepath.Base(source.Path))
|
||||||
// Output: example service.yml
|
// Output: example service.yml
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
package consumers
|
package consumers
|
||||||
|
|
||||||
import configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
import configkit "go.maximumdirect.net/configkit"
|
||||||
|
|
||||||
// PresetCatalog is ordinary application data; no inheritance fields or
|
// PresetCatalog is ordinary application data; no inheritance fields or
|
||||||
// registration hooks are required in the typed configuration.
|
// registration hooks are required in the typed configuration.
|
||||||
|
|||||||
@@ -3,8 +3,8 @@ package consumers_test
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "go.maximumdirect.net/configkit"
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit/examples/consumers"
|
"go.maximumdirect.net/configkit/examples/consumers"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestPresetCatalogOneLoadAndDerivation(t *testing.T) {
|
func TestPresetCatalogOneLoadAndDerivation(t *testing.T) {
|
||||||
|
|||||||
@@ -6,10 +6,10 @@ import (
|
|||||||
"io/fs"
|
"io/fs"
|
||||||
"path"
|
"path"
|
||||||
|
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "go.maximumdirect.net/configkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed testdata/native-source testdata/layered-bundle testdata/collection-bundle testdata/ordinary-imports testdata/source-version
|
//go:embed testdata/native-source testdata/layered-bundle testdata/collection-bundle testdata/ordinary-imports testdata/source-version testdata/main-relative-bundle
|
||||||
var bundledFiles embed.FS
|
var bundledFiles embed.FS
|
||||||
|
|
||||||
// BundledConfig is a small configuration loaded directly from an embedded file.
|
// BundledConfig is a small configuration loaded directly from an embedded file.
|
||||||
|
|||||||
@@ -6,8 +6,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "go.maximumdirect.net/configkit"
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit/examples/consumers"
|
"go.maximumdirect.net/configkit/examples/consumers"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Example_nativeFilesystem() {
|
func Example_nativeFilesystem() {
|
||||||
@@ -26,7 +26,7 @@ func Example_rootRelativeImports() {
|
|||||||
fmt.Println(err)
|
fmt.Println(err)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
source := result.Sources()[2]
|
source, _ := result.Source("source-3")
|
||||||
fmt.Println(result.Config.Name, result.Config.Version, result.Config.Prompts["brief"].Summary, source.Destination.String(), err)
|
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>
|
// Output: Root import example 1.0 A root-loaded prompt. /prompts <nil>
|
||||||
}
|
}
|
||||||
@@ -68,7 +68,7 @@ func Example_sourceVersionAdoption() {
|
|||||||
}
|
}
|
||||||
checks, redacted := 0, true
|
checks, redacted := 0, true
|
||||||
for _, source := range result.Sources() {
|
for _, source := range result.Sources() {
|
||||||
if check := source.VersionCheck; check != nil {
|
if check := source.VersionEvidence; check != nil {
|
||||||
checks++
|
checks++
|
||||||
redacted = redacted && check.Redacted && check.Version.Text == ""
|
redacted = redacted && check.Redacted && check.Version.Text == ""
|
||||||
}
|
}
|
||||||
@@ -95,7 +95,7 @@ func TestVersionedServiceModesAndMountedProtection(t *testing.T) {
|
|||||||
t.Fatalf("versioned service sources = %#v", sources)
|
t.Fatalf("versioned service sources = %#v", sources)
|
||||||
}
|
}
|
||||||
for _, source := range sources {
|
for _, source := range sources {
|
||||||
check := source.VersionCheck
|
check := source.VersionEvidence
|
||||||
if check == nil || check.Path.String() != "/version" || !check.Version.Present ||
|
if check == nil || check.Path.String() != "/version" || !check.Version.Present ||
|
||||||
!check.Redacted || check.Version.Text != "" {
|
!check.Redacted || check.Version.Text != "" {
|
||||||
t.Fatalf("safe source version evidence = %#v", sources)
|
t.Fatalf("safe source version evidence = %#v", sources)
|
||||||
@@ -119,7 +119,7 @@ func TestCatalogRetainsPresenceAndSourceContext(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, key := range []string{"enabled", "workers"} {
|
for _, key := range []string{"enabled", "workers"} {
|
||||||
info, found := result.Metadata().Lookup(configkit.Keys(key))
|
info, found := result.Metadata().Lookup(configkit.Keys(key))
|
||||||
if !found || !info.Explicit || info.Origin.Layer != configkit.LayerBase {
|
if !found || !info.Explicit || info.Origin.Layer != configkit.LayerMain {
|
||||||
t.Fatalf("%s presence = %#v, %v", key, info, found)
|
t.Fatalf("%s presence = %#v, %v", key, info, found)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
47
examples/consumers/main_relative.go
Normal file
47
examples/consumers/main_relative.go
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
package consumers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io/fs"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
configkit "go.maximumdirect.net/configkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
// CompanionConfig demonstrates independently bundled and operator prompt tiers.
|
||||||
|
type CompanionConfig struct {
|
||||||
|
Mode string `yaml:"mode"`
|
||||||
|
Prompts []CompanionPrompt `yaml:"prompts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type CompanionPrompt struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
Text string `yaml:"text"`
|
||||||
|
Note string `yaml:"note"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadCompanionConfig discovers a main file and binds operator imports and the
|
||||||
|
// selected overlay to its directory while retaining the embedded catalog tier.
|
||||||
|
func LoadCompanionConfig(mainPath, overlay string) (*configkit.Result[CompanionConfig], error) {
|
||||||
|
files, err := fs.Sub(bundledFiles, "testdata/main-relative-bundle")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
bundle := configkit.NewFilesystem("bundled catalog", files)
|
||||||
|
absolute, err := filepath.Abs(mainPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
declarations := map[string]any{"config": map[string]any{"imports": []any{
|
||||||
|
map[string]any{"path": "prompts.yml", "destination": []any{"prompts"}, "placement": "sequence-item"},
|
||||||
|
map[string]any{"dir": "operator", "recursive": true, "pattern": []any{"*.yml", "*.yaml"}, "destination": []any{"prompts"}, "placement": "sequence-item"},
|
||||||
|
}}}
|
||||||
|
main := configkit.SearchConfig("", configkit.Searches(configkit.SearchPaths(absolute)), configkit.SearchOptions{Required: true})
|
||||||
|
return configkit.LoadLayered(configkit.Input{Source: main}, configkit.LayeredOptions[CompanionConfig]{
|
||||||
|
Underlays: []configkit.Underlay{
|
||||||
|
{Inputs: []configkit.Input{{Source: bundle.File("catalog.yml")}}},
|
||||||
|
{Inputs: []configkit.Input{{Source: configkit.Mapping("operator imports", declarations), Imports: true, MainRelative: configkit.MainRelative()}}},
|
||||||
|
},
|
||||||
|
Overlay: overlay,
|
||||||
|
Definition: configkit.Definition[CompanionConfig]{SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("prompts"), "id")}},
|
||||||
|
})
|
||||||
|
}
|
||||||
86
examples/consumers/main_relative_test.go
Normal file
86
examples/consumers/main_relative_test.go
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
package consumers_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
configkit "go.maximumdirect.net/configkit"
|
||||||
|
"go.maximumdirect.net/configkit/examples/consumers"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Example_mainRelativeCompanions() {
|
||||||
|
result, err := consumers.LoadCompanionConfig(fixture("main-relative", "main.yml"), "live")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Println(result.Config.Mode, result.Config.Prompts)
|
||||||
|
// Output: overlay [{shared operator replacement } {bundled bundled text } {local local text transitive detail}]
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMainRelativeStrictPeers(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
for name, contents := range map[string]string{
|
||||||
|
"main.yml": "mode: main\n", "prompts.yml": "id: duplicate\ntext: first\n", "operator/second.yml": "id: duplicate\ntext: second\n",
|
||||||
|
} {
|
||||||
|
file := filepath.Join(root, name)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(file), 0700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(file, []byte(contents), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_, err := consumers.LoadCompanionConfig(filepath.Join(root, "main.yml"), "")
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("duplicate peer identities succeeded")
|
||||||
|
}
|
||||||
|
for _, d := range err.(*configkit.LoadError).Diagnostics() {
|
||||||
|
if d.Code == configkit.CodeComposeConflict {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("diagnostics = %#v", err.(*configkit.LoadError).Diagnostics())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMainRelativeOptionalResources(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
file, directory, empty bool
|
||||||
|
count int
|
||||||
|
}{
|
||||||
|
{"neither", false, false, false, 2}, {"file", true, false, false, 3},
|
||||||
|
{"directory", false, true, false, 3}, {"both", true, true, false, 4}, {"empty directory", false, false, true, 2},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "main.yml"), []byte("mode: main\n"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if tc.file {
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "prompts.yml"), []byte("id: file\ntext: file\n"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tc.directory || tc.empty {
|
||||||
|
if err := os.Mkdir(filepath.Join(root, "operator"), 0700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if tc.directory {
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "operator", "item.yaml"), []byte("id: directory\ntext: directory\n"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result, err := consumers.LoadCompanionConfig(filepath.Join(root, "main.yml"), "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%v: %#v", err, err.(*configkit.LoadError).Diagnostics())
|
||||||
|
}
|
||||||
|
if len(result.Config.Prompts) != tc.count {
|
||||||
|
t.Fatalf("prompts = %#v", result.Config.Prompts)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ package consumers
|
|||||||
import (
|
import (
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "go.maximumdirect.net/configkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
// PromptCatalogConfig is an application-owned prompt collection shape.
|
// PromptCatalogConfig is an application-owned prompt collection shape.
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "go.maximumdirect.net/configkit"
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit/examples/consumers"
|
"go.maximumdirect.net/configkit/examples/consumers"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Example_identifiedPromptCatalog() {
|
func Example_identifiedPromptCatalog() {
|
||||||
@@ -24,8 +24,8 @@ func Example_identifiedPromptCatalog() {
|
|||||||
}
|
}
|
||||||
emptySearches := 0
|
emptySearches := 0
|
||||||
for _, source := range result.Sources() {
|
for _, source := range result.Sources() {
|
||||||
for _, selection := range source.ImportSelections {
|
for _, selection := range source.EmptyImportSelections {
|
||||||
if selection.Selector == configkit.ImportSelectorDirectory && selection.Outcome == configkit.ImportSelectionNone {
|
if selection.Selector == configkit.ImportSelectorDirectory {
|
||||||
emptySearches++
|
emptySearches++
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "go.maximumdirect.net/configkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SequenceCatalogConfig is a source-backed prompt catalog with an application
|
// SequenceCatalogConfig is a source-backed prompt catalog with an application
|
||||||
@@ -43,7 +43,7 @@ func LoadSequenceCatalog(mainPath, overlayName, runLabel string) (*configkit.Res
|
|||||||
filesystem := configkit.NewFilesystem("sequence catalog", os.DirFS(root))
|
filesystem := configkit.NewFilesystem("sequence catalog", os.DirFS(root))
|
||||||
filesystemContext := &configkit.FilesystemContext{BaseDir: ".", Root: ".", FS: &filesystem}
|
filesystemContext := &configkit.FilesystemContext{BaseDir: ".", Root: ".", FS: &filesystem}
|
||||||
mainSearch := configkit.SearchConfig("", configkit.Searches(
|
mainSearch := configkit.SearchConfig("", configkit.Searches(
|
||||||
configkit.Paths(absoluteMain),
|
configkit.SearchPaths(absoluteMain),
|
||||||
configkit.SearchDirectory(filepath.Join(root, "fallback"), configkit.DirectorySearchOptions{
|
configkit.SearchDirectory(filepath.Join(root, "fallback"), configkit.DirectorySearchOptions{
|
||||||
Filenames: []configkit.FilenameGroup{configkit.CustomFilenames(filepath.Base(absoluteMain))},
|
Filenames: []configkit.FilenameGroup{configkit.CustomFilenames(filepath.Base(absoluteMain))},
|
||||||
}),
|
}),
|
||||||
@@ -54,7 +54,7 @@ func LoadSequenceCatalog(mainPath, overlayName, runLabel string) (*configkit.Res
|
|||||||
},
|
},
|
||||||
Resolve: func(configuration *SequenceCatalogConfig, resolution *configkit.Resolution) error {
|
Resolve: func(configuration *SequenceCatalogConfig, resolution *configkit.Resolution) error {
|
||||||
configuration.ResolvedLabel = "for:" + configuration.RunLabel
|
configuration.ResolvedLabel = "for:" + configuration.RunLabel
|
||||||
resolution.Record(configkit.Keys("resolved_label"), configkit.ChangeNormalization,
|
resolution.RecordNormalization(configkit.Keys("resolved_label"),
|
||||||
"derive the resolved label from the selected run label", configkit.Keys("run_label"))
|
"derive the resolved label from the selected run label", configkit.Keys("run_label"))
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -8,8 +8,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "go.maximumdirect.net/configkit"
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit/examples/consumers"
|
"go.maximumdirect.net/configkit/examples/consumers"
|
||||||
)
|
)
|
||||||
|
|
||||||
func Example_sequenceCatalog() {
|
func Example_sequenceCatalog() {
|
||||||
@@ -171,7 +171,7 @@ collections:
|
|||||||
|
|
||||||
func Example_yamlDeclaredIdentityOnly() {
|
func Example_yamlDeclaredIdentityOnly() {
|
||||||
result, err := configkit.Load(configkit.Bytes("YAML identity only", []byte(`
|
result, err := configkit.Load(configkit.Bytes("YAML identity only", []byte(`
|
||||||
configkit:
|
config:
|
||||||
sequences:
|
sequences:
|
||||||
- destination: [prompts]
|
- destination: [prompts]
|
||||||
key: id
|
key: id
|
||||||
@@ -193,7 +193,7 @@ prompts:
|
|||||||
|
|
||||||
func TestYAMLDeclaredIdentityDoesNotRegisterFinalTypedConstraint(t *testing.T) {
|
func TestYAMLDeclaredIdentityDoesNotRegisterFinalTypedConstraint(t *testing.T) {
|
||||||
result, err := configkit.Load(configkit.Bytes("YAML identity only", []byte(`
|
result, err := configkit.Load(configkit.Bytes("YAML identity only", []byte(`
|
||||||
configkit:
|
config:
|
||||||
sequences:
|
sequences:
|
||||||
- destination: [prompts]
|
- destination: [prompts]
|
||||||
key: id
|
key: id
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
|
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "go.maximumdirect.net/configkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
// VersionedServiceConfig demonstrates a mounted main input whose source-local
|
// VersionedServiceConfig demonstrates a mounted main input whose source-local
|
||||||
@@ -47,7 +47,7 @@ func LoadVersionedService(mode configkit.SourceVersionMode, overrides ...configk
|
|||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
Secret: func(path configkit.Path) bool {
|
Sensitive: func(path configkit.Path) bool {
|
||||||
return path.String() == "/service/version"
|
return path.String() == "/service/version"
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
configkit:
|
config:
|
||||||
imports:
|
imports:
|
||||||
- path: metadata.yml
|
- path: metadata.yml
|
||||||
destination: []
|
destination: []
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
configkit:
|
config:
|
||||||
imports:
|
imports:
|
||||||
- path: version.yml
|
- path: version.yml
|
||||||
description: Imported collection metadata.
|
description: Imported collection metadata.
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
configkit:
|
config:
|
||||||
imports:
|
imports:
|
||||||
- path: compact.yml
|
- path: compact.yml
|
||||||
- path: weather.yml
|
- path: weather.yml
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
configkit:
|
config:
|
||||||
imports:
|
imports:
|
||||||
- path: brief.yml
|
- path: brief.yml
|
||||||
- path: custom.yml
|
- path: custom.yml
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
configkit:
|
config:
|
||||||
imports:
|
imports:
|
||||||
- path: ../shared.yml
|
- path: ../shared.yml
|
||||||
enabled: true
|
enabled: true
|
||||||
|
|||||||
7
examples/consumers/testdata/main-relative-bundle/catalog.yml
vendored
Normal file
7
examples/consumers/testdata/main-relative-bundle/catalog.yml
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
mode: embedded
|
||||||
|
prompts:
|
||||||
|
- id: shared
|
||||||
|
text: embedded text
|
||||||
|
note: replaced
|
||||||
|
- id: bundled
|
||||||
|
text: bundled text
|
||||||
1
examples/consumers/testdata/main-relative/main.yml
vendored
Normal file
1
examples/consumers/testdata/main-relative/main.yml
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
mode: main
|
||||||
5
examples/consumers/testdata/main-relative/operator/nested/one.yaml
vendored
Normal file
5
examples/consumers/testdata/main-relative/operator/nested/one.yaml
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
config:
|
||||||
|
imports:
|
||||||
|
- path: ../../shared/extra.yml
|
||||||
|
id: local
|
||||||
|
text: local text
|
||||||
1
examples/consumers/testdata/main-relative/overlays/live.yml
vendored
Normal file
1
examples/consumers/testdata/main-relative/overlays/live.yml
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
mode: overlay
|
||||||
2
examples/consumers/testdata/main-relative/prompts.yml
vendored
Normal file
2
examples/consumers/testdata/main-relative/prompts.yml
vendored
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
id: shared
|
||||||
|
text: operator replacement
|
||||||
1
examples/consumers/testdata/main-relative/shared/extra.yml
vendored
Normal file
1
examples/consumers/testdata/main-relative/shared/extra.yml
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
note: transitive detail
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
configkit:
|
config:
|
||||||
imports:
|
imports:
|
||||||
- path: artifacts.yml
|
- path: artifacts.yml
|
||||||
poll_interval: 1m
|
poll_interval: 1m
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
configkit:
|
config:
|
||||||
imports:
|
imports:
|
||||||
- path: declarations/path.yml
|
- path: declarations/path.yml
|
||||||
sensitive: PATH_REFERENCE_SECRET_CANARY
|
sensitive: PATH_REFERENCE_SECRET_CANARY
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
configkit:
|
config:
|
||||||
imports:
|
imports:
|
||||||
- path: metadata.yml
|
- path: metadata.yml
|
||||||
- path: prompts/brief.yml
|
- path: prompts/brief.yml
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
configkit:
|
config:
|
||||||
sequences:
|
sequences:
|
||||||
- destination: [prompts]
|
- destination: [prompts]
|
||||||
key: id
|
key: id
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
configkit:
|
config:
|
||||||
sequences:
|
sequences:
|
||||||
- destination: [prompts]
|
- destination: [prompts]
|
||||||
key: id
|
key: id
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
configkit:
|
config:
|
||||||
imports:
|
imports:
|
||||||
- path: helpers/curated-id.yml
|
- path: helpers/curated-id.yml
|
||||||
name: Curated summary
|
name: Curated summary
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
configkit:
|
config:
|
||||||
imports:
|
imports:
|
||||||
- path: prompts/required.yml
|
- path: prompts/required.yml
|
||||||
required: true
|
required: true
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
run_label: source-default
|
run_label: source-default
|
||||||
configkit:
|
config:
|
||||||
imports:
|
imports:
|
||||||
- dir: fragments
|
- dir: fragments
|
||||||
pattern: '*.yml'
|
pattern: '*.yml'
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
configkit:
|
config:
|
||||||
sequences:
|
sequences:
|
||||||
- destination: [prompts]
|
- destination: [prompts]
|
||||||
key: id
|
key: id
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
configkit:
|
config:
|
||||||
sequences:
|
sequences:
|
||||||
- destination: [prompts]
|
- destination: [prompts]
|
||||||
key: id
|
key: id
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
configkit:
|
config:
|
||||||
sequences:
|
sequences:
|
||||||
- destination: [prompts]
|
- destination: [prompts]
|
||||||
key: id
|
key: id
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
configkit:
|
config:
|
||||||
sequences:
|
sequences:
|
||||||
- destination: [prompts]
|
- destination: [prompts]
|
||||||
key: id
|
key: id
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "go.maximumdirect.net/configkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -23,7 +23,7 @@ var (
|
|||||||
|
|
||||||
func TestFacadeValuesRemainRootOwned(t *testing.T) {
|
func TestFacadeValuesRemainRootOwned(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
const packagePath = "gitea.maximumdirect.net/eric/ConfigKit"
|
const packagePath = "go.maximumdirect.net/configkit"
|
||||||
for _, candidate := range []reflect.Type{
|
for _, candidate := range []reflect.Type{
|
||||||
reflect.TypeFor[configkit.Path](), reflect.TypeFor[configkit.Source](),
|
reflect.TypeFor[configkit.Path](), reflect.TypeFor[configkit.Source](),
|
||||||
reflect.TypeFor[configkit.Secret](), reflect.TypeFor[configkit.Diagnostic](),
|
reflect.TypeFor[configkit.Secret](), reflect.TypeFor[configkit.Diagnostic](),
|
||||||
@@ -140,7 +140,7 @@ func TestFacadeSecretsBootstrapUsesFacadeRecords(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
report, err := configkit.LoadSecretsDir(directory)
|
report, err := configkit.LoadSecretsIntoEnvironment(directory)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -164,7 +164,7 @@ func TestFacadeSecretsBootstrapUsesFacadeRecords(t *testing.T) {
|
|||||||
t.Fatalf("report rendering = %q, %v", rendered.String(), err)
|
t.Fatalf("report rendering = %q, %v", rendered.String(), err)
|
||||||
}
|
}
|
||||||
|
|
||||||
failedReport, err := configkit.LoadSecretsDir("")
|
failedReport, err := configkit.LoadSecretsIntoEnvironment("")
|
||||||
var secretsError *configkit.SecretsError
|
var secretsError *configkit.SecretsError
|
||||||
if failedReport == nil || !errors.As(err, &secretsError) || len(secretsError.Diagnostics()) != 1 || secretsError.Diagnostics()[0].Code != configkit.CodeSecretsInvalidPath {
|
if failedReport == nil || !errors.As(err, &secretsError) || len(secretsError.Diagnostics()) != 1 || secretsError.Diagnostics()[0].Code != configkit.CodeSecretsInvalidPath {
|
||||||
t.Fatalf("bootstrap failure = %#v / %#v", failedReport, err)
|
t.Fatalf("bootstrap failure = %#v / %#v", failedReport, err)
|
||||||
@@ -203,7 +203,7 @@ func TestFacadeLoadBridgesLifecycleCallbacks(t *testing.T) {
|
|||||||
if !resolution.IsExplicit(configkit.Keys("input")) {
|
if !resolution.IsExplicit(configkit.Keys("input")) {
|
||||||
resolution.Error(configkit.Keys("input"), "input is required")
|
resolution.Error(configkit.Keys("input"), "input is required")
|
||||||
}
|
}
|
||||||
input, ok := resolution.InputInfo(configkit.Keys("input"))
|
input, ok := resolution.EntryInfo(configkit.Keys("input"))
|
||||||
if !ok || !input.Explicit {
|
if !ok || !input.Explicit {
|
||||||
resolution.Error(configkit.Keys("input"), "missing input provenance")
|
resolution.Error(configkit.Keys("input"), "missing input provenance")
|
||||||
}
|
}
|
||||||
@@ -245,6 +245,8 @@ func TestFacadeLoadBridgesLifecycleCallbacks(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, context := range []func(){
|
for _, context := range []func(){
|
||||||
func() { retainedResolution.IsExplicit(configkit.Keys("input")) },
|
func() { retainedResolution.IsExplicit(configkit.Keys("input")) },
|
||||||
|
func() { retainedResolution.RecordDerivedDefault(configkit.Keys("input"), "too late") },
|
||||||
|
func() { retainedResolution.RecordNormalization(configkit.Keys("input"), "too late") },
|
||||||
func() { retainedValidation.Warning(configkit.Keys("input"), "too late") },
|
func() { retainedValidation.Warning(configkit.Keys("input"), "too late") },
|
||||||
} {
|
} {
|
||||||
func() {
|
func() {
|
||||||
|
|||||||
@@ -5,10 +5,9 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"reflect"
|
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "go.maximumdirect.net/configkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
var (
|
var (
|
||||||
@@ -27,23 +26,6 @@ type facadeCompatibilityConfig struct {
|
|||||||
func TestFacadeCompatibility(t *testing.T) {
|
func TestFacadeCompatibility(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
const packagePath = "gitea.maximumdirect.net/eric/ConfigKit"
|
|
||||||
for _, candidate := range []struct {
|
|
||||||
name string
|
|
||||||
type_ reflect.Type
|
|
||||||
}{
|
|
||||||
{"Path", reflect.TypeFor[configkit.Path]()},
|
|
||||||
{"Source", reflect.TypeFor[configkit.Source]()},
|
|
||||||
{"Secret", reflect.TypeFor[configkit.Secret]()},
|
|
||||||
{"Definition", reflect.TypeFor[configkit.Definition[facadeCompatibilityConfig]]()},
|
|
||||||
{"Options", reflect.TypeFor[configkit.Options[facadeCompatibilityConfig]]()},
|
|
||||||
{"Result", reflect.TypeFor[configkit.Result[facadeCompatibilityConfig]]()},
|
|
||||||
} {
|
|
||||||
if candidate.type_.PkgPath() != packagePath {
|
|
||||||
t.Errorf("%s belongs to %q, want %q", candidate.name, candidate.type_.PkgPath(), packagePath)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
constructed := configkit.Options[facadeCompatibilityConfig]{
|
constructed := configkit.Options[facadeCompatibilityConfig]{
|
||||||
Definition: configkit.Definition[facadeCompatibilityConfig]{},
|
Definition: configkit.Definition[facadeCompatibilityConfig]{},
|
||||||
Filesystem: &configkit.FilesystemContext{BaseDir: "configs", Root: "configs"},
|
Filesystem: &configkit.FilesystemContext{BaseDir: "configs", Root: "configs"},
|
||||||
@@ -56,11 +38,6 @@ func TestFacadeCompatibility(t *testing.T) {
|
|||||||
if got := constructed.Overrides[0].Path.String(); got != "/value" {
|
if got := constructed.Overrides[0].Path.String(); got != "/value" {
|
||||||
t.Fatalf("constructed override path = %q, want /value", got)
|
t.Fatalf("constructed override path = %q, want /value", got)
|
||||||
}
|
}
|
||||||
publicResult := configkit.Result[facadeCompatibilityConfig]{Config: facadeCompatibilityConfig{Value: "constructed"}}
|
|
||||||
if publicResult.Config.Value != "constructed" {
|
|
||||||
t.Fatal("constructed result lost its public configuration")
|
|
||||||
}
|
|
||||||
|
|
||||||
result, err := configkit.Load(configkit.Bytes("facade.yml", []byte("value: input\n")), configkit.Options[facadeCompatibilityConfig]{
|
result, err := configkit.Load(configkit.Bytes("facade.yml", []byte("value: input\n")), configkit.Options[facadeCompatibilityConfig]{
|
||||||
Definition: configkit.Definition[facadeCompatibilityConfig]{
|
Definition: configkit.Definition[facadeCompatibilityConfig]{
|
||||||
Resolve: func(configuration *facadeCompatibilityConfig, resolution *configkit.Resolution) error {
|
Resolve: func(configuration *facadeCompatibilityConfig, resolution *configkit.Resolution) error {
|
||||||
|
|||||||
108
facade_config_directives_test.go
Normal file
108
facade_config_directives_test.go
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
package configkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
|
||||||
|
configkit "go.maximumdirect.net/configkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDocumentRootControlsAndNestedApplicationConfig(t *testing.T) {
|
||||||
|
type settings struct {
|
||||||
|
Imports string `yaml:"imports"`
|
||||||
|
}
|
||||||
|
type app struct {
|
||||||
|
Service struct {
|
||||||
|
Config settings `yaml:"config"`
|
||||||
|
} `yaml:"service"`
|
||||||
|
}
|
||||||
|
result, err := configkit.Load[app](configkit.Bytes("main", []byte("config: {sequences: []}\nservice: {config: {imports: ordinary}}\n")))
|
||||||
|
if err != nil || result.Config.Service.Config.Imports != "ordinary" {
|
||||||
|
t.Fatalf("result = %#v, error = %v", result, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMountedConfigDataKeepsControlEvidenceAndHiding(t *testing.T) {
|
||||||
|
type settings struct {
|
||||||
|
Imports string `yaml:"imports"`
|
||||||
|
Token string `yaml:"token" configkit:"secret"`
|
||||||
|
}
|
||||||
|
type app struct {
|
||||||
|
Config settings `yaml:"config"`
|
||||||
|
}
|
||||||
|
fs := configkit.NewFilesystem("bundle", fstest.MapFS{
|
||||||
|
"main.yml": {Data: []byte("config: {imports: [{path: missing.yml}, {path: data.yml, destination: [config]}]}\n")},
|
||||||
|
"data.yml": {Data: []byte("imports: ordinary\ntoken: disposable-canary\n")},
|
||||||
|
})
|
||||||
|
result, err := configkit.Load[app](fs.File("main.yml"), configkit.Options[app]{Imports: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result.Config.Config.Imports != "ordinary" {
|
||||||
|
t.Fatalf("config = %#v", result.Config)
|
||||||
|
}
|
||||||
|
sources := result.Sources()
|
||||||
|
if len(sources) != 2 || len(sources[0].EmptyImportSelections) != 1 || sources[0].EmptyImportSelections[0].Path.String() != "/config/imports/0" {
|
||||||
|
t.Fatalf("sources = %#v", sources)
|
||||||
|
}
|
||||||
|
if _, ok := result.Metadata().Lookup(configkit.Keys("config", "imports")); !ok {
|
||||||
|
t.Fatal("missing application value")
|
||||||
|
}
|
||||||
|
if _, ok := result.Explain(configkit.Keys("config", "imports")); !ok {
|
||||||
|
t.Fatal("missing explanation")
|
||||||
|
}
|
||||||
|
var output strings.Builder
|
||||||
|
if err := result.WriteEffective(&output); err != nil || strings.Contains(output.String(), "disposable-canary") || !strings.Contains(output.String(), "ordinary") {
|
||||||
|
t.Fatalf("effective output = %q, %v", output.String(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOldControlSpellingsAreOrdinaryData(t *testing.T) {
|
||||||
|
body := "configkit: {imports: [{path: nonexistent.yml}]}\n_configkit: ordinary\n"
|
||||||
|
result, err := configkit.Load[map[string]any](configkit.Bytes("old", []byte(body)))
|
||||||
|
if err != nil || result.Config["configkit"] == nil || result.Config["_configkit"] != "ordinary" {
|
||||||
|
t.Fatalf("result = %#v, error = %v", result, err)
|
||||||
|
}
|
||||||
|
if _, err := configkit.Load[struct{}](configkit.Bytes("strict", []byte(body))); err == nil {
|
||||||
|
t.Fatal("strict schema accepted unknown data")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDefaultsOverridesAndResolutionAtConfigRoot(t *testing.T) {
|
||||||
|
type settings struct {
|
||||||
|
Enabled bool `yaml:"enabled"`
|
||||||
|
Count int `yaml:"count"`
|
||||||
|
}
|
||||||
|
type app struct {
|
||||||
|
Config settings `yaml:"config"`
|
||||||
|
}
|
||||||
|
entryExplicit := false
|
||||||
|
entryCountExplicit := false
|
||||||
|
result, err := configkit.Load[app](configkit.NoSource(), configkit.Options[app]{
|
||||||
|
Definition: configkit.Definition[app]{
|
||||||
|
Defaults: func() app { return app{Config: settings{Enabled: true, Count: 2}} },
|
||||||
|
Resolve: func(value *app, r *configkit.Resolution) error {
|
||||||
|
info, ok := r.EntryInfo(configkit.Keys("config", "enabled"))
|
||||||
|
entryExplicit = ok && info.Explicit && info.Origin.Layer == configkit.LayerCLI
|
||||||
|
entryCountExplicit = r.IsExplicit(configkit.Keys("config", "count"))
|
||||||
|
value.Config.Count = 7
|
||||||
|
r.RecordNormalization(configkit.Keys("config", "count"), "normalize count")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Overrides: []configkit.Override{{Path: configkit.Keys("config", "enabled"), Value: false, Flag: "--enabled"}},
|
||||||
|
})
|
||||||
|
if err != nil || result == nil || !entryExplicit || entryCountExplicit || result.Config.Config.Enabled || result.Config.Config.Count != 7 {
|
||||||
|
t.Fatalf("result = %#v, entry explicit = %t, error = %v", result, entryExplicit, err)
|
||||||
|
}
|
||||||
|
if info, ok := result.Metadata().Lookup(configkit.Keys("config", "enabled")); !ok || !info.Explicit || info.Origin.Layer != configkit.LayerCLI {
|
||||||
|
t.Fatalf("final enabled info = %#v, %t", info, ok)
|
||||||
|
}
|
||||||
|
if info, ok := result.Metadata().Lookup(configkit.Keys("config", "count")); !ok || info.Origin.Layer != configkit.LayerResolution {
|
||||||
|
t.Fatalf("final count info = %#v, %t", info, ok)
|
||||||
|
}
|
||||||
|
if _, ok := result.Explain(configkit.Keys("config", "count")); !ok {
|
||||||
|
t.Fatal("missing resolution explanation")
|
||||||
|
}
|
||||||
|
}
|
||||||
41
facade_container_origin_test.go
Normal file
41
facade_container_origin_test.go
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
package configkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
|
||||||
|
configkit "go.maximumdirect.net/configkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOverlayExplanationUsesQueryScopedOrigin(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
filesystem := configkit.NewFilesystem("origin", fstest.MapFS{
|
||||||
|
"main.yml": {Data: []byte("a: base\ngroup: {x: X, y: Y}\n")},
|
||||||
|
"overlays/dev.yml": {Data: []byte("a: overlay\ngroup: {x: O}\n")},
|
||||||
|
})
|
||||||
|
result, err := configkit.Load[map[string]any](filesystem.File("main.yml"), configkit.Options[map[string]any]{Overlay: "dev"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, test := range []struct {
|
||||||
|
path configkit.Path
|
||||||
|
line, column int
|
||||||
|
}{
|
||||||
|
{configkit.Keys(), 1, 1},
|
||||||
|
{configkit.Keys("group"), 2, 8},
|
||||||
|
{configkit.Keys("group", "x"), 2, 12},
|
||||||
|
} {
|
||||||
|
explanation, found := result.Explain(test.path)
|
||||||
|
if !found || len(explanation.History) == 0 {
|
||||||
|
t.Fatalf("missing explanation for %s", test.path)
|
||||||
|
}
|
||||||
|
origin := explanation.History[len(explanation.History)-1].Origin
|
||||||
|
if origin.Layer != configkit.LayerOverlay || len(origin.Locations) != 1 {
|
||||||
|
t.Fatalf("overlay origin for %s = %#v", test.path, origin)
|
||||||
|
}
|
||||||
|
location := origin.Locations[0]
|
||||||
|
if location.SourceID != "source-2" || location.Line != test.line || location.Column != test.column {
|
||||||
|
t.Fatalf("overlay location for %s = %#v; want line %d column %d", test.path, location, test.line, test.column)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,7 +3,7 @@ package configkit_test
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "go.maximumdirect.net/configkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestFacadeInactiveContextsPreserveClosedFailure(t *testing.T) {
|
func TestFacadeInactiveContextsPreserveClosedFailure(t *testing.T) {
|
||||||
@@ -24,10 +24,11 @@ func TestFacadeInactiveContextsPreserveClosedFailure(t *testing.T) {
|
|||||||
call func()
|
call func()
|
||||||
}{
|
}{
|
||||||
{"IsExplicit", func() { state.resolution.IsExplicit(path) }},
|
{"IsExplicit", func() { state.resolution.IsExplicit(path) }},
|
||||||
{"InputInfo", func() { state.resolution.InputInfo(path) }},
|
{"EntryInfo", func() { state.resolution.EntryInfo(path) }},
|
||||||
{"Source", func() { state.resolution.Source("source") }},
|
{"Source", func() { state.resolution.Source("source") }},
|
||||||
{"SourceContext", func() { state.resolution.SourceContext("source") }},
|
{"SourceContext", func() { state.resolution.SourceContext("source") }},
|
||||||
{"Record", func() { state.resolution.Record(path, configkit.ChangeNormalization, "reason") }},
|
{"RecordDerivedDefault", func() { state.resolution.RecordDerivedDefault(path, "reason") }},
|
||||||
|
{"RecordNormalization", func() { state.resolution.RecordNormalization(path, "reason") }},
|
||||||
{"Resolution.Error", func() { state.resolution.Error(path, "message") }},
|
{"Resolution.Error", func() { state.resolution.Error(path, "message") }},
|
||||||
{"Resolution.Warning", func() { state.resolution.Warning(path, "message") }},
|
{"Resolution.Warning", func() { state.resolution.Warning(path, "message") }},
|
||||||
{"Default", func() { configkit.Default(state.resolution, path, &target, 1, "reason") }},
|
{"Default", func() { configkit.Default(state.resolution, path, &target, 1, "reason") }},
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"testing/fstest"
|
"testing/fstest"
|
||||||
|
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "go.maximumdirect.net/configkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestFacadeInputDestinationsAcrossSourceKinds(t *testing.T) {
|
func TestFacadeInputDestinationsAcrossSourceKinds(t *testing.T) {
|
||||||
@@ -109,7 +109,7 @@ func TestFacadeDestinationRootEmptyMappingNoSourceAndInvalidPaths(t *testing.T)
|
|||||||
wantReads int
|
wantReads int
|
||||||
}{
|
}{
|
||||||
{name: "index segment", source: configkit.Reader("unread", reader), destination: configkit.Keys("catalog").Index(0)},
|
{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("extends")},
|
||||||
{name: "reserved extends key", source: configkit.NoSource(), destination: configkit.Keys("catalog", "extends")},
|
{name: "reserved extends key", source: configkit.NoSource(), destination: configkit.Keys("catalog", "extends")},
|
||||||
} {
|
} {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
@@ -129,7 +129,7 @@ func TestFacadeImportedSourcesRetainInputDestination(t *testing.T) {
|
|||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := filepath.Join(directory, "main.yml")
|
mainPath := filepath.Join(directory, "main.yml")
|
||||||
fragmentPath := filepath.Join(directory, "fragment.yml")
|
fragmentPath := filepath.Join(directory, "fragment.yml")
|
||||||
if err := os.WriteFile(mainPath, []byte("configkit: {imports: [{path: fragment.yml}]}\nmain: present\n"), 0o600); err != nil {
|
if err := os.WriteFile(mainPath, []byte("config: {imports: [{path: fragment.yml}]}\nmain: present\n"), 0o600); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if err := os.WriteFile(fragmentPath, []byte("imported: present\n"), 0o600); err != nil {
|
if err := os.WriteFile(fragmentPath, []byte("imported: present\n"), 0o600); err != nil {
|
||||||
@@ -169,7 +169,7 @@ func TestFacadeRelativeImportDestinationsForRootAndMountedLoads(t *testing.T) {
|
|||||||
return filename
|
return filename
|
||||||
}
|
}
|
||||||
|
|
||||||
mainPath := write("main.yml", `configkit:
|
mainPath := write("main.yml", `config:
|
||||||
imports:
|
imports:
|
||||||
- path: metadata.yml
|
- path: metadata.yml
|
||||||
destination: [metadata]
|
destination: [metadata]
|
||||||
@@ -184,7 +184,7 @@ root: main
|
|||||||
write("metadata.yml", "name: imported\n")
|
write("metadata.yml", "name: imported\n")
|
||||||
write("prompts/a.yml", "a: {name: alpha}\n")
|
write("prompts/a.yml", "a: {name: alpha}\n")
|
||||||
write("prompts/b.yml", "b: {name: beta}\n")
|
write("prompts/b.yml", "b: {name: beta}\n")
|
||||||
write("entries/main.yml", `configkit:
|
write("entries/main.yml", `config:
|
||||||
imports:
|
imports:
|
||||||
- path: nested/details.yml
|
- path: nested/details.yml
|
||||||
destination: [details]
|
destination: [details]
|
||||||
@@ -352,7 +352,7 @@ func TestFacadeMountedMainAndUnderlayInspectionResourcesAndDiff(t *testing.T) {
|
|||||||
Resolve: func(configuration *map[string]any, resolution *configkit.Resolution) error {
|
Resolve: func(configuration *map[string]any, resolution *configkit.Resolution) error {
|
||||||
callbacks.resolve++
|
callbacks.resolve++
|
||||||
resourcePath := configkit.Keys("catalog", "entries", "item", "resource")
|
resourcePath := configkit.Keys("catalog", "entries", "item", "resource")
|
||||||
input, ok := resolution.InputInfo(resourcePath)
|
input, ok := resolution.EntryInfo(resourcePath)
|
||||||
if !ok || len(input.Origin.Locations) != 1 {
|
if !ok || len(input.Origin.Locations) != 1 {
|
||||||
return fmt.Errorf("mounted resource input has no single source origin")
|
return fmt.Errorf("mounted resource input has no single source origin")
|
||||||
}
|
}
|
||||||
@@ -370,11 +370,11 @@ func TestFacadeMountedMainAndUnderlayInspectionResourcesAndDiff(t *testing.T) {
|
|||||||
}
|
}
|
||||||
entry := facadeDestinationMap(t, facadeDestinationMap(t, facadeDestinationMap(t, *configuration, "catalog"), "entries"), "item")
|
entry := facadeDestinationMap(t, facadeDestinationMap(t, facadeDestinationMap(t, *configuration, "catalog"), "entries"), "item")
|
||||||
entry["resource_text"] = string(data)
|
entry["resource_text"] = string(data)
|
||||||
resolution.Record(configkit.Keys("catalog", "entries", "item", "resource_text"), configkit.ChangeNormalization, "read source-relative summary", resourcePath)
|
resolution.RecordNormalization(configkit.Keys("catalog", "entries", "item", "resource_text"), "read source-relative summary", resourcePath)
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
Validate: func(*map[string]any, *configkit.Validation) { callbacks.validate++ },
|
Validate: func(*map[string]any, *configkit.Validation) { callbacks.validate++ },
|
||||||
Secret: func(candidate configkit.Path) bool { return candidate.String() == tokenPath.String() },
|
Sensitive: func(candidate configkit.Path) bool { return candidate.String() == tokenPath.String() },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -405,7 +405,7 @@ func TestFacadeMountedMainAndUnderlayInspectionResourcesAndDiff(t *testing.T) {
|
|||||||
if err != nil || string(data) != "source-relative summary" {
|
if err != nil || string(data) != "source-relative summary" {
|
||||||
t.Fatalf("completed relative resource = %q, %v", data, err)
|
t.Fatalf("completed relative resource = %q, %v", data, err)
|
||||||
}
|
}
|
||||||
if info, ok := result.Metadata().Lookup(titlePath); !ok || info.Origin.Layer != configkit.LayerBase {
|
if info, ok := result.Metadata().Lookup(titlePath); !ok || info.Origin.Layer != configkit.LayerMain {
|
||||||
t.Fatalf("mounted title metadata = %#v, %v", info, ok)
|
t.Fatalf("mounted title metadata = %#v, %v", info, ok)
|
||||||
}
|
}
|
||||||
if history := result.Metadata().History(titlePath); len(history) < 2 {
|
if history := result.Metadata().History(titlePath); len(history) < 2 {
|
||||||
@@ -505,7 +505,7 @@ func TestFacadeRelativeImportFailuresRetainSourceRecordsAndCoordinates(t *testin
|
|||||||
}
|
}
|
||||||
|
|
||||||
t.Run("imported parse failure retains effective source destination", func(t *testing.T) {
|
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")
|
mainPath := write("parse/main.yml", "config:\n imports:\n - path: child.yml\n destination: [tenant]\n")
|
||||||
childPath := write("parse/child.yml", "count: [unterminated\n")
|
childPath := write("parse/child.yml", "count: [unterminated\n")
|
||||||
_, err := configkit.LoadLayered[map[string]any](configkit.Input{
|
_, err := configkit.LoadLayered[map[string]any](configkit.Input{
|
||||||
Source: configkit.File(mainPath), Imports: true, Destination: configkit.Keys("mount"),
|
Source: configkit.File(mainPath), Imports: true, Destination: configkit.Keys("mount"),
|
||||||
@@ -525,8 +525,8 @@ func TestFacadeRelativeImportFailuresRetainSourceRecordsAndCoordinates(t *testin
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("directive failure stays source local", func(t *testing.T) {
|
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")
|
mainPath := write("directive/main.yml", "config:\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")
|
parentPath := write("directive/parent.yml", "config:\n imports:\n - path: details.yml\n destination: [3]\n")
|
||||||
_, err := configkit.LoadLayered[map[string]any](configkit.Input{
|
_, err := configkit.LoadLayered[map[string]any](configkit.Input{
|
||||||
Source: configkit.File(mainPath), Imports: true, Destination: configkit.Keys("mount"),
|
Source: configkit.File(mainPath), Imports: true, Destination: configkit.Keys("mount"),
|
||||||
})
|
})
|
||||||
@@ -535,7 +535,7 @@ func TestFacadeRelativeImportFailuresRetainSourceRecordsAndCoordinates(t *testin
|
|||||||
t.Fatalf("load error = %T %v", err, err)
|
t.Fatalf("load error = %T %v", err, err)
|
||||||
}
|
}
|
||||||
diagnostics := loadError.Diagnostics()
|
diagnostics := loadError.Diagnostics()
|
||||||
wantPath := configkit.Keys("configkit", "imports").Index(0).Key("destination").Index(0)
|
wantPath := configkit.Keys("config", "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" {
|
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)
|
t.Fatalf("import directive diagnostic = %#v", diagnostics)
|
||||||
}
|
}
|
||||||
@@ -546,7 +546,7 @@ func TestFacadeRelativeImportFailuresRetainSourceRecordsAndCoordinates(t *testin
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("composition failure reports effective path and all sources", func(t *testing.T) {
|
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")
|
mainPath := write("compose/main.yml", "config:\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/left.yml", "value: same\n")
|
||||||
write("compose/right.yml", "value: same\n")
|
write("compose/right.yml", "value: same\n")
|
||||||
_, err := configkit.LoadLayered[map[string]any](configkit.Input{
|
_, err := configkit.LoadLayered[map[string]any](configkit.Input{
|
||||||
@@ -567,7 +567,7 @@ func TestFacadeRelativeImportFailuresRetainSourceRecordsAndCoordinates(t *testin
|
|||||||
})
|
})
|
||||||
|
|
||||||
t.Run("version failure reports effective path and imported source", func(t *testing.T) {
|
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")
|
mainPath := write("version/main.yml", "config:\n imports:\n - path: child.yml\n destination: [tenant]\n")
|
||||||
childPath := write("version/child.yml", "version: forbidden\n")
|
childPath := write("version/child.yml", "version: forbidden\n")
|
||||||
_, err := configkit.LoadLayered[map[string]any](configkit.Input{
|
_, err := configkit.LoadLayered[map[string]any](configkit.Input{
|
||||||
Source: configkit.File(mainPath), Imports: true, Destination: configkit.Keys("mount"),
|
Source: configkit.File(mainPath), Imports: true, Destination: configkit.Keys("mount"),
|
||||||
@@ -599,7 +599,7 @@ func TestFacadeRelativeImportFailuresRetainSourceRecordsAndCoordinates(t *testin
|
|||||||
} `yaml:"tenant"`
|
} `yaml:"tenant"`
|
||||||
} `yaml:"mount"`
|
} `yaml:"mount"`
|
||||||
}
|
}
|
||||||
mainPath := write("typed/main.yml", "configkit:\n imports:\n - path: child.yml\n destination: [tenant]\n")
|
mainPath := write("typed/main.yml", "config:\n imports:\n - path: child.yml\n destination: [tenant]\n")
|
||||||
childPath := write("typed/child.yml", "count: invalid\n")
|
childPath := write("typed/child.yml", "count: invalid\n")
|
||||||
_, err := configkit.LoadLayered[configuration](configkit.Input{
|
_, err := configkit.LoadLayered[configuration](configkit.Input{
|
||||||
Source: configkit.File(mainPath), Imports: true, Destination: configkit.Keys("mount"),
|
Source: configkit.File(mainPath), Imports: true, Destination: configkit.Keys("mount"),
|
||||||
|
|||||||
95
facade_diagnostic_order_test.go
Normal file
95
facade_diagnostic_order_test.go
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
package configkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
|
||||||
|
configkit "go.maximumdirect.net/configkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFacadeCompositionConflictKeepsClaimantOrder(t *testing.T) {
|
||||||
|
filesystem := configkit.NewFilesystem("bundle", fstest.MapFS{
|
||||||
|
"a.yml": &fstest.MapFile{Data: []byte(`config:
|
||||||
|
imports:
|
||||||
|
- path: z.yml
|
||||||
|
`)},
|
||||||
|
"b.yml": &fstest.MapFile{Data: []byte("x: b\n")},
|
||||||
|
"c.yml": &fstest.MapFile{Data: []byte("x: c\n")},
|
||||||
|
"z.yml": &fstest.MapFile{Data: []byte("x: z\n")},
|
||||||
|
})
|
||||||
|
main := configkit.Mapping("main", map[string]any{
|
||||||
|
"config": map[string]any{"imports": []any{
|
||||||
|
map[string]any{"path": "a.yml"},
|
||||||
|
map[string]any{"path": "b.yml"},
|
||||||
|
map[string]any{"path": "c.yml"},
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
_, err := configkit.Load[map[string]any](main, configkit.Options[map[string]any]{
|
||||||
|
Imports: true,
|
||||||
|
Filesystem: &configkit.FilesystemContext{
|
||||||
|
FS: &filesystem, Root: ".", BaseDir: ".",
|
||||||
|
},
|
||||||
|
})
|
||||||
|
var loadError *configkit.LoadError
|
||||||
|
if !errors.As(err, &loadError) {
|
||||||
|
t.Fatalf("Load error = %v, want composition LoadError", err)
|
||||||
|
}
|
||||||
|
diagnostics := loadError.Diagnostics()
|
||||||
|
if len(diagnostics) != 1 {
|
||||||
|
t.Fatalf("diagnostics = %#v, want one composition conflict", diagnostics)
|
||||||
|
}
|
||||||
|
diagnostic := diagnostics[0]
|
||||||
|
if diagnostic.Code != configkit.CodeComposeConflict || diagnostic.Path.String() != "/x" || diagnostic.Primary == nil {
|
||||||
|
t.Fatalf("composition diagnostic = %#v", diagnostic)
|
||||||
|
}
|
||||||
|
|
||||||
|
pathsByID := make(map[string]string)
|
||||||
|
var acquisitionOrder []string
|
||||||
|
for _, source := range loadError.Sources() {
|
||||||
|
pathsByID[source.ID] = source.Path
|
||||||
|
for _, name := range []string{"z.yml", "b.yml", "c.yml"} {
|
||||||
|
if source.Path == name || strings.HasSuffix(source.Path, "/"+name) {
|
||||||
|
acquisitionOrder = append(acquisitionOrder, strings.TrimSuffix(name, ".yml"))
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if strings.Join(acquisitionOrder, ",") != "z,b,c" {
|
||||||
|
t.Fatalf("claimant acquisition order = %v, want z,b,c", acquisitionOrder)
|
||||||
|
}
|
||||||
|
claimant := func(location configkit.Location) string {
|
||||||
|
for _, name := range []string{"b.yml", "c.yml", "z.yml"} {
|
||||||
|
if pathsByID[location.SourceID] == name || strings.HasSuffix(pathsByID[location.SourceID], "/"+name) {
|
||||||
|
return strings.TrimSuffix(name, ".yml")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if got := claimant(*diagnostic.Primary); got != "b" {
|
||||||
|
t.Fatalf("primary claimant = %q, want b (sources: %#v)", got, pathsByID)
|
||||||
|
}
|
||||||
|
if len(diagnostic.Related) != 2 || claimant(diagnostic.Related[0]) != "c" || claimant(diagnostic.Related[1]) != "z" {
|
||||||
|
t.Fatalf("related claimants = %#v, want c then z", diagnostic.Related)
|
||||||
|
}
|
||||||
|
|
||||||
|
var output bytes.Buffer
|
||||||
|
if err := configkit.WriteDiagnostics(&output, diagnostics); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
positions := []int{
|
||||||
|
strings.Index(output.String(), diagnostic.Primary.SourceID),
|
||||||
|
strings.Index(output.String(), diagnostic.Related[0].SourceID),
|
||||||
|
strings.Index(output.String(), diagnostic.Related[1].SourceID),
|
||||||
|
}
|
||||||
|
if positions[0] < 0 || positions[1] <= positions[0] || positions[2] <= positions[1] {
|
||||||
|
t.Fatalf("rendered claimant order = %v in %q", positions, output.String())
|
||||||
|
}
|
||||||
|
|
||||||
|
diagnostics[0].Related[0].SourceID = "mutated"
|
||||||
|
if got := claimant(loadError.Diagnostics()[0].Related[0]); got != "c" {
|
||||||
|
t.Fatalf("LoadError.Diagnostics retained caller mutation; first claimant = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "go.maximumdirect.net/configkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
type facadeDiffConfig struct {
|
type facadeDiffConfig struct {
|
||||||
@@ -29,10 +29,10 @@ func TestFacadeDiffKeepsPublicSequenceChangesWithHistoricalDescendants(t *testin
|
|||||||
t.Helper()
|
t.Helper()
|
||||||
result, err := configkit.Load(configkit.Bytes("historical-sequence.yml", []byte("items: [{secret: disposable-canary}, "+public+"]\n")),
|
result, err := configkit.Load(configkit.Bytes("historical-sequence.yml", []byte("items: [{secret: disposable-canary}, "+public+"]\n")),
|
||||||
configkit.Options[configuration]{Definition: configkit.Definition[configuration]{
|
configkit.Options[configuration]{Definition: configkit.Definition[configuration]{
|
||||||
Secret: func(path configkit.Path) bool { return path.String() == "/items/0/secret" },
|
Sensitive: func(path configkit.Path) bool { return path.String() == "/items/0/secret" },
|
||||||
Resolve: func(current *configuration, resolution *configkit.Resolution) error {
|
Resolve: func(current *configuration, resolution *configkit.Resolution) error {
|
||||||
current.Items[0] = "public scalar"
|
current.Items[0] = "public scalar"
|
||||||
resolution.Record(configkit.Keys("items"), configkit.ChangeNormalization, "normalize first item")
|
resolution.RecordNormalization(configkit.Keys("items"), "normalize first item")
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}})
|
}})
|
||||||
@@ -71,10 +71,10 @@ func TestFacadeDiffKeepsPublicScalarAcrossHistoricalAbsence(t *testing.T) {
|
|||||||
Dynamic map[string]any `yaml:"dynamic"`
|
Dynamic map[string]any `yaml:"dynamic"`
|
||||||
}
|
}
|
||||||
options := configkit.Options[configuration]{Definition: configkit.Definition[configuration]{
|
options := configkit.Options[configuration]{Definition: configkit.Definition[configuration]{
|
||||||
Secret: func(path configkit.Path) bool { return path.String() == "/dynamic/entry/token" },
|
Sensitive: func(path configkit.Path) bool { return path.String() == "/dynamic/entry/token" },
|
||||||
Resolve: func(current *configuration, resolution *configkit.Resolution) error {
|
Resolve: func(current *configuration, resolution *configkit.Resolution) error {
|
||||||
delete(current.Dynamic, "entry")
|
delete(current.Dynamic, "entry")
|
||||||
resolution.Record(configkit.Keys("dynamic", "entry"), configkit.ChangeNormalization, "remove entry")
|
resolution.RecordNormalization(configkit.Keys("dynamic", "entry"), "remove entry")
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
@@ -221,7 +221,7 @@ func TestFacadeDiffIgnoresSourceAndResolutionHistory(t *testing.T) {
|
|||||||
resolve := func(value string) configkit.Options[config] {
|
resolve := func(value string) configkit.Options[config] {
|
||||||
return configkit.Options[config]{Definition: configkit.Definition[config]{Resolve: func(current *config, resolution *configkit.Resolution) error {
|
return configkit.Options[config]{Definition: configkit.Definition[config]{Resolve: func(current *config, resolution *configkit.Resolution) error {
|
||||||
current.Count = len(value) + 8
|
current.Count = len(value) + 8
|
||||||
resolution.Record(configkit.Keys("count"), configkit.ChangeNormalization, "set effective count")
|
resolution.RecordNormalization(configkit.Keys("count"), "set effective count")
|
||||||
return nil
|
return nil
|
||||||
}}}
|
}}}
|
||||||
}
|
}
|
||||||
@@ -281,7 +281,7 @@ func TestFacadeDiffUsesFrozenFilesAndRunsCallbacksOnlyDuringLoad(t *testing.T) {
|
|||||||
|
|
||||||
func TestFacadeDiffCombinesSecretSensitivityAndLoadErrors(t *testing.T) {
|
func TestFacadeDiffCombinesSecretSensitivityAndLoadErrors(t *testing.T) {
|
||||||
t.Setenv("FACADE_DIFF_SECRET", "credential-one")
|
t.Setenv("FACADE_DIFF_SECRET", "credential-one")
|
||||||
secretOptions := configkit.Options[facadeDiffSecretConfig]{Definition: configkit.Definition[facadeDiffSecretConfig]{Secret: func(path configkit.Path) bool {
|
secretOptions := configkit.Options[facadeDiffSecretConfig]{Definition: configkit.Definition[facadeDiffSecretConfig]{Sensitive: func(path configkit.Path) bool {
|
||||||
return path.String() == "/secret"
|
return path.String() == "/secret"
|
||||||
}}}
|
}}}
|
||||||
left, err := configkit.Load[facadeDiffSecretConfig](configkit.Bytes("left.yml", []byte("secret: FACADE_DIFF_SECRET\n")), secretOptions)
|
left, err := configkit.Load[facadeDiffSecretConfig](configkit.Bytes("left.yml", []byte("secret: FACADE_DIFF_SECRET\n")), secretOptions)
|
||||||
|
|||||||
@@ -93,14 +93,14 @@ func TestFacadeSearchOrderingRequirednessAndSnapshots(t *testing.T) {
|
|||||||
names := []string{"config.yml"}
|
names := []string{"config.yml"}
|
||||||
groups := []FilenameGroup{CustomFilenames(names...)}
|
groups := []FilenameGroup{CustomFilenames(names...)}
|
||||||
directoryOptions := []DirectorySearchOptions{{Filenames: groups}}
|
directoryOptions := []DirectorySearchOptions{{Filenames: groups}}
|
||||||
search := Searches(Paths(paths...), SearchDirectory(root, directoryOptions...), Paths(laterPath))
|
search := Searches(SearchPaths(paths...), SearchDirectory(root, directoryOptions...), SearchPaths(laterPath))
|
||||||
source := SearchConfig("", search, SearchOptions{Required: true})
|
source := SearchConfig("", search, SearchOptions{Required: true})
|
||||||
|
|
||||||
paths[0] = laterPath
|
paths[0] = laterPath
|
||||||
names[0] = "later.yml"
|
names[0] = "later.yml"
|
||||||
groups[0] = CustomFilenames("later.yml")
|
groups[0] = CustomFilenames("later.yml")
|
||||||
directoryOptions[0].Filenames[0] = CustomFilenames("later.yml")
|
directoryOptions[0].Filenames[0] = CustomFilenames("later.yml")
|
||||||
search = Paths(laterPath)
|
search = SearchPaths(laterPath)
|
||||||
|
|
||||||
result, err := Load[facadeDiscoveryConfig](source)
|
result, err := Load[facadeDiscoveryConfig](source)
|
||||||
if err != nil || result.Config.Value != "selected" {
|
if err != nil || result.Config.Value != "selected" {
|
||||||
@@ -110,7 +110,7 @@ func TestFacadeSearchOrderingRequirednessAndSnapshots(t *testing.T) {
|
|||||||
t.Fatalf("ordered search sources = %#v", sources)
|
t.Fatalf("ordered search sources = %#v", sources)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = Load[facadeDiscoveryConfig](SearchConfig("", Paths(filepath.Join(root, "missing.yml")), SearchOptions{Required: true}))
|
_, err = Load[facadeDiscoveryConfig](SearchConfig("", SearchPaths(filepath.Join(root, "missing.yml")), SearchOptions{Required: true}))
|
||||||
var loadErr *LoadError
|
var loadErr *LoadError
|
||||||
if !errors.As(err, &loadErr) {
|
if !errors.As(err, &loadErr) {
|
||||||
t.Fatalf("required exhausted search error = %v, want *LoadError", err)
|
t.Fatalf("required exhausted search error = %v, want *LoadError", err)
|
||||||
@@ -131,7 +131,7 @@ func TestFacadeSearchWorksForLayeredMainAndUnderlay(t *testing.T) {
|
|||||||
mainPath := writeFacadeImportFile(t, root, "main.yml", "settings: {main: value}\n")
|
mainPath := writeFacadeImportFile(t, root, "main.yml", "settings: {main: value}\n")
|
||||||
underlayPath := writeFacadeImportFile(t, root, "underlay/base.yml", "settings: {underlay: value}\n")
|
underlayPath := writeFacadeImportFile(t, root, "underlay/base.yml", "settings: {underlay: value}\n")
|
||||||
result, err := LoadLayered(
|
result, err := LoadLayered(
|
||||||
Input{Source: SearchConfig("", Paths(mainPath))},
|
Input{Source: SearchConfig("", SearchPaths(mainPath))},
|
||||||
LayeredOptions[facadeDiscoveryConfig]{Underlays: []Underlay{{Inputs: []Input{{
|
LayeredOptions[facadeDiscoveryConfig]{Underlays: []Underlay{{Inputs: []Input{{
|
||||||
Source: SearchConfig("", SearchDirectory(filepath.Dir(underlayPath), DirectorySearchOptions{
|
Source: SearchConfig("", SearchDirectory(filepath.Dir(underlayPath), DirectorySearchOptions{
|
||||||
Filenames: []FilenameGroup{CustomFilenames("base.yml")},
|
Filenames: []FilenameGroup{CustomFilenames("base.yml")},
|
||||||
@@ -142,7 +142,7 @@ func TestFacadeSearchWorksForLayeredMainAndUnderlay(t *testing.T) {
|
|||||||
t.Fatalf("layered search = %#v, %v", result, err)
|
t.Fatalf("layered search = %#v, %v", result, err)
|
||||||
}
|
}
|
||||||
sources := result.Sources()
|
sources := result.Sources()
|
||||||
if len(sources) != 2 || sources[0].Path != canonicalFacadePath(t, mainPath) || sources[0].Layer != LayerBase ||
|
if len(sources) != 2 || sources[0].Path != canonicalFacadePath(t, mainPath) || sources[0].Layer != LayerMain ||
|
||||||
sources[1].Path != canonicalFacadePath(t, underlayPath) || sources[1].Layer != LayerUnderlay {
|
sources[1].Path != canonicalFacadePath(t, underlayPath) || sources[1].Layer != LayerUnderlay {
|
||||||
t.Fatalf("layered search sources = %#v", sources)
|
t.Fatalf("layered search sources = %#v", sources)
|
||||||
}
|
}
|
||||||
@@ -157,7 +157,7 @@ func TestFacadeSearchSelectedAndAbsentUseRootOnlyContext(t *testing.T) {
|
|||||||
filesystem := &FilesystemContext{Root: root}
|
filesystem := &FilesystemContext{Root: root}
|
||||||
options := Options[facadeDiscoveryConfig]{Overlay: "testing", Filesystem: filesystem}
|
options := Options[facadeDiscoveryConfig]{Overlay: "testing", Filesystem: filesystem}
|
||||||
|
|
||||||
selected, err := Load(SearchConfig("", Paths(mainPath)), options)
|
selected, err := Load(SearchConfig("", SearchPaths(mainPath)), options)
|
||||||
if err != nil || selected.Config.Value != "overlay" {
|
if err != nil || selected.Config.Value != "overlay" {
|
||||||
t.Fatalf("selected search with root-only context = %#v, %v", selected, err)
|
t.Fatalf("selected search with root-only context = %#v, %v", selected, err)
|
||||||
}
|
}
|
||||||
@@ -171,7 +171,7 @@ func TestFacadeSearchSelectedAndAbsentUseRootOnlyContext(t *testing.T) {
|
|||||||
t.Fatalf("selected file base context = %#v, %v", mainContext, ok)
|
t.Fatalf("selected file base context = %#v, %v", mainContext, ok)
|
||||||
}
|
}
|
||||||
|
|
||||||
missing := SearchConfig("", Paths(filepath.Join(root, "missing.yml")))
|
missing := SearchConfig("", SearchPaths(filepath.Join(root, "missing.yml")))
|
||||||
searchedAbsent, err := Load(missing, options)
|
searchedAbsent, err := Load(missing, options)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("exhausted search with root-only overlay context: %v", err)
|
t.Fatalf("exhausted search with root-only overlay context: %v", err)
|
||||||
@@ -201,7 +201,7 @@ func TestFacadeSearchRejectsSelectedContextWithoutFallback(t *testing.T) {
|
|||||||
inside := writeFacadeImportFile(t, root, "inside.yml", "value: fallback\n")
|
inside := writeFacadeImportFile(t, root, "inside.yml", "value: fallback\n")
|
||||||
outsideRoot := t.TempDir()
|
outsideRoot := t.TempDir()
|
||||||
outside := writeFacadeImportFile(t, outsideRoot, "outside.yml", "value: selected\n")
|
outside := writeFacadeImportFile(t, outsideRoot, "outside.yml", "value: selected\n")
|
||||||
search := SearchConfig("", Paths(outside, inside))
|
search := SearchConfig("", SearchPaths(outside, inside))
|
||||||
|
|
||||||
_, err := Load(search, Options[facadeDiscoveryConfig]{Filesystem: &FilesystemContext{Root: root}})
|
_, err := Load(search, Options[facadeDiscoveryConfig]{Filesystem: &FilesystemContext{Root: root}})
|
||||||
var loadErr *LoadError
|
var loadErr *LoadError
|
||||||
@@ -228,7 +228,7 @@ func TestFacadeSearchRunsCallbacksOnceAndKeepsInspectionWithSelectedFile(t *test
|
|||||||
selectedPath := writeFacadeImportFile(t, root, "selected.yml", "value: selected\n")
|
selectedPath := writeFacadeImportFile(t, root, "selected.yml", "value: selected\n")
|
||||||
writeFacadeImportFile(t, root, "later.yml", "value: later\n")
|
writeFacadeImportFile(t, root, "later.yml", "value: later\n")
|
||||||
defaultsCalls, resolveCalls, validateCalls := 0, 0, 0
|
defaultsCalls, resolveCalls, validateCalls := 0, 0, 0
|
||||||
result, err := Load(SearchConfig("", Paths(selectedPath, filepath.Join(root, "later.yml"))), Options[facadeDiscoveryLifecycleConfig]{
|
result, err := Load(SearchConfig("", SearchPaths(selectedPath, filepath.Join(root, "later.yml"))), Options[facadeDiscoveryLifecycleConfig]{
|
||||||
Definition: Definition[facadeDiscoveryLifecycleConfig]{
|
Definition: Definition[facadeDiscoveryLifecycleConfig]{
|
||||||
Defaults: func() facadeDiscoveryLifecycleConfig {
|
Defaults: func() facadeDiscoveryLifecycleConfig {
|
||||||
defaultsCalls++
|
defaultsCalls++
|
||||||
@@ -237,7 +237,7 @@ func TestFacadeSearchRunsCallbacksOnceAndKeepsInspectionWithSelectedFile(t *test
|
|||||||
Resolve: func(configuration *facadeDiscoveryLifecycleConfig, resolution *Resolution) error {
|
Resolve: func(configuration *facadeDiscoveryLifecycleConfig, resolution *Resolution) error {
|
||||||
resolveCalls++
|
resolveCalls++
|
||||||
configuration.Resolved = "from:" + configuration.Value
|
configuration.Resolved = "from:" + configuration.Value
|
||||||
resolution.Record(Keys("resolved"), ChangeNormalization, "derive from selected value", Keys("value"))
|
resolution.RecordNormalization(Keys("resolved"), "derive from selected value", Keys("value"))
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
Validate: func(_ *facadeDiscoveryLifecycleConfig, _ *Validation) { validateCalls++ },
|
Validate: func(_ *facadeDiscoveryLifecycleConfig, _ *Validation) { validateCalls++ },
|
||||||
@@ -272,7 +272,7 @@ func TestFacadeSearchSequenceItemAndAbsentInputPlacement(t *testing.T) {
|
|||||||
Destination: Keys("items"),
|
Destination: Keys("items"),
|
||||||
PlacementMode: PlacementModeSequenceItem,
|
PlacementMode: PlacementModeSequenceItem,
|
||||||
}
|
}
|
||||||
selected, err := Load(SearchConfig("", Paths(selectedPath)), options)
|
selected, err := Load(SearchConfig("", SearchPaths(selectedPath)), options)
|
||||||
if err != nil || len(selected.Config.Items) != 1 || selected.Config.Items[0].Name != "selected" {
|
if err != nil || len(selected.Config.Items) != 1 || selected.Config.Items[0].Name != "selected" {
|
||||||
t.Fatalf("selected sequence item = %#v, %v", selected, err)
|
t.Fatalf("selected sequence item = %#v, %v", selected, err)
|
||||||
}
|
}
|
||||||
@@ -283,7 +283,7 @@ func TestFacadeSearchSequenceItemAndAbsentInputPlacement(t *testing.T) {
|
|||||||
defaults := func() facadeDiscoveryPlacementConfig {
|
defaults := func() facadeDiscoveryPlacementConfig {
|
||||||
return facadeDiscoveryPlacementConfig{Items: []facadeDiscoveryPlacementItem{}}
|
return facadeDiscoveryPlacementConfig{Items: []facadeDiscoveryPlacementItem{}}
|
||||||
}
|
}
|
||||||
absent, err := Load(SearchConfig("", Paths(filepath.Join(root, "missing.yml"))), Options[facadeDiscoveryPlacementConfig]{
|
absent, err := Load(SearchConfig("", SearchPaths(filepath.Join(root, "missing.yml"))), Options[facadeDiscoveryPlacementConfig]{
|
||||||
Definition: Definition[facadeDiscoveryPlacementConfig]{Defaults: defaults},
|
Definition: Definition[facadeDiscoveryPlacementConfig]{Defaults: defaults},
|
||||||
Destination: options.Destination,
|
Destination: options.Destination,
|
||||||
PlacementMode: options.PlacementMode,
|
PlacementMode: options.PlacementMode,
|
||||||
|
|||||||
53
facade_dynamic_redaction_test.go
Normal file
53
facade_dynamic_redaction_test.go
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
package configkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
configkit "go.maximumdirect.net/configkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFinalDynamicTagRedactsEqualValuedResolution(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
type classified struct {
|
||||||
|
Token string `yaml:"token" configkit:"secret"`
|
||||||
|
}
|
||||||
|
type configuration struct {
|
||||||
|
Value any `yaml:"value"`
|
||||||
|
}
|
||||||
|
const canary = "DYNAMIC-TAG-CANARY"
|
||||||
|
result, err := configkit.Load(configkit.Bytes("input", []byte("value: {token: "+canary+"}\n")), configkit.Options[configuration]{
|
||||||
|
Definition: configkit.Definition[configuration]{
|
||||||
|
Resolve: func(c *configuration, _ *configkit.Resolution) error {
|
||||||
|
// No canonical value changes, so no normalization record is needed.
|
||||||
|
c.Value = classified{Token: canary}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var effective bytes.Buffer
|
||||||
|
if err := result.WriteEffective(&effective); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Contains(effective.String(), canary) || !strings.Contains(effective.String(), "[REDACTED]") {
|
||||||
|
t.Fatal("effective rendering did not preserve the final dynamic tag")
|
||||||
|
}
|
||||||
|
explanation, found := result.Explain(configkit.Keys("value", "token"))
|
||||||
|
if !found || !explanation.Effective.Redacted || strings.Contains(explanation.Effective.YAML, canary) {
|
||||||
|
t.Fatal("effective explanation did not preserve the final dynamic tag")
|
||||||
|
}
|
||||||
|
if len(explanation.History) == 0 {
|
||||||
|
t.Fatal("source history is absent")
|
||||||
|
}
|
||||||
|
for _, change := range explanation.History {
|
||||||
|
for _, value := range []configkit.ValueSnapshot{change.Before, change.After} {
|
||||||
|
if value.Present && (!value.Redacted || strings.Contains(value.YAML, canary)) {
|
||||||
|
t.Fatal("historical explanation did not preserve the final dynamic tag")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"testing/fstest"
|
"testing/fstest"
|
||||||
|
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "go.maximumdirect.net/configkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
type equivalentConfig struct {
|
type equivalentConfig struct {
|
||||||
@@ -23,6 +23,9 @@ type equivalentConfig struct {
|
|||||||
|
|
||||||
func TestEquivalentSourceFormsPreserveInputSemantics(t *testing.T) {
|
func TestEquivalentSourceFormsPreserveInputSemantics(t *testing.T) {
|
||||||
const yaml = "version: 1\nname: retained\nenabled: false\ncount: 0\nitems: []\noptional: null\n"
|
const yaml = "version: 1\nname: retained\nenabled: false\ncount: 0\nitems: []\noptional: null\n"
|
||||||
|
bytesData := []byte(yaml)
|
||||||
|
bytesSource := configkit.Bytes("memory", bytesData)
|
||||||
|
bytesData[0] = 'X'
|
||||||
hostPath := filepath.Join(t.TempDir(), "main.yml")
|
hostPath := filepath.Join(t.TempDir(), "main.yml")
|
||||||
if err := os.WriteFile(hostPath, []byte(yaml), 0o600); err != nil {
|
if err := os.WriteFile(hostPath, []byte(yaml), 0o600); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
@@ -37,7 +40,7 @@ func TestEquivalentSourceFormsPreserveInputSemantics(t *testing.T) {
|
|||||||
}{
|
}{
|
||||||
{"host", configkit.File(hostPath), nil, configkit.SourceKindFile},
|
{"host", configkit.File(hostPath), nil, configkit.SourceKindFile},
|
||||||
{"native", namespace.File("main.yml"), nil, configkit.SourceKindFilesystem},
|
{"native", namespace.File("main.yml"), nil, configkit.SourceKindFilesystem},
|
||||||
{"bytes", configkit.Bytes("memory", []byte(yaml)), context, configkit.SourceKindBytes},
|
{"bytes", bytesSource, context, configkit.SourceKindBytes},
|
||||||
{"reader", configkit.Reader("stream", strings.NewReader(yaml)), context, configkit.SourceKindReader},
|
{"reader", configkit.Reader("stream", strings.NewReader(yaml)), context, configkit.SourceKindReader},
|
||||||
{"mapping", configkit.Mapping("programmatic", map[string]any{
|
{"mapping", configkit.Mapping("programmatic", map[string]any{
|
||||||
"version": uint64(1), "name": "retained", "enabled": false, "count": 0,
|
"version": uint64(1), "name": "retained", "enabled": false, "count": 0,
|
||||||
@@ -66,7 +69,7 @@ func TestEquivalentSourceFormsPreserveInputSemantics(t *testing.T) {
|
|||||||
}
|
}
|
||||||
for _, field := range []string{"version", "name", "enabled", "count", "items", "optional"} {
|
for _, field := range []string{"version", "name", "enabled", "count", "items", "optional"} {
|
||||||
info, ok := result.Metadata().Lookup(configkit.Keys(field))
|
info, ok := result.Metadata().Lookup(configkit.Keys(field))
|
||||||
if !ok || !info.Explicit || info.Origin.Layer != configkit.LayerBase || info.Null != (field == "optional") {
|
if !ok || !info.Explicit || info.Origin.Layer != configkit.LayerMain || info.Null != (field == "optional") {
|
||||||
t.Errorf("%s input info = %#v, %v", field, info, ok)
|
t.Errorf("%s input info = %#v, %v", field, info, ok)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -84,8 +87,8 @@ func TestEquivalentSourceFormsPreserveInputSemantics(t *testing.T) {
|
|||||||
|
|
||||||
func TestRepeatedImportsAcrossTiersAndReaderConsumption(t *testing.T) {
|
func TestRepeatedImportsAcrossTiersAndReaderConsumption(t *testing.T) {
|
||||||
namespace := configkit.NewFilesystem("bundle", fstest.MapFS{
|
namespace := configkit.NewFilesystem("bundle", fstest.MapFS{
|
||||||
"left.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: shared.yml}]}\n")},
|
"left.yml": &fstest.MapFile{Data: []byte("config: {imports: [{path: shared.yml}]}\n")},
|
||||||
"right.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: shared.yml}]}\n")},
|
"right.yml": &fstest.MapFile{Data: []byte("config: {imports: [{path: shared.yml}]}\n")},
|
||||||
"shared.yml": &fstest.MapFile{Data: []byte("name: retained\n")},
|
"shared.yml": &fstest.MapFile{Data: []byte("name: retained\n")},
|
||||||
})
|
})
|
||||||
type config struct {
|
type config struct {
|
||||||
@@ -112,6 +115,15 @@ func TestRepeatedImportsAcrossTiersAndReaderConsumption(t *testing.T) {
|
|||||||
if got := layeredErrorCode(t, err); got != configkit.CodeSourceReaderUsed {
|
if got := layeredErrorCode(t, err); got != configkit.CodeSourceReaderUsed {
|
||||||
t.Fatalf("reader re-consumption = %s", got)
|
t.Fatalf("reader re-consumption = %s", got)
|
||||||
}
|
}
|
||||||
|
copiedReader := configkit.Reader("copied-single-use", strings.NewReader("name: retained\n"))
|
||||||
|
readerCopy := copiedReader
|
||||||
|
if _, err := configkit.Load[config](readerCopy, configkit.Options[config]{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err = configkit.Load[config](copiedReader, configkit.Options[config]{})
|
||||||
|
if got := layeredErrorCode(t, err); got != configkit.CodeSourceReaderUsed {
|
||||||
|
t.Fatalf("copied reader re-consumption = %s", got)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestReplacedSecretRemainsAbsentFromSafeOutput(t *testing.T) {
|
func TestReplacedSecretRemainsAbsentFromSafeOutput(t *testing.T) {
|
||||||
@@ -120,7 +132,7 @@ func TestReplacedSecretRemainsAbsentFromSafeOutput(t *testing.T) {
|
|||||||
}
|
}
|
||||||
result, err := configkit.LoadLayered(configkit.Input{Source: configkit.Bytes("main", []byte("secret: new-secret-canary\n"))}, configkit.LayeredOptions[config]{
|
result, err := configkit.LoadLayered(configkit.Input{Source: configkit.Bytes("main", []byte("secret: new-secret-canary\n"))}, configkit.LayeredOptions[config]{
|
||||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.Mapping("lower", map[string]any{"secret": "old-secret-canary"})}}}},
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.Mapping("lower", map[string]any{"secret": "old-secret-canary"})}}}},
|
||||||
Definition: configkit.Definition[config]{Secret: func(path configkit.Path) bool { return path.String() == "/secret" }},
|
Definition: configkit.Definition[config]{Sensitive: func(path configkit.Path) bool { return path.String() == "/secret" }},
|
||||||
})
|
})
|
||||||
if err != nil || result.Config.Secret != "new-secret-canary" {
|
if err != nil || result.Config.Secret != "new-secret-canary" {
|
||||||
t.Fatalf("secret load = %#v, %v", result, err)
|
t.Fatalf("secret load = %#v, %v", result, err)
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"testing/fstest"
|
"testing/fstest"
|
||||||
|
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "go.maximumdirect.net/configkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
//go:embed testdata/filesystem
|
//go:embed testdata/filesystem
|
||||||
@@ -57,7 +57,7 @@ func TestFacadeFilesystemSourceImportsOverlayAndReuse(t *testing.T) {
|
|||||||
|
|
||||||
func TestFacadeFilesystemContextsAndIdentity(t *testing.T) {
|
func TestFacadeFilesystemContextsAndIdentity(t *testing.T) {
|
||||||
first := configkit.NewFilesystem("same", fstest.MapFS{
|
first := configkit.NewFilesystem("same", fstest.MapFS{
|
||||||
"root/config/main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: ../shared.yml}]}\n")},
|
"root/config/main.yml": &fstest.MapFile{Data: []byte("config: {imports: [{path: ../shared.yml}]}\n")},
|
||||||
"root/shared.yml": &fstest.MapFile{Data: []byte("items: {value: first}\n")},
|
"root/shared.yml": &fstest.MapFile{Data: []byte("items: {value: first}\n")},
|
||||||
"root/overlays/testing.yml": &fstest.MapFile{Data: []byte("items: {overlay: first}\n")},
|
"root/overlays/testing.yml": &fstest.MapFile{Data: []byte("items: {overlay: first}\n")},
|
||||||
})
|
})
|
||||||
@@ -82,8 +82,8 @@ func TestFacadeFilesystemContextsAndIdentity(t *testing.T) {
|
|||||||
source configkit.Source
|
source configkit.Source
|
||||||
kind configkit.SourceKind
|
kind configkit.SourceKind
|
||||||
}{
|
}{
|
||||||
{"bytes", configkit.Bytes("memory", []byte("configkit: {imports: [{path: shared.yml}]}\n")), configkit.SourceKindBytes},
|
{"bytes", configkit.Bytes("memory", []byte("config: {imports: [{path: shared.yml}]}\n")), configkit.SourceKindBytes},
|
||||||
{"reader", configkit.Reader("stream", strings.NewReader("configkit: {imports: [{path: shared.yml}]}\n")), configkit.SourceKindReader},
|
{"reader", configkit.Reader("stream", strings.NewReader("config: {imports: [{path: shared.yml}]}\n")), configkit.SourceKindReader},
|
||||||
} {
|
} {
|
||||||
t.Run(input.name, func(t *testing.T) {
|
t.Run(input.name, func(t *testing.T) {
|
||||||
result, err := configkit.Load(input.source, configkit.Options[filesystemFacadeConfig]{Imports: true, Filesystem: &configkit.FilesystemContext{Root: "root", BaseDir: "root", FS: &first}})
|
result, err := configkit.Load(input.source, configkit.Options[filesystemFacadeConfig]{Imports: true, Filesystem: &configkit.FilesystemContext{Root: "root", BaseDir: "root", FS: &first}})
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit"
|
"go.maximumdirect.net/configkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestFinalSequenceIdentityTreatsLateExtendsFieldsAsPayload(t *testing.T) {
|
func TestFinalSequenceIdentityTreatsLateExtendsFieldsAsPayload(t *testing.T) {
|
||||||
@@ -29,7 +29,7 @@ func TestFinalSequenceIdentityTreatsLateExtendsFieldsAsPayload(t *testing.T) {
|
|||||||
if phase == "resolution" {
|
if phase == "resolution" {
|
||||||
definition.Resolve = func(value *map[string]any, resolution *configkit.Resolution) error {
|
definition.Resolve = func(value *map[string]any, resolution *configkit.Resolution) error {
|
||||||
(*value)["items"] = items
|
(*value)["items"] = items
|
||||||
resolution.Record(configkit.Keys("items"), configkit.ChangeNormalization, "supply application payload")
|
resolution.RecordNormalization(configkit.Keys("items"), "supply application payload")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -77,7 +77,7 @@ func TestFinalSequenceIdentityValidatesLateValues(t *testing.T) {
|
|||||||
case "resolution":
|
case "resolution":
|
||||||
options.Definition.Resolve = func(value *map[string]any, resolution *configkit.Resolution) error {
|
options.Definition.Resolve = func(value *map[string]any, resolution *configkit.Resolution) error {
|
||||||
(*value)["items"] = items
|
(*value)["items"] = items
|
||||||
resolution.Record(configkit.Keys("items"), configkit.ChangeNormalization, "create effective records")
|
resolution.RecordNormalization(configkit.Keys("items"), "create effective records")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -134,7 +134,7 @@ func TestFinalSequenceIdentityMatchesLateDynamicNamespaces(t *testing.T) {
|
|||||||
case "resolution":
|
case "resolution":
|
||||||
options.Definition.Resolve = func(value *map[string]any, resolution *configkit.Resolution) error {
|
options.Definition.Resolve = func(value *map[string]any, resolution *configkit.Resolution) error {
|
||||||
(*value)["collections"] = map[string]any{name: map[string]any{"items": items}}
|
(*value)["collections"] = map[string]any{name: map[string]any{"items": items}}
|
||||||
resolution.Record(configkit.Keys("collections"), configkit.ChangeNormalization, "create namespaced records")
|
resolution.RecordNormalization(configkit.Keys("collections"), "create namespaced records")
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
120
facade_historical_paths_test.go
Normal file
120
facade_historical_paths_test.go
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
package configkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
configkit "go.maximumdirect.net/configkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestPublicHistoricalPathsEnumerateContextualShapes(t *testing.T) {
|
||||||
|
loaded, err := configkit.Load[map[string]any](configkit.Bytes("main", []byte(
|
||||||
|
"retired: {child: 1}\nshape: {'0': base}\n'a/b~': {'': 2}\n",
|
||||||
|
)), configkit.Options[map[string]any]{Overrides: []configkit.Override{
|
||||||
|
{Path: configkit.Keys("retired"), Value: nil, Flag: "--retired"},
|
||||||
|
{Path: configkit.Keys("shape"), Value: []any{"replacement"}, Flag: "--shape"},
|
||||||
|
}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
metadata := loaded.Metadata()
|
||||||
|
paths := metadata.HistoricalPaths()
|
||||||
|
got := make([]string, len(paths))
|
||||||
|
for index, path := range paths {
|
||||||
|
got[index] = path.String()
|
||||||
|
if len(metadata.History(path)) == 0 {
|
||||||
|
t.Errorf("enumerated path %s has no history", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
want := []string{"", "/a~1b~0", "/a~1b~0/", "/retired", "/retired/child", "/shape", "/shape/0"}
|
||||||
|
if !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("historical paths = %v, want sorted unique pointers %v", got, want)
|
||||||
|
}
|
||||||
|
if _, ok := metadata.Lookup(paths[4]); ok {
|
||||||
|
t.Fatal("removed descendant remains effective")
|
||||||
|
}
|
||||||
|
if _, ok := metadata.Lookup(paths[6]); !ok {
|
||||||
|
t.Fatal("enumerated numeric path did not follow the effective sequence shape")
|
||||||
|
}
|
||||||
|
if len(metadata.History(paths[6])) == 0 {
|
||||||
|
t.Fatal("enumerated numeric path lost history")
|
||||||
|
}
|
||||||
|
assertPublicShapeHistory(t, metadata, configkit.LayerCLI)
|
||||||
|
if _, ok := metadata.Lookup(configkit.Keys("shape", "0")); ok {
|
||||||
|
t.Fatal("mapping-key path resolved against the effective sequence")
|
||||||
|
}
|
||||||
|
if len((configkit.Metadata{}).HistoricalPaths()) != 0 {
|
||||||
|
t.Fatal("zero metadata contains historical paths")
|
||||||
|
}
|
||||||
|
if (configkit.Metadata{}).History(configkit.Keys("shape")) != nil {
|
||||||
|
t.Fatal("zero metadata contains history")
|
||||||
|
}
|
||||||
|
|
||||||
|
paths[1] = configkit.Keys("mutated")
|
||||||
|
if again := metadata.HistoricalPaths(); !reflect.DeepEqual(pathStrings(again), want) {
|
||||||
|
t.Fatalf("returned path slice mutated retained metadata: %v", pathStrings(again))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublicHistoryUsesResolutionShapeAttribution(t *testing.T) {
|
||||||
|
loaded, err := configkit.Load[map[string]any](configkit.Bytes("main", []byte("shape: {'0': base}\n")), configkit.Options[map[string]any]{
|
||||||
|
Definition: configkit.Definition[map[string]any]{
|
||||||
|
Resolve: func(configuration *map[string]any, resolution *configkit.Resolution) error {
|
||||||
|
(*configuration)["shape"] = []any{"replacement"}
|
||||||
|
resolution.RecordNormalization(configkit.Keys("shape"), "replace numeric mapping key")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Load: %v", err)
|
||||||
|
}
|
||||||
|
assertPublicShapeHistory(t, loaded.Metadata(), configkit.LayerResolution)
|
||||||
|
}
|
||||||
|
|
||||||
|
func assertPublicShapeHistory(t *testing.T, metadata configkit.Metadata, operationLayer configkit.Layer) {
|
||||||
|
t.Helper()
|
||||||
|
|
||||||
|
keyPath := configkit.Keys("shape", "0")
|
||||||
|
indexPath := configkit.Keys("shape").Index(0)
|
||||||
|
keyHistory := metadata.History(keyPath)
|
||||||
|
indexHistory := metadata.History(indexPath)
|
||||||
|
if len(keyHistory) == 0 || len(indexHistory) == 0 {
|
||||||
|
t.Fatalf("explicit shape histories = key %#v, index %#v", keyHistory, indexHistory)
|
||||||
|
}
|
||||||
|
var keyDefinition, keyReplacement, indexReplacement bool
|
||||||
|
for _, change := range keyHistory {
|
||||||
|
keyDefinition = keyDefinition || change.Origin.Layer == configkit.LayerMain
|
||||||
|
keyReplacement = keyReplacement || change.Origin.Layer == operationLayer && change.Path.String() == "/shape"
|
||||||
|
}
|
||||||
|
for _, change := range indexHistory {
|
||||||
|
if change.Origin.Layer == configkit.LayerMain {
|
||||||
|
t.Fatalf("introduced index inherited mapping-key history: %#v", indexHistory)
|
||||||
|
}
|
||||||
|
indexReplacement = indexReplacement || change.Origin.Layer == operationLayer && change.Path.String() == "/shape"
|
||||||
|
for _, previous := range change.Previous {
|
||||||
|
if previous.Layer == configkit.LayerMain {
|
||||||
|
t.Fatalf("introduced index inherited mapping-key previous origin: %#v", change)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !keyDefinition || !keyReplacement || !indexReplacement {
|
||||||
|
t.Fatalf("shape histories missed applicable definitions or replacement: key %#v, index %#v", keyHistory, indexHistory)
|
||||||
|
}
|
||||||
|
parsed, err := configkit.ParsePath("/shape/0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if parsedHistory := metadata.History(parsed); len(parsedHistory) == 0 {
|
||||||
|
t.Fatal("parsed numeric query lost contextual history")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func pathStrings(paths []configkit.Path) []string {
|
||||||
|
result := make([]string, len(paths))
|
||||||
|
for index, path := range paths {
|
||||||
|
result[index] = path.String()
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
@@ -8,13 +8,13 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "go.maximumdirect.net/configkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestFacadeNativeDiscoveryPreservesAliasIdentity(t *testing.T) {
|
func TestFacadeNativeDiscoveryPreservesAliasIdentity(t *testing.T) {
|
||||||
root := t.TempDir()
|
root := t.TempDir()
|
||||||
for name, contents := range map[string]string{
|
for name, contents := range map[string]string{
|
||||||
"data/item.yml": "configkit:\n imports:\n - path: helper.yml\nvalue: item\n",
|
"data/item.yml": "config:\n imports:\n - path: helper.yml\nvalue: item\n",
|
||||||
"data/helper.yml": "owner: target\nresource: resource.txt\n",
|
"data/helper.yml": "owner: target\nresource: resource.txt\n",
|
||||||
"aliases/helper.yml": "owner: alias\nresource: resource.txt\n",
|
"aliases/helper.yml": "owner: alias\nresource: resource.txt\n",
|
||||||
"data/resource.txt": "target resource",
|
"data/resource.txt": "target resource",
|
||||||
@@ -50,7 +50,7 @@ func TestFacadeNativeDiscoveryPreservesAliasIdentity(t *testing.T) {
|
|||||||
{"directory/glob", "glob: 'lin*/ite*.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) {
|
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]{
|
result, err := configkit.Load(configkit.Bytes("main", []byte("config:\n imports:\n - "+test.declaration+"\n")), configkit.Options[configuration]{
|
||||||
Imports: true, Filesystem: &configkit.FilesystemContext{FS: &filesystem, Root: ".", BaseDir: "."},
|
Imports: true, Filesystem: &configkit.FilesystemContext{FS: &filesystem, Root: ".", BaseDir: "."},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -82,14 +82,14 @@ func TestFacadeNativeDiscoveryPreservesAliasIdentity(t *testing.T) {
|
|||||||
// The same native path is still a repeat when separately included. An alias
|
// 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.
|
// 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'"} {
|
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]{
|
_, err := configkit.Load(configkit.Bytes("main", []byte("config:\n imports:\n - "+selector+"\n - path: aliases/item.yml\n")), configkit.Options[configuration]{
|
||||||
Imports: true, Filesystem: &configkit.FilesystemContext{FS: &filesystem, Root: ".", BaseDir: "."},
|
Imports: true, Filesystem: &configkit.FilesystemContext{FS: &filesystem, Root: ".", BaseDir: "."},
|
||||||
})
|
})
|
||||||
var loadError *configkit.LoadError
|
var loadError *configkit.LoadError
|
||||||
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != configkit.CodeImportRepeated {
|
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != configkit.CodeImportRepeated {
|
||||||
t.Fatalf("repeated source: %v", err)
|
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]{
|
_, err = configkit.Load(configkit.Bytes("main", []byte("config:\n imports:\n - "+selector+"\n - path: data/item.yml\n")), configkit.Options[configuration]{
|
||||||
Imports: true, Filesystem: &configkit.FilesystemContext{FS: &filesystem, Root: ".", BaseDir: "."},
|
Imports: true, Filesystem: &configkit.FilesystemContext{FS: &filesystem, Root: ".", BaseDir: "."},
|
||||||
})
|
})
|
||||||
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != configkit.CodeComposeConflict {
|
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != configkit.CodeComposeConflict {
|
||||||
|
|||||||
@@ -6,8 +6,6 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestImportDepthChecksSuffixAfterSaturation(t *testing.T) {
|
func TestImportDepthChecksSuffixAfterSaturation(t *testing.T) {
|
||||||
@@ -21,7 +19,7 @@ func TestImportDepthChecksSuffixAfterSaturation(t *testing.T) {
|
|||||||
{"-0", true},
|
{"-0", true},
|
||||||
} {
|
} {
|
||||||
t.Run(test.depth, func(t *testing.T) {
|
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")
|
main := writeFacadeImportFile(t, t.TempDir(), "main.yml", "value: true\nconfig:\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})
|
result, err := Load[map[string]any](File(main), Options[map[string]any]{Imports: true})
|
||||||
if test.valid {
|
if test.valid {
|
||||||
if err != nil || result.Config["value"] != true {
|
if err != nil || result.Config["value"] != true {
|
||||||
@@ -33,7 +31,7 @@ func TestImportDepthChecksSuffixAfterSaturation(t *testing.T) {
|
|||||||
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 {
|
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 {
|
||||||
t.Fatalf("invalid depth accepted: %v", err)
|
t.Fatalf("invalid depth accepted: %v", err)
|
||||||
}
|
}
|
||||||
if diagnostic := loadError.Diagnostics()[0]; diagnostic.Code != CodeImportInvalid || diagnostic.Path.String() != "/configkit/imports/0/depth" {
|
if diagnostic := loadError.Diagnostics()[0]; diagnostic.Code != CodeImportInvalid || diagnostic.Path.String() != "/config/imports/0/depth" {
|
||||||
t.Fatalf("invalid depth diagnostic = %#v", diagnostic)
|
t.Fatalf("invalid depth diagnostic = %#v", diagnostic)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -49,12 +47,12 @@ func TestImportDefaultsApplyToTransitiveAndUnderlayImports(t *testing.T) {
|
|||||||
IgnoredDeep string `yaml:"ignored_deep"`
|
IgnoredDeep string `yaml:"ignored_deep"`
|
||||||
}
|
}
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeFacadeImportFile(t, directory, "main.yml", `configkit:
|
mainPath := writeFacadeImportFile(t, directory, "main.yml", `config:
|
||||||
imports:
|
imports:
|
||||||
- path: parent/parent.yml
|
- path: parent/parent.yml
|
||||||
main: main
|
main: main
|
||||||
`)
|
`)
|
||||||
writeFacadeImportFile(t, directory, "parent/parent.yml", `configkit:
|
writeFacadeImportFile(t, directory, "parent/parent.yml", `config:
|
||||||
imports:
|
imports:
|
||||||
- dir: generated
|
- dir: generated
|
||||||
- dir: explicit
|
- dir: explicit
|
||||||
@@ -66,7 +64,7 @@ main: main
|
|||||||
explicitPath := writeFacadeImportFile(t, directory, "parent/explicit/exception.yaml", "exception: declaration\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/deep/ignored.yaml", "ignored_deep: wrong-depth\n")
|
||||||
writeFacadeImportFile(t, directory, "parent/explicit/ignored.cfg", "ignored_deep: wrong-explicit-pattern\n")
|
writeFacadeImportFile(t, directory, "parent/explicit/ignored.cfg", "ignored_deep: wrong-explicit-pattern\n")
|
||||||
underlayPath := writeFacadeImportFile(t, directory, "underlay.yml", `configkit:
|
underlayPath := writeFacadeImportFile(t, directory, "underlay.yml", `config:
|
||||||
imports:
|
imports:
|
||||||
- dir: underlay-files
|
- dir: underlay-files
|
||||||
`)
|
`)
|
||||||
@@ -95,25 +93,32 @@ main: main
|
|||||||
|
|
||||||
func TestImportDefaultsPreserveEmptyPatternAndRequiredness(t *testing.T) {
|
func TestImportDefaultsPreserveEmptyPatternAndRequiredness(t *testing.T) {
|
||||||
type config struct {
|
type config struct {
|
||||||
Value string `yaml:"value"`
|
Value string `yaml:"value"`
|
||||||
|
Hidden string `yaml:"hidden"`
|
||||||
}
|
}
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeFacadeImportFile(t, directory, "main.yml", `configkit:
|
mainPath := writeFacadeImportFile(t, directory, "main.yml", `config:
|
||||||
imports:
|
imports:
|
||||||
- dir: snippets
|
- dir: snippets
|
||||||
`)
|
`)
|
||||||
writeFacadeImportFile(t, directory, "snippets/value.cfg", "value: imported\n")
|
writeFacadeImportFile(t, directory, "snippets/value.cfg", "value: imported\n")
|
||||||
|
writeFacadeImportFile(t, directory, "snippets/.hidden.cfg", "hidden: imported\n")
|
||||||
|
|
||||||
unrestricted, err := Load(File(mainPath), Options[config]{Imports: true})
|
unrestricted, err := Load(File(mainPath), Options[config]{Imports: true})
|
||||||
if err != nil || unrestricted.Config.Value != "imported" {
|
if err != nil || unrestricted.Config.Value != "imported" {
|
||||||
t.Fatalf("unspecified pattern = %#v, %v", unrestricted, err)
|
t.Fatalf("unspecified pattern = %#v, %v", unrestricted, err)
|
||||||
}
|
}
|
||||||
|
includeHidden := false
|
||||||
|
visibleOnly, err := Load(File(mainPath), Options[config]{Imports: true, ImportDefaults: ImportDefaults{IncludeHidden: &includeHidden}})
|
||||||
|
if err != nil || visibleOnly.Config.Value != "imported" || visibleOnly.Config.Hidden != "" {
|
||||||
|
t.Fatalf("hidden-file default = %#v, %v", visibleOnly, err)
|
||||||
|
}
|
||||||
empty, err := Load(File(mainPath), Options[config]{Imports: true, ImportDefaults: ImportDefaults{Pattern: []string{}}})
|
empty, err := Load(File(mainPath), Options[config]{Imports: true, ImportDefaults: ImportDefaults{Pattern: []string{}}})
|
||||||
if err != nil || empty.Config.Value != "" {
|
if err != nil || empty.Config.Value != "" {
|
||||||
t.Fatalf("empty pattern = %#v, %v", empty, err)
|
t.Fatalf("empty pattern = %#v, %v", empty, err)
|
||||||
}
|
}
|
||||||
emptySources := empty.Sources()
|
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 {
|
if len(emptySources) != 1 || len(emptySources[0].EmptyImportSelections) != 1 || emptySources[0].EmptyImportSelections[0].Path.String() != "/config/imports/0" || emptySources[0].EmptyImportSelections[0].Selector != ImportSelectorDirectory {
|
||||||
t.Fatalf("empty-pattern evidence = %#v", emptySources)
|
t.Fatalf("empty-pattern evidence = %#v", emptySources)
|
||||||
}
|
}
|
||||||
required := true
|
required := true
|
||||||
@@ -122,23 +127,64 @@ func TestImportDefaultsPreserveEmptyPatternAndRequiredness(t *testing.T) {
|
|||||||
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != CodeImportMissing {
|
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != CodeImportMissing {
|
||||||
t.Fatalf("required empty selection error = %T %v", err, err)
|
t.Fatalf("required empty selection error = %T %v", err, err)
|
||||||
}
|
}
|
||||||
if sources := loadError.Sources(); len(sources) != 1 || len(sources[0].ImportSelections) != 0 {
|
if sources := loadError.Sources(); len(sources) != 1 || len(sources[0].EmptyImportSelections) != 0 {
|
||||||
t.Fatalf("required empty selection was recorded as optional: %#v", sources)
|
t.Fatalf("required empty selection was recorded as optional: %#v", sources)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
recursiveMain := writeFacadeImportFile(t, directory, "recursive.yml", `configkit:
|
func TestImportDefaultsAreSnapshottedBeforeOrdinaryAndLayeredCallbacks(t *testing.T) {
|
||||||
|
type config struct {
|
||||||
|
Version string `yaml:"version"`
|
||||||
|
Imported string `yaml:"imported"`
|
||||||
|
}
|
||||||
|
for _, layered := range []bool{false, true} {
|
||||||
|
t.Run(map[bool]string{false: "ordinary", true: "layered"}[layered], func(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
mainPath := writeFacadeImportFile(t, directory, "main.yml", `version: v1
|
||||||
|
config:
|
||||||
imports:
|
imports:
|
||||||
- dir: recursive-files
|
- dir: parts
|
||||||
`)
|
`)
|
||||||
writeFacadeImportFile(t, directory, "recursive-files/nested/value.cfg", "value: selected\n")
|
writeFacadeImportFile(t, directory, "parts/nested/value.cfg", "imported: selected\n")
|
||||||
recursive := true
|
|
||||||
patterns := []string{"*.cfg"}
|
recursive := true
|
||||||
engineOptions := toEngineOptions(Options[config]{Imports: true, ImportDefaults: ImportDefaults{Recursive: &recursive, Pattern: patterns}})
|
patterns := []string{"*.cfg"}
|
||||||
recursive = false
|
definition := Definition[config]{Version: &VersionRule{
|
||||||
patterns[0] = "["
|
Path: Keys("version"),
|
||||||
loadedEngine, err := engine.Load(File(recursiveMain).toEngine(), secretBinding(), engineOptions)
|
Check: func(version Version, _ *Validation) error {
|
||||||
if err != nil || loadedEngine.Config.Value != "selected" {
|
if version != (Version{Present: true, Text: "v1"}) {
|
||||||
t.Fatalf("load after mutating caller defaults = %#v, %v", loadedEngine, err)
|
t.Errorf("version callback received %#v", version)
|
||||||
|
}
|
||||||
|
recursive = false
|
||||||
|
patterns[0] = "*.skip"
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
load := func() (*Result[config], error) {
|
||||||
|
defaults := ImportDefaults{Recursive: &recursive, Pattern: patterns}
|
||||||
|
if layered {
|
||||||
|
return LoadLayered(Input{Source: File(mainPath), Imports: true}, LayeredOptions[config]{
|
||||||
|
Definition: definition, SourceVersionMode: SourceVersionModeIfPresent, ImportDefaults: defaults,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return Load(File(mainPath), Options[config]{
|
||||||
|
Definition: definition, SourceVersionMode: SourceVersionModeIfPresent, Imports: true, ImportDefaults: defaults,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
first, err := load()
|
||||||
|
if err != nil || first.Config.Imported != "selected" {
|
||||||
|
t.Fatalf("load with pre-callback defaults = %#v, %v", first, err)
|
||||||
|
}
|
||||||
|
if recursive || patterns[0] != "*.skip" {
|
||||||
|
t.Fatalf("version callback did not mutate caller defaults: recursive=%v pattern=%q", recursive, patterns[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
second, err := load()
|
||||||
|
if err != nil || second.Config.Imported != "" {
|
||||||
|
t.Fatalf("subsequent load reused earlier defaults: %#v, %v", second, err)
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -183,7 +229,7 @@ func TestPublicImportSelectionSnapshotsAreDetachedAcrossResultErrorAndResolution
|
|||||||
Value string `yaml:"value"`
|
Value string `yaml:"value"`
|
||||||
}
|
}
|
||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := writeFacadeImportFile(t, directory, "main.yml", `configkit:
|
mainPath := writeFacadeImportFile(t, directory, "main.yml", `config:
|
||||||
imports:
|
imports:
|
||||||
- dir: absent
|
- dir: absent
|
||||||
value: loaded
|
value: loaded
|
||||||
@@ -191,33 +237,33 @@ value: loaded
|
|||||||
callbackPath := ""
|
callbackPath := ""
|
||||||
loaded, err := Load(File(mainPath), Options[config]{Imports: true, Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error {
|
loaded, err := Load(File(mainPath), Options[config]{Imports: true, Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error {
|
||||||
first, ok := resolution.Source("source-1")
|
first, ok := resolution.Source("source-1")
|
||||||
if !ok || len(first.ImportSelections) != 1 {
|
if !ok || len(first.EmptyImportSelections) != 1 {
|
||||||
t.Fatalf("resolution source = %#v, %v", first, ok)
|
t.Fatalf("resolution source = %#v, %v", first, ok)
|
||||||
}
|
}
|
||||||
first.ImportSelections[0].Path = Keys("callback mutation")
|
first.EmptyImportSelections[0].Path = Keys("callback mutation")
|
||||||
second, ok := resolution.Source("source-1")
|
second, ok := resolution.Source("source-1")
|
||||||
if !ok || second.ImportSelections[0].Path.String() != "/configkit/imports/0" {
|
if !ok || second.EmptyImportSelections[0].Path.String() != "/config/imports/0" {
|
||||||
t.Fatalf("resolution snapshot changed after mutation: %#v", second)
|
t.Fatalf("resolution snapshot changed after mutation: %#v", second)
|
||||||
}
|
}
|
||||||
callbackPath = second.ImportSelections[0].Path.String()
|
callbackPath = second.EmptyImportSelections[0].Path.String()
|
||||||
return nil
|
return nil
|
||||||
}}})
|
}}})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if callbackPath != "/configkit/imports/0" {
|
if callbackPath != "/config/imports/0" {
|
||||||
t.Fatalf("callback path = %q", callbackPath)
|
t.Fatalf("callback path = %q", callbackPath)
|
||||||
}
|
}
|
||||||
sources := loaded.Sources()
|
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 {
|
if len(sources) != 1 || len(sources[0].EmptyImportSelections) != 1 || sources[0].EmptyImportSelections[0].Location.SourceID != sources[0].ID || sources[0].EmptyImportSelections[0].Selector != ImportSelectorDirectory {
|
||||||
t.Fatalf("result source evidence = %#v", sources)
|
t.Fatalf("result source evidence = %#v", sources)
|
||||||
}
|
}
|
||||||
sources[0].ImportSelections[0].Path = Keys("result mutation")
|
sources[0].EmptyImportSelections[0].Path = Keys("result mutation")
|
||||||
if got := loaded.Sources()[0].ImportSelections[0].Path.String(); got != "/configkit/imports/0" {
|
if got := loaded.Sources()[0].EmptyImportSelections[0].Path.String(); got != "/config/imports/0" {
|
||||||
t.Fatalf("result snapshot changed after mutation: %q", got)
|
t.Fatalf("result snapshot changed after mutation: %q", got)
|
||||||
}
|
}
|
||||||
|
|
||||||
failurePath := writeFacadeImportFile(t, directory, "failure.yml", `configkit:
|
failurePath := writeFacadeImportFile(t, directory, "failure.yml", `config:
|
||||||
imports:
|
imports:
|
||||||
- dir: absent
|
- dir: absent
|
||||||
- path: required-missing.yml
|
- path: required-missing.yml
|
||||||
@@ -229,11 +275,11 @@ value: loaded
|
|||||||
t.Fatalf("failed load error = %T %v", err, err)
|
t.Fatalf("failed load error = %T %v", err, err)
|
||||||
}
|
}
|
||||||
errorSources := loadError.Sources()
|
errorSources := loadError.Sources()
|
||||||
if len(errorSources) != 1 || len(errorSources[0].ImportSelections) != 1 || errorSources[0].ImportSelections[0].Path.String() != "/configkit/imports/0" {
|
if len(errorSources) != 1 || len(errorSources[0].EmptyImportSelections) != 1 || errorSources[0].EmptyImportSelections[0].Path.String() != "/config/imports/0" {
|
||||||
t.Fatalf("error source evidence = %#v", errorSources)
|
t.Fatalf("error source evidence = %#v", errorSources)
|
||||||
}
|
}
|
||||||
errorSources[0].ImportSelections[0].Path = Keys("error mutation")
|
errorSources[0].EmptyImportSelections[0].Path = Keys("error mutation")
|
||||||
if got := loadError.Sources()[0].ImportSelections[0].Path.String(); got != "/configkit/imports/0" {
|
if got := loadError.Sources()[0].EmptyImportSelections[0].Path.String(); got != "/config/imports/0" {
|
||||||
t.Fatalf("error snapshot changed after mutation: %q", got)
|
t.Fatalf("error snapshot changed after mutation: %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
func TestDiscoveredItemHelpersPreserveNestedInheritedResourceEvidence(t *testing.T) {
|
func TestDiscoveredItemHelpersPreserveNestedInheritedResourceEvidence(t *testing.T) {
|
||||||
const canary = "PRIVATE-CONSUMED-HEADER-CANARY"
|
const canary = "PRIVATE-CONSUMED-HEADER-CANARY"
|
||||||
filesystem := NewFilesystem("bundle", fstest.MapFS{
|
filesystem := NewFilesystem("bundle", fstest.MapFS{
|
||||||
"bundle/config/main.yml": {Data: []byte(`configkit:
|
"bundle/config/main.yml": {Data: []byte(`config:
|
||||||
imports:
|
imports:
|
||||||
- dir: records
|
- dir: records
|
||||||
pattern: '*-item.yml'
|
pattern: '*-item.yml'
|
||||||
@@ -23,7 +23,7 @@ services:
|
|||||||
- id: authored
|
- id: authored
|
||||||
version: authored
|
version: authored
|
||||||
`)},
|
`)},
|
||||||
"bundle/config/records/a-item.yml": {Data: []byte(`configkit:
|
"bundle/config/records/a-item.yml": {Data: []byte(`config:
|
||||||
imports:
|
imports:
|
||||||
- path: helper.yml
|
- path: helper.yml
|
||||||
version: ` + canary + `
|
version: ` + canary + `
|
||||||
@@ -42,7 +42,7 @@ entries:
|
|||||||
Definition: Definition[map[string]any]{
|
Definition: Definition[map[string]any]{
|
||||||
Version: &VersionRule{Path: Keys("version"), Check: func(Version, *Validation) error { return nil }},
|
Version: &VersionRule{Path: Keys("version"), Check: func(Version, *Validation) error { return nil }},
|
||||||
SequenceIdentities: []SequenceIdentity{SequenceIdentityAt(Keys("services", "records"), "id")},
|
SequenceIdentities: []SequenceIdentity{SequenceIdentityAt(Keys("services", "records"), "id")},
|
||||||
Secret: func(candidate Path) bool {
|
Sensitive: func(candidate Path) bool {
|
||||||
return candidate.String() == "/services/records/0/version"
|
return candidate.String() == "/services/records/0/version"
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -67,6 +67,14 @@ entries:
|
|||||||
}
|
}
|
||||||
|
|
||||||
sources := result.Sources()
|
sources := result.Sources()
|
||||||
|
if len(sources) < 3 || sources[0].Layer != LayerMain || string(sources[0].Layer) != "main" {
|
||||||
|
t.Fatalf("main source attribution = %#v", sources)
|
||||||
|
}
|
||||||
|
for _, source := range sources[1:] {
|
||||||
|
if source.ImportedFrom != nil && (source.Layer != LayerMain || string(source.Layer) != "main") {
|
||||||
|
t.Errorf("imported main source attribution = %#v", source)
|
||||||
|
}
|
||||||
|
}
|
||||||
filesByID := make(map[string]string, len(sources))
|
filesByID := make(map[string]string, len(sources))
|
||||||
for _, source := range sources {
|
for _, source := range sources {
|
||||||
filesByID[source.ID] = source.Path
|
filesByID[source.ID] = source.Path
|
||||||
@@ -77,8 +85,8 @@ entries:
|
|||||||
itemSource = source
|
itemSource = source
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if itemSource.VersionCheck == nil || !itemSource.VersionCheck.Version.Present || !itemSource.VersionCheck.Redacted || itemSource.VersionCheck.Version.Text != "" {
|
if itemSource.VersionEvidence == nil || !itemSource.VersionEvidence.Version.Present || !itemSource.VersionEvidence.Redacted || itemSource.VersionEvidence.Version.Text != "" {
|
||||||
t.Fatalf("consumed header inspection = %#v", itemSource.VersionCheck)
|
t.Fatalf("consumed header inspection = %#v", itemSource.VersionEvidence)
|
||||||
}
|
}
|
||||||
idInfo, ok := result.Metadata().Lookup(Keys("services", "records").Index(0).Key("id"))
|
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" {
|
if !ok || len(idInfo.Origin.Locations) != 1 || filesByID[idInfo.Origin.Locations[0].SourceID] != "bundle/config/records/helper.yml" {
|
||||||
@@ -107,7 +115,7 @@ entries:
|
|||||||
Definition: Definition[map[string]any]{
|
Definition: Definition[map[string]any]{
|
||||||
Version: &VersionRule{Path: Keys("version"), Check: func(Version, *Validation) error { return nil }},
|
Version: &VersionRule{Path: Keys("version"), Check: func(Version, *Validation) error { return nil }},
|
||||||
SequenceIdentities: []SequenceIdentity{SequenceIdentityAt(Keys("services", "records"), "version")},
|
SequenceIdentities: []SequenceIdentity{SequenceIdentityAt(Keys("services", "records"), "version")},
|
||||||
Secret: func(candidate Path) bool {
|
Sensitive: func(candidate Path) bool {
|
||||||
return candidate.String() == "/services/records/0/version"
|
return candidate.String() == "/services/records/0/version"
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -129,7 +137,7 @@ entries:
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if !foundFailedItem || failedItemSource.VersionCheck == nil || failedItemSource.VersionCheck.Version.Text != "" || !failedItemSource.VersionCheck.Redacted {
|
if !foundFailedItem || failedItemSource.VersionEvidence == nil || failedItemSource.VersionEvidence.Version.Text != "" || !failedItemSource.VersionEvidence.Redacted {
|
||||||
t.Fatalf("failed-load source inspection exposed or omitted header evidence: %#v", failedItemSource)
|
t.Fatalf("failed-load source inspection exposed or omitted header evidence: %#v", failedItemSource)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "go.maximumdirect.net/configkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestFacadeDirectoryRootBelowContainedAbsoluteSymlink(t *testing.T) {
|
func TestFacadeDirectoryRootBelowContainedAbsoluteSymlink(t *testing.T) {
|
||||||
@@ -29,7 +29,7 @@ func TestFacadeDirectoryRootBelowContainedAbsoluteSymlink(t *testing.T) {
|
|||||||
for _, follow := range []bool{true, false} {
|
for _, follow := range []bool{true, false} {
|
||||||
t.Run(fmt.Sprintf("follow=%t", follow), func(t *testing.T) {
|
t.Run(fmt.Sprintf("follow=%t", follow), func(t *testing.T) {
|
||||||
mainPath := filepath.Join(root, "main.yml")
|
mainPath := filepath.Join(root, "main.yml")
|
||||||
contents := fmt.Sprintf("configkit:\n imports:\n - dir: absolute/parts\n pattern: '*.yml'\n follow: %t\n", follow)
|
contents := fmt.Sprintf("config:\n imports:\n - dir: absolute/parts\n pattern: '*.yml'\n follow: %t\n", follow)
|
||||||
if err := os.WriteFile(mainPath, []byte(contents), 0o600); err != nil {
|
if err := os.WriteFile(mainPath, []byte(contents), 0o600); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -65,7 +65,7 @@ func TestFacadeNativeDiscoveryWithNestedRoot(t *testing.T) {
|
|||||||
"dir: parts", "glob: 'parts/*.yml'", "dir: linked", "glob: 'lin*/*.yml'",
|
"dir: parts", "glob: 'parts/*.yml'", "dir: linked", "glob: 'lin*/*.yml'",
|
||||||
} {
|
} {
|
||||||
t.Run(selector, func(t *testing.T) {
|
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})
|
result, err := configkit.Load(configkit.Bytes("main", []byte("config:\n imports:\n - "+selector+"\n")), configkit.Options[map[string]any]{Imports: true, Filesystem: context})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -81,7 +81,7 @@ func TestFacadeNativeDiscoveryWithNestedRoot(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
for _, selector := range []string{"dir: parts", "glob: 'parts/*.yml'"} {
|
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})
|
_, err := configkit.Load(configkit.Bytes("main", []byte("config:\n imports:\n - "+selector+"\n")), configkit.Options[map[string]any]{Imports: true, Filesystem: context})
|
||||||
var loadError *configkit.LoadError
|
var loadError *configkit.LoadError
|
||||||
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != configkit.CodeImportOutsideRoot {
|
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != configkit.CodeImportOutsideRoot {
|
||||||
t.Fatalf("escaping %s: %v", selector, err)
|
t.Fatalf("escaping %s: %v", selector, err)
|
||||||
@@ -107,7 +107,7 @@ func TestFacadeDiscoveryAllowsFiniteLinkReuse(t *testing.T) {
|
|||||||
if native {
|
if native {
|
||||||
context = &configkit.FilesystemContext{FS: &filesystem, Root: ".", BaseDir: "."}
|
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})
|
result, err := configkit.Load(configkit.Bytes("main", []byte("config:\n imports:\n - "+selector+"\n")), configkit.Options[map[string]any]{Imports: true, Filesystem: context})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("native=%v selector=%s: %v", native, selector, err)
|
t.Fatalf("native=%v selector=%s: %v", native, selector, err)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"testing/fstest"
|
"testing/fstest"
|
||||||
|
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "go.maximumdirect.net/configkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
type inheritedEntry struct {
|
type inheritedEntry struct {
|
||||||
@@ -42,7 +42,7 @@ func TestFacadeInheritancePreservesPresenceOriginsAndHistory(t *testing.T) {
|
|||||||
if !resolution.IsExplicit(configkit.Keys("entries", "child", "enabled")) {
|
if !resolution.IsExplicit(configkit.Keys("entries", "child", "enabled")) {
|
||||||
t.Error("inherited false is not explicit")
|
t.Error("inherited false is not explicit")
|
||||||
}
|
}
|
||||||
info, found := resolution.InputInfo(configkit.Keys("entries", "child", "enabled"))
|
info, found := resolution.EntryInfo(configkit.Keys("entries", "child", "enabled"))
|
||||||
if !found || len(info.Origin.Inheritance) != 1 {
|
if !found || len(info.Origin.Inheritance) != 1 {
|
||||||
t.Errorf("resolver inherited origin = %#v, %v", info, found)
|
t.Errorf("resolver inherited origin = %#v, %v", info, found)
|
||||||
}
|
}
|
||||||
@@ -139,7 +139,7 @@ func TestFacadeIdentifiedInheritanceResolvesChainsAndNestedCollections(t *testin
|
|||||||
result, err := configkit.Load(filesystem.File("config/identified.yml"), configkit.Options[configuration]{
|
result, err := configkit.Load(filesystem.File("config/identified.yml"), configkit.Options[configuration]{
|
||||||
Definition: configkit.Definition[configuration]{
|
Definition: configkit.Definition[configuration]{
|
||||||
SequenceIdentities: registrations,
|
SequenceIdentities: registrations,
|
||||||
Secret: func(path configkit.Path) bool {
|
Sensitive: func(path configkit.Path) bool {
|
||||||
return path.String() == "/jobs/0/credential" || path.String() == "/jobs/1/credential" || path.String() == "/jobs/2/credential"
|
return path.String() == "/jobs/0/credential" || path.String() == "/jobs/1/credential" || path.String() == "/jobs/2/credential"
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -221,7 +221,7 @@ func TestFacadeIdentifiedInheritanceFailureDoesNotRevealSecretCanary(t *testing.
|
|||||||
_, err := configkit.Load(configkit.Bytes("identified-failure.yml", input), configkit.Options[configuration]{
|
_, err := configkit.Load(configkit.Bytes("identified-failure.yml", input), configkit.Options[configuration]{
|
||||||
Definition: configkit.Definition[configuration]{
|
Definition: configkit.Definition[configuration]{
|
||||||
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("jobs"), "id")},
|
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("jobs"), "id")},
|
||||||
Secret: func(path configkit.Path) bool { return path.String() == "/jobs/0/credential" },
|
Sensitive: func(path configkit.Path) bool { return path.String() == "/jobs/0/credential" },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if err == nil || strings.Contains(err.Error(), canary) {
|
if err == nil || strings.Contains(err.Error(), canary) {
|
||||||
@@ -231,7 +231,7 @@ func TestFacadeIdentifiedInheritanceFailureDoesNotRevealSecretCanary(t *testing.
|
|||||||
|
|
||||||
func TestFacadeLayeredIdentifiedInheritanceUsesSurvivingRecords(t *testing.T) {
|
func TestFacadeLayeredIdentifiedInheritanceUsesSurvivingRecords(t *testing.T) {
|
||||||
policy := func(mode string) string {
|
policy := func(mode string) string {
|
||||||
return "configkit:\n sequences:\n - destination: [jobs]\n key: id\n mode: " + mode + "\n"
|
return "config:\n sequences:\n - destination: [jobs]\n key: id\n mode: " + mode + "\n"
|
||||||
}
|
}
|
||||||
tests := []struct {
|
tests := []struct {
|
||||||
name string
|
name string
|
||||||
@@ -416,7 +416,7 @@ func TestFacadeInheritanceFailureStopsLaterCallbacks(t *testing.T) {
|
|||||||
|
|
||||||
func TestFacadeCLIOverridesCannotChangeInheritanceRelationship(t *testing.T) {
|
func TestFacadeCLIOverridesCannotChangeInheritanceRelationship(t *testing.T) {
|
||||||
filesystem := configkit.NewFilesystem("bundle", fstest.MapFS{
|
filesystem := configkit.NewFilesystem("bundle", fstest.MapFS{
|
||||||
"main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: parent.yml}]}\nentries: {child: {extends: first}}\n")},
|
"main.yml": &fstest.MapFile{Data: []byte("config: {imports: [{path: parent.yml}]}\nentries: {child: {extends: first}}\n")},
|
||||||
"parent.yml": &fstest.MapFile{Data: []byte("entries: {first: {count: 1}, second: {count: 2}}\n")},
|
"parent.yml": &fstest.MapFile{Data: []byte("entries: {first: {count: 1}, second: {count: 2}}\n")},
|
||||||
})
|
})
|
||||||
_, err := configkit.Load(filesystem.File("main.yml"), configkit.Options[inheritedConfiguration]{
|
_, err := configkit.Load(filesystem.File("main.yml"), configkit.Options[inheritedConfiguration]{
|
||||||
@@ -427,7 +427,7 @@ func TestFacadeCLIOverridesCannotChangeInheritanceRelationship(t *testing.T) {
|
|||||||
t.Fatalf("CLI inheritance relationship change = %v", err)
|
t.Fatalf("CLI inheritance relationship change = %v", err)
|
||||||
}
|
}
|
||||||
conflicting := configkit.NewFilesystem("bundle", fstest.MapFS{
|
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")},
|
"main.yml": &fstest.MapFile{Data: []byte("config: {imports: [{path: parent.yml}]}\nentries: {child: {extends: first}, first: {count: 3}}\n")},
|
||||||
"parent.yml": &fstest.MapFile{Data: []byte("entries: {first: {count: 1}}\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})
|
_, err = configkit.Load[inheritedConfiguration](conflicting.File("main.yml"), configkit.Options[inheritedConfiguration]{Imports: true})
|
||||||
@@ -468,7 +468,7 @@ func TestFacadeInheritedSecretsStayRedactedAfterLookup(t *testing.T) {
|
|||||||
Entries map[string]entry `yaml:"entries"`
|
Entries map[string]entry `yaml:"entries"`
|
||||||
}
|
}
|
||||||
result, err := configkit.Load[configuration](configkit.Bytes("secret.yml", []byte("entries: {parent: {credential: CONFIGKIT_INHERITED_TEST_SECRET, plain: visible}, child: {extends: parent}}\n")), configkit.Options[configuration]{Definition: configkit.Definition[configuration]{
|
result, err := configkit.Load[configuration](configkit.Bytes("secret.yml", []byte("entries: {parent: {credential: CONFIGKIT_INHERITED_TEST_SECRET, plain: visible}, child: {extends: parent}}\n")), configkit.Options[configuration]{Definition: configkit.Definition[configuration]{
|
||||||
Secret: func(path configkit.Path) bool { return path.String() == "/entries/parent/credential" },
|
Sensitive: func(path configkit.Path) bool { return path.String() == "/entries/parent/credential" },
|
||||||
}})
|
}})
|
||||||
if err != nil || result.Config.Entries["child"].Credential.Value() != canary {
|
if err != nil || result.Config.Entries["child"].Credential.Value() != canary {
|
||||||
t.Fatalf("inherited credential = %#v, %v", result, err)
|
t.Fatalf("inherited credential = %#v, %v", result, err)
|
||||||
|
|||||||
@@ -1,52 +0,0 @@
|
|||||||
package configkit
|
|
||||||
|
|
||||||
import (
|
|
||||||
"errors"
|
|
||||||
"reflect"
|
|
||||||
"strings"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
|
|
||||||
)
|
|
||||||
|
|
||||||
func TestWrappedSourcesPreserveEngineOwnership(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
type config struct {
|
|
||||||
Value string `yaml:"value"`
|
|
||||||
}
|
|
||||||
data := []byte("value: retained\n")
|
|
||||||
source := Bytes("snapshot.yml", data)
|
|
||||||
data[7] = 'X'
|
|
||||||
result, err := engine.Load[config](source.toEngine(), engine.SecretBinding{})
|
|
||||||
if err != nil || result.Config.Value != "retained" {
|
|
||||||
t.Fatalf("snapshotted source = %#v, %v", result, err)
|
|
||||||
}
|
|
||||||
|
|
||||||
readerSource := Reader("reader.yml", strings.NewReader("value: once\n"))
|
|
||||||
first := readerSource
|
|
||||||
if _, err := engine.Load[config](first.toEngine(), engine.SecretBinding{}); err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
_, err = engine.Load[config](readerSource.toEngine(), engine.SecretBinding{})
|
|
||||||
var loadError *engine.LoadError
|
|
||||||
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != engine.CodeSourceReaderUsed {
|
|
||||||
t.Fatalf("copied reader source error = %#v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func TestWrappedPathsRetainEngineSegmentIntent(t *testing.T) {
|
|
||||||
t.Parallel()
|
|
||||||
|
|
||||||
parsed, err := ParsePath("/0")
|
|
||||||
if err != nil {
|
|
||||||
t.Fatal(err)
|
|
||||||
}
|
|
||||||
keyed, indexed := Keys("0"), Path{}.Index(0)
|
|
||||||
if parsed.String() != "/0" || keyed.String() != "/0" || indexed.String() != "/0" {
|
|
||||||
t.Fatalf("path spellings = %q, %q, %q", parsed, keyed, indexed)
|
|
||||||
}
|
|
||||||
if reflect.DeepEqual(parsed.value, keyed.value) || reflect.DeepEqual(parsed.value, indexed.value) || reflect.DeepEqual(keyed.value, indexed.value) {
|
|
||||||
t.Fatal("path wrappers collapsed parsed, mapping-key, and index segments")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"testing/fstest"
|
"testing/fstest"
|
||||||
|
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "go.maximumdirect.net/configkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestFacadeItemScopesDoNotReinterpretGlobalRegistrations(t *testing.T) {
|
func TestFacadeItemScopesDoNotReinterpretGlobalRegistrations(t *testing.T) {
|
||||||
@@ -22,14 +22,14 @@ func TestFacadeItemScopesDoNotReinterpretGlobalRegistrations(t *testing.T) {
|
|||||||
configkit.SequenceLiteralKey("catalog"), configkit.SequenceAnyMappingKey(), configkit.SequenceLiteralKey("prompts"),
|
configkit.SequenceLiteralKey("catalog"), configkit.SequenceAnyMappingKey(), configkit.SequenceLiteralKey("prompts"),
|
||||||
}, "id")
|
}, "id")
|
||||||
}
|
}
|
||||||
item := "configkit: {imports: [{path: helper.yml}, {path: child.yml, destination: [children], placement: sequence-item}]}\n"
|
item := "config: {imports: [{path: helper.yml}, {path: child.yml, destination: [children], placement: sequence-item}]}\n"
|
||||||
if !missingOuterID {
|
if !missingOuterID {
|
||||||
item += "id: outer\n"
|
item += "id: outer\n"
|
||||||
}
|
}
|
||||||
files := configkit.NewFilesystem("items", fstest.MapFS{
|
files := configkit.NewFilesystem("items", fstest.MapFS{
|
||||||
"main.yml": {Data: []byte("configkit: {imports: [{path: item.yml, destination: [items], placement: sequence-item}]}\n")},
|
"main.yml": {Data: []byte("config: {imports: [{path: item.yml, destination: [items], placement: sequence-item}]}\n")},
|
||||||
"item.yml": {Data: []byte(item)}, "helper.yml": {Data: []byte(payload)},
|
"item.yml": {Data: []byte(item)}, "helper.yml": {Data: []byte(payload)},
|
||||||
"child.yml": {Data: []byte("id: child\nconfigkit: {imports: [{path: child-helper.yml}]}\n")},
|
"child.yml": {Data: []byte("id: child\nconfig: {imports: [{path: child-helper.yml}]}\n")},
|
||||||
"child-helper.yml": {Data: []byte(payload)},
|
"child-helper.yml": {Data: []byte(payload)},
|
||||||
})
|
})
|
||||||
definition := configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{
|
definition := configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"testing/fstest"
|
"testing/fstest"
|
||||||
|
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "go.maximumdirect.net/configkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestFacadeItemDomainVersionsKeepEffectiveScope(t *testing.T) {
|
func TestFacadeItemDomainVersionsKeepEffectiveScope(t *testing.T) {
|
||||||
@@ -13,18 +13,18 @@ func TestFacadeItemDomainVersionsKeepEffectiveScope(t *testing.T) {
|
|||||||
underlay, mainItem bool
|
underlay, mainItem bool
|
||||||
}{
|
}{
|
||||||
{name: "item", item: "id: item\nversion: domain\n"},
|
{name: "item", item: "id: item\nversion: domain\n"},
|
||||||
{name: "helper", item: "id: item\nconfigkit: {imports: [{path: helper.yml}]}\n", helper: "version: domain\n"},
|
{name: "helper", item: "id: item\nconfig: {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: "nested", item: "id: parent\nconfig: {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: "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},
|
{name: "main-item", item: "id: parent\nconfig: {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) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
files := fstest.MapFS{
|
files := fstest.MapFS{
|
||||||
"main.yml": {Data: []byte("version: 1\nconfigkit: {imports: [{path: item.yml, destination: [items], placement: sequence-item}]}\n")},
|
"main.yml": {Data: []byte("version: 1\nconfig: {imports: [{path: item.yml, destination: [items], placement: sequence-item}]}\n")},
|
||||||
"item.yml": {Data: []byte(test.item)},
|
"item.yml": {Data: []byte(test.item)},
|
||||||
"child.yml": {Data: []byte(test.child)},
|
"child.yml": {Data: []byte(test.child)},
|
||||||
"helper.yml": {Data: []byte(test.helper)},
|
"helper.yml": {Data: []byte(test.helper)},
|
||||||
"underlay.yml": {Data: []byte("configkit: {imports: [{path: item.yml, destination: [items], placement: sequence-item}]}\n")},
|
"underlay.yml": {Data: []byte("config: {imports: [{path: item.yml, destination: [items], placement: sequence-item}]}\n")},
|
||||||
}
|
}
|
||||||
filesystem := configkit.NewFilesystem("items", files)
|
filesystem := configkit.NewFilesystem("items", files)
|
||||||
definition := configkit.Definition[map[string]any]{Version: &configkit.VersionRule{
|
definition := configkit.Definition[map[string]any]{Version: &configkit.VersionRule{
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"testing/fstest"
|
"testing/fstest"
|
||||||
|
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "go.maximumdirect.net/configkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
type layeredFacadeConfig struct {
|
type layeredFacadeConfig struct {
|
||||||
@@ -28,7 +28,7 @@ func TestFacadeLayeredMixedSourcesAndInspection(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
lower := configkit.NewFilesystem("bundle", fstest.MapFS{
|
lower := configkit.NewFilesystem("bundle", fstest.MapFS{
|
||||||
"main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: defaults.yml}]}\nsettings: {shared: lower, native: retained, secret: old-canary}\nlist: [lower]\n")},
|
"main.yml": &fstest.MapFile{Data: []byte("config: {imports: [{path: defaults.yml}]}\nsettings: {shared: lower, native: retained, secret: old-canary}\nlist: [lower]\n")},
|
||||||
"defaults.yml": &fstest.MapFile{Data: []byte("settings: {imported: retained}\n")},
|
"defaults.yml": &fstest.MapFile{Data: []byte("settings: {imported: retained}\n")},
|
||||||
"overlays/testing.yml": &fstest.MapFile{Data: []byte("settings: {from_lower_namespace: retained}\n")},
|
"overlays/testing.yml": &fstest.MapFile{Data: []byte("settings: {from_lower_namespace: retained}\n")},
|
||||||
})
|
})
|
||||||
@@ -59,8 +59,8 @@ func TestFacadeLayeredMixedSourcesAndInspection(t *testing.T) {
|
|||||||
configkit.Default(resolution, configkit.Keys("derived"), &config.Derived, config.Settings["native"], "derive from native", configkit.Keys("settings", "native"))
|
configkit.Default(resolution, configkit.Keys("derived"), &config.Derived, config.Settings["native"], "derive from native", configkit.Keys("settings", "native"))
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
Validate: func(config *layeredFacadeConfig, validation *configkit.Validation) { checks.validation++ },
|
Validate: func(config *layeredFacadeConfig, validation *configkit.Validation) { checks.validation++ },
|
||||||
Secret: func(path configkit.Path) bool { return path.String() == "/settings/secret" },
|
Sensitive: func(path configkit.Path) bool { return path.String() == "/settings/secret" },
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -80,7 +80,7 @@ func TestFacadeLayeredMixedSourcesAndInspection(t *testing.T) {
|
|||||||
if len(sources) != 8 {
|
if len(sources) != 8 {
|
||||||
t.Fatalf("sources = %#v", sources)
|
t.Fatalf("sources = %#v", sources)
|
||||||
}
|
}
|
||||||
wantLayers := []configkit.Layer{configkit.LayerBase, configkit.LayerUnderlay, configkit.LayerUnderlay, configkit.LayerUnderlay, configkit.LayerUnderlay, configkit.LayerUnderlay, configkit.LayerOverlay, configkit.LayerCLI}
|
wantLayers := []configkit.Layer{configkit.LayerMain, configkit.LayerUnderlay, configkit.LayerUnderlay, configkit.LayerUnderlay, configkit.LayerUnderlay, configkit.LayerUnderlay, configkit.LayerOverlay, configkit.LayerCLI}
|
||||||
wantIndexes := []int{0, 1, 1, 1, 1, 2, 0, 0}
|
wantIndexes := []int{0, 1, 1, 1, 1, 2, 0, 0}
|
||||||
wantKinds := []configkit.SourceKind{configkit.SourceKindFile, configkit.SourceKindFilesystem, configkit.SourceKindFilesystem, configkit.SourceKindMapping, configkit.SourceKindFilesystem, configkit.SourceKindMapping, configkit.SourceKindFilesystem, configkit.SourceKindCLI}
|
wantKinds := []configkit.SourceKind{configkit.SourceKindFile, configkit.SourceKindFilesystem, configkit.SourceKindFilesystem, configkit.SourceKindMapping, configkit.SourceKindFilesystem, configkit.SourceKindMapping, configkit.SourceKindFilesystem, configkit.SourceKindCLI}
|
||||||
for index, source := range sources {
|
for index, source := range sources {
|
||||||
|
|||||||
253
facade_main_relative_integration_test.go
Normal file
253
facade_main_relative_integration_test.go
Normal file
@@ -0,0 +1,253 @@
|
|||||||
|
package configkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
configkit "go.maximumdirect.net/configkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMainRelativeStandaloneRejectsInaccessibleHostRoot(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
mainPath := filepath.Join(root, "main.yml")
|
||||||
|
if err := os.WriteFile(mainPath, []byte("count: 1\n"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Chmod(root, 0100); err != nil {
|
||||||
|
t.Skipf("cannot set search-only permissions: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if err := os.Chmod(root, 0700); err != nil {
|
||||||
|
t.Error(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
handle, err := os.OpenRoot(root)
|
||||||
|
if err == nil {
|
||||||
|
_ = handle.Close()
|
||||||
|
t.Skip("host allows opening this search-only directory")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, os.ErrPermission) {
|
||||||
|
t.Fatalf("open root = %v, want permission denied", err)
|
||||||
|
}
|
||||||
|
if _, err := os.ReadFile(mainPath); err != nil {
|
||||||
|
t.Skipf("main must remain readable by name: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
main := configkit.Input{Source: configkit.File(mainPath)}
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
filesystem *configkit.FilesystemContext
|
||||||
|
}{
|
||||||
|
{name: "derived"},
|
||||||
|
{name: "explicit", filesystem: &configkit.FilesystemContext{Root: root, BaseDir: root}},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
underlay := configkit.Input{
|
||||||
|
Source: configkit.Bytes("companion", []byte("{}\n")),
|
||||||
|
MainRelative: &configkit.MainRelativeContext{RequireBase: true},
|
||||||
|
Filesystem: tc.filesystem,
|
||||||
|
}
|
||||||
|
_, err := configkit.LoadLayered(main, configkit.LayeredOptions[layeredFacadeConfig]{
|
||||||
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{underlay}}},
|
||||||
|
})
|
||||||
|
var failure *configkit.LoadError
|
||||||
|
if !errors.As(err, &failure) {
|
||||||
|
t.Fatalf("error = %v, want a load error", err)
|
||||||
|
}
|
||||||
|
diagnostics := failure.Diagnostics()
|
||||||
|
if len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeImportPermission {
|
||||||
|
t.Fatalf("diagnostics = %#v, want import.permission", diagnostics)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMainRelativeDiscoveryAndEvidence(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
first, second := filepath.Join(root, "first"), filepath.Join(root, "second")
|
||||||
|
for _, dir := range []string{first, second} {
|
||||||
|
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "main.yml"), []byte("count: 1\n"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "fragment.yml"), []byte("settings: {selected: "+filepath.Base(dir)+"}\n"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
binding := configkit.MainRelative()
|
||||||
|
underlay := configkit.Input{Source: configkit.Bytes("declaration", []byte("config: {imports: [{path: fragment.yml}]}\n")), Imports: true, MainRelative: binding}
|
||||||
|
for _, test := range []struct {
|
||||||
|
name string
|
||||||
|
paths []string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"first", []string{filepath.Join(first, "main.yml"), filepath.Join(second, "main.yml")}, "first"},
|
||||||
|
{"later", []string{filepath.Join(root, "missing.yml"), filepath.Join(second, "main.yml")}, "second"},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
var callbacks int
|
||||||
|
result, err := configkit.LoadLayered(configkit.Input{Source: configkit.SearchConfig("", configkit.SearchPaths(test.paths...))}, configkit.LayeredOptions[layeredFacadeConfig]{Underlays: []configkit.Underlay{{Inputs: []configkit.Input{underlay}}}, Definition: configkit.Definition[layeredFacadeConfig]{Validate: func(*layeredFacadeConfig, *configkit.Validation) { callbacks++ }}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%v: %#v", err, err.(*configkit.LoadError).Diagnostics())
|
||||||
|
}
|
||||||
|
if result.Config.Settings["selected"] != test.want || callbacks != 1 {
|
||||||
|
t.Fatalf("config = %#v, callbacks = %d", result.Config, callbacks)
|
||||||
|
}
|
||||||
|
sources := result.Sources()
|
||||||
|
if len(sources) != 3 || sources[0].Layer != configkit.LayerMain || sources[1].Layer != configkit.LayerUnderlay || sources[2].Layer != configkit.LayerUnderlay || sources[2].UnderlayIndex != 1 || filepath.Base(sources[2].Path) != "fragment.yml" || sources[1].Path != "" {
|
||||||
|
t.Fatalf("sources = %#v", sources)
|
||||||
|
}
|
||||||
|
if _, ok := result.SourceContext(sources[1].ID); !ok {
|
||||||
|
t.Fatal("bound synthetic source lacks context")
|
||||||
|
}
|
||||||
|
if _, ok := result.SourceContext(sources[2].ID); !ok {
|
||||||
|
t.Fatal("import source lacks context")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMainRelativeSyntheticMainContexts(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "fragment.yml"), []byte("settings: {selected: synthetic}\n"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ctx := &configkit.FilesystemContext{Root: root, BaseDir: root}
|
||||||
|
mainSources := []configkit.Source{configkit.Bytes("main bytes", []byte("count: 1\n")), configkit.Reader("main reader", strings.NewReader("count: 1\n")), configkit.Mapping("main mapping", map[string]any{"count": 1})}
|
||||||
|
for _, source := range mainSources {
|
||||||
|
input := configkit.Input{Source: configkit.Bytes("underlay", []byte("config: {imports: [{path: fragment.yml}]}\n")), Imports: true, MainRelative: configkit.MainRelative()}
|
||||||
|
result, err := configkit.LoadLayered(configkit.Input{Source: source, Filesystem: ctx}, configkit.LayeredOptions[layeredFacadeConfig]{Underlays: []configkit.Underlay{{Inputs: []configkit.Input{input}}}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%v: %#v", err, err.(*configkit.LoadError).Diagnostics())
|
||||||
|
}
|
||||||
|
if result.Config.Settings["selected"] != "synthetic" {
|
||||||
|
t.Fatalf("config = %#v", result.Config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMainRelativeSelectedMainFailureIsTerminal(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
bad, good := filepath.Join(root, "bad.yml"), filepath.Join(root, "good.yml")
|
||||||
|
if err := os.WriteFile(bad, []byte("count: [\n"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(good, []byte("count: 1\n"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
called := false
|
||||||
|
input := configkit.Input{Source: configkit.Bytes("underlay", []byte("count: 3\n")), MainRelative: &configkit.MainRelativeContext{Fallback: &configkit.FilesystemContext{Root: root, BaseDir: root}}}
|
||||||
|
_, err := configkit.LoadLayered(configkit.Input{Source: configkit.SearchConfig("", configkit.SearchPaths(bad, good))}, configkit.LayeredOptions[layeredFacadeConfig]{Underlays: []configkit.Underlay{{Inputs: []configkit.Input{input}}}, Definition: configkit.Definition[layeredFacadeConfig]{Validate: func(*layeredFacadeConfig, *configkit.Validation) { called = true }}})
|
||||||
|
if err == nil || called {
|
||||||
|
t.Fatalf("error = %v, callback = %v", err, called)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMainRelativeSourceVersionEvidence(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "main.yml"), []byte("version: 1\ncount: 1\n"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "fragment.yml"), []byte("version: 2\nsettings: {loaded: fragment}\n"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
input := configkit.Input{Source: configkit.Bytes("companion", []byte("version: 3\nconfig: {imports: [{path: fragment.yml}]}\n")), Imports: true, MainRelative: configkit.MainRelative()}
|
||||||
|
type versioned struct {
|
||||||
|
Version int `yaml:"version"`
|
||||||
|
Count int `yaml:"count"`
|
||||||
|
Settings map[string]string `yaml:"settings"`
|
||||||
|
}
|
||||||
|
definition := configkit.Definition[versioned]{Version: &configkit.VersionRule{Path: configkit.Keys("version"), Check: func(configkit.Version, *configkit.Validation) error { return nil }}}
|
||||||
|
result, err := configkit.LoadLayered(configkit.Input{Source: configkit.File(filepath.Join(root, "main.yml"))}, configkit.LayeredOptions[versioned]{Definition: definition, SourceVersionMode: configkit.SourceVersionModeRequired, Underlays: []configkit.Underlay{{Inputs: []configkit.Input{input}}}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%v: %#v", err, err.(*configkit.LoadError).Diagnostics())
|
||||||
|
}
|
||||||
|
sources := result.Sources()
|
||||||
|
if len(sources) != 3 {
|
||||||
|
t.Fatalf("sources = %#v", sources)
|
||||||
|
}
|
||||||
|
for i, want := range []string{"1", "3", "2"} {
|
||||||
|
evidence := sources[i].VersionEvidence
|
||||||
|
if evidence == nil || evidence.Version.Text != want || evidence.Location == nil || evidence.Location.SourceID != sources[i].ID {
|
||||||
|
t.Fatalf("version evidence = %#v", sources)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMainRelativeOptionalSearchExhaustion(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
if err := os.Mkdir(filepath.Join(root, "overlays"), 0700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for name, content := range map[string]string{
|
||||||
|
"fragment.yml": "settings: {companion: loaded}\n",
|
||||||
|
"overlays/live.yml": "count: 9\n",
|
||||||
|
} {
|
||||||
|
if err := os.WriteFile(filepath.Join(root, name), []byte(content), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
search := configkit.SearchConfig("", configkit.SearchPaths(filepath.Join(root, "absent.yml")))
|
||||||
|
missing := filepath.Join(root, "missing-fallback")
|
||||||
|
fallback := &configkit.FilesystemContext{Root: missing, BaseDir: missing}
|
||||||
|
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name string
|
||||||
|
mainContext *configkit.FilesystemContext
|
||||||
|
bindingFallback *configkit.FilesystemContext
|
||||||
|
overlayFallback *configkit.FilesystemContext
|
||||||
|
overlay string
|
||||||
|
wantCount int
|
||||||
|
wantSources int
|
||||||
|
}{
|
||||||
|
{name: "skip without context", wantCount: 3, wantSources: 1},
|
||||||
|
{
|
||||||
|
name: "root-only main context wins", mainContext: &configkit.FilesystemContext{Root: root},
|
||||||
|
bindingFallback: fallback, overlayFallback: fallback, overlay: "live", wantCount: 9, wantSources: 3,
|
||||||
|
},
|
||||||
|
{name: "selected missing companion fallback", bindingFallback: fallback},
|
||||||
|
{name: "selected missing overlay fallback", overlay: "live", overlayFallback: fallback},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
underlay := configkit.Input{
|
||||||
|
Source: configkit.Bytes("companion", []byte("count: 3\nconfig: {imports: [{path: fragment.yml}]}\n")),
|
||||||
|
Imports: true, MainRelative: &configkit.MainRelativeContext{Fallback: tc.bindingFallback},
|
||||||
|
}
|
||||||
|
result, err := configkit.LoadLayered(
|
||||||
|
configkit.Input{Source: search, Filesystem: tc.mainContext},
|
||||||
|
configkit.LayeredOptions[layeredFacadeConfig]{
|
||||||
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{underlay}}},
|
||||||
|
Overlay: tc.overlay, OverlayFallback: tc.overlayFallback,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
if tc.wantSources == 0 {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("missing selected fallback succeeded")
|
||||||
|
}
|
||||||
|
diagnostics := err.(*configkit.LoadError).Diagnostics()
|
||||||
|
if len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeImportInvalid {
|
||||||
|
t.Fatalf("diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
sources := result.Sources()
|
||||||
|
if result.Config.Count != tc.wantCount || len(sources) != tc.wantSources {
|
||||||
|
t.Fatalf("config = %#v, sources = %#v", result.Config, sources)
|
||||||
|
}
|
||||||
|
if sources[0].Layer != configkit.LayerUnderlay || len(sources[0].EmptyImportSelections) != 0 {
|
||||||
|
t.Fatalf("declaring source = %#v", sources[0])
|
||||||
|
}
|
||||||
|
if tc.mainContext != nil && (result.Config.Settings["companion"] != "loaded" || sources[2].Layer != configkit.LayerOverlay) {
|
||||||
|
t.Fatalf("config = %#v, sources = %#v", result.Config, sources)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
45
facade_main_relative_options_test.go
Normal file
45
facade_main_relative_options_test.go
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
package configkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
configkit "go.maximumdirect.net/configkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMainRelativeOptions(t *testing.T) {
|
||||||
|
if configkit.MainRelative() == configkit.MainRelative() {
|
||||||
|
t.Fatal("binding descriptions must be distinct")
|
||||||
|
}
|
||||||
|
invalid := &configkit.FilesystemContext{BaseDir: "relative", Root: "relative"}
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
main configkit.Input
|
||||||
|
underlay configkit.Input
|
||||||
|
overlay string
|
||||||
|
overlayContext, fallback *configkit.FilesystemContext
|
||||||
|
wantError bool
|
||||||
|
}{
|
||||||
|
{name: "binding on main", main: configkit.Input{Source: configkit.NoSource(), MainRelative: configkit.MainRelative()}, wantError: true},
|
||||||
|
{name: "binding on file", underlay: configkit.Input{Source: configkit.File("missing"), MainRelative: configkit.MainRelative()}, wantError: true},
|
||||||
|
{name: "invalid fallback", underlay: configkit.Input{Source: configkit.NoSource(), MainRelative: &configkit.MainRelativeContext{Fallback: invalid}}, wantError: true},
|
||||||
|
{name: "shadowed binding", underlay: configkit.Input{Source: configkit.NoSource(), Filesystem: &configkit.FilesystemContext{Root: t.TempDir()}, MainRelative: &configkit.MainRelativeContext{Fallback: invalid}}},
|
||||||
|
{name: "inactive overlay fallback", fallback: invalid},
|
||||||
|
{name: "invalid explicit overlay", overlay: "test", overlayContext: invalid, fallback: &configkit.FilesystemContext{Root: t.TempDir(), BaseDir: t.TempDir()}, wantError: true},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
main := tc.main
|
||||||
|
if tc.name != "binding on main" {
|
||||||
|
main.Source = configkit.NoSource()
|
||||||
|
}
|
||||||
|
opts := configkit.LayeredOptions[layeredFacadeConfig]{Overlay: tc.overlay, OverlayFilesystem: tc.overlayContext, OverlayFallback: tc.fallback}
|
||||||
|
if tc.name == "binding on file" || tc.name == "invalid fallback" || tc.name == "shadowed binding" {
|
||||||
|
opts.Underlays = []configkit.Underlay{{Inputs: []configkit.Input{tc.underlay}}}
|
||||||
|
}
|
||||||
|
_, err := configkit.LoadLayered(main, opts)
|
||||||
|
if (err != nil) != tc.wantError {
|
||||||
|
t.Fatalf("error = %v, want error %v", err, tc.wantError)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
260
facade_main_relative_test.go
Normal file
260
facade_main_relative_test.go
Normal file
@@ -0,0 +1,260 @@
|
|||||||
|
package configkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
|
||||||
|
configkit "go.maximumdirect.net/configkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMainRelativeCompanion(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "main.yml"), []byte("count: 3\n"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "extra.yml"), []byte("settings: {companion: host}\n"), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
binding := configkit.MainRelative()
|
||||||
|
companion := configkit.Input{Source: configkit.Bytes("companions", []byte("config: {imports: [{path: extra.yml}]}\n")), Imports: true, MainRelative: binding}
|
||||||
|
result, err := configkit.LoadLayered(configkit.Input{Source: configkit.File(filepath.Join(dir, "main.yml"))}, configkit.LayeredOptions[layeredFacadeConfig]{Underlays: []configkit.Underlay{{Inputs: []configkit.Input{companion}}}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%v: %#v", err, err.(*configkit.LoadError).Diagnostics())
|
||||||
|
}
|
||||||
|
if result.Config.Count != 3 || result.Config.Settings["companion"] != "host" {
|
||||||
|
t.Fatalf("configuration = %#v", result.Config)
|
||||||
|
}
|
||||||
|
native := configkit.NewFilesystem("bundle", fstest.MapFS{"dir/main.yml": &fstest.MapFile{Data: []byte("count: 4\n")}, "dir/extra.yml": &fstest.MapFile{Data: []byte("settings: {companion: native}\n")}})
|
||||||
|
result, err = configkit.LoadLayered(configkit.Input{Source: native.File("dir/main.yml")}, configkit.LayeredOptions[layeredFacadeConfig]{Underlays: []configkit.Underlay{{Inputs: []configkit.Input{companion}}}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%v: %#v", err, err.(*configkit.LoadError).Diagnostics())
|
||||||
|
}
|
||||||
|
if result.Config.Count != 4 || result.Config.Settings["companion"] != "native" {
|
||||||
|
t.Fatalf("configuration = %#v", result.Config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMainRelativeContextSelection(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
base := filepath.Join(root, "main")
|
||||||
|
if err := os.Mkdir(base, 0700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for path, value := range map[string]string{"main/main.yml": "count: 1\n", "main/extra.yml": "settings: {value: derived}\n", "extra.yml": "settings: {value: fallback}\n"} {
|
||||||
|
if err := os.WriteFile(filepath.Join(root, path), []byte(value), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
link := filepath.Join(root, "link.yml")
|
||||||
|
if err := os.Symlink(filepath.Join(base, "main.yml"), link); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
companion := configkit.Input{Source: configkit.Bytes("companion", []byte("config: {imports: [{path: extra.yml}]}\n")), Imports: true, MainRelative: &configkit.MainRelativeContext{Fallback: &configkit.FilesystemContext{Root: root, BaseDir: root}}}
|
||||||
|
opts := configkit.LayeredOptions[layeredFacadeConfig]{Underlays: []configkit.Underlay{{Inputs: []configkit.Input{companion}}}}
|
||||||
|
result, err := configkit.LoadLayered(configkit.Input{Source: configkit.File(link), Filesystem: &configkit.FilesystemContext{Root: root}}, opts)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%v: %#v", err, err.(*configkit.LoadError).Diagnostics())
|
||||||
|
}
|
||||||
|
if got := result.Config.Settings["value"]; got != "derived" {
|
||||||
|
t.Fatalf("derived value = %q", got)
|
||||||
|
}
|
||||||
|
result, err = configkit.LoadLayered(configkit.Input{Source: configkit.NoSource()}, opts)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%v: %#v", err, err.(*configkit.LoadError).Diagnostics())
|
||||||
|
}
|
||||||
|
if got := result.Config.Settings["value"]; got != "fallback" {
|
||||||
|
t.Fatalf("fallback value = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMainRelativeWithoutBase(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name, source string
|
||||||
|
requireBase bool
|
||||||
|
wantCode configkit.Code
|
||||||
|
}{
|
||||||
|
{"optional", "config: {imports: [{path: missing.yml}]}\ncount: 7\n", false, ""},
|
||||||
|
{"required", "config: {imports: [{path: missing.yml, required: true}]}\n", false, configkit.CodeImportMissing},
|
||||||
|
{"required directory", "config: {imports: [{dir: missing, required: true}]}\n", false, configkit.CodeImportMissing},
|
||||||
|
{"required glob", "config: {imports: [{glob: '*.yml', required: true}]}\n", false, configkit.CodeImportMissing},
|
||||||
|
{"empty required", "config: {imports: [{path: [], required: true}]}\ncount: 7\n", false, ""},
|
||||||
|
{"base required", "count: 7\n", true, configkit.CodeOptionsInvalid},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
input := configkit.Input{Source: configkit.Bytes("companion", []byte(test.source)), Imports: true, MainRelative: &configkit.MainRelativeContext{RequireBase: test.requireBase}}
|
||||||
|
result, err := configkit.LoadLayered(configkit.Input{Source: configkit.NoSource()}, configkit.LayeredOptions[layeredFacadeConfig]{Underlays: []configkit.Underlay{{Inputs: []configkit.Input{input}}}})
|
||||||
|
if test.wantCode != "" {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected failure")
|
||||||
|
}
|
||||||
|
for _, d := range err.(*configkit.LoadError).Diagnostics() {
|
||||||
|
if d.Code == test.wantCode {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("diagnostics: %#v", err.(*configkit.LoadError).Diagnostics())
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%v: %#v", err, err.(*configkit.LoadError).Diagnostics())
|
||||||
|
}
|
||||||
|
if result.Config.Count != 7 {
|
||||||
|
t.Fatalf("config = %#v", result.Config)
|
||||||
|
}
|
||||||
|
if sources := result.Sources(); len(sources) != 1 || len(sources[0].EmptyImportSelections) != 0 {
|
||||||
|
t.Fatalf("sources = %#v", sources)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMainRelativeWithoutBaseSequenceItem(t *testing.T) {
|
||||||
|
type item struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
}
|
||||||
|
type config struct {
|
||||||
|
Items []item `yaml:"items"`
|
||||||
|
}
|
||||||
|
input := configkit.Input{Source: configkit.Bytes("item", []byte("config: {imports: [{path: missing.yml}]}\nname: example\n")), Imports: true, MainRelative: configkit.MainRelative(), Destination: configkit.Keys("items"), PlacementMode: configkit.PlacementModeSequenceItem}
|
||||||
|
result, err := configkit.LoadLayered(configkit.Input{Source: configkit.NoSource()}, configkit.LayeredOptions[config]{Underlays: []configkit.Underlay{{Inputs: []configkit.Input{input}}}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%v: %#v", err, err.(*configkit.LoadError).Diagnostics())
|
||||||
|
}
|
||||||
|
if len(result.Config.Items) != 1 || result.Config.Items[0].Name != "example" {
|
||||||
|
t.Fatalf("config = %#v", result.Config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMainRelativeWithoutBaseImportDefaults(t *testing.T) {
|
||||||
|
required := true
|
||||||
|
tests := []struct {
|
||||||
|
name, source string
|
||||||
|
imports bool
|
||||||
|
requireBase bool
|
||||||
|
code configkit.Code
|
||||||
|
}{
|
||||||
|
{"default required", "config: {imports: [{path: missing.yml}]}\n", true, false, configkit.CodeImportMissing},
|
||||||
|
{"malformed", "config: {imports: [{path: 42}]}\n", true, false, configkit.CodeImportInvalid},
|
||||||
|
{"imports disabled", "config: {imports: [{path: missing.yml}]}\n", false, false, configkit.CodeImportDisabled},
|
||||||
|
{"base required without imports", "count: 1\n", false, true, configkit.CodeOptionsInvalid},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
input := configkit.Input{Source: configkit.Bytes("companion", []byte(test.source)), Imports: test.imports, MainRelative: &configkit.MainRelativeContext{RequireBase: test.requireBase}}
|
||||||
|
_, err := configkit.LoadLayered(configkit.Input{Source: configkit.NoSource()}, configkit.LayeredOptions[layeredFacadeConfig]{ImportDefaults: configkit.ImportDefaults{Required: &required}, Underlays: []configkit.Underlay{{Inputs: []configkit.Input{input}}}})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("expected failure")
|
||||||
|
}
|
||||||
|
for _, diagnostic := range err.(*configkit.LoadError).Diagnostics() {
|
||||||
|
if diagnostic.Code == test.code {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("diagnostics = %#v", err.(*configkit.LoadError).Diagnostics())
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLayeredOverlayContextSelection(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
base := filepath.Join(root, "base")
|
||||||
|
for _, dir := range []string{filepath.Join(root, "overlays"), filepath.Join(base, "overlays")} {
|
||||||
|
if err := os.MkdirAll(dir, 0700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for path, content := range map[string]string{"base/main.yml": "count: 1\n", "base/overlays/test.yml": "count: 2\n", "overlays/test.yml": "count: 3\n"} {
|
||||||
|
if err := os.WriteFile(filepath.Join(root, path), []byte(content), 0600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
main := configkit.Input{Source: configkit.File(filepath.Join(base, "main.yml")), Filesystem: &configkit.FilesystemContext{Root: root}}
|
||||||
|
result, err := configkit.LoadLayered(main, configkit.LayeredOptions[layeredFacadeConfig]{Overlay: "test"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%v: %#v", err, err.(*configkit.LoadError).Diagnostics())
|
||||||
|
}
|
||||||
|
if result.Config.Count != 2 {
|
||||||
|
t.Fatalf("derived overlay = %d", result.Config.Count)
|
||||||
|
}
|
||||||
|
result, err = configkit.LoadLayered(main, configkit.LayeredOptions[layeredFacadeConfig]{Overlay: "test", OverlayFilesystem: &configkit.FilesystemContext{Root: root, BaseDir: base}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%v: %#v", err, err.(*configkit.LoadError).Diagnostics())
|
||||||
|
}
|
||||||
|
if result.Config.Count != 3 {
|
||||||
|
t.Fatalf("explicit overlay = %d", result.Config.Count)
|
||||||
|
}
|
||||||
|
result, err = configkit.LoadLayered(configkit.Input{Source: configkit.NoSource()}, configkit.LayeredOptions[layeredFacadeConfig]{Overlay: "test", OverlayFallback: &configkit.FilesystemContext{Root: root, BaseDir: base}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%v: %#v", err, err.(*configkit.LoadError).Diagnostics())
|
||||||
|
}
|
||||||
|
if result.Config.Count != 2 {
|
||||||
|
t.Fatalf("fallback overlay = %d", result.Config.Count)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLayeredOverlayNativeContext(t *testing.T) {
|
||||||
|
filesystem := configkit.NewFilesystem("bundle", fstest.MapFS{
|
||||||
|
"dir/main.yml": &fstest.MapFile{Data: []byte("count: 1\n")},
|
||||||
|
"dir/overlays/test.yml": &fstest.MapFile{Data: []byte("count: 9\n")},
|
||||||
|
})
|
||||||
|
main := configkit.Input{Source: filesystem.File("dir/main.yml"), Filesystem: &configkit.FilesystemContext{Root: ".", FS: &filesystem}}
|
||||||
|
result, err := configkit.LoadLayered(main, configkit.LayeredOptions[layeredFacadeConfig]{Overlay: "test"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("%v: %#v", err, err.(*configkit.LoadError).Diagnostics())
|
||||||
|
}
|
||||||
|
if result.Config.Count != 9 {
|
||||||
|
t.Fatalf("config = %#v", result.Config)
|
||||||
|
}
|
||||||
|
_, err = configkit.LoadLayered(main, configkit.LayeredOptions[layeredFacadeConfig]{Overlay: "missing", OverlayFallback: &configkit.FilesystemContext{Root: ".", BaseDir: ".", FS: &filesystem}})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("missing selected overlay succeeded")
|
||||||
|
}
|
||||||
|
for _, diagnostic := range err.(*configkit.LoadError).Diagnostics() {
|
||||||
|
if diagnostic.Code == configkit.CodeOverlayMissing {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("diagnostics = %#v", err.(*configkit.LoadError).Diagnostics())
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLayeredOverlayNativeLinks(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
name, mainPath, target string
|
||||||
|
want configkit.Code
|
||||||
|
}{
|
||||||
|
{"outside root", "scope/main.yml", "../../outside.yml", configkit.CodeOverlayOutsideRoot},
|
||||||
|
{"overlay aliases main", "scope/main.yml", "../main.yml", configkit.CodeOverlayRepeated},
|
||||||
|
{"main aliases overlay target", "scope/alias.yml", "../target.yml", configkit.CodeOverlayRepeated},
|
||||||
|
{"contained overlay", "scope/main.yml", "../target.yml", ""},
|
||||||
|
} {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
native := configkit.NewFilesystem("bundle", fstest.MapFS{
|
||||||
|
"scope/main.yml": {Data: []byte("count: 1\n")},
|
||||||
|
"scope/alias.yml": {Data: []byte("target.yml"), Mode: os.ModeSymlink},
|
||||||
|
"scope/target.yml": {Data: []byte("count: 7\n")},
|
||||||
|
"scope/overlays/test.yml": {Data: []byte(tc.target), Mode: os.ModeSymlink},
|
||||||
|
"outside.yml": {Data: []byte("count: 9\n")},
|
||||||
|
})
|
||||||
|
main := configkit.Input{Source: native.File(tc.mainPath), Filesystem: &configkit.FilesystemContext{Root: "scope", FS: &native}}
|
||||||
|
result, err := configkit.LoadLayered(main, configkit.LayeredOptions[layeredFacadeConfig]{Overlay: "test"})
|
||||||
|
if tc.want != "" {
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("overlay link unexpectedly loaded")
|
||||||
|
}
|
||||||
|
diagnostics := err.(*configkit.LoadError).Diagnostics()
|
||||||
|
if len(diagnostics) != 1 || diagnostics[0].Code != tc.want {
|
||||||
|
t.Fatalf("diagnostics = %#v, want %v", diagnostics, tc.want)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result.Config.Count != 7 || result.Sources()[1].Path != "scope/target.yml" {
|
||||||
|
t.Fatalf("config = %#v, sources = %#v", result.Config, result.Sources())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"testing/fstest"
|
"testing/fstest"
|
||||||
|
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "go.maximumdirect.net/configkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
type mappingFacadeConfig struct {
|
type mappingFacadeConfig struct {
|
||||||
@@ -36,7 +36,7 @@ func TestFacadeMappingSnapshotsAndMatchesYAML(t *testing.T) {
|
|||||||
if !reflect.DeepEqual(result.Config, want) {
|
if !reflect.DeepEqual(result.Config, want) {
|
||||||
t.Fatalf("mapping config = %#v, want %#v", result.Config, want)
|
t.Fatalf("mapping config = %#v, want %#v", result.Config, want)
|
||||||
}
|
}
|
||||||
if sources := result.Sources(); len(sources) != 1 || sources[0].ID != "source-1" || sources[0].Kind != configkit.SourceKindMapping || sources[0].Layer != configkit.LayerBase || sources[0].Path != "" {
|
if sources := result.Sources(); len(sources) != 1 || sources[0].ID != "source-1" || sources[0].Kind != configkit.SourceKindMapping || sources[0].Layer != configkit.LayerMain || sources[0].Path != "" {
|
||||||
t.Fatalf("mapping sources = %#v", sources)
|
t.Fatalf("mapping sources = %#v", sources)
|
||||||
}
|
}
|
||||||
info, ok := result.Metadata().Lookup(configkit.Keys("name"))
|
info, ok := result.Metadata().Lookup(configkit.Keys("name"))
|
||||||
@@ -80,8 +80,8 @@ func TestFacadeMappingImportsUseExplicitContext(t *testing.T) {
|
|||||||
"root/shared.yml": &fstest.MapFile{Data: []byte("name: imported\n")},
|
"root/shared.yml": &fstest.MapFile{Data: []byte("name: imported\n")},
|
||||||
})
|
})
|
||||||
source := configkit.Mapping("programmatic", map[string]any{
|
source := configkit.Mapping("programmatic", map[string]any{
|
||||||
"configkit": map[string]any{"imports": []any{map[string]any{"path": "shared.yml"}}},
|
"config": map[string]any{"imports": []any{map[string]any{"path": "shared.yml"}}},
|
||||||
"enabled": true,
|
"enabled": true,
|
||||||
})
|
})
|
||||||
result, err := configkit.Load(source, configkit.Options[mappingFacadeConfig]{
|
result, err := configkit.Load(source, configkit.Options[mappingFacadeConfig]{
|
||||||
Imports: true, Filesystem: &configkit.FilesystemContext{Root: "root", BaseDir: "root", FS: &namespace},
|
Imports: true, Filesystem: &configkit.FilesystemContext{Root: "root", BaseDir: "root", FS: &namespace},
|
||||||
@@ -89,7 +89,7 @@ func TestFacadeMappingImportsUseExplicitContext(t *testing.T) {
|
|||||||
if err != nil || result.Config.Name != "imported" || !result.Config.Enabled {
|
if err != nil || result.Config.Name != "imported" || !result.Config.Enabled {
|
||||||
t.Fatalf("mapping import = %#v, %v", result, err)
|
t.Fatalf("mapping import = %#v, %v", result, err)
|
||||||
}
|
}
|
||||||
if sources := result.Sources(); len(sources) != 2 || sources[0].Kind != configkit.SourceKindMapping || sources[1].Kind != configkit.SourceKindFilesystem || sources[1].Layer != configkit.LayerBase {
|
if sources := result.Sources(); len(sources) != 2 || sources[0].Kind != configkit.SourceKindMapping || sources[1].Kind != configkit.SourceKindFilesystem || sources[1].Layer != configkit.LayerMain {
|
||||||
t.Fatalf("mapping import sources = %#v", sources)
|
t.Fatalf("mapping import sources = %#v", sources)
|
||||||
}
|
}
|
||||||
checkMappingCode(t, source, configkit.Options[mappingFacadeConfig]{Imports: true}, configkit.CodeOptionsInvalid)
|
checkMappingCode(t, source, configkit.Options[mappingFacadeConfig]{Imports: true}, configkit.CodeOptionsInvalid)
|
||||||
|
|||||||
63
facade_resolution_presence_test.go
Normal file
63
facade_resolution_presence_test.go
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
package configkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
configkit "go.maximumdirect.net/configkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFacadeResolutionPresenceUsesDynamicEntryShape(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
type configuration struct {
|
||||||
|
Shape any `yaml:"shape"`
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input string
|
||||||
|
keyPresent bool
|
||||||
|
}{
|
||||||
|
{name: "mapping key", input: "shape: {'0': input}\n", keyPresent: true},
|
||||||
|
{name: "sequence index", input: "shape: [input]\n", keyPresent: false},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
parsed, err := configkit.ParsePath("/shape/0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err = configkit.Load(configkit.Bytes("dynamic.yml", []byte(test.input)), configkit.Options[configuration]{
|
||||||
|
Definition: configkit.Definition[configuration]{
|
||||||
|
Resolve: func(_ *configuration, resolution *configkit.Resolution) error {
|
||||||
|
check := func(path configkit.Path, want bool) error {
|
||||||
|
info, found := resolution.EntryInfo(path)
|
||||||
|
explicit := resolution.IsExplicit(path)
|
||||||
|
if found != want || explicit != want || (found && info.Explicit != explicit) {
|
||||||
|
return errors.New("resolution presence disagrees with dynamic entry shape")
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
for _, checkPath := range []struct {
|
||||||
|
path configkit.Path
|
||||||
|
want bool
|
||||||
|
}{
|
||||||
|
{path: configkit.Keys("shape", "0"), want: test.keyPresent},
|
||||||
|
{path: configkit.Keys("shape").Index(0), want: !test.keyPresent},
|
||||||
|
{path: parsed, want: true},
|
||||||
|
{path: configkit.Keys("shape", "missing"), want: false},
|
||||||
|
} {
|
||||||
|
if err := check(checkPath.path, checkPath.want); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user