Compare commits
251 Commits
v0.5.0
...
bc6b977585
| Author | SHA1 | Date | |
|---|---|---|---|
| bc6b977585 | |||
| d06aae5461 | |||
| 4f941f8ba4 | |||
| 9a49833339 | |||
| 749cf6868c | |||
| a5f0b92ee9 | |||
| 6ec23b2de1 | |||
| 3298eb916c | |||
| 40430e787c | |||
| f434fa159f | |||
| 8ddf73ede3 | |||
| 844a15a3cb | |||
| 58f4c39d6f | |||
| 8a54cecd1d | |||
| 0e430e8077 | |||
| 26caa6024e | |||
| 8691a93ebf | |||
| c563f80934 | |||
| 27797d2392 | |||
| 6a4e57e5d3 | |||
| 29f94d89e1 | |||
| 12f6045da0 | |||
| 6eb80b06e3 | |||
| 86feb6d755 | |||
| 8bf49f6ea6 | |||
| f3d0fe6411 | |||
| cba3a0efb0 | |||
| b58cd75c98 | |||
| c530a989df | |||
| 2baf14d9a5 | |||
| 6903c9e51a | |||
| 31e10af9cb | |||
| 0d841d9d73 | |||
| 4bcefbc9d4 | |||
| 91db99b967 | |||
| 5747ec49c6 | |||
| d3a714a1cb | |||
| 7f8ad81065 | |||
| 75da54150f | |||
| 38fb315415 | |||
| 1c79ec3aa1 | |||
| 01e1b2fc2d | |||
| 15943e233c | |||
| f6a6b0184b | |||
| 898035d83a | |||
| de3666aed3 | |||
| c00811da2e | |||
| 55abcf8340 | |||
| 9d7c79cf0b | |||
| 24131617c7 | |||
| 05b67cbfcf | |||
| e433988962 | |||
| 05a28d1178 | |||
| 1e5bffc0e6 | |||
| f5a689b578 | |||
| e4abff4164 | |||
| 1a8f1cc3a5 | |||
| 9b99463187 | |||
| f69fa24b40 | |||
| 77d97296ea | |||
| 032679bf34 | |||
| 55a59de6db | |||
| a0d13962bd | |||
| 5f33e1e90c | |||
| b750b0fec6 | |||
| b498e5bee5 | |||
| ba577429e6 | |||
| 014b99504b | |||
| 263dd2f1bd | |||
| af43d6f438 | |||
| a4cbd6390e | |||
| 5ed0f55970 | |||
| 91ab37a39d | |||
| fbe9ae6d8b | |||
| 1aa121df71 | |||
| 6e40c166d6 | |||
| a56d4691ff | |||
| 2718fbb8fe | |||
| 2d52421919 | |||
| 167fcd475f | |||
| aa93aeda3f | |||
| 94381a67fd | |||
| c26b34147b | |||
| c9f9c98fbe | |||
| f6bf4454b0 | |||
| a7ca2c59a2 | |||
| 10c14aa794 | |||
| e9774b252b | |||
| 19fe621df1 | |||
| 82b289f4fe | |||
| 137769f3f1 | |||
| ef28eabaf8 | |||
| 008d2ba9c8 | |||
| 8e3a0892d4 | |||
| 023e428b9c | |||
| 527a38daf1 | |||
| 30bcd8eeb6 | |||
| 884215c403 | |||
| 8a89806e44 | |||
| 26720b98f0 | |||
| 4633a1b8a2 | |||
| 87986a12d2 | |||
| 0858af1029 | |||
| acddce8e2f | |||
| 77c5d9482a | |||
| adef16ccdd | |||
| c19825620c | |||
| 190431740a | |||
| 25cf6d32b6 | |||
| 1a04bb3835 | |||
| 1cf3b0d78f | |||
| 66dced0823 | |||
| b1b5163cc3 | |||
| bb9bde799a | |||
| dc7a70811a | |||
| ff212885c4 | |||
| 90c2d7e409 | |||
| 60e6e2697f | |||
| 20511773fc | |||
| 31fb6a9bd1 | |||
| 103e10639a | |||
| 97c5a0390a | |||
| 8b547765f0 | |||
| 5215cf5c4f | |||
| e66c37161c | |||
| e4270c9b14 | |||
| e69ad0f32d | |||
| 0a2246bdbe | |||
| e5a6df7af7 | |||
| 25cb3c47e7 | |||
| 020d72084d | |||
| 358b28c421 | |||
| b0550af6a3 | |||
| 43c994a32a | |||
| 1e47ee21cd | |||
| fed591183b | |||
| 1f95cf905a | |||
| 15f5b591b9 | |||
| 184f29c7af | |||
| 813d6a38c9 | |||
| 23ae700194 | |||
| 49218a836b | |||
| 2fcf892324 | |||
| 8645cab79a | |||
| 70c95bf660 | |||
| d9f08ff719 | |||
| 2c91e41d10 | |||
| e3860036f8 | |||
| b3fc6a5950 | |||
| a858a066e4 | |||
| 10edd95d4d | |||
| 08e03f6a38 | |||
| a20ba0af27 | |||
| 6458dbd3aa | |||
| 43e55dfa85 | |||
| 430985c629 | |||
| 4635ef08d5 | |||
| 49c0c6d908 | |||
| a8beb3e620 | |||
| 934c1022e0 | |||
| 7248a8bd95 | |||
| b2db3233b2 | |||
| edb6d05ec1 | |||
| 315f464dfc | |||
| a029a2a754 | |||
| 945e111c4e | |||
| 2d8df22dd1 | |||
| 53601e68ed | |||
| 751787ec24 | |||
| f50122d9f8 | |||
| c71faf2065 | |||
| 2f5d57ce1d | |||
| a1c557603f | |||
| a2fab66aba | |||
| 2f1f7f4b8a | |||
| 667a536ae7 | |||
| ea55ab43ce | |||
| d3d468a8b4 | |||
| 6353598bd6 | |||
| 3c2d7facb3 | |||
| 6fffde4af8 | |||
| 9e04a397c4 | |||
| c54a9bf51e | |||
| 8f3396d6f0 | |||
| 1c2610c2c0 | |||
| 92025d65cb | |||
| a4b5a6840a | |||
| 2ebdfb539b | |||
| a37e6895e1 | |||
| 8ae542b3a3 | |||
| 174efb65f6 | |||
| 756149beaa | |||
| e5dddef204 | |||
| ade5d6a273 | |||
| c20ea4b916 | |||
| 2e17e365a1 | |||
| fcf914a2ab | |||
| 3697a51124 | |||
| 9194c5b52a | |||
| e82b1c9054 | |||
| 6f59cdd237 | |||
| 1a885934c2 | |||
| 5917cebf50 | |||
| 8e91a2b16b | |||
| 08402d098b | |||
| 53b79528d9 | |||
| f849e06b77 | |||
| b37948cfea | |||
| 9323f89ad1 | |||
| 6c62226838 | |||
| 799ebab5a6 | |||
| c8546ae599 | |||
| d32c94eca3 | |||
| 344f4f73d7 | |||
| 5fedc11c8f | |||
| 5b8b7a2415 | |||
| e591e97f22 | |||
| 71e142afb4 | |||
| ca1ba04676 | |||
| 528495c75a | |||
| aa3b4a5881 | |||
| db2d8ed217 | |||
| d34a98d34b | |||
| 9ac65ccc08 | |||
| edc6660654 | |||
| a2084df11a | |||
| b59f002314 | |||
| 52793301ba | |||
| 0c77deed05 | |||
| 48619de024 | |||
| c546124029 | |||
| 673faea097 | |||
| 91ae4fa7ef | |||
| f1722274c0 | |||
| d53021ea2c | |||
| a22aaa5dfa | |||
| b1ecd9a078 | |||
| 84b59c690d | |||
| ac90b9835b | |||
| f887fac809 | |||
| c03457c814 | |||
| e9b6b63e5e | |||
| 1dc50d8cdf | |||
| 0e92085002 | |||
| f96f2b8295 | |||
| 1917dcf921 | |||
| 0ac4731903 | |||
| c42930c6d5 | |||
| 2d3ea588f1 | |||
| 5aafd943b4 | |||
| c0605fab44 |
48
README.md
48
README.md
@@ -9,7 +9,7 @@ ConfigKit's simple `Load` accepts one strict YAML source (or explicit
|
||||
defaults-only input). `LoadLayered` additionally combines explicit lower-priority
|
||||
source groups with an independently selected main input. Sources can be host
|
||||
files, native `fs.FS` files, labeled bytes or readers, or snapshotted mappings.
|
||||
Enabled path and glob imports compose strictly within their group; precedence
|
||||
Enabled explicit imports compose strictly within their group; precedence
|
||||
between groups combines mappings recursively. Both entry points return a complete,
|
||||
validated configuration with detached metadata and safe diagnostics.
|
||||
Applications may perform audited defaulting and normalization through a resolver,
|
||||
@@ -20,7 +20,27 @@ secrets-directory bootstrap is available as a separate startup operation. Explic
|
||||
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.
|
||||
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).
|
||||
|
||||
Imports are also explicit: a source may name files, search declared directories,
|
||||
or select files with path globs. Applications can set load-wide import defaults
|
||||
while keeping declaration-specific exceptions. Files are acquired in authored
|
||||
declaration order; dependency-ready composition uses a shared stable scheduler.
|
||||
ConfigKit does not search conventional application paths implicitly. See the
|
||||
[import contract](docs/config.md#explicit-imports), [consumer examples](docs/consumers/README.md),
|
||||
and [ADR 0015](docs/adr/0015-shared-dependency-ready-ordering.md) and
|
||||
[ADR 0016](docs/adr/0016-independent-import-controls.md).
|
||||
|
||||
```go
|
||||
type Config struct {
|
||||
@@ -58,18 +78,26 @@ 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.
|
||||
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
|
||||
these priorities and how genuine new needs can justify extending the shared
|
||||
model. The [deferred scope](docs/roadmap/deferred.md) records deliberate exclusions.
|
||||
model. The [future feature list](docs/roadmap/future.md) preserves candidates for
|
||||
consideration in future releases.
|
||||
|
||||
The [configuration contract](docs/config.md) describes implemented behavior. The
|
||||
[consumer adoption guide](docs/consumers/README.md) provides maintained simple
|
||||
native-filesystem, layered, and sibling-inheritance examples alongside the migration shapes.
|
||||
See the [development guide](docs/development.md) for repository orientation and the
|
||||
[release procedure](docs/release.md) for maintainer-only candidate and publication
|
||||
steps.
|
||||
The [configuration contract](docs/config.md) describes implemented behavior,
|
||||
including [input destinations](docs/config.md#input-destinations). The
|
||||
[consumer adoption guide](docs/consumers/README.md) provides maintained simple,
|
||||
layered, mounted-collection, and identified-prompt examples alongside the
|
||||
migration shapes. Input placement decisions are recorded in
|
||||
[ADR 0011](docs/adr/0011-input-destinations.md) and sequence placement and
|
||||
composition in [ADR 0014](docs/adr/0014-sequence-placement-and-composition.md).
|
||||
See the [development guide](docs/development.md) for repository orientation and
|
||||
the [release procedure](docs/release.md) for maintainer-only candidate and
|
||||
publication steps.
|
||||
|
||||
ConfigKit code is licensed under the [LGPL v3.0 or later](COPYRIGHT).
|
||||
|
||||
|
||||
@@ -43,10 +43,99 @@ func toEngineLocations(values []Location) []engine.Location {
|
||||
return result
|
||||
}
|
||||
func fromEngineSourceInfo(value engine.SourceInfo) SourceInfo {
|
||||
return SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Layer: Layer(value.Layer), Kind: SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex, ImportedFrom: fromEngineLocationPointer(value.ImportedFrom)}
|
||||
return SourceInfo{
|
||||
ID: value.ID, Label: value.Label, Path: value.Path, Destination: fromEnginePath(value.Destination),
|
||||
PlacementMode: PlacementMode(engine.SourceInfoPlacementModeForFacade(value)),
|
||||
DestinationUnresolved: engine.SourceInfoDestinationUnresolvedForFacade(value),
|
||||
Layer: Layer(value.Layer), Kind: SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex,
|
||||
ImportedFrom: fromEngineLocationPointer(value.ImportedFrom), VersionCheck: fromEngineSourceVersionCheck(value.VersionCheck),
|
||||
ImportSelections: fromEngineImportSelections(engine.SourceInfoImportSelectionsForFacade(value)),
|
||||
}
|
||||
}
|
||||
func toEngineSourceInfo(value SourceInfo) engine.SourceInfo {
|
||||
return engine.SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Layer: engine.Layer(value.Layer), Kind: engine.SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex, ImportedFrom: toEngineLocationPointer(value.ImportedFrom)}
|
||||
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),
|
||||
}
|
||||
result = engine.SourceInfoWithImportSelections(result, toEngineImportSelections(value.ImportSelections))
|
||||
return engine.SourceInfoWithPlacement(result, uint8(value.PlacementMode), value.DestinationUnresolved)
|
||||
}
|
||||
|
||||
func fromEngineImportSelections(values []engine.ImportSelectionForFacade) []ImportSelection {
|
||||
if values == nil {
|
||||
return nil
|
||||
}
|
||||
result := make([]ImportSelection, len(values))
|
||||
for index, value := range values {
|
||||
result[index] = ImportSelection{
|
||||
Path: fromEnginePath(value.Path), Location: fromEngineLocation(value.Location),
|
||||
Selector: ImportSelector(value.Selector), Outcome: ImportSelectionOutcome(value.Outcome),
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func toEngineImportSelections(values []ImportSelection) []engine.ImportSelectionForFacade {
|
||||
if values == nil {
|
||||
return nil
|
||||
}
|
||||
result := make([]engine.ImportSelectionForFacade, len(values))
|
||||
for index, value := range values {
|
||||
result[index] = engine.ImportSelectionForFacade{
|
||||
Path: toEnginePath(value.Path), Location: toEngineLocation(value.Location),
|
||||
Selector: string(value.Selector), Outcome: string(value.Outcome),
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func toEngineImportDefaults(value ImportDefaults) engine.ImportDefaults {
|
||||
return engine.ImportDefaults{
|
||||
Required: cloneImportDefaultBool(value.Required),
|
||||
Follow: cloneImportDefaultBool(value.Follow),
|
||||
Hidden: cloneImportDefaultBool(value.Hidden),
|
||||
Recursive: cloneImportDefaultBool(value.Recursive),
|
||||
Pattern: cloneImportDefaultStrings(value.Pattern),
|
||||
}
|
||||
}
|
||||
|
||||
func cloneImportDefaultBool(value *bool) *bool {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := *value
|
||||
return &cloned
|
||||
}
|
||||
|
||||
func cloneImportDefaultStrings(values []string) []string {
|
||||
if values == nil {
|
||||
return nil
|
||||
}
|
||||
cloned := make([]string, len(values))
|
||||
copy(cloned, values)
|
||||
return cloned
|
||||
}
|
||||
|
||||
func fromEngineSourceVersionCheck(value *engine.SourceVersionCheck) *SourceVersionCheck {
|
||||
if value == nil {
|
||||
return nil
|
||||
}
|
||||
return &SourceVersionCheck{
|
||||
Path: fromEnginePath(value.Path),
|
||||
Version: Version{Present: value.Version.Present, Null: value.Version.Null, Text: value.Version.Text},
|
||||
Location: fromEngineLocationPointer(value.Location), Redacted: value.Redacted,
|
||||
}
|
||||
}
|
||||
func 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 {
|
||||
result := Origin{Layer: Layer(value.Layer), Locations: fromEngineLocations(value.Locations), Operation: ChangeKind(value.Operation)}
|
||||
|
||||
@@ -66,7 +66,7 @@ func TestDevelopmentCheckHandlesFilenamesAndStopsOnFailure(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd := exec.Command("/bin/bash", filepath.Join(root, "scripts", scriptName))
|
||||
cmd.Env = append(os.Environ(), "PATH="+bin+string(os.PathListSeparator)+os.Getenv("PATH"), "CONFIGKIT_BASELINE_TOOLCHAIN=test-baseline", "CONFIGKIT_NEWER_TOOLCHAIN=", "CONFIGKIT_TEST_FMT_LOG="+log, "CONFIGKIT_TEST_GO_LOG="+calls)
|
||||
cmd.Env = append(os.Environ(), "PATH="+bin+string(os.PathListSeparator)+os.Getenv("PATH"), "CONFIGKIT_TEST_FMT_LOG="+log, "CONFIGKIT_TEST_GO_LOG="+calls)
|
||||
// Log only targets selected by the script, not inherited host settings.
|
||||
cmd.Env = append(cmd.Env, "GOOS=", "GOARCH=")
|
||||
if name == "failed-go-check" {
|
||||
@@ -108,90 +108,3 @@ func TestDevelopmentCheckHandlesFilenamesAndStopsOnFailure(t *testing.T) {
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckScriptsReleaseGates(t *testing.T) {
|
||||
script, err := os.ReadFile("scripts/release-check.sh")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, scenario := range []string{"success", "failed-test", "missing-toolchain"} {
|
||||
t.Run(scenario, func(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
bin := t.TempDir()
|
||||
for _, directory := range []string{"scripts", "nested"} {
|
||||
if err := os.Mkdir(filepath.Join(root, directory), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
checker := filepath.Join(root, "scripts", "release-check.sh")
|
||||
if err := os.WriteFile(checker, script, 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
calls := filepath.Join(bin, "go.calls")
|
||||
goStub := `#!/bin/sh
|
||||
printf '%s|%s|%s|%s|%s|%s\n' "$GOTOOLCHAIN" "$GOPROXY" "${CGO_ENABLED-}" "${GOOS-}" "${GOARCH-}" "$*" >> "$CONFIGKIT_TEST_GO_LOG"
|
||||
if [ "${CONFIGKIT_TEST_FAIL-}" = toolchain ] && [ "${1-}" = version ]; then exit 37; fi
|
||||
if [ "${CONFIGKIT_TEST_FAIL-}" = test ] && [ "${1-}" = test ]; then exit 37; fi
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(bin, "go"), []byte(goStub), 0700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cmd := exec.Command("/bin/bash", checker)
|
||||
cmd.Dir = filepath.Join(root, "nested")
|
||||
cmd.Env = append(os.Environ(),
|
||||
"PATH="+bin+string(os.PathListSeparator)+os.Getenv("PATH"),
|
||||
"CONFIGKIT_BASELINE_TOOLCHAIN=test-baseline",
|
||||
"CONFIGKIT_TEST_GO_LOG="+calls,
|
||||
"GOOS=", "GOARCH=", "CGO_ENABLED=",
|
||||
)
|
||||
switch scenario {
|
||||
case "failed-test":
|
||||
cmd.Env = append(cmd.Env, "CONFIGKIT_TEST_FAIL=test")
|
||||
case "missing-toolchain":
|
||||
cmd.Env = append(cmd.Env, "CONFIGKIT_TEST_FAIL=toolchain")
|
||||
}
|
||||
output, err := cmd.CombinedOutput()
|
||||
if scenario == "success" && err != nil {
|
||||
t.Fatalf("checker: %v\n%s", err, output)
|
||||
}
|
||||
if scenario != "success" && err == nil {
|
||||
t.Fatalf("checker accepted %s", scenario)
|
||||
}
|
||||
data, err := os.ReadFile(calls)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
lines := strings.Split(strings.TrimSpace(string(data)), "\n")
|
||||
for _, line := range lines {
|
||||
if !strings.HasPrefix(line, "test-baseline|off|") {
|
||||
t.Fatalf("toolchain or offline setting lost: %q", line)
|
||||
}
|
||||
}
|
||||
if scenario == "missing-toolchain" {
|
||||
if len(lines) != 1 || !strings.HasSuffix(lines[0], "|version") {
|
||||
t.Fatalf("checker ran after unavailable toolchain: %q", lines)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(lines) < 2 || lines[1] != "test-baseline|off||||test -mod=readonly -vet=off -race -v ./..." {
|
||||
t.Fatalf("release suite command changed: %q", lines)
|
||||
}
|
||||
if scenario == "failed-test" {
|
||||
if len(lines) != 2 {
|
||||
t.Fatalf("checker continued after failure: %q", lines)
|
||||
}
|
||||
return
|
||||
}
|
||||
want := []string{
|
||||
"test-baseline|off||||version",
|
||||
"test-baseline|off||||test -mod=readonly -vet=off -race -v ./...",
|
||||
"test-baseline|off||||vet -mod=readonly ./...",
|
||||
"test-baseline|off|0|linux|amd64|build -mod=readonly ./...",
|
||||
"test-baseline|off|0|linux|arm64|build -mod=readonly ./...",
|
||||
}
|
||||
if strings.Join(lines, "\n") != strings.Join(want, "\n") {
|
||||
t.Fatalf("unexpected release gates:\n%s", data)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
53
diff.go
Normal file
53
diff.go
Normal file
@@ -0,0 +1,53 @@
|
||||
package configkit
|
||||
|
||||
import "gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
|
||||
|
||||
// Difference describes one canonical changed region between two successful
|
||||
// loads. Left is the earlier value and Right is the later value. Each snapshot
|
||||
// is detached; Present distinguishes absence from an explicit null, and
|
||||
// Redacted reports whether combined sensitivity affected that side. Mappings
|
||||
// are compared recursively, while sequences are reported as atomic values.
|
||||
// Records are ordered by canonical JSON pointer. HiddenChanged indicates that
|
||||
// a value concealed by combined sensitivity changed, without revealing which
|
||||
// concealed member changed.
|
||||
type Difference struct {
|
||||
Path Path
|
||||
Left ValueSnapshot
|
||||
Right ValueSnapshot
|
||||
HiddenChanged bool
|
||||
}
|
||||
|
||||
// Diff compares the frozen effective values of two successful loads of the
|
||||
// same configuration type. It does not reread sources, environment variables,
|
||||
// or rerun defaults or resolvers. Equal values produce no records even when
|
||||
// their sources, defaults, or resolution histories differ. Credential contents
|
||||
// are not compared: typed secret references compare by reference name only.
|
||||
//
|
||||
// Both results must be non-nil successful loads with inspection state; nil or
|
||||
// zero results return an error and no records. Load errors are returned by
|
||||
// Load, so callers must handle those before calling Diff. Snapshots are safe
|
||||
// for presentation but are not a complete reloadable configuration.
|
||||
func Diff[T any](left, right *Result[T]) ([]Difference, error) {
|
||||
var leftInspection, rightInspection *engine.Inspection
|
||||
if left != nil {
|
||||
leftInspection = left.inspection
|
||||
}
|
||||
if right != nil {
|
||||
rightInspection = right.inspection
|
||||
}
|
||||
|
||||
values, err := engine.Diff(leftInspection, rightInspection)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
differences := make([]Difference, len(values))
|
||||
for index, value := range values {
|
||||
differences[index] = Difference{
|
||||
Path: fromEnginePath(value.Path),
|
||||
Left: fromEngineValueSnapshot(value.Left),
|
||||
Right: fromEngineValueSnapshot(value.Right),
|
||||
HiddenChanged: value.HiddenChanged,
|
||||
}
|
||||
}
|
||||
return differences, nil
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
# ADR 0005: Record explicit CLI overrides and safe value explanation
|
||||
|
||||
Status: accepted
|
||||
Status: Partially superseded by
|
||||
[ADR 0018](0018-cli-after-structural-resolution.md) for override-before-decoding
|
||||
ordering. The remaining ownership and safe explanation decisions remain accepted.
|
||||
|
||||
Date: 2026-09-06
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
## Status
|
||||
|
||||
Proposed draft for review. Completed planning and remediation records are
|
||||
Proposed draft for review; its whole-sequence precedence replacement rule is
|
||||
superseded by [ADR 0017](0017-collection-sequence-policy.md). The remainder
|
||||
retains its proposed status. Completed planning and remediation records are
|
||||
preserved in Git history at `b604aa1` under `docs/roadmap/`; the
|
||||
[configuration contract](../config.md#layered-loading) owns current behavior.
|
||||
|
||||
|
||||
@@ -2,8 +2,14 @@
|
||||
|
||||
## Status
|
||||
|
||||
Accepted and implemented. The [configuration contract](../config.md#sibling-inheritance)
|
||||
owns current behavior. The completed implementation plan is preserved in Git
|
||||
Partially superseded by [ADR 0017](0017-collection-sequence-policy.md) for
|
||||
sequence combination and the addition of identified-sequence sibling scopes,
|
||||
and [ADR 0018](0018-cli-after-structural-resolution.md) for CLI-before-inheritance
|
||||
ordering. Existing mapping-sibling scopes and the remaining ownership and
|
||||
inspection decisions remain accepted.
|
||||
|
||||
The [configuration contract](../config.md#sibling-inheritance) owns implemented
|
||||
behavior. The completed implementation plan is preserved in Git
|
||||
history at `83c59ea`.
|
||||
|
||||
## Date
|
||||
|
||||
99
docs/adr/0010-effective-configuration-diff.md
Normal file
99
docs/adr/0010-effective-configuration-diff.md
Normal file
@@ -0,0 +1,99 @@
|
||||
# ADR 0010: Compare Frozen Effective Configuration Snapshots
|
||||
|
||||
## Status
|
||||
|
||||
Accepted and implemented. The
|
||||
[configuration contract](../config.md#effective-configuration-diffs) owns current
|
||||
behavior. Completed planning and remediation records are preserved in Git
|
||||
history at `1c2610c` under `docs/roadmap/`.
|
||||
|
||||
## Date
|
||||
|
||||
2026-10-09
|
||||
|
||||
## Context
|
||||
|
||||
Applications need to inspect how two completed configurations differ, including
|
||||
the effective impact of an overlay after defaults and application resolution.
|
||||
ConfigKit already retains canonical typed inspection snapshots, semantic paths,
|
||||
presence information, and sensitivity classification for successful loads.
|
||||
|
||||
Extending this inspection model lets applications share configuration comparison
|
||||
and safe disclosure while keeping their presentation and domain interpretation.
|
||||
The operation should follow ConfigKit's existing equality, ownership, and secret
|
||||
boundaries so consumers can reason about loading and inspection consistently.
|
||||
|
||||
## Decision
|
||||
|
||||
Provide structured effective-value comparison between successful results of the
|
||||
same configuration type. Compare their retained canonical inspection snapshots
|
||||
through the root-owned public facade. Each comparison uses the completed load
|
||||
state and returns detached records with a semantic path and safe left and right
|
||||
value snapshots.
|
||||
|
||||
Use canonical value equality and the existing structural comparison rules.
|
||||
Mappings compare recursively, sequences report atomic changes, and scalar or
|
||||
kind replacements report the affected region. Presence distinguishes additions
|
||||
and removals from explicit null. Equal effective values produce an empty diff;
|
||||
source attribution, explicit-input status, and history remain separately
|
||||
inspectable through each result. Canonical pointer ordering makes reporting
|
||||
deterministic.
|
||||
|
||||
Detect changes before masking values. Apply sensitivity from both loads to both
|
||||
returned sides, including historical and dependency-derived classification.
|
||||
Preserve public content where the shapes permit corresponding sensitive regions
|
||||
to be identified. When a structural replacement prevents safe correspondence,
|
||||
mask the complete reported replacement region on both present sides. Absence
|
||||
retains its own representation.
|
||||
|
||||
Keep masking and hidden-change information distinct. Each record indicates
|
||||
whether an actual change is withheld by redaction, allowing applications to
|
||||
display a changed-sensitive-value indicator even when safe snapshots render
|
||||
identically. Public-only changes alongside unchanged masked content retain that
|
||||
distinction.
|
||||
|
||||
Typed secret references participate through their configured environment names.
|
||||
Resolved credential bytes remain behind the existing explicit secret accessor.
|
||||
The comparison uses the same frozen inspection boundary as effective rendering
|
||||
and explanation.
|
||||
|
||||
ConfigKit owns comparison and paired safe disclosure. Applications own labels,
|
||||
layout, and rendering of the returned changed regions. Reusing the existing
|
||||
snapshot representation and engine boundary keeps the extension consistent with
|
||||
the library's established inspection model.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Compare input text or separately rendered YAML.** Supports familiar textual
|
||||
displays, but input text precedes composition and resolution, and independently
|
||||
redacted output can hide changes or apply different disclosure rules. Canonical
|
||||
comparison with paired masking represents the completed configuration.
|
||||
- **Leave comparison to applications using returned Go values.** Offers direct
|
||||
access to application types, but duplicates structural and redaction machinery
|
||||
and observes caller mutation. Retained inspection snapshots preserve the
|
||||
completed load's values and established ownership boundary.
|
||||
- **Align sequence elements and return context-rich rendering data.** Enables
|
||||
finer-grained displays, but requires additional correspondence and disclosure
|
||||
rules. Atomic sequence records and existing value snapshots satisfy the
|
||||
selected changed-region workflows with a smaller public model.
|
||||
- **Compare resolved credentials or retain credential-derived tokens.** Could
|
||||
detect rotation behind an unchanged reference, but expands retained sensitive
|
||||
state and introduces a separate trusted comparison mechanism. Reference-name
|
||||
comparison preserves the existing inspection boundary.
|
||||
|
||||
## Consequences
|
||||
|
||||
Applications can compare independently loaded configurations using ConfigKit's
|
||||
shared equality, presence, and disclosure rules. Existing composition and load
|
||||
semantics continue to determine the values being compared. Caller mutation of a
|
||||
returned configuration leaves its retained comparison snapshot unchanged.
|
||||
|
||||
Paired redaction can conceal public content within a structurally incompatible
|
||||
replacement. The changed region and hidden-change indicator still communicate
|
||||
its effective impact. Credential rotation behind an unchanged environment name
|
||||
produces equal reference values for comparison.
|
||||
|
||||
The implementation requires operation-local disclosure preparation and focused
|
||||
coverage for presence, shape changes, historical sensitivity, and detached
|
||||
results. Applications retain control of presentation; changed-region records
|
||||
supply the safe data needed for that responsibility.
|
||||
74
docs/adr/0011-input-destinations.md
Normal file
74
docs/adr/0011-input-destinations.md
Normal file
@@ -0,0 +1,74 @@
|
||||
# ADR 0011: Place Configuration Inputs at Mapping Destinations
|
||||
|
||||
## Status
|
||||
|
||||
Accepted and implemented. Current placement behavior is owned by the
|
||||
[configuration contract](../config.md#input-destinations). The accepted
|
||||
relative-import extension is recorded in
|
||||
[ADR 0012](0012-relative-import-destinations.md). The completed roadmap, consumer
|
||||
request, and review records are preserved at `e4270c9` under `docs/roadmap/` in
|
||||
Git history; the original input-destination implementation plan remains at
|
||||
`315f464:docs/roadmap/implementation.md`.
|
||||
|
||||
## Date
|
||||
|
||||
2026-10-14
|
||||
|
||||
## Context
|
||||
|
||||
Consumers assembling collections of independently authored configuration
|
||||
fragments need each source to contribute beneath a selected application path.
|
||||
Requiring consumers to rewrite source documents or merge completed values would
|
||||
separate placement from ConfigKit's existing composition, precedence, source
|
||||
identity, and inspection behavior. Placement should remain part of the ordinary
|
||||
loading lifecycle and preserve the root-owned public facade.
|
||||
|
||||
## Decision
|
||||
|
||||
Add an optional `Destination Path` to root-owned `Input` and expose the effective
|
||||
destination on existing `SourceInfo` records. The empty path means the existing
|
||||
application root. A non-empty destination is a sequence of literal mapping keys;
|
||||
explicit sequence-index segments are invalid. Existing reserved structural keys
|
||||
remain reserved, while keys that merely contain their spelling remain ordinary
|
||||
keys.
|
||||
|
||||
Acquire and interpret each source using its original physical identity and
|
||||
source-local directives, then place its application body before strict
|
||||
composition and the existing final lifecycle. Imported application bodies
|
||||
inherit the input destination. Placement applies to main and underlay inputs;
|
||||
ordinary root loading, selected overlays, and CLI inputs retain root placement.
|
||||
The main-owned version rule continues to check its effective application path
|
||||
at its existing lifecycle point.
|
||||
|
||||
Placement preserves authored node locations and source identity. Introduced
|
||||
mapping structure belongs to the contributing source and uses that document's
|
||||
root location as its source-coordinate fallback. A present empty mapping
|
||||
materializes its destination; an absent `NoSource` contributes nothing. The
|
||||
result continues through the existing strict composition, overlay, inheritance,
|
||||
typed loading, diagnostics, provenance, history, sensitivity, and resource
|
||||
context mechanisms.
|
||||
|
||||
The added exported fields preserve keyed and zero-value struct use. Callers
|
||||
using unkeyed composite literals for `Input` or `SourceInfo` must migrate to
|
||||
keyed literals. Treat this feature as minor-release work; select the version and
|
||||
document the compatibility impact during separately authorized release
|
||||
preparation, consistent with the release policy.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Rewrite or merge sources in consumers.** This makes each consumer reproduce
|
||||
placement, strict composition, source attribution, and inspection semantics.
|
||||
- **Add a separate loading entry point or public wrapper type.** This creates a
|
||||
parallel API for a behavior that belongs to existing per-input loading.
|
||||
- **Load each fragment as a complete application before placement.** This runs
|
||||
defaults, decoding, resolution, and validation on incomplete fragments rather
|
||||
than once on the assembled application.
|
||||
|
||||
## Consequences
|
||||
|
||||
Consumers can assemble contributions at effective mapping paths in one ordinary
|
||||
layered load and inspect each source's placement through existing source
|
||||
records. Paths, conflicts, and later application operations use the placed
|
||||
structure while source coordinates and relative-resource contexts continue to
|
||||
identify their original sources. Public field additions require release-note
|
||||
guidance for callers using unkeyed literals.
|
||||
78
docs/adr/0012-relative-import-destinations.md
Normal file
78
docs/adr/0012-relative-import-destinations.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# ADR 0012: Place Imported Configuration at Relative Destinations
|
||||
|
||||
## Status
|
||||
|
||||
Accepted and implemented. The
|
||||
[configuration contract](../config.md#input-destinations) owns current behavior.
|
||||
The completed roadmap and review records are preserved at `e4270c9` under
|
||||
`docs/roadmap/` in Git history; the relative-import implementation plan remains
|
||||
at `d9f08ff:docs/roadmap/implementation.md`.
|
||||
|
||||
## Date
|
||||
|
||||
2026-10-14
|
||||
|
||||
## Context
|
||||
|
||||
ConfigKit can place an input document at a literal mapping path while retaining
|
||||
source identity, composition, and inspection in its normal loading lifecycle.
|
||||
An importing document can currently bring in other documents, but those
|
||||
contributions inherit only the importing input's destination. Consumers that
|
||||
assemble a collection therefore cannot declare the layout of imported
|
||||
fragments alongside the import itself without rewriting source documents or
|
||||
performing another merge outside ConfigKit.
|
||||
|
||||
The extension should reuse input placement and the existing import lifecycle.
|
||||
Logical application placement must remain independent from physical path
|
||||
resolution, source identity, precedence, and resource ownership.
|
||||
|
||||
## Decision
|
||||
|
||||
Add an optional `destination` field to each YAML import declaration. Its value
|
||||
is a sequence of strings representing literal mapping-key segments. Omission
|
||||
and an empty sequence add no segments. A non-empty destination appends to the
|
||||
effective destination of the immediately importing document; nested imports
|
||||
continue from the resulting child destination. Every match of one glob
|
||||
declaration receives the same computed destination.
|
||||
|
||||
Use the existing input-destination validation and reserved-key rules. Do not
|
||||
interpret destination segments as filesystem paths, separators, sequence
|
||||
indices, or root-reset instructions. Physical import paths continue to resolve
|
||||
from their declaring source under existing containment rules.
|
||||
|
||||
Imports remain in their existing strict-composition group. A destination does
|
||||
not grant precedence, bypass cycle or repeated-source rules, or change source
|
||||
identity. Existing placement, version ownership, source records, diagnostics,
|
||||
resource contexts, inheritance, history, and inspection mechanisms continue to
|
||||
govern the placed contributions. No public Go entry point or independent
|
||||
specification-header behavior is introduced.
|
||||
|
||||
This is additive YAML functionality and is minor-release work. Version
|
||||
selection, release notes, roadmap retirement, and publication remain separate
|
||||
release-preparation work.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
- **Require complete application-root destinations on every import.** This
|
||||
avoids inherited context but repeats collection prefixes in nested source
|
||||
declarations and does not express placement relative to the importing
|
||||
document.
|
||||
- **Encode destinations as dotted or slash-delimited strings.** This introduces
|
||||
separator and escaping rules even though existing paths already represent
|
||||
literal mapping keys as segments.
|
||||
- **Rewrite or merge imported documents in consumers.** This duplicates source
|
||||
placement, strict composition, attribution, and inspection behavior outside
|
||||
ConfigKit's loading lifecycle.
|
||||
- **Add independent specification headers or collection-specific validation.**
|
||||
These are separate design questions and are not required to declare relative
|
||||
placement.
|
||||
|
||||
## Consequences
|
||||
|
||||
Import declarations can describe the logical placement of their contributions
|
||||
while preserving the physical source and existing composition lifecycle.
|
||||
Placement is immediate-parent-relative, so nested imports accumulate their
|
||||
declared segments and sibling declarations remain independent. The feature is
|
||||
implemented behind the existing public loading surface; detailed validation,
|
||||
diagnostic, accounting, and inspection requirements are tracked by the
|
||||
implementation roadmap.
|
||||
92
docs/adr/0013-shared-source-version-checks.md
Normal file
92
docs/adr/0013-shared-source-version-checks.md
Normal file
@@ -0,0 +1,92 @@
|
||||
# ADR 0013: Check Source Versions with One Shared Policy
|
||||
|
||||
## Status
|
||||
|
||||
Accepted and implemented. The
|
||||
[configuration contract](../config.md#shared-source-version-checks) owns current
|
||||
behavior. The completed roadmap, implementation plan, consumer request, and
|
||||
resolved review findings are preserved at `e4270c9` under `docs/roadmap/` in
|
||||
Git history; the [development guide](../development.md#baseline-validation)
|
||||
provides retrieval examples.
|
||||
|
||||
## Date
|
||||
|
||||
2026-10-15
|
||||
|
||||
## Context
|
||||
|
||||
Applications that assemble one configuration from independently authored
|
||||
documents need a way to require that each contributing document declares a
|
||||
version accepted by the application's existing `Definition.Version` checker.
|
||||
Checking only the eventual composed value lets another source hide a rejected
|
||||
declaration, while separate consumer-side parsing duplicates ConfigKit's source
|
||||
handling and diagnostics.
|
||||
|
||||
The extension must preserve one application-owned acceptance policy and the
|
||||
existing loading lifecycle. Source declarations are compatibility evidence, not
|
||||
independent fragment schemas or values to compare for equality. Existing
|
||||
composition, placement, precedence, source identity, diagnostics, and
|
||||
sensitivity rules remain authoritative.
|
||||
|
||||
## Decision
|
||||
|
||||
Provide a load-wide source-version mode for ordinary and layered loading. The
|
||||
zero-value mode preserves current behavior. The enabled modes check declarations
|
||||
when present or require them, using the existing `Definition.Version` path and
|
||||
checker. Interpret the configured path relative to each participating source
|
||||
before placement or composition. Different declarations may be accepted by the
|
||||
same checker; ConfigKit adds no equality, SemVer, or expected-version rule.
|
||||
|
||||
Apply the selected policy to the actual main document, direct underlays,
|
||||
imports, and selected overlays. Missing non-main declarations are skipped in
|
||||
if-present mode and rejected in required mode. Existing main absence behavior
|
||||
remains, with required mode additionally rejecting an absent declaration on an
|
||||
actual main document. `NoSource`, unmatched optional inputs, synthetic field
|
||||
operations, and empty groups do not become documents that need declarations.
|
||||
|
||||
Reuse the existing version vocabulary and validation boundary: absence, null,
|
||||
exact strings, and strict decimal integers retain their established meanings;
|
||||
unsupported present values fail version-type validation. Warnings remain
|
||||
nonfatal, and source declarations do not replace final application decoding or
|
||||
domain validation.
|
||||
|
||||
After an accepted non-main declaration is checked, consume exactly that node
|
||||
before its remaining application content is composed or overlaid. Preserve the
|
||||
main declaration and its value semantics. Keep the declaration, source-local
|
||||
path, physical location, and checked absence as detached source evidence,
|
||||
separate from effective application values and history. Apply established
|
||||
sensitivity rules to that evidence, including original mounted paths and
|
||||
ancestors; retain existing safe failure disclosure.
|
||||
|
||||
In enabled modes, the main declaration remains source-local while its protected
|
||||
application path is derived from the main input's destination. This distinction
|
||||
does not change default-mode path interpretation or weaken protection of
|
||||
remaining payload and synthetic operations.
|
||||
|
||||
Public option-field additions and the enabled-mode interpretation for mounted
|
||||
main inputs are compatibility considerations for separately authorized minor-
|
||||
release preparation. This decision does not select a release version or
|
||||
authorize release notes or publication.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
- **Check only the final composed value.** Later sources can hide an earlier
|
||||
rejected declaration, so this does not validate every contributing document.
|
||||
- **Let consumers parse and check each source.** This duplicates acquisition,
|
||||
source attribution, safe diagnostics, and lifecycle coordination in every
|
||||
application.
|
||||
- **Require declarations to be equal or impose a universal version grammar.**
|
||||
Consumers already own the acceptance policy; ConfigKit need not invent an
|
||||
expected value or a domain-specific compatibility rule.
|
||||
- **Keep accepted non-main declarations in application payload.** A selected
|
||||
source-local declaration is metadata for this policy and cannot also remain
|
||||
an ordinary field at the same path without ambiguity.
|
||||
|
||||
## Consequences
|
||||
|
||||
Consumers can apply one existing version policy across contributing documents
|
||||
and select gradual or required adoption per load. Accepted non-main headers no
|
||||
longer participate in application composition; their inspectable evidence is
|
||||
kept separate and subject to sensitivity classification. Default callers retain
|
||||
current behavior. The implementation plan records the concrete API names,
|
||||
integration order, and validation evidence.
|
||||
81
docs/adr/0014-sequence-placement-and-composition.md
Normal file
81
docs/adr/0014-sequence-placement-and-composition.md
Normal file
@@ -0,0 +1,81 @@
|
||||
# ADR 0014: Place and Compose Sequence Items Explicitly
|
||||
|
||||
## Status
|
||||
|
||||
Partially superseded by [ADR 0017](0017-collection-sequence-policy.md) for
|
||||
identity ownership and sequence combination. Explicit source placement remains
|
||||
accepted.
|
||||
|
||||
The [configuration contract](../config.md#strict-document-composition)
|
||||
defines implemented behavior, and the [consumer guide](../consumers/README.md#identified-prompt-sequences)
|
||||
shows the maintained example. The completed roadmap, implementation plan, and
|
||||
resolved remediation record are preserved in Git history at `008d2ba` under
|
||||
`docs/roadmap/`; see the [development guide](../development.md) for retrieval
|
||||
commands.
|
||||
|
||||
## Date
|
||||
|
||||
2026-10-16
|
||||
|
||||
## Context
|
||||
|
||||
ConfigKit strictly composes independent documents by recursively combining
|
||||
mappings and rejecting other overlapping values. This makes two independently
|
||||
authored sequence fragments conflict, even when their records are distinct.
|
||||
Precedence assembly has a different purpose: a higher-priority present sequence
|
||||
replaces the lower-priority sequence as one value. Those existing rules are
|
||||
predictable, but they do not express the separate operation of collecting
|
||||
independent records while rejecting duplicate identities.
|
||||
|
||||
Likewise, mapping destinations place a source beneath literal mapping keys, but
|
||||
there is no explicit way to state that a source mapping represents one item in a
|
||||
sequence. Inferring that intent from document shape or fields would make source
|
||||
meaning implicit and change existing placement behavior.
|
||||
|
||||
## Decision
|
||||
|
||||
Keep mapping placement as the zero-value behavior and add an explicit item
|
||||
placement choice for a source whose mapping should become one element at a
|
||||
non-root sequence destination. Item placement provides structure only; it does
|
||||
not infer or synthesize identity values.
|
||||
|
||||
Allow a definition to register literal mapping paths and one literal identity
|
||||
field for sequences at those paths. Within each strict composition tier, a
|
||||
registered sequence accepts mapping records with a present string identity,
|
||||
appends distinct records in contribution order, and rejects repeated exact
|
||||
identity strings. Identical registrations coalesce; conflicting identity fields
|
||||
for the same path are invalid. Registration does not recursively select paths
|
||||
inside sequence records.
|
||||
|
||||
Keep precedence assembly unchanged. A present higher-priority sequence replaces
|
||||
the lower-priority value wholesale, including at a registered path. Registration
|
||||
changes strict composition only; it does not turn precedence into append or
|
||||
identity-based merging.
|
||||
|
||||
This decision preserves ConfigKit's distinction between independent
|
||||
same-priority contributions and intentional precedence. Explicit source
|
||||
placement and definition-owned composition policy make the two required choices
|
||||
visible while keeping the existing zero-value load behavior.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
- **Infer item placement from a source mapping or identity field.** Shape-based
|
||||
inference would make ordinary mapping placement depend on document contents
|
||||
and would leave the intended destination ambiguous.
|
||||
- **Append all overlapping sequences during strict composition.** This would
|
||||
change existing conflict semantics at unregistered paths and would not detect
|
||||
duplicate records.
|
||||
- **Merge matching records recursively by identity.** This would add a second
|
||||
recursive merge rule with different conflict and precedence behavior from
|
||||
ConfigKit's existing mapping composition and overlay operations.
|
||||
- **Use identity to merge sequences across precedence tiers.** This would
|
||||
change established whole-value replacement semantics and conflate strict
|
||||
composition with precedence.
|
||||
|
||||
## Consequences
|
||||
|
||||
Consumers can explicitly place mappings as sequence items and opt selected
|
||||
sequences into duplicate-rejecting strict composition. Unregistered sequences
|
||||
retain their current strict-conflict behavior, and higher-priority sequences
|
||||
continue to replace lower-priority ones. The implementation plan owns the exact
|
||||
API, YAML syntax, diagnostics, and implementation status.
|
||||
68
docs/adr/0015-shared-dependency-ready-ordering.md
Normal file
68
docs/adr/0015-shared-dependency-ready-ordering.md
Normal file
@@ -0,0 +1,68 @@
|
||||
# ADR 0015: Share Dependency-Ready Ordering Across Imports and Inheritance
|
||||
|
||||
## Status
|
||||
|
||||
Accepted. Implemented behavior is described in the
|
||||
[configuration contract](../config.md#explicit-imports) and
|
||||
[internal import overview](../internal/imports.md).
|
||||
|
||||
## Date
|
||||
|
||||
2026-10-19
|
||||
|
||||
## Context
|
||||
|
||||
Imports and sibling inheritance both resolve relationships between configuration
|
||||
contributions. Their existing resolvers use different traversal rules, making
|
||||
ordering depend on which mechanism expresses a dependency. Ordering affects
|
||||
sequence contributions, provenance, and diagnostics, so a consistent rule is
|
||||
part of ConfigKit's configuration model.
|
||||
|
||||
The resolvers also serve distinct purposes. Imports strictly compose sources;
|
||||
inheritance overlays a child on its resolved parent after source assembly.
|
||||
Item-local contributions and nested inheritance introduce scope boundaries that
|
||||
resolution must preserve.
|
||||
|
||||
## Decision
|
||||
|
||||
ConfigKit owns one shared dependency-ready scheduler for imports and sibling
|
||||
inheritance. Both resolvers use Kahn's algorithm with a stable ordering key,
|
||||
selecting the smallest currently eligible node after its dependencies have
|
||||
completed. The shared scheduler owns eligibility, deterministic tie-breaking,
|
||||
and dependency-cycle identification. Each resolver owns construction of its
|
||||
dependency relationships and application of its domain operations.
|
||||
|
||||
Apply the common scheduling rule within the scopes where dependencies are
|
||||
available. Complete item-local contributions before item placement and resolve
|
||||
enclosing inheritance before relationships in the resulting nested scopes.
|
||||
Source discovery supplies the import graph; the scheduler determines resolution
|
||||
order.
|
||||
|
||||
Strict composition, inheritance overlay, and precedence tiers retain their
|
||||
existing ownership and meanings under [ADR 0001](0001-separate-composition-and-overlay.md).
|
||||
The lifecycle and scope decisions in
|
||||
[ADR 0009](0009-sibling-inheritance.md) continue to govern inheritance. Import
|
||||
admission retains its cycle and repeated-source rules.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
- **Deterministic depth-first postorder.** Keeps dependency branches together,
|
||||
but gives traversal branch selection an additional role in ordering.
|
||||
- **Dependency rounds.** Groups nodes by readiness rounds, but delays newly
|
||||
eligible nodes until the current round finishes.
|
||||
- **Descending longest-path depth.** Prioritizes the furthest dependencies,
|
||||
but adds distance from the root as an ordering policy.
|
||||
- **Separate resolver algorithms.** Allows local specialization, but leaves
|
||||
consumers and maintainers with different rules for analogous dependencies.
|
||||
|
||||
## Consequences
|
||||
|
||||
Both resolvers follow one deterministic ordering model and share responsibility
|
||||
for maintaining that model. Resolver-specific scopes, combination semantics,
|
||||
and source-admission rules remain explicit at their integration boundaries.
|
||||
|
||||
Independent branches may interleave as nodes become eligible. Adopting this
|
||||
rule changes existing traversal-dependent sequence positions and reporting
|
||||
order, requiring coordinated review of source assembly, provenance, and
|
||||
inheritance behavior. The configuration contract owns the implemented ordering
|
||||
details.
|
||||
83
docs/adr/0016-independent-import-controls.md
Normal file
83
docs/adr/0016-independent-import-controls.md
Normal file
@@ -0,0 +1,83 @@
|
||||
# ADR 0016: Separate Import Selection, Traversal, and Presence Requirements
|
||||
|
||||
## Status
|
||||
|
||||
Accepted. Implemented behavior is described in the
|
||||
[configuration contract](../config.md#explicit-imports), the
|
||||
[public API declarations](../../lifecycle.go), and the
|
||||
[internal import overview](../internal/imports.md).
|
||||
|
||||
## Date
|
||||
|
||||
2026-10-19
|
||||
|
||||
## Context
|
||||
|
||||
Consumers assemble configurations from named files and collections whose layout
|
||||
varies between installations. Simple imports should require little setup, while
|
||||
larger collections need control over discovery, recursion, filtering, and
|
||||
required contributions. Combining those choices implicitly makes declarations
|
||||
harder to interpret and pushes source-selection work into applications.
|
||||
|
||||
Discovery can also reach a candidate through overlapping searches or encounter
|
||||
different names for one canonical source. Selection and source admission need
|
||||
distinct identities to support useful search unions while preserving ConfigKit's
|
||||
existing source-integrity rules.
|
||||
|
||||
## Decision
|
||||
|
||||
ConfigKit owns import discovery and selection within its source-loading
|
||||
lifecycle. Support explicit files, directory searches, and path globs as
|
||||
distinct selection forms. Keep traversal, filename filtering, and presence
|
||||
requirements independently expressible. Discovery determines the selected
|
||||
contributions; the shared scheduler in
|
||||
[ADR 0015](0015-shared-dependency-ready-ordering.md) determines their resolution
|
||||
order.
|
||||
|
||||
Use optional-by-default imports with explicit requiredness controls. Selected
|
||||
sources remain subject to ConfigKit's validity, containment, resource, and
|
||||
composition contracts. This applies the library's
|
||||
[simplicity and defaults principles](../policy/architecture.md#simplicity-defaults-and-explicit-controls)
|
||||
to import absence while retaining meaningful errors for invalid contributions.
|
||||
|
||||
Applications own their selected import defaults. Authored declaration values
|
||||
take precedence over application defaults, which take precedence over library
|
||||
defaults. Each declaration resolves applicable controls independently,
|
||||
including declarations in imported sources. This gives applications a shared
|
||||
baseline while preserving explicit choices in individual declarations.
|
||||
|
||||
Keep declaration-local candidate identity separate from canonical source
|
||||
identity. Search unions coalesce repeated candidate paths within a source
|
||||
namespace before symlink resolution; filename filtering uses the selected or
|
||||
discovered name. Source admission continues to use canonical identity and the
|
||||
existing repeated-source rules. Distinct aliases retain their separate
|
||||
inclusion routes.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
- **Use path globs for all discovery.** Offers one selection form, but makes
|
||||
collection traversal depend on path-pattern structure and complicates
|
||||
independently controlling recursion and filename filtering.
|
||||
- **Offer only explicit files and directory searches.** Reduces the selector
|
||||
vocabulary, but moves wildcard directory selection into repeated declarations
|
||||
or application-side discovery.
|
||||
- **Require every import by default.** Detects absent contributions immediately,
|
||||
but requires repeated opt-outs for installations with optional components.
|
||||
- **Make application settings override authored declarations.** Provides central
|
||||
enforcement, but prevents declarations from expressing local exceptions.
|
||||
- **Deduplicate selections globally by canonical source identity.** Coalesces
|
||||
aliases and separate inclusion routes, but changes the existing repeated-source
|
||||
contract and obscures independently authored contributions.
|
||||
|
||||
## Consequences
|
||||
|
||||
Consumers can express simple imports and more selective collection searches
|
||||
through the same loading lifecycle. Applications choose defaults and validate
|
||||
their required effective configuration; ConfigKit owns consistent selection,
|
||||
source admission, composition, and inspection.
|
||||
|
||||
Optional absence can conceal misspelled selectors, so requiredness and
|
||||
inspection provide explicit ways to detect and explain missing contributions.
|
||||
Declaration-local deduplication requires the loader to preserve candidate
|
||||
identity alongside canonical source identity. The configuration and public API
|
||||
contracts own the implemented behavior.
|
||||
143
docs/adr/0017-collection-sequence-policy.md
Normal file
143
docs/adr/0017-collection-sequence-policy.md
Normal file
@@ -0,0 +1,143 @@
|
||||
# ADR 0017: Define Identity and Merge Rules for Each Collection
|
||||
|
||||
## Status
|
||||
|
||||
Accepted.
|
||||
|
||||
This supersedes the decisions about who declares identity and how
|
||||
sequences combine in [ADR 0014](0014-sequence-placement-and-composition.md),
|
||||
and the rule that inheritance always replaces sequences in
|
||||
[ADR 0009](0009-sibling-inheritance.md). It also extends that ADR's sibling
|
||||
scopes to identified sequences. Existing mapping-sibling scopes and source
|
||||
placement continue to apply. It supersedes the whole-sequence
|
||||
precedence replacement rule in the proposed
|
||||
[ADR 0007](0007-source-backed-underlays.md).
|
||||
|
||||
## Date
|
||||
|
||||
2026-10-22
|
||||
|
||||
## Context
|
||||
|
||||
ConfigKit can load separate files as records in a sequence. Applications can
|
||||
register an identity key so records with different identities combine and
|
||||
duplicates fail. However, a higher configuration layer or an inheriting mapping
|
||||
currently replaces the whole sequence. Applications that need to extend a
|
||||
catalog across layers must write their own merging code and keep source and
|
||||
history information consistent with its results.
|
||||
|
||||
Configuration authors need to choose how records are identified and whether a
|
||||
layer adds records, updates records, or replaces the collection. Applications
|
||||
also need a way to require unique identities in the final loaded configuration.
|
||||
These rules must work consistently for imported records, inline sequences,
|
||||
overlays, and inheritance.
|
||||
|
||||
Records in a sequence may also share settings. Authors need to reuse those
|
||||
settings through inheritance while keeping each record's own identity.
|
||||
|
||||
## Decision
|
||||
|
||||
ConfigKit manages identity and merge rules for each collection during loading.
|
||||
Configuration files can declare those rules for imported and inline sequences,
|
||||
including sequences in selected overlays. Applications can require a particular
|
||||
identity definition, and configuration declarations must agree with it. Source
|
||||
placement determines where each contribution enters the configuration.
|
||||
|
||||
Keep the distinction between contributions at the same priority and overrides
|
||||
from a higher layer, as established in
|
||||
[ADR 0001](0001-separate-composition-and-overlay.md). Within one tier, records
|
||||
with distinct identities combine; duplicate identities and conflicting
|
||||
declarations fail. Determine the applicable rules before combining those
|
||||
contributions, including any identity established in lower tiers.
|
||||
|
||||
Across layers, support three operations: replace the whole sequence, append
|
||||
records while rejecting duplicate identities, or replace whole records by
|
||||
identity (`upsert`). Source layering and sibling inheritance use the same
|
||||
operations. For inheritance, the rules at the inheriting destination determine
|
||||
which operation applies.
|
||||
|
||||
An identified sequence is also a sibling scope for inheritance. A record may
|
||||
inherit from one other record in the same sequence, selected by its complete
|
||||
identity. Each record supplies its own identity before inheritance. The child
|
||||
keeps that identity and combines its settings with the parent's other fields,
|
||||
using the existing mapping and collection rules. Parents remain ordinary records
|
||||
subject to normal validation.
|
||||
|
||||
Resolve these relationships after source assembly, using the same dependency
|
||||
ordering as mapping-sibling inheritance. Resolve nested relationships at each
|
||||
inheriting destination. Preserve the assembled sequence order. Whole-record
|
||||
upsert keeps its existing meaning; inheritance then uses the records that remain.
|
||||
Typed defaults, CLI overrides, and application resolution follow the lifecycle
|
||||
in [ADR 0018](0018-cli-after-structural-resolution.md).
|
||||
|
||||
An explicitly selected operation remains in effect at its destination until a
|
||||
higher layer changes it. When no explicit operation applies, use upsert if an
|
||||
identity definition is available; otherwise replace the whole sequence. Authors
|
||||
can declare identity and an operation before supplying any records. Keep source
|
||||
acquisition order separate from layer priority and the dependency ordering
|
||||
established by [ADR 0015](0015-shared-dependency-ready-ordering.md).
|
||||
|
||||
Rules declared inside a record belong to that record. Retaining the record
|
||||
retains its local rules; replacing it discards them. Replacing a whole collection
|
||||
can discard its previous YAML identity definition, while application requirements
|
||||
still apply. Inheritance can copy collection identity, but the inheriting
|
||||
destination chooses its operation. Application registrations continue to govern
|
||||
the paths where they were registered. The same ownership rules apply to
|
||||
declarations made before a sequence exists.
|
||||
|
||||
Check YAML-declared identity when combining collections during source assembly
|
||||
and inheritance. Application registrations also require the final loaded
|
||||
sequences to satisfy their identity rules, including after typed defaults, CLI
|
||||
overrides, and application resolution. This guarantee checks sequences that
|
||||
exist; the application schema and validation decide whether a collection must
|
||||
exist and which value types are allowed. The guarantee applies when loading
|
||||
completes, rather than to every intermediate callback state or changes made by
|
||||
the caller afterward.
|
||||
|
||||
Preserve explicit presence, source attribution, history, sensitivity, and
|
||||
resource context through these operations, following
|
||||
[ADR 0003](0003-preserve-presence-and-resolution-history.md). Typed defaults,
|
||||
CLI assignments, and application resolution keep their own rules for changing
|
||||
values. The public API provides registration and inspection; the implementation
|
||||
chooses how to store and track collection rules internally.
|
||||
|
||||
## Alternatives Considered
|
||||
|
||||
- **Keep identity application-owned and always replace sequences across layers.**
|
||||
Keeps the current design small, but reusable catalogs require application
|
||||
changes or custom merging code.
|
||||
- **Put merge rules on each import.** Keeps the rules beside the imported source,
|
||||
but inline sequences and overlays need a separate way to declare them. It also
|
||||
makes rules for a collection spread across several imports harder to reconcile.
|
||||
- **Merge individual fields of records with matching identities.** Allows partial
|
||||
record updates, but adds rules for field conflicts and retained values.
|
||||
Replacing the whole record makes the update's scope clear.
|
||||
- **Keep inheritance limited to mapping siblings.** Keeps parent lookup simpler,
|
||||
but authors of sequence records must duplicate shared settings or applications
|
||||
must implement inheritance themselves. Collection identity provides a way to
|
||||
select a parent within the existing loading process.
|
||||
- **Check every YAML identity again at the end of loading.** Gives YAML declarations
|
||||
authority over values changed by defaults, CLI assignments, and application
|
||||
resolution. This extends the lifetime of those declarations; application
|
||||
registration provides that final guarantee when needed.
|
||||
|
||||
## Consequences
|
||||
|
||||
Consumers can build and customize catalogs in one load while retaining useful
|
||||
inspection information. Configuration authors choose how collections combine
|
||||
within application requirements. Duplicate identities among contributions at
|
||||
the same priority remain errors.
|
||||
|
||||
Existing registered collections use upsert across layers by default unless an
|
||||
explicit operation selects otherwise. Correct loading now requires tracking
|
||||
which declarations still apply as records are replaced, copied, or moved to new
|
||||
positions. Lower-tier identity must be available before combining higher-tier
|
||||
contributions.
|
||||
|
||||
Record inheritance also requires parent lookup by identity and checking for
|
||||
missing parents and cycles. Copied settings retain the same source, history,
|
||||
and sensitivity information as mapping-sibling inheritance.
|
||||
|
||||
The configuration contract will define declaration syntax, identity comparison,
|
||||
omission and empty-value behavior, and diagnostics when the feature is
|
||||
implemented.
|
||||
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 and consumer guidance
|
||||
must explain these behavior changes when they are implemented.
|
||||
396
docs/config.md
396
docs/config.md
@@ -67,13 +67,18 @@ ID `source-1`; enabled imports continue in depth-first order as `source-2` onwar
|
||||
contributes no source, and continues through defaults, decode, and projection. A
|
||||
missing file remains an error; it is never interpreted as no input.
|
||||
|
||||
The current `Options` boundary accepts a defaults factory, `VersionRule`, resolver,
|
||||
semantic validator, explicit path and glob imports, one selected overlay, and
|
||||
filesystem context. It also accepts explicit CLI override records, static secret
|
||||
tags, and a dynamic path predicate. `NoSource` rejects imports, active overlays,
|
||||
and filesystem context as invalid combinations but accepts overrides. Input
|
||||
containing a reserved `configkit` mapping key is rejected while imports are
|
||||
disabled.
|
||||
`Options` accepts the application definition, ordinary-source `Destination` and
|
||||
`PlacementMode`, explicit imports, one selected overlay, filesystem
|
||||
context, explicit CLI overrides, and shared source-version controls. The definition
|
||||
may register strict sequence composition paths through
|
||||
`SequenceCompositions`. Its zero-valued placement mode places the source mapping
|
||||
at the destination; `sequence-item` explicitly wraps the mapping as one item at a
|
||||
non-root destination. Unknown modes, invalid destinations, and invalid or
|
||||
conflicting sequence registrations fail before source acquisition. Overlay and
|
||||
CLI coordinates remain rooted at the application root. `NoSource` rejects
|
||||
imports, active overlays, and filesystem context as invalid combinations but
|
||||
accepts overrides. Input containing a reserved `configkit` mapping key is rejected
|
||||
while imports are disabled.
|
||||
|
||||
Every source, options, schema, version, defaults, decode, resolution, or validation
|
||||
failure returns a nil result and a `*LoadError`. Successful results contain caller-owned `Config`, detached
|
||||
@@ -149,9 +154,60 @@ have index zero. Explicit empty groups and `NoSource` consume no source ID.
|
||||
Byte, source, node, and glob-entry budgets are shared across the load, while
|
||||
the existing per-source and import-depth bounds still apply.
|
||||
|
||||
When a source mapping is explicitly placed as an item, its effective destination
|
||||
is the sequence container. After assembly assigns an index, nested source
|
||||
destinations are projected through that item. Physical source locations and
|
||||
resource contexts continue to identify the declaring source.
|
||||
|
||||
#### Input destinations
|
||||
|
||||
`Input.Destination` places the source's application mapping beneath a path
|
||||
relative to the configuration root. Its zero or empty value means root, so
|
||||
existing inputs keep their behavior. Main inputs and underlay inputs use the
|
||||
same destination model. Destination segments are literal mapping keys: dots,
|
||||
slashes, and numeric-looking text are not split or interpreted as sequence
|
||||
indexes. Explicit index segments and paths that introduce the reserved
|
||||
`configkit` or mapping-valued `extends` structure are invalid input options and
|
||||
produce a structured `LoadError` before source acquisition.
|
||||
|
||||
Source-root `configkit.imports` directives are processed before placement. An
|
||||
import without an explicit destination inherits the effective destination of
|
||||
its importing document, including through nested imports. An import may instead
|
||||
declare a relative destination as a sequence of literal mapping keys; its keys
|
||||
append to the importing document's effective destination. Omission and `[]`
|
||||
add no keys. Nested imports continue relative to their immediate importing
|
||||
document, each declaration is independent of its siblings, and all matches of
|
||||
one glob receive the same resulting destination. This logical placement does
|
||||
not change how the physical import path resolves from its declaring file.
|
||||
|
||||
A valid destination on `NoSource()` contributes nothing; an actual empty
|
||||
mapping materializes its destination containers. Once placed, contributions
|
||||
follow the same strict composition within a tier and recursive precedence
|
||||
behavior described above. A selected overlay and CLI overrides remain rooted at
|
||||
the application root and are not moved beneath the main input's destination.
|
||||
|
||||
`SourceInfo.Destination` reports each acquired source's effective mapping or
|
||||
sequence-container path, including imports. `PlacementMode` reports how that
|
||||
source was placed; mapping is the zero value, and CLI/overlay sources report
|
||||
mapping unless item placement was applied. `DestinationUnresolved` is true only
|
||||
when an enclosing item index needed to name the source's destination has not yet
|
||||
been assigned. In that case `Destination` reports the nearest resolved enclosing
|
||||
collection container and omits the unresolved local suffix. A source's own item
|
||||
placement still has an exact destination at its sequence container. Completed
|
||||
loads project nested source destinations through their owning items. Callback and
|
||||
failed-load snapshots preserve the evidence available at that point; a fallback
|
||||
snapshot remains unresolved, while a later exact snapshot is false. Returned
|
||||
source records and paths are detached. Placement does not change source IDs,
|
||||
acquisition identity, physical source locations, or passive resource contexts.
|
||||
Introduced ancestor mappings use the contributing document's root location as a
|
||||
source fallback; authored content retains its original source coordinates.
|
||||
|
||||
The maintained [layered consumer example](consumers/README.md#native-filesystem-and-layered-input)
|
||||
shows a main host file, two underlay tiers, embedded defaults, a mapping source,
|
||||
and explicit resource access without a second configuration pass.
|
||||
The accepted rationale for input placement and its relative-import extension
|
||||
is recorded in [ADR 0011](adr/0011-input-destinations.md) and
|
||||
[ADR 0012](adr/0012-relative-import-destinations.md).
|
||||
|
||||
### Sibling inheritance
|
||||
|
||||
@@ -164,6 +220,13 @@ value of null cancels a relationship without looking up a parent. Other value
|
||||
types, missing or non-mapping parents, and cycles fail the load with value-free
|
||||
diagnostics.
|
||||
|
||||
Within each sibling scope, direct mapping definitions resolve by dependency
|
||||
readiness. A parent is resolved before a child; among currently eligible
|
||||
definitions, the lexically smallest canonical semantic pointer is selected,
|
||||
including newly eligible definitions as soon as their parent completes. This
|
||||
ordering governs inheritance operations and their history, controls, and
|
||||
transfers. It does not reorder the mappings in the resulting configuration.
|
||||
|
||||
`extends` is reserved structural input across ordinary and layered loads, every
|
||||
source kind, imports, selected overlays, and explicit CLI overrides. It is
|
||||
consumed before typed decoding and need not appear in the application's Go
|
||||
@@ -269,6 +332,67 @@ error without reporting one, ConfigKit emits a generic `version.hook_failed`
|
||||
diagnostic and suppresses the returned error text. Warnings alone are retained on a
|
||||
successful result. Callback panics remain programming failures.
|
||||
|
||||
#### Shared source-version checks
|
||||
|
||||
`Options.SourceVersionMode` and `LayeredOptions.SourceVersionMode` select how
|
||||
one `Definition.Version` policy applies across source documents. The zero value
|
||||
`SourceVersionModeDefault` preserves the existing main-only check at its
|
||||
effective path and existing protected-path behavior. `SourceVersionModeIfPresent`
|
||||
checks each participating document that declares a version and permits absent
|
||||
non-main declarations. `SourceVersionModeRequired` rejects a participating
|
||||
document without a declaration. Enabled modes require a usable `VersionRule`
|
||||
and checker; an unknown mode or unusable rule is an options error before source
|
||||
access or callbacks.
|
||||
|
||||
In enabled modes, `Definition.Version.Path` is interpreted in each acquired
|
||||
document's own coordinates, before placement and composition. The main document,
|
||||
direct underlay documents, imported documents (including transitive and glob
|
||||
matches), and the selected overlay participate. `NoSource`, absent optional
|
||||
imports, empty underlay groups, defaults, resolution output, inheritance, and
|
||||
synthetic CLI overrides are not documents to check. An absent declaration in an
|
||||
actual main document still reaches the checker in if-present mode; required mode
|
||||
rejects it first. With `NoSource`, the existing single absent-main checker
|
||||
behavior remains in both enabled modes. If-present omissions in non-main
|
||||
documents skip the checker. Explicit null is present and reaches the checker;
|
||||
unsupported selected values produce `version.type`. No equality with the main
|
||||
declaration or non-null rule is imposed. Acceptance establishes only that the
|
||||
declaration is accepted by the consumer's checker; it does not establish that
|
||||
each fragment conforms to a complete application schema. Final assembled
|
||||
decoding and application validation still run once.
|
||||
|
||||
A header that is absent below a structural control follows the same omission
|
||||
policy. Actual header/control overlap is rejected, and ordinary structural
|
||||
validation and main-version ownership protection continue independently.
|
||||
|
||||
Accepted non-main declarations are consumed from their owned payload before
|
||||
composition; their authored ancestors remain, even if empty. The main
|
||||
declaration remains in the application. Ordinary release metadata should use a
|
||||
different path. Enabled main protection uses the main input's effective path:
|
||||
its `Destination` prefixed to the source-local `VersionRule.Path`. This protects
|
||||
the remaining payload against imports, underlays, inheritance, and CLI
|
||||
overrides. Thus a mounted main looks up a local header while ownership checks
|
||||
protect its destination-prefixed application path. Main checker diagnostics and
|
||||
declaration locations use source-local coordinates; ordinary payload
|
||||
diagnostics continue to use effective paths. Default-mode diagnostics are
|
||||
unchanged.
|
||||
|
||||
`SourceInfo.VersionCheck` is nil when enabled declaration processing did not
|
||||
occur. Otherwise it records a source-local `Path`, the observed `Version` state
|
||||
(`Present`, `Null`, and, when disclosed, exact `Text`), an actual or nearest
|
||||
available `Location`, and whether text was redacted. A non-nil record with
|
||||
`Present == false` records observed absence; it does not say the checker ran or
|
||||
accepted a declaration. Absent evidence still identifies the full requested
|
||||
header path, while its location identifies the nearest available source node.
|
||||
During callbacks and in failed-load source records,
|
||||
text is conservatively withheld because final classification may not be
|
||||
available. Successful final source records reflect completed sensitivity
|
||||
classification at the declaration's mounted path and ancestors, including
|
||||
historical classification. Redaction empties `Text` while retaining presence
|
||||
and null state. Returned copies are detached; evidence does not reopen inputs.
|
||||
Consult [ADR 0013](adr/0013-shared-source-version-checks.md) for the accepted
|
||||
decision and the [development guide](development.md#baseline-validation) for
|
||||
the completed implementation and remediation records in Git history.
|
||||
|
||||
### Layered metadata
|
||||
|
||||
`Result.Metadata` describes every final canonical path. Queries retain explicit
|
||||
@@ -299,15 +423,21 @@ the descendant can therefore retain history after `Lookup` stops reporting it as
|
||||
effective. Equal-valued input is still an explicit base definition, while a derived
|
||||
value for absent input remains non-explicit.
|
||||
|
||||
Appending registered records during strict composition assigns each contribution
|
||||
its assembled sequence index in history. A later precedence tier that supplies a
|
||||
sequence records replacement at the sequence path; history does not imply
|
||||
identity-based merging across tiers.
|
||||
|
||||
A mapping assembled from the selected file and imports has one composite base origin
|
||||
whose locations retain source traversal order. A mapping also visited by an overlay
|
||||
has an overlay-layer composite origin with the lower contributing locations followed
|
||||
by the overlay location; an empty mapping is still an explicit container even though
|
||||
it contributes no children. Resolution origins are synthetic: they have layer
|
||||
whose locations follow [scoped assembly order](#explicit-imports), not source-record
|
||||
acquisition order. A mapping also visited by an overlay has an overlay-layer
|
||||
composite origin with the lower contributing locations followed by the overlay
|
||||
location; an empty mapping is still an explicit container even though it contributes
|
||||
no children. Resolution origins are synthetic: they have layer
|
||||
`resolution`, no fabricated file location, and a `derived_default` or `normalization`
|
||||
operation. Reasons and dependency paths retain first occurrence order with duplicates
|
||||
removed. Source records remain ordered as selected source, depth-first imports, the
|
||||
selected overlay, then synthetic CLI records in supplied order.
|
||||
removed. Source records and IDs retain [acquisition order](#explicit-imports),
|
||||
including the underlay ordering described in [layered loading](#layered-loading).
|
||||
|
||||
Inheritance adds no acquired source or precedence layer. `Origin.Inheritance`
|
||||
lists concrete transfers from contributor paths through destinations, with
|
||||
@@ -370,9 +500,11 @@ historical path. Its effective value and each chronological before/after value a
|
||||
`ValueSnapshot` records containing deterministic YAML text rather than mutable Go or
|
||||
parser values. `Present=false` with empty YAML means absence or deletion; explicit
|
||||
null is present and renders as `null`. The history starts with the canonical typed
|
||||
application-default or Go-zero baseline, then includes only relevant base sources in
|
||||
traversal order, the selected overlay, supplied CLI records, and audited net
|
||||
resolution operations. Structural inheritance events appear after source replay
|
||||
application-default or Go-zero baseline, followed by relevant underlay and main-tier
|
||||
contributions in precedence order, the selected overlay, supplied CLI records,
|
||||
and audited net resolution operations. Within each strict tier, replay follows
|
||||
[scoped assembly order](#explicit-imports), not source-record acquisition order.
|
||||
Structural inheritance events appear after source replay
|
||||
and before typed reconciliation, including consumed or canceled `extends` input
|
||||
and equal-valued child replacements. Overlapping parent/child resolution records
|
||||
are rendered
|
||||
@@ -423,6 +555,46 @@ an explicit `LoadSecretsDir` bootstrap, then use `WriteDiagnostics`,
|
||||
ConfigKit does not infer bootstrap outcomes from a secret reference and does not
|
||||
bundle these independent reports into a command framework.
|
||||
|
||||
## Effective configuration diffs
|
||||
|
||||
`configkit.Diff(left, right)` compares two completed successful loads of the same
|
||||
configuration type. `left` is the earlier state and `right` is the later state.
|
||||
Applications load both sides with the sources and options they choose; to inspect
|
||||
an overlay's impact, load the same base once without the overlay and once with it
|
||||
selected. The [consumer guide](consumers/README.md#effective-configuration-diffs)
|
||||
links to maintained examples. The public API returns safe changed-region records;
|
||||
applications own their presentation.
|
||||
|
||||
Comparison uses the frozen canonical effective inspection values, not mutable
|
||||
`Result.Config`, source text, provenance, or history. It does not reopen sources,
|
||||
reread environment variables, or rerun defaults or callbacks. Equal effective
|
||||
values produce no records even when sources, explicit-input status, defaults, or
|
||||
resolution histories differ; for example, explicit zero and defaulted zero are
|
||||
equal. Mappings compare recursively, sequences compare atomically, and scalar,
|
||||
kind, or null/container replacements report their affected region. Records use
|
||||
the existing comparator's canonical pointer ordering. `Present=false` with empty
|
||||
YAML means absence; explicit null is present and renders as `null`.
|
||||
|
||||
Before either side is returned, sensitivity from both loads is applied to both
|
||||
snapshots, including historical and dependency-derived classification. A
|
||||
classification on either side protects the corresponding values on both sides.
|
||||
`Redacted` reports masking, while `HiddenChanged` reports an actual concealed
|
||||
value or presence change; it is not implied merely by redaction. A structural
|
||||
replacement that prevents safe correspondence masks the complete reported
|
||||
region, and an absent side remains absent. Record membership establishes that a
|
||||
change occurred even when safe snapshots render identically. Literal
|
||||
marker-looking input is ordinary input; consumers use the record fields rather
|
||||
than interpreting marker text.
|
||||
|
||||
Typed secret references contribute their configured environment names to the
|
||||
frozen comparison, never resolved credential bytes. Credential rotation behind
|
||||
an unchanged reference is therefore not detected. The records contain changed
|
||||
regions only, not paired full-document context; independently calling
|
||||
`WriteEffective` on each result does not create cross-protected context. Nil or
|
||||
uninitialized results return an error with no records. A failed `Load` retains
|
||||
its normal nil-result/`LoadError` behavior, which callers handle before invoking
|
||||
`Diff`.
|
||||
|
||||
## Typed secret references
|
||||
|
||||
Credential-bearing fields use the opaque `Secret` type and store an environment
|
||||
@@ -606,20 +778,55 @@ Application defaults do not participate in this operation.
|
||||
| Mapping | Mapping | Recurse by mapping key |
|
||||
| Any other pair of present nodes | Any other pair of present nodes | `compose.conflict` |
|
||||
|
||||
Every non-mapping overlap is a conflict, even when values are equal. Nulls and
|
||||
sequences are present atomic values, so they also conflict when repeated; sequences
|
||||
are never joined by index. Disjoint entries under shared mappings compose, making
|
||||
Every non-mapping overlap is a conflict, even when values are equal, except for
|
||||
sequence/sequence contributions at a registered sequence-composition path. Nulls
|
||||
remain atomic conflicts. Unregistered sequences conflict when repeated and are
|
||||
never joined by index. Disjoint entries under shared mappings compose, making
|
||||
stable keyed mappings the natural representation for independently contributed
|
||||
entries.
|
||||
|
||||
`Definition.SequenceCompositions` registers literal mapping paths and one literal
|
||||
`IdentityField` for sequences that should compose independently authored records.
|
||||
Registration paths follow input-destination key rules: parsed numeric tokens are
|
||||
literal mapping keys, explicit index segments and reserved `configkit` or
|
||||
mapping-valued `extends` segments are invalid, and the root registration is
|
||||
harmless and unused. The identity field is one literal string key; neither the
|
||||
field name nor identity strings are normalized.
|
||||
At each registered path, every actual sequence contribution in a strict tier is
|
||||
validated, including a singleton contribution: each record must be a mapping with
|
||||
a present string identity field. Distinct exact identity strings append in source
|
||||
and list order; duplicate identities conflict even if the records are otherwise
|
||||
equal or disjoint. Empty identity strings are valid. Malformed records produce
|
||||
`compose.invalid`; duplicate identities produce `compose.conflict`, with the
|
||||
sequence container and source locations identifying a duplicate without exposing
|
||||
identity values. Missing paths and non-sequence values do not fail by themselves.
|
||||
Identical registrations coalesce; conflicting registrations at one path and
|
||||
invalid registration paths produce `options.invalid`. Unused and root registrations
|
||||
are allowed and have no effect.
|
||||
|
||||
Registration changes strict composition only. Each underlay group and the main
|
||||
tier has independent duplicate-identity state. Precedence assembly is unchanged:
|
||||
a present higher-priority sequence replaces the lower sequence as one value,
|
||||
including at a registered path. Overlays, CLI overrides, defaults, inheritance,
|
||||
and application resolution do not run the registered identity check. The accepted
|
||||
rationale is recorded in [ADR 0014](adr/0014-sequence-placement-and-composition.md).
|
||||
The [maintained prompt catalog](consumers/README.md#identified-prompt-sequences)
|
||||
shows item path and glob imports, helper-supplied identity, and main-tier
|
||||
replacement of a lower-priority sequence.
|
||||
When an enabled source-version mode is selected, declarations are checked in each
|
||||
source's own coordinates before placement and composition. Accepted non-main
|
||||
headers are consumed before strict composition and cannot supply a registered
|
||||
record identity. Final typed decoding and application validation still run on the
|
||||
completed configuration.
|
||||
|
||||
An empty mapping contributes no child definitions. Two empty mappings compose, and
|
||||
an empty mapping neither clears nor conflicts with another mapping's children.
|
||||
Shared mapping containers retain all contributing value locations as their composite
|
||||
base origin. A conflict reports the first claim as its primary location and every
|
||||
remaining claim as related locations, in deterministic source and declaration
|
||||
traversal order. Traversal order never authorizes replacement. Composition clones
|
||||
the resulting tree and its ordered claim records rather than mutating or aliasing an
|
||||
input document.
|
||||
base origin. A conflict reports the first claim in
|
||||
[scoped assembly order](#explicit-imports) as its primary location and every
|
||||
remaining claim as related locations in the same order. Assembly order never
|
||||
authorizes replacement. Composition clones the resulting tree and its ordered
|
||||
claim records rather than mutating or aliasing an input document.
|
||||
|
||||
## Selected overlays
|
||||
|
||||
@@ -710,10 +917,18 @@ zero.
|
||||
|
||||
Imports are opt-in. With `Options.Imports` enabled, a document may reserve its root
|
||||
`configkit` mapping for an `imports` sequence. ConfigKit removes that mapping before
|
||||
application decoding. Each sequence item is a mapping with exactly one nonempty
|
||||
string `path` or `glob` plus an optional boolean `optional`; all other shapes and
|
||||
fields are `import.invalid`. A `configkit` key below the root is invalid. With
|
||||
imports disabled, any occurrence remains `import.disabled`.
|
||||
application decoding. Each sequence item is a mapping with exactly one `path`,
|
||||
`dir`, or `glob` selector. `path` and `dir` accept a string or a list of strings;
|
||||
`glob` accepts one path pattern string. The optional `required` boolean controls
|
||||
whether an empty selection or a missing named path is an error. Its default is
|
||||
false, so missing imports are absent unless `required: true` is specified. The
|
||||
removed `optional` field is invalid. `destination` is an optional sequence of
|
||||
strings. Every destination string is one literal mapping key and follows the
|
||||
input-destination validation rules above; quote numeric-looking keys so YAML
|
||||
reads them as strings. Null, scalar destinations, and non-string members are
|
||||
`import.invalid`, as are all other unsupported shapes and fields. A `configkit`
|
||||
key below the root is invalid. With imports disabled, any occurrence remains
|
||||
`import.disabled`.
|
||||
|
||||
The directive key is `configkit`, not the `_configkit` spelling used in v0.1.0.
|
||||
There is no legacy alias: `_configkit` is ordinary application data, subject to
|
||||
@@ -722,25 +937,107 @@ directive keys in root files and imported fragments, and rename any application
|
||||
fields previously named `configkit` to avoid the newly reserved key.
|
||||
|
||||
Path declarations use slash-separated relative paths. Absolute paths and network
|
||||
URLs are invalid. Each path resolves from its declaring file's canonical directory,
|
||||
and files are traversed depth-first in declaration order: a file contributes its own
|
||||
body before its imports. Order stabilizes source records and diagnostics but never
|
||||
permits replacement. Required missing paths fail; `optional: true` suppresses only
|
||||
target nonexistence, not invalid paths, containment failures, directories,
|
||||
permissions, parse errors, or composition errors.
|
||||
URLs are invalid. Each path resolves from its declaring file's canonical directory.
|
||||
An empty `path: []` selects nothing; it succeeds even when `required: true` because
|
||||
there are no named targets to be missing. A nonempty required path list verifies
|
||||
every named target, including targets later excluded from selection.
|
||||
|
||||
Directory declarations search the listed literal directory roots. Missing roots
|
||||
contribute no candidates; supplied inaccessible or invalid roots fail. `pattern`
|
||||
accepts one basename pattern or a list, matching filenames only. Omission selects
|
||||
all filenames; an explicit empty list selects none. `exclude` accepts one basename
|
||||
pattern or a list and removes matching final candidates. Patterns use Go's
|
||||
case-sensitive `path.Match` grammar. `recursive` enables descendant traversal;
|
||||
`depth` is valid only when recursion is effective, with direct files at depth 1 and
|
||||
depth 0 or omission meaning unlimited traversal subject to library budgets.
|
||||
`follow` controls discovered symlink entries and `hidden` controls discovered
|
||||
names beginning with a dot. Both default to true. Explicit roots retain their
|
||||
normal access behavior.
|
||||
|
||||
Glob declarations use the slash-based Go `path.Match` grammar: `*`, `?`, character
|
||||
classes, and backslash escapes are supported, while recursive `**` syntax is invalid.
|
||||
Backslashes escape pattern characters rather than acting as directory separators.
|
||||
ConfigKit validates the complete pattern before filesystem traversal, walks only
|
||||
directories selected by preceding segments, and reads wildcard directories in
|
||||
bounded batches. Every examined entry consumes the separate visit budget, including
|
||||
nonmatches. Matches are sorted lexicographically by slash-relative spelling within
|
||||
each declaration before depth-first source traversal. A required zero-match glob is
|
||||
`import.missing`; `optional: true` suppresses only zero matches. Invalid patterns,
|
||||
walk failures, matched directories, invalid documents, escapes, and repeated targets
|
||||
remain errors. Final candidates are bounded by the remaining source limit before
|
||||
their contents are accumulated.
|
||||
classes, and backslash escapes are supported, while recursive `**` syntax is
|
||||
invalid. Backslashes escape pattern characters rather than acting as directory
|
||||
separators. ConfigKit validates the complete pattern before filesystem traversal,
|
||||
walks only directories selected by preceding segments, and reads wildcard
|
||||
directories in bounded batches. Every examined entry consumes the separate visit
|
||||
budget, including nonmatches. Literal components are explicit path components;
|
||||
`follow` and `hidden` filter only wildcard-discovered entries. `exclude` filters
|
||||
final candidate basenames.
|
||||
|
||||
`Options.ImportDefaults` and `LayeredOptions.ImportDefaults` provide one
|
||||
application-owned load-wide policy for enabled imports in the main source,
|
||||
underlays, and transitive imports; they do not apply to selected overlays or
|
||||
discover sources by themselves. Each applicable field resolves independently:
|
||||
an explicit declaration value wins, then the application default, then the
|
||||
library default (`required: false`, `follow: true`, `hidden: true`,
|
||||
`recursive: false`, and unrestricted filenames). A declaration's `pattern`
|
||||
replaces rather than extends the default pattern. Nil `Pattern` leaves directory
|
||||
filenames unrestricted; a non-nil empty slice selects none. Defaults do not make
|
||||
a field applicable to selectors that do not support it, and load calls snapshot
|
||||
the supplied pointers and slice before source acquisition.
|
||||
|
||||
Acquisition visits declarations in authored order and each declaration's selected
|
||||
paths in namespace-path lexical order, preserving source-record and version-callback
|
||||
order. Ordinary mapping contributions in a main tier are assembled dependency-first:
|
||||
ConfigKit selects the lexically smallest stable source key among the contributions
|
||||
whose imports are complete. Independent branches may interleave, and the main body
|
||||
may be assembled after its imports. Sequence-item contributions in that tier are
|
||||
nodes in the same schedule; each selected item first assembles its own strict
|
||||
mapping scope, with helpers before the bodies that import them. Those local scopes
|
||||
do not use application collection policies and are not flattened into the outer
|
||||
schedule. Item positions and diagnostic paths follow the resulting assembly order.
|
||||
Each underlay group likewise acquires its inputs before dependency-ready assembly
|
||||
in one strict scope. Independent groups remain separate precedence tiers; a higher
|
||||
tier still replaces a lower tier's sequence values wholesale. Assembly order never
|
||||
permits replacement.
|
||||
|
||||
Acquisition order and resolution order serve different purposes: acquisition
|
||||
assigns source records and callback order, while the shared scheduler determines
|
||||
composition order among currently dependency-ready contributions. Inheritance
|
||||
uses the same eligibility rule within each destination-local sibling scope; it
|
||||
does not reorder the authored output mapping. The private namespace and stable-key
|
||||
construction is described in the [import implementation overview](internal/imports.md).
|
||||
|
||||
An empty selection is allowed by default. `required: true` makes an empty directory
|
||||
or glob selection fail with `import.missing`; it also makes missing named paths
|
||||
fail. Requiredness does not suppress invalid paths, containment failures,
|
||||
directories selected as files, permissions, parse errors, or composition errors.
|
||||
|
||||
For inspection, `SourceInfo.ImportSelections` records one outcome for each
|
||||
completed optional declaration whose entire selected set is empty. The record is
|
||||
attached to its declaring source, keeps the declaration path local to that source,
|
||||
identifies the declaration mapping (including its `SourceID`) and selector, and
|
||||
uses outcome `none`. Records remain in declaration order and are available
|
||||
through the existing result, load
|
||||
error, and callback source snapshots. They do not enumerate candidates, explain
|
||||
why a path was absent, or describe partial omissions. Empty `path: []` succeeds
|
||||
regardless of requiredness: it produces a `none` record when effectively optional,
|
||||
and no record when effectively required. Snapshots detach the record paths and
|
||||
slices.
|
||||
|
||||
Each declaration may include `placement: mapping` or
|
||||
`placement: sequence-item`. Omission means `mapping` for that declaration,
|
||||
including every match of a glob; placement is not inherited. Sequence-item
|
||||
placement requires a nonempty effective destination and wraps the imported
|
||||
mapping as one element of a sequence there. It does not split source sequences
|
||||
or infer identity fields. Repeated sequences still conflict unless their path is
|
||||
registered through `Definition.SequenceCompositions`; registration does not
|
||||
change placement or precedence behavior. Sequence records retain source
|
||||
locations through placement and history; indexes describe their assembled
|
||||
positions while source coordinates continue to identify the declaring files.
|
||||
Header/version policy and final application validation retain their existing
|
||||
owners and timing; sequence registration is a strict-composition rule only.
|
||||
|
||||
An item import first opens a local mapping scope. Its body and ordinary helper
|
||||
imports compose within that record. An omitted helper destination or
|
||||
`destination: []` places the helper at the record root; other destinations are
|
||||
relative to the immediate importing source. A helper may supply fields such as
|
||||
the record's `id`. An explicit nested `sequence-item` import creates a sequence
|
||||
inside the record, and its placement is independent of the enclosing item.
|
||||
At the record root, an item import must name a nonempty nested destination.
|
||||
Physical paths continue to resolve from the file that declares each import.
|
||||
An actual empty mapping contributes an empty item, while an unmatched declaration
|
||||
with default or explicit `required: false` contributes nothing.
|
||||
|
||||
For host files, the configuration root defaults to the selected file's canonical
|
||||
directory. A host file may supply `FilesystemContext.Root` to widen that root but
|
||||
@@ -759,8 +1056,13 @@ ConfigKit canonicalizes symlinks,
|
||||
checks containment with relative paths, and opens the canonical root-relative target
|
||||
through a library-owned root handle. Contained relative or absolute symlink targets
|
||||
are accepted; escapes are rejected. Canonical paths define identity, so hard links at
|
||||
distinct paths remain distinct sources. Native filesystems use slash-path operations
|
||||
and their own symlink behavior; imports stay in their selected namespace and root.
|
||||
distinct paths remain distinct sources. Native filesystems use slash-path
|
||||
operations. Where the supplied `fs.FS` implements `fs.ReadLinkFS`, ConfigKit uses
|
||||
that capability to inspect and resolve discovered links; otherwise it follows
|
||||
the filesystem's ordinary `fs.FS` behavior. ConfigKit does not require every
|
||||
native filesystem to expose link inspection, and native filesystem behavior is
|
||||
not a general sandbox guarantee. Imports stay in their selected namespace and
|
||||
root.
|
||||
|
||||
An active-stack repeat is `import.cycle` and reports the complete cycle's import
|
||||
sites. A previously visited canonical path is `import.repeated` and reports both
|
||||
|
||||
@@ -17,7 +17,11 @@ sanitized shapes using only ConfigKit and the Go standard library:
|
||||
| Seriatim-like | Independent strict ordered speaker and autocorrect rule files | First-match and ordered replacement algorithms; no central application configuration file |
|
||||
| Bundled file | Simple native `fs.FS` source through ordinary `Load` | Selection and lifetime of the application-owned embedded filesystem |
|
||||
| Layered catalog | Embedded defaults with an import, a mapping underlay, an independent host main file, explicit false/zero input, and source-relative resource access | Domain validation and opening/interpreting the template resource |
|
||||
| Mounted collections | Entry-declared relative destinations, nested metadata/prompt/profile imports, local inheritance, and a version-only higher-tier override | Collection IDs, profile meaning, and opening/interpreting prompt resources |
|
||||
| Named presets | One-load sibling inheritance, explicit false, nested destination resolution, and source/derivation inspection | Choosing and executing a preset |
|
||||
| Effective configuration diff | Same-type frozen effective comparisons for two selected files or base-versus-overlay impact | Source/options selection and consumer-owned safe presentation |
|
||||
| Shared source-version checks | One `Definition.Version` policy for accepted declarations across participating source documents, with optional or required declarations and sanitized evidence | Choosing which declarations the application accepts; final assembled schema and domain validation |
|
||||
| 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
|
||||
executable examples and ordinary tests. It contains no private application data,
|
||||
@@ -51,6 +55,55 @@ both without redefining their syntax. ConfigKit overlays are configuration overl
|
||||
they do not select or replace an application's LLM, model, pipeline, report, or
|
||||
execution profile concepts.
|
||||
|
||||
## Import defaults and selection inspection
|
||||
|
||||
`Options.ImportDefaults` and `LayeredOptions.ImportDefaults` apply one
|
||||
load-wide policy to enabled main, underlay, and transitive imports. Each field
|
||||
is resolved independently: a declaration's explicit value wins, then the
|
||||
application default, then the library default. `Required`, `Follow`, `Hidden`,
|
||||
and `Recursive` use nil pointers for unspecified values. `Pattern` applies to
|
||||
directory searches: nil leaves filenames unrestricted, while a non-nil empty
|
||||
slice selects no files. A declaration's own `pattern` replaces the application
|
||||
default. The defaults do not enable imports or change selected-overlay behavior.
|
||||
|
||||
`Result.Sources`, `LoadError.Sources`, and `Resolution.Source` expose each
|
||||
declaring source's `ImportSelections`. These records identify the source-local
|
||||
declaration path, its authored location and selector, and the `none` outcome;
|
||||
they do not include candidate names or a guessed reason for absence. Returned
|
||||
records are detached snapshots. The [configuration contract](../config.md#explicit-imports)
|
||||
owns selector applicability and exact absence semantics.
|
||||
|
||||
## Shared source-version checks
|
||||
|
||||
[`LoadVersionedService`](../../examples/consumers/source_version.go) demonstrates
|
||||
if-present and required modes with one `Definition.Version` rule across a mounted
|
||||
main input and an underlay. The example reads only sanitized
|
||||
`SourceInfo.VersionCheck` fields; its executable test is in
|
||||
[`layered_test.go`](../../examples/consumers/layered_test.go). The
|
||||
[configuration contract](../config.md#shared-source-version-checks) defines
|
||||
participation, source-local lookup and diagnostics, non-main header consumption,
|
||||
main-path protection, default behavior, and evidence disclosure. Accepted
|
||||
declarations do not replace final assembled decoding or application validation.
|
||||
|
||||
## Effective configuration diffs
|
||||
|
||||
[`CompareFiles`](../../examples/consumers/diff.go) loads two caller-selected files
|
||||
with one application definition, handles each load error, calls `configkit.Diff`,
|
||||
and presents the safe records through consumer-owned `FormatDifferences`.
|
||||
[`CompareRolloutOverlay`](../../examples/consumers/diff.go) loads the same base
|
||||
once without an overlay and once with the selected overlay, so omitted lower
|
||||
priority values remain present. Its resolver records a derived endpoint URL after
|
||||
overlay application, and its nested typed secret reference is classified before
|
||||
the resulting records are presented.
|
||||
|
||||
The maintained [behavior tests and executable examples](../../examples/consumers/diff_test.go)
|
||||
use temporary files and disposable secret values. Presenters should use
|
||||
`ValueSnapshot.Present` for absence, record membership for change, and
|
||||
`HiddenChanged` for a withheld change indicator. Do not parse the redaction marker
|
||||
or add separately rendered full contexts that lack combined sensitivity. The
|
||||
[configuration contract](../config.md#effective-configuration-diffs) owns shared
|
||||
equality, granularity, and disclosure semantics.
|
||||
|
||||
## Named sibling inheritance
|
||||
|
||||
[`LoadPresets`](../../examples/consumers/inheritance.go) loads the maintained
|
||||
@@ -88,6 +141,80 @@ and may fail independently of the completed configuration load. The
|
||||
[configuration contract](../config.md#source-resource-contexts) owns exact
|
||||
lifetime and precedence rules.
|
||||
|
||||
[`LoadPromptBundle`](../../examples/consumers/layered.go) is the ordinary-root
|
||||
counterpart: its entry opts into imports through `Load`, imports metadata at the
|
||||
root, and places a prompt beneath `prompts` using YAML. The executable example
|
||||
and behavior test are in
|
||||
[`layered_test.go`](../../examples/consumers/layered_test.go).
|
||||
|
||||
## Input destinations and mounted collections
|
||||
|
||||
Main and underlay inputs use the same destination field. An import without a
|
||||
`destination` inherits its declaring document's effective destination; an
|
||||
explicit sequence of key strings appends a relative suffix. Nested imports
|
||||
append to their immediate parent's effective destination, while filesystem
|
||||
paths remain relative to the declaring file. Destinations are application
|
||||
mapping paths, independent of filesystem context. The configuration contract
|
||||
owns the complete behavior and validation rules.
|
||||
|
||||
The small [`Example_inputDestination`](../../examples/consumers/destinations_test.go)
|
||||
mounts one mapping source beneath literal collection/prompt keys and loads a
|
||||
second input without a destination to show that root placement remains the
|
||||
default. Consumers pass paths, not filenames or namespace strings for ConfigKit
|
||||
to interpret.
|
||||
|
||||
[`LoadCollectionBundle`](../../examples/consumers/destinations.go) assembles an
|
||||
embedded collection manifest mounted beneath a literal collection path. The
|
||||
entry's YAML places nested metadata imports at the collection root and prompt
|
||||
and profile peers beneath their own keys. A higher-priority input changes only
|
||||
one prompt's ordinary `version` field. The main document alone supplies the
|
||||
application schema version. A prompt's local `extends` relationship resolves
|
||||
after all tiers, so `custom` inherits the final `brief` content and fields while
|
||||
retaining its own profile selection.
|
||||
|
||||
The resolver follows each effective `content_file` origin to its declaring
|
||||
source context. This also works for `custom`'s inherited resource. The
|
||||
application-owned [`ReadCollectionPromptContent`](../../examples/consumers/destinations.go)
|
||||
demonstrates the same lookup after loading; ConfigKit does not open or interpret
|
||||
the resource. [`destinations_test.go`](../../examples/consumers/destinations_test.go)
|
||||
checks source destinations and authored coordinates, presence, inherited values,
|
||||
relative resources, and strict peer conflicts. The
|
||||
[configuration contract](../config.md#input-destinations) owns shared placement,
|
||||
import, source-location, and ownership behavior.
|
||||
|
||||
## Identified prompt sequences
|
||||
|
||||
[`LoadPromptCatalog`](../../examples/consumers/prompt_catalog.go) uses one
|
||||
definition registration for the literal `prompts` path and `id` field. Its
|
||||
load-wide filename defaults accept two patterns, an explicit directory
|
||||
declaration overrides those patterns, and an exclusion omits one matching
|
||||
candidate. A nested ordinary helper import supplies an item's ID. A separate
|
||||
empty optional directory search is read from `SourceInfo.ImportSelections`.
|
||||
The executable example prints both the scheduled prompt order and the single
|
||||
empty-search outcome; the behavior test also follows a mounted resource through
|
||||
its effective metadata origin to the declaring source context. The completed
|
||||
main sequence replaces the lower-priority default collection as one value.
|
||||
The maintained [executable example and behavior tests](../../examples/consumers/prompt_catalog_test.go)
|
||||
show the scheduled result, empty-search inspection, excluded candidate, and
|
||||
resource lookup without a consumer-side discovery pass. The corresponding
|
||||
[complex fixture](../../examples/consumers/testdata/prompt-catalog/config.yml)
|
||||
and [simple required-file fixture](../../examples/consumers/testdata/prompt-catalog/simple.yml)
|
||||
are complete local examples.
|
||||
|
||||
[`LoadRequiredPromptCatalog`](../../examples/consumers/prompt_catalog.go) and
|
||||
[`Example_requiredPromptCatalog`](../../examples/consumers/prompt_catalog_test.go)
|
||||
keep the smaller adoption path visible: one explicitly required file import,
|
||||
the same identity registration, and the same application validation.
|
||||
|
||||
The example's `validatePromptCatalog` remains application-owned. It checks that
|
||||
prompt names and message lists satisfy this consumer's requirements after
|
||||
ConfigKit has composed, projected, and decoded the configuration. It does not
|
||||
add recursive record merging, identity-based overlay, or final uniqueness checks
|
||||
after defaults and resolution. See the [strict composition contract](../config.md#strict-document-composition),
|
||||
[import syntax](../config.md#explicit-imports), and
|
||||
[ADR 0014](../adr/0014-sequence-placement-and-composition.md) for the exact
|
||||
boundaries.
|
||||
|
||||
Known module catalogs should use canonical object entries with a module identifier
|
||||
and typed options. A small union struct is sufficient when the available option
|
||||
fields are known; validation selects which fields apply to each module. Runtime-
|
||||
@@ -167,7 +294,9 @@ focused behavioral owner:
|
||||
| Canonical module objects and dynamic option handling | WeatherReporter- and Notarius-shaped examples and migration tests |
|
||||
| `LoadSecretsDir`, bootstrap reporting, typed lookup, CLI explanation | `Example_weatherStartup` |
|
||||
| Native filesystem, grouped underlays, programmatic mapping, retained resource context | `Example_nativeFilesystem`, `Example_layeredCatalog`, and their focused consumer tests |
|
||||
| Literal input destinations, root-default compatibility, and mounted peer collections | `Example_inputDestination`, `LoadCollectionBundle`, and `destinations_test.go` |
|
||||
| Named sibling inheritance, presence, and derivation | `Example_inheritedPresets` and `TestPresetCatalogOneLoadAndDerivation` |
|
||||
| Effective configuration diffs and consumer-owned presentation | `ExampleDiff`, `ExampleCompareFiles`, and `ExampleCompareRolloutOverlay` |
|
||||
| Results, metadata, diagnostics, effective output, and writer failures | Focused root-package load, provenance, rendering, and explanation tests |
|
||||
|
||||
The package-level import-boundary test covers the consumer examples too: supported
|
||||
|
||||
@@ -39,9 +39,11 @@ public behavior.
|
||||
| Task | Read |
|
||||
| --- | --- |
|
||||
| Repository orientation or component responsibility | [Component overview](internal/overview.md) |
|
||||
| Input destinations | [Configuration contract](config.md#input-destinations), [ADR 0011](adr/0011-input-destinations.md), and the destination tests below |
|
||||
| Shared source-version checks | [Configuration contract](config.md#shared-source-version-checks), [ADR 0013](adr/0013-shared-source-version-checks.md), and the version/source-version tests below |
|
||||
| Documentation changes | The owning document |
|
||||
| Licensing, dependencies, attribution, or release-document changes | [Project grant](../COPYRIGHT) and [attribution](oss/attribution.md); release changes also follow the release procedure |
|
||||
| Architecture decisions or future work | [Deferred scope](roadmap/deferred.md) and relevant ADRs listed below |
|
||||
| Architecture decisions or future work | [Future features](roadmap/future.md) and relevant ADRs listed below |
|
||||
| Code and test changes | The owning contract and tests from the map below |
|
||||
| Examples or copyable assets | The owning implemented contract and [consumer adoption guide](consumers/README.md) |
|
||||
| Release preparation | [Release procedure](release.md) |
|
||||
@@ -49,11 +51,18 @@ public behavior.
|
||||
Accepted decisions:
|
||||
|
||||
- [Separate composition and overlay](adr/0001-separate-composition-and-overlay.md)
|
||||
- [Check source versions with one shared policy](adr/0013-shared-source-version-checks.md)
|
||||
- [Own YAML semantics behind a private parser boundary](adr/0002-own-yaml-semantics.md)
|
||||
- [Preserve presence and record application resolution](adr/0003-preserve-presence-and-resolution-history.md)
|
||||
- [Secret references and explicit bootstrap](adr/0004-secret-references-and-explicit-bootstrap.md)
|
||||
- [Explicit overrides and safe explanation](adr/0005-explicit-overrides-and-safe-explanation.md)
|
||||
- [Keep the public facade root-owned](adr/0006-public-facade.md)
|
||||
- [Compare frozen effective configuration snapshots](adr/0010-effective-configuration-diff.md)
|
||||
- [Place inputs at literal application mapping paths](adr/0011-input-destinations.md)
|
||||
- [Share dependency-ready ordering](adr/0015-shared-dependency-ready-ordering.md)
|
||||
- [Separate import selection controls](adr/0016-independent-import-controls.md)
|
||||
- [Define identity and merge rules for each collection](adr/0017-collection-sequence-policy.md)
|
||||
- [Apply CLI overrides after inheritance and decoding](adr/0018-cli-after-structural-resolution.md)
|
||||
|
||||
For cross-cutting changes, follow every applicable row. Completed implementation
|
||||
and remediation plans are preserved in Git history at `cb5b435`; use current
|
||||
@@ -64,8 +73,9 @@ git show cb5b435:docs/roadmap/remediation.md
|
||||
```
|
||||
|
||||
Release-candidate, publication, and verification work follows the operational
|
||||
[release procedure](release.md). Its single publication command validates the
|
||||
committed candidate and publishes only the intended refs.
|
||||
[release procedure](release.md). Its module-selected ReleaseKit `publish`
|
||||
invocation validates the committed candidate and publishes only the intended
|
||||
tag; those release checks remain distinct from the everyday development check.
|
||||
|
||||
The completed string-and-integer schema-version implementation and remediation
|
||||
records are preserved in Git history at `244104f`. The
|
||||
@@ -93,9 +103,74 @@ preserved in Git history at `73ce6e9` under `docs/roadmap/`. Current overlay
|
||||
behavior belongs to the [configuration contract](config.md#selected-overlays),
|
||||
and release operations belong to the [release procedure](release.md).
|
||||
|
||||
Install Go 1.25.0 or a supported patched/newer Go toolchain. Acquire declared
|
||||
dependencies explicitly before entering an offline development or validation
|
||||
environment:
|
||||
The completed ReleaseKit adoption, startup performance request and experiments,
|
||||
benchmark evidence, performance audit, implementation plan, and remediation
|
||||
record are preserved in Git history at `8e91a2b` under `docs/roadmap/`. For example:
|
||||
|
||||
```sh
|
||||
git show 8e91a2b:docs/roadmap/startup-benchmark.md
|
||||
```
|
||||
|
||||
Remaining optimization candidates belong to
|
||||
[future performance experiments](roadmap/future.md#3-performance-experiments).
|
||||
|
||||
The completed effective-configuration diff roadmap, implementation plan, and
|
||||
resolved review findings are preserved in Git history at `1c2610c` under
|
||||
`docs/roadmap/`. The
|
||||
[configuration contract](config.md#effective-configuration-diffs) owns current
|
||||
comparison and disclosure behavior, and
|
||||
[ADR 0010](adr/0010-effective-configuration-diff.md) records the decision.
|
||||
|
||||
The completed input-destination, relative-import-destination, and shared
|
||||
source-version roadmaps, associated consumer request, implementation plan, and
|
||||
resolved review findings are preserved in Git history at `e4270c9` under
|
||||
`docs/roadmap/`. For example:
|
||||
|
||||
```sh
|
||||
git show e4270c9:docs/roadmap/implementation.md
|
||||
git show e4270c9:docs/roadmap/remediation.md
|
||||
```
|
||||
|
||||
Current behavior belongs to the configuration contract's
|
||||
[input destinations](config.md#input-destinations),
|
||||
[imports](config.md#explicit-imports), and
|
||||
[shared source-version checks](config.md#shared-source-version-checks) sections.
|
||||
Explicit sequence-item placement and registered strict composition are owned by
|
||||
the [strict composition](config.md#strict-document-composition) and
|
||||
[imports](config.md#explicit-imports) sections. The
|
||||
[maintained prompt catalog](consumers/README.md#identified-prompt-sequences)
|
||||
shows the current consumer boundary, and [ADR 0014](adr/0014-sequence-placement-and-composition.md)
|
||||
records the decision.
|
||||
|
||||
The completed sequence-composition roadmap, implementation plan, and resolved
|
||||
remediation record are preserved in Git history at `008d2ba` under
|
||||
`docs/roadmap/`. For example:
|
||||
|
||||
```sh
|
||||
git show 008d2ba:docs/roadmap/sequence-composition.md
|
||||
git show 008d2ba:docs/roadmap/implementation.md
|
||||
git show 008d2ba:docs/roadmap/remediation.md
|
||||
```
|
||||
|
||||
The completed import-discovery roadmap, implementation plan, and resolved
|
||||
remediation record are preserved in Git history at `5747ec4` under
|
||||
`docs/roadmap/`. For example:
|
||||
|
||||
```sh
|
||||
git show 5747ec4:docs/roadmap/imports.md
|
||||
git show 5747ec4:docs/roadmap/implementation.md
|
||||
git show 5747ec4:docs/roadmap/remediation.md
|
||||
```
|
||||
|
||||
The subsequent import-integration audit and completed remediation record are
|
||||
preserved at `6903c9e:docs/roadmap/remediation.md`; retrieve them with
|
||||
`git show 6903c9e:docs/roadmap/remediation.md`.
|
||||
|
||||
Potential enhancements are tracked in [future features](roadmap/future.md).
|
||||
|
||||
Install the Go version required by `go.mod` or a compatible newer toolchain.
|
||||
Acquire declared dependencies explicitly before entering an offline
|
||||
development or validation environment:
|
||||
|
||||
```sh
|
||||
go mod download
|
||||
@@ -125,11 +200,10 @@ Use focused checks while iterating, with validation proportionate to the change
|
||||
under the testing policy. Documentation-only work normally needs the local-link
|
||||
check below and whitespace review; behavior-bearing examples and commands also
|
||||
need execution. Use `go test -race ./...` for ownership or concurrency changes.
|
||||
The [publication command](release.md#4-publish-the-committed-candidate) invokes
|
||||
the release checker once. It performs one minimum-toolchain offline race-suite
|
||||
run, one full vet pass, and Linux cross-builds using readonly module mode.
|
||||
It already covers examples, links, and package boundaries; do not additionally
|
||||
run the everyday checker just to repeat those checks. CI never gates a release.
|
||||
The [release procedure](release.md#4-publish-the-committed-candidate) performs
|
||||
its selected validation through one `publish` invocation. The everyday checker
|
||||
remains a development gate and is not a separate release prerequisite. CI never
|
||||
gates a release.
|
||||
|
||||
## Task-to-test map
|
||||
|
||||
@@ -140,22 +214,26 @@ code changes. Use the component overview to find the corresponding implementatio
|
||||
| Task | Behavioral test owners | Focused command |
|
||||
| --- | --- | --- |
|
||||
| YAML syntax, diagnostics, parser limits | [Adapter tests](../internal/yaml/parse_test.go), [source translation](../internal/engine/parse_test.go) | `go test ./internal/yaml` and `go test ./internal/engine -run '^TestParseSource'` |
|
||||
| File/reader access, imports, globs, containment | [Sources](../internal/engine/source_test.go), [FIFO](../internal/engine/source_fifo_test.go), [imports](../internal/engine/imports_test.go), [globs](../internal/engine/glob_test.go) | `go test ./internal/engine -run 'Source\|Import\|Glob\|Filesystem\|Nonregular\|RegularFile'` |
|
||||
| File/reader access, imports, globs, containment | [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'` |
|
||||
| Import defaults, empty-selection evidence, and dependency-ready ordering | [Defaults and facade evidence](../facade_import_defaults_test.go), [engine import evidence](../internal/engine/import_evidence_test.go), [scheduler](../internal/engine/dependency_order_test.go), [scheduling integrations](../internal/engine/imports_test.go) | `go test ./internal/engine -run 'Import|ScheduleDependencies|Inheritance'`, `go test . -run 'ImportDefaults|FacadeImport'`, and `go test ./examples/consumers` |
|
||||
| Native filesystem and mapping sources | [Native acquisition](../internal/engine/native_filesystem_test.go), [filesystem facade](../facade_filesystem_test.go), [mapping facade](../facade_mapping_test.go) | `go test ./internal/engine -run 'NativeFilesystem'` and `go test . -run 'TestFacade'` |
|
||||
| Composition, selected overlays, CLI precedence | [Composition](../internal/engine/compose_test.go), [overlay](../internal/engine/overlay_test.go), [selected overlays](../internal/engine/selected_overlay_test.go), [overrides](../internal/engine/override_test.go) | `go test ./internal/engine -run 'Compose\|Overlay\|Override'` |
|
||||
| Sibling inheritance, provenance, history, sensitivity | [Expansion](../internal/engine/inheritance_test.go), [destination scopes](../internal/engine/inheritance_walk_test.go), [origins](../internal/engine/inheritance_origin_test.go), [history](../internal/engine/inheritance_history_test.go), [secrets](../internal/engine/inheritance_secret_test.go), [public loading](../facade_inheritance_test.go) | `go test ./internal/engine -run 'Inheritance\|Inherited'` and `go test . -run '^TestFacadeInheritance\|^TestFacadeInheritedSecrets'` |
|
||||
| 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` |
|
||||
| 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'` |
|
||||
| Lifecycle, resolution, validation, callback ownership | [Loading](../internal/engine/load_test.go), [resolution](../internal/engine/resolution_test.go), [validation](../internal/engine/validation_test.go), [ownership](../internal/engine/callback_ownership_test.go), [independence](../internal/engine/independence_test.go) | `go test ./internal/engine -run 'Load\|Resolution\|Validation\|Validator\|Hook\|Callback\|Parallel'` |
|
||||
| Paths, metadata, provenance | [Paths](../internal/engine/path_test.go), [metadata](../internal/engine/metadata_test.go), [provenance](../internal/engine/provenance_test.go) | `go test ./internal/engine -run 'Path\|Metadata\|Provenance\|Queries'` |
|
||||
| Passive resource contexts | [Engine contexts](../internal/engine/resource_context_test.go), [public contexts](../facade_resource_context_test.go) | `go test ./internal/engine -run 'Resource'` and `go test . -run 'SourceContext'` |
|
||||
| Secrets, bootstrap, redaction, explanation | [References](../internal/engine/secret_reference_test.go), [bootstrap](../internal/engine/secrets_test.go), [rendering](../internal/engine/render_test.go), [explanation](../internal/engine/explain_test.go) | `go test ./internal/engine -run 'Secret\|Bootstrap\|Effective\|Explain\|Explanation\|History\|Sensitivity'` |
|
||||
| Consumer examples and migration fixtures | [Consumer tests](../examples/consumers/consumers_test.go), [native and layered examples](../examples/consumers/layered_test.go), [API examples](../example_test.go) | `go test ./examples/consumers` and `go test . -run '^Example'` |
|
||||
| Effective configuration diff | [Engine comparison and masking tests](../internal/engine/diff_test.go), [engine operation tests](../internal/engine/diff_operation_test.go), [root facade tests](../facade_diff_test.go), and [consumer examples](../examples/consumers/diff_test.go) | `go test ./internal/engine -run 'Diff|Explain|History|Secret|Independent'`, `go test . -run 'Diff|FacadeInspection|PublicFacadeBoundary|DependencyImportBoundary'`, `go test ./examples/consumers`, and `go test . -run '^ExampleDiff$'` |
|
||||
| Consumer examples and migration fixtures | [Consumer tests](../examples/consumers/consumers_test.go), [native, layered, and ordinary relative-import examples](../examples/consumers/layered_test.go), [mounted collection examples](../examples/consumers/destinations_test.go), [API examples](../example_test.go) | `go test ./examples/consumers` and `go test . -run '^Example'` |
|
||||
| Assembly and layered scaling evidence | [Assembly benchmarks](../internal/engine/assembly_bench_test.go), [layered benchmarks](../internal/engine/underlay_bench_test.go) | `go test ./internal/engine -run '^$' -bench '^BenchmarkUnderlay' -benchmem`; repeat with `^BenchmarkCompose` and `^BenchmarkOverlay` |
|
||||
| Inheritance scaling evidence | [Expansion benchmarks](../internal/engine/inheritance_bench_test.go) | `go test ./internal/engine -run '^$' -bench '^BenchmarkInheritance' -benchmem` |
|
||||
| Documentation links and dependency isolation | [Link checks](../documentation_test.go), [import boundary](../import_boundary_test.go), [public boundary](../public_boundary_test.go) | `go test . -run 'TestLocalDocumentationLinks\|TestDependencyImportBoundary\|TestPublicFacadeBoundary'` |
|
||||
| Development and release checker behavior | [Shell regression](../release_check_test.go) | `go test . -run 'TestCheckScripts\|TestDevelopmentCheck'` |
|
||||
| Guarded release publication and recovery | [Publication regression](../release_publish_test.go) | `go test . -run '^TestReleasePublication'` |
|
||||
| Development check behavior | [Development checker test](../development_check_test.go) | `go test . -run '^TestDevelopmentCheckHandlesFilenamesAndStopsOnFailure$'` |
|
||||
| Release profile wiring | [Release profile test](../releasekit_profile_test.go) | `go test . -run '^TestReleaseKitProfile$'` |
|
||||
|
||||
## Fuzz validation
|
||||
|
||||
|
||||
80
docs/internal/imports.md
Normal file
80
docs/internal/imports.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# 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. Candidate selection 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. 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.
|
||||
|
||||
Acquisition remains authored: declarations are visited in source order and each
|
||||
selected candidate set in namespace-path lexical order. This preserves source
|
||||
records, IDs, source-local version checks, and callback ordering. The acquired
|
||||
inclusion graph is retained separately from composition scopes so cycles that
|
||||
cross item boundaries remain detectable. Source discovery does not itself choose
|
||||
sequence assembly order.
|
||||
|
||||
## Stable keys and scheduling
|
||||
|
||||
The shared scheduler compares immutable tuples component by component using Go's
|
||||
bytewise string order; if one tuple is a prefix of another, the shorter tuple
|
||||
sorts first. The import key is
|
||||
`(namespace-kind, namespace-anchor, resource-kind, resource-path)`:
|
||||
|
||||
- Namespace kind is `host` or `native`. Host resource paths are canonical
|
||||
absolute paths rendered with slash separators; native paths retain their
|
||||
namespace-relative identity.
|
||||
- Anchors come from explicit input positions, not discovery order: main is
|
||||
`(0,0)` and underlay/input positions are `(group+1,input+1)`, each encoded as
|
||||
fixed-width decimal. Host accesses in one scheduled tier share its host anchor.
|
||||
A native filesystem namespace uses its earliest explicit selecting position
|
||||
in that tier. Filesystem tokens distinguish namespace equality, not sort order,
|
||||
so separately constructed filesystems with the same label remain distinct.
|
||||
- Resource kind is `file` or `synthetic`. A synthetic root uses its explicit
|
||||
input anchor and an empty resource path rather than a display label or token
|
||||
address.
|
||||
|
||||
Inheritance keys are
|
||||
`("definition", scope-pointer, definition-pointer)`, where both pointers use
|
||||
canonical escaped semantic path spelling. Source IDs are not part of the key.
|
||||
|
||||
Both integrations use the same Kahn scheduler: it deduplicates dependency edges,
|
||||
keeps currently ready nodes in a min-heap, and selects the smallest ready key
|
||||
after every completion. Its scheduling cost is `O(E + V log V)` time and
|
||||
`O(V + E)` additional memory, excluding key construction, tuple copying, and
|
||||
tuple comparisons. On a stalled graph, deterministic DFS over the residual graph
|
||||
returns a closed cycle rotated to its smallest key; blocked dependents are not
|
||||
reported as cycle members.
|
||||
|
||||
Import composition schedules a main tier or one underlay group as a single
|
||||
strict-scope graph. 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 main owners are [`import_selection.go`](../../internal/engine/import_selection.go),
|
||||
[`directory_walk.go`](../../internal/engine/directory_walk.go),
|
||||
[`import_graph.go`](../../internal/engine/import_graph.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, scheduler, and scheduling
|
||||
integration tests under `internal/engine`.
|
||||
@@ -8,11 +8,23 @@ private state; public signatures and fields do not expose engine types.
|
||||
Implemented component owners:
|
||||
|
||||
- `internal/engine` owns host and native-filesystem source acquisition,
|
||||
grouped strict composition, recursive precedence assembly, and destination-local
|
||||
sibling inheritance before typed decoding. It also owns schema
|
||||
grouped strict composition, recursive precedence assembly, per-input mapping
|
||||
and sequence-item placement, definition-registered distinct-ID sequence
|
||||
composition within strict tiers, and immediate-parent-relative import
|
||||
placement within that assembly. Higher-priority sequences replace lower
|
||||
sequences as whole values. The engine also owns dependency-ready,
|
||||
destination-local sibling inheritance before typed decoding, plus shared
|
||||
source-version checking with sanitized declaration evidence.
|
||||
Explicit import discovery supports declared paths, directory searches, and
|
||||
globs; load-wide defaults and source-local empty-selection evidence cross the
|
||||
root facade without changing acquisition order. Import and inheritance
|
||||
resolution share dependency-ready scheduling within their composition scopes.
|
||||
It retains each source's effective placement alongside physical source
|
||||
identity and owns schema
|
||||
inspection, lifecycle coordination, callback auditing,
|
||||
provenance/history, passive source-context retention, secret resolution and
|
||||
bootstrap, and safe inspection rendering. It never imports the root package.
|
||||
bootstrap, safe inspection rendering, and effective-value comparison over
|
||||
frozen inspection snapshots. It never imports the root package.
|
||||
- Root `Source`, `Filesystem`, `Input`, `Underlay`, `Path`, `Secret`, records,
|
||||
lifecycle callbacks, results, and bootstrap reports are root-defined wrappers
|
||||
or records. Callback wrappers pass only live engine context during a callback;
|
||||
@@ -21,6 +33,9 @@ Implemented component owners:
|
||||
- Root conversion files explicitly cross the path, record, callback, secret, and
|
||||
error boundaries. There are no public aliases, anonymous engine embeddings, or
|
||||
public engine bridge methods.
|
||||
- Root `Diff` and `Difference` provide the public comparison boundary and
|
||||
convert detached engine records to root-owned paths and snapshots; consumer
|
||||
examples own presentation of the safe changed regions.
|
||||
- `internal/document` owns the parser-independent ordered tree and cloning.
|
||||
`internal/yaml` is the sole upstream YAML adapter and parser/dumper boundary.
|
||||
- Engine mechanism tests own source, composition, inheritance, decoding,
|
||||
@@ -37,10 +52,27 @@ these constraints and rejects exported aliases, fields, or signatures involving
|
||||
internal types.
|
||||
|
||||
The [configuration contract](../config.md) owns implemented YAML and lifecycle
|
||||
semantics. The [consumer adoption guide](../consumers/README.md) owns application
|
||||
guidance. The [architecture policy](../policy/architecture.md) owns contributor
|
||||
boundaries, and [ADR 0006](../adr/0006-public-facade.md) records the facade
|
||||
semantics, including [input destinations](../config.md#input-destinations),
|
||||
[shared source-version checks](../config.md#shared-source-version-checks), and
|
||||
[effective configuration diffs](../config.md#effective-configuration-diffs).
|
||||
The [strict composition contract](../config.md#strict-document-composition)
|
||||
owns sequence placement, registration, replacement, and history behavior; the
|
||||
[consumer guide](../consumers/README.md#identified-prompt-sequences) shows the
|
||||
maintained example.
|
||||
The [consumer adoption guide](../consumers/README.md) owns application guidance.
|
||||
The [architecture policy](../policy/architecture.md) owns contributor
|
||||
boundaries; [ADR 0006](../adr/0006-public-facade.md) records the facade decision
|
||||
and [ADR 0010](../adr/0010-effective-configuration-diff.md) records the diff
|
||||
decision; [ADR 0011](../adr/0011-input-destinations.md) records the placement
|
||||
decision. See the [development guide](../development.md) for validation commands
|
||||
and [release procedure](../release.md) for source-candidate gates. [ADR
|
||||
0009](../adr/0009-sibling-inheritance.md) records why inheritance is a built-in
|
||||
post-assembly structural operation.
|
||||
post-assembly structural operation. [ADR 0012](../adr/0012-relative-import-destinations.md)
|
||||
records the relative-import extension; [ADR 0013](../adr/0013-shared-source-version-checks.md)
|
||||
records the source-version decision.
|
||||
[ADR 0014](../adr/0014-sequence-placement-and-composition.md) records explicit
|
||||
sequence placement and composition. [ADR 0015](../adr/0015-shared-dependency-ready-ordering.md)
|
||||
records the shared dependency-ready ordering decision; [ADR
|
||||
0016](../adr/0016-independent-import-controls.md) records the independent
|
||||
selection controls. The [import internals](imports.md) document candidate and
|
||||
namespace identities, discovery and scheduling, and the bounded algorithms.
|
||||
|
||||
@@ -2,11 +2,12 @@
|
||||
|
||||
## Standard Repository Policies
|
||||
|
||||
Material: `docs/policy/meta.md`, `docs/policy/oss.md`,
|
||||
Material: `docs/policy/release.md`, adapted from
|
||||
`e833faddf120c789a9b45348edaf75720de16348`; `docs/policy/meta.md`, `docs/policy/oss.md`,
|
||||
`docs/policy/documentation.md`, `docs/policy/agents.md`,
|
||||
`docs/policy/architecture.md`, `docs/policy/testing.md`, and
|
||||
`docs/policy/release.md`; root `AGENTS.md`; and the Policy Reading Guide in
|
||||
`docs/development.md`, adapted for this repository.
|
||||
`docs/policy/architecture.md`, `docs/policy/testing.md`; root `AGENTS.md`; and the
|
||||
Policy Reading Guide in `docs/development.md`, adapted from
|
||||
`d84a38ebab26f048cafbe59a2246700c35d29843` for this repository.
|
||||
|
||||
Copyright (C) 2026 Eric Rakestraw
|
||||
|
||||
@@ -14,10 +15,14 @@ SPDX-License-Identifier: CC-BY-SA-4.0
|
||||
|
||||
License and notices: [CC BY-SA 4.0](licenses/CC-BY-SA-4.0.txt) |
|
||||
[COPYRIGHT](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/COPYRIGHT) |
|
||||
[LICENSE](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/LICENSE).
|
||||
[LICENSE](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/LICENSE) |
|
||||
[COPYRIGHT](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/COPYRIGHT) |
|
||||
[LICENSE](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/LICENSE).
|
||||
|
||||
[Source](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843).
|
||||
|
||||
[Source](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348).
|
||||
|
||||
## go-yaml v4
|
||||
|
||||
Selected module: `go.yaml.in/yaml/v4` at `v4.0.0-rc.6`.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Base Policy Version:** 1.3
|
||||
|
||||
**Last Updated**: 2026-09-29
|
||||
**Last Updated**: 2026-10-22
|
||||
|
||||
**Upstream Source:** [Standard Repository Policies](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/architecture.md)
|
||||
|
||||
@@ -19,8 +19,8 @@ to ADRs for decision rationale.
|
||||
ConfigKit is an opinionated Go library for consistent YAML configuration loading
|
||||
and resolution across consuming applications. The [component overview](../internal/overview.md)
|
||||
owns implementation inventory, the [configuration contract](../config.md) owns
|
||||
implemented behavior, and the [deferred scope](../roadmap/deferred.md) preserves
|
||||
deferred scope.
|
||||
implemented behavior, and the [future feature list](../roadmap/future.md) preserves
|
||||
candidates for future consideration.
|
||||
|
||||
## Adopted Constraints And Implemented Structure
|
||||
|
||||
@@ -32,6 +32,12 @@ structure as implemented when its changes land.
|
||||
Constraints must reflect deliberately selected requirements and decisions.
|
||||
Do not promote incidental implementation choices into architectural invariants.
|
||||
|
||||
The collection-policy and CLI-ordering requirements below reflect accepted
|
||||
[ADR 0017](../adr/0017-collection-sequence-policy.md) and
|
||||
[ADR 0018](../adr/0018-cli-after-structural-resolution.md). The
|
||||
[sequence roadmap](../roadmap/sequences.md) tracks the work to implement them;
|
||||
the configuration contract continues to describe implemented behavior.
|
||||
|
||||
## Success Criteria
|
||||
|
||||
ConfigKit prioritizes a coherent configuration model: consistency and
|
||||
@@ -91,6 +97,48 @@ boundaries, and keep simple consumers independent of optional composition
|
||||
features. The [consumer adoption guide](../consumers/README.md) demonstrates the
|
||||
implemented surface against representative simple and complex shapes.
|
||||
|
||||
## Simplicity, Defaults, And Explicit Controls
|
||||
|
||||
These principles apply throughout ConfigKit: to configuration syntax, the public
|
||||
Go API, defaults, and feature design. Evaluate their effects on both configuration
|
||||
authors and consuming applications.
|
||||
|
||||
**Keep straightforward workflows straightforward.** Common configuration
|
||||
workflows should require little configuration text and minimal API setup.
|
||||
Support more demanding workflows through explicit, composable options, without
|
||||
requiring simple consumers to understand or configure those options.
|
||||
|
||||
**Prefer simple, permissive defaults with explicit controls.** Choose predictable
|
||||
defaults that minimize required setup and accommodate valid configurations
|
||||
without unnecessary restrictions. Provide selectable options for workflows that
|
||||
need additional capabilities, narrower behavior, or stronger requirements.
|
||||
|
||||
Measure simplicity by what consumers must understand and maintain, including
|
||||
configuration text and application-side plumbing. Concise syntax is useful, but
|
||||
hidden inference and interacting meanings can make a shorter declaration harder
|
||||
to understand. Prefer explicitness where it makes behavior more predictable.
|
||||
A terse configuration format that requires substantial application-side
|
||||
infrastructure does not satisfy the simplicity principle.
|
||||
|
||||
Define what permissiveness means for each behavior. Allowing an optional
|
||||
contribution to be absent or accepting valid inputs without an unnecessary
|
||||
filename restriction can reduce required setup. Invalid declarations, ambiguous
|
||||
composition, and unreadable selected sources must still produce meaningful
|
||||
errors under the applicable contracts. Permissiveness does not authorize silent
|
||||
recovery from invalid supplied configuration or weaken configuration integrity.
|
||||
|
||||
Options should express coherent choices with understandable purposes and fit
|
||||
ConfigKit's shared configuration model. Support powerful controls when they
|
||||
serve concrete needs; these principles do not require a switch for every
|
||||
implementation behavior or conceivable consumer preference.
|
||||
|
||||
Defaults require judgment when simplicity and permissiveness pull in different
|
||||
directions. Broader discovery can accommodate more layouts while increasing the
|
||||
implicit search scope. Evaluate such tradeoffs explicitly; these principles do
|
||||
not mechanically select the broadest behavior for every option. Changes to
|
||||
existing defaults remain subject to compatibility review, and proposed behavior
|
||||
remains in the roadmap until implemented.
|
||||
|
||||
## Responsibility Boundaries
|
||||
|
||||
ConfigKit owns configuration input processing and the coordination of decoding,
|
||||
@@ -99,9 +147,26 @@ types, field meanings, defaults, normalization, semantic validation, and schema
|
||||
version policy. Applications also own main-file search conventions, CLI wiring,
|
||||
remote acquisition, runtime initialization, and resource checks.
|
||||
|
||||
The engine applies each input's mapping destination within the existing source
|
||||
assembly, applies immediate-parent-relative YAML destinations to imports, and
|
||||
retains each effective placement with source identity and inspection records;
|
||||
the root facade owns the public fields and conversions. The
|
||||
[configuration contract](../config.md#input-destinations) defines the observable
|
||||
placement behavior. [ADR 0011](../adr/0011-input-destinations.md) records input
|
||||
placement, and [ADR 0012](../adr/0012-relative-import-destinations.md) records
|
||||
the import extension.
|
||||
|
||||
Library code must not depend on consuming applications or initialize their
|
||||
runtime resources. It must not perform implicit network access, discover
|
||||
application-specific paths, or change process-global state during ordinary `Load`.
|
||||
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 or discover a main
|
||||
configuration file implicitly. Imports and sibling inheritance share
|
||||
dependency-ready scheduling within their existing scopes; acquisition order and
|
||||
precedence-tier boundaries remain distinct. The [configuration contract](../config.md#explicit-imports)
|
||||
owns observable behavior; [ADR 0015](../adr/0015-shared-dependency-ready-ordering.md)
|
||||
and [ADR 0016](../adr/0016-independent-import-controls.md) record the decisions.
|
||||
The explicit secrets-directory bootstrap is a narrow, opt-in exception: applications
|
||||
invoke it during startup, and it reports writes, skips and partial failures. It
|
||||
must never run implicitly as part of configuration loading.
|
||||
@@ -124,6 +189,29 @@ The [configuration contract](../config.md#completion-and-resource-lifetime) owns
|
||||
the precise completion and ownership behavior. The proposed decision record is
|
||||
[ADR 0008](../adr/0008-complete-on-return-loading.md).
|
||||
|
||||
### Accepted Processing Order
|
||||
|
||||
Complete source composition and precedence, including selected overlays, before
|
||||
sibling inheritance. Then apply typed defaults and decode the configuration.
|
||||
Apply explicit CLI overrides to that configuration and check their types before
|
||||
running application resolution. Effective secret lookup and final validation
|
||||
follow resolution, before publication. [ADR 0018](../adr/0018-cli-after-structural-resolution.md)
|
||||
records this ordering.
|
||||
|
||||
CLI overrides are the highest-priority input to application resolution. Supplied
|
||||
sequences replace whole sequences, and mappings combine recursively. Inheritance
|
||||
and typed defaulting do not run again after CLI application. CLI paths and values
|
||||
must not introduce structural directives; earlier source, inheritance, and
|
||||
decoding failures remain errors even if a CLI assignment would replace the value.
|
||||
Semantic validation assesses the final configuration after overrides and resolution.
|
||||
|
||||
Applications own flag parsing and domain resolution. ConfigKit owns override
|
||||
application, validation, and source attribution. Resolution receives CLI values
|
||||
with their explicit-presence and source information. Absence-based defaults
|
||||
preserve explicit values; recorded normalization and derivation can still change
|
||||
them. Typed defaults and application resolution retain their own assignment
|
||||
semantics, independently of collection operations during source assembly.
|
||||
|
||||
## Public API And Dependency Direction
|
||||
|
||||
Keep one small, coherent public surface for the supported lifecycle. Simple
|
||||
@@ -152,31 +240,44 @@ framework on this library.
|
||||
|
||||
## Configuration Integrity
|
||||
|
||||
Within a configuration tier, independent contributions use strict composition:
|
||||
shared mappings combine recursively, while overlapping non-mapping definitions
|
||||
are conflicts, including equal values. Between precedence tiers, intentional
|
||||
overlay combines mappings recursively, retains omitted lower-priority children,
|
||||
and replaces other present values according to the existing overlay rules.
|
||||
Tier precedence is explicit; traversal order within a tier establishes stable
|
||||
reporting, not permission to override. The distinction and its rationale are
|
||||
recorded in [ADR 0001](../adr/0001-separate-composition-and-overlay.md).
|
||||
Within a configuration tier, independent contributions use strict composition.
|
||||
Shared mappings combine recursively. Sequences with an applicable identity
|
||||
combine distinct records and reject duplicate identities, including equal
|
||||
records. Other overlapping non-mapping values remain conflicts, including equal
|
||||
values. Conflicting identity or operation declarations among peers also fail.
|
||||
Traversal order establishes stable reporting and record order; it does not grant
|
||||
precedence to peers. [ADR 0001](../adr/0001-separate-composition-and-overlay.md)
|
||||
records the distinction between strict composition and intentional overrides.
|
||||
|
||||
The main document and its imports form a strictly composed tier. Explicit
|
||||
underlay groups compose strictly within each group, then combine recursively
|
||||
from lowest to highest beneath the main tier. Source acquisition kind does not
|
||||
determine its precedence role. Input-specific constraints, including CLI path
|
||||
rules and opt-in version ownership, remain part of the configuration contract. Consistent combination
|
||||
semantics do not require identical acquisition or lifetime behavior from every
|
||||
source kind.
|
||||
underlay groups compose strictly within each group, then combine from lowest to
|
||||
highest beneath the main tier. Between tiers, mappings combine recursively and
|
||||
retain omitted lower-priority children. Sequence values follow collection policy;
|
||||
other present values retain the established replacement rules. Source acquisition
|
||||
kind does not determine precedence. Input-specific constraints, including version
|
||||
ownership and path protections, remain part of the configuration contract.
|
||||
Consistent combination semantics do not require identical acquisition or lifetime
|
||||
behavior from every source kind.
|
||||
|
||||
After source precedence is complete, built-in sibling `extends` relationships
|
||||
resolve structurally in the same parser-independent representation, using the
|
||||
existing recursive overlay rules. This adds neither an acquired source nor a
|
||||
consumer registration mechanism. Enclosing relationships precede nested
|
||||
destination-local resolution; typed defaults, application resolution, and
|
||||
secret lookup retain their existing lifecycle roles. The [configuration
|
||||
contract](../config.md#sibling-inheritance) owns the exact syntax and presence
|
||||
rules, and [ADR 0009](../adr/0009-sibling-inheritance.md) records the rationale.
|
||||
Built-in sibling `extends` relationships resolve after source precedence in the
|
||||
parser-independent document representation. Preserve existing mapping-sibling
|
||||
scopes and allow an identified sequence to provide a sibling scope for its
|
||||
records. A record may inherit from one parent in that sequence, selected by
|
||||
complete identity. The child supplies and keeps its own identity while inheriting
|
||||
the parent's other settings. Parents remain ordinary records subject to normal
|
||||
validation.
|
||||
|
||||
Use the shared dependency ordering and preserve assembled sequence order.
|
||||
Resolve enclosing relationships before nested destination-local relationships.
|
||||
Mappings combine recursively; sequences use the inheriting collection's policy.
|
||||
Whole-record upsert replaces records during combination; explicit inheritance
|
||||
uses the records that remain. Inheritance remains built-in and creates no
|
||||
acquired source.
|
||||
[ADR 0009](../adr/0009-sibling-inheritance.md) records the
|
||||
original scope and continuing ownership decisions, with sequence combination
|
||||
and identified-sequence inheritance defined by
|
||||
[ADR 0017](../adr/0017-collection-sequence-policy.md) and CLI ordering by
|
||||
[ADR 0018](../adr/0018-cli-after-structural-resolution.md).
|
||||
|
||||
ConfigKit must define supported YAML semantics explicitly rather than inherit
|
||||
parser defaults. Processing order and diagnostics must be deterministic for the
|
||||
@@ -185,13 +286,14 @@ configuration. Do not run application hooks on invalid typed configuration.
|
||||
|
||||
User-controlled precedence is application defaults, ordered underlays, composed
|
||||
main input, selected overlay, then explicitly supplied CLI overrides.
|
||||
Applications own flag parsing; ConfigKit owns overlay semantics and provenance.
|
||||
Domain resolution follows these
|
||||
inputs and must remain recorded, not become a hidden override channel.
|
||||
This priority order is distinct from processing order: typed defaults are applied
|
||||
at decoding, and application resolution follows CLI input under the lifecycle
|
||||
rules above.
|
||||
|
||||
Preserve node-based composition until the effective input is ready for typed
|
||||
decoding. Preserve presence and source information through processing. Ordinary Go zero
|
||||
values must not be used to infer whether input was absent. Final configuration,
|
||||
Preserve node-based composition through source assembly and inheritance until
|
||||
typed decoding. Preserve presence and source information through later CLI and
|
||||
application-resolution changes as well. Ordinary Go zero values must not be used
|
||||
to infer whether input was absent. Final configuration,
|
||||
metadata, and effective inspection must agree, including application resolution
|
||||
changes; see [ADR 0003](../adr/0003-preserve-presence-and-resolution-history.md).
|
||||
Safe historical values and an explanation renderer are part of the implemented contract,
|
||||
@@ -201,6 +303,79 @@ See [ADR 0005](../adr/0005-explicit-overrides-and-safe-explanation.md).
|
||||
Detailed syntax and operation tables belong to the
|
||||
[current configuration contract](../config.md).
|
||||
|
||||
The frozen inspection boundary also supports comparing completed same-type
|
||||
results without reopening sources or rerunning the load lifecycle. The
|
||||
[configuration contract](../config.md#effective-configuration-diffs) owns
|
||||
comparison and disclosure behavior, the [component overview](../internal/overview.md)
|
||||
owns implementation responsibility, and [ADR 0010](../adr/0010-effective-configuration-diff.md)
|
||||
records the accepted decision.
|
||||
|
||||
## Collection Identity And Combination
|
||||
|
||||
ConfigKit owns a common collection policy for imported records, inline sequences,
|
||||
selected overlays, and sibling inheritance. Configuration authors may declare
|
||||
identity and combination rules; applications may register authoritative identity
|
||||
constraints. Configuration declarations must agree with application constraints.
|
||||
Keep source placement explicit and separate from policy, retaining the placement
|
||||
decision in [ADR 0014](../adr/0014-sequence-placement-and-composition.md).
|
||||
[ADR 0017](../adr/0017-collection-sequence-policy.md) owns the rationale for
|
||||
collection policy and its relationship to application authority.
|
||||
|
||||
Gather declarations for the relevant destination and source or item-local scope
|
||||
before strict composition. Applicable identity from accumulated lower tiers must
|
||||
be available when composing higher-tier peers. Higher-tier declarations cannot
|
||||
repair invalid lower-tier composition. Preserve the distinction between source
|
||||
acquisition, dependency-ready scheduling within scopes, and precedence order.
|
||||
|
||||
Strictly compose each tier's contributions before applying one operation for the
|
||||
collection against its lower value. Support whole-sequence replacement,
|
||||
duplicate-rejecting append, and whole-record replacement by identity (`upsert`).
|
||||
Append and upsert require identity; do not infer it from record fields or position.
|
||||
Duplicate identities among peers fail under every mode. Source precedence and
|
||||
inheritance use the same operations and deterministic ordering rules. Whole-record
|
||||
upsert replaces the matching record's fields together; recursive record merging
|
||||
and unkeyed sequence concatenation are outside this operation model.
|
||||
|
||||
An explicit mode persists at its applicable destination until a higher explicit
|
||||
mode changes it. With no explicit mode, effective identity selects upsert and
|
||||
its absence selects whole-sequence replacement. Inferred defaults do not become
|
||||
persistent instructions. The inheriting destination chooses its operation; copying
|
||||
a parent collection does not automatically copy its mode to that destination.
|
||||
|
||||
Track YAML identity and explicit modes according to their separate lifetimes.
|
||||
Actual whole-value replacement discards lower YAML identity while preserving
|
||||
application constraints and any new identity declaration. Item-local policies
|
||||
belong to their records: retaining a record retains its rules, and discarding it
|
||||
discards those rules. Policies independently applicable at a destination remain
|
||||
in effect. Inheritance can copy YAML identity subject to agreement and replacement
|
||||
rules; application registrations continue to constrain their registered paths.
|
||||
Numeric positions alone do not establish policy ownership.
|
||||
|
||||
Allow declarations without accompanying records. A key declaration validates a
|
||||
retained sequence immediately or remains pending in its owning scope when no
|
||||
sequence exists. A mode-only declaration affects later combinations without
|
||||
recombining earlier tiers. Neither declaration alone clears a collection, and
|
||||
declaring replace alone does not authorize changing a retained collection's
|
||||
identity. These rules do not independently impose path-existence or shape
|
||||
requirements.
|
||||
|
||||
Enforce YAML identity during source composition, precedence, and identified
|
||||
inheritance operations. Application registrations additionally constrain final
|
||||
effective sequences after defaults, CLI overrides, and application resolution.
|
||||
The application schema and validation own existence, nullability, and allowed
|
||||
shape. The final identity guarantee covers actual sequences on successful load;
|
||||
it does not cover every intermediate callback state or later caller mutations.
|
||||
YAML-only declarations carry no such final guarantee through defaults, CLI
|
||||
assignments, or application resolution.
|
||||
|
||||
Preserve source attribution, presence, history, sensitivity, and resource context
|
||||
as records survive, move, or are replaced. Identity diagnostics use paths and
|
||||
available origin information without exposing identity values. The guarantee
|
||||
requires no new mutation-history system or exact reconstruction of causes for
|
||||
records supplied by defaults or callbacks. Exact identity equality, declaration
|
||||
syntax, presence cases, and operation tables belong in the configuration contract
|
||||
when implemented; the roadmap owns those details until then.
|
||||
|
||||
## Ownership And Confidentiality
|
||||
|
||||
Returned configuration and metadata must not share mutable state across loads.
|
||||
@@ -227,7 +402,7 @@ for safe messages supplied by their hooks.
|
||||
Do not introduce automatic directory discovery, network providers, arbitrary
|
||||
merge strategies, additional configuration languages, or application runtime
|
||||
management without concrete consumer evidence and an explicit design decision.
|
||||
The [deferred scope](../roadmap/deferred.md#explicit-non-goals-and-deferred-scope)
|
||||
owns the deferred-feature inventory. Significant boundary
|
||||
changes require an ADR and corresponding policy/contract updates; implementation
|
||||
convenience alone is not a reason to broaden the public API.
|
||||
Record significant decisions to exclude capabilities, and their rationale, in
|
||||
ADRs. Significant boundary changes require an ADR and corresponding
|
||||
policy/contract updates; implementation convenience alone is not a reason to
|
||||
broaden the public API.
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
**Base Policy Version:** 1.11
|
||||
|
||||
**Last Updated**: 2026-09-28
|
||||
**Last Updated**: 2026-10-16
|
||||
|
||||
**Upstream Source:** [Standard Repository Policies](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/documentation.md)
|
||||
|
||||
@@ -38,9 +38,10 @@ roadmap owns implementation status and sequencing until the decision is
|
||||
implemented. Current architecture, user, operator, integration, and internal
|
||||
documentation are updated when the behavior lands.
|
||||
|
||||
`docs/roadmap/deferred.md` owns deliberate exclusions; task-specific plans
|
||||
own execution order and progress. Completed plans retire during release
|
||||
preparation.
|
||||
`docs/roadmap/future.md` owns potential enhancements and their open questions;
|
||||
task-specific plans own execution order and progress. Record significant
|
||||
architectural exclusions and their rationale in ADRs. Do not maintain a roadmap
|
||||
inventory of rejected proposals. Completed plans retire during release preparation.
|
||||
|
||||
### Audience And Detail
|
||||
|
||||
@@ -91,7 +92,7 @@ secret values.
|
||||
| Inventory of implemented internal packages | `docs/internal/overview.md` | Implemented packages and components, responsibilities, and links to focused internal docs. | Normative architecture, contributor reading policy, external contracts. |
|
||||
| Behavior and architecture of internal packages | Other files under `docs/internal/` | Implementation flow, collaborators, state transitions, package-local guarantees and failures, and relevant tests. | Global architecture invariants, configuration definitions and defaults, external schemas, operator procedures. |
|
||||
| Architectural decision records | `docs/adr/` | Significant decisions, context, alternatives, rationale, consequences, and supersession history. | Current behavior reference, implementation status, task sequencing. |
|
||||
| Deferred scope | `docs/roadmap/deferred.md` | Deliberate exclusions and conditions for reconsideration. | Task execution and current behavior. |
|
||||
| Potential future features | `docs/roadmap/future.md` | Candidate enhancements and open questions for future consideration. | Rejected proposals, architectural decisions, task execution, and current behavior. |
|
||||
| Future implementation plans | Task-specific files under `docs/roadmap/` | Unfinished work, sequencing, acceptance, and progress; completed plans retire during release preparation. | Implemented behavior and ADR rationale. |
|
||||
| Release-note history | `docs/releases/` | One checked-in summary per source release under the procedure; the note at its immutable tag is the release record. | Current commands, behavior, contracts, and compatibility definitions. |
|
||||
| Complete examples | `examples/` and executable Go examples | Maintained copyable artifacts and Go API examples. | Configuration reference, symbol contracts, and prose explanation. |
|
||||
@@ -156,8 +157,10 @@ Use sequentially numbered ADR filenames such as
|
||||
|
||||
Treat the decision content of an accepted ADR as immutable. When a decision
|
||||
changes, create a new ADR and update the earlier ADR's status to superseded.
|
||||
Rejected architectural alternatives belong in the ADR; rejected product ideas
|
||||
belong in the roadmap. Routine choices need no ADR.
|
||||
Rejected architectural alternatives and significant architectural exclusions
|
||||
belong in ADRs. Roadmaps preserve plausible future work, not rejected product
|
||||
ideas. Routine choices and rejected suggestions need no ADR unless they
|
||||
represent a significant architectural decision.
|
||||
|
||||
## Maintenance
|
||||
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
# Release Policy
|
||||
|
||||
**Base Policy Version:** 1.4
|
||||
**Base Policy Version:** 1.6
|
||||
|
||||
**Last Updated**: 2026-09-28
|
||||
**Last Updated**: 2026-10-09
|
||||
|
||||
**Upstream Source:** [Standard Repository Policies](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/release.md)
|
||||
**Upstream Source:** [Standard Repository Policies](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/docs/policy/release.md)
|
||||
|
||||
## Purpose
|
||||
|
||||
@@ -12,6 +12,9 @@ This policy defines a proportionate workflow for validating and publishing an
|
||||
intended source revision under a fresh version. Releases should require little
|
||||
routine intervention, avoid repeated work, and preserve published contents.
|
||||
|
||||
Adopt stronger verification or reproducibility requirements through an explicit
|
||||
project-specific decision justified by the intended audience and support needs.
|
||||
|
||||
The repository-specific commands belong in `docs/release.md`. Historical release
|
||||
summaries belong in `docs/releases/`. The procedure must agree with the adopted
|
||||
policy and link to it rather than introduce additional release requirements.
|
||||
@@ -54,10 +57,11 @@ authoritative primary branch, completes the release. The source commit may be
|
||||
pushed before the tag or published together with it through an atomic push.
|
||||
The procedure must choose and document the supported approach.
|
||||
|
||||
ConfigKit uses sequential explicit pushes: first the captured candidate to
|
||||
authoritative `main`, then the annotated tag. Its
|
||||
[release procedure](../release.md) names `origin` and checks that its fetch and
|
||||
push destinations resolve to the authoritative URL.
|
||||
ConfigKit uses sequential publication: first push the committed candidate to
|
||||
authoritative `main`, then invoke ReleaseKit once to validate that candidate and
|
||||
publish its annotated tag. The [release procedure](../release.md) names
|
||||
`origin`; ReleaseKit checks its exact effective push URL before validation and
|
||||
tag publication.
|
||||
|
||||
Release commands must finish at successful publication. They must not wait for
|
||||
or poll CI, require successful CI status, inspect downloadable artifacts, or
|
||||
@@ -76,8 +80,9 @@ the tagged source. The procedure must identify those deliverables and the
|
||||
responsibility of their automation. Published deliverables must be traceable
|
||||
to that source and identify the same version.
|
||||
|
||||
ConfigKit currently declares no additional deliverables. Its source-tag push
|
||||
is the completion point.
|
||||
ConfigKit currently declares no additional deliverables. Confirmed publication
|
||||
of the intended source tag is the completion point. CI, macOS validation,
|
||||
hosted delivery, and consumer retrieval are outside the release gates.
|
||||
|
||||
## Versioning And Compatibility
|
||||
|
||||
@@ -96,7 +101,8 @@ requires a deliberate update to the policy and procedure where needed.
|
||||
For releases at or after `v1.0.0`:
|
||||
|
||||
- a major release may make intentional incompatible changes;
|
||||
- a minor release may add backward-compatible behavior; and
|
||||
- a minor release may add backward-compatible behavior or raise the Go consumer
|
||||
minimum as described below; and
|
||||
- a patch release may make backward-compatible fixes.
|
||||
|
||||
Before `v1.0.0`, a minor release may change a documented public, operational,
|
||||
@@ -108,13 +114,53 @@ Additional compatibility promises and deliberate exceptions belong in the
|
||||
adopted policy or canonical contract documentation. Release notes explain
|
||||
changes; they do not redefine current compatibility contracts.
|
||||
|
||||
## Consumer Minimum And Release Toolchain
|
||||
|
||||
Distinguish the minimum toolchain supported for consumers from the toolchain
|
||||
maintainers use to build and validate a release. Record the consumer minimum in
|
||||
the language's standard metadata and explain its support meaning in canonical
|
||||
documentation. Define the selected release toolchain and its setup commands in
|
||||
`docs/release.md`. Toolchain selection and upgrade cadence are repository-specific
|
||||
choices.
|
||||
|
||||
For Go repositories, normally declare the initial stable release of the selected
|
||||
series as the consumer minimum, such as `go 1.26.0`. Use a higher patch minimum
|
||||
when a dependency or an identified correctness fix requires it, and document
|
||||
the reason.
|
||||
|
||||
For release-policy purposes, successful build and release validation on the
|
||||
selected Go patch release are sufficient to presume support across that Go
|
||||
series, subject to the declared consumer minimum and concrete evidence of
|
||||
incompatibility. Investigate specific compatibility concerns as they arise.
|
||||
|
||||
Use the selected release toolchain for candidate validation and release builds,
|
||||
including CI jobs performing those tasks. A newer validation patch version alone
|
||||
leaves the consumer minimum unchanged.
|
||||
|
||||
A minor release may raise the Go consumer minimum, including from one Go series
|
||||
to the next, both before and after `v1.0.0`. This is an explicit exception to the
|
||||
backward-compatibility requirement for minor releases. Patch releases preserve
|
||||
the consumer minimum. Explain an increase's impact and required consumer action
|
||||
in the release note. Other incompatible changes remain subject to the versioning
|
||||
rules above.
|
||||
|
||||
### ConfigKit Toolchain Selection
|
||||
|
||||
For minor and major releases, select the newest stable Go series available in
|
||||
the development environment. For patch releases, retain the series recorded in
|
||||
`go.mod` and its consumer minimum. Use the latest locally available compatible
|
||||
patch in the selected series, with `GOTOOLCHAIN=local`, to build the
|
||||
module-selected ReleaseKit CLI and validate the candidate. The same Go
|
||||
executable builds the tool and runs its validation commands.
|
||||
|
||||
## Release Documentation And Preparation
|
||||
|
||||
Every new release must include `docs/releases/<version>.md` in the tagged
|
||||
source tree. Use the complete version tag as the filename and begin with a
|
||||
title containing the product name and that exact version.
|
||||
|
||||
ConfigKit requires the first line `# ConfigKit <version>`.
|
||||
ConfigKit requires the first line `# ConfigKit <version>`. ReleaseKit's
|
||||
publication guard checks the release-note H1 for the product and version.
|
||||
|
||||
Include a short summary and any relevant compatibility impact or upgrade
|
||||
instructions. Choose sections suited to the release and keep the note
|
||||
@@ -146,10 +192,9 @@ checks publication state, and publishes the intended refs. A validation-only
|
||||
mode or command may be provided for diagnosis, but must not be a required step
|
||||
before publication repeats the same checks.
|
||||
|
||||
ConfigKit uses `./scripts/release.sh vMAJOR.MINOR.PATCH`. The separate
|
||||
`scripts/release-check.sh` remains an optional diagnostic command; the
|
||||
publication command invokes it once and does not use a persistent candidate
|
||||
record.
|
||||
ConfigKit uses the module-selected ReleaseKit CLI's `publish
|
||||
vMAJOR.MINOR.PATCH` command. Its `check` command is optional for diagnosis; it
|
||||
is not a required preliminary pass to publication.
|
||||
|
||||
Run the complete deterministic, offline default test suite once for the final
|
||||
candidate, following the [testing policy](testing.md). For repositories that
|
||||
@@ -157,13 +202,17 @@ publish builds, verify the required build targets. Select those targets for
|
||||
the application's actual support commitments; native testing on each platform
|
||||
and access to particular hardware are not default release prerequisites.
|
||||
|
||||
ConfigKit's one maintained Go 1.25 baseline runs the full suite with the race
|
||||
detector and test-time vet disabled. This protects library behavior, ownership,
|
||||
and concurrent use; the suite already includes examples, consumer fixtures,
|
||||
documentation links, and package boundaries. One full vet pass adds static
|
||||
checks beyond the test-time subset. CGO-disabled Linux amd64 and arm64 builds
|
||||
cover supported configurations distinct from native race compilation. macOS
|
||||
and newer-toolchain checks are optional.
|
||||
Prepare dependencies and build the tool before offline validation; the
|
||||
publication command is the required validation run, with no separate successful
|
||||
test or CI run required before it.
|
||||
|
||||
The selected profile runs the complete deterministic, offline default suite
|
||||
once with the race detector and test-time vet disabled. The suite already
|
||||
includes examples, consumer fixtures, documentation links, and package
|
||||
boundaries. One full vet pass adds static checks beyond the test-time subset.
|
||||
CGO-disabled Linux amd64 and arm64 package compilation covers supported
|
||||
configurations distinct from native race compilation. macOS and newer-toolchain
|
||||
checks are optional.
|
||||
|
||||
Additional gates must address a concrete risk that the existing checks do not
|
||||
adequately cover. Do not automatically require linting, race testing, module
|
||||
@@ -172,9 +221,10 @@ expanded platform matrices solely because they are possible. Retain such
|
||||
checks when their distinct protection justifies their cost. A one-time migration
|
||||
check must not silently become a permanent release requirement.
|
||||
|
||||
The selected Go commands use readonly module mode with `GOPROXY=off`; acquire
|
||||
the baseline toolchain and dependencies before validation. Formatting and
|
||||
whitespace remain ordinary development checks in `scripts/check.sh`.
|
||||
ReleaseKit runs its selected Go commands with readonly module mode and
|
||||
`GOPROXY=off`; acquire dependencies and build the tool before candidate
|
||||
validation. Formatting and whitespace remain ordinary development checks in
|
||||
`scripts/check.sh`.
|
||||
Review module tidiness when dependencies change, not as a release gate.
|
||||
|
||||
Release tests must not require real credentials, mutable application services,
|
||||
@@ -206,16 +256,19 @@ Automate the checks needed to publish the intended source under a fresh version:
|
||||
authoritative remote;
|
||||
- the worktree and index are clean on the primary release branch;
|
||||
- the source being published is the exact candidate that passed validation,
|
||||
including its matching release note;
|
||||
including its matching release note H1;
|
||||
- the remote primary branch can be advanced to that candidate without
|
||||
discarding remote history, and is published before the tag; and
|
||||
- the tag identifies the validated candidate and only the intended refs are
|
||||
published.
|
||||
|
||||
ConfigKit also checks that `origin` has exactly the authoritative fetch and
|
||||
push destination, including configured push URLs. The command checks the
|
||||
candidate and unused version again after validation, and checks local state
|
||||
before creating the tag. Ignored local files do not block publication.
|
||||
ReleaseKit checks that `origin`'s effective push URL exactly matches the
|
||||
authoritative URL before candidate validation. This does not assert the
|
||||
configured fetch URL or require the release-note H1 to be the first line; the
|
||||
required `# ConfigKit <version>` first-line format remains a content rule. The
|
||||
command checks the candidate and unused version again after validation, and
|
||||
checks local state before creating the tag. Ignored local files do not block
|
||||
publication.
|
||||
|
||||
Fetch the authoritative branch and tag state as needed for these checks.
|
||||
Do not force branch or tag updates to bypass a failed guard. Publish explicit
|
||||
@@ -248,10 +301,10 @@ release without replacing its contents or reusing its version.
|
||||
## Repository Release Procedure
|
||||
|
||||
Maintain `docs/release.md` as a short, executable maintainer runbook. It must
|
||||
identify prerequisites, the primary branch and authoritative remote, version
|
||||
and note preparation, one publication command with local checks and guards,
|
||||
source-only deliverables, the successful-tag-push completion point, and
|
||||
partial or unknown-state recovery.
|
||||
identify prerequisites, the selected release toolchain, the primary branch and
|
||||
authoritative remote, version and note preparation, one publication command with
|
||||
local checks and guards, source-only deliverables, the successful-tag-push
|
||||
completion point, and partial or unknown-state recovery.
|
||||
|
||||
Use copyable commands where practical. Keep optional diagnostics and consumer
|
||||
verification clearly optional. The procedure must not turn preparation work,
|
||||
|
||||
298
docs/release.md
298
docs/release.md
@@ -1,7 +1,7 @@
|
||||
# ConfigKit Release Procedure
|
||||
|
||||
This is the maintainer runbook for ConfigKit's [release policy](policy/release.md).
|
||||
ConfigKit publishes one source-only Go module. The immutable annotated tag and
|
||||
ConfigKit publishes a source-only Go module. The immutable annotated tag and
|
||||
matching checked-in release note are the deliverables; there is no binary,
|
||||
container image, generated package, or separate registry publication.
|
||||
|
||||
@@ -9,183 +9,221 @@ container image, generated package, or separate registry publication.
|
||||
|
||||
- Module: `gitea.maximumdirect.net/eric/ConfigKit`
|
||||
- Primary branch: `main`
|
||||
- Authoritative remote: `origin`, with fetch and push destination
|
||||
`https://gitea.maximumdirect.net/eric/ConfigKit.git`
|
||||
- Authoritative remote: `origin`
|
||||
- Authoritative push URL: `https://gitea.maximumdirect.net/eric/ConfigKit.git`
|
||||
- Tags: stable Semantic Versions such as `v0.5.0`; no prereleases
|
||||
- Minimum Go line: Go 1.25, validated with one maintained patch
|
||||
- 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
|
||||
recorded in `go.mod`; see the
|
||||
[consumer minimum policy](policy/release.md#consumer-minimum-and-release-toolchain)
|
||||
- Release-gated targets: Linux amd64 and arm64
|
||||
- Best-effort targets: macOS amd64 and arm64, under the
|
||||
[platform support policy](policy/release.md#platform-support)
|
||||
|
||||
The checker runs one offline race suite, one full vet pass, and CGO-disabled
|
||||
builds for both Linux targets. It reports skipped filesystem cases. macOS checks
|
||||
and newer-toolchain tests are optional. CI is never a readiness or completion
|
||||
gate; do not wait for or poll it.
|
||||
Release validation runs one offline race-enabled Go suite, one full vet pass,
|
||||
and CGO-disabled package compilation for both Linux targets. macOS and CI are
|
||||
not release gates; do not wait for or poll CI.
|
||||
|
||||
## 1. Prepare access and tools
|
||||
## 1. Select Go and prepare the tool
|
||||
|
||||
Use a clean checkout with Git, Bash 3.2 or newer, a maintained Go 1.25 patch,
|
||||
and a native compiler for race tests. Configure private-host access through the
|
||||
normal credential manager. Keep credentials and environment dumps outside the
|
||||
repository and command transcript.
|
||||
Use a clean checkout with Git, Bash 3.2 or newer, and a native compiler for race
|
||||
tests. Choose the release type before selecting Go. For a minor or major release,
|
||||
select the newest stable Go series available in the development environment.
|
||||
For a patch release, retain the series recorded in `go.mod` and leave its consumer
|
||||
minimum unchanged. Follow the
|
||||
[toolchain selection policy](policy/release.md#configkit-toolchain-selection).
|
||||
|
||||
Verify access without displaying credentials:
|
||||
Put the selected series' latest locally available compatible patch first on
|
||||
`PATH`, ensuring it meets `go.mod`'s minimum. The same `go` executable must build
|
||||
ReleaseKit and run its child validation commands. Set local toolchain selection
|
||||
and verify it:
|
||||
|
||||
```sh
|
||||
git remote get-url origin
|
||||
git ls-remote origin HEAD
|
||||
export GOTOOLCHAIN=local
|
||||
go version
|
||||
go env GOVERSION
|
||||
command -v go
|
||||
```
|
||||
|
||||
The fetch URL and all configured push destinations must resolve to the
|
||||
authoritative URL above. The publication command checks this again. Acquire the
|
||||
selected toolchain and dependencies explicitly before offline validation:
|
||||
Only when a minor or major release advances the consumer minimum to a newer Go
|
||||
series, derive that series' initial stable release from the selected executable:
|
||||
|
||||
```sh
|
||||
export CONFIGKIT_BASELINE_TOOLCHAIN=go1.25.14
|
||||
GOTOOLCHAIN="$CONFIGKIT_BASELINE_TOOLCHAIN" go version
|
||||
GOTOOLCHAIN="$CONFIGKIT_BASELINE_TOOLCHAIN" go mod download
|
||||
selected_go=$(GOTOOLCHAIN=local go env GOVERSION)
|
||||
selected_series=${selected_go#go}
|
||||
selected_series=${selected_series%.*}
|
||||
release_minimum="$selected_series.0"
|
||||
go mod edit -go="$release_minimum"
|
||||
```
|
||||
|
||||
A maintained patch may replace the example pin. No per-release latest-version
|
||||
survey is required. Dependencies must be cached; the checker sets `GOPROXY=off`.
|
||||
Publication requires user authorization. Standing authorization for local
|
||||
commits does not authorize a push or release.
|
||||
If a dependency or identified correctness fix requires a higher patch minimum,
|
||||
use that required version instead and document the reason. When the series is
|
||||
unchanged, leave the minimum unchanged unless a minor or major release
|
||||
deliberately changes it under the
|
||||
[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
|
||||
separate requirement to validate with Go 1.26.0.
|
||||
|
||||
## 2. Select the version and review compatibility
|
||||
Prepare dependencies whether or not the minimum changes:
|
||||
|
||||
Choose an unused stable version. Compare the candidate with the previous tag.
|
||||
Review exported Go signatures and ownership, YAML and scalar semantics,
|
||||
precedence, diagnostic codes, minimum Go version, and platform support. A
|
||||
signature-preserving change may still affect consumers. Before `v1.0.0`,
|
||||
intentional incompatible minor-release changes need a note explaining impact
|
||||
and consumer action; patch releases remain compatible within their minor line.
|
||||
Review the maintained consumer examples and migration guide.
|
||||
```sh
|
||||
go mod download
|
||||
```
|
||||
|
||||
When dependencies change, review parser behavior and migration impact as part
|
||||
of that change. Module tidiness belongs to dependency-change review, not the
|
||||
release checker. Do not repeat a dependency-version survey for every release.
|
||||
Build the tool selected by `go.mod` into temporary storage outside the checkout.
|
||||
Ensure `$TMPDIR` names a system temporary location, not the repository. These
|
||||
commands never install another ReleaseKit version:
|
||||
|
||||
## 3. Write and commit the release note
|
||||
```sh
|
||||
releasekit_tmp=$(mktemp -d "${TMPDIR:-/tmp}/configkit-releasekit.XXXXXX")
|
||||
releasekit_bin="$releasekit_tmp/releasekit"
|
||||
go build -o "$releasekit_bin" gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit
|
||||
go list -m gitea.maximumdirect.net/eric/ReleaseKit
|
||||
"$releasekit_bin" version
|
||||
```
|
||||
|
||||
Create `docs/releases/<version>.md` for the selected version. Its first line
|
||||
must be `# ConfigKit <version>`. Give a meaningful summary and include
|
||||
compatibility impact or upgrade instructions when applicable. Link to current
|
||||
contracts for exact behavior. Review the note and affected canonical documents
|
||||
as ordinary preparation, then commit the complete candidate.
|
||||
The module-selected executable is ReleaseKit v0.4.1. Use the built executable
|
||||
for publication to preserve its exit status and publication-state diagnostics.
|
||||
`go run` maps a nonzero child exit status to 1. ReleaseKit's child Go commands
|
||||
use the `go` found on `PATH` with `GOTOOLCHAIN=local`; keep the selected
|
||||
executable first on `PATH` through the publication command.
|
||||
|
||||
End every new note with these links, replacing `VERSION` with the complete tag:
|
||||
Verify access to the authoritative remote without displaying credentials:
|
||||
|
||||
```sh
|
||||
git ls-remote https://gitea.maximumdirect.net/eric/ConfigKit.git HEAD refs/heads/main
|
||||
```
|
||||
|
||||
Publication requires explicit authorization. Local implementation commits do
|
||||
not authorize a push or release.
|
||||
|
||||
## 2. Prepare and commit the candidate
|
||||
|
||||
Choose an unused stable version locally and on the authoritative remote. Compare
|
||||
the candidate with the previous tag and review exported Go signatures and
|
||||
ownership, YAML and resolution semantics, diagnostic contracts, minimum Go
|
||||
version, and platform support. Review maintained consumer examples and the
|
||||
migration guidance. Explain any consumer-minimum increase and required consumer
|
||||
action under the
|
||||
[consumer minimum policy](policy/release.md#consumer-minimum-and-release-toolchain).
|
||||
|
||||
Create `docs/releases/<version>.md`. Its first line must be exactly
|
||||
`# ConfigKit <version>`, and it must end with the license and attribution footer:
|
||||
|
||||
```markdown
|
||||
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/VERSION/COPYRIGHT) | [LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/VERSION/LICENSE) | [ATTRIBUTION](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/VERSION/docs/oss/attribution.md)
|
||||
```
|
||||
|
||||
Confirm the three target files exist in the candidate and review the version in
|
||||
the URLs. The future tag need not resolve remotely yet; this review adds no
|
||||
network gate. ConfigKit uses checked-in notes without hosted release objects.
|
||||
If a hosted page is used later, reuse the note and preserve its footer. Do not
|
||||
rewrite historical notes or add downloadable license assets.
|
||||
Replace `VERSION` with the full tag and confirm the three target files exist in
|
||||
the candidate. Retire completed roadmaps, preserve unfinished and deferred
|
||||
work, and repair affected links. Review the complete candidate for unintended
|
||||
files and credentials, then commit it on `main`. These are ordinary preparation
|
||||
tasks, not separate approval gates. Any `go.mod` change belongs in this
|
||||
candidate.
|
||||
|
||||
## 3. Push the candidate to `main`
|
||||
|
||||
Push only that committed candidate to the literal authoritative URL. This does
|
||||
not follow local tags:
|
||||
|
||||
```sh
|
||||
git push --no-follow-tags https://gitea.maximumdirect.net/eric/ConfigKit.git HEAD:refs/heads/main
|
||||
```
|
||||
|
||||
ReleaseKit requires authoritative `main` to equal this candidate before it
|
||||
validates. A failure before this push means ReleaseKit publication has not
|
||||
started. If the push result is uncertain, inspect authoritative `main` and
|
||||
continue only when it is confirmed at the candidate. If validation later fails,
|
||||
no release tag is published; correct the issue in a new commit, push the new
|
||||
candidate to `main`, and invoke validation again.
|
||||
|
||||
## 4. Publish the committed candidate
|
||||
|
||||
Before publication, review compatibility, release-note accuracy, and the file
|
||||
list for unintended content or credentials. Retire completed roadmaps as part of
|
||||
release preparation, preserving unfinished and deferred work and repairing
|
||||
links. These are ordinary preparation tasks, not separate human sign-offs.
|
||||
|
||||
With the release explicitly authorized, run the one publication command.
|
||||
The script resolves the repository root from its own path, so it may be invoked
|
||||
from another directory by using its path there. Replace the sample version with
|
||||
the selected version:
|
||||
With the authorized candidate already on authoritative `main`, invoke the
|
||||
module-selected native executable exactly once:
|
||||
|
||||
```sh
|
||||
./scripts/release.sh v0.5.0
|
||||
"$releasekit_bin" publish vMAJOR.MINOR.PATCH
|
||||
```
|
||||
|
||||
The command requires `main`, a clean index/worktree, an unused version locally
|
||||
and on the authoritative remote, and a matching note in the captured commit.
|
||||
Ignored local files do not block it. It checks remote ancestry, runs
|
||||
[`release-check.sh`](../scripts/release-check.sh) once against that commit,
|
||||
rechecks candidate and version state, pushes only that commit to `main`, then
|
||||
creates and pushes one annotated tag. The tag message is the checked-in note.
|
||||
It never commits or repairs the checkout.
|
||||
|
||||
A successful intended tag push completes publication. The command reports the
|
||||
version and commit immediately. No CI, module retrieval, remote read-back, or
|
||||
hosted release object is required. The checker may be run separately for
|
||||
diagnosis, but it is not a prerequisite: a fresh publication invocation
|
||||
validates its candidate once.
|
||||
|
||||
## 5. Recover from a failed or uncertain push
|
||||
|
||||
The command performs one bounded authoritative-state check after a failed push.
|
||||
If the main push failed but authoritative main equals the candidate, it proceeds.
|
||||
If the tag push failed but its exact tag object reached the remote, it reports
|
||||
success. Otherwise it reports branch-only, conflicting, or unknown state and
|
||||
retains any local tag.
|
||||
|
||||
Inspect exact refs before any retry. Set `release_version` to the attempted
|
||||
version and compare main with the candidate from the command's report:
|
||||
Replace the placeholder with the selected version. ReleaseKit checks the
|
||||
effective push URL against `expected_push_url`, verifies the candidate on
|
||||
`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,
|
||||
one full vet pass, and CGO-disabled package compilation for Linux amd64 and
|
||||
arm64. It publishes only the annotated tag for that candidate. Confirmed
|
||||
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.
|
||||
The optional `check`
|
||||
command is available for diagnosis but is not a separate required pass:
|
||||
|
||||
```sh
|
||||
release_version=v0.5.0
|
||||
authoritative=https://gitea.maximumdirect.net/eric/ConfigKit.git
|
||||
if remote_main=$(git ls-remote --refs --exit-code "$authoritative" refs/heads/main); then
|
||||
printf 'remote main: %s\n' "$remote_main"
|
||||
else
|
||||
result=$?
|
||||
if [ "$result" -eq 2 ]; then
|
||||
printf 'authoritative main confirmed absent\n' >&2
|
||||
else
|
||||
printf 'remote main state unknown; inspect before retrying\n' >&2
|
||||
fi
|
||||
fi
|
||||
ref="refs/tags/$release_version"
|
||||
if remote_tag=$(git ls-remote --refs --exit-code "$authoritative" "$ref"); then
|
||||
printf 'remote tag: %s\n' "$remote_tag"
|
||||
else
|
||||
result=$?
|
||||
if [ "$result" -eq 2 ]; then
|
||||
printf 'remote tag confirmed absent\n'
|
||||
else
|
||||
printf 'remote tag state unknown; do not reuse the version\n' >&2
|
||||
fi
|
||||
fi
|
||||
if local_tag=$(git rev-parse --verify "refs/tags/$release_version" 2>/dev/null); then
|
||||
printf 'local tag object: %s\n' "$local_tag"
|
||||
else
|
||||
printf 'no local tag object\n'
|
||||
fi
|
||||
"$releasekit_bin" check vMAJOR.MINOR.PATCH
|
||||
```
|
||||
|
||||
A query failure is not proof that the tag is absent. Compare the remote tag
|
||||
object ID with the local annotated tag object ID; its peeled commit alone is
|
||||
insufficient to establish identical publication. A conflicting published tag
|
||||
must not be moved or replaced. If remote main advanced but the tag was not
|
||||
published, report that partial progress. Only after confirming the local tag
|
||||
was never published or used by another channel may a maintainer remove it:
|
||||
Status 0 confirms publication, status 1 reports a known publication failure or
|
||||
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
|
||||
diagnostics.
|
||||
|
||||
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
|
||||
remote read-back or consumer retrieval; those checks remain optional.
|
||||
|
||||
## 5. Recover from a failed or uncertain tag push
|
||||
|
||||
The executable reports the publication state, phase, version, candidate commit,
|
||||
tag-object ID, effective endpoint, and error on standard error. It performs one
|
||||
bounded authoritative reconciliation after a failed tag push. Exit status 3
|
||||
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
|
||||
tag-object ID from the report. Copy the captured endpoint into the variable
|
||||
below using normal shell quoting. Check that Git still resolves it to itself,
|
||||
then query the authoritative refs once:
|
||||
|
||||
```sh
|
||||
authoritative='COPY_CAPTURED_ENDPOINT'
|
||||
release_version='vMAJOR.MINOR.PATCH'
|
||||
test "$(git ls-remote --get-url -- "$authoritative")" = "$authoritative" &&
|
||||
git ls-remote --refs -- "$authoritative" refs/heads/main "refs/tags/$release_version"
|
||||
```
|
||||
|
||||
A failed URL-equality check or remote query leaves the outcome unresolved.
|
||||
Correct transport configuration before continuing; a rewritten destination
|
||||
cannot establish absence at the captured endpoint.
|
||||
|
||||
Compare the `main` object ID with the reported candidate commit and the tag
|
||||
object ID with the reported tag-object ID. `--refs` returns the annotated tag
|
||||
object itself, not merely its peeled commit. If the tag object matches, the
|
||||
release is published. If the query fails, the result is conflicting, or the
|
||||
endpoint or candidate differs, stop and resolve the state before retrying. If
|
||||
the endpoint is reachable, the tag is confirmed absent, and `main` still equals
|
||||
the reported candidate, remove a retained local tag only after confirming it
|
||||
was not published or used by another channel; then rerun `publish`, which
|
||||
revalidates the candidate:
|
||||
|
||||
```sh
|
||||
git tag -d "$release_version"
|
||||
"$releasekit_bin" publish "$release_version"
|
||||
```
|
||||
|
||||
A subsequent invocation revalidates the candidate; a correctly published tag
|
||||
needs no rerun. Do not roll back main or automatically delete a tag. Transport
|
||||
or authentication failure alone does not require a new version. A defect in
|
||||
published source requires a corrected commit, a new semantically appropriate
|
||||
version and note, and the release procedure again.
|
||||
A validation failure after the candidate reached `main` leaves an untagged
|
||||
candidate there. Correct it with a new commit rather than rewriting or rolling
|
||||
back `main`. A conflicting published tag must never be moved or reused. A
|
||||
defect in published source requires a corrected commit, a new semantically
|
||||
appropriate version, and a matching release note.
|
||||
|
||||
## 6. Optional consumer verification
|
||||
|
||||
Only when useful or requested, read back the remote tag or retrieve the exact
|
||||
version into a new module without a local `replace` directive. These checks
|
||||
are optional and non-gating. Do not poll CI or wait for module propagation.
|
||||
Private-host credentials must already be configured. For direct-origin
|
||||
verification, set the version to the release just published (replace the sample
|
||||
below), then use a temporary consumer module and check the exact module path
|
||||
and version reported by `go list -m`:
|
||||
When useful or requested, read back the remote tag or retrieve the exact
|
||||
version into a new module without a local `replace` directive. These checks are
|
||||
optional and non-gating. Do not poll CI or wait for module propagation. For
|
||||
direct-origin verification, use the exact published version in a temporary
|
||||
consumer module:
|
||||
|
||||
```sh
|
||||
release_version=v0.5.0
|
||||
release_version=vMAJOR.MINOR.PATCH
|
||||
consumer_dir=$(mktemp -d)
|
||||
(
|
||||
cd "$consumer_dir"
|
||||
|
||||
63
docs/releases/v0.6.0.md
Normal file
63
docs/releases/v0.6.0.md
Normal file
@@ -0,0 +1,63 @@
|
||||
# ConfigKit v0.6.0
|
||||
|
||||
This minor release reduces avoidable configuration-loading and inspection work,
|
||||
adopts ReleaseKit for source publication, and raises the consumer Go minimum to
|
||||
1.26.0. The public configuration model remains unchanged from v0.5.0.
|
||||
|
||||
## Changes
|
||||
|
||||
- Reduce temporary path allocations by sizing pointer-formatting buffers,
|
||||
combining final metadata traversal, reusing parsed sensitivity paths, and
|
||||
avoiding path construction when detecting inheritance. Effective and
|
||||
historical metadata indexes retain independently owned paths.
|
||||
- Replace repeated resolution-coverage, metadata-update, CLI-overlap, and
|
||||
sensitivity-propagation scans with private indexes and incremental processing.
|
||||
Preserve diagnostic ordering, history, contextual path shapes, and redaction.
|
||||
- Make presence-only metadata queries avoid materializing provenance. Prepare
|
||||
resolution replay descriptors and immutable mapping and inheritance-history
|
||||
lookups for inspection, and use ordered membership indexes for provenance
|
||||
collections. Returned records remain detached; explanations still replay
|
||||
complete trees and render on demand.
|
||||
- Add a maintained public-API startup construction benchmark. The initial path
|
||||
formatting and metadata traversal experiments reproduced allocation reductions
|
||||
across local and downstream workloads. Later algorithmic improvements were
|
||||
validated behaviorally without a new benchmark campaign. Busy-host timings
|
||||
were inconclusive; no fixed speedup or allocation threshold is promised.
|
||||
- Replace ConfigKit-owned release scripts with a small profile and the
|
||||
module-selected ReleaseKit v0.3.0 tool. The
|
||||
[release procedure](../release.md) documents candidate preparation, tag-only
|
||||
publication, and failed-push recovery. Existing release checks and the
|
||||
successful-tag-push completion point remain unchanged; CI is not a gate.
|
||||
- Adopt release policy 1.6, distinguishing the consumer Go minimum from the
|
||||
validation toolchain. Retire completed planning and measurement records into
|
||||
Git history while preserving [performance candidates](../roadmap/future.md#3-performance-experiments).
|
||||
|
||||
## Compatibility and upgrade
|
||||
|
||||
The consumer minimum increases from Go 1.25.0 to **Go 1.26.0**. Install Go 1.26.0
|
||||
or newer before upgrading, and update application build and CI environments that
|
||||
still use Go 1.25. The `go.mod` directive records the consumer minimum; using a
|
||||
newer validation patch does not itself raise that minimum. See the
|
||||
[consumer minimum policy](../policy/release.md#consumer-minimum-and-release-toolchain).
|
||||
|
||||
Select this version and rebuild:
|
||||
|
||||
```sh
|
||||
go get gitea.maximumdirect.net/eric/ConfigKit@v0.6.0
|
||||
```
|
||||
|
||||
No application-code or configuration migration is required from v0.5.0. Public
|
||||
Go signatures, strict within-tier composition, recursive tier precedence,
|
||||
sibling inheritance, presence/null distinctions, provenance, secret handling,
|
||||
and synchronous completion retain their existing contracts. See the
|
||||
[configuration contract](../config.md) and
|
||||
[consumer guide](../consumers/README.md).
|
||||
|
||||
The YAML dependency remains `go.yaml.in/yaml/v4 v4.0.0-rc.6`. ReleaseKit is a
|
||||
declared Go tool dependency for maintainer commands, not a library runtime
|
||||
import; its requirements participate in ConfigKit's module graph. Linux amd64
|
||||
and arm64 remain release-validated targets, and macOS remains best-effort.
|
||||
Maintainers should use the current release procedure instead of the retired
|
||||
`scripts/release.sh` and `scripts/release-check.sh` commands.
|
||||
|
||||
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.6.0/COPYRIGHT) | [LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.6.0/LICENSE) | [ATTRIBUTION](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.6.0/docs/oss/attribution.md)
|
||||
58
docs/releases/v0.7.0.md
Normal file
58
docs/releases/v0.7.0.md
Normal file
@@ -0,0 +1,58 @@
|
||||
# ConfigKit v0.7.0
|
||||
|
||||
This minor release adds structured comparison of completed configurations.
|
||||
Applications can inspect changes between two configuration files or the
|
||||
effective impact of a selected overlay, with ConfigKit handling comparison and
|
||||
safe value disclosure while applications own presentation.
|
||||
|
||||
## Changes
|
||||
|
||||
- Add `Diff[T](left, right *Result[T]) ([]Difference, error)` through the public
|
||||
facade. It compares frozen effective inspection values from successful loads,
|
||||
including defaults and recorded application resolution, rather than source
|
||||
text or later mutations of `Result.Config`.
|
||||
- Return detached changed-region records containing a semantic path, safe left
|
||||
and right `ValueSnapshot` values, and `HiddenChanged`. Mappings compare
|
||||
recursively, sequences report atomic changes, and records follow canonical
|
||||
pointer order. Presence distinguishes additions and removals from explicit
|
||||
null; equal effective values produce no records regardless of provenance or
|
||||
loading history.
|
||||
- Protect both snapshots with sensitivity from both loads, including historical
|
||||
and dependency-derived classification. Compatible regions retain public
|
||||
siblings; incompatible structural replacements use complete-region masking
|
||||
where required. `HiddenChanged` identifies actual changes withheld by masking,
|
||||
rather than merely indicating that a snapshot contains redacted content.
|
||||
- Preserve visible public scalar changes when only historical sensitive
|
||||
descendants remain, including additions and removals. Historical explanation
|
||||
redaction and masking of incompatible present shapes remain intact.
|
||||
- Add maintained file-comparison and base-versus-overlay examples with
|
||||
consumer-owned formatting, plus an executable public API example. See the
|
||||
[consumer guide](../consumers/README.md#effective-configuration-diffs) and
|
||||
[configuration contract](../config.md#effective-configuration-diffs).
|
||||
|
||||
## Compatibility and upgrade
|
||||
|
||||
No application-code or configuration migration is required from v0.6.0.
|
||||
Adopting the new API is optional. Loading, composition, inheritance, provenance,
|
||||
existing inspection, and secret-handling contracts remain unchanged.
|
||||
|
||||
Load both configurations successfully before calling `Diff`; nil or
|
||||
uninitialized results return an error with no records. Comparison does not
|
||||
reopen sources, rerun callbacks, or reread environment variables. Typed secret
|
||||
references compare by configured reference name, not resolved credential bytes,
|
||||
so credential rotation behind an unchanged name is not reported. Returned
|
||||
snapshots describe changed regions, not cross-protected full-document context.
|
||||
|
||||
Select this version and rebuild:
|
||||
|
||||
```sh
|
||||
go get gitea.maximumdirect.net/eric/ConfigKit@v0.7.0
|
||||
```
|
||||
|
||||
The consumer minimum remains Go 1.26.0. The YAML dependency remains
|
||||
`go.yaml.in/yaml/v4 v4.0.0-rc.6`, and the maintainer tool dependency remains
|
||||
ReleaseKit v0.3.0. Linux amd64 and arm64 remain release-validated targets;
|
||||
macOS remains best-effort. Publication follows the existing
|
||||
[release procedure](../release.md).
|
||||
|
||||
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.7.0/COPYRIGHT) | [LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.7.0/LICENSE) | [ATTRIBUTION](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.7.0/docs/oss/attribution.md)
|
||||
22
docs/releases/v0.7.1.md
Normal file
22
docs/releases/v0.7.1.md
Normal file
@@ -0,0 +1,22 @@
|
||||
# ConfigKit v0.7.1
|
||||
|
||||
This patch updates the maintainer release tool to ReleaseKit v0.4.1. ConfigKit's
|
||||
runtime behavior and public API are unchanged.
|
||||
|
||||
## Compatibility and upgrade
|
||||
|
||||
No application-code or configuration migration is required from v0.7.0. The
|
||||
consumer minimum remains Go 1.26.0, and the YAML dependency is unchanged.
|
||||
|
||||
Select this version and rebuild:
|
||||
|
||||
```sh
|
||||
go get gitea.maximumdirect.net/eric/ConfigKit@v0.7.1
|
||||
```
|
||||
|
||||
ReleaseKit v0.4.1 is a maintainer tool dependency and does not change consumer
|
||||
configuration or runtime behavior. Linux amd64 and arm64 remain
|
||||
release-validated targets; macOS remains best-effort. Publication follows the
|
||||
existing [release procedure](../release.md).
|
||||
|
||||
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.7.1/COPYRIGHT) | [LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.7.1/LICENSE) | [ATTRIBUTION](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.7.1/docs/oss/attribution.md)
|
||||
70
docs/releases/v0.8.0.md
Normal file
70
docs/releases/v0.8.0.md
Normal file
@@ -0,0 +1,70 @@
|
||||
# ConfigKit v0.8.0
|
||||
|
||||
This minor release adds explicit input placement, relative import destinations,
|
||||
shared source-version checking, and opt-in composition of identified sequence
|
||||
records. Applications can assemble independently authored configuration fragments
|
||||
into one final configuration while retaining source locations, provenance,
|
||||
history, and sensitivity information.
|
||||
|
||||
## Changes
|
||||
|
||||
- Add `Destination` to ordinary load options and layered inputs. A source mapping
|
||||
can contribute beneath literal application keys without being rewritten by the
|
||||
consumer. Imports inherit that destination or append a relative YAML
|
||||
`destination`. Physical import paths and resource contexts remain relative to
|
||||
their declaring sources. See [input destinations](../config.md#input-destinations),
|
||||
[import syntax](../config.md#explicit-imports), and the
|
||||
[mounted collection example](../consumers/README.md#input-destinations-and-mounted-collections).
|
||||
- Add `SourceVersionModeIfPresent` and `SourceVersionModeRequired` to apply one
|
||||
application-owned version checker across main, underlay, imported, and selected
|
||||
overlay documents. Enabled modes check source-local declarations and consume
|
||||
accepted non-main headers before composition. Detached, sensitivity-aware
|
||||
evidence is available through `SourceInfo.VersionCheck`. Acceptance remains
|
||||
application policy; ConfigKit does not require declarations to equal the main
|
||||
version. See [shared source-version checks](../config.md#shared-source-version-checks).
|
||||
- Add explicit `PlacementModeSequenceItem` placement for ordinary and layered
|
||||
inputs, and `placement: sequence-item` for path and glob imports. A source
|
||||
mapping becomes one item at its destination; ordinary helper imports compose
|
||||
within that item and can supply its identity. See
|
||||
[imports](../config.md#explicit-imports).
|
||||
- Add `Definition.SequenceCompositions` registrations for strict composition at
|
||||
selected literal paths using one string identity field. Distinct records append
|
||||
in contribution order, while duplicate identities conflict. Higher-priority
|
||||
sequences still replace lower-priority sequences wholesale. Registration does
|
||||
not add identity-based overlays or final identity validation after defaults and
|
||||
application resolution. See the [composition contract](../config.md#strict-document-composition)
|
||||
and [maintained prompt catalog](../consumers/README.md#identified-prompt-sequences).
|
||||
- Extend source inspection with destination and placement evidence, including
|
||||
whether a nested destination is unresolved in an intermediate or failed-load
|
||||
snapshot. Preserve source-specific history, consumed-header sensitivity, and
|
||||
explanations of descendants removed by sequence replacement as records are
|
||||
assembled. Source locations continue to identify authored coordinates.
|
||||
|
||||
## Compatibility and upgrade
|
||||
|
||||
The new behavior is opt-in: zero-valued destinations retain root placement,
|
||||
mapping placement remains the default, unregistered sequences retain strict
|
||||
conflict behavior, and the default source-version mode preserves the existing
|
||||
main-only version policy. Recursive mapping precedence, whole-sequence
|
||||
replacement between tiers, and application-owned final validation remain intact.
|
||||
|
||||
Public option, definition, input, and source-information structs gain fields.
|
||||
Consumers using unkeyed literals for affected structs must switch to keyed
|
||||
literals or update them. `SourceInfo` now contains a `Path` and is no longer
|
||||
comparable with `==` or usable directly as a map key; compare the relevant fields
|
||||
or select an application-appropriate key instead. Existing keyed literals need
|
||||
no changes to retain the previous loading behavior.
|
||||
|
||||
Select this version and rebuild:
|
||||
|
||||
```sh
|
||||
go get gitea.maximumdirect.net/eric/ConfigKit@v0.8.0
|
||||
```
|
||||
|
||||
The consumer minimum remains Go 1.26.0. The YAML dependency remains
|
||||
`go.yaml.in/yaml/v4 v4.0.0-rc.6`, and the maintainer tool dependency remains
|
||||
ReleaseKit v0.4.1. Linux amd64 and arm64 remain release-validated targets;
|
||||
macOS remains best-effort. Publication follows the existing
|
||||
[release procedure](../release.md).
|
||||
|
||||
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.8.0/COPYRIGHT) | [LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.8.0/LICENSE) | [ATTRIBUTION](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.8.0/docs/oss/attribution.md)
|
||||
@@ -1,34 +0,0 @@
|
||||
# Deferred Scope
|
||||
|
||||
This document owns intentionally excluded features, not a committed delivery plan.
|
||||
The [architecture policy](../policy/architecture.md) governs changes to these
|
||||
boundaries. Implemented behavior belongs in the [configuration contract](../config.md)
|
||||
and consumer adoption in the [consumer guide](../consumers/README.md).
|
||||
|
||||
## Explicit non-goals and deferred scope
|
||||
|
||||
General environment-variable configuration overlays are not supported. Typed secret
|
||||
references and explicit directory bootstrap are the environment convention. Literal
|
||||
credential configuration and implicit secret-directory discovery are unsupported.
|
||||
|
||||
The following remain deliberately excluded:
|
||||
|
||||
- automatic `conf.d` discovery;
|
||||
- remote imports, arbitrary providers, and additional configuration formats;
|
||||
- custom merge strategies, sequence merging, reset operators, multiple-parent
|
||||
inheritance, and cross-scope references;
|
||||
- named overlay inheritance;
|
||||
- parser-specific or custom structural-decoding extension APIs;
|
||||
- generic dynamic-subtree conversion and module-registry APIs;
|
||||
- automatic schema migrations;
|
||||
- hot reload, runtime resource replacement, and remote resource validation;
|
||||
- a bundled CLI framework or application commands; and
|
||||
- configuration diffs and fingerprints.
|
||||
|
||||
Applications may build their own validate, effective-value, source, bootstrap, and
|
||||
explanation commands around the library. Broadening these boundaries requires
|
||||
concrete consumer evidence, compatibility review, and an explicit design change.
|
||||
|
||||
Built-in sibling inheritance using `extends` is implemented as described in the
|
||||
[configuration contract](../config.md#sibling-inheritance). It is distinct from
|
||||
inheritance between selected overlay files, which remains excluded above.
|
||||
178
docs/roadmap/future.md
Normal file
178
docs/roadmap/future.md
Normal file
@@ -0,0 +1,178 @@
|
||||
# Potential Future Features
|
||||
|
||||
This document lists potential enhancements for consideration in future ConfigKit
|
||||
releases. Entries are not accepted contracts, implementation plans, or release
|
||||
commitments. Adopting a candidate requires an explicit design decision and a
|
||||
separate plan. Significant architectural exclusions and their rationale belong
|
||||
in ADRs; this document does not maintain a list of rejected proposals.
|
||||
|
||||
## 1. Identified record sequences
|
||||
|
||||
Status: deferred. PromptKit proposed this capability from its retired proposal 2,
|
||||
**Namespaced Collections As Identified Records**. The original proposal is in the
|
||||
PromptKit repository at
|
||||
`b5866dd:docs/roadmap/configuration-collections-identified-records.md`.
|
||||
PromptKit selected mounted mappings instead; this feature is not a dependency
|
||||
of that design or a request to restore multiple installed prompt versions.
|
||||
|
||||
A narrower [sequence placement and composition feature](../config.md#strict-document-composition)
|
||||
is implemented: consumers can place imported mappings as sequence items and
|
||||
register a single literal string identity field for duplicate-rejecting strict
|
||||
composition. Precedence still replaces sequences as whole values. This entry
|
||||
tracks broader identified-record behavior that remains deferred and is not part
|
||||
of that implemented contract.
|
||||
|
||||
The active [sequence roadmap](sequences.md) now selects a narrower design for
|
||||
collection policy, compound scalar identities, final application-registered
|
||||
identity validation, and inheritance within identified sequences. It owns those
|
||||
selected behaviors and their remaining decisions. The original broader proposal
|
||||
below remains background for further consideration, particularly recursive
|
||||
combination of matching record bodies; it does not override the active roadmap.
|
||||
|
||||
### Motivation and proposed behavior
|
||||
|
||||
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
|
||||
a selected subset of source files while permitting other fragments to omit them.
|
||||
|
||||
### Motivation and proposed behavior
|
||||
|
||||
PromptKit could require each collection's root file (for example,
|
||||
`collection.yml` or `meta.yml`, with naming still undecided) to declare an
|
||||
accepted specification version. Other files would use the existing
|
||||
[check-when-present behavior](../config.md#shared-source-version-checks).
|
||||
This would enforce a rule that otherwise remains a documentation convention.
|
||||
|
||||
All declarations would use the same application-owned `Definition.Version`
|
||||
path and acceptance policy. The proposal concerns where declarations are
|
||||
required; it does not introduce independently versioned collections or subtrees.
|
||||
A root declaration would not prove that headerless descendants declare a
|
||||
compatible version or replace final configuration validation.
|
||||
|
||||
### Questions to resolve before implementation
|
||||
|
||||
- **Selection API:** should callers mark explicit inputs, select sources by
|
||||
path or predicate, or annotate imports? An input-level requirement is simpler,
|
||||
but cannot by itself select a root reached only through an import.
|
||||
- **Scope:** does a requirement apply only to the selected document or also to
|
||||
its imports? Applying it only to that document best fits PromptKit's request;
|
||||
imported fragments could still declare versions checked by the shared policy.
|
||||
- **Mode interaction:** how should selection interact with disabled source
|
||||
checks? Require an enabled mode, or activate checking for selected sources?
|
||||
Selection would strengthen check-when-present behavior and add no requirement
|
||||
beyond the load-wide required mode.
|
||||
- **Source handling and evidence:** how should selection work across file,
|
||||
reader, byte, and mapping inputs, glob matches, and repeated imports? Define
|
||||
behavior for absent optional inputs and preserve source-local diagnostics,
|
||||
inspection evidence, and existing sensitivity handling.
|
||||
|
||||
## 3. Performance experiments
|
||||
|
||||
Status: deferred pending evidence. The completed performance audit identified
|
||||
four internal optimization candidates for separate investigation:
|
||||
|
||||
- Branch-local explanation replay instead of the current whole-tree replay.
|
||||
- Redesigning the private metadata-copy handoff while preserving detached public
|
||||
records.
|
||||
- Caching schema descriptors across loads.
|
||||
- Indexing complex contributor-trace membership while preserving its equality
|
||||
rules and compact linked traces.
|
||||
|
||||
These are experiments, not committed implementation work or promised speedups.
|
||||
Reconsider them with evidence that the benefit justifies the ownership,
|
||||
retained-memory, or equivalence risks. The completed audit and implementation
|
||||
evidence are preserved in Git history at `8e91a2b` under `docs/roadmap/`.
|
||||
|
||||
### Questions to resolve before implementation
|
||||
|
||||
- Which representative workloads show enough remaining cost to justify each
|
||||
optimization, and what measurements would establish a useful improvement?
|
||||
- Can narrower replay and membership indexes preserve explanation results,
|
||||
ordering, provenance, and sensitivity behavior?
|
||||
- Can metadata handoff changes preserve detached public records and ownership
|
||||
across loads without introducing aliasing?
|
||||
- What cache lifetime and concurrency design would make schema reuse worthwhile
|
||||
without excessive retained memory?
|
||||
894
docs/roadmap/implementation.md
Normal file
894
docs/roadmap/implementation.md
Normal file
@@ -0,0 +1,894 @@
|
||||
# Sequence Identity And Combination Implementation Plan
|
||||
|
||||
Status: ready for implementation; no stages completed.
|
||||
|
||||
Audience: a Luna coding agent implementing one numbered stage per prompt, in
|
||||
order. This plan starts at commit
|
||||
`4f941f8ba4c9a9ed0afa87b07fe6ba29f131d1c6`. It implements the settled
|
||||
[sequence roadmap](sequences.md), [ADR 0017](../adr/0017-collection-sequence-policy.md),
|
||||
and [ADR 0018](../adr/0018-cli-after-structural-resolution.md).
|
||||
|
||||
## Working Rules
|
||||
|
||||
- Read [development guidance](../development.md) and the applicable policies,
|
||||
especially [agents](../policy/agents.md), [architecture](../policy/architecture.md),
|
||||
[testing](../policy/testing.md), and [documentation](../policy/documentation.md).
|
||||
Prefer the repository knowledge graph for code discovery.
|
||||
- The roadmap owns the selected feature semantics. This plan supplies execution
|
||||
order and implementation choices, not additional product requirements. Preserve
|
||||
unchanged source, parser, version, schema, ownership, and inspection contracts.
|
||||
- Complete each stage's implementation and behavioral tests together. Record its
|
||||
completion and validation here, then commit the nonempty unit with a short,
|
||||
plain-English message. Preserve unrelated changes. Do not publish intermediate
|
||||
stages or a release, update consumers' dependencies, or wait for CI.
|
||||
- Run focused tests while iterating and `./scripts/check.sh` before handing off
|
||||
each code stage. Use the installed compatible Go toolchain with dependencies
|
||||
already acquired, as the development guide describes. Add race coverage at the
|
||||
ownership milestones below. These are implementation checks, not new release
|
||||
gates. Reuse successful checks for unchanged content rather than repeating them
|
||||
at a documentation-only milestone. Do not require human platform validation.
|
||||
- Update affected declaration comments and canonical current-behavior prose when
|
||||
behavior lands. Keep incomplete target behavior in this roadmap/plan until it
|
||||
works. The final documentation stage reconciles the cross-feature narrative;
|
||||
it is not permission to leave misleading current contracts in earlier stages.
|
||||
- Transitional internal adapters are acceptable where specified. Remove them by
|
||||
completion. Do not add public compatibility aliases, permanent feature flags,
|
||||
alternate semantics, new dependencies, new resource caps, or an extension/plugin
|
||||
framework for this work.
|
||||
- Preparation stages keep existing public behavior working and test new machinery
|
||||
privately. Activation stages integrate values, policy, metadata, and relevant
|
||||
failure handling together; do not defer correct inspection to a later stage.
|
||||
|
||||
## Implementation Decisions
|
||||
|
||||
### Public Registration Surface
|
||||
|
||||
Replace `SequenceComposition` and `Definition.SequenceCompositions` with:
|
||||
|
||||
```go
|
||||
type SequenceIdentity struct { /* private, root-owned registration state */ }
|
||||
type SequenceSelector []SequenceSelectorSegment
|
||||
type SequenceSelectorSegment struct { /* private literal-or-any-mapping-key state */ }
|
||||
|
||||
func SequenceLiteralKey(key string) SequenceSelectorSegment
|
||||
func SequenceAnyMappingKey() SequenceSelectorSegment
|
||||
func SequenceIdentityAt(path Path, fields ...string) SequenceIdentity
|
||||
func SequenceIdentityMatching(selector SequenceSelector, fields ...string) SequenceIdentity
|
||||
|
||||
// Field on Definition[T]:
|
||||
// SequenceIdentities []SequenceIdentity
|
||||
```
|
||||
|
||||
For example, the reusable definition can contain both registrations:
|
||||
|
||||
```go
|
||||
SequenceIdentities: []configkit.SequenceIdentity{
|
||||
configkit.SequenceIdentityAt(configkit.Keys("prompts"), "id"),
|
||||
configkit.SequenceIdentityMatching(configkit.SequenceSelector{
|
||||
configkit.SequenceLiteralKey("collections"),
|
||||
configkit.SequenceAnyMappingKey(),
|
||||
configkit.SequenceLiteralKey("prompts"),
|
||||
}, "id"),
|
||||
},
|
||||
```
|
||||
|
||||
The exact constructor converts mapping-only `Path` segments into literal selector
|
||||
segments. Preserve invalid index segments long enough to report an ordinary
|
||||
definition error; do not panic, silently drop them, or change `Path` semantics.
|
||||
The zero selector is the exact root selector, harmless under the existing
|
||||
mapping-root schema. A zero selector segment denotes the literal empty key,
|
||||
not a wildcard. Empty field names are valid literal field names; an empty field
|
||||
set is invalid. Constructors snapshot slices; load-entry conversion also takes
|
||||
an owned snapshot. Definition validation, not construction, reports errors.
|
||||
Root types and functions must not expose internal types or bridge methods.
|
||||
|
||||
### Identity Representation And Matching
|
||||
|
||||
- Normalize identity field sets once: deduplicate and sort exact field names.
|
||||
Use the same normalizer for YAML and application registration.
|
||||
- Encode a record's identity as a hashable, type-tagged, length-delimited tuple in
|
||||
normalized field order. Mapping lookup indexes avoid scanning record fields
|
||||
repeatedly. Distinguish structural-payload lookup from final typed-payload and
|
||||
parent-reference lookup: only fields acting as directives are excluded, not
|
||||
every occurrence of the same literal spelling. Never include tuple encodings
|
||||
in diagnostics.
|
||||
- Normalize finite JSON numbers to sign, significant decimal digits, and a signed
|
||||
base-10 exponent. Strip insignificant zeroes and give all zero spellings one
|
||||
representation. Use `math/big.Int` for exponent arithmetic, not a power-of-ten
|
||||
expansion or a float conversion. Thus large exponent spellings do not allocate
|
||||
storage proportional to their mathematical magnitude. This comparison-only
|
||||
representation does not rewrite document nodes or typed projection.
|
||||
- Compile selectors into a mapping-segment trie with literal and wildcard edges.
|
||||
Carry applicable trie states through traversal; stop at sequence-index edges.
|
||||
Index concrete matches within one assembly/inheritance scope where useful, but
|
||||
do not save an assembly-only destination list for final validation.
|
||||
- Check registration overlap pairwise once during definition validation using
|
||||
equal length and compatible corresponding segments. This straightforward
|
||||
`O(R² × D)` check, for R registrations and maximum selector depth D, is
|
||||
proportionate for registration definitions. Equivalent constraints coalesce;
|
||||
conflicting overlaps fail even with no matching input.
|
||||
Do not impose a registration-count limit to simplify this operation.
|
||||
|
||||
### Policy And Evidence Storage
|
||||
|
||||
Keep application constraints separate from YAML policy. Add private engine
|
||||
collection-policy records containing normalized identity, explicit mode, and
|
||||
declaration locations. Track whether identity is pending or belongs to a retained
|
||||
collection. Retain declaration presence separately from inferred defaults.
|
||||
|
||||
Use the existing acquisition/item scopes as ownership anchors, with an opaque
|
||||
per-load owner token when a retained record needs one. Store item-local policies
|
||||
relative to their owning record, and project concrete paths when that record is
|
||||
placed. Do not key ownership solely by numeric index, identity tuple, or source
|
||||
filename: all three can coincide for a replacement that must lose old policies.
|
||||
Inheritance clones the relevant scoped state; it does not create registrations.
|
||||
|
||||
Resolve policies through shared engine helpers used by strict composition,
|
||||
precedence, and inheritance. Do not attach engine policy to public values or add
|
||||
policy fields to the parser-independent `document.Node`. The sidecar can be
|
||||
extended with concrete-path indexes; it need not become a general event store.
|
||||
|
||||
Sequence combination returns the resulting list plus explicit input-to-output
|
||||
item projections and retained/replaced contribution information. Use that same
|
||||
result for values, policy transfer, origins, source destinations, sensitivity,
|
||||
and historical replay. Extend the existing sequence-history descriptors for
|
||||
noncontiguous upsert positions rather than inferring them from equal values.
|
||||
Sharing immutable internal evidence is acceptable under existing ownership rules;
|
||||
independently returned mutable records must remain detached.
|
||||
|
||||
### Diagnostics
|
||||
|
||||
Use existing `options.invalid` for invalid application definitions and existing
|
||||
`compose.invalid` / `compose.conflict` for malformed or duplicate strict-peer
|
||||
contributions. Add root-owned and engine diagnostic constants
|
||||
`CodeSequenceInvalid` (`sequence.invalid`) and `CodeSequenceConflict`
|
||||
(`sequence.conflict`) for malformed collection declarations/operations or final
|
||||
identities, and conflicting policy / precedence duplicates respectively.
|
||||
Final duplicate identities use `sequence.conflict`. Keep existing inheritance
|
||||
invalid/missing/cycle codes for record relationships. Use existing override and
|
||||
schema/decode codes at the CLI boundary; do not invent a new error taxonomy there.
|
||||
|
||||
Report collection/item paths and available locations, not identity values,
|
||||
normalized tuple strings, raw parent references, or raw hook errors. Declaration
|
||||
conflicts include both sites. Definition overlap errors identify registration
|
||||
positions without fabricating a concrete matched configuration path. Exact
|
||||
English diagnostic wording is not a new guarantee.
|
||||
|
||||
## Stage 1 — Implement Shared Scalar Identity Primitives
|
||||
|
||||
Scope: new private identity helpers and their focused tests; no load-path change.
|
||||
|
||||
1. Add `internal/engine/sequence_identity.go`. Implement field-set normalization,
|
||||
scalar tuple construction, and exact decimal normalization as chosen above.
|
||||
Accept string, number, boolean, and explicit null nodes; distinguish categories
|
||||
and absence. Exclude fields acting as structural directives from payload lookup.
|
||||
2. Validate mapping records, complete direct-field sets, and uniqueness using one
|
||||
tuple-to-first-record index. Return paths and source locations, not values.
|
||||
Support caller-selected diagnostic families for strict and other boundaries.
|
||||
3. Cover equivalent field sets; empty strings and null; missing/non-scalar fields;
|
||||
literal punctuation; compound tuple boundaries; `1`/`1.0`/`1e0`; signed zero;
|
||||
close unequal decimals; and large exponents without expansion. Verify that
|
||||
node text remains unchanged. Include `extends: null` as a structural field
|
||||
that cannot supply source identity.
|
||||
|
||||
Validation: `go test ./internal/engine -run 'SequenceIdentity|SequenceNumber'`
|
||||
and the everyday check. Retain the old registration path until its adapters are
|
||||
replaced; do not generalize unrelated YAML scalar decoding.
|
||||
|
||||
## Stage 2 — Compile Exact And Dynamic Application Constraints
|
||||
|
||||
Scope: private registration model, selector matching, overlap validation.
|
||||
|
||||
1. Extend `sequence_registration.go` with the common registration representation
|
||||
and compiled selector trie. Represent literal and arbitrary-mapping-key segments
|
||||
structurally, not with a magic string. Snapshot registration inputs.
|
||||
2. Validate field sets and mapping-only selectors before source acquisition.
|
||||
Preserve existing destination validation without adding restrictions on identity
|
||||
field spelling. Root and unused selectors remain harmless.
|
||||
3. Implement full-destination matching, equivalent overlap coalescing, and
|
||||
incompatible-overlap errors. Exact rules have no priority over dynamic rules.
|
||||
Keep a temporary conversion from the current single-field registration so
|
||||
existing callers still use their current behavior.
|
||||
4. Test exact and wildcard matches, multiple wildcard levels, empty/punctuation
|
||||
keys, sequence-index nonmatches, disjoint lengths/literals, equivalent sets,
|
||||
incompatible overlaps with absent input, and independent uniqueness scopes.
|
||||
|
||||
Validation: focused registration/selector tests and the everyday check. This
|
||||
stage does not export the replacement API or add final-result validation yet.
|
||||
|
||||
## Stage 3 — Parse Collection Declarations Without Filesystem Coupling
|
||||
|
||||
Scope: private parser and directive extraction helpers, initially unit-tested
|
||||
without activating collection combination in public loads. Keep source-form
|
||||
activation out of this stage; ordinary sources and selected overlays are wired
|
||||
in Stages 13 and 14 respectively.
|
||||
|
||||
1. Add `sequence_declaration.go` for source-root `configkit.sequences`. Each entry
|
||||
has a required existing literal `destination` form and optional `key` and
|
||||
`mode`. `key` is a string or nonempty string list; `mode` is exactly replace,
|
||||
append, or upsert. Use shared identity normalization. A destination-only entry
|
||||
can be a harmless no-op; do not add an unrequested ban on empty declarations.
|
||||
2. Split common root-directive extraction from import-specific parsing in
|
||||
`import_spec.go` / `parse.go`. Extract imports and sequences together, retaining
|
||||
source-local locations and explicit presence. Reject unrecognized directive
|
||||
fields and wrong supplied field types using existing strict parsing style.
|
||||
3. Keep declaration destinations relative to source placement, with item-local
|
||||
coordinates retained until their scope is assembled. Do not project them
|
||||
prematurely to a guessed numeric index.
|
||||
4. Give extraction an explicit overlay context: sequences are allowed, imports
|
||||
are not. Import enablement remains separately enforced; do not turn on
|
||||
filesystem access because a source declares sequences. Keep enabled
|
||||
source-version checks in raw source coordinates before consuming controls;
|
||||
extend existing actual header/control-overlap handling to sequence directives
|
||||
without treating an absent header beneath a control as an actual overlap.
|
||||
5. Test key-only, mode-only, combined, repeated-field-set equivalence, no-op,
|
||||
malformed types/modes/destinations, mixed imports/sequences, and overlay rules.
|
||||
Do not add import-level key/mode shorthand or sequence wrappers.
|
||||
|
||||
Validation: focused declaration/parser tests, existing import-spec tests, and the
|
||||
everyday check. Existing import-only behavior remains intact during preparation.
|
||||
|
||||
## Stage 4 — Reconcile Declarations And Select Collection Operations
|
||||
|
||||
Scope: pure agreement and operation-selection helpers, independent of loading
|
||||
and of the ownership transformations introduced next.
|
||||
|
||||
1. Add `collection_policy.go`. Gather the current scope's declarations before
|
||||
combining peers. Reconcile equivalent keys and agreeing explicit modes;
|
||||
conflicting peers fail. Omitted fields have no preference. Apply authoritative
|
||||
application constraints even for key-only declarations without a value.
|
||||
2. Implement shared operation selection: current explicit mode, retained explicit
|
||||
mode, inferred upsert with effective identity, otherwise replace. Persist only
|
||||
explicit modes. Policy-only declarations must not replay earlier combinations.
|
||||
3. Accept eligible lower policy as an input; do not decide record survival or
|
||||
ancestor removal inside declaration reconciliation. Retain declaration
|
||||
locations/presence separately from inferred mode and application constraints.
|
||||
4. Test equivalent/different field sets, agreeing/conflicting peer modes,
|
||||
omission, explicit default modes, and operation inference. Include key-only
|
||||
declarations agreeing or conflicting with application constraints before a
|
||||
sequence exists.
|
||||
|
||||
Validation: focused policy-agreement/selection tests and the everyday check.
|
||||
Do not activate collection combination or implement the ownership layer here.
|
||||
|
||||
## Stage 5 — Track Policy Lifetime And Record Ownership
|
||||
|
||||
Scope: private scoped-state transformations built on Stage 4's resolution helpers.
|
||||
|
||||
1. Implement key agreement across retained values, replacement reset, pending
|
||||
key declarations, and immediate validation when a key-only declaration
|
||||
identifies retained records.
|
||||
Changing mode to replace without a replacement value does not reset identity.
|
||||
2. Implement scope/record ownership transfer and retirement hooks. Actual ancestor
|
||||
replacement removes descendant collection identity and removed-record policy;
|
||||
independently owned modes and application constraints survive as specified in
|
||||
the roadmap. Do not repeatedly reapply old declarations and resurrect reset
|
||||
identity. Pending declarations retain their owning-scope rules.
|
||||
3. Use item/acquisition scope anchors and record-owned relative policy as selected
|
||||
above. Provide transformations for retention, whole-record replacement,
|
||||
whole-sequence replacement, and inheritance copies without wiring them into
|
||||
public loading yet. Application registrations remain destination constraints.
|
||||
4. Use table-driven tests derived from the roadmap's lifetime, persistence, and
|
||||
replacement tables, including absent/non-sequence destinations, equal outer
|
||||
identities with distinct record owners, and independent root versus item policy.
|
||||
|
||||
Validation: focused collection-policy tests and the everyday check. This stage
|
||||
must not add path-existence, shape, nullability, or global YAML final-check rules.
|
||||
|
||||
## Stage 6 — Implement The Shared Sequence Operations
|
||||
|
||||
Scope: a pure document-level combination helper, not separate implementations
|
||||
for overlays and inheritance.
|
||||
|
||||
1. Add `sequence_combine.go`, accepting lower/higher sequence nodes, resolved
|
||||
policy, and combination context. Reuse Stage 1 identity extraction and indexes.
|
||||
2. Implement replace, duplicate-rejecting append, and whole-record upsert. Upsert
|
||||
retains matched lower positions and appends new higher identities in order.
|
||||
Return item projections and replacement/retention evidence with the new value.
|
||||
3. Keep peer validation distinct: duplicate identities within any contribution or
|
||||
among peers fail under every mode. Do not merge record payloads or sort by ID.
|
||||
4. Cover absent versus null/non-sequence lower values; higher omission, `[]`, null,
|
||||
and non-sequence values; missing identity under append/upsert; empty inputs;
|
||||
stable ordering; equivalent/different keys; and explicit replacement identity
|
||||
reset. An empty input does not bypass kind checks or identity requirements.
|
||||
5. Verify clones do not alias mutable input nodes or policy slices. Build indexes
|
||||
once per operation rather than scanning the accumulated list for every record.
|
||||
|
||||
Validation: focused combination tests and the everyday check. Do not change the
|
||||
ordinary recursive mapping rule or add unkeyed append.
|
||||
|
||||
## Stage 7 — Extend Sequence History Representation And Replay
|
||||
|
||||
Scope: represent and replay sequence operations without yet changing all metadata
|
||||
projection consumers or public precedence.
|
||||
|
||||
1. Extend `sequenceHistoryContribution`, sequence replay in `history.go`, and
|
||||
its contribution indexes to consume Stage 6's explicit item mappings. Existing
|
||||
append ranges may remain a compact case; upsert cannot be represented by one
|
||||
offset.
|
||||
2. Preserve authored raw contribution snapshots and displaced values. A replaced
|
||||
record's omitted fields must appear as removed, not as retained lower payload.
|
||||
Preserve equal-valued replacement events. Keep existing index-based query and
|
||||
atomic sequence-diff APIs; add no public identity-selector path syntax.
|
||||
3. Exercise synthetic append/upsert/replace histories, noncontiguous matches,
|
||||
nested paths, and discarded descendants. Compare replayed final values with
|
||||
the actual combination result, including first/last and unchanged neighboring
|
||||
records. Reuse existing escaped-path and detached-query tests rather than
|
||||
duplicating them.
|
||||
|
||||
Validation: sequence/history/replay tests and the everyday check. Keep current
|
||||
append/replace callers working through the extended descriptors.
|
||||
|
||||
## Stage 8 — Project Sequence Origins And Inspection Evidence
|
||||
|
||||
Scope: use the prepared item mappings consistently in metadata consumers.
|
||||
|
||||
1. Project composition origins, nested source destinations, historical paths, and
|
||||
sensitivity through Stage 6's item mapping and Stage 7's history descriptors.
|
||||
Physical source IDs/locations and resource contexts continue to identify the
|
||||
declaring source. Reuse one projection mechanism rather than searching again
|
||||
by identity or equal payload.
|
||||
2. Test synthetic noncontiguous upserts, replaced descendants, nested source
|
||||
destinations, unchanged neighbors, and secret-marked replaced records. Verify
|
||||
projected queries agree with replayed and actual final values, retaining
|
||||
displaced evidence and detached results.
|
||||
3. Keep the new precedence operations unactivated until their value, policy, and
|
||||
projection machinery are integrated in the later load-path stages.
|
||||
|
||||
Validation: metadata/history/sequence/provenance/sensitivity tests,
|
||||
`go test -race ./internal/engine`, and the everyday check.
|
||||
|
||||
## Stage 9 — Separate Per-Input Acquisition From Composition
|
||||
|
||||
Scope: a behavior-preserving acquired-input handoff; grouped underlay coordination
|
||||
is the next stage.
|
||||
|
||||
1. In `load_input.go`, `imports.go`, and related graph helpers, retain acquisition
|
||||
scopes, raw contributions, inclusion edges, source records,
|
||||
contexts, and extracted declarations without irrevocably composing them.
|
||||
Reuse existing `deferAssembly` and acquired-scope mechanisms instead of adding
|
||||
a second loader. Keep already acquired document handles closed as before.
|
||||
2. Separate acquisition from the per-input composition entry. Retain pending
|
||||
item/helper scope boundaries, namespace identities, inclusion/repetition checks,
|
||||
source records, accounting, and scheduler inputs. Do not reacquire sources to
|
||||
compose them later or change grouped coordination yet.
|
||||
3. Preserve existing version checks for this refactor. Retain a small adapter
|
||||
invoking current composition semantics at the existing lifecycle point.
|
||||
Preserve source-version callbacks at acquisition, including header consumption.
|
||||
4. Run existing acquisition, dependency scheduling, item placement, source-version,
|
||||
ordinary/layered equivalence, resource-lifetime, and source-context tests. Add
|
||||
only targeted regressions for any newly split handoff not already covered.
|
||||
|
||||
Validation: the focused suites above and the everyday check. This refactor must
|
||||
not change source IDs or turn acquisition order into precedence order.
|
||||
|
||||
## Stage 10 — Separate Underlay-Group Acquisition From Composition
|
||||
|
||||
Scope: behavior-preserving group coordination in `load_request.go`, using the
|
||||
per-input handoff already prepared in Stage 9.
|
||||
|
||||
1. Separate acquiring an underlay group from scheduling/composing its acquired
|
||||
scopes. Preserve namespace reanchoring, shared inclusion/repetition state,
|
||||
item-local helper scopes, source/context aggregation, and group accounting.
|
||||
2. Keep authored acquisition order: main and depth-first imports, each underlay
|
||||
and input, overlay, then synthetic CLI records. Preserve underlay indexes and
|
||||
current version-check timing in this refactor; policy-dependent layered
|
||||
composition is activated only in Stage 15.
|
||||
3. Expose the common scope-composition handoff with inputs for accumulated lower
|
||||
policy and compiled application constraints. Initially adapt it to the current
|
||||
semantics. Do not add another loader or repeat acquisition/version callbacks.
|
||||
4. Test the split with existing grouped/dependency/version/destination coverage,
|
||||
including empty inputs/groups and repeated sources within versus across tiers.
|
||||
Confirm unchanged values, source ordering, contexts, and failure evidence.
|
||||
|
||||
Validation: grouped/layered, import scheduling, version, destination, and
|
||||
resource-context tests plus the everyday check.
|
||||
|
||||
## Stage 11 — Compose Strict Collections With Resolved Identity
|
||||
|
||||
Scope: the policy-aware strict composition core. Nested pending-item scope
|
||||
integration is deferred to Stage 12, not absorbed into this prompt.
|
||||
|
||||
1. Extend `composeDocumentsWithPolicy` and `validateRegisteredSequences` to use
|
||||
normalized field sets and scalar tuples. Resolve current declarations,
|
||||
applicable application constraints, and eligible lower identity before peer
|
||||
composition, using the Stage 4 agreement and Stage 5 lifetime helpers.
|
||||
2. Use the existing dependency-ready order for contributions and authored list
|
||||
order. Validate every identified sequence, including singleton contributions;
|
||||
strictly append distinct peers and reject duplicates under all modes.
|
||||
3. For explicit replacement, do not use obsolete lower YAML identity to authorize
|
||||
otherwise unkeyed peer composition. Still apply independently registered
|
||||
application constraints and genuinely applicable declarations.
|
||||
4. Attach Stages 7–8 evidence and Stage 5 scoped policy to composed results. Test
|
||||
completed-document contributions with lower-established identity, declaration
|
||||
conflicts, compound/scalar IDs, singleton validation, and replacement resets.
|
||||
|
||||
Validation: composition and sequence identity tests plus the everyday check.
|
||||
Keep unregistered non-mapping conflicts unchanged. Public load activation remains
|
||||
in the later integration stages.
|
||||
|
||||
## Stage 12 — Integrate Policy-Aware Item And Helper Scopes
|
||||
|
||||
Scope: adapt pending-item/helper composition to Stage 11's core using the acquired
|
||||
scope boundaries from Stages 9–10.
|
||||
|
||||
1. Gather declarations before composing each relevant scope, complete item-local
|
||||
helper imports before item placement, and consume applicable non-main version
|
||||
headers before checking record payload identity. Keep relative policy with its
|
||||
owner until concrete placement is known.
|
||||
2. Apply eligible lower identity at each scope, but prevent a replacement outer
|
||||
record from borrowing nested identity owned by the discarded lower record.
|
||||
Preserve independently applicable policy and application constraints.
|
||||
3. Retained structural directives cannot serve as identity fields. A helper may
|
||||
supply payload identity components. Do not require a helper-built compound
|
||||
parent reference to be complete at this point; validate the completed surviving
|
||||
reference during inheritance.
|
||||
4. Project item evidence and owned policies through Stages 7–8 machinery. Test
|
||||
nested items, helper-supplied identity, header consumption, declaration scope,
|
||||
discarded owner identity, and exact diagnostic paths with no value disclosure.
|
||||
|
||||
Validation: item/destination/import/composition/source-version tests and the
|
||||
everyday check. Keep current public behavior until load-path activation.
|
||||
|
||||
## Stage 13 — Activate Ordinary Source Collection Declarations
|
||||
|
||||
Scope: ordinary `Load` base-source assembly, excluding activation of the new
|
||||
selected-overlay policy surface until Stage 14.
|
||||
|
||||
1. Wire common directive extraction into every ordinary source form: file,
|
||||
native filesystem, bytes, reader, mapping, imported mapping, and sequence item.
|
||||
Declarations work without `Imports`; disabled imports retain their existing
|
||||
rejection semantics. `NoSource` remains absence rather than an empty document.
|
||||
2. Use Stages 11–12 composition and Stage 5 scoped policy. Strip collection
|
||||
controls from application decoding, but retain their locations/evidence for
|
||||
their structural lifetime and diagnostics.
|
||||
3. Integrate policy/projections together with the values. Cover inline/imported
|
||||
equivalence, placement-relative declarations, singleton and peer validation,
|
||||
and declarations without accompanying values. Maintain ordinary loading's
|
||||
existing non-collection overlay behavior while preparing its policy handoff.
|
||||
4. Update the implemented declaration/composition contract sections. Collection
|
||||
precedence, inheritance, and later CLI behavior remain incomplete until their
|
||||
integration stages; do not claim them implemented here.
|
||||
|
||||
Validation: ordinary-load, import, composition, and metadata tests plus
|
||||
the everyday check. Include one public ordinary-load declaration example test.
|
||||
|
||||
## Stage 14 — Activate Selected Overlay Collection Policies
|
||||
|
||||
Scope: ordinary loading's selected overlay and shared precedence integration,
|
||||
using the source/declaration handoff already active in Stage 13.
|
||||
|
||||
1. Permit `configkit.sequences` in selected overlays while continuing to reject
|
||||
overlay imports. Preserve source-version header/control handling and ordinary
|
||||
overlay namespace selection. Consume controls before application decoding.
|
||||
2. Use Stage 6's sequence operations and Stages 4–5 policy helpers in the assembly
|
||||
accumulator. Mappings still recurse and unrelated sequences still replace.
|
||||
Wire value, policy retirement/transfer, and Stages 7–8 inspection projections
|
||||
together, not as a later best-effort repair pass.
|
||||
3. Cover append/upsert/replace, empty/null/kind cases, key-only/mode-only changes,
|
||||
retained explicit modes, overlay identity agreement, and independently owned
|
||||
policies. Check values, history, origins, and discarded descendants together.
|
||||
4. Update the selected-overlay and collection-operation contract sections. Make
|
||||
the shared precedence entry reusable by layered loading without adding another
|
||||
operation implementation or turning overlay imports on.
|
||||
|
||||
Validation: ordinary-load, overlay, composition, policy, and metadata tests plus
|
||||
the everyday check.
|
||||
|
||||
## Stage 15 — Integrate Lower Policy Across Layered Assembly
|
||||
|
||||
Scope: source-backed underlay coordination and the layered selected-overlay path,
|
||||
using the prepared acquisition, ownership, strict-scope, and precedence machinery.
|
||||
Do not absorb unfinished work from Stages 5–14 into this stage.
|
||||
|
||||
1. Acquire main first, but assemble underlays from lowest to highest, strictly
|
||||
composing each group with applicable accumulated lower policy before applying
|
||||
its one precedence operation. Then compose/apply main and the selected overlay.
|
||||
Do not sequentially upsert peer sources as if they were different tiers.
|
||||
2. Pass lower identity into higher strict scopes before peer composition. Cover an
|
||||
otherwise empty underlay establishing identity, inherited explicit mode, and
|
||||
replacement that deliberately discards the old YAML key. Invalid lower tiers
|
||||
still fail even if a higher replacement would hide their values.
|
||||
3. Keep main-only version ownership and every enabled per-source version rule.
|
||||
In default source-version mode, run the main checker once on the composed
|
||||
main tier before overlay/inheritance/defaults, never on underlay-augmented
|
||||
values. Its former main-before-underlay composition timing need not persist:
|
||||
main composition now depends on lower policy. Enabled-mode source checks keep
|
||||
acquisition order and exact source-local header handling. No extra callbacks
|
||||
or second acquisition pass are permitted.
|
||||
4. Keep histories in precedence order, sources in acquisition order, underlay
|
||||
indexes stable across empty groups, and shared budgets intact. Update the
|
||||
layered contract's acquisition-versus-composition description accordingly.
|
||||
Remove the acquisition/composition adapters superseded by this integration;
|
||||
the old public-registration adapter remains until Stage 22.
|
||||
5. Cover multiple inputs/groups, mounted main inputs, item scopes, empty main,
|
||||
explicit empty groups, layered overlays, version protection and diagnostics,
|
||||
and equivalence with ordinary loading where the inputs express the same model.
|
||||
|
||||
Validation: layered/grouped, source-version, dependency scheduling, destination,
|
||||
and metadata tests plus the everyday check.
|
||||
|
||||
## Stage 16 — Verify Replacement And Nested Policy Ownership End To End
|
||||
|
||||
Scope: the consequential lifetime cases across the now-active source assembly.
|
||||
|
||||
1. Exercise an item-local nested collection through retaining the outer record,
|
||||
upserting the same outer ID, and replacing the entire outer sequence. Retained
|
||||
items keep their local key/mode; replacements do not inherit discarded policy
|
||||
merely because identity, source label, or final index is equal.
|
||||
2. Exercise ancestor replacement and later reintroduction. Removed collection
|
||||
identity does not reappear; independently declared append mode can survive
|
||||
and require renewed identity. Cover renewed YAML key, application constraint,
|
||||
explicit replace, and the intended failure with no available identity.
|
||||
3. Cover key-only validation of retained records, pending declarations, a no-value
|
||||
replace declaration that cannot authorize key changes, and independent policy
|
||||
whose owning scope survives a record replacement.
|
||||
4. Correct any integration defects in the Stage 5 ownership transformations;
|
||||
consolidate tests with their mechanism owners. Do not introduce special-case
|
||||
ownership by numeric path or a historical identity cache to make cases pass.
|
||||
5. Verify frozen metadata, origin/resource context, sensitivity of discarded
|
||||
records, and mutable independence across repeated loads for these scenarios.
|
||||
|
||||
Validation: focused policy/sequence/layered/ownership tests,
|
||||
`go test -race ./internal/engine`, and the everyday check.
|
||||
|
||||
## Stage 17 — Prepare Inheritance Policy And Template Transport
|
||||
|
||||
Scope: behavior-preserving inheritance state transport, before activating
|
||||
collection-aware inheritance operations in Stage 18.
|
||||
|
||||
1. Extend inheritance templates/expansion sidecars to carry collection identity
|
||||
and record-local policy alongside nodes and origins. Keep existing scheduler
|
||||
keys, cancellation, version protection, missing-parent/cycle diagnostics, and
|
||||
destination-local evaluation of nested templates.
|
||||
2. Provide owned-state copy/transfer helpers from Stage 5 and path projections from
|
||||
Stage 8. Preserve original nested instructions for each destination's later
|
||||
evaluation rather than retaining only a parent's expanded output. Keep
|
||||
application registration matching separate from copied YAML policy.
|
||||
3. Test template reuse at multiple destinations, independent scoped state, and
|
||||
retained instruction locations/resource context. Preserve current inheritance
|
||||
values/history while these new helpers remain preparatory; do not activate a
|
||||
value change without its corresponding policy/evidence handling.
|
||||
|
||||
Validation: existing inheritance, ownership, history, and resource-context tests,
|
||||
focused transport tests, and the everyday check.
|
||||
|
||||
## Stage 18 — Apply Collection Policy During Mapping-Sibling Inheritance
|
||||
|
||||
Scope: activate shared collection operations using the prepared template/state
|
||||
handoff; existing mapping-sibling relationship selection remains unchanged.
|
||||
|
||||
1. Use the shared sequence combiner at the inheriting destination: parent lower,
|
||||
child higher, destination explicit mode or identity-based default. Copy YAML
|
||||
identity where applicable, but do not automatically copy a parent collection's
|
||||
explicit mode to a different destination. Match application constraints at the
|
||||
destination, including newly created matching paths.
|
||||
2. Transfer record-owned state only with surviving records using Stage 17's
|
||||
helpers. Integrate inheritance evidence with Stage 8's projections in the same
|
||||
activation, preserving origins, historical sensitivity, resource context,
|
||||
cancellation, and existing expansion/history accounting.
|
||||
3. Cover nested append/upsert/replace, explicit empty/null/kind cases, copied key
|
||||
with inferred upsert, destination explicit mode, incompatible keys, application
|
||||
constraints, cancellation, and policy at an inherited new namespace.
|
||||
4. Update current mapping-sibling inheritance documentation for collection
|
||||
operations. Identified-record parent lookup remains incomplete until Stage 21.
|
||||
|
||||
Validation: inheritance, history, provenance, sensitivity, and resource-context
|
||||
tests plus the everyday check. No identified-record parent lookup is active yet.
|
||||
|
||||
## Stage 19 — Build Identified-Record Inheritance Graphs
|
||||
|
||||
Scope: relationship extraction/validation and shared scheduling for sequence
|
||||
scopes; test these helpers before activating payload expansion.
|
||||
|
||||
1. Extend sibling-scope discovery to identified sequences. Build the tuple-to-item
|
||||
lookup once using effective identity. Every record supplies its own complete
|
||||
identity before inheritance; neither parent lookup nor typed defaults supplies
|
||||
a missing child field.
|
||||
2. Parse a surviving record's `extends` as null cancellation or a mapping with
|
||||
exactly the normalized identity fields and supported scalar components.
|
||||
Mapping-sibling references keep their existing string form. Do not accept a
|
||||
scalar shorthand, multiple parents, positional or cross-collection lookup.
|
||||
3. Parent-reference mappings have already used ordinary strict mapping
|
||||
composition: disjoint helper fields can complete one reference, while equal
|
||||
overlapping fields conflict. Validate completeness/extra fields only now on
|
||||
the surviving reference. Discarded references remain historical evidence.
|
||||
4. Build one-parent dependency edges and reuse `scheduleDependencies` with stable
|
||||
semantic-pointer keys. Missing parents, self-reference, and longer cycles
|
||||
use existing inheritance diagnostics without identity values. Keep authored
|
||||
output positions independent from graph schedule.
|
||||
5. Cover compound and null references, numeric equality, forward edges, chains,
|
||||
unrelated equal identities in different lists, malformed refs, missing parents,
|
||||
cycles, helper-built references, and overwritten/cancelled relationships.
|
||||
|
||||
Validation: focused inheritance graph/reference tests and the everyday check.
|
||||
|
||||
## Stage 20 — Prepare Reusable Inheritance Scope Execution
|
||||
|
||||
Scope: remove mapping-container assumptions from the private template executor
|
||||
without activating identified-record inheritance in public loads.
|
||||
|
||||
1. Refactor template execution to separate relationship scheduling/payload
|
||||
expansion from reading and rebuilding its scope container. Preserve current
|
||||
mapping-sibling output, locations, cancellation, evidence, and budgets.
|
||||
2. Provide private mapping- and identified-sequence-scope adapters using Stage 19's
|
||||
relationship graphs. The sequence adapter preserves output positions and
|
||||
exposes child identity fields to the payload-expansion step. Keep this private;
|
||||
do not add a public generic inheritance or merge callback API.
|
||||
3. Reuse Stage 17's template/policy transport and Stage 18's collection-aware
|
||||
payload operations. Existing origin/history/retained-node handling must remain
|
||||
available to both adapters rather than duplicating an entire executor.
|
||||
4. Run existing mapping-inheritance regressions and privately exercise a small
|
||||
sequence graph through the adapter. Check output order, independent templates,
|
||||
and operation/evidence paths before public activation in Stage 21.
|
||||
|
||||
Validation: inheritance execution, scheduling, history, and budget tests plus
|
||||
the everyday check. This refactor does not introduce new parent-reference forms
|
||||
or change public loading semantics yet.
|
||||
|
||||
## Stage 21 — Resolve Record Inheritance With Ownership And Evidence
|
||||
|
||||
Scope: integrate Stage 19's graphs and Stage 20's executor into the structural
|
||||
expansion phase, with values and inspection working together.
|
||||
|
||||
1. Expand parent payload into the child while preserving the child's own identity
|
||||
fields and their origin/presence. Exclude parent identity fields from payload
|
||||
inheritance. Recursively combine mappings and use destination collection policy
|
||||
for nested sequences; retain whole-record upsert at the earlier boundary.
|
||||
2. Consume the child's directive before decoding. Keep parents as ordinary records
|
||||
subject to the same schema/semantic validation; create no abstract templates or
|
||||
acquired source records.
|
||||
3. Resolve only records surviving tier assembly. Cover parent upsert/removal,
|
||||
replacement of a child without `extends`, self-reference not exposing a lower
|
||||
predecessor, and nested destination-local relationships. Enclosing scopes
|
||||
finish before nested scopes; assembled list order stays unchanged.
|
||||
4. Carry policy, origin/history, sensitivity, resource context, and existing
|
||||
expansion/retained-history accounting through copies. Do not widen budgets or
|
||||
require a new inspection-path API.
|
||||
5. Add public-load regressions for complete inheritance chains and nested
|
||||
collections, including secret canaries on success and failure. Update the
|
||||
implemented inheritance contract and consumer guidance for this scope.
|
||||
|
||||
Validation: all inheritance and cross-feature inspection tests,
|
||||
`go test -race ./internal/engine`, and the everyday check.
|
||||
|
||||
## Stage 22 — Expose The Root-Owned Registration API
|
||||
|
||||
Scope: the settled facade surface and migration of in-repository callers.
|
||||
|
||||
1. Implement the exact public names/signatures above in a focused root file, and
|
||||
add `Definition.SequenceIdentities`. Convert root records/selectors explicitly
|
||||
into the engine model. Snapshot fields, selectors, and paths on construction
|
||||
and loading, preserving immutable definition reuse.
|
||||
2. Remove `SequenceComposition`, `Definition.SequenceCompositions`, and the private
|
||||
old-shape adapters. Migrate all maintained code/tests/examples; do not edit
|
||||
historical release notes solely to erase the old name or add deprecated aliases.
|
||||
3. Cover mixed exact/dynamic constructors, compound scalar fields, literal `*`
|
||||
versus wildcard segments, root/unused selectors, invalid index paths and empty
|
||||
field sets, overlap failure before source access, and independent loads.
|
||||
4. Prove dynamic constraints apply before strict composition and at inherited
|
||||
destinations, without consumer namespace enumeration. Keep final-result
|
||||
enforcement described as pending until Stage 27 actually adds it.
|
||||
5. Verify the public boundary and declaration documentation. Do not expose engine
|
||||
enums, policy stores, parser nodes, or internal methods.
|
||||
|
||||
Validation: root sequence/facade, registration, public/dependency-boundary, and
|
||||
consumer example tests plus the everyday check.
|
||||
|
||||
## Stage 23 — Separate Reusable CLI Override Preparation
|
||||
|
||||
Scope: mechanical preparation refactor and a privately tested post-structural
|
||||
validation entry, without changing current CLI processing order.
|
||||
|
||||
1. Refactor override preparation so labels, paths, snapshots, type support,
|
||||
disjointness, version protection, and existing budgets remain reusable outside
|
||||
the source assembly accumulator. Allocate synthetic sources once in the same
|
||||
order; do not reopen or reinterpret CLI values as YAML.
|
||||
2. Prepare post-structural validation that rejects `extends` as well as
|
||||
`configkit` in supplied paths and nested values, using their established
|
||||
structural roles. Test this entry privately; retain the existing entry's
|
||||
behavior until the lifecycle switches in Stage 26. Do not reject previously
|
||||
valid early-CLI instructions before that switch or add unrelated restrictions.
|
||||
3. Test detached prepared batches, synthetic source ordering, and the new
|
||||
directive validation. Reuse existing label/path/cycle/type-support/budget tests
|
||||
rather than adding another inventory of the same preparation cases.
|
||||
|
||||
Validation: override preparation/source tests and the everyday check. Keep
|
||||
ordinary/layered behavior unchanged; typed assignment is the next stage.
|
||||
|
||||
## Stage 24 — Implement Schema-Aware Post-Decode CLI Assignment
|
||||
|
||||
Scope: a private typed assignment helper using Stage 23's prepared overrides,
|
||||
without activating the later lifecycle order yet.
|
||||
|
||||
1. Add `applyDecodedOverrides`, using existing schema/decode mechanisms to bind
|
||||
only supplied override subtrees against a detached copy of the decoded state.
|
||||
Mappings recursively overlay, sequences replace, and explicit zero/empty/null
|
||||
retain normal schema rules. Current typed values supply retained children,
|
||||
not new defaults for replaced list items. Do not call `Definition.Defaults`
|
||||
again or re-decode the whole projected configuration as newly authored input.
|
||||
2. Return the updated typed value, canonical post-CLI projection, presence/origin
|
||||
changes, and existing raw CLI history contributions. Preserve a pre-CLI typed
|
||||
projection for decoding reconciliation/history. No resolver runs on a failed
|
||||
override; no partially applied result is returned.
|
||||
3. Test unknown/type-invalid fields, mapping retention, sequence replacement,
|
||||
nested maps/lists, named/text/duration/secret schema fields, explicit null/false/
|
||||
zero, discarded children, structural controls, and mutable ownership.
|
||||
|
||||
Validation: override, decode, schema, projection, and ownership tests plus the
|
||||
everyday check. Only the helper is added here; the shared lifecycle does not
|
||||
switch until its inspection/context support is prepared in Stage 25.
|
||||
|
||||
## Stage 25 — Prepare Post-Decode CLI Inspection And Effective Context
|
||||
|
||||
Scope: prepare history, resolution-input, and secret-lookup context handling;
|
||||
retain the existing public CLI lifecycle until Stage 26.
|
||||
|
||||
1. Extend existing replay/reconciliation to represent the target order:
|
||||
source tiers, inheritance, typed decoding/defaults, CLI, application resolution.
|
||||
Keep the structural input, pre-CLI typed snapshot, raw CLI contributions, and
|
||||
post-CLI projection distinct where needed. Use the existing history machinery,
|
||||
not a second replay framework, and retain legacy input compatibility until the
|
||||
lifecycle switch. Do not mislabel CLI changes as defaults or inheritance.
|
||||
2. Prepare resolution-entry metadata from Stage 24's result so explicit presence,
|
||||
origins, source records, and resource contexts describe the overridden state.
|
||||
3. Prepare the effective reference/location handoff for secret lookup and final
|
||||
validation using the post-override/post-resolution values, not stale structural
|
||||
input. Preserve existing no-credential-byte and ownership behavior.
|
||||
4. Test the target pipeline with privately assembled snapshots: equal-value CLI
|
||||
replacement, replaced descendants, decoding reconciliation before CLI, later
|
||||
recorded resolver transformations, explicit-presence defaults, and an overridden
|
||||
secret reference. Verify current public-load regressions still pass unchanged.
|
||||
|
||||
Validation: history/metadata/resolution/secret tests and the everyday check.
|
||||
Public activation must not require a later stage to repair these mechanisms.
|
||||
|
||||
## Stage 26 — Activate CLI After Decoding In The Shared Lifecycle
|
||||
|
||||
Scope: activate ADR 0018 through both loading paths using Stages 23–25's complete
|
||||
assignment, inspection, and effective-value handoffs.
|
||||
|
||||
1. Remove CLI assignment from ordinary/layered structural assembly. Invoke
|
||||
Stage 24 after inheritance and successful typed-default creation/decoding,
|
||||
immediately before application resolution. Activate Stage 23's post-structural
|
||||
validation. Keep the single shared lifecycle, not separate CLI implementations.
|
||||
2. Wire Stage 25's history and context handoffs in the same change. Resolution
|
||||
receives explicit-presence/origin metadata and effective CLI values;
|
||||
`Resolution.Default` preserves them while recorded transformations can change
|
||||
them normally. Secret lookup uses effective references and locations. Preserve
|
||||
exactly-once default-factory behavior and synthetic source ordering.
|
||||
3. Cover `NoSource`, ordinary/layered equivalence, CLI clearing/replacing an
|
||||
identified list despite append/upsert mode, derived values seeing CLI input,
|
||||
equal-value provenance, and resolver changes after CLI. Add explicit regressions
|
||||
that CLI cannot fix earlier decode/inheritance errors or introduce/cancel a
|
||||
relationship. Update obsolete early-CLI tests deliberately.
|
||||
4. Update the lifecycle, override, history, and secret-reference contracts and
|
||||
affected examples to describe the now-implemented order.
|
||||
5. Remove the superseded early-CLI application paths, old preparation entry, and
|
||||
temporary replay compatibility paths. Keep their still-relevant behavioral
|
||||
tests under the new lifecycle rather than postponing code cleanup to the
|
||||
documentation stage.
|
||||
|
||||
Validation: override/lifecycle/resolution/inheritance/history/secret tests,
|
||||
`go test -race ./...`, and the everyday check.
|
||||
|
||||
## Stage 27 — Enforce Final Application Identity Constraints
|
||||
|
||||
Scope: the final-result guarantee, not another combination or inheritance phase.
|
||||
|
||||
1. Add shared final identity validation after effective secret lookup and before
|
||||
application semantic validation/publication in the common lifecycle. Use the
|
||||
canonical effective typed projection, which retains secret references rather
|
||||
than credential bytes. Preserve the existing validated publication snapshot
|
||||
and detachment behavior.
|
||||
2. Traverse the actual final tree with the compiled selector trie, validating each
|
||||
matching sequence independently with Stage 1 primitives. This must discover
|
||||
paths introduced by typed defaults, CLI, and callbacks, not only assembly paths.
|
||||
Support root/namespaced rules and unrelated unselected sequences.
|
||||
3. Validate actual sequences only. Missing paths, null, and other shapes remain
|
||||
the schema/application validator's responsibility. Identity extraction here
|
||||
examines effective typed payload, not whether a field was authored. Do not
|
||||
recheck YAML-only policies or every intermediate callback state.
|
||||
4. Cover duplicate/missing/non-scalar identities produced at each late phase,
|
||||
valid null/scalar identities, numeric equality after typed conversion, dynamic
|
||||
namespaces created by each late phase, harmless unmatched rules, concrete-path
|
||||
diagnostics with available origins, and secret-value non-disclosure.
|
||||
5. Prove both `Load` and `LoadLayered` use this guarantee and return no usable
|
||||
result on failure. Update registration comments and canonical contract with
|
||||
this final boundary, excluding later caller mutations from the guarantee.
|
||||
|
||||
Validation: final identity, selector, facade, defaults/resolution/override, and
|
||||
secret tests plus the everyday check.
|
||||
|
||||
## Stage 28 — Exercise Representative Consumer Workflows
|
||||
|
||||
Scope: a small number of meaningful integrations, not a Cartesian test matrix.
|
||||
|
||||
1. Extend `examples/consumers` with an executable source-backed catalog: underlay
|
||||
records, main fragments, selected overlay append/upsert/replace, and explicit
|
||||
record inheritance. Demonstrate complete replacement of a matched record,
|
||||
stable order, and final inspection without application merge code.
|
||||
2. Add a namespaced typed-map example using one wildcard registration plus an
|
||||
exact root registration, with independent uniqueness. Show YAML-declared
|
||||
identity without registration separately so the final guarantee is not implied.
|
||||
3. Include one integrated CLI/resolution case and one representative provenance/
|
||||
resource-context/secret-history case where those interactions add protection
|
||||
beyond mechanism tests. Keep application domain validation recognizable.
|
||||
4. Audit coverage against the roadmap's work inventory, especially helper-built
|
||||
parent references, version-header consumption, pending declarations, lower
|
||||
identity before higher peers, replacement ownership, and dynamic destinations
|
||||
introduced through inheritance and late typed changes. Add only missing,
|
||||
consequential regressions at their narrowest stable owners.
|
||||
5. Consolidate obsolete/brittle tests. Do not restore old replacement semantics
|
||||
merely to keep fixtures unchanged, and do not preserve duplicates of the same
|
||||
semantic table across every layer.
|
||||
|
||||
Validation: `go test ./examples/consumers`, focused root integration tests,
|
||||
`go test -race ./...`, and the everyday check.
|
||||
|
||||
## Stage 29 — Reconcile Documentation
|
||||
|
||||
Scope: finish the current-contract narrative. Code cleanup belongs with the
|
||||
integrations that supersede each adapter, not in this documentation prompt.
|
||||
|
||||
1. Reconcile `docs/config.md` for declarations, identity equality, selectors,
|
||||
operations/persistence, ownership, inheritance, final validation, and CLI order.
|
||||
Keep its operation and presence tables authoritative; Go comments own exported
|
||||
symbols and consumer guidance links rather than copying full contracts.
|
||||
2. Update `docs/consumers/README.md`, relevant root examples, `README.md`,
|
||||
`docs/internal/overview.md`, `docs/internal/imports.md`, and
|
||||
`docs/development.md` where the implemented structure or reading/test map has
|
||||
changed. Remove stale claims about string-only registration, universal sequence
|
||||
replacement, early CLI, and overlay prohibition of all directives.
|
||||
3. Remove implemented portions from the deferred identified-record discussion in
|
||||
`future.md`, retaining genuinely deferred recursive body merging and other
|
||||
still-relevant ideas. Preserve accepted ADR decision content; update only status
|
||||
or implementation references where appropriate. Architecture already adopts
|
||||
the decisions: remove pending-implementation wording when true, without adding
|
||||
new policy. Leave release version selection/publication to the release task.
|
||||
4. Search maintained prose/examples for obsolete registration names and lifecycle
|
||||
claims; retain intentional historical references. Follow canonical ownership
|
||||
rather than copying the complete contract into each guide.
|
||||
|
||||
Validation: local documentation-link checks and whitespace review; run relevant
|
||||
example tests if executable examples changed. Reuse Stage 28's successful
|
||||
code/race validation when its covered content is unchanged.
|
||||
|
||||
## Stage 30 — Audit Scaling And Verify The Completed Target
|
||||
|
||||
Scope: a bounded implementation review and final validation, not another broad
|
||||
refactor or expansion of the feature/performance scope.
|
||||
|
||||
1. Inspect the completed code for duplicate combination/identity algorithms,
|
||||
per-record scans of whole collections, repeated declaration gathering, numeric
|
||||
magnitude expansion, stale ownership/path caches, and unnecessary copies.
|
||||
Prefer shared indexed mechanisms without pooling/global caches. Reuse existing
|
||||
composition/underlay/inheritance benchmarks and add a focused mixed-upsert or
|
||||
wildcard-collection benchmark only where existing fixtures cannot exercise the
|
||||
new scaling risk. Record commands and qualitative findings here; timing or
|
||||
allocation thresholds and a quiet-host benchmark campaign are not gates.
|
||||
2. Confirm transitional adapters/dead early-CLI paths have been removed. Search
|
||||
current code and maintained examples for `SequenceCompositions` and
|
||||
`SequenceComposition`; only intentional historical references may remain.
|
||||
Confirm no new dependencies, public internal-type exposure, background work,
|
||||
release machinery, or additional limits were introduced. Review the full diff
|
||||
against the roadmap, not merely passing tests. Record bounded review findings
|
||||
here; significant corrective
|
||||
work requires its own focused unit rather than being hidden inside this stage.
|
||||
Do not declare completion while a target behavior remains incorrect or missing.
|
||||
|
||||
Validation: `./scripts/check.sh`, `go test -race ./...`, and the development
|
||||
guide's local documentation/public-boundary checks, reusing successful checks
|
||||
from Stages 28–29 if their covered content is unchanged. Record completed scope,
|
||||
validation, benchmark commands/qualitative findings, and any real remaining
|
||||
issues accurately. No new timing thresholds or release gates are introduced.
|
||||
|
||||
Acceptance: all roadmap operations and lifecycle boundaries work through ordinary
|
||||
and layered loads; values and inspection agree; application constraints hold on
|
||||
successful final results; deferred behavior and YAML-only guarantees have not
|
||||
expanded. All thirty stages are complete and their nonempty units committed.
|
||||
|
||||
## Open Questions
|
||||
|
||||
None. Feature choices are settled in the roadmap and accepted ADRs; this plan
|
||||
selects the remaining API, representation, validation, and execution decisions.
|
||||
999
docs/roadmap/sequences.md
Normal file
999
docs/roadmap/sequences.md
Normal file
@@ -0,0 +1,999 @@
|
||||
# Sequence Identity And Combination Roadmap
|
||||
|
||||
Status: settled feature roadmap; implementation is specified in the
|
||||
[staged implementation plan](implementation.md). This document defines the
|
||||
targeted end state, not an implemented contract. The implementation plan owns
|
||||
execution order, exact Go names and signatures, bookkeeping, and validation.
|
||||
|
||||
## Purpose And Scope
|
||||
|
||||
Allow configuration authors to assemble collections from independently authored
|
||||
records, declare identity in configuration, and progressively extend a baseline
|
||||
collection through higher layers. Authors can replace the entire collection,
|
||||
append distinct records, or replace complete records by identity while retaining
|
||||
unmatched records. Sibling inheritance uses the same sequence operations and
|
||||
identity-based defaults. Within an identified sequence, a record can explicitly
|
||||
inherit settings from another record in that sequence while keeping its own
|
||||
identity.
|
||||
|
||||
The motivating use case is a prompt catalog whose source-backed baseline supplies
|
||||
records and whose application or installation layers add or supersede records.
|
||||
Applications can optionally impose identity constraints that configuration must
|
||||
respect and that hold in the final loaded configuration. These constraints can
|
||||
cover exact destinations or collections beneath dynamically named mapping keys,
|
||||
without requiring the application to discover those names before loading.
|
||||
|
||||
The feature covers ordinary and layered loading, imported items, inline
|
||||
sequences, selected overlays, and sibling inheritance. Typed defaults, CLI
|
||||
assignments, and application resolution retain their own combination semantics;
|
||||
CLI application moves later in the loading lifecycle.
|
||||
|
||||
This scope is narrower than the deferred
|
||||
[identified-record sequences proposal](future.md#1-identified-record-sequences).
|
||||
Automatic recursive merging of matching records remains outside this scope;
|
||||
explicit inheritance between distinct records is included. The initial
|
||||
declaration surface is `configkit.sequences`; `key` and `mode` shorthand on
|
||||
imports is deferred. Application lists remain
|
||||
ordinary YAML sequences without control wrappers. Unkeyed sequence concatenation
|
||||
is outside this feature.
|
||||
|
||||
## Current Baseline And Required Changes
|
||||
|
||||
The [import contract](../config.md#explicit-imports) owns existing selectors,
|
||||
destinations, placement, and ordering. `placement: sequence-item` already places
|
||||
each selected source mapping as one item at its effective destination. Item-local
|
||||
helper imports can contribute fields before placement.
|
||||
|
||||
The [composition contract](../config.md#strict-document-composition),
|
||||
[layered-loading contract](../config.md#layered-loading), and
|
||||
[inheritance contract](../config.md#sibling-inheritance) describe the implemented
|
||||
baseline:
|
||||
|
||||
| Area | Current behavior | Target behavior |
|
||||
| --- | --- | --- |
|
||||
| Identity declaration | `Definition.SequenceCompositions` registers one literal string field at each registered mapping path. | YAML collection declarations and optional authoritative application registrations support field sets and scalar identity components. |
|
||||
| Application registration destinations | Registrations select exact literal mapping paths. | Exact and structural selectors can impose identity beneath arbitrary mapping keys, independently at each matched destination. |
|
||||
| Strict composition | Registered sequences append distinct string identities and reject duplicates; repeated unregistered sequences conflict. | Collections with effective identity append distinct records and reject duplicates. Applicable lower-tier identity is available before composing higher-tier peers. Unidentified sequences retain strict-conflict behavior. |
|
||||
| Source precedence and selected overlays | A present higher sequence replaces the lower sequence wholesale. | Collection policy selects `replace`, `append`, or `upsert`; overlays can declare policy for inline sequences. |
|
||||
| Sibling inheritance | An inheriting mapping recursively overlays its resolved parent; supplied sequences replace wholesale. | Mappings retain recursive combination, while sequences use destination policy and the same three operations. |
|
||||
| Sequence-member inheritance | A sequence element has no sibling scope for its own inheritance relationship. | An identified sequence provides a sibling scope: a record can inherit from one record in the same sequence, selected by complete identity. |
|
||||
| CLI overrides | Apply before inheritance and typed defaults/decoding. | Apply after inheritance and typed defaults/decoding, before application resolution. |
|
||||
| Identity enforcement | Registration governs strict sequence composition. | Structural identity checks cover identified combinations; application registrations additionally constrain final effective sequences. |
|
||||
|
||||
Source-backed underlays participate in source assembly. `Definition.Defaults`
|
||||
provides typed defaults later; extending source combination does not turn typed
|
||||
default lists into source underlays.
|
||||
|
||||
## Collection Declarations
|
||||
|
||||
Use a source-root `configkit.sequences` block containing collection declarations.
|
||||
Each entry identifies a `destination` and may declare `key`, `mode`, or both.
|
||||
Identity and mode can be supplied independently. This form serves imported items,
|
||||
inline records, ordinary mapping imports, and inline sequences in selected
|
||||
overlays.
|
||||
|
||||
```yaml
|
||||
configkit:
|
||||
sequences:
|
||||
- destination: [prompts]
|
||||
key: [namespace, id]
|
||||
mode: append
|
||||
imports:
|
||||
- dir: prompts
|
||||
pattern: '*.yaml'
|
||||
destination: [prompts]
|
||||
placement: sequence-item
|
||||
```
|
||||
|
||||
Each selected file contributes a record such as:
|
||||
|
||||
```yaml
|
||||
namespace: support
|
||||
id: welcome
|
||||
text: Welcome
|
||||
```
|
||||
|
||||
Here append retains a lower-layer collection and adds distinct higher-layer
|
||||
records. Identity and combination controls belong to the collection declaration,
|
||||
so imported and inline contributions share one policy surface.
|
||||
|
||||
### Destinations And Scope
|
||||
|
||||
`destination` uses the existing sequence of literal mapping keys. Resolve it
|
||||
relative to the declaring source's placement: a fragment mounted under `catalog`
|
||||
can declare `[prompts]` locally. A sequence-item source can declare a nested
|
||||
collection within its own item without referring to an eventual numeric index.
|
||||
Preserve source and item-local scope boundaries.
|
||||
|
||||
A collection's scope is its resolved destination within its composition scope.
|
||||
Resolve relative destinations before reconciling declarations. Different imported
|
||||
items retain separate scopes for their nested collections. A declaration governs
|
||||
all contributions to its collection in the tier, including imported and inline
|
||||
records; it does not govern only records in the declaring file.
|
||||
|
||||
Gather declarations before composing the relevant scope so their effect does not
|
||||
depend on which contribution is assembled first. Retain applicable controls
|
||||
through source precedence and inheritance, then keep them out of application
|
||||
decoding.
|
||||
|
||||
Sequence declarations are independent of filesystem import enablement. Selected
|
||||
overlays may contain `configkit.sequences`, extending the current blanket
|
||||
prohibition on overlay `configkit` declarations. This does not enable imports
|
||||
in overlays.
|
||||
|
||||
### Overlay Authoring
|
||||
|
||||
For an existing sequence `[foo, bar]`, an overlay can replace `foo` in full:
|
||||
|
||||
```yaml
|
||||
configkit:
|
||||
sequences:
|
||||
- destination: [prompts]
|
||||
key: id
|
||||
mode: upsert
|
||||
prompts:
|
||||
- id: foo
|
||||
text: Revised definition
|
||||
```
|
||||
|
||||
The result is `[foo*, bar]`. The explicit upsert mode can be omitted when no
|
||||
different explicit mode applies from a lower tier: effective identity then
|
||||
selects upsert. To add a distinct record instead:
|
||||
|
||||
```yaml
|
||||
configkit:
|
||||
sequences:
|
||||
- destination: [prompts]
|
||||
key: id
|
||||
mode: append
|
||||
prompts:
|
||||
- id: baz
|
||||
text: Another prompt
|
||||
```
|
||||
|
||||
The result is `[foo, bar, baz]`. To replace the whole sequence:
|
||||
|
||||
```yaml
|
||||
configkit:
|
||||
sequences:
|
||||
- destination: [prompts]
|
||||
mode: replace
|
||||
prompts:
|
||||
- id: bar
|
||||
text: Bar definition
|
||||
- id: baz
|
||||
text: Baz definition
|
||||
```
|
||||
|
||||
The result is `[bar, baz]`. Explicitly selecting the default operation is valid.
|
||||
With no applicable explicit mode, omitting this declaration selects replace for
|
||||
an unidentified collection and upsert for an identified one. A retained explicit
|
||||
mode continues to govern.
|
||||
|
||||
## Identity Definitions And Constraints
|
||||
|
||||
### Field Sets
|
||||
|
||||
`key` accepts a string or a nonempty list of strings naming direct, literal
|
||||
fields in each record. A name such as `metadata.id` denotes that literal field,
|
||||
not a nested lookup. Treat field names as a set: remove repeated names and
|
||||
ignore authored order when comparing definitions.
|
||||
|
||||
Thus `id`, `[id]`, and `[id, id]` are equivalent; `[namespace, id]`,
|
||||
`[id, namespace]`, and `[namespace, id, id]` are also equivalent. An empty list
|
||||
is invalid because it supplies no identity fields. These rules apply to both
|
||||
configuration declarations and application registrations.
|
||||
|
||||
Use a consistent field order internally to construct identity tuples. Preserve
|
||||
component types and tuple boundaries so unstructured concatenation cannot cause
|
||||
otherwise distinct identities to collide.
|
||||
|
||||
### Scalar Components And Equality
|
||||
|
||||
Each identified record must be a mapping with every designated identity field
|
||||
present. Components may be strings, numbers, booleans, or explicit null. Missing
|
||||
fields and mapping or sequence components are invalid. Present null differs
|
||||
from absence; two single-field null identities are equal. Compound identities
|
||||
are equal when all corresponding components are equal.
|
||||
|
||||
Strings compare exactly, including empty strings, without trimming, case folding,
|
||||
or normalization. Scalar categories remain distinct: `"1"`, `1`, `true`, and
|
||||
null are different identities.
|
||||
|
||||
Numbers compare by mathematical value across integer, fractional, and exponent
|
||||
spellings: `1`, `1.0`, and `1e0` are equal, as are `0` and `-0`. Use exact numeric
|
||||
normalization for identity comparison without rounding through a floating-point
|
||||
conversion. This does not rewrite authored values or change existing typed
|
||||
decoding and projection semantics. The parser's rejection of non-finite numeric
|
||||
forms remains unchanged.
|
||||
|
||||
### Application-Owned Constraints
|
||||
|
||||
Retain an optional application interface for registering collections and imposing
|
||||
their identity definitions. Configuration may define identity where no application
|
||||
constraint exists. When a declaration omits `key`, an applicable registration
|
||||
supplies identity. An explicit equivalent definition is accepted, and a different
|
||||
definition fails under every mode, including replace.
|
||||
|
||||
For example, if the application requires `id`, `key: [id, name]` is invalid:
|
||||
that compound identity would allow repeated `id` values with different names.
|
||||
A registration is an authoritative constraint, not an omission-filling default.
|
||||
Copying a collection through inheritance does not create another application
|
||||
registration; registrations constrain their registered destinations.
|
||||
|
||||
Identity constraints validate actual sequences. They do not independently require
|
||||
a path to exist, be non-null, or have sequence shape. The application schema and
|
||||
validation own those requirements. Final-result enforcement for application
|
||||
registrations is defined under [Validation And Diagnostics](#validation-and-diagnostics).
|
||||
|
||||
### Application Registration API
|
||||
|
||||
Use one public sequence-identity registration type, collected in one field on
|
||||
`Definition`. Each registration contains an explicit destination selector and
|
||||
its identity fields. Exact and dynamic registrations share matching, overlap
|
||||
validation, identity comparison, and lifecycle behavior.
|
||||
|
||||
Provide two constructors returning that same registration type:
|
||||
|
||||
- An exact-path convenience constructor accepting a literal `Path` and identity
|
||||
field names. It converts the path into a selector containing literal segments.
|
||||
- An explicit-selector constructor accepting a structural selector and identity
|
||||
field names, for destinations that include arbitrary mapping keys.
|
||||
|
||||
Both constructors accept identity fields as variadic strings, so one-field and
|
||||
compound identities use the same construction form. Apply the settled field-set
|
||||
rules, including deduplication. Invalid registrations, including an empty identity
|
||||
field set, fail during application-definition validation.
|
||||
|
||||
Existing `Path` values retain their literal meaning throughout ConfigKit. The
|
||||
exact-path constructor supplies convenience at the registration boundary; dynamic
|
||||
matching uses the explicit selector type. Both forms can appear together in the
|
||||
same definition field.
|
||||
|
||||
Replace the current registration shape with this model. Preserving the existing
|
||||
`SequenceComposition` fields and `Definition.SequenceCompositions` spelling is not
|
||||
a requirement, and no compatibility facade is needed solely to retain that syntax.
|
||||
Choose names centered on sequence identity, reflecting its role in composition,
|
||||
inheritance, and final validation. Exact Go type, field, and constructor names
|
||||
and signatures belong in the implementation plan.
|
||||
|
||||
### Dynamic Application Registration Destinations
|
||||
|
||||
Allow applications to register identity for collections whose containing mapping
|
||||
keys become known only during loading. For example:
|
||||
|
||||
```yaml
|
||||
collections:
|
||||
weatherreporter:
|
||||
prompts:
|
||||
- id: foo.bar
|
||||
text: Report the weather.
|
||||
homewatch:
|
||||
prompts:
|
||||
- id: foo.bar
|
||||
text: Report the household status.
|
||||
```
|
||||
|
||||
One application registration can require `id` for every namespace's `prompts`
|
||||
list. The equal IDs above are valid because each list has its own uniqueness
|
||||
scope. An optional root `prompts` list can have a separate exact registration.
|
||||
|
||||
Use an explicit application-side structural selector composed of literal mapping
|
||||
keys and an arbitrary-mapping-key segment. Conceptually, the namespace rule is:
|
||||
|
||||
```text
|
||||
Literal("collections") -> AnyMappingKey -> Literal("prompts")
|
||||
```
|
||||
|
||||
This notation explains the selector; it is not a Go signature or YAML syntax.
|
||||
Each segment consumes exactly one mapping level. Match the complete destination,
|
||||
not an arbitrary prefix or suffix. Literal keys remain exact, including empty
|
||||
strings and keys containing punctuation such as `*`, `/`, or `.`. An arbitrary-key
|
||||
segment is distinct from any literal spelling and does not match a sequence
|
||||
index. Existing `Path` values retain their literal meaning.
|
||||
|
||||
This capability belongs to the reusable application definition and applies to
|
||||
ordinary and layered loads. YAML collection destinations and import destinations
|
||||
retain their existing literal syntax. Recursive search, regular expressions,
|
||||
filesystem glob rules, sequence-index selectors, and custom matching callbacks
|
||||
are outside this addition.
|
||||
|
||||
At each concrete matched destination, apply the same authoritative identity
|
||||
constraint as an exact registration. Use the settled field-set and scalar-equality
|
||||
rules. Do not pool identities across destinations or require records to repeat
|
||||
the containing mapping key as an identity component. Applications retain ownership
|
||||
of namespace interpretation, qualified runtime IDs, and domain validation.
|
||||
|
||||
Matching supplies effective identity before relevant strict composition and
|
||||
precedence operations. Without an applicable explicit mode, this identity selects
|
||||
upsert under the existing default rule. It also supplies identity for inheritance
|
||||
within the matched sequence. YAML declarations must agree with it under every
|
||||
mode, including replace. These are ordinary application constraints with a broader
|
||||
destination selector, not a separate final-validation facility.
|
||||
|
||||
### Registration Overlap
|
||||
|
||||
Equivalent application constraints coalesce at any shared destination. Equivalence
|
||||
uses the identity field-set rules: for example, `id` and `[id, id]` agree.
|
||||
Incompatible constraints fail; exact registrations do not override broader
|
||||
selectors, and registration order grants no precedence.
|
||||
|
||||
Detect incompatible overlapping registrations while validating the application
|
||||
definition, before source acquisition. For these fixed-depth mapping selectors,
|
||||
overlap exists when the selectors have the same number of segments and every
|
||||
corresponding pair can match a common key. Unequal literal keys at any position
|
||||
make the selectors disjoint; an arbitrary-key segment can overlap a literal or
|
||||
another arbitrary-key segment. Compare segment structure rather than rendered
|
||||
path strings. No configuration pre-read is needed.
|
||||
|
||||
Thus an `id` rule for all namespace prompt lists conflicts with a `[id, name]`
|
||||
rule for one named namespace, even if that namespace is absent from a particular
|
||||
load. This is an invalid application definition. A valid selector matching no
|
||||
collections is harmless: registration does not require a path to exist, create
|
||||
collections, or impose shape and nullability requirements.
|
||||
|
||||
### Matching Across The Loading Lifecycle
|
||||
|
||||
Match application selectors against effective configuration destinations after
|
||||
accounting for input placement. Physical filenames and source-local paths do not
|
||||
determine applicability. Make constraints available before composing contributions
|
||||
at those destinations, including namespaces first introduced by imports, mounted
|
||||
inputs, or higher tiers. Apply matching constraints to key-only YAML declarations
|
||||
as well, even before their sequence exists. Consumers need neither a preliminary
|
||||
load nor source inspection to enumerate namespaces.
|
||||
|
||||
During inheritance, apply registrations at the inheriting destination, including
|
||||
newly created matching destinations. Copying a collection does not transfer its
|
||||
application registrations to a different path. The existing YAML-policy ownership
|
||||
and transfer rules remain separate from application selector matching.
|
||||
|
||||
During final validation, find matching actual sequences in the final effective
|
||||
configuration. This includes namespaces introduced or replaced by typed defaults,
|
||||
CLI overrides, and application resolution. Reusing only destinations found during
|
||||
source assembly would miss these values. Use the same selector meaning and
|
||||
identity validator in both phases; later assignments do not trigger composition
|
||||
or inheritance again. YAML-only identities retain their existing enforcement
|
||||
boundary.
|
||||
|
||||
## Strict Composition And Precedence Operations
|
||||
|
||||
### Compose Peers Before Applying Precedence
|
||||
|
||||
Resolve applicable identity and mode policy, strictly compose the tier's peer
|
||||
contributions, then apply one selected operation per collection against the
|
||||
accumulated lower value. The tier operation does not sequentially replace peer
|
||||
source contributions. Selected overlay contributions use the same combination
|
||||
semantics.
|
||||
|
||||
Within a strict tier, equivalent identity definitions coalesce and different
|
||||
field sets fail. Agreeing explicit modes coalesce; conflicting explicit modes
|
||||
fail. Omission expresses no preference. A root declaration or a later declaration
|
||||
has no precedence over conflicting peers. Explicitly selecting a default mode
|
||||
is still a real instruction and can conflict with another explicit mode.
|
||||
|
||||
Identified peer sequences append distinct records. Duplicate identities among
|
||||
peers or within a contributed list fail under all three modes, including replace
|
||||
and upsert. Equal or disjoint record payloads do not excuse a duplicate identity.
|
||||
Unidentified sequences retain their current strict-composition conflict behavior.
|
||||
|
||||
For example, higher-tier fragments `[C]` and `[D]` with an agreed identity assemble
|
||||
as `[C, D]`. Against lower `[A, B]`, append or upsert produces `[A, B, C, D]`,
|
||||
while replace produces `[C, D]`. Peers `[B]` and `[B*]` conflict even under
|
||||
upsert because neither has higher precedence.
|
||||
|
||||
### Available Operations
|
||||
|
||||
Use one spelling for each mode: `replace`, `append`, and `upsert`.
|
||||
|
||||
| Mode | Behavior at source-precedence and inheritance boundaries |
|
||||
| --- | --- |
|
||||
| `replace` | Replace the complete lower sequence with the assembled higher sequence. |
|
||||
| `append` | Retain lower records and append higher records; duplicate identities fail. |
|
||||
| `upsert` | Replace matching records in full, retain unmatched lower records, and append new identities. |
|
||||
|
||||
Append and upsert require effective identity. ConfigKit does not infer identity
|
||||
from an `id` field or record position. Whole-sequence replacement does not require
|
||||
identity agreement with lower YAML policy, but application constraints still apply.
|
||||
|
||||
Letters denote record identities; `B*` has the same identity as `B` with a different
|
||||
payload:
|
||||
|
||||
| Lower | Higher | Mode | Result |
|
||||
| --- | --- | --- | --- |
|
||||
| `[A, B]` | `[C]` | `append` | `[A, B, C]` |
|
||||
| `[A, B]` | `[C]` | `replace` | `[C]` |
|
||||
| `[A, B]` | `[B*]` | `append` | Duplicate-identity error |
|
||||
| `[A, B]` | `[B*]` | `replace` | `[B*]` |
|
||||
| `[A, B]` | `[B*]` | `upsert` | `[A, B*]` |
|
||||
| `[A, B]` | `[B*, C]` | `upsert` | `[A, B*, C]` |
|
||||
|
||||
Upsert replaces the whole matched record. If lower `B` has `id`, `text`, and
|
||||
`temperature`, while higher `B` has only `id` and `text`, the result at this
|
||||
boundary has only `id` and `text`. Subsequent defaulting or other lifecycle
|
||||
operations retain their own semantics. Mappings continue to combine recursively;
|
||||
matching sequence records do not recursively merge fields.
|
||||
|
||||
### Mode Selection And Persistence
|
||||
|
||||
Select the operation in this order:
|
||||
|
||||
1. Reconcile explicit modes within the current tier; conflicting peers fail.
|
||||
2. Use the current tier's explicit mode if supplied.
|
||||
3. Otherwise retain an applicable explicit mode from a lower tier.
|
||||
4. With no explicit mode, infer upsert when effective identity is available.
|
||||
5. Otherwise infer replace.
|
||||
|
||||
Effective identity can come from an application registration, current declarations,
|
||||
or applicable lower policy, subject to the identity agreement and lifetime rules
|
||||
below. Determine the inferred mode from available identity before applying the
|
||||
operation. A plain higher-tier sequence can therefore use upsert without repeating
|
||||
a key declaration.
|
||||
|
||||
An explicit mode persists at its applicable destination until a higher-priority
|
||||
explicit declaration changes it. New sequence values alone do not reset it, and
|
||||
an unrelated overlay leaves it intact. A mode-only declaration can change policy
|
||||
without supplying records; it neither clears the collection nor retroactively
|
||||
recombines earlier tiers. Later source contributions and inheritance use the
|
||||
resulting policy.
|
||||
|
||||
| Lower explicit mode | Higher declaration | Effective mode |
|
||||
| --- | --- | --- |
|
||||
| `append` | Mode omitted | `append` |
|
||||
| `append` | `upsert` | `upsert` |
|
||||
| `upsert` | `replace` | `replace` |
|
||||
| None | Mode omitted, effective identity available | Inferred `upsert` |
|
||||
| None | Mode omitted, no effective identity | Inferred `replace` |
|
||||
|
||||
Only explicit modes persist. Inferred defaults are recomputed from effective
|
||||
identity when needed. Explicit replace continues to apply in subsequent tiers
|
||||
until another mode is declared; introducing identity alone does not override it.
|
||||
Mode persistence does not preserve YAML identity discarded by replacement, nor
|
||||
policies owned by discarded records. Source precedence and inheritance use the
|
||||
same destination policy, without separate per-phase settings. CLI assignments
|
||||
remain outside mode selection.
|
||||
|
||||
### Presence, Empty Sequences, Null, And Kind Mismatches
|
||||
|
||||
At a collection's precedence boundary:
|
||||
|
||||
| Higher contribution | `replace` | `append` / `upsert` |
|
||||
| --- | --- | --- |
|
||||
| Absent | Leave the lower value unchanged. | Leave the lower value unchanged. |
|
||||
| Explicit `[]` | Clear the sequence. | Contribute no records. |
|
||||
| Explicit null or another non-sequence value | Ordinary replacement, subject to application schema. | Error: the operation requires a sequence. |
|
||||
|
||||
A declaration alone does not clear a collection. An optional import selecting
|
||||
nothing contributes nothing and does not erase existing records. An absent lower
|
||||
collection can be initialized by an incoming sequence under any mode, subject to
|
||||
identity requirements. Explicit lower null or another non-sequence value is not
|
||||
absence: append and upsert fail against it, while replacement remains available.
|
||||
An empty incoming sequence bypasses neither kind checks nor identity requirements.
|
||||
|
||||
With effective identity and no explicit mode, `prompts: []` uses upsert and does
|
||||
not clear the collection. Clearing requires replace, selected in the current tier
|
||||
or retained from lower policy. Replacing an identified collection with null also
|
||||
requires replace and a schema permitting null. Collection null is distinct from
|
||||
a present null identity component inside a record.
|
||||
|
||||
### Ordering
|
||||
|
||||
Retain existing dependency-ready source scheduling within each tier and authored
|
||||
item order within lists. Gathering declarations does not change assembly order;
|
||||
identity values do not sort records.
|
||||
|
||||
- Append keeps lower records in order, followed by higher records in assembled order.
|
||||
- Replace keeps only higher records in their assembled order.
|
||||
- Upsert keeps a matched record's lower position and appends new identities in
|
||||
higher-tier order.
|
||||
|
||||
For example, lower `[A, B, C]` and higher `[C*, A*, D]` produce `[A*, B, C*, D]`
|
||||
under upsert. Whole-sequence replacement gives the higher tier control of the
|
||||
complete sequence order.
|
||||
|
||||
## Policy Agreement, Lifetime, And Ownership
|
||||
|
||||
### Identity Agreement And Replacement
|
||||
|
||||
Append and upsert use one agreed identity for all participating records:
|
||||
|
||||
| Lower identity | Higher declaration | Result when combining records |
|
||||
| --- | --- | --- |
|
||||
| `id` | Omitted | Use `id`. |
|
||||
| `id` | `id`, `[id]`, or `[id, id]` | Accept the equivalent definition. |
|
||||
| `id` | `[namespace, id]` | Error: different definitions. |
|
||||
| None | `id` | Establish `id` and validate all participating records. |
|
||||
|
||||
Introducing identity for an unkeyed lower sequence requires checking both lower
|
||||
and incoming records, including uniqueness within each contribution. With no
|
||||
identity from the application or either participating collection, append and
|
||||
upsert fail.
|
||||
|
||||
YAML identity follows the collection that survives an operation:
|
||||
|
||||
| Operation | Effective YAML identity |
|
||||
| --- | --- |
|
||||
| Append or upsert | Retain or establish the agreed identity. |
|
||||
| Whole-sequence replacement | Discard lower YAML identity and use the replacing tier's identity declaration, if any. |
|
||||
| Replacement by null or another non-sequence value | Discard lower YAML identity and retain any identity explicitly declared by the replacing tier. |
|
||||
|
||||
Application constraints remain authoritative under every operation. Without an
|
||||
application constraint, replacement can introduce a different identity, such as
|
||||
changing from `id` to `name`. Replacing a sequence without a new key declaration
|
||||
or application registration produces an unkeyed sequence. No identity-removal
|
||||
directive is needed.
|
||||
|
||||
The reset occurs only when the collection value is actually replaced. Declaring
|
||||
replace without supplying a replacement does not authorize changing the identity
|
||||
of retained records. Explicit mode lifetime is separate from YAML identity
|
||||
lifetime.
|
||||
|
||||
### Ancestor Replacement
|
||||
|
||||
Replacing an enclosing mapping can remove a collection without replacing that
|
||||
collection directly. Apply the same identity and ownership rules to this removal:
|
||||
discard the removed collection's lower YAML identity, and discard policies owned
|
||||
by any removed records. Reintroducing the same path later does not restore those
|
||||
discarded policies.
|
||||
|
||||
Independently declared explicit modes retain their existing persistence rules.
|
||||
Removing a descendant collection does not remove a mode whose declaring scope
|
||||
survives. Application registrations remain applicable at matching destinations,
|
||||
including destinations reintroduced later. Identity newly declared by the
|
||||
replacing tier follows the existing declaration and ownership rules.
|
||||
|
||||
For example, a root-level declaration establishes `key: id` and `mode: append`
|
||||
for `[catalog, prompts]`. A higher tier replaces `catalog` with null, removing
|
||||
the prompts collection and its YAML identity. The independently declared append
|
||||
mode survives. If a later tier recreates `catalog.prompts`, append requires
|
||||
identity supplied by a new declaration or an application registration. Without
|
||||
that identity, loading fails; the later tier can instead explicitly choose
|
||||
replace. The replacement of `catalog` itself follows ordinary ancestor-value
|
||||
precedence and does not attempt to append null to the removed prompts list.
|
||||
|
||||
### Declarations Without Sequence Values
|
||||
|
||||
A key declaration without accompanying records applies immediately to an existing
|
||||
retained sequence: check agreement and validate its records, without recombining
|
||||
earlier tiers. For example, a higher tier that only declares
|
||||
`destination: [prompts], key: id` identifies and validates the retained lower
|
||||
`prompts` sequence.
|
||||
|
||||
If no collection exists, retain the declaration in its owning scope for later
|
||||
contributions. An otherwise empty underlay can establish identity for main-tier
|
||||
fragments. An explicit identity declaration can also remain pending at a
|
||||
non-sequence destination; it does not independently impose a shape requirement.
|
||||
The presence and kind rules still determine whether an attempted operation fails.
|
||||
Item-local declarations retain their ownership boundaries.
|
||||
|
||||
### Lower-Tier Identity During Strict Composition
|
||||
|
||||
Resolve applicable identity before composing a higher tier's peers, using
|
||||
application registrations, available lower-tier policy, and declarations gathered
|
||||
within the higher tier's scope. Authors need not repeat a baseline key solely
|
||||
because the higher tier has multiple fragments.
|
||||
|
||||
For example, an underlay establishes `key: id` for `prompts`, and two main-tier
|
||||
fragments contribute `[foo]` and `[bar]` without a key declaration. They strictly
|
||||
compose as `[foo, bar]` before the main tier combines with the accumulated lower
|
||||
collection. If both contribute `foo`, composition fails; upsert does not give
|
||||
precedence to peers.
|
||||
|
||||
Lower YAML identity remains subject to agreement, replacement, and ownership.
|
||||
A replacing collection must not be constrained by obsolete lower identity just
|
||||
because policy resolution precedes composition. Likewise, a replacement record
|
||||
cannot borrow nested identity owned by the discarded lower record, even when
|
||||
outer identity or numeric position matches. Application constraints still apply.
|
||||
Higher-tier declarations do not retroactively repair invalid lower-tier composition.
|
||||
|
||||
### Nested Record Ownership
|
||||
|
||||
Attach item-local policies to their owning contributions so they follow surviving
|
||||
records independently of numeric indexes. Whole-record upsert discards the old
|
||||
record's local policies along with its fields. Whole-sequence replacement discards
|
||||
policies owned by removed records. A replacement record supplies its own local
|
||||
declarations; equal outer identity or final index does not transfer discarded
|
||||
policies.
|
||||
|
||||
For example, a source imported as one record into `prompts` can declare:
|
||||
|
||||
```yaml
|
||||
configkit:
|
||||
sequences:
|
||||
- destination: [steps]
|
||||
key: id
|
||||
mode: append
|
||||
id: foo
|
||||
steps:
|
||||
- id: prepare
|
||||
```
|
||||
|
||||
The declaration governs `steps` inside that `foo` record. Retaining `foo` retains
|
||||
its local policy, including the explicit mode. Upserting a new `foo` discards the
|
||||
old `steps` and its declaration even though the outer record keeps its position.
|
||||
|
||||
Policies independently applicable at a destination are not discarded merely
|
||||
because an individual record is replaced. Ownership determines which declarations
|
||||
are removed. Application registrations remain constraints at their registered
|
||||
destinations. Inheritance copies transfer identity and choose operations as
|
||||
specified next.
|
||||
|
||||
## Sibling Inheritance
|
||||
|
||||
Complete source-tier assembly before resolving inheritance. Retain existing
|
||||
mapping-sibling scopes and add identified-sequence scopes as described below.
|
||||
Both use the existing dependency-ready algorithm. At each inheritance combination,
|
||||
the resolved parent's collection is the lower value and the inheriting mapping's
|
||||
local collection is the higher value. Mappings combine recursively; upsert within
|
||||
an identified sequence remains whole-record replacement. Explicit record
|
||||
inheritance is a separate construction step after those replacements.
|
||||
|
||||
Use the inheriting destination collection's policy: an applicable explicit mode,
|
||||
including one retained through source tiers, wins. Otherwise infer upsert with
|
||||
effective identity and replace without it. There is no separate default-append
|
||||
rule for inheritance. The same destination policy governs source-precedence and
|
||||
inheritance combinations.
|
||||
|
||||
YAML identity follows a collection copied through inheritance, subject to agreement
|
||||
and replacement rules. A source collection's mode does not automatically become an
|
||||
instruction at a different inheriting destination. For example, source `steps`
|
||||
has `key: id` and `mode: append`; an inheriting mapping supplies local `steps`
|
||||
without an applicable explicit mode. The copied identity selects upsert there.
|
||||
An explicit destination append instead selects append. A matching destination key
|
||||
agrees; a different key conflicts when combining records, while explicit replace
|
||||
can establish new YAML identity subject to application constraints.
|
||||
|
||||
For example:
|
||||
|
||||
```yaml
|
||||
configkit:
|
||||
sequences:
|
||||
- destination: [parent, items]
|
||||
key: id
|
||||
child:
|
||||
items:
|
||||
- id: foo
|
||||
value: A
|
||||
- id: bar
|
||||
value: B
|
||||
parent:
|
||||
extends: child
|
||||
items:
|
||||
- id: baz
|
||||
value: C
|
||||
```
|
||||
|
||||
Despite the names, `parent` inherits from `child`. Identity at `[parent, items]`
|
||||
selects upsert, producing `[foo, bar, baz]`. If the local sequence instead supplies
|
||||
a revised `foo`, the result is `[foo*, bar]`. Append rejects that repeated identity;
|
||||
replace retains only the local sequence.
|
||||
|
||||
The same ordering, presence, and kind rules apply at inheritance boundaries.
|
||||
An omitted local sequence retains the inherited sequence. Explicit `[]` clears it
|
||||
under replace and contributes no records under append or upsert. Retain controls
|
||||
until inheritance uses them, then exclude them from application decoding.
|
||||
|
||||
### Inheritance Between Identified Sequence Records
|
||||
|
||||
An identified sequence supplies a sibling scope for its records. Identity can
|
||||
come from an application registration or an applicable YAML declaration. A record
|
||||
may inherit from one other record in that same sequence using an `extends`
|
||||
mapping that selects the parent's complete identity:
|
||||
|
||||
```yaml
|
||||
configkit:
|
||||
sequences:
|
||||
- destination: [prompts]
|
||||
key: id
|
||||
prompts:
|
||||
- id: base
|
||||
model: standard
|
||||
temperature: 0.5
|
||||
- id: concise
|
||||
extends: {id: base}
|
||||
temperature: 0.2
|
||||
```
|
||||
|
||||
The final `concise` record is:
|
||||
|
||||
```yaml
|
||||
id: concise
|
||||
model: standard
|
||||
temperature: 0.2
|
||||
```
|
||||
|
||||
The `base` record remains in the collection. Parents are ordinary records subject
|
||||
to the same decoding and validation as other records; there is no abstract
|
||||
template category.
|
||||
|
||||
Use mapping-form references for both single and compound keys. With
|
||||
`key: [namespace, id]`, a reference can be
|
||||
`extends: {namespace: shared, id: base}`. Match components using the collection's
|
||||
field-set and scalar-equality rules, including mathematical numeric equality.
|
||||
`extends: {id: null}` selects a null-valued identity; `extends: null` cancels a
|
||||
relationship.
|
||||
|
||||
Parent-reference mappings use ordinary strict mapping composition. Item-local
|
||||
helper imports may supply disjoint fields of a compound reference; overlapping
|
||||
fields conflict, even when equal. For example, a helper's
|
||||
`extends: {namespace: shared}` and the importing record's `extends: {id: base}`
|
||||
compose into `extends: {namespace: shared, id: base}`. This assembles one record;
|
||||
it does not permit strict peers to contribute duplicate record identities.
|
||||
|
||||
Before parent lookup, require the surviving effective reference mapping to contain
|
||||
exactly the collection's identity fields, with supported scalar values. Missing
|
||||
or extra fields fail. Validate the completed reference during inheritance, rather
|
||||
than requiring each helper contribution to be independently complete. Superseded
|
||||
references remain historical evidence; earlier strict-composition conflicts
|
||||
remain errors.
|
||||
|
||||
Every record must supply its complete own identity before inheritance. Preserve
|
||||
the child's identity fields and inherit only the parent's nonidentity payload;
|
||||
inheritance cannot supply a missing child identity. Child payload fields take
|
||||
precedence, mappings combine recursively, and nested sequences use the inheriting
|
||||
destination's collection policy. Consume the record's directive before decoding.
|
||||
|
||||
Support one parent within the same identified sequence, including forward
|
||||
references and chains. Missing parents and cycles, including self-reference,
|
||||
fail the load. Cross-collection references, position-based references, multiple
|
||||
parents, and inheritance between unidentified sequence members are outside this
|
||||
scope. Existing string-form references between mapping siblings retain their
|
||||
meaning; they do not become shorthand for sequence identity references.
|
||||
|
||||
Resolve relationships against records surviving source-tier assembly and selected
|
||||
overlays. A higher layer can replace a parent before inheritance runs; removing
|
||||
that parent makes a surviving reference fail. Whole-record upsert discards the
|
||||
old record's directive along with its payload. Omitting `extends` in the
|
||||
replacement record does not retain the displaced record's relationship. An
|
||||
explicit reference to the record's own identity is a cycle, not a way to access
|
||||
its lower-tier predecessor.
|
||||
|
||||
Reuse Kahn's algorithm with the existing stable canonical semantic-pointer key
|
||||
for dependency-ready ordering. Build parent lookup from the sequence's effective
|
||||
identity, and preserve the assembled list order independently of resolution
|
||||
order. Enclosing relationships resolve before nested relationships. Parent
|
||||
templates retain nested instructions for evaluation at each inheriting
|
||||
destination, following the existing destination-local inheritance rule. Copied
|
||||
collection identity, destination operation selection, and record-owned policy
|
||||
transfer follow the rules above.
|
||||
|
||||
Record inheritance runs in the existing structural phase, before typed defaults,
|
||||
decoding, CLI overrides, and application resolution. Those later stages do not
|
||||
trigger another inheritance pass. Preserve inherited presence, origin and
|
||||
history evidence, sensitivity, declaring resource contexts, and existing
|
||||
expansion budgets just as for mapping-sibling inheritance. Identity lookup must
|
||||
not expose identity values in diagnostics. Existing index-based inspection paths
|
||||
remain sufficient; no new identity-selector path API is needed.
|
||||
|
||||
## Loading Lifecycle
|
||||
|
||||
Use the following order:
|
||||
|
||||
1. Assemble source tiers and selected overlays, resolving policy before each
|
||||
tier's strict composition and applying collection operations at precedence
|
||||
boundaries.
|
||||
2. Resolve sibling inheritance in mappings and identified sequences, then apply
|
||||
typed defaults and decode the assembled configuration.
|
||||
3. Apply explicit CLI overrides to that configuration and check supplied values
|
||||
against the application schema.
|
||||
4. Run application resolution against the overridden configuration.
|
||||
5. Resolve effective secret references, validate the final configuration including
|
||||
application-registered identities, and publish it.
|
||||
|
||||
### Typed Defaults And Application Resolution
|
||||
|
||||
Source-backed underlays, including application-supplied mapping sources,
|
||||
participate in sequence combination. Typed `Definition.Defaults` remains the
|
||||
initial decoded state: supplied sequences replace default sequences. Source
|
||||
append does not append to a typed default list.
|
||||
|
||||
Application resolution retains assignment and recorded-transformation semantics.
|
||||
ConfigKit does not automatically combine a callback's assigned sequence with its
|
||||
previous value. Application-registered identities receive final validation
|
||||
independently of how defaults, CLI overrides, or callbacks produce their values.
|
||||
|
||||
### CLI Precedence
|
||||
|
||||
CLI overrides are the highest-precedence input to application resolution.
|
||||
Supplied sequences replace whole sequences, including sequences inside supplied
|
||||
mappings. Mappings retain recursive override behavior; empty sequences and
|
||||
permitted nulls retain explicit replacement meaning. Configuration append or
|
||||
upsert does not reinterpret CLI assignments. Inheritance and defaulting do not
|
||||
run again afterward: overriding assembled `[A, B]` with `[C]` presents `[C]` to
|
||||
application resolution without inheritance reintroducing prior records.
|
||||
|
||||
Application resolution sees CLI values and their explicit-presence and source
|
||||
metadata. `Resolution.Default` preserves explicit CLI values; normalization and
|
||||
recorded application transformations may still change them. For example, a derived
|
||||
URL can use a CLI hostname, while an explicit CLI URL can be preserved by an
|
||||
absence-based default. Retain recording and provenance requirements for those
|
||||
transformations.
|
||||
|
||||
Reject structural directives such as `extends` and `configkit` in CLI override
|
||||
paths or supplied values. CLI input supplies application values after structural
|
||||
processing; it cannot supply or cancel inheritance directives, change completed
|
||||
relationships, or prevent earlier inheritance failures.
|
||||
|
||||
Typed decoding also precedes CLI application, so a CLI replacement cannot repair
|
||||
an earlier decoding failure. A source string in an integer field fails before a
|
||||
valid CLI integer can apply. This changes current override-before-decoding
|
||||
behavior. Semantic validation remains after overrides and application resolution.
|
||||
|
||||
Secret lookup uses effective references after overrides and resolution. Neither
|
||||
stage bypasses final application identity constraints or semantic validation.
|
||||
|
||||
## Validation And Diagnostics
|
||||
|
||||
### Structural Identity Checks
|
||||
|
||||
Allow item-local helper imports to complete and consume applicable non-main
|
||||
source-version headers before checking payload identity for collection assembly.
|
||||
A helper can supply an identity component; a consumed header cannot supply one
|
||||
after removal.
|
||||
|
||||
Structural controls are likewise unavailable as source identity components.
|
||||
Check identity against application payload fields, excluding fields acting as
|
||||
structural directives even when the engine retains them for later processing.
|
||||
For example, `key: extends` with an item containing `extends: null` does not
|
||||
supply a null identity: that field cancels inheritance and is consumed. Report
|
||||
the ordinary missing-identity-component error at the collection/item path using
|
||||
available origins, without exposing values. Do not defer this failure until
|
||||
directive consumption or final application validation.
|
||||
|
||||
This rule follows a field's structural role; it does not introduce a broader
|
||||
restriction on unrelated literal field names. Final application identity checks
|
||||
continue to examine the effective typed payload under their existing rules.
|
||||
|
||||
Validate each identified contribution before combination and check uniqueness in
|
||||
the resulting identified collection, including inheritance operations. Upsert
|
||||
matches across precedence are replacements; duplicates within a contribution
|
||||
remain errors. Later inheritance, typed defaults, or callbacks cannot repair an
|
||||
identity missing at an earlier assembly boundary.
|
||||
|
||||
YAML-only identities are enforced during structural composition, precedence
|
||||
assembly, and identified inheritance operations. They have no final-result
|
||||
guarantee after typed defaulting, CLI overrides, or application resolution. Those
|
||||
stages can produce duplicate identities or otherwise violate a YAML-only identity.
|
||||
Applications needing a final guarantee register the constraint through the
|
||||
application-owned interface.
|
||||
|
||||
Adding record inheritance does not expand this guarantee: a YAML declaration
|
||||
can enable parent lookup without imposing final validation on values later
|
||||
produced by defaults, CLI overrides, or application resolution.
|
||||
|
||||
### Final Application Constraints
|
||||
|
||||
On successful loading, final effective collections covered by application
|
||||
registrations satisfy their declared identity constraints. Retain assembly-time
|
||||
checks and validate final effective sequences before publication, after defaults,
|
||||
inheritance, overrides, application resolution, and secret lookup.
|
||||
|
||||
This covers both exact and dynamic application registrations. Evaluate dynamic
|
||||
selectors against the final effective configuration, with independent validation
|
||||
for each concrete matched sequence as described under
|
||||
[Matching Across The Loading Lifecycle](#matching-across-the-loading-lifecycle).
|
||||
|
||||
ConfigKit performs these identity checks; consumers register the constraint
|
||||
without needing to repeat duplicate detection in `Definition.Validate`.
|
||||
Application-owned semantic validation continues to cover domain-specific rules.
|
||||
|
||||
This guarantee covers actual sequences, not path existence or allowed shape.
|
||||
It does not require every intermediate callback state to satisfy the constraint
|
||||
and does not cover caller mutations after loading returns. Final checks examine
|
||||
effective typed values; assembly checks validate authored contributions. A final
|
||||
scalar alone does not prove that a source explicitly supplied that field.
|
||||
|
||||
Use the existing canonical final-value representation and shared publication
|
||||
path as the foundation. The guarantee requires neither a new mutation-history
|
||||
system nor exact causal reconstruction for records supplied by defaults or
|
||||
callbacks.
|
||||
|
||||
### Diagnostics And Inspection
|
||||
|
||||
Identity diagnostics identify collection and item paths using available origin
|
||||
information without exposing identity values. Conflicting declarations identify
|
||||
the collection destination and declaration locations.
|
||||
|
||||
For dynamic registrations, use concrete matched collection and item paths for
|
||||
value diagnostics, with available origins and existing sensitivity rules.
|
||||
Definition-time overlap errors identify the conflicting registrations; they do
|
||||
not require or invent a concrete destination from a loaded document.
|
||||
|
||||
Correctness includes final values, projected item paths, source destinations,
|
||||
provenance, history, sensitivity, and passive resource contexts. Appending can
|
||||
change final indexes; replacing changes which contributions survive. Preserve
|
||||
the existing loading and inspection contracts through both operations, including
|
||||
policy ownership and inheritance transfer.
|
||||
|
||||
## Work Needed From The Baseline
|
||||
|
||||
The baseline needs the following connected capabilities to reach the target
|
||||
state. Execution order and test sequencing belong to the
|
||||
[implementation plan](implementation.md):
|
||||
|
||||
- **Declarations and public API:** extend application registration to the settled
|
||||
identity model with one public registration type and definition field, an
|
||||
exact-path convenience constructor, and an explicit-selector constructor.
|
||||
Preserve literal `Path` semantics. Parse and consume `configkit.sequences` across
|
||||
supported source forms and selected overlays; preserve placement-relative and
|
||||
item-local scopes. Keep sequence declarations independent of filesystem import
|
||||
enablement.
|
||||
- **Identity and policy representation:** implement field-set comparison, typed
|
||||
scalar identity tuples with exact numeric equality, declaration agreement,
|
||||
pending declarations, explicit-mode persistence, replacement lifetime, and
|
||||
record-owned policy transfer. Choose bookkeeping proportional to these semantics.
|
||||
Handle descendant collection removal through ancestor replacement, preserving
|
||||
independently declared modes and application constraints while discarding
|
||||
removed collection identity and record-owned policies.
|
||||
- **Registration matching:** support literal and arbitrary-mapping-key segments,
|
||||
detect incompatible selector overlap during definition validation, and resolve
|
||||
applicable constraints at concrete placed destinations. Reuse the identity
|
||||
rules and validator for exact and dynamic registrations. Keep matching separate
|
||||
from source discovery and from YAML declaration ownership.
|
||||
- **Assembly handoffs:** make accumulated lower policy available before higher-tier
|
||||
strict composition. Separate source acquisition from policy-aware composition
|
||||
or otherwise revise assembly sequencing; current main-before-underlay composition
|
||||
is not a design constraint. Preserve distinctions among acquisition order,
|
||||
dependency-ready scheduling within tiers, and precedence order. Account for
|
||||
source records, callbacks, main-version checks, and failure diagnostics. Retain
|
||||
version ownership and validation semantics; higher replacement cannot hide
|
||||
lower-tier structural failures.
|
||||
- **Combination and inheritance:** implement the three operations with strict peer
|
||||
handling, whole-record replacement, presence rules, stable ordering, and the
|
||||
destination policy rules for inheritance. Extend sibling-scope discovery and
|
||||
parent lookup to identified sequences, validate mapping-form references, and
|
||||
preserve child identities while copying parent payloads. Reuse the shared
|
||||
dependency-ready scheduler and destination-local nested resolution. Preserve
|
||||
provenance, history, sensitivity, paths, and resource contexts as records survive,
|
||||
move, or disappear.
|
||||
- **Lifecycle and validation:** move CLI application after inheritance and typed
|
||||
defaults/decoding, preserve explicit-presence and source metadata for application
|
||||
resolution, reject CLI structural directives, and add final application identity
|
||||
validation through the shared publication path. Keep YAML-only enforcement
|
||||
within structural operations. Match dynamic registrations during assembly and
|
||||
inheritance, and against the final effective configuration rather than a saved
|
||||
list of source-backed destinations.
|
||||
Exclude structural controls from source identity checks even while retaining
|
||||
those controls for later inheritance processing.
|
||||
- **Documentation and verification:** exercise ordinary and layered loading,
|
||||
imported and inline collections, item-local scopes, version-header consumption,
|
||||
selected overlays, inheritance, typed defaults, CLI, callbacks, and inspection.
|
||||
Cover single and compound parent references, scalar equality, forward references
|
||||
and chains, missing parents and cycles, parent replacement/removal, child identity
|
||||
preservation, nested destination policy, cancellation, and stable output order.
|
||||
Verify helper-composed references, overlapping reference fields, missing or extra
|
||||
fields, and replacement of references together with their containing records.
|
||||
Verify independent namespace uniqueness, root and namespaced registrations,
|
||||
literal punctuation keys, equivalent and incompatible overlaps, and conflicts
|
||||
detected even without a matching input destination. Cover namespaces introduced
|
||||
by imports, placement, higher tiers, inheritance, defaults, CLI, and callbacks;
|
||||
preserve ordinary strict-peer and upsert behavior. Check YAML disagreement under
|
||||
replace, harmless absent or empty collections, schema-owned shape requirements,
|
||||
unrelated sequences remaining unselected, and concrete-path diagnostics.
|
||||
Verify both constructors feed the same registration model, including mixed
|
||||
exact and dynamic registrations, compound identities, and definition-time
|
||||
rejection of empty identity field sets.
|
||||
Cover ancestor replacement followed by collection reintroduction, including a
|
||||
surviving append mode with and without renewed identity, explicit replacement,
|
||||
and application-registered identity. Verify that `extends: null` cannot supply
|
||||
an identity component and that structural-control exclusion uses ordinary
|
||||
value-free missing-identity diagnostics.
|
||||
Update the public contract and consumer examples when behavior lands.
|
||||
|
||||
The collection-policy and CLI-ordering decisions are accepted in
|
||||
[ADR 0017](../adr/0017-collection-sequence-policy.md) for collection policy and
|
||||
[ADR 0018](../adr/0018-cli-after-structural-resolution.md) for CLI ordering.
|
||||
Their scoped supersession is recorded in the affected earlier ADRs, and the
|
||||
[architecture policy](../policy/architecture.md) reflects the accepted decisions.
|
||||
These govern the target architecture; the configuration contract continues to
|
||||
describe implemented behavior until the feature lands. This roadmap does not
|
||||
select migration machinery.
|
||||
|
||||
ADR 0017 now includes inheritance within identified sequences;
|
||||
[ADR 0009](../adr/0009-sibling-inheritance.md) records that scope extension in its
|
||||
status while preserving its decision content. The architecture policy is aligned.
|
||||
Dynamic selectors extend the destinations covered by application registration;
|
||||
they retain the accepted application authority and final-validation boundary.
|
||||
|
||||
No open feature decisions remain. The implementation plan translates this end
|
||||
state into reviewable stages without adding guarantees, restrictions, or
|
||||
combination semantics.
|
||||
@@ -30,6 +30,32 @@ func ExampleNoSource() {
|
||||
// Output: 4 0 <nil>
|
||||
}
|
||||
|
||||
func ExampleDiff() {
|
||||
type Config struct {
|
||||
Workers int `yaml:"workers"`
|
||||
}
|
||||
before, err := configkit.Load[Config](configkit.Mapping("before", map[string]any{"workers": 2}))
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
after, err := configkit.Load[Config](configkit.Mapping("after", map[string]any{"workers": 4}))
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
differences, err := configkit.Diff(before, after)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
for _, difference := range differences {
|
||||
fmt.Printf("%s: %s -> %s\n", difference.Path.String(),
|
||||
strings.TrimSpace(difference.Left.YAML), strings.TrimSpace(difference.Right.YAML))
|
||||
}
|
||||
// Output: /workers: 2 -> 4
|
||||
}
|
||||
|
||||
func ExampleValidation_Error() {
|
||||
type Config struct {
|
||||
First string `yaml:"first"`
|
||||
|
||||
154
examples/consumers/destinations.go
Normal file
154
examples/consumers/destinations.go
Normal file
@@ -0,0 +1,154 @@
|
||||
package consumers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"path"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
// CollectionBundleConfig is a representative application-owned collection
|
||||
// shape. Collection and prompt version labels remain ordinary domain fields.
|
||||
type CollectionBundleConfig struct {
|
||||
Version string `yaml:"version"`
|
||||
Collections map[string]CollectionDefinition `yaml:"collections"`
|
||||
}
|
||||
|
||||
type CollectionDefinition struct {
|
||||
Name string `yaml:"name"`
|
||||
Version string `yaml:"version"`
|
||||
Description string `yaml:"description"`
|
||||
Prompts map[string]CollectionPrompt `yaml:"prompts"`
|
||||
Profiles map[string]CollectionProfile `yaml:"profiles"`
|
||||
}
|
||||
|
||||
type CollectionPrompt struct {
|
||||
Version string `yaml:"version"`
|
||||
DefaultProfile string `yaml:"default_profile"`
|
||||
ContentFile string `yaml:"content_file"`
|
||||
ResourceText string `yaml:"resource_text"`
|
||||
Messages []CollectionMessage `yaml:"messages"`
|
||||
}
|
||||
|
||||
type CollectionMessage struct {
|
||||
Role string `yaml:"role"`
|
||||
Content string `yaml:"content"`
|
||||
}
|
||||
|
||||
type CollectionProfile struct {
|
||||
Format string `yaml:"format"`
|
||||
Temperature float64 `yaml:"temperature"`
|
||||
}
|
||||
|
||||
// LoadCollectionBundle demonstrates one layered collection load. Its main
|
||||
// document owns the application schema version; the mounted entry declares
|
||||
// relative destinations for collection metadata, prompt/profile peers, and
|
||||
// their nested imports. The operator version override remains a separate tier.
|
||||
func LoadCollectionBundle() (*configkit.Result[CollectionBundleConfig], error) {
|
||||
files, err := fs.Sub(bundledFiles, "testdata/collection-bundle")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
bundle := configkit.NewFilesystem("collection bundle", files)
|
||||
collectionDestination := configkit.Keys("collections", "weatherreporter")
|
||||
promptDestination := collectionDestination.Key("prompts")
|
||||
return configkit.LoadLayered(
|
||||
configkit.Input{Source: configkit.Bytes("application main", []byte("version: 1\n"))},
|
||||
configkit.LayeredOptions[CollectionBundleConfig]{
|
||||
Definition: configkit.Definition[CollectionBundleConfig]{
|
||||
Version: &configkit.VersionRule{Path: configkit.Keys("version"), Check: func(version configkit.Version, _ *configkit.Validation) error {
|
||||
if !version.Present || version.Text != "1" {
|
||||
return errors.New("unsupported application schema version")
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
Resolve: resolveCollectionResources,
|
||||
Validate: validateCollectionBundle,
|
||||
},
|
||||
Underlays: []configkit.Underlay{
|
||||
{Inputs: []configkit.Input{{
|
||||
Source: bundle.File("collections/weather/collection.yml"), Imports: true, Destination: collectionDestination,
|
||||
}}},
|
||||
{Inputs: []configkit.Input{{
|
||||
Source: bundle.File("operators/weather-prompt-version.yml"), Destination: promptDestination,
|
||||
}}},
|
||||
},
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
func resolveCollectionResources(configuration *CollectionBundleConfig, resolution *configkit.Resolution) error {
|
||||
for collectionID, collection := range configuration.Collections {
|
||||
for promptID, prompt := range collection.Prompts {
|
||||
if prompt.ContentFile == "" {
|
||||
continue
|
||||
}
|
||||
contentPath := configkit.Keys("collections", collectionID, "prompts", promptID, "content_file")
|
||||
info, found := resolution.InputInfo(contentPath)
|
||||
if !found || len(info.Origin.Locations) != 1 {
|
||||
return fmt.Errorf("prompt resource has no single declaring source")
|
||||
}
|
||||
resourceSource, found := resolution.Source(info.Origin.Locations[0].SourceID)
|
||||
if !found {
|
||||
return fmt.Errorf("prompt resource source is unavailable")
|
||||
}
|
||||
context, found := resolution.SourceContext(resourceSource.ID)
|
||||
if !found {
|
||||
return fmt.Errorf("prompt resource context is unavailable")
|
||||
}
|
||||
data, err := fs.ReadFile(context.FS, path.Join(context.BaseDir, prompt.ContentFile))
|
||||
if err != nil {
|
||||
return fmt.Errorf("read prompt resource: %w", err)
|
||||
}
|
||||
prompt.ResourceText = string(data)
|
||||
collection.Prompts[promptID] = prompt
|
||||
resolution.Record(
|
||||
configkit.Keys("collections", collectionID, "prompts", promptID, "resource_text"),
|
||||
configkit.ChangeNormalization, "read source-relative prompt content", contentPath,
|
||||
)
|
||||
}
|
||||
configuration.Collections[collectionID] = collection
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateCollectionBundle(configuration *CollectionBundleConfig, validation *configkit.Validation) {
|
||||
for collectionID, collection := range configuration.Collections {
|
||||
for promptID, prompt := range collection.Prompts {
|
||||
if prompt.DefaultProfile == "" {
|
||||
continue
|
||||
}
|
||||
if _, found := collection.Profiles[prompt.DefaultProfile]; !found {
|
||||
validation.Error(configkit.Keys("collections", collectionID, "prompts", promptID, "default_profile"), "must name a profile in this collection")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ReadCollectionPromptContent follows the effective content field's physical
|
||||
// source, including when it was inherited from a peer prompt.
|
||||
func ReadCollectionPromptContent(result *configkit.Result[CollectionBundleConfig], collectionID, promptID string) (string, error) {
|
||||
if result == nil {
|
||||
return "", errors.New("collection result is nil")
|
||||
}
|
||||
contentPath := configkit.Keys("collections", collectionID, "prompts", promptID, "content_file")
|
||||
info, found := result.Metadata().Lookup(contentPath)
|
||||
if !found || len(info.Origin.Locations) != 1 {
|
||||
return "", errors.New("prompt resource has no single declaring source")
|
||||
}
|
||||
context, found := result.SourceContext(info.Origin.Locations[0].SourceID)
|
||||
if !found {
|
||||
return "", errors.New("prompt resource context is unavailable")
|
||||
}
|
||||
prompt, found := result.Config.Collections[collectionID].Prompts[promptID]
|
||||
if !found {
|
||||
return "", errors.New("prompt is unavailable")
|
||||
}
|
||||
data, err := fs.ReadFile(context.FS, path.Join(context.BaseDir, prompt.ContentFile))
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
137
examples/consumers/destinations_test.go
Normal file
137
examples/consumers/destinations_test.go
Normal file
@@ -0,0 +1,137 @@
|
||||
package consumers_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"testing"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/examples/consumers"
|
||||
)
|
||||
|
||||
func Example_inputDestination() {
|
||||
mounted, err := configkit.LoadLayered[map[string]any](configkit.Input{
|
||||
Source: configkit.Mapping("weather prompt", map[string]any{
|
||||
"summarize.alerts": map[string]any{"version": "1"},
|
||||
}),
|
||||
Destination: configkit.Keys("collections", "weather.reporter", "prompts"),
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
root, err := configkit.LoadLayered[map[string]any](configkit.Input{
|
||||
Source: configkit.Bytes("standalone", []byte("name: standalone\n")),
|
||||
})
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
_, found := mounted.Config["collections"]
|
||||
fmt.Println(found, root.Config["name"])
|
||||
// Output: true standalone
|
||||
}
|
||||
|
||||
func TestCollectionBundleAssemblesMountedPeersAndResources(t *testing.T) {
|
||||
result, err := consumers.LoadCollectionBundle()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadCollectionBundle: %v", err)
|
||||
}
|
||||
collection := result.Config.Collections["weatherreporter"]
|
||||
if result.Config.Version != "1" || collection.Name != "Weather Reporter" || collection.Version != "2.4.0" {
|
||||
t.Fatalf("main and collection versions = %q, %#v", result.Config.Version, collection)
|
||||
}
|
||||
brief := collection.Prompts["brief"]
|
||||
custom := collection.Prompts["custom"]
|
||||
if brief.Version != "2" || brief.DefaultProfile != "compact" || brief.ContentFile != "welcome.txt" || len(brief.Messages) != 1 {
|
||||
t.Fatalf("version-only override replaced lower prompt fields: %#v", brief)
|
||||
}
|
||||
if custom.Version != "2" || custom.DefaultProfile != "weather" || custom.ContentFile != brief.ContentFile ||
|
||||
len(custom.Messages) != 1 || custom.ResourceText != "Welcome to the weather collection.\n" {
|
||||
t.Fatalf("inherited prompt = %#v", custom)
|
||||
}
|
||||
weatherProfile := collection.Profiles["weather"]
|
||||
if weatherProfile.Format != "text" || weatherProfile.Temperature != 0.4 {
|
||||
t.Fatalf("inherited profile = %#v", weatherProfile)
|
||||
}
|
||||
resource, err := consumers.ReadCollectionPromptContent(result, "weatherreporter", "custom")
|
||||
if err != nil || resource != custom.ResourceText {
|
||||
t.Fatalf("inherited prompt resource = %q, %v", resource, err)
|
||||
}
|
||||
|
||||
versionInfo, ok := result.Metadata().Lookup(configkit.Keys("version"))
|
||||
if !ok || !versionInfo.Explicit || versionInfo.Origin.Layer != configkit.LayerBase || len(versionInfo.Origin.Locations) != 1 {
|
||||
t.Fatalf("main-owned version metadata = %#v, %v", versionInfo, ok)
|
||||
}
|
||||
collectionVersionPath := configkit.Keys("collections", "weatherreporter", "version")
|
||||
collectionVersion, ok := result.Metadata().Lookup(collectionVersionPath)
|
||||
if !ok || !collectionVersion.Explicit || collectionVersion.Origin.Layer != configkit.LayerUnderlay || len(collectionVersion.Origin.Locations) != 1 {
|
||||
t.Fatalf("ordinary collection version metadata = %#v, %v", collectionVersion, ok)
|
||||
}
|
||||
var metadataSource configkit.SourceInfo
|
||||
var collectionEntrySource configkit.SourceInfo
|
||||
var versionSource configkit.SourceInfo
|
||||
var parentPromptSource configkit.SourceInfo
|
||||
var promptIndexSource configkit.SourceInfo
|
||||
var profileIndexSource configkit.SourceInfo
|
||||
var promptVersionSource configkit.SourceInfo
|
||||
for _, source := range result.Sources() {
|
||||
switch source.Path {
|
||||
case "collections/weather/collection.yml":
|
||||
collectionEntrySource = source
|
||||
case "collections/weather/metadata.yml":
|
||||
metadataSource = source
|
||||
case "collections/weather/version.yml":
|
||||
versionSource = source
|
||||
case "collections/weather/prompts/index.yml":
|
||||
promptIndexSource = source
|
||||
case "collections/weather/prompts/brief.yml":
|
||||
parentPromptSource = source
|
||||
case "collections/weather/profiles/index.yml":
|
||||
profileIndexSource = source
|
||||
case "operators/weather-prompt-version.yml":
|
||||
promptVersionSource = source
|
||||
}
|
||||
}
|
||||
collectionVersionLocation := collectionVersion.Origin.Locations[0]
|
||||
if collectionEntrySource.ID != "source-2" || collectionEntrySource.Destination.String() != "/collections/weatherreporter" ||
|
||||
metadataSource.ID == "" || metadataSource.Destination.String() != "/collections/weatherreporter" ||
|
||||
metadataSource.ImportedFrom == nil || metadataSource.ImportedFrom.SourceID != collectionEntrySource.ID ||
|
||||
versionSource.ID == "" || collectionVersionLocation.SourceID != versionSource.ID || versionSource.Destination.String() != "/collections/weatherreporter" ||
|
||||
versionSource.ImportedFrom == nil || versionSource.ImportedFrom.SourceID != metadataSource.ID || collectionVersionLocation.Line != 1 {
|
||||
t.Fatalf("nested collection version provenance = entry %#v, metadata %#v, version %#v, location %#v", collectionEntrySource, metadataSource, versionSource, collectionVersionLocation)
|
||||
}
|
||||
if promptIndexSource.ID == "" || promptIndexSource.Destination.String() != "/collections/weatherreporter/prompts" ||
|
||||
promptIndexSource.ImportedFrom == nil || promptIndexSource.ImportedFrom.SourceID != collectionEntrySource.ID ||
|
||||
profileIndexSource.ID == "" || profileIndexSource.Destination.String() != "/collections/weatherreporter/profiles" ||
|
||||
profileIndexSource.ImportedFrom == nil || profileIndexSource.ImportedFrom.SourceID != collectionEntrySource.ID {
|
||||
t.Fatalf("nested prompt/profile placement = prompt %#v profile %#v", promptIndexSource, profileIndexSource)
|
||||
}
|
||||
if promptVersionSource.ID == "" || promptVersionSource.Destination.String() != "/collections/weatherreporter/prompts" || promptVersionSource.UnderlayIndex != 2 {
|
||||
t.Fatalf("prompt version source = %#v", promptVersionSource)
|
||||
}
|
||||
contentPath := configkit.Keys("collections", "weatherreporter", "prompts", "custom", "content_file")
|
||||
contentInfo, ok := result.Metadata().Lookup(contentPath)
|
||||
if !ok || !contentInfo.Explicit || len(contentInfo.Origin.Inheritance) != 1 || len(contentInfo.Origin.Locations) != 1 {
|
||||
t.Fatalf("inherited content field metadata = %#v, %v", contentInfo, ok)
|
||||
}
|
||||
contentLocation := contentInfo.Origin.Locations[0]
|
||||
if parentPromptSource.ID == "" || contentLocation.SourceID != parentPromptSource.ID || contentLocation.Line != 4 ||
|
||||
parentPromptSource.Destination.String() != "/collections/weatherreporter/prompts" {
|
||||
t.Fatalf("inherited resource source coordinates = %#v, source %#v", contentLocation, parentPromptSource)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCollectionPeerPromptConflictsRemainStrict(t *testing.T) {
|
||||
destination := configkit.Keys("collections", "weatherreporter", "prompts")
|
||||
_, err := configkit.LoadLayered[map[string]any](configkit.Input{Source: configkit.NoSource()}, configkit.LayeredOptions[map[string]any]{
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{
|
||||
{Source: configkit.Bytes("peer-a", []byte("brief: {version: '1'}\n")), Destination: destination},
|
||||
{Source: configkit.Bytes("peer-b", []byte("brief: {version: '1'}\n")), Destination: destination},
|
||||
}}},
|
||||
})
|
||||
var loadError *configkit.LoadError
|
||||
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != configkit.CodeComposeConflict {
|
||||
t.Fatalf("duplicate peer prompt error = %v", err)
|
||||
}
|
||||
}
|
||||
136
examples/consumers/diff.go
Normal file
136
examples/consumers/diff.go
Normal file
@@ -0,0 +1,136 @@
|
||||
package consumers
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
// FileComparisonConfig is a small application-owned shape for comparing
|
||||
// caller-selected configuration files.
|
||||
type FileComparisonConfig struct {
|
||||
Name string `yaml:"name"`
|
||||
Entries map[string]any `yaml:"entries"`
|
||||
}
|
||||
|
||||
// CompareFiles loads two caller-selected files with the same application
|
||||
// definition, then formats only the safe records returned by configkit.Diff.
|
||||
func CompareFiles(leftPath, rightPath string) (string, error) {
|
||||
options := configkit.Options[FileComparisonConfig]{
|
||||
Definition: configkit.Definition[FileComparisonConfig]{
|
||||
Defaults: func() FileComparisonConfig {
|
||||
return FileComparisonConfig{Entries: map[string]any{}}
|
||||
},
|
||||
},
|
||||
}
|
||||
left, err := configkit.Load(configkit.File(leftPath), options)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("load left configuration: %w", err)
|
||||
}
|
||||
right, err := configkit.Load(configkit.File(rightPath), options)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("load right configuration: %w", err)
|
||||
}
|
||||
differences, err := configkit.Diff(left, right)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return FormatDifferences(differences), nil
|
||||
}
|
||||
|
||||
// RolloutConfig models a service endpoint whose URL is derived by the
|
||||
// application resolver and whose credential reference is classified.
|
||||
type RolloutConfig struct {
|
||||
Service RolloutService `yaml:"service"`
|
||||
}
|
||||
|
||||
// RolloutService contains endpoint settings and a classified credential.
|
||||
type RolloutService struct {
|
||||
Endpoint RolloutEndpoint `yaml:"endpoint"`
|
||||
Credential RolloutCredential `yaml:"credential"`
|
||||
}
|
||||
|
||||
// RolloutEndpoint is the effective destination used by the application.
|
||||
type RolloutEndpoint struct {
|
||||
Host string `yaml:"host"`
|
||||
Port int `yaml:"port"`
|
||||
URL string `yaml:"url"`
|
||||
}
|
||||
|
||||
// RolloutCredential holds only a typed secret reference in configuration.
|
||||
type RolloutCredential struct {
|
||||
Token configkit.Secret `yaml:"token" configkit:"secret"`
|
||||
}
|
||||
|
||||
// LoadRollout loads a base file, optionally with one selected configuration
|
||||
// overlay. Resolution derives the effective URL after the overlay is applied.
|
||||
func LoadRollout(basePath, overlayName string) (*configkit.Result[RolloutConfig], error) {
|
||||
return configkit.Load(configkit.File(basePath), configkit.Options[RolloutConfig]{
|
||||
Overlay: overlayName,
|
||||
Definition: configkit.Definition[RolloutConfig]{
|
||||
Resolve: resolveRollout,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
// CompareRolloutOverlay compares the effective base configuration with that
|
||||
// same file plus one selected overlay. It presents only safe diff records.
|
||||
func CompareRolloutOverlay(basePath, overlayName string) (string, error) {
|
||||
base, err := LoadRollout(basePath, "")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("load base configuration: %w", err)
|
||||
}
|
||||
withOverlay, err := LoadRollout(basePath, overlayName)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("load overlay configuration: %w", err)
|
||||
}
|
||||
differences, err := configkit.Diff(base, withOverlay)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return FormatDifferences(differences), nil
|
||||
}
|
||||
|
||||
func resolveRollout(configuration *RolloutConfig, resolution *configkit.Resolution) error {
|
||||
endpoint := &configuration.Service.Endpoint
|
||||
if endpoint.Host == "" || endpoint.Port <= 0 {
|
||||
return nil
|
||||
}
|
||||
url := fmt.Sprintf("https://%s:%d", endpoint.Host, endpoint.Port)
|
||||
if endpoint.URL != url {
|
||||
endpoint.URL = url
|
||||
resolution.Record(configkit.Keys("service", "endpoint", "url"),
|
||||
configkit.ChangeNormalization, "derived endpoint URL",
|
||||
configkit.Keys("service", "endpoint", "host"),
|
||||
configkit.Keys("service", "endpoint", "port"))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// FormatDifferences is consumer-owned presentation. It distinguishes absence
|
||||
// through Present and reports withheld changes through HiddenChanged without
|
||||
// interpreting redaction marker text.
|
||||
func FormatDifferences(differences []configkit.Difference) string {
|
||||
if len(differences) == 0 {
|
||||
return "No effective changes.\n"
|
||||
}
|
||||
var output strings.Builder
|
||||
for _, difference := range differences {
|
||||
fmt.Fprintf(&output, "%s: left=%s right=%s", difference.Path.String(),
|
||||
formatSnapshot(difference.Left), formatSnapshot(difference.Right))
|
||||
if difference.HiddenChanged {
|
||||
output.WriteString(" hidden-change")
|
||||
}
|
||||
output.WriteByte('\n')
|
||||
}
|
||||
return output.String()
|
||||
}
|
||||
|
||||
func formatSnapshot(snapshot configkit.ValueSnapshot) string {
|
||||
if !snapshot.Present {
|
||||
return "<absent>"
|
||||
}
|
||||
return strconv.Quote(snapshot.YAML)
|
||||
}
|
||||
187
examples/consumers/diff_test.go
Normal file
187
examples/consumers/diff_test.go
Normal file
@@ -0,0 +1,187 @@
|
||||
package consumers_test
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/examples/consumers"
|
||||
)
|
||||
|
||||
func ExampleCompareFiles() {
|
||||
directory, err := os.MkdirTemp("", "configkit-diff-files-")
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
defer os.RemoveAll(directory)
|
||||
leftPath := filepath.Join(directory, "before.yml")
|
||||
rightPath := filepath.Join(directory, "after.yml")
|
||||
if err := os.WriteFile(leftPath, []byte("name: before\nentries: {}\n"), 0o600); err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(rightPath, []byte("name: after\nentries:\n added: null\n"), 0o600); err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
output, err := consumers.CompareFiles(leftPath, rightPath)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
fmt.Print(output)
|
||||
// Output:
|
||||
// /entries/added: left=<absent> right="null\n"
|
||||
// /name: left="before\n" right="after\n"
|
||||
}
|
||||
|
||||
func ExampleCompareRolloutOverlay() {
|
||||
const baseToken = "CONFIGKIT_DIFF_EXAMPLE_BASE_TOKEN"
|
||||
const overlayToken = "CONFIGKIT_DIFF_EXAMPLE_OVERLAY_TOKEN"
|
||||
defer preserveEnvironment(baseToken)()
|
||||
defer preserveEnvironment(overlayToken)()
|
||||
_ = os.Setenv(baseToken, "disposable-example-base-credential")
|
||||
_ = os.Setenv(overlayToken, "disposable-example-overlay-credential")
|
||||
|
||||
directory, err := os.MkdirTemp("", "configkit-diff-overlay-")
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
defer os.RemoveAll(directory)
|
||||
basePath := filepath.Join(directory, "config.yml")
|
||||
overlayPath := filepath.Join(directory, "overlays", "canary.yml")
|
||||
if err := os.MkdirAll(filepath.Dir(overlayPath), 0o700); err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
base := "service:\n endpoint:\n host: base.example\n port: 8443\n credential:\n token: " + baseToken + "\n"
|
||||
overlay := "service:\n endpoint:\n host: canary.example\n credential:\n token: " + overlayToken + "\n"
|
||||
if err := os.WriteFile(basePath, []byte(base), 0o600); err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
if err := os.WriteFile(overlayPath, []byte(overlay), 0o600); err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
output, err := consumers.CompareRolloutOverlay(basePath, "canary")
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
fmt.Print(output)
|
||||
// Output:
|
||||
// /service/credential/token: left="'[REDACTED]'\n" right="'[REDACTED]'\n" hidden-change
|
||||
// /service/endpoint/host: left="base.example\n" right="canary.example\n"
|
||||
// /service/endpoint/url: left="https://base.example:8443\n" right="https://canary.example:8443\n"
|
||||
}
|
||||
|
||||
func TestCompareFilesUsesCallerSelectedSourcesAndSafePresence(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
leftPath := filepath.Join(directory, "before.yml")
|
||||
rightPath := filepath.Join(directory, "after.yml")
|
||||
writeConfiguration(t, leftPath, "name: before\nentries: {}\n")
|
||||
writeConfiguration(t, rightPath, "name: after\nentries:\n added: null\n")
|
||||
|
||||
output, err := consumers.CompareFiles(leftPath, rightPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
want := "/entries/added: left=<absent> right=\"null\\n\"\n" +
|
||||
"/name: left=\"before\\n\" right=\"after\\n\"\n"
|
||||
if output != want {
|
||||
t.Fatalf("comparison output = %q, want %q", output, want)
|
||||
}
|
||||
if again, err := consumers.CompareFiles(leftPath, rightPath); err != nil || again != output {
|
||||
t.Fatalf("repeat output = %q, %v", again, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareFilesReturnsEachLoadError(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
leftPath := filepath.Join(directory, "before.yml")
|
||||
rightPath := filepath.Join(directory, "after.yml")
|
||||
writeConfiguration(t, leftPath, "name: before\n")
|
||||
writeConfiguration(t, rightPath, "unknown: invalid\n")
|
||||
|
||||
output, err := consumers.CompareFiles(leftPath, rightPath)
|
||||
if err == nil || output != "" || !strings.Contains(err.Error(), "load right configuration") {
|
||||
t.Fatalf("right load failure = %q, %v", output, err)
|
||||
}
|
||||
if _, err := consumers.CompareFiles(filepath.Join(directory, "missing.yml"), rightPath); err == nil || !strings.Contains(err.Error(), "load left configuration") {
|
||||
t.Fatalf("left load failure = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCompareRolloutOverlayShowsResolvedSafeChangesAndRetainsSiblings(t *testing.T) {
|
||||
t.Setenv("CONFIGKIT_DIFF_BASE_TOKEN", "disposable-base-credential")
|
||||
t.Setenv("CONFIGKIT_DIFF_OVERLAY_TOKEN", "disposable-overlay-credential")
|
||||
directory := t.TempDir()
|
||||
basePath := filepath.Join(directory, "config.yml")
|
||||
overlayPath := filepath.Join(directory, "overlays", "canary.yml")
|
||||
if err := os.MkdirAll(filepath.Dir(overlayPath), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeConfiguration(t, basePath, "service:\n endpoint:\n host: base.example\n port: 8443\n credential:\n token: CONFIGKIT_DIFF_BASE_TOKEN\n")
|
||||
writeConfiguration(t, overlayPath, "service:\n endpoint:\n host: canary.example\n credential:\n token: CONFIGKIT_DIFF_OVERLAY_TOKEN\n")
|
||||
|
||||
base, err := consumers.LoadRollout(basePath, "")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
withOverlay, err := consumers.LoadRollout(basePath, "canary")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if base.Config.Service.Endpoint.Port != 8443 || withOverlay.Config.Service.Endpoint.Port != 8443 {
|
||||
t.Fatalf("lower-priority port was not retained: base=%#v overlay=%#v", base.Config.Service.Endpoint, withOverlay.Config.Service.Endpoint)
|
||||
}
|
||||
urlInfo, found := withOverlay.Metadata().Lookup(configkit.Keys("service", "endpoint", "url"))
|
||||
if !found || urlInfo.Origin.Layer != configkit.LayerResolution || withOverlay.Config.Service.Endpoint.URL != "https://canary.example:8443" {
|
||||
t.Fatalf("resolved endpoint = %#v, metadata=%#v, %v", withOverlay.Config.Service.Endpoint, urlInfo, found)
|
||||
}
|
||||
|
||||
differences, err := configkit.Diff(base, withOverlay)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(differences) != 3 {
|
||||
t.Fatalf("differences = %#v", differences)
|
||||
}
|
||||
wantPaths := []string{"/service/credential/token", "/service/endpoint/host", "/service/endpoint/url"}
|
||||
for index, difference := range differences {
|
||||
if got := difference.Path.String(); got != wantPaths[index] {
|
||||
t.Errorf("path[%d] = %q, want %q", index, got, wantPaths[index])
|
||||
}
|
||||
}
|
||||
secret := differences[0]
|
||||
if !secret.Left.Redacted || !secret.Right.Redacted || !secret.HiddenChanged || secret.Left.YAML != "'[REDACTED]'\n" || secret.Right.YAML != "'[REDACTED]'\n" {
|
||||
t.Fatalf("classified credential difference = %#v", secret)
|
||||
}
|
||||
if strings.Contains(consumers.FormatDifferences(differences), "disposable-") {
|
||||
t.Fatal("formatted differences exposed credential contents")
|
||||
}
|
||||
|
||||
output, err := consumers.CompareRolloutOverlay(basePath, "canary")
|
||||
if err != nil || output != consumers.FormatDifferences(differences) {
|
||||
t.Fatalf("formatted base/overlay comparison = %q, %v", output, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFormatDifferencesUsesRecordMembershipForNoChange(t *testing.T) {
|
||||
if got := consumers.FormatDifferences(nil); got != "No effective changes.\n" {
|
||||
t.Fatalf("no-change output = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func writeConfiguration(t *testing.T, filename, contents string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filename, []byte(contents), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
@@ -9,7 +9,7 @@ import (
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
//go:embed testdata/native-source testdata/layered-bundle
|
||||
//go:embed testdata/native-source testdata/layered-bundle testdata/collection-bundle testdata/ordinary-imports testdata/source-version
|
||||
var bundledFiles embed.FS
|
||||
|
||||
// BundledConfig is a small configuration loaded directly from an embedded file.
|
||||
@@ -18,6 +18,30 @@ type BundledConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
}
|
||||
|
||||
type PromptBundleConfig struct {
|
||||
Name string `yaml:"name"`
|
||||
Version string `yaml:"version"`
|
||||
Description string `yaml:"description"`
|
||||
Prompts map[string]BundlePrompt `yaml:"prompts"`
|
||||
}
|
||||
|
||||
type BundlePrompt struct {
|
||||
Summary string `yaml:"summary"`
|
||||
}
|
||||
|
||||
// LoadPromptBundle demonstrates ordinary Load with opt-in imports whose
|
||||
// destinations are declared relative to the entry document.
|
||||
func LoadPromptBundle() (*configkit.Result[PromptBundleConfig], error) {
|
||||
files, err := fs.Sub(bundledFiles, "testdata/ordinary-imports")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return configkit.Load[PromptBundleConfig](
|
||||
configkit.NewFilesystem("prompt bundle", files).File("config.yml"),
|
||||
configkit.Options[PromptBundleConfig]{Imports: true},
|
||||
)
|
||||
}
|
||||
|
||||
// LoadBundled demonstrates a native filesystem source without layered options.
|
||||
func LoadBundled() (*configkit.Result[BundledConfig], error) {
|
||||
files, err := fs.Sub(bundledFiles, "testdata/native-source")
|
||||
|
||||
@@ -20,6 +20,34 @@ func Example_nativeFilesystem() {
|
||||
// Output: embedded false
|
||||
}
|
||||
|
||||
func Example_rootRelativeImports() {
|
||||
result, err := consumers.LoadPromptBundle()
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
source := result.Sources()[2]
|
||||
fmt.Println(result.Config.Name, result.Config.Version, result.Config.Prompts["brief"].Summary, source.Destination.String(), err)
|
||||
// Output: Root import example 1.0 A root-loaded prompt. /prompts <nil>
|
||||
}
|
||||
|
||||
func TestPromptBundleUsesRelativeImportDestinations(t *testing.T) {
|
||||
result, err := consumers.LoadPromptBundle()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Config.Name != "Root import example" || result.Config.Version != "1.0" || result.Config.Prompts["brief"].Summary != "A root-loaded prompt." {
|
||||
t.Fatalf("root import result = %#v", result.Config)
|
||||
}
|
||||
sources := result.Sources()
|
||||
if len(sources) != 3 || sources[0].Destination.String() != configkit.Keys().String() ||
|
||||
sources[1].Path != "metadata.yml" || sources[1].Destination.String() != configkit.Keys().String() ||
|
||||
sources[2].Path != "prompts/brief.yml" || sources[2].Destination.String() != "/prompts" ||
|
||||
sources[2].ImportedFrom == nil || sources[2].ImportedFrom.SourceID != sources[0].ID {
|
||||
t.Fatalf("root import sources = %#v", sources)
|
||||
}
|
||||
}
|
||||
|
||||
func Example_layeredCatalog() {
|
||||
result, err := consumers.LoadCatalog(fixture("layered-main", "config.yml"))
|
||||
if err != nil {
|
||||
@@ -32,6 +60,58 @@ func Example_layeredCatalog() {
|
||||
// Output: production false 0 bundle example Welcome from the bundle. <nil>
|
||||
}
|
||||
|
||||
func Example_sourceVersionAdoption() {
|
||||
result, err := consumers.LoadVersionedService(configkit.SourceVersionModeIfPresent)
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
checks, redacted := 0, true
|
||||
for _, source := range result.Sources() {
|
||||
if check := source.VersionCheck; check != nil {
|
||||
checks++
|
||||
redacted = redacted && check.Redacted && check.Version.Text == ""
|
||||
}
|
||||
}
|
||||
required, requiredErr := consumers.LoadVersionedService(configkit.SourceVersionModeRequired)
|
||||
fmt.Println(result.Config.Service.Version, result.Config.Service.Metadata.Release,
|
||||
checks, redacted, required != nil && requiredErr == nil)
|
||||
// Output: service-v2 application-2026.10 2 true true
|
||||
}
|
||||
|
||||
func TestVersionedServiceModesAndMountedProtection(t *testing.T) {
|
||||
for _, mode := range []configkit.SourceVersionMode{
|
||||
configkit.SourceVersionModeIfPresent,
|
||||
configkit.SourceVersionModeRequired,
|
||||
} {
|
||||
result, err := consumers.LoadVersionedService(mode)
|
||||
if err != nil || result == nil || result.Config.Service.Version != "service-v2" ||
|
||||
result.Config.Service.Name != "example service" || result.Config.Service.Endpoint != "https://service.example.invalid" ||
|
||||
result.Config.Service.Metadata.Release != "application-2026.10" {
|
||||
t.Fatalf("versioned service mode %d = %#v, %v", mode, result, err)
|
||||
}
|
||||
sources := result.Sources()
|
||||
if len(sources) != 2 {
|
||||
t.Fatalf("versioned service sources = %#v", sources)
|
||||
}
|
||||
for _, source := range sources {
|
||||
check := source.VersionCheck
|
||||
if check == nil || check.Path.String() != "/version" || !check.Version.Present ||
|
||||
!check.Redacted || check.Version.Text != "" {
|
||||
t.Fatalf("safe source version evidence = %#v", sources)
|
||||
}
|
||||
}
|
||||
}
|
||||
_, err := consumers.LoadVersionedService(configkit.SourceVersionModeIfPresent,
|
||||
configkit.Override{Path: configkit.Keys("service", "version"), Value: "cli-v3", Flag: "--service-version"})
|
||||
var loadError *configkit.LoadError
|
||||
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 ||
|
||||
loadError.Diagnostics()[0].Code != configkit.CodeOverrideVersion ||
|
||||
loadError.Diagnostics()[0].Path.String() != "/service/version" {
|
||||
t.Fatalf("mounted main version override error = %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCatalogRetainsPresenceAndSourceContext(t *testing.T) {
|
||||
result, err := consumers.LoadCatalog(fixture("layered-main", "config.yml"))
|
||||
if err != nil {
|
||||
|
||||
68
examples/consumers/prompt_catalog.go
Normal file
68
examples/consumers/prompt_catalog.go
Normal file
@@ -0,0 +1,68 @@
|
||||
package consumers
|
||||
|
||||
import (
|
||||
"strings"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
// PromptCatalogConfig is an application-owned prompt collection shape.
|
||||
type PromptCatalogConfig struct {
|
||||
Prompts []PromptRecord `yaml:"prompts"`
|
||||
}
|
||||
|
||||
// PromptRecord keeps its identity and message meaning in the consumer.
|
||||
type PromptRecord struct {
|
||||
ID string `yaml:"id"`
|
||||
Name string `yaml:"name"`
|
||||
Messages []string `yaml:"messages"`
|
||||
Resource string `yaml:"resource"`
|
||||
}
|
||||
|
||||
// LoadPromptCatalog discovers explicitly registered prompt records in the main
|
||||
// tier and replaces the lower-priority default collection as a whole.
|
||||
func LoadPromptCatalog(mainPath string) (*configkit.Result[PromptCatalogConfig], error) {
|
||||
return configkit.LoadLayered(configkit.Input{
|
||||
Source: configkit.File(mainPath),
|
||||
Imports: true,
|
||||
}, configkit.LayeredOptions[PromptCatalogConfig]{
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{
|
||||
Source: configkit.Bytes("default prompts", []byte("prompts:\n - id: default\n name: Default prompt\n messages: [Default message]\n")),
|
||||
}}}},
|
||||
ImportDefaults: configkit.ImportDefaults{
|
||||
Pattern: []string{"curated*.yml", "generated-*.yml"},
|
||||
},
|
||||
Definition: promptCatalogDefinition(),
|
||||
})
|
||||
}
|
||||
|
||||
// LoadRequiredPromptCatalog keeps the common one-file import shape small.
|
||||
func LoadRequiredPromptCatalog(mainPath string) (*configkit.Result[PromptCatalogConfig], error) {
|
||||
return configkit.Load(configkit.File(mainPath), configkit.Options[PromptCatalogConfig]{
|
||||
Imports: true, Definition: promptCatalogDefinition(),
|
||||
})
|
||||
}
|
||||
|
||||
func promptCatalogDefinition() configkit.Definition[PromptCatalogConfig] {
|
||||
return configkit.Definition[PromptCatalogConfig]{
|
||||
SequenceCompositions: []configkit.SequenceComposition{{
|
||||
Path: configkit.Keys("prompts"),
|
||||
IdentityField: "id",
|
||||
}},
|
||||
Validate: validatePromptCatalog,
|
||||
}
|
||||
}
|
||||
|
||||
func validatePromptCatalog(configuration *PromptCatalogConfig, validation *configkit.Validation) {
|
||||
for index, prompt := range configuration.Prompts {
|
||||
if strings.TrimSpace(prompt.Name) == "" {
|
||||
validation.Error(configkit.Keys("prompts").Index(index).Key("name"), "must not be empty")
|
||||
}
|
||||
if len(prompt.Messages) == 0 {
|
||||
validation.Error(configkit.Keys("prompts").Index(index).Key("messages"), "must contain at least one message")
|
||||
}
|
||||
}
|
||||
if len(configuration.Prompts) == 0 {
|
||||
validation.Error(configkit.Keys("prompts"), "must contain at least one prompt")
|
||||
}
|
||||
}
|
||||
135
examples/consumers/prompt_catalog_test.go
Normal file
135
examples/consumers/prompt_catalog_test.go
Normal file
@@ -0,0 +1,135 @@
|
||||
package consumers_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
pathpkg "path"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/examples/consumers"
|
||||
)
|
||||
|
||||
func Example_identifiedPromptCatalog() {
|
||||
result, err := consumers.LoadPromptCatalog(fixture("prompt-catalog", "config.yml"))
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
ids := make([]string, 0, len(result.Config.Prompts))
|
||||
for _, prompt := range result.Config.Prompts {
|
||||
ids = append(ids, prompt.ID)
|
||||
}
|
||||
emptySearches := 0
|
||||
for _, source := range result.Sources() {
|
||||
for _, selection := range source.ImportSelections {
|
||||
if selection.Selector == configkit.ImportSelectorDirectory && selection.Outcome == configkit.ImportSelectionNone {
|
||||
emptySearches++
|
||||
}
|
||||
}
|
||||
}
|
||||
fmt.Println(ids, emptySearches, err)
|
||||
// Output: [weather curated welcome main] 1 <nil>
|
||||
}
|
||||
|
||||
func Example_requiredPromptCatalog() {
|
||||
result, err := consumers.LoadRequiredPromptCatalog(fixture("prompt-catalog", "simple.yml"))
|
||||
if err != nil {
|
||||
fmt.Println(err)
|
||||
return
|
||||
}
|
||||
fmt.Println(result.Config.Prompts[0].ID, err)
|
||||
// Output: required <nil>
|
||||
}
|
||||
|
||||
func TestPromptCatalogImportsRegisteredItemsAndReplacesDefaults(t *testing.T) {
|
||||
result, err := consumers.LoadPromptCatalog(fixture("prompt-catalog", "config.yml"))
|
||||
if err != nil {
|
||||
var loadError *configkit.LoadError
|
||||
if errors.As(err, &loadError) {
|
||||
t.Fatalf("load prompt catalog: %v (%#v)", err, loadError.Diagnostics())
|
||||
}
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(result.Config.Prompts) != 4 {
|
||||
t.Fatalf("prompt records = %#v", result.Config.Prompts)
|
||||
}
|
||||
ids := []string{"weather", "curated", "welcome", "main"}
|
||||
for index, id := range ids {
|
||||
if result.Config.Prompts[index].ID != id {
|
||||
t.Fatalf("prompt %d ID = %q, want %q", index, result.Config.Prompts[index].ID, id)
|
||||
}
|
||||
}
|
||||
for _, prompt := range result.Config.Prompts {
|
||||
if prompt.ID == "default" {
|
||||
t.Fatalf("lower-priority default survived main sequence replacement: %#v", result.Config.Prompts)
|
||||
}
|
||||
if prompt.ID == "ignored" {
|
||||
t.Fatalf("excluded generated prompt was selected: %#v", result.Config.Prompts)
|
||||
}
|
||||
}
|
||||
|
||||
var itemImports, helperImports int
|
||||
for _, source := range result.Sources() {
|
||||
switch filepath.Base(source.Path) {
|
||||
case "curated.yml", "weather-feature.yml", "generated-welcome.yml":
|
||||
if source.PlacementMode != configkit.PlacementModeSequenceItem || source.Destination.String() != "/prompts" {
|
||||
t.Fatalf("item source placement = %#v", source)
|
||||
}
|
||||
itemImports++
|
||||
case "curated-id.yml":
|
||||
if source.PlacementMode != configkit.PlacementModeMapping || source.ImportedFrom == nil {
|
||||
t.Fatalf("helper source = %#v", source)
|
||||
}
|
||||
helperImports++
|
||||
}
|
||||
}
|
||||
if itemImports != 3 || helperImports != 1 {
|
||||
t.Fatalf("item imports = %d, helper imports = %d, sources = %#v", itemImports, helperImports, result.Sources())
|
||||
}
|
||||
if result.Config.Prompts[1].ID != "curated" {
|
||||
t.Fatalf("helper did not provide prompt ID: %#v", result.Config.Prompts[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptCatalogMountsResourceFromScheduledPromptOrigin(t *testing.T) {
|
||||
result, err := consumers.LoadPromptCatalog(fixture("prompt-catalog", "config.yml"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Config.Prompts[2].ID != "welcome" || result.Config.Prompts[2].Resource != "welcome.txt" {
|
||||
t.Fatalf("scheduled resource prompt = %#v", result.Config.Prompts)
|
||||
}
|
||||
resourcePath := configkit.Keys("prompts").Index(2).Key("resource")
|
||||
info, ok := result.Metadata().Lookup(resourcePath)
|
||||
if !ok || len(info.Origin.Locations) != 1 {
|
||||
t.Fatalf("resource origin = %#v, %v", info, ok)
|
||||
}
|
||||
context, ok := result.SourceContext(info.Origin.Locations[0].SourceID)
|
||||
if !ok || context.FS == nil || filepath.Base(context.BaseDir) != "prompts" {
|
||||
t.Fatalf("resource source context = %#v, %v", context, ok)
|
||||
}
|
||||
contents, err := fs.ReadFile(context.FS, pathpkg.Join(context.BaseDir, result.Config.Prompts[2].Resource))
|
||||
if err != nil || string(contents) != "Welcome resource text.\n" {
|
||||
t.Fatalf("mounted resource = %q, %v", contents, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRequiredPromptCatalogUsesOneRequiredFile(t *testing.T) {
|
||||
result, err := consumers.LoadRequiredPromptCatalog(fixture("prompt-catalog", "simple.yml"))
|
||||
if err != nil || len(result.Config.Prompts) != 1 || result.Config.Prompts[0].ID != "required" {
|
||||
t.Fatalf("simple required prompt = %#v, %v", result, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPromptCatalogLeavesDomainValidationWithConsumer(t *testing.T) {
|
||||
_, err := consumers.LoadPromptCatalog(fixture("prompt-catalog", "invalid.yml"))
|
||||
var loadError *configkit.LoadError
|
||||
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 ||
|
||||
loadError.Diagnostics()[0].Code != configkit.CodeValidateError ||
|
||||
loadError.Diagnostics()[0].Path.String() != "/prompts/0/messages" {
|
||||
t.Fatalf("consumer domain validation error = %v", err)
|
||||
}
|
||||
}
|
||||
64
examples/consumers/source_version.go
Normal file
64
examples/consumers/source_version.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package consumers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
// VersionedServiceConfig demonstrates a mounted main input whose source-local
|
||||
// schema version is retained at the effective service path.
|
||||
type VersionedServiceConfig struct {
|
||||
Service struct {
|
||||
Version string `yaml:"version"`
|
||||
Name string `yaml:"name"`
|
||||
Endpoint string `yaml:"endpoint"`
|
||||
Metadata struct {
|
||||
Release string `yaml:"release"`
|
||||
} `yaml:"metadata"`
|
||||
} `yaml:"service"`
|
||||
}
|
||||
|
||||
// LoadVersionedService checks the main and underlay declarations with one
|
||||
// application policy. The local rule path is "version" for each source; the
|
||||
// main input's destination places its protected value at /service/version.
|
||||
// Optional CLI overrides are passed through to ConfigKit for normal protection.
|
||||
func LoadVersionedService(mode configkit.SourceVersionMode, overrides ...configkit.Override) (*configkit.Result[VersionedServiceConfig], error) {
|
||||
files, err := fs.Sub(bundledFiles, "testdata/source-version")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
main, err := fs.ReadFile(files, "main.yml")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
underlay, err := fs.ReadFile(files, "underlay.yml")
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
definition := configkit.Definition[VersionedServiceConfig]{
|
||||
Version: &configkit.VersionRule{
|
||||
Path: configkit.Keys("version"),
|
||||
Check: func(version configkit.Version, _ *configkit.Validation) error {
|
||||
if !version.Present || version.Null || version.Text == "" {
|
||||
return errors.New("a nonempty source version is required")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
Secret: func(path configkit.Path) bool {
|
||||
return path.String() == "/service/version"
|
||||
},
|
||||
}
|
||||
return configkit.LoadLayered(configkit.Input{
|
||||
Source: configkit.Bytes("service main", main), Destination: configkit.Keys("service"),
|
||||
}, configkit.LayeredOptions[VersionedServiceConfig]{
|
||||
Definition: definition,
|
||||
SourceVersionMode: mode,
|
||||
Overrides: overrides,
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{
|
||||
Source: configkit.Bytes("service defaults", underlay), Destination: configkit.Keys("service"),
|
||||
}}}},
|
||||
})
|
||||
}
|
||||
9
examples/consumers/testdata/collection-bundle/collections/weather/collection.yml
vendored
Normal file
9
examples/consumers/testdata/collection-bundle/collections/weather/collection.yml
vendored
Normal file
@@ -0,0 +1,9 @@
|
||||
configkit:
|
||||
imports:
|
||||
- path: metadata.yml
|
||||
destination: []
|
||||
- path: prompts/index.yml
|
||||
destination: [prompts]
|
||||
- path: profiles/index.yml
|
||||
destination: [profiles]
|
||||
name: Weather Reporter
|
||||
4
examples/consumers/testdata/collection-bundle/collections/weather/metadata.yml
vendored
Normal file
4
examples/consumers/testdata/collection-bundle/collections/weather/metadata.yml
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
configkit:
|
||||
imports:
|
||||
- path: version.yml
|
||||
description: Imported collection metadata.
|
||||
3
examples/consumers/testdata/collection-bundle/collections/weather/profiles/compact.yml
vendored
Normal file
3
examples/consumers/testdata/collection-bundle/collections/weather/profiles/compact.yml
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
compact:
|
||||
format: text
|
||||
temperature: 0.2
|
||||
4
examples/consumers/testdata/collection-bundle/collections/weather/profiles/index.yml
vendored
Normal file
4
examples/consumers/testdata/collection-bundle/collections/weather/profiles/index.yml
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
configkit:
|
||||
imports:
|
||||
- path: compact.yml
|
||||
- path: weather.yml
|
||||
3
examples/consumers/testdata/collection-bundle/collections/weather/profiles/weather.yml
vendored
Normal file
3
examples/consumers/testdata/collection-bundle/collections/weather/profiles/weather.yml
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
weather:
|
||||
extends: compact
|
||||
temperature: 0.4
|
||||
7
examples/consumers/testdata/collection-bundle/collections/weather/prompts/brief.yml
vendored
Normal file
7
examples/consumers/testdata/collection-bundle/collections/weather/prompts/brief.yml
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
brief:
|
||||
version: "1"
|
||||
default_profile: compact
|
||||
content_file: welcome.txt
|
||||
messages:
|
||||
- role: user
|
||||
content: Write a concise weather summary.
|
||||
3
examples/consumers/testdata/collection-bundle/collections/weather/prompts/custom.yml
vendored
Normal file
3
examples/consumers/testdata/collection-bundle/collections/weather/prompts/custom.yml
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
custom:
|
||||
extends: brief
|
||||
default_profile: weather
|
||||
4
examples/consumers/testdata/collection-bundle/collections/weather/prompts/index.yml
vendored
Normal file
4
examples/consumers/testdata/collection-bundle/collections/weather/prompts/index.yml
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
configkit:
|
||||
imports:
|
||||
- path: brief.yml
|
||||
- path: custom.yml
|
||||
1
examples/consumers/testdata/collection-bundle/collections/weather/prompts/welcome.txt
vendored
Normal file
1
examples/consumers/testdata/collection-bundle/collections/weather/prompts/welcome.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
Welcome to the weather collection.
|
||||
1
examples/consumers/testdata/collection-bundle/collections/weather/version.yml
vendored
Normal file
1
examples/consumers/testdata/collection-bundle/collections/weather/version.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
version: "2.4.0"
|
||||
2
examples/consumers/testdata/collection-bundle/operators/weather-prompt-version.yml
vendored
Normal file
2
examples/consumers/testdata/collection-bundle/operators/weather-prompt-version.yml
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
brief:
|
||||
version: "2"
|
||||
6
examples/consumers/testdata/ordinary-imports/config.yml
vendored
Normal file
6
examples/consumers/testdata/ordinary-imports/config.yml
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
configkit:
|
||||
imports:
|
||||
- path: metadata.yml
|
||||
- path: prompts/brief.yml
|
||||
destination: [prompts]
|
||||
name: Root import example
|
||||
2
examples/consumers/testdata/ordinary-imports/metadata.yml
vendored
Normal file
2
examples/consumers/testdata/ordinary-imports/metadata.yml
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
version: "1.0"
|
||||
description: Metadata imported into the root document.
|
||||
2
examples/consumers/testdata/ordinary-imports/prompts/brief.yml
vendored
Normal file
2
examples/consumers/testdata/ordinary-imports/prompts/brief.yml
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
brief:
|
||||
summary: A root-loaded prompt.
|
||||
15
examples/consumers/testdata/prompt-catalog/config.yml
vendored
Normal file
15
examples/consumers/testdata/prompt-catalog/config.yml
vendored
Normal file
@@ -0,0 +1,15 @@
|
||||
configkit:
|
||||
imports:
|
||||
- dir: prompts
|
||||
exclude: [generated-ignored.yml]
|
||||
destination: [prompts]
|
||||
placement: sequence-item
|
||||
- dir: featured
|
||||
pattern: [weather-*.yml]
|
||||
destination: [prompts]
|
||||
placement: sequence-item
|
||||
- dir: optional
|
||||
prompts:
|
||||
- id: main
|
||||
name: Main authored prompt
|
||||
messages: [Use the main prompt.]
|
||||
4
examples/consumers/testdata/prompt-catalog/featured/weather-feature.yml
vendored
Normal file
4
examples/consumers/testdata/prompt-catalog/featured/weather-feature.yml
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
id: weather
|
||||
name: Weather forecast
|
||||
messages:
|
||||
- Describe today's forecast.
|
||||
4
examples/consumers/testdata/prompt-catalog/invalid.yml
vendored
Normal file
4
examples/consumers/testdata/prompt-catalog/invalid.yml
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
prompts:
|
||||
- id: invalid
|
||||
name: Invalid prompt
|
||||
messages: []
|
||||
1
examples/consumers/testdata/prompt-catalog/optional/ignored.txt
vendored
Normal file
1
examples/consumers/testdata/prompt-catalog/optional/ignored.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
This file does not match the application import defaults.
|
||||
6
examples/consumers/testdata/prompt-catalog/prompts/curated.yml
vendored
Normal file
6
examples/consumers/testdata/prompt-catalog/prompts/curated.yml
vendored
Normal file
@@ -0,0 +1,6 @@
|
||||
configkit:
|
||||
imports:
|
||||
- path: helpers/curated-id.yml
|
||||
name: Curated summary
|
||||
messages:
|
||||
- Summarize the supplied content.
|
||||
3
examples/consumers/testdata/prompt-catalog/prompts/generated-ignored.yml
vendored
Normal file
3
examples/consumers/testdata/prompt-catalog/prompts/generated-ignored.yml
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
id: ignored
|
||||
name: Excluded generated prompt
|
||||
messages: [This record is excluded.]
|
||||
5
examples/consumers/testdata/prompt-catalog/prompts/generated-welcome.yml
vendored
Normal file
5
examples/consumers/testdata/prompt-catalog/prompts/generated-welcome.yml
vendored
Normal file
@@ -0,0 +1,5 @@
|
||||
id: welcome
|
||||
name: Welcome
|
||||
messages:
|
||||
- Welcome the user.
|
||||
resource: welcome.txt
|
||||
1
examples/consumers/testdata/prompt-catalog/prompts/helpers/curated-id.yml
vendored
Normal file
1
examples/consumers/testdata/prompt-catalog/prompts/helpers/curated-id.yml
vendored
Normal file
@@ -0,0 +1 @@
|
||||
id: curated
|
||||
3
examples/consumers/testdata/prompt-catalog/prompts/required.yml
vendored
Normal file
3
examples/consumers/testdata/prompt-catalog/prompts/required.yml
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
id: required
|
||||
name: Required prompt
|
||||
messages: [Use the required prompt.]
|
||||
1
examples/consumers/testdata/prompt-catalog/prompts/welcome.txt
vendored
Normal file
1
examples/consumers/testdata/prompt-catalog/prompts/welcome.txt
vendored
Normal file
@@ -0,0 +1 @@
|
||||
Welcome resource text.
|
||||
7
examples/consumers/testdata/prompt-catalog/simple.yml
vendored
Normal file
7
examples/consumers/testdata/prompt-catalog/simple.yml
vendored
Normal file
@@ -0,0 +1,7 @@
|
||||
configkit:
|
||||
imports:
|
||||
- path: prompts/required.yml
|
||||
required: true
|
||||
destination: [prompts]
|
||||
placement: sequence-item
|
||||
prompts: []
|
||||
4
examples/consumers/testdata/source-version/main.yml
vendored
Normal file
4
examples/consumers/testdata/source-version/main.yml
vendored
Normal file
@@ -0,0 +1,4 @@
|
||||
version: service-v2
|
||||
name: example service
|
||||
metadata:
|
||||
release: application-2026.10
|
||||
2
examples/consumers/testdata/source-version/underlay.yml
vendored
Normal file
2
examples/consumers/testdata/source-version/underlay.yml
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
version: catalog-v1
|
||||
endpoint: https://service.example.invalid
|
||||
705
facade_destination_test.go
Normal file
705
facade_destination_test.go
Normal file
@@ -0,0 +1,705 @@
|
||||
package configkit_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
func TestFacadeInputDestinationsAcrossSourceKinds(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
hostPath := filepath.Join(directory, "host.yml")
|
||||
if err := os.WriteFile(hostPath, []byte("value: host\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
native := configkit.NewFilesystem("native", fstest.MapFS{
|
||||
"main.yml": &fstest.MapFile{Data: []byte("value: native\n")},
|
||||
})
|
||||
const literalValuePath = "/collections/weather.reporter/profiles/a~1b/~0/0"
|
||||
destination := configkit.Keys("collections", "weather.reporter", "profiles", "a/b", "~", "0")
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
source configkit.Source
|
||||
want string
|
||||
kind configkit.SourceKind
|
||||
}{
|
||||
{name: "host file", source: configkit.File(hostPath), want: "host", kind: configkit.SourceKindFile},
|
||||
{name: "native filesystem", source: native.File("main.yml"), want: "native", kind: configkit.SourceKindFilesystem},
|
||||
{name: "bytes", source: configkit.Bytes("bytes", []byte("value: bytes\n")), want: "bytes", kind: configkit.SourceKindBytes},
|
||||
{name: "reader", source: configkit.Reader("reader", strings.NewReader("value: reader\n")), want: "reader", kind: configkit.SourceKindReader},
|
||||
{name: "mapping", source: configkit.Mapping("mapping", map[string]any{"value": "mapping"}), want: "mapping", kind: configkit.SourceKindMapping},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
result, err := configkit.LoadLayered[map[string]any](configkit.Input{Source: test.source, Destination: destination})
|
||||
if err != nil {
|
||||
t.Fatalf("LoadLayered: %v", err)
|
||||
}
|
||||
if got := facadeDestinationValue(t, result.Config, "collections", "weather.reporter", "profiles", "a/b", "~", "0", "value"); got != test.want {
|
||||
t.Fatalf("placed value = %#v, want %q", got, test.want)
|
||||
}
|
||||
sources := result.Sources()
|
||||
if len(sources) != 1 || sources[0].Kind != test.kind || sources[0].Destination.String() != destination.String() || destination.String() != literalValuePath {
|
||||
t.Fatalf("source placement = %#v, destination = %q", sources, destination.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeDestinationRootEmptyMappingNoSourceAndInvalidPaths(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
destination configkit.Path
|
||||
}{
|
||||
{name: "omitted destination"},
|
||||
{name: "explicit empty destination", destination: configkit.Path{}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
result, err := configkit.LoadLayered[map[string]any](configkit.Input{Source: configkit.Bytes("root", []byte("value: root\n")), Destination: test.destination})
|
||||
if err != nil {
|
||||
t.Fatalf("root load: %v", err)
|
||||
}
|
||||
if result.Config["value"] != "root" || len(result.Sources()) != 1 || result.Sources()[0].Destination.String() != "" {
|
||||
t.Fatalf("root placement = %#v, sources %#v", result.Config, result.Sources())
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
emptyPath := configkit.Keys("collections", "empty")
|
||||
empty, err := configkit.LoadLayered[map[string]any](configkit.Input{
|
||||
Source: configkit.Mapping("empty", map[string]any{}), Destination: emptyPath,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("empty mapping load: %v", err)
|
||||
}
|
||||
if value := facadeDestinationMap(t, empty.Config, "collections")["empty"]; value == nil {
|
||||
t.Fatal("empty mapping did not materialize its destination")
|
||||
}
|
||||
if info, ok := empty.Metadata().Lookup(emptyPath); !ok || !info.Explicit {
|
||||
t.Fatalf("empty destination metadata = %#v, %v", info, ok)
|
||||
}
|
||||
|
||||
absentPath := configkit.Keys("collections", "absent")
|
||||
absent, err := configkit.LoadLayered[map[string]any](configkit.Input{Source: configkit.NoSource(), Destination: absentPath})
|
||||
if err != nil {
|
||||
t.Fatalf("NoSource with a destination: %v", err)
|
||||
}
|
||||
if len(absent.Sources()) != 0 {
|
||||
t.Fatalf("NoSource created source records: %#v", absent.Sources())
|
||||
}
|
||||
if _, ok := absent.Metadata().Lookup(absentPath); ok {
|
||||
t.Fatal("NoSource materialized its destination")
|
||||
}
|
||||
|
||||
reader := &destinationCountingReader{}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
source configkit.Source
|
||||
destination configkit.Path
|
||||
wantReads int
|
||||
}{
|
||||
{name: "index segment", source: configkit.Reader("unread", reader), destination: configkit.Keys("catalog").Index(0)},
|
||||
{name: "reserved configkit key", source: configkit.NoSource(), destination: configkit.Keys("configkit")},
|
||||
{name: "reserved extends key", source: configkit.NoSource(), destination: configkit.Keys("catalog", "extends")},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
_, err := configkit.LoadLayered[map[string]any](configkit.Input{Source: test.source, Destination: test.destination})
|
||||
loadError, ok := err.(*configkit.LoadError)
|
||||
if !ok || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != configkit.CodeOptionsInvalid {
|
||||
t.Fatalf("invalid destination error = %#v", err)
|
||||
}
|
||||
if reader.reads != test.wantReads {
|
||||
t.Fatalf("reader calls = %d, want %d", reader.reads, test.wantReads)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeImportedSourcesRetainInputDestination(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := filepath.Join(directory, "main.yml")
|
||||
fragmentPath := filepath.Join(directory, "fragment.yml")
|
||||
if err := os.WriteFile(mainPath, []byte("configkit: {imports: [{path: fragment.yml}]}\nmain: present\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(fragmentPath, []byte("imported: present\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
destination := configkit.Keys("collections", "weatherreporter")
|
||||
result, err := configkit.LoadLayered[map[string]any](configkit.Input{
|
||||
Source: configkit.File(mainPath), Imports: true, Destination: destination,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("mounted import load: %v", err)
|
||||
}
|
||||
collection := facadeDestinationMap(t, result.Config, "collections", "weatherreporter")
|
||||
if collection["main"] != "present" || collection["imported"] != "present" {
|
||||
t.Fatalf("mounted imported values = %#v", collection)
|
||||
}
|
||||
sources := result.Sources()
|
||||
if len(sources) != 2 || sources[0].Destination.String() != destination.String() || sources[1].Destination.String() != destination.String() {
|
||||
t.Fatalf("mounted import source destinations = %#v", sources)
|
||||
}
|
||||
if sources[1].ImportedFrom == nil {
|
||||
t.Fatalf("imported source lacks its authored directive: %#v", sources[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeRelativeImportDestinationsForRootAndMountedLoads(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
write := func(relative, contents string) string {
|
||||
t.Helper()
|
||||
filename := filepath.Join(directory, filepath.FromSlash(relative))
|
||||
if err := os.MkdirAll(filepath.Dir(filename), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filename, []byte(contents), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return filename
|
||||
}
|
||||
|
||||
mainPath := write("main.yml", `configkit:
|
||||
imports:
|
||||
- path: metadata.yml
|
||||
destination: [metadata]
|
||||
- glob: prompts/*.yml
|
||||
destination: [prompts]
|
||||
- path: entries/main.yml
|
||||
destination: [profiles]
|
||||
- path: sibling.yml
|
||||
destination: [sibling]
|
||||
root: main
|
||||
`)
|
||||
write("metadata.yml", "name: imported\n")
|
||||
write("prompts/a.yml", "a: {name: alpha}\n")
|
||||
write("prompts/b.yml", "b: {name: beta}\n")
|
||||
write("entries/main.yml", `configkit:
|
||||
imports:
|
||||
- path: nested/details.yml
|
||||
destination: [details]
|
||||
name: profile
|
||||
`)
|
||||
write("entries/nested/details.yml", "summary: nested\n")
|
||||
write("sibling.yml", "name: separate\n")
|
||||
|
||||
root, err := configkit.Load(
|
||||
configkit.File(mainPath), configkit.Options[map[string]any]{Imports: true},
|
||||
)
|
||||
if err != nil {
|
||||
if loadError, ok := err.(*configkit.LoadError); ok {
|
||||
t.Fatalf("ordinary root Load: %v, diagnostics %#v", err, loadError.Diagnostics())
|
||||
}
|
||||
t.Fatalf("ordinary root Load: %v", err)
|
||||
}
|
||||
mountedDestination := configkit.Keys("collections", "weatherreporter")
|
||||
mounted, err := configkit.LoadLayered[map[string]any](configkit.Input{
|
||||
Source: configkit.File(mainPath), Imports: true, Destination: mountedDestination,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("mounted layered Load: %v", err)
|
||||
}
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
result *configkit.Result[map[string]any]
|
||||
prefix []string
|
||||
}{
|
||||
{name: "ordinary root", result: root},
|
||||
{name: "mounted entry", result: mounted, prefix: []string{"collections", "weatherreporter"}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
at := func(keys ...string) []string {
|
||||
return append(append([]string(nil), test.prefix...), keys...)
|
||||
}
|
||||
if got := facadeDestinationValue(t, test.result.Config, at("root")...); got != "main" {
|
||||
t.Fatalf("root value = %#v", got)
|
||||
}
|
||||
if got := facadeDestinationValue(t, test.result.Config, at("metadata", "name")...); got != "imported" {
|
||||
t.Fatalf("metadata value = %#v", got)
|
||||
}
|
||||
if got := facadeDestinationValue(t, test.result.Config, at("prompts", "a", "name")...); got != "alpha" {
|
||||
t.Fatalf("first glob value = %#v", got)
|
||||
}
|
||||
if got := facadeDestinationValue(t, test.result.Config, at("prompts", "b", "name")...); got != "beta" {
|
||||
t.Fatalf("second glob value = %#v", got)
|
||||
}
|
||||
if got := facadeDestinationValue(t, test.result.Config, at("profiles", "name")...); got != "profile" {
|
||||
t.Fatalf("entry value = %#v", got)
|
||||
}
|
||||
if got := facadeDestinationValue(t, test.result.Config, at("profiles", "details", "summary")...); got != "nested" {
|
||||
t.Fatalf("nested import value = %#v", got)
|
||||
}
|
||||
if got := facadeDestinationValue(t, test.result.Config, at("sibling", "name")...); got != "separate" {
|
||||
t.Fatalf("subsequent sibling value = %#v", got)
|
||||
}
|
||||
|
||||
wantPaths := []string{
|
||||
"main.yml", "metadata.yml", "a.yml", "b.yml", "main.yml", "details.yml", "sibling.yml",
|
||||
}
|
||||
wantPhysicalPaths := []string{
|
||||
mainPath,
|
||||
filepath.Join(directory, "metadata.yml"),
|
||||
filepath.Join(directory, "prompts", "a.yml"),
|
||||
filepath.Join(directory, "prompts", "b.yml"),
|
||||
filepath.Join(directory, "entries", "main.yml"),
|
||||
filepath.Join(directory, "entries", "nested", "details.yml"),
|
||||
filepath.Join(directory, "sibling.yml"),
|
||||
}
|
||||
wantImportParents := []string{"", "source-1", "source-1", "source-1", "source-1", "source-5", "source-1"}
|
||||
wantDestinations := []configkit.Path{configkit.Keys(test.prefix...)}
|
||||
for _, suffix := range [][]string{{"metadata"}, {"prompts"}, {"prompts"}, {"profiles"}, {"profiles", "details"}, {"sibling"}} {
|
||||
destination := append([]string(nil), test.prefix...)
|
||||
for _, segment := range suffix {
|
||||
destination = append(destination, segment)
|
||||
}
|
||||
wantDestinations = append(wantDestinations, configkit.Keys(destination...))
|
||||
}
|
||||
sources := test.result.Sources()
|
||||
if len(sources) != len(wantPaths) {
|
||||
t.Fatalf("sources = %#v, want %d", sources, len(wantPaths))
|
||||
}
|
||||
for index, source := range sources {
|
||||
if filepath.Base(source.Path) != wantPaths[index] || source.Path != wantPhysicalPaths[index] || source.Destination.String() != wantDestinations[index].String() {
|
||||
t.Errorf("source %d path/destination = (%q, %q), want physical path %q and destination %q", index, source.Path, source.Destination.String(), wantPhysicalPaths[index], wantDestinations[index].String())
|
||||
}
|
||||
if source.ID != fmt.Sprintf("source-%d", index+1) || source.Kind != configkit.SourceKindFile {
|
||||
t.Errorf("source %d identity/kind = (%q, %q)", index, source.ID, source.Kind)
|
||||
}
|
||||
if wantImportParents[index] == "" {
|
||||
if source.ImportedFrom != nil {
|
||||
t.Errorf("entry source unexpectedly has an import location: %#v", source.ImportedFrom)
|
||||
}
|
||||
} else if source.ImportedFrom == nil || source.ImportedFrom.SourceID != wantImportParents[index] || source.ImportedFrom.Line == 0 || source.ImportedFrom.Column == 0 {
|
||||
t.Errorf("source %d import location = %#v, want declaration in %s", index, source.ImportedFrom, wantImportParents[index])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeSameFragmentMountsAtSeparateCollectionDestinations(t *testing.T) {
|
||||
weatherDestination := configkit.Keys("collections", "weather", "prompts")
|
||||
homeDestination := configkit.Keys("collections", "homewatch", "prompts")
|
||||
result, err := configkit.LoadLayered(
|
||||
configkit.Input{Source: configkit.NoSource()},
|
||||
configkit.LayeredOptions[map[string]any]{Underlays: []configkit.Underlay{{Inputs: []configkit.Input{
|
||||
{Source: configkit.Bytes("weather", []byte("summarize.alerts: {title: weather}\n")), Destination: weatherDestination},
|
||||
{Source: configkit.Bytes("homewatch", []byte("summarize.alerts: {title: home}\n")), Destination: homeDestination},
|
||||
}}}},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadLayered: %v", err)
|
||||
}
|
||||
for _, test := range []struct {
|
||||
path []string
|
||||
want string
|
||||
}{
|
||||
{path: []string{"collections", "weather", "prompts", "summarize.alerts", "title"}, want: "weather"},
|
||||
{path: []string{"collections", "homewatch", "prompts", "summarize.alerts", "title"}, want: "home"},
|
||||
} {
|
||||
if got := facadeDestinationValue(t, result.Config, test.path...); got != test.want {
|
||||
t.Errorf("value at %v = %#v, want %q", test.path, got, test.want)
|
||||
}
|
||||
}
|
||||
sources := result.Sources()
|
||||
if len(sources) != 2 || sources[0].Destination.String() != weatherDestination.String() || sources[1].Destination.String() != homeDestination.String() {
|
||||
t.Fatalf("independent collection source placements = %#v", sources)
|
||||
}
|
||||
}
|
||||
|
||||
type destinationCountingReader struct{ reads int }
|
||||
|
||||
func (reader *destinationCountingReader) Read([]byte) (int, error) {
|
||||
reader.reads++
|
||||
return 0, io.EOF
|
||||
}
|
||||
|
||||
func TestFacadeMountedMainAndUnderlayInspectionResourcesAndDiff(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
filesystem := configkit.NewFilesystem("resources", fstest.MapFS{
|
||||
"assets/summary.md": &fstest.MapFile{Data: []byte("source-relative summary")},
|
||||
})
|
||||
context := &configkit.FilesystemContext{Root: ".", BaseDir: "assets", FS: &filesystem}
|
||||
destination := configkit.Keys("catalog", "entries")
|
||||
tokenPath := configkit.Keys("catalog", "entries", "item", "token")
|
||||
titlePath := configkit.Keys("catalog", "entries", "item", "title")
|
||||
var callbacks struct{ resolve, validate int }
|
||||
load := func(token string) *configkit.Result[map[string]any] {
|
||||
t.Helper()
|
||||
main := configkit.Input{
|
||||
Source: configkit.Bytes("main", []byte("item:\n title: main\n resource: summary.md\n token: "+token+"\n")),
|
||||
Destination: destination, Filesystem: context,
|
||||
}
|
||||
underlay := configkit.Input{
|
||||
Source: configkit.Bytes("underlay", []byte("item:\n title: lower\n retained: lower-value\n token: LOWER-TOKEN-CANARY\n")),
|
||||
Destination: destination,
|
||||
}
|
||||
result, err := configkit.LoadLayered(main, configkit.LayeredOptions[map[string]any]{
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{underlay}}},
|
||||
Definition: configkit.Definition[map[string]any]{
|
||||
Resolve: func(configuration *map[string]any, resolution *configkit.Resolution) error {
|
||||
callbacks.resolve++
|
||||
resourcePath := configkit.Keys("catalog", "entries", "item", "resource")
|
||||
input, ok := resolution.InputInfo(resourcePath)
|
||||
if !ok || len(input.Origin.Locations) != 1 {
|
||||
return fmt.Errorf("mounted resource input has no single source origin")
|
||||
}
|
||||
source, ok := resolution.Source(input.Origin.Locations[0].SourceID)
|
||||
if !ok || source.Destination.String() != destination.String() {
|
||||
return fmt.Errorf("resolution source did not retain its destination")
|
||||
}
|
||||
resourceContext, ok := resolution.SourceContext(source.ID)
|
||||
if !ok {
|
||||
return fmt.Errorf("resolution source context is unavailable")
|
||||
}
|
||||
data, err := fs.ReadFile(resourceContext.FS, path.Join(resourceContext.BaseDir, "summary.md"))
|
||||
if err != nil {
|
||||
return fmt.Errorf("read source-relative resource: %w", err)
|
||||
}
|
||||
entry := facadeDestinationMap(t, facadeDestinationMap(t, facadeDestinationMap(t, *configuration, "catalog"), "entries"), "item")
|
||||
entry["resource_text"] = string(data)
|
||||
resolution.Record(configkit.Keys("catalog", "entries", "item", "resource_text"), configkit.ChangeNormalization, "read source-relative summary", resourcePath)
|
||||
return nil
|
||||
},
|
||||
Validate: func(*map[string]any, *configkit.Validation) { callbacks.validate++ },
|
||||
Secret: func(candidate configkit.Path) bool { return candidate.String() == tokenPath.String() },
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("mounted load: %v", err)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
first := load("FIRST-TOKEN-CANARY")
|
||||
second := load("SECOND-TOKEN-CANARY")
|
||||
if callbacks.resolve != 2 || callbacks.validate != 2 {
|
||||
t.Fatalf("lifecycle callbacks = %#v", callbacks)
|
||||
}
|
||||
for _, result := range []*configkit.Result[map[string]any]{first, second} {
|
||||
entry := facadeDestinationMap(t, facadeDestinationMap(t, facadeDestinationMap(t, result.Config, "catalog"), "entries"), "item")
|
||||
if entry["title"] != "main" || entry["retained"] != "lower-value" || entry["resource_text"] != "source-relative summary" {
|
||||
t.Fatalf("mounted main/underlay configuration = %#v", entry)
|
||||
}
|
||||
sources := result.Sources()
|
||||
if len(sources) != 2 || sources[0].Destination.String() != destination.String() || sources[1].Destination.String() != destination.String() {
|
||||
t.Fatalf("public source destinations = %#v", sources)
|
||||
}
|
||||
resource, ok := result.SourceContext(sources[0].ID)
|
||||
if !ok || resource.BaseDir != "assets" {
|
||||
t.Fatalf("completed source context = %#v, %v", resource, ok)
|
||||
}
|
||||
data, err := fs.ReadFile(resource.FS, path.Join(resource.BaseDir, "summary.md"))
|
||||
if err != nil || string(data) != "source-relative summary" {
|
||||
t.Fatalf("completed relative resource = %q, %v", data, err)
|
||||
}
|
||||
if info, ok := result.Metadata().Lookup(titlePath); !ok || info.Origin.Layer != configkit.LayerBase {
|
||||
t.Fatalf("mounted title metadata = %#v, %v", info, ok)
|
||||
}
|
||||
if history := result.Metadata().History(titlePath); len(history) < 2 {
|
||||
t.Fatalf("mounted title history = %#v", history)
|
||||
}
|
||||
if explanation, ok := result.Explain(tokenPath); !ok || !explanation.Effective.Redacted {
|
||||
t.Fatalf("mounted secret explanation = %#v, %v", explanation, ok)
|
||||
}
|
||||
var effective, explanation bytes.Buffer
|
||||
if err := result.WriteEffective(&effective); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := result.WriteExplanation(&explanation, tokenPath); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
output := effective.String() + explanation.String()
|
||||
for _, canary := range []string{"FIRST-TOKEN-CANARY", "SECOND-TOKEN-CANARY", "LOWER-TOKEN-CANARY"} {
|
||||
if strings.Contains(output, canary) {
|
||||
t.Fatalf("public inspection leaked %q: %s", canary, output)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Mutating published records and query copies must not alter the retained result.
|
||||
returnedSources := first.Sources()
|
||||
returnedSources[0].Destination = configkit.Keys("mutated")
|
||||
if got := first.Sources()[0].Destination.String(); got != destination.String() {
|
||||
t.Fatalf("source destination changed after caller mutation: %q", got)
|
||||
}
|
||||
info, ok := first.Metadata().Lookup(titlePath)
|
||||
if !ok {
|
||||
t.Fatal("mounted metadata lookup failed")
|
||||
}
|
||||
info.Origin.Locations[0].SourceID = "mutated"
|
||||
if fresh, ok := first.Metadata().Lookup(titlePath); !ok || fresh.Origin.Locations[0].SourceID == "mutated" {
|
||||
t.Fatalf("metadata copy mutation escaped: %#v, %v", fresh, ok)
|
||||
}
|
||||
history := first.Metadata().History(titlePath)
|
||||
if len(history) == 0 {
|
||||
t.Fatal("mounted history is empty")
|
||||
}
|
||||
history[0].Path = configkit.Keys("mutated")
|
||||
if fresh := first.Metadata().History(titlePath); len(fresh) == 0 || fresh[0].Path.String() != titlePath.String() {
|
||||
t.Fatalf("history copy mutation escaped: %#v", fresh)
|
||||
}
|
||||
|
||||
differences, err := configkit.Diff(first, second)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(differences) != 1 || differences[0].Path.String() != tokenPath.String() ||
|
||||
!differences[0].Left.Redacted || !differences[0].Right.Redacted ||
|
||||
strings.Contains(differences[0].Left.YAML+differences[0].Right.YAML, "TOKEN-CANARY") {
|
||||
t.Fatalf("public mounted secret diff = %#v", differences)
|
||||
}
|
||||
if callbacks.resolve != 2 || callbacks.validate != 2 {
|
||||
t.Fatalf("inspection reran lifecycle callbacks: %#v", callbacks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeFailedLoadExposesDetachedSourceDestination(t *testing.T) {
|
||||
type configuration struct {
|
||||
Tenant struct {
|
||||
Count int `yaml:"count"`
|
||||
} `yaml:"tenant"`
|
||||
}
|
||||
destination := configkit.Keys("tenant")
|
||||
_, err := configkit.LoadLayered[configuration](configkit.Input{
|
||||
Source: configkit.Bytes("invalid", []byte("count: not-an-integer\n")), Destination: destination,
|
||||
})
|
||||
loadError, ok := err.(*configkit.LoadError)
|
||||
if !ok {
|
||||
t.Fatalf("failed load error = %T %v", err, err)
|
||||
}
|
||||
sources := loadError.Sources()
|
||||
if len(sources) != 1 || sources[0].Destination.String() != destination.String() {
|
||||
t.Fatalf("failed source destinations = %#v", sources)
|
||||
}
|
||||
sources[0].Destination = configkit.Keys("caller-change")
|
||||
if fresh := loadError.Sources(); len(fresh) != 1 || fresh[0].Destination.String() != destination.String() {
|
||||
t.Fatalf("failed source destination was not detached: %#v", fresh)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeRelativeImportFailuresRetainSourceRecordsAndCoordinates(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
write := func(name, contents string) string {
|
||||
t.Helper()
|
||||
filename := filepath.Join(directory, name)
|
||||
if err := os.MkdirAll(filepath.Dir(filename), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filename, []byte(contents), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return filename
|
||||
}
|
||||
|
||||
t.Run("imported parse failure retains effective source destination", func(t *testing.T) {
|
||||
mainPath := write("parse/main.yml", "configkit:\n imports:\n - path: child.yml\n destination: [tenant]\n")
|
||||
childPath := write("parse/child.yml", "count: [unterminated\n")
|
||||
_, err := configkit.LoadLayered[map[string]any](configkit.Input{
|
||||
Source: configkit.File(mainPath), Imports: true, Destination: configkit.Keys("mount"),
|
||||
})
|
||||
var loadError *configkit.LoadError
|
||||
if !errors.As(err, &loadError) {
|
||||
t.Fatalf("load error = %T %v", err, err)
|
||||
}
|
||||
diagnostics := loadError.Diagnostics()
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeYAMLInvalid || diagnostics[0].Primary == nil || diagnostics[0].Primary.SourceID != "source-2" {
|
||||
t.Fatalf("imported parse diagnostic = %#v", diagnostics)
|
||||
}
|
||||
sources := loadError.Sources()
|
||||
if len(sources) != 2 || sources[0].Destination.String() != "/mount" || sources[1].Destination.String() != "/mount/tenant" || sources[1].Path != childPath {
|
||||
t.Fatalf("imported parse failure sources = %#v", sources)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("directive failure stays source local", func(t *testing.T) {
|
||||
mainPath := write("directive/main.yml", "configkit:\n imports:\n - path: parent.yml\n destination: [tenant]\n")
|
||||
parentPath := write("directive/parent.yml", "configkit:\n imports:\n - path: details.yml\n destination: [3]\n")
|
||||
_, err := configkit.LoadLayered[map[string]any](configkit.Input{
|
||||
Source: configkit.File(mainPath), Imports: true, Destination: configkit.Keys("mount"),
|
||||
})
|
||||
var loadError *configkit.LoadError
|
||||
if !errors.As(err, &loadError) {
|
||||
t.Fatalf("load error = %T %v", err, err)
|
||||
}
|
||||
diagnostics := loadError.Diagnostics()
|
||||
wantPath := configkit.Keys("configkit", "imports").Index(0).Key("destination").Index(0)
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeImportInvalid || !reflect.DeepEqual(diagnostics[0].Path, wantPath) || diagnostics[0].Primary == nil || diagnostics[0].Primary.SourceID != "source-2" {
|
||||
t.Fatalf("import directive diagnostic = %#v", diagnostics)
|
||||
}
|
||||
sources := loadError.Sources()
|
||||
if len(sources) != 2 || sources[0].Destination.String() != "/mount" || sources[1].Destination.String() != "/mount/tenant" || sources[1].Path != parentPath {
|
||||
t.Fatalf("directive failure sources = %#v", sources)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("composition failure reports effective path and all sources", func(t *testing.T) {
|
||||
mainPath := write("compose/main.yml", "configkit:\n imports:\n - path: left.yml\n destination: [tenant]\n - path: right.yml\n destination: [tenant]\n")
|
||||
write("compose/left.yml", "value: same\n")
|
||||
write("compose/right.yml", "value: same\n")
|
||||
_, err := configkit.LoadLayered[map[string]any](configkit.Input{
|
||||
Source: configkit.File(mainPath), Imports: true, Destination: configkit.Keys("mount"),
|
||||
})
|
||||
var loadError *configkit.LoadError
|
||||
if !errors.As(err, &loadError) {
|
||||
t.Fatalf("load error = %T %v", err, err)
|
||||
}
|
||||
diagnostics := loadError.Diagnostics()
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeComposeConflict || diagnostics[0].Path.String() != "/mount/tenant/value" {
|
||||
t.Fatalf("composition diagnostic = %#v", diagnostics)
|
||||
}
|
||||
sources := loadError.Sources()
|
||||
if len(sources) != 3 || sources[0].Destination.String() != "/mount" || sources[1].Destination.String() != "/mount/tenant" || sources[2].Destination.String() != "/mount/tenant" {
|
||||
t.Fatalf("composition failure sources = %#v", sources)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("version failure reports effective path and imported source", func(t *testing.T) {
|
||||
mainPath := write("version/main.yml", "configkit:\n imports:\n - path: child.yml\n destination: [tenant]\n")
|
||||
childPath := write("version/child.yml", "version: forbidden\n")
|
||||
_, err := configkit.LoadLayered[map[string]any](configkit.Input{
|
||||
Source: configkit.File(mainPath), Imports: true, Destination: configkit.Keys("mount"),
|
||||
}, configkit.LayeredOptions[map[string]any]{
|
||||
Definition: configkit.Definition[map[string]any]{Version: &configkit.VersionRule{
|
||||
Path: configkit.Keys("mount", "tenant", "version"),
|
||||
Check: func(configkit.Version, *configkit.Validation) error { return nil },
|
||||
}},
|
||||
})
|
||||
var loadError *configkit.LoadError
|
||||
if !errors.As(err, &loadError) {
|
||||
t.Fatalf("load error = %T %v", err, err)
|
||||
}
|
||||
diagnostics := loadError.Diagnostics()
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeImportVersion || diagnostics[0].Path.String() != "/mount/tenant/version" || diagnostics[0].Primary == nil || diagnostics[0].Primary.SourceID != "source-2" {
|
||||
t.Fatalf("version diagnostic = %#v", diagnostics)
|
||||
}
|
||||
sources := loadError.Sources()
|
||||
if len(sources) != 2 || sources[0].Destination.String() != "/mount" || sources[1].Destination.String() != "/mount/tenant" || sources[1].Path != childPath {
|
||||
t.Fatalf("version failure sources = %#v", sources)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("typed validation reports effective path and authored location", func(t *testing.T) {
|
||||
type configuration struct {
|
||||
Mount struct {
|
||||
Tenant struct {
|
||||
Count int `yaml:"count"`
|
||||
} `yaml:"tenant"`
|
||||
} `yaml:"mount"`
|
||||
}
|
||||
mainPath := write("typed/main.yml", "configkit:\n imports:\n - path: child.yml\n destination: [tenant]\n")
|
||||
childPath := write("typed/child.yml", "count: invalid\n")
|
||||
_, err := configkit.LoadLayered[configuration](configkit.Input{
|
||||
Source: configkit.File(mainPath), Imports: true, Destination: configkit.Keys("mount"),
|
||||
})
|
||||
var loadError *configkit.LoadError
|
||||
if !errors.As(err, &loadError) {
|
||||
t.Fatalf("load error = %T %v", err, err)
|
||||
}
|
||||
diagnostics := loadError.Diagnostics()
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeDecodeType || diagnostics[0].Path.String() != "/mount/tenant/count" || diagnostics[0].Primary == nil || diagnostics[0].Primary.SourceID != "source-2" || diagnostics[0].Primary.Line != 1 {
|
||||
t.Fatalf("typed validation diagnostic = %#v", diagnostics)
|
||||
}
|
||||
sources := loadError.Sources()
|
||||
if len(sources) != 2 || sources[0].Destination.String() != "/mount" || sources[1].Destination.String() != "/mount/tenant" || sources[1].Path != childPath {
|
||||
t.Fatalf("typed validation sources = %#v", sources)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestFacadeDestinationMappingFailurePaths(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cycle := map[string]any{}
|
||||
cycle["self"] = cycle
|
||||
for _, fixture := range []struct {
|
||||
name string
|
||||
values map[string]any
|
||||
path func(configkit.Path) configkit.Path
|
||||
}{
|
||||
{
|
||||
name: "unsupported nested sequence value",
|
||||
values: map[string]any{"a/b": []any{map[string]any{"~": func() {}}}},
|
||||
path: func(base configkit.Path) configkit.Path { return base.Key("a/b").Index(0).Key("~") },
|
||||
},
|
||||
{
|
||||
name: "cyclic value",
|
||||
values: map[string]any{"item": cycle},
|
||||
path: func(base configkit.Path) configkit.Path { return base.Key("item").Key("self") },
|
||||
},
|
||||
{
|
||||
name: "constructor failure keeps source coordinates",
|
||||
path: func(configkit.Path) configkit.Path { return configkit.Path{} },
|
||||
},
|
||||
} {
|
||||
t.Run(fixture.name, func(t *testing.T) {
|
||||
for _, role := range []string{"root", "mounted main", "mounted underlay"} {
|
||||
t.Run(role, func(t *testing.T) {
|
||||
var destination configkit.Path
|
||||
if role != "root" {
|
||||
destination = configkit.Keys("catalog", "entries")
|
||||
}
|
||||
wantPath := fixture.path(destination)
|
||||
input := configkit.Input{Source: configkit.Mapping("invalid mapping", fixture.values), Destination: destination}
|
||||
var result *configkit.Result[map[string]any]
|
||||
var err error
|
||||
if role == "mounted underlay" {
|
||||
result, err = configkit.LoadLayered(configkit.Input{Source: configkit.NoSource()}, configkit.LayeredOptions[map[string]any]{
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{input}}},
|
||||
})
|
||||
} else {
|
||||
result, err = configkit.LoadLayered[map[string]any](input)
|
||||
}
|
||||
var loadError *configkit.LoadError
|
||||
if result != nil || !errors.As(err, &loadError) {
|
||||
t.Fatalf("failed load = %#v, %v", result, err)
|
||||
}
|
||||
diagnostics := loadError.Diagnostics()
|
||||
if len(diagnostics) != 1 {
|
||||
t.Fatalf("diagnostics = %#v", diagnostics)
|
||||
}
|
||||
diagnostic := diagnostics[0]
|
||||
if diagnostic.Code != configkit.CodeSourceInvalid || !reflect.DeepEqual(diagnostic.Path, wantPath) {
|
||||
t.Fatalf("diagnostic = %#v; want source.invalid at %q", diagnostic, wantPath.String())
|
||||
}
|
||||
if diagnostic.Primary == nil || *diagnostic.Primary != (configkit.Location{SourceID: "source-1"}) || len(diagnostic.Related) != 0 {
|
||||
t.Fatalf("mapping source locations = %#v, %#v", diagnostic.Primary, diagnostic.Related)
|
||||
}
|
||||
sources := loadError.Sources()
|
||||
if len(sources) != 1 || sources[0].ID != "source-1" || sources[0].Destination.String() != destination.String() {
|
||||
t.Fatalf("error sources = %#v", sources)
|
||||
}
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func facadeDestinationMap(t *testing.T, value map[string]any, keys ...string) map[string]any {
|
||||
t.Helper()
|
||||
for _, key := range keys {
|
||||
child, ok := value[key].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("%q = %#v, want mapping", key, value[key])
|
||||
}
|
||||
value = child
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func facadeDestinationValue(t *testing.T, value map[string]any, keys ...string) any {
|
||||
t.Helper()
|
||||
return facadeDestinationMap(t, value, keys[:len(keys)-1]...)[keys[len(keys)-1]]
|
||||
}
|
||||
327
facade_diff_test.go
Normal file
327
facade_diff_test.go
Normal file
@@ -0,0 +1,327 @@
|
||||
package configkit_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
type facadeDiffConfig struct {
|
||||
Value string `yaml:"value"`
|
||||
Count int `yaml:"count"`
|
||||
Items []string `yaml:"items"`
|
||||
Dynamic map[string]any `yaml:"dynamic"`
|
||||
}
|
||||
|
||||
type facadeDiffSecretConfig struct {
|
||||
Secret configkit.Secret `yaml:"secret"`
|
||||
}
|
||||
|
||||
func TestFacadeDiffKeepsPublicSequenceChangesWithHistoricalDescendants(t *testing.T) {
|
||||
type configuration struct {
|
||||
Items []any `yaml:"items"`
|
||||
}
|
||||
load := func(public string) *configkit.Result[configuration] {
|
||||
t.Helper()
|
||||
result, err := configkit.Load(configkit.Bytes("historical-sequence.yml", []byte("items: [{secret: disposable-canary}, "+public+"]\n")),
|
||||
configkit.Options[configuration]{Definition: configkit.Definition[configuration]{
|
||||
Secret: func(path configkit.Path) bool { return path.String() == "/items/0/secret" },
|
||||
Resolve: func(current *configuration, resolution *configkit.Resolution) error {
|
||||
current.Items[0] = "public scalar"
|
||||
resolution.Record(configkit.Keys("items"), configkit.ChangeNormalization, "normalize first item")
|
||||
return nil
|
||||
},
|
||||
}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return result
|
||||
}
|
||||
left, right := load("1"), load("2")
|
||||
history, ok := left.Explain(configkit.Keys("items").Index(0).Key("secret"))
|
||||
if !ok || history.Effective.Present || len(history.History) == 0 {
|
||||
t.Fatalf("removed historical secret = %#v, known=%v", history, ok)
|
||||
}
|
||||
maskedHistory := false
|
||||
for _, change := range history.History {
|
||||
maskedHistory = maskedHistory || change.Before.Redacted || change.After.Redacted
|
||||
}
|
||||
if !maskedHistory {
|
||||
t.Fatal("historical sensitivity was not retained")
|
||||
}
|
||||
differences, err := configkit.Diff(left, right)
|
||||
if err != nil || len(differences) != 1 || differences[0].Path.String() != "/items" {
|
||||
t.Fatalf("sequence difference = %#v, %v", differences, err)
|
||||
}
|
||||
difference := differences[0]
|
||||
if !difference.Left.Present || !difference.Right.Present || difference.Left.Redacted || difference.Right.Redacted || difference.HiddenChanged {
|
||||
t.Fatalf("public sequence change was concealed: %#v", difference)
|
||||
}
|
||||
if difference.Left.YAML != "- public scalar\n- 1\n" || difference.Right.YAML != "- public scalar\n- 2\n" {
|
||||
t.Fatalf("public sequence snapshots = %#v", difference)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeDiffKeepsPublicScalarAcrossHistoricalAbsence(t *testing.T) {
|
||||
type configuration struct {
|
||||
Dynamic map[string]any `yaml:"dynamic"`
|
||||
}
|
||||
options := configkit.Options[configuration]{Definition: configkit.Definition[configuration]{
|
||||
Secret: func(path configkit.Path) bool { return path.String() == "/dynamic/entry/token" },
|
||||
Resolve: func(current *configuration, resolution *configkit.Resolution) error {
|
||||
delete(current.Dynamic, "entry")
|
||||
resolution.Record(configkit.Keys("dynamic", "entry"), configkit.ChangeNormalization, "remove entry")
|
||||
return nil
|
||||
},
|
||||
}}
|
||||
absent, err := configkit.Load(configkit.Bytes("removed-entry.yml", []byte("dynamic: {entry: {token: disposable-canary}}\n")), options)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
options.Definition.Resolve = nil
|
||||
present, err := configkit.Load(configkit.Bytes("public-entry.yml", []byte("dynamic: {entry: public}\n")), options)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
left, right *configkit.Result[configuration]
|
||||
leftPresent bool
|
||||
}{
|
||||
{name: "addition", left: absent, right: present},
|
||||
{name: "removal", left: present, right: absent, leftPresent: true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
differences, err := configkit.Diff(test.left, test.right)
|
||||
if err != nil || len(differences) != 1 || differences[0].Path.String() != "/dynamic/entry" {
|
||||
t.Fatalf("entry difference = %#v, %v", differences, err)
|
||||
}
|
||||
difference := differences[0]
|
||||
if difference.Left.Present != test.leftPresent || difference.Right.Present == test.leftPresent ||
|
||||
difference.Left.Redacted || difference.Right.Redacted || difference.HiddenChanged {
|
||||
t.Fatalf("public presence change was concealed: %#v", difference)
|
||||
}
|
||||
wantLeft, wantRight := "", "public\n"
|
||||
if test.leftPresent {
|
||||
wantLeft, wantRight = wantRight, wantLeft
|
||||
}
|
||||
if difference.Left.YAML != wantLeft || difference.Right.YAML != wantRight {
|
||||
t.Fatalf("entry snapshots = %#v", difference)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeDiffReturnsDetachedCanonicalRecords(t *testing.T) {
|
||||
left, err := configkit.Load[facadeDiffConfig](configkit.Bytes("left.yml", []byte("value: before\ncount: 0\nitems: [one, two]\ndynamic: {}\n")))
|
||||
if err != nil {
|
||||
t.Fatalf("load left: %v", err)
|
||||
}
|
||||
right, err := configkit.Load[facadeDiffConfig](configkit.Bytes("right.yml", []byte("value: after\ncount: 0\nitems: [one, three]\ndynamic:\n added: null\n")))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
differences, err := configkit.Diff(left, right)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wantPaths := []string{"/dynamic/added", "/items", "/value"}
|
||||
if len(differences) != len(wantPaths) {
|
||||
t.Fatalf("differences = %#v", differences)
|
||||
}
|
||||
for index, difference := range differences {
|
||||
if got := difference.Path.String(); got != wantPaths[index] {
|
||||
t.Errorf("path[%d] = %q, want %q", index, got, wantPaths[index])
|
||||
}
|
||||
}
|
||||
if differences[0].Left.Present || !differences[0].Right.Present || differences[0].Right.YAML != "null\n" {
|
||||
t.Errorf("null/absence snapshots = %#v / %#v", differences[0].Left, differences[0].Right)
|
||||
}
|
||||
if differences[1].Left.YAML != "- one\n- two\n" || differences[1].Right.YAML != "- one\n- three\n" {
|
||||
t.Errorf("sequence snapshots = %#v / %#v", differences[1].Left, differences[1].Right)
|
||||
}
|
||||
|
||||
left.Config.Value = "caller mutation"
|
||||
left.Config.Items[0] = "caller mutation"
|
||||
right.Config.Dynamic["added"] = "caller mutation"
|
||||
differences[2].Left.YAML = "record mutation"
|
||||
differences[2].Path = configkit.Keys("rewritten")
|
||||
|
||||
again, err := configkit.Diff(left, right)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if again[2].Path.String() != "/value" || again[2].Left.YAML != "before\n" || again[1].Left.YAML != "- one\n- two\n" {
|
||||
t.Fatalf("repeat diff changed = %#v", again)
|
||||
}
|
||||
explanation, ok := left.Explain(configkit.Keys("value"))
|
||||
if !ok || explanation.Effective.YAML != "before\n" {
|
||||
t.Fatalf("frozen explanation = %#v, %v", explanation, ok)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeDiffRejectsUnavailableResults(t *testing.T) {
|
||||
type config struct {
|
||||
Value string `yaml:"value"`
|
||||
}
|
||||
loaded, err := configkit.Load[config](configkit.Bytes("valid.yml", []byte("value: valid\n")))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var nilResult *configkit.Result[config]
|
||||
zeroResult := &configkit.Result[config]{Config: config{Value: "must not be inspected"}}
|
||||
for name, left := range map[string]*configkit.Result[config]{"nil": nilResult, "zero": zeroResult} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if records, err := configkit.Diff(left, loaded); err == nil || records != nil {
|
||||
t.Fatalf("Diff = %#v, %v; want nil records and error", records, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
if records, err := configkit.Diff(loaded, nilResult); err == nil || records != nil {
|
||||
t.Fatalf("right nil Diff = %#v, %v", records, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeDiffIgnoresSourceAndResolutionHistory(t *testing.T) {
|
||||
type config struct {
|
||||
Count int `yaml:"count"`
|
||||
}
|
||||
defaults := configkit.Options[config]{Definition: configkit.Definition[config]{
|
||||
Defaults: func() config { return config{Count: 7} },
|
||||
}}
|
||||
defaulted, err := configkit.Load[config](configkit.NoSource(), defaults)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
explicit, err := configkit.Load[config](configkit.Bytes("explicit.yml", []byte("count: 7\n")))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if differences, err := configkit.Diff(defaulted, explicit); err != nil || len(differences) != 0 {
|
||||
t.Fatalf("default/source difference = %#v, %v", differences, err)
|
||||
}
|
||||
|
||||
zeroDefault, err := configkit.Load[config](configkit.NoSource(), configkit.Options[config]{Definition: configkit.Definition[config]{Defaults: func() config { return config{} }}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
explicitZero, err := configkit.Load[config](configkit.Bytes("explicit-zero.yml", []byte("count: 0\n")))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if differences, err := configkit.Diff(zeroDefault, explicitZero); err != nil || len(differences) != 0 {
|
||||
t.Fatalf("explicit/defaulted zero difference = %#v, %v", differences, err)
|
||||
}
|
||||
|
||||
resolve := func(value string) configkit.Options[config] {
|
||||
return configkit.Options[config]{Definition: configkit.Definition[config]{Resolve: func(current *config, resolution *configkit.Resolution) error {
|
||||
current.Count = len(value) + 8
|
||||
resolution.Record(configkit.Keys("count"), configkit.ChangeNormalization, "set effective count")
|
||||
return nil
|
||||
}}}
|
||||
}
|
||||
resolvedA, err := configkit.Load[config](configkit.Bytes("a.yml", []byte("{}\n")), resolve("a"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
resolvedB, err := configkit.Load[config](configkit.Bytes("b.yml", []byte("{}\n")), resolve("b"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if differences, err := configkit.Diff(resolvedA, resolvedB); err != nil || len(differences) != 0 {
|
||||
t.Fatalf("equal resolved values = %#v, %v", differences, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeDiffUsesFrozenFilesAndRunsCallbacksOnlyDuringLoad(t *testing.T) {
|
||||
type config struct {
|
||||
Value string `yaml:"value"`
|
||||
}
|
||||
calls := 0
|
||||
options := configkit.Options[config]{Definition: configkit.Definition[config]{Defaults: func() config {
|
||||
calls++
|
||||
return config{}
|
||||
}}}
|
||||
dir := t.TempDir()
|
||||
paths := []string{filepath.Join(dir, "left.yml"), filepath.Join(dir, "right.yml")}
|
||||
for index, contents := range []string{"value: left\n", "value: right\n"} {
|
||||
if err := os.WriteFile(paths[index], []byte(contents), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
left, err := configkit.Load[config](configkit.File(paths[0]), options)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
right, err := configkit.Load[config](configkit.File(paths[1]), options)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Fatalf("defaults calls after loads = %d", calls)
|
||||
}
|
||||
for _, path := range paths {
|
||||
if err := os.Remove(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
differences, err := configkit.Diff(left, right)
|
||||
if err != nil || len(differences) != 1 || differences[0].Path.String() != "/value" {
|
||||
t.Fatalf("Diff after removing files = %#v, %v", differences, err)
|
||||
}
|
||||
if calls != 2 {
|
||||
t.Fatalf("defaults reran during Diff: %d calls", calls)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeDiffCombinesSecretSensitivityAndLoadErrors(t *testing.T) {
|
||||
t.Setenv("FACADE_DIFF_SECRET", "credential-one")
|
||||
secretOptions := configkit.Options[facadeDiffSecretConfig]{Definition: configkit.Definition[facadeDiffSecretConfig]{Secret: func(path configkit.Path) bool {
|
||||
return path.String() == "/secret"
|
||||
}}}
|
||||
left, err := configkit.Load[facadeDiffSecretConfig](configkit.Bytes("left.yml", []byte("secret: FACADE_DIFF_SECRET\n")), secretOptions)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
t.Setenv("FACADE_DIFF_SECRET", "credential-two")
|
||||
right, err := configkit.Load[facadeDiffSecretConfig](configkit.Bytes("right.yml", []byte("secret: FACADE_DIFF_SECRET\n")), secretOptions)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if differences, err := configkit.Diff(left, right); err != nil || len(differences) != 0 {
|
||||
t.Fatalf("credential rotation = %#v, %v", differences, err)
|
||||
}
|
||||
t.Setenv("OTHER_FACADE_DIFF_SECRET", "other-credential")
|
||||
otherReference, err := configkit.Load[facadeDiffSecretConfig](configkit.Bytes("other.yml", []byte("secret: OTHER_FACADE_DIFF_SECRET\n")), secretOptions)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
differences, err := configkit.Diff(left, otherReference)
|
||||
if err != nil || len(differences) != 1 || !differences[0].HiddenChanged || !differences[0].Left.Redacted || !differences[0].Right.Redacted {
|
||||
t.Fatalf("reference change = %#v, %v", differences, err)
|
||||
}
|
||||
if strings.Contains(differences[0].Left.YAML+differences[0].Right.YAML, "credential") {
|
||||
t.Fatalf("credential leaked: %#v", differences)
|
||||
}
|
||||
|
||||
invalidLeft, leftErr := configkit.Load[facadeDiffSecretConfig](configkit.Bytes("invalid-left.yml", []byte("unknown: true\n")))
|
||||
if invalidLeft != nil || leftErr == nil {
|
||||
t.Fatalf("invalid left load = %#v, %v", invalidLeft, leftErr)
|
||||
}
|
||||
var loadError *configkit.LoadError
|
||||
if !errors.As(leftErr, &loadError) {
|
||||
t.Fatalf("left load error = %T, want *configkit.LoadError", leftErr)
|
||||
}
|
||||
invalidRight, rightErr := configkit.Load[facadeDiffSecretConfig](configkit.Bytes("invalid-right.yml", []byte("unknown: true\n")))
|
||||
if invalidRight != nil || rightErr == nil {
|
||||
t.Fatalf("invalid right load = %#v, %v", invalidRight, rightErr)
|
||||
}
|
||||
if !errors.As(rightErr, &loadError) {
|
||||
t.Fatalf("right load error = %T, want *configkit.LoadError", rightErr)
|
||||
}
|
||||
}
|
||||
99
facade_import_alias_test.go
Normal file
99
facade_import_alias_test.go
Normal file
@@ -0,0 +1,99 @@
|
||||
package configkit_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
func TestFacadeNativeDiscoveryPreservesAliasIdentity(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
for name, contents := range map[string]string{
|
||||
"data/item.yml": "configkit:\n imports:\n - path: helper.yml\nvalue: item\n",
|
||||
"data/helper.yml": "owner: target\nresource: resource.txt\n",
|
||||
"aliases/helper.yml": "owner: alias\nresource: resource.txt\n",
|
||||
"data/resource.txt": "target resource",
|
||||
"aliases/resource.txt": "alias resource",
|
||||
} {
|
||||
filename := filepath.Join(root, filepath.FromSlash(name))
|
||||
if err := os.MkdirAll(filepath.Dir(filename), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filename, []byte(contents), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for name, target := range map[string]string{"aliases/item.yml": "../data/item.yml", "linked": "data"} {
|
||||
if err := os.Symlink(target, filepath.Join(root, filepath.FromSlash(name))); err != nil {
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
}
|
||||
}
|
||||
filesystem := configkit.NewFilesystem("aliases", os.DirFS(root))
|
||||
type configuration struct {
|
||||
Value string `yaml:"value"`
|
||||
Owner string `yaml:"owner"`
|
||||
Resource string `yaml:"resource"`
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name, declaration, item, helper, owner, resource string
|
||||
}{
|
||||
{"file/path", "path: aliases/item.yml", "aliases/item.yml", "aliases/helper.yml", "alias", "alias resource"},
|
||||
{"file/dir", "dir: aliases\n pattern: item.yml", "aliases/item.yml", "aliases/helper.yml", "alias", "alias resource"},
|
||||
{"file/glob", "glob: 'aliases/ite*.yml'", "aliases/item.yml", "aliases/helper.yml", "alias", "alias resource"},
|
||||
{"directory/path", "path: linked/item.yml", "linked/item.yml", "linked/helper.yml", "target", "target resource"},
|
||||
{"directory/dir", "dir: linked\n pattern: item.yml", "linked/item.yml", "linked/helper.yml", "target", "target resource"},
|
||||
{"directory/glob", "glob: 'lin*/ite*.yml'", "linked/item.yml", "linked/helper.yml", "target", "target resource"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
result, err := configkit.Load(configkit.Bytes("main", []byte("configkit:\n imports:\n - "+test.declaration+"\n")), configkit.Options[configuration]{
|
||||
Imports: true, Filesystem: &configkit.FilesystemContext{FS: &filesystem, Root: ".", BaseDir: "."},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Config.Owner != test.owner || result.Config.Value != "item" {
|
||||
t.Fatalf("configuration = %#v", result.Config)
|
||||
}
|
||||
sources := result.Sources()
|
||||
if len(sources) != 3 || sources[1].Path != test.item || sources[2].Path != test.helper {
|
||||
t.Fatalf("source identities = %#v", sources)
|
||||
}
|
||||
for field, source := range map[string]configkit.SourceInfo{"value": sources[1], "resource": sources[2]} {
|
||||
info, ok := result.Metadata().Lookup(configkit.Keys(field))
|
||||
if !ok || len(info.Origin.Locations) != 1 || info.Origin.Locations[0].SourceID != source.ID {
|
||||
t.Fatalf("%s origin = %#v", field, info)
|
||||
}
|
||||
context, ok := result.SourceContext(source.ID)
|
||||
if !ok || context.BaseDir != path.Dir(source.Path) {
|
||||
t.Fatalf("%s context = %#v", field, context)
|
||||
}
|
||||
contents, err := fs.ReadFile(context.FS, path.Join(context.BaseDir, result.Config.Resource))
|
||||
if err != nil || string(contents) != test.resource {
|
||||
t.Fatalf("%s resource = %q, %v", field, contents, err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
// The same native path is still a repeat when separately included. An alias
|
||||
// to a leaf file remains a different native namespace path, as for path imports.
|
||||
for _, selector := range []string{"path: aliases/item.yml", "dir: aliases\n pattern: item.yml", "glob: 'aliases/ite*.yml'"} {
|
||||
_, err := configkit.Load(configkit.Bytes("main", []byte("configkit:\n imports:\n - "+selector+"\n - path: aliases/item.yml\n")), configkit.Options[configuration]{
|
||||
Imports: true, Filesystem: &configkit.FilesystemContext{FS: &filesystem, Root: ".", BaseDir: "."},
|
||||
})
|
||||
var loadError *configkit.LoadError
|
||||
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != configkit.CodeImportRepeated {
|
||||
t.Fatalf("repeated source: %v", err)
|
||||
}
|
||||
_, err = configkit.Load(configkit.Bytes("main", []byte("configkit:\n imports:\n - "+selector+"\n - path: data/item.yml\n")), configkit.Options[configuration]{
|
||||
Imports: true, Filesystem: &configkit.FilesystemContext{FS: &filesystem, Root: ".", BaseDir: "."},
|
||||
})
|
||||
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != configkit.CodeComposeConflict {
|
||||
t.Fatalf("distinct native alias paths should reach composition: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
260
facade_import_defaults_test.go
Normal file
260
facade_import_defaults_test.go
Normal file
@@ -0,0 +1,260 @@
|
||||
package configkit
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
|
||||
)
|
||||
|
||||
func TestImportDepthChecksSuffixAfterSaturation(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
depth string
|
||||
valid bool
|
||||
}{
|
||||
{"999999999999999999999999.5", false},
|
||||
{"999999999999999999999999e1", false},
|
||||
{"999999999999999999999999", true},
|
||||
{"-0", true},
|
||||
} {
|
||||
t.Run(test.depth, func(t *testing.T) {
|
||||
main := writeFacadeImportFile(t, t.TempDir(), "main.yml", "value: true\nconfigkit:\n imports:\n - dir: absent\n recursive: true\n depth: "+test.depth+"\n")
|
||||
result, err := Load[map[string]any](File(main), Options[map[string]any]{Imports: true})
|
||||
if test.valid {
|
||||
if err != nil || result.Config["value"] != true {
|
||||
t.Fatalf("valid depth: %v", err)
|
||||
}
|
||||
return
|
||||
}
|
||||
var loadError *LoadError
|
||||
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 {
|
||||
t.Fatalf("invalid depth accepted: %v", err)
|
||||
}
|
||||
if diagnostic := loadError.Diagnostics()[0]; diagnostic.Code != CodeImportInvalid || diagnostic.Path.String() != "/configkit/imports/0/depth" {
|
||||
t.Fatalf("invalid depth diagnostic = %#v", diagnostic)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportDefaultsApplyToTransitiveAndUnderlayImports(t *testing.T) {
|
||||
type config struct {
|
||||
Main string `yaml:"main"`
|
||||
Transitive string `yaml:"transitive"`
|
||||
Exception string `yaml:"exception"`
|
||||
Underlay string `yaml:"underlay"`
|
||||
IgnoredDeep string `yaml:"ignored_deep"`
|
||||
}
|
||||
directory := t.TempDir()
|
||||
mainPath := writeFacadeImportFile(t, directory, "main.yml", `configkit:
|
||||
imports:
|
||||
- path: parent/parent.yml
|
||||
main: main
|
||||
`)
|
||||
writeFacadeImportFile(t, directory, "parent/parent.yml", `configkit:
|
||||
imports:
|
||||
- dir: generated
|
||||
- dir: explicit
|
||||
pattern: '*.yaml'
|
||||
recursive: false
|
||||
`)
|
||||
transitivePath := writeFacadeImportFile(t, directory, "parent/generated/deep/transitive.cfg", "transitive: selected\n")
|
||||
writeFacadeImportFile(t, directory, "parent/generated/ignored.yaml", "ignored_deep: wrong-pattern\n")
|
||||
explicitPath := writeFacadeImportFile(t, directory, "parent/explicit/exception.yaml", "exception: declaration\n")
|
||||
writeFacadeImportFile(t, directory, "parent/explicit/deep/ignored.yaml", "ignored_deep: wrong-depth\n")
|
||||
writeFacadeImportFile(t, directory, "parent/explicit/ignored.cfg", "ignored_deep: wrong-explicit-pattern\n")
|
||||
underlayPath := writeFacadeImportFile(t, directory, "underlay.yml", `configkit:
|
||||
imports:
|
||||
- dir: underlay-files
|
||||
`)
|
||||
underlayImportPath := writeFacadeImportFile(t, directory, "underlay-files/underlay.cfg", "underlay: selected\n")
|
||||
recursive := true
|
||||
loaded, err := LoadLayered(Input{Source: File(mainPath), Imports: true}, LayeredOptions[config]{
|
||||
ImportDefaults: ImportDefaults{Recursive: &recursive, Pattern: []string{"*.cfg"}},
|
||||
Underlays: []Underlay{{Inputs: []Input{{Source: File(underlayPath), Imports: true}}}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("LoadLayered: %v", err)
|
||||
}
|
||||
if loaded.Config.Main != "main" || loaded.Config.Transitive != "selected" || loaded.Config.Exception != "declaration" || loaded.Config.Underlay != "selected" || loaded.Config.IgnoredDeep != "" {
|
||||
t.Fatalf("configuration = %#v", loaded.Config)
|
||||
}
|
||||
paths := make(map[string]bool)
|
||||
for _, source := range loaded.Sources() {
|
||||
paths[source.Path] = true
|
||||
}
|
||||
for _, selected := range []string{transitivePath, explicitPath, underlayImportPath} {
|
||||
if !paths[canonicalFacadeImportPath(t, selected)] {
|
||||
t.Errorf("selected source %q missing from source records", selected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportDefaultsPreserveEmptyPatternAndRequiredness(t *testing.T) {
|
||||
type config struct {
|
||||
Value string `yaml:"value"`
|
||||
}
|
||||
directory := t.TempDir()
|
||||
mainPath := writeFacadeImportFile(t, directory, "main.yml", `configkit:
|
||||
imports:
|
||||
- dir: snippets
|
||||
`)
|
||||
writeFacadeImportFile(t, directory, "snippets/value.cfg", "value: imported\n")
|
||||
|
||||
unrestricted, err := Load(File(mainPath), Options[config]{Imports: true})
|
||||
if err != nil || unrestricted.Config.Value != "imported" {
|
||||
t.Fatalf("unspecified pattern = %#v, %v", unrestricted, err)
|
||||
}
|
||||
empty, err := Load(File(mainPath), Options[config]{Imports: true, ImportDefaults: ImportDefaults{Pattern: []string{}}})
|
||||
if err != nil || empty.Config.Value != "" {
|
||||
t.Fatalf("empty pattern = %#v, %v", empty, err)
|
||||
}
|
||||
emptySources := empty.Sources()
|
||||
if len(emptySources) != 1 || len(emptySources[0].ImportSelections) != 1 || emptySources[0].ImportSelections[0].Path.String() != "/configkit/imports/0" || emptySources[0].ImportSelections[0].Selector != ImportSelectorDirectory || emptySources[0].ImportSelections[0].Outcome != ImportSelectionNone {
|
||||
t.Fatalf("empty-pattern evidence = %#v", emptySources)
|
||||
}
|
||||
required := true
|
||||
_, err = Load(File(mainPath), Options[config]{Imports: true, ImportDefaults: ImportDefaults{Required: &required, Pattern: []string{}}})
|
||||
var loadError *LoadError
|
||||
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != CodeImportMissing {
|
||||
t.Fatalf("required empty selection error = %T %v", err, err)
|
||||
}
|
||||
if sources := loadError.Sources(); len(sources) != 1 || len(sources[0].ImportSelections) != 0 {
|
||||
t.Fatalf("required empty selection was recorded as optional: %#v", sources)
|
||||
}
|
||||
|
||||
recursiveMain := writeFacadeImportFile(t, directory, "recursive.yml", `configkit:
|
||||
imports:
|
||||
- dir: recursive-files
|
||||
`)
|
||||
writeFacadeImportFile(t, directory, "recursive-files/nested/value.cfg", "value: selected\n")
|
||||
recursive := true
|
||||
patterns := []string{"*.cfg"}
|
||||
engineOptions := toEngineOptions(Options[config]{Imports: true, ImportDefaults: ImportDefaults{Recursive: &recursive, Pattern: patterns}})
|
||||
recursive = false
|
||||
patterns[0] = "["
|
||||
loadedEngine, err := engine.Load(File(recursiveMain).toEngine(), secretBinding(), engineOptions)
|
||||
if err != nil || loadedEngine.Config.Value != "selected" {
|
||||
t.Fatalf("load after mutating caller defaults = %#v, %v", loadedEngine, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInvalidImportDefaultsPreflightBeforeOrdinaryAndLayeredReads(t *testing.T) {
|
||||
type config struct {
|
||||
Value string `yaml:"value"`
|
||||
}
|
||||
for _, layered := range []bool{false, true} {
|
||||
input := strings.NewReader("value: read\n")
|
||||
underlayInput := strings.NewReader("value: underlay\n")
|
||||
defaultsCalled := false
|
||||
resolveCalled := false
|
||||
definition := Definition[config]{
|
||||
Defaults: func() config { defaultsCalled = true; return config{} },
|
||||
Resolve: func(*config, *Resolution) error { resolveCalled = true; return nil },
|
||||
}
|
||||
defaults := ImportDefaults{Pattern: []string{"["}}
|
||||
var err error
|
||||
if layered {
|
||||
_, err = LoadLayered(Input{Source: Reader("reader.yml", input)}, LayeredOptions[config]{
|
||||
Definition: definition, ImportDefaults: defaults,
|
||||
Underlays: []Underlay{{Inputs: []Input{{Source: Reader("underlay.yml", underlayInput)}}}},
|
||||
})
|
||||
} else {
|
||||
_, err = Load(Reader("reader.yml", input), Options[config]{Definition: definition, ImportDefaults: defaults})
|
||||
}
|
||||
var loadError *LoadError
|
||||
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != CodeOptionsInvalid {
|
||||
if loadError != nil {
|
||||
t.Fatalf("layered=%v diagnostics = %#v", layered, loadError.Diagnostics())
|
||||
}
|
||||
t.Fatalf("layered=%v error = %T %v", layered, err, err)
|
||||
}
|
||||
if input.Len() != len("value: read\n") || underlayInput.Len() != len("value: underlay\n") || defaultsCalled || resolveCalled {
|
||||
t.Fatalf("layered=%v consumed input or called application hooks: remaining=%d underlay=%d defaults=%v resolve=%v", layered, input.Len(), underlayInput.Len(), defaultsCalled, resolveCalled)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPublicImportSelectionSnapshotsAreDetachedAcrossResultErrorAndResolution(t *testing.T) {
|
||||
type config struct {
|
||||
Value string `yaml:"value"`
|
||||
}
|
||||
directory := t.TempDir()
|
||||
mainPath := writeFacadeImportFile(t, directory, "main.yml", `configkit:
|
||||
imports:
|
||||
- dir: absent
|
||||
value: loaded
|
||||
`)
|
||||
callbackPath := ""
|
||||
loaded, err := Load(File(mainPath), Options[config]{Imports: true, Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error {
|
||||
first, ok := resolution.Source("source-1")
|
||||
if !ok || len(first.ImportSelections) != 1 {
|
||||
t.Fatalf("resolution source = %#v, %v", first, ok)
|
||||
}
|
||||
first.ImportSelections[0].Path = Keys("callback mutation")
|
||||
second, ok := resolution.Source("source-1")
|
||||
if !ok || second.ImportSelections[0].Path.String() != "/configkit/imports/0" {
|
||||
t.Fatalf("resolution snapshot changed after mutation: %#v", second)
|
||||
}
|
||||
callbackPath = second.ImportSelections[0].Path.String()
|
||||
return nil
|
||||
}}})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if callbackPath != "/configkit/imports/0" {
|
||||
t.Fatalf("callback path = %q", callbackPath)
|
||||
}
|
||||
sources := loaded.Sources()
|
||||
if len(sources) != 1 || len(sources[0].ImportSelections) != 1 || sources[0].ImportSelections[0].Location.SourceID != sources[0].ID || sources[0].ImportSelections[0].Selector != ImportSelectorDirectory {
|
||||
t.Fatalf("result source evidence = %#v", sources)
|
||||
}
|
||||
sources[0].ImportSelections[0].Path = Keys("result mutation")
|
||||
if got := loaded.Sources()[0].ImportSelections[0].Path.String(); got != "/configkit/imports/0" {
|
||||
t.Fatalf("result snapshot changed after mutation: %q", got)
|
||||
}
|
||||
|
||||
failurePath := writeFacadeImportFile(t, directory, "failure.yml", `configkit:
|
||||
imports:
|
||||
- dir: absent
|
||||
- path: required-missing.yml
|
||||
required: true
|
||||
`)
|
||||
_, err = Load(File(failurePath), Options[config]{Imports: true})
|
||||
var loadError *LoadError
|
||||
if !errors.As(err, &loadError) {
|
||||
t.Fatalf("failed load error = %T %v", err, err)
|
||||
}
|
||||
errorSources := loadError.Sources()
|
||||
if len(errorSources) != 1 || len(errorSources[0].ImportSelections) != 1 || errorSources[0].ImportSelections[0].Path.String() != "/configkit/imports/0" {
|
||||
t.Fatalf("error source evidence = %#v", errorSources)
|
||||
}
|
||||
errorSources[0].ImportSelections[0].Path = Keys("error mutation")
|
||||
if got := loadError.Sources()[0].ImportSelections[0].Path.String(); got != "/configkit/imports/0" {
|
||||
t.Fatalf("error snapshot changed after mutation: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func writeFacadeImportFile(t *testing.T, root, name, contents string) string {
|
||||
t.Helper()
|
||||
filename := filepath.Join(root, name)
|
||||
if err := os.MkdirAll(filepath.Dir(filename), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filename, []byte(contents), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return filename
|
||||
}
|
||||
|
||||
func canonicalFacadeImportPath(t *testing.T, filename string) string {
|
||||
t.Helper()
|
||||
canonical, err := filepath.EvalSymlinks(filename)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return filepath.ToSlash(canonical)
|
||||
}
|
||||
135
facade_import_lifecycle_test.go
Normal file
135
facade_import_lifecycle_test.go
Normal file
@@ -0,0 +1,135 @@
|
||||
package configkit
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"path"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
)
|
||||
|
||||
func TestDiscoveredItemHelpersPreserveNestedInheritedResourceEvidence(t *testing.T) {
|
||||
const canary = "PRIVATE-CONSUMED-HEADER-CANARY"
|
||||
filesystem := NewFilesystem("bundle", fstest.MapFS{
|
||||
"bundle/config/main.yml": {Data: []byte(`configkit:
|
||||
imports:
|
||||
- dir: records
|
||||
pattern: '*-item.yml'
|
||||
destination: [services, records]
|
||||
placement: sequence-item
|
||||
services:
|
||||
records:
|
||||
- id: authored
|
||||
version: authored
|
||||
`)},
|
||||
"bundle/config/records/a-item.yml": {Data: []byte(`configkit:
|
||||
imports:
|
||||
- path: helper.yml
|
||||
version: ` + canary + `
|
||||
entries:
|
||||
parent:
|
||||
resource: icon.txt
|
||||
child:
|
||||
extends: parent
|
||||
`)},
|
||||
"bundle/config/records/helper.yml": {Data: []byte("id: helper-supplied\n")},
|
||||
"bundle/config/records/icon.txt": {Data: []byte("icon payload")},
|
||||
})
|
||||
result, err := Load(filesystem.File("bundle/config/main.yml"), Options[map[string]any]{
|
||||
Imports: true,
|
||||
Filesystem: &FilesystemContext{Root: "bundle", FS: &filesystem},
|
||||
Definition: Definition[map[string]any]{
|
||||
Version: &VersionRule{Path: Keys("version"), Check: func(Version, *Validation) error { return nil }},
|
||||
SequenceCompositions: []SequenceComposition{{Path: Keys("services", "records"), IdentityField: "id"}},
|
||||
Secret: func(candidate Path) bool {
|
||||
return candidate.String() == "/services/records/0/version"
|
||||
},
|
||||
},
|
||||
SourceVersionMode: SourceVersionModeIfPresent,
|
||||
})
|
||||
if err != nil {
|
||||
var loadError *LoadError
|
||||
if errors.As(err, &loadError) {
|
||||
t.Fatalf("load discovered item: %v; diagnostics: %#v", err, loadError.Diagnostics())
|
||||
}
|
||||
t.Fatalf("load discovered item: %v", err)
|
||||
}
|
||||
services := result.Config["services"].(map[string]any)
|
||||
records := services["records"].([]any)
|
||||
if len(records) != 2 || records[0].(map[string]any)["id"] != "helper-supplied" || records[0].(map[string]any)["version"] != nil || records[1].(map[string]any)["version"] != "authored" {
|
||||
t.Fatalf("nested records = %#v", records)
|
||||
}
|
||||
childPath := Keys("services", "records").Index(0).Key("entries").Key("child").Key("resource")
|
||||
child := records[0].(map[string]any)["entries"].(map[string]any)["child"].(map[string]any)
|
||||
if child["resource"] != "icon.txt" {
|
||||
t.Fatalf("inherited relative resource = %#v", child)
|
||||
}
|
||||
|
||||
sources := result.Sources()
|
||||
filesByID := make(map[string]string, len(sources))
|
||||
for _, source := range sources {
|
||||
filesByID[source.ID] = source.Path
|
||||
}
|
||||
var itemSource SourceInfo
|
||||
for _, source := range sources {
|
||||
if source.Path == "bundle/config/records/a-item.yml" {
|
||||
itemSource = source
|
||||
}
|
||||
}
|
||||
if itemSource.VersionCheck == nil || !itemSource.VersionCheck.Version.Present || !itemSource.VersionCheck.Redacted || itemSource.VersionCheck.Version.Text != "" {
|
||||
t.Fatalf("consumed header inspection = %#v", itemSource.VersionCheck)
|
||||
}
|
||||
idInfo, ok := result.Metadata().Lookup(Keys("services", "records").Index(0).Key("id"))
|
||||
if !ok || len(idInfo.Origin.Locations) != 1 || filesByID[idInfo.Origin.Locations[0].SourceID] != "bundle/config/records/helper.yml" {
|
||||
t.Fatalf("assigned helper ID origin = %#v", idInfo)
|
||||
}
|
||||
resourceInfo, ok := result.Metadata().Lookup(childPath)
|
||||
if !ok || len(resourceInfo.Origin.Locations) != 1 || filesByID[resourceInfo.Origin.Locations[0].SourceID] != "bundle/config/records/a-item.yml" {
|
||||
t.Fatalf("inherited resource metadata = %#v", resourceInfo)
|
||||
}
|
||||
explanation, ok := result.Explain(childPath)
|
||||
if !ok || explanation.Effective.YAML != "icon.txt\n" || len(explanation.Origin.Locations) != 1 || explanation.Origin.Locations[0].SourceID != resourceInfo.Origin.Locations[0].SourceID {
|
||||
t.Fatalf("inherited resource explanation = %#v", explanation)
|
||||
}
|
||||
context, ok := result.SourceContext(resourceInfo.Origin.Locations[0].SourceID)
|
||||
if !ok || context.FS == nil || context.BaseDir != "config/records" {
|
||||
t.Fatalf("inherited resource context = %#v, %v", context, ok)
|
||||
}
|
||||
contents, err := fs.ReadFile(context.FS, path.Join(context.BaseDir, child["resource"].(string)))
|
||||
if err != nil || string(contents) != "icon payload" {
|
||||
t.Fatalf("inherited resource read = %q, %v", contents, err)
|
||||
}
|
||||
|
||||
_, err = Load(filesystem.File("bundle/config/main.yml"), Options[map[string]any]{
|
||||
Imports: true,
|
||||
Filesystem: &FilesystemContext{Root: "bundle", FS: &filesystem},
|
||||
Definition: Definition[map[string]any]{
|
||||
Version: &VersionRule{Path: Keys("version"), Check: func(Version, *Validation) error { return nil }},
|
||||
SequenceCompositions: []SequenceComposition{{Path: Keys("services", "records"), IdentityField: "version"}},
|
||||
Secret: func(candidate Path) bool {
|
||||
return candidate.String() == "/services/records/0/version"
|
||||
},
|
||||
},
|
||||
SourceVersionMode: SourceVersionModeIfPresent,
|
||||
})
|
||||
loadError, ok := err.(*LoadError)
|
||||
if !ok || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != CodeComposeInvalid {
|
||||
t.Fatalf("consumed header supplied an identity: %v", err)
|
||||
}
|
||||
if strings.Contains(err.Error(), canary) {
|
||||
t.Fatalf("diagnostic exposed the consumed secret header: %v", err)
|
||||
}
|
||||
var failedItemSource SourceInfo
|
||||
foundFailedItem := false
|
||||
for _, source := range loadError.Sources() {
|
||||
if source.Path == "bundle/config/records/a-item.yml" {
|
||||
failedItemSource = source
|
||||
foundFailedItem = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundFailedItem || failedItemSource.VersionCheck == nil || failedItemSource.VersionCheck.Version.Text != "" || !failedItemSource.VersionCheck.Redacted {
|
||||
t.Fatalf("failed-load source inspection exposed or omitted header evidence: %#v", failedItemSource)
|
||||
}
|
||||
}
|
||||
119
facade_import_root_test.go
Normal file
119
facade_import_root_test.go
Normal file
@@ -0,0 +1,119 @@
|
||||
package configkit_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
func TestFacadeDirectoryRootBelowContainedAbsoluteSymlink(t *testing.T) {
|
||||
root, err := filepath.EvalSymlinks(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
parts := filepath.Join(root, "real", "parts")
|
||||
if err := os.MkdirAll(parts, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
partPath := filepath.Join(parts, "part.yml")
|
||||
if err := os.WriteFile(partPath, []byte("value: included\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink(filepath.Join(root, "real"), filepath.Join(root, "absolute")); err != nil {
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
}
|
||||
for _, follow := range []bool{true, false} {
|
||||
t.Run(fmt.Sprintf("follow=%t", follow), func(t *testing.T) {
|
||||
mainPath := filepath.Join(root, "main.yml")
|
||||
contents := fmt.Sprintf("configkit:\n imports:\n - dir: absolute/parts\n pattern: '*.yml'\n follow: %t\n", follow)
|
||||
if err := os.WriteFile(mainPath, []byte(contents), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
result, err := configkit.Load[map[string]any](configkit.File(mainPath), configkit.Options[map[string]any]{Imports: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Config["value"] != "included" {
|
||||
t.Fatalf("configuration = %#v", result.Config)
|
||||
}
|
||||
if sources := result.Sources(); len(sources) != 2 || sources[1].Path != partPath {
|
||||
t.Fatalf("canonical imported source = %#v", sources)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeNativeDiscoveryWithNestedRoot(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
parts := filepath.Join(root, "nested/root/parts")
|
||||
if err := os.MkdirAll(parts, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(parts, "value.yml"), []byte("value: 1\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink("parts", filepath.Join(root, "nested/root/linked")); err != nil {
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
}
|
||||
filesystem := configkit.NewFilesystem("nested", os.DirFS(root))
|
||||
context := &configkit.FilesystemContext{FS: &filesystem, Root: "nested/root", BaseDir: "nested/root"}
|
||||
for _, selector := range []string{
|
||||
"dir: parts", "glob: 'parts/*.yml'", "dir: linked", "glob: 'lin*/*.yml'",
|
||||
} {
|
||||
t.Run(selector, func(t *testing.T) {
|
||||
result, err := configkit.Load(configkit.Bytes("main", []byte("configkit:\n imports:\n - "+selector+"\n")), configkit.Options[map[string]any]{Imports: true, Filesystem: context})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if result.Config["value"] != int64(1) {
|
||||
t.Fatalf("configuration = %#v", result.Config)
|
||||
}
|
||||
})
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "nested/outside.yml"), []byte("value: outside\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink("../../outside.yml", filepath.Join(parts, "escape.yml")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, selector := range []string{"dir: parts", "glob: 'parts/*.yml'"} {
|
||||
_, err := configkit.Load(configkit.Bytes("main", []byte("configkit:\n imports:\n - "+selector+"\n")), configkit.Options[map[string]any]{Imports: true, Filesystem: context})
|
||||
var loadError *configkit.LoadError
|
||||
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != configkit.CodeImportOutsideRoot {
|
||||
t.Fatalf("escaping %s: %v", selector, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeDiscoveryAllowsFiniteLinkReuse(t *testing.T) {
|
||||
root := t.TempDir()
|
||||
if err := os.Mkdir(filepath.Join(root, "parts"), 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(root, "parts/value.yml"), []byte("value: 1\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Symlink("..", filepath.Join(root, "parts/up")); err != nil {
|
||||
t.Skipf("symlinks unavailable: %v", err)
|
||||
}
|
||||
filesystem := configkit.NewFilesystem("finite", os.DirFS(root))
|
||||
for _, native := range []bool{false, true} {
|
||||
for _, selector := range []string{"dir: parts/up/parts/up/parts\n pattern: '*.yml'", "glob: 'parts/up/parts/up/parts/*.yml'"} {
|
||||
context := &configkit.FilesystemContext{Root: root, BaseDir: root}
|
||||
if native {
|
||||
context = &configkit.FilesystemContext{FS: &filesystem, Root: ".", BaseDir: "."}
|
||||
}
|
||||
result, err := configkit.Load(configkit.Bytes("main", []byte("configkit:\n imports:\n - "+selector+"\n")), configkit.Options[map[string]any]{Imports: true, Filesystem: context})
|
||||
if err != nil {
|
||||
t.Fatalf("native=%v selector=%s: %v", native, selector, err)
|
||||
}
|
||||
if result.Config["value"] != int64(1) {
|
||||
t.Fatalf("configuration = %#v", result.Config)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
64
facade_item_version_test.go
Normal file
64
facade_item_version_test.go
Normal file
@@ -0,0 +1,64 @@
|
||||
package configkit_test
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
func TestFacadeItemDomainVersionsKeepEffectiveScope(t *testing.T) {
|
||||
for _, test := range []struct {
|
||||
name, item, child, helper string
|
||||
underlay, mainItem bool
|
||||
}{
|
||||
{name: "item", item: "id: item\nversion: domain\n"},
|
||||
{name: "helper", item: "id: item\nconfigkit: {imports: [{path: helper.yml}]}\n", helper: "version: domain\n"},
|
||||
{name: "nested", item: "id: parent\nconfigkit: {imports: [{path: child.yml, destination: [children], placement: sequence-item}]}\n", child: "id: child\nversion: domain\n"},
|
||||
{name: "underlay", item: "id: item\nversion: domain\n", underlay: true},
|
||||
{name: "main-item", item: "id: parent\nconfigkit: {imports: [{path: child.yml, destination: [children], placement: sequence-item}]}\n", child: "id: child\nversion: domain\n", mainItem: true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
files := fstest.MapFS{
|
||||
"main.yml": {Data: []byte("version: 1\nconfigkit: {imports: [{path: item.yml, destination: [items], placement: sequence-item}]}\n")},
|
||||
"item.yml": {Data: []byte(test.item)},
|
||||
"child.yml": {Data: []byte(test.child)},
|
||||
"helper.yml": {Data: []byte(test.helper)},
|
||||
"underlay.yml": {Data: []byte("configkit: {imports: [{path: item.yml, destination: [items], placement: sequence-item}]}\n")},
|
||||
}
|
||||
filesystem := configkit.NewFilesystem("items", files)
|
||||
definition := configkit.Definition[map[string]any]{Version: &configkit.VersionRule{
|
||||
Path: configkit.Keys("version"), Check: func(configkit.Version, *configkit.Validation) error { return nil },
|
||||
}}
|
||||
var result *configkit.Result[map[string]any]
|
||||
var err error
|
||||
switch {
|
||||
case test.underlay:
|
||||
result, err = configkit.LoadLayered(configkit.Input{Source: configkit.Mapping("main", map[string]any{"version": "1"})}, configkit.LayeredOptions[map[string]any]{
|
||||
Definition: definition, Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: filesystem.File("underlay.yml"), Imports: true}}}},
|
||||
})
|
||||
case test.mainItem:
|
||||
result, err = configkit.Load(filesystem.File("item.yml"), configkit.Options[map[string]any]{
|
||||
Imports: true, Destination: configkit.Keys("items"), PlacementMode: configkit.PlacementModeSequenceItem, Definition: definition,
|
||||
})
|
||||
default:
|
||||
result, err = configkit.Load(filesystem.File("main.yml"), configkit.Options[map[string]any]{Imports: true, Definition: definition})
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
item := result.Config["items"].([]any)[0].(map[string]any)
|
||||
fieldPath := configkit.Keys("items").Index(0).Key("version")
|
||||
if test.child != "" {
|
||||
item = item["children"].([]any)[0].(map[string]any)
|
||||
fieldPath = configkit.Keys("items").Index(0).Key("children").Index(0).Key("version")
|
||||
}
|
||||
if item["version"] != "domain" {
|
||||
t.Fatalf("domain version = %#v", item)
|
||||
}
|
||||
if info, ok := result.Metadata().Lookup(fieldPath); !ok || !info.Explicit {
|
||||
t.Fatalf("version metadata = %#v, %v", info, ok)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -2,10 +2,12 @@ package configkit_test
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
@@ -219,3 +221,77 @@ func TestFacadeSyntheticSourceContextsAndContextlessSources(t *testing.T) {
|
||||
t.Fatal("CLI source returned a context")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeRelativeImportDestinationsPreserveNativeResourceContexts(t *testing.T) {
|
||||
borrowed := &observedResourceFS{files: fstest.MapFS{
|
||||
"bundle/config/main.yml": &fstest.MapFile{Data: []byte("configkit:\n imports:\n - path: entries/parent.yml\n destination: [logical]\n")},
|
||||
"bundle/config/entries/parent.yml": &fstest.MapFile{Data: []byte("configkit:\n imports:\n - path: nested/details.yml\n destination: [details]\nresource: icon.txt\n")},
|
||||
"bundle/config/entries/nested/details.yml": &fstest.MapFile{Data: []byte("resource: detail.txt\n")},
|
||||
"bundle/config/entries/icon.txt": &fstest.MapFile{Data: []byte("parent-resource")},
|
||||
"bundle/config/entries/nested/detail.txt": &fstest.MapFile{Data: []byte("nested-resource")},
|
||||
}}
|
||||
namespace := configkit.NewFilesystem("bundle", borrowed)
|
||||
input := configkit.Input{
|
||||
Source: namespace.File("bundle/config/main.yml"), Imports: true,
|
||||
Filesystem: &configkit.FilesystemContext{Root: "bundle", FS: &namespace},
|
||||
}
|
||||
load := func() *configkit.Result[map[string]any] {
|
||||
t.Helper()
|
||||
result, err := configkit.LoadLayered[map[string]any](input)
|
||||
if err != nil {
|
||||
t.Fatalf("load relative native imports: %v", err)
|
||||
}
|
||||
return result
|
||||
}
|
||||
result := load()
|
||||
wantDestinations := []string{configkit.Keys().String(), "/logical", "/logical/details"}
|
||||
wantPaths := []string{"bundle/config/main.yml", "bundle/config/entries/parent.yml", "bundle/config/entries/nested/details.yml"}
|
||||
sources := result.Sources()
|
||||
if len(sources) != len(wantPaths) {
|
||||
t.Fatalf("sources = %#v", sources)
|
||||
}
|
||||
for index, source := range sources {
|
||||
if source.ID != fmt.Sprintf("source-%d", index+1) || source.Kind != configkit.SourceKindFilesystem || source.Path != wantPaths[index] || source.Destination.String() != wantDestinations[index] {
|
||||
t.Errorf("source %d = %#v", index, source)
|
||||
}
|
||||
if index == 0 {
|
||||
if source.ImportedFrom != nil {
|
||||
t.Errorf("entry source has import location %#v", source.ImportedFrom)
|
||||
}
|
||||
} else if source.ImportedFrom == nil || source.ImportedFrom.SourceID != fmt.Sprintf("source-%d", index) || source.ImportedFrom.Line == 0 || source.ImportedFrom.Column == 0 {
|
||||
t.Errorf("source %d import location = %#v", index, source.ImportedFrom)
|
||||
}
|
||||
}
|
||||
|
||||
for _, test := range []struct {
|
||||
field, effectivePath, wantBase, wantResource, wantContents string
|
||||
}{
|
||||
{"parent resource", "/logical/resource", "config/entries", "icon.txt", "parent-resource"},
|
||||
{"nested resource", "/logical/details/resource", "config/entries/nested", "detail.txt", "nested-resource"},
|
||||
} {
|
||||
info, ok := result.Metadata().Lookup(configkit.Keys(strings.Split(strings.Trim(test.effectivePath, "/"), "/")...))
|
||||
if !ok || len(info.Origin.Locations) != 1 {
|
||||
t.Fatalf("%s origin = %#v, %v", test.field, info, ok)
|
||||
}
|
||||
context, ok := result.SourceContext(info.Origin.Locations[0].SourceID)
|
||||
if !ok || context.BaseDir != test.wantBase || context.FS == nil {
|
||||
t.Fatalf("%s context = %#v, %v", test.field, context, ok)
|
||||
}
|
||||
contents, err := fs.ReadFile(context.FS, path.Join(context.BaseDir, test.wantResource))
|
||||
if err != nil || string(contents) != test.wantContents {
|
||||
t.Fatalf("%s opened resource = %q, %v", test.field, contents, err)
|
||||
}
|
||||
}
|
||||
|
||||
sources[1].Destination = configkit.Keys("caller", "mutation")
|
||||
for query := 0; query < 2; query++ {
|
||||
fresh := result.Sources()
|
||||
if fresh[1].Destination.String() != "/logical" {
|
||||
t.Fatalf("query %d source destination = %q", query, fresh[1].Destination.String())
|
||||
}
|
||||
}
|
||||
second := load()
|
||||
if !reflect.DeepEqual(second.Sources(), result.Sources()) {
|
||||
t.Fatalf("independent source records differ: %#v vs %#v", second.Sources(), result.Sources())
|
||||
}
|
||||
}
|
||||
|
||||
661
facade_sequence_test.go
Normal file
661
facade_sequence_test.go
Normal file
@@ -0,0 +1,661 @@
|
||||
package configkit_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
func TestFacadeNoSourcePreservesAbsenceWithSequencePolicy(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, defaults := range []bool{false, true} {
|
||||
for _, registered := range []bool{false, true} {
|
||||
definition := configkit.Definition[map[string]any]{}
|
||||
if defaults {
|
||||
definition.Defaults = func() map[string]any { return map[string]any{"fallback": true} }
|
||||
}
|
||||
if registered {
|
||||
definition.SequenceCompositions = []configkit.SequenceComposition{{Path: configkit.Keys("records"), IdentityField: "id"}}
|
||||
}
|
||||
ordinary, err := configkit.Load(configkit.NoSource(), configkit.Options[map[string]any]{Definition: definition})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
layered, err := configkit.LoadLayered[map[string]any](configkit.Input{Source: configkit.NoSource()}, configkit.LayeredOptions[map[string]any]{Definition: definition})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(ordinary.Config, layered.Config) {
|
||||
t.Fatal("NoSource values differ between entry points")
|
||||
}
|
||||
for _, result := range []*configkit.Result[map[string]any]{ordinary, layered} {
|
||||
if len(result.Sources()) != 0 || result.Metadata().IsExplicit(configkit.Keys()) {
|
||||
t.Fatal("NoSource contributed an explicit root")
|
||||
}
|
||||
info, ok := result.Metadata().Lookup(configkit.Keys())
|
||||
if !ok || info.Origin.Layer != configkit.LayerDefault {
|
||||
t.Fatalf("NoSource root origin = %#v, %t", info, ok)
|
||||
}
|
||||
}
|
||||
empty, err := configkit.Load(configkit.Mapping("empty", map[string]any{}), configkit.Options[map[string]any]{Definition: definition})
|
||||
if err != nil || !empty.Metadata().IsExplicit(configkit.Keys()) {
|
||||
t.Fatal("real empty mapping lost its presence")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeItemHelperHistoryPreservesScheduledPlacement(t *testing.T) {
|
||||
t.Parallel()
|
||||
filesystem := configkit.NewFilesystem("history", fstest.MapFS{
|
||||
"main.yml": {Data: []byte("records: [{id: authored}]\nconfigkit: {imports: [{path: one.yml, destination: [records], placement: sequence-item}, {path: two.yml, destination: [records], placement: sequence-item}]}\n")},
|
||||
"one.yml": {Data: []byte("id: one\nconfigkit: {imports: [{path: helper-one.yml}, {path: child.yml, destination: [children], placement: sequence-item}]}\n")},
|
||||
"two.yml": {Data: []byte("id: two\nconfigkit: {imports: [{path: helper-two.yml}]}\n")},
|
||||
"helper-one.yml": {Data: []byte("text: first helper\n")},
|
||||
"helper-two.yml": {Data: []byte("text: second helper\n")},
|
||||
"child.yml": {Data: []byte("id: child\nconfigkit: {imports: [{path: child-helper.yml}]}\n")},
|
||||
"child-helper.yml": {Data: []byte("text: nested helper\n")},
|
||||
})
|
||||
result, err := configkit.Load(filesystem.File("main.yml"), configkit.Options[map[string]any]{
|
||||
Imports: true, Definition: configkit.Definition[map[string]any]{SequenceCompositions: []configkit.SequenceComposition{{Path: configkit.Keys("records"), IdentityField: "id"}}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sourceFiles := make(map[string]string)
|
||||
for _, source := range result.Sources() {
|
||||
sourceFiles[source.ID] = source.Path
|
||||
}
|
||||
for _, test := range []struct {
|
||||
path configkit.Path
|
||||
file, text string
|
||||
}{
|
||||
{configkit.Keys("records").Index(0).Key("text"), "helper-one.yml", "first helper\n"},
|
||||
{configkit.Keys("records").Index(1).Key("text"), "helper-two.yml", "second helper\n"},
|
||||
{configkit.Keys("records").Index(0).Key("children").Index(0).Key("text"), "child-helper.yml", "nested helper\n"},
|
||||
} {
|
||||
explanation, ok := result.Explain(test.path)
|
||||
if !ok || explanation.Effective.YAML != test.text || len(explanation.History) != 1 {
|
||||
t.Fatalf("helper history at %s = %#v", test.path.String(), explanation)
|
||||
}
|
||||
locations := explanation.History[0].Origin.Locations
|
||||
if len(locations) != 1 || sourceFiles[locations[0].SourceID] != test.file {
|
||||
t.Fatalf("helper origin at %s = %#v", test.path.String(), locations)
|
||||
}
|
||||
info, ok := result.Metadata().Lookup(test.path)
|
||||
if !ok || len(info.Origin.Locations) != 1 || info.Origin.Locations[0].SourceID != locations[0].SourceID {
|
||||
t.Fatal("metadata and explanation disagree")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeRegisteredReplacementExplainsRemovedDescendants(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
upper []any
|
||||
path configkit.Path
|
||||
}{
|
||||
{"shorter", []any{map[string]any{"id": "main"}}, configkit.Keys("records").Index(1).Key("id")},
|
||||
{"empty", []any{}, configkit.Keys("records").Index(0).Key("id")},
|
||||
{"removed field", []any{map[string]any{"id": "main"}}, configkit.Keys("records").Index(0).Key("text")},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
result, err := configkit.LoadLayered[map[string]any](configkit.Input{Source: configkit.Mapping("main", map[string]any{"records": test.upper})}, configkit.LayeredOptions[map[string]any]{
|
||||
Definition: configkit.Definition[map[string]any]{SequenceCompositions: []configkit.SequenceComposition{{Path: configkit.Keys("records"), IdentityField: "id"}}},
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.Mapping("lower", map[string]any{"records": []any{map[string]any{"id": "one", "text": "old"}, map[string]any{"id": "two"}}})}}}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
explanation, ok := result.Explain(test.path)
|
||||
if !ok || explanation.Effective.Present || len(explanation.History) < 2 {
|
||||
t.Fatalf("removal not explained: %#v", explanation)
|
||||
}
|
||||
last := explanation.History[len(explanation.History)-1]
|
||||
if !last.Before.Present || last.After.Present || last.Origin.Layer != configkit.LayerBase || len(last.Origin.Locations) != 1 || last.Origin.Locations[0].SourceID != result.Sources()[0].ID {
|
||||
t.Fatalf("wrong removal transition/origin: %#v", last)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeItemConflictDiagnosticsFollowEnclosingIndexes(t *testing.T) {
|
||||
t.Parallel()
|
||||
filesystem := configkit.NewFilesystem("conflicts", fstest.MapFS{
|
||||
"main.yml": {Data: []byte("records: [{id: authored}]\nconfigkit: {imports: [{path: good.yml, destination: [records], placement: sequence-item}, {path: bad.yml, destination: [records], placement: sequence-item}]}\n")},
|
||||
"group.yml": {Data: []byte("records: [{id: authored}]\nconfigkit: {imports: [{path: bad.yml, destination: [records], placement: sequence-item}]}\n")},
|
||||
"nested.yml": {Data: []byte("records: [{id: authored}]\nconfigkit: {imports: [{path: parent.yml, destination: [records], placement: sequence-item}]}\n")},
|
||||
"parent.yml": {Data: []byte("id: parent\nchildren: [{id: authored-child}]\nconfigkit: {imports: [{path: bad.yml, destination: [children], placement: sequence-item}]}\n")},
|
||||
"good.yml": {Data: []byte("id: good\n")},
|
||||
"bad.yml": {Data: []byte("id: bad\nconfigkit: {imports: [{path: helper.yml}]}\n")},
|
||||
"helper.yml": {Data: []byte("id: duplicate-field\n")},
|
||||
})
|
||||
definition := configkit.Definition[map[string]any]{SequenceCompositions: []configkit.SequenceComposition{{Path: configkit.Keys("records"), IdentityField: "id"}}}
|
||||
for _, test := range []struct {
|
||||
name, file, path string
|
||||
grouped, itemMain bool
|
||||
}{
|
||||
{"preceding authored and imported items", "main.yml", "/records/0/id", false, false},
|
||||
{"nested item", "nested.yml", "/records/0/children/0/id", false, false},
|
||||
{"preceding grouped input", "group.yml", "/records/1/id", true, false},
|
||||
{"item main source", "bad.yml", "/records/0/id", false, true},
|
||||
{"grouped item source", "bad.yml", "/records/1/id", true, true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
var err error
|
||||
input := configkit.Input{Source: filesystem.File(test.file), Imports: true}
|
||||
if test.itemMain {
|
||||
input.Destination = configkit.Keys("records")
|
||||
input.PlacementMode = configkit.PlacementModeSequenceItem
|
||||
}
|
||||
if test.grouped {
|
||||
_, err = configkit.LoadLayered[map[string]any](configkit.Input{Source: configkit.NoSource()}, configkit.LayeredOptions[map[string]any]{
|
||||
Definition: definition, Underlays: []configkit.Underlay{{Inputs: []configkit.Input{
|
||||
{Source: configkit.Mapping("prior", map[string]any{"records": []any{map[string]any{"id": "prior"}}})},
|
||||
input,
|
||||
}}},
|
||||
})
|
||||
} else {
|
||||
_, err = configkit.Load(input.Source, configkit.Options[map[string]any]{Definition: definition, Imports: true, Destination: input.Destination, PlacementMode: input.PlacementMode})
|
||||
}
|
||||
loadError, ok := err.(*configkit.LoadError)
|
||||
if !ok || len(loadError.Diagnostics()) != 1 {
|
||||
t.Fatalf("expected helper conflict: %v", err)
|
||||
}
|
||||
diagnostic := loadError.Diagnostics()[0]
|
||||
if diagnostic.Code != configkit.CodeComposeConflict || diagnostic.Path.String() != test.path {
|
||||
t.Fatalf("diagnostic = %s %s, want %s", diagnostic.Code, diagnostic.Path.String(), test.path)
|
||||
}
|
||||
files := make(map[string]string)
|
||||
for _, source := range loadError.Sources() {
|
||||
files[source.ID] = source.Path
|
||||
}
|
||||
locations := append([]configkit.Location(nil), diagnostic.Related...)
|
||||
if diagnostic.Primary != nil {
|
||||
locations = append(locations, *diagnostic.Primary)
|
||||
}
|
||||
gotFiles := make(map[string]bool, len(locations))
|
||||
for _, location := range locations {
|
||||
gotFiles[files[location.SourceID]] = location.Line == 1 && location.Column == 5
|
||||
}
|
||||
if len(locations) != 2 || !gotFiles["bad.yml"] || !gotFiles["helper.yml"] {
|
||||
t.Fatalf("conflict lost physical source locations: diagnostic=%#v sources=%#v", diagnostic, files)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeSequencePlacementControlsAcrossLoadEntryPoints(t *testing.T) {
|
||||
definition := configkit.Definition[map[string]any]{
|
||||
SequenceCompositions: []configkit.SequenceComposition{{Path: configkit.Keys("entries"), IdentityField: "id"}},
|
||||
}
|
||||
ordinary, err := configkit.Load(configkit.Mapping("ordinary", map[string]any{"id": "one"}), configkit.Options[map[string]any]{
|
||||
Definition: definition, Destination: configkit.Keys("entries"), PlacementMode: configkit.PlacementModeSequenceItem,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("ordinary item load: %v", err)
|
||||
}
|
||||
if got := ordinary.Config["entries"].([]any); len(got) != 1 || got[0].(map[string]any)["id"] != "one" {
|
||||
t.Fatalf("ordinary item placement = %#v", ordinary.Config)
|
||||
}
|
||||
if sources := ordinary.Sources(); len(sources) != 1 || sources[0].PlacementMode != configkit.PlacementModeSequenceItem || sources[0].Destination.String() != "/entries" || sources[0].DestinationUnresolved {
|
||||
t.Fatalf("ordinary item source = %#v", sources)
|
||||
}
|
||||
|
||||
layered, err := configkit.LoadLayered[map[string]any](configkit.Input{Source: configkit.NoSource()}, configkit.LayeredOptions[map[string]any]{
|
||||
Definition: definition,
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{
|
||||
{Source: configkit.Mapping("first", map[string]any{"id": "first"}), Destination: configkit.Keys("entries"), PlacementMode: configkit.PlacementModeSequenceItem},
|
||||
{Source: configkit.Mapping("second", map[string]any{"id": "second"}), Destination: configkit.Keys("entries"), PlacementMode: configkit.PlacementModeSequenceItem},
|
||||
}}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("layered item load: %v", err)
|
||||
}
|
||||
items := layered.Config["entries"].([]any)
|
||||
if len(items) != 2 || items[0].(map[string]any)["id"] != "first" || items[1].(map[string]any)["id"] != "second" {
|
||||
t.Fatalf("layered item placement = %#v", layered.Config)
|
||||
}
|
||||
for _, source := range layered.Sources() {
|
||||
if source.PlacementMode != configkit.PlacementModeSequenceItem || source.Destination.String() != "/entries" || source.DestinationUnresolved {
|
||||
t.Errorf("layered item source = %#v", source)
|
||||
}
|
||||
}
|
||||
|
||||
precedence, err := configkit.LoadLayered[map[string]any](configkit.Input{Source: configkit.Mapping("main", map[string]any{
|
||||
"entries": []any{map[string]any{"id": "main"}},
|
||||
})}, configkit.LayeredOptions[map[string]any]{
|
||||
Definition: definition,
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{
|
||||
Source: configkit.Mapping("lower", map[string]any{"id": "lower"}), Destination: configkit.Keys("entries"), PlacementMode: configkit.PlacementModeSequenceItem,
|
||||
}}}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("registered sequence precedence load: %v", err)
|
||||
}
|
||||
if got := precedence.Config["entries"].([]any); len(got) != 1 || got[0].(map[string]any)["id"] != "main" {
|
||||
t.Fatalf("higher-priority list did not replace the lower sequence: %#v", precedence.Config)
|
||||
}
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
load func(configkit.Source) error
|
||||
}{
|
||||
{name: "ordinary", load: func(source configkit.Source) error {
|
||||
_, loadErr := configkit.Load(source, configkit.Options[map[string]any]{PlacementMode: configkit.PlacementMode(9)})
|
||||
return loadErr
|
||||
}},
|
||||
{name: "layered", load: func(source configkit.Source) error {
|
||||
_, loadErr := configkit.LoadLayered[map[string]any](configkit.Input{Source: source, PlacementMode: configkit.PlacementMode(9)})
|
||||
return loadErr
|
||||
}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
reader := &destinationCountingReader{}
|
||||
err := test.load(configkit.Reader("unread", reader))
|
||||
loadError, ok := err.(*configkit.LoadError)
|
||||
if !ok || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != configkit.CodeOptionsInvalid || reader.reads != 0 {
|
||||
t.Fatalf("invalid placement result = %v, reads = %d", err, reader.reads)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeItemImportGlobComposesWithAuthoredSequence(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := filepath.Join(directory, "main.yml")
|
||||
if err := os.WriteFile(mainPath, []byte(`configkit:
|
||||
imports:
|
||||
- glob: records/*.yml
|
||||
destination: [items]
|
||||
placement: sequence-item
|
||||
items:
|
||||
- id: authored
|
||||
name: authored item
|
||||
`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, id := range map[string]string{"b.yml": "imported-b", "a.yml": "imported-a"} {
|
||||
if err := os.MkdirAll(filepath.Join(directory, "records"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(directory, "records", name), []byte("id: "+id+"\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
var callbackSources []configkit.SourceInfo
|
||||
var callbackContexts []configkit.ResourceContext
|
||||
result, err := configkit.Load(configkit.File(mainPath), configkit.Options[map[string]any]{
|
||||
Imports: true,
|
||||
Definition: configkit.Definition[map[string]any]{
|
||||
SequenceCompositions: []configkit.SequenceComposition{{Path: configkit.Keys("items"), IdentityField: "id"}},
|
||||
Resolve: func(_ *map[string]any, resolution *configkit.Resolution) error {
|
||||
for _, id := range []string{"source-2", "source-3"} {
|
||||
source, ok := resolution.Source(id)
|
||||
if !ok {
|
||||
t.Errorf("resolver could not query %s", id)
|
||||
continue
|
||||
}
|
||||
context, found := resolution.SourceContext(id)
|
||||
if !found || context.FS == nil || context.BaseDir != "records" {
|
||||
t.Errorf("resolver context for %s = %#v, %v", id, context, found)
|
||||
}
|
||||
callbackSources = append(callbackSources, source)
|
||||
callbackContexts = append(callbackContexts, context)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("load item glob: %v", err)
|
||||
}
|
||||
items := result.Config["items"].([]any)
|
||||
wantIDs := []string{"imported-a", "imported-b", "authored"}
|
||||
for index, item := range items {
|
||||
if got := item.(map[string]any)["id"]; got != wantIDs[index] {
|
||||
t.Errorf("item %d ID = %#v, want %q", index, got, wantIDs[index])
|
||||
}
|
||||
}
|
||||
if len(callbackSources) != 2 || len(callbackContexts) != 2 {
|
||||
t.Fatalf("callback snapshots = %#v, contexts %#v", callbackSources, callbackContexts)
|
||||
}
|
||||
for index, source := range callbackSources {
|
||||
if source.PlacementMode != configkit.PlacementModeSequenceItem || source.Destination.String() != "/items" || source.DestinationUnresolved {
|
||||
t.Errorf("callback source = %#v", source)
|
||||
}
|
||||
if callbackContexts[index].FS == nil || callbackContexts[index].BaseDir != "records" {
|
||||
t.Errorf("callback context = %#v", callbackContexts[index])
|
||||
}
|
||||
}
|
||||
metadata := result.Metadata()
|
||||
for index := 0; index < 2; index++ {
|
||||
path := configkit.Keys("items").Index(index).Key("id")
|
||||
info, ok := metadata.Lookup(path)
|
||||
if !ok || !info.Explicit || len(info.Origin.Locations) != 1 || info.Origin.Locations[0].SourceID != []string{"source-2", "source-3"}[index] {
|
||||
t.Errorf("shifted item origin at %s = %#v, %v", path.String(), info, ok)
|
||||
}
|
||||
}
|
||||
if got := result.Sources(); len(got) != 3 || got[1].Destination.String() != "/items" || got[1].DestinationUnresolved {
|
||||
t.Fatalf("final item sources = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeItemDestinationFallbackEvidenceSurvivesFailures(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := filepath.Join(directory, "main.yml")
|
||||
helperPath := filepath.Join(directory, "helper.yml")
|
||||
if err := os.WriteFile(mainPath, []byte("configkit: {imports: [{path: helper.yml, destination: [metadata]}]}\nid: main\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(helperPath, []byte("value: [unterminated\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := configkit.Load(configkit.File(mainPath), configkit.Options[map[string]any]{
|
||||
Imports: true, Destination: configkit.Keys("items"), PlacementMode: configkit.PlacementModeSequenceItem,
|
||||
})
|
||||
loadError, ok := err.(*configkit.LoadError)
|
||||
if !ok {
|
||||
t.Fatalf("early failure = %v", err)
|
||||
}
|
||||
sources := loadError.Sources()
|
||||
if len(sources) != 2 || sources[0].Destination.String() != "/items" || sources[0].DestinationUnresolved ||
|
||||
sources[0].PlacementMode != configkit.PlacementModeSequenceItem ||
|
||||
sources[1].Destination.String() != "/items" || !sources[1].DestinationUnresolved ||
|
||||
sources[1].PlacementMode != configkit.PlacementModeMapping {
|
||||
t.Fatalf("early source snapshots = %#v", sources)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(helperPath, []byte("value: invalid-for-typed-schema\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
type item struct {
|
||||
ID string `yaml:"id"`
|
||||
Metadata struct {
|
||||
Value int `yaml:"value"`
|
||||
} `yaml:"metadata"`
|
||||
}
|
||||
type configuration struct {
|
||||
Items []item `yaml:"items"`
|
||||
}
|
||||
_, err = configkit.Load(configkit.File(mainPath), configkit.Options[configuration]{
|
||||
Imports: true, Destination: configkit.Keys("items"), PlacementMode: configkit.PlacementModeSequenceItem,
|
||||
})
|
||||
loadError, ok = err.(*configkit.LoadError)
|
||||
if !ok {
|
||||
t.Fatalf("post-placement failure = %v", err)
|
||||
}
|
||||
sources = loadError.Sources()
|
||||
if len(sources) != 2 || sources[0].Destination.String() != "/items" || sources[0].DestinationUnresolved ||
|
||||
sources[1].Destination.String() != "/items/0/metadata" || sources[1].DestinationUnresolved {
|
||||
t.Fatalf("post-placement source snapshots = %#v", sources)
|
||||
}
|
||||
firstSnapshot := sources[1]
|
||||
sources[1].Destination = configkit.Keys("mutated")
|
||||
if again := loadError.Sources(); len(again) != 2 || again[1].Destination.String() != firstSnapshot.Destination.String() || again[1].DestinationUnresolved {
|
||||
t.Fatalf("failure source snapshots were aliased: %#v", again)
|
||||
}
|
||||
|
||||
var callback configkit.SourceInfo
|
||||
_, err = configkit.Load(configkit.File(mainPath), configkit.Options[map[string]any]{
|
||||
Imports: true, Destination: configkit.Keys("items"), PlacementMode: configkit.PlacementModeSequenceItem,
|
||||
Definition: configkit.Definition[map[string]any]{Resolve: func(_ *map[string]any, resolution *configkit.Resolution) error {
|
||||
callback, _ = resolution.Source("source-2")
|
||||
return nil
|
||||
}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("successful item load: %v", err)
|
||||
}
|
||||
if callback.Destination.String() != "/items/0/metadata" || callback.DestinationUnresolved || callback.PlacementMode != configkit.PlacementModeMapping {
|
||||
t.Fatalf("callback source snapshot = %#v", callback)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeNestedItemFallbackUsesNearestResolvedContainer(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := filepath.Join(directory, "main.yml")
|
||||
childPath := filepath.Join(directory, "child.yml")
|
||||
helperPath := filepath.Join(directory, "helper.yml")
|
||||
if err := os.WriteFile(mainPath, []byte("configkit: {imports: [{path: child.yml, destination: [children], placement: sequence-item}]}\nid: parent\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(childPath, []byte("configkit: {imports: [{path: helper.yml, destination: [metadata]}]}\nid: child\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(helperPath, []byte("value: [unterminated\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := configkit.Load(configkit.File(mainPath), configkit.Options[map[string]any]{
|
||||
Imports: true, Destination: configkit.Keys("groups"), PlacementMode: configkit.PlacementModeSequenceItem,
|
||||
})
|
||||
loadError, ok := err.(*configkit.LoadError)
|
||||
if !ok {
|
||||
t.Fatalf("nested acquisition failure = %v", err)
|
||||
}
|
||||
sources := loadError.Sources()
|
||||
if len(sources) != 3 {
|
||||
t.Fatalf("nested source snapshots = %#v", sources)
|
||||
}
|
||||
if got := sources[0]; got.Destination.String() != "/groups" || got.DestinationUnresolved || got.PlacementMode != configkit.PlacementModeSequenceItem {
|
||||
t.Errorf("outer item snapshot = %#v", got)
|
||||
}
|
||||
if got := sources[1]; got.Destination.String() != "/groups" || !got.DestinationUnresolved || got.PlacementMode != configkit.PlacementModeSequenceItem {
|
||||
t.Errorf("nested item fallback = %#v", got)
|
||||
}
|
||||
if got := sources[2]; got.Destination.String() != "/groups" || !got.DestinationUnresolved || got.PlacementMode != configkit.PlacementModeMapping {
|
||||
t.Errorf("nested helper fallback = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeSequenceCompositionRegistrationPathIsDetached(t *testing.T) {
|
||||
registrations := []configkit.SequenceComposition{{Path: configkit.Keys("records"), IdentityField: "id"}}
|
||||
definition := configkit.Definition[map[string]any]{
|
||||
SequenceCompositions: registrations,
|
||||
Version: &configkit.VersionRule{
|
||||
Path: configkit.Keys("version"),
|
||||
Check: func(configkit.Version, *configkit.Validation) error {
|
||||
registrations[0].Path = configkit.Keys("other")
|
||||
registrations[0].IdentityField = "changed"
|
||||
return nil
|
||||
},
|
||||
},
|
||||
}
|
||||
_, err := configkit.Load(configkit.Mapping("registration", map[string]any{
|
||||
"version": "1",
|
||||
"records": []any{map[string]any{"id": "same"}, map[string]any{"id": "same"}},
|
||||
}), configkit.Options[map[string]any]{
|
||||
Definition: definition, SourceVersionMode: configkit.SourceVersionModeIfPresent,
|
||||
})
|
||||
loadError, ok := err.(*configkit.LoadError)
|
||||
if !ok {
|
||||
t.Fatalf("duplicate registered identity = %v", err)
|
||||
}
|
||||
for _, diagnostic := range loadError.Diagnostics() {
|
||||
if diagnostic.Code == configkit.CodeComposeConflict {
|
||||
return
|
||||
}
|
||||
}
|
||||
t.Fatalf("registration mutation changed the in-flight policy: %#v", loadError.Diagnostics())
|
||||
}
|
||||
|
||||
func TestFacadeSourceInfoPlacementCopiesAndZeroValues(t *testing.T) {
|
||||
zero := configkit.SourceInfo{}
|
||||
if zero.PlacementMode != configkit.PlacementModeMapping || zero.DestinationUnresolved {
|
||||
t.Fatalf("zero source info = %#v", zero)
|
||||
}
|
||||
loaded, err := configkit.Load[map[string]any](configkit.Mapping("mapping", map[string]any{"value": true}))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sources := loaded.Sources()
|
||||
if len(sources) != 1 || sources[0].PlacementMode != configkit.PlacementModeMapping || sources[0].DestinationUnresolved {
|
||||
t.Fatalf("mapping source evidence = %#v", sources)
|
||||
}
|
||||
first := sources[0]
|
||||
first.Destination = configkit.Keys("caller")
|
||||
if again := loaded.Sources(); len(again) != 1 || again[0].Destination.String() != "" || again[0].DestinationUnresolved {
|
||||
t.Fatalf("source evidence changed after caller mutation: %#v", again)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeSequenceReplacementKeepsTypedAndHistoricalEvidenceAligned(t *testing.T) {
|
||||
type record struct {
|
||||
ID string `yaml:"id"`
|
||||
Count int `yaml:"count"`
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Empty string `yaml:"empty"`
|
||||
Nullable *string `yaml:"nullable"`
|
||||
Token string `yaml:"token"`
|
||||
Omitted string `yaml:"omitted"`
|
||||
}
|
||||
type configuration struct {
|
||||
Entries []record `yaml:"entries"`
|
||||
}
|
||||
|
||||
directory := t.TempDir()
|
||||
mainPath := filepath.Join(directory, "main.yml")
|
||||
underlayPath := filepath.Join(directory, "underlay.yml")
|
||||
if err := os.WriteFile(mainPath, []byte(`entries:
|
||||
- id: current
|
||||
count: 0
|
||||
enabled: false
|
||||
empty: ""
|
||||
nullable: null
|
||||
token: CURRENT-SECRET-CANARY
|
||||
`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(underlayPath, []byte(`configkit:
|
||||
imports:
|
||||
- glob: records/*.yml
|
||||
destination: [entries]
|
||||
placement: sequence-item
|
||||
`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
recordsDir := filepath.Join(directory, "records")
|
||||
if err := os.MkdirAll(recordsDir, 0o700); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for name, body := range map[string]string{
|
||||
"a.yml": "id: old-a\ntoken: OLD-SECRET-CANARY-A\n",
|
||||
"b.yml": "id: old-b\ntoken: OLD-SECRET-CANARY-B\n",
|
||||
} {
|
||||
if err := os.WriteFile(filepath.Join(recordsDir, name), []byte(body), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
var callbackItems []configkit.SourceInfo
|
||||
var callbackContexts []configkit.ResourceContext
|
||||
result, err := configkit.LoadLayered(configkit.Input{Source: configkit.File(mainPath)}, configkit.LayeredOptions[configuration]{
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.File(underlayPath), Imports: true}}}},
|
||||
Definition: configkit.Definition[configuration]{
|
||||
SequenceCompositions: []configkit.SequenceComposition{{Path: configkit.Keys("entries"), IdentityField: "id"}},
|
||||
Secret: func(path configkit.Path) bool {
|
||||
return path.String() == "/entries/0/token" || path.String() == "/entries/1/token"
|
||||
},
|
||||
Resolve: func(value *configuration, resolution *configkit.Resolution) error {
|
||||
for _, field := range []string{"count", "enabled", "empty", "nullable"} {
|
||||
path := configkit.Keys("entries").Index(0).Key(field)
|
||||
if !resolution.IsExplicit(path) {
|
||||
t.Errorf("resolver did not see explicit %s", path.String())
|
||||
}
|
||||
}
|
||||
if resolution.IsExplicit(configkit.Keys("entries").Index(0).Key("omitted")) {
|
||||
t.Error("resolver treated an omitted field as explicit")
|
||||
}
|
||||
info, found := resolution.InputInfo(configkit.Keys("entries").Index(0).Key("count"))
|
||||
if !found || info.Origin.Layer != configkit.LayerBase {
|
||||
t.Errorf("resolver current-record input info = %#v, %v", info, found)
|
||||
}
|
||||
for _, id := range []string{"source-3", "source-4"} {
|
||||
source, ok := resolution.Source(id)
|
||||
if !ok {
|
||||
t.Errorf("resolver could not query %s", id)
|
||||
continue
|
||||
}
|
||||
context, contextOK := resolution.SourceContext(id)
|
||||
if !contextOK || context.FS == nil || context.BaseDir != "records" {
|
||||
t.Errorf("resolver context for %s = %#v, %v", id, context, contextOK)
|
||||
}
|
||||
callbackItems = append(callbackItems, source)
|
||||
callbackContexts = append(callbackContexts, context)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
if loadError, ok := err.(*configkit.LoadError); ok {
|
||||
t.Fatalf("layered item replacement: %#v", loadError.Diagnostics())
|
||||
}
|
||||
t.Fatalf("layered item replacement: %v", err)
|
||||
}
|
||||
if len(result.Config.Entries) != 1 || result.Config.Entries[0].ID != "current" || result.Config.Entries[0].Count != 0 ||
|
||||
result.Config.Entries[0].Enabled || result.Config.Entries[0].Empty != "" || result.Config.Entries[0].Nullable != nil {
|
||||
t.Fatalf("typed replacement result = %#v", result.Config)
|
||||
}
|
||||
if len(callbackItems) != 2 || len(callbackContexts) != 2 {
|
||||
t.Fatalf("callback sources = %#v; contexts = %#v", callbackItems, callbackContexts)
|
||||
}
|
||||
for _, source := range callbackItems {
|
||||
if source.PlacementMode != configkit.PlacementModeSequenceItem || source.Destination.String() != "/entries" || source.DestinationUnresolved {
|
||||
t.Errorf("callback item source = %#v", source)
|
||||
}
|
||||
}
|
||||
metadata := result.Metadata()
|
||||
for _, field := range []string{"count", "enabled", "empty", "nullable"} {
|
||||
path := configkit.Keys("entries").Index(0).Key(field)
|
||||
if info, ok := metadata.Lookup(path); !ok || !info.Explicit || (field == "nullable") != info.Null {
|
||||
t.Errorf("explicit metadata at %s = %#v, %v", path.String(), info, ok)
|
||||
}
|
||||
}
|
||||
if info, ok := metadata.Lookup(configkit.Keys("entries").Index(0).Key("omitted")); ok && info.Explicit {
|
||||
t.Errorf("metadata marked an omitted zero-valued field explicit: %#v", info)
|
||||
}
|
||||
mainID := ""
|
||||
for _, source := range result.Sources() {
|
||||
if source.Path == mainPath {
|
||||
mainID = source.ID
|
||||
}
|
||||
}
|
||||
currentOrigin, ok := metadata.Lookup(configkit.Keys("entries").Index(0).Key("id"))
|
||||
if !ok || len(currentOrigin.Origin.Locations) != 1 || currentOrigin.Origin.Locations[0].SourceID != mainID || mainID == "" {
|
||||
t.Fatalf("replacement origin = %#v, main source %q", currentOrigin, mainID)
|
||||
}
|
||||
oldRecord, ok := result.Explain(configkit.Keys("entries").Index(1).Key("token"))
|
||||
if !ok || oldRecord.Effective.Present {
|
||||
t.Fatalf("removed appended item explanation = %#v, %v", oldRecord, ok)
|
||||
}
|
||||
maskedHistory := false
|
||||
for _, change := range oldRecord.History {
|
||||
maskedHistory = maskedHistory || change.Before.Redacted || change.After.Redacted
|
||||
}
|
||||
if !maskedHistory {
|
||||
t.Fatal("removed appended item lost historical sensitivity")
|
||||
}
|
||||
var effective bytes.Buffer
|
||||
if err := result.WriteEffective(&effective); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var explanation bytes.Buffer
|
||||
if err := result.WriteExplanation(&explanation, configkit.Keys("entries").Index(1).Key("token")); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, output := range []string{effective.String(), explanation.String()} {
|
||||
for _, canary := range []string{"CURRENT-SECRET-CANARY", "OLD-SECRET-CANARY-A", "OLD-SECRET-CANARY-B"} {
|
||||
if strings.Contains(output, canary) {
|
||||
t.Fatalf("sensitive sequence value leaked: %s", output)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
893
facade_source_version_mode_test.go
Normal file
893
facade_source_version_mode_test.go
Normal file
@@ -0,0 +1,893 @@
|
||||
package configkit_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||
)
|
||||
|
||||
func TestFacadeSourceVersionEvidenceFollowsAssembledSequenceRanges(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, grouped := range []bool{false, true} {
|
||||
index := 0
|
||||
if grouped {
|
||||
index = 1
|
||||
}
|
||||
for _, sensitivePath := range []string{"", fmt.Sprintf("/records/%d/version", index), fmt.Sprintf("/records/%d", index)} {
|
||||
t.Run(fmt.Sprintf("grouped=%t/secret=%s", grouped, sensitivePath), func(t *testing.T) {
|
||||
definition := configkit.Definition[map[string]any]{
|
||||
SequenceCompositions: []configkit.SequenceComposition{{Path: configkit.Keys("records"), IdentityField: "id"}},
|
||||
Version: &configkit.VersionRule{Path: configkit.Keys("records").Index(0).Key("version"), Check: func(configkit.Version, *configkit.Validation) error { return nil }},
|
||||
Secret: func(path configkit.Path) bool { return sensitivePath != "" && path.String() == sensitivePath },
|
||||
}
|
||||
first := "records: [{id: first, version: first-version}]\n"
|
||||
child := "records: [{id: child, version: PRIVATE-VERSION-CANARY}]\n"
|
||||
var result *configkit.Result[map[string]any]
|
||||
var err error
|
||||
if grouped {
|
||||
result, err = configkit.LoadLayered(configkit.Input{Source: configkit.Bytes("main", []byte(first))}, configkit.LayeredOptions[map[string]any]{
|
||||
Definition: definition, SourceVersionMode: configkit.SourceVersionModeRequired,
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{
|
||||
{Source: configkit.Bytes("first", []byte(first))}, {Source: configkit.Bytes("child", []byte(child))},
|
||||
}}},
|
||||
})
|
||||
} else {
|
||||
filesystem := configkit.NewFilesystem("versions", fstest.MapFS{
|
||||
"main.yml": {Data: []byte("configkit: {imports: [{path: child.yml}]}\n" + first)},
|
||||
"child.yml": {Data: []byte(child)},
|
||||
})
|
||||
result, err = configkit.Load(filesystem.File("main.yml"), configkit.Options[map[string]any]{
|
||||
Definition: definition, Imports: true, SourceVersionMode: configkit.SourceVersionModeRequired,
|
||||
})
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !grouped {
|
||||
records, ok := result.Config["records"].([]any)
|
||||
if !ok || len(records) != 2 {
|
||||
t.Fatalf("scheduled records = %#v", result.Config["records"])
|
||||
}
|
||||
firstRecord, ok := records[0].(map[string]any)
|
||||
if !ok || firstRecord["id"] != "child" {
|
||||
t.Fatalf("scheduled records = %#v", result.Config["records"])
|
||||
}
|
||||
}
|
||||
sources := result.Sources()
|
||||
check := sources[len(sources)-1].VersionCheck
|
||||
if check == nil || check.Path.String() != "/records/0/version" || check.Location == nil || check.Location.SourceID != sources[len(sources)-1].ID {
|
||||
t.Fatalf("source-local evidence changed: %#v", check)
|
||||
}
|
||||
if sensitivePath != "" {
|
||||
if !check.Redacted || check.Version.Text != "" {
|
||||
t.Fatal("shifted version evidence was disclosed")
|
||||
}
|
||||
} else if check.Redacted || check.Version.Text != "PRIVATE-VERSION-CANARY" {
|
||||
t.Fatal("unclassified version evidence was lost")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeSourceVersionOwnershipAcceptsContextualIndexes(t *testing.T) {
|
||||
t.Parallel()
|
||||
parsed, err := configkit.ParsePath("/records/0/version")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, grouped := range []bool{false, true} {
|
||||
for _, path := range []configkit.Path{configkit.Keys("records").Index(0).Key("version"), parsed} {
|
||||
definition := configkit.Definition[map[string]any]{
|
||||
SequenceCompositions: []configkit.SequenceComposition{{Path: configkit.Keys("records"), IdentityField: "id"}},
|
||||
Version: &configkit.VersionRule{Path: path, Check: func(configkit.Version, *configkit.Validation) error { return nil }},
|
||||
}
|
||||
first := "records: [{id: first, version: first}]\n"
|
||||
child := "records: [{id: child, version: child}]\n"
|
||||
var result *configkit.Result[map[string]any]
|
||||
if grouped {
|
||||
result, err = configkit.LoadLayered(configkit.Input{Source: configkit.Bytes("main", []byte(first))}, configkit.LayeredOptions[map[string]any]{
|
||||
Definition: definition, SourceVersionMode: configkit.SourceVersionModeRequired,
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.Bytes("lower", []byte(child))}}}},
|
||||
})
|
||||
} else {
|
||||
filesystem := configkit.NewFilesystem("versions", fstest.MapFS{"main.yml": {Data: []byte("configkit: {imports: [{path: child.yml}]}\n" + first)}, "child.yml": {Data: []byte(child)}})
|
||||
result, err = configkit.Load(filesystem.File("main.yml"), configkit.Options[map[string]any]{Definition: definition, SourceVersionMode: configkit.SourceVersionModeRequired, Imports: true})
|
||||
}
|
||||
if err != nil || result == nil {
|
||||
t.Fatalf("grouped=%t path=%#v: %v", grouped, path, err)
|
||||
}
|
||||
want := 2
|
||||
if grouped {
|
||||
want = 1
|
||||
}
|
||||
if len(result.Config["records"].([]any)) != want {
|
||||
t.Fatal("version path changed composition")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeItemMainKeepsDefaultVersionOwnership(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, layered := range []bool{false, true} {
|
||||
for _, protected := range []bool{true, false} {
|
||||
body, helper := "id: main\n", "version: imported-version\n"
|
||||
if !protected {
|
||||
body += "version: main-version\n"
|
||||
helper = "other: {version: permitted}\n"
|
||||
}
|
||||
filesystem := configkit.NewFilesystem("item-main", fstest.MapFS{
|
||||
"main.yml": {Data: []byte(body + "configkit: {imports: [{path: helper.yml}]}\n")}, "helper.yml": {Data: []byte(helper)},
|
||||
})
|
||||
checks := 0
|
||||
definition := configkit.Definition[map[string]any]{Version: &configkit.VersionRule{
|
||||
Path: configkit.Keys("records").Index(0).Key("version"),
|
||||
Check: func(version configkit.Version, _ *configkit.Validation) error {
|
||||
checks++
|
||||
if version.Text != "main-version" {
|
||||
t.Error("helper supplied main version")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}}
|
||||
var err error
|
||||
if layered {
|
||||
_, err = configkit.LoadLayered[map[string]any](configkit.Input{Source: filesystem.File("main.yml"), Imports: true, Destination: configkit.Keys("records"), PlacementMode: configkit.PlacementModeSequenceItem}, configkit.LayeredOptions[map[string]any]{Definition: definition})
|
||||
} else {
|
||||
_, err = configkit.Load(filesystem.File("main.yml"), configkit.Options[map[string]any]{Definition: definition, Imports: true, Destination: configkit.Keys("records"), PlacementMode: configkit.PlacementModeSequenceItem})
|
||||
}
|
||||
if protected {
|
||||
loadError, ok := err.(*configkit.LoadError)
|
||||
if !ok || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != configkit.CodeImportVersion || loadError.Diagnostics()[0].Path.String() != "/records/0/version" || checks != 0 {
|
||||
t.Fatalf("layered=%t ownership error = %v, checks=%d", layered, err, checks)
|
||||
}
|
||||
} else if err != nil || checks != 1 {
|
||||
t.Fatalf("unrelated helper version rejected: %v, checks=%d", err, checks)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type sourceVersionModeReader struct {
|
||||
*strings.Reader
|
||||
reads int
|
||||
}
|
||||
|
||||
func TestFacadeSourceVersionEvidenceRedactsCollidingShapes(t *testing.T) {
|
||||
t.Parallel()
|
||||
type header struct {
|
||||
Version string `yaml:"version"`
|
||||
}
|
||||
type secretHeader struct {
|
||||
Version string `yaml:"version" configkit:"secret"`
|
||||
}
|
||||
type configuration struct {
|
||||
Meta map[string]header `yaml:"meta"`
|
||||
Data struct {
|
||||
Meta map[string]secretHeader `yaml:"meta"`
|
||||
} `yaml:"data"`
|
||||
}
|
||||
path, err := configkit.ParsePath("/meta/0/version")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, reverse := range []bool{false, true} {
|
||||
groups := []configkit.Underlay{
|
||||
{Inputs: []configkit.Input{{
|
||||
Source: configkit.Bytes("mapping", []byte("meta: {\"0\": {version: PRIVATE-HEADER-CANARY}}\n")),
|
||||
Destination: configkit.Keys("data"),
|
||||
}}},
|
||||
{Inputs: []configkit.Input{{
|
||||
Source: configkit.Bytes("sequence", []byte("meta: [{version: another-header}]\n")),
|
||||
Destination: configkit.Keys("data"),
|
||||
}}},
|
||||
}
|
||||
if reverse {
|
||||
groups[0], groups[1] = groups[1], groups[0]
|
||||
}
|
||||
loaded, err := configkit.LoadLayered(configkit.Input{
|
||||
Source: configkit.Bytes("main", []byte("meta: {\"0\": {version: main}}\ndata: {meta: null}\n")),
|
||||
}, configkit.LayeredOptions[configuration]{
|
||||
Definition: configkit.Definition[configuration]{Version: &configkit.VersionRule{
|
||||
Path: path, Check: func(configkit.Version, *configkit.Validation) error { return nil },
|
||||
}},
|
||||
SourceVersionMode: configkit.SourceVersionModeRequired, Underlays: groups,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if loaded.Config.Meta["0"].Version != "main" || loaded.Config.Data.Meta != nil {
|
||||
t.Fatalf("reverse=%v: final payload changed: %#v", reverse, loaded.Config)
|
||||
}
|
||||
foundMapping := false
|
||||
for _, source := range loaded.Sources() {
|
||||
if source.Label != "mapping" {
|
||||
continue
|
||||
}
|
||||
foundMapping = true
|
||||
if check := source.VersionCheck; check == nil || !check.Version.Present || !check.Redacted || check.Version.Text != "" {
|
||||
t.Errorf("reverse=%v: schema-tagged header was not redacted", reverse)
|
||||
}
|
||||
}
|
||||
if !foundMapping {
|
||||
t.Fatal("missing mapping source record")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeSourceVersionModesPreserveImportOptIn(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, mode := range []configkit.SourceVersionMode{configkit.SourceVersionModeIfPresent, configkit.SourceVersionModeRequired} {
|
||||
for _, underlay := range []bool{false, true} {
|
||||
bad := configkit.Bytes("disabled-imports", []byte("version: accepted\nconfigkit: {imports: [{path: missing.yml}]}\nvalue: visible\n"))
|
||||
definition := configkit.Definition[map[string]any]{Version: &configkit.VersionRule{
|
||||
Path: configkit.Keys("version"), Check: func(configkit.Version, *configkit.Validation) error { return nil },
|
||||
}}
|
||||
var err error
|
||||
var resultPresent bool
|
||||
wantSource := "source-1"
|
||||
if underlay {
|
||||
loaded, loadErr := configkit.LoadLayered(configkit.Input{Source: configkit.Bytes("main", []byte("version: main\n"))}, configkit.LayeredOptions[map[string]any]{
|
||||
Definition: definition, SourceVersionMode: mode,
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: bad}}}},
|
||||
})
|
||||
err, resultPresent, wantSource = loadErr, loaded != nil, "source-2"
|
||||
} else {
|
||||
loaded, loadErr := configkit.Load(bad, configkit.Options[map[string]any]{Definition: definition, SourceVersionMode: mode})
|
||||
err, resultPresent = loadErr, loaded != nil
|
||||
}
|
||||
var failure *configkit.LoadError
|
||||
if resultPresent || !errors.As(err, &failure) {
|
||||
t.Errorf("mode=%v underlay=%v: import opt-in did not fail safely", mode, underlay)
|
||||
continue
|
||||
}
|
||||
diagnostics := failure.Diagnostics()
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeImportDisabled || diagnostics[0].Path.String() != "/configkit" ||
|
||||
diagnostics[0].Primary == nil || diagnostics[0].Primary.SourceID != wantSource {
|
||||
t.Errorf("mode=%v underlay=%v: diagnostics=%#v", mode, underlay, diagnostics)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeSourceVersionAbsenceKeepsCompleteEvidencePath(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, test := range []struct {
|
||||
name, main, underlay string
|
||||
path configkit.Path
|
||||
}{
|
||||
{"missing member", "meta: {version: main}\n", "{}\n", configkit.Keys("meta", "version")},
|
||||
{"scalar obstruction", "meta: {version: main}\n", "meta: false\n", configkit.Keys("meta", "version")},
|
||||
{"unavailable index", "meta: [null, null, null, {version: main}]\n", "meta: []\n", configkit.Keys("meta").Index(3).Key("version")},
|
||||
} {
|
||||
for _, mode := range []configkit.SourceVersionMode{configkit.SourceVersionModeIfPresent, configkit.SourceVersionModeRequired} {
|
||||
var callbackEvidence *configkit.SourceVersionCheck
|
||||
loaded, err := configkit.LoadLayered(configkit.Input{Source: configkit.Bytes("main", []byte(test.main))}, configkit.LayeredOptions[map[string]any]{
|
||||
SourceVersionMode: mode,
|
||||
Definition: configkit.Definition[map[string]any]{
|
||||
Version: &configkit.VersionRule{Path: test.path, Check: func(configkit.Version, *configkit.Validation) error { return nil }},
|
||||
Resolve: func(_ *map[string]any, resolution *configkit.Resolution) error {
|
||||
source, found := resolution.Source("source-2")
|
||||
if !found {
|
||||
t.Fatal("missing callback source")
|
||||
}
|
||||
callbackEvidence = source.VersionCheck
|
||||
return nil
|
||||
},
|
||||
},
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{
|
||||
Source: configkit.Bytes("underlay", []byte(test.underlay)), Destination: configkit.Keys("under"),
|
||||
}}}},
|
||||
})
|
||||
var evidence *configkit.SourceVersionCheck
|
||||
if mode == configkit.SourceVersionModeRequired {
|
||||
var failure *configkit.LoadError
|
||||
if loaded != nil || !errors.As(err, &failure) {
|
||||
t.Fatalf("%s: required omission did not fail", test.name)
|
||||
}
|
||||
diagnostics := failure.Diagnostics()
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeVersionError || diagnostics[0].Path.String() != test.path.String() {
|
||||
t.Fatalf("%s: required diagnostics=%#v", test.name, diagnostics)
|
||||
}
|
||||
evidence = failure.Sources()[1].VersionCheck
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Fatalf("%s: %v", test.name, err)
|
||||
}
|
||||
evidence = loaded.Sources()[1].VersionCheck
|
||||
if callbackEvidence == nil || callbackEvidence.Path.String() != test.path.String() {
|
||||
t.Errorf("%s: callback path is incomplete", test.name)
|
||||
}
|
||||
}
|
||||
if evidence == nil || evidence.Version.Present || evidence.Path.String() != test.path.String() ||
|
||||
evidence.Location == nil || evidence.Location.SourceID != "source-2" || evidence.Location.Line != 1 {
|
||||
t.Errorf("%s mode=%v: absence evidence=%#v", test.name, mode, evidence)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeSourceVersionMissingHeaderDoesNotOverlapControl(t *testing.T) {
|
||||
t.Parallel()
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
mode configkit.SourceVersionMode
|
||||
control string
|
||||
wantCode configkit.Code
|
||||
}{
|
||||
{"optional omission", configkit.SourceVersionModeIfPresent, "base", ""},
|
||||
{"required omission", configkit.SourceVersionModeRequired, "base", configkit.CodeVersionError},
|
||||
{"invalid control", configkit.SourceVersionModeIfPresent, "true", configkit.CodeInheritanceInvalid},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
main := configkit.Bytes("main", []byte("{}\n"))
|
||||
if test.mode == configkit.SourceVersionModeRequired {
|
||||
main = configkit.NoSource()
|
||||
}
|
||||
checks := 0
|
||||
path := configkit.Keys("entries", "child", "extends", "version")
|
||||
loaded, err := configkit.LoadLayered(configkit.Input{Source: main, Destination: configkit.Keys("main")}, configkit.LayeredOptions[map[string]any]{
|
||||
SourceVersionMode: test.mode,
|
||||
Definition: configkit.Definition[map[string]any]{Version: &configkit.VersionRule{
|
||||
Path: path, Check: func(version configkit.Version, _ *configkit.Validation) error {
|
||||
checks++
|
||||
if version.Present {
|
||||
t.Error("checker received a nonexistent declaration")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}},
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{
|
||||
Source: configkit.Bytes("underlay", []byte("entries:\n base: {value: good}\n child: {extends: "+test.control+"}\n")), Destination: configkit.Keys("under"),
|
||||
}}}},
|
||||
})
|
||||
if checks != 1 {
|
||||
t.Errorf("checker ran %d times, want absent main only", checks)
|
||||
}
|
||||
if test.wantCode != "" {
|
||||
var failure *configkit.LoadError
|
||||
if loaded != nil || !errors.As(err, &failure) {
|
||||
t.Fatal("invalid input did not fail safely")
|
||||
}
|
||||
if diagnostics := failure.Diagnostics(); len(diagnostics) != 1 || diagnostics[0].Code != test.wantCode {
|
||||
t.Fatalf("diagnostics=%#v", diagnostics)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
child := loaded.Config["under"].(map[string]any)["entries"].(map[string]any)["child"].(map[string]any)
|
||||
if child["value"] != "good" {
|
||||
t.Fatal("valid sibling inheritance was not applied")
|
||||
}
|
||||
check := loaded.Sources()[1].VersionCheck
|
||||
if check == nil || check.Version.Present || check.Path.String() != path.String() {
|
||||
t.Fatal("optional omission evidence was not retained")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (reader *sourceVersionModeReader) Read(data []byte) (int, error) {
|
||||
reader.reads++
|
||||
return reader.Reader.Read(data)
|
||||
}
|
||||
|
||||
func TestFacadeSourceVersionModesAcrossOrdinaryAndLayeredLoads(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var checked []configkit.Version
|
||||
definition := configkit.Definition[map[string]any]{Version: &configkit.VersionRule{
|
||||
Path: configkit.Keys("meta", "version"),
|
||||
Check: func(version configkit.Version, _ *configkit.Validation) error {
|
||||
checked = append(checked, version)
|
||||
return nil
|
||||
},
|
||||
}}
|
||||
ordinary, err := configkit.Load(configkit.Bytes("ordinary", []byte("meta: {version: ordinary-v}\n")), configkit.Options[map[string]any]{
|
||||
Definition: definition, SourceVersionMode: configkit.SourceVersionModeIfPresent,
|
||||
})
|
||||
if err != nil || ordinary == nil {
|
||||
t.Fatalf("ordinary enabled load = %#v, %v", ordinary, err)
|
||||
}
|
||||
if got := ordinary.Config["meta"].(map[string]any)["version"]; got != "ordinary-v" {
|
||||
t.Fatalf("main declaration was not retained: %#v", ordinary.Config)
|
||||
}
|
||||
|
||||
directory := t.TempDir()
|
||||
mainPath := filepath.Join(directory, "main.yml")
|
||||
if err := os.WriteFile(mainPath, []byte("meta: {version: main-v}\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
layered, err := configkit.LoadLayered(configkit.Input{Source: configkit.File(mainPath)}, configkit.LayeredOptions[map[string]any]{
|
||||
Definition: definition, SourceVersionMode: configkit.SourceVersionModeRequired,
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{
|
||||
Source: configkit.Mapping("underlay", map[string]any{
|
||||
"meta": map[string]any{"version": "underlay-v"}, "fallback": "present",
|
||||
}),
|
||||
Destination: configkit.Keys("defaults"),
|
||||
}}}},
|
||||
})
|
||||
if err != nil || layered == nil {
|
||||
t.Fatalf("layered enabled load = %#v, %v", layered, err)
|
||||
}
|
||||
defaults, ok := layered.Config["defaults"].(map[string]any)
|
||||
if !ok || defaults["fallback"] != "present" {
|
||||
t.Fatalf("layered underlay payload = %#v", layered.Config["defaults"])
|
||||
}
|
||||
if meta, ok := defaults["meta"].(map[string]any); !ok || len(meta) != 0 {
|
||||
t.Fatalf("accepted underlay declaration was not consumed: %#v", defaults["meta"])
|
||||
}
|
||||
if len(checked) != 3 {
|
||||
t.Fatalf("checker calls across separate mode uses = %#v", checked)
|
||||
}
|
||||
for _, want := range []string{"ordinary-v", "main-v", "underlay-v"} {
|
||||
found := false
|
||||
for _, version := range checked {
|
||||
found = found || version.Present && version.Text == want
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("checked versions %#v do not include %q", checked, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeSourceVersionModeZeroPreservesDefaultBehavior(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
checks := 0
|
||||
result, err := configkit.Load(configkit.Bytes("default", []byte("version: v1\n")), configkit.Options[map[string]any]{
|
||||
Definition: configkit.Definition[map[string]any]{Version: &configkit.VersionRule{
|
||||
Path: configkit.Keys("version"),
|
||||
Check: func(version configkit.Version, _ *configkit.Validation) error {
|
||||
checks++
|
||||
if version != (configkit.Version{Present: true, Text: "v1"}) {
|
||||
t.Errorf("default version = %#v", version)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}},
|
||||
})
|
||||
if err != nil || result == nil || result.Config["version"] != "v1" || checks != 1 {
|
||||
t.Fatalf("zero-mode load = %#v, %v; checks = %d", result, err, checks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeSourceVersionModeValidationPrecedesSourceReads(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
validDefinition := configkit.Definition[map[string]any]{Version: &configkit.VersionRule{
|
||||
Path: configkit.Keys("version"), Check: func(configkit.Version, *configkit.Validation) error { return nil },
|
||||
}}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
mode configkit.SourceVersionMode
|
||||
def configkit.Definition[map[string]any]
|
||||
layered bool
|
||||
}{
|
||||
{name: "unknown ordinary mode", mode: configkit.SourceVersionMode(255), def: validDefinition},
|
||||
{name: "ordinary mode requires checker", mode: configkit.SourceVersionModeIfPresent},
|
||||
{name: "unknown layered mode", mode: configkit.SourceVersionMode(255), def: validDefinition, layered: true},
|
||||
{name: "layered mode requires checker", mode: configkit.SourceVersionModeRequired, layered: true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
reader := &sourceVersionModeReader{Reader: strings.NewReader("version: v1\n")}
|
||||
source := configkit.Reader("version.yml", reader)
|
||||
var err error
|
||||
if test.layered {
|
||||
_, err = configkit.LoadLayered(configkit.Input{Source: source}, configkit.LayeredOptions[map[string]any]{
|
||||
Definition: test.def, SourceVersionMode: test.mode,
|
||||
})
|
||||
} else {
|
||||
_, err = configkit.Load(source, configkit.Options[map[string]any]{
|
||||
Definition: test.def, SourceVersionMode: test.mode,
|
||||
})
|
||||
}
|
||||
var loadError *configkit.LoadError
|
||||
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != configkit.CodeOptionsInvalid {
|
||||
t.Fatalf("invalid mode error = %#v", err)
|
||||
}
|
||||
if reader.reads != 0 {
|
||||
t.Fatalf("invalid options read source %d times", reader.reads)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeSourceVersionDefinitionCanBeReusedWithDifferentModes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
checks := 0
|
||||
definition := configkit.Definition[map[string]any]{Version: &configkit.VersionRule{
|
||||
Path: configkit.Keys("version"),
|
||||
Check: func(version configkit.Version, _ *configkit.Validation) error {
|
||||
checks++
|
||||
if version.Present {
|
||||
t.Errorf("missing declaration reached checker as %#v", version)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}}
|
||||
source := configkit.Bytes("reused", []byte("value: retained\n"))
|
||||
ifPresent, err := configkit.Load(source, configkit.Options[map[string]any]{
|
||||
Definition: definition, SourceVersionMode: configkit.SourceVersionModeIfPresent,
|
||||
})
|
||||
if err != nil || ifPresent == nil || checks != 1 {
|
||||
t.Fatalf("if-present load = %#v, %v; checks = %d", ifPresent, err, checks)
|
||||
}
|
||||
required, err := configkit.Load(source, configkit.Options[map[string]any]{
|
||||
Definition: definition, SourceVersionMode: configkit.SourceVersionModeRequired,
|
||||
})
|
||||
var loadError *configkit.LoadError
|
||||
if required != nil || !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != configkit.CodeVersionError {
|
||||
t.Fatalf("required load = %#v, %v", required, err)
|
||||
}
|
||||
if checks != 1 {
|
||||
t.Fatalf("required absence reached checker: calls = %d", checks)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeSourceVersionEvidenceIsSanitizedAndDetached(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
const canary = "PRIVATE-SOURCE-VERSION-CANARY"
|
||||
directory := t.TempDir()
|
||||
path := filepath.Join(directory, "main.yml")
|
||||
if err := os.WriteFile(path, []byte("version: "+canary+"\nvalue: visible\n"), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var callbackSource configkit.SourceInfo
|
||||
var callbackContext configkit.ResourceContext
|
||||
definition := configkit.Definition[map[string]any]{
|
||||
Version: &configkit.VersionRule{Path: configkit.Keys("version"), Check: func(configkit.Version, *configkit.Validation) error { return nil }},
|
||||
Secret: func(candidate configkit.Path) bool { return candidate.String() == "/service/version" },
|
||||
Resolve: func(_ *map[string]any, resolution *configkit.Resolution) error {
|
||||
var found bool
|
||||
callbackSource, found = resolution.Source("source-1")
|
||||
if !found {
|
||||
t.Fatal("resolution callback could not query the acquired source")
|
||||
}
|
||||
if callbackSource.VersionCheck == nil || !callbackSource.VersionCheck.Redacted || callbackSource.VersionCheck.Version.Text != "" {
|
||||
t.Fatalf("callback source did not conservatively redact evidence: %#v", callbackSource.VersionCheck)
|
||||
}
|
||||
callbackContext, found = resolution.SourceContext("source-1")
|
||||
if !found || callbackContext.FS == nil || callbackContext.BaseDir != "." {
|
||||
t.Fatalf("callback source context = %#v, found=%v", callbackContext, found)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
loaded, err := configkit.LoadLayered(configkit.Input{Source: configkit.File(path), Destination: configkit.Keys("service")}, configkit.LayeredOptions[map[string]any]{
|
||||
Definition: definition, SourceVersionMode: configkit.SourceVersionModeIfPresent,
|
||||
})
|
||||
if err != nil || loaded == nil {
|
||||
t.Fatalf("LoadLayered = %#v, %v", loaded, err)
|
||||
}
|
||||
if callbackSource.VersionCheck == nil || !callbackSource.VersionCheck.Redacted || callbackSource.VersionCheck.Version.Text != "" {
|
||||
t.Fatalf("callback evidence lost its conservative snapshot: %#v", callbackSource.VersionCheck)
|
||||
}
|
||||
callbackSource.VersionCheck.Path = configkit.Keys("mutated-callback")
|
||||
callbackSource.VersionCheck.Version.Text = canary
|
||||
callbackSource.VersionCheck.Location.Line = 98
|
||||
finalSources := loaded.Sources()
|
||||
if len(finalSources) != 1 || finalSources[0].Path != path || finalSources[0].VersionCheck == nil {
|
||||
t.Fatalf("final sources = %#v", finalSources)
|
||||
}
|
||||
check := finalSources[0].VersionCheck
|
||||
if check.Path.String() != "/version" || check.Version != (configkit.Version{Present: true}) || !check.Redacted ||
|
||||
check.Location == nil || check.Location.SourceID != finalSources[0].ID || check.Location.Line != 1 {
|
||||
t.Fatalf("final classified evidence = %#v", check)
|
||||
}
|
||||
if err := os.Remove(path); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if callbackContext.FS == nil || callbackContext.BaseDir != "." {
|
||||
t.Fatalf("resource metadata changed after removing the source: %#v", callbackContext)
|
||||
}
|
||||
check.Path = configkit.Keys("mutated")
|
||||
check.Location.Line = 99
|
||||
check.Redacted = false
|
||||
check.Version.Text = "mutated"
|
||||
repeated := loaded.Sources()
|
||||
if len(repeated) != 1 || repeated[0].VersionCheck.Path.String() != "/version" ||
|
||||
repeated[0].VersionCheck.Location.Line != 1 || !repeated[0].VersionCheck.Redacted ||
|
||||
repeated[0].VersionCheck.Version.Text != "" {
|
||||
t.Fatalf("source evidence was aliased or re-read after source removal: %#v", repeated)
|
||||
}
|
||||
|
||||
publicText := "PUBLIC-SOURCE-VERSION"
|
||||
var publicCallback configkit.SourceInfo
|
||||
public, err := configkit.Load(configkit.Bytes("public", []byte("version: "+publicText+"\n")), configkit.Options[map[string]any]{
|
||||
SourceVersionMode: configkit.SourceVersionModeIfPresent,
|
||||
Definition: configkit.Definition[map[string]any]{
|
||||
Version: definition.Version,
|
||||
Resolve: func(_ *map[string]any, resolution *configkit.Resolution) error {
|
||||
publicCallback, _ = resolution.Source("source-1")
|
||||
return nil
|
||||
},
|
||||
},
|
||||
})
|
||||
if err != nil || public == nil || len(public.Sources()) != 1 || public.Sources()[0].VersionCheck == nil {
|
||||
t.Fatalf("unclassified source evidence = %#v, %v", public, err)
|
||||
}
|
||||
if got := public.Sources()[0].VersionCheck; got.Redacted || got.Version.Text != publicText || !got.Version.Present {
|
||||
t.Fatalf("unclassified evidence = %#v", got)
|
||||
}
|
||||
if publicCallback.VersionCheck == nil || !publicCallback.VersionCheck.Redacted || publicCallback.VersionCheck.Version.Text != "" {
|
||||
t.Fatalf("unclassified callback evidence was not initially conservative: %#v", publicCallback.VersionCheck)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeSourceVersionEvidenceOnFailedLoadsAndSyntheticSources(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
definition := configkit.Definition[map[string]any]{Version: &configkit.VersionRule{
|
||||
Path: configkit.Keys("version"), Check: func(configkit.Version, *configkit.Validation) error { return nil },
|
||||
}}
|
||||
t.Run("early import acquisition failure", func(t *testing.T) {
|
||||
const canary = "EARLY-ACQUISITION-SOURCE-VERSION-CANARY"
|
||||
checked := 0
|
||||
earlyDefinition := configkit.Definition[map[string]any]{Version: &configkit.VersionRule{
|
||||
Path: configkit.Keys("version"), Check: func(configkit.Version, *configkit.Validation) error { checked++; return nil },
|
||||
}}
|
||||
path := filepath.Join(t.TempDir(), "main.yml")
|
||||
contents := "version: " + canary + "\nconfigkit:\n imports:\n - path: missing.yml\n required: true\n"
|
||||
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := configkit.LoadLayered(configkit.Input{Source: configkit.File(path), Imports: true}, configkit.LayeredOptions[map[string]any]{
|
||||
Definition: earlyDefinition, SourceVersionMode: configkit.SourceVersionModeIfPresent,
|
||||
})
|
||||
var failure *configkit.LoadError
|
||||
if !errors.As(err, &failure) || len(failure.Sources()) != 1 {
|
||||
t.Fatalf("early acquisition failure sources = %#v, error %v", failure, err)
|
||||
}
|
||||
if checked != 1 {
|
||||
t.Fatalf("main declaration checker calls = %d; error %v", checked, err)
|
||||
}
|
||||
check := failure.Sources()[0].VersionCheck
|
||||
if check == nil || !check.Redacted || !check.Version.Present || check.Version.Text != "" {
|
||||
t.Fatalf("early acquisition evidence was not conservatively redacted: %#v; sources %#v", check, failure.Sources())
|
||||
}
|
||||
if strings.Contains(err.Error(), canary) {
|
||||
t.Fatalf("early acquisition failure disclosed source-version canary: %v", err)
|
||||
}
|
||||
})
|
||||
_, err := configkit.Load(configkit.Bytes("malformed", []byte("version: true\n")), configkit.Options[map[string]any]{
|
||||
Definition: definition, SourceVersionMode: configkit.SourceVersionModeIfPresent,
|
||||
})
|
||||
var loadError *configkit.LoadError
|
||||
if !errors.As(err, &loadError) {
|
||||
t.Fatalf("malformed version error = %v", err)
|
||||
}
|
||||
sources := loadError.Sources()
|
||||
if len(sources) != 1 || sources[0].VersionCheck == nil || !sources[0].VersionCheck.Redacted ||
|
||||
!sources[0].VersionCheck.Version.Present || sources[0].VersionCheck.Version.Text != "" || sources[0].VersionCheck.Path.String() != "/version" {
|
||||
t.Fatalf("failed-load source evidence = %#v", sources)
|
||||
}
|
||||
sources[0].VersionCheck.Path = configkit.Keys("mutated-error")
|
||||
sources[0].VersionCheck.Version.Text = "mutated"
|
||||
sources[0].VersionCheck.Location.Line = 99
|
||||
if again := loadError.Sources()[0].VersionCheck; again.Path.String() != "/version" || again.Version.Text != "" || again.Location.Line == 99 {
|
||||
t.Fatalf("LoadError source evidence was aliased: %#v", again)
|
||||
}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
def configkit.Definition[map[string]any]
|
||||
canary string
|
||||
}{
|
||||
{
|
||||
name: "resolver failure",
|
||||
canary: "RESOLVER-SOURCE-VERSION-CANARY",
|
||||
def: configkit.Definition[map[string]any]{Version: definition.Version, Resolve: func(*map[string]any, *configkit.Resolution) error {
|
||||
return errors.New("resolver failed")
|
||||
}},
|
||||
},
|
||||
{
|
||||
name: "validator failure",
|
||||
canary: "VALIDATOR-SOURCE-VERSION-CANARY",
|
||||
def: configkit.Definition[map[string]any]{Version: definition.Version, Validate: func(_ *map[string]any, validation *configkit.Validation) {
|
||||
validation.Error(configkit.Keys("value"), "invalid value")
|
||||
}},
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
_, err := configkit.Load(configkit.Bytes("late-failure", []byte("version: "+test.canary+"\nvalue: configured\n")), configkit.Options[map[string]any]{
|
||||
Definition: test.def, SourceVersionMode: configkit.SourceVersionModeIfPresent,
|
||||
})
|
||||
var failure *configkit.LoadError
|
||||
if !errors.As(err, &failure) || len(failure.Sources()) != 1 {
|
||||
t.Fatalf("late failure sources = %#v, error %v", failure, err)
|
||||
}
|
||||
if strings.Contains(err.Error(), test.canary) {
|
||||
t.Fatalf("late failure disclosed source-version canary: %v", err)
|
||||
}
|
||||
check := failure.Sources()[0].VersionCheck
|
||||
if check == nil || !check.Redacted || !check.Version.Present || check.Version.Text != "" {
|
||||
t.Fatalf("late-failure evidence was not conservatively redacted: %#v", check)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
defaultResult, err := configkit.Load(configkit.Bytes("default", []byte("version: 7\n")), configkit.Options[map[string]any]{})
|
||||
if err != nil || defaultResult == nil || len(defaultResult.Sources()) != 1 || defaultResult.Sources()[0].VersionCheck != nil {
|
||||
t.Fatalf("default source version check = %#v, %v", defaultResult, err)
|
||||
}
|
||||
|
||||
withCLI, err := configkit.Load(configkit.Bytes("main", []byte("version: 8\nvalue: old\n")), configkit.Options[map[string]any]{
|
||||
Definition: definition, SourceVersionMode: configkit.SourceVersionModeIfPresent,
|
||||
Overrides: []configkit.Override{{Path: configkit.Keys("value"), Value: "new", Flag: "--value"}},
|
||||
})
|
||||
if err != nil || withCLI == nil {
|
||||
t.Fatalf("enabled load with CLI source = %#v, %v", withCLI, err)
|
||||
}
|
||||
sawCLI := false
|
||||
for _, source := range withCLI.Sources() {
|
||||
if source.Kind == configkit.SourceKindCLI && source.VersionCheck != nil {
|
||||
t.Fatalf("synthetic CLI source acquired declaration evidence: %#v", source)
|
||||
}
|
||||
sawCLI = sawCLI || source.Kind == configkit.SourceKindCLI
|
||||
}
|
||||
if !sawCLI {
|
||||
t.Fatalf("CLI source record missing: %#v", withCLI.Sources())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeSourceVersionEvidencePreservesAbsenceAndNull(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
var checked []configkit.Version
|
||||
loaded, err := configkit.LoadLayered(configkit.Input{Source: configkit.Bytes("main", []byte("version: 17\n"))}, configkit.LayeredOptions[map[string]any]{
|
||||
SourceVersionMode: configkit.SourceVersionModeIfPresent,
|
||||
Definition: configkit.Definition[map[string]any]{Version: &configkit.VersionRule{
|
||||
Path: configkit.Keys("version"),
|
||||
Check: func(version configkit.Version, _ *configkit.Validation) error {
|
||||
checked = append(checked, version)
|
||||
return nil
|
||||
},
|
||||
}},
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{
|
||||
{Source: configkit.Bytes("absent", []byte("value: visible\n")), Destination: configkit.Keys("absent")},
|
||||
{Source: configkit.Bytes("null", []byte("version: null\nvalue: visible\n")), Destination: configkit.Keys("nullable")},
|
||||
}}},
|
||||
})
|
||||
if err != nil || loaded == nil {
|
||||
t.Fatalf("LoadLayered = %#v, %v", loaded, err)
|
||||
}
|
||||
if len(checked) != 2 || !checked[0].Present || checked[0].Text != "17" ||
|
||||
!checked[1].Present || !checked[1].Null {
|
||||
t.Fatalf("checker calls = %#v; absent contribution must skip while null is checked", checked)
|
||||
}
|
||||
sources := loaded.Sources()
|
||||
if len(sources) != 3 || sources[1].VersionCheck == nil || sources[2].VersionCheck == nil {
|
||||
t.Fatalf("source version evidence = %#v", sources)
|
||||
}
|
||||
absent := sources[1].VersionCheck
|
||||
if absent.Version.Present || absent.Path.String() != "/version" || absent.Location == nil || absent.Redacted {
|
||||
t.Fatalf("optional omission evidence = %#v", absent)
|
||||
}
|
||||
null := sources[2].VersionCheck
|
||||
if !null.Version.Present || !null.Version.Null || null.Version.Text != "" || null.Path.String() != "/version" {
|
||||
t.Fatalf("null declaration evidence = %#v", null)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFacadeSourceVersionLifecycleKeepsConsumedHeadersOutOfPayloadHistory(t *testing.T) {
|
||||
type serviceConfig struct {
|
||||
Version uint64 `yaml:"version"`
|
||||
Count int `yaml:"count"`
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Nullable *string `yaml:"nullable"`
|
||||
Revision string `yaml:"revision"`
|
||||
Defaulted string `yaml:"defaulted"`
|
||||
Resolved string `yaml:"resolved"`
|
||||
Credential configkit.Secret `yaml:"credential"`
|
||||
}
|
||||
type configuration struct {
|
||||
Service serviceConfig `yaml:"service"`
|
||||
}
|
||||
t.Setenv("CONFIGKIT_LIFECYCLE_SECRET", "lifecycle-secret-value")
|
||||
var checked []configkit.Version
|
||||
var validationCalls int
|
||||
definition := configkit.Definition[configuration]{
|
||||
Defaults: func() configuration {
|
||||
return configuration{Service: serviceConfig{Defaulted: "from defaults"}}
|
||||
},
|
||||
Version: &configkit.VersionRule{
|
||||
Path: configkit.Keys("version"),
|
||||
Check: func(version configkit.Version, _ *configkit.Validation) error {
|
||||
checked = append(checked, version)
|
||||
return nil
|
||||
},
|
||||
},
|
||||
Resolve: func(current *configuration, resolution *configkit.Resolution) error {
|
||||
current.Service.Resolved = "resolved once"
|
||||
resolution.Record(configkit.Keys("service", "resolved"), configkit.ChangeNormalization, "derive resolved value")
|
||||
return nil
|
||||
},
|
||||
Validate: func(current *configuration, validation *configkit.Validation) {
|
||||
validationCalls++
|
||||
if current.Service.Version != 9007199254740993 || current.Service.Count != 0 || current.Service.Enabled ||
|
||||
current.Service.Nullable != nil || current.Service.Revision != "ordinary-payload" || current.Service.Defaulted != "from defaults" ||
|
||||
current.Service.Resolved != "resolved once" || current.Service.Credential.Value() != "lifecycle-secret-value" {
|
||||
validation.Error(configkit.Keys("service"), "assembled values did not survive source declaration processing")
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
load := func(header string) *configkit.Result[configuration] {
|
||||
t.Helper()
|
||||
result, err := configkit.LoadLayered(configkit.Input{
|
||||
Source: configkit.Bytes("main", []byte("version: 9007199254740993\n")), Destination: configkit.Keys("service"),
|
||||
}, configkit.LayeredOptions[configuration]{
|
||||
Definition: definition, SourceVersionMode: configkit.SourceVersionModeIfPresent,
|
||||
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{
|
||||
Source: configkit.Bytes("underlay", []byte("version: "+header+"\ncount: 0\nenabled: false\nnullable: null\nrevision: ordinary-payload\ncredential: CONFIGKIT_LIFECYCLE_SECRET\n")),
|
||||
Destination: configkit.Keys("service"),
|
||||
}}}},
|
||||
})
|
||||
if err != nil || result == nil {
|
||||
var failure *configkit.LoadError
|
||||
var diagnostics []configkit.Diagnostic
|
||||
if errors.As(err, &failure) {
|
||||
diagnostics = failure.Diagnostics()
|
||||
}
|
||||
t.Fatalf("LoadLayered(%q) = %#v, %v; diagnostics %#v", header, result, err, diagnostics)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
first := load("underlay-release-a")
|
||||
second := load("underlay-release-b")
|
||||
if len(checked) != 4 || checked[0].Text != "9007199254740993" || checked[1].Text != "underlay-release-a" ||
|
||||
checked[2].Text != "9007199254740993" || checked[3].Text != "underlay-release-b" {
|
||||
t.Fatalf("source declarations checked = %#v", checked)
|
||||
}
|
||||
if validationCalls != 2 || first.Config.Service != second.Config.Service {
|
||||
t.Fatalf("final configs or validation count = %#v, %#v, %d", first.Config, second.Config, validationCalls)
|
||||
}
|
||||
if differences, err := configkit.Diff(first, second); err != nil || len(differences) != 0 {
|
||||
t.Fatalf("different consumed declarations created effective differences: %#v, %v", differences, err)
|
||||
}
|
||||
var firstEffective, secondEffective bytes.Buffer
|
||||
if err := first.WriteEffective(&firstEffective); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := second.WriteEffective(&secondEffective); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if firstEffective.String() != secondEffective.String() || strings.Contains(firstEffective.String(), "underlay-release") {
|
||||
t.Fatalf("consumed declarations affected effective rendering:\n%s\n%s", firstEffective.String(), secondEffective.String())
|
||||
}
|
||||
sourcesA, sourcesB := first.Sources(), second.Sources()
|
||||
if len(sourcesA) != 2 || len(sourcesB) != 2 || sourcesA[1].VersionCheck == nil || sourcesB[1].VersionCheck == nil ||
|
||||
sourcesA[1].VersionCheck.Version.Text != "underlay-release-a" || sourcesB[1].VersionCheck.Version.Text != "underlay-release-b" {
|
||||
t.Fatalf("sanitized evidence did not retain distinct accepted declarations: %#v / %#v", sourcesA, sourcesB)
|
||||
}
|
||||
versionExplanation, found := first.Explain(configkit.Keys("service", "version"))
|
||||
if !found {
|
||||
t.Fatal("retained main version missing from effective explanation")
|
||||
}
|
||||
underlayID := sourcesA[1].ID
|
||||
for _, location := range versionExplanation.Origin.Locations {
|
||||
if location.SourceID == underlayID {
|
||||
t.Fatalf("consumed underlay declaration entered effective origin: %#v", versionExplanation.Origin)
|
||||
}
|
||||
}
|
||||
for _, change := range versionExplanation.History {
|
||||
for _, location := range change.Origin.Locations {
|
||||
if location.SourceID == underlayID {
|
||||
t.Fatalf("consumed underlay declaration entered effective history: %#v", versionExplanation.History)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
10
go.mod
10
go.mod
@@ -1,5 +1,13 @@
|
||||
module gitea.maximumdirect.net/eric/ConfigKit
|
||||
|
||||
go 1.25.0
|
||||
go 1.26.0
|
||||
|
||||
require go.yaml.in/yaml/v4 v4.0.0-rc.6
|
||||
|
||||
require (
|
||||
gitea.maximumdirect.net/eric/ReleaseKit v0.4.1 // indirect
|
||||
github.com/yuin/goldmark/v2 v2.1.6 // indirect
|
||||
golang.org/x/mod v0.40.0 // indirect
|
||||
)
|
||||
|
||||
tool gitea.maximumdirect.net/eric/ReleaseKit/cmd/releasekit
|
||||
|
||||
6
go.sum
6
go.sum
@@ -1,2 +1,8 @@
|
||||
gitea.maximumdirect.net/eric/ReleaseKit v0.4.1 h1:Qn+mdebYOzCVCEXgCbuGSO/WHmJtItwaQ8e3XwY3qtI=
|
||||
gitea.maximumdirect.net/eric/ReleaseKit v0.4.1/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/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/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0=
|
||||
golang.org/x/mod v0.40.0 h1:hUv+3cXcdRHz08UmSiOob7sadHig73uo5bkXxQ/tvUs=
|
||||
golang.org/x/mod v0.40.0/go.mod h1:0/weTWkPWGBikyTWAX3dkjVztMmBA5hM0DH6BElSupE=
|
||||
|
||||
@@ -6,13 +6,30 @@ import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
// precedence layers. Claims remain ordered by source and declaration traversal;
|
||||
// origins retain every source that contributes to a composite mapping.
|
||||
type composedDocument struct {
|
||||
root *document.Node
|
||||
claims []compositionClaim
|
||||
origins map[string]Origin
|
||||
overlays []overlayRecord
|
||||
applications []historyApplication
|
||||
inputBytes int64
|
||||
inputNodes int
|
||||
root *document.Node
|
||||
acquisition []acquisitionScope
|
||||
importGraph importInclusionGraph
|
||||
claims []compositionClaim
|
||||
sequenceRanges []sequenceRange
|
||||
origins map[string]Origin
|
||||
overlays []overlayRecord
|
||||
applications []historyApplication
|
||||
inputBytes int64
|
||||
inputNodes int
|
||||
}
|
||||
|
||||
type sequenceHistoryMode uint8
|
||||
|
||||
const (
|
||||
sequenceHistoryReplace sequenceHistoryMode = iota + 1
|
||||
sequenceHistoryAppend
|
||||
)
|
||||
|
||||
type sequenceHistoryContribution struct {
|
||||
path Path
|
||||
mode sequenceHistoryMode
|
||||
offset int
|
||||
length int
|
||||
}
|
||||
|
||||
// assemblyAccumulator owns one mutable working document. Acquired trees held by
|
||||
@@ -29,18 +46,26 @@ func newAssemblyAccumulator(owned *composedDocument) *assemblyAccumulator {
|
||||
// cloneComposedDocument detaches mutable composition and provenance state;
|
||||
// history application trees are immutable and retain their existing sharing.
|
||||
func cloneComposedDocument(base *composedDocument) *composedDocument {
|
||||
if base == nil {
|
||||
return nil
|
||||
}
|
||||
return &composedDocument{
|
||||
root: document.Clone(base.root), claims: cloneCompositionClaims(base.claims),
|
||||
origins: cloneOriginIndex(base.origins), overlays: cloneOverlayRecords(base.overlays),
|
||||
acquisition: cloneAcquisitionScopes(base.acquisition),
|
||||
importGraph: base.importGraph.clone(),
|
||||
sequenceRanges: cloneSequenceRanges(base.sequenceRanges),
|
||||
origins: cloneOriginIndex(base.origins), overlays: cloneOverlayRecords(base.overlays),
|
||||
applications: cloneHistoryApplications(base.applications),
|
||||
inputBytes: base.inputBytes, inputNodes: base.inputNodes,
|
||||
}
|
||||
}
|
||||
|
||||
type compositionClaim struct {
|
||||
path Path
|
||||
kind document.Kind
|
||||
location Location
|
||||
node *document.Node // borrowed immutable contribution, not the assembly tree
|
||||
path Path
|
||||
kind document.Kind
|
||||
location Location
|
||||
sourceIndex int
|
||||
}
|
||||
|
||||
type compositionClaims struct {
|
||||
@@ -48,16 +73,32 @@ type compositionClaims struct {
|
||||
byPath map[string][]compositionClaim
|
||||
}
|
||||
|
||||
// sequenceRange records where one source's sequence items were assigned in the
|
||||
// composed sequence at path. End is exclusive.
|
||||
type sequenceRange struct {
|
||||
sourceIndex int
|
||||
path Path
|
||||
start int
|
||||
end int
|
||||
}
|
||||
|
||||
// composeDocuments recursively joins disjoint mapping entries. Source order
|
||||
// controls only output traversal and diagnostics: every non-mapping overlap is
|
||||
// rejected before a result is assembled.
|
||||
func composeDocuments(inputs ...*document.Node) (*composedDocument, []Diagnostic) {
|
||||
return composeDocumentsWithPolicy(sequencePolicy{}, inputs...)
|
||||
}
|
||||
|
||||
// composeDocumentsWithPolicy composes one strict tier using the registered
|
||||
// sequence rules when policy contains registrations. The ordinary adapter above
|
||||
// deliberately preserves the existing unregistered behavior for current callers.
|
||||
func composeDocumentsWithPolicy(policy sequencePolicy, inputs ...*document.Node) (*composedDocument, []Diagnostic) {
|
||||
if len(inputs) == 0 {
|
||||
return nil, []Diagnostic{compositionInvalidDiagnostic("strict composition requires at least one document", nil)}
|
||||
}
|
||||
|
||||
collected := compositionClaims{byPath: make(map[string][]compositionClaim)}
|
||||
for _, input := range inputs {
|
||||
for sourceIndex, input := range inputs {
|
||||
if input == nil || input.Kind != document.Mapping {
|
||||
var location *Location
|
||||
if input != nil {
|
||||
@@ -66,7 +107,7 @@ func composeDocuments(inputs ...*document.Node) (*composedDocument, []Diagnostic
|
||||
}
|
||||
return nil, []Diagnostic{compositionInvalidDiagnostic("strict composition requires mapping document roots", location)}
|
||||
}
|
||||
collected.append(input, Path{})
|
||||
collected.append(input, Path{}, sourceIndex)
|
||||
}
|
||||
|
||||
checked := make(map[string]struct{}, len(collected.byPath))
|
||||
@@ -77,28 +118,52 @@ func composeDocuments(inputs ...*document.Node) (*composedDocument, []Diagnostic
|
||||
}
|
||||
checked[pointer] = struct{}{}
|
||||
claims := collected.byPath[pointer]
|
||||
if len(claims) < 2 || allMappingClaims(claims) {
|
||||
if len(claims) < 2 || allMappingClaims(claims) || (allSequenceClaims(claims) && policy.registered(claim.path)) {
|
||||
continue
|
||||
}
|
||||
return nil, []Diagnostic{compositionConflictDiagnostic(claim.path, claims)}
|
||||
}
|
||||
|
||||
validated, ranges, diagnostics := validateRegisteredSequences(collected.ordered, policy)
|
||||
if len(diagnostics) != 0 {
|
||||
return nil, diagnostics
|
||||
}
|
||||
|
||||
root := document.Clone(inputs[0])
|
||||
index := new(mappingIndex)
|
||||
for _, input := range inputs[1:] {
|
||||
mergeDisjointMappings(root, input, index)
|
||||
for sourceIndex, input := range inputs[1:] {
|
||||
mergeComposedMappings(root, input, Path{}, index, sourceIndex+1, policy, validated)
|
||||
}
|
||||
applications := make([]historyApplication, 0, len(inputs))
|
||||
for _, input := range inputs {
|
||||
applications = append(applications, historyApplication{layer: LayerBase, value: input})
|
||||
seenSequences := make(map[string]bool)
|
||||
rangesBySource := make([][]sequenceRange, len(inputs))
|
||||
for _, assigned := range ranges {
|
||||
rangesBySource[assigned.sourceIndex] = append(rangesBySource[assigned.sourceIndex], assigned)
|
||||
}
|
||||
origins := compositionOrigins(collected.ordered)
|
||||
for sourceIndex, input := range inputs {
|
||||
application := historyApplication{layer: LayerBase, value: input}
|
||||
for _, assigned := range rangesBySource[sourceIndex] {
|
||||
pointer := assigned.path.String()
|
||||
mode := sequenceHistoryAppend
|
||||
if !seenSequences[pointer] {
|
||||
mode = sequenceHistoryReplace
|
||||
}
|
||||
seenSequences[pointer] = true
|
||||
application.sequences = append(application.sequences, sequenceHistoryContribution{
|
||||
path: assigned.path.clone(), mode: mode, offset: assigned.start, length: assigned.end - assigned.start,
|
||||
})
|
||||
}
|
||||
application.sequencesByPath = indexSequenceHistoryContributions(application.sequences)
|
||||
applications = append(applications, application)
|
||||
}
|
||||
origins := projectedCompositionOrigins(inputs, ranges)
|
||||
completeCompositionOrigins(root, Path{}, origins)
|
||||
return &composedDocument{
|
||||
root: root,
|
||||
claims: cloneCompositionClaims(collected.ordered),
|
||||
origins: origins,
|
||||
applications: applications,
|
||||
root: root,
|
||||
claims: cloneCompositionClaims(collected.ordered),
|
||||
sequenceRanges: cloneSequenceRanges(ranges),
|
||||
origins: origins,
|
||||
applications: applications,
|
||||
}, nil
|
||||
}
|
||||
|
||||
@@ -121,11 +186,13 @@ func completeCompositionOrigins(node *document.Node, path Path, origins map[stri
|
||||
}
|
||||
}
|
||||
|
||||
func (c *compositionClaims) append(node *document.Node, path Path) {
|
||||
func (c *compositionClaims) append(node *document.Node, path Path, sourceIndex int) {
|
||||
claim := compositionClaim{
|
||||
path: path.clone(),
|
||||
kind: node.Kind,
|
||||
location: publicLocation(node.Location),
|
||||
node: node,
|
||||
path: path.clone(),
|
||||
kind: node.Kind,
|
||||
location: publicLocation(node.Location),
|
||||
sourceIndex: sourceIndex,
|
||||
}
|
||||
c.ordered = append(c.ordered, claim)
|
||||
pointer := path.String()
|
||||
@@ -135,10 +202,146 @@ func (c *compositionClaims) append(node *document.Node, path Path) {
|
||||
return
|
||||
}
|
||||
for _, entry := range node.Mapping {
|
||||
c.append(entry.Value, path.Key(entry.Key))
|
||||
c.append(entry.Value, path.Key(entry.Key), sourceIndex)
|
||||
}
|
||||
}
|
||||
|
||||
func allSequenceClaims(claims []compositionClaim) bool {
|
||||
for _, claim := range claims {
|
||||
if claim.kind != document.Sequence {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
type validatedSequence struct {
|
||||
items []*document.Node
|
||||
}
|
||||
|
||||
func validateRegisteredSequences(claims []compositionClaim, policy sequencePolicy) (map[string]map[int]validatedSequence, []sequenceRange, []Diagnostic) {
|
||||
itemsByPath := make(map[string]map[int]validatedSequence)
|
||||
identitiesByPath := make(map[string]map[string]Location)
|
||||
lengthsByPath := make(map[string]int)
|
||||
var ranges []sequenceRange
|
||||
for _, claim := range claims {
|
||||
if claim.kind != document.Sequence {
|
||||
continue
|
||||
}
|
||||
identityField, registered := policy.identityFields[claim.path.String()]
|
||||
if !registered {
|
||||
continue
|
||||
}
|
||||
node := claim.node
|
||||
pathKey := claim.path.String()
|
||||
identities := identitiesByPath[pathKey]
|
||||
if identities == nil {
|
||||
identities = make(map[string]Location, len(node.Sequence))
|
||||
identitiesByPath[pathKey] = identities
|
||||
}
|
||||
start := lengthsByPath[pathKey]
|
||||
contribution := validatedSequence{items: node.Sequence}
|
||||
for itemIndex, item := range node.Sequence {
|
||||
itemPath := claim.path.Index(start + itemIndex)
|
||||
if item == nil || item.Kind != document.Mapping {
|
||||
location := (*Location)(nil)
|
||||
if item != nil {
|
||||
converted := publicLocation(item.Location)
|
||||
location = &converted
|
||||
}
|
||||
return nil, nil, []Diagnostic{{
|
||||
Code: CodeComposeInvalid, Severity: SeverityError,
|
||||
Path: itemPath, Message: "registered sequence items must be mappings", Primary: location,
|
||||
}}
|
||||
}
|
||||
fieldIndex := documentEntryIndex(item.Mapping, identityField)
|
||||
if fieldIndex < 0 {
|
||||
location := publicLocation(item.Location)
|
||||
return nil, nil, []Diagnostic{{
|
||||
Code: CodeComposeInvalid, Severity: SeverityError,
|
||||
Path: itemPath, Message: "registered sequence item is missing its identity field",
|
||||
Primary: &location,
|
||||
}}
|
||||
}
|
||||
field := item.Mapping[fieldIndex].Value
|
||||
if field == nil || field.Kind != document.String {
|
||||
location := publicLocation(item.Mapping[fieldIndex].ValueLocation)
|
||||
if field != nil {
|
||||
location = publicLocation(field.Location)
|
||||
}
|
||||
return nil, nil, []Diagnostic{{
|
||||
Code: CodeComposeInvalid, Severity: SeverityError,
|
||||
Path: itemPath.Key(identityField), Message: "registered sequence identity must be a string",
|
||||
Primary: &location,
|
||||
}}
|
||||
}
|
||||
if first, duplicate := identities[field.Text]; duplicate {
|
||||
current := publicLocation(field.Location)
|
||||
return nil, nil, []Diagnostic{{
|
||||
Code: CodeComposeConflict, Severity: SeverityError,
|
||||
Path: claim.path.clone(), Message: "registered sequence contains duplicate identities",
|
||||
Primary: &first, Related: []Location{current},
|
||||
}}
|
||||
}
|
||||
identities[field.Text] = publicLocation(field.Location)
|
||||
}
|
||||
if itemsByPath[pathKey] == nil {
|
||||
itemsByPath[pathKey] = make(map[int]validatedSequence)
|
||||
}
|
||||
itemsByPath[pathKey][claim.sourceIndex] = contribution
|
||||
lengthsByPath[pathKey] = start + len(node.Sequence)
|
||||
ranges = append(ranges, sequenceRange{
|
||||
sourceIndex: claim.sourceIndex, path: claim.path.clone(), start: start, end: start + len(node.Sequence),
|
||||
})
|
||||
}
|
||||
return itemsByPath, ranges, nil
|
||||
}
|
||||
|
||||
func mergeComposedMappings(target, incoming *document.Node, path Path, index *mappingIndex, sourceIndex int, policy sequencePolicy, validated map[string]map[int]validatedSequence) {
|
||||
for _, incomingEntry := range incoming.Mapping {
|
||||
entryPath := path.Key(incomingEntry.Key)
|
||||
position := index.find(target, incomingEntry.Key)
|
||||
if position < 0 {
|
||||
cloned := incomingEntry
|
||||
cloned.Value = document.Clone(incomingEntry.Value)
|
||||
index.append(target, cloned)
|
||||
continue
|
||||
}
|
||||
current := target.Mapping[position].Value
|
||||
if current.Kind == document.Sequence && incomingEntry.Value.Kind == document.Sequence && policy.registered(entryPath) {
|
||||
contribution := validated[entryPath.String()][sourceIndex]
|
||||
if contribution.items != nil {
|
||||
for _, item := range contribution.items {
|
||||
current.Sequence = append(current.Sequence, document.Clone(item))
|
||||
}
|
||||
}
|
||||
continue
|
||||
}
|
||||
mergeDisjointMappingsAtPath(current, incomingEntry.Value, entryPath, index, sourceIndex, policy, validated)
|
||||
}
|
||||
}
|
||||
|
||||
func mergeDisjointMappingsAtPath(target, incoming *document.Node, path Path, index *mappingIndex, sourceIndex int, policy sequencePolicy, validated map[string]map[int]validatedSequence) {
|
||||
if target.Kind != document.Mapping || incoming.Kind != document.Mapping {
|
||||
return
|
||||
}
|
||||
mergeComposedMappings(target, incoming, path, index, sourceIndex, policy, validated)
|
||||
}
|
||||
|
||||
func (p sequencePolicy) registered(path Path) bool {
|
||||
_, ok := p.identityFields[path.String()]
|
||||
return ok
|
||||
}
|
||||
|
||||
func cloneSequenceRanges(ranges []sequenceRange) []sequenceRange {
|
||||
cloned := make([]sequenceRange, len(ranges))
|
||||
for i, assigned := range ranges {
|
||||
cloned[i] = assigned
|
||||
cloned[i].path = assigned.path.clone()
|
||||
}
|
||||
return cloned
|
||||
}
|
||||
|
||||
func allMappingClaims(claims []compositionClaim) bool {
|
||||
for _, claim := range claims {
|
||||
if claim.kind != document.Mapping {
|
||||
@@ -172,18 +375,64 @@ func documentEntryIndex(entries []document.Entry, key string) int {
|
||||
return -1
|
||||
}
|
||||
|
||||
func compositionOrigins(claims []compositionClaim) map[string]Origin {
|
||||
origins := make(map[string]Origin, len(claims))
|
||||
for _, claim := range claims {
|
||||
pointer := claim.path.String()
|
||||
origin := origins[pointer]
|
||||
origin.Layer = LayerBase
|
||||
origin.Locations = append(origin.Locations, claim.location)
|
||||
origins[pointer] = origin
|
||||
func projectedCompositionOrigins(inputs []*document.Node, ranges []sequenceRange) map[string]Origin {
|
||||
origins := make(map[string]Origin)
|
||||
rangeBySourcePath := make(map[sequenceRangeKey]int, len(ranges))
|
||||
for index, assigned := range ranges {
|
||||
rangeBySourcePath[sequenceRangeKey{sourceIndex: assigned.sourceIndex, path: assigned.path.String()}] = index
|
||||
}
|
||||
for sourceIndex, input := range inputs {
|
||||
collectProjectedCompositionOrigins(input, Path{}, Path{}, sourceIndex, ranges, rangeBySourcePath, origins)
|
||||
}
|
||||
return origins
|
||||
}
|
||||
|
||||
type sequenceRangeKey struct {
|
||||
sourceIndex int
|
||||
path string
|
||||
}
|
||||
|
||||
type sequenceRangeIndex map[sequenceRangeKey]sequenceRange
|
||||
|
||||
func indexSequenceRanges(ranges []sequenceRange) sequenceRangeIndex {
|
||||
if len(ranges) == 0 {
|
||||
return nil
|
||||
}
|
||||
index := make(sequenceRangeIndex, len(ranges))
|
||||
for _, assigned := range ranges {
|
||||
index[sequenceRangeKey{sourceIndex: assigned.sourceIndex, path: assigned.path.String()}] = assigned
|
||||
}
|
||||
return index
|
||||
}
|
||||
|
||||
// Keep local paths for matching Stage 2 ranges and effective paths for emitted
|
||||
// evidence. The paths are transformed segment by segment; pointer strings are
|
||||
// only used as map keys after projection is complete.
|
||||
func collectProjectedCompositionOrigins(node *document.Node, localPath, effectivePath Path, sourceIndex int, ranges []sequenceRange, rangeBySourcePath map[sequenceRangeKey]int, origins map[string]Origin) {
|
||||
pointer := effectivePath.String()
|
||||
origin := origins[pointer]
|
||||
origin.Layer = LayerBase
|
||||
origin.Locations = append(origin.Locations, publicLocation(node.Location))
|
||||
origins[pointer] = origin
|
||||
|
||||
switch node.Kind {
|
||||
case document.Mapping:
|
||||
for _, entry := range node.Mapping {
|
||||
collectProjectedCompositionOrigins(entry.Value, localPath.Key(entry.Key), effectivePath.Key(entry.Key), sourceIndex, ranges, rangeBySourcePath, origins)
|
||||
}
|
||||
case document.Sequence:
|
||||
start := 0
|
||||
if rangeIndex, ok := rangeBySourcePath[sequenceRangeKey{sourceIndex: sourceIndex, path: localPath.String()}]; ok {
|
||||
start = ranges[rangeIndex].start
|
||||
}
|
||||
for index, child := range node.Sequence {
|
||||
localChild := localPath.Index(index)
|
||||
effectiveChild := effectivePath.Index(start + index)
|
||||
collectProjectedCompositionOrigins(child, localChild, effectiveChild, sourceIndex, ranges, rangeBySourcePath, origins)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func applyCompositionOrigins(decoded *decodedConfiguration, origins map[string]Origin) {
|
||||
for pointer, origin := range origins {
|
||||
if _, present := decoded.presence[pointer]; present {
|
||||
|
||||
@@ -1,12 +1,60 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
)
|
||||
|
||||
func TestComposeWideRegisteredCollectionsPreservesEvidenceAndReplay(t *testing.T) {
|
||||
policy := sequencePolicy{identityFields: make(map[string]string)}
|
||||
var inputs []*document.Node
|
||||
for source := 0; source < 3; source++ {
|
||||
var text strings.Builder
|
||||
for collection := 0; collection < 128; collection++ {
|
||||
key := fmt.Sprintf("records%d", collection)
|
||||
policy.identityFields[Keys(key).String()] = "id"
|
||||
fmt.Fprintf(&text, "%s: [{id: source%d}]\n", key, source)
|
||||
}
|
||||
inputs = append(inputs, parseCompositionDocument(t, fmt.Sprintf("source-%d", source), text.String()))
|
||||
}
|
||||
composed, diagnostics := composeDocumentsWithPolicy(policy, inputs...)
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
working := &document.Node{Kind: document.Mapping}
|
||||
for _, application := range composed.applications {
|
||||
applyHistoryApplication(&working, application, new(mappingIndex))
|
||||
}
|
||||
if !configurationValuesEqual(working, composed.root) {
|
||||
t.Fatal("wide registered collections replay differs from assembly")
|
||||
}
|
||||
for collection := 0; collection < 128; collection++ {
|
||||
path := Keys(fmt.Sprintf("records%d", collection)).Index(2).Key("id")
|
||||
origin := composed.origins[path.String()]
|
||||
if len(origin.Locations) != 1 || origin.Locations[0].SourceID != "source-2" {
|
||||
t.Fatalf("shifted origin at %s = %#v", path.String(), origin)
|
||||
}
|
||||
local, ok := historyApplicationLocalPath(composed.applications[2], path)
|
||||
if !ok || local.String() != Keys(fmt.Sprintf("records%d", collection)).Index(0).Key("id").String() {
|
||||
t.Fatal("wide local path projection failed")
|
||||
}
|
||||
}
|
||||
clone := cloneComposedDocument(composed)
|
||||
for _, original := range composed.applications[0].sequences {
|
||||
pointer := original.path.String()
|
||||
copy := clone.applications[0].sequencesByPath[pointer]
|
||||
copy.path.segments[0].key = "changed"
|
||||
if composed.applications[0].sequencesByPath[pointer].path.String() != pointer {
|
||||
t.Fatal("history path index aliases cloned state")
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeDocumentsJoinsDisjointMappingsAndTracksClaims(t *testing.T) {
|
||||
base := parseCompositionDocument(t, "source-1", `services:
|
||||
api:
|
||||
@@ -190,6 +238,268 @@ func TestComposeDocumentsRejectsInvalidBoundaries(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeDocumentsAppendsRegisteredSequencesAndReturnsRanges(t *testing.T) {
|
||||
first := parseCompositionDocument(t, "source-1", "records: [{id: '', left: 1}, {id: ' spaced ', right: 1}]\nother: [one]\n")
|
||||
second := parseCompositionDocument(t, "source-2", "records: [{id: ' ', text: second}]\n")
|
||||
firstBefore, secondBefore := document.Clone(first), document.Clone(second)
|
||||
policy := sequencePolicy{identityFields: map[string]string{"/records": "id"}}
|
||||
|
||||
composed, diagnostics := composeDocumentsWithPolicy(policy, first, second)
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("composeDocumentsWithPolicy() diagnostics = %#v", diagnostics)
|
||||
}
|
||||
records, ok := documentAtPath(composed.root, Keys("records"))
|
||||
if !ok || records.Kind != document.Sequence || len(records.Sequence) != 3 {
|
||||
t.Fatalf("records = %#v, want three appended records", records)
|
||||
}
|
||||
if got := composed.sequenceRanges; !reflect.DeepEqual(got, []sequenceRange{
|
||||
{sourceIndex: 0, path: Keys("records"), start: 0, end: 2},
|
||||
{sourceIndex: 1, path: Keys("records"), start: 2, end: 3},
|
||||
}) {
|
||||
t.Fatalf("sequence ranges = %#v", got)
|
||||
}
|
||||
if !reflect.DeepEqual(first, firstBefore) || !reflect.DeepEqual(second, secondBefore) {
|
||||
t.Fatal("registered composition mutated an input")
|
||||
}
|
||||
composed.root.Mapping[0].Value.Sequence[0].Mapping[1].Value.Text = "changed"
|
||||
if !reflect.DeepEqual(first, firstBefore) || !reflect.DeepEqual(second, secondBefore) {
|
||||
t.Fatal("composed sequence aliases an input")
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeDocumentsProjectsSequenceOriginsToAssignedIndexes(t *testing.T) {
|
||||
first := parseCompositionDocument(t, "source-1", `mounted:
|
||||
records:
|
||||
- id: first
|
||||
name: first-name
|
||||
escaped/~:
|
||||
"7":
|
||||
- id: escaped-first
|
||||
name: escaped-first-name
|
||||
`)
|
||||
second := parseCompositionDocument(t, "source-2", `mounted:
|
||||
records:
|
||||
- id: second
|
||||
name: second-name
|
||||
escaped/~:
|
||||
"7":
|
||||
- id: escaped-second
|
||||
name: escaped-second-name
|
||||
`)
|
||||
firstBefore, secondBefore := document.Clone(first), document.Clone(second)
|
||||
policy := sequencePolicy{identityFields: map[string]string{
|
||||
Keys("mounted", "records").String(): "id",
|
||||
Keys("escaped/~", "7").String(): "id",
|
||||
}}
|
||||
|
||||
composed, diagnostics := composeDocumentsWithPolicy(policy, first, second)
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("composeDocumentsWithPolicy() diagnostics = %#v", diagnostics)
|
||||
}
|
||||
for _, test := range []struct {
|
||||
path Path
|
||||
want Location
|
||||
}{
|
||||
{path: Keys("mounted", "records").Index(1).Key("name"), want: Location{SourceID: "source-2", Line: 4, Column: 13}},
|
||||
{path: Keys("escaped/~", "7").Index(1).Key("name"), want: Location{SourceID: "source-2", Line: 8, Column: 13}},
|
||||
} {
|
||||
path, want := test.path, test.want
|
||||
origin, ok := composed.origins[path.String()]
|
||||
if !ok || !reflect.DeepEqual(origin.Locations, []Location{want}) {
|
||||
t.Errorf("origin at %s = %#v, want source-specific location %#v", path, origin, want)
|
||||
}
|
||||
}
|
||||
for _, test := range []struct {
|
||||
path Path
|
||||
wantSources []string
|
||||
}{
|
||||
{path: Keys("mounted", "records"), wantSources: []string{"source-1", "source-2"}},
|
||||
{path: Keys("escaped/~", "7"), wantSources: []string{"source-1", "source-2"}},
|
||||
} {
|
||||
path, wantSources := test.path, test.wantSources
|
||||
origin := composed.origins[path.String()]
|
||||
gotSources := make([]string, len(origin.Locations))
|
||||
for index, location := range origin.Locations {
|
||||
gotSources[index] = location.SourceID
|
||||
}
|
||||
if !reflect.DeepEqual(gotSources, wantSources) {
|
||||
t.Errorf("container origin at %s = %#v, want contributors %#v", path, gotSources, wantSources)
|
||||
}
|
||||
}
|
||||
if !reflect.DeepEqual(first, firstBefore) || !reflect.DeepEqual(second, secondBefore) {
|
||||
t.Fatal("composition projection mutated an input")
|
||||
}
|
||||
|
||||
copy := cloneComposedDocument(composed)
|
||||
copy.origins[Keys("mounted", "records").Index(1).Key("name").String()].Locations[0].SourceID = "changed"
|
||||
for index := range copy.claims {
|
||||
if copy.claims[index].path.String() == "/mounted/records" {
|
||||
copy.claims[index].path.segments[0].key = "changed"
|
||||
break
|
||||
}
|
||||
}
|
||||
copy.sequenceRanges[0].path.segments[0].key = "changed"
|
||||
if got := composed.origins[Keys("mounted", "records").Index(1).Key("name").String()].Locations[0].SourceID; got != "source-2" {
|
||||
t.Fatalf("copied origin mutation changed source evidence: %q", got)
|
||||
}
|
||||
foundClaim := false
|
||||
for _, claim := range composed.claims {
|
||||
if claim.path.String() == "/mounted/records" {
|
||||
foundClaim = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !foundClaim {
|
||||
t.Fatal("copied claim mutation changed source path")
|
||||
}
|
||||
if got := composed.sequenceRanges[0].path.String(); got != "/mounted/records" {
|
||||
t.Fatalf("copied range mutation changed assigned path: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeDocumentsValidatesRegisteredSequenceSingletons(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
body string
|
||||
path string
|
||||
wantCode Code
|
||||
}{
|
||||
{name: "nonmapping item", body: "records: [plain]\n", path: "/records/0", wantCode: CodeComposeInvalid},
|
||||
{name: "missing identity", body: "records: [{name: plain}]\n", path: "/records/0", wantCode: CodeComposeInvalid},
|
||||
{name: "null identity", body: "records: [{id: null}]\n", path: "/records/0/id", wantCode: CodeComposeInvalid},
|
||||
{name: "numeric identity", body: "records: [{id: 12}]\n", path: "/records/0/id", wantCode: CodeComposeInvalid},
|
||||
{name: "boolean identity", body: "records: [{id: false}]\n", path: "/records/0/id", wantCode: CodeComposeInvalid},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
_, diagnostics := composeDocumentsWithPolicy(
|
||||
sequencePolicy{identityFields: map[string]string{"/records": "id"}},
|
||||
parseCompositionDocument(t, "source-1", test.body),
|
||||
)
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != test.wantCode || diagnostics[0].Path.String() != test.path {
|
||||
t.Fatalf("diagnostics = %#v, want %s at %s", diagnostics, test.wantCode, test.path)
|
||||
}
|
||||
if diagnostics[0].Primary == nil {
|
||||
t.Fatal("malformed sequence diagnostic has no source location")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeDocumentsReportsAssignedPathForMalformedRegisteredSequenceContribution(t *testing.T) {
|
||||
_, diagnostics := composeDocumentsWithPolicy(
|
||||
sequencePolicy{identityFields: map[string]string{"/records": "id"}},
|
||||
parseCompositionDocument(t, "source-1", "records: [{id: first}]\n"),
|
||||
parseCompositionDocument(t, "source-2", "records: [{name: malformed}]\n"),
|
||||
)
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeComposeInvalid || diagnostics[0].Path.String() != "/records/1" {
|
||||
t.Fatalf("diagnostics = %#v, want invalid item at /records/1", diagnostics)
|
||||
}
|
||||
if diagnostics[0].Primary == nil || diagnostics[0].Primary.SourceID != "source-2" {
|
||||
t.Fatalf("malformed item location = %#v, want source-2", diagnostics[0].Primary)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeDocumentsRejectsRegisteredDuplicateIdentities(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
first string
|
||||
second string
|
||||
within bool
|
||||
}{
|
||||
{name: "within identical bodies", first: "records: [{id: '', value: same}, {id: '', value: same}]\n", within: true},
|
||||
{name: "within disjoint bodies", first: "records: [{id: same, left: 1}, {id: same, right: 2}]\n", within: true},
|
||||
{name: "across identical bodies", first: "records: [{id: same, value: same}]\n", second: "records: [{id: same, value: same}]\n"},
|
||||
{name: "across disjoint bodies", first: "records: [{id: same, left: 1}]\n", second: "records: [{id: same, right: 2}]\n"},
|
||||
{name: "empty identity across lists", first: "records: [{id: ''}]\n", second: "records: [{id: ''}]\n"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
inputs := []*document.Node{parseCompositionDocument(t, "source-1", test.first)}
|
||||
if !test.within {
|
||||
inputs = append(inputs, parseCompositionDocument(t, "source-2", test.second))
|
||||
}
|
||||
_, diagnostics := composeDocumentsWithPolicy(
|
||||
sequencePolicy{identityFields: map[string]string{"/records": "id"}}, inputs...,
|
||||
)
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeComposeConflict || diagnostics[0].Path.String() != "/records" {
|
||||
t.Fatalf("diagnostics = %#v, want duplicate conflict at /records", diagnostics)
|
||||
}
|
||||
if diagnostics[0].Primary == nil || len(diagnostics[0].Related) != 1 {
|
||||
t.Fatalf("duplicate locations = %#v", diagnostics[0])
|
||||
}
|
||||
for _, secret := range []string{"same", "left", "right", "value"} {
|
||||
if strings.Contains(diagnostics[0].Message, secret) {
|
||||
t.Fatalf("diagnostic leaks %q: %q", secret, diagnostics[0].Message)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeDocumentsSkipsMissingAndNonSequenceRegistrations(t *testing.T) {
|
||||
policy := sequencePolicy{identityFields: map[string]string{
|
||||
"/absent": "id", "/not-a-sequence": "id", "/catalog": "id", "/catalog/records": "id",
|
||||
}}
|
||||
composed, diagnostics := composeDocumentsWithPolicy(policy, parseCompositionDocument(t, "source-1", `
|
||||
not-a-sequence: null
|
||||
catalog:
|
||||
records: [{id: present}]
|
||||
`))
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("composeDocumentsWithPolicy() diagnostics = %#v", diagnostics)
|
||||
}
|
||||
if len(composed.sequenceRanges) != 1 || composed.sequenceRanges[0].path.String() != "/catalog/records" {
|
||||
t.Fatalf("sequence ranges = %#v, want only the reached descendant sequence", composed.sequenceRanges)
|
||||
}
|
||||
|
||||
composed, diagnostics = composeDocumentsWithPolicy(
|
||||
sequencePolicy{identityFields: map[string]string{"/records": "id"}},
|
||||
parseCompositionDocument(t, "source-1", "records: []\n"),
|
||||
parseCompositionDocument(t, "source-2", "records: []\n"),
|
||||
)
|
||||
if len(diagnostics) != 0 || len(composed.root.Mapping[0].Value.Sequence) != 0 {
|
||||
t.Fatalf("empty registered sequences = (%#v, %#v)", composed, diagnostics)
|
||||
}
|
||||
if got := composed.sequenceRanges; !reflect.DeepEqual(got, []sequenceRange{
|
||||
{sourceIndex: 0, path: Keys("records"), start: 0, end: 0},
|
||||
{sourceIndex: 1, path: Keys("records"), start: 0, end: 0},
|
||||
}) {
|
||||
t.Fatalf("empty sequence ranges = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestComposeDocumentsKeepsUnregisteredSequenceConflicts(t *testing.T) {
|
||||
_, diagnostics := composeDocumentsWithPolicy(
|
||||
sequencePolicy{identityFields: map[string]string{"/registered": "id"}},
|
||||
parseCompositionDocument(t, "source-1", "unregistered: [one]\n"),
|
||||
parseCompositionDocument(t, "source-2", "unregistered: [two]\n"),
|
||||
)
|
||||
assertCompositionConflict(t, diagnostics, "/unregistered", []string{"source-1", "source-2"})
|
||||
}
|
||||
|
||||
func TestComposeDocumentsKeepsNonSequenceRegisteredOverlapsConflicting(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
first string
|
||||
second string
|
||||
}{
|
||||
{name: "mapping and sequence", first: "records: {id: one}\n", second: "records: [{id: two}]\n"},
|
||||
{name: "null and sequence", first: "records: null\n", second: "records: [{id: two}]\n"},
|
||||
{name: "scalar and sequence", first: "records: value\n", second: "records: [{id: two}]\n"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
_, diagnostics := composeDocumentsWithPolicy(
|
||||
sequencePolicy{identityFields: map[string]string{"/records": "id"}},
|
||||
parseCompositionDocument(t, "source-1", test.first),
|
||||
parseCompositionDocument(t, "source-2", test.second),
|
||||
)
|
||||
assertCompositionConflict(t, diagnostics, "/records", []string{"source-1", "source-2"})
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func parseCompositionDocument(t *testing.T, sourceID, input string) *document.Node {
|
||||
t.Helper()
|
||||
parsed, diagnostics := parseSource(Bytes(sourceID, []byte(input)), sourceID, defaultParseLimits())
|
||||
|
||||
77
internal/engine/dependency_integration_test.go
Normal file
77
internal/engine/dependency_integration_test.go
Normal file
@@ -0,0 +1,77 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestImportAndInheritanceIntegrationsUseEquivalentReadySchedules(t *testing.T) {
|
||||
names := []string{"a-base", "b-independent", "z-dependent"}
|
||||
keys := make(map[string]importNodeKey, len(names))
|
||||
contributions := make([]acquiredContribution, len(names))
|
||||
for index, name := range names {
|
||||
key := importNodeKey{
|
||||
namespaceKind: "host", namespaceAnchor: "/shared",
|
||||
resourceKind: "file", resourcePath: "/" + name,
|
||||
}
|
||||
keys[name] = key
|
||||
contributions[index] = acquiredContribution{
|
||||
identity: key,
|
||||
source: SourceInfo{ID: "source-" + name, Path: name},
|
||||
}
|
||||
}
|
||||
|
||||
var want []string
|
||||
for _, reverse := range []bool{false, true} {
|
||||
graph := newImportInclusionGraph()
|
||||
orderedNames := append([]string(nil), names...)
|
||||
if reverse {
|
||||
orderedNames[0], orderedNames[2] = orderedNames[2], orderedNames[0]
|
||||
}
|
||||
for _, name := range orderedNames {
|
||||
graph.addNode(importGraphNode{key: keys[name], sourceID: "source-" + name, role: importRoleMapping})
|
||||
}
|
||||
graph.addEdge(keys["z-dependent"], keys["a-base"], Location{})
|
||||
|
||||
scheduled := scheduleContributionSlots(contributions, []int{0, 1, 2}, graph)
|
||||
got := make([]string, len(scheduled))
|
||||
for index, contribution := range scheduled {
|
||||
got[index] = contribution.identity.resourcePath[1:]
|
||||
}
|
||||
if !reflect.DeepEqual(got, []string{"a-base", "b-independent", "z-dependent"}) {
|
||||
t.Fatalf("import schedule reverse=%t: %v", reverse, got)
|
||||
}
|
||||
if !reflect.DeepEqual(
|
||||
[]string{contributions[0].source.ID, contributions[1].source.ID, contributions[2].source.ID},
|
||||
[]string{"source-a-base", "source-b-independent", "source-z-dependent"},
|
||||
) {
|
||||
t.Fatal("scheduling mutated acquisition source records")
|
||||
}
|
||||
|
||||
inheritanceText := "a-base: {value: ready}\nb-independent: {value: independent}\nz-dependent: {extends: a-base}\n"
|
||||
if reverse {
|
||||
inheritanceText = "z-dependent: {extends: a-base}\nb-independent: {value: independent}\na-base: {value: ready}\n"
|
||||
}
|
||||
scope := parseCompositionDocument(t, "inheritance", inheritanceText)
|
||||
inheritance, diagnostics := analyzeInheritanceGraph(scope, Path{})
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("analyze inheritance reverse=%t: %#v", reverse, diagnostics)
|
||||
}
|
||||
order, diagnostics := inheritance.schedule()
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("schedule inheritance reverse=%t: %#v", reverse, diagnostics)
|
||||
}
|
||||
inheritanceNames := make([]string, len(order))
|
||||
for index, node := range order {
|
||||
inheritanceNames[index] = inheritance.nodes[node].name
|
||||
}
|
||||
if !reflect.DeepEqual(inheritanceNames, got) {
|
||||
t.Fatalf("equivalent graph schedules differ reverse=%t: imports=%v inheritance=%v", reverse, got, inheritanceNames)
|
||||
}
|
||||
if want == nil {
|
||||
want = got
|
||||
} else if !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("import graph construction changed schedule: got %v, want %v", got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
200
internal/engine/dependency_order.go
Normal file
200
internal/engine/dependency_order.go
Normal file
@@ -0,0 +1,200 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"container/heap"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// dependencyKey is an immutable tuple used to order dependency-graph nodes.
|
||||
// Callers provide unique keys and valid edge endpoints.
|
||||
type dependencyKey []string
|
||||
|
||||
// dependencyEdge points from a prerequisite to the node that depends on it.
|
||||
type dependencyEdge struct {
|
||||
prerequisite int
|
||||
dependent int
|
||||
}
|
||||
|
||||
type dependencyReadyHeap struct {
|
||||
nodes []int
|
||||
keys []dependencyKey
|
||||
}
|
||||
|
||||
func (h dependencyReadyHeap) Len() int { return len(h.nodes) }
|
||||
|
||||
func (h dependencyReadyHeap) Less(i, j int) bool {
|
||||
return dependencyKeyLess(h.keys[h.nodes[i]], h.keys[h.nodes[j]])
|
||||
}
|
||||
|
||||
func (h dependencyReadyHeap) Swap(i, j int) {
|
||||
h.nodes[i], h.nodes[j] = h.nodes[j], h.nodes[i]
|
||||
}
|
||||
|
||||
func (h *dependencyReadyHeap) Push(value any) {
|
||||
h.nodes = append(h.nodes, value.(int))
|
||||
}
|
||||
|
||||
func (h *dependencyReadyHeap) Pop() any {
|
||||
last := len(h.nodes) - 1
|
||||
node := h.nodes[last]
|
||||
h.nodes = h.nodes[:last]
|
||||
return node
|
||||
}
|
||||
|
||||
// dependencyKeyLess compares tuple components bytewise, with a shorter tuple
|
||||
// sorting first when all of its components match the corresponding prefix.
|
||||
func dependencyKeyLess(left, right dependencyKey) bool {
|
||||
for i := 0; i < len(left) && i < len(right); i++ {
|
||||
if left[i] != right[i] {
|
||||
return left[i] < right[i]
|
||||
}
|
||||
}
|
||||
return len(left) < len(right)
|
||||
}
|
||||
|
||||
// scheduleDependencies applies Kahn's algorithm, always selecting the
|
||||
// smallest currently-ready key. Duplicate edges count only once. If scheduling
|
||||
// stalls, an explicit depth-first traversal of the residual graph returns an
|
||||
// actual directed cycle, rotated to its smallest key and closed by repeating
|
||||
// its first node; blocked non-cycle dependents are excluded.
|
||||
//
|
||||
// Nodes are visited by precomputed key rank, which also makes each adjacency
|
||||
// list deterministic without sorting edges. The work is O(E + V log V), with
|
||||
// O(V + E) additional memory, apart from key copying and tuple comparisons.
|
||||
func scheduleDependencies(keys []dependencyKey, edges []dependencyEdge) (order, cycle []int) {
|
||||
ownedKeys := make([]dependencyKey, len(keys))
|
||||
for i, key := range keys {
|
||||
ownedKeys[i] = append(dependencyKey(nil), key...)
|
||||
}
|
||||
|
||||
nodeOrder := make([]int, len(ownedKeys))
|
||||
for i := range nodeOrder {
|
||||
nodeOrder[i] = i
|
||||
}
|
||||
sort.Slice(nodeOrder, func(i, j int) bool {
|
||||
return dependencyKeyLess(ownedKeys[nodeOrder[i]], ownedKeys[nodeOrder[j]])
|
||||
})
|
||||
|
||||
incoming := make([][]int, len(ownedKeys))
|
||||
for _, edge := range edges {
|
||||
incoming[edge.dependent] = append(incoming[edge.dependent], edge.prerequisite)
|
||||
}
|
||||
|
||||
outgoing := make([][]int, len(ownedKeys))
|
||||
indegree := make([]int, len(ownedKeys))
|
||||
for _, dependent := range nodeOrder {
|
||||
seen := make(map[int]struct{}, len(incoming[dependent]))
|
||||
for _, prerequisite := range incoming[dependent] {
|
||||
if _, duplicate := seen[prerequisite]; duplicate {
|
||||
continue
|
||||
}
|
||||
seen[prerequisite] = struct{}{}
|
||||
indegree[dependent]++
|
||||
// Dependents are visited by key rank, so each outgoing list is
|
||||
// already ordered by key.
|
||||
outgoing[prerequisite] = append(outgoing[prerequisite], dependent)
|
||||
}
|
||||
}
|
||||
|
||||
ready := dependencyReadyHeap{keys: ownedKeys}
|
||||
for _, node := range nodeOrder {
|
||||
if indegree[node] == 0 {
|
||||
ready.nodes = append(ready.nodes, node)
|
||||
}
|
||||
}
|
||||
heap.Init(&ready)
|
||||
|
||||
order = make([]int, 0, len(ownedKeys))
|
||||
for ready.Len() != 0 {
|
||||
node := heap.Pop(&ready).(int)
|
||||
order = append(order, node)
|
||||
for _, dependent := range outgoing[node] {
|
||||
indegree[dependent]--
|
||||
if indegree[dependent] == 0 {
|
||||
heap.Push(&ready, dependent)
|
||||
}
|
||||
}
|
||||
}
|
||||
if len(order) == len(ownedKeys) {
|
||||
return order, nil
|
||||
}
|
||||
return nil, dependencyCycle(ownedKeys, nodeOrder, outgoing, indegree)
|
||||
}
|
||||
|
||||
type dependencyDFSFrame struct {
|
||||
node int
|
||||
next int
|
||||
}
|
||||
|
||||
// dependencyCycle searches the residual graph iteratively to avoid recursion
|
||||
// depth depending on input size.
|
||||
func dependencyCycle(keys []dependencyKey, nodeOrder []int, outgoing [][]int, indegree []int) []int {
|
||||
const (
|
||||
unvisited = uint8(0)
|
||||
active = uint8(1)
|
||||
finished = uint8(2)
|
||||
)
|
||||
|
||||
state := make([]uint8, len(keys))
|
||||
position := make([]int, len(keys))
|
||||
for i := range position {
|
||||
position[i] = -1
|
||||
}
|
||||
path := make([]int, 0, len(keys))
|
||||
frames := make([]dependencyDFSFrame, 0, len(keys))
|
||||
|
||||
for _, root := range nodeOrder {
|
||||
if indegree[root] == 0 || state[root] != unvisited {
|
||||
continue
|
||||
}
|
||||
state[root] = active
|
||||
position[root] = len(path)
|
||||
path = append(path, root)
|
||||
frames = append(frames, dependencyDFSFrame{node: root})
|
||||
|
||||
for len(frames) != 0 {
|
||||
frame := &frames[len(frames)-1]
|
||||
if frame.next == len(outgoing[frame.node]) {
|
||||
state[frame.node] = finished
|
||||
position[frame.node] = -1
|
||||
path = path[:len(path)-1]
|
||||
frames = frames[:len(frames)-1]
|
||||
continue
|
||||
}
|
||||
|
||||
next := outgoing[frame.node][frame.next]
|
||||
frame.next++
|
||||
if indegree[next] == 0 {
|
||||
continue
|
||||
}
|
||||
switch state[next] {
|
||||
case unvisited:
|
||||
state[next] = active
|
||||
position[next] = len(path)
|
||||
path = append(path, next)
|
||||
frames = append(frames, dependencyDFSFrame{node: next})
|
||||
case active:
|
||||
return canonicalDependencyCycle(keys, path[position[next]:])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A stalled Kahn traversal always leaves a cycle in the residual graph.
|
||||
return nil
|
||||
}
|
||||
|
||||
func canonicalDependencyCycle(keys []dependencyKey, path []int) []int {
|
||||
start := 0
|
||||
for i := 1; i < len(path); i++ {
|
||||
if dependencyKeyLess(keys[path[i]], keys[path[start]]) {
|
||||
start = i
|
||||
}
|
||||
}
|
||||
|
||||
cycle := make([]int, len(path)+1)
|
||||
for i := range path {
|
||||
cycle[i] = path[(start+i)%len(path)]
|
||||
}
|
||||
cycle[len(path)] = cycle[0]
|
||||
return cycle
|
||||
}
|
||||
215
internal/engine/dependency_order_test.go
Normal file
215
internal/engine/dependency_order_test.go
Normal file
@@ -0,0 +1,215 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestScheduleDependenciesOrdersReadyNodes(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
keys []string
|
||||
edges [][2]string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "independent branches",
|
||||
keys: []string{"branch-c", "branch-a", "branch-b"},
|
||||
want: []string{"branch-a", "branch-b", "branch-c"},
|
||||
},
|
||||
{
|
||||
name: "unequal chains",
|
||||
keys: []string{"chain-d", "chain-c", "chain-b", "chain-a"},
|
||||
edges: [][2]string{
|
||||
{"chain-a", "chain-b"},
|
||||
{"chain-b", "chain-c"},
|
||||
{"chain-c", "chain-d"},
|
||||
},
|
||||
want: []string{"chain-a", "chain-b", "chain-c", "chain-d"},
|
||||
},
|
||||
{
|
||||
name: "multiple dependencies",
|
||||
keys: []string{"application", "right", "base", "left"},
|
||||
edges: [][2]string{
|
||||
{"base", "left"}, {"base", "right"},
|
||||
{"left", "application"}, {"right", "application"},
|
||||
},
|
||||
want: []string{"base", "left", "right", "application"},
|
||||
},
|
||||
{
|
||||
name: "shared dependency at equal depths",
|
||||
keys: []string{"sink", "right", "root", "left"},
|
||||
edges: [][2]string{
|
||||
{"root", "left"}, {"root", "right"},
|
||||
{"left", "sink"}, {"right", "sink"},
|
||||
},
|
||||
want: []string{"root", "left", "right", "sink"},
|
||||
},
|
||||
{
|
||||
name: "shared dependency at unequal depths",
|
||||
keys: []string{"sink", "deep", "side", "middle", "root"},
|
||||
edges: [][2]string{
|
||||
{"root", "middle"}, {"middle", "deep"}, {"root", "side"},
|
||||
{"deep", "sink"}, {"side", "sink"},
|
||||
},
|
||||
want: []string{"root", "middle", "deep", "side", "sink"},
|
||||
},
|
||||
{
|
||||
name: "newly ready small key precedes older large key",
|
||||
keys: []string{"z-ready", "a-new", "b-ready"},
|
||||
edges: [][2]string{{"b-ready", "a-new"}},
|
||||
want: []string{"b-ready", "a-new", "z-ready"},
|
||||
},
|
||||
{
|
||||
name: "duplicate edges count once",
|
||||
keys: []string{"dependent", "prerequisite"},
|
||||
edges: [][2]string{
|
||||
{"prerequisite", "dependent"},
|
||||
{"prerequisite", "dependent"},
|
||||
},
|
||||
want: []string{"prerequisite", "dependent"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
keys, edges, indexes := dependencyGraph(test.keys, test.edges)
|
||||
order, cycle := scheduleDependencies(keys, edges)
|
||||
if len(cycle) != 0 {
|
||||
t.Fatalf("unexpected cycle: %v", dependencyNames(cycle, indexes))
|
||||
}
|
||||
if got := dependencyNames(order, indexes); !reflect.DeepEqual(got, test.want) {
|
||||
t.Fatalf("schedule = %v, want %v", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduleDependenciesUsesTupleKeys(t *testing.T) {
|
||||
keys := []dependencyKey{
|
||||
{"a", "z"},
|
||||
{"a"},
|
||||
{"a", "aa"},
|
||||
{"A"},
|
||||
}
|
||||
order, cycle := scheduleDependencies(keys, nil)
|
||||
if len(cycle) != 0 {
|
||||
t.Fatalf("unexpected cycle: %v", cycle)
|
||||
}
|
||||
want := []int{3, 1, 2, 0}
|
||||
if !reflect.DeepEqual(order, want) {
|
||||
t.Fatalf("schedule = %v, want %v", order, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduleDependenciesReportsActualCanonicalCycles(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
keys []string
|
||||
edges [][2]string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
name: "self cycle",
|
||||
keys: []string{"self"},
|
||||
edges: [][2]string{{"self", "self"}},
|
||||
want: []string{"self", "self"},
|
||||
},
|
||||
{
|
||||
name: "multi-node cycle excludes blocked dependent",
|
||||
keys: []string{"blocked", "cycle-b", "cycle-a"},
|
||||
edges: [][2]string{
|
||||
{"cycle-a", "cycle-b"},
|
||||
{"cycle-b", "cycle-a"},
|
||||
{"cycle-b", "blocked"},
|
||||
},
|
||||
want: []string{"cycle-a", "cycle-b", "cycle-a"},
|
||||
},
|
||||
}
|
||||
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
keys, edges, indexes := dependencyGraph(test.keys, test.edges)
|
||||
order, cycle := scheduleDependencies(keys, edges)
|
||||
if len(order) != 0 {
|
||||
t.Fatalf("unexpected schedule: %v", dependencyNames(order, indexes))
|
||||
}
|
||||
if got := dependencyNames(cycle, indexes); !reflect.DeepEqual(got, test.want) {
|
||||
t.Fatalf("cycle = %v, want %v", got, test.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestScheduleDependenciesIsStableAcrossInsertionPermutations(t *testing.T) {
|
||||
edges := [][2]string{
|
||||
{"base", "left"}, {"base", "right"},
|
||||
{"left", "sink"}, {"right", "sink"},
|
||||
}
|
||||
permutations := [][]string{
|
||||
{"sink", "right", "base", "left"},
|
||||
{"left", "base", "sink", "right"},
|
||||
{"right", "left", "base", "sink"},
|
||||
}
|
||||
for i, permutation := range permutations {
|
||||
keys, permutedEdges, indexes := dependencyGraph(permutation, edges)
|
||||
if i%2 != 0 {
|
||||
reverseDependencyEdges(permutedEdges)
|
||||
}
|
||||
order, cycle := scheduleDependencies(keys, permutedEdges)
|
||||
if len(cycle) != 0 {
|
||||
t.Fatalf("permutation %d returned cycle %v", i, dependencyNames(cycle, indexes))
|
||||
}
|
||||
if got, want := dependencyNames(order, indexes), []string{"base", "left", "right", "sink"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("permutation %d schedule = %v, want %v", i, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
cycleEdges := [][2]string{{"b", "a"}, {"a", "b"}, {"b", "blocked"}}
|
||||
cyclePermutations := [][]string{{"blocked", "b", "a"}, {"a", "blocked", "b"}, {"b", "a", "blocked"}}
|
||||
for i, permutation := range cyclePermutations {
|
||||
keys, permutedEdges, indexes := dependencyGraph(permutation, cycleEdges)
|
||||
if i%2 != 0 {
|
||||
reverseDependencyEdges(permutedEdges)
|
||||
}
|
||||
order, cycle := scheduleDependencies(keys, permutedEdges)
|
||||
if len(order) != 0 {
|
||||
t.Fatalf("cycle permutation %d returned schedule %v", i, dependencyNames(order, indexes))
|
||||
}
|
||||
if got, want := dependencyNames(cycle, indexes), []string{"a", "b", "a"}; !reflect.DeepEqual(got, want) {
|
||||
t.Fatalf("cycle permutation %d returned %v, want %v", i, got, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func dependencyGraph(nodeNames []string, namedEdges [][2]string) ([]dependencyKey, []dependencyEdge, map[int]string) {
|
||||
keys := make([]dependencyKey, len(nodeNames))
|
||||
indexes := make(map[string]int, len(nodeNames))
|
||||
for i, name := range nodeNames {
|
||||
keys[i] = dependencyKey{name}
|
||||
indexes[name] = i
|
||||
}
|
||||
edges := make([]dependencyEdge, len(namedEdges))
|
||||
for i, edge := range namedEdges {
|
||||
edges[i] = dependencyEdge{prerequisite: indexes[edge[0]], dependent: indexes[edge[1]]}
|
||||
}
|
||||
byIndex := make(map[int]string, len(nodeNames))
|
||||
for name, index := range indexes {
|
||||
byIndex[index] = name
|
||||
}
|
||||
return keys, edges, byIndex
|
||||
}
|
||||
|
||||
func dependencyNames(nodes []int, names map[int]string) []string {
|
||||
result := make([]string, len(nodes))
|
||||
for i, node := range nodes {
|
||||
result[i] = names[node]
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func reverseDependencyEdges(edges []dependencyEdge) {
|
||||
for i, j := 0, len(edges)-1; i < j; i, j = i+1, j-1 {
|
||||
edges[i], edges[j] = edges[j], edges[i]
|
||||
}
|
||||
}
|
||||
197
internal/engine/destination.go
Normal file
197
internal/engine/destination.go
Normal file
@@ -0,0 +1,197 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
)
|
||||
|
||||
var (
|
||||
errInvalidDestination = errors.New("configkit: destination must contain only non-reserved mapping keys")
|
||||
errDestinationLimit = errors.New("configkit: destination exceeds document limits")
|
||||
)
|
||||
|
||||
type placementMode uint8
|
||||
|
||||
const (
|
||||
placementModeMapping placementMode = iota
|
||||
placementModeSequenceItem
|
||||
)
|
||||
|
||||
// PlacementModeFromFacade preserves root-owned numeric values until preflight
|
||||
// can reject unsupported modes.
|
||||
func PlacementModeFromFacade(value uint8) placementMode { return placementMode(value) }
|
||||
|
||||
// PlacementModeForFacade converts private source evidence to the root-owned mode.
|
||||
func PlacementModeForFacade(value placementMode) uint8 { return uint8(value) }
|
||||
|
||||
// InputWithPlacement copies the root-owned mode into a private engine input.
|
||||
func InputWithPlacement(input Input, value uint8) Input {
|
||||
input.placementMode = placementMode(value)
|
||||
return input
|
||||
}
|
||||
|
||||
// OptionsWithPlacement copies root-owned controls into private engine options.
|
||||
func OptionsWithPlacement[T any](options Options[T], destination Path, value uint8) Options[T] {
|
||||
options.destination = destination.clone()
|
||||
options.placementMode = placementMode(value)
|
||||
return options
|
||||
}
|
||||
|
||||
func normalizeDestination(path Path) (Path, error) {
|
||||
if !path.valid() {
|
||||
return Path{}, errInvalidDestination
|
||||
}
|
||||
|
||||
segments := make([]pathSegment, 0, len(path.segments))
|
||||
for _, segment := range path.segments {
|
||||
if segment.kind != pathSegmentKey && segment.kind != pathSegmentParsed {
|
||||
return Path{}, errInvalidDestination
|
||||
}
|
||||
if segment.key == "configkit" || segment.key == "extends" {
|
||||
return Path{}, errInvalidDestination
|
||||
}
|
||||
segments = append(segments, pathSegment{kind: pathSegmentKey, key: segment.key})
|
||||
}
|
||||
return Path{segments: segments}, nil
|
||||
}
|
||||
|
||||
// appendDestination combines validated mapping-key paths with owned segments.
|
||||
func appendDestination(parent, relative Path) Path {
|
||||
segments := clonePathSegments(parent.segments, len(relative.segments))
|
||||
segments = append(segments, relative.segments...)
|
||||
return Path{segments: segments}
|
||||
}
|
||||
|
||||
func invalidDestinationDiagnostic(destination Path) Diagnostic {
|
||||
return Diagnostic{
|
||||
Code: CodeOptionsInvalid, Severity: SeverityError,
|
||||
Path: destination.clone(),
|
||||
Message: "input destination must contain only non-reserved mapping keys",
|
||||
}
|
||||
}
|
||||
|
||||
// placeDocument checks all per-document bounds before allocating destination
|
||||
// wrappers. Aggregate accounting remains the caller's responsibility.
|
||||
func placeDocument(body *document.Node, destination Path, sourceRoot document.Location, maxDepth, maxNodes int) (Path, *document.Node, error) {
|
||||
return placeDocumentWithMode(body, destination, placementModeMapping, sourceRoot, maxDepth, maxNodes)
|
||||
}
|
||||
|
||||
// placeDocumentWithMode places a source mapping beneath its literal destination.
|
||||
// Sequence-item placement adds one sequence containing the complete mapping.
|
||||
func placeDocumentWithMode(body *document.Node, destination Path, mode placementMode, sourceRoot document.Location, maxDepth, maxNodes int) (Path, *document.Node, error) {
|
||||
normalized, err := normalizeDestination(destination)
|
||||
if err != nil {
|
||||
return Path{}, nil, err
|
||||
}
|
||||
if mode != placementModeMapping && mode != placementModeSequenceItem {
|
||||
return Path{}, nil, errInvalidDestination
|
||||
}
|
||||
if mode == placementModeSequenceItem && len(normalized.segments) == 0 {
|
||||
return Path{}, nil, errInvalidDestination
|
||||
}
|
||||
if body == nil {
|
||||
return normalized, nil, nil
|
||||
}
|
||||
if mode == placementModeSequenceItem && body.Kind != document.Mapping {
|
||||
return normalized, nil, errInvalidDestination
|
||||
}
|
||||
if !destinationWithinLimitsWithMode(body, normalized, mode, maxDepth, maxNodes) {
|
||||
return normalized, nil, errDestinationLimit
|
||||
}
|
||||
|
||||
placed := body
|
||||
if mode == placementModeSequenceItem {
|
||||
placed = &document.Node{
|
||||
Kind: document.Sequence, Location: sourceRoot,
|
||||
Sequence: []*document.Node{placed},
|
||||
}
|
||||
}
|
||||
for index := len(normalized.segments) - 1; index >= 0; index-- {
|
||||
key := normalized.segments[index].key
|
||||
placed = &document.Node{
|
||||
Kind: document.Mapping,
|
||||
Location: sourceRoot,
|
||||
Mapping: []document.Entry{{
|
||||
Key: key,
|
||||
KeyLocation: sourceRoot,
|
||||
ValueLocation: sourceRoot,
|
||||
Value: placed,
|
||||
}},
|
||||
}
|
||||
}
|
||||
return normalized, placed, nil
|
||||
}
|
||||
|
||||
// destinationWithinLimits expects a normalized mapping-key destination.
|
||||
func destinationWithinLimits(body *document.Node, destination Path, maxDepth, maxNodes int) bool {
|
||||
return destinationWithinLimitsWithMode(body, destination, placementModeMapping, maxDepth, maxNodes)
|
||||
}
|
||||
|
||||
func destinationWithinLimitsWithMode(body *document.Node, destination Path, mode placementMode, maxDepth, maxNodes int) bool {
|
||||
if body == nil {
|
||||
return true
|
||||
}
|
||||
if maxDepth < 0 || maxNodes < 0 || !destination.valid() || (mode != placementModeMapping && mode != placementModeSequenceItem) {
|
||||
return false
|
||||
}
|
||||
segments := len(destination.segments)
|
||||
sequenceNodes := 0
|
||||
sequenceDepth := 0
|
||||
if mode == placementModeSequenceItem {
|
||||
if segments == 0 {
|
||||
return false
|
||||
}
|
||||
sequenceNodes = 1
|
||||
sequenceDepth = 1
|
||||
}
|
||||
if segments > maxDepth-sequenceDepth || segments > (maxNodes-sequenceNodes)/2 {
|
||||
return false
|
||||
}
|
||||
wrapperNodes := destinationWrapperNodeCount(destination) + sequenceNodes
|
||||
for _, segment := range destination.segments {
|
||||
if segment.kind != pathSegmentKey {
|
||||
return false
|
||||
}
|
||||
}
|
||||
if documentDepth(body) > maxDepth-segments-sequenceDepth {
|
||||
return false
|
||||
}
|
||||
return countInputNodes(body) <= maxNodes-wrapperNodes
|
||||
}
|
||||
|
||||
func destinationWrapperNodeCountForMode(destination Path, mode placementMode) int {
|
||||
count := destinationWrapperNodeCount(destination)
|
||||
if mode == placementModeSequenceItem {
|
||||
count++
|
||||
}
|
||||
return count
|
||||
}
|
||||
|
||||
func destinationWrapperNodeCount(destination Path) int {
|
||||
return 2 * len(destination.segments)
|
||||
}
|
||||
|
||||
func documentDepth(node *document.Node) int {
|
||||
if node == nil {
|
||||
return -1
|
||||
}
|
||||
depth := 0
|
||||
var visit func(*document.Node, int)
|
||||
visit = func(current *document.Node, currentDepth int) {
|
||||
if current == nil {
|
||||
return
|
||||
}
|
||||
if currentDepth > depth {
|
||||
depth = currentDepth
|
||||
}
|
||||
for _, entry := range current.Mapping {
|
||||
visit(entry.Value, currentDepth+1)
|
||||
}
|
||||
for _, child := range current.Sequence {
|
||||
visit(child, currentDepth+1)
|
||||
}
|
||||
}
|
||||
visit(node, 0)
|
||||
return depth
|
||||
}
|
||||
439
internal/engine/destination_accounting_test.go
Normal file
439
internal/engine/destination_accounting_test.go
Normal file
@@ -0,0 +1,439 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
)
|
||||
|
||||
func TestDestinationPlacementCountsAgainstSharedNodeBudget(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
limits := defaultImportLimits()
|
||||
destination := Keys("collections", "entry")
|
||||
empty := Mapping("empty", map[string]any{})
|
||||
parsed, diagnostics := parseSource(empty, "fixture", defaultParseLimits())
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
perInput := countInputNodes(parsed.document) + destinationWrapperNodeCount(destination)
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
delta int
|
||||
wantError bool
|
||||
wantCharge int
|
||||
}{
|
||||
{name: "below aggregate bound", delta: -1, wantCharge: perInput},
|
||||
{name: "at aggregate bound", delta: 0, wantCharge: perInput},
|
||||
{name: "above aggregate bound", delta: 1, wantError: true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
accounting := &importAccounting{nodes: limits.nodes - perInput + test.delta}
|
||||
composition, _, _, diagnostics := acquireUnderlayGroup(loadGroup{inputs: []loadInput{{
|
||||
source: empty, destination: destination,
|
||||
}}}, 1, nil, accounting)
|
||||
if test.wantError {
|
||||
if composition != nil || len(diagnostics) != 1 || diagnostics[0].Code != CodeSourceLimitExceeded {
|
||||
t.Fatalf("over-budget placement = %#v, %#v", composition, diagnostics)
|
||||
}
|
||||
return
|
||||
}
|
||||
if composition == nil || len(diagnostics) != 0 || composition.inputNodes != test.wantCharge {
|
||||
t.Fatalf("bounded placement = %#v, %#v", composition, diagnostics)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipleMountedInputsSharePlacementCharges(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
limits := defaultImportLimits()
|
||||
inputs := []loadInput{
|
||||
{source: Mapping("one", map[string]any{}), destination: Keys("collection", "one")},
|
||||
{source: Mapping("two", map[string]any{}), destination: Keys("collection", "two")},
|
||||
{source: Mapping("three", map[string]any{}), destination: Keys("collection", "three")},
|
||||
}
|
||||
perInput := countInputNodes(&document.Node{Kind: document.Mapping}) + destinationWrapperNodeCount(Keys("collection", "one"))
|
||||
charge := len(inputs) * perInput
|
||||
accounting := &importAccounting{nodes: limits.nodes - charge}
|
||||
composition, _, _, diagnostics := acquireUnderlayGroup(loadGroup{inputs: inputs}, 1, nil, accounting)
|
||||
if len(diagnostics) != 0 || composition == nil || accounting.nodes != limits.nodes || composition.inputNodes != charge {
|
||||
t.Fatalf("shared placement accounting = %#v, %#v, accounting %#v; want charge %d", composition, diagnostics, accounting, charge)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNestedItemGlobsShareUnderlayNodeBudget(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||
imports:
|
||||
- glob: children/*.yml
|
||||
destination: [children]
|
||||
placement: sequence-item
|
||||
id: parent
|
||||
`)
|
||||
fragmentPath := writeImportFile(t, directory, "children/a.yml", "id: child-a\n")
|
||||
parseNodes := func(path string) int {
|
||||
t.Helper()
|
||||
parsed, diagnostics := parseSource(File(path), "fixture", defaultParseLimits())
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("parse %s: %#v", path, diagnostics)
|
||||
}
|
||||
return countInputNodes(parsed.document)
|
||||
}
|
||||
destination := Keys("groups")
|
||||
childDestination := appendDestination(destination, Keys("children"))
|
||||
otherDestination := Keys("other")
|
||||
wantNodes := parseNodes(mainPath) + parseNodes(fragmentPath) + countInputNodes(&document.Node{Kind: document.Mapping}) +
|
||||
destinationWrapperNodeCountForMode(destination, placementModeSequenceItem) +
|
||||
destinationWrapperNodeCountForMode(childDestination, placementModeSequenceItem) +
|
||||
destinationWrapperNodeCountForMode(otherDestination, placementModeSequenceItem)
|
||||
limits := defaultImportLimits()
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
delta int
|
||||
wantError bool
|
||||
}{
|
||||
{name: "at aggregate bound"},
|
||||
{name: "one node below aggregate bound", delta: 1, wantError: true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
accounting := &importAccounting{nodes: limits.nodes - wantNodes + test.delta}
|
||||
composition, sources, _, diagnostics := acquireUnderlayGroup(loadGroup{inputs: []loadInput{{
|
||||
source: File(mainPath), imports: true, destination: destination, placementMode: placementModeSequenceItem,
|
||||
}, {
|
||||
source: Mapping("other", map[string]any{"id": "other"}), destination: otherDestination, placementMode: placementModeSequenceItem,
|
||||
}}}, 1, nil, accounting)
|
||||
if test.wantError {
|
||||
if composition != nil || len(diagnostics) == 0 || diagnostics[0].Code != CodeSourceLimitExceeded {
|
||||
t.Fatalf("under-budget nested item glob = %#v, %#v", composition, diagnostics)
|
||||
}
|
||||
return
|
||||
}
|
||||
if composition == nil || len(diagnostics) != 0 || len(sources) != 3 {
|
||||
t.Fatalf("bounded nested item glob = %#v, sources %#v, diagnostics %#v", composition, sources, diagnostics)
|
||||
}
|
||||
if composition.inputNodes != wantNodes || accounting.nodes != limits.nodes+test.delta {
|
||||
t.Fatalf("nested item node charge = %d/%d, want %d/%d", composition.inputNodes, accounting.nodes, wantNodes, limits.nodes+test.delta)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportedEmptyBodiesChargeEachDestinationWrapper(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
filesystem := NewFilesystem("bundle", fstest.MapFS{
|
||||
"main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: fragment.yml}]}\n")},
|
||||
"fragment.yml": &fstest.MapFile{Data: []byte("{}\n")},
|
||||
})
|
||||
mainSource := filesystem.File("main.yml")
|
||||
mainParsed, diagnostics := parseSource(mainSource, "fixture", defaultParseLimits())
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
fragmentParsed, diagnostics := parseSource(Bytes("fragment", []byte("{}\n")), "fixture", defaultParseLimits())
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatal(diagnostics)
|
||||
}
|
||||
rawNodes := countInputNodes(mainParsed.document) + countInputNodes(fragmentParsed.document)
|
||||
destination := Keys("collection", "empty")
|
||||
placementNodes := 2 * destinationWrapperNodeCount(destination)
|
||||
limits := defaultImportLimits()
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
delta int
|
||||
wantError bool
|
||||
}{
|
||||
{name: "below aggregate bound", delta: -1},
|
||||
{name: "at aggregate bound", delta: 0},
|
||||
{name: "above aggregate bound", delta: 1, wantError: true},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
accounting := &importAccounting{nodes: limits.nodes - rawNodes - placementNodes + test.delta}
|
||||
composition, sources, _, diagnostics := acquireUnderlayGroup(loadGroup{inputs: []loadInput{{
|
||||
source: mainSource, imports: true, destination: destination,
|
||||
}}}, 1, nil, accounting)
|
||||
if test.wantError {
|
||||
if composition != nil || len(diagnostics) == 0 || diagnostics[0].Code != CodeImportLimitExceeded {
|
||||
t.Fatalf("over-budget imports = %#v, %#v", composition, diagnostics)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(diagnostics) != 0 || composition == nil || len(sources) != 2 {
|
||||
t.Fatalf("bounded imports = %#v, sources %#v, diagnostics %#v", composition, sources, diagnostics)
|
||||
}
|
||||
if composition.inputNodes != rawNodes+placementNodes || accounting.nodes != limits.nodes+test.delta {
|
||||
t.Fatalf("import node charge = %d, accounting %d; want %d and %d", composition.inputNodes, accounting.nodes, rawNodes+placementNodes, limits.nodes+test.delta)
|
||||
}
|
||||
if node, present := documentAtPath(composition.root, Keys("collection", "empty")); !present || node.Kind != document.Mapping || len(node.Mapping) != 0 {
|
||||
t.Fatalf("placed empty import body = %#v, present %v", node, present)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportedEmptyBodiesChargeFullRelativeDestinations(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||
imports:
|
||||
- path: empty.yml
|
||||
destination: [groups, empty]
|
||||
`)
|
||||
fragmentPath := writeImportFile(t, directory, "empty.yml", "{}\n")
|
||||
main := parseImportMain(t, mainPath)
|
||||
fragment, diagnostics := parseSource(File(fragmentPath), "source-2", defaultParseLimits())
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("empty fragment diagnostics = %#v", diagnostics)
|
||||
}
|
||||
entryDestination := Keys("collections", "entry")
|
||||
importDestination := appendDestination(entryDestination, Keys("groups", "empty"))
|
||||
wantNodes := countInputNodes(main.document) + countInputNodes(fragment.document) +
|
||||
destinationWrapperNodeCount(entryDestination) + destinationWrapperNodeCount(importDestination)
|
||||
limits := defaultImportLimits()
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
delta int
|
||||
wantError bool
|
||||
}{
|
||||
{name: "below aggregate bound", delta: -1, wantError: true},
|
||||
{name: "at aggregate bound"},
|
||||
{name: "above aggregate bound", delta: 1},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
currentLimits := limits
|
||||
currentLimits.nodes = wantNodes + test.delta
|
||||
accounting := &importAccounting{}
|
||||
composed, sources, diagnostics := loadImportedDocumentsTracked(
|
||||
File(mainPath), nil, main, entryDestination, nil, currentLimits, accounting, nil, nil,
|
||||
)
|
||||
if test.wantError {
|
||||
if composed != nil || len(diagnostics) != 1 || diagnostics[0].Code != CodeImportLimitExceeded {
|
||||
t.Fatalf("below-bound relative placement = %#v, %#v", composed, diagnostics)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(diagnostics) != 0 || composed == nil || len(sources) != 2 {
|
||||
t.Fatalf("bounded relative placement = %#v, %#v, %#v", composed, sources, diagnostics)
|
||||
}
|
||||
if composed.inputNodes != wantNodes || accounting.nodes != wantNodes {
|
||||
t.Fatalf("relative placement node charge = %d/%d, want %d", composed.inputNodes, accounting.nodes, wantNodes)
|
||||
}
|
||||
if sources[1].Destination.String() != importDestination.String() {
|
||||
t.Fatalf("empty import destination = %q, want %q", sources[1].Destination.String(), importDestination.String())
|
||||
}
|
||||
if node, present := documentAtPath(composed.root, importDestination); !present || node.Kind != document.Mapping || len(node.Mapping) != 0 {
|
||||
t.Fatalf("empty relative import body = %#v, present %v", node, present)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestGlobMatchesChargeTheirSharedRelativeDestination(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||
imports:
|
||||
- glob: fragments/*.yml
|
||||
destination: [groups, empty]
|
||||
`)
|
||||
fragmentPaths := []string{
|
||||
writeImportFile(t, directory, "fragments/a.yml", "{}\n"),
|
||||
writeImportFile(t, directory, "fragments/b.yml", "{}\n"),
|
||||
writeImportFile(t, directory, "fragments/c.yml", "{}\n"),
|
||||
}
|
||||
main := parseImportMain(t, mainPath)
|
||||
fragment, diagnostics := parseSource(File(fragmentPaths[0]), "source-fragment", defaultParseLimits())
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("glob fragment diagnostics = %#v", diagnostics)
|
||||
}
|
||||
entryDestination := Keys("collections", "entry")
|
||||
importDestination := appendDestination(entryDestination, Keys("groups", "empty"))
|
||||
wantNodes := countInputNodes(main.document) + len(fragmentPaths)*countInputNodes(fragment.document) +
|
||||
destinationWrapperNodeCount(entryDestination) + len(fragmentPaths)*destinationWrapperNodeCount(importDestination)
|
||||
limits := defaultImportLimits()
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
delta int
|
||||
wantError bool
|
||||
}{
|
||||
{name: "below aggregate bound", delta: -1, wantError: true},
|
||||
{name: "at aggregate bound"},
|
||||
{name: "above aggregate bound", delta: 1},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
currentLimits := limits
|
||||
currentLimits.nodes = wantNodes + test.delta
|
||||
accounting := &importAccounting{}
|
||||
composed, sources, diagnostics := loadImportedDocumentsTracked(
|
||||
File(mainPath), nil, main, entryDestination, nil, currentLimits, accounting, nil, nil,
|
||||
)
|
||||
if test.wantError {
|
||||
if composed != nil || len(diagnostics) != 1 || diagnostics[0].Code != CodeImportLimitExceeded {
|
||||
t.Fatalf("below-bound glob placement = %#v, %#v", composed, diagnostics)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(diagnostics) != 0 || composed == nil || len(sources) != len(fragmentPaths)+1 {
|
||||
t.Fatalf("bounded glob placement = %#v, %#v, %#v", composed, sources, diagnostics)
|
||||
}
|
||||
if composed.inputNodes != wantNodes || accounting.nodes != wantNodes {
|
||||
t.Fatalf("glob relative placement node charge = %d/%d, want %d", composed.inputNodes, accounting.nodes, wantNodes)
|
||||
}
|
||||
for index, source := range sources[1:] {
|
||||
if source.Destination.String() != importDestination.String() {
|
||||
t.Errorf("glob source %d destination = %q, want %q", index, source.Destination.String(), importDestination.String())
|
||||
}
|
||||
}
|
||||
if node, present := documentAtPath(composed.root, importDestination); !present || node.Kind != document.Mapping || len(node.Mapping) != 0 {
|
||||
t.Fatalf("empty glob contributions = %#v, present %v", node, present)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelativeImportDestinationUsesAccumulatedDocumentDepth(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: fragment.yml, destination: [extra]}]}\n")
|
||||
writeImportFile(t, directory, "fragment.yml", "value: nested\n")
|
||||
destination := Path{segments: make([]pathSegment, maxDocumentDepth)}
|
||||
for index := range destination.segments {
|
||||
destination.segments[index] = pathSegment{kind: pathSegmentKey, key: "entry"}
|
||||
}
|
||||
_, err := LoadLayered(
|
||||
Input{Source: File(mainPath), Imports: true, Destination: destination}, testSecretBinding(),
|
||||
LayeredOptions[map[string]any]{},
|
||||
)
|
||||
loadError, ok := err.(*LoadError)
|
||||
if !ok || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != CodeImportLimitExceeded {
|
||||
t.Fatalf("accumulated depth result = %v", err)
|
||||
}
|
||||
sources := loadError.Sources()
|
||||
if len(sources) != 2 || len(sources[0].Destination.segments) != maxDocumentDepth || len(sources[1].Destination.segments) != maxDocumentDepth+1 {
|
||||
t.Fatalf("depth failure source destinations = %#v", sources)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAbsentOptionalImportsDoNotChargeUnusedDeepDestinations(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
destination := Path{segments: make([]pathSegment, maxDocumentDepth)}
|
||||
for index := range destination.segments {
|
||||
destination.segments[index] = pathSegment{kind: pathSegmentKey, key: "entry"}
|
||||
}
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
body string
|
||||
setup func(*testing.T, string)
|
||||
}{
|
||||
{
|
||||
name: "optional missing path",
|
||||
body: "configkit: {imports: [{path: missing.yml, destination: [unused]}]}\n",
|
||||
},
|
||||
{
|
||||
name: "optional unmatched glob",
|
||||
body: "configkit: {imports: [{glob: empty/*.yml, destination: [unused]}]}\n",
|
||||
setup: func(t *testing.T, directory string) {
|
||||
if err := os.Mkdir(filepath.Join(directory, "empty"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
},
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
if test.setup != nil {
|
||||
test.setup(t, directory)
|
||||
}
|
||||
mainPath := writeImportFile(t, directory, "main.yml", test.body)
|
||||
main := parseImportMain(t, mainPath)
|
||||
limits := defaultImportLimits()
|
||||
accounting := &importAccounting{}
|
||||
composed, sources, diagnostics := loadImportedDocumentsTracked(
|
||||
File(mainPath), nil, main, destination, nil, limits, accounting, nil, nil,
|
||||
)
|
||||
if len(diagnostics) != 0 || composed == nil || len(sources) != 1 {
|
||||
t.Fatalf("absent optional import = %#v, %#v, %#v", composed, sources, diagnostics)
|
||||
}
|
||||
wantNodes := countInputNodes(main.document) + destinationWrapperNodeCount(destination)
|
||||
if composed.inputNodes != wantNodes || accounting.nodes != wantNodes {
|
||||
t.Fatalf("absent optional node charge = %d/%d, want %d", composed.inputNodes, accounting.nodes, wantNodes)
|
||||
}
|
||||
if node, present := documentAtPath(composed.root, destination); !present || node.Kind != document.Mapping || len(node.Mapping) != 0 {
|
||||
t.Fatalf("empty entry body = %#v, present %v", node, present)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoSourceAndRootPlacementAddNoWrapperCharges(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
accounting := &importAccounting{}
|
||||
composition, sources, _, diagnostics := acquireUnderlayGroup(loadGroup{inputs: []loadInput{
|
||||
{source: NoSource(), destination: Keys("absent")},
|
||||
{source: Mapping("root", map[string]any{})},
|
||||
}}, 1, nil, accounting)
|
||||
if len(diagnostics) != 0 || composition == nil || len(sources) != 1 || accounting.nodes != 1 || composition.inputNodes != 1 {
|
||||
t.Fatalf("root and absent placement = %#v, sources %#v, diagnostics %#v, accounting %#v", composition, sources, diagnostics, accounting)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSelectedOverlayAndCLIUsePlacementNodeCharges(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
limits := defaultImportLimits()
|
||||
baseNodes := countInputNodes(&document.Node{Kind: document.Mapping}) + destinationWrapperNodeCount(Keys("mounted"))
|
||||
|
||||
t.Run("selected overlay", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
filesystem := NewFilesystem("bundle", fstest.MapFS{
|
||||
"overlays/test.yml": &fstest.MapFile{Data: []byte("{}\n")},
|
||||
})
|
||||
request := loadRequest[map[string]any]{
|
||||
underlays: []loadGroup{{inputs: []loadInput{{source: Mapping("empty", map[string]any{}), destination: Keys("mounted")}}}},
|
||||
overlay: "test", overlayFilesystem: &FilesystemContext{Root: ".", BaseDir: ".", FS: &filesystem},
|
||||
}
|
||||
accounting := &importAccounting{nodes: limits.nodes - baseNodes}
|
||||
_, diagnostics := assembleLayeredInput(request, assembledInput{}, accounting)
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeOverlayLimitExceeded {
|
||||
t.Fatalf("overlay diagnostics = %#v", diagnostics)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("CLI override", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
override := Override{Path: Keys("mounted", "value"), Value: "x", Flag: "--value"}
|
||||
builder := valueBuilder{sourceID: "fixture", active: make(map[valueVisit]bool)}
|
||||
if _, issue := builder.build(override.Value, override.Path, len(override.Path.segments)); issue != nil {
|
||||
t.Fatal(issue)
|
||||
}
|
||||
overrideNodes := builder.nodes + destinationWrapperNodeCount(override.Path)
|
||||
request := loadRequest[map[string]any]{
|
||||
underlays: []loadGroup{{inputs: []loadInput{{source: Mapping("empty", map[string]any{}), destination: Keys("mounted")}}}},
|
||||
overrides: []Override{override},
|
||||
}
|
||||
accounting := &importAccounting{nodes: limits.nodes - baseNodes - overrideNodes + 1}
|
||||
_, diagnostics := assembleLayeredInput(request, assembledInput{}, accounting)
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeOverrideLimitExceeded {
|
||||
t.Fatalf("override diagnostics = %#v", diagnostics)
|
||||
}
|
||||
})
|
||||
}
|
||||
142
internal/engine/destination_diagnostic_test.go
Normal file
142
internal/engine/destination_diagnostic_test.go
Normal file
@@ -0,0 +1,142 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
)
|
||||
|
||||
func TestLayeredDestinationDiagnosticsUseEffectivePathsAndAuthoredLocations(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("strict conflict retains both source locations", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
loaded, err := LoadLayered(
|
||||
Input{Source: NoSource()},
|
||||
testSecretBinding(),
|
||||
LayeredOptions[map[string]any]{Underlays: []Underlay{{Inputs: []Input{
|
||||
{Source: Bytes("left", []byte("section:\n value: left\n")), Destination: Keys("application")},
|
||||
{Source: Bytes("right", []byte("section:\n value: right\n")), Destination: Keys("application")},
|
||||
}}}},
|
||||
)
|
||||
if loaded != nil {
|
||||
t.Fatalf("conflicting load returned a result: %#v", loaded)
|
||||
}
|
||||
diagnostic := firstDestinationDiagnostic(t, err)
|
||||
if diagnostic.Code != CodeComposeConflict || diagnostic.Path.String() != "/application/section/value" {
|
||||
t.Fatalf("conflict diagnostic = %#v", diagnostic)
|
||||
}
|
||||
if diagnostic.Primary == nil || diagnostic.Primary.SourceID != "source-1" || diagnostic.Primary.Line != 2 {
|
||||
t.Fatalf("primary location = %#v", diagnostic.Primary)
|
||||
}
|
||||
if len(diagnostic.Related) != 1 || diagnostic.Related[0].SourceID != "source-2" || diagnostic.Related[0].Line != 2 {
|
||||
t.Fatalf("related locations = %#v", diagnostic.Related)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("decode failure keeps effective path and content line", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
type section struct {
|
||||
Count int `yaml:"count"`
|
||||
}
|
||||
type config struct {
|
||||
Application struct {
|
||||
Section section `yaml:"section"`
|
||||
} `yaml:"application"`
|
||||
}
|
||||
loaded, err := LoadLayered(
|
||||
Input{Source: Bytes("main", []byte("section:\n count: invalid\n")), Destination: Keys("application")},
|
||||
testSecretBinding(),
|
||||
LayeredOptions[config]{},
|
||||
)
|
||||
if loaded != nil {
|
||||
t.Fatalf("invalid load returned a result: %#v", loaded)
|
||||
}
|
||||
diagnostic := firstDestinationDiagnostic(t, err)
|
||||
if diagnostic.Code != CodeDecodeType || diagnostic.Path.String() != "/application/section/count" {
|
||||
t.Fatalf("decode diagnostic = %#v", diagnostic)
|
||||
}
|
||||
if diagnostic.Primary == nil || diagnostic.Primary.SourceID != "source-1" || diagnostic.Primary.Line != 2 {
|
||||
t.Fatalf("decode location = %#v", diagnostic.Primary)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("imported version failure uses effective path and import source", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
directory := t.TempDir()
|
||||
writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: fragment.yml}]}\n")
|
||||
writeImportFile(t, directory, "fragment.yml", "version: forbidden\n")
|
||||
rule := &VersionRule{Path: Keys("application", "version"), Check: func(Version, *Validation) error { return nil }}
|
||||
loaded, err := LoadLayered(
|
||||
Input{Source: NoSource()},
|
||||
testSecretBinding(),
|
||||
LayeredOptions[map[string]any]{
|
||||
Definition: Definition[map[string]any]{Version: rule},
|
||||
Underlays: []Underlay{{Inputs: []Input{{
|
||||
Source: File(filepath.Join(directory, "main.yml")), Imports: true, Destination: Keys("application"),
|
||||
}}}},
|
||||
},
|
||||
)
|
||||
if loaded != nil {
|
||||
t.Fatalf("invalid import returned a result: %#v", loaded)
|
||||
}
|
||||
diagnostic := firstDestinationDiagnostic(t, err)
|
||||
if diagnostic.Code != CodeImportVersion || diagnostic.Path.String() != "/application/version" {
|
||||
t.Fatalf("import version diagnostic = %#v", diagnostic)
|
||||
}
|
||||
if diagnostic.Primary == nil || diagnostic.Primary.SourceID != "source-2" || diagnostic.Primary.Line != 1 {
|
||||
t.Fatalf("import version location = %#v", diagnostic.Primary)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("invalid destination has no fictional source location", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
loaded, err := LoadLayered(
|
||||
Input{Source: Bytes("main", []byte("value: present\n")), Destination: Keys("configkit")},
|
||||
testSecretBinding(),
|
||||
LayeredOptions[map[string]any]{},
|
||||
)
|
||||
if loaded != nil {
|
||||
t.Fatalf("invalid destination returned a result: %#v", loaded)
|
||||
}
|
||||
diagnostic := firstDestinationDiagnostic(t, err)
|
||||
if diagnostic.Code != CodeOptionsInvalid || diagnostic.Path.String() != "/configkit" || diagnostic.Primary != nil {
|
||||
t.Fatalf("invalid destination diagnostic = %#v", diagnostic)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("malformed source directive keeps its local path and location", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
filesystem := NewFilesystem("bundle", fstest.MapFS{
|
||||
"main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [true]}\nvalue: present\n")},
|
||||
})
|
||||
loaded, err := LoadLayered(
|
||||
Input{Source: filesystem.File("main.yml"), Imports: true, Destination: Keys("application")},
|
||||
testSecretBinding(),
|
||||
LayeredOptions[map[string]any]{},
|
||||
)
|
||||
if loaded != nil {
|
||||
t.Fatalf("malformed directive returned a result: %#v", loaded)
|
||||
}
|
||||
diagnostic := firstDestinationDiagnostic(t, err)
|
||||
if diagnostic.Code != CodeImportInvalid || diagnostic.Path.String() != "/configkit/imports/0" {
|
||||
t.Fatalf("malformed directive diagnostic = %#v", diagnostic)
|
||||
}
|
||||
if diagnostic.Primary == nil || diagnostic.Primary.SourceID != "source-1" || diagnostic.Primary.Line != 1 {
|
||||
t.Fatalf("malformed directive location = %#v", diagnostic.Primary)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func firstDestinationDiagnostic(t *testing.T, err error) Diagnostic {
|
||||
t.Helper()
|
||||
loadError, ok := err.(*LoadError)
|
||||
if !ok {
|
||||
t.Fatalf("load error = %#v", err)
|
||||
}
|
||||
diagnostics := loadError.Diagnostics()
|
||||
if len(diagnostics) == 0 {
|
||||
t.Fatal("load error has no diagnostics")
|
||||
}
|
||||
return diagnostics[0]
|
||||
}
|
||||
211
internal/engine/destination_import_lifecycle_test.go
Normal file
211
internal/engine/destination_import_lifecycle_test.go
Normal file
@@ -0,0 +1,211 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRelativeImportsFeedFinalInheritanceAndInspectionLifecycle(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type entry struct {
|
||||
Extends string `yaml:"extends"`
|
||||
Label string `yaml:"label"`
|
||||
Token *Secret `yaml:"token" configkit:"secret"`
|
||||
Dynamic string `yaml:"dynamic"`
|
||||
Derived string `yaml:"derived"`
|
||||
Height int `yaml:"height"`
|
||||
}
|
||||
type configuration struct {
|
||||
Catalog struct {
|
||||
Entries map[string]entry `yaml:"entries"`
|
||||
} `yaml:"catalog"`
|
||||
}
|
||||
|
||||
load := func(dynamic string) *testResult[configuration] {
|
||||
t.Helper()
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "entry.yml", `configkit:
|
||||
imports:
|
||||
- path: parent.yml
|
||||
destination: [entries]
|
||||
- path: child.yml
|
||||
destination: [entries]
|
||||
`)
|
||||
writeImportFile(t, directory, "parent.yml", `parent:
|
||||
label: lower
|
||||
token: PARENT_TOKEN
|
||||
height: 1
|
||||
`)
|
||||
writeImportFile(t, directory, "child.yml", fmt.Sprintf(`child:
|
||||
extends: parent
|
||||
label: child
|
||||
dynamic: %s
|
||||
height: 9
|
||||
`, dynamic))
|
||||
|
||||
var defaultsCalls, resolveCalls, validateCalls int
|
||||
secretPaths := make(map[string]bool)
|
||||
lookupCalls := make(map[string]int)
|
||||
request := loadRequest[configuration]{
|
||||
main: loadInput{
|
||||
source: Bytes("main", []byte("parent: {label: final, height: 4}\n")),
|
||||
destination: Keys("catalog", "entries"),
|
||||
},
|
||||
underlays: []loadGroup{{inputs: []loadInput{{
|
||||
source: File(mainPath), imports: true, destination: Keys("catalog"),
|
||||
}}}},
|
||||
definition: Definition[configuration]{
|
||||
Defaults: func() configuration {
|
||||
defaultsCalls++
|
||||
var defaults configuration
|
||||
defaults.Catalog.Entries = map[string]entry{"fallback": {Label: "default"}}
|
||||
return defaults
|
||||
},
|
||||
Resolve: func(config *configuration, resolution *Resolution) error {
|
||||
resolveCalls++
|
||||
child := config.Catalog.Entries["child"]
|
||||
child.Derived = child.Label + ":" + child.Dynamic + ":" + fmt.Sprint(child.Height)
|
||||
config.Catalog.Entries["child"] = child
|
||||
resolution.Record(Keys("catalog", "entries", "child", "derived"), ChangeNormalization, "derive final child summary", Keys("catalog", "entries", "child", "label"), Keys("catalog", "entries", "child", "dynamic"), Keys("catalog", "entries", "child", "height"))
|
||||
return nil
|
||||
},
|
||||
Validate: func(config *configuration, validation *Validation) {
|
||||
validateCalls++
|
||||
parent := config.Catalog.Entries["parent"]
|
||||
child := config.Catalog.Entries["child"]
|
||||
if parent.Label != "final" || parent.Height != 4 || child.Label != "child" || child.Height != 9 || child.Derived != child.Label+":"+child.Dynamic+":9" || child.Token == nil || !child.Token.IsResolved() || child.Token.Value() != "parent-secret-canary" {
|
||||
validation.Error(Keys("catalog", "entries", "child"), "final imported configuration was not available to validation")
|
||||
}
|
||||
},
|
||||
Secret: func(path Path) bool {
|
||||
secretPaths[path.String()] = true
|
||||
return path.String() == "/catalog/entries/child/dynamic"
|
||||
},
|
||||
},
|
||||
}
|
||||
loaded, err := loadGroupedWithSecretLookup(request, testSecretBinding(), func(name string) (string, bool) {
|
||||
lookupCalls[name]++
|
||||
if name == "PARENT_TOKEN" {
|
||||
return "parent-secret-canary", true
|
||||
}
|
||||
return "", false
|
||||
})
|
||||
if err != nil {
|
||||
if loadError, ok := err.(*LoadError); ok {
|
||||
t.Fatalf("relative import lifecycle diagnostics: %#v", loadError.Diagnostics())
|
||||
}
|
||||
t.Fatalf("relative import lifecycle: %v", err)
|
||||
}
|
||||
if defaultsCalls != 1 || resolveCalls != 1 || validateCalls != 1 || lookupCalls["PARENT_TOKEN"] != 1 {
|
||||
t.Fatalf("lifecycle calls defaults=%d resolve=%d validate=%d lookups=%#v", defaultsCalls, resolveCalls, validateCalls, lookupCalls)
|
||||
}
|
||||
if !secretPaths["/catalog/entries/child/dynamic"] {
|
||||
t.Fatalf("secret classifier did not see effective paths: %#v", secretPaths)
|
||||
}
|
||||
return testLoaded(loaded)
|
||||
}
|
||||
|
||||
left := load("LEFT-DYNAMIC-CANARY")
|
||||
child := left.Config.Catalog.Entries["child"]
|
||||
if child.Extends != "" || child.Label != "child" || child.Height != 9 || child.Derived != "child:LEFT-DYNAMIC-CANARY:9" || child.Token == nil || child.Token.Value() != "parent-secret-canary" {
|
||||
t.Fatalf("resolved imported child = %#v", child)
|
||||
}
|
||||
if left.Config.Catalog.Entries["parent"].Label != "final" || left.Config.Catalog.Entries["parent"].Height != 4 || left.Config.Catalog.Entries["fallback"].Label != "default" {
|
||||
t.Fatalf("final parent/default shape = %#v", left.Config.Catalog.Entries)
|
||||
}
|
||||
|
||||
childTokenPath := Keys("catalog", "entries", "child", "token")
|
||||
childToken, found := left.Inspection.Explain(childTokenPath)
|
||||
childTokenInfo, tokenInfoFound := left.Inspection.Metadata().Lookup(childTokenPath)
|
||||
if !found || !tokenInfoFound || !childToken.Effective.Redacted || strings.Contains(childToken.Effective.YAML, "parent-secret-canary") || len(childTokenInfo.Origin.Inheritance) != 1 {
|
||||
t.Fatalf("inherited field explanation = %#v, %v", childToken, found)
|
||||
}
|
||||
var explanation bytes.Buffer
|
||||
if err := left.Inspection.WriteExplanation(&explanation, childTokenPath); err != nil || strings.Contains(explanation.String(), "parent-secret-canary") || strings.Contains(explanation.String(), "LEFT-DYNAMIC-CANARY") {
|
||||
t.Fatalf("safe explanation = %q, %v", explanation.String(), err)
|
||||
}
|
||||
labelHistory := left.Inspection.Metadata().History(Keys("catalog", "entries", "parent", "label"))
|
||||
if len(labelHistory) < 2 {
|
||||
t.Fatalf("parent label history omitted replaced contribution: %#v", labelHistory)
|
||||
}
|
||||
derived, found := left.Inspection.Explain(Keys("catalog", "entries", "child", "derived"))
|
||||
if !found || !derived.Effective.Redacted || strings.Contains(derived.Effective.YAML, "LEFT-DYNAMIC-CANARY") || len(derived.History) == 0 || derived.History[len(derived.History)-1].Origin.Operation != ChangeNormalization {
|
||||
t.Fatalf("recorded derived field explanation = %#v, %v", derived, found)
|
||||
}
|
||||
|
||||
right := load("RIGHT-DYNAMIC-CANARY")
|
||||
differences, err := Diff(left.Inspection, right.Inspection)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
seen := make(map[string]bool)
|
||||
for _, difference := range differences {
|
||||
path := difference.Path.String()
|
||||
seen[path] = true
|
||||
if !difference.Left.Redacted || !difference.Right.Redacted || !difference.HiddenChanged || strings.Contains(difference.Left.YAML+difference.Right.YAML, "DYNAMIC-CANARY") || strings.Contains(difference.Left.YAML+difference.Right.YAML, "parent-secret-canary") {
|
||||
t.Errorf("sensitive difference at %s = %#v", path, difference)
|
||||
}
|
||||
}
|
||||
for _, path := range []string{"/catalog/entries/child/dynamic", "/catalog/entries/child/derived"} {
|
||||
if !seen[path] {
|
||||
t.Errorf("missing inherited/derived difference at %s: %#v", path, differences)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelativeImportMatchesWrappedPlacementAndSafeDiff(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
load := func(relativeImport bool, value string) *testResult[map[string]any] {
|
||||
t.Helper()
|
||||
input := Input{Source: NoSource()}
|
||||
options := LayeredOptions[map[string]any]{}
|
||||
if relativeImport {
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||
imports:
|
||||
- path: fragment.yml
|
||||
destination: [application]
|
||||
`)
|
||||
writeImportFile(t, directory, "fragment.yml", "field: "+value+"\n")
|
||||
options.Underlays = []Underlay{{Inputs: []Input{{Source: File(mainPath), Imports: true}}}}
|
||||
} else {
|
||||
options.Underlays = []Underlay{{Inputs: []Input{{Source: Bytes("wrapped", []byte("application: {field: "+value+"}\n"))}}}}
|
||||
}
|
||||
loaded, err := LoadLayered(input, testSecretBinding(), options)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadLayered: %v", err)
|
||||
}
|
||||
return testLoaded(loaded)
|
||||
}
|
||||
|
||||
relativeBefore := load(true, "before")
|
||||
relativeAfter := load(true, "after")
|
||||
wrappedBefore := load(false, "before")
|
||||
wrappedAfter := load(false, "after")
|
||||
for _, pair := range [][2]*testResult[map[string]any]{{relativeBefore, wrappedBefore}, {relativeAfter, wrappedAfter}} {
|
||||
if !reflect.DeepEqual(pair[0].Config, pair[1].Config) {
|
||||
t.Fatalf("relative and wrapped configurations differ: %#v vs %#v", pair[0].Config, pair[1].Config)
|
||||
}
|
||||
for _, result := range pair {
|
||||
if info, ok := result.Metadata().Lookup(Keys("application", "field")); !ok || !info.Explicit {
|
||||
t.Fatalf("effective field presence = %#v, %v", info, ok)
|
||||
}
|
||||
}
|
||||
}
|
||||
relativeDiff, err := Diff(relativeBefore.Inspection, relativeAfter.Inspection)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
wrappedDiff, err := Diff(wrappedBefore.Inspection, wrappedAfter.Inspection)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !reflect.DeepEqual(relativeDiff, wrappedDiff) {
|
||||
t.Fatalf("relative safe diff = %#v, wrapped safe diff = %#v", relativeDiff, wrappedDiff)
|
||||
}
|
||||
}
|
||||
573
internal/engine/destination_import_test.go
Normal file
573
internal/engine/destination_import_test.go
Normal file
@@ -0,0 +1,573 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
)
|
||||
|
||||
type importDestinationConfig struct {
|
||||
Mounted map[string]string `yaml:"mounted"`
|
||||
Collections map[string]map[string]string `yaml:"collections"`
|
||||
}
|
||||
|
||||
func TestLayeredHostImportsInheritInputDestinationTransitively(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||
imports:
|
||||
- path: first.yml
|
||||
- path: second.yml
|
||||
root: main
|
||||
`)
|
||||
firstPath := writeImportFile(t, directory, "first.yml", `configkit:
|
||||
imports:
|
||||
- path: nested/third.yml
|
||||
first: one
|
||||
`)
|
||||
thirdPath := writeImportFile(t, directory, "nested/third.yml", "nested: three\n")
|
||||
secondPath := writeImportFile(t, directory, "second.yml", "second: two\n")
|
||||
|
||||
loaded, err := LoadLayered(
|
||||
Input{Source: File(mainPath), Imports: true, Destination: Keys("mounted")},
|
||||
testSecretBinding(),
|
||||
LayeredOptions[importDestinationConfig]{},
|
||||
)
|
||||
if err != nil {
|
||||
if loadError, ok := err.(*LoadError); ok {
|
||||
t.Fatalf("LoadLayered diagnostics: %#v", loadError.Diagnostics())
|
||||
}
|
||||
t.Fatalf("LoadLayered: %v", err)
|
||||
}
|
||||
result := testLoaded(loaded)
|
||||
want := map[string]string{"root": "main", "first": "one", "nested": "three", "second": "two"}
|
||||
if len(result.Config.Mounted) != len(want) {
|
||||
t.Fatalf("mounted values = %#v", result.Config.Mounted)
|
||||
}
|
||||
for key, value := range want {
|
||||
if result.Config.Mounted[key] != value {
|
||||
t.Errorf("mounted[%q] = %q, want %q", key, result.Config.Mounted[key], value)
|
||||
}
|
||||
}
|
||||
if _, ok := result.Metadata().Lookup(Keys("mounted", "configkit")); ok {
|
||||
t.Fatal("source-root import directive leaked into application content")
|
||||
}
|
||||
sources := result.Sources()
|
||||
wantPaths := []string{canonicalTestPath(t, mainPath), canonicalTestPath(t, firstPath), canonicalTestPath(t, thirdPath), canonicalTestPath(t, secondPath)}
|
||||
if len(sources) != len(wantPaths) {
|
||||
t.Fatalf("sources = %#v", sources)
|
||||
}
|
||||
for index, source := range sources {
|
||||
if source.Destination.String() != "/mounted" {
|
||||
t.Errorf("source %d destination = %q", index, source.Destination.String())
|
||||
}
|
||||
if source.Path != wantPaths[index] {
|
||||
t.Errorf("source %d path = %q, want %q", index, source.Path, wantPaths[index])
|
||||
}
|
||||
if index > 0 && source.ImportedFrom == nil {
|
||||
t.Errorf("imported source %d has no physical import location", index)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportTraversalUsesPerDeclarationDestinations(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||
imports:
|
||||
- path: parent.yml
|
||||
- path: sibling.yml
|
||||
- glob: fragments/*.yml
|
||||
`)
|
||||
parentPath := writeImportFile(t, directory, "parent.yml", `configkit:
|
||||
imports:
|
||||
- path: nested/child.yml
|
||||
parent: true
|
||||
`)
|
||||
childPath := writeImportFile(t, directory, "nested/child.yml", "child: true\n")
|
||||
siblingPath := writeImportFile(t, directory, "sibling.yml", "sibling: true\n")
|
||||
alphaPath := writeImportFile(t, directory, "fragments/a.yml", "alpha: true\n")
|
||||
betaPath := writeImportFile(t, directory, "fragments/b.yml", "beta: true\n")
|
||||
|
||||
main := parseImportMain(t, mainPath)
|
||||
_, declarations, diagnostics := splitImportDirectives(main.document)
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("main directive diagnostics = %#v", diagnostics)
|
||||
}
|
||||
declarations[0].destination = Keys("parent")
|
||||
declarations[1].destination = Keys("sibling")
|
||||
declarations[2].destination = Keys("collection")
|
||||
|
||||
access, err := openHostAccess(directory)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer func() { _ = access.close() }()
|
||||
state := importTraversal{
|
||||
access: access, accounting: &importAccounting{}, limits: defaultImportLimits(),
|
||||
active: make(map[resourceIdentity]int), visited: make(map[resourceIdentity]importRoute),
|
||||
}
|
||||
if diagnostics := state.walkDeclarations(directory, declarations, 0, Keys("entry")); len(diagnostics) != 0 {
|
||||
t.Fatalf("walk diagnostics = %#v", diagnostics)
|
||||
}
|
||||
|
||||
wantSources := []struct {
|
||||
path string
|
||||
destination string
|
||||
}{
|
||||
{path: parentPath, destination: "/entry/parent"},
|
||||
{path: childPath, destination: "/entry/parent"},
|
||||
{path: siblingPath, destination: "/entry/sibling"},
|
||||
{path: alphaPath, destination: "/entry/collection"},
|
||||
{path: betaPath, destination: "/entry/collection"},
|
||||
}
|
||||
if len(state.sources) != len(wantSources) {
|
||||
t.Fatalf("import sources = %#v, want %d", state.sources, len(wantSources))
|
||||
}
|
||||
for index, want := range wantSources {
|
||||
got := state.sources[index]
|
||||
if got.Path != canonicalTestPath(t, want.path) || got.Destination.String() != want.destination {
|
||||
t.Errorf("source %d = (%q, %q), want (%q, %q)", index, got.Path, got.Destination.String(), canonicalTestPath(t, want.path), want.destination)
|
||||
}
|
||||
}
|
||||
|
||||
documents := make([]*document.Node, 0, len(state.scope.contributions))
|
||||
for _, contribution := range state.scope.contributions {
|
||||
documents = append(documents, contribution.document)
|
||||
}
|
||||
composed, diagnostics := composeDocuments(documents...)
|
||||
if len(diagnostics) != 0 {
|
||||
t.Fatalf("composition diagnostics = %#v", diagnostics)
|
||||
}
|
||||
for _, path := range []Path{
|
||||
Keys("entry", "parent", "parent"), Keys("entry", "parent", "child"),
|
||||
Keys("entry", "sibling", "sibling"), Keys("entry", "collection", "alpha"),
|
||||
Keys("entry", "collection", "beta"),
|
||||
} {
|
||||
if _, present := documentAtPath(composed.root, path); !present {
|
||||
t.Errorf("no composed value at %q", path.String())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestEntryDestinationSurvivesImportSetupAndParseFailures(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
t.Run("import context failure", func(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: child.yml}]}\n")
|
||||
input, diagnostics := assembleBaseInputTracked(
|
||||
File(mainPath),
|
||||
Options[map[string]any]{Imports: true, Filesystem: &FilesystemContext{Root: t.TempDir()}},
|
||||
Keys("mounted"), &importAccounting{}, nil, nil,
|
||||
)
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportOutsideRoot {
|
||||
t.Fatalf("context diagnostics = %#v", diagnostics)
|
||||
}
|
||||
if len(input.sources) != 1 || input.sources[0].Destination.String() != "/mounted" {
|
||||
t.Fatalf("entry source fallback = %#v", input.sources)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("imported parse failure", func(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: broken.yml}]}\n")
|
||||
writeImportFile(t, directory, "broken.yml", "broken: [\n")
|
||||
input, diagnostics := assembleBaseInputTracked(
|
||||
File(mainPath), Options[map[string]any]{Imports: true},
|
||||
Keys("mounted"), &importAccounting{}, nil, nil,
|
||||
)
|
||||
if len(diagnostics) != 1 || diagnostics[0].Code != CodeYAMLInvalid {
|
||||
t.Fatalf("parse diagnostics = %#v", diagnostics)
|
||||
}
|
||||
if len(input.sources) != 2 {
|
||||
t.Fatalf("parse-failure sources = %#v", input.sources)
|
||||
}
|
||||
for index, source := range input.sources {
|
||||
if source.Destination.String() != "/mounted" {
|
||||
t.Errorf("source %d destination = %q, want /mounted", index, source.Destination.String())
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRelativeImportDestinationsKeepStrictCompositionBoundaries(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
main string
|
||||
files map[string]string
|
||||
want map[string]any
|
||||
conflict bool
|
||||
wantPath string
|
||||
}{
|
||||
{
|
||||
name: "disjoint peers at one destination",
|
||||
main: "configkit:\n imports:\n - path: left.yml\n destination: [application]\n - path: right.yml\n destination: [application]\n",
|
||||
files: map[string]string{"left.yml": "left: one\n", "right.yml": "right: two\n"},
|
||||
want: map[string]any{"/application/left": "one", "/application/right": "two"},
|
||||
},
|
||||
{
|
||||
name: "different destinations",
|
||||
main: "configkit:\n imports:\n - path: left.yml\n destination: [left]\n - path: right.yml\n destination: [right]\n",
|
||||
files: map[string]string{"left.yml": "value: shared\n", "right.yml": "value: shared\n"},
|
||||
want: map[string]any{"/left/value": "shared", "/right/value": "shared"},
|
||||
},
|
||||
{
|
||||
name: "ancestor and descendant overlap",
|
||||
main: "configkit:\n imports:\n - path: parent.yml\n destination: [application]\n - path: child.yml\n destination: [application, branch]\n",
|
||||
files: map[string]string{"parent.yml": "branch: {base: one}\n", "child.yml": "leaf: two\n"},
|
||||
want: map[string]any{"/application/branch/base": "one", "/application/branch/leaf": "two"},
|
||||
},
|
||||
{
|
||||
name: "collision with explicitly wrapped content",
|
||||
main: "configkit: {imports: [{path: fragment.yml, destination: [application]}]}\napplication: {value: root}\n",
|
||||
files: map[string]string{"fragment.yml": "value: imported\n"},
|
||||
conflict: true,
|
||||
wantPath: "/application/value",
|
||||
},
|
||||
{
|
||||
name: "equal peer values conflict",
|
||||
main: "configkit:\n imports:\n - path: left.yml\n destination: [application]\n - path: right.yml\n destination: [application]\n",
|
||||
files: map[string]string{"left.yml": "value: same\n", "right.yml": "value: same\n"},
|
||||
conflict: true,
|
||||
wantPath: "/application/value",
|
||||
},
|
||||
{
|
||||
name: "unequal peer values conflict",
|
||||
main: "configkit:\n imports:\n - path: left.yml\n destination: [application]\n - path: right.yml\n destination: [application]\n",
|
||||
files: map[string]string{"left.yml": "value: left\n", "right.yml": "value: right\n"},
|
||||
conflict: true,
|
||||
wantPath: "/application/value",
|
||||
},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", test.main)
|
||||
for name, body := range test.files {
|
||||
writeImportFile(t, directory, name, body)
|
||||
}
|
||||
loaded, err := LoadLayered(
|
||||
Input{Source: File(mainPath), Imports: true}, testSecretBinding(),
|
||||
LayeredOptions[map[string]any]{},
|
||||
)
|
||||
if test.conflict {
|
||||
if loaded != nil || groupedErrorCode(t, err) != CodeComposeConflict {
|
||||
t.Fatalf("relative import composition = %#v, %v", loaded, err)
|
||||
}
|
||||
loadError := err.(*LoadError)
|
||||
if diagnostics := loadError.Diagnostics(); diagnostics[0].Path.String() != test.wantPath {
|
||||
t.Fatalf("conflict path = %q, want %q", diagnostics[0].Path.String(), test.wantPath)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("relative import composition: %v", err)
|
||||
}
|
||||
configuration := testLoaded(loaded).Config
|
||||
for path, want := range test.want {
|
||||
parsed, err := ParsePath(path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
keys := make([]string, len(parsed.segments))
|
||||
for index, segment := range parsed.segments {
|
||||
keys[index] = segment.key
|
||||
}
|
||||
if got := layeredMapValue(t, configuration, keys...); got != want {
|
||||
t.Errorf("value at %s = %#v, want %#v", path, got, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("upper tier cannot repair a lower import conflict", func(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
lowerPath := writeImportFile(t, directory, "lower.yml", `configkit:
|
||||
imports:
|
||||
- path: left.yml
|
||||
destination: [application]
|
||||
- path: right.yml
|
||||
destination: [application]
|
||||
`)
|
||||
writeImportFile(t, directory, "left.yml", "value: left\n")
|
||||
writeImportFile(t, directory, "right.yml", "value: right\n")
|
||||
loaded, err := LoadLayered(
|
||||
Input{Source: Bytes("upper", []byte("application: {value: upper}\n"))},
|
||||
testSecretBinding(),
|
||||
LayeredOptions[map[string]any]{Underlays: []Underlay{{Inputs: []Input{{Source: File(lowerPath), Imports: true}}}}},
|
||||
)
|
||||
if loaded != nil || groupedErrorCode(t, err) != CodeComposeConflict {
|
||||
t.Fatalf("upper tier repaired a lower conflict: %#v, %v", loaded, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestRelativeImportDestinationsUseEffectiveVersionOwnership(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
rulePath Path
|
||||
fragment string
|
||||
}{
|
||||
{name: "direct claim", rulePath: Keys("collection", "group", "nested", "version"), fragment: "version: v2\n"},
|
||||
{name: "ancestor claim", rulePath: Keys("collection", "group", "nested", "schema", "version"), fragment: "schema: null\n"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||
imports:
|
||||
- path: parent.yml
|
||||
destination: [group]
|
||||
`)
|
||||
writeImportFile(t, directory, "parent.yml", `configkit:
|
||||
imports:
|
||||
- path: fragment.yml
|
||||
destination: [nested]
|
||||
`)
|
||||
writeImportFile(t, directory, "fragment.yml", test.fragment)
|
||||
_, err := LoadLayered(
|
||||
Input{Source: File(mainPath), Imports: true, Destination: Keys("collection")},
|
||||
testSecretBinding(),
|
||||
LayeredOptions[map[string]any]{Definition: Definition[map[string]any]{Version: &VersionRule{
|
||||
Path: test.rulePath, Check: func(Version, *Validation) error { return nil },
|
||||
}}},
|
||||
)
|
||||
if groupedErrorCode(t, err) != CodeImportVersion {
|
||||
t.Fatalf("effective imported claim error = %v", err)
|
||||
}
|
||||
loadError := err.(*LoadError)
|
||||
diagnostic := loadError.Diagnostics()[0]
|
||||
if diagnostic.Path.String() != test.rulePath.String() || diagnostic.Primary == nil || diagnostic.Primary.SourceID != "source-3" {
|
||||
t.Fatalf("effective version diagnostic = %#v", diagnostic)
|
||||
}
|
||||
sources := loadError.Sources()
|
||||
wantDestinations := []string{"/collection", "/collection/group", "/collection/group/nested"}
|
||||
if len(sources) != len(wantDestinations) {
|
||||
t.Fatalf("version failure sources = %#v", sources)
|
||||
}
|
||||
for index, source := range sources {
|
||||
if source.Destination.String() != wantDestinations[index] {
|
||||
t.Errorf("source %d destination = %q, want %q", index, source.Destination.String(), wantDestinations[index])
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRelativeImportDestinationsKeepMainVersionCheckScoped(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type config struct {
|
||||
Collection struct {
|
||||
SchemaVersion string `yaml:"schema_version"`
|
||||
Group map[string]any `yaml:"group"`
|
||||
} `yaml:"collection"`
|
||||
}
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||
imports:
|
||||
- path: parent.yml
|
||||
destination: [group]
|
||||
schema_version: 6
|
||||
`)
|
||||
writeImportFile(t, directory, "parent.yml", `configkit:
|
||||
imports:
|
||||
- path: fragment.yml
|
||||
destination: [nested]
|
||||
`)
|
||||
writeImportFile(t, directory, "fragment.yml", "version: 7\n")
|
||||
checks := 0
|
||||
loaded, err := LoadLayered(
|
||||
Input{Source: File(mainPath), Imports: true, Destination: Keys("collection")},
|
||||
testSecretBinding(),
|
||||
LayeredOptions[config]{Definition: Definition[config]{Version: &VersionRule{
|
||||
Path: Keys("collection", "schema_version"),
|
||||
Check: func(version Version, _ *Validation) error {
|
||||
checks++
|
||||
if !version.Present || version.Text != "6" {
|
||||
t.Errorf("main-owned version = %#v", version)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}}},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("ordinary nested imported version was rejected: %v", err)
|
||||
}
|
||||
result := testLoaded(loaded)
|
||||
if checks != 1 || result.Config.Collection.SchemaVersion != "6" {
|
||||
t.Fatalf("main version callback/value = %d/%q", checks, result.Config.Collection.SchemaVersion)
|
||||
}
|
||||
nested, ok := result.Config.Collection.Group["nested"].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("nested imported payload = %#v", result.Config.Collection.Group["nested"])
|
||||
}
|
||||
if got, ok := nested["version"].(int64); !ok || got != 7 {
|
||||
t.Fatalf("ordinary nested version = %#v, want integer payload 7", nested["version"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayeredNativeFilesystemImportsInheritInputDestination(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
native := fstest.MapFS{
|
||||
"main.yml": &fstest.MapFile{Data: []byte("configkit:\n imports:\n - path: first.yml\nroot: main\n")},
|
||||
"first.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: nested.yml}]}\nfirst: imported\n")},
|
||||
"nested.yml": &fstest.MapFile{Data: []byte("nested: imported\n")},
|
||||
}
|
||||
namespace := newNativeNamespace("bundle", native)
|
||||
loaded, err := LoadLayered(
|
||||
Input{Source: filesystemFile(namespace, "main.yml"), Imports: true, Destination: Keys("mounted")},
|
||||
testSecretBinding(),
|
||||
LayeredOptions[importDestinationConfig]{},
|
||||
)
|
||||
if err != nil {
|
||||
if loadError, ok := err.(*LoadError); ok {
|
||||
t.Fatalf("LoadLayered diagnostics: %#v", loadError.Diagnostics())
|
||||
}
|
||||
t.Fatalf("LoadLayered: %v", err)
|
||||
}
|
||||
result := testLoaded(loaded)
|
||||
if result.Config.Mounted["root"] != "main" || result.Config.Mounted["first"] != "imported" || result.Config.Mounted["nested"] != "imported" {
|
||||
t.Fatalf("mounted native filesystem values = %#v", result.Config.Mounted)
|
||||
}
|
||||
sources := result.Sources()
|
||||
if len(sources) != 3 || sources[0].Destination.String() != "/mounted" || sources[1].Destination.String() != "/mounted" || sources[2].Destination.String() != "/mounted" {
|
||||
t.Fatalf("native source destinations = %#v", sources)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayeredImportOnlyInputMaterializesDestination(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", `configkit:
|
||||
imports:
|
||||
- path: optional.yml
|
||||
`)
|
||||
loaded, err := LoadLayered(
|
||||
Input{Source: File(mainPath), Imports: true, Destination: Keys("collections", "empty")},
|
||||
testSecretBinding(),
|
||||
LayeredOptions[importDestinationConfig]{},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadLayered: %v", err)
|
||||
}
|
||||
result := testLoaded(loaded)
|
||||
info, ok := result.Metadata().Lookup(Keys("collections", "empty"))
|
||||
if !ok || !info.Explicit {
|
||||
t.Fatalf("import-only body destination = %#v, %v", info, ok)
|
||||
}
|
||||
sources := result.Sources()
|
||||
if len(sources) != 1 || sources[0].Destination.String() != "/collections/empty" {
|
||||
t.Fatalf("sources = %#v", sources)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayeredImportedVersionClaimsUseEffectiveDestination(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
rulePath Path
|
||||
destination Path
|
||||
wantCode Code
|
||||
}{
|
||||
{name: "protected effective path", rulePath: Keys("mounted", "version"), destination: Keys("mounted"), wantCode: CodeImportVersion},
|
||||
{name: "ordinary nested domain version", rulePath: Keys("version"), destination: Keys("collections", "entry")},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
directory := t.TempDir()
|
||||
writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: fragment.yml}]}\n")
|
||||
writeImportFile(t, directory, "fragment.yml", "version: v2\n")
|
||||
rule := &VersionRule{Path: test.rulePath, Check: func(Version, *Validation) error { return nil }}
|
||||
loaded, err := LoadLayered(
|
||||
Input{Source: NoSource()},
|
||||
testSecretBinding(),
|
||||
LayeredOptions[importDestinationConfig]{
|
||||
Definition: Definition[importDestinationConfig]{Version: rule},
|
||||
Underlays: []Underlay{{Inputs: []Input{{
|
||||
Source: File(filepath.Join(directory, "main.yml")), Imports: true, Destination: test.destination,
|
||||
}}}},
|
||||
},
|
||||
)
|
||||
if test.wantCode != "" {
|
||||
if err == nil {
|
||||
t.Fatal("protected imported version claim was accepted")
|
||||
}
|
||||
loadError, ok := err.(*LoadError)
|
||||
if !ok || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != test.wantCode {
|
||||
t.Fatalf("load error = %#v", err)
|
||||
}
|
||||
if test.name == "protected effective path" {
|
||||
sources := loadError.Sources()
|
||||
if len(sources) != 2 || sources[0].Destination.String() != "/mounted" || sources[1].Destination.String() != "/mounted" {
|
||||
t.Fatalf("error source destinations = %#v", sources)
|
||||
}
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("ordinary nested version was rejected: %v", err)
|
||||
}
|
||||
result := testLoaded(loaded)
|
||||
if got := result.Config.Collections["entry"]["version"]; got != "v2" {
|
||||
t.Fatalf("nested version = %q", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayeredDestinationsDoNotChangeRepeatedSourceIdentity(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
directory := t.TempDir()
|
||||
writeImportFile(t, directory, "shared.yml", "value: shared\n")
|
||||
importerPath := writeImportFile(t, directory, "importer.yml", "configkit: {imports: [{path: shared.yml, destination: [nested]}]}\n")
|
||||
_, err := LoadLayered(
|
||||
Input{Source: NoSource()},
|
||||
testSecretBinding(),
|
||||
LayeredOptions[importDestinationConfig]{Underlays: []Underlay{{Inputs: []Input{
|
||||
{Source: File(importerPath), Imports: true, Destination: Keys("imported")},
|
||||
{Source: File(filepath.Join(directory, "shared.yml")), Destination: Keys("direct")},
|
||||
}}}},
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("the same physical source was accepted at two destinations in one group")
|
||||
}
|
||||
loadError, ok := err.(*LoadError)
|
||||
if !ok || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != CodeImportRepeated {
|
||||
t.Fatalf("load error = %#v", err)
|
||||
}
|
||||
sources := loadError.Sources()
|
||||
if len(sources) != 3 || sources[1].Destination.String() != "/imported/nested" || sources[2].Destination.String() != "/direct" {
|
||||
t.Fatalf("repeated import sources = %#v", sources)
|
||||
}
|
||||
|
||||
t.Run("placement does not break a physical cycle", func(t *testing.T) {
|
||||
t.Parallel()
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: parent.yml, destination: [one]}]}\n")
|
||||
writeImportFile(t, directory, "parent.yml", "configkit: {imports: [{path: main.yml, destination: [two]}]}\n")
|
||||
_, err := LoadLayered(Input{Source: File(mainPath), Imports: true}, testSecretBinding(), LayeredOptions[map[string]any]{})
|
||||
loadError, ok := err.(*LoadError)
|
||||
if !ok || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != CodeImportCycle {
|
||||
t.Fatalf("cycle load error = %#v", err)
|
||||
}
|
||||
diagnostic := loadError.Diagnostics()[0]
|
||||
if diagnostic.Primary == nil || diagnostic.Primary.SourceID != "source-2" {
|
||||
t.Fatalf("cycle primary location = %#v", diagnostic.Primary)
|
||||
}
|
||||
if sources := loadError.Sources(); len(sources) != 2 || sources[1].Destination.String() != "/one" {
|
||||
t.Fatalf("cycle sources = %#v", sources)
|
||||
}
|
||||
})
|
||||
}
|
||||
500
internal/engine/destination_integration_test.go
Normal file
500
internal/engine/destination_integration_test.go
Normal file
@@ -0,0 +1,500 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
)
|
||||
|
||||
func TestLayeredDestinationInheritanceUsesFinalSiblingScopes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
loaded, err := LoadLayered(
|
||||
Input{Source: Bytes("main", []byte("parent:\n label: final\n settings:\n primary: {height: 4}\ncancelled:\n extends: null\n own: retained\n")), Destination: Keys("catalog", "entries")},
|
||||
testSecretBinding(),
|
||||
LayeredOptions[map[string]any]{Underlays: []Underlay{{Inputs: []Input{
|
||||
{Source: Bytes("parent", []byte("parent:\n label: lower\n settings:\n primary: {width: '10'}\n secondary: {extends: primary}\ncancelled:\n extends: parent\n own: retained\n")), Destination: Keys("catalog", "entries")},
|
||||
{Source: Bytes("child", []byte("child:\n extends: parent\n settings:\n primary: {width: '20'}\n secondary: {extends: primary}\n")), Destination: Keys("catalog", "entries")},
|
||||
}}}},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadLayered: %v", err)
|
||||
}
|
||||
result := testLoaded(loaded)
|
||||
entries := layeredMap(t, layeredMap(t, result.Config, "catalog"), "entries")
|
||||
parent := layeredMap(t, entries, "parent")
|
||||
child := layeredMap(t, entries, "child")
|
||||
parentSettings := layeredMap(t, parent, "settings")
|
||||
childSettings := layeredMap(t, child, "settings")
|
||||
if parent["label"] != "final" || child["label"] != "final" {
|
||||
t.Fatalf("higher-tier parent was not inherited after precedence: parent=%#v child=%#v", parent, child)
|
||||
}
|
||||
if layeredMapValue(t, parentSettings, "secondary", "width") != "10" ||
|
||||
fmt.Sprint(layeredMapValue(t, parentSettings, "secondary", "height")) != "4" ||
|
||||
layeredMapValue(t, childSettings, "secondary", "width") != "20" ||
|
||||
fmt.Sprint(layeredMapValue(t, childSettings, "secondary", "height")) != "4" {
|
||||
t.Fatalf("nested sibling scopes were not resolved at their destinations: parent=%#v child=%#v", parentSettings, childSettings)
|
||||
}
|
||||
cancelled := layeredMap(t, entries, "cancelled")
|
||||
if len(cancelled) != 1 || cancelled["own"] != "retained" {
|
||||
t.Fatalf("higher-tier cancellation did not suppress inheritance: %#v", cancelled)
|
||||
}
|
||||
if _, ok := result.Metadata().Lookup(Keys("catalog", "entries", "child", inheritanceKey)); ok {
|
||||
t.Fatal("effective child retained its extends directive")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayeredDestinationInheritanceDoesNotSearchOtherCollections(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
_, err := LoadLayered(
|
||||
Input{Source: Bytes("main", []byte("parent: {value: foreign}\n")), Destination: Keys("catalog", "other")},
|
||||
testSecretBinding(),
|
||||
LayeredOptions[map[string]any]{Underlays: []Underlay{{Inputs: []Input{{
|
||||
Source: Bytes("child", []byte("child: {extends: parent}\n")),
|
||||
Destination: Keys("catalog", "entries"),
|
||||
}}}}},
|
||||
)
|
||||
if got := groupedErrorCode(t, err); got != CodeInheritanceMissing {
|
||||
t.Fatalf("cross-collection parent lookup error = %s, want %s", got, CodeInheritanceMissing)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayeredMountedVersionProtectionCoversInheritanceAndUpperTiers(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
body string
|
||||
rulePath Path
|
||||
}{
|
||||
{name: "inherited protected value", body: "parent: {version: v2}\nchild: {extends: parent}\n", rulePath: Keys("catalog", "entries", "child", "version")},
|
||||
{name: "inherited protected ancestor", body: "parent: {schema: null}\nchild: {extends: parent}\n", rulePath: Keys("catalog", "entries", "child", "schema", "version")},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := LoadLayered(
|
||||
Input{Source: NoSource()}, testSecretBinding(),
|
||||
LayeredOptions[map[string]any]{
|
||||
Definition: Definition[map[string]any]{Version: &VersionRule{Path: test.rulePath, Check: func(Version, *Validation) error { return nil }}},
|
||||
Underlays: []Underlay{{Inputs: []Input{{Source: Bytes("underlay", []byte(test.body)), Destination: Keys("catalog", "entries")}}}},
|
||||
},
|
||||
)
|
||||
if got := groupedErrorCode(t, err); got != CodeInheritanceVersion {
|
||||
t.Fatalf("inheritance version protection code = %s", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
t.Run("inheritance consumes a protected directive", func(t *testing.T) {
|
||||
_, err := LoadLayered(
|
||||
Input{Source: Bytes("main", []byte("parent: {value: inherited}\nchild: {extends: parent}\n")), Destination: Keys("catalog", "entries")},
|
||||
testSecretBinding(),
|
||||
LayeredOptions[map[string]any]{Definition: Definition[map[string]any]{Version: &VersionRule{
|
||||
Path: Keys("catalog", "entries", "child", "extends"),
|
||||
Check: func(version Version, _ *Validation) error {
|
||||
if !version.Present || version.Text != "parent" {
|
||||
t.Errorf("mounted protected directive value = %#v", version)
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}}},
|
||||
)
|
||||
if got := groupedErrorCode(t, err); got != CodeInheritanceVersion {
|
||||
t.Fatalf("consumed directive protection code = %s", got)
|
||||
}
|
||||
})
|
||||
t.Run("inherited mapping ancestor without a version claim", func(t *testing.T) {
|
||||
loaded, err := LoadLayered(
|
||||
Input{Source: NoSource()}, testSecretBinding(),
|
||||
LayeredOptions[map[string]any]{
|
||||
Definition: Definition[map[string]any]{Version: &VersionRule{Path: Keys("catalog", "entries", "child", "schema", "version"), Check: func(Version, *Validation) error { return nil }}},
|
||||
Underlays: []Underlay{{Inputs: []Input{{
|
||||
Source: Bytes("underlay", []byte("parent: {schema: {name: ordinary}}\nchild: {extends: parent}\n")), Destination: Keys("catalog", "entries"),
|
||||
}}}},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("nonclaiming inherited mapping ancestor was rejected: %v", err)
|
||||
}
|
||||
if got := layeredMapValue(t, testLoaded(loaded).Config, "catalog", "entries", "child", "schema", "name"); got != "ordinary" {
|
||||
t.Fatalf("inherited non-version value = %#v", got)
|
||||
}
|
||||
})
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
body string
|
||||
}{
|
||||
{name: "overlay protected value", body: "catalog: {schema: {version: v2}}\n"},
|
||||
{name: "overlay protected ancestor", body: "catalog: {schema: null}\n"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
filesystem := NewFilesystem("overlay", fstest.MapFS{"overlays/test.yml": &fstest.MapFile{Data: []byte(test.body)}})
|
||||
_, err := LoadLayered(
|
||||
Input{Source: Bytes("main", []byte("schema: {version: v1}\n")), Destination: Keys("catalog")},
|
||||
testSecretBinding(),
|
||||
LayeredOptions[map[string]any]{
|
||||
Definition: Definition[map[string]any]{Version: &VersionRule{Path: Keys("catalog", "schema", "version"), Check: func(version Version, _ *Validation) error {
|
||||
if !version.Present || version.Text != "v1" {
|
||||
t.Errorf("mounted main version = %#v", version)
|
||||
}
|
||||
return nil
|
||||
}}},
|
||||
Overlay: "test", OverlayFilesystem: &FilesystemContext{Root: ".", BaseDir: ".", FS: &filesystem},
|
||||
},
|
||||
)
|
||||
if got := groupedErrorCode(t, err); got != CodeOverlayVersion {
|
||||
t.Fatalf("overlay version protection code = %s", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
path Path
|
||||
value any
|
||||
}{
|
||||
{name: "override protected value", path: Keys("catalog", "schema", "version"), value: "v2"},
|
||||
{name: "override protected ancestor", path: Keys("catalog", "schema"), value: map[string]any{"version": "v2"}},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, err := LoadLayered(
|
||||
Input{Source: Bytes("main", []byte("schema: {version: v1}\n")), Destination: Keys("catalog")},
|
||||
testSecretBinding(),
|
||||
LayeredOptions[map[string]any]{
|
||||
Definition: Definition[map[string]any]{Version: &VersionRule{Path: Keys("catalog", "schema", "version"), Check: func(Version, *Validation) error { return nil }}},
|
||||
Overrides: []Override{{Path: test.path, Value: test.value, Flag: "--config"}},
|
||||
},
|
||||
)
|
||||
if got := groupedErrorCode(t, err); got != CodeOverrideVersion {
|
||||
t.Fatalf("override version protection code = %s", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("unrelated inheritance under the protected mapping ancestor", func(t *testing.T) {
|
||||
loaded, err := LoadLayered(
|
||||
Input{Source: Bytes("main", []byte("schema: {version: v1}\nentries:\n parent: {value: inherited}\n child: {extends: parent}\n")), Destination: Keys("catalog")},
|
||||
testSecretBinding(),
|
||||
LayeredOptions[map[string]any]{Definition: Definition[map[string]any]{Version: &VersionRule{Path: Keys("catalog", "schema", "version"), Check: func(Version, *Validation) error { return nil }}}},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("unrelated mounted inheritance was rejected: %v", err)
|
||||
}
|
||||
child := layeredMapValue(t, testLoaded(loaded).Config, "catalog", "entries", "child", "value")
|
||||
if child != "inherited" {
|
||||
t.Fatalf("unrelated inheritance value = %#v", child)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestLayeredItemVersionProtectionTracksAssignedPeerIndexes(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
definition := Definition[map[string]any]{
|
||||
Version: &VersionRule{Path: Keys("schema", "version"), Check: func(Version, *Validation) error { return nil }},
|
||||
sequenceRegistrations: []sequenceRegistration{{path: Keys("catalog", "entries"), identityField: "id"}},
|
||||
}
|
||||
item := func(label string, value map[string]any) Input {
|
||||
return Input{Source: Mapping(label, value), Destination: Keys("catalog", "entries"), placementMode: placementModeSequenceItem}
|
||||
}
|
||||
options := func(inputs ...Input) LayeredOptions[map[string]any] {
|
||||
return LayeredOptions[map[string]any]{Definition: definition, SourceVersionMode: sourceVersionModeIfPresent, Underlays: []Underlay{{Inputs: inputs}}}
|
||||
}
|
||||
main := item("main", map[string]any{"id": "main", "schema": map[string]any{"version": "v1"}})
|
||||
|
||||
t.Run("same assigned main path is protected", func(t *testing.T) {
|
||||
_, err := LoadLayered(main, testSecretBinding(), options(item("underlay", map[string]any{"id": "underlay", "schema": nil})))
|
||||
if got := groupedErrorCode(t, err); got != CodeUnderlayVersion {
|
||||
t.Fatalf("protected item claim code = %s, want %s", got, CodeUnderlayVersion)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("appended peer does not claim main path", func(t *testing.T) {
|
||||
loaded, err := LoadLayered(main, testSecretBinding(), options(
|
||||
item("first", map[string]any{"id": "first", "schema": map[string]any{"other": "value"}}),
|
||||
item("peer", map[string]any{"id": "peer", "schema": nil}),
|
||||
))
|
||||
if err != nil {
|
||||
t.Fatalf("unrelated appended peer rejected: %v", err)
|
||||
}
|
||||
entries := layeredMapValue(t, testLoaded(loaded).Config, "catalog", "entries").([]any)
|
||||
if len(entries) != 1 || entries[0].(map[string]any)["id"] != "main" {
|
||||
t.Fatalf("main item projection = %#v", entries)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
type destinationDecodeProbe string
|
||||
|
||||
var destinationDecodeProbeValues []string
|
||||
|
||||
func (value *destinationDecodeProbe) UnmarshalText(text []byte) error {
|
||||
destinationDecodeProbeValues = append(destinationDecodeProbeValues, string(text))
|
||||
*value = destinationDecodeProbe(text)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (value destinationDecodeProbe) MarshalText() ([]byte, error) {
|
||||
return []byte(value), nil
|
||||
}
|
||||
|
||||
type destinationLifecycleConfig struct {
|
||||
Defaulted string `yaml:"defaulted"`
|
||||
Tenant struct {
|
||||
Tagged string `yaml:"tagged" configkit:"secret"`
|
||||
Dynamic string `yaml:"dynamic"`
|
||||
Credential Secret `yaml:"credential"`
|
||||
Derived string `yaml:"derived"`
|
||||
Decoded destinationDecodeProbe `yaml:"decoded"`
|
||||
} `yaml:"tenant"`
|
||||
}
|
||||
|
||||
func TestLayeredDestinationLifecycleAndSensitiveInspectionUseFinalShape(t *testing.T) {
|
||||
const (
|
||||
oldTagged = "SUPERSEDED-TAGGED-CANARY"
|
||||
newTagged = "CURRENT-TAGGED-CANARY"
|
||||
oldDynamic = "SUPERSEDED-DYNAMIC-CANARY"
|
||||
newDynamic = "CURRENT-DYNAMIC-CANARY"
|
||||
credential = "RESOLVED-CREDENTIAL-CANARY"
|
||||
secretName = "DESTINATION_SECRET_NAME"
|
||||
decodeValue = "decoded-once"
|
||||
)
|
||||
type calls struct{ defaults, resolve, lookup, validate int }
|
||||
newLoad := func(mounted bool, observed *calls) (*testResult[destinationLifecycleConfig], error) {
|
||||
mainBody := "tagged: " + newTagged + "\ndynamic: " + newDynamic + "\ncredential: " + secretName + "\ndecoded: " + decodeValue + "\n"
|
||||
underlayBody := "tagged: " + oldTagged + "\ndynamic: " + oldDynamic + "\ncredential: OLD_DESTINATION_SECRET_NAME\ndecoded: old\n"
|
||||
if !mounted {
|
||||
mainBody = "tenant:\n" + indentDestinationBody(mainBody)
|
||||
underlayBody = "tenant:\n" + indentDestinationBody(underlayBody)
|
||||
}
|
||||
main := loadInput{source: Bytes("main", []byte(mainBody))}
|
||||
underlay := loadInput{source: Bytes("underlay", []byte(underlayBody))}
|
||||
if mounted {
|
||||
main.destination = Keys("tenant")
|
||||
underlay.destination = Keys("tenant")
|
||||
}
|
||||
definition := Definition[destinationLifecycleConfig]{
|
||||
Defaults: func() destinationLifecycleConfig {
|
||||
observed.defaults++
|
||||
return destinationLifecycleConfig{Defaulted: "from-default"}
|
||||
},
|
||||
Resolve: func(configuration *destinationLifecycleConfig, resolution *Resolution) error {
|
||||
observed.resolve++
|
||||
configuration.Tenant.Derived = configuration.Tenant.Dynamic
|
||||
resolution.Record(Keys("tenant", "derived"), ChangeNormalization, "derive from mounted input", Keys("tenant", "dynamic"))
|
||||
return nil
|
||||
},
|
||||
Validate: func(configuration *destinationLifecycleConfig, _ *Validation) {
|
||||
observed.validate++
|
||||
if configuration.Tenant.Decoded != destinationDecodeProbe(decodeValue) || configuration.Tenant.Dynamic != newDynamic {
|
||||
t.Errorf("validation saw a non-final mounted configuration: %#v", configuration.Tenant)
|
||||
}
|
||||
},
|
||||
Secret: func(path Path) bool { return path.String() == "/tenant/dynamic" },
|
||||
}
|
||||
loaded, err := loadGroupedWithSecretLookup(loadRequest[destinationLifecycleConfig]{
|
||||
main: main, underlays: []loadGroup{{inputs: []loadInput{underlay}}}, definition: definition,
|
||||
}, testSecretBinding(), func(name string) (string, bool) {
|
||||
observed.lookup++
|
||||
if name != secretName {
|
||||
return "", false
|
||||
}
|
||||
return credential, true
|
||||
})
|
||||
return testLoaded(loaded), err
|
||||
}
|
||||
|
||||
var observed calls
|
||||
destinationDecodeProbeValues = nil
|
||||
mounted, err := newLoad(true, &observed)
|
||||
if err != nil {
|
||||
if loadError, ok := err.(*LoadError); ok {
|
||||
t.Fatalf("mounted load: %#v", loadError.Diagnostics())
|
||||
}
|
||||
t.Fatalf("mounted load: %v", err)
|
||||
}
|
||||
if observed != (calls{defaults: 1, resolve: 1, lookup: 1, validate: 1}) {
|
||||
t.Fatalf("mounted lifecycle counts = %#v", observed)
|
||||
}
|
||||
sawFinalDecode := false
|
||||
for _, value := range destinationDecodeProbeValues {
|
||||
if value == decodeValue {
|
||||
sawFinalDecode = true
|
||||
} else if value != "" {
|
||||
t.Fatalf("decoder observed a non-final mounted value %q: %#v", value, destinationDecodeProbeValues)
|
||||
}
|
||||
}
|
||||
if !sawFinalDecode {
|
||||
t.Fatal("final mounted value was not decoded")
|
||||
}
|
||||
if mounted.Config.Defaulted != "from-default" || mounted.Config.Tenant.Tagged != newTagged || mounted.Config.Tenant.Dynamic != newDynamic ||
|
||||
mounted.Config.Tenant.Derived != newDynamic || mounted.Config.Tenant.Decoded != destinationDecodeProbe(decodeValue) ||
|
||||
mounted.Config.Tenant.Credential.Value() != credential {
|
||||
t.Fatalf("mounted final configuration = %#v", mounted.Config)
|
||||
}
|
||||
for _, path := range []Path{Keys("tenant", "tagged"), Keys("tenant", "dynamic"), Keys("tenant", "derived")} {
|
||||
if !mounted.Inspection.secrets[path.String()] {
|
||||
t.Errorf("effective sensitivity missing at %s: %#v", path, mounted.Inspection.secrets)
|
||||
}
|
||||
}
|
||||
for _, path := range []Path{Keys("tenant", "tagged"), Keys("tenant", "dynamic"), Keys("tenant", "derived")} {
|
||||
explanation, ok := mounted.Explain(path)
|
||||
if !ok || !explanation.Effective.Redacted {
|
||||
t.Errorf("effective explanation at %s = %#v, %v", path, explanation, ok)
|
||||
continue
|
||||
}
|
||||
for _, change := range explanation.History {
|
||||
if strings.Contains(change.Before.YAML+change.After.YAML, oldTagged) ||
|
||||
strings.Contains(change.Before.YAML+change.After.YAML, newTagged) ||
|
||||
strings.Contains(change.Before.YAML+change.After.YAML, oldDynamic) ||
|
||||
strings.Contains(change.Before.YAML+change.After.YAML, newDynamic) {
|
||||
t.Errorf("classified history leaked a canary at %s: %#v", path, change)
|
||||
}
|
||||
}
|
||||
var rendered bytes.Buffer
|
||||
if err := mounted.WriteExplanation(&rendered, path); err != nil || strings.Contains(rendered.String(), "CANARY") {
|
||||
t.Errorf("safe explanation at %s = %q, %v", path, rendered.String(), err)
|
||||
}
|
||||
}
|
||||
var effective bytes.Buffer
|
||||
if err := mounted.WriteEffective(&effective); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, canary := range []string{oldTagged, newTagged, oldDynamic, newDynamic, credential} {
|
||||
if strings.Contains(effective.String(), canary) {
|
||||
t.Fatalf("effective rendering exposed %q: %s", canary, effective.String())
|
||||
}
|
||||
}
|
||||
if serializedConfig := fmt.Sprintf("%#v", mounted.Config); strings.Contains(serializedConfig, credential) {
|
||||
t.Fatalf("configuration formatting exposed the resolved credential: %s", serializedConfig)
|
||||
}
|
||||
for _, path := range []Path{Keys("tenant", "tagged"), Keys("tenant", "dynamic")} {
|
||||
if history := fmt.Sprintf("%#v", mounted.Metadata().History(path)); strings.Contains(history, "CANARY") {
|
||||
t.Fatalf("metadata history at %s exposed a canary: %s", path, history)
|
||||
}
|
||||
}
|
||||
|
||||
var authoredCalls calls
|
||||
destinationDecodeProbeValues = nil
|
||||
authored, err := newLoad(false, &authoredCalls)
|
||||
if err != nil {
|
||||
t.Fatalf("explicitly wrapped load: %v", err)
|
||||
}
|
||||
if authoredCalls != (calls{defaults: 1, resolve: 1, lookup: 1, validate: 1}) {
|
||||
t.Fatalf("authored lifecycle counts = %#v", authoredCalls)
|
||||
}
|
||||
sawFinalDecode = false
|
||||
for _, value := range destinationDecodeProbeValues {
|
||||
if value == decodeValue {
|
||||
sawFinalDecode = true
|
||||
} else if value != "" {
|
||||
t.Fatalf("authored decoder observed a non-final value %q: %#v", value, destinationDecodeProbeValues)
|
||||
}
|
||||
}
|
||||
if !sawFinalDecode {
|
||||
t.Fatal("final authored value was not decoded")
|
||||
}
|
||||
if !reflect.DeepEqual(mounted.Config, authored.Config) {
|
||||
t.Fatalf("mounted config %#v differs from explicitly wrapped config %#v", mounted.Config, authored.Config)
|
||||
}
|
||||
differences, err := Diff(mounted.Inspection, authored.Inspection)
|
||||
if err != nil || len(differences) != 0 {
|
||||
t.Fatalf("mounted versus authored diff = %#v, %v", differences, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayeredDestinationDiffUsesBilateralSensitivity(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type config struct {
|
||||
Tenant struct {
|
||||
Dynamic string `yaml:"dynamic"`
|
||||
Derived string `yaml:"derived"`
|
||||
} `yaml:"tenant"`
|
||||
}
|
||||
load := func(value string, classify bool) *testResult[config] {
|
||||
t.Helper()
|
||||
loaded, err := loadGroupedWithSecretLookup(loadRequest[config]{
|
||||
main: loadInput{source: Bytes("input", []byte("dynamic: "+value+"\n")), destination: Keys("tenant")},
|
||||
definition: Definition[config]{
|
||||
Resolve: func(configuration *config, resolution *Resolution) error {
|
||||
configuration.Tenant.Derived = configuration.Tenant.Dynamic
|
||||
resolution.Record(Keys("tenant", "derived"), ChangeNormalization, "derive from dynamic input", Keys("tenant", "dynamic"))
|
||||
return nil
|
||||
},
|
||||
Secret: func(path Path) bool { return classify && path.String() == "/tenant/dynamic" },
|
||||
},
|
||||
}, testSecretBinding(), func(string) (string, bool) { return "", false })
|
||||
if err != nil {
|
||||
t.Fatalf("LoadLayered: %v", err)
|
||||
}
|
||||
return testLoaded(loaded)
|
||||
}
|
||||
left := load("LEFT-DIFF-CANARY", true)
|
||||
right := load("RIGHT-DIFF-CANARY", false)
|
||||
differences, err := Diff(left.Inspection, right.Inspection)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(differences) != 2 {
|
||||
t.Fatalf("differences = %#v", differences)
|
||||
}
|
||||
seen := make(map[string]bool)
|
||||
for _, difference := range differences {
|
||||
path := difference.Path.String()
|
||||
seen[path] = true
|
||||
if !difference.Left.Redacted || !difference.Right.Redacted || !difference.HiddenChanged ||
|
||||
strings.Contains(difference.Left.YAML+difference.Right.YAML, "DIFF-CANARY") {
|
||||
t.Errorf("bilaterally classified difference at %s = %#v", path, difference)
|
||||
}
|
||||
}
|
||||
for _, path := range []string{"/tenant/dynamic", "/tenant/derived"} {
|
||||
if !seen[path] || !left.Inspection.secrets[path] || right.Inspection.secrets[path] {
|
||||
t.Errorf("classification/difference at %s: seen=%v left=%v right=%v", path, seen[path], left.Inspection.secrets[path], right.Inspection.secrets[path])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayeredDestinationDecodeFailureReturnsNoResultOrLaterCallbacks(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type config struct {
|
||||
Tenant struct {
|
||||
Count int `yaml:"count"`
|
||||
} `yaml:"tenant"`
|
||||
}
|
||||
calls := struct{ defaults, resolve, lookup, validate int }{}
|
||||
loaded, err := loadGroupedWithSecretLookup(loadRequest[config]{
|
||||
main: loadInput{source: Bytes("invalid", []byte("count: not-an-integer\n")), destination: Keys("tenant")},
|
||||
definition: Definition[config]{
|
||||
Defaults: func() config { calls.defaults++; return config{} },
|
||||
Resolve: func(*config, *Resolution) error { calls.resolve++; return nil },
|
||||
Validate: func(*config, *Validation) { calls.validate++ },
|
||||
},
|
||||
}, testSecretBinding(), func(string) (string, bool) { calls.lookup++; return "", false })
|
||||
if loaded != nil || err == nil {
|
||||
t.Fatalf("mounted decode failure returned a usable result: %#v, %v", loaded, err)
|
||||
}
|
||||
if calls.defaults != 1 || calls.resolve != 0 || calls.lookup != 0 || calls.validate != 0 {
|
||||
t.Fatalf("callbacks after mounted decode failure = %#v", calls)
|
||||
}
|
||||
if got := groupedErrorCode(t, err); got != CodeDecodeType {
|
||||
t.Fatalf("mounted decode failure code = %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func indentDestinationBody(body string) string {
|
||||
var result strings.Builder
|
||||
for _, line := range strings.Split(strings.TrimSuffix(body, "\n"), "\n") {
|
||||
result.WriteString(" ")
|
||||
result.WriteString(line)
|
||||
result.WriteByte('\n')
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
210
internal/engine/destination_load_test.go
Normal file
210
internal/engine/destination_load_test.go
Normal file
@@ -0,0 +1,210 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type destinationLoadConfig struct {
|
||||
Main map[string]string `yaml:"main"`
|
||||
Defaults map[string]string `yaml:"defaults"`
|
||||
Collections map[string]map[string]string `yaml:"collections"`
|
||||
Value string `yaml:"value"`
|
||||
}
|
||||
|
||||
type destinationItemConfig struct {
|
||||
Catalog struct {
|
||||
Entries []map[string]string `yaml:"entries"`
|
||||
} `yaml:"catalog"`
|
||||
}
|
||||
|
||||
func TestLayeredLoadPlacesCompletedMappingAsOneItem(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
loaded, err := LoadLayered[destinationItemConfig](
|
||||
Input{Source: NoSource(), Destination: Keys("catalog", "entries"), placementMode: placementModeSequenceItem},
|
||||
testSecretBinding(),
|
||||
LayeredOptions[destinationItemConfig]{Underlays: []Underlay{{Inputs: []Input{{
|
||||
Source: Mapping("empty-item", map[string]any{}), Destination: Keys("catalog", "entries"),
|
||||
placementMode: placementModeSequenceItem,
|
||||
}}}}},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadLayered: %v", err)
|
||||
}
|
||||
result := testLoaded(loaded)
|
||||
if len(result.Config.Catalog.Entries) != 1 || result.Config.Catalog.Entries[0] == nil || len(result.Config.Catalog.Entries[0]) != 0 {
|
||||
t.Fatalf("empty mapping item = %#v", result.Config.Catalog.Entries)
|
||||
}
|
||||
if _, ok := result.Metadata().Lookup(Keys("catalog", "entries", "0")); ok {
|
||||
t.Fatal("sequence index was treated as a literal mapping key during metadata lookup")
|
||||
}
|
||||
sources := result.Sources()
|
||||
if len(sources) != 1 || sources[0].placementMode != placementModeSequenceItem || sources[0].destinationUnresolved {
|
||||
t.Fatalf("source placement evidence = %#v", sources)
|
||||
}
|
||||
if got := sources[0].Destination.String(); got != "/catalog/entries" {
|
||||
t.Fatalf("source destination = %q", got)
|
||||
}
|
||||
|
||||
absent, err := LoadLayered[destinationItemConfig](
|
||||
Input{Source: NoSource(), Destination: Keys("catalog", "entries"), placementMode: placementModeSequenceItem},
|
||||
testSecretBinding(),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("NoSource item load: %v", err)
|
||||
}
|
||||
if got := len(testLoaded(absent).Config.Catalog.Entries); got != 0 {
|
||||
t.Fatalf("absent item created %d entries", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayeredItemPlacementPreflightsModeAndDestination(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
mode placementMode
|
||||
path Path
|
||||
}{
|
||||
{name: "root item", mode: placementModeSequenceItem},
|
||||
{name: "unknown mode", mode: placementMode(255), path: Keys("catalog")},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
reader := bytes.NewBufferString("value: unused\n")
|
||||
_, err := LoadLayered[destinationItemConfig](
|
||||
Input{Source: Reader("reader", reader), Destination: test.path, placementMode: test.mode},
|
||||
testSecretBinding(),
|
||||
)
|
||||
if err == nil || reader.Len() == 0 {
|
||||
t.Fatalf("preflight err %v, unread bytes %d", err, reader.Len())
|
||||
}
|
||||
loadError, ok := err.(*LoadError)
|
||||
if !ok || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != CodeOptionsInvalid {
|
||||
t.Fatalf("preflight error = %#v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadOptionsPlaceOneMappingItem(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
loaded, err := Load[destinationItemConfig](
|
||||
Mapping("ordinary", map[string]any{}), testSecretBinding(),
|
||||
Options[destinationItemConfig]{destination: Keys("catalog", "entries"), placementMode: placementModeSequenceItem},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("Load: %v", err)
|
||||
}
|
||||
result := testLoaded(loaded)
|
||||
if len(result.Config.Catalog.Entries) != 1 || result.Config.Catalog.Entries[0] == nil {
|
||||
t.Fatalf("ordinary item placement = %#v", result.Config.Catalog.Entries)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayeredLoadPlacesNonImportMainAndUnderlayInputs(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
destination := Keys("main")
|
||||
main := Input{Source: Bytes("main", []byte("value: primary\n")), Destination: destination}
|
||||
underlay := Input{Source: Bytes("defaults", []byte("value: fallback\n")), Destination: Keys("defaults")}
|
||||
loaded, err := LoadLayered(main, testSecretBinding(), LayeredOptions[destinationLoadConfig]{
|
||||
Underlays: []Underlay{{Inputs: []Input{underlay}}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("LoadLayered: %v", err)
|
||||
}
|
||||
result := testLoaded(loaded)
|
||||
if result.Config.Main["value"] != "primary" || result.Config.Defaults["value"] != "fallback" {
|
||||
t.Fatalf("mounted configuration = %#v", result.Config)
|
||||
}
|
||||
sources := result.Sources()
|
||||
if len(sources) != 2 || sources[0].Destination.String() != "/main" || sources[1].Destination.String() != "/defaults" {
|
||||
t.Fatalf("source destinations = %#v", sources)
|
||||
}
|
||||
|
||||
destination.segments[0].key = "caller mutation"
|
||||
sources[0].Destination.segments[0].key = "record mutation"
|
||||
if got := result.Sources()[0].Destination.String(); got != "/main" {
|
||||
t.Fatalf("source destination was not independently owned: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayeredLoadOmittedAndEmptyDestinationsKeepRootPlacement(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
inputs := []Input{
|
||||
{Source: Bytes("omitted", []byte("value: same\n"))},
|
||||
{Source: Bytes("empty", []byte("value: same\n")), Destination: Keys()},
|
||||
}
|
||||
for _, input := range inputs {
|
||||
loaded, err := LoadLayered(input, testSecretBinding(), LayeredOptions[destinationLoadConfig]{})
|
||||
if err != nil {
|
||||
t.Fatalf("LoadLayered: %v", err)
|
||||
}
|
||||
result := testLoaded(loaded)
|
||||
if result.Config.Value != "same" {
|
||||
t.Fatalf("root value = %q", result.Config.Value)
|
||||
}
|
||||
sources := result.Sources()
|
||||
if len(sources) != 1 || sources[0].Destination.String() != "" {
|
||||
t.Fatalf("root source destination = %#v", sources)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayeredLoadDistinguishesEmptyBodyFromNoSource(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
loaded, err := LoadLayered(
|
||||
Input{Source: NoSource(), Destination: Keys("absent")},
|
||||
testSecretBinding(),
|
||||
LayeredOptions[destinationLoadConfig]{Underlays: []Underlay{{Inputs: []Input{{
|
||||
Source: Bytes("empty", []byte("{}\n")), Destination: Keys("collections", "one"),
|
||||
}}}}},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadLayered: %v", err)
|
||||
}
|
||||
result := testLoaded(loaded)
|
||||
info, ok := result.Metadata().Lookup(Keys("collections", "one"))
|
||||
if !ok || !info.Explicit || info.Origin.Layer != LayerUnderlay {
|
||||
t.Fatalf("empty mounted mapping = %#v, %v", info, ok)
|
||||
}
|
||||
if _, ok := result.Metadata().Lookup(Keys("absent")); ok {
|
||||
t.Fatal("NoSource materialized its destination")
|
||||
}
|
||||
sources := result.Sources()
|
||||
if len(sources) != 1 || sources[0].Label != "empty" || sources[0].Destination.String() != "/collections/one" {
|
||||
t.Fatalf("sources = %#v", sources)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayeredLoadPreflightsDestinationsBeforeReadingSources(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
reader := bytes.NewBufferString("value: unused\n")
|
||||
_, err := LoadLayered(
|
||||
Input{Source: NoSource()},
|
||||
testSecretBinding(),
|
||||
LayeredOptions[destinationLoadConfig]{Underlays: []Underlay{{Inputs: []Input{{
|
||||
Source: Reader("reader", reader), Destination: Keys("configkit"),
|
||||
}}}}},
|
||||
)
|
||||
if err == nil {
|
||||
t.Fatal("reserved destination was accepted")
|
||||
}
|
||||
if reader.Len() == 0 {
|
||||
t.Fatal("source reader was consumed before destination preflight")
|
||||
}
|
||||
loadError, ok := err.(*LoadError)
|
||||
if !ok || len(loadError.Diagnostics()) != 1 {
|
||||
t.Fatalf("error = %#v", err)
|
||||
}
|
||||
diagnostic := loadError.Diagnostics()[0]
|
||||
if diagnostic.Code != CodeOptionsInvalid || diagnostic.Path.String() != "/configkit" || diagnostic.Primary != nil {
|
||||
t.Fatalf("destination diagnostic = %#v", diagnostic)
|
||||
}
|
||||
}
|
||||
181
internal/engine/destination_provenance_test.go
Normal file
181
internal/engine/destination_provenance_test.go
Normal file
@@ -0,0 +1,181 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"io/fs"
|
||||
"path"
|
||||
"reflect"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestLayeredDestinationsRetainOriginsHistoryAndDetachedRecords(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
directory := t.TempDir()
|
||||
sharedPath := writeImportFile(t, directory, "shared.yml", "section:\n shared: lower\n branch:\n child: old\n kept: retained\n")
|
||||
shared := File(sharedPath)
|
||||
load := func() (*Loaded[map[string]any], error) {
|
||||
return LoadLayered(
|
||||
Input{Source: NoSource()}, testSecretBinding(),
|
||||
LayeredOptions[map[string]any]{Underlays: []Underlay{
|
||||
{Inputs: []Input{
|
||||
{Source: shared, Destination: Keys("catalog")},
|
||||
{Source: Bytes("peer", []byte("section:\n peer: present\n")), Destination: Keys("catalog")},
|
||||
}},
|
||||
{Inputs: []Input{{Source: shared, Destination: Keys("backup")}}},
|
||||
{Inputs: []Input{{Source: Bytes("replacement", []byte("section:\n branch: replaced\n")), Destination: Keys("catalog")}}},
|
||||
}},
|
||||
)
|
||||
}
|
||||
loaded, err := load()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadLayered: %v, diagnostics %#v", err, err.(*LoadError).Diagnostics())
|
||||
}
|
||||
result := testLoaded(loaded)
|
||||
metadata := result.Metadata()
|
||||
|
||||
container, ok := metadata.Lookup(Keys("catalog"))
|
||||
if !ok || !container.Explicit || len(container.Origin.Locations) < 3 {
|
||||
t.Fatalf("mounted container origin = %#v, %v", container, ok)
|
||||
}
|
||||
containerSources := make(map[string]bool)
|
||||
for _, location := range container.Origin.Locations {
|
||||
containerSources[location.SourceID] = true
|
||||
if location.Line < 1 {
|
||||
t.Fatalf("introduced container lacks its source-root fallback: %#v", location)
|
||||
}
|
||||
}
|
||||
for _, sourceID := range []string{"source-1", "source-2", "source-4"} {
|
||||
if !containerSources[sourceID] {
|
||||
t.Fatalf("container contributors = %#v, missing %s", container.Origin.Locations, sourceID)
|
||||
}
|
||||
}
|
||||
|
||||
sharedField, ok := metadata.Lookup(Keys("catalog", "section", "shared"))
|
||||
if !ok || len(sharedField.Origin.Locations) != 1 || sharedField.Origin.Locations[0].SourceID != "source-1" || sharedField.Origin.Locations[0].Line != 2 {
|
||||
t.Fatalf("authored leaf origin = %#v, %v", sharedField, ok)
|
||||
}
|
||||
peerField, ok := metadata.Lookup(Keys("catalog", "section", "peer"))
|
||||
if !ok || len(peerField.Origin.Locations) != 1 || peerField.Origin.Locations[0].SourceID != "source-2" {
|
||||
t.Fatalf("peer leaf origin = %#v, %v", peerField, ok)
|
||||
}
|
||||
if _, ok := metadata.Lookup(Keys("catalog", "section", "branch", "child")); ok {
|
||||
t.Fatal("replaced descendant remains effective")
|
||||
}
|
||||
removed := metadata.History(Keys("catalog", "section", "branch", "child"))
|
||||
if len(removed) < 2 || removed[0].Origin.Locations[0].SourceID != "source-1" {
|
||||
t.Fatalf("removed descendant history = %#v", removed)
|
||||
}
|
||||
|
||||
backup, ok := metadata.Lookup(Keys("backup", "section", "shared"))
|
||||
if !ok || len(backup.Origin.Locations) != 1 || backup.Origin.Locations[0].SourceID != "source-3" {
|
||||
t.Fatalf("second-tier leaf origin = %#v, %v", backup, ok)
|
||||
}
|
||||
sources := result.Sources()
|
||||
if len(sources) != 4 || sources[0].Path != sources[2].Path || sources[0].Destination.String() != "/catalog" || sources[2].Destination.String() != "/backup" {
|
||||
t.Fatalf("same source in separate tiers = %#v", sources)
|
||||
}
|
||||
if sources[0].Layer != LayerUnderlay || sources[0].UnderlayIndex != 1 || sources[2].Layer != LayerUnderlay || sources[2].UnderlayIndex != 2 {
|
||||
t.Fatalf("tier source records = %#v", sources)
|
||||
}
|
||||
|
||||
other, err := load()
|
||||
if err != nil {
|
||||
t.Fatalf("independent load: %v", err)
|
||||
}
|
||||
differences, err := Diff(loaded.Inspection, other.Inspection)
|
||||
if err != nil || len(differences) != 0 {
|
||||
t.Fatalf("equivalent inspection diff = %#v, %v", differences, err)
|
||||
}
|
||||
|
||||
sources[0].Destination.segments[0].key = "mutated"
|
||||
sharedField.Origin.Locations[0].SourceID = "mutated"
|
||||
removed[0].Path.segments[0].key = "mutated"
|
||||
if got := result.Sources()[0].Destination.String(); got != "/catalog" {
|
||||
t.Fatalf("source record copy changed after inspection: %q", got)
|
||||
}
|
||||
fresh, ok := result.Metadata().Lookup(Keys("catalog", "section", "shared"))
|
||||
if !ok || fresh.Origin.Locations[0].SourceID != "source-1" {
|
||||
t.Fatalf("effective origin changed after mutating returned records: %#v, %v", fresh, ok)
|
||||
}
|
||||
if got := result.Metadata().History(Keys("catalog", "section", "branch", "child")); len(got) < 2 || got[0].Path.String() != "/catalog/section/branch/child" {
|
||||
t.Fatalf("historical path changed after mutating returned records: %#v", got)
|
||||
}
|
||||
if got := other.Inspection.sources[0].Destination.String(); got != "/catalog" {
|
||||
t.Fatalf("independent load destination changed: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayeredImportedInheritedValuesRetainResourceContexts(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type entry struct {
|
||||
Extends string `yaml:"extends"`
|
||||
Resource string `yaml:"resource"`
|
||||
Count int `yaml:"count"`
|
||||
}
|
||||
type config struct {
|
||||
Catalog struct {
|
||||
Entries map[string]entry `yaml:"entries"`
|
||||
} `yaml:"catalog"`
|
||||
}
|
||||
directory := t.TempDir()
|
||||
mainPath := writeImportFile(t, directory, "config/main.yml", "child:\n extends: parent\n")
|
||||
inputPath := writeImportFile(t, directory, "shared/input.yml", "configkit: {imports: [{path: parent.yml}]}\n")
|
||||
writeImportFile(t, directory, "shared/parent.yml", "parent:\n resource: icon.txt\n count: 1\n")
|
||||
writeImportFile(t, directory, "shared/icon.txt", "parent-resource")
|
||||
var resolutionContext ResourceContext
|
||||
loaded, err := LoadLayered(
|
||||
Input{Source: File(mainPath), Destination: Keys("catalog", "entries")},
|
||||
testSecretBinding(),
|
||||
LayeredOptions[config]{
|
||||
Underlays: []Underlay{{Inputs: []Input{{
|
||||
Source: File(inputPath), Imports: true, Destination: Keys("catalog", "entries"),
|
||||
}}}},
|
||||
Definition: Definition[config]{Resolve: func(configuration *config, resolution *Resolution) error {
|
||||
info, ok := resolution.InputInfo(Keys("catalog", "entries", "child", "resource"))
|
||||
if !ok || len(info.Origin.Locations) != 1 || len(info.Origin.Inheritance) != 1 {
|
||||
return errors.New("inherited resource lacks its source origin")
|
||||
}
|
||||
var found bool
|
||||
resolutionContext, found = resolution.state.input.sourceContext(info.Origin.Locations[0].SourceID)
|
||||
if !found || resolutionContext.BaseDir != "." || resolutionContext.FS == nil {
|
||||
return errors.New("resolver could not find the declaring resource context")
|
||||
}
|
||||
data, err := fs.ReadFile(resolutionContext.FS, path.Join(resolutionContext.BaseDir, configuration.Catalog.Entries["child"].Resource))
|
||||
if err != nil || string(data) != "parent-resource" {
|
||||
return errors.New("resolver could not read the inherited relative resource")
|
||||
}
|
||||
return nil
|
||||
}},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadLayered: %v, diagnostics %#v", err, err.(*LoadError).Diagnostics())
|
||||
}
|
||||
result := testLoaded(loaded)
|
||||
child := result.Config.Catalog.Entries["child"]
|
||||
if child.Resource != "icon.txt" || child.Count != 1 || child.Extends != "" {
|
||||
t.Fatalf("inherited child = %#v", child)
|
||||
}
|
||||
field, ok := result.Metadata().Lookup(Keys("catalog", "entries", "child", "resource"))
|
||||
if !ok || len(field.Origin.Locations) != 1 || len(field.Origin.Inheritance) != 1 {
|
||||
t.Fatalf("inherited resource origin = %#v, %v", field, ok)
|
||||
}
|
||||
context, ok := result.Inspection.SourceContext(field.Origin.Locations[0].SourceID)
|
||||
if !ok || context.BaseDir != "." || context.FS == nil || resolutionContext.BaseDir != context.BaseDir {
|
||||
t.Fatalf("completed resource contexts = %#v, %#v, %v", resolutionContext, context, ok)
|
||||
}
|
||||
data, err := fs.ReadFile(context.FS, path.Join(context.BaseDir, child.Resource))
|
||||
if err != nil || string(data) != "parent-resource" {
|
||||
t.Fatalf("completed source-relative resource = %q, %v", data, err)
|
||||
}
|
||||
if !reflect.DeepEqual(result.Config.Catalog.Entries["parent"], entry{Resource: "icon.txt", Count: 1}) {
|
||||
t.Fatalf("surviving lower-tier parent = %#v", result.Config.Catalog.Entries["parent"])
|
||||
}
|
||||
for _, source := range result.Sources() {
|
||||
if source.Layer == LayerUnderlay && source.Destination.String() != "/catalog/entries" {
|
||||
t.Errorf("underlay destination = %#v", source)
|
||||
}
|
||||
}
|
||||
}
|
||||
287
internal/engine/destination_test.go
Normal file
287
internal/engine/destination_test.go
Normal file
@@ -0,0 +1,287 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reflect"
|
||||
"testing"
|
||||
|
||||
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
|
||||
)
|
||||
|
||||
func TestNormalizeDestinationUsesLiteralMappingKeys(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
parsed, err := ParsePath("/a~1b/0/~0")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
normalized, err := normalizeDestination(parsed)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := normalized.String(), "/a~1b/0/~0"; got != want {
|
||||
t.Fatalf("normalized path = %q, want %q", got, want)
|
||||
}
|
||||
for index, want := range []string{"a/b", "0", "~"} {
|
||||
segment := normalized.segments[index]
|
||||
if segment.kind != pathSegmentKey || segment.key != want {
|
||||
t.Fatalf("segment %d = %#v, want key %q", index, segment, want)
|
||||
}
|
||||
}
|
||||
|
||||
original := Keys("owned", "path")
|
||||
owned, err := normalizeDestination(original)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
owned.segments[0].key = "changed"
|
||||
if got := original.String(); got != "/owned/path" {
|
||||
t.Fatalf("normalizing shared path storage mutated its input: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMappingPlacementModeMatchesZeroValue(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := &document.Node{
|
||||
Kind: document.Mapping,
|
||||
Mapping: []document.Entry{{
|
||||
Key: "name", Value: &document.Node{Kind: document.String, Text: "entry"},
|
||||
}},
|
||||
}
|
||||
destination := Keys("catalog", "entry")
|
||||
path, zero, err := placeDocument(body, destination, body.Location, 5, 32)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
explicitPath, explicit, err := placeDocumentWithMode(body, destination, placementModeMapping, body.Location, 5, 32)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if path.String() != explicitPath.String() || !reflect.DeepEqual(zero, explicit) {
|
||||
t.Fatalf("zero placement (%q, %#v) differs from mapping mode (%q, %#v)", path.String(), zero, explicitPath.String(), explicit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendDestinationCopiesLiteralSegments(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
parent := Keys("collection", "entry")
|
||||
relative := Keys("a/b", "0", "~")
|
||||
combined := appendDestination(parent, relative)
|
||||
if got, want := combined.String(), "/collection/entry/a~1b/0/~0"; got != want {
|
||||
t.Fatalf("combined destination = %q, want %q", got, want)
|
||||
}
|
||||
if len(combined.segments) != 5 {
|
||||
t.Fatalf("combined segments = %#v", combined.segments)
|
||||
}
|
||||
for index, segment := range combined.segments {
|
||||
if segment.kind != pathSegmentKey {
|
||||
t.Errorf("segment %d kind = %v, want mapping key", index, segment.kind)
|
||||
}
|
||||
}
|
||||
|
||||
combined.segments[0].key = "changed parent"
|
||||
combined.segments[2].key = "changed relative"
|
||||
if got := parent.String(); got != "/collection/entry" {
|
||||
t.Fatalf("combined path shares parent segment storage: %q", got)
|
||||
}
|
||||
if got := relative.String(); got != "/a~1b/0/~0" {
|
||||
t.Fatalf("combined path shares relative segment storage: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeDestinationRejectsIndicesAndReservedKeys(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
for name, path := range map[string]Path{
|
||||
"explicit index": Keys("items").Index(0),
|
||||
"negative index": Keys("items").Index(-1),
|
||||
"invalid path": Path{invalid: true},
|
||||
"configkit": Keys("configkit"),
|
||||
"extends": Keys("extends"),
|
||||
"nested configkit": Keys("outer", "configkit"),
|
||||
"nested extends": Keys("outer", "extends"),
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if _, err := normalizeDestination(path); !errors.Is(err, errInvalidDestination) {
|
||||
t.Fatalf("normalizeDestination(%q) error = %v, want invalid destination", path.String(), err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, key := range []string{"myextends", "configkit2", "", ".", "..", "../x", "a.b", "a/b", "~"} {
|
||||
t.Run("ordinary key "+key, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
if _, err := normalizeDestination(Keys(key)); err != nil {
|
||||
t.Fatalf("normalizeDestination(%q): %v", key, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaceDocumentWrapsEmptyMappingAndPreservesSourceCoordinates(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rootLocation := document.Location{SourceID: "source-a", Line: 1, Column: 1}
|
||||
body := &document.Node{
|
||||
Kind: document.Mapping,
|
||||
Location: document.Location{SourceID: "source-a", Line: 4, Column: 1},
|
||||
}
|
||||
path, placed, err := placeDocument(body, Keys("a", "b"), rootLocation, 2, 5)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got, want := path.String(), "/a/b"; got != want {
|
||||
t.Fatalf("normalized destination = %q, want %q", got, want)
|
||||
}
|
||||
if placed == body || len(placed.Mapping) != 1 || placed.Mapping[0].Key != "a" {
|
||||
t.Fatalf("outer destination mapping was not created: %#v", placed)
|
||||
}
|
||||
inner := placed.Mapping[0].Value
|
||||
if inner.Kind != document.Mapping || len(inner.Mapping) != 1 || inner.Mapping[0].Key != "b" {
|
||||
t.Fatalf("inner destination mapping was not created: %#v", inner)
|
||||
}
|
||||
if inner.Mapping[0].Value != body || len(body.Mapping) != 0 {
|
||||
t.Fatal("placement mutated or copied the authored body")
|
||||
}
|
||||
if placed.Location != rootLocation || inner.Location != rootLocation || placed.Mapping[0].KeyLocation != rootLocation || placed.Mapping[0].ValueLocation != rootLocation {
|
||||
t.Fatal("introduced structure did not use the source-root coordinate fallback")
|
||||
}
|
||||
if body.Location != (document.Location{SourceID: "source-a", Line: 4, Column: 1}) {
|
||||
t.Fatalf("authored body location changed: %#v", body.Location)
|
||||
}
|
||||
if got, want := countInputNodes(placed), countInputNodes(body)+4; got != want {
|
||||
t.Fatalf("placed node count = %d, want body count plus four synthetic nodes (%d)", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaceDocumentPreservesAuthoredEntryCoordinates(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rootLocation := document.Location{SourceID: "source-b", Line: 1, Column: 1}
|
||||
bodyLocation := document.Location{SourceID: "source-b", Line: 3, Column: 1}
|
||||
keyLocation := document.Location{SourceID: "source-b", Line: 3, Column: 3}
|
||||
valueLocation := document.Location{SourceID: "source-b", Line: 4, Column: 5}
|
||||
value := &document.Node{Kind: document.String, Location: valueLocation, Text: "value"}
|
||||
body := &document.Node{
|
||||
Kind: document.Mapping,
|
||||
Location: bodyLocation,
|
||||
Mapping: []document.Entry{{
|
||||
Key: "leaf",
|
||||
KeyLocation: keyLocation,
|
||||
ValueLocation: valueLocation,
|
||||
Value: value,
|
||||
}},
|
||||
}
|
||||
_, placed, err := placeDocument(body, Keys("mounted"), rootLocation, 2, 5)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if placed.Mapping[0].KeyLocation != rootLocation || placed.Mapping[0].ValueLocation != rootLocation {
|
||||
t.Fatal("introduced destination entry did not use the source-root fallback")
|
||||
}
|
||||
if placed.Mapping[0].Value != body || body.Location != bodyLocation {
|
||||
t.Fatal("placement changed the authored body node or location")
|
||||
}
|
||||
entry := body.Mapping[0]
|
||||
if entry.KeyLocation != keyLocation || entry.ValueLocation != valueLocation || entry.Value != value || value.Location != valueLocation {
|
||||
t.Fatal("placement changed authored entry or value coordinates")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaceDocumentRootAndBounds(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
body := &document.Node{
|
||||
Kind: document.Mapping,
|
||||
Mapping: []document.Entry{{
|
||||
Key: "leaf",
|
||||
Value: &document.Node{Kind: document.String, Text: "value"},
|
||||
}},
|
||||
}
|
||||
path, placed, err := placeDocument(body, Path{}, body.Location, 1, 3)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if path.String() != "" || placed != body {
|
||||
t.Fatal("root placement must preserve the existing body")
|
||||
}
|
||||
|
||||
destination := Keys("a", "b")
|
||||
if !destinationWithinLimits(body, destination, 3, 7) {
|
||||
t.Fatal("exact depth and node limits should admit the placed document")
|
||||
}
|
||||
if destinationWithinLimits(body, destination, 2, 7) {
|
||||
t.Fatal("placement exceeding the depth limit was accepted")
|
||||
}
|
||||
if destinationWithinLimits(body, destination, 3, 6) {
|
||||
t.Fatal("placement exceeding the node limit was accepted")
|
||||
}
|
||||
_, placed, err = placeDocument(body, destination, body.Location, 3, 6)
|
||||
if !errors.Is(err, errDestinationLimit) || placed != nil {
|
||||
t.Fatalf("bounded placement returned node %p and error %v", placed, err)
|
||||
}
|
||||
_, absent, err := placeDocument(nil, destination, document.Location{}, 0, 0)
|
||||
if err != nil || absent != nil {
|
||||
t.Fatalf("absent body should not consume placement limits: node %p, error %v", absent, err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPlaceDocumentSequenceItemAndMountedBounds(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
root := document.Location{SourceID: "item", Line: 1, Column: 1}
|
||||
body := &document.Node{Kind: document.Mapping, Location: root}
|
||||
destination := Keys("catalog", "entries")
|
||||
placedPath, placed, err := placeDocumentWithMode(body, destination, placementModeSequenceItem, root, 3, 6)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if placedPath.String() != "/catalog/entries" || placed.Kind != document.Mapping {
|
||||
t.Fatalf("placed root = %q %#v", placedPath.String(), placed)
|
||||
}
|
||||
container, ok := documentAtPath(placed, destination)
|
||||
if !ok || container.Kind != document.Sequence || len(container.Sequence) != 1 || container.Sequence[0] != body {
|
||||
t.Fatalf("item destination = %#v, present %v", container, ok)
|
||||
}
|
||||
if got, want := countInputNodes(placed), countInputNodes(body)+destinationWrapperNodeCount(destination)+1; got != want {
|
||||
t.Fatalf("item node count = %d, want %d", got, want)
|
||||
}
|
||||
if container.Location != root || container.Sequence[0].Location != root {
|
||||
t.Fatal("synthetic sequence or authored mapping lost source coordinates")
|
||||
}
|
||||
|
||||
if _, _, err := placeDocumentWithMode(body, Path{}, placementModeSequenceItem, root, 3, 6); !errors.Is(err, errInvalidDestination) {
|
||||
t.Fatalf("root item placement error = %v, want invalid destination", err)
|
||||
}
|
||||
if _, _, err := placeDocumentWithMode(&document.Node{Kind: document.String}, destination, placementModeSequenceItem, root, 3, 6); !errors.Is(err, errInvalidDestination) {
|
||||
t.Fatalf("non-mapping item placement error = %v, want invalid destination", err)
|
||||
}
|
||||
if _, _, err := placeDocumentWithMode(body, destination, placementMode(255), root, 3, 6); !errors.Is(err, errInvalidDestination) {
|
||||
t.Fatalf("unknown placement mode error = %v, want invalid destination", err)
|
||||
}
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
depth int
|
||||
nodes int
|
||||
wantAccept bool
|
||||
}{
|
||||
{name: "exact allowance", depth: 3, nodes: 6, wantAccept: true},
|
||||
{name: "one too shallow", depth: 2, nodes: 6},
|
||||
{name: "one node short", depth: 3, nodes: 5},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
_, _, err := placeDocumentWithMode(body, destination, placementModeSequenceItem, root, test.depth, test.nodes)
|
||||
if test.wantAccept && err != nil {
|
||||
t.Fatalf("exact bounds rejected: %v", err)
|
||||
}
|
||||
if !test.wantAccept && !errors.Is(err, errDestinationLimit) {
|
||||
t.Fatalf("over-bound placement error = %v", err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
347
internal/engine/destination_tiers_test.go
Normal file
347
internal/engine/destination_tiers_test.go
Normal file
@@ -0,0 +1,347 @@
|
||||
package engine
|
||||
|
||||
import (
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"testing"
|
||||
"testing/fstest"
|
||||
)
|
||||
|
||||
func TestLayeredDestinationStrictCompositionBoundaries(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
inputs []Input
|
||||
wantPath []string
|
||||
want any
|
||||
alsoPath []string
|
||||
alsoWant any
|
||||
}{
|
||||
{
|
||||
name: "disjoint peers at one destination",
|
||||
inputs: []Input{
|
||||
{Source: Bytes("left", []byte("left: one\n")), Destination: Keys("application")},
|
||||
{Source: Bytes("right", []byte("right: two\n")), Destination: Keys("application")},
|
||||
},
|
||||
wantPath: []string{"application", "right"},
|
||||
want: "two",
|
||||
alsoPath: []string{"application", "left"},
|
||||
alsoWant: "one",
|
||||
},
|
||||
{
|
||||
name: "different destinations",
|
||||
inputs: []Input{
|
||||
{Source: Bytes("left", []byte("value: shared\n")), Destination: Keys("left")},
|
||||
{Source: Bytes("right", []byte("value: shared\n")), Destination: Keys("right")},
|
||||
},
|
||||
wantPath: []string{"right", "value"},
|
||||
want: "shared",
|
||||
},
|
||||
{
|
||||
name: "ancestor and descendant destinations",
|
||||
inputs: []Input{
|
||||
{Source: Bytes("parent", []byte("branch: {base: one}\n")), Destination: Keys("application")},
|
||||
{Source: Bytes("child", []byte("leaf: two\n")), Destination: Keys("application", "branch")},
|
||||
},
|
||||
wantPath: []string{"application", "branch", "leaf"},
|
||||
want: "two",
|
||||
alsoPath: []string{"application", "branch", "base"},
|
||||
alsoWant: "one",
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
loaded, err := LoadLayered(Input{Source: NoSource()}, testSecretBinding(), LayeredOptions[map[string]any]{
|
||||
Underlays: []Underlay{{Inputs: test.inputs}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("LoadLayered: %v", err)
|
||||
}
|
||||
result := testLoaded(loaded)
|
||||
if got := layeredMapValue(t, result.Config, test.wantPath...); got != test.want {
|
||||
t.Fatalf("value at %v = %#v, want %#v", test.wantPath, got, test.want)
|
||||
}
|
||||
if len(test.alsoPath) != 0 {
|
||||
if got := layeredMapValue(t, result.Config, test.alsoPath...); got != test.alsoWant {
|
||||
t.Fatalf("value at %v = %#v, want %#v", test.alsoPath, got, test.alsoWant)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
left string
|
||||
right string
|
||||
}{
|
||||
{name: "equal values conflict", left: "value: same\n", right: "value: same\n"},
|
||||
{name: "unequal values conflict", left: "value: left\n", right: "value: right\n"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
inputs := []Input{
|
||||
{Source: Bytes("left", []byte(test.left)), Destination: Keys("application")},
|
||||
{Source: Bytes("right", []byte(test.right)), Destination: Keys("application")},
|
||||
}
|
||||
loaded, err := LoadLayered(Input{Source: NoSource()}, testSecretBinding(), LayeredOptions[map[string]any]{
|
||||
Underlays: []Underlay{{Inputs: inputs}},
|
||||
})
|
||||
if loaded != nil || groupedErrorCode(t, err) != CodeComposeConflict {
|
||||
t.Fatalf("strict composition result = %#v, %v", loaded, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
t.Run("strict conflict is not hidden by an upper override", func(t *testing.T) {
|
||||
inputs := []Input{
|
||||
{Source: Bytes("left", []byte("value: left\n")), Destination: Keys("application")},
|
||||
{Source: Bytes("right", []byte("value: right\n")), Destination: Keys("application")},
|
||||
}
|
||||
loaded, err := LoadLayered(
|
||||
Input{Source: Bytes("main", []byte("value: upper\n")), Destination: Keys("application")},
|
||||
testSecretBinding(),
|
||||
LayeredOptions[map[string]any]{Underlays: []Underlay{{Inputs: inputs}}},
|
||||
)
|
||||
if loaded != nil || groupedErrorCode(t, err) != CodeComposeConflict {
|
||||
t.Fatalf("strict composition result = %#v, %v", loaded, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestLayeredDestinationMatchesExplicitlyWrappedInput(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
load := func(input Input) map[string]any {
|
||||
t.Helper()
|
||||
loaded, err := LoadLayered(Input{Source: NoSource()}, testSecretBinding(), LayeredOptions[map[string]any]{
|
||||
Underlays: []Underlay{{Inputs: []Input{input}}},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("LoadLayered: %v", err)
|
||||
}
|
||||
return testLoaded(loaded).Config
|
||||
}
|
||||
|
||||
placed := load(Input{Source: Bytes("placed", []byte("field: value\n")), Destination: Keys("application")})
|
||||
authored := load(Input{Source: Bytes("authored", []byte("application: {field: value}\n"))})
|
||||
if !reflect.DeepEqual(placed, authored) {
|
||||
t.Fatalf("placed configuration %#v differs from explicitly wrapped %#v", placed, authored)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEmptyMountedMappingRetainsLowerChildren(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
destination := Keys("collections", "weather")
|
||||
loaded, err := LoadLayered(
|
||||
Input{Source: Bytes("empty-main", []byte("{}\n")), Destination: destination},
|
||||
testSecretBinding(),
|
||||
LayeredOptions[map[string]any]{Underlays: []Underlay{{Inputs: []Input{{
|
||||
Source: Bytes("lower", []byte("prompt: {title: retained}\n")), Destination: destination,
|
||||
}}}}},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadLayered: %v", err)
|
||||
}
|
||||
if got := layeredMapValue(t, testLoaded(loaded).Config, "collections", "weather", "prompt", "title"); got != "retained" {
|
||||
t.Fatalf("lower child beneath empty mounted mapping = %#v, want retained", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayeredDestinationOverlayRetainsOrdinaryValueSemantics(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
filesystem := NewFilesystem("bundle", fstest.MapFS{
|
||||
"overlays/test.yml": &fstest.MapFile{Data: []byte("settings:\n metadata:\n version: overlay\n")},
|
||||
})
|
||||
loaded, err := LoadLayered(
|
||||
Input{Source: Bytes("main", []byte("mapping: {shared: main}\nsequence: [9]\ncleared: null\nenabled: false\nmetadata: {version: main}\n")), Destination: Keys("settings")},
|
||||
testSecretBinding(),
|
||||
LayeredOptions[map[string]any]{
|
||||
Underlays: []Underlay{{Inputs: []Input{{
|
||||
Source: Bytes("underlay", []byte("mapping: {retained: lower, shared: lower}\nsequence: [1, 2]\ncleared: {old: true}\nenabled: true\ncount: 7\nmetadata: {version: lower, label: retained}\n")),
|
||||
Destination: Keys("settings"),
|
||||
}}}},
|
||||
Overlay: "test",
|
||||
OverlayFilesystem: &FilesystemContext{Root: ".", BaseDir: ".", FS: &filesystem},
|
||||
Overrides: []Override{{Path: Keys("settings", "count"), Value: 0, Flag: "--count"}},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadLayered: %v", err)
|
||||
}
|
||||
result := testLoaded(loaded)
|
||||
settings := layeredMap(t, result.Config, "settings")
|
||||
mapping := layeredMap(t, settings, "mapping")
|
||||
if mapping["retained"] != "lower" || mapping["shared"] != "main" {
|
||||
t.Fatalf("recursive mapping overlay = %#v", mapping)
|
||||
}
|
||||
sequence, ok := settings["sequence"].([]any)
|
||||
if !ok || len(sequence) != 1 || sequence[0] != int64(9) {
|
||||
t.Fatalf("sequence overlay = %#v", settings["sequence"])
|
||||
}
|
||||
if value, present := settings["cleared"]; !present || value != nil {
|
||||
t.Fatalf("explicit null = %#v, present %v", value, present)
|
||||
}
|
||||
if value, ok := settings["enabled"].(bool); !ok || value {
|
||||
t.Fatalf("explicit false = %#v", settings["enabled"])
|
||||
}
|
||||
if value, ok := settings["count"].(int64); !ok || value != 0 {
|
||||
t.Fatalf("explicit zero override = %#v", settings["count"])
|
||||
}
|
||||
metadata := layeredMap(t, settings, "metadata")
|
||||
if metadata["version"] != "overlay" || metadata["label"] != "retained" {
|
||||
t.Fatalf("version-only overlay = %#v", metadata)
|
||||
}
|
||||
if _, present := settings["settings"]; present {
|
||||
t.Fatalf("upper tiers were incorrectly nested under the main destination: %#v", result.Config)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayeredDestinationChecksVersionOnceOnComposedMain(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
type appConfig struct {
|
||||
Version int8 `yaml:"version"`
|
||||
Count int `yaml:"count"`
|
||||
Main string `yaml:"main"`
|
||||
Imported string `yaml:"imported"`
|
||||
Lower string `yaml:"lower"`
|
||||
}
|
||||
type config struct {
|
||||
App appConfig `yaml:"app"`
|
||||
}
|
||||
filesystem := NewFilesystem("bundle", fstest.MapFS{
|
||||
"main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: fragment.yml}]}\nversion: 6\ncount: 4\nmain: main\n")},
|
||||
"fragment.yml": &fstest.MapFile{Data: []byte("imported: included\n")},
|
||||
})
|
||||
checks := 0
|
||||
loaded, err := LoadLayered(
|
||||
Input{Source: filesystem.File("main.yml"), Imports: true, Destination: Keys("app")},
|
||||
testSecretBinding(),
|
||||
LayeredOptions[config]{
|
||||
Definition: Definition[config]{Version: &VersionRule{Path: Keys("app", "version"), Check: func(version Version, _ *Validation) error {
|
||||
checks++
|
||||
if !version.Present || version.Text != "6" {
|
||||
t.Errorf("nested main version = %#v", version)
|
||||
}
|
||||
return nil
|
||||
}}},
|
||||
Underlays: []Underlay{{Inputs: []Input{{
|
||||
Source: Bytes("lower", []byte("lower: underlay\n")), Destination: Keys("app"),
|
||||
}}}},
|
||||
},
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("LoadLayered: %v", err)
|
||||
}
|
||||
result := testLoaded(loaded)
|
||||
if checks != 1 {
|
||||
t.Fatalf("version hook calls = %d, want 1", checks)
|
||||
}
|
||||
if result.Config.App.Version != 6 || result.Config.App.Count != 4 || result.Config.App.Main != "main" || result.Config.App.Imported != "included" || result.Config.App.Lower != "underlay" {
|
||||
t.Fatalf("nested app = %#v", result.Config.App)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLayeredDestinationVersionClaimsUsePlacedApplicationPaths(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
rule := &VersionRule{Path: Keys("app", "schema", "version"), Check: func(Version, *Validation) error { return nil }}
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
body string
|
||||
wantCode Code
|
||||
wantValue any
|
||||
}{
|
||||
{name: "underlay direct claim", body: "schema: {version: v2}\n", wantCode: CodeUnderlayVersion},
|
||||
{name: "underlay ancestor claim", body: "schema: null\n", wantCode: CodeUnderlayVersion},
|
||||
{name: "underlay mapping ancestor without claim", body: "schema: {name: ordinary}\n", wantValue: "ordinary"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
loaded, err := LoadLayered(
|
||||
Input{Source: NoSource()},
|
||||
testSecretBinding(),
|
||||
LayeredOptions[map[string]any]{
|
||||
Definition: Definition[map[string]any]{Version: rule},
|
||||
Underlays: []Underlay{{Inputs: []Input{{
|
||||
Source: Bytes("underlay", []byte(test.body)), Destination: Keys("app"),
|
||||
}}}},
|
||||
},
|
||||
)
|
||||
if test.wantCode != "" {
|
||||
if loaded != nil || groupedErrorCode(t, err) != test.wantCode {
|
||||
t.Fatalf("version claim result = %#v, %v", loaded, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("nonclaiming mapping ancestor was rejected: %v", err)
|
||||
}
|
||||
result := testLoaded(loaded)
|
||||
if got := layeredMapValue(t, result.Config, "app", "schema", "name"); got != test.wantValue {
|
||||
t.Fatalf("nested ordinary value = %#v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
body string
|
||||
wantCode Code
|
||||
}{
|
||||
{name: "imported direct claim", body: "schema: {version: v2}\n", wantCode: CodeImportVersion},
|
||||
{name: "imported ancestor claim", body: "schema: null\n", wantCode: CodeImportVersion},
|
||||
{name: "imported mapping ancestor without claim", body: "schema: {name: ordinary}\n"},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
t.Parallel()
|
||||
directory := t.TempDir()
|
||||
writeImportFile(t, directory, "main.yml", "configkit: {imports: [{path: fragment.yml}]}\n")
|
||||
writeImportFile(t, directory, "fragment.yml", test.body)
|
||||
loaded, err := LoadLayered(
|
||||
Input{Source: NoSource()},
|
||||
testSecretBinding(),
|
||||
LayeredOptions[map[string]any]{
|
||||
Definition: Definition[map[string]any]{Version: rule},
|
||||
Underlays: []Underlay{{Inputs: []Input{{
|
||||
Source: File(filepath.Join(directory, "main.yml")), Imports: true, Destination: Keys("app"),
|
||||
}}}},
|
||||
},
|
||||
)
|
||||
if test.wantCode != "" {
|
||||
if loaded != nil || groupedErrorCode(t, err) != test.wantCode {
|
||||
t.Fatalf("version claim result = %#v, %v", loaded, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if err != nil {
|
||||
t.Fatalf("nonclaiming imported mapping ancestor was rejected: %v", err)
|
||||
}
|
||||
result := testLoaded(loaded)
|
||||
if got := layeredMapValue(t, result.Config, "app", "schema", "name"); got != "ordinary" {
|
||||
t.Fatalf("nested ordinary value = %#v", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func layeredMap(t *testing.T, value map[string]any, key string) map[string]any {
|
||||
t.Helper()
|
||||
result, ok := value[key].(map[string]any)
|
||||
if !ok {
|
||||
t.Fatalf("%q = %#v, want mapping", key, value[key])
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func layeredMapValue(t *testing.T, value map[string]any, keys ...string) any {
|
||||
t.Helper()
|
||||
for _, key := range keys[:len(keys)-1] {
|
||||
value = layeredMap(t, value, key)
|
||||
}
|
||||
return value[keys[len(keys)-1]]
|
||||
}
|
||||
@@ -63,8 +63,9 @@ func WriteDiagnostics(output io.Writer, diagnostics []Diagnostic) error {
|
||||
}
|
||||
|
||||
type diagnosticAccumulator struct {
|
||||
diagnostics []Diagnostic
|
||||
sources []SourceInfo
|
||||
diagnostics []Diagnostic
|
||||
sources []SourceInfo
|
||||
versionState *sourceVersionState
|
||||
}
|
||||
|
||||
func (a *diagnosticAccumulator) add(diagnostics ...Diagnostic) {
|
||||
@@ -74,9 +75,13 @@ func (a *diagnosticAccumulator) add(diagnostics ...Diagnostic) {
|
||||
}
|
||||
|
||||
func (a *diagnosticAccumulator) loadError() *LoadError {
|
||||
sources := a.sources
|
||||
if a.versionState != nil {
|
||||
sources = sourceInfosWithVersionEvidence(sources, a.versionState.evidence, nil, true)
|
||||
}
|
||||
return &LoadError{
|
||||
diagnostics: canonicalDiagnostics(a.diagnostics),
|
||||
sources: cloneSourceInfos(a.sources),
|
||||
sources: cloneSourceInfos(sources),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,15 +90,28 @@ func canonicalDiagnostics(diagnostics []Diagnostic) []Diagnostic {
|
||||
for i := range canonical {
|
||||
slices.SortFunc(canonical[i].Related, compareLocation)
|
||||
}
|
||||
slices.SortFunc(canonical, compareDiagnostic)
|
||||
type orderedDiagnostic struct {
|
||||
diagnostic Diagnostic
|
||||
pointer string
|
||||
}
|
||||
ordered := make([]orderedDiagnostic, len(canonical))
|
||||
for i, diagnostic := range canonical {
|
||||
ordered[i] = orderedDiagnostic{diagnostic: diagnostic, pointer: diagnostic.Path.String()}
|
||||
}
|
||||
slices.SortFunc(ordered, func(left, right orderedDiagnostic) int {
|
||||
return compareDiagnosticWithPaths(left.diagnostic, right.diagnostic, left.pointer, right.pointer)
|
||||
})
|
||||
for i, item := range ordered {
|
||||
canonical[i] = item.diagnostic
|
||||
}
|
||||
return canonical
|
||||
}
|
||||
|
||||
func compareDiagnostic(left, right Diagnostic) int {
|
||||
func compareDiagnosticWithPaths(left, right Diagnostic, leftPath, rightPath string) int {
|
||||
if order := compareOptionalLocation(left.Primary, right.Primary); order != 0 {
|
||||
return order
|
||||
}
|
||||
if order := cmp.Compare(left.Path.String(), right.Path.String()); order != 0 {
|
||||
if order := cmp.Compare(leftPath, rightPath); order != 0 {
|
||||
return order
|
||||
}
|
||||
if order := cmp.Compare(left.Code, right.Code); order != 0 {
|
||||
|
||||
@@ -90,6 +90,26 @@ func TestDiagnosticsAreCanonicalAndDetached(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCanonicalDiagnosticsSortEscapedPaths(t *testing.T) {
|
||||
primary := &Location{SourceID: "source-1", Line: 1, Column: 1}
|
||||
diagnostics := []Diagnostic{
|
||||
{Code: "same", Severity: SeverityError, Path: Keys("a/b"), Message: "same", Primary: primary},
|
||||
{Code: "same", Severity: SeverityError, Path: Keys("a~b"), Message: "same", Primary: primary},
|
||||
{Code: "z", Severity: SeverityError, Path: Keys("same"), Message: "same", Primary: primary},
|
||||
{Code: "a", Severity: SeverityError, Path: Keys("same"), Message: "same", Primary: primary},
|
||||
}
|
||||
canonical := canonicalDiagnostics(diagnostics)
|
||||
want := []string{"/a~0b", "/a~1b", "/same", "/same"}
|
||||
for i, diagnostic := range canonical {
|
||||
if got := diagnostic.Path.String(); got != want[i] {
|
||||
t.Fatalf("diagnostic[%d] path = %q, want %q", i, got, want[i])
|
||||
}
|
||||
}
|
||||
if canonical[2].Code != "a" || canonical[3].Code != "z" {
|
||||
t.Fatalf("same-path code order = %q, %q", canonical[2].Code, canonical[3].Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteDiagnosticsEscapesControls(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user