Compare commits
344 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 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 | |||
| 50199eb0af | |||
| 34cbc0d63b | |||
| 5e12b2d360 | |||
| 8dfaf44d1d | |||
| 64be8b4df5 | |||
| e585decdb2 | |||
| 6440a500f7 | |||
| d898cb193d | |||
| 557e006a4a | |||
| 3fd83b6b2b | |||
| 0fbce5d141 | |||
| ac4ce242a2 | |||
| fa6e876ab5 | |||
| c5275df1fc | |||
| f404168900 | |||
| 729c6fb5d5 | |||
| d314a269d5 | |||
| 2c3eb3308b | |||
| 6bc625b68f | |||
| b915dd776a | |||
| 9a64a65b6c | |||
| 6158e9db3b | |||
| 0d149e51f0 | |||
| bd51b9c3f2 | |||
| d05426d8a2 | |||
| 3f0efcb4ff | |||
| 19f5c2a2d5 | |||
| 455a4a355d | |||
| 8ab1568352 | |||
| 8a99e61070 | |||
| de7513d488 | |||
| 26771dc03e | |||
| fbaaaf18cd | |||
| c9574ef3c1 | |||
| 5959537d1a | |||
| 0369111d30 | |||
| 2fedb0307f | |||
| 087050fa25 | |||
| 1ac1f8bc9e | |||
| 4dc3e70877 | |||
| f64497cb13 | |||
| 68cf5c489b | |||
| e50fee2ca5 | |||
| eec214944c | |||
| 4f2c113237 | |||
| 2d93a5c703 | |||
| 2f6c1eca00 | |||
| 8642337966 | |||
| 53ce6c63d8 | |||
| 2f0900535d | |||
| 4748313105 | |||
| e2634c8815 | |||
| 424c89780e | |||
| 92309ddcc3 | |||
| 7ff204bf3d | |||
| b3ec860fac | |||
| 435dea102f | |||
| ea46001f78 | |||
| a0e5693d07 | |||
| 5c5f2af6cc | |||
| 8253993005 | |||
| d0848290da | |||
| e94a0642ff | |||
| 571d063675 | |||
| d0a605afbb | |||
| 7a83704f43 | |||
| ac5bf29ddc | |||
| cce8356b8e | |||
| b746dcc609 | |||
| 9c32b8d3b5 | |||
| c73daf891d | |||
| ae038a5165 | |||
| 367bbaaa1b | |||
| fafc28421f | |||
| 1a673753b9 | |||
| 988e49bb09 | |||
| 5bb4f364b7 | |||
| af84107905 | |||
| f94dd9b961 | |||
| 146e7e56d9 | |||
| d2e20a304a | |||
| 8d40983271 | |||
| 25cf7ce05c | |||
| 57edb8ec19 | |||
| 42663cf07c | |||
| 085b57eeb6 | |||
| dcf0379e02 | |||
| 8ecfedc91b | |||
| f7624ae92a | |||
| a035df9845 | |||
| e6598810e0 | |||
| f8cbb2ed87 | |||
| af80ccb90c | |||
| 0be7cdb00e | |||
| e01aeca761 | |||
| d4133f23f5 | |||
| 5af71a7533 | |||
| 41b5dff69b | |||
| ecfb78d84d | |||
| 041c4cff02 | |||
| 23f22d8982 | |||
| 0023617a07 | |||
| 33a51606da | |||
| 268fd03579 | |||
| 9d4d2e2a17 | |||
| 36ff9170b7 | |||
| 9e75496664 | |||
| 80d8c1dc82 | |||
| acd9e9a9fa | |||
| 89a9684f0d | |||
| 9a7027a04c | |||
| dfeaf90320 | |||
| d03e56398b | |||
| 628fc53d2c | |||
| 5fec16a768 | |||
| f6b02dec03 | |||
| 7b04dc6a50 | |||
| 12fdf0d331 | |||
| b202dd187f | |||
| 0314e587b6 | |||
| 9d7635d1e4 | |||
| 298e70b552 | |||
| 64c51647f0 | |||
| bc6b977585 | |||
| d06aae5461 | |||
| 4f941f8ba4 | |||
| 9a49833339 | |||
| 749cf6868c | |||
| a5f0b92ee9 | |||
| 6ec23b2de1 | |||
| 3298eb916c | |||
| 40430e787c | |||
| f434fa159f | |||
| 8ddf73ede3 | |||
| 844a15a3cb | |||
| 58f4c39d6f | |||
| 8a54cecd1d | |||
| 0e430e8077 | |||
| 26caa6024e | |||
| 8691a93ebf | |||
| c563f80934 | |||
| 27797d2392 | |||
| 6a4e57e5d3 | |||
| 29f94d89e1 | |||
| 12f6045da0 | |||
| 6eb80b06e3 | |||
| 86feb6d755 | |||
| 8bf49f6ea6 | |||
| f3d0fe6411 | |||
| cba3a0efb0 | |||
| b58cd75c98 | |||
| c530a989df | |||
| 2baf14d9a5 | |||
| 6903c9e51a | |||
| 31e10af9cb | |||
| 0d841d9d73 | |||
| 4bcefbc9d4 | |||
| 91db99b967 | |||
| 5747ec49c6 | |||
| d3a714a1cb | |||
| 7f8ad81065 | |||
| 75da54150f | |||
| 38fb315415 | |||
| 1c79ec3aa1 | |||
| 01e1b2fc2d | |||
| 15943e233c | |||
| f6a6b0184b | |||
| 898035d83a | |||
| de3666aed3 | |||
| c00811da2e | |||
| 55abcf8340 | |||
| 9d7c79cf0b | |||
| 24131617c7 | |||
| 05b67cbfcf | |||
| e433988962 | |||
| 05a28d1178 | |||
| 1e5bffc0e6 | |||
| f5a689b578 | |||
| e4abff4164 | |||
| 1a8f1cc3a5 | |||
| 9b99463187 | |||
| f69fa24b40 | |||
| 77d97296ea | |||
| 032679bf34 | |||
| 55a59de6db | |||
| a0d13962bd | |||
| 5f33e1e90c | |||
| b750b0fec6 | |||
| b498e5bee5 | |||
| ba577429e6 | |||
| 014b99504b | |||
| 263dd2f1bd | |||
| af43d6f438 | |||
| a4cbd6390e | |||
| 5ed0f55970 | |||
| 91ab37a39d | |||
| fbe9ae6d8b | |||
| 1aa121df71 | |||
| 6e40c166d6 | |||
| a56d4691ff | |||
| 2718fbb8fe | |||
| 2d52421919 | |||
| 167fcd475f | |||
| aa93aeda3f | |||
| 94381a67fd | |||
| c26b34147b | |||
| c9f9c98fbe | |||
| f6bf4454b0 | |||
| a7ca2c59a2 | |||
| 10c14aa794 | |||
| e9774b252b | |||
| 19fe621df1 | |||
| 82b289f4fe |
@@ -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.
|
|
||||||
|
|||||||
119
README.md
119
README.md
@@ -1,39 +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 path and glob 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 above overlays. 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, and definitions can
|
|
||||||
register selected sequences to append records with distinct string IDs during
|
|
||||||
strict composition. Higher-priority sequences still replace lower-priority
|
|
||||||
sequences as whole values. See the [sequence contract](docs/config.md#strict-document-composition),
|
|
||||||
[maintained prompt example](docs/consumers/README.md#identified-prompt-sequences),
|
|
||||||
and [ADR 0014](docs/adr/0014-sequence-placement-and-composition.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"`
|
||||||
@@ -48,47 +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 and explicit CLI overrides sit
|
|
||||||
above it. The [configuration contract](docs/config.md#strict-document-composition)
|
|
||||||
owns the detailed combination rules and input-specific constraints. At
|
|
||||||
definition-registered paths only, strict composition can append records with
|
|
||||||
distinct explicit IDs; ordinary and unregistered sequences keep their conflict
|
|
||||||
or 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}
|
||||||
@@ -43,32 +43,62 @@ func toEngineLocations(values []Location) []engine.Location {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
func fromEngineSourceInfo(value engine.SourceInfo) SourceInfo {
|
func fromEngineSourceInfo(value engine.SourceInfo) SourceInfo {
|
||||||
return SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Destination: fromEnginePath(value.Destination), PlacementMode: PlacementMode(engine.SourceInfoPlacementModeForFacade(value)), DestinationUnresolved: engine.SourceInfoDestinationUnresolvedForFacade(value), Layer: Layer(value.Layer), Kind: SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex, ImportedFrom: fromEngineLocationPointer(value.ImportedFrom), VersionCheck: fromEngineSourceVersionCheck(value.VersionCheck)}
|
return SourceInfo{
|
||||||
|
ID: value.ID, Label: value.Label, Path: value.Path, Destination: fromEnginePath(value.Destination),
|
||||||
|
PlacementMode: PlacementMode(engine.SourceInfoPlacementModeForFacade(value)),
|
||||||
|
DestinationIsFallback: engine.SourceInfoDestinationUnresolvedForFacade(value),
|
||||||
|
Layer: Layer(value.Layer), Kind: SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex,
|
||||||
|
ImportedFrom: fromEngineLocationPointer(value.ImportedFrom), VersionEvidence: fromEngineSourceVersionEvidence(value.VersionCheck),
|
||||||
|
EmptyImportSelections: fromEngineEmptyImportSelections(engine.SourceInfoImportSelectionsForFacade(value)),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
func toEngineSourceInfo(value SourceInfo) engine.SourceInfo {
|
func fromEngineEmptyImportSelections(values []engine.ImportSelectionForFacade) []EmptyImportSelection {
|
||||||
result := engine.SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Destination: toEnginePath(value.Destination), Layer: engine.Layer(value.Layer), Kind: engine.SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex, ImportedFrom: toEngineLocationPointer(value.ImportedFrom), VersionCheck: toEngineSourceVersionCheck(value.VersionCheck)}
|
if values == nil {
|
||||||
return engine.SourceInfoWithPlacement(result, uint8(value.PlacementMode), value.DestinationUnresolved)
|
return nil
|
||||||
|
}
|
||||||
|
result := make([]EmptyImportSelection, len(values))
|
||||||
|
for index, value := range values {
|
||||||
|
result[index] = EmptyImportSelection{
|
||||||
|
Path: fromEnginePath(value.Path), Location: fromEngineLocation(value.Location),
|
||||||
|
Selector: ImportSelector(value.Selector),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
func fromEngineSourceVersionCheck(value *engine.SourceVersionCheck) *SourceVersionCheck {
|
|
||||||
|
func toEngineOverrides(values []Override) []engine.Override {
|
||||||
|
if values == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := make([]engine.Override, len(values))
|
||||||
|
for index, value := range values {
|
||||||
|
result[index] = engine.Override{
|
||||||
|
Path: toEnginePath(value.Path), Value: value.Value, Flag: value.Flag,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func toEngineImportDefaults(value ImportDefaults) engine.ImportDefaults {
|
||||||
|
return engine.ImportDefaults{
|
||||||
|
Required: value.Required,
|
||||||
|
Follow: value.FollowSymlinks,
|
||||||
|
Hidden: value.IncludeHidden,
|
||||||
|
Recursive: value.Recursive,
|
||||||
|
Pattern: value.Pattern,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func fromEngineSourceVersionEvidence(value *engine.SourceVersionCheck) *SourceVersionEvidence {
|
||||||
if value == nil {
|
if value == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return &SourceVersionCheck{
|
return &SourceVersionEvidence{
|
||||||
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,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
func toEngineSourceVersionCheck(value *SourceVersionCheck) *engine.SourceVersionCheck {
|
|
||||||
if value == nil {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return &engine.SourceVersionCheck{
|
|
||||||
Path: toEnginePath(value.Path),
|
|
||||||
Version: engine.Version{Present: value.Version.Present, Null: value.Version.Null, Text: value.Version.Text},
|
|
||||||
Location: toEngineLocationPointer(value.Location), Redacted: value.Redacted,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
func fromEngineOrigin(value engine.Origin) Origin {
|
func fromEngineOrigin(value engine.Origin) Origin {
|
||||||
result := Origin{Layer: Layer(value.Layer), Locations: fromEngineLocations(value.Locations), Operation: ChangeKind(value.Operation)}
|
result := Origin{Layer: Layer(value.Layer), Locations: fromEngineLocations(value.Locations), Operation: ChangeKind(value.Operation)}
|
||||||
if value.Inheritance != nil {
|
if value.Inheritance != nil {
|
||||||
@@ -82,19 +112,6 @@ func fromEngineOrigin(value engine.Origin) Origin {
|
|||||||
}
|
}
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
func toEngineOrigin(value Origin) engine.Origin {
|
|
||||||
result := engine.Origin{Layer: engine.Layer(value.Layer), Locations: toEngineLocations(value.Locations), Operation: engine.ChangeKind(value.Operation)}
|
|
||||||
if value.Inheritance != nil {
|
|
||||||
result.Inheritance = make([]engine.InheritanceStep, len(value.Inheritance))
|
|
||||||
for index, step := range value.Inheritance {
|
|
||||||
result.Inheritance[index] = engine.InheritanceStep{
|
|
||||||
From: toEnginePath(step.From), To: toEnginePath(step.To), Reference: toEnginePath(step.Reference),
|
|
||||||
Locations: toEngineLocations(step.Locations),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return result
|
|
||||||
}
|
|
||||||
func fromEngineDiagnostic(value engine.Diagnostic) Diagnostic {
|
func fromEngineDiagnostic(value engine.Diagnostic) Diagnostic {
|
||||||
return Diagnostic{Code: Code(value.Code), Severity: Severity(value.Severity), Path: fromEnginePath(value.Path), Message: value.Message, Primary: fromEngineLocationPointer(value.Primary), Related: fromEngineLocations(value.Related)}
|
return Diagnostic{Code: Code(value.Code), Severity: Severity(value.Severity), Path: fromEnginePath(value.Path), Message: value.Message, Primary: fromEngineLocationPointer(value.Primary), Related: fromEngineLocations(value.Related)}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
90
discovery.go
Normal file
90
discovery.go
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
package configkit
|
||||||
|
|
||||||
|
import "go.maximumdirect.net/configkit/internal/engine"
|
||||||
|
|
||||||
|
// Search is an immutable ordered description of possible host-file inputs.
|
||||||
|
// It is consumed only when passed to SearchConfig and used by a load.
|
||||||
|
type Search struct{ value engine.Search }
|
||||||
|
|
||||||
|
// FilenameGroup selects filenames in order for a directory search.
|
||||||
|
// Construct groups with NamedFilenames, GenericFilenames, or CustomFilenames.
|
||||||
|
type FilenameGroup struct{ value engine.FilenameGroup }
|
||||||
|
|
||||||
|
// SearchOptions controls whether a search must select a file.
|
||||||
|
type SearchOptions struct {
|
||||||
|
// Required reports a missing-source error if the complete search is exhausted.
|
||||||
|
Required bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// DirectorySearchOptions selects the filename groups used by SearchDirectory.
|
||||||
|
type DirectorySearchOptions struct {
|
||||||
|
// Filenames concatenates groups in order. A nil slice uses Named then Generic;
|
||||||
|
// a non-nil empty slice selects no filenames.
|
||||||
|
Filenames []FilenameGroup
|
||||||
|
}
|
||||||
|
|
||||||
|
// NamedFilenames selects <application>.yml followed by <application>.yaml.
|
||||||
|
func NamedFilenames() FilenameGroup { return FilenameGroup{value: engine.NamedFilenames()} }
|
||||||
|
|
||||||
|
// GenericFilenames selects config.yml, config.yaml, main.yml, and main.yaml.
|
||||||
|
func GenericFilenames() FilenameGroup { return FilenameGroup{value: engine.GenericFilenames()} }
|
||||||
|
|
||||||
|
// CustomFilenames selects literal filename components in the supplied order.
|
||||||
|
// Names cannot be empty or contain a directory component.
|
||||||
|
func CustomFilenames(names ...string) FilenameGroup {
|
||||||
|
return FilenameGroup{value: engine.CustomFilenames(names...)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchDirectory describes a search within directory. Omitted options or a nil
|
||||||
|
// Filenames slice uses Named then Generic groups. Explicitly empty Filenames
|
||||||
|
// selects no names and does not resolve the directory base.
|
||||||
|
func SearchDirectory(directory string, supplied ...DirectorySearchOptions) Search {
|
||||||
|
options := make([]engine.DirectorySearchOptions, len(supplied))
|
||||||
|
for index, option := range supplied {
|
||||||
|
if option.Filenames == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
options[index].Filenames = make([]engine.FilenameGroup, len(option.Filenames))
|
||||||
|
for groupIndex, group := range option.Filenames {
|
||||||
|
options[index].Filenames[groupIndex] = group.value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return Search{value: engine.SearchDirectory(directory, options...)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SearchPaths describes literal host-file candidates in the supplied order.
|
||||||
|
func SearchPaths(paths ...string) Search { return Search{value: engine.Paths(paths...)} }
|
||||||
|
|
||||||
|
// Searches concatenates descriptions in order and snapshots their entries.
|
||||||
|
func Searches(searches ...Search) Search {
|
||||||
|
values := make([]engine.Search, len(searches))
|
||||||
|
for index, search := range searches {
|
||||||
|
values[index] = search.value
|
||||||
|
}
|
||||||
|
return Search{value: engine.Searches(values...)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// UnixSystemDirectories describes the conventional Unix system directories.
|
||||||
|
// Loading on an unsupported platform fails if this preset is reached.
|
||||||
|
func UnixSystemDirectories() Search { return Search{value: engine.UnixSystemDirectories()} }
|
||||||
|
|
||||||
|
// UserConfigDirectory describes searches rooted at the Go user-config directory.
|
||||||
|
func UserConfigDirectory() Search { return Search{value: engine.UserConfigDirectory()} }
|
||||||
|
|
||||||
|
// HomeDirectory describes searches rooted at the Go home directory.
|
||||||
|
func HomeDirectory() Search { return Search{value: engine.HomeDirectory()} }
|
||||||
|
|
||||||
|
// WorkingDirectory describes application-named files in the current directory.
|
||||||
|
func WorkingDirectory() Search { return Search{value: engine.WorkingDirectory()} }
|
||||||
|
|
||||||
|
// SearchConfig creates a source that selects the first matching host file when
|
||||||
|
// consumed by Load or LoadLayered. Without Required, exhausting the search uses
|
||||||
|
// the absent-input lifecycle. Description and option errors are reported while
|
||||||
|
// loading.
|
||||||
|
func SearchConfig(application string, search Search, supplied ...SearchOptions) Source {
|
||||||
|
options := make([]engine.SearchOptions, len(supplied))
|
||||||
|
for index, option := range supplied {
|
||||||
|
options[index] = engine.SearchOptions{Required: option.Required}
|
||||||
|
}
|
||||||
|
return Source{value: engine.SearchConfig(application, search.value, options...)}
|
||||||
|
}
|
||||||
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
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
# ADR 0005: Record explicit CLI overrides and safe value explanation
|
# ADR 0005: Record explicit CLI overrides and safe value explanation
|
||||||
|
|
||||||
Status: accepted
|
Status: Partially superseded by
|
||||||
|
[ADR 0018](0018-cli-after-structural-resolution.md) for override-before-decoding
|
||||||
|
ordering. The remaining ownership and safe explanation decisions remain accepted.
|
||||||
|
|
||||||
Date: 2026-09-06
|
Date: 2026-09-06
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,9 @@
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Proposed draft for review. Completed planning and remediation records are
|
Proposed draft for review; its whole-sequence precedence replacement rule is
|
||||||
|
superseded by [ADR 0017](0017-collection-sequence-policy.md). The remainder
|
||||||
|
retains its proposed status. Completed planning and remediation records are
|
||||||
preserved in Git history at `b604aa1` under `docs/roadmap/`; the
|
preserved in Git history at `b604aa1` under `docs/roadmap/`; the
|
||||||
[configuration contract](../config.md#layered-loading) owns current behavior.
|
[configuration contract](../config.md#layered-loading) owns current behavior.
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,14 @@
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Accepted and implemented. The [configuration contract](../config.md#sibling-inheritance)
|
Partially superseded by [ADR 0017](0017-collection-sequence-policy.md) for
|
||||||
owns current behavior. The completed implementation plan is preserved in Git
|
sequence combination and the addition of identified-sequence sibling scopes,
|
||||||
|
and [ADR 0018](0018-cli-after-structural-resolution.md) for CLI-before-inheritance
|
||||||
|
ordering. Existing mapping-sibling scopes and the remaining ownership and
|
||||||
|
inspection decisions remain accepted.
|
||||||
|
|
||||||
|
The [configuration contract](../config.md#sibling-inheritance) owns implemented
|
||||||
|
behavior. The completed implementation plan is preserved in Git
|
||||||
history at `83c59ea`.
|
history at `83c59ea`.
|
||||||
|
|
||||||
## Date
|
## Date
|
||||||
|
|||||||
@@ -2,8 +2,12 @@
|
|||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
Accepted and implemented. The [configuration contract](../config.md#strict-document-composition)
|
Partially superseded by [ADR 0017](0017-collection-sequence-policy.md) for
|
||||||
defines current behavior, and the [consumer guide](../consumers/README.md#identified-prompt-sequences)
|
identity ownership and sequence combination. Explicit source placement remains
|
||||||
|
accepted.
|
||||||
|
|
||||||
|
The [configuration contract](../config.md#strict-document-composition)
|
||||||
|
defines implemented behavior, and the [consumer guide](../consumers/README.md#identified-prompt-sequences)
|
||||||
shows the maintained example. The completed roadmap, implementation plan, and
|
shows the maintained example. The completed roadmap, implementation plan, and
|
||||||
resolved remediation record are preserved in Git history at `008d2ba` under
|
resolved remediation record are preserved in Git history at `008d2ba` under
|
||||||
`docs/roadmap/`; see the [development guide](../development.md) for retrieval
|
`docs/roadmap/`; see the [development guide](../development.md) for retrieval
|
||||||
|
|||||||
68
docs/adr/0015-shared-dependency-ready-ordering.md
Normal file
68
docs/adr/0015-shared-dependency-ready-ordering.md
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# ADR 0015: Share Dependency-Ready Ordering Across Imports and Inheritance
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted. Implemented behavior is described in the
|
||||||
|
[configuration contract](../config.md#explicit-imports) and
|
||||||
|
[internal import overview](../internal/imports.md).
|
||||||
|
|
||||||
|
## Date
|
||||||
|
|
||||||
|
2026-10-19
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Imports and sibling inheritance both resolve relationships between configuration
|
||||||
|
contributions. Their existing resolvers use different traversal rules, making
|
||||||
|
ordering depend on which mechanism expresses a dependency. Ordering affects
|
||||||
|
sequence contributions, provenance, and diagnostics, so a consistent rule is
|
||||||
|
part of ConfigKit's configuration model.
|
||||||
|
|
||||||
|
The resolvers also serve distinct purposes. Imports strictly compose sources;
|
||||||
|
inheritance overlays a child on its resolved parent after source assembly.
|
||||||
|
Item-local contributions and nested inheritance introduce scope boundaries that
|
||||||
|
resolution must preserve.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
ConfigKit owns one shared dependency-ready scheduler for imports and sibling
|
||||||
|
inheritance. Both resolvers use Kahn's algorithm with a stable ordering key,
|
||||||
|
selecting the smallest currently eligible node after its dependencies have
|
||||||
|
completed. The shared scheduler owns eligibility, deterministic tie-breaking,
|
||||||
|
and dependency-cycle identification. Each resolver owns construction of its
|
||||||
|
dependency relationships and application of its domain operations.
|
||||||
|
|
||||||
|
Apply the common scheduling rule within the scopes where dependencies are
|
||||||
|
available. Complete item-local contributions before item placement and resolve
|
||||||
|
enclosing inheritance before relationships in the resulting nested scopes.
|
||||||
|
Source discovery supplies the import graph; the scheduler determines resolution
|
||||||
|
order.
|
||||||
|
|
||||||
|
Strict composition, inheritance overlay, and precedence tiers retain their
|
||||||
|
existing ownership and meanings under [ADR 0001](0001-separate-composition-and-overlay.md).
|
||||||
|
The lifecycle and scope decisions in
|
||||||
|
[ADR 0009](0009-sibling-inheritance.md) continue to govern inheritance. Import
|
||||||
|
admission retains its cycle and repeated-source rules.
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
- **Deterministic depth-first postorder.** Keeps dependency branches together,
|
||||||
|
but gives traversal branch selection an additional role in ordering.
|
||||||
|
- **Dependency rounds.** Groups nodes by readiness rounds, but delays newly
|
||||||
|
eligible nodes until the current round finishes.
|
||||||
|
- **Descending longest-path depth.** Prioritizes the furthest dependencies,
|
||||||
|
but adds distance from the root as an ordering policy.
|
||||||
|
- **Separate resolver algorithms.** Allows local specialization, but leaves
|
||||||
|
consumers and maintainers with different rules for analogous dependencies.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
Both resolvers follow one deterministic ordering model and share responsibility
|
||||||
|
for maintaining that model. Resolver-specific scopes, combination semantics,
|
||||||
|
and source-admission rules remain explicit at their integration boundaries.
|
||||||
|
|
||||||
|
Independent branches may interleave as nodes become eligible. Adopting this
|
||||||
|
rule changes existing traversal-dependent sequence positions and reporting
|
||||||
|
order, requiring coordinated review of source assembly, provenance, and
|
||||||
|
inheritance behavior. The configuration contract owns the implemented ordering
|
||||||
|
details.
|
||||||
83
docs/adr/0016-independent-import-controls.md
Normal file
83
docs/adr/0016-independent-import-controls.md
Normal file
@@ -0,0 +1,83 @@
|
|||||||
|
# ADR 0016: Separate Import Selection, Traversal, and Presence Requirements
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted. Implemented behavior is described in the
|
||||||
|
[configuration contract](../config.md#explicit-imports), the
|
||||||
|
[public API declarations](../../lifecycle.go), and the
|
||||||
|
[internal import overview](../internal/imports.md).
|
||||||
|
|
||||||
|
## Date
|
||||||
|
|
||||||
|
2026-10-19
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Consumers assemble configurations from named files and collections whose layout
|
||||||
|
varies between installations. Simple imports should require little setup, while
|
||||||
|
larger collections need control over discovery, recursion, filtering, and
|
||||||
|
required contributions. Combining those choices implicitly makes declarations
|
||||||
|
harder to interpret and pushes source-selection work into applications.
|
||||||
|
|
||||||
|
Discovery can also reach a candidate through overlapping searches or encounter
|
||||||
|
different names for one canonical source. Selection and source admission need
|
||||||
|
distinct identities to support useful search unions while preserving ConfigKit's
|
||||||
|
existing source-integrity rules.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
ConfigKit owns import discovery and selection within its source-loading
|
||||||
|
lifecycle. Support explicit files, directory searches, and path globs as
|
||||||
|
distinct selection forms. Keep traversal, filename filtering, and presence
|
||||||
|
requirements independently expressible. Discovery determines the selected
|
||||||
|
contributions; the shared scheduler in
|
||||||
|
[ADR 0015](0015-shared-dependency-ready-ordering.md) determines their resolution
|
||||||
|
order.
|
||||||
|
|
||||||
|
Use optional-by-default imports with explicit requiredness controls. Selected
|
||||||
|
sources remain subject to ConfigKit's validity, containment, resource, and
|
||||||
|
composition contracts. This applies the library's
|
||||||
|
[simplicity and defaults principles](../policy/architecture.md#simplicity-defaults-and-explicit-controls)
|
||||||
|
to import absence while retaining meaningful errors for invalid contributions.
|
||||||
|
|
||||||
|
Applications own their selected import defaults. Authored declaration values
|
||||||
|
take precedence over application defaults, which take precedence over library
|
||||||
|
defaults. Each declaration resolves applicable controls independently,
|
||||||
|
including declarations in imported sources. This gives applications a shared
|
||||||
|
baseline while preserving explicit choices in individual declarations.
|
||||||
|
|
||||||
|
Keep declaration-local candidate identity separate from canonical source
|
||||||
|
identity. Search unions coalesce repeated candidate paths within a source
|
||||||
|
namespace before symlink resolution; filename filtering uses the selected or
|
||||||
|
discovered name. Source admission continues to use canonical identity and the
|
||||||
|
existing repeated-source rules. Distinct aliases retain their separate
|
||||||
|
inclusion routes.
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
- **Use path globs for all discovery.** Offers one selection form, but makes
|
||||||
|
collection traversal depend on path-pattern structure and complicates
|
||||||
|
independently controlling recursion and filename filtering.
|
||||||
|
- **Offer only explicit files and directory searches.** Reduces the selector
|
||||||
|
vocabulary, but moves wildcard directory selection into repeated declarations
|
||||||
|
or application-side discovery.
|
||||||
|
- **Require every import by default.** Detects absent contributions immediately,
|
||||||
|
but requires repeated opt-outs for installations with optional components.
|
||||||
|
- **Make application settings override authored declarations.** Provides central
|
||||||
|
enforcement, but prevents declarations from expressing local exceptions.
|
||||||
|
- **Deduplicate selections globally by canonical source identity.** Coalesces
|
||||||
|
aliases and separate inclusion routes, but changes the existing repeated-source
|
||||||
|
contract and obscures independently authored contributions.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
Consumers can express simple imports and more selective collection searches
|
||||||
|
through the same loading lifecycle. Applications choose defaults and validate
|
||||||
|
their required effective configuration; ConfigKit owns consistent selection,
|
||||||
|
source admission, composition, and inspection.
|
||||||
|
|
||||||
|
Optional absence can conceal misspelled selectors, so requiredness and
|
||||||
|
inspection provide explicit ways to detect and explain missing contributions.
|
||||||
|
Declaration-local deduplication requires the loader to preserve candidate
|
||||||
|
identity alongside canonical source identity. The configuration and public API
|
||||||
|
contracts own the implemented behavior.
|
||||||
143
docs/adr/0017-collection-sequence-policy.md
Normal file
143
docs/adr/0017-collection-sequence-policy.md
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
# ADR 0017: Define Identity and Merge Rules for Each Collection
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted.
|
||||||
|
|
||||||
|
This supersedes the decisions about who declares identity and how
|
||||||
|
sequences combine in [ADR 0014](0014-sequence-placement-and-composition.md),
|
||||||
|
and the rule that inheritance always replaces sequences in
|
||||||
|
[ADR 0009](0009-sibling-inheritance.md). It also extends that ADR's sibling
|
||||||
|
scopes to identified sequences. Existing mapping-sibling scopes and source
|
||||||
|
placement continue to apply. It supersedes the whole-sequence
|
||||||
|
precedence replacement rule in the proposed
|
||||||
|
[ADR 0007](0007-source-backed-underlays.md).
|
||||||
|
|
||||||
|
## Date
|
||||||
|
|
||||||
|
2026-10-22
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
ConfigKit can load separate files as records in a sequence. Applications can
|
||||||
|
register an identity key so records with different identities combine and
|
||||||
|
duplicates fail. However, a higher configuration layer or an inheriting mapping
|
||||||
|
currently replaces the whole sequence. Applications that need to extend a
|
||||||
|
catalog across layers must write their own merging code and keep source and
|
||||||
|
history information consistent with its results.
|
||||||
|
|
||||||
|
Configuration authors need to choose how records are identified and whether a
|
||||||
|
layer adds records, updates records, or replaces the collection. Applications
|
||||||
|
also need a way to require unique identities in the final loaded configuration.
|
||||||
|
These rules must work consistently for imported records, inline sequences,
|
||||||
|
overlays, and inheritance.
|
||||||
|
|
||||||
|
Records in a sequence may also share settings. Authors need to reuse those
|
||||||
|
settings through inheritance while keeping each record's own identity.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
ConfigKit manages identity and merge rules for each collection during loading.
|
||||||
|
Configuration files can declare those rules for imported and inline sequences,
|
||||||
|
including sequences in selected overlays. Applications can require a particular
|
||||||
|
identity definition, and configuration declarations must agree with it. Source
|
||||||
|
placement determines where each contribution enters the configuration.
|
||||||
|
|
||||||
|
Keep the distinction between contributions at the same priority and overrides
|
||||||
|
from a higher layer, as established in
|
||||||
|
[ADR 0001](0001-separate-composition-and-overlay.md). Within one tier, records
|
||||||
|
with distinct identities combine; duplicate identities and conflicting
|
||||||
|
declarations fail. Determine the applicable rules before combining those
|
||||||
|
contributions, including any identity established in lower tiers.
|
||||||
|
|
||||||
|
Across layers, support three operations: replace the whole sequence, append
|
||||||
|
records while rejecting duplicate identities, or replace whole records by
|
||||||
|
identity (`upsert`). Source layering and sibling inheritance use the same
|
||||||
|
operations. For inheritance, the rules at the inheriting destination determine
|
||||||
|
which operation applies.
|
||||||
|
|
||||||
|
An identified sequence is also a sibling scope for inheritance. A record may
|
||||||
|
inherit from one other record in the same sequence, selected by its complete
|
||||||
|
identity. Each record supplies its own identity before inheritance. The child
|
||||||
|
keeps that identity and combines its settings with the parent's other fields,
|
||||||
|
using the existing mapping and collection rules. Parents remain ordinary records
|
||||||
|
subject to normal validation.
|
||||||
|
|
||||||
|
Resolve these relationships after source assembly, using the same dependency
|
||||||
|
ordering as mapping-sibling inheritance. Resolve nested relationships at each
|
||||||
|
inheriting destination. Preserve the assembled sequence order. Whole-record
|
||||||
|
upsert keeps its existing meaning; inheritance then uses the records that remain.
|
||||||
|
Typed defaults, CLI overrides, and application resolution follow the lifecycle
|
||||||
|
in [ADR 0018](0018-cli-after-structural-resolution.md).
|
||||||
|
|
||||||
|
An explicitly selected operation remains in effect at its destination until a
|
||||||
|
higher layer changes it. When no explicit operation applies, use upsert if an
|
||||||
|
identity definition is available; otherwise replace the whole sequence. Authors
|
||||||
|
can declare identity and an operation before supplying any records. Keep source
|
||||||
|
acquisition order separate from layer priority and the dependency ordering
|
||||||
|
established by [ADR 0015](0015-shared-dependency-ready-ordering.md).
|
||||||
|
|
||||||
|
Rules declared inside a record belong to that record. Retaining the record
|
||||||
|
retains its local rules; replacing it discards them. Replacing a whole collection
|
||||||
|
can discard its previous YAML identity definition, while application requirements
|
||||||
|
still apply. Inheritance can copy collection identity, but the inheriting
|
||||||
|
destination chooses its operation. Application registrations continue to govern
|
||||||
|
the paths where they were registered. The same ownership rules apply to
|
||||||
|
declarations made before a sequence exists.
|
||||||
|
|
||||||
|
Check YAML-declared identity when combining collections during source assembly
|
||||||
|
and inheritance. Application registrations also require the final loaded
|
||||||
|
sequences to satisfy their identity rules, including after typed defaults, CLI
|
||||||
|
overrides, and application resolution. This guarantee checks sequences that
|
||||||
|
exist; the application schema and validation decide whether a collection must
|
||||||
|
exist and which value types are allowed. The guarantee applies when loading
|
||||||
|
completes, rather than to every intermediate callback state or changes made by
|
||||||
|
the caller afterward.
|
||||||
|
|
||||||
|
Preserve explicit presence, source attribution, history, sensitivity, and
|
||||||
|
resource context through these operations, following
|
||||||
|
[ADR 0003](0003-preserve-presence-and-resolution-history.md). Typed defaults,
|
||||||
|
CLI assignments, and application resolution keep their own rules for changing
|
||||||
|
values. The public API provides registration and inspection; the implementation
|
||||||
|
chooses how to store and track collection rules internally.
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
- **Keep identity application-owned and always replace sequences across layers.**
|
||||||
|
Keeps the current design small, but reusable catalogs require application
|
||||||
|
changes or custom merging code.
|
||||||
|
- **Put merge rules on each import.** Keeps the rules beside the imported source,
|
||||||
|
but inline sequences and overlays need a separate way to declare them. It also
|
||||||
|
makes rules for a collection spread across several imports harder to reconcile.
|
||||||
|
- **Merge individual fields of records with matching identities.** Allows partial
|
||||||
|
record updates, but adds rules for field conflicts and retained values.
|
||||||
|
Replacing the whole record makes the update's scope clear.
|
||||||
|
- **Keep inheritance limited to mapping siblings.** Keeps parent lookup simpler,
|
||||||
|
but authors of sequence records must duplicate shared settings or applications
|
||||||
|
must implement inheritance themselves. Collection identity provides a way to
|
||||||
|
select a parent within the existing loading process.
|
||||||
|
- **Check every YAML identity again at the end of loading.** Gives YAML declarations
|
||||||
|
authority over values changed by defaults, CLI assignments, and application
|
||||||
|
resolution. This extends the lifetime of those declarations; application
|
||||||
|
registration provides that final guarantee when needed.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
Consumers can build and customize catalogs in one load while retaining useful
|
||||||
|
inspection information. Configuration authors choose how collections combine
|
||||||
|
within application requirements. Duplicate identities among contributions at
|
||||||
|
the same priority remain errors.
|
||||||
|
|
||||||
|
Existing registered collections use upsert across layers by default unless an
|
||||||
|
explicit operation selects otherwise. Correct loading now requires tracking
|
||||||
|
which declarations still apply as records are replaced, copied, or moved to new
|
||||||
|
positions. Lower-tier identity must be available before combining higher-tier
|
||||||
|
contributions.
|
||||||
|
|
||||||
|
Record inheritance also requires parent lookup by identity and checking for
|
||||||
|
missing parents and cycles. Copied settings retain the same source, history,
|
||||||
|
and sensitivity information as mapping-sibling inheritance.
|
||||||
|
|
||||||
|
The [configuration contract](../config.md#strict-document-composition) defines
|
||||||
|
declaration syntax, identity comparison, omission and empty-value behavior, and
|
||||||
|
diagnostics for the implemented feature.
|
||||||
84
docs/adr/0018-cli-after-structural-resolution.md
Normal file
84
docs/adr/0018-cli-after-structural-resolution.md
Normal file
@@ -0,0 +1,84 @@
|
|||||||
|
# ADR 0018: Apply CLI Overrides After Inheritance and Decoding
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted.
|
||||||
|
|
||||||
|
This supersedes the rule that CLI overrides apply before decoding in
|
||||||
|
[ADR 0005](0005-explicit-overrides-and-safe-explanation.md) and the
|
||||||
|
rule that they apply before inheritance in [ADR 0009](0009-sibling-inheritance.md).
|
||||||
|
Their remaining ownership, inheritance scope, and safe inspection decisions
|
||||||
|
continue to apply.
|
||||||
|
|
||||||
|
## Date
|
||||||
|
|
||||||
|
2026-10-22
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Explicit command-line values are the operator's highest-priority input. If they
|
||||||
|
apply before inheritance, a sequence supplied as a replacement could gain
|
||||||
|
inherited records afterward. Its final contents would then differ from the
|
||||||
|
operator's assignment.
|
||||||
|
|
||||||
|
The application also needs these values when it fills missing settings,
|
||||||
|
normalizes values, or computes derived settings. ConfigKit needs a clear loading
|
||||||
|
order that preserves both CLI precedence and the application's role in resolving
|
||||||
|
the final configuration.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
ConfigKit uses this loading order:
|
||||||
|
|
||||||
|
1. Combine sources within tiers and apply precedence between layers.
|
||||||
|
2. Resolve sibling inheritance, apply typed defaults, and decode the configuration
|
||||||
|
into the application's types.
|
||||||
|
3. Apply explicit CLI overrides and check them against the application schema.
|
||||||
|
4. Run application resolution using the overridden configuration.
|
||||||
|
5. Look up effective secret references, validate the final configuration, and
|
||||||
|
return it.
|
||||||
|
|
||||||
|
CLI overrides are the highest-priority input to application resolution. A CLI
|
||||||
|
sequence replaces the whole sequence; mappings combine recursively. Collection
|
||||||
|
merge rules apply during the earlier source and inheritance steps. Inheritance
|
||||||
|
and typed defaulting do not run again after CLI overrides.
|
||||||
|
|
||||||
|
Keep track of which values were explicitly supplied and which CLI input supplied
|
||||||
|
them. Applications continue to parse command-line arguments; ConfigKit applies
|
||||||
|
and validates the overrides and makes their effects inspectable. Reject
|
||||||
|
structural directives in override paths and values, so instructions such as
|
||||||
|
inheritance relationships remain part of source processing.
|
||||||
|
|
||||||
|
Application resolution keeps its existing rules for defaults and recorded
|
||||||
|
changes under [ADR 0003](0003-preserve-presence-and-resolution-history.md).
|
||||||
|
It receives CLI values together with their presence and source information.
|
||||||
|
Defaults that only fill missing settings preserve explicit CLI values.
|
||||||
|
Application normalization and derived-value calculations can still change them,
|
||||||
|
with those changes recorded. Secret lookup and final validation use the resulting
|
||||||
|
configuration.
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
- **Apply CLI overrides before inheritance.** Preserves existing ordering, but
|
||||||
|
later merging needs special handling to preserve CLI replacement intent while
|
||||||
|
still combining ordinary source contributions.
|
||||||
|
- **Apply CLI overrides after inheritance but before typed decoding.** Allows an
|
||||||
|
override to fix a source value that would otherwise fail decoding, but leaves
|
||||||
|
typed defaulting after the operator's assignment. Completing defaulting and
|
||||||
|
decoding first gives CLI overrides an already resolved configuration to modify.
|
||||||
|
- **Apply CLI overrides after application resolution.** Applies assignments after
|
||||||
|
the application's changes, but derived settings still reflect earlier values
|
||||||
|
unless resolution runs again.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
CLI overrides change a configuration whose inheritance and typed defaults are
|
||||||
|
already resolved. The application can then derive settings from the operator's
|
||||||
|
values. The final values, source information, history, and sensitivity handling
|
||||||
|
must remain consistent throughout loading.
|
||||||
|
|
||||||
|
CLI input can no longer add or cancel inheritance relationships or bypass an
|
||||||
|
earlier error in source assembly or inheritance. It also cannot fix a value that
|
||||||
|
fails the earlier decoding step. Semantic validation still runs after overrides
|
||||||
|
and application resolution. The [configuration contract](../config.md#explicit-cli-overrides)
|
||||||
|
and [consumer guidance](../consumers/README.md) describe this behavior.
|
||||||
68
docs/adr/0019-explicit-configuration-discovery.md
Normal file
68
docs/adr/0019-explicit-configuration-discovery.md
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
# ADR 0019: Keep Configuration Discovery Explicit and Inside Loading
|
||||||
|
|
||||||
|
## Status
|
||||||
|
|
||||||
|
Accepted.
|
||||||
|
|
||||||
|
## Date
|
||||||
|
|
||||||
|
2026-10-24
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Applications often need a small ordered convention for choosing a main
|
||||||
|
configuration file. Keeping that choice entirely in each application repeats
|
||||||
|
path selection and file-loading plumbing. Moving the convention into ConfigKit
|
||||||
|
without an explicit declaration would make ordinary loads search paths that a
|
||||||
|
consumer did not choose, obscuring both precedence and filesystem access.
|
||||||
|
|
||||||
|
ConfigKit already owns synchronous source acquisition, parsing, composition,
|
||||||
|
decoding, validation, and inspection. A selected host file can therefore enter
|
||||||
|
that lifecycle as the existing file source, preserving its source identity,
|
||||||
|
resource context, and diagnostics. Optional exhaustion can use the same absent
|
||||||
|
main lifecycle as an explicitly absent source.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
Consumers choose and order the main-file conventions they want to use. They may
|
||||||
|
describe literal paths, directory filename groups, and supported directory
|
||||||
|
presets, and may mark the complete search as required. Search descriptions are
|
||||||
|
explicit inputs to loading; no preset or convention is activated implicitly.
|
||||||
|
|
||||||
|
During synchronous loading, ConfigKit resolves the description to one real host
|
||||||
|
file or to absence before parsing. The selected file follows the existing source
|
||||||
|
acquisition and processing rules. A selected-file access, parse, version,
|
||||||
|
composition, decode, or validation failure is terminal and does not resume the
|
||||||
|
search. Optional exhaustion shares the absent-main lifecycle; required
|
||||||
|
exhaustion reports a missing-source diagnostic. No candidate-attempt history is
|
||||||
|
added to public metadata.
|
||||||
|
|
||||||
|
This keeps convention choice with applications while moving repeated selection
|
||||||
|
and loading plumbing into the library's established lifecycle. It preserves
|
||||||
|
explicit control, ordinary source behavior, and complete-on-return ownership.
|
||||||
|
|
||||||
|
## Alternatives Considered
|
||||||
|
|
||||||
|
- **Keep all search and fallback loading in each application.** This preserves
|
||||||
|
consumer ownership but repeats the file-selection and load coordination that
|
||||||
|
ConfigKit already owns, and risks divergent handling of selected-file errors.
|
||||||
|
- **Search conventional locations implicitly during every load.** This reduces
|
||||||
|
setup, but activates filesystem access and precedence the consumer did not
|
||||||
|
select and makes ordinary source behavior less predictable.
|
||||||
|
- **Return candidate paths for the application to load one by one.** This keeps
|
||||||
|
selection outside the load lifecycle and encourages repeated complete loads,
|
||||||
|
potentially rerunning defaults, callbacks, and validation during fallback.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
The public search description must be immutable and root-owned, with private
|
||||||
|
engine conversion and selection. The contract documents candidate ordering,
|
||||||
|
selection, exhaustion, context, and provenance; the API declaration documents
|
||||||
|
the public symbols. Dynamic bases and file availability are observed when each
|
||||||
|
load consumes the description. Applications remain responsible for selecting
|
||||||
|
conventions and for their schema and domain requirements.
|
||||||
|
|
||||||
|
The [configuration contract](../config.md) owns implemented behavior, and the
|
||||||
|
[component overview](../internal/overview.md) describes implementation ownership.
|
||||||
|
The completed implementation plan and remediation record are preserved in Git
|
||||||
|
history at `0369111` under `docs/roadmap/`.
|
||||||
1448
docs/config.md
1448
docs/config.md
File diff suppressed because it is too large
Load Diff
@@ -1,66 +1,142 @@
|
|||||||
# 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.
|
||||||
|
|
||||||
The maintained [`examples/consumers`](../../examples/consumers) package demonstrates
|
Import `"go.maximumdirect.net/configkit"`; the package name is `configkit`.
|
||||||
sanitized shapes using only ConfigKit and the Go standard library:
|
Start with one file, then choose the tasks your application needs:
|
||||||
|
|
||||||
| Shape | Demonstrated fit | Application code deliberately retained |
|
| Task | Start here |
|
||||||
| --- | --- | --- |
|
| --- | --- |
|
||||||
| 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 |
|
| Load one file | [Happy path](#happy-path) and the [README example](../../README.md#load-a-configuration-file) |
|
||||||
| 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` |
|
| Supply fixed defaults and check application rules | [Defaults and application rules](#defaults-and-application-rules) |
|
||||||
| 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 |
|
| Add bundled defaults beneath application settings | [Native filesystem and layered input](#native-filesystem-and-layered-input) |
|
||||||
| Seriatim-like | Independent strict ordered speaker and autocorrect rule files | First-match and ordered replacement algorithms; no central application configuration file |
|
| 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) |
|
||||||
| Bundled file | Simple native `fs.FS` source through ordinary `Load` | Selection and lifetime of the application-owned embedded filesystem |
|
| Reuse settings or combine record lists | [Inheritance](#named-sibling-inheritance) and [identified sequences](#identified-prompt-sequences) |
|
||||||
| 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 |
|
| Handle credentials | [Typed-secret reference](../config.md#typed-secret-references) and the [startup example](../../examples/consumers/consumers_test.go) |
|
||||||
| 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 |
|
| Inspect sources, earlier values, or changes | [Inspect a completed load](#inspect-a-completed-load) and [diffs](#effective-configuration-diffs) |
|
||||||
| Named presets | One-load sibling inheritance, explicit false, nested destination resolution, and source/derivation inspection | Choosing and executing a preset |
|
| Upgrade an existing consumer | [Migration guidance](#intentional-migration-changes) |
|
||||||
| 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 |
|
|
||||||
| Identified prompt sequences | Explicit item path/glob imports, helper-supplied IDs, reusable single-field registration, and main-tier replacement of a lower-priority collection | Prompt meaning and final domain validation |
|
|
||||||
|
|
||||||
The YAML under [`testdata`](../../examples/consumers/testdata) is loaded by the
|
## Choosing a loading entry point
|
||||||
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
|
- Use [`Load`](../../lifecycle.go) with one source for an ordinary configuration.
|
||||||
`version: "4"` into the same string-backed application field. Its version checker
|
[`SearchConfig`](../../discovery.go) is also a source; pass it to `Load` or use
|
||||||
still owns the supported identifier; fractional numeric input and unsupported
|
it as the main input to [`LoadLayered`](../../layered.go).
|
||||||
integer identifiers remain negative examples.
|
- Use `LoadLayered` when several inputs should supply lower-priority defaults
|
||||||
|
beneath the main source. Each underlay group combines its inputs strictly.
|
||||||
|
Each [`Input`](../../layered.go) owns its source, filesystem, destination, and
|
||||||
|
import choice. The overlay filesystem context is selected independently in
|
||||||
|
[`LayeredOptions`](../../layered.go).
|
||||||
|
- Load-wide rules belong to [`Options`](../../lifecycle.go) or
|
||||||
|
[`LayeredOptions`](../../layered.go): the application definition (including
|
||||||
|
version policy), import defaults, and explicit overrides. These do not turn
|
||||||
|
on imports or discovery by themselves. See the
|
||||||
|
[configuration contract](../config.md#layered-loading) and
|
||||||
|
[discovery contract](../config.md#configuration-discovery) for exact rules.
|
||||||
|
|
||||||
|
The root [README example](../../README.md) shows the small `Load` path.
|
||||||
|
[`layered.go`](../../examples/consumers/layered.go) demonstrates the bundled
|
||||||
|
file and layered cases. For import-focused adoption, see the
|
||||||
|
[prompt catalog](../../examples/consumers/prompt_catalog.go); for explicit
|
||||||
|
discovery composed with sequences, see the
|
||||||
|
[sequence catalog](../../examples/consumers/sequence_catalog.go).
|
||||||
|
|
||||||
## 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
|
||||||
|
|
||||||
|
Applications choose the search conventions and their precedence, then pass the
|
||||||
|
resulting `SearchConfig` source to the same `Load` or `LoadLayered` entry
|
||||||
|
point. For example, the maintained sequence catalog puts a caller-selected
|
||||||
|
file before a custom directory fallback while keeping its underlay and overlay
|
||||||
|
choices independent. See
|
||||||
|
[`sequence_catalog.go`](../../examples/consumers/sequence_catalog.go) and its
|
||||||
|
[behavior tests](../../examples/consumers/sequence_catalog_test.go), plus the
|
||||||
|
[working-directory executable
|
||||||
|
example](../../examples/consumers/discovery_test.go). Together, the [discovery
|
||||||
|
API](../../discovery.go) and [configuration discovery
|
||||||
|
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.
|
||||||
|
|
||||||
|
## Import defaults and selection inspection
|
||||||
|
|
||||||
|
`Options.ImportDefaults` and `LayeredOptions.ImportDefaults` apply one
|
||||||
|
load-wide policy to enabled main, underlay, and transitive imports. Each field
|
||||||
|
is resolved independently: a declaration's explicit value wins, then the
|
||||||
|
application default, then the library default. `Required`, `FollowSymlinks`,
|
||||||
|
`IncludeHidden`, and `Recursive` use nil pointers for unspecified values.
|
||||||
|
`Pattern` applies to
|
||||||
|
directory searches: nil leaves filenames unrestricted, while a non-nil empty
|
||||||
|
slice selects no files. A declaration's own `pattern` replaces the application
|
||||||
|
default. The defaults do not enable imports or change selected-overlay behavior.
|
||||||
|
The Go fields `FollowSymlinks` and `IncludeHidden` correspond to the unchanged
|
||||||
|
YAML keys `follow` and `hidden`.
|
||||||
|
|
||||||
|
`Result.Sources`, `LoadError.Sources`, and `Resolution.Source` expose each
|
||||||
|
declaring source's `EmptyImportSelections`. `Result.Source(id)` and
|
||||||
|
`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
|
||||||
|
records are detached snapshots. The [configuration contract](../config.md#explicit-imports)
|
||||||
|
owns selector applicability and exact absence semantics.
|
||||||
|
|
||||||
## Shared source-version checks
|
## Shared source-version checks
|
||||||
|
|
||||||
[`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,
|
||||||
@@ -78,13 +154,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
|
||||||
|
|
||||||
@@ -166,21 +243,35 @@ import, source-location, and ownership behavior.
|
|||||||
|
|
||||||
## Identified prompt sequences
|
## Identified prompt sequences
|
||||||
|
|
||||||
[`LoadPromptCatalog`](../../examples/consumers/prompt_catalog.go) uses one
|
[`LoadPromptCatalog`](../../examples/consumers/prompt_catalog.go) shows the
|
||||||
definition registration for the literal `prompts` path and `id` field. Its main
|
smaller import-focused adoption path: a registered `prompts.id` identity,
|
||||||
file imports one prompt by path and others by glob, placing each source as an
|
directory and glob selection, a helper import that supplies an item's ID, and
|
||||||
item. A nested ordinary helper import supplies an item's ID. The main tier
|
resource lookup through the effective source context. Its executable examples
|
||||||
strictly appends the distinct records in contribution order; its completed
|
and tests are in
|
||||||
sequence replaces the lower-priority default collection as one value.
|
[`prompt_catalog_test.go`](../../examples/consumers/prompt_catalog_test.go), with
|
||||||
|
complete [catalog](../../examples/consumers/testdata/prompt-catalog/config.yml)
|
||||||
|
and [required-file](../../examples/consumers/testdata/prompt-catalog/simple.yml)
|
||||||
|
fixtures.
|
||||||
|
|
||||||
The example's `validatePromptCatalog` remains application-owned. It checks that
|
[`LoadSequenceCatalog`](../../examples/consumers/sequence_catalog.go) integrates
|
||||||
prompt names and message lists satisfy this consumer's requirements after
|
an underlay declaration, imported main fragments, identified-record inheritance,
|
||||||
ConfigKit has composed, projected, and decoded the configuration. It does not
|
selected overlay `append`, `upsert`, and `replace`, a typed CLI override, and
|
||||||
add recursive record merging, identity-based overlay, or final uniqueness checks
|
recorded application resolution. Its tests and runnable example demonstrate
|
||||||
after defaults and resolution. See the [strict composition contract](../config.md#strict-document-composition),
|
stable output order, whole-record upsert, provenance/history inspection,
|
||||||
[import syntax](../config.md#explicit-imports), and
|
resource lookup for inherited records, and secret-safe explanation in
|
||||||
[ADR 0014](../adr/0014-sequence-placement-and-composition.md) for the exact
|
[`sequence_catalog_test.go`](../../examples/consumers/sequence_catalog_test.go).
|
||||||
boundaries.
|
[`LoadNamespacedMapCatalog`](../../examples/consumers/sequence_catalog.go) adds
|
||||||
|
an exact root registration and a wildcard namespace registration, showing that
|
||||||
|
each concrete sequence has an independent identity scope. The same tests show
|
||||||
|
that a YAML identity declaration without an application registration does not
|
||||||
|
create final-result identity enforcement.
|
||||||
|
|
||||||
|
The examples keep prompt meaning and domain checks in the consuming application.
|
||||||
|
The [configuration contract](../config.md#strict-document-composition) owns
|
||||||
|
declaration, identity, operation, ownership, and final-validation semantics;
|
||||||
|
[sibling inheritance](../config.md#sibling-inheritance) defines record
|
||||||
|
relationships, and the [CLI contract](../config.md#explicit-cli-overrides) owns
|
||||||
|
override ordering.
|
||||||
|
|
||||||
Known module catalogs should use canonical object entries with a module identifier
|
Known module catalogs should use canonical object entries with a module identifier
|
||||||
and typed options. A small union struct is sufficient when the available option
|
and typed options. A small union struct is sufficient when the available option
|
||||||
@@ -193,14 +284,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
|
||||||
@@ -210,7 +300,15 @@ existence checks nor confinement checks.
|
|||||||
|
|
||||||
## 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
|
||||||
@@ -230,10 +328,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`,
|
||||||
@@ -244,10 +402,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
|
||||||
|
|
||||||
@@ -259,7 +447,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` |
|
||||||
|
|||||||
@@ -59,6 +59,11 @@ Accepted decisions:
|
|||||||
- [Keep the public facade root-owned](adr/0006-public-facade.md)
|
- [Keep the public facade root-owned](adr/0006-public-facade.md)
|
||||||
- [Compare frozen effective configuration snapshots](adr/0010-effective-configuration-diff.md)
|
- [Compare frozen effective configuration snapshots](adr/0010-effective-configuration-diff.md)
|
||||||
- [Place inputs at literal application mapping paths](adr/0011-input-destinations.md)
|
- [Place inputs at literal application mapping paths](adr/0011-input-destinations.md)
|
||||||
|
- [Share dependency-ready ordering](adr/0015-shared-dependency-ready-ordering.md)
|
||||||
|
- [Separate import selection controls](adr/0016-independent-import-controls.md)
|
||||||
|
- [Define identity and merge rules for each collection](adr/0017-collection-sequence-policy.md)
|
||||||
|
- [Apply CLI overrides after inheritance and decoding](adr/0018-cli-after-structural-resolution.md)
|
||||||
|
- [Keep main-file discovery explicit and composable](adr/0019-explicit-configuration-discovery.md)
|
||||||
|
|
||||||
For cross-cutting changes, follow every applicable row. Completed implementation
|
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
|
||||||
@@ -108,7 +113,7 @@ git show 8e91a2b:docs/roadmap/startup-benchmark.md
|
|||||||
```
|
```
|
||||||
|
|
||||||
Remaining optimization candidates belong to
|
Remaining optimization candidates belong to
|
||||||
[future performance experiments](roadmap/future.md#3-performance-experiments).
|
[future performance experiments](roadmap/future.md#5-performance-experiments).
|
||||||
|
|
||||||
The completed effective-configuration diff roadmap, implementation plan, and
|
The completed effective-configuration diff roadmap, implementation plan, and
|
||||||
resolved review findings are preserved in Git history at `1c2610c` under
|
resolved review findings are preserved in Git history at `1c2610c` under
|
||||||
@@ -131,12 +136,13 @@ Current behavior belongs to the configuration contract's
|
|||||||
[input destinations](config.md#input-destinations),
|
[input destinations](config.md#input-destinations),
|
||||||
[imports](config.md#explicit-imports), and
|
[imports](config.md#explicit-imports), and
|
||||||
[shared source-version checks](config.md#shared-source-version-checks) sections.
|
[shared source-version checks](config.md#shared-source-version-checks) sections.
|
||||||
Explicit sequence-item placement and registered strict composition are owned by
|
Explicit sequence-item placement, collection operations, identity registration,
|
||||||
the [strict composition](config.md#strict-document-composition) and
|
and final registered-identity validation are owned by the
|
||||||
[imports](config.md#explicit-imports) sections. The
|
[strict composition](config.md#strict-document-composition) section;
|
||||||
[maintained prompt catalog](consumers/README.md#identified-prompt-sequences)
|
[imports](config.md#explicit-imports) owns acquisition and scheduling. The
|
||||||
shows the current consumer boundary, and [ADR 0014](adr/0014-sequence-placement-and-composition.md)
|
[maintained sequence catalog](consumers/README.md#identified-prompt-sequences)
|
||||||
records the decision.
|
shows the consumer boundary, and [ADR 0017](adr/0017-collection-sequence-policy.md)
|
||||||
|
records the collection decision.
|
||||||
|
|
||||||
The completed sequence-composition roadmap, implementation plan, and resolved
|
The completed sequence-composition roadmap, implementation plan, and resolved
|
||||||
remediation record are preserved in Git history at `008d2ba` under
|
remediation record are preserved in Git history at `008d2ba` under
|
||||||
@@ -148,7 +154,77 @@ git show 008d2ba:docs/roadmap/implementation.md
|
|||||||
git show 008d2ba:docs/roadmap/remediation.md
|
git show 008d2ba:docs/roadmap/remediation.md
|
||||||
```
|
```
|
||||||
|
|
||||||
|
The completed import-discovery roadmap, implementation plan, and resolved
|
||||||
|
remediation record are preserved in Git history at `5747ec4` under
|
||||||
|
`docs/roadmap/`. For example:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git show 5747ec4:docs/roadmap/imports.md
|
||||||
|
git show 5747ec4:docs/roadmap/implementation.md
|
||||||
|
git show 5747ec4:docs/roadmap/remediation.md
|
||||||
|
```
|
||||||
|
|
||||||
|
The subsequent import-integration audit and completed remediation record are
|
||||||
|
preserved at `6903c9e:docs/roadmap/remediation.md`; retrieve them with
|
||||||
|
`git show 6903c9e:docs/roadmap/remediation.md`.
|
||||||
|
|
||||||
|
The completed sequence-identity/collection-policy roadmap and implementation
|
||||||
|
plan are preserved in Git history at `d0a605a`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git show d0a605a:docs/roadmap/sequences.md
|
||||||
|
git show d0a605a:docs/roadmap/implementation.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Current behavior belongs to the configuration contract's
|
||||||
|
[strict composition](config.md#strict-document-composition),
|
||||||
|
[sibling inheritance](config.md#sibling-inheritance), and
|
||||||
|
[CLI overrides](config.md#explicit-cli-overrides) sections.
|
||||||
|
The completed remediation record, including the algorithm-audit findings, is
|
||||||
|
preserved at `ea46001:docs/roadmap/remediation.md`.
|
||||||
|
|
||||||
|
The completed configuration-discovery roadmap, implementation plan, and
|
||||||
|
remediation record are preserved in Git history at `0369111`, for example
|
||||||
|
`git show 0369111:docs/roadmap/remediation.md`. Current behavior belongs to
|
||||||
|
[configuration discovery](config.md#configuration-discovery); the public
|
||||||
|
[discovery declarations](../discovery.go) own the Go API.
|
||||||
|
|
||||||
|
The completed consolidation proposal, implementation plan, and resolved audit
|
||||||
|
findings are preserved in Git history at `34cbc0d` under `docs/roadmap/`. For
|
||||||
|
example:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git show 34cbc0d:docs/roadmap/implementation.md
|
||||||
|
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
|
||||||
@@ -196,21 +272,25 @@ code changes. Use the component overview to find the corresponding implementatio
|
|||||||
| Task | Behavioral test owners | Focused command |
|
| Task | Behavioral test owners | Focused command |
|
||||||
| --- | --- | --- |
|
| --- | --- | --- |
|
||||||
| YAML syntax, diagnostics, parser limits | [Adapter tests](../internal/yaml/parse_test.go), [source translation](../internal/engine/parse_test.go) | `go test ./internal/yaml` and `go test ./internal/engine -run '^TestParseSource'` |
|
| YAML syntax, diagnostics, parser limits | [Adapter tests](../internal/yaml/parse_test.go), [source translation](../internal/engine/parse_test.go) | `go test ./internal/yaml` and `go test ./internal/engine -run '^TestParseSource'` |
|
||||||
| File/reader access, imports, globs, containment | [Sources](../internal/engine/source_test.go), [FIFO](../internal/engine/source_fifo_test.go), [imports](../internal/engine/imports_test.go), [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'` |
|
||||||
|
| 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'` |
|
||||||
| Sibling inheritance, provenance, history, sensitivity | [Expansion](../internal/engine/inheritance_test.go), [destination scopes](../internal/engine/inheritance_walk_test.go), [origins](../internal/engine/inheritance_origin_test.go), [history](../internal/engine/inheritance_history_test.go), [secrets](../internal/engine/inheritance_secret_test.go), [public loading](../facade_inheritance_test.go) | `go test ./internal/engine -run 'Inheritance\|Inherited'` and `go test . -run '^TestFacadeInheritance\|^TestFacadeInheritedSecrets'` |
|
| Sequence identity, declarations, and operations | [Identity](../internal/engine/sequence_identity_test.go), [registrations](../internal/engine/sequence_registration_test.go), [declarations](../internal/engine/sequence_declaration_test.go), [combination](../internal/engine/sequence_combine_test.go), [final facade validation](../facade_final_sequence_identity_test.go), and [consumer catalog](../examples/consumers/sequence_catalog_test.go) | `go test ./internal/engine -run 'Sequence\|Collection'`, `go test . -run 'SequenceIdentity\|SequenceRegistration'`, and `go test ./examples/consumers` |
|
||||||
|
| Sibling inheritance, provenance, history, sensitivity | [Implementation flow](internal/overview.md#implementation-flow), [expansion](../internal/engine/inheritance_test.go), [destination scopes](../internal/engine/inheritance_walk_test.go), [origins](../internal/engine/inheritance_origin_test.go), [history](../internal/engine/inheritance_history_test.go), [secrets](../internal/engine/inheritance_secret_test.go), [public loading](../facade_inheritance_test.go) | `go test ./internal/engine -run 'Inheritance\|Inherited'` and `go test . -run '^TestFacadeInheritance\|^TestFacadeInheritedSecrets'` |
|
||||||
| Layered loading and source equivalence | [Grouped lifecycle](../internal/engine/load_request_test.go), [layered facade](../facade_layered_test.go), [equivalence](../facade_equivalence_test.go) | `go test ./internal/engine -run '^TestGrouped'`, `go test . -run '^TestFacadeLayered'`, and `go test . -run '^TestEquivalentSourceForms'` |
|
| 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 | [Schema](../internal/engine/schema_test.go), [decoding](../internal/engine/decode_test.go), [projection](../internal/engine/projection_test.go) | `go test ./internal/engine -run 'Schema\|Decode\|Projection\|Clone\|Inline\|CompareDocument'` |
|
| 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'` |
|
||||||
| 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` |
|
||||||
| 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'` |
|
| Documentation links and dependency isolation | [Link checks](../documentation_test.go), [import boundary](../import_boundary_test.go), [public boundary](../public_boundary_test.go) | `go test . -run 'TestLocalDocumentationLinks\|TestDependencyImportBoundary\|TestPublicFacadeBoundary'` |
|
||||||
| Development check behavior | [Development checker test](../development_check_test.go) | `go test . -run '^TestDevelopmentCheckHandlesFilenamesAndStopsOnFailure$'` |
|
| Development check behavior | [Development checker test](../development_check_test.go) | `go test . -run '^TestDevelopmentCheckHandlesFilenamesAndStopsOnFailure$'` |
|
||||||
|
|||||||
125
docs/internal/imports.md
Normal file
125
docs/internal/imports.md
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
# Import Discovery and Scheduling Internals
|
||||||
|
|
||||||
|
The [configuration contract](../config.md#explicit-imports) owns selector syntax,
|
||||||
|
defaults, requiredness, placement, inspection evidence, and observable ordering.
|
||||||
|
This note records implementation flow and private identities for maintainers.
|
||||||
|
|
||||||
|
## Discovery and acquisition
|
||||||
|
|
||||||
|
Each source's root directive is parsed into presence-aware declarations and
|
||||||
|
resolved against load-wide defaults. `acquisition.go` owns shared acquisition
|
||||||
|
policy, load-wide accounting, and tier-scoped repetition/namespace state;
|
||||||
|
`import_acquisition.go` owns reading candidate bytes, declaration traversal,
|
||||||
|
and import admission. Candidate
|
||||||
|
selection in `import_selection.go` preserves a normalized
|
||||||
|
namespace path separately from canonical access identity: overlapping roots
|
||||||
|
coalesce the same candidate spelling in that namespace, while aliases reached
|
||||||
|
through distinct spellings retain separate inclusion routes for admission.
|
||||||
|
Directory searches walk each distinct normalized root once, apply all filename
|
||||||
|
patterns to discovered names, and form a set union of matching candidates across
|
||||||
|
overlapping roots. Exclusions are applied as set difference after discovery and
|
||||||
|
candidate normalization. They do not suppress discovery or resource errors.
|
||||||
|
|
||||||
|
Directory traversal tracks canonical directories on the active traversal path.
|
||||||
|
It adds an identity when descending and removes it when the frame is popped, so
|
||||||
|
only a back-edge to an active directory is a traversal cycle; a later alias is
|
||||||
|
not mistaken for one. This is separate from import dependency-cycle detection.
|
||||||
|
For host sources, discovery uses rooted `Lstat` and canonical containment.
|
||||||
|
`filesystem_context.go` owns resolved access facts and live access setup; the
|
||||||
|
acquisition operation closes its live handles before composition. Native sources
|
||||||
|
use `fs.ReadLinkFS` inspection when supplied; otherwise traversal follows the
|
||||||
|
capabilities and behavior of the provided `fs.FS`, without claiming a general
|
||||||
|
sandbox guarantee. `source_context.go` derives passive resource contexts from
|
||||||
|
resolved sources for later caller-owned reads; those records do not retain live
|
||||||
|
acquisition handles.
|
||||||
|
|
||||||
|
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
|
||||||
|
selected candidate set in namespace-path lexical order. This preserves source
|
||||||
|
records, IDs, source-local version checks, and callback ordering. The acquired
|
||||||
|
inclusion graph is retained separately from composition scopes so cycles that
|
||||||
|
cross item boundaries remain detectable. Source discovery does not itself choose
|
||||||
|
sequence assembly order.
|
||||||
|
|
||||||
|
## Stable keys and scheduling
|
||||||
|
|
||||||
|
The shared scheduler compares immutable tuples component by component using Go's
|
||||||
|
bytewise string order; if one tuple is a prefix of another, the shorter tuple
|
||||||
|
sorts first. The import key is
|
||||||
|
`(namespace-kind, namespace-anchor, resource-kind, resource-path)`:
|
||||||
|
|
||||||
|
- Namespace kind is `host` or `native`. Host resource paths are canonical
|
||||||
|
absolute paths rendered with slash separators; native paths retain their
|
||||||
|
namespace-relative identity.
|
||||||
|
- Anchors come from explicit input positions, not discovery order: main is
|
||||||
|
`(0,0)` and underlay/input positions are `(group+1,input+1)`, each encoded as
|
||||||
|
fixed-width decimal. Host accesses in one scheduled tier share its host anchor.
|
||||||
|
A native filesystem namespace uses its earliest explicit selecting position
|
||||||
|
in that tier. Filesystem tokens distinguish namespace equality, not sort order,
|
||||||
|
so separately constructed filesystems with the same label remain distinct.
|
||||||
|
- Resource kind is `file` or `synthetic`. A synthetic root uses its explicit
|
||||||
|
input anchor and an empty resource path rather than a display label or token
|
||||||
|
address.
|
||||||
|
|
||||||
|
Inheritance keys are
|
||||||
|
`("definition", scope-pointer, definition-pointer)`, where both pointers use
|
||||||
|
canonical escaped semantic path spelling. Source IDs are not part of the key.
|
||||||
|
|
||||||
|
Both integrations use the same Kahn scheduler: it deduplicates dependency edges,
|
||||||
|
keeps currently ready nodes in a min-heap, and selects the smallest ready key
|
||||||
|
after every completion. Its scheduling cost is `O(E + V log V)` time and
|
||||||
|
`O(V + E)` additional memory, excluding key construction, tuple copying, and
|
||||||
|
tuple comparisons. On a stalled graph, deterministic DFS over the residual graph
|
||||||
|
returns a closed cycle rotated to its smallest key; blocked dependents are not
|
||||||
|
reported as cycle members.
|
||||||
|
|
||||||
|
Import edge preparation builds an importer-to-target adjacency index once for
|
||||||
|
an acquisition's outer and item-local scheduling, and once for grouped
|
||||||
|
scheduling. Each scope gathers edges from its participating identities and
|
||||||
|
retains only edges with both endpoints in its local slots. The retained
|
||||||
|
inclusion graph still owns cycle diagnostics and source relationships.
|
||||||
|
|
||||||
|
Import composition schedules a main tier or one underlay group as a single
|
||||||
|
strict-scope graph. Applicable collection declarations are gathered for the
|
||||||
|
scope before peer composition, including identity inherited from lower tiers.
|
||||||
|
Identified peer sequences then combine distinct records in assembled source and
|
||||||
|
list order; after the strict scope completes, layered assembly applies the
|
||||||
|
resolved collection operation against the lower value, as defined by the
|
||||||
|
[configuration contract](../config.md#strict-document-composition). Item
|
||||||
|
contributions own nested local strict-mapping scopes; their helpers complete
|
||||||
|
before the item is placed in its outer scope. Inheritance uses the same
|
||||||
|
ready-node rule inside each sibling mapping scope, while preserving the authored
|
||||||
|
mapping order in its output.
|
||||||
|
These scopes do not flatten into one global graph, and independent precedence
|
||||||
|
tiers remain separate.
|
||||||
|
|
||||||
|
The owners are [`acquisition.go`](../../internal/engine/acquisition.go),
|
||||||
|
[`import_selection.go`](../../internal/engine/import_selection.go),
|
||||||
|
[`import_acquisition.go`](../../internal/engine/import_acquisition.go),
|
||||||
|
[`directory_walk.go`](../../internal/engine/directory_walk.go),
|
||||||
|
[`import_graph.go`](../../internal/engine/import_graph.go),
|
||||||
|
[`import_projection.go`](../../internal/engine/import_projection.go),
|
||||||
|
[`dependency_order.go`](../../internal/engine/dependency_order.go), and
|
||||||
|
[`inheritance_schedule.go`](../../internal/engine/inheritance_schedule.go).
|
||||||
|
Their focused behavior is covered by import-selection, acquisition, scheduler,
|
||||||
|
and scheduling integration tests under `internal/engine`; see the
|
||||||
|
[development test map](../development.md#task-to-test-map).
|
||||||
@@ -7,28 +7,41 @@ private state; public signatures and fields do not expose engine types.
|
|||||||
|
|
||||||
Implemented component owners:
|
Implemented component owners:
|
||||||
|
|
||||||
- `internal/engine` owns host and native-filesystem source acquisition,
|
- `internal/engine` owns host and native-filesystem source acquisition and
|
||||||
grouped strict composition, recursive precedence assembly, per-input mapping
|
explicitly selected configuration discovery. A search resolves once during
|
||||||
and sequence-item placement, definition-registered distinct-ID sequence
|
shared acquisition; the selected concrete file or absent-input state then
|
||||||
composition within strict tiers, and immediate-parent-relative import
|
follows the existing composition, metadata, and resource-context lifecycle.
|
||||||
placement within that assembly. Higher-priority sequences replace lower
|
It also owns grouped strict composition, recursive precedence assembly,
|
||||||
sequences as whole values. The engine also owns destination-local sibling
|
per-input mapping and sequence-item placement, YAML collection declarations
|
||||||
inheritance before typed decoding, plus shared source-version checking with
|
and operations for identified sequences, and application identity registrations for strict
|
||||||
sanitized declaration evidence.
|
composition, inheritance, and final validation. It applies the selected
|
||||||
|
collection operation at precedence boundaries and owns dependency-ready,
|
||||||
|
destination-local inheritance before typed decoding. CLI assignment follows
|
||||||
|
inheritance and decoding, before application resolution. The engine also owns
|
||||||
|
shared source-version checking with sanitized declaration evidence.
|
||||||
|
Explicit import discovery supports declared paths, directory searches, and
|
||||||
|
globs; root-owned defaults are converted to the engine's single defaults input
|
||||||
|
and snapshotted during load preflight before acquisition, while source-local
|
||||||
|
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
|
||||||
or records. Callback wrappers pass only live engine context during a callback;
|
or records. Callback wrappers pass only live engine context during a callback;
|
||||||
`Result.Config` is detached and its private inspection remains frozen
|
`Result.Config` is detached and its private inspection remains frozen
|
||||||
independently.
|
independently.
|
||||||
- Root conversion files explicitly cross the path, record, callback, secret, and
|
- Root conversion files explicitly cross the path, record, callback, secret,
|
||||||
error boundaries. There are no public aliases, anonymous engine embeddings, or
|
search-description, and error boundaries. There are no public aliases,
|
||||||
public engine bridge methods.
|
anonymous engine embeddings, or public engine bridge methods.
|
||||||
- Root `Diff` and `Difference` provide the public comparison boundary and
|
- Root `Diff` and `Difference` provide the public comparison boundary and
|
||||||
convert detached engine records to root-owned paths and snapshots; consumer
|
convert detached engine records to root-owned paths and snapshots; consumer
|
||||||
examples own presentation of the safe changed regions.
|
examples own presentation of the safe changed regions.
|
||||||
@@ -48,13 +61,16 @@ these constraints and rejects exported aliases, fields, or signatures involving
|
|||||||
internal types.
|
internal types.
|
||||||
|
|
||||||
The [configuration contract](../config.md) owns implemented YAML and lifecycle
|
The [configuration contract](../config.md) owns implemented YAML and lifecycle
|
||||||
semantics, including [input destinations](../config.md#input-destinations),
|
semantics, including
|
||||||
|
[configuration discovery](../config.md#configuration-discovery),
|
||||||
|
[input destinations](../config.md#input-destinations),
|
||||||
[shared source-version checks](../config.md#shared-source-version-checks), and
|
[shared source-version checks](../config.md#shared-source-version-checks), and
|
||||||
[effective configuration diffs](../config.md#effective-configuration-diffs).
|
[effective configuration diffs](../config.md#effective-configuration-diffs).
|
||||||
The [strict composition contract](../config.md#strict-document-composition)
|
The [strict composition contract](../config.md#strict-document-composition)
|
||||||
owns sequence placement, registration, replacement, and history behavior; the
|
owns sequence declarations, identity, operations, ownership, and final
|
||||||
[consumer guide](../consumers/README.md#identified-prompt-sequences) shows the
|
validation; [CLI overrides](../config.md#explicit-cli-overrides) own override
|
||||||
maintained example.
|
ordering. The [consumer guide](../consumers/README.md#identified-prompt-sequences)
|
||||||
|
links maintained workflow examples.
|
||||||
The [consumer adoption guide](../consumers/README.md) owns application guidance.
|
The [consumer adoption guide](../consumers/README.md) owns application guidance.
|
||||||
The [architecture policy](../policy/architecture.md) owns contributor
|
The [architecture policy](../policy/architecture.md) owns contributor
|
||||||
boundaries; [ADR 0006](../adr/0006-public-facade.md) records the facade decision
|
boundaries; [ADR 0006](../adr/0006-public-facade.md) records the facade decision
|
||||||
@@ -67,4 +83,105 @@ post-assembly structural operation. [ADR 0012](../adr/0012-relative-import-desti
|
|||||||
records the relative-import extension; [ADR 0013](../adr/0013-shared-source-version-checks.md)
|
records the relative-import extension; [ADR 0013](../adr/0013-shared-source-version-checks.md)
|
||||||
records the source-version decision.
|
records the source-version decision.
|
||||||
[ADR 0014](../adr/0014-sequence-placement-and-composition.md) records explicit
|
[ADR 0014](../adr/0014-sequence-placement-and-composition.md) records explicit
|
||||||
sequence placement and composition.
|
sequence placement and composition. [ADR 0015](../adr/0015-shared-dependency-ready-ordering.md)
|
||||||
|
records the shared dependency-ready ordering decision; [ADR
|
||||||
|
0016](../adr/0016-independent-import-controls.md) records the independent
|
||||||
|
selection controls. [ADR 0017](../adr/0017-collection-sequence-policy.md) and
|
||||||
|
[ADR 0018](../adr/0018-cli-after-structural-resolution.md) record collection
|
||||||
|
operations and CLI ordering. [ADR 0019](../adr/0019-explicit-configuration-discovery.md)
|
||||||
|
records explicit main-file discovery. The [import internals](imports.md)
|
||||||
|
document candidate and namespace identities, discovery and scheduling, and the
|
||||||
|
bounded algorithms.
|
||||||
|
|
||||||
|
## Implementation flow
|
||||||
|
|
||||||
|
The root facade converts caller-owned values into engine-owned requests. Its
|
||||||
|
`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
|
||||||
|
[`load_request.go`](../../internal/engine/load_request.go). Load-wide definition,
|
||||||
|
version, default-import, and override policy stays distinct from each input's
|
||||||
|
source, filesystem, destination, and import settings.
|
||||||
|
|
||||||
|
[`acquisition.go`](../../internal/engine/acquisition.go) defines acquisition
|
||||||
|
policy, load-wide accounting, and tier-scoped repetition/namespace state. Each
|
||||||
|
input is acquired under that policy; grouped requests retain acquired underlay
|
||||||
|
scopes until their tier is composed in
|
||||||
|
[`load_request.go`](../../internal/engine/load_request.go). 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 facts used to authorize access are separate from passively retained
|
||||||
|
resource contexts. Non-import validation retains those facts on `assembledInput`
|
||||||
|
for passive-context construction and ordinary-overlay handling: see
|
||||||
|
[`filesystem_context.go`](../../internal/engine/filesystem_context.go) and
|
||||||
|
[`source_context.go`](../../internal/engine/source_context.go).
|
||||||
|
|
||||||
|
Import responsibilities are split across candidate selection and path
|
||||||
|
normalization in [`import_selection.go`](../../internal/engine/import_selection.go),
|
||||||
|
source reading and traversal admission in
|
||||||
|
[`import_acquisition.go`](../../internal/engine/import_acquisition.go),
|
||||||
|
directory walking in [`directory_walk.go`](../../internal/engine/directory_walk.go),
|
||||||
|
dependency graph scheduling in [`import_graph.go`](../../internal/engine/import_graph.go),
|
||||||
|
and projection of acquired evidence into final source coordinates in
|
||||||
|
[`import_projection.go`](../../internal/engine/import_projection.go). The
|
||||||
|
[import internals](imports.md) page describes the boundaries and identities.
|
||||||
|
|
||||||
|
After acquisition handles are closed, grouped composition and inheritance run on
|
||||||
|
the retained ordered documents. `inheritance.go` coordinates traversal and
|
||||||
|
[`inheritance_schedule.go`](../../internal/engine/inheritance_schedule.go) applies
|
||||||
|
dependency-ready templates;
|
||||||
|
[`inheritance_prepare.go`](../../internal/engine/inheritance_prepare.go)
|
||||||
|
prepares child and parent inputs,
|
||||||
|
[`inheritance_combine.go`](../../internal/engine/inheritance_combine.go)
|
||||||
|
returns the combined value with its origin, policy, sequence, and diagnostic
|
||||||
|
evidence, and [`inheritance_finalize.go`](../../internal/engine/inheritance_finalize.go)
|
||||||
|
accounts for and finalizes the result. Projection and schema work have separate
|
||||||
|
operation owners: [`value_operation.go`](../../internal/engine/value_operation.go)
|
||||||
|
holds per-operation state, [`projection.go`](../../internal/engine/projection.go)
|
||||||
|
builds projected values, [`schema.go`](../../internal/engine/schema.go) owns
|
||||||
|
descriptor caching and type policy, and
|
||||||
|
[`schema_fields.go`](../../internal/engine/schema_fields.go) owns ordinary and
|
||||||
|
inline field assembly. [`decode.go`](../../internal/engine/decode.go) consumes
|
||||||
|
those descriptors for typed decoding.
|
||||||
|
|
||||||
|
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
|
||||||
|
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
|
||||||
|
covered by `imports_test.go`, `load_request_test.go`, and focused source,
|
||||||
|
discovery, and destination tests; inheritance by the inheritance test files;
|
||||||
|
reflection behavior by `schema_test.go`, `projection_test.go`, and
|
||||||
|
`decode_test.go`. The [development test map](../development.md#task-to-test-map)
|
||||||
|
links focused commands. [`clone.go`](../../internal/engine/clone.go) owns typed
|
||||||
|
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,7 +2,7 @@
|
|||||||
|
|
||||||
**Base Policy Version:** 1.3
|
**Base Policy Version:** 1.3
|
||||||
|
|
||||||
**Last Updated**: 2026-10-16
|
**Last Updated**: 2026-10-22
|
||||||
|
|
||||||
**Upstream Source:** [Standard Repository Policies](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/architecture.md)
|
**Upstream Source:** [Standard Repository Policies](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/architecture.md)
|
||||||
|
|
||||||
@@ -32,6 +32,12 @@ structure as implemented when its changes land.
|
|||||||
Constraints must reflect deliberately selected requirements and decisions.
|
Constraints must reflect deliberately selected requirements and decisions.
|
||||||
Do not promote incidental implementation choices into architectural invariants.
|
Do not promote incidental implementation choices into architectural invariants.
|
||||||
|
|
||||||
|
The implemented collection-policy and CLI-ordering constraints below reflect
|
||||||
|
accepted [ADR 0017](../adr/0017-collection-sequence-policy.md) and
|
||||||
|
[ADR 0018](../adr/0018-cli-after-structural-resolution.md). The configuration
|
||||||
|
contract owns their current behavior; these architectural constraints describe
|
||||||
|
the system boundaries and guarantees that guide changes to that behavior.
|
||||||
|
|
||||||
## Success Criteria
|
## Success Criteria
|
||||||
|
|
||||||
ConfigKit prioritizes a coherent configuration model: consistency and
|
ConfigKit prioritizes a coherent configuration model: consistency and
|
||||||
@@ -91,13 +97,59 @@ boundaries, and keep simple consumers independent of optional composition
|
|||||||
features. The [consumer adoption guide](../consumers/README.md) demonstrates the
|
features. The [consumer adoption guide](../consumers/README.md) demonstrates the
|
||||||
implemented surface against representative simple and complex shapes.
|
implemented surface against representative simple and complex shapes.
|
||||||
|
|
||||||
|
## Simplicity, Defaults, And Explicit Controls
|
||||||
|
|
||||||
|
These principles apply throughout ConfigKit: to configuration syntax, the public
|
||||||
|
Go API, defaults, and feature design. Evaluate their effects on both configuration
|
||||||
|
authors and consuming applications.
|
||||||
|
|
||||||
|
**Keep straightforward workflows straightforward.** Common configuration
|
||||||
|
workflows should require little configuration text and minimal API setup.
|
||||||
|
Support more demanding workflows through explicit, composable options, without
|
||||||
|
requiring simple consumers to understand or configure those options.
|
||||||
|
|
||||||
|
**Prefer simple, permissive defaults with explicit controls.** Choose predictable
|
||||||
|
defaults that minimize required setup and accommodate valid configurations
|
||||||
|
without unnecessary restrictions. Provide selectable options for workflows that
|
||||||
|
need additional capabilities, narrower behavior, or stronger requirements.
|
||||||
|
|
||||||
|
Measure simplicity by what consumers must understand and maintain, including
|
||||||
|
configuration text and application-side plumbing. Concise syntax is useful, but
|
||||||
|
hidden inference and interacting meanings can make a shorter declaration harder
|
||||||
|
to understand. Prefer explicitness where it makes behavior more predictable.
|
||||||
|
A terse configuration format that requires substantial application-side
|
||||||
|
infrastructure does not satisfy the simplicity principle.
|
||||||
|
|
||||||
|
Define what permissiveness means for each behavior. Allowing an optional
|
||||||
|
contribution to be absent or accepting valid inputs without an unnecessary
|
||||||
|
filename restriction can reduce required setup. Invalid declarations, ambiguous
|
||||||
|
composition, and unreadable selected sources must still produce meaningful
|
||||||
|
errors under the applicable contracts. Permissiveness does not authorize silent
|
||||||
|
recovery from invalid supplied configuration or weaken configuration integrity.
|
||||||
|
|
||||||
|
Options should express coherent choices with understandable purposes and fit
|
||||||
|
ConfigKit's shared configuration model. Support powerful controls when they
|
||||||
|
serve concrete needs; these principles do not require a switch for every
|
||||||
|
implementation behavior or conceivable consumer preference.
|
||||||
|
|
||||||
|
Defaults require judgment when simplicity and permissiveness pull in different
|
||||||
|
directions. Broader discovery can accommodate more layouts while increasing the
|
||||||
|
implicit search scope. Evaluate such tradeoffs explicitly; these principles do
|
||||||
|
not mechanically select the broadest behavior for every option. Changes to
|
||||||
|
existing defaults remain subject to compatibility review, and proposed behavior
|
||||||
|
remains in the roadmap until implemented.
|
||||||
|
|
||||||
## Responsibility Boundaries
|
## Responsibility Boundaries
|
||||||
|
|
||||||
ConfigKit owns configuration input processing and the coordination of decoding,
|
ConfigKit owns configuration input processing and the coordination of decoding,
|
||||||
resolution, validation, and inspection. Applications own their configuration
|
resolution, validation, and inspection. Applications own their configuration
|
||||||
types, field meanings, defaults, normalization, semantic validation, and schema
|
types, field meanings, defaults, normalization, semantic validation, and schema
|
||||||
version policy. Applications also own main-file search conventions, CLI wiring,
|
version policy. Applications choose and order their main-file search conventions;
|
||||||
remote acquisition, runtime initialization, and resource checks.
|
ConfigKit provides explicitly selected search descriptions that resolve during
|
||||||
|
loading. Applications also own CLI wiring, remote acquisition, runtime
|
||||||
|
initialization, and resource checks. See the [configuration discovery
|
||||||
|
contract](../config.md#configuration-discovery) and [ADR
|
||||||
|
0019](../adr/0019-explicit-configuration-discovery.md).
|
||||||
|
|
||||||
The engine applies each input's mapping destination within the existing source
|
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
|
||||||
@@ -109,8 +161,18 @@ placement, and [ADR 0012](../adr/0012-relative-import-destinations.md) records
|
|||||||
the import extension.
|
the import extension.
|
||||||
|
|
||||||
Library code must not depend on consuming applications or initialize their
|
Library code must not depend on consuming applications or initialize their
|
||||||
runtime resources. It must not perform implicit network access, discover
|
runtime resources. It must not perform implicit network access, implicitly
|
||||||
application-specific paths, or change process-global state during ordinary `Load`.
|
discover application-specific paths, or change process-global state during
|
||||||
|
ordinary `Load`.
|
||||||
|
Import discovery is available only through declarations in selected sources:
|
||||||
|
explicit `path`, `dir`, and `glob` selectors may search their declared scope, but
|
||||||
|
ConfigKit does not search conventional application paths unless the application
|
||||||
|
explicitly supplies a discovery description, and it never discovers a main
|
||||||
|
configuration file implicitly. Imports and sibling inheritance share
|
||||||
|
dependency-ready scheduling within their existing scopes; acquisition order and
|
||||||
|
precedence-tier boundaries remain distinct. The [configuration contract](../config.md#explicit-imports)
|
||||||
|
owns observable behavior; [ADR 0015](../adr/0015-shared-dependency-ready-ordering.md)
|
||||||
|
and [ADR 0016](../adr/0016-independent-import-controls.md) record the decisions.
|
||||||
The explicit secrets-directory bootstrap is a narrow, opt-in exception: applications
|
The explicit secrets-directory bootstrap is a narrow, opt-in exception: applications
|
||||||
invoke it during startup, and it reports writes, skips and partial failures. It
|
invoke it during startup, and it reports writes, skips and partial failures. It
|
||||||
must never run implicitly as part of configuration loading.
|
must never run implicitly as part of configuration loading.
|
||||||
@@ -133,6 +195,29 @@ The [configuration contract](../config.md#completion-and-resource-lifetime) owns
|
|||||||
the precise completion and ownership behavior. The proposed decision record is
|
the precise completion and ownership behavior. The proposed decision record is
|
||||||
[ADR 0008](../adr/0008-complete-on-return-loading.md).
|
[ADR 0008](../adr/0008-complete-on-return-loading.md).
|
||||||
|
|
||||||
|
### Accepted Processing Order
|
||||||
|
|
||||||
|
Complete source composition and precedence, including selected overlays, before
|
||||||
|
sibling inheritance. Then apply typed defaults and decode the configuration.
|
||||||
|
Apply explicit CLI overrides to that configuration and check their types before
|
||||||
|
running application resolution. Effective secret lookup and final validation
|
||||||
|
follow resolution, before publication. [ADR 0018](../adr/0018-cli-after-structural-resolution.md)
|
||||||
|
records this ordering.
|
||||||
|
|
||||||
|
CLI overrides are the highest-priority input to application resolution. Supplied
|
||||||
|
sequences replace whole sequences, and mappings combine recursively. Inheritance
|
||||||
|
and typed defaulting do not run again after CLI application. CLI paths and values
|
||||||
|
must not introduce structural directives; earlier source, inheritance, and
|
||||||
|
decoding failures remain errors even if a CLI assignment would replace the value.
|
||||||
|
Semantic validation assesses the final configuration after overrides and resolution.
|
||||||
|
|
||||||
|
Applications own flag parsing and domain resolution. ConfigKit owns override
|
||||||
|
application, validation, and source attribution. Resolution receives CLI values
|
||||||
|
with their explicit-presence and source information. Absence-based defaults
|
||||||
|
preserve explicit values; recorded normalization and derivation can still change
|
||||||
|
them. Typed defaults and application resolution retain their own assignment
|
||||||
|
semantics, independently of collection operations during source assembly.
|
||||||
|
|
||||||
## Public API And Dependency Direction
|
## Public API And Dependency Direction
|
||||||
|
|
||||||
Keep one small, coherent public surface for the supported lifecycle. Simple
|
Keep one small, coherent public surface for the supported lifecycle. Simple
|
||||||
@@ -161,41 +246,44 @@ framework on this library.
|
|||||||
|
|
||||||
## Configuration Integrity
|
## Configuration Integrity
|
||||||
|
|
||||||
Within a configuration tier, independent contributions use strict composition:
|
Within a configuration tier, independent contributions use strict composition.
|
||||||
shared mappings combine recursively, while overlapping non-mapping definitions
|
Shared mappings combine recursively. Sequences with an applicable identity
|
||||||
are conflicts, including equal values, except that sequences at explicitly
|
combine distinct records and reject duplicate identities, including equal
|
||||||
registered paths append records with distinct present string identities. Between
|
records. Other overlapping non-mapping values remain conflicts, including equal
|
||||||
precedence tiers, intentional overlay combines mappings recursively, retains
|
values. Conflicting identity or operation declarations among peers also fail.
|
||||||
omitted lower-priority children,
|
Traversal order establishes stable reporting and record order; it does not grant
|
||||||
and replaces other present values according to the existing overlay rules.
|
precedence to peers. [ADR 0001](../adr/0001-separate-composition-and-overlay.md)
|
||||||
Tier precedence is explicit; traversal order within a tier establishes stable
|
records the distinction between strict composition and intentional overrides.
|
||||||
reporting, not permission to override. The distinction and its rationale are
|
|
||||||
recorded in [ADR 0001](../adr/0001-separate-composition-and-overlay.md).
|
|
||||||
|
|
||||||
Sequence-item placement and sequence registration are explicit source and
|
|
||||||
definition controls; registration does not change precedence replacement. The
|
|
||||||
[configuration contract](../config.md#strict-document-composition) defines the
|
|
||||||
behavior, [ADR 0014](../adr/0014-sequence-placement-and-composition.md) records
|
|
||||||
the decision, and the [consumer guide](../consumers/README.md#identified-prompt-sequences)
|
|
||||||
shows the implemented subset. This feature does not authorize arbitrary
|
|
||||||
configurable merge strategies.
|
|
||||||
|
|
||||||
The main document and its imports form a strictly composed tier. Explicit
|
The main document and its imports form a strictly composed tier. Explicit
|
||||||
underlay groups compose strictly within each group, then combine recursively
|
underlay groups compose strictly within each group, then combine from lowest to
|
||||||
from lowest to highest beneath the main tier. Source acquisition kind does not
|
highest beneath the main tier. Between tiers, mappings combine recursively and
|
||||||
determine its precedence role. Input-specific constraints, including CLI path
|
retain omitted lower-priority children. Sequence values follow collection policy;
|
||||||
rules and opt-in version ownership, remain part of the configuration contract. Consistent combination
|
other present values retain the established replacement rules. Source acquisition
|
||||||
semantics do not require identical acquisition or lifetime behavior from every
|
kind does not determine precedence. Input-specific constraints, including version
|
||||||
source kind.
|
ownership and path protections, remain part of the configuration contract.
|
||||||
|
Consistent combination semantics do not require identical acquisition or lifetime
|
||||||
|
behavior from every source kind.
|
||||||
|
|
||||||
After source precedence is complete, built-in sibling `extends` relationships
|
Built-in sibling `extends` relationships resolve after source precedence in the
|
||||||
resolve structurally in the same parser-independent representation, using the
|
parser-independent document representation. Preserve existing mapping-sibling
|
||||||
existing recursive overlay rules. This adds neither an acquired source nor a
|
scopes and allow an identified sequence to provide a sibling scope for its
|
||||||
consumer registration mechanism. Enclosing relationships precede nested
|
records. A record may inherit from one parent in that sequence, selected by
|
||||||
destination-local resolution; typed defaults, application resolution, and
|
complete identity. The child supplies and keeps its own identity while inheriting
|
||||||
secret lookup retain their existing lifecycle roles. The [configuration
|
the parent's other settings. Parents remain ordinary records subject to normal
|
||||||
contract](../config.md#sibling-inheritance) owns the exact syntax and presence
|
validation.
|
||||||
rules, and [ADR 0009](../adr/0009-sibling-inheritance.md) records the rationale.
|
|
||||||
|
Use the shared dependency ordering and preserve assembled sequence order.
|
||||||
|
Resolve enclosing relationships before nested destination-local relationships.
|
||||||
|
Mappings combine recursively; sequences use the inheriting collection's policy.
|
||||||
|
Whole-record upsert replaces records during combination; explicit inheritance
|
||||||
|
uses the records that remain. Inheritance remains built-in and creates no
|
||||||
|
acquired source.
|
||||||
|
[ADR 0009](../adr/0009-sibling-inheritance.md) records the
|
||||||
|
original scope and continuing ownership decisions, with sequence combination
|
||||||
|
and identified-sequence inheritance defined by
|
||||||
|
[ADR 0017](../adr/0017-collection-sequence-policy.md) and CLI ordering by
|
||||||
|
[ADR 0018](../adr/0018-cli-after-structural-resolution.md).
|
||||||
|
|
||||||
ConfigKit must define supported YAML semantics explicitly rather than inherit
|
ConfigKit must define supported YAML semantics explicitly rather than inherit
|
||||||
parser defaults. Processing order and diagnostics must be deterministic for the
|
parser defaults. Processing order and diagnostics must be deterministic for the
|
||||||
@@ -204,13 +292,14 @@ configuration. Do not run application hooks on invalid typed configuration.
|
|||||||
|
|
||||||
User-controlled precedence is application defaults, ordered underlays, composed
|
User-controlled precedence is application defaults, ordered underlays, composed
|
||||||
main input, selected overlay, then explicitly supplied CLI overrides.
|
main input, selected overlay, then explicitly supplied CLI overrides.
|
||||||
Applications own flag parsing; ConfigKit owns overlay semantics and provenance.
|
This priority order is distinct from processing order: typed defaults are applied
|
||||||
Domain resolution follows these
|
at decoding, and application resolution follows CLI input under the lifecycle
|
||||||
inputs and must remain recorded, not become a hidden override channel.
|
rules above.
|
||||||
|
|
||||||
Preserve node-based composition until the effective input is ready for typed
|
Preserve node-based composition through source assembly and inheritance until
|
||||||
decoding. Preserve presence and source information through processing. Ordinary Go zero
|
typed decoding. Preserve presence and source information through later CLI and
|
||||||
values must not be used to infer whether input was absent. Final configuration,
|
application-resolution changes as well. Ordinary Go zero values must not be used
|
||||||
|
to infer whether input was absent. Final configuration,
|
||||||
metadata, and effective inspection must agree, including application resolution
|
metadata, and effective inspection must agree, including application resolution
|
||||||
changes; see [ADR 0003](../adr/0003-preserve-presence-and-resolution-history.md).
|
changes; see [ADR 0003](../adr/0003-preserve-presence-and-resolution-history.md).
|
||||||
Safe historical values and an explanation renderer are part of the implemented contract,
|
Safe historical values and an explanation renderer are part of the implemented contract,
|
||||||
@@ -227,6 +316,72 @@ comparison and disclosure behavior, the [component overview](../internal/overvie
|
|||||||
owns implementation responsibility, and [ADR 0010](../adr/0010-effective-configuration-diff.md)
|
owns implementation responsibility, and [ADR 0010](../adr/0010-effective-configuration-diff.md)
|
||||||
records the accepted decision.
|
records the accepted decision.
|
||||||
|
|
||||||
|
## Collection Identity And Combination
|
||||||
|
|
||||||
|
ConfigKit owns a common collection policy for imported records, inline sequences,
|
||||||
|
selected overlays, and sibling inheritance. Configuration authors may declare
|
||||||
|
identity and combination rules; applications may register authoritative identity
|
||||||
|
constraints. Configuration declarations must agree with application constraints.
|
||||||
|
Keep source placement explicit and separate from policy, retaining the placement
|
||||||
|
decision in [ADR 0014](../adr/0014-sequence-placement-and-composition.md).
|
||||||
|
[ADR 0017](../adr/0017-collection-sequence-policy.md) owns the rationale for
|
||||||
|
collection policy and its relationship to application authority.
|
||||||
|
|
||||||
|
Gather declarations for the relevant destination and source or item-local scope
|
||||||
|
before strict composition. Applicable identity from accumulated lower tiers must
|
||||||
|
be available when composing higher-tier peers. Higher-tier declarations cannot
|
||||||
|
repair invalid lower-tier composition. Preserve the distinction between source
|
||||||
|
acquisition, dependency-ready scheduling within scopes, and precedence order.
|
||||||
|
|
||||||
|
Strictly compose each tier's contributions before applying one operation for the
|
||||||
|
collection against its lower value. Support whole-sequence replacement,
|
||||||
|
duplicate-rejecting append, and whole-record replacement by identity (`upsert`).
|
||||||
|
Append and upsert require identity; do not infer it from record fields or position.
|
||||||
|
Duplicate identities among peers fail under every mode. Source precedence and
|
||||||
|
inheritance use the same operations and deterministic ordering rules. Whole-record
|
||||||
|
upsert replaces the matching record's fields together; recursive record merging
|
||||||
|
and unkeyed sequence concatenation are outside this operation model.
|
||||||
|
|
||||||
|
An explicit mode persists at its applicable destination until a higher explicit
|
||||||
|
mode changes it. With no explicit mode, effective identity selects upsert and
|
||||||
|
its absence selects whole-sequence replacement. Inferred defaults do not become
|
||||||
|
persistent instructions. The inheriting destination chooses its operation; copying
|
||||||
|
a parent collection does not automatically copy its mode to that destination.
|
||||||
|
|
||||||
|
Track YAML identity and explicit modes according to their separate lifetimes.
|
||||||
|
Actual whole-value replacement discards lower YAML identity while preserving
|
||||||
|
application constraints and any new identity declaration. Item-local policies
|
||||||
|
belong to their records: retaining a record retains its rules, and discarding it
|
||||||
|
discards those rules. Policies independently applicable at a destination remain
|
||||||
|
in effect. Inheritance can copy YAML identity subject to agreement and replacement
|
||||||
|
rules; application registrations continue to constrain their registered paths.
|
||||||
|
Numeric positions alone do not establish policy ownership.
|
||||||
|
|
||||||
|
Allow declarations without accompanying records. A key declaration validates a
|
||||||
|
retained sequence immediately or remains pending in its owning scope when no
|
||||||
|
sequence exists. A mode-only declaration affects later combinations without
|
||||||
|
recombining earlier tiers. Neither declaration alone clears a collection, and
|
||||||
|
declaring replace alone does not authorize changing a retained collection's
|
||||||
|
identity. These rules do not independently impose path-existence or shape
|
||||||
|
requirements.
|
||||||
|
|
||||||
|
Enforce YAML identity during source composition, precedence, and identified
|
||||||
|
inheritance operations. Application registrations additionally constrain final
|
||||||
|
effective sequences after defaults, CLI overrides, and application resolution.
|
||||||
|
The application schema and validation own existence, nullability, and allowed
|
||||||
|
shape. The final identity guarantee covers actual sequences on successful load;
|
||||||
|
it does not cover every intermediate callback state or later caller mutations.
|
||||||
|
YAML-only declarations carry no such final guarantee through defaults, CLI
|
||||||
|
assignments, or application resolution.
|
||||||
|
|
||||||
|
Preserve source attribution, presence, history, sensitivity, and resource context
|
||||||
|
as records survive, move, or are replaced. Identity diagnostics use paths and
|
||||||
|
available origin information without exposing identity values. The guarantee
|
||||||
|
requires no new mutation-history system or exact reconstruction of causes for
|
||||||
|
records supplied by defaults or callbacks. Exact identity equality, declaration
|
||||||
|
syntax, presence cases, and operation tables belong in the
|
||||||
|
[configuration contract](../config.md#strict-document-composition).
|
||||||
|
|
||||||
## Ownership And Confidentiality
|
## Ownership And Confidentiality
|
||||||
|
|
||||||
Returned configuration and metadata must not share mutable state across loads.
|
Returned configuration and metadata must not share mutable state across loads.
|
||||||
|
|||||||
114
docs/release.md
114
docs/release.md
@@ -1,16 +1,23 @@
|
|||||||
# 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.
|
||||||
|
|
||||||
|
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 +27,15 @@ 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`). 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 +45,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 +73,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,7 +91,8 @@ 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.
|
Build the ReleaseKit version recorded in `go.mod` into temporary storage outside
|
||||||
|
the checkout.
|
||||||
Ensure `$TMPDIR` names a system temporary location, not the repository. These
|
Ensure `$TMPDIR` names a system temporary location, not the repository. These
|
||||||
commands never install another ReleaseKit version:
|
commands never install another ReleaseKit version:
|
||||||
|
|
||||||
@@ -82,8 +104,8 @@ go list -m gitea.maximumdirect.net/eric/ReleaseKit
|
|||||||
"$releasekit_bin" version
|
"$releasekit_bin" version
|
||||||
```
|
```
|
||||||
|
|
||||||
The module-selected executable is ReleaseKit v0.4.1. Use the built executable
|
This repository selects ReleaseKit v0.4.2. Use the compiled program above for
|
||||||
for publication to preserve its exit status and publication-state diagnostics.
|
publication so its exit status and publication report reach you unchanged.
|
||||||
`go run` maps a nonzero child exit status to 1. ReleaseKit's child Go commands
|
`go run` maps a nonzero child exit status to 1. ReleaseKit's child Go commands
|
||||||
use the `go` found on `PATH` with `GOTOOLCHAIN=local`; keep the selected
|
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.
|
||||||
@@ -91,7 +113,7 @@ executable first on `PATH` through the publication command.
|
|||||||
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 +121,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 +133,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 +149,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,32 +161,40 @@ 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 compiled ReleaseKit program once to validate and publish it:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
"$releasekit_bin" publish vMAJOR.MINOR.PATCH
|
"$releasekit_bin" 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 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
|
"$releasekit_bin" 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 release is complete. Optional verification can follow. |
|
||||||
|
| `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
|
||||||
@@ -173,12 +203,17 @@ remote read-back or consumer retrieval; those checks remain optional.
|
|||||||
## 5. Recover from a failed or uncertain tag push
|
## 5. Recover from a failed or uncertain tag push
|
||||||
|
|
||||||
The executable reports the publication state, phase, version, candidate commit,
|
The executable reports the publication state, phase, version, candidate commit,
|
||||||
tag-object ID, effective endpoint, and error on standard error. It performs one
|
tag-object ID, effective endpoint, and error on standard error. After a failed
|
||||||
bounded authoritative reconciliation after a failed tag push. Exit status 3
|
tag push, it makes one bounded check of the exact publication remote to find out
|
||||||
|
whether the tag arrived. Exit status 3
|
||||||
means the tag outcome remains unknown; do not blindly retry or move a tag.
|
means the tag outcome remains unknown; do not blindly retry or move a tag.
|
||||||
|
|
||||||
For an uncertain result, use the exact endpoint, candidate commit, version, and
|
An annotated tag is a Git object containing the release note and a pointer to
|
||||||
tag-object ID from the report. Copy the captured endpoint into the variable
|
the source commit. Its tag-object ID identifies that complete object. Checking
|
||||||
|
only the commit ID would not establish that the intended tag object arrived.
|
||||||
|
|
||||||
|
For an uncertain result, use the exact endpoint (remote URL), candidate commit,
|
||||||
|
version, and 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,
|
below using normal shell quoting. Check that Git still resolves it to itself,
|
||||||
then query the authoritative refs once:
|
then query the authoritative refs once:
|
||||||
|
|
||||||
@@ -195,7 +230,8 @@ cannot establish absence at the captured endpoint.
|
|||||||
|
|
||||||
Compare the `main` object ID with the reported candidate commit and the tag
|
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 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
|
object itself. A peeled commit is the source commit reached by following that
|
||||||
|
tag; it is a different identity. If the tag object matches, the
|
||||||
release is published. If the query fails, the result is conflicting, or 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
|
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 endpoint is reachable, the tag is confirmed absent, and `main` still equals
|
||||||
@@ -228,8 +264,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
|
||||||
)
|
)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
77
docs/releases/v0.10.0.md
Normal file
77
docs/releases/v0.10.0.md
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
# ConfigKit v0.10.0
|
||||||
|
|
||||||
|
This minor release adds explicit, composable discovery of a configuration file.
|
||||||
|
Applications choose the search conventions and their order; ConfigKit selects one
|
||||||
|
file during loading and returns the final configuration with its normal metadata.
|
||||||
|
Optional searches that find no file share the expanded `NoSource()` lifecycle.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Add `SearchConfig`, immutable `Search` and `FilenameGroup` descriptions, and
|
||||||
|
search-wide requiredness through `SearchOptions`. Compose explicit `Paths`,
|
||||||
|
custom `SearchDirectory` descriptions, and built-in Unix system, user-config,
|
||||||
|
home, or working-directory presets with `Searches`. See the
|
||||||
|
[public declarations](../../discovery.go) and
|
||||||
|
[discovery contract](../config.md#configuration-discovery).
|
||||||
|
- Expand searches in supplied order, trying a directory's filename groups before
|
||||||
|
moving to the next directory. Named and generic groups provide conventional
|
||||||
|
YAML filenames; custom groups provide literal names. Directory searches
|
||||||
|
distinguish omitted filename selection from an explicitly empty selection.
|
||||||
|
Constructors perform no filesystem access, and each load observes current
|
||||||
|
directory bases and file availability.
|
||||||
|
- Select the first existing candidate. Access errors and failures while loading
|
||||||
|
the selected file terminate the load rather than trying another configuration.
|
||||||
|
Selected files retain physical source identity, placement, provenance, imports,
|
||||||
|
and relative-resource context. Discovery works with ordinary loading and
|
||||||
|
layered inputs, including underlays, without adding a precedence tier.
|
||||||
|
- Permit enabled imports with an absent main input: there are no main import
|
||||||
|
declarations to traverse, while actual underlay sources retain normal imports.
|
||||||
|
Support ordinary selected overlays with an absent main and explicit filesystem
|
||||||
|
context, including a root-only host context. Layered overlays retain their
|
||||||
|
independent `OverlayFilesystem` requirement. See
|
||||||
|
[single-source loading](../config.md#single-source-loading) and
|
||||||
|
[layered loading](../config.md#layered-loading).
|
||||||
|
- Preserve ordinary file-context behavior after discovery and include the
|
||||||
|
failing candidate's file identity in access diagnostics. Use Go's
|
||||||
|
platform-aware Windows component rules and native drive-root-relative path
|
||||||
|
resolution. Isolate user-config tests according to the host Go conventions.
|
||||||
|
|
||||||
|
## Compatibility and upgrade
|
||||||
|
|
||||||
|
Existing explicit source constructors and loading APIs remain available.
|
||||||
|
`File(path)` remains an exclusive selection that fails for a missing file;
|
||||||
|
discovery is activated only by explicitly supplying `SearchConfig`.
|
||||||
|
|
||||||
|
Searches are optional by default. Set `SearchOptions{Required: true}` when the
|
||||||
|
application must find a file. An exhausted optional search contributes no main
|
||||||
|
document or source record; application version checks, defaults, resolution,
|
||||||
|
and validation still run under their existing rules and may reject incomplete
|
||||||
|
configuration. A selected overlay is still required to exist.
|
||||||
|
|
||||||
|
The former `NoSource()` rejections of enabled imports and explicit filesystem
|
||||||
|
context are relaxed. An active ordinary overlay with no main file now loads
|
||||||
|
when explicit context is supplied. Applications that relied on those combinations
|
||||||
|
being rejected should review their option handling. Selected host files derive
|
||||||
|
their own base directory; do not supply an explicit `BaseDir` or native filesystem
|
||||||
|
context for a search that selects a host file.
|
||||||
|
|
||||||
|
Applications may replace their own fallback-loading loops with the new search
|
||||||
|
descriptions. The maintained [consumer examples](../consumers/README.md#explicit-main-file-discovery)
|
||||||
|
show simple preset selection and custom-first discovery alongside existing
|
||||||
|
configuration tiers. Composition, collection operations, inheritance, CLI
|
||||||
|
precedence, and the complete-on-return ownership model retain their existing
|
||||||
|
semantics.
|
||||||
|
|
||||||
|
Select this version and rebuild:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get gitea.maximumdirect.net/eric/ConfigKit@v0.10.0
|
||||||
|
```
|
||||||
|
|
||||||
|
The consumer minimum remains Go 1.26.0. Release validation uses Go 1.26.10.
|
||||||
|
The YAML dependency remains `go.yaml.in/yaml/v4 v4.0.0-rc.6`, and the maintainer
|
||||||
|
tool remains ReleaseKit v0.4.1. Linux amd64 and arm64 remain release-validated
|
||||||
|
targets; macOS remains best-effort. Publication follows the existing
|
||||||
|
[release procedure](../release.md).
|
||||||
|
|
||||||
|
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.10.0/COPYRIGHT) | [LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.10.0/LICENSE) | [ATTRIBUTION](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.10.0/docs/oss/attribution.md)
|
||||||
42
docs/releases/v0.10.1.md
Normal file
42
docs/releases/v0.10.1.md
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
# ConfigKit v0.10.1
|
||||||
|
|
||||||
|
This patch release consolidates ConfigKit's internal loading machinery and
|
||||||
|
reduces repeated import bookkeeping. Public APIs and configuration semantics
|
||||||
|
remain unchanged from v0.10.0.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Consolidate ordinary and layered input acquisition, including import requests
|
||||||
|
and selected-overlay handling, while retaining their distinct context and
|
||||||
|
precedence rules. Remove unused private reverse conversions.
|
||||||
|
- Separate import acquisition, scoped composition, and evidence projection;
|
||||||
|
clarify inheritance preparation, combination, and finalization; and simplify
|
||||||
|
reflective projection and schema-field assembly responsibilities.
|
||||||
|
- Retain resolved filesystem facts from non-import validation for later passive
|
||||||
|
resource contexts and ordinary overlays. Remove duplicate absent-host context
|
||||||
|
derivation while preserving validation timing and handle cleanup.
|
||||||
|
- Record empty optional-import selections directly against their declaring
|
||||||
|
source, avoiding repeated searches through acquired sources. Index import
|
||||||
|
dependencies for scope-local edge preparation instead of scanning the whole
|
||||||
|
inclusion graph for every item scope. Stable dependency-ready ordering,
|
||||||
|
provenance, and source destinations are preserved.
|
||||||
|
- Expand regression coverage for import evidence ownership, scope boundaries,
|
||||||
|
and broad main/underlay item imports. Improve
|
||||||
|
[consumer loading guidance](../consumers/README.md) and internal documentation.
|
||||||
|
|
||||||
|
## Compatibility and upgrade
|
||||||
|
|
||||||
|
No consumer code or configuration migration is required. Composition, collection
|
||||||
|
operations, inheritance, discovery, CLI precedence, source-version checks, and
|
||||||
|
inspection retain their existing contracts.
|
||||||
|
|
||||||
|
Select this version and rebuild:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get gitea.maximumdirect.net/eric/ConfigKit@v0.10.1
|
||||||
|
```
|
||||||
|
|
||||||
|
The consumer minimum remains Go 1.26.0. Dependencies and platform support are
|
||||||
|
unchanged. Publication follows the existing [release procedure](../release.md).
|
||||||
|
|
||||||
|
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.10.1/COPYRIGHT) | [LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.10.1/LICENSE) | [ATTRIBUTION](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.10.1/docs/oss/attribution.md)
|
||||||
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)
|
||||||
@@ -30,7 +30,7 @@ adopts ReleaseKit for source publication, and raises the consumer Go minimum to
|
|||||||
successful-tag-push completion point remain unchanged; CI is not a gate.
|
successful-tag-push completion point remain unchanged; CI is not a gate.
|
||||||
- Adopt release policy 1.6, distinguishing the consumer Go minimum from the
|
- Adopt release policy 1.6, distinguishing the consumer Go minimum from the
|
||||||
validation toolchain. Retire completed planning and measurement records into
|
validation toolchain. Retire completed planning and measurement records into
|
||||||
Git history while preserving [performance candidates](../roadmap/future.md#3-performance-experiments).
|
Git history while preserving [performance candidates](../roadmap/future.md#5-performance-experiments).
|
||||||
|
|
||||||
## Compatibility and upgrade
|
## Compatibility and upgrade
|
||||||
|
|
||||||
|
|||||||
111
docs/releases/v0.9.0.md
Normal file
111
docs/releases/v0.9.0.md
Normal file
@@ -0,0 +1,111 @@
|
|||||||
|
# ConfigKit v0.9.0
|
||||||
|
|
||||||
|
This minor release expands import selection, adds author-declared collection
|
||||||
|
identity and operations, and supports inheritance between identified records.
|
||||||
|
It also moves CLI assignments after inheritance and typed decoding. These changes
|
||||||
|
include breaking API and configuration behavior changes from v0.8.0; review the
|
||||||
|
migration guidance below before upgrading.
|
||||||
|
|
||||||
|
## Changes
|
||||||
|
|
||||||
|
- Extend imports with named-file and directory lists, directory filename patterns,
|
||||||
|
exclusions, recursion and depth controls, and explicit hidden-file and symlink
|
||||||
|
discovery controls. Add `ImportDefaults` to ordinary and layered options, with
|
||||||
|
authored declarations taking precedence over application defaults. Imports are
|
||||||
|
optional by default. `SourceInfo.ImportSelections` exposes completed optional
|
||||||
|
declarations that selected no sources. See the
|
||||||
|
[import contract](../config.md#explicit-imports) and
|
||||||
|
[consumer guidance](../consumers/README.md#import-defaults-and-selection-inspection).
|
||||||
|
- Use shared dependency-ready ordering for import composition and sibling
|
||||||
|
inheritance. Dependencies complete before dependents, with deterministic
|
||||||
|
selection among currently eligible contributions. Source acquisition and
|
||||||
|
callback order remain separate from assembly order. See the
|
||||||
|
[ordering decision](../adr/0015-shared-dependency-ready-ordering.md).
|
||||||
|
- Add root `configkit.sequences` declarations for collection identity and
|
||||||
|
`replace`, `append`, or `upsert` operations, including in selected overlays.
|
||||||
|
Strict peers combine distinct identities and reject duplicates; precedence
|
||||||
|
and inheritance apply the destination's collection operation. Upsert replaces
|
||||||
|
a whole matching record at its retained position. Identity supports compound
|
||||||
|
field sets and scalar strings, numbers, booleans, and null. See the
|
||||||
|
[collection contract](../config.md#strict-document-composition).
|
||||||
|
- Add `Definition.SequenceIdentities`, exact `SequenceIdentityAt` registrations,
|
||||||
|
and dynamic `SequenceIdentityMatching` selectors. Application registrations
|
||||||
|
govern structural collection operations and check existing matching final
|
||||||
|
typed sequences, including values from defaults, CLI assignments, and
|
||||||
|
application resolution. YAML-only declarations govern structural operations
|
||||||
|
without adding that final-result check.
|
||||||
|
- Support record inheritance within an identified sequence using an `extends`
|
||||||
|
mapping containing the parent's complete identity. The child retains its own
|
||||||
|
identity, relationships use surviving assembled records, and output sequence
|
||||||
|
order is preserved. See [sibling inheritance](../config.md#sibling-inheritance)
|
||||||
|
and the maintained [sequence catalog](../consumers/README.md#identified-prompt-sequences).
|
||||||
|
- Apply explicit CLI overrides to the decoded configuration before application
|
||||||
|
resolution. Mappings retain omitted fields and their presence metadata;
|
||||||
|
sequences replace whole lists independently of collection operations. See the
|
||||||
|
[CLI contract](../config.md#explicit-cli-overrides).
|
||||||
|
- Correct native import alias and contained-symlink handling, source-version
|
||||||
|
path projection, and provenance/history through collection movement,
|
||||||
|
replacement, inheritance, and CLI assignment. Reduce repeated scans,
|
||||||
|
accumulated policy copying, and sparse-projection storage during construction.
|
||||||
|
|
||||||
|
## Compatibility and upgrade
|
||||||
|
|
||||||
|
This pre-v1 minor release intentionally changes the v0.8.0 contracts:
|
||||||
|
|
||||||
|
1. **Migrate import requiredness.** The YAML `optional` field is removed and is
|
||||||
|
rejected. Replace `optional: true` with `required: false`; omission is equivalent
|
||||||
|
when the application does not default imports to required. Replace
|
||||||
|
`optional: false` with `required: true`. Imports that previously relied on
|
||||||
|
implicit requiredness now need `required: true`, or an application
|
||||||
|
`ImportDefaults.Required` default set to true. Review discovery filters:
|
||||||
|
`follow` and `hidden` default to true, and directory searches select all
|
||||||
|
filenames unless a pattern is supplied.
|
||||||
|
2. **Replace sequence registrations.** `SequenceComposition` and
|
||||||
|
`Definition.SequenceCompositions` are removed without compatibility aliases.
|
||||||
|
For a previous registration at `prompts` using `id`, use:
|
||||||
|
|
||||||
|
```go
|
||||||
|
rules.SequenceIdentities = []configkit.SequenceIdentity{
|
||||||
|
configkit.SequenceIdentityAt(configkit.Keys("prompts"), "id"),
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
These registrations now also enforce identity in final typed sequences. Fix
|
||||||
|
missing or duplicate identities introduced by defaults, CLI assignments, or
|
||||||
|
application resolution. Registration does not require a collection to exist
|
||||||
|
or determine its allowed shape; schema and application validation retain
|
||||||
|
those responsibilities.
|
||||||
|
3. **Review collection precedence and ordering.** An identified collection now
|
||||||
|
defaults to upsert across layers and inheritance when no explicit mode
|
||||||
|
applies, rather than unconditional whole-sequence replacement. Declare
|
||||||
|
`mode: replace` at the relevant destination where replacement is intended.
|
||||||
|
With append or upsert, `[]` contributes no records and does not clear the
|
||||||
|
collection; null is invalid. Use `mode: replace` with `[]` to clear it.
|
||||||
|
An explicit mode persists at its destination until changed; a parent's mode
|
||||||
|
does not transfer to a different inheriting destination. Unidentified
|
||||||
|
sequences retain strict peer conflicts and whole-value precedence
|
||||||
|
replacement. Dependency-ready assembly can change sequence positions and
|
||||||
|
diagnostic or history order compared with the former traversal order.
|
||||||
|
4. **Review CLI recovery and inheritance assumptions.** CLI overrides can no
|
||||||
|
longer repair values that fail earlier decoding, bypass source or inheritance
|
||||||
|
errors, or add or cancel inheritance relationships. Supply structural
|
||||||
|
directives in source configuration. Application resolution still receives
|
||||||
|
explicit CLI values and may record subsequent normalization or derivation.
|
||||||
|
5. **Update affected Go literals and diagnostic handling.** Use keyed literals
|
||||||
|
for expanded option and source-information structs. Collection validation adds
|
||||||
|
`CodeSequenceInvalid` (`sequence.invalid`) and `CodeSequenceConflict`
|
||||||
|
(`sequence.conflict`); update diagnostic handling where applicable.
|
||||||
|
|
||||||
|
Select this version and rebuild:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go get gitea.maximumdirect.net/eric/ConfigKit@v0.9.0
|
||||||
|
```
|
||||||
|
|
||||||
|
The consumer minimum remains Go 1.26.0. The YAML dependency remains
|
||||||
|
`go.yaml.in/yaml/v4 v4.0.0-rc.6`, and the maintainer tool dependency remains
|
||||||
|
ReleaseKit v0.4.1. Linux amd64 and arm64 remain release-validated targets;
|
||||||
|
macOS remains best-effort. Publication follows the existing
|
||||||
|
[release procedure](../release.md).
|
||||||
|
|
||||||
|
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.9.0/COPYRIGHT) | [LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.9.0/LICENSE) | [ATTRIBUTION](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.9.0/docs/oss/attribution.md)
|
||||||
@@ -1,112 +1,26 @@
|
|||||||
# 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. Identified record sequences
|
## 3. Recursive body merging for identified records
|
||||||
|
|
||||||
Status: deferred. PromptKit proposed this capability from its retired proposal 2,
|
Status: deferred. The implemented
|
||||||
**Namespaced Collections As Identified Records**. The original proposal is in the
|
[collection policy](../config.md#strict-document-composition) uses `upsert` to
|
||||||
PromptKit repository at
|
replace a complete matched record. Recursive merging of fields inside matching
|
||||||
`b5866dd:docs/roadmap/configuration-collections-identified-records.md`.
|
records remains a separate candidate and would revisit the whole-record update
|
||||||
PromptKit selected mounted mappings instead; this feature is not a dependency
|
boundary selected by [ADR 0017](../adr/0017-collection-sequence-policy.md).
|
||||||
of that design or a request to restore multiple installed prompt versions.
|
|
||||||
|
|
||||||
A narrower [sequence placement and composition feature](../config.md#strict-document-composition)
|
Consider this only in response to a concrete consumer need. A proposal would need
|
||||||
is implemented: consumers can place imported mappings as sequence items and
|
to define conflicts for overlapping body fields, removal of omitted fields,
|
||||||
register a single literal string identity field for duplicate-rejecting strict
|
presence and ordering, and how provenance, history, sensitivity, and resource
|
||||||
composition. Precedence still replaces sequences as whole values. This entry
|
context follow partially retained records. Any change requires a new decision and
|
||||||
tracks broader identified-record behavior that remains deferred and is not part
|
implementation plan; this entry does not alter current upsert behavior.
|
||||||
of that implemented contract.
|
|
||||||
|
|
||||||
### Motivation and proposed behavior
|
## 4. Required version declarations for selected sources
|
||||||
|
|
||||||
Allow applications to declare that selected sequences contain records identified
|
|
||||||
by explicit fields, while retaining sequence-shaped output for slice decoding
|
|
||||||
and effective YAML. This could make composite identities and independently
|
|
||||||
authored catalogs more convenient than encoding identity in nested mapping keys.
|
|
||||||
Current [composition](../config.md#strict-document-composition) appends distinct
|
|
||||||
records only at explicitly registered paths; other sequences remain atomic.
|
|
||||||
Selected [overlays](../config.md#selected-overlays) always treat sequences
|
|
||||||
atomically.
|
|
||||||
|
|
||||||
The proposed broader opt-in registration would extend the current reusable
|
|
||||||
application definition from one identity field to a composite identity made of
|
|
||||||
an ordered, nonempty set of distinct direct string-field names. For example,
|
|
||||||
records at `profiles` could be identified by `(namespace, id)`. Records
|
|
||||||
`(provider, brief)`
|
|
||||||
and `(application, brief)` would coexist, and a later layer could update the
|
|
||||||
provider record's temperature while retaining its backend and model.
|
|
||||||
|
|
||||||
The requested semantics, all still subject to design review, are:
|
|
||||||
|
|
||||||
- Match exact string tuples, preserving component boundaries and spelling,
|
|
||||||
including empty strings. Every contribution supplies its complete identity;
|
|
||||||
defaults, inheritance, filenames, and normalization do not supply it.
|
|
||||||
- Retain distinct identities. Matching records from strict peers recursively
|
|
||||||
compose disjoint body fields and conflict on overlapping non-mapping body
|
|
||||||
values, even equal values. Equal identity fields act as structural keys rather
|
|
||||||
than conflicts and retain their contributing source evidence.
|
|
||||||
- Higher layers recursively overlay matching bodies. A changed identity denotes
|
|
||||||
a different record, not a rename. Proposed ordering is first contribution in
|
|
||||||
precedence-assembly order; matching records keep position and new ones append.
|
|
||||||
- Reject malformed identities, non-mapping records, non-null non-sequence values
|
|
||||||
at registered paths, and duplicate tuples within one source sequence. Later
|
|
||||||
layers cannot repair malformed structural identity in an acquired contribution.
|
|
||||||
- Treat an empty registered sequence as contributing no records; whole-path null
|
|
||||||
retains ordinary conflict/replacement semantics. Unregistered sequences,
|
|
||||||
including nested message lists, retain existing atomic behavior.
|
|
||||||
- Support record inheritance within the same registered sequence using an
|
|
||||||
`extends` mapping containing the complete parent tuple. Resolve only final
|
|
||||||
active relationships after precedence, preserve the child's identity, overlay
|
|
||||||
its body on the parent's body, and consume the directive before decoding.
|
|
||||||
Preserve cancellation, missing-parent/cycle errors, and declaring resource
|
|
||||||
contexts. Existing string-based mapping-sibling inheritance stays unchanged.
|
|
||||||
|
|
||||||
Integrate ordinary and layered loads, imports, mounted paths, selected overlays,
|
|
||||||
and whole-sequence CLI overrides. Keep origins, presence, history, sensitivity,
|
|
||||||
and resource contexts attached to the correct record despite different source
|
|
||||||
and final indexes. Diagnostics must retain source locations without exposing
|
|
||||||
sensitive identity values. Final inspection may use snapshot-specific sequence
|
|
||||||
indexes; existing atomic sequence diffs need not become identity-aware.
|
|
||||||
|
|
||||||
Successful results would have valid, unique identities, including after defaults
|
|
||||||
and application resolution, without a second merge or inheritance pass. Existing
|
|
||||||
source-version checks remain independent; consumed headers cannot supply missing
|
|
||||||
record identities. No identity-selector path syntax, arbitrary merge callbacks,
|
|
||||||
wildcard registration, per-record deletion/rename operator, multiple inheritance,
|
|
||||||
or cross-path parent references are requested.
|
|
||||||
|
|
||||||
### Questions to resolve before implementation
|
|
||||||
|
|
||||||
- **Need and delivery scope:** does an active consumer benefit enough to justify
|
|
||||||
the added semantics over keyed mappings? Are composite identities required?
|
|
||||||
Staging composition before inheritance is possible but would leave part of
|
|
||||||
the request incomplete.
|
|
||||||
- **Defaults and callbacks:** do default records merge by identity or retain
|
|
||||||
existing sequence-replacement behavior? How are callback-created duplicates,
|
|
||||||
identity edits, and reordering validated and reflected in provenance?
|
|
||||||
- **Null, empty, and order:** what happens when null clears a collection and a
|
|
||||||
later layer reintroduces records? How do absent and explicitly empty sequences
|
|
||||||
interact with defaults and first-contribution order?
|
|
||||||
- **Inheritance references:** how do peers compose relationship declarations,
|
|
||||||
and how are complete references enforced without assembling partial tuples
|
|
||||||
accidentally? What happens if identity fields overlap structural controls?
|
|
||||||
- **Registration paths:** how do overlapping registrations and paths beneath
|
|
||||||
sequence indexes behave when assembly changes those indexes?
|
|
||||||
- **Evidence and lifecycle:** how are record identities mapped between source
|
|
||||||
trees, history, inheritance, callback snapshots, and final indexes without
|
|
||||||
mixing records' provenance or sensitivity? Where do identity checks run relative
|
|
||||||
to header consumption and other structural processing?
|
|
||||||
|
|
||||||
Keep the feature deferred until its consumer value and full lifecycle design
|
|
||||||
justify reconsidering the sequence-merging exclusion. A correct final sequence
|
|
||||||
alone would not establish correct inspection or disclosure behavior.
|
|
||||||
|
|
||||||
## 2. Required version declarations for selected sources
|
|
||||||
|
|
||||||
Status: deferred. PromptKit requested a way to require version declarations on
|
Status: deferred. PromptKit requested a way to require version declarations on
|
||||||
a selected subset of source files while permitting other fragments to omit them.
|
a selected subset of source files while permitting other fragments to omit them.
|
||||||
@@ -142,7 +56,7 @@ compatible version or replace final configuration validation.
|
|||||||
behavior for absent optional inputs and preserve source-local diagnostics,
|
behavior for absent optional inputs and preserve source-local diagnostics,
|
||||||
inspection evidence, and existing sensitivity handling.
|
inspection evidence, and existing sensitivity handling.
|
||||||
|
|
||||||
## 3. Performance experiments
|
## 5. Performance experiments
|
||||||
|
|
||||||
Status: deferred pending evidence. The completed performance audit identified
|
Status: deferred pending evidence. The completed performance audit identified
|
||||||
four internal optimization candidates for separate investigation:
|
four internal optimization candidates for separate investigation:
|
||||||
@@ -169,3 +83,22 @@ 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?
|
||||||
|
|
||||||
|
## 6. 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.
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import (
|
|||||||
"os"
|
"os"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "go.maximumdirect.net/configkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
func ExampleLoad() {
|
func ExampleLoad() {
|
||||||
@@ -19,6 +19,28 @@ func ExampleLoad() {
|
|||||||
// Output: {true 3} <nil>
|
// Output: {true 3} <nil>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func ExampleLoad_sequenceDeclaration() {
|
||||||
|
type Item struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
}
|
||||||
|
type Config struct {
|
||||||
|
Items []Item `yaml:"items"`
|
||||||
|
}
|
||||||
|
result, err := configkit.Load[Config](configkit.Bytes("catalog.yml", []byte(`configkit:
|
||||||
|
sequences:
|
||||||
|
- destination: [items]
|
||||||
|
key: id
|
||||||
|
items:
|
||||||
|
- id: prompt
|
||||||
|
`)))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Println(result.Config.Items[0].ID)
|
||||||
|
// Output: prompt
|
||||||
|
}
|
||||||
|
|
||||||
func ExampleNoSource() {
|
func ExampleNoSource() {
|
||||||
type Config struct {
|
type Config struct {
|
||||||
Workers int `yaml:"workers"`
|
Workers int `yaml:"workers"`
|
||||||
@@ -154,10 +176,18 @@ func ExampleOverride() {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
result, err := configkit.Load(configkit.NoSource(), configkit.Options[Config]{
|
result, err := configkit.Load(configkit.NoSource(), configkit.Options[Config]{
|
||||||
Definition: configkit.Definition[Config]{Defaults: func() Config { return Config{Workers: 4} }},
|
Definition: configkit.Definition[Config]{
|
||||||
Overrides: overrides,
|
Defaults: func() Config { return Config{Workers: 4} },
|
||||||
|
Resolve: func(configuration *Config, resolution *configkit.Resolution) error {
|
||||||
|
configkit.Default(resolution, configkit.Keys("workers"), &configuration.Workers, 100,
|
||||||
|
"preserve an explicitly supplied worker count")
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Overrides: overrides,
|
||||||
})
|
})
|
||||||
fmt.Println(result.Config.Workers, result.Sources()[0].Label, err)
|
source, _ := result.Source("source-1")
|
||||||
|
fmt.Println(result.Config.Workers, source.Label, err)
|
||||||
// Output: 0 --workers <nil>
|
// Output: 0 --workers <nil>
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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() {
|
||||||
|
|||||||
46
examples/consumers/discovery_test.go
Normal file
46
examples/consumers/discovery_test.go
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
package consumers_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
|
||||||
|
configkit "go.maximumdirect.net/configkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
type workingDirectoryPresetConfig struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func Example_workingDirectoryPreset() {
|
||||||
|
original, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer func() { _ = os.Chdir(original) }()
|
||||||
|
root, err := os.MkdirTemp("", "configkit-working-search-")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer os.RemoveAll(root)
|
||||||
|
if err := os.Chdir(root); err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "service.yml"), []byte("name: example\n"), 0o600); err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
result, err := configkit.Load[workingDirectoryPresetConfig](
|
||||||
|
configkit.SearchConfig("service", configkit.WorkingDirectory()),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
source, _ := result.Source("source-1")
|
||||||
|
fmt.Println(result.Config.Name, filepath.Base(source.Path))
|
||||||
|
// 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,7 +6,7 @@ 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
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -16,9 +16,10 @@ type PromptRecord struct {
|
|||||||
ID string `yaml:"id"`
|
ID string `yaml:"id"`
|
||||||
Name string `yaml:"name"`
|
Name string `yaml:"name"`
|
||||||
Messages []string `yaml:"messages"`
|
Messages []string `yaml:"messages"`
|
||||||
|
Resource string `yaml:"resource"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// LoadPromptCatalog composes explicitly registered prompt records in the main
|
// LoadPromptCatalog discovers explicitly registered prompt records in the main
|
||||||
// tier and replaces the lower-priority default collection as a whole.
|
// tier and replaces the lower-priority default collection as a whole.
|
||||||
func LoadPromptCatalog(mainPath string) (*configkit.Result[PromptCatalogConfig], error) {
|
func LoadPromptCatalog(mainPath string) (*configkit.Result[PromptCatalogConfig], error) {
|
||||||
return configkit.LoadLayered(configkit.Input{
|
return configkit.LoadLayered(configkit.Input{
|
||||||
@@ -28,16 +29,29 @@ func LoadPromptCatalog(mainPath string) (*configkit.Result[PromptCatalogConfig],
|
|||||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{
|
||||||
Source: configkit.Bytes("default prompts", []byte("prompts:\n - id: default\n name: Default prompt\n messages: [Default message]\n")),
|
Source: configkit.Bytes("default prompts", []byte("prompts:\n - id: default\n name: Default prompt\n messages: [Default message]\n")),
|
||||||
}}}},
|
}}}},
|
||||||
Definition: configkit.Definition[PromptCatalogConfig]{
|
ImportDefaults: configkit.ImportDefaults{
|
||||||
SequenceCompositions: []configkit.SequenceComposition{{
|
Pattern: []string{"curated*.yml", "generated-*.yml"},
|
||||||
Path: configkit.Keys("prompts"),
|
|
||||||
IdentityField: "id",
|
|
||||||
}},
|
|
||||||
Validate: validatePromptCatalog,
|
|
||||||
},
|
},
|
||||||
|
Definition: promptCatalogDefinition(),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LoadRequiredPromptCatalog keeps the common one-file import shape small.
|
||||||
|
func LoadRequiredPromptCatalog(mainPath string) (*configkit.Result[PromptCatalogConfig], error) {
|
||||||
|
return configkit.Load(configkit.File(mainPath), configkit.Options[PromptCatalogConfig]{
|
||||||
|
Imports: true, Definition: promptCatalogDefinition(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func promptCatalogDefinition() configkit.Definition[PromptCatalogConfig] {
|
||||||
|
return configkit.Definition[PromptCatalogConfig]{
|
||||||
|
SequenceIdentities: []configkit.SequenceIdentity{
|
||||||
|
configkit.SequenceIdentityAt(configkit.Keys("prompts"), "id"),
|
||||||
|
},
|
||||||
|
Validate: validatePromptCatalog,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func validatePromptCatalog(configuration *PromptCatalogConfig, validation *configkit.Validation) {
|
func validatePromptCatalog(configuration *PromptCatalogConfig, validation *configkit.Validation) {
|
||||||
for index, prompt := range configuration.Prompts {
|
for index, prompt := range configuration.Prompts {
|
||||||
if strings.TrimSpace(prompt.Name) == "" {
|
if strings.TrimSpace(prompt.Name) == "" {
|
||||||
|
|||||||
@@ -3,11 +3,13 @@ package consumers_test
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
pathpkg "path"
|
||||||
"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() {
|
||||||
@@ -20,8 +22,26 @@ func Example_identifiedPromptCatalog() {
|
|||||||
for _, prompt := range result.Config.Prompts {
|
for _, prompt := range result.Config.Prompts {
|
||||||
ids = append(ids, prompt.ID)
|
ids = append(ids, prompt.ID)
|
||||||
}
|
}
|
||||||
fmt.Println(ids, err)
|
emptySearches := 0
|
||||||
// Output: [main curated weather welcome] <nil>
|
for _, source := range result.Sources() {
|
||||||
|
for _, selection := range source.EmptyImportSelections {
|
||||||
|
if selection.Selector == configkit.ImportSelectorDirectory {
|
||||||
|
emptySearches++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fmt.Println(ids, emptySearches, err)
|
||||||
|
// Output: [weather curated welcome main] 1 <nil>
|
||||||
|
}
|
||||||
|
|
||||||
|
func Example_requiredPromptCatalog() {
|
||||||
|
result, err := consumers.LoadRequiredPromptCatalog(fixture("prompt-catalog", "simple.yml"))
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
fmt.Println(result.Config.Prompts[0].ID, err)
|
||||||
|
// Output: required <nil>
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestPromptCatalogImportsRegisteredItemsAndReplacesDefaults(t *testing.T) {
|
func TestPromptCatalogImportsRegisteredItemsAndReplacesDefaults(t *testing.T) {
|
||||||
@@ -36,7 +56,7 @@ func TestPromptCatalogImportsRegisteredItemsAndReplacesDefaults(t *testing.T) {
|
|||||||
if len(result.Config.Prompts) != 4 {
|
if len(result.Config.Prompts) != 4 {
|
||||||
t.Fatalf("prompt records = %#v", result.Config.Prompts)
|
t.Fatalf("prompt records = %#v", result.Config.Prompts)
|
||||||
}
|
}
|
||||||
ids := []string{"main", "curated", "weather", "welcome"}
|
ids := []string{"weather", "curated", "welcome", "main"}
|
||||||
for index, id := range ids {
|
for index, id := range ids {
|
||||||
if result.Config.Prompts[index].ID != id {
|
if result.Config.Prompts[index].ID != id {
|
||||||
t.Fatalf("prompt %d ID = %q, want %q", index, result.Config.Prompts[index].ID, id)
|
t.Fatalf("prompt %d ID = %q, want %q", index, result.Config.Prompts[index].ID, id)
|
||||||
@@ -46,12 +66,15 @@ func TestPromptCatalogImportsRegisteredItemsAndReplacesDefaults(t *testing.T) {
|
|||||||
if prompt.ID == "default" {
|
if prompt.ID == "default" {
|
||||||
t.Fatalf("lower-priority default survived main sequence replacement: %#v", result.Config.Prompts)
|
t.Fatalf("lower-priority default survived main sequence replacement: %#v", result.Config.Prompts)
|
||||||
}
|
}
|
||||||
|
if prompt.ID == "ignored" {
|
||||||
|
t.Fatalf("excluded generated prompt was selected: %#v", result.Config.Prompts)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var itemImports, helperImports int
|
var itemImports, helperImports int
|
||||||
for _, source := range result.Sources() {
|
for _, source := range result.Sources() {
|
||||||
switch filepath.Base(source.Path) {
|
switch filepath.Base(source.Path) {
|
||||||
case "curated.yml", "generated-weather.yml", "generated-welcome.yml":
|
case "curated.yml", "weather-feature.yml", "generated-welcome.yml":
|
||||||
if source.PlacementMode != configkit.PlacementModeSequenceItem || source.Destination.String() != "/prompts" {
|
if source.PlacementMode != configkit.PlacementModeSequenceItem || source.Destination.String() != "/prompts" {
|
||||||
t.Fatalf("item source placement = %#v", source)
|
t.Fatalf("item source placement = %#v", source)
|
||||||
}
|
}
|
||||||
@@ -71,6 +94,36 @@ func TestPromptCatalogImportsRegisteredItemsAndReplacesDefaults(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestPromptCatalogMountsResourceFromScheduledPromptOrigin(t *testing.T) {
|
||||||
|
result, err := consumers.LoadPromptCatalog(fixture("prompt-catalog", "config.yml"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result.Config.Prompts[2].ID != "welcome" || result.Config.Prompts[2].Resource != "welcome.txt" {
|
||||||
|
t.Fatalf("scheduled resource prompt = %#v", result.Config.Prompts)
|
||||||
|
}
|
||||||
|
resourcePath := configkit.Keys("prompts").Index(2).Key("resource")
|
||||||
|
info, ok := result.Metadata().Lookup(resourcePath)
|
||||||
|
if !ok || len(info.Origin.Locations) != 1 {
|
||||||
|
t.Fatalf("resource origin = %#v, %v", info, ok)
|
||||||
|
}
|
||||||
|
context, ok := result.SourceContext(info.Origin.Locations[0].SourceID)
|
||||||
|
if !ok || context.FS == nil || filepath.Base(context.BaseDir) != "prompts" {
|
||||||
|
t.Fatalf("resource source context = %#v, %v", context, ok)
|
||||||
|
}
|
||||||
|
contents, err := fs.ReadFile(context.FS, pathpkg.Join(context.BaseDir, result.Config.Prompts[2].Resource))
|
||||||
|
if err != nil || string(contents) != "Welcome resource text.\n" {
|
||||||
|
t.Fatalf("mounted resource = %q, %v", contents, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRequiredPromptCatalogUsesOneRequiredFile(t *testing.T) {
|
||||||
|
result, err := consumers.LoadRequiredPromptCatalog(fixture("prompt-catalog", "simple.yml"))
|
||||||
|
if err != nil || len(result.Config.Prompts) != 1 || result.Config.Prompts[0].ID != "required" {
|
||||||
|
t.Fatalf("simple required prompt = %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestPromptCatalogLeavesDomainValidationWithConsumer(t *testing.T) {
|
func TestPromptCatalogLeavesDomainValidationWithConsumer(t *testing.T) {
|
||||||
_, err := consumers.LoadPromptCatalog(fixture("prompt-catalog", "invalid.yml"))
|
_, err := consumers.LoadPromptCatalog(fixture("prompt-catalog", "invalid.yml"))
|
||||||
var loadError *configkit.LoadError
|
var loadError *configkit.LoadError
|
||||||
|
|||||||
145
examples/consumers/sequence_catalog.go
Normal file
145
examples/consumers/sequence_catalog.go
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
package consumers
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
configkit "go.maximumdirect.net/configkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
// SequenceCatalogConfig is a source-backed prompt catalog with an application
|
||||||
|
// supplied run label and a value derived from that label during resolution.
|
||||||
|
type SequenceCatalogConfig struct {
|
||||||
|
RunLabel string `yaml:"run_label"`
|
||||||
|
ResolvedLabel string `yaml:"resolved_label"`
|
||||||
|
Prompts []SequenceCatalogEntry `yaml:"prompts"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// SequenceCatalogEntry keeps prompt meaning, resources, and credentials in the
|
||||||
|
// consuming application while ConfigKit handles collection assembly.
|
||||||
|
type SequenceCatalogEntry struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Description string `yaml:"description"`
|
||||||
|
Messages []string `yaml:"messages"`
|
||||||
|
Resource string `yaml:"resource"`
|
||||||
|
Credential configkit.Secret `yaml:"credential"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadSequenceCatalog searches its caller-selected main path before a custom
|
||||||
|
// fallback directory, assembles imported fragments over a file-backed underlay,
|
||||||
|
// applies one selected collection overlay, then resolves and validates once.
|
||||||
|
func LoadSequenceCatalog(mainPath, overlayName, runLabel string) (*configkit.Result[SequenceCatalogConfig], error) {
|
||||||
|
absoluteMain, err := filepath.Abs(mainPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
root := filepath.Dir(absoluteMain)
|
||||||
|
filesystem := configkit.NewFilesystem("sequence catalog", os.DirFS(root))
|
||||||
|
filesystemContext := &configkit.FilesystemContext{BaseDir: ".", Root: ".", FS: &filesystem}
|
||||||
|
mainSearch := configkit.SearchConfig("", configkit.Searches(
|
||||||
|
configkit.SearchPaths(absoluteMain),
|
||||||
|
configkit.SearchDirectory(filepath.Join(root, "fallback"), configkit.DirectorySearchOptions{
|
||||||
|
Filenames: []configkit.FilenameGroup{configkit.CustomFilenames(filepath.Base(absoluteMain))},
|
||||||
|
}),
|
||||||
|
), configkit.SearchOptions{Required: true})
|
||||||
|
definition := configkit.Definition[SequenceCatalogConfig]{
|
||||||
|
SequenceIdentities: []configkit.SequenceIdentity{
|
||||||
|
configkit.SequenceIdentityAt(configkit.Keys("prompts"), "id"),
|
||||||
|
},
|
||||||
|
Resolve: func(configuration *SequenceCatalogConfig, resolution *configkit.Resolution) error {
|
||||||
|
configuration.ResolvedLabel = "for:" + configuration.RunLabel
|
||||||
|
resolution.RecordNormalization(configkit.Keys("resolved_label"),
|
||||||
|
"derive the resolved label from the selected run label", configkit.Keys("run_label"))
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
Validate: validateSequenceCatalog,
|
||||||
|
}
|
||||||
|
return configkit.LoadLayered(configkit.Input{
|
||||||
|
Source: mainSearch,
|
||||||
|
Imports: true,
|
||||||
|
Filesystem: &configkit.FilesystemContext{Root: root},
|
||||||
|
}, configkit.LayeredOptions[SequenceCatalogConfig]{
|
||||||
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{
|
||||||
|
Source: filesystem.File("underlay.yml"),
|
||||||
|
}}}},
|
||||||
|
Overlay: overlayName,
|
||||||
|
OverlayFilesystem: filesystemContext,
|
||||||
|
Overrides: []configkit.Override{{
|
||||||
|
Path: configkit.Keys("run_label"), Value: runLabel, Flag: "--run-label",
|
||||||
|
}},
|
||||||
|
Definition: definition,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// ReadSequenceCatalogResource follows the final prompt field's source context,
|
||||||
|
// including when record inheritance retained a resource from an underlay.
|
||||||
|
func ReadSequenceCatalogResource(result *configkit.Result[SequenceCatalogConfig], promptID string) (string, error) {
|
||||||
|
if result == nil {
|
||||||
|
return "", errors.New("sequence catalog result is nil")
|
||||||
|
}
|
||||||
|
for index, prompt := range result.Config.Prompts {
|
||||||
|
if prompt.ID != promptID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
resourcePath := configkit.Keys("prompts").Index(index).Key("resource")
|
||||||
|
info, found := result.Metadata().Lookup(resourcePath)
|
||||||
|
if !found || len(info.Origin.Locations) != 1 {
|
||||||
|
return "", errors.New("prompt resource has no single declaring source")
|
||||||
|
}
|
||||||
|
context, found := result.SourceContext(info.Origin.Locations[0].SourceID)
|
||||||
|
if !found {
|
||||||
|
return "", errors.New("prompt resource source context is unavailable")
|
||||||
|
}
|
||||||
|
data, err := fs.ReadFile(context.FS, path.Join(context.BaseDir, prompt.Resource))
|
||||||
|
if err != nil {
|
||||||
|
return "", fmt.Errorf("read prompt resource: %w", err)
|
||||||
|
}
|
||||||
|
return string(data), nil
|
||||||
|
}
|
||||||
|
return "", fmt.Errorf("prompt %q is unavailable", promptID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func validateSequenceCatalog(configuration *SequenceCatalogConfig, validation *configkit.Validation) {
|
||||||
|
for index, prompt := range configuration.Prompts {
|
||||||
|
if strings.TrimSpace(prompt.Name) == "" {
|
||||||
|
validation.Error(configkit.Keys("prompts").Index(index).Key("name"), "must not be empty")
|
||||||
|
}
|
||||||
|
if len(prompt.Messages) == 0 {
|
||||||
|
validation.Error(configkit.Keys("prompts").Index(index).Key("messages"), "must contain at least one message")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(configuration.Prompts) == 0 {
|
||||||
|
validation.Error(configkit.Keys("prompts"), "must contain at least one prompt")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NamespacedMapCatalogConfig demonstrates typed application maps whose
|
||||||
|
// independent prompt sequences use root and dynamic namespace registrations.
|
||||||
|
type NamespacedMapCatalogConfig struct {
|
||||||
|
Prompts []map[string]any `yaml:"prompts"`
|
||||||
|
Collections map[string]map[string]any `yaml:"collections"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// LoadNamespacedMapCatalog applies the same scalar identity constraint to one
|
||||||
|
// root collection and each namespace's independently scoped collection.
|
||||||
|
func LoadNamespacedMapCatalog(source configkit.Source) (*configkit.Result[NamespacedMapCatalogConfig], error) {
|
||||||
|
selector := configkit.SequenceSelector{
|
||||||
|
configkit.SequenceLiteralKey("collections"),
|
||||||
|
configkit.SequenceAnyMappingKey(),
|
||||||
|
configkit.SequenceLiteralKey("prompts"),
|
||||||
|
}
|
||||||
|
return configkit.Load(source, configkit.Options[NamespacedMapCatalogConfig]{
|
||||||
|
Definition: configkit.Definition[NamespacedMapCatalogConfig]{
|
||||||
|
SequenceIdentities: []configkit.SequenceIdentity{
|
||||||
|
configkit.SequenceIdentityAt(configkit.Keys("prompts"), "id"),
|
||||||
|
configkit.SequenceIdentityMatching(selector, "id"),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
239
examples/consumers/sequence_catalog_test.go
Normal file
239
examples/consumers/sequence_catalog_test.go
Normal file
@@ -0,0 +1,239 @@
|
|||||||
|
package consumers_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
configkit "go.maximumdirect.net/configkit"
|
||||||
|
"go.maximumdirect.net/configkit/examples/consumers"
|
||||||
|
)
|
||||||
|
|
||||||
|
func Example_sequenceCatalog() {
|
||||||
|
const oldSecret = "CONFIGKIT_SEQUENCE_CATALOG_OLD_SECRET"
|
||||||
|
const newSecret = "CONFIGKIT_SEQUENCE_CATALOG_NEW_SECRET"
|
||||||
|
restoreOld, restoreNew := preserveEnvironment(oldSecret), preserveEnvironment(newSecret)
|
||||||
|
defer restoreOld()
|
||||||
|
defer restoreNew()
|
||||||
|
_ = os.Setenv(oldSecret, "disposable-old-example-credential")
|
||||||
|
_ = os.Setenv(newSecret, "disposable-new-example-credential")
|
||||||
|
|
||||||
|
result, err := consumers.LoadSequenceCatalog(fixture("sequence-catalog", "config.yml"), "upsert", "preview")
|
||||||
|
if err != nil {
|
||||||
|
fmt.Println(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ids := make([]string, 0, len(result.Config.Prompts))
|
||||||
|
for _, prompt := range result.Config.Prompts {
|
||||||
|
ids = append(ids, prompt.ID)
|
||||||
|
}
|
||||||
|
shared := configkit.Keys("prompts").Index(1).Key("name")
|
||||||
|
info, _ := result.Metadata().Lookup(shared)
|
||||||
|
resource, resourceErr := consumers.ReadSequenceCatalogResource(result, "concise")
|
||||||
|
fmt.Println(ids, result.Config.ResolvedLabel, info.Explicit, len(result.Metadata().History(shared)), resource, resourceErr == nil, err)
|
||||||
|
// Output: [foundation shared welcome concise extra] for:preview true 2 Foundation resource text.
|
||||||
|
// true <nil>
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSequenceCatalogSelectedOverlayOperations(t *testing.T) {
|
||||||
|
const oldSecret = "CONFIGKIT_SEQUENCE_CATALOG_OLD_SECRET"
|
||||||
|
const newSecret = "CONFIGKIT_SEQUENCE_CATALOG_NEW_SECRET"
|
||||||
|
t.Setenv(oldSecret, "sequence-catalog-old-canary")
|
||||||
|
t.Setenv(newSecret, "sequence-catalog-new-canary")
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
wantIDs []string
|
||||||
|
shared *consumers.SequenceCatalogEntry
|
||||||
|
}{
|
||||||
|
{name: "append", wantIDs: []string{"foundation", "shared", "welcome", "concise", "extra"}},
|
||||||
|
{name: "upsert", wantIDs: []string{"foundation", "shared", "welcome", "concise", "extra"}, shared: &consumers.SequenceCatalogEntry{ID: "shared", Name: "Shared replacement", Messages: []string{"Replacement message"}, Credential: configkit.SecretEnv(newSecret)}},
|
||||||
|
{name: "replace", wantIDs: []string{"replacement", "last"}},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
result, err := consumers.LoadSequenceCatalog(fixture("sequence-catalog", "config.yml"), test.name, "test")
|
||||||
|
if err != nil {
|
||||||
|
var loadError *configkit.LoadError
|
||||||
|
if errors.As(err, &loadError) {
|
||||||
|
t.Fatalf("load selected overlay: %v (%#v)", err, loadError.Diagnostics())
|
||||||
|
}
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
gotIDs := make([]string, 0, len(result.Config.Prompts))
|
||||||
|
for _, prompt := range result.Config.Prompts {
|
||||||
|
gotIDs = append(gotIDs, prompt.ID)
|
||||||
|
}
|
||||||
|
if strings.Join(gotIDs, ",") != strings.Join(test.wantIDs, ",") {
|
||||||
|
t.Fatalf("prompt order = %v, want %v", gotIDs, test.wantIDs)
|
||||||
|
}
|
||||||
|
if test.shared == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
shared := result.Config.Prompts[1]
|
||||||
|
if shared.ID != test.shared.ID || shared.Name != test.shared.Name || len(shared.Messages) != 1 ||
|
||||||
|
shared.Messages[0] != test.shared.Messages[0] || shared.Description != "" || shared.Resource != "" || shared.Credential.EnvName() != newSecret {
|
||||||
|
t.Fatalf("upsert did not replace the complete matched record: %#v", shared)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSequenceCatalogInspectionTracksResourcesAndSecretHistory(t *testing.T) {
|
||||||
|
const oldSecret = "CONFIGKIT_SEQUENCE_CATALOG_OLD_SECRET"
|
||||||
|
const newSecret = "CONFIGKIT_SEQUENCE_CATALOG_NEW_SECRET"
|
||||||
|
const oldCanary = "sequence-catalog-old-canary"
|
||||||
|
const newCanary = "sequence-catalog-new-canary"
|
||||||
|
t.Setenv(oldSecret, oldCanary)
|
||||||
|
t.Setenv(newSecret, newCanary)
|
||||||
|
|
||||||
|
result, err := consumers.LoadSequenceCatalog(fixture("sequence-catalog", "config.yml"), "upsert", "history")
|
||||||
|
if err != nil {
|
||||||
|
var loadError *configkit.LoadError
|
||||||
|
if errors.As(err, &loadError) {
|
||||||
|
t.Fatalf("load sequence catalog: %v (%#v)", err, loadError.Diagnostics())
|
||||||
|
}
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
resourcePath := configkit.Keys("prompts").Index(3).Key("resource")
|
||||||
|
resourceInfo, found := result.Metadata().Lookup(resourcePath)
|
||||||
|
if !found || len(resourceInfo.Origin.Locations) != 1 || len(resourceInfo.Origin.Inheritance) != 1 {
|
||||||
|
t.Fatalf("inherited resource inspection = %#v, %v", resourceInfo, found)
|
||||||
|
}
|
||||||
|
resource, err := consumers.ReadSequenceCatalogResource(result, "concise")
|
||||||
|
if err != nil || resource != "Foundation resource text.\n" {
|
||||||
|
t.Fatalf("resource from inherited source context = %q, %v", resource, err)
|
||||||
|
}
|
||||||
|
if len(result.Metadata().History(configkit.Keys("resolved_label"))) == 0 {
|
||||||
|
t.Fatal("recorded CLI-driven resolution was absent from final inspection")
|
||||||
|
}
|
||||||
|
credentialPath := configkit.Keys("prompts").Index(1).Key("credential")
|
||||||
|
credentialHistory := result.Metadata().History(credentialPath)
|
||||||
|
if len(credentialHistory) == 0 {
|
||||||
|
t.Fatal("upsert did not retain the replaced secret reference in history")
|
||||||
|
}
|
||||||
|
var explanation bytes.Buffer
|
||||||
|
if err := result.WriteExplanation(&explanation, credentialPath); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if strings.Contains(explanation.String(), oldCanary) || strings.Contains(explanation.String(), newCanary) {
|
||||||
|
t.Fatalf("secret explanation exposed a credential value: %s", explanation.String())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNamespacedTypedMapIdentityScopesAreIndependent(t *testing.T) {
|
||||||
|
source := configkit.Bytes("namespaced maps", []byte(`
|
||||||
|
prompts:
|
||||||
|
- id: shared
|
||||||
|
text: root prompt
|
||||||
|
collections:
|
||||||
|
team:
|
||||||
|
prompts:
|
||||||
|
- id: shared
|
||||||
|
text: team prompt
|
||||||
|
operations:
|
||||||
|
prompts:
|
||||||
|
- id: shared
|
||||||
|
text: operations prompt
|
||||||
|
`))
|
||||||
|
result, err := consumers.LoadNamespacedMapCatalog(source)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result.Config.Prompts[0]["text"] != "root prompt" ||
|
||||||
|
result.Config.Collections["team"]["prompts"].([]any)[0].(map[string]any)["text"] != "team prompt" ||
|
||||||
|
result.Config.Collections["operations"]["prompts"].([]any)[0].(map[string]any)["text"] != "operations prompt" {
|
||||||
|
t.Fatalf("typed map collections = %#v", result.Config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNamespacedTypedMapIdentityFindsCollectionsIntroducedByInheritance(t *testing.T) {
|
||||||
|
result, err := consumers.LoadNamespacedMapCatalog(configkit.Bytes("inherited namespace", []byte(`
|
||||||
|
collections:
|
||||||
|
source:
|
||||||
|
prompts:
|
||||||
|
- id: shared
|
||||||
|
text: inherited prompt
|
||||||
|
copied:
|
||||||
|
extends: source
|
||||||
|
`)))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
copied := result.Config.Collections["copied"]["prompts"].([]any)
|
||||||
|
if len(copied) != 1 || copied[0].(map[string]any)["text"] != "inherited prompt" {
|
||||||
|
t.Fatalf("inherited typed-map collection = %#v", result.Config.Collections["copied"])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func Example_yamlDeclaredIdentityOnly() {
|
||||||
|
result, err := configkit.Load(configkit.Bytes("YAML identity only", []byte(`
|
||||||
|
configkit:
|
||||||
|
sequences:
|
||||||
|
- destination: [prompts]
|
||||||
|
key: id
|
||||||
|
prompts:
|
||||||
|
- id: initial
|
||||||
|
`)), configkit.Options[map[string]any]{
|
||||||
|
Overrides: []configkit.Override{{
|
||||||
|
Path: configkit.Keys("prompts"),
|
||||||
|
Value: []any{
|
||||||
|
map[string]any{"id": "same"},
|
||||||
|
map[string]any{"id": "same"},
|
||||||
|
},
|
||||||
|
Flag: "--prompts",
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
fmt.Println(len(result.Config["prompts"].([]any)), err)
|
||||||
|
// Output: 2 <nil>
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestYAMLDeclaredIdentityDoesNotRegisterFinalTypedConstraint(t *testing.T) {
|
||||||
|
result, err := configkit.Load(configkit.Bytes("YAML identity only", []byte(`
|
||||||
|
configkit:
|
||||||
|
sequences:
|
||||||
|
- destination: [prompts]
|
||||||
|
key: id
|
||||||
|
prompts:
|
||||||
|
- id: initial
|
||||||
|
`)), configkit.Options[map[string]any]{
|
||||||
|
Overrides: []configkit.Override{{
|
||||||
|
Path: configkit.Keys("prompts"),
|
||||||
|
Value: []any{
|
||||||
|
map[string]any{"id": "same"},
|
||||||
|
map[string]any{"id": "same"},
|
||||||
|
},
|
||||||
|
Flag: "--prompts",
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("YAML-only identity incorrectly became an application constraint: %v", err)
|
||||||
|
}
|
||||||
|
prompts := result.Config["prompts"].([]any)
|
||||||
|
if len(prompts) != 2 || prompts[0].(map[string]any)["id"] != prompts[1].(map[string]any)["id"] {
|
||||||
|
t.Fatalf("CLI result = %#v", result.Config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNamespacedTypedMapRejectsDuplicatesWithinOneCollection(t *testing.T) {
|
||||||
|
_, err := consumers.LoadNamespacedMapCatalog(configkit.Bytes("duplicate namespace", []byte(`
|
||||||
|
collections:
|
||||||
|
team:
|
||||||
|
prompts:
|
||||||
|
- id: duplicate
|
||||||
|
- id: duplicate
|
||||||
|
`)))
|
||||||
|
var loadError *configkit.LoadError
|
||||||
|
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 ||
|
||||||
|
loadError.Diagnostics()[0].Code != configkit.CodeComposeConflict ||
|
||||||
|
loadError.Diagnostics()[0].Path.String() != "/collections/team/prompts" {
|
||||||
|
diagnostics := []configkit.Diagnostic(nil)
|
||||||
|
if loadError != nil {
|
||||||
|
diagnostics = loadError.Diagnostics()
|
||||||
|
}
|
||||||
|
t.Fatalf("duplicate namespaced identity error = %v (%#v)", err, diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,11 +1,18 @@
|
|||||||
configkit:
|
configkit:
|
||||||
|
sequences:
|
||||||
|
- destination: [prompts]
|
||||||
|
key: id
|
||||||
|
mode: replace
|
||||||
imports:
|
imports:
|
||||||
- path: prompts/curated.yml
|
- dir: prompts
|
||||||
|
exclude: [generated-ignored.yml]
|
||||||
destination: [prompts]
|
destination: [prompts]
|
||||||
placement: sequence-item
|
placement: sequence-item
|
||||||
- glob: prompts/generated-*.yml
|
- dir: featured
|
||||||
|
pattern: [weather-*.yml]
|
||||||
destination: [prompts]
|
destination: [prompts]
|
||||||
placement: sequence-item
|
placement: sequence-item
|
||||||
|
- dir: optional
|
||||||
prompts:
|
prompts:
|
||||||
- id: main
|
- id: main
|
||||||
name: Main authored prompt
|
name: Main authored prompt
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
|
configkit:
|
||||||
|
sequences:
|
||||||
|
- destination: [prompts]
|
||||||
|
key: id
|
||||||
|
mode: replace
|
||||||
prompts:
|
prompts:
|
||||||
- id: invalid
|
- id: invalid
|
||||||
name: Invalid prompt
|
name: Invalid prompt
|
||||||
|
|||||||
1
examples/consumers/testdata/prompt-catalog/optional/ignored.txt
vendored
Normal file
1
examples/consumers/testdata/prompt-catalog/optional/ignored.txt
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
This file does not match the application import defaults.
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
configkit:
|
configkit:
|
||||||
imports:
|
imports:
|
||||||
- path: curated-id.yml
|
- path: helpers/curated-id.yml
|
||||||
name: Curated summary
|
name: Curated summary
|
||||||
messages:
|
messages:
|
||||||
- Summarize the supplied content.
|
- Summarize the supplied content.
|
||||||
|
|||||||
3
examples/consumers/testdata/prompt-catalog/prompts/generated-ignored.yml
vendored
Normal file
3
examples/consumers/testdata/prompt-catalog/prompts/generated-ignored.yml
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
id: ignored
|
||||||
|
name: Excluded generated prompt
|
||||||
|
messages: [This record is excluded.]
|
||||||
@@ -2,3 +2,4 @@ id: welcome
|
|||||||
name: Welcome
|
name: Welcome
|
||||||
messages:
|
messages:
|
||||||
- Welcome the user.
|
- Welcome the user.
|
||||||
|
resource: welcome.txt
|
||||||
|
|||||||
3
examples/consumers/testdata/prompt-catalog/prompts/required.yml
vendored
Normal file
3
examples/consumers/testdata/prompt-catalog/prompts/required.yml
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
id: required
|
||||||
|
name: Required prompt
|
||||||
|
messages: [Use the required prompt.]
|
||||||
1
examples/consumers/testdata/prompt-catalog/prompts/welcome.txt
vendored
Normal file
1
examples/consumers/testdata/prompt-catalog/prompts/welcome.txt
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Welcome resource text.
|
||||||
7
examples/consumers/testdata/prompt-catalog/simple.yml
vendored
Normal file
7
examples/consumers/testdata/prompt-catalog/simple.yml
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
configkit:
|
||||||
|
imports:
|
||||||
|
- path: prompts/required.yml
|
||||||
|
required: true
|
||||||
|
destination: [prompts]
|
||||||
|
placement: sequence-item
|
||||||
|
prompts: []
|
||||||
7
examples/consumers/testdata/sequence-catalog/config.yml
vendored
Normal file
7
examples/consumers/testdata/sequence-catalog/config.yml
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
run_label: source-default
|
||||||
|
configkit:
|
||||||
|
imports:
|
||||||
|
- dir: fragments
|
||||||
|
pattern: '*.yml'
|
||||||
|
destination: [prompts]
|
||||||
|
placement: sequence-item
|
||||||
1
examples/consumers/testdata/sequence-catalog/foundation.txt
vendored
Normal file
1
examples/consumers/testdata/sequence-catalog/foundation.txt
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
Foundation resource text.
|
||||||
4
examples/consumers/testdata/sequence-catalog/fragments/01-welcome.yml
vendored
Normal file
4
examples/consumers/testdata/sequence-catalog/fragments/01-welcome.yml
vendored
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
id: welcome
|
||||||
|
name: Welcome
|
||||||
|
messages: [Welcome message]
|
||||||
|
credential: CONFIGKIT_SEQUENCE_CATALOG_OLD_SECRET
|
||||||
3
examples/consumers/testdata/sequence-catalog/fragments/02-concise.yml
vendored
Normal file
3
examples/consumers/testdata/sequence-catalog/fragments/02-concise.yml
vendored
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
id: concise
|
||||||
|
extends: {id: foundation}
|
||||||
|
name: Concise
|
||||||
10
examples/consumers/testdata/sequence-catalog/overlays/append.yml
vendored
Normal file
10
examples/consumers/testdata/sequence-catalog/overlays/append.yml
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
configkit:
|
||||||
|
sequences:
|
||||||
|
- destination: [prompts]
|
||||||
|
key: id
|
||||||
|
mode: append
|
||||||
|
prompts:
|
||||||
|
- id: extra
|
||||||
|
name: Extra
|
||||||
|
messages: [Extra message]
|
||||||
|
credential: CONFIGKIT_SEQUENCE_CATALOG_OLD_SECRET
|
||||||
14
examples/consumers/testdata/sequence-catalog/overlays/replace.yml
vendored
Normal file
14
examples/consumers/testdata/sequence-catalog/overlays/replace.yml
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
configkit:
|
||||||
|
sequences:
|
||||||
|
- destination: [prompts]
|
||||||
|
key: id
|
||||||
|
mode: replace
|
||||||
|
prompts:
|
||||||
|
- id: replacement
|
||||||
|
name: Replacement
|
||||||
|
messages: [Replacement message]
|
||||||
|
credential: CONFIGKIT_SEQUENCE_CATALOG_OLD_SECRET
|
||||||
|
- id: last
|
||||||
|
name: Last
|
||||||
|
messages: [Last message]
|
||||||
|
credential: CONFIGKIT_SEQUENCE_CATALOG_OLD_SECRET
|
||||||
14
examples/consumers/testdata/sequence-catalog/overlays/upsert.yml
vendored
Normal file
14
examples/consumers/testdata/sequence-catalog/overlays/upsert.yml
vendored
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
configkit:
|
||||||
|
sequences:
|
||||||
|
- destination: [prompts]
|
||||||
|
key: id
|
||||||
|
mode: upsert
|
||||||
|
prompts:
|
||||||
|
- id: shared
|
||||||
|
name: Shared replacement
|
||||||
|
messages: [Replacement message]
|
||||||
|
credential: CONFIGKIT_SEQUENCE_CATALOG_NEW_SECRET
|
||||||
|
- id: extra
|
||||||
|
name: Extra
|
||||||
|
messages: [Extra message]
|
||||||
|
credential: CONFIGKIT_SEQUENCE_CATALOG_OLD_SECRET
|
||||||
18
examples/consumers/testdata/sequence-catalog/underlay.yml
vendored
Normal file
18
examples/consumers/testdata/sequence-catalog/underlay.yml
vendored
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
configkit:
|
||||||
|
sequences:
|
||||||
|
- destination: [prompts]
|
||||||
|
key: id
|
||||||
|
mode: append
|
||||||
|
prompts:
|
||||||
|
- id: foundation
|
||||||
|
name: Foundation
|
||||||
|
description: Shared foundation instruction
|
||||||
|
messages: [Foundation message]
|
||||||
|
resource: foundation.txt
|
||||||
|
credential: CONFIGKIT_SEQUENCE_CATALOG_OLD_SECRET
|
||||||
|
- id: shared
|
||||||
|
name: Shared draft
|
||||||
|
description: This field must not survive a whole-record replacement
|
||||||
|
messages: [Draft message]
|
||||||
|
resource: shared.txt
|
||||||
|
credential: CONFIGKIT_SEQUENCE_CATALOG_OLD_SECRET
|
||||||
@@ -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 {
|
||||||
|
|||||||
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) {
|
||||||
@@ -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 {
|
||||||
|
|||||||
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(`configkit:
|
||||||
|
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{
|
||||||
|
"configkit": 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)
|
||||||
|
|||||||
316
facade_discovery_test.go
Normal file
316
facade_discovery_test.go
Normal file
@@ -0,0 +1,316 @@
|
|||||||
|
package configkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
)
|
||||||
|
|
||||||
|
type facadeDiscoveryConfig struct {
|
||||||
|
Value string `yaml:"value"`
|
||||||
|
Settings map[string]string `yaml:"settings"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type facadeDiscoveryLifecycleConfig struct {
|
||||||
|
Value string `yaml:"value"`
|
||||||
|
Resolved string `yaml:"resolved"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type facadeDiscoveryPlacementConfig struct {
|
||||||
|
Items []facadeDiscoveryPlacementItem `yaml:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type facadeDiscoveryPlacementItem struct {
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeSearchPresetUsesCurrentWorkingDirectory(t *testing.T) {
|
||||||
|
original, err := os.Getwd()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
root := t.TempDir()
|
||||||
|
if err := os.Chdir(root); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
if err := os.Chdir(original); err != nil {
|
||||||
|
t.Errorf("restore working directory: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
writeFacadeImportFile(t, root, "myapp.yml", "value: working\n")
|
||||||
|
|
||||||
|
result, err := Load[facadeDiscoveryConfig](SearchConfig("myapp", WorkingDirectory()))
|
||||||
|
if err != nil || result.Config.Value != "working" {
|
||||||
|
t.Fatalf("working-directory search = %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
if sources := result.Sources(); len(sources) != 1 || sources[0].Path != canonicalFacadePath(t, filepath.Join(root, "myapp.yml")) {
|
||||||
|
t.Fatalf("working-directory sources = %#v", sources)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeSearchCustomGenericNamesAndEmptyFilenameSelection(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
filename := writeFacadeImportFile(t, root, "config.yml", "value: found\n")
|
||||||
|
custom := SearchDirectory(root, DirectorySearchOptions{
|
||||||
|
Filenames: []FilenameGroup{CustomFilenames("config.yml")},
|
||||||
|
})
|
||||||
|
result, err := Load[facadeDiscoveryConfig](SearchConfig("", custom))
|
||||||
|
if err != nil || result.Config.Value != "found" {
|
||||||
|
t.Fatalf("custom generic-only search with empty slug = %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
if sources := result.Sources(); len(sources) != 1 || sources[0].Path != canonicalFacadePath(t, filename) {
|
||||||
|
t.Fatalf("custom generic-only sources = %#v", sources)
|
||||||
|
}
|
||||||
|
|
||||||
|
defaults := func() facadeDiscoveryConfig { return facadeDiscoveryConfig{Value: "default"} }
|
||||||
|
empty := SearchDirectory(root, DirectorySearchOptions{Filenames: []FilenameGroup{}})
|
||||||
|
absent, err := Load(SearchConfig("myapp", empty), Options[facadeDiscoveryConfig]{
|
||||||
|
Definition: Definition[facadeDiscoveryConfig]{Defaults: defaults},
|
||||||
|
})
|
||||||
|
if err != nil || absent.Config.Value != "default" || len(absent.Sources()) != 0 {
|
||||||
|
t.Fatalf("explicit empty filename selection = %#v, %v", absent, err)
|
||||||
|
}
|
||||||
|
omitted, err := Load[facadeDiscoveryConfig](SearchConfig("myapp", SearchDirectory(root)))
|
||||||
|
if err != nil || omitted.Config.Value != "found" {
|
||||||
|
t.Fatalf("omitted filename selection did not use defaults = %#v, %v", omitted, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeSearchOrderingRequirednessAndSnapshots(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
selectedPath := writeFacadeImportFile(t, root, "config.yml", "value: selected\n")
|
||||||
|
laterPath := writeFacadeImportFile(t, root, "later.yml", "value: later\n")
|
||||||
|
paths := []string{filepath.Join(root, "missing.yml")}
|
||||||
|
names := []string{"config.yml"}
|
||||||
|
groups := []FilenameGroup{CustomFilenames(names...)}
|
||||||
|
directoryOptions := []DirectorySearchOptions{{Filenames: groups}}
|
||||||
|
search := Searches(SearchPaths(paths...), SearchDirectory(root, directoryOptions...), SearchPaths(laterPath))
|
||||||
|
source := SearchConfig("", search, SearchOptions{Required: true})
|
||||||
|
|
||||||
|
paths[0] = laterPath
|
||||||
|
names[0] = "later.yml"
|
||||||
|
groups[0] = CustomFilenames("later.yml")
|
||||||
|
directoryOptions[0].Filenames[0] = CustomFilenames("later.yml")
|
||||||
|
search = SearchPaths(laterPath)
|
||||||
|
|
||||||
|
result, err := Load[facadeDiscoveryConfig](source)
|
||||||
|
if err != nil || result.Config.Value != "selected" {
|
||||||
|
t.Fatalf("ordered required search after caller mutation = %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
if sources := result.Sources(); len(sources) != 1 || sources[0].Path != canonicalFacadePath(t, selectedPath) {
|
||||||
|
t.Fatalf("ordered search sources = %#v", sources)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = Load[facadeDiscoveryConfig](SearchConfig("", SearchPaths(filepath.Join(root, "missing.yml")), SearchOptions{Required: true}))
|
||||||
|
var loadErr *LoadError
|
||||||
|
if !errors.As(err, &loadErr) {
|
||||||
|
t.Fatalf("required exhausted search error = %v, want *LoadError", err)
|
||||||
|
}
|
||||||
|
foundMissing := false
|
||||||
|
for _, diagnostic := range loadErr.Diagnostics() {
|
||||||
|
foundMissing = foundMissing || diagnostic.Code == CodeSourceMissing
|
||||||
|
}
|
||||||
|
if !foundMissing {
|
||||||
|
t.Fatalf("required exhausted search diagnostics = %#v", loadErr.Diagnostics())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeSearchWorksForLayeredMainAndUnderlay(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
mainPath := writeFacadeImportFile(t, root, "main.yml", "settings: {main: value}\n")
|
||||||
|
underlayPath := writeFacadeImportFile(t, root, "underlay/base.yml", "settings: {underlay: value}\n")
|
||||||
|
result, err := LoadLayered(
|
||||||
|
Input{Source: SearchConfig("", SearchPaths(mainPath))},
|
||||||
|
LayeredOptions[facadeDiscoveryConfig]{Underlays: []Underlay{{Inputs: []Input{{
|
||||||
|
Source: SearchConfig("", SearchDirectory(filepath.Dir(underlayPath), DirectorySearchOptions{
|
||||||
|
Filenames: []FilenameGroup{CustomFilenames("base.yml")},
|
||||||
|
})),
|
||||||
|
}}}}},
|
||||||
|
)
|
||||||
|
if err != nil || !reflect.DeepEqual(result.Config.Settings, map[string]string{"main": "value", "underlay": "value"}) {
|
||||||
|
t.Fatalf("layered search = %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
sources := result.Sources()
|
||||||
|
if len(sources) != 2 || sources[0].Path != canonicalFacadePath(t, mainPath) || sources[0].Layer != LayerMain ||
|
||||||
|
sources[1].Path != canonicalFacadePath(t, underlayPath) || sources[1].Layer != LayerUnderlay {
|
||||||
|
t.Fatalf("layered search sources = %#v", sources)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeSearchSelectedAndAbsentUseRootOnlyContext(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
mainPath := writeFacadeImportFile(t, root, "main.yml", "value: main\n")
|
||||||
|
writeFacadeImportFile(t, root, "overlays/testing.yml", "value: overlay\n")
|
||||||
|
filesystem := &FilesystemContext{Root: root}
|
||||||
|
options := Options[facadeDiscoveryConfig]{Overlay: "testing", Filesystem: filesystem}
|
||||||
|
|
||||||
|
selected, err := Load(SearchConfig("", SearchPaths(mainPath)), options)
|
||||||
|
if err != nil || selected.Config.Value != "overlay" {
|
||||||
|
t.Fatalf("selected search with root-only context = %#v, %v", selected, err)
|
||||||
|
}
|
||||||
|
sources := selected.Sources()
|
||||||
|
if len(sources) != 2 || sources[0].Path != canonicalFacadePath(t, mainPath) || sources[0].ID != "source-1" ||
|
||||||
|
sources[1].ID != "source-2" || sources[1].Layer != LayerOverlay {
|
||||||
|
t.Fatalf("selected search source evidence = %#v", sources)
|
||||||
|
}
|
||||||
|
mainContext, ok := selected.SourceContext("source-1")
|
||||||
|
if !ok || mainContext.BaseDir != "." {
|
||||||
|
t.Fatalf("selected file base context = %#v, %v", mainContext, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
missing := SearchConfig("", SearchPaths(filepath.Join(root, "missing.yml")))
|
||||||
|
searchedAbsent, err := Load(missing, options)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("exhausted search with root-only overlay context: %v", err)
|
||||||
|
}
|
||||||
|
explicitAbsent, err := Load(NoSource(), options)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("NoSource with root-only overlay context: %v", err)
|
||||||
|
}
|
||||||
|
if !reflect.DeepEqual(searchedAbsent.Config, explicitAbsent.Config) ||
|
||||||
|
!reflect.DeepEqual(searchedAbsent.Sources(), explicitAbsent.Sources()) {
|
||||||
|
t.Fatalf("searched absence differs from NoSource: %#v / %#v", searchedAbsent, explicitAbsent)
|
||||||
|
}
|
||||||
|
left, leftOK := searchedAbsent.Metadata().Lookup(Keys("value"))
|
||||||
|
right, rightOK := explicitAbsent.Metadata().Lookup(Keys("value"))
|
||||||
|
if leftOK != rightOK || !reflect.DeepEqual(left, right) || !leftOK {
|
||||||
|
t.Fatalf("searched absence metadata = %#v, %v; NoSource metadata = %#v, %v", left, leftOK, right, rightOK)
|
||||||
|
}
|
||||||
|
if sources := searchedAbsent.Sources(); len(sources) != 1 || sources[0].ID != "source-1" || sources[0].Layer != LayerOverlay {
|
||||||
|
t.Fatalf("absent search created unexpected source evidence = %#v", sources)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeSearchRejectsSelectedContextWithoutFallback(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
inside := writeFacadeImportFile(t, root, "inside.yml", "value: fallback\n")
|
||||||
|
outsideRoot := t.TempDir()
|
||||||
|
outside := writeFacadeImportFile(t, outsideRoot, "outside.yml", "value: selected\n")
|
||||||
|
search := SearchConfig("", SearchPaths(outside, inside))
|
||||||
|
|
||||||
|
_, err := Load(search, Options[facadeDiscoveryConfig]{Filesystem: &FilesystemContext{Root: root}})
|
||||||
|
var loadErr *LoadError
|
||||||
|
if !errors.As(err, &loadErr) || !hasFacadeDiagnostic(loadErr, CodeImportOutsideRoot) {
|
||||||
|
t.Fatalf("selected outside-root file error = %v, want containment failure", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = Load(search, Options[facadeDiscoveryConfig]{Filesystem: &FilesystemContext{BaseDir: root, Root: root}})
|
||||||
|
if !errors.As(err, &loadErr) || !hasFacadeDiagnostic(loadErr, CodeOptionsInvalid) {
|
||||||
|
t.Fatalf("selected file with explicit base error = %v, want option failure", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
native := NewFilesystem("native", fstest.MapFS{})
|
||||||
|
_, err = Load(search, Options[facadeDiscoveryConfig]{Filesystem: &FilesystemContext{FS: &native, BaseDir: ".", Root: "."}})
|
||||||
|
if !errors.As(err, &loadErr) || !hasFacadeDiagnostic(loadErr, CodeOptionsInvalid) {
|
||||||
|
t.Fatalf("selected file with native context error = %v, want option failure", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeSearchRunsCallbacksOnceAndKeepsInspectionWithSelectedFile(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
selectedPath := writeFacadeImportFile(t, root, "selected.yml", "value: selected\n")
|
||||||
|
writeFacadeImportFile(t, root, "later.yml", "value: later\n")
|
||||||
|
defaultsCalls, resolveCalls, validateCalls := 0, 0, 0
|
||||||
|
result, err := Load(SearchConfig("", SearchPaths(selectedPath, filepath.Join(root, "later.yml"))), Options[facadeDiscoveryLifecycleConfig]{
|
||||||
|
Definition: Definition[facadeDiscoveryLifecycleConfig]{
|
||||||
|
Defaults: func() facadeDiscoveryLifecycleConfig {
|
||||||
|
defaultsCalls++
|
||||||
|
return facadeDiscoveryLifecycleConfig{}
|
||||||
|
},
|
||||||
|
Resolve: func(configuration *facadeDiscoveryLifecycleConfig, resolution *Resolution) error {
|
||||||
|
resolveCalls++
|
||||||
|
configuration.Resolved = "from:" + configuration.Value
|
||||||
|
resolution.RecordNormalization(Keys("resolved"), "derive from selected value", Keys("value"))
|
||||||
|
return nil
|
||||||
|
},
|
||||||
|
Validate: func(_ *facadeDiscoveryLifecycleConfig, _ *Validation) { validateCalls++ },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil || result.Config.Value != "selected" || result.Config.Resolved != "from:selected" {
|
||||||
|
t.Fatalf("search lifecycle result = %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
if defaultsCalls != 1 || resolveCalls != 1 || validateCalls != 1 {
|
||||||
|
t.Fatalf("search callbacks ran defaults=%d resolve=%d validate=%d times", defaultsCalls, resolveCalls, validateCalls)
|
||||||
|
}
|
||||||
|
sources := result.Sources()
|
||||||
|
if len(sources) != 1 || sources[0].Path != canonicalFacadePath(t, selectedPath) || sources[0].ID != "source-1" {
|
||||||
|
t.Fatalf("selected source evidence = %#v", sources)
|
||||||
|
}
|
||||||
|
valueInfo, found := result.Metadata().Lookup(Keys("value"))
|
||||||
|
if !found || len(valueInfo.Origin.Locations) != 1 || valueInfo.Origin.Locations[0].SourceID != sources[0].ID {
|
||||||
|
t.Fatalf("selected value metadata = %#v, %v", valueInfo, found)
|
||||||
|
}
|
||||||
|
var rendered strings.Builder
|
||||||
|
if err := result.WriteEffective(&rendered); err != nil || !strings.Contains(rendered.String(), "value: selected") || strings.Contains(rendered.String(), "later") {
|
||||||
|
t.Fatalf("effective output = %q, %v", rendered.String(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeSearchSequenceItemAndAbsentInputPlacement(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
root := t.TempDir()
|
||||||
|
selectedPath := writeFacadeImportFile(t, root, "item.yml", "name: selected\n")
|
||||||
|
options := Options[facadeDiscoveryPlacementConfig]{
|
||||||
|
Destination: Keys("items"),
|
||||||
|
PlacementMode: PlacementModeSequenceItem,
|
||||||
|
}
|
||||||
|
selected, err := Load(SearchConfig("", SearchPaths(selectedPath)), options)
|
||||||
|
if err != nil || len(selected.Config.Items) != 1 || selected.Config.Items[0].Name != "selected" {
|
||||||
|
t.Fatalf("selected sequence item = %#v, %v", selected, err)
|
||||||
|
}
|
||||||
|
if sources := selected.Sources(); len(sources) != 1 || sources[0].Destination.String() != "/items" || sources[0].PlacementMode != PlacementModeSequenceItem {
|
||||||
|
t.Fatalf("selected sequence-item source = %#v", sources)
|
||||||
|
}
|
||||||
|
|
||||||
|
defaults := func() facadeDiscoveryPlacementConfig {
|
||||||
|
return facadeDiscoveryPlacementConfig{Items: []facadeDiscoveryPlacementItem{}}
|
||||||
|
}
|
||||||
|
absent, err := Load(SearchConfig("", SearchPaths(filepath.Join(root, "missing.yml"))), Options[facadeDiscoveryPlacementConfig]{
|
||||||
|
Definition: Definition[facadeDiscoveryPlacementConfig]{Defaults: defaults},
|
||||||
|
Destination: options.Destination,
|
||||||
|
PlacementMode: options.PlacementMode,
|
||||||
|
})
|
||||||
|
if err != nil || len(absent.Config.Items) != 0 || len(absent.Sources()) != 0 {
|
||||||
|
t.Fatalf("absent search created a sequence item: %#v, %v", absent, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func hasFacadeDiagnostic(err *LoadError, code Code) bool {
|
||||||
|
for _, diagnostic := range err.Diagnostics() {
|
||||||
|
if diagnostic.Code == code {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
func canonicalFacadePath(t *testing.T, path string) string {
|
||||||
|
t.Helper()
|
||||||
|
absolute, err := filepath.Abs(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
canonical, err := filepath.EvalSymlinks(absolute)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return canonical
|
||||||
|
}
|
||||||
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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
|
||||||
|
|||||||
249
facade_final_sequence_identity_test.go
Normal file
249
facade_final_sequence_identity_test.go
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
package configkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"go.maximumdirect.net/configkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFinalSequenceIdentityTreatsLateExtendsFieldsAsPayload(t *testing.T) {
|
||||||
|
for _, phase := range []string{"defaults", "resolution"} {
|
||||||
|
for _, component := range []any{"application-data", nil} {
|
||||||
|
for _, duplicate := range []bool{false, true} {
|
||||||
|
t.Run(fmt.Sprintf("%s/%v/duplicate=%t", phase, component, duplicate), func(t *testing.T) {
|
||||||
|
items := []any{map[string]any{"extends": component}}
|
||||||
|
if duplicate {
|
||||||
|
items = append(items, map[string]any{"extends": component})
|
||||||
|
}
|
||||||
|
definition := configkit.Definition[map[string]any]{
|
||||||
|
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("items"), "extends")},
|
||||||
|
Defaults: func() map[string]any {
|
||||||
|
if phase == "defaults" {
|
||||||
|
return map[string]any{"items": items}
|
||||||
|
}
|
||||||
|
return map[string]any{}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
if phase == "resolution" {
|
||||||
|
definition.Resolve = func(value *map[string]any, resolution *configkit.Resolution) error {
|
||||||
|
(*value)["items"] = items
|
||||||
|
resolution.RecordNormalization(configkit.Keys("items"), "supply application payload")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
loaded, err := configkit.Load(configkit.NoSource(), configkit.Options[map[string]any]{Definition: definition})
|
||||||
|
if duplicate {
|
||||||
|
loadErr := requireFacadeLoadError(t, err)
|
||||||
|
diagnostics := loadErr.Diagnostics()
|
||||||
|
if loaded != nil || len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeSequenceConflict || diagnostics[0].Path.String() != "/items" {
|
||||||
|
t.Fatalf("duplicate effective payload diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil || loaded == nil {
|
||||||
|
t.Fatalf("ordinary effective identity was interpreted as a directive: %v", err)
|
||||||
|
}
|
||||||
|
value := loaded.Config["items"].([]any)[0].(map[string]any)
|
||||||
|
if actual, present := value["extends"]; !present || actual != component {
|
||||||
|
t.Fatalf("effective payload = %#v", value)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFinalSequenceIdentityValidatesLateValues(t *testing.T) {
|
||||||
|
identity := configkit.SequenceIdentityAt(configkit.Keys("items"), "id")
|
||||||
|
for _, source := range []string{"defaults", "CLI", "resolution"} {
|
||||||
|
for _, shape := range []string{"duplicate", "missing", "non-scalar"} {
|
||||||
|
t.Run(source+"/"+shape, func(t *testing.T) {
|
||||||
|
items := finalIdentityRecords(shape)
|
||||||
|
definition := configkit.Definition[map[string]any]{
|
||||||
|
SequenceIdentities: []configkit.SequenceIdentity{identity},
|
||||||
|
Defaults: func() map[string]any {
|
||||||
|
if source == "defaults" {
|
||||||
|
return map[string]any{"items": items}
|
||||||
|
}
|
||||||
|
return map[string]any{}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
options := configkit.Options[map[string]any]{Definition: definition}
|
||||||
|
switch source {
|
||||||
|
case "CLI":
|
||||||
|
options.Overrides = []configkit.Override{{Path: configkit.Keys("items"), Value: items, Flag: "--items"}}
|
||||||
|
case "resolution":
|
||||||
|
options.Definition.Resolve = func(value *map[string]any, resolution *configkit.Resolution) error {
|
||||||
|
(*value)["items"] = items
|
||||||
|
resolution.RecordNormalization(configkit.Keys("items"), "create effective records")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result, err := configkit.Load(configkit.NoSource(), options)
|
||||||
|
if result != nil {
|
||||||
|
t.Fatalf("invalid final identities returned a result: %#v", result.Config)
|
||||||
|
}
|
||||||
|
loadErr := requireFacadeLoadError(t, err)
|
||||||
|
if len(loadErr.Diagnostics()) != 1 {
|
||||||
|
t.Fatalf("diagnostics = %#v", loadErr.Diagnostics())
|
||||||
|
}
|
||||||
|
diagnostic := loadErr.Diagnostics()[0]
|
||||||
|
wantCode, wantPath := configkit.CodeSequenceInvalid, "/items/0/id"
|
||||||
|
if shape == "duplicate" {
|
||||||
|
wantCode, wantPath = configkit.CodeSequenceConflict, "/items"
|
||||||
|
}
|
||||||
|
if diagnostic.Code != wantCode || diagnostic.Path.String() != wantPath {
|
||||||
|
t.Fatalf("diagnostic = %#v, want %s at %s", diagnostic, wantCode, wantPath)
|
||||||
|
}
|
||||||
|
if source == "CLI" && (diagnostic.Primary == nil || diagnostic.Primary.SourceID != "source-1") {
|
||||||
|
t.Fatalf("CLI identity diagnostic lost its source: %#v", diagnostic)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFinalSequenceIdentityMatchesLateDynamicNamespaces(t *testing.T) {
|
||||||
|
selector := configkit.SequenceSelector{
|
||||||
|
configkit.SequenceLiteralKey("collections"),
|
||||||
|
configkit.SequenceAnyMappingKey(),
|
||||||
|
configkit.SequenceLiteralKey("items"),
|
||||||
|
}
|
||||||
|
for _, source := range []string{"defaults", "CLI", "resolution"} {
|
||||||
|
t.Run(source, func(t *testing.T) {
|
||||||
|
name := map[string]string{"defaults": "team", "CLI": "cloud", "resolution": "orbit"}[source]
|
||||||
|
path := configkit.Keys("collections", name, "items")
|
||||||
|
items := finalIdentityRecords("duplicate")
|
||||||
|
definition := configkit.Definition[map[string]any]{
|
||||||
|
SequenceIdentities: []configkit.SequenceIdentity{
|
||||||
|
configkit.SequenceIdentityMatching(selector, "id"),
|
||||||
|
},
|
||||||
|
Defaults: func() map[string]any {
|
||||||
|
if source == "defaults" {
|
||||||
|
return map[string]any{"collections": map[string]any{name: map[string]any{"items": items}}}
|
||||||
|
}
|
||||||
|
return map[string]any{}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
options := configkit.Options[map[string]any]{Definition: definition}
|
||||||
|
switch source {
|
||||||
|
case "CLI":
|
||||||
|
options.Overrides = []configkit.Override{{Path: path, Value: items, Flag: "--items"}}
|
||||||
|
case "resolution":
|
||||||
|
options.Definition.Resolve = func(value *map[string]any, resolution *configkit.Resolution) error {
|
||||||
|
(*value)["collections"] = map[string]any{name: map[string]any{"items": items}}
|
||||||
|
resolution.RecordNormalization(configkit.Keys("collections"), "create namespaced records")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
result, err := configkit.Load(configkit.NoSource(), options)
|
||||||
|
if result != nil {
|
||||||
|
t.Fatalf("invalid dynamic identity returned a result: %#v", result.Config)
|
||||||
|
}
|
||||||
|
loadErr := requireFacadeLoadError(t, err)
|
||||||
|
if diagnostics := loadErr.Diagnostics(); len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeSequenceConflict || diagnostics[0].Path.String() != path.String() {
|
||||||
|
t.Fatalf("dynamic identity diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFinalSequenceIdentityAppliesToLayeredLoads(t *testing.T) {
|
||||||
|
definition := configkit.Definition[map[string]any]{
|
||||||
|
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("items"), "id")},
|
||||||
|
Defaults: func() map[string]any {
|
||||||
|
return map[string]any{"items": finalIdentityRecords("duplicate")}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
result, err := configkit.LoadLayered(configkit.Input{Source: configkit.NoSource()}, configkit.LayeredOptions[map[string]any]{Definition: definition})
|
||||||
|
if result != nil {
|
||||||
|
t.Fatalf("invalid layered final identities returned a result: %#v", result.Config)
|
||||||
|
}
|
||||||
|
loadErr := requireFacadeLoadError(t, err)
|
||||||
|
if diagnostics := loadErr.Diagnostics(); len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeSequenceConflict {
|
||||||
|
t.Fatalf("layered identity diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFinalSequenceIdentityUsesTypedNumbersAndIgnoresUnmatchedShapes(t *testing.T) {
|
||||||
|
definition := configkit.Definition[map[string]any]{
|
||||||
|
SequenceIdentities: []configkit.SequenceIdentity{
|
||||||
|
configkit.SequenceIdentityAt(configkit.Keys("items"), "id"),
|
||||||
|
configkit.SequenceIdentityAt(configkit.Keys("unmatched"), "id"),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
_, err := configkit.Load(configkit.NoSource(), configkit.Options[map[string]any]{
|
||||||
|
Definition: configkit.Definition[map[string]any]{
|
||||||
|
SequenceIdentities: definition.SequenceIdentities,
|
||||||
|
Defaults: func() map[string]any {
|
||||||
|
return map[string]any{"items": []any{
|
||||||
|
map[string]any{"id": int64(1)}, map[string]any{"id": float64(1)},
|
||||||
|
}}
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
loadErr := requireFacadeLoadError(t, err)
|
||||||
|
if diagnostics := loadErr.Diagnostics(); len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeSequenceConflict {
|
||||||
|
t.Fatalf("typed numeric identity diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
|
||||||
|
valid, err := configkit.Load(configkit.Mapping("valid", map[string]any{
|
||||||
|
"items": []any{
|
||||||
|
map[string]any{"id": nil}, map[string]any{"id": true},
|
||||||
|
map[string]any{"id": int64(1)}, map[string]any{"id": "one"},
|
||||||
|
},
|
||||||
|
"other": []any{map[string]any{"id": "same"}, map[string]any{"id": "same"}},
|
||||||
|
}), configkit.Options[map[string]any]{Definition: definition})
|
||||||
|
if err != nil || valid == nil {
|
||||||
|
t.Fatalf("valid scalar identities and unmatched duplicate sequence = %#v, %v", valid, err)
|
||||||
|
}
|
||||||
|
if _, err := configkit.Load(configkit.Mapping("other-shapes", map[string]any{"items": nil}), configkit.Options[map[string]any]{Definition: definition}); err != nil {
|
||||||
|
t.Fatalf("null at a registered path was validated as an identity sequence: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFinalSequenceIdentityDiagnosticDoesNotRevealResolvedSecrets(t *testing.T) {
|
||||||
|
const canary = "FINAL-IDENTITY-CREDENTIAL-CANARY"
|
||||||
|
const secretName = "CONFIGKIT_FINAL_IDENTITY_SECRET"
|
||||||
|
t.Setenv(secretName, canary)
|
||||||
|
type item struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
Token configkit.Secret `yaml:"token"`
|
||||||
|
}
|
||||||
|
type config struct {
|
||||||
|
Items []item `yaml:"items"`
|
||||||
|
}
|
||||||
|
definition := configkit.Definition[config]{
|
||||||
|
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("items"), "id")},
|
||||||
|
Defaults: func() config {
|
||||||
|
return config{Items: []item{
|
||||||
|
{ID: "duplicate", Token: configkit.SecretEnv(secretName)},
|
||||||
|
{ID: "duplicate", Token: configkit.SecretEnv(secretName)},
|
||||||
|
}}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
result, err := configkit.Load(configkit.NoSource(), configkit.Options[config]{Definition: definition})
|
||||||
|
if result != nil {
|
||||||
|
t.Fatalf("duplicate identity returned a result: %#v", result.Config)
|
||||||
|
}
|
||||||
|
if err == nil || strings.Contains(err.Error(), canary) {
|
||||||
|
t.Fatalf("identity diagnostic leaked a resolved secret: %v", err)
|
||||||
|
}
|
||||||
|
loadErr := requireFacadeLoadError(t, err)
|
||||||
|
if diagnostics := loadErr.Diagnostics(); len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeSequenceConflict {
|
||||||
|
t.Fatalf("secret-bearing identity diagnostics = %#v", diagnostics)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func finalIdentityRecords(shape string) []any {
|
||||||
|
switch shape {
|
||||||
|
case "duplicate":
|
||||||
|
return []any{map[string]any{"id": "same"}, map[string]any{"id": "same"}}
|
||||||
|
case "missing":
|
||||||
|
return []any{map[string]any{"other": "value"}}
|
||||||
|
default:
|
||||||
|
return []any{map[string]any{"id": map[string]any{"nested": "value"}}}
|
||||||
|
}
|
||||||
|
}
|
||||||
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
|
||||||
|
}
|
||||||
99
facade_import_alias_test.go
Normal file
99
facade_import_alias_test.go
Normal file
@@ -0,0 +1,99 @@
|
|||||||
|
package configkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io/fs"
|
||||||
|
"os"
|
||||||
|
"path"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
configkit "go.maximumdirect.net/configkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFacadeNativeDiscoveryPreservesAliasIdentity(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
for name, contents := range map[string]string{
|
||||||
|
"data/item.yml": "configkit:\n imports:\n - path: helper.yml\nvalue: item\n",
|
||||||
|
"data/helper.yml": "owner: target\nresource: resource.txt\n",
|
||||||
|
"aliases/helper.yml": "owner: alias\nresource: resource.txt\n",
|
||||||
|
"data/resource.txt": "target resource",
|
||||||
|
"aliases/resource.txt": "alias resource",
|
||||||
|
} {
|
||||||
|
filename := filepath.Join(root, filepath.FromSlash(name))
|
||||||
|
if err := os.MkdirAll(filepath.Dir(filename), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filename, []byte(contents), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for name, target := range map[string]string{"aliases/item.yml": "../data/item.yml", "linked": "data"} {
|
||||||
|
if err := os.Symlink(target, filepath.Join(root, filepath.FromSlash(name))); err != nil {
|
||||||
|
t.Skipf("symlinks unavailable: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
filesystem := configkit.NewFilesystem("aliases", os.DirFS(root))
|
||||||
|
type configuration struct {
|
||||||
|
Value string `yaml:"value"`
|
||||||
|
Owner string `yaml:"owner"`
|
||||||
|
Resource string `yaml:"resource"`
|
||||||
|
}
|
||||||
|
for _, test := range []struct {
|
||||||
|
name, declaration, item, helper, owner, resource string
|
||||||
|
}{
|
||||||
|
{"file/path", "path: aliases/item.yml", "aliases/item.yml", "aliases/helper.yml", "alias", "alias resource"},
|
||||||
|
{"file/dir", "dir: aliases\n pattern: item.yml", "aliases/item.yml", "aliases/helper.yml", "alias", "alias resource"},
|
||||||
|
{"file/glob", "glob: 'aliases/ite*.yml'", "aliases/item.yml", "aliases/helper.yml", "alias", "alias resource"},
|
||||||
|
{"directory/path", "path: linked/item.yml", "linked/item.yml", "linked/helper.yml", "target", "target resource"},
|
||||||
|
{"directory/dir", "dir: linked\n pattern: item.yml", "linked/item.yml", "linked/helper.yml", "target", "target resource"},
|
||||||
|
{"directory/glob", "glob: 'lin*/ite*.yml'", "linked/item.yml", "linked/helper.yml", "target", "target resource"},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
result, err := configkit.Load(configkit.Bytes("main", []byte("configkit:\n imports:\n - "+test.declaration+"\n")), configkit.Options[configuration]{
|
||||||
|
Imports: true, Filesystem: &configkit.FilesystemContext{FS: &filesystem, Root: ".", BaseDir: "."},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result.Config.Owner != test.owner || result.Config.Value != "item" {
|
||||||
|
t.Fatalf("configuration = %#v", result.Config)
|
||||||
|
}
|
||||||
|
sources := result.Sources()
|
||||||
|
if len(sources) != 3 || sources[1].Path != test.item || sources[2].Path != test.helper {
|
||||||
|
t.Fatalf("source identities = %#v", sources)
|
||||||
|
}
|
||||||
|
for field, source := range map[string]configkit.SourceInfo{"value": sources[1], "resource": sources[2]} {
|
||||||
|
info, ok := result.Metadata().Lookup(configkit.Keys(field))
|
||||||
|
if !ok || len(info.Origin.Locations) != 1 || info.Origin.Locations[0].SourceID != source.ID {
|
||||||
|
t.Fatalf("%s origin = %#v", field, info)
|
||||||
|
}
|
||||||
|
context, ok := result.SourceContext(source.ID)
|
||||||
|
if !ok || context.BaseDir != path.Dir(source.Path) {
|
||||||
|
t.Fatalf("%s context = %#v", field, context)
|
||||||
|
}
|
||||||
|
contents, err := fs.ReadFile(context.FS, path.Join(context.BaseDir, result.Config.Resource))
|
||||||
|
if err != nil || string(contents) != test.resource {
|
||||||
|
t.Fatalf("%s resource = %q, %v", field, contents, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
// The same native path is still a repeat when separately included. An alias
|
||||||
|
// to a leaf file remains a different native namespace path, as for path imports.
|
||||||
|
for _, selector := range []string{"path: aliases/item.yml", "dir: aliases\n pattern: item.yml", "glob: 'aliases/ite*.yml'"} {
|
||||||
|
_, err := configkit.Load(configkit.Bytes("main", []byte("configkit:\n imports:\n - "+selector+"\n - path: aliases/item.yml\n")), configkit.Options[configuration]{
|
||||||
|
Imports: true, Filesystem: &configkit.FilesystemContext{FS: &filesystem, Root: ".", BaseDir: "."},
|
||||||
|
})
|
||||||
|
var loadError *configkit.LoadError
|
||||||
|
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != configkit.CodeImportRepeated {
|
||||||
|
t.Fatalf("repeated source: %v", err)
|
||||||
|
}
|
||||||
|
_, err = configkit.Load(configkit.Bytes("main", []byte("configkit:\n imports:\n - "+selector+"\n - path: data/item.yml\n")), configkit.Options[configuration]{
|
||||||
|
Imports: true, Filesystem: &configkit.FilesystemContext{FS: &filesystem, Root: ".", BaseDir: "."},
|
||||||
|
})
|
||||||
|
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != configkit.CodeComposeConflict {
|
||||||
|
t.Fatalf("distinct native alias paths should reach composition: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
306
facade_import_defaults_test.go
Normal file
306
facade_import_defaults_test.go
Normal file
@@ -0,0 +1,306 @@
|
|||||||
|
package configkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestImportDepthChecksSuffixAfterSaturation(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
depth string
|
||||||
|
valid bool
|
||||||
|
}{
|
||||||
|
{"999999999999999999999999.5", false},
|
||||||
|
{"999999999999999999999999e1", false},
|
||||||
|
{"999999999999999999999999", true},
|
||||||
|
{"-0", true},
|
||||||
|
} {
|
||||||
|
t.Run(test.depth, func(t *testing.T) {
|
||||||
|
main := writeFacadeImportFile(t, t.TempDir(), "main.yml", "value: true\nconfigkit:\n imports:\n - dir: absent\n recursive: true\n depth: "+test.depth+"\n")
|
||||||
|
result, err := Load[map[string]any](File(main), Options[map[string]any]{Imports: true})
|
||||||
|
if test.valid {
|
||||||
|
if err != nil || result.Config["value"] != true {
|
||||||
|
t.Fatalf("valid depth: %v", err)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var loadError *LoadError
|
||||||
|
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 {
|
||||||
|
t.Fatalf("invalid depth accepted: %v", err)
|
||||||
|
}
|
||||||
|
if diagnostic := loadError.Diagnostics()[0]; diagnostic.Code != CodeImportInvalid || diagnostic.Path.String() != "/configkit/imports/0/depth" {
|
||||||
|
t.Fatalf("invalid depth diagnostic = %#v", diagnostic)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImportDefaultsApplyToTransitiveAndUnderlayImports(t *testing.T) {
|
||||||
|
type config struct {
|
||||||
|
Main string `yaml:"main"`
|
||||||
|
Transitive string `yaml:"transitive"`
|
||||||
|
Exception string `yaml:"exception"`
|
||||||
|
Underlay string `yaml:"underlay"`
|
||||||
|
IgnoredDeep string `yaml:"ignored_deep"`
|
||||||
|
}
|
||||||
|
directory := t.TempDir()
|
||||||
|
mainPath := writeFacadeImportFile(t, directory, "main.yml", `configkit:
|
||||||
|
imports:
|
||||||
|
- path: parent/parent.yml
|
||||||
|
main: main
|
||||||
|
`)
|
||||||
|
writeFacadeImportFile(t, directory, "parent/parent.yml", `configkit:
|
||||||
|
imports:
|
||||||
|
- dir: generated
|
||||||
|
- dir: explicit
|
||||||
|
pattern: '*.yaml'
|
||||||
|
recursive: false
|
||||||
|
`)
|
||||||
|
transitivePath := writeFacadeImportFile(t, directory, "parent/generated/deep/transitive.cfg", "transitive: selected\n")
|
||||||
|
writeFacadeImportFile(t, directory, "parent/generated/ignored.yaml", "ignored_deep: wrong-pattern\n")
|
||||||
|
explicitPath := writeFacadeImportFile(t, directory, "parent/explicit/exception.yaml", "exception: declaration\n")
|
||||||
|
writeFacadeImportFile(t, directory, "parent/explicit/deep/ignored.yaml", "ignored_deep: wrong-depth\n")
|
||||||
|
writeFacadeImportFile(t, directory, "parent/explicit/ignored.cfg", "ignored_deep: wrong-explicit-pattern\n")
|
||||||
|
underlayPath := writeFacadeImportFile(t, directory, "underlay.yml", `configkit:
|
||||||
|
imports:
|
||||||
|
- dir: underlay-files
|
||||||
|
`)
|
||||||
|
underlayImportPath := writeFacadeImportFile(t, directory, "underlay-files/underlay.cfg", "underlay: selected\n")
|
||||||
|
recursive := true
|
||||||
|
loaded, err := LoadLayered(Input{Source: File(mainPath), Imports: true}, LayeredOptions[config]{
|
||||||
|
ImportDefaults: ImportDefaults{Recursive: &recursive, Pattern: []string{"*.cfg"}},
|
||||||
|
Underlays: []Underlay{{Inputs: []Input{{Source: File(underlayPath), Imports: true}}}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("LoadLayered: %v", err)
|
||||||
|
}
|
||||||
|
if loaded.Config.Main != "main" || loaded.Config.Transitive != "selected" || loaded.Config.Exception != "declaration" || loaded.Config.Underlay != "selected" || loaded.Config.IgnoredDeep != "" {
|
||||||
|
t.Fatalf("configuration = %#v", loaded.Config)
|
||||||
|
}
|
||||||
|
paths := make(map[string]bool)
|
||||||
|
for _, source := range loaded.Sources() {
|
||||||
|
paths[source.Path] = true
|
||||||
|
}
|
||||||
|
for _, selected := range []string{transitivePath, explicitPath, underlayImportPath} {
|
||||||
|
if !paths[canonicalFacadeImportPath(t, selected)] {
|
||||||
|
t.Errorf("selected source %q missing from source records", selected)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImportDefaultsPreserveEmptyPatternAndRequiredness(t *testing.T) {
|
||||||
|
type config struct {
|
||||||
|
Value string `yaml:"value"`
|
||||||
|
Hidden string `yaml:"hidden"`
|
||||||
|
}
|
||||||
|
directory := t.TempDir()
|
||||||
|
mainPath := writeFacadeImportFile(t, directory, "main.yml", `configkit:
|
||||||
|
imports:
|
||||||
|
- dir: snippets
|
||||||
|
`)
|
||||||
|
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})
|
||||||
|
if err != nil || unrestricted.Config.Value != "imported" {
|
||||||
|
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{}}})
|
||||||
|
if err != nil || empty.Config.Value != "" {
|
||||||
|
t.Fatalf("empty pattern = %#v, %v", empty, err)
|
||||||
|
}
|
||||||
|
emptySources := empty.Sources()
|
||||||
|
if len(emptySources) != 1 || len(emptySources[0].EmptyImportSelections) != 1 || emptySources[0].EmptyImportSelections[0].Path.String() != "/configkit/imports/0" || emptySources[0].EmptyImportSelections[0].Selector != ImportSelectorDirectory {
|
||||||
|
t.Fatalf("empty-pattern evidence = %#v", emptySources)
|
||||||
|
}
|
||||||
|
required := true
|
||||||
|
_, err = Load(File(mainPath), Options[config]{Imports: true, ImportDefaults: ImportDefaults{Required: &required, Pattern: []string{}}})
|
||||||
|
var loadError *LoadError
|
||||||
|
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != CodeImportMissing {
|
||||||
|
t.Fatalf("required empty selection error = %T %v", err, err)
|
||||||
|
}
|
||||||
|
if sources := loadError.Sources(); len(sources) != 1 || len(sources[0].EmptyImportSelections) != 0 {
|
||||||
|
t.Fatalf("required empty selection was recorded as optional: %#v", sources)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
configkit:
|
||||||
|
imports:
|
||||||
|
- dir: parts
|
||||||
|
`)
|
||||||
|
writeFacadeImportFile(t, directory, "parts/nested/value.cfg", "imported: selected\n")
|
||||||
|
|
||||||
|
recursive := true
|
||||||
|
patterns := []string{"*.cfg"}
|
||||||
|
definition := Definition[config]{Version: &VersionRule{
|
||||||
|
Path: Keys("version"),
|
||||||
|
Check: func(version Version, _ *Validation) error {
|
||||||
|
if version != (Version{Present: true, Text: "v1"}) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestInvalidImportDefaultsPreflightBeforeOrdinaryAndLayeredReads(t *testing.T) {
|
||||||
|
type config struct {
|
||||||
|
Value string `yaml:"value"`
|
||||||
|
}
|
||||||
|
for _, layered := range []bool{false, true} {
|
||||||
|
input := strings.NewReader("value: read\n")
|
||||||
|
underlayInput := strings.NewReader("value: underlay\n")
|
||||||
|
defaultsCalled := false
|
||||||
|
resolveCalled := false
|
||||||
|
definition := Definition[config]{
|
||||||
|
Defaults: func() config { defaultsCalled = true; return config{} },
|
||||||
|
Resolve: func(*config, *Resolution) error { resolveCalled = true; return nil },
|
||||||
|
}
|
||||||
|
defaults := ImportDefaults{Pattern: []string{"["}}
|
||||||
|
var err error
|
||||||
|
if layered {
|
||||||
|
_, err = LoadLayered(Input{Source: Reader("reader.yml", input)}, LayeredOptions[config]{
|
||||||
|
Definition: definition, ImportDefaults: defaults,
|
||||||
|
Underlays: []Underlay{{Inputs: []Input{{Source: Reader("underlay.yml", underlayInput)}}}},
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
_, err = Load(Reader("reader.yml", input), Options[config]{Definition: definition, ImportDefaults: defaults})
|
||||||
|
}
|
||||||
|
var loadError *LoadError
|
||||||
|
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != CodeOptionsInvalid {
|
||||||
|
if loadError != nil {
|
||||||
|
t.Fatalf("layered=%v diagnostics = %#v", layered, loadError.Diagnostics())
|
||||||
|
}
|
||||||
|
t.Fatalf("layered=%v error = %T %v", layered, err, err)
|
||||||
|
}
|
||||||
|
if input.Len() != len("value: read\n") || underlayInput.Len() != len("value: underlay\n") || defaultsCalled || resolveCalled {
|
||||||
|
t.Fatalf("layered=%v consumed input or called application hooks: remaining=%d underlay=%d defaults=%v resolve=%v", layered, input.Len(), underlayInput.Len(), defaultsCalled, resolveCalled)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestPublicImportSelectionSnapshotsAreDetachedAcrossResultErrorAndResolution(t *testing.T) {
|
||||||
|
type config struct {
|
||||||
|
Value string `yaml:"value"`
|
||||||
|
}
|
||||||
|
directory := t.TempDir()
|
||||||
|
mainPath := writeFacadeImportFile(t, directory, "main.yml", `configkit:
|
||||||
|
imports:
|
||||||
|
- dir: absent
|
||||||
|
value: loaded
|
||||||
|
`)
|
||||||
|
callbackPath := ""
|
||||||
|
loaded, err := Load(File(mainPath), Options[config]{Imports: true, Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error {
|
||||||
|
first, ok := resolution.Source("source-1")
|
||||||
|
if !ok || len(first.EmptyImportSelections) != 1 {
|
||||||
|
t.Fatalf("resolution source = %#v, %v", first, ok)
|
||||||
|
}
|
||||||
|
first.EmptyImportSelections[0].Path = Keys("callback mutation")
|
||||||
|
second, ok := resolution.Source("source-1")
|
||||||
|
if !ok || second.EmptyImportSelections[0].Path.String() != "/configkit/imports/0" {
|
||||||
|
t.Fatalf("resolution snapshot changed after mutation: %#v", second)
|
||||||
|
}
|
||||||
|
callbackPath = second.EmptyImportSelections[0].Path.String()
|
||||||
|
return nil
|
||||||
|
}}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if callbackPath != "/configkit/imports/0" {
|
||||||
|
t.Fatalf("callback path = %q", callbackPath)
|
||||||
|
}
|
||||||
|
sources := loaded.Sources()
|
||||||
|
if len(sources) != 1 || len(sources[0].EmptyImportSelections) != 1 || sources[0].EmptyImportSelections[0].Location.SourceID != sources[0].ID || sources[0].EmptyImportSelections[0].Selector != ImportSelectorDirectory {
|
||||||
|
t.Fatalf("result source evidence = %#v", sources)
|
||||||
|
}
|
||||||
|
sources[0].EmptyImportSelections[0].Path = Keys("result mutation")
|
||||||
|
if got := loaded.Sources()[0].EmptyImportSelections[0].Path.String(); got != "/configkit/imports/0" {
|
||||||
|
t.Fatalf("result snapshot changed after mutation: %q", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
failurePath := writeFacadeImportFile(t, directory, "failure.yml", `configkit:
|
||||||
|
imports:
|
||||||
|
- dir: absent
|
||||||
|
- path: required-missing.yml
|
||||||
|
required: true
|
||||||
|
`)
|
||||||
|
_, err = Load(File(failurePath), Options[config]{Imports: true})
|
||||||
|
var loadError *LoadError
|
||||||
|
if !errors.As(err, &loadError) {
|
||||||
|
t.Fatalf("failed load error = %T %v", err, err)
|
||||||
|
}
|
||||||
|
errorSources := loadError.Sources()
|
||||||
|
if len(errorSources) != 1 || len(errorSources[0].EmptyImportSelections) != 1 || errorSources[0].EmptyImportSelections[0].Path.String() != "/configkit/imports/0" {
|
||||||
|
t.Fatalf("error source evidence = %#v", errorSources)
|
||||||
|
}
|
||||||
|
errorSources[0].EmptyImportSelections[0].Path = Keys("error mutation")
|
||||||
|
if got := loadError.Sources()[0].EmptyImportSelections[0].Path.String(); got != "/configkit/imports/0" {
|
||||||
|
t.Fatalf("error snapshot changed after mutation: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeFacadeImportFile(t *testing.T, root, name, contents string) string {
|
||||||
|
t.Helper()
|
||||||
|
filename := filepath.Join(root, name)
|
||||||
|
if err := os.MkdirAll(filepath.Dir(filename), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filename, []byte(contents), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return filename
|
||||||
|
}
|
||||||
|
|
||||||
|
func canonicalFacadeImportPath(t *testing.T, filename string) string {
|
||||||
|
t.Helper()
|
||||||
|
canonical, err := filepath.EvalSymlinks(filename)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return filepath.ToSlash(canonical)
|
||||||
|
}
|
||||||
143
facade_import_lifecycle_test.go
Normal file
143
facade_import_lifecycle_test.go
Normal file
@@ -0,0 +1,143 @@
|
|||||||
|
package configkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"io/fs"
|
||||||
|
"path"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDiscoveredItemHelpersPreserveNestedInheritedResourceEvidence(t *testing.T) {
|
||||||
|
const canary = "PRIVATE-CONSUMED-HEADER-CANARY"
|
||||||
|
filesystem := NewFilesystem("bundle", fstest.MapFS{
|
||||||
|
"bundle/config/main.yml": {Data: []byte(`configkit:
|
||||||
|
imports:
|
||||||
|
- dir: records
|
||||||
|
pattern: '*-item.yml'
|
||||||
|
destination: [services, records]
|
||||||
|
placement: sequence-item
|
||||||
|
services:
|
||||||
|
records:
|
||||||
|
- id: authored
|
||||||
|
version: authored
|
||||||
|
`)},
|
||||||
|
"bundle/config/records/a-item.yml": {Data: []byte(`configkit:
|
||||||
|
imports:
|
||||||
|
- path: helper.yml
|
||||||
|
version: ` + canary + `
|
||||||
|
entries:
|
||||||
|
parent:
|
||||||
|
resource: icon.txt
|
||||||
|
child:
|
||||||
|
extends: parent
|
||||||
|
`)},
|
||||||
|
"bundle/config/records/helper.yml": {Data: []byte("id: helper-supplied\n")},
|
||||||
|
"bundle/config/records/icon.txt": {Data: []byte("icon payload")},
|
||||||
|
})
|
||||||
|
result, err := Load(filesystem.File("bundle/config/main.yml"), Options[map[string]any]{
|
||||||
|
Imports: true,
|
||||||
|
Filesystem: &FilesystemContext{Root: "bundle", FS: &filesystem},
|
||||||
|
Definition: Definition[map[string]any]{
|
||||||
|
Version: &VersionRule{Path: Keys("version"), Check: func(Version, *Validation) error { return nil }},
|
||||||
|
SequenceIdentities: []SequenceIdentity{SequenceIdentityAt(Keys("services", "records"), "id")},
|
||||||
|
Sensitive: func(candidate Path) bool {
|
||||||
|
return candidate.String() == "/services/records/0/version"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
SourceVersionMode: SourceVersionModeIfPresent,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
var loadError *LoadError
|
||||||
|
if errors.As(err, &loadError) {
|
||||||
|
t.Fatalf("load discovered item: %v; diagnostics: %#v", err, loadError.Diagnostics())
|
||||||
|
}
|
||||||
|
t.Fatalf("load discovered item: %v", err)
|
||||||
|
}
|
||||||
|
services := result.Config["services"].(map[string]any)
|
||||||
|
records := services["records"].([]any)
|
||||||
|
if len(records) != 2 || records[0].(map[string]any)["id"] != "helper-supplied" || records[0].(map[string]any)["version"] != nil || records[1].(map[string]any)["version"] != "authored" {
|
||||||
|
t.Fatalf("nested records = %#v", records)
|
||||||
|
}
|
||||||
|
childPath := Keys("services", "records").Index(0).Key("entries").Key("child").Key("resource")
|
||||||
|
child := records[0].(map[string]any)["entries"].(map[string]any)["child"].(map[string]any)
|
||||||
|
if child["resource"] != "icon.txt" {
|
||||||
|
t.Fatalf("inherited relative resource = %#v", child)
|
||||||
|
}
|
||||||
|
|
||||||
|
sources := result.Sources()
|
||||||
|
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))
|
||||||
|
for _, source := range sources {
|
||||||
|
filesByID[source.ID] = source.Path
|
||||||
|
}
|
||||||
|
var itemSource SourceInfo
|
||||||
|
for _, source := range sources {
|
||||||
|
if source.Path == "bundle/config/records/a-item.yml" {
|
||||||
|
itemSource = source
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if itemSource.VersionEvidence == nil || !itemSource.VersionEvidence.Version.Present || !itemSource.VersionEvidence.Redacted || itemSource.VersionEvidence.Version.Text != "" {
|
||||||
|
t.Fatalf("consumed header inspection = %#v", itemSource.VersionEvidence)
|
||||||
|
}
|
||||||
|
idInfo, ok := result.Metadata().Lookup(Keys("services", "records").Index(0).Key("id"))
|
||||||
|
if !ok || len(idInfo.Origin.Locations) != 1 || filesByID[idInfo.Origin.Locations[0].SourceID] != "bundle/config/records/helper.yml" {
|
||||||
|
t.Fatalf("assigned helper ID origin = %#v", idInfo)
|
||||||
|
}
|
||||||
|
resourceInfo, ok := result.Metadata().Lookup(childPath)
|
||||||
|
if !ok || len(resourceInfo.Origin.Locations) != 1 || filesByID[resourceInfo.Origin.Locations[0].SourceID] != "bundle/config/records/a-item.yml" {
|
||||||
|
t.Fatalf("inherited resource metadata = %#v", resourceInfo)
|
||||||
|
}
|
||||||
|
explanation, ok := result.Explain(childPath)
|
||||||
|
if !ok || explanation.Effective.YAML != "icon.txt\n" || len(explanation.Origin.Locations) != 1 || explanation.Origin.Locations[0].SourceID != resourceInfo.Origin.Locations[0].SourceID {
|
||||||
|
t.Fatalf("inherited resource explanation = %#v", explanation)
|
||||||
|
}
|
||||||
|
context, ok := result.SourceContext(resourceInfo.Origin.Locations[0].SourceID)
|
||||||
|
if !ok || context.FS == nil || context.BaseDir != "config/records" {
|
||||||
|
t.Fatalf("inherited resource context = %#v, %v", context, ok)
|
||||||
|
}
|
||||||
|
contents, err := fs.ReadFile(context.FS, path.Join(context.BaseDir, child["resource"].(string)))
|
||||||
|
if err != nil || string(contents) != "icon payload" {
|
||||||
|
t.Fatalf("inherited resource read = %q, %v", contents, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err = Load(filesystem.File("bundle/config/main.yml"), Options[map[string]any]{
|
||||||
|
Imports: true,
|
||||||
|
Filesystem: &FilesystemContext{Root: "bundle", FS: &filesystem},
|
||||||
|
Definition: Definition[map[string]any]{
|
||||||
|
Version: &VersionRule{Path: Keys("version"), Check: func(Version, *Validation) error { return nil }},
|
||||||
|
SequenceIdentities: []SequenceIdentity{SequenceIdentityAt(Keys("services", "records"), "version")},
|
||||||
|
Sensitive: func(candidate Path) bool {
|
||||||
|
return candidate.String() == "/services/records/0/version"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
SourceVersionMode: SourceVersionModeIfPresent,
|
||||||
|
})
|
||||||
|
loadError, ok := err.(*LoadError)
|
||||||
|
if !ok || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != CodeComposeInvalid {
|
||||||
|
t.Fatalf("consumed header supplied an identity: %v", err)
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), canary) {
|
||||||
|
t.Fatalf("diagnostic exposed the consumed secret header: %v", err)
|
||||||
|
}
|
||||||
|
var failedItemSource SourceInfo
|
||||||
|
foundFailedItem := false
|
||||||
|
for _, source := range loadError.Sources() {
|
||||||
|
if source.Path == "bundle/config/records/a-item.yml" {
|
||||||
|
failedItemSource = source
|
||||||
|
foundFailedItem = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundFailedItem || failedItemSource.VersionEvidence == nil || failedItemSource.VersionEvidence.Version.Text != "" || !failedItemSource.VersionEvidence.Redacted {
|
||||||
|
t.Fatalf("failed-load source inspection exposed or omitted header evidence: %#v", failedItemSource)
|
||||||
|
}
|
||||||
|
}
|
||||||
119
facade_import_root_test.go
Normal file
119
facade_import_root_test.go
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
package configkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
configkit "go.maximumdirect.net/configkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFacadeDirectoryRootBelowContainedAbsoluteSymlink(t *testing.T) {
|
||||||
|
root, err := filepath.EvalSymlinks(t.TempDir())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
parts := filepath.Join(root, "real", "parts")
|
||||||
|
if err := os.MkdirAll(parts, 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
partPath := filepath.Join(parts, "part.yml")
|
||||||
|
if err := os.WriteFile(partPath, []byte("value: included\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink(filepath.Join(root, "real"), filepath.Join(root, "absolute")); err != nil {
|
||||||
|
t.Skipf("symlinks unavailable: %v", err)
|
||||||
|
}
|
||||||
|
for _, follow := range []bool{true, false} {
|
||||||
|
t.Run(fmt.Sprintf("follow=%t", follow), func(t *testing.T) {
|
||||||
|
mainPath := filepath.Join(root, "main.yml")
|
||||||
|
contents := fmt.Sprintf("configkit:\n imports:\n - dir: absolute/parts\n pattern: '*.yml'\n follow: %t\n", follow)
|
||||||
|
if err := os.WriteFile(mainPath, []byte(contents), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
result, err := configkit.Load[map[string]any](configkit.File(mainPath), configkit.Options[map[string]any]{Imports: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result.Config["value"] != "included" {
|
||||||
|
t.Fatalf("configuration = %#v", result.Config)
|
||||||
|
}
|
||||||
|
if sources := result.Sources(); len(sources) != 2 || sources[1].Path != partPath {
|
||||||
|
t.Fatalf("canonical imported source = %#v", sources)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeNativeDiscoveryWithNestedRoot(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
parts := filepath.Join(root, "nested/root/parts")
|
||||||
|
if err := os.MkdirAll(parts, 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(parts, "value.yml"), []byte("value: 1\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink("parts", filepath.Join(root, "nested/root/linked")); err != nil {
|
||||||
|
t.Skipf("symlinks unavailable: %v", err)
|
||||||
|
}
|
||||||
|
filesystem := configkit.NewFilesystem("nested", os.DirFS(root))
|
||||||
|
context := &configkit.FilesystemContext{FS: &filesystem, Root: "nested/root", BaseDir: "nested/root"}
|
||||||
|
for _, selector := range []string{
|
||||||
|
"dir: parts", "glob: 'parts/*.yml'", "dir: linked", "glob: 'lin*/*.yml'",
|
||||||
|
} {
|
||||||
|
t.Run(selector, func(t *testing.T) {
|
||||||
|
result, err := configkit.Load(configkit.Bytes("main", []byte("configkit:\n imports:\n - "+selector+"\n")), configkit.Options[map[string]any]{Imports: true, Filesystem: context})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if result.Config["value"] != int64(1) {
|
||||||
|
t.Fatalf("configuration = %#v", result.Config)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "nested/outside.yml"), []byte("value: outside\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink("../../outside.yml", filepath.Join(parts, "escape.yml")); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, selector := range []string{"dir: parts", "glob: 'parts/*.yml'"} {
|
||||||
|
_, err := configkit.Load(configkit.Bytes("main", []byte("configkit:\n imports:\n - "+selector+"\n")), configkit.Options[map[string]any]{Imports: true, Filesystem: context})
|
||||||
|
var loadError *configkit.LoadError
|
||||||
|
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != configkit.CodeImportOutsideRoot {
|
||||||
|
t.Fatalf("escaping %s: %v", selector, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeDiscoveryAllowsFiniteLinkReuse(t *testing.T) {
|
||||||
|
root := t.TempDir()
|
||||||
|
if err := os.Mkdir(filepath.Join(root, "parts"), 0o700); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(root, "parts/value.yml"), []byte("value: 1\n"), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.Symlink("..", filepath.Join(root, "parts/up")); err != nil {
|
||||||
|
t.Skipf("symlinks unavailable: %v", err)
|
||||||
|
}
|
||||||
|
filesystem := configkit.NewFilesystem("finite", os.DirFS(root))
|
||||||
|
for _, native := range []bool{false, true} {
|
||||||
|
for _, selector := range []string{"dir: parts/up/parts/up/parts\n pattern: '*.yml'", "glob: 'parts/up/parts/up/parts/*.yml'"} {
|
||||||
|
context := &configkit.FilesystemContext{Root: root, BaseDir: root}
|
||||||
|
if native {
|
||||||
|
context = &configkit.FilesystemContext{FS: &filesystem, Root: ".", BaseDir: "."}
|
||||||
|
}
|
||||||
|
result, err := configkit.Load(configkit.Bytes("main", []byte("configkit:\n imports:\n - "+selector+"\n")), configkit.Options[map[string]any]{Imports: true, Filesystem: context})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("native=%v selector=%s: %v", native, selector, err)
|
||||||
|
}
|
||||||
|
if result.Config["value"] != int64(1) {
|
||||||
|
t.Fatalf("configuration = %#v", result.Config)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
@@ -91,6 +91,214 @@ func TestFacadeInheritancePreservesPresenceOriginsAndHistory(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestFacadeIdentifiedInheritanceResolvesChainsAndNestedCollections(t *testing.T) {
|
||||||
|
const secretName = "CONFIGKIT_IDENTIFIED_INHERITANCE_SECRET"
|
||||||
|
const canary = "IDENTIFIED-INHERITANCE-SECRET-CANARY"
|
||||||
|
t.Setenv(secretName, canary)
|
||||||
|
type step struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
Value string `yaml:"value"`
|
||||||
|
}
|
||||||
|
type job struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
Name string `yaml:"name"`
|
||||||
|
Credential configkit.Secret `yaml:"credential"`
|
||||||
|
Resource string `yaml:"resource"`
|
||||||
|
Steps []step `yaml:"steps"`
|
||||||
|
Settings map[string]map[string]string `yaml:"settings"`
|
||||||
|
}
|
||||||
|
type configuration struct {
|
||||||
|
Jobs []job `yaml:"jobs"`
|
||||||
|
}
|
||||||
|
input := []byte(`jobs:
|
||||||
|
- id: child
|
||||||
|
extends: {id: parent}
|
||||||
|
name: child-name
|
||||||
|
- id: parent
|
||||||
|
extends: {id: base}
|
||||||
|
- id: base
|
||||||
|
name: base-name
|
||||||
|
credential: CONFIGKIT_IDENTIFIED_INHERITANCE_SECRET
|
||||||
|
resource: icon.txt
|
||||||
|
steps:
|
||||||
|
- id: base-step
|
||||||
|
value: base-step-value
|
||||||
|
- id: second-step
|
||||||
|
value: second-step-value
|
||||||
|
settings:
|
||||||
|
primary: {value: base-setting}
|
||||||
|
secondary: {extends: primary}
|
||||||
|
`)
|
||||||
|
filesystem := configkit.NewFilesystem("identified-bundle", fstest.MapFS{
|
||||||
|
"config/identified.yml": &fstest.MapFile{Data: input},
|
||||||
|
"config/icon.txt": &fstest.MapFile{Data: []byte("identified-parent-resource")},
|
||||||
|
})
|
||||||
|
registrations := []configkit.SequenceIdentity{
|
||||||
|
configkit.SequenceIdentityAt(configkit.Keys("jobs"), "id"),
|
||||||
|
}
|
||||||
|
result, err := configkit.Load(filesystem.File("config/identified.yml"), configkit.Options[configuration]{
|
||||||
|
Definition: configkit.Definition[configuration]{
|
||||||
|
SequenceIdentities: registrations,
|
||||||
|
Sensitive: func(path configkit.Path) bool {
|
||||||
|
return path.String() == "/jobs/0/credential" || path.String() == "/jobs/1/credential" || path.String() == "/jobs/2/credential"
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
if loadError, ok := err.(*configkit.LoadError); ok {
|
||||||
|
t.Fatalf("load identified inheritance: %v (%#v)", err, loadError.Diagnostics())
|
||||||
|
}
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(result.Config.Jobs) != 3 || result.Config.Jobs[0].ID != "child" || result.Config.Jobs[1].ID != "parent" || result.Config.Jobs[2].ID != "base" {
|
||||||
|
t.Fatalf("inherited job positions and identities = %#v", result.Config.Jobs)
|
||||||
|
}
|
||||||
|
child := result.Config.Jobs[0]
|
||||||
|
if child.Name != "child-name" || child.Credential.Value() != canary || child.Resource != "icon.txt" || len(child.Steps) != 2 {
|
||||||
|
t.Fatalf("inherited child = %#v", child)
|
||||||
|
}
|
||||||
|
if got := []string{child.Steps[0].ID, child.Steps[1].ID}; strings.Join(got, ",") != "base-step,second-step" {
|
||||||
|
t.Fatalf("inherited nested steps = %v", got)
|
||||||
|
}
|
||||||
|
if child.Steps[0].Value != "base-step-value" || child.Steps[1].Value != "second-step-value" || child.Settings["secondary"]["value"] != "base-setting" {
|
||||||
|
t.Fatalf("inherited nested collection and mapping = %#v", child)
|
||||||
|
}
|
||||||
|
credentialPath := configkit.Keys("jobs").Index(0).Key("credential")
|
||||||
|
credentialInfo, found := result.Metadata().Lookup(credentialPath)
|
||||||
|
if !found || len(credentialInfo.Origin.Inheritance) != 2 {
|
||||||
|
t.Fatalf("inherited secret provenance = %#v, %v", credentialInfo, found)
|
||||||
|
}
|
||||||
|
resourceInfo, found := result.Metadata().Lookup(configkit.Keys("jobs").Index(0).Key("resource"))
|
||||||
|
if !found || len(resourceInfo.Origin.Inheritance) != 2 || len(resourceInfo.Origin.Locations) != 1 {
|
||||||
|
t.Fatalf("inherited resource provenance = %#v, %v", resourceInfo, found)
|
||||||
|
}
|
||||||
|
context, found := result.SourceContext(resourceInfo.Origin.Locations[0].SourceID)
|
||||||
|
if !found || context.BaseDir != "config" || context.FS == nil {
|
||||||
|
t.Fatalf("inherited resource context = %#v, %v", context, found)
|
||||||
|
}
|
||||||
|
resource, readErr := fs.ReadFile(context.FS, context.BaseDir+"/"+child.Resource)
|
||||||
|
if readErr != nil || string(resource) != "identified-parent-resource" {
|
||||||
|
t.Fatalf("inherited resource = %q, %v", resource, readErr)
|
||||||
|
}
|
||||||
|
resourceHistory, found := result.Explain(configkit.Keys("jobs").Index(0).Key("resource"))
|
||||||
|
if !found || len(resourceHistory.History) == 0 {
|
||||||
|
t.Fatalf("inherited resource history = %#v, %v", resourceHistory, found)
|
||||||
|
}
|
||||||
|
credentialHistory, found := result.Explain(credentialPath)
|
||||||
|
if !found || !credentialHistory.Effective.Redacted {
|
||||||
|
t.Fatalf("inherited secret explanation = %#v, %v", credentialHistory, found)
|
||||||
|
}
|
||||||
|
identityInfo, found := result.Metadata().Lookup(configkit.Keys("jobs").Index(0).Key("id"))
|
||||||
|
if !found || !identityInfo.Explicit || len(identityInfo.Origin.Inheritance) != 0 ||
|
||||||
|
len(identityInfo.Origin.Locations) != 1 {
|
||||||
|
t.Fatalf("child identity origin and presence = %#v, %v", identityInfo, found)
|
||||||
|
}
|
||||||
|
if _, found := result.Metadata().Lookup(configkit.Keys("jobs").Index(0).Key("extends")); found {
|
||||||
|
t.Fatal("identified record relationship remained in effective metadata")
|
||||||
|
}
|
||||||
|
var rendered bytes.Buffer
|
||||||
|
if err := result.WriteEffective(&rendered); err != nil || strings.Contains(rendered.String(), canary) || strings.Contains(rendered.String(), "extends:") {
|
||||||
|
t.Fatalf("effective inherited configuration = %q, %v", rendered.String(), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeIdentifiedInheritanceFailureDoesNotRevealSecretCanary(t *testing.T) {
|
||||||
|
const secretName = "CONFIGKIT_IDENTIFIED_INHERITANCE_FAILURE_SECRET"
|
||||||
|
const canary = "IDENTIFIED-INHERITANCE-FAILURE-CANARY"
|
||||||
|
t.Setenv(secretName, canary)
|
||||||
|
type job struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
Credential configkit.Secret `yaml:"credential"`
|
||||||
|
}
|
||||||
|
type configuration struct {
|
||||||
|
Jobs []job `yaml:"jobs"`
|
||||||
|
}
|
||||||
|
input := []byte(`jobs:
|
||||||
|
- id: child
|
||||||
|
extends: {id: missing}
|
||||||
|
credential: CONFIGKIT_IDENTIFIED_INHERITANCE_FAILURE_SECRET
|
||||||
|
`)
|
||||||
|
_, err := configkit.Load(configkit.Bytes("identified-failure.yml", input), configkit.Options[configuration]{
|
||||||
|
Definition: configkit.Definition[configuration]{
|
||||||
|
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("jobs"), "id")},
|
||||||
|
Sensitive: func(path configkit.Path) bool { return path.String() == "/jobs/0/credential" },
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err == nil || strings.Contains(err.Error(), canary) {
|
||||||
|
t.Fatalf("identified inheritance failure = %v; secret canary must remain undisclosed", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeLayeredIdentifiedInheritanceUsesSurvivingRecords(t *testing.T) {
|
||||||
|
policy := func(mode string) string {
|
||||||
|
return "configkit:\n sequences:\n - destination: [jobs]\n key: id\n mode: " + mode + "\n"
|
||||||
|
}
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
lower string
|
||||||
|
higher string
|
||||||
|
wantCode configkit.Code
|
||||||
|
wantValue string
|
||||||
|
wantSuccess bool
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "upserted parent supplies its final payload",
|
||||||
|
lower: policy("upsert") + "jobs: [{id: parent, value: lower}]\n",
|
||||||
|
higher: policy("upsert") + "jobs: [{id: parent, value: higher}, {id: child, extends: {id: parent}}]\n",
|
||||||
|
wantValue: "higher", wantSuccess: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "replacement removes a lower parent before lookup",
|
||||||
|
lower: policy("upsert") + "jobs: [{id: parent, value: lower}]\n",
|
||||||
|
higher: policy("replace") + "jobs: [{id: child, extends: {id: parent}}]\n",
|
||||||
|
wantCode: configkit.CodeInheritanceMissing,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "upserted child without a directive replaces the invalid lower child",
|
||||||
|
lower: policy("upsert") + "jobs: [{id: child, extends: {id: missing}, value: lower}]\n",
|
||||||
|
higher: policy("upsert") + "jobs: [{id: child, value: higher}]\n",
|
||||||
|
wantValue: "higher", wantSuccess: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "self-reference cannot resolve through a lower predecessor",
|
||||||
|
lower: policy("upsert") + "jobs: [{id: parent, value: lower}]\n",
|
||||||
|
higher: policy("upsert") + "jobs: [{id: parent, extends: {id: parent}, value: higher}]\n",
|
||||||
|
wantCode: configkit.CodeInheritanceCycle,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
result, err := configkit.LoadLayered(configkit.Input{Source: configkit.Bytes("main.yml", []byte(test.higher))}, configkit.LayeredOptions[map[string]any]{
|
||||||
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.Bytes("lower.yml", []byte(test.lower))}}}},
|
||||||
|
})
|
||||||
|
if test.wantSuccess {
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
jobs, ok := result.Config["jobs"].([]any)
|
||||||
|
if !ok || len(jobs) == 0 {
|
||||||
|
t.Fatalf("assembled jobs = %#v", result.Config["jobs"])
|
||||||
|
}
|
||||||
|
child, ok := jobs[len(jobs)-1].(map[string]any)
|
||||||
|
if !ok || child["value"] != test.wantValue {
|
||||||
|
t.Fatalf("surviving record = %#v, want inherited value %q", jobs[len(jobs)-1], test.wantValue)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
loadError, ok := err.(*configkit.LoadError)
|
||||||
|
if !ok {
|
||||||
|
t.Fatalf("load error = %v, want code %s", err, test.wantCode)
|
||||||
|
}
|
||||||
|
for _, diagnostic := range loadError.Diagnostics() {
|
||||||
|
if diagnostic.Code == test.wantCode {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("load diagnostics = %#v, want code %s", loadError.Diagnostics(), test.wantCode)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFacadeInheritanceLayeredPrecedenceAndSourceContext(t *testing.T) {
|
func TestFacadeInheritanceLayeredPrecedenceAndSourceContext(t *testing.T) {
|
||||||
filesystem := configkit.NewFilesystem("bundle", fstest.MapFS{
|
filesystem := configkit.NewFilesystem("bundle", fstest.MapFS{
|
||||||
"config/main.yml": &fstest.MapFile{Data: []byte("entries: {child: {extends: parent}}\n")},
|
"config/main.yml": &fstest.MapFile{Data: []byte("entries: {child: {extends: parent}}\n")},
|
||||||
@@ -206,27 +414,24 @@ func TestFacadeInheritanceFailureStopsLaterCallbacks(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFacadeInheritanceResolvesAfterImportsAndCLIReferenceChange(t *testing.T) {
|
func TestFacadeCLIOverridesCannotChangeInheritanceRelationship(t *testing.T) {
|
||||||
filesystem := configkit.NewFilesystem("bundle", fstest.MapFS{
|
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("configkit: {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")},
|
||||||
})
|
})
|
||||||
result, err := configkit.Load(filesystem.File("main.yml"), configkit.Options[inheritedConfiguration]{
|
_, err := configkit.Load(filesystem.File("main.yml"), configkit.Options[inheritedConfiguration]{
|
||||||
Imports: true, Overrides: []configkit.Override{{Path: configkit.Keys("entries", "child", "extends"), Value: "second", Flag: "--extends"}},
|
Imports: true, Overrides: []configkit.Override{{Path: configkit.Keys("entries", "child", "extends"), Value: "second", Flag: "--extends"}},
|
||||||
})
|
})
|
||||||
if err != nil || result.Config.Entries["child"].Count != 2 {
|
loadError, ok := err.(*configkit.LoadError)
|
||||||
t.Fatalf("import and CLI reference = %#v, %v", result, err)
|
if !ok || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != configkit.CodeOverrideDirective {
|
||||||
}
|
t.Fatalf("CLI inheritance relationship change = %v", err)
|
||||||
history := result.Metadata().History(configkit.Keys("entries", "child", "extends"))
|
|
||||||
if len(history) < 3 || history[len(history)-1].Origin.Operation != configkit.ChangeInheritance {
|
|
||||||
t.Fatalf("superseded reference history = %#v", history)
|
|
||||||
}
|
}
|
||||||
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("configkit: {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})
|
||||||
loadError, ok := err.(*configkit.LoadError)
|
loadError, ok = err.(*configkit.LoadError)
|
||||||
if !ok || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != configkit.CodeComposeConflict {
|
if !ok || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != configkit.CodeComposeConflict {
|
||||||
t.Fatalf("strict conflict before expansion = %v", err)
|
t.Fatalf("strict conflict before expansion = %v", err)
|
||||||
}
|
}
|
||||||
@@ -263,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")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
69
facade_item_registration_scope_test.go
Normal file
69
facade_item_registration_scope_test.go
Normal file
@@ -0,0 +1,69 @@
|
|||||||
|
package configkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
|
||||||
|
configkit "go.maximumdirect.net/configkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFacadeItemScopesDoNotReinterpretGlobalRegistrations(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
for _, layered := range []bool{false, true} {
|
||||||
|
for _, dynamic := range []bool{false, true} {
|
||||||
|
for _, missingOuterID := range []bool{false, true} {
|
||||||
|
t.Run(fmt.Sprintf("layered=%t/dynamic=%t/missing-outer-id=%t", layered, dynamic, missingOuterID), func(t *testing.T) {
|
||||||
|
payload := "prompts: [{text: helper}]\n"
|
||||||
|
unused := configkit.SequenceIdentityAt(configkit.Keys("prompts"), "id")
|
||||||
|
if dynamic {
|
||||||
|
payload = "catalog: {local: {prompts: [{text: helper}]}}\n"
|
||||||
|
unused = configkit.SequenceIdentityMatching(configkit.SequenceSelector{
|
||||||
|
configkit.SequenceLiteralKey("catalog"), configkit.SequenceAnyMappingKey(), configkit.SequenceLiteralKey("prompts"),
|
||||||
|
}, "id")
|
||||||
|
}
|
||||||
|
item := "configkit: {imports: [{path: helper.yml}, {path: child.yml, destination: [children], placement: sequence-item}]}\n"
|
||||||
|
if !missingOuterID {
|
||||||
|
item += "id: outer\n"
|
||||||
|
}
|
||||||
|
files := configkit.NewFilesystem("items", fstest.MapFS{
|
||||||
|
"main.yml": {Data: []byte("configkit: {imports: [{path: item.yml, destination: [items], placement: sequence-item}]}\n")},
|
||||||
|
"item.yml": {Data: []byte(item)}, "helper.yml": {Data: []byte(payload)},
|
||||||
|
"child.yml": {Data: []byte("id: child\nconfigkit: {imports: [{path: child-helper.yml}]}\n")},
|
||||||
|
"child-helper.yml": {Data: []byte(payload)},
|
||||||
|
})
|
||||||
|
definition := configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{
|
||||||
|
unused, configkit.SequenceIdentityAt(configkit.Keys("items"), "id"),
|
||||||
|
}}
|
||||||
|
var result *configkit.Result[map[string]any]
|
||||||
|
var err error
|
||||||
|
if layered {
|
||||||
|
result, err = configkit.LoadLayered(configkit.Input{Source: files.File("main.yml"), Imports: true}, configkit.LayeredOptions[map[string]any]{Definition: definition})
|
||||||
|
} else {
|
||||||
|
result, err = configkit.Load(files.File("main.yml"), configkit.Options[map[string]any]{Imports: true, Definition: definition})
|
||||||
|
}
|
||||||
|
if missingOuterID {
|
||||||
|
failure := requireFacadeLoadError(t, err)
|
||||||
|
if result != nil || len(failure.Diagnostics()) != 1 || failure.Diagnostics()[0].Code != configkit.CodeComposeInvalid || failure.Diagnostics()[0].Path.String() != "/items/0/id" {
|
||||||
|
t.Fatalf("outer constraint diagnostics = %#v", failure.Diagnostics())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for _, record := range []configkit.Path{configkit.Keys("items").Index(0), configkit.Keys("items").Index(0).Key("children").Index(0)} {
|
||||||
|
path := record
|
||||||
|
if dynamic {
|
||||||
|
path = path.Key("catalog").Key("local")
|
||||||
|
}
|
||||||
|
info, found := result.Metadata().Lookup(path.Key("prompts").Index(0).Key("text"))
|
||||||
|
if !found || !info.Explicit {
|
||||||
|
t.Fatalf("nested helper payload lost at %s", path)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
64
facade_item_version_test.go
Normal file
64
facade_item_version_test.go
Normal file
@@ -0,0 +1,64 @@
|
|||||||
|
package configkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
|
||||||
|
configkit "go.maximumdirect.net/configkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFacadeItemDomainVersionsKeepEffectiveScope(t *testing.T) {
|
||||||
|
for _, test := range []struct {
|
||||||
|
name, item, child, helper string
|
||||||
|
underlay, mainItem bool
|
||||||
|
}{
|
||||||
|
{name: "item", item: "id: item\nversion: domain\n"},
|
||||||
|
{name: "helper", item: "id: item\nconfigkit: {imports: [{path: helper.yml}]}\n", helper: "version: domain\n"},
|
||||||
|
{name: "nested", item: "id: parent\nconfigkit: {imports: [{path: child.yml, destination: [children], placement: sequence-item}]}\n", child: "id: child\nversion: domain\n"},
|
||||||
|
{name: "underlay", item: "id: item\nversion: domain\n", underlay: true},
|
||||||
|
{name: "main-item", item: "id: parent\nconfigkit: {imports: [{path: child.yml, destination: [children], placement: sequence-item}]}\n", child: "id: child\nversion: domain\n", mainItem: true},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
files := fstest.MapFS{
|
||||||
|
"main.yml": {Data: []byte("version: 1\nconfigkit: {imports: [{path: item.yml, destination: [items], placement: sequence-item}]}\n")},
|
||||||
|
"item.yml": {Data: []byte(test.item)},
|
||||||
|
"child.yml": {Data: []byte(test.child)},
|
||||||
|
"helper.yml": {Data: []byte(test.helper)},
|
||||||
|
"underlay.yml": {Data: []byte("configkit: {imports: [{path: item.yml, destination: [items], placement: sequence-item}]}\n")},
|
||||||
|
}
|
||||||
|
filesystem := configkit.NewFilesystem("items", files)
|
||||||
|
definition := configkit.Definition[map[string]any]{Version: &configkit.VersionRule{
|
||||||
|
Path: configkit.Keys("version"), Check: func(configkit.Version, *configkit.Validation) error { return nil },
|
||||||
|
}}
|
||||||
|
var result *configkit.Result[map[string]any]
|
||||||
|
var err error
|
||||||
|
switch {
|
||||||
|
case test.underlay:
|
||||||
|
result, err = configkit.LoadLayered(configkit.Input{Source: configkit.Mapping("main", map[string]any{"version": "1"})}, configkit.LayeredOptions[map[string]any]{
|
||||||
|
Definition: definition, Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: filesystem.File("underlay.yml"), Imports: true}}}},
|
||||||
|
})
|
||||||
|
case test.mainItem:
|
||||||
|
result, err = configkit.Load(filesystem.File("item.yml"), configkit.Options[map[string]any]{
|
||||||
|
Imports: true, Destination: configkit.Keys("items"), PlacementMode: configkit.PlacementModeSequenceItem, Definition: definition,
|
||||||
|
})
|
||||||
|
default:
|
||||||
|
result, err = configkit.Load(filesystem.File("main.yml"), configkit.Options[map[string]any]{Imports: true, Definition: definition})
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
item := result.Config["items"].([]any)[0].(map[string]any)
|
||||||
|
fieldPath := configkit.Keys("items").Index(0).Key("version")
|
||||||
|
if test.child != "" {
|
||||||
|
item = item["children"].([]any)[0].(map[string]any)
|
||||||
|
fieldPath = configkit.Keys("items").Index(0).Key("children").Index(0).Key("version")
|
||||||
|
}
|
||||||
|
if item["version"] != "domain" {
|
||||||
|
t.Fatalf("domain version = %#v", item)
|
||||||
|
}
|
||||||
|
if info, ok := result.Metadata().Lookup(fieldPath); !ok || !info.Explicit {
|
||||||
|
t.Fatalf("version metadata = %#v, %v", info, ok)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 {
|
||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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"))
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
43
facade_resolution_recording_test.go
Normal file
43
facade_resolution_recording_test.go
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
package configkit
|
||||||
|
|
||||||
|
import "testing"
|
||||||
|
|
||||||
|
func TestFacadeResolutionRecordingIntents(t *testing.T) {
|
||||||
|
type configuration struct {
|
||||||
|
Derived string `yaml:"derived"`
|
||||||
|
Normalized string `yaml:"normalized"`
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := Load(Bytes("recording.yml", []byte("derived: explicit\nnormalized: before\n")), Options[configuration]{
|
||||||
|
Definition: Definition[configuration]{Resolve: func(value *configuration, resolution *Resolution) error {
|
||||||
|
value.Derived = "changed explicit input"
|
||||||
|
resolution.RecordDerivedDefault(Keys("derived"), "derive explicit input")
|
||||||
|
value.Normalized = "after"
|
||||||
|
resolution.RecordNormalization(Keys("normalized"), "normalize configured input")
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, test := range []struct {
|
||||||
|
path Path
|
||||||
|
kind ChangeKind
|
||||||
|
}{
|
||||||
|
{path: Keys("derived"), kind: ChangeDerivedDefault},
|
||||||
|
{path: Keys("normalized"), kind: ChangeNormalization},
|
||||||
|
} {
|
||||||
|
info, ok := result.Metadata().Lookup(test.path)
|
||||||
|
if !ok || info.Origin.Operation != test.kind {
|
||||||
|
t.Errorf("metadata %s = %#v, %v; want operation %q", test.path, info, ok, test.kind)
|
||||||
|
}
|
||||||
|
history := result.Metadata().History(test.path)
|
||||||
|
if len(history) == 0 || history[len(history)-1].Origin.Operation != test.kind {
|
||||||
|
t.Errorf("history %s = %#v; want final operation %q", test.path, history, test.kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !result.Metadata().IsExplicit(Keys("derived")) {
|
||||||
|
t.Fatal("recording a derived default suppressed explicit-input provenance")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -12,7 +12,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"testing/fstest"
|
"testing/fstest"
|
||||||
|
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "go.maximumdirect.net/configkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
type observedResourceFS struct {
|
type observedResourceFS struct {
|
||||||
|
|||||||
240
facade_sequence_identity_test.go
Normal file
240
facade_sequence_identity_test.go
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
package configkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
configkit "go.maximumdirect.net/configkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFacadeSequenceIdentitySnapshotsSelectorsAndFields(t *testing.T) {
|
||||||
|
fields := []string{"namespace", "id", "id"}
|
||||||
|
selector := configkit.SequenceSelector{
|
||||||
|
configkit.SequenceLiteralKey("collections"),
|
||||||
|
configkit.SequenceAnyMappingKey(),
|
||||||
|
configkit.SequenceLiteralKey("prompts"),
|
||||||
|
}
|
||||||
|
identity := configkit.SequenceIdentityMatching(selector, fields...)
|
||||||
|
selector[1] = configkit.SequenceLiteralKey("other")
|
||||||
|
fields[0] = "changed"
|
||||||
|
definition := configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{identity}}
|
||||||
|
|
||||||
|
underlay := configkit.Mapping("underlay", map[string]any{"collections": map[string]any{
|
||||||
|
"team": map[string]any{"prompts": []any{map[string]any{"namespace": "team", "id": "kept"}}},
|
||||||
|
}})
|
||||||
|
main := configkit.Mapping("main", map[string]any{"collections": map[string]any{
|
||||||
|
"team": map[string]any{"prompts": []any{map[string]any{"namespace": "team", "id": "added"}}},
|
||||||
|
}})
|
||||||
|
loaded, err := configkit.LoadLayered(configkit.Input{Source: main}, configkit.LayeredOptions[map[string]any]{
|
||||||
|
Definition: definition,
|
||||||
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: underlay}}}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load after mutating constructor inputs: %v", err)
|
||||||
|
}
|
||||||
|
prompts := loaded.Config["collections"].(map[string]any)["team"].(map[string]any)["prompts"].([]any)
|
||||||
|
if len(prompts) != 2 {
|
||||||
|
t.Fatalf("registered compound identity did not append records: %#v", prompts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeSequenceIdentityUsesExactAndDynamicSelectors(t *testing.T) {
|
||||||
|
dynamic := configkit.SequenceIdentityMatching(configkit.SequenceSelector{
|
||||||
|
configkit.SequenceLiteralKey("collections"),
|
||||||
|
configkit.SequenceAnyMappingKey(),
|
||||||
|
configkit.SequenceLiteralKey("prompts"),
|
||||||
|
}, "id")
|
||||||
|
definition := configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{
|
||||||
|
configkit.SequenceIdentityAt(configkit.Keys("prompts"), "namespace", "id"),
|
||||||
|
dynamic,
|
||||||
|
}}
|
||||||
|
underlay := configkit.Mapping("underlay", map[string]any{
|
||||||
|
"prompts": []any{map[string]any{"namespace": "alpha", "id": "same"}},
|
||||||
|
"collections": map[string]any{
|
||||||
|
"team": map[string]any{"prompts": []any{map[string]any{"id": "team-one"}}},
|
||||||
|
"cloud": map[string]any{"prompts": []any{map[string]any{"id": "cloud-one"}}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
main := configkit.Mapping("main", map[string]any{
|
||||||
|
"prompts": []any{map[string]any{"namespace": "beta", "id": "same"}},
|
||||||
|
"collections": map[string]any{
|
||||||
|
"team": map[string]any{"prompts": []any{map[string]any{"id": "team-two"}}},
|
||||||
|
"cloud": map[string]any{"prompts": []any{map[string]any{"id": "cloud-two"}}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
loaded, err := configkit.LoadLayered(configkit.Input{Source: main}, configkit.LayeredOptions[map[string]any]{
|
||||||
|
Definition: definition,
|
||||||
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: underlay}}}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load mixed exact and dynamic registrations: %v", err)
|
||||||
|
}
|
||||||
|
rootRecords := loaded.Config["prompts"].([]any)
|
||||||
|
if len(rootRecords) != 2 {
|
||||||
|
t.Fatalf("compound root identities = %#v", rootRecords)
|
||||||
|
}
|
||||||
|
collections := loaded.Config["collections"].(map[string]any)
|
||||||
|
for _, name := range []string{"team", "cloud"} {
|
||||||
|
prompts := collections[name].(map[string]any)["prompts"].([]any)
|
||||||
|
if len(prompts) != 2 {
|
||||||
|
t.Errorf("dynamic prompts for %q = %#v", name, prompts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeSequenceIdentityLiteralStarIsNotWildcard(t *testing.T) {
|
||||||
|
definition := configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{
|
||||||
|
configkit.SequenceIdentityMatching(configkit.SequenceSelector{
|
||||||
|
configkit.SequenceLiteralKey("collections"),
|
||||||
|
configkit.SequenceLiteralKey("*"),
|
||||||
|
configkit.SequenceLiteralKey("prompts"),
|
||||||
|
}, "id"),
|
||||||
|
}}
|
||||||
|
underlay := configkit.Mapping("underlay", map[string]any{"collections": map[string]any{
|
||||||
|
"*": map[string]any{"prompts": []any{map[string]any{"id": "one"}}},
|
||||||
|
"team": map[string]any{"prompts": []any{map[string]any{"id": "team-one"}}},
|
||||||
|
}})
|
||||||
|
main := configkit.Mapping("main", map[string]any{"collections": map[string]any{
|
||||||
|
"*": map[string]any{"prompts": []any{map[string]any{"id": "two"}}},
|
||||||
|
"team": map[string]any{"prompts": []any{map[string]any{"id": "team-two"}}},
|
||||||
|
}})
|
||||||
|
_, err := configkit.LoadLayered(configkit.Input{Source: configkit.Mapping("main", map[string]any{})}, configkit.LayeredOptions[map[string]any]{
|
||||||
|
Definition: definition,
|
||||||
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: underlay}, {Source: main}}}},
|
||||||
|
})
|
||||||
|
loadErr := requireFacadeLoadError(t, err)
|
||||||
|
if len(loadErr.Diagnostics()) == 0 || loadErr.Diagnostics()[0].Path.String() != "/collections/team/prompts" {
|
||||||
|
t.Fatalf("literal star registration also matched another key: %#v", loadErr.Diagnostics())
|
||||||
|
}
|
||||||
|
|
||||||
|
wildcard := configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{
|
||||||
|
configkit.SequenceIdentityMatching(configkit.SequenceSelector{
|
||||||
|
configkit.SequenceLiteralKey("collections"),
|
||||||
|
configkit.SequenceAnyMappingKey(),
|
||||||
|
configkit.SequenceLiteralKey("prompts"),
|
||||||
|
}, "id"),
|
||||||
|
}}
|
||||||
|
loaded, err := configkit.LoadLayered(configkit.Input{Source: configkit.Mapping("main", map[string]any{})}, configkit.LayeredOptions[map[string]any]{
|
||||||
|
Definition: wildcard,
|
||||||
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: underlay}, {Source: main}}}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("wildcard registration did not match mapping keys: %v", err)
|
||||||
|
}
|
||||||
|
for name, raw := range loaded.Config["collections"].(map[string]any) {
|
||||||
|
if got := len(raw.(map[string]any)["prompts"].([]any)); got != 2 {
|
||||||
|
t.Errorf("wildcard result for %q has %d records", name, got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeSequenceIdentityRejectsInvalidDefinitionsBeforeReadingSource(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
identity configkit.SequenceIdentity
|
||||||
|
wantPath string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "index path",
|
||||||
|
identity: configkit.SequenceIdentityAt(configkit.Keys("records").Index(0), "id"),
|
||||||
|
wantPath: "/records/0",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "empty fields",
|
||||||
|
identity: configkit.SequenceIdentityAt(configkit.Keys("records")),
|
||||||
|
wantPath: "/records",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, test := range tests {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
reader := strings.NewReader("records: []\n")
|
||||||
|
_, err := configkit.Load(configkit.Reader("input.yml", reader), configkit.Options[map[string]any]{
|
||||||
|
Definition: configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{test.identity}},
|
||||||
|
})
|
||||||
|
loadErr := requireFacadeLoadError(t, err)
|
||||||
|
if len(loadErr.Diagnostics()) != 1 || loadErr.Diagnostics()[0].Code != configkit.CodeOptionsInvalid || loadErr.Diagnostics()[0].Path.String() != test.wantPath {
|
||||||
|
t.Fatalf("definition diagnostics = %#v", loadErr.Diagnostics())
|
||||||
|
}
|
||||||
|
if reader.Len() == 0 {
|
||||||
|
t.Fatal("source was read before registration validation")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeSequenceIdentityOverlapFailsBeforeSourceAccess(t *testing.T) {
|
||||||
|
selector := configkit.SequenceSelector{configkit.SequenceLiteralKey("collections"), configkit.SequenceAnyMappingKey()}
|
||||||
|
definition := configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{
|
||||||
|
configkit.SequenceIdentityMatching(selector, "id"),
|
||||||
|
configkit.SequenceIdentityMatching(configkit.SequenceSelector{
|
||||||
|
configkit.SequenceLiteralKey("collections"), configkit.SequenceLiteralKey("team"),
|
||||||
|
}, "slug"),
|
||||||
|
}}
|
||||||
|
reader := strings.NewReader("collections: {}\n")
|
||||||
|
_, err := configkit.Load(configkit.Reader("input.yml", reader), configkit.Options[map[string]any]{Definition: definition})
|
||||||
|
loadErr := requireFacadeLoadError(t, err)
|
||||||
|
if len(loadErr.Diagnostics()) == 0 || loadErr.Diagnostics()[0].Code != configkit.CodeOptionsInvalid {
|
||||||
|
t.Fatalf("overlap diagnostics = %#v", loadErr.Diagnostics())
|
||||||
|
}
|
||||||
|
if reader.Len() == 0 {
|
||||||
|
t.Fatal("source was read before overlapping registrations were rejected")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeSequenceIdentityConstraintsApplyAtInheritedDestinations(t *testing.T) {
|
||||||
|
definition := configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{
|
||||||
|
configkit.SequenceIdentityMatching(configkit.SequenceSelector{
|
||||||
|
configkit.SequenceLiteralKey("entries"),
|
||||||
|
configkit.SequenceLiteralKey("child"),
|
||||||
|
configkit.SequenceLiteralKey("collections"),
|
||||||
|
configkit.SequenceAnyMappingKey(),
|
||||||
|
configkit.SequenceLiteralKey("prompts"),
|
||||||
|
}, "id"),
|
||||||
|
}}
|
||||||
|
loaded, err := configkit.Load(configkit.Bytes("main", []byte(`jobs:
|
||||||
|
entries:
|
||||||
|
parent:
|
||||||
|
collections:
|
||||||
|
team:
|
||||||
|
prompts: [{id: inherited, text: base}]
|
||||||
|
child:
|
||||||
|
extends: parent
|
||||||
|
collections:
|
||||||
|
team:
|
||||||
|
prompts: [{id: added, text: child}]
|
||||||
|
`)), configkit.Options[map[string]any]{Definition: definition})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load with dynamic inherited destination: %v", err)
|
||||||
|
}
|
||||||
|
child := loaded.Config["entries"].(map[string]any)["child"].(map[string]any)
|
||||||
|
prompts := child["collections"].(map[string]any)["team"].(map[string]any)["prompts"].([]any)
|
||||||
|
if len(prompts) != 2 {
|
||||||
|
t.Fatalf("inherited dynamic collection = %#v, want parent and child records", prompts)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestFacadeSequenceIdentityLoadsUseIndependentScopes(t *testing.T) {
|
||||||
|
definition := configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{
|
||||||
|
configkit.SequenceIdentityMatching(configkit.SequenceSelector{
|
||||||
|
configkit.SequenceLiteralKey("collections"), configkit.SequenceAnyMappingKey(),
|
||||||
|
}, "id"),
|
||||||
|
}}
|
||||||
|
for _, source := range []string{"first", "second"} {
|
||||||
|
loaded, err := configkit.Load(configkit.Mapping(source, map[string]any{"collections": map[string]any{
|
||||||
|
source: []any{map[string]any{"id": "reused"}},
|
||||||
|
}}), configkit.Options[map[string]any]{Definition: definition})
|
||||||
|
if err != nil || len(loaded.Config["collections"].(map[string]any)[source].([]any)) != 1 {
|
||||||
|
t.Fatalf("independent load %q = %#v, %v", source, loaded, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func requireFacadeLoadError(t *testing.T, err error) *configkit.LoadError {
|
||||||
|
t.Helper()
|
||||||
|
var loadErr *configkit.LoadError
|
||||||
|
if !errors.As(err, &loadErr) {
|
||||||
|
t.Fatalf("error = %v, want *LoadError", err)
|
||||||
|
}
|
||||||
|
return loadErr
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"testing/fstest"
|
"testing/fstest"
|
||||||
|
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "go.maximumdirect.net/configkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestFacadeNoSourcePreservesAbsenceWithSequencePolicy(t *testing.T) {
|
func TestFacadeNoSourcePreservesAbsenceWithSequencePolicy(t *testing.T) {
|
||||||
@@ -21,7 +21,7 @@ func TestFacadeNoSourcePreservesAbsenceWithSequencePolicy(t *testing.T) {
|
|||||||
definition.Defaults = func() map[string]any { return map[string]any{"fallback": true} }
|
definition.Defaults = func() map[string]any { return map[string]any{"fallback": true} }
|
||||||
}
|
}
|
||||||
if registered {
|
if registered {
|
||||||
definition.SequenceCompositions = []configkit.SequenceComposition{{Path: configkit.Keys("records"), IdentityField: "id"}}
|
definition.SequenceIdentities = []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("records"), "id")}
|
||||||
}
|
}
|
||||||
ordinary, err := configkit.Load(configkit.NoSource(), configkit.Options[map[string]any]{Definition: definition})
|
ordinary, err := configkit.Load(configkit.NoSource(), configkit.Options[map[string]any]{Definition: definition})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -51,7 +51,7 @@ func TestFacadeNoSourcePreservesAbsenceWithSequencePolicy(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFacadeItemHelperHistoryPreservesTraversalOrder(t *testing.T) {
|
func TestFacadeItemHelperHistoryPreservesScheduledPlacement(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
filesystem := configkit.NewFilesystem("history", fstest.MapFS{
|
filesystem := configkit.NewFilesystem("history", fstest.MapFS{
|
||||||
"main.yml": {Data: []byte("records: [{id: authored}]\nconfigkit: {imports: [{path: one.yml, destination: [records], placement: sequence-item}, {path: two.yml, destination: [records], placement: sequence-item}]}\n")},
|
"main.yml": {Data: []byte("records: [{id: authored}]\nconfigkit: {imports: [{path: one.yml, destination: [records], placement: sequence-item}, {path: two.yml, destination: [records], placement: sequence-item}]}\n")},
|
||||||
@@ -63,24 +63,22 @@ func TestFacadeItemHelperHistoryPreservesTraversalOrder(t *testing.T) {
|
|||||||
"child-helper.yml": {Data: []byte("text: nested helper\n")},
|
"child-helper.yml": {Data: []byte("text: nested helper\n")},
|
||||||
})
|
})
|
||||||
result, err := configkit.Load(filesystem.File("main.yml"), configkit.Options[map[string]any]{
|
result, err := configkit.Load(filesystem.File("main.yml"), configkit.Options[map[string]any]{
|
||||||
Imports: true, Definition: configkit.Definition[map[string]any]{SequenceCompositions: []configkit.SequenceComposition{{Path: configkit.Keys("records"), IdentityField: "id"}}},
|
Imports: true, Definition: configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("records"), "id")}},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
sourceOrder := make(map[string]int)
|
|
||||||
sourceFiles := make(map[string]string)
|
sourceFiles := make(map[string]string)
|
||||||
for index, source := range result.Sources() {
|
for _, source := range result.Sources() {
|
||||||
sourceOrder[source.ID] = index
|
|
||||||
sourceFiles[source.ID] = source.Path
|
sourceFiles[source.ID] = source.Path
|
||||||
}
|
}
|
||||||
for _, test := range []struct {
|
for _, test := range []struct {
|
||||||
path configkit.Path
|
path configkit.Path
|
||||||
file, text string
|
file, text string
|
||||||
}{
|
}{
|
||||||
{configkit.Keys("records").Index(1).Key("text"), "helper-one.yml", "first helper\n"},
|
{configkit.Keys("records").Index(0).Key("text"), "helper-one.yml", "first helper\n"},
|
||||||
{configkit.Keys("records").Index(2).Key("text"), "helper-two.yml", "second helper\n"},
|
{configkit.Keys("records").Index(1).Key("text"), "helper-two.yml", "second helper\n"},
|
||||||
{configkit.Keys("records").Index(1).Key("children").Index(0).Key("text"), "child-helper.yml", "nested helper\n"},
|
{configkit.Keys("records").Index(0).Key("children").Index(0).Key("text"), "child-helper.yml", "nested helper\n"},
|
||||||
} {
|
} {
|
||||||
explanation, ok := result.Explain(test.path)
|
explanation, ok := result.Explain(test.path)
|
||||||
if !ok || explanation.Effective.YAML != test.text || len(explanation.History) != 1 {
|
if !ok || explanation.Effective.YAML != test.text || len(explanation.History) != 1 {
|
||||||
@@ -95,20 +93,6 @@ func TestFacadeItemHelperHistoryPreservesTraversalOrder(t *testing.T) {
|
|||||||
t.Fatal("metadata and explanation disagree")
|
t.Fatal("metadata and explanation disagree")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
explanation, ok := result.Explain(configkit.Keys())
|
|
||||||
if !ok {
|
|
||||||
t.Fatal("root explanation missing")
|
|
||||||
}
|
|
||||||
previous := -1
|
|
||||||
for _, change := range explanation.History {
|
|
||||||
for _, location := range change.Origin.Locations {
|
|
||||||
current := sourceOrder[location.SourceID]
|
|
||||||
if current < previous {
|
|
||||||
t.Fatalf("source %s replayed out of traversal order", location.SourceID)
|
|
||||||
}
|
|
||||||
previous = current
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFacadeRegisteredReplacementExplainsRemovedDescendants(t *testing.T) {
|
func TestFacadeRegisteredReplacementExplainsRemovedDescendants(t *testing.T) {
|
||||||
@@ -123,8 +107,14 @@ func TestFacadeRegisteredReplacementExplainsRemovedDescendants(t *testing.T) {
|
|||||||
{"removed field", []any{map[string]any{"id": "main"}}, configkit.Keys("records").Index(0).Key("text")},
|
{"removed field", []any{map[string]any{"id": "main"}}, configkit.Keys("records").Index(0).Key("text")},
|
||||||
} {
|
} {
|
||||||
t.Run(test.name, func(t *testing.T) {
|
t.Run(test.name, func(t *testing.T) {
|
||||||
result, err := configkit.LoadLayered[map[string]any](configkit.Input{Source: configkit.Mapping("main", map[string]any{"records": test.upper})}, configkit.LayeredOptions[map[string]any]{
|
mainYAML := "configkit:\n sequences:\n - destination: [records]\n key: id\n mode: replace\n"
|
||||||
Definition: configkit.Definition[map[string]any]{SequenceCompositions: []configkit.SequenceComposition{{Path: configkit.Keys("records"), IdentityField: "id"}}},
|
if len(test.upper) == 0 {
|
||||||
|
mainYAML += "records: []\n"
|
||||||
|
} else {
|
||||||
|
mainYAML += "records: [{id: main}]\n"
|
||||||
|
}
|
||||||
|
result, err := configkit.LoadLayered[map[string]any](configkit.Input{Source: configkit.Bytes("main", []byte(mainYAML))}, configkit.LayeredOptions[map[string]any]{
|
||||||
|
Definition: configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("records"), "id")}},
|
||||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.Mapping("lower", map[string]any{"records": []any{map[string]any{"id": "one", "text": "old"}, map[string]any{"id": "two"}}})}}}},
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.Mapping("lower", map[string]any{"records": []any{map[string]any{"id": "one", "text": "old"}, map[string]any{"id": "two"}}})}}}},
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@@ -135,7 +125,7 @@ func TestFacadeRegisteredReplacementExplainsRemovedDescendants(t *testing.T) {
|
|||||||
t.Fatalf("removal not explained: %#v", explanation)
|
t.Fatalf("removal not explained: %#v", explanation)
|
||||||
}
|
}
|
||||||
last := explanation.History[len(explanation.History)-1]
|
last := explanation.History[len(explanation.History)-1]
|
||||||
if !last.Before.Present || last.After.Present || last.Origin.Layer != configkit.LayerBase || len(last.Origin.Locations) != 1 || last.Origin.Locations[0].SourceID != result.Sources()[0].ID {
|
if !last.Before.Present || last.After.Present || last.Origin.Layer != configkit.LayerMain || len(last.Origin.Locations) != 1 || last.Origin.Locations[0].SourceID != result.Sources()[0].ID {
|
||||||
t.Fatalf("wrong removal transition/origin: %#v", last)
|
t.Fatalf("wrong removal transition/origin: %#v", last)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
@@ -153,14 +143,14 @@ func TestFacadeItemConflictDiagnosticsFollowEnclosingIndexes(t *testing.T) {
|
|||||||
"bad.yml": {Data: []byte("id: bad\nconfigkit: {imports: [{path: helper.yml}]}\n")},
|
"bad.yml": {Data: []byte("id: bad\nconfigkit: {imports: [{path: helper.yml}]}\n")},
|
||||||
"helper.yml": {Data: []byte("id: duplicate-field\n")},
|
"helper.yml": {Data: []byte("id: duplicate-field\n")},
|
||||||
})
|
})
|
||||||
definition := configkit.Definition[map[string]any]{SequenceCompositions: []configkit.SequenceComposition{{Path: configkit.Keys("records"), IdentityField: "id"}}}
|
definition := configkit.Definition[map[string]any]{SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("records"), "id")}}
|
||||||
for _, test := range []struct {
|
for _, test := range []struct {
|
||||||
name, file, path string
|
name, file, path string
|
||||||
grouped, itemMain bool
|
grouped, itemMain bool
|
||||||
}{
|
}{
|
||||||
{"preceding authored and imported items", "main.yml", "/records/2/id", false, false},
|
{"preceding authored and imported items", "main.yml", "/records/0/id", false, false},
|
||||||
{"nested item", "nested.yml", "/records/1/children/1/id", false, false},
|
{"nested item", "nested.yml", "/records/0/children/0/id", false, false},
|
||||||
{"preceding grouped input", "group.yml", "/records/2/id", true, false},
|
{"preceding grouped input", "group.yml", "/records/1/id", true, false},
|
||||||
{"item main source", "bad.yml", "/records/0/id", false, true},
|
{"item main source", "bad.yml", "/records/0/id", false, true},
|
||||||
{"grouped item source", "bad.yml", "/records/1/id", true, true},
|
{"grouped item source", "bad.yml", "/records/1/id", true, true},
|
||||||
} {
|
} {
|
||||||
@@ -193,8 +183,16 @@ func TestFacadeItemConflictDiagnosticsFollowEnclosingIndexes(t *testing.T) {
|
|||||||
for _, source := range loadError.Sources() {
|
for _, source := range loadError.Sources() {
|
||||||
files[source.ID] = source.Path
|
files[source.ID] = source.Path
|
||||||
}
|
}
|
||||||
if diagnostic.Primary == nil || files[diagnostic.Primary.SourceID] != "bad.yml" || len(diagnostic.Related) != 1 || files[diagnostic.Related[0].SourceID] != "helper.yml" {
|
locations := append([]configkit.Location(nil), diagnostic.Related...)
|
||||||
t.Fatal("conflict lost physical source locations")
|
if diagnostic.Primary != nil {
|
||||||
|
locations = append(locations, *diagnostic.Primary)
|
||||||
|
}
|
||||||
|
gotFiles := make(map[string]bool, len(locations))
|
||||||
|
for _, location := range locations {
|
||||||
|
gotFiles[files[location.SourceID]] = location.Line == 1 && location.Column == 5
|
||||||
|
}
|
||||||
|
if len(locations) != 2 || !gotFiles["bad.yml"] || !gotFiles["helper.yml"] {
|
||||||
|
t.Fatalf("conflict lost physical source locations: diagnostic=%#v sources=%#v", diagnostic, files)
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -202,7 +200,7 @@ func TestFacadeItemConflictDiagnosticsFollowEnclosingIndexes(t *testing.T) {
|
|||||||
|
|
||||||
func TestFacadeSequencePlacementControlsAcrossLoadEntryPoints(t *testing.T) {
|
func TestFacadeSequencePlacementControlsAcrossLoadEntryPoints(t *testing.T) {
|
||||||
definition := configkit.Definition[map[string]any]{
|
definition := configkit.Definition[map[string]any]{
|
||||||
SequenceCompositions: []configkit.SequenceComposition{{Path: configkit.Keys("entries"), IdentityField: "id"}},
|
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("entries"), "id")},
|
||||||
}
|
}
|
||||||
ordinary, err := configkit.Load(configkit.Mapping("ordinary", map[string]any{"id": "one"}), configkit.Options[map[string]any]{
|
ordinary, err := configkit.Load(configkit.Mapping("ordinary", map[string]any{"id": "one"}), configkit.Options[map[string]any]{
|
||||||
Definition: definition, Destination: configkit.Keys("entries"), PlacementMode: configkit.PlacementModeSequenceItem,
|
Definition: definition, Destination: configkit.Keys("entries"), PlacementMode: configkit.PlacementModeSequenceItem,
|
||||||
@@ -213,7 +211,7 @@ func TestFacadeSequencePlacementControlsAcrossLoadEntryPoints(t *testing.T) {
|
|||||||
if got := ordinary.Config["entries"].([]any); len(got) != 1 || got[0].(map[string]any)["id"] != "one" {
|
if got := ordinary.Config["entries"].([]any); len(got) != 1 || got[0].(map[string]any)["id"] != "one" {
|
||||||
t.Fatalf("ordinary item placement = %#v", ordinary.Config)
|
t.Fatalf("ordinary item placement = %#v", ordinary.Config)
|
||||||
}
|
}
|
||||||
if sources := ordinary.Sources(); len(sources) != 1 || sources[0].PlacementMode != configkit.PlacementModeSequenceItem || sources[0].Destination.String() != "/entries" || sources[0].DestinationUnresolved {
|
if sources := ordinary.Sources(); len(sources) != 1 || sources[0].PlacementMode != configkit.PlacementModeSequenceItem || sources[0].Destination.String() != "/entries" || sources[0].DestinationIsFallback {
|
||||||
t.Fatalf("ordinary item source = %#v", sources)
|
t.Fatalf("ordinary item source = %#v", sources)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -232,7 +230,7 @@ func TestFacadeSequencePlacementControlsAcrossLoadEntryPoints(t *testing.T) {
|
|||||||
t.Fatalf("layered item placement = %#v", layered.Config)
|
t.Fatalf("layered item placement = %#v", layered.Config)
|
||||||
}
|
}
|
||||||
for _, source := range layered.Sources() {
|
for _, source := range layered.Sources() {
|
||||||
if source.PlacementMode != configkit.PlacementModeSequenceItem || source.Destination.String() != "/entries" || source.DestinationUnresolved {
|
if source.PlacementMode != configkit.PlacementModeSequenceItem || source.Destination.String() != "/entries" || source.DestinationIsFallback {
|
||||||
t.Errorf("layered item source = %#v", source)
|
t.Errorf("layered item source = %#v", source)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -248,8 +246,8 @@ func TestFacadeSequencePlacementControlsAcrossLoadEntryPoints(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("registered sequence precedence load: %v", err)
|
t.Fatalf("registered sequence precedence load: %v", err)
|
||||||
}
|
}
|
||||||
if got := precedence.Config["entries"].([]any); len(got) != 1 || got[0].(map[string]any)["id"] != "main" {
|
if got := precedence.Config["entries"].([]any); len(got) != 2 || got[0].(map[string]any)["id"] != "lower" || got[1].(map[string]any)["id"] != "main" {
|
||||||
t.Fatalf("higher-priority list did not replace the lower sequence: %#v", precedence.Config)
|
t.Fatalf("registered upsert did not retain the lower sequence: %#v", precedence.Config)
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, test := range []struct {
|
for _, test := range []struct {
|
||||||
@@ -303,7 +301,7 @@ items:
|
|||||||
result, err := configkit.Load(configkit.File(mainPath), configkit.Options[map[string]any]{
|
result, err := configkit.Load(configkit.File(mainPath), configkit.Options[map[string]any]{
|
||||||
Imports: true,
|
Imports: true,
|
||||||
Definition: configkit.Definition[map[string]any]{
|
Definition: configkit.Definition[map[string]any]{
|
||||||
SequenceCompositions: []configkit.SequenceComposition{{Path: configkit.Keys("items"), IdentityField: "id"}},
|
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("items"), "id")},
|
||||||
Resolve: func(_ *map[string]any, resolution *configkit.Resolution) error {
|
Resolve: func(_ *map[string]any, resolution *configkit.Resolution) error {
|
||||||
for _, id := range []string{"source-2", "source-3"} {
|
for _, id := range []string{"source-2", "source-3"} {
|
||||||
source, ok := resolution.Source(id)
|
source, ok := resolution.Source(id)
|
||||||
@@ -326,7 +324,7 @@ items:
|
|||||||
t.Fatalf("load item glob: %v", err)
|
t.Fatalf("load item glob: %v", err)
|
||||||
}
|
}
|
||||||
items := result.Config["items"].([]any)
|
items := result.Config["items"].([]any)
|
||||||
wantIDs := []string{"authored", "imported-a", "imported-b"}
|
wantIDs := []string{"imported-a", "imported-b", "authored"}
|
||||||
for index, item := range items {
|
for index, item := range items {
|
||||||
if got := item.(map[string]any)["id"]; got != wantIDs[index] {
|
if got := item.(map[string]any)["id"]; got != wantIDs[index] {
|
||||||
t.Errorf("item %d ID = %#v, want %q", index, got, wantIDs[index])
|
t.Errorf("item %d ID = %#v, want %q", index, got, wantIDs[index])
|
||||||
@@ -336,7 +334,7 @@ items:
|
|||||||
t.Fatalf("callback snapshots = %#v, contexts %#v", callbackSources, callbackContexts)
|
t.Fatalf("callback snapshots = %#v, contexts %#v", callbackSources, callbackContexts)
|
||||||
}
|
}
|
||||||
for index, source := range callbackSources {
|
for index, source := range callbackSources {
|
||||||
if source.PlacementMode != configkit.PlacementModeSequenceItem || source.Destination.String() != "/items" || source.DestinationUnresolved {
|
if source.PlacementMode != configkit.PlacementModeSequenceItem || source.Destination.String() != "/items" || source.DestinationIsFallback {
|
||||||
t.Errorf("callback source = %#v", source)
|
t.Errorf("callback source = %#v", source)
|
||||||
}
|
}
|
||||||
if callbackContexts[index].FS == nil || callbackContexts[index].BaseDir != "records" {
|
if callbackContexts[index].FS == nil || callbackContexts[index].BaseDir != "records" {
|
||||||
@@ -344,14 +342,14 @@ items:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
metadata := result.Metadata()
|
metadata := result.Metadata()
|
||||||
for index := 1; index < len(wantIDs); index++ {
|
for index := 0; index < 2; index++ {
|
||||||
path := configkit.Keys("items").Index(index).Key("id")
|
path := configkit.Keys("items").Index(index).Key("id")
|
||||||
info, ok := metadata.Lookup(path)
|
info, ok := metadata.Lookup(path)
|
||||||
if !ok || !info.Explicit || len(info.Origin.Locations) != 1 || info.Origin.Locations[0].SourceID != []string{"source-2", "source-3"}[index-1] {
|
if !ok || !info.Explicit || len(info.Origin.Locations) != 1 || info.Origin.Locations[0].SourceID != []string{"source-2", "source-3"}[index] {
|
||||||
t.Errorf("shifted item origin at %s = %#v, %v", path.String(), info, ok)
|
t.Errorf("shifted item origin at %s = %#v, %v", path.String(), info, ok)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if got := result.Sources(); len(got) != 3 || got[1].Destination.String() != "/items" || got[1].DestinationUnresolved {
|
if got := result.Sources(); len(got) != 3 || got[1].Destination.String() != "/items" || got[1].DestinationIsFallback {
|
||||||
t.Fatalf("final item sources = %#v", got)
|
t.Fatalf("final item sources = %#v", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -374,9 +372,9 @@ func TestFacadeItemDestinationFallbackEvidenceSurvivesFailures(t *testing.T) {
|
|||||||
t.Fatalf("early failure = %v", err)
|
t.Fatalf("early failure = %v", err)
|
||||||
}
|
}
|
||||||
sources := loadError.Sources()
|
sources := loadError.Sources()
|
||||||
if len(sources) != 2 || sources[0].Destination.String() != "/items" || sources[0].DestinationUnresolved ||
|
if len(sources) != 2 || sources[0].Destination.String() != "/items" || sources[0].DestinationIsFallback ||
|
||||||
sources[0].PlacementMode != configkit.PlacementModeSequenceItem ||
|
sources[0].PlacementMode != configkit.PlacementModeSequenceItem ||
|
||||||
sources[1].Destination.String() != "/items" || !sources[1].DestinationUnresolved ||
|
sources[1].Destination.String() != "/items" || !sources[1].DestinationIsFallback ||
|
||||||
sources[1].PlacementMode != configkit.PlacementModeMapping {
|
sources[1].PlacementMode != configkit.PlacementModeMapping {
|
||||||
t.Fatalf("early source snapshots = %#v", sources)
|
t.Fatalf("early source snapshots = %#v", sources)
|
||||||
}
|
}
|
||||||
@@ -401,13 +399,13 @@ func TestFacadeItemDestinationFallbackEvidenceSurvivesFailures(t *testing.T) {
|
|||||||
t.Fatalf("post-placement failure = %v", err)
|
t.Fatalf("post-placement failure = %v", err)
|
||||||
}
|
}
|
||||||
sources = loadError.Sources()
|
sources = loadError.Sources()
|
||||||
if len(sources) != 2 || sources[0].Destination.String() != "/items" || sources[0].DestinationUnresolved ||
|
if len(sources) != 2 || sources[0].Destination.String() != "/items" || sources[0].DestinationIsFallback ||
|
||||||
sources[1].Destination.String() != "/items/0/metadata" || sources[1].DestinationUnresolved {
|
sources[1].Destination.String() != "/items/0/metadata" || sources[1].DestinationIsFallback {
|
||||||
t.Fatalf("post-placement source snapshots = %#v", sources)
|
t.Fatalf("post-placement source snapshots = %#v", sources)
|
||||||
}
|
}
|
||||||
firstSnapshot := sources[1]
|
firstSnapshot := sources[1]
|
||||||
sources[1].Destination = configkit.Keys("mutated")
|
sources[1].Destination = configkit.Keys("mutated")
|
||||||
if again := loadError.Sources(); len(again) != 2 || again[1].Destination.String() != firstSnapshot.Destination.String() || again[1].DestinationUnresolved {
|
if again := loadError.Sources(); len(again) != 2 || again[1].Destination.String() != firstSnapshot.Destination.String() || again[1].DestinationIsFallback {
|
||||||
t.Fatalf("failure source snapshots were aliased: %#v", again)
|
t.Fatalf("failure source snapshots were aliased: %#v", again)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -422,7 +420,7 @@ func TestFacadeItemDestinationFallbackEvidenceSurvivesFailures(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("successful item load: %v", err)
|
t.Fatalf("successful item load: %v", err)
|
||||||
}
|
}
|
||||||
if callback.Destination.String() != "/items/0/metadata" || callback.DestinationUnresolved || callback.PlacementMode != configkit.PlacementModeMapping {
|
if callback.Destination.String() != "/items/0/metadata" || callback.DestinationIsFallback || callback.PlacementMode != configkit.PlacementModeMapping {
|
||||||
t.Fatalf("callback source snapshot = %#v", callback)
|
t.Fatalf("callback source snapshot = %#v", callback)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -452,26 +450,28 @@ func TestFacadeNestedItemFallbackUsesNearestResolvedContainer(t *testing.T) {
|
|||||||
if len(sources) != 3 {
|
if len(sources) != 3 {
|
||||||
t.Fatalf("nested source snapshots = %#v", sources)
|
t.Fatalf("nested source snapshots = %#v", sources)
|
||||||
}
|
}
|
||||||
if got := sources[0]; got.Destination.String() != "/groups" || got.DestinationUnresolved || got.PlacementMode != configkit.PlacementModeSequenceItem {
|
if got := sources[0]; got.Destination.String() != "/groups" || got.DestinationIsFallback || got.PlacementMode != configkit.PlacementModeSequenceItem {
|
||||||
t.Errorf("outer item snapshot = %#v", got)
|
t.Errorf("outer item snapshot = %#v", got)
|
||||||
}
|
}
|
||||||
if got := sources[1]; got.Destination.String() != "/groups" || !got.DestinationUnresolved || got.PlacementMode != configkit.PlacementModeSequenceItem {
|
if got := sources[1]; got.Destination.String() != "/groups" || !got.DestinationIsFallback || got.PlacementMode != configkit.PlacementModeSequenceItem {
|
||||||
t.Errorf("nested item fallback = %#v", got)
|
t.Errorf("nested item fallback = %#v", got)
|
||||||
}
|
}
|
||||||
if got := sources[2]; got.Destination.String() != "/groups" || !got.DestinationUnresolved || got.PlacementMode != configkit.PlacementModeMapping {
|
if got := sources[2]; got.Destination.String() != "/groups" || !got.DestinationIsFallback || got.PlacementMode != configkit.PlacementModeMapping {
|
||||||
t.Errorf("nested helper fallback = %#v", got)
|
t.Errorf("nested helper fallback = %#v", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestFacadeSequenceCompositionRegistrationPathIsDetached(t *testing.T) {
|
func TestFacadeSequenceIdentityRegistrationIsDetached(t *testing.T) {
|
||||||
registrations := []configkit.SequenceComposition{{Path: configkit.Keys("records"), IdentityField: "id"}}
|
fields := []string{"id"}
|
||||||
|
path := configkit.Keys("records")
|
||||||
|
registration := configkit.SequenceIdentityAt(path, fields...)
|
||||||
|
fields[0] = "changed"
|
||||||
definition := configkit.Definition[map[string]any]{
|
definition := configkit.Definition[map[string]any]{
|
||||||
SequenceCompositions: registrations,
|
SequenceIdentities: []configkit.SequenceIdentity{registration},
|
||||||
Version: &configkit.VersionRule{
|
Version: &configkit.VersionRule{
|
||||||
Path: configkit.Keys("version"),
|
Path: configkit.Keys("version"),
|
||||||
Check: func(configkit.Version, *configkit.Validation) error {
|
Check: func(configkit.Version, *configkit.Validation) error {
|
||||||
registrations[0].Path = configkit.Keys("other")
|
path = configkit.Keys("other")
|
||||||
registrations[0].IdentityField = "changed"
|
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -496,7 +496,7 @@ func TestFacadeSequenceCompositionRegistrationPathIsDetached(t *testing.T) {
|
|||||||
|
|
||||||
func TestFacadeSourceInfoPlacementCopiesAndZeroValues(t *testing.T) {
|
func TestFacadeSourceInfoPlacementCopiesAndZeroValues(t *testing.T) {
|
||||||
zero := configkit.SourceInfo{}
|
zero := configkit.SourceInfo{}
|
||||||
if zero.PlacementMode != configkit.PlacementModeMapping || zero.DestinationUnresolved {
|
if zero.PlacementMode != configkit.PlacementModeMapping || zero.DestinationIsFallback {
|
||||||
t.Fatalf("zero source info = %#v", zero)
|
t.Fatalf("zero source info = %#v", zero)
|
||||||
}
|
}
|
||||||
loaded, err := configkit.Load[map[string]any](configkit.Mapping("mapping", map[string]any{"value": true}))
|
loaded, err := configkit.Load[map[string]any](configkit.Mapping("mapping", map[string]any{"value": true}))
|
||||||
@@ -504,16 +504,41 @@ func TestFacadeSourceInfoPlacementCopiesAndZeroValues(t *testing.T) {
|
|||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
sources := loaded.Sources()
|
sources := loaded.Sources()
|
||||||
if len(sources) != 1 || sources[0].PlacementMode != configkit.PlacementModeMapping || sources[0].DestinationUnresolved {
|
if len(sources) != 1 || sources[0].PlacementMode != configkit.PlacementModeMapping || sources[0].DestinationIsFallback {
|
||||||
t.Fatalf("mapping source evidence = %#v", sources)
|
t.Fatalf("mapping source evidence = %#v", sources)
|
||||||
}
|
}
|
||||||
first := sources[0]
|
first := sources[0]
|
||||||
first.Destination = configkit.Keys("caller")
|
first.Destination = configkit.Keys("caller")
|
||||||
if again := loaded.Sources(); len(again) != 1 || again[0].Destination.String() != "" || again[0].DestinationUnresolved {
|
if again := loaded.Sources(); len(again) != 1 || again[0].Destination.String() != "" || again[0].DestinationIsFallback {
|
||||||
t.Fatalf("source evidence changed after caller mutation: %#v", again)
|
t.Fatalf("source evidence changed after caller mutation: %#v", again)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLayeredSequenceUpsertHistoryRetainsLowerTierRecords(t *testing.T) {
|
||||||
|
definition := configkit.Definition[map[string]any]{
|
||||||
|
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("entries"), "id")},
|
||||||
|
}
|
||||||
|
result, err := configkit.LoadLayered[map[string]any](configkit.Input{Source: configkit.Bytes("main", []byte("entries: [{id: main}]\n"))}, configkit.LayeredOptions[map[string]any]{
|
||||||
|
Definition: definition,
|
||||||
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{
|
||||||
|
Source: configkit.Bytes("lower", []byte("entries: [{id: lower}]\n")),
|
||||||
|
}}}},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
for index, id := range []string{"lower", "main"} {
|
||||||
|
path := configkit.Keys("entries").Index(index).Key("id")
|
||||||
|
explanation, ok := result.Explain(path)
|
||||||
|
if !ok || !explanation.Effective.Present || explanation.Effective.YAML != id+"\n" || len(explanation.History) == 0 {
|
||||||
|
t.Fatalf("tier history at %s = %#v, %v", path.String(), explanation, ok)
|
||||||
|
}
|
||||||
|
if explanation.History[len(explanation.History)-1].After.YAML != id+"\n" {
|
||||||
|
t.Fatalf("tier history at %s does not retain final record: %#v", path.String(), explanation.History)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestFacadeSequenceReplacementKeepsTypedAndHistoricalEvidenceAligned(t *testing.T) {
|
func TestFacadeSequenceReplacementKeepsTypedAndHistoricalEvidenceAligned(t *testing.T) {
|
||||||
type record struct {
|
type record struct {
|
||||||
ID string `yaml:"id"`
|
ID string `yaml:"id"`
|
||||||
@@ -531,7 +556,12 @@ func TestFacadeSequenceReplacementKeepsTypedAndHistoricalEvidenceAligned(t *test
|
|||||||
directory := t.TempDir()
|
directory := t.TempDir()
|
||||||
mainPath := filepath.Join(directory, "main.yml")
|
mainPath := filepath.Join(directory, "main.yml")
|
||||||
underlayPath := filepath.Join(directory, "underlay.yml")
|
underlayPath := filepath.Join(directory, "underlay.yml")
|
||||||
if err := os.WriteFile(mainPath, []byte(`entries:
|
if err := os.WriteFile(mainPath, []byte(`configkit:
|
||||||
|
sequences:
|
||||||
|
- destination: [entries]
|
||||||
|
key: id
|
||||||
|
mode: replace
|
||||||
|
entries:
|
||||||
- id: current
|
- id: current
|
||||||
count: 0
|
count: 0
|
||||||
enabled: false
|
enabled: false
|
||||||
@@ -567,8 +597,8 @@ func TestFacadeSequenceReplacementKeepsTypedAndHistoricalEvidenceAligned(t *test
|
|||||||
result, err := configkit.LoadLayered(configkit.Input{Source: configkit.File(mainPath)}, configkit.LayeredOptions[configuration]{
|
result, err := configkit.LoadLayered(configkit.Input{Source: configkit.File(mainPath)}, configkit.LayeredOptions[configuration]{
|
||||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.File(underlayPath), Imports: true}}}},
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.File(underlayPath), Imports: true}}}},
|
||||||
Definition: configkit.Definition[configuration]{
|
Definition: configkit.Definition[configuration]{
|
||||||
SequenceCompositions: []configkit.SequenceComposition{{Path: configkit.Keys("entries"), IdentityField: "id"}},
|
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("entries"), "id")},
|
||||||
Secret: func(path configkit.Path) bool {
|
Sensitive: func(path configkit.Path) bool {
|
||||||
return path.String() == "/entries/0/token" || path.String() == "/entries/1/token"
|
return path.String() == "/entries/0/token" || path.String() == "/entries/1/token"
|
||||||
},
|
},
|
||||||
Resolve: func(value *configuration, resolution *configkit.Resolution) error {
|
Resolve: func(value *configuration, resolution *configkit.Resolution) error {
|
||||||
@@ -581,8 +611,8 @@ func TestFacadeSequenceReplacementKeepsTypedAndHistoricalEvidenceAligned(t *test
|
|||||||
if resolution.IsExplicit(configkit.Keys("entries").Index(0).Key("omitted")) {
|
if resolution.IsExplicit(configkit.Keys("entries").Index(0).Key("omitted")) {
|
||||||
t.Error("resolver treated an omitted field as explicit")
|
t.Error("resolver treated an omitted field as explicit")
|
||||||
}
|
}
|
||||||
info, found := resolution.InputInfo(configkit.Keys("entries").Index(0).Key("count"))
|
info, found := resolution.EntryInfo(configkit.Keys("entries").Index(0).Key("count"))
|
||||||
if !found || info.Origin.Layer != configkit.LayerBase {
|
if !found || info.Origin.Layer != configkit.LayerMain {
|
||||||
t.Errorf("resolver current-record input info = %#v, %v", info, found)
|
t.Errorf("resolver current-record input info = %#v, %v", info, found)
|
||||||
}
|
}
|
||||||
for _, id := range []string{"source-3", "source-4"} {
|
for _, id := range []string{"source-3", "source-4"} {
|
||||||
@@ -616,7 +646,7 @@ func TestFacadeSequenceReplacementKeepsTypedAndHistoricalEvidenceAligned(t *test
|
|||||||
t.Fatalf("callback sources = %#v; contexts = %#v", callbackItems, callbackContexts)
|
t.Fatalf("callback sources = %#v; contexts = %#v", callbackItems, callbackContexts)
|
||||||
}
|
}
|
||||||
for _, source := range callbackItems {
|
for _, source := range callbackItems {
|
||||||
if source.PlacementMode != configkit.PlacementModeSequenceItem || source.Destination.String() != "/entries" || source.DestinationUnresolved {
|
if source.PlacementMode != configkit.PlacementModeSequenceItem || source.Destination.String() != "/entries" || source.DestinationIsFallback {
|
||||||
t.Errorf("callback item source = %#v", source)
|
t.Errorf("callback item source = %#v", source)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
197
facade_sequence_version_test.go
Normal file
197
facade_sequence_version_test.go
Normal file
@@ -0,0 +1,197 @@
|
|||||||
|
package configkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"testing"
|
||||||
|
"testing/fstest"
|
||||||
|
|
||||||
|
configkit "go.maximumdirect.net/configkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
type movedVersionItem struct {
|
||||||
|
ID string `yaml:"id"`
|
||||||
|
Version string `yaml:"version"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type movedVersionCatalog struct {
|
||||||
|
Items []movedVersionItem `yaml:"items"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMainVersionDecodingFollowsSequenceMovement(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
for _, mounted := range []bool{false, true} {
|
||||||
|
for _, matching := range []bool{false, true} {
|
||||||
|
for _, version := range []string{"4", "'4'"} {
|
||||||
|
t.Run(fmt.Sprintf("mounted=%t/matched=%t/%s", mounted, matching, version), func(t *testing.T) {
|
||||||
|
body := "items: [{id: main, version: " + version + "}]\n"
|
||||||
|
path := configkit.Keys("items").Index(0).Key("version")
|
||||||
|
main := configkit.Input{}
|
||||||
|
if mounted {
|
||||||
|
body, path = "id: main\nversion: "+version+"\n", configkit.Keys("version")
|
||||||
|
main.Destination, main.PlacementMode = configkit.Keys("items"), configkit.PlacementModeSequenceItem
|
||||||
|
}
|
||||||
|
main.Source = configkit.Bytes("main", []byte(body))
|
||||||
|
keep := "{id: keep}"
|
||||||
|
if mounted {
|
||||||
|
keep = "{id: keep, version: ordinary}"
|
||||||
|
}
|
||||||
|
lower := "items: [" + keep + "]\n"
|
||||||
|
if matching {
|
||||||
|
lower = "items: [" + keep + ", {id: main}]\n"
|
||||||
|
}
|
||||||
|
calls := 0
|
||||||
|
loaded, err := configkit.LoadLayered(main, configkit.LayeredOptions[movedVersionCatalog]{
|
||||||
|
SourceVersionMode: configkit.SourceVersionModeIfPresent,
|
||||||
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.Bytes("lower", []byte(lower))}}}},
|
||||||
|
Definition: configkit.Definition[movedVersionCatalog]{
|
||||||
|
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("items"), "id")},
|
||||||
|
Version: &configkit.VersionRule{Path: path, Check: func(version configkit.Version, _ *configkit.Validation) error {
|
||||||
|
calls++
|
||||||
|
if !version.Present || version.Text != "4" {
|
||||||
|
t.Errorf("checked version = %#v", version)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if calls != 1 || len(loaded.Config.Items) != 2 || loaded.Config.Items[1].ID != "main" || loaded.Config.Items[1].Version != "4" {
|
||||||
|
t.Fatalf("moved main = %#v, checker calls = %d", loaded.Config, calls)
|
||||||
|
}
|
||||||
|
if mounted && loaded.Config.Items[0].Version != "ordinary" {
|
||||||
|
t.Fatal("unrelated underlay payload was treated as the main declaration")
|
||||||
|
}
|
||||||
|
for _, source := range loaded.Sources() {
|
||||||
|
if source.Layer == configkit.LayerMain && source.VersionEvidence.Path.String() != path.String() {
|
||||||
|
t.Fatal("effective movement changed source-local version evidence")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOverlayVersionProtectionUsesAssignedRecordPositions(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
for _, test := range []struct {
|
||||||
|
name, payload string
|
||||||
|
code configkit.Code
|
||||||
|
}{
|
||||||
|
{name: "change main header", payload: "[{id: main, version: changed}]", code: configkit.CodeOverlayVersion},
|
||||||
|
{name: "remove main header", payload: "[{id: main}]", code: configkit.CodeOverlayVersion},
|
||||||
|
{name: "other record version", payload: "[{id: keep, version: ordinary}]"},
|
||||||
|
{name: "new record version", payload: "[{id: new, version: ordinary}]"},
|
||||||
|
{name: "only main header gets integer conversion", payload: "[{id: new, version: 5}]", code: configkit.CodeDecodeType},
|
||||||
|
} {
|
||||||
|
t.Run(test.name, func(t *testing.T) {
|
||||||
|
files := configkit.NewFilesystem("catalog", fstest.MapFS{
|
||||||
|
"overlays/test.yml": {Data: []byte("items: " + test.payload + "\n")},
|
||||||
|
})
|
||||||
|
calls := 0
|
||||||
|
loaded, err := configkit.LoadLayered(configkit.Input{
|
||||||
|
Source: configkit.Bytes("main", []byte("id: main\nversion: 4\n")), Destination: configkit.Keys("items"), PlacementMode: configkit.PlacementModeSequenceItem,
|
||||||
|
}, configkit.LayeredOptions[movedVersionCatalog]{
|
||||||
|
SourceVersionMode: configkit.SourceVersionModeIfPresent,
|
||||||
|
Overlay: "test", OverlayFilesystem: &configkit.FilesystemContext{FS: &files, BaseDir: ".", Root: "."},
|
||||||
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.Bytes("lower", []byte("items: [{id: keep}]\n"))}}}},
|
||||||
|
Definition: configkit.Definition[movedVersionCatalog]{
|
||||||
|
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("items"), "id")},
|
||||||
|
Version: &configkit.VersionRule{Path: configkit.Keys("version"), Check: func(configkit.Version, *configkit.Validation) error { calls++; return nil }},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if calls != 1 {
|
||||||
|
t.Fatalf("checker calls = %d", calls)
|
||||||
|
}
|
||||||
|
if test.code != "" {
|
||||||
|
loadErr := requireFacadeLoadError(t, err)
|
||||||
|
if loaded != nil || len(loadErr.Diagnostics()) != 1 || loadErr.Diagnostics()[0].Code != test.code {
|
||||||
|
t.Fatalf("version diagnostics = %#v", loadErr.Diagnostics())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if loaded.Config.Items[1].Version != "4" {
|
||||||
|
t.Fatal("unrelated overlay changed main header")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestUnderlayVersionProtectionUsesMainRecordPosition(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
for _, imported := range []bool{false, true} {
|
||||||
|
t.Run(fmt.Sprintf("imported=%t", imported), func(t *testing.T) {
|
||||||
|
payload := "items: [{id: keep}, {id: main, version: forbidden}]\n"
|
||||||
|
lower := configkit.Input{Source: configkit.Bytes("lower", []byte(payload))}
|
||||||
|
want := configkit.CodeUnderlayVersion
|
||||||
|
if imported {
|
||||||
|
files := configkit.NewFilesystem("lower", fstest.MapFS{
|
||||||
|
"main.yml": {Data: []byte("configkit: {imports: [{path: fragment.yml}]}\n")},
|
||||||
|
"fragment.yml": {Data: []byte(payload)},
|
||||||
|
})
|
||||||
|
lower.Source, lower.Imports = files.File("main.yml"), true
|
||||||
|
want = configkit.CodeImportVersion
|
||||||
|
}
|
||||||
|
calls := 0
|
||||||
|
result, err := configkit.LoadLayered(configkit.Input{
|
||||||
|
Source: configkit.Bytes("main", []byte("id: main\nversion: 4\n")),
|
||||||
|
Destination: configkit.Keys("items"), PlacementMode: configkit.PlacementModeSequenceItem,
|
||||||
|
}, configkit.LayeredOptions[movedVersionCatalog]{
|
||||||
|
SourceVersionMode: configkit.SourceVersionModeIfPresent,
|
||||||
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{lower}}},
|
||||||
|
Definition: configkit.Definition[movedVersionCatalog]{
|
||||||
|
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("items"), "id")},
|
||||||
|
Version: &configkit.VersionRule{Path: configkit.Keys("version"), Check: func(configkit.Version, *configkit.Validation) error { calls++; return nil }},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
failure := requireFacadeLoadError(t, err)
|
||||||
|
if result != nil || calls != 1 || len(failure.Diagnostics()) != 1 || failure.Diagnostics()[0].Code != want || failure.Diagnostics()[0].Path.String() != "/items/1/version" {
|
||||||
|
t.Fatalf("result=%#v calls=%d diagnostics=%#v", result, calls, failure.Diagnostics())
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMainVersionFollowsInheritedSequenceMovement(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
for _, parentID := range []string{"keep", "main"} {
|
||||||
|
t.Run(parentID, func(t *testing.T) {
|
||||||
|
body := "base: {items: [{id: " + parentID + ", version: ordinary}]}\nchild:\n extends: base\n items: [{id: main, version: 4}]\n"
|
||||||
|
type config struct {
|
||||||
|
Base, Child movedVersionCatalog
|
||||||
|
}
|
||||||
|
calls := 0
|
||||||
|
result, err := configkit.Load(configkit.Bytes("main", []byte(body)), configkit.Options[config]{
|
||||||
|
SourceVersionMode: configkit.SourceVersionModeIfPresent,
|
||||||
|
Definition: configkit.Definition[config]{
|
||||||
|
SequenceIdentities: []configkit.SequenceIdentity{
|
||||||
|
configkit.SequenceIdentityAt(configkit.Keys("base", "items"), "id"),
|
||||||
|
configkit.SequenceIdentityAt(configkit.Keys("child", "items"), "id"),
|
||||||
|
},
|
||||||
|
Version: &configkit.VersionRule{Path: configkit.Keys("child", "items").Index(0).Key("version"), Check: func(configkit.Version, *configkit.Validation) error { calls++; return nil }},
|
||||||
|
},
|
||||||
|
})
|
||||||
|
if calls != 1 {
|
||||||
|
t.Fatalf("checker calls = %d", calls)
|
||||||
|
}
|
||||||
|
if parentID == "main" {
|
||||||
|
failure := requireFacadeLoadError(t, err)
|
||||||
|
if result != nil || len(failure.Diagnostics()) != 1 || failure.Diagnostics()[0].Code != configkit.CodeInheritanceVersion {
|
||||||
|
t.Fatalf("diagnostics = %#v", failure.Diagnostics())
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(result.Config.Child.Items) != 2 || result.Config.Child.Items[1].Version != "4" || result.Config.Child.Items[0].Version != "ordinary" {
|
||||||
|
t.Fatalf("inherited items = %#v", result.Config.Child.Items)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -10,18 +10,22 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"testing/fstest"
|
"testing/fstest"
|
||||||
|
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "go.maximumdirect.net/configkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestFacadeSourceVersionEvidenceFollowsAuthoredSequenceRanges(t *testing.T) {
|
func TestFacadeSourceVersionEvidenceFollowsAssembledSequenceRanges(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
for _, grouped := range []bool{false, true} {
|
for _, grouped := range []bool{false, true} {
|
||||||
for _, sensitivePath := range []string{"", "/records/1/version", "/records/1"} {
|
index := 0
|
||||||
|
if grouped {
|
||||||
|
index = 1
|
||||||
|
}
|
||||||
|
for _, sensitivePath := range []string{"", fmt.Sprintf("/records/%d/version", index), fmt.Sprintf("/records/%d", index)} {
|
||||||
t.Run(fmt.Sprintf("grouped=%t/secret=%s", grouped, sensitivePath), func(t *testing.T) {
|
t.Run(fmt.Sprintf("grouped=%t/secret=%s", grouped, sensitivePath), func(t *testing.T) {
|
||||||
definition := configkit.Definition[map[string]any]{
|
definition := configkit.Definition[map[string]any]{
|
||||||
SequenceCompositions: []configkit.SequenceComposition{{Path: configkit.Keys("records"), IdentityField: "id"}},
|
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("records"), "id")},
|
||||||
Version: &configkit.VersionRule{Path: configkit.Keys("records").Index(0).Key("version"), Check: func(configkit.Version, *configkit.Validation) error { return nil }},
|
Version: &configkit.VersionRule{Path: configkit.Keys("records").Index(0).Key("version"), Check: func(configkit.Version, *configkit.Validation) error { return nil }},
|
||||||
Secret: func(path configkit.Path) bool { return sensitivePath != "" && path.String() == sensitivePath },
|
Sensitive: func(path configkit.Path) bool { return sensitivePath != "" && path.String() == sensitivePath },
|
||||||
}
|
}
|
||||||
first := "records: [{id: first, version: first-version}]\n"
|
first := "records: [{id: first, version: first-version}]\n"
|
||||||
child := "records: [{id: child, version: PRIVATE-VERSION-CANARY}]\n"
|
child := "records: [{id: child, version: PRIVATE-VERSION-CANARY}]\n"
|
||||||
@@ -46,8 +50,18 @@ func TestFacadeSourceVersionEvidenceFollowsAuthoredSequenceRanges(t *testing.T)
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
|
if !grouped {
|
||||||
|
records, ok := result.Config["records"].([]any)
|
||||||
|
if !ok || len(records) != 2 {
|
||||||
|
t.Fatalf("scheduled records = %#v", result.Config["records"])
|
||||||
|
}
|
||||||
|
firstRecord, ok := records[0].(map[string]any)
|
||||||
|
if !ok || firstRecord["id"] != "child" {
|
||||||
|
t.Fatalf("scheduled records = %#v", result.Config["records"])
|
||||||
|
}
|
||||||
|
}
|
||||||
sources := result.Sources()
|
sources := result.Sources()
|
||||||
check := sources[len(sources)-1].VersionCheck
|
check := sources[len(sources)-1].VersionEvidence
|
||||||
if check == nil || check.Path.String() != "/records/0/version" || check.Location == nil || check.Location.SourceID != sources[len(sources)-1].ID {
|
if check == nil || check.Path.String() != "/records/0/version" || check.Location == nil || check.Location.SourceID != sources[len(sources)-1].ID {
|
||||||
t.Fatalf("source-local evidence changed: %#v", check)
|
t.Fatalf("source-local evidence changed: %#v", check)
|
||||||
}
|
}
|
||||||
@@ -72,8 +86,8 @@ func TestFacadeSourceVersionOwnershipAcceptsContextualIndexes(t *testing.T) {
|
|||||||
for _, grouped := range []bool{false, true} {
|
for _, grouped := range []bool{false, true} {
|
||||||
for _, path := range []configkit.Path{configkit.Keys("records").Index(0).Key("version"), parsed} {
|
for _, path := range []configkit.Path{configkit.Keys("records").Index(0).Key("version"), parsed} {
|
||||||
definition := configkit.Definition[map[string]any]{
|
definition := configkit.Definition[map[string]any]{
|
||||||
SequenceCompositions: []configkit.SequenceComposition{{Path: configkit.Keys("records"), IdentityField: "id"}},
|
SequenceIdentities: []configkit.SequenceIdentity{configkit.SequenceIdentityAt(configkit.Keys("records"), "id")},
|
||||||
Version: &configkit.VersionRule{Path: path, Check: func(configkit.Version, *configkit.Validation) error { return nil }},
|
Version: &configkit.VersionRule{Path: path, Check: func(configkit.Version, *configkit.Validation) error { return nil }},
|
||||||
}
|
}
|
||||||
first := "records: [{id: first, version: first}]\n"
|
first := "records: [{id: first, version: first}]\n"
|
||||||
child := "records: [{id: child, version: child}]\n"
|
child := "records: [{id: child, version: child}]\n"
|
||||||
@@ -90,11 +104,7 @@ func TestFacadeSourceVersionOwnershipAcceptsContextualIndexes(t *testing.T) {
|
|||||||
if err != nil || result == nil {
|
if err != nil || result == nil {
|
||||||
t.Fatalf("grouped=%t path=%#v: %v", grouped, path, err)
|
t.Fatalf("grouped=%t path=%#v: %v", grouped, path, err)
|
||||||
}
|
}
|
||||||
want := 2
|
if len(result.Config["records"].([]any)) != 2 {
|
||||||
if grouped {
|
|
||||||
want = 1
|
|
||||||
}
|
|
||||||
if len(result.Config["records"].([]any)) != want {
|
|
||||||
t.Fatal("version path changed composition")
|
t.Fatal("version path changed composition")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -199,7 +209,7 @@ func TestFacadeSourceVersionEvidenceRedactsCollidingShapes(t *testing.T) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
foundMapping = true
|
foundMapping = true
|
||||||
if check := source.VersionCheck; check == nil || !check.Version.Present || !check.Redacted || check.Version.Text != "" {
|
if check := source.VersionEvidence; check == nil || !check.Version.Present || !check.Redacted || check.Version.Text != "" {
|
||||||
t.Errorf("reverse=%v: schema-tagged header was not redacted", reverse)
|
t.Errorf("reverse=%v: schema-tagged header was not redacted", reverse)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -255,7 +265,7 @@ func TestFacadeSourceVersionAbsenceKeepsCompleteEvidencePath(t *testing.T) {
|
|||||||
{"unavailable index", "meta: [null, null, null, {version: main}]\n", "meta: []\n", configkit.Keys("meta").Index(3).Key("version")},
|
{"unavailable index", "meta: [null, null, null, {version: main}]\n", "meta: []\n", configkit.Keys("meta").Index(3).Key("version")},
|
||||||
} {
|
} {
|
||||||
for _, mode := range []configkit.SourceVersionMode{configkit.SourceVersionModeIfPresent, configkit.SourceVersionModeRequired} {
|
for _, mode := range []configkit.SourceVersionMode{configkit.SourceVersionModeIfPresent, configkit.SourceVersionModeRequired} {
|
||||||
var callbackEvidence *configkit.SourceVersionCheck
|
var callbackEvidence *configkit.SourceVersionEvidence
|
||||||
loaded, err := configkit.LoadLayered(configkit.Input{Source: configkit.Bytes("main", []byte(test.main))}, configkit.LayeredOptions[map[string]any]{
|
loaded, err := configkit.LoadLayered(configkit.Input{Source: configkit.Bytes("main", []byte(test.main))}, configkit.LayeredOptions[map[string]any]{
|
||||||
SourceVersionMode: mode,
|
SourceVersionMode: mode,
|
||||||
Definition: configkit.Definition[map[string]any]{
|
Definition: configkit.Definition[map[string]any]{
|
||||||
@@ -265,7 +275,7 @@ func TestFacadeSourceVersionAbsenceKeepsCompleteEvidencePath(t *testing.T) {
|
|||||||
if !found {
|
if !found {
|
||||||
t.Fatal("missing callback source")
|
t.Fatal("missing callback source")
|
||||||
}
|
}
|
||||||
callbackEvidence = source.VersionCheck
|
callbackEvidence = source.VersionEvidence
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -273,7 +283,7 @@ func TestFacadeSourceVersionAbsenceKeepsCompleteEvidencePath(t *testing.T) {
|
|||||||
Source: configkit.Bytes("underlay", []byte(test.underlay)), Destination: configkit.Keys("under"),
|
Source: configkit.Bytes("underlay", []byte(test.underlay)), Destination: configkit.Keys("under"),
|
||||||
}}}},
|
}}}},
|
||||||
})
|
})
|
||||||
var evidence *configkit.SourceVersionCheck
|
var evidence *configkit.SourceVersionEvidence
|
||||||
if mode == configkit.SourceVersionModeRequired {
|
if mode == configkit.SourceVersionModeRequired {
|
||||||
var failure *configkit.LoadError
|
var failure *configkit.LoadError
|
||||||
if loaded != nil || !errors.As(err, &failure) {
|
if loaded != nil || !errors.As(err, &failure) {
|
||||||
@@ -283,12 +293,12 @@ func TestFacadeSourceVersionAbsenceKeepsCompleteEvidencePath(t *testing.T) {
|
|||||||
if len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeVersionError || diagnostics[0].Path.String() != test.path.String() {
|
if len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeVersionError || diagnostics[0].Path.String() != test.path.String() {
|
||||||
t.Fatalf("%s: required diagnostics=%#v", test.name, diagnostics)
|
t.Fatalf("%s: required diagnostics=%#v", test.name, diagnostics)
|
||||||
}
|
}
|
||||||
evidence = failure.Sources()[1].VersionCheck
|
evidence = failure.Sources()[1].VersionEvidence
|
||||||
} else {
|
} else {
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("%s: %v", test.name, err)
|
t.Fatalf("%s: %v", test.name, err)
|
||||||
}
|
}
|
||||||
evidence = loaded.Sources()[1].VersionCheck
|
evidence = loaded.Sources()[1].VersionEvidence
|
||||||
if callbackEvidence == nil || callbackEvidence.Path.String() != test.path.String() {
|
if callbackEvidence == nil || callbackEvidence.Path.String() != test.path.String() {
|
||||||
t.Errorf("%s: callback path is incomplete", test.name)
|
t.Errorf("%s: callback path is incomplete", test.name)
|
||||||
}
|
}
|
||||||
@@ -355,7 +365,7 @@ func TestFacadeSourceVersionMissingHeaderDoesNotOverlapControl(t *testing.T) {
|
|||||||
if child["value"] != "good" {
|
if child["value"] != "good" {
|
||||||
t.Fatal("valid sibling inheritance was not applied")
|
t.Fatal("valid sibling inheritance was not applied")
|
||||||
}
|
}
|
||||||
check := loaded.Sources()[1].VersionCheck
|
check := loaded.Sources()[1].VersionEvidence
|
||||||
if check == nil || check.Version.Present || check.Path.String() != path.String() {
|
if check == nil || check.Version.Present || check.Path.String() != path.String() {
|
||||||
t.Fatal("optional omission evidence was not retained")
|
t.Fatal("optional omission evidence was not retained")
|
||||||
}
|
}
|
||||||
@@ -534,16 +544,16 @@ func TestFacadeSourceVersionEvidenceIsSanitizedAndDetached(t *testing.T) {
|
|||||||
var callbackSource configkit.SourceInfo
|
var callbackSource configkit.SourceInfo
|
||||||
var callbackContext configkit.ResourceContext
|
var callbackContext configkit.ResourceContext
|
||||||
definition := configkit.Definition[map[string]any]{
|
definition := configkit.Definition[map[string]any]{
|
||||||
Version: &configkit.VersionRule{Path: configkit.Keys("version"), Check: func(configkit.Version, *configkit.Validation) error { return nil }},
|
Version: &configkit.VersionRule{Path: configkit.Keys("version"), Check: func(configkit.Version, *configkit.Validation) error { return nil }},
|
||||||
Secret: func(candidate configkit.Path) bool { return candidate.String() == "/service/version" },
|
Sensitive: func(candidate configkit.Path) bool { return candidate.String() == "/service/version" },
|
||||||
Resolve: func(_ *map[string]any, resolution *configkit.Resolution) error {
|
Resolve: func(_ *map[string]any, resolution *configkit.Resolution) error {
|
||||||
var found bool
|
var found bool
|
||||||
callbackSource, found = resolution.Source("source-1")
|
callbackSource, found = resolution.Source("source-1")
|
||||||
if !found {
|
if !found {
|
||||||
t.Fatal("resolution callback could not query the acquired source")
|
t.Fatal("resolution callback could not query the acquired source")
|
||||||
}
|
}
|
||||||
if callbackSource.VersionCheck == nil || !callbackSource.VersionCheck.Redacted || callbackSource.VersionCheck.Version.Text != "" {
|
if callbackSource.VersionEvidence == nil || !callbackSource.VersionEvidence.Redacted || callbackSource.VersionEvidence.Version.Text != "" {
|
||||||
t.Fatalf("callback source did not conservatively redact evidence: %#v", callbackSource.VersionCheck)
|
t.Fatalf("callback source did not conservatively redact evidence: %#v", callbackSource.VersionEvidence)
|
||||||
}
|
}
|
||||||
callbackContext, found = resolution.SourceContext("source-1")
|
callbackContext, found = resolution.SourceContext("source-1")
|
||||||
if !found || callbackContext.FS == nil || callbackContext.BaseDir != "." {
|
if !found || callbackContext.FS == nil || callbackContext.BaseDir != "." {
|
||||||
@@ -558,17 +568,17 @@ func TestFacadeSourceVersionEvidenceIsSanitizedAndDetached(t *testing.T) {
|
|||||||
if err != nil || loaded == nil {
|
if err != nil || loaded == nil {
|
||||||
t.Fatalf("LoadLayered = %#v, %v", loaded, err)
|
t.Fatalf("LoadLayered = %#v, %v", loaded, err)
|
||||||
}
|
}
|
||||||
if callbackSource.VersionCheck == nil || !callbackSource.VersionCheck.Redacted || callbackSource.VersionCheck.Version.Text != "" {
|
if callbackSource.VersionEvidence == nil || !callbackSource.VersionEvidence.Redacted || callbackSource.VersionEvidence.Version.Text != "" {
|
||||||
t.Fatalf("callback evidence lost its conservative snapshot: %#v", callbackSource.VersionCheck)
|
t.Fatalf("callback evidence lost its conservative snapshot: %#v", callbackSource.VersionEvidence)
|
||||||
}
|
}
|
||||||
callbackSource.VersionCheck.Path = configkit.Keys("mutated-callback")
|
callbackSource.VersionEvidence.Path = configkit.Keys("mutated-callback")
|
||||||
callbackSource.VersionCheck.Version.Text = canary
|
callbackSource.VersionEvidence.Version.Text = canary
|
||||||
callbackSource.VersionCheck.Location.Line = 98
|
callbackSource.VersionEvidence.Location.Line = 98
|
||||||
finalSources := loaded.Sources()
|
finalSources := loaded.Sources()
|
||||||
if len(finalSources) != 1 || finalSources[0].Path != path || finalSources[0].VersionCheck == nil {
|
if len(finalSources) != 1 || finalSources[0].Path != path || finalSources[0].VersionEvidence == nil {
|
||||||
t.Fatalf("final sources = %#v", finalSources)
|
t.Fatalf("final sources = %#v", finalSources)
|
||||||
}
|
}
|
||||||
check := finalSources[0].VersionCheck
|
check := finalSources[0].VersionEvidence
|
||||||
if check.Path.String() != "/version" || check.Version != (configkit.Version{Present: true}) || !check.Redacted ||
|
if check.Path.String() != "/version" || check.Version != (configkit.Version{Present: true}) || !check.Redacted ||
|
||||||
check.Location == nil || check.Location.SourceID != finalSources[0].ID || check.Location.Line != 1 {
|
check.Location == nil || check.Location.SourceID != finalSources[0].ID || check.Location.Line != 1 {
|
||||||
t.Fatalf("final classified evidence = %#v", check)
|
t.Fatalf("final classified evidence = %#v", check)
|
||||||
@@ -584,9 +594,9 @@ func TestFacadeSourceVersionEvidenceIsSanitizedAndDetached(t *testing.T) {
|
|||||||
check.Redacted = false
|
check.Redacted = false
|
||||||
check.Version.Text = "mutated"
|
check.Version.Text = "mutated"
|
||||||
repeated := loaded.Sources()
|
repeated := loaded.Sources()
|
||||||
if len(repeated) != 1 || repeated[0].VersionCheck.Path.String() != "/version" ||
|
if len(repeated) != 1 || repeated[0].VersionEvidence.Path.String() != "/version" ||
|
||||||
repeated[0].VersionCheck.Location.Line != 1 || !repeated[0].VersionCheck.Redacted ||
|
repeated[0].VersionEvidence.Location.Line != 1 || !repeated[0].VersionEvidence.Redacted ||
|
||||||
repeated[0].VersionCheck.Version.Text != "" {
|
repeated[0].VersionEvidence.Version.Text != "" {
|
||||||
t.Fatalf("source evidence was aliased or re-read after source removal: %#v", repeated)
|
t.Fatalf("source evidence was aliased or re-read after source removal: %#v", repeated)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -602,14 +612,14 @@ func TestFacadeSourceVersionEvidenceIsSanitizedAndDetached(t *testing.T) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
if err != nil || public == nil || len(public.Sources()) != 1 || public.Sources()[0].VersionCheck == nil {
|
if err != nil || public == nil || len(public.Sources()) != 1 || public.Sources()[0].VersionEvidence == nil {
|
||||||
t.Fatalf("unclassified source evidence = %#v, %v", public, err)
|
t.Fatalf("unclassified source evidence = %#v, %v", public, err)
|
||||||
}
|
}
|
||||||
if got := public.Sources()[0].VersionCheck; got.Redacted || got.Version.Text != publicText || !got.Version.Present {
|
if got := public.Sources()[0].VersionEvidence; got.Redacted || got.Version.Text != publicText || !got.Version.Present {
|
||||||
t.Fatalf("unclassified evidence = %#v", got)
|
t.Fatalf("unclassified evidence = %#v", got)
|
||||||
}
|
}
|
||||||
if publicCallback.VersionCheck == nil || !publicCallback.VersionCheck.Redacted || publicCallback.VersionCheck.Version.Text != "" {
|
if publicCallback.VersionEvidence == nil || !publicCallback.VersionEvidence.Redacted || publicCallback.VersionEvidence.Version.Text != "" {
|
||||||
t.Fatalf("unclassified callback evidence was not initially conservative: %#v", publicCallback.VersionCheck)
|
t.Fatalf("unclassified callback evidence was not initially conservative: %#v", publicCallback.VersionEvidence)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -626,7 +636,7 @@ func TestFacadeSourceVersionEvidenceOnFailedLoadsAndSyntheticSources(t *testing.
|
|||||||
Path: configkit.Keys("version"), Check: func(configkit.Version, *configkit.Validation) error { checked++; return nil },
|
Path: configkit.Keys("version"), Check: func(configkit.Version, *configkit.Validation) error { checked++; return nil },
|
||||||
}}
|
}}
|
||||||
path := filepath.Join(t.TempDir(), "main.yml")
|
path := filepath.Join(t.TempDir(), "main.yml")
|
||||||
contents := "version: " + canary + "\nconfigkit:\n imports:\n - path: missing.yml\n"
|
contents := "version: " + canary + "\nconfigkit:\n imports:\n - path: missing.yml\n required: true\n"
|
||||||
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
|
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
@@ -640,7 +650,7 @@ func TestFacadeSourceVersionEvidenceOnFailedLoadsAndSyntheticSources(t *testing.
|
|||||||
if checked != 1 {
|
if checked != 1 {
|
||||||
t.Fatalf("main declaration checker calls = %d; error %v", checked, err)
|
t.Fatalf("main declaration checker calls = %d; error %v", checked, err)
|
||||||
}
|
}
|
||||||
check := failure.Sources()[0].VersionCheck
|
check := failure.Sources()[0].VersionEvidence
|
||||||
if check == nil || !check.Redacted || !check.Version.Present || check.Version.Text != "" {
|
if check == nil || !check.Redacted || !check.Version.Present || check.Version.Text != "" {
|
||||||
t.Fatalf("early acquisition evidence was not conservatively redacted: %#v; sources %#v", check, failure.Sources())
|
t.Fatalf("early acquisition evidence was not conservatively redacted: %#v; sources %#v", check, failure.Sources())
|
||||||
}
|
}
|
||||||
@@ -656,14 +666,14 @@ func TestFacadeSourceVersionEvidenceOnFailedLoadsAndSyntheticSources(t *testing.
|
|||||||
t.Fatalf("malformed version error = %v", err)
|
t.Fatalf("malformed version error = %v", err)
|
||||||
}
|
}
|
||||||
sources := loadError.Sources()
|
sources := loadError.Sources()
|
||||||
if len(sources) != 1 || sources[0].VersionCheck == nil || !sources[0].VersionCheck.Redacted ||
|
if len(sources) != 1 || sources[0].VersionEvidence == nil || !sources[0].VersionEvidence.Redacted ||
|
||||||
!sources[0].VersionCheck.Version.Present || sources[0].VersionCheck.Version.Text != "" || sources[0].VersionCheck.Path.String() != "/version" {
|
!sources[0].VersionEvidence.Version.Present || sources[0].VersionEvidence.Version.Text != "" || sources[0].VersionEvidence.Path.String() != "/version" {
|
||||||
t.Fatalf("failed-load source evidence = %#v", sources)
|
t.Fatalf("failed-load source evidence = %#v", sources)
|
||||||
}
|
}
|
||||||
sources[0].VersionCheck.Path = configkit.Keys("mutated-error")
|
sources[0].VersionEvidence.Path = configkit.Keys("mutated-error")
|
||||||
sources[0].VersionCheck.Version.Text = "mutated"
|
sources[0].VersionEvidence.Version.Text = "mutated"
|
||||||
sources[0].VersionCheck.Location.Line = 99
|
sources[0].VersionEvidence.Location.Line = 99
|
||||||
if again := loadError.Sources()[0].VersionCheck; again.Path.String() != "/version" || again.Version.Text != "" || again.Location.Line == 99 {
|
if again := loadError.Sources()[0].VersionEvidence; again.Path.String() != "/version" || again.Version.Text != "" || again.Location.Line == 99 {
|
||||||
t.Fatalf("LoadError source evidence was aliased: %#v", again)
|
t.Fatalf("LoadError source evidence was aliased: %#v", again)
|
||||||
}
|
}
|
||||||
for _, test := range []struct {
|
for _, test := range []struct {
|
||||||
@@ -697,7 +707,7 @@ func TestFacadeSourceVersionEvidenceOnFailedLoadsAndSyntheticSources(t *testing.
|
|||||||
if strings.Contains(err.Error(), test.canary) {
|
if strings.Contains(err.Error(), test.canary) {
|
||||||
t.Fatalf("late failure disclosed source-version canary: %v", err)
|
t.Fatalf("late failure disclosed source-version canary: %v", err)
|
||||||
}
|
}
|
||||||
check := failure.Sources()[0].VersionCheck
|
check := failure.Sources()[0].VersionEvidence
|
||||||
if check == nil || !check.Redacted || !check.Version.Present || check.Version.Text != "" {
|
if check == nil || !check.Redacted || !check.Version.Present || check.Version.Text != "" {
|
||||||
t.Fatalf("late-failure evidence was not conservatively redacted: %#v", check)
|
t.Fatalf("late-failure evidence was not conservatively redacted: %#v", check)
|
||||||
}
|
}
|
||||||
@@ -705,7 +715,7 @@ func TestFacadeSourceVersionEvidenceOnFailedLoadsAndSyntheticSources(t *testing.
|
|||||||
}
|
}
|
||||||
|
|
||||||
defaultResult, err := configkit.Load(configkit.Bytes("default", []byte("version: 7\n")), configkit.Options[map[string]any]{})
|
defaultResult, err := configkit.Load(configkit.Bytes("default", []byte("version: 7\n")), configkit.Options[map[string]any]{})
|
||||||
if err != nil || defaultResult == nil || len(defaultResult.Sources()) != 1 || defaultResult.Sources()[0].VersionCheck != nil {
|
if err != nil || defaultResult == nil || len(defaultResult.Sources()) != 1 || defaultResult.Sources()[0].VersionEvidence != nil {
|
||||||
t.Fatalf("default source version check = %#v, %v", defaultResult, err)
|
t.Fatalf("default source version check = %#v, %v", defaultResult, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -718,7 +728,7 @@ func TestFacadeSourceVersionEvidenceOnFailedLoadsAndSyntheticSources(t *testing.
|
|||||||
}
|
}
|
||||||
sawCLI := false
|
sawCLI := false
|
||||||
for _, source := range withCLI.Sources() {
|
for _, source := range withCLI.Sources() {
|
||||||
if source.Kind == configkit.SourceKindCLI && source.VersionCheck != nil {
|
if source.Kind == configkit.SourceKindCLI && source.VersionEvidence != nil {
|
||||||
t.Fatalf("synthetic CLI source acquired declaration evidence: %#v", source)
|
t.Fatalf("synthetic CLI source acquired declaration evidence: %#v", source)
|
||||||
}
|
}
|
||||||
sawCLI = sawCLI || source.Kind == configkit.SourceKindCLI
|
sawCLI = sawCLI || source.Kind == configkit.SourceKindCLI
|
||||||
@@ -754,14 +764,14 @@ func TestFacadeSourceVersionEvidencePreservesAbsenceAndNull(t *testing.T) {
|
|||||||
t.Fatalf("checker calls = %#v; absent contribution must skip while null is checked", checked)
|
t.Fatalf("checker calls = %#v; absent contribution must skip while null is checked", checked)
|
||||||
}
|
}
|
||||||
sources := loaded.Sources()
|
sources := loaded.Sources()
|
||||||
if len(sources) != 3 || sources[1].VersionCheck == nil || sources[2].VersionCheck == nil {
|
if len(sources) != 3 || sources[1].VersionEvidence == nil || sources[2].VersionEvidence == nil {
|
||||||
t.Fatalf("source version evidence = %#v", sources)
|
t.Fatalf("source version evidence = %#v", sources)
|
||||||
}
|
}
|
||||||
absent := sources[1].VersionCheck
|
absent := sources[1].VersionEvidence
|
||||||
if absent.Version.Present || absent.Path.String() != "/version" || absent.Location == nil || absent.Redacted {
|
if absent.Version.Present || absent.Path.String() != "/version" || absent.Location == nil || absent.Redacted {
|
||||||
t.Fatalf("optional omission evidence = %#v", absent)
|
t.Fatalf("optional omission evidence = %#v", absent)
|
||||||
}
|
}
|
||||||
null := sources[2].VersionCheck
|
null := sources[2].VersionEvidence
|
||||||
if !null.Version.Present || !null.Version.Null || null.Version.Text != "" || null.Path.String() != "/version" {
|
if !null.Version.Present || !null.Version.Null || null.Version.Text != "" || null.Path.String() != "/version" {
|
||||||
t.Fatalf("null declaration evidence = %#v", null)
|
t.Fatalf("null declaration evidence = %#v", null)
|
||||||
}
|
}
|
||||||
@@ -797,7 +807,7 @@ func TestFacadeSourceVersionLifecycleKeepsConsumedHeadersOutOfPayloadHistory(t *
|
|||||||
},
|
},
|
||||||
Resolve: func(current *configuration, resolution *configkit.Resolution) error {
|
Resolve: func(current *configuration, resolution *configkit.Resolution) error {
|
||||||
current.Service.Resolved = "resolved once"
|
current.Service.Resolved = "resolved once"
|
||||||
resolution.Record(configkit.Keys("service", "resolved"), configkit.ChangeNormalization, "derive resolved value")
|
resolution.RecordNormalization(configkit.Keys("service", "resolved"), "derive resolved value")
|
||||||
return nil
|
return nil
|
||||||
},
|
},
|
||||||
Validate: func(current *configuration, validation *configkit.Validation) {
|
Validate: func(current *configuration, validation *configkit.Validation) {
|
||||||
@@ -855,8 +865,8 @@ func TestFacadeSourceVersionLifecycleKeepsConsumedHeadersOutOfPayloadHistory(t *
|
|||||||
t.Fatalf("consumed declarations affected effective rendering:\n%s\n%s", firstEffective.String(), secondEffective.String())
|
t.Fatalf("consumed declarations affected effective rendering:\n%s\n%s", firstEffective.String(), secondEffective.String())
|
||||||
}
|
}
|
||||||
sourcesA, sourcesB := first.Sources(), second.Sources()
|
sourcesA, sourcesB := first.Sources(), second.Sources()
|
||||||
if len(sourcesA) != 2 || len(sourcesB) != 2 || sourcesA[1].VersionCheck == nil || sourcesB[1].VersionCheck == nil ||
|
if len(sourcesA) != 2 || len(sourcesB) != 2 || sourcesA[1].VersionEvidence == nil || sourcesB[1].VersionEvidence == nil ||
|
||||||
sourcesA[1].VersionCheck.Version.Text != "underlay-release-a" || sourcesB[1].VersionCheck.Version.Text != "underlay-release-b" {
|
sourcesA[1].VersionEvidence.Version.Text != "underlay-release-a" || sourcesB[1].VersionEvidence.Version.Text != "underlay-release-b" {
|
||||||
t.Fatalf("sanitized evidence did not retain distinct accepted declarations: %#v / %#v", sourcesA, sourcesB)
|
t.Fatalf("sanitized evidence did not retain distinct accepted declarations: %#v / %#v", sourcesA, sourcesB)
|
||||||
}
|
}
|
||||||
versionExplanation, found := first.Explain(configkit.Keys("service", "version"))
|
versionExplanation, found := first.Explain(configkit.Keys("service", "version"))
|
||||||
|
|||||||
50
facade_tier_history_test.go
Normal file
50
facade_tier_history_test.go
Normal file
@@ -0,0 +1,50 @@
|
|||||||
|
package configkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
configkit "go.maximumdirect.net/configkit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestTierHistoryRecordsEachContributionOnce(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
for _, middle := range []string{"mid", "low"} {
|
||||||
|
t.Run(middle, func(t *testing.T) {
|
||||||
|
result, err := configkit.LoadLayered[map[string]any](
|
||||||
|
configkit.Input{Source: configkit.Bytes("main", []byte("x: high\n"))},
|
||||||
|
configkit.LayeredOptions[map[string]any]{Underlays: []configkit.Underlay{
|
||||||
|
{Inputs: []configkit.Input{{Source: configkit.Bytes("low", []byte("x: low\n"))}}},
|
||||||
|
{Inputs: []configkit.Input{{Source: configkit.Bytes("middle", []byte("x: "+middle+"\n"))}}},
|
||||||
|
}},
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
history := result.Metadata().History(configkit.Keys("x"))
|
||||||
|
if len(history) != 3 {
|
||||||
|
t.Fatalf("history has %d records; want the three independent contributions", len(history))
|
||||||
|
}
|
||||||
|
explanation, found := result.Explain(configkit.Keys("x"))
|
||||||
|
if !found || len(explanation.History) != len(history) {
|
||||||
|
t.Fatal("explanation lost ordered tier contributions")
|
||||||
|
}
|
||||||
|
for index, sourceID := range []string{"source-2", "source-3", "source-1"} {
|
||||||
|
change := history[index]
|
||||||
|
if len(change.Origin.Locations) != 1 || change.Origin.Locations[0].SourceID != sourceID {
|
||||||
|
t.Fatalf("contribution %d has origin %#v", index, change.Origin)
|
||||||
|
}
|
||||||
|
if len(explanation.History[index].Origin.Locations) != 1 || explanation.History[index].Origin.Locations[0] != change.Origin.Locations[0] {
|
||||||
|
t.Fatalf("explanation contribution %d has a different source origin", index)
|
||||||
|
}
|
||||||
|
if index == 0 {
|
||||||
|
if len(change.Previous) != 0 {
|
||||||
|
t.Fatal("first source contribution has a previous origin")
|
||||||
|
}
|
||||||
|
} else if len(change.Previous) != 1 || len(change.Previous[0].Locations) != 1 ||
|
||||||
|
change.Previous[0].Locations[0] != history[index-1].Origin.Locations[0] {
|
||||||
|
t.Fatalf("contribution %d has incorrect previous origin %#v", index, change.Previous)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
"testing/fstest"
|
"testing/fstest"
|
||||||
|
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "go.maximumdirect.net/configkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestUnderlaySequenceDescendantOrigins(t *testing.T) {
|
func TestUnderlaySequenceDescendantOrigins(t *testing.T) {
|
||||||
@@ -31,7 +31,7 @@ func TestUnderlaySequenceDescendantOrigins(t *testing.T) {
|
|||||||
Definition: configkit.Definition[map[string]any]{Resolve: func(_ *map[string]any, resolution *configkit.Resolution) error {
|
Definition: configkit.Definition[map[string]any]{Resolve: func(_ *map[string]any, resolution *configkit.Resolution) error {
|
||||||
for _, pointer := range paths {
|
for _, pointer := range paths {
|
||||||
path, _ := configkit.ParsePath(pointer)
|
path, _ := configkit.ParsePath(pointer)
|
||||||
info, ok := resolution.InputInfo(path)
|
info, ok := resolution.EntryInfo(path)
|
||||||
if !ok {
|
if !ok {
|
||||||
t.Errorf("missing resolver input %s", pointer)
|
t.Errorf("missing resolver input %s", pointer)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
configkit "go.maximumdirect.net/configkit"
|
||||||
)
|
)
|
||||||
|
|
||||||
type versionCountingReader struct {
|
type versionCountingReader struct {
|
||||||
|
|||||||
4
go.mod
4
go.mod
@@ -1,11 +1,11 @@
|
|||||||
module gitea.maximumdirect.net/eric/ConfigKit
|
module go.maximumdirect.net/configkit
|
||||||
|
|
||||||
go 1.26.0
|
go 1.26.0
|
||||||
|
|
||||||
require go.yaml.in/yaml/v4 v4.0.0-rc.6
|
require go.yaml.in/yaml/v4 v4.0.0-rc.6
|
||||||
|
|
||||||
require (
|
require (
|
||||||
gitea.maximumdirect.net/eric/ReleaseKit v0.4.1 // indirect
|
gitea.maximumdirect.net/eric/ReleaseKit v0.4.2 // indirect
|
||||||
github.com/yuin/goldmark/v2 v2.1.6 // indirect
|
github.com/yuin/goldmark/v2 v2.1.6 // indirect
|
||||||
golang.org/x/mod v0.40.0 // indirect
|
golang.org/x/mod v0.40.0 // indirect
|
||||||
)
|
)
|
||||||
|
|||||||
4
go.sum
4
go.sum
@@ -1,5 +1,5 @@
|
|||||||
gitea.maximumdirect.net/eric/ReleaseKit v0.4.1 h1:Qn+mdebYOzCVCEXgCbuGSO/WHmJtItwaQ8e3XwY3qtI=
|
gitea.maximumdirect.net/eric/ReleaseKit v0.4.2 h1:P2eDQ29BgyYqHnaz5MV+vzOk7r3s8py2y9k8JISFtJ0=
|
||||||
gitea.maximumdirect.net/eric/ReleaseKit v0.4.1/go.mod h1:LVodqNQ3OIKv9NBq+fFeyMByd1lHY25YOmmpah/NaNI=
|
gitea.maximumdirect.net/eric/ReleaseKit v0.4.2/go.mod h1:LVodqNQ3OIKv9NBq+fFeyMByd1lHY25YOmmpah/NaNI=
|
||||||
github.com/yuin/goldmark/v2 v2.1.6 h1:igy0q9JcqovoFXFx/XjtU8sHVhcUHavPj2sA3EoKJUE=
|
github.com/yuin/goldmark/v2 v2.1.6 h1:igy0q9JcqovoFXFx/XjtU8sHVhcUHavPj2sA3EoKJUE=
|
||||||
github.com/yuin/goldmark/v2 v2.1.6/go.mod h1:G6M4/qOFtfn01/o14BU1UR2Lo5N3S9Qo7xCuz/sHjGQ=
|
github.com/yuin/goldmark/v2 v2.1.6/go.mod h1:G6M4/qOFtfn01/o14BU1UR2Lo5N3S9Qo7xCuz/sHjGQ=
|
||||||
go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=
|
go.yaml.in/yaml/v4 v4.0.0-rc.6 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user