123 Commits

Author SHA1 Message Date
315f464dfc Correct mounted mapping diagnostic paths 2026-10-14 15:49:51 +00:00
a029a2a754 Record input destination review findings 2026-10-14 14:12:18 +00:00
945e111c4e Complete input destination implementation audit 2026-10-14 14:06:38 +00:00
2d8df22dd1 Add mounted collection consumer examples 2026-10-14 13:59:37 +00:00
53601e68ed Expose input destinations through the public facade 2026-10-14 13:56:22 +00:00
751787ec24 Verify mounted inheritance and sensitivity integration 2026-10-14 13:50:08 +00:00
f50122d9f8 Verify mounted provenance and resource contexts 2026-10-14 13:38:56 +00:00
c71faf2065 Count destination wrappers in shared input limits 2026-10-14 13:31:32 +00:00
2f5d57ce1d Verify mounted diagnostic paths and locations 2026-10-14 13:25:55 +00:00
a1c557603f Verify mounted composition and version ownership 2026-10-14 13:24:06 +00:00
a2fab66aba Carry destinations through imported configuration 2026-10-14 13:19:31 +00:00
2f1f7f4b8a Integrate private destinations for layered inputs 2026-10-14 13:14:06 +00:00
667a536ae7 Add private input destination placement helpers 2026-10-14 13:09:06 +00:00
ea55ab43ce Record input destination decision and baseline 2026-10-14 13:05:09 +00:00
d3d468a8b4 Plan configuration input destination implementation 2026-10-14 12:48:08 +00:00
6353598bd6 Settle input destination roadmap decisions 2026-10-14 12:40:06 +00:00
3c2d7facb3 Plan configuration input destinations 2026-10-14 03:27:34 +00:00
6fffde4af8 Prepare ConfigKit v0.7.1 release 2026-10-11 01:14:23 +00:00
9e04a397c4 Upgrade ReleaseKit to v0.4.1 2026-10-11 01:10:26 +00:00
c54a9bf51e Upgrade ReleaseKit to v0.4.0 2026-10-10 23:51:44 +00:00
8f3396d6f0 Retire completed diff plans for release preparation 2026-10-09 14:23:20 +00:00
1c2610c2c0 Prepare release notes for v0.7.0 2026-10-09 14:21:37 +00:00
92025d65cb Correct diff masking for historical descendants 2026-10-09 13:22:40 +00:00
a4b5a6840a Record effective diff review findings 2026-10-09 12:57:40 +00:00
2ebdfb539b Document and audit effective configuration diffs 2026-10-09 12:42:17 +00:00
a37e6895e1 Add effective diff consumer examples 2026-10-09 12:37:18 +00:00
8ae542b3a3 Expose effective diffs through the root facade 2026-10-09 12:34:11 +00:00
174efb65f6 Assemble the safe engine diff operation 2026-10-09 12:30:20 +00:00
756149beaa Track hidden changes behind redaction 2026-10-09 12:24:01 +00:00
e5dddef204 Mask sensitive structural replacements 2026-10-09 12:22:36 +00:00
ade5d6a273 Prepare paired safe snapshots for diffs 2026-10-09 12:20:15 +00:00
c20ea4b916 Index combined diff sensitivity 2026-10-09 12:16:13 +00:00
2e17e365a1 Prepare canonical configuration diff regions 2026-10-09 12:14:47 +00:00
fcf914a2ab Record effective diff baseline evidence 2026-10-09 12:12:12 +00:00
3697a51124 Accept the effective configuration diff decision 2026-10-09 12:10:56 +00:00
9194c5b52a Plan effective configuration diff implementation 2026-10-09 05:09:25 +00:00
e82b1c9054 Finalize the effective configuration diff roadmap 2026-10-09 05:02:49 +00:00
6f59cdd237 Plan structured effective configuration diffs 2026-10-09 04:55:29 +00:00
1a885934c2 Prepare release notes for v0.6.0 2026-10-09 04:29:32 +00:00
5917cebf50 Retire completed roadmaps for release preparation 2026-10-09 04:28:16 +00:00
8e91a2b16b Upgrade ReleaseKit to v0.3.0 2026-10-09 04:26:30 +00:00
08402d098b Adopt updated release toolchain policy 2026-10-09 04:23:15 +00:00
53b79528d9 Keep reference algorithms in tests 2026-10-09 04:14:45 +00:00
f849e06b77 Correct the unindexed sensitivity fixture 2026-10-09 04:11:50 +00:00
b37948cfea Reuse parsed sensitivity paths 2026-10-09 04:10:41 +00:00
9323f89ad1 Record performance implementation review findings 2026-10-09 04:07:46 +00:00
6c62226838 Record performance audit completion 2026-10-09 04:01:14 +00:00
799ebab5a6 Index resolution and origin membership 2026-10-09 03:59:16 +00:00
c8546ae599 Index inherited step membership 2026-10-09 03:56:29 +00:00
d32c94eca3 Reuse frozen inspection mapping lookups 2026-10-09 03:54:14 +00:00
344f4f73d7 Index inheritance history lookups 2026-10-09 03:52:07 +00:00
5fedc11c8f Prepare coalesced resolution replay 2026-10-09 03:49:43 +00:00
5b8b7a2415 Index CLI override validation 2026-10-09 03:45:43 +00:00
e591e97f22 Integrate indexed sensitivity propagation 2026-10-09 03:42:29 +00:00
71e142afb4 Add indexed secret dependency closure 2026-10-09 03:41:27 +00:00
ca1ba04676 Collect sensitivity directly from document trees 2026-10-09 03:39:12 +00:00
528495c75a Index resolution metadata updates 2026-10-09 03:36:29 +00:00
aa3b4a5881 Index resolution coverage and document reads 2026-10-09 03:32:30 +00:00
db2d8ed217 Add private canonical path prefix index 2026-10-09 03:30:01 +00:00
d34a98d34b Precompute canonical paths before sorting 2026-10-09 03:27:42 +00:00
9ac65ccc08 Compare canonical paths by token spelling 2026-10-09 03:23:08 +00:00
edc6660654 Detect inheritance directives without paths 2026-10-09 03:20:24 +00:00
a2084df11a Make explicit metadata queries lightweight 2026-10-09 03:18:50 +00:00
b59f002314 Record performance audit baseline evidence 2026-10-09 03:15:39 +00:00
52793301ba Plan the performance improvements 2026-10-09 03:13:22 +00:00
0c77deed05 Record performance audit and optimization candidates 2026-10-09 02:09:25 +00:00
48619de024 Verify startup loading improvements 2026-10-09 01:19:13 +00:00
c546124029 Combine final metadata traversal 2026-10-09 00:52:55 +00:00
673faea097 Reduce path formatting allocations 2026-10-09 00:34:21 +00:00
91ae4fa7ef Measure startup configuration loading 2026-10-09 00:14:19 +00:00
f1722274c0 Add an ordered roadmap for startup loading performance 2026-10-08 23:58:35 +00:00
d53021ea2c Request more efficient configuration loading for PromptKit 2026-10-08 23:50:02 +00:00
a22aaa5dfa Mark ReleaseKit adoption complete 2026-10-08 17:08:53 +00:00
b1ecd9a078 Clarify release command exit statuses 2026-10-08 17:08:35 +00:00
84b59c690d Correct release recovery endpoint checks 2026-10-08 17:08:00 +00:00
ac90b9835b Record ReleaseKit adoption review findings 2026-10-08 17:01:58 +00:00
f887fac809 Complete ReleaseKit endpoint and recovery validation 2026-10-08 16:55:10 +00:00
c03457c814 Verify ReleaseKit against a disposable remote 2026-10-08 16:51:09 +00:00
e9b6b63e5e Retire duplicated release scripts and tests 2026-10-08 16:46:28 +00:00
1dc50d8cdf Replace the release procedure with ReleaseKit 2026-10-08 16:42:56 +00:00
0e92085002 Align release policy with ReleaseKit 2026-10-08 16:37:10 +00:00
f96f2b8295 Add the ReleaseKit source profile 2026-10-08 16:35:58 +00:00
1917dcf921 Declare ReleaseKit as a Go tool dependency 2026-10-08 16:34:58 +00:00
0ac4731903 Separate Go minimum from release toolchain 2026-10-08 16:23:25 +00:00
c42930c6d5 Plan ReleaseKit migration stages 2026-10-08 13:48:36 +00:00
2d3ea588f1 Resolve ReleaseKit adoption decisions 2026-10-08 13:40:50 +00:00
5aafd943b4 Plan Go toolchain updates for releases 2026-10-08 13:33:43 +00:00
c0605fab44 Plan ReleaseKit adoption 2026-10-08 13:12:18 +00:00
dc09959cca Prepare ConfigKit v0.5.0 release 2026-09-30 00:18:40 +00:00
83c59ea7d3 Fix nested inheritance history and budget accounting 2026-09-30 00:04:02 +00:00
b45f7a0410 Record sibling inheritance review findings 2026-09-30 00:00:09 +00:00
2ca1f80094 Document and benchmark sibling inheritance 2026-09-29 23:13:50 +00:00
df9a02539c Activate sibling inheritance in configuration loading 2026-09-29 23:04:13 +00:00
501d59f21b Preserve secret sensitivity across sibling inheritance 2026-09-29 22:56:10 +00:00
f916e7d1f9 Preserve sibling inheritance in configuration history and explanations 2026-09-29 22:52:51 +00:00
462e57df3e Preserve inherited source origins and transfer evidence 2026-09-29 22:40:49 +00:00
4207110e2f Resolve nested inheritance in destination scopes 2026-09-29 22:31:07 +00:00
fdbc306cfb Resolve sibling inheritance with memoized parent templates 2026-09-29 22:23:48 +00:00
78d4b59bbe Share recursive mapping merge across configuration assembly 2026-09-29 22:20:10 +00:00
f564fef84c Plan sibling inheritance implementation 2026-09-29 21:37:38 +00:00
01e38b5840 Record agreed inheritance reference and presence rules 2026-09-29 21:18:48 +00:00
ac1eec2e30 Revise inheritance roadmap around built-in sibling references 2026-09-29 21:10:04 +00:00
0a4b774bfe Retire completed plans and outline named inheritance 2026-09-29 20:11:42 +00:00
b604aa167b Fix underlay provenance and resource path handling 2026-09-29 20:03:39 +00:00
15f5083282 Record underlay implementation review findings 2026-09-29 19:16:18 +00:00
cbf031aedc Document and demonstrate native and layered configuration loading 2026-09-29 18:22:45 +00:00
34e18c84c1 Validate source equivalence and layered scaling 2026-09-29 18:15:10 +00:00
36362ee6af Provide passive per-source resource contexts 2026-09-29 18:08:27 +00:00
5a77728a81 Expose layered configuration loading and inspection 2026-09-29 17:58:15 +00:00
1cdf90d831 Compose grouped underlays in shared load lifecycle 2026-09-29 17:52:20 +00:00
90c6e8ed7e Add snapshotted mapping configuration sources 2026-09-29 17:43:19 +00:00
1ff3c812a7 Expose native filesystem sources through public loading API 2026-09-29 17:36:20 +00:00
6111cb23a1 Unify host and native filesystem acquisition 2026-09-29 17:29:01 +00:00
de28718941 Reuse owned assembly across configuration layers 2026-09-29 17:13:14 +00:00
8d9a3a8dfa Index mapping assembly for composition and overlays 2026-09-29 17:03:02 +00:00
27df838513 Plan filesystem sources and underlays 2026-09-29 16:50:11 +00:00
36172ed20e Clarify ConfigKit design goals and priorities 2026-09-29 16:10:16 +00:00
e1ce24aabb Focus the source roadmap on recursive underlays 2026-09-29 16:06:31 +00:00
e4bbedc695 Preserve version semantics in the layering roadmap 2026-09-29 15:52:20 +00:00
798091b276 Align layering roadmap with consistent version semantics 2026-09-29 15:45:21 +00:00
5c1aa1814c Document filesystem sources and layered loading roadmap 2026-09-29 15:25:23 +00:00
ec26591b27 Request named configuration inheritance for PromptKit 2026-09-29 15:07:21 +00:00
99338baabb Record PromptKit filesystem and layered loading requests 2026-09-29 14:54:44 +00:00
170 changed files with 16968 additions and 1589 deletions

View File

@@ -5,18 +5,26 @@ An opinionated Go library for applications that use YAML configuration files.
Linux is release-validated; macOS support is best-effort. See the
[platform support policy](docs/policy/release.md#platform-support) for details.
ConfigKit's initial loader accepts one strict YAML
source (or explicit defaults-only input), optionally traverses explicit path and glob
imports, strictly composes their disjoint definitions, applies typed defaults, and
can overlay one selected fixed overlay and check an application schema version. It
returns detached metadata and safe diagnostics.
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
between groups combines mappings recursively. Both entry points return a complete,
validated configuration with detached metadata and safe diagnostics.
Applications may perform audited defaulting and normalization through a resolver,
then report domain errors and warnings through read-only semantic validation. Secret
tags and a path predicate drive deterministic redacted effective YAML. Typed secret
references resolve environment names before validation, and explicit
secrets-directory bootstrap is available as a separate startup operation. Explicit
CLI override records apply above overlays. Value explanation is available through
detached structured records and deterministic redacted output.
detached structured records and deterministic redacted output. Built-in
`extends` relationships let a mapping inherit from a named sibling during the
same load, retaining explicit presence and source/derivation history. The root
`Diff` API compares frozen effective values from two successful same-type loads;
the [configuration contract](docs/config.md#effective-configuration-diffs) and
[consumer guide](docs/consumers/README.md#effective-configuration-diffs) describe
its safe changed-region records and maintained workflows.
```go
type Config struct {
@@ -33,13 +41,41 @@ func ReadConfig(path string) (Config, error) {
}
```
The [configuration contract](docs/config.md) describes implemented behavior. The
[consumer adoption guide](docs/consumers/README.md) provides maintained Narratio-,
Notarius-, WeatherReporter-, and Seriatim-shaped examples and migration guidance.
The [deferred scope](docs/roadmap/deferred.md) records deliberate exclusions. See the
[development guide](docs/development.md) for repository orientation and the
[release procedure](docs/release.md) for maintainer-only candidate and publication
steps.
## Design philosophy
ConfigKit favors one coherent, predictable configuration model over flexibility
for reproducing each consumer's conventions. Its goal is to let applications
load once, inspect where final values came from, and distinguish explicit values
from omissions with minimal configuration bookkeeping. Applications retain their
domain rules and execution responsibilities.
Loading is synchronous and complete on return. Applications normally load once
near startup and then use the final configuration and metadata; ConfigKit stays
inactive until explicitly called again. Applications may initiate additional
loads as needed. See the [loading lifecycle](docs/config.md#completion-and-resource-lifetime)
for completion and ownership details.
Within a configuration tier, independent contributions compose strictly: shared
mappings combine, while overlapping non-mapping definitions are errors. Between
precedence tiers, mappings combine recursively, retaining omitted lower-priority
children; other values replace lower-priority values. The main document and its
imports form one strictly composed tier. Explicit underlay groups sit below it
and above typed defaults; a selected overlay and explicit CLI overrides sit
above it. The [configuration contract](docs/config.md#strict-document-composition)
owns the detailed combination rules and input-specific constraints.
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.
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, and mounted-collection examples alongside the migration shapes. The
accepted placement decision is recorded in [ADR 0011](docs/adr/0011-input-destinations.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).

View File

@@ -43,16 +43,36 @@ 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), ImportedFrom: fromEngineLocationPointer(value.ImportedFrom)}
return SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Destination: fromEnginePath(value.Destination), Layer: Layer(value.Layer), Kind: SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex, ImportedFrom: fromEngineLocationPointer(value.ImportedFrom)}
}
func toEngineSourceInfo(value SourceInfo) engine.SourceInfo {
return engine.SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Layer: engine.Layer(value.Layer), ImportedFrom: toEngineLocationPointer(value.ImportedFrom)}
return 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)}
}
func fromEngineOrigin(value engine.Origin) Origin {
return Origin{Layer: Layer(value.Layer), Locations: fromEngineLocations(value.Locations), Operation: ChangeKind(value.Operation)}
result := Origin{Layer: Layer(value.Layer), Locations: fromEngineLocations(value.Locations), Operation: ChangeKind(value.Operation)}
if value.Inheritance != nil {
result.Inheritance = make([]InheritanceStep, len(value.Inheritance))
for index, step := range value.Inheritance {
result.Inheritance[index] = InheritanceStep{
From: fromEnginePath(step.From), To: fromEnginePath(step.To), Reference: fromEnginePath(step.Reference),
Locations: fromEngineLocations(step.Locations),
}
}
}
return result
}
func toEngineOrigin(value Origin) engine.Origin {
return engine.Origin{Layer: engine.Layer(value.Layer), Locations: toEngineLocations(value.Locations), Operation: engine.ChangeKind(value.Operation)}
result := engine.Origin{Layer: engine.Layer(value.Layer), Locations: toEngineLocations(value.Locations), Operation: engine.ChangeKind(value.Operation)}
if value.Inheritance != nil {
result.Inheritance = make([]engine.InheritanceStep, len(value.Inheritance))
for index, step := range value.Inheritance {
result.Inheritance[index] = engine.InheritanceStep{
From: toEnginePath(step.From), To: toEnginePath(step.To), Reference: toEnginePath(step.Reference),
Locations: toEngineLocations(step.Locations),
}
}
}
return result
}
func fromEngineDiagnostic(value engine.Diagnostic) Diagnostic {
return Diagnostic{Code: Code(value.Code), Severity: Severity(value.Severity), Path: fromEnginePath(value.Path), Message: value.Message, Primary: fromEngineLocationPointer(value.Primary), Related: fromEngineLocations(value.Related)}

View File

@@ -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
View 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
}

View File

@@ -0,0 +1,118 @@
# ADR 0007: Add Source-Backed Underlays with Consistent Recursive Precedence
## Status
Proposed draft for review. 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.
This extends [ADR 0001](0001-separate-composition-and-overlay.md) and preserves
its distinction between strict composition and intentional overlay.
## Date
2026-09-29
## Context
Configuration-backed defaults provide lower-priority settings that applications
can override. These sources carry imports, locations, and relative-resource
contexts. Integrating them into ConfigKit's loading lifecycle gives their values
the same presence tracking and inspectable provenance as other configuration
inputs.
The [architecture goals](../policy/architecture.md#success-criteria) prioritize
one coherent, predictable configuration model with consistent rules across
source representations and loading interfaces. Consumers should load once,
receive a complete validated configuration, and inspect its final values' origins.
Reducing integration code is important subject to those principles.
## Decision
### Add underlays to the existing precedence model
Introduce explicitly supplied source-backed underlays between typed defaults
and main configuration. The proposed precedence, from lowest to highest, is:
**Typed application defaults < source underlays < strictly composed main
configuration < selected overlay < explicit CLI overrides.**
Typed defaults remain the Go-value baseline. Underlays supply lower-priority
configuration with source provenance and, where applicable, imports and resource
context. This ordering describes value precedence; typed defaults fill fields
absent from the assembled input under the existing defaulting rules.
Preserve strict composition of the main document and its imports before applying
the resulting configuration over underlays. Independent contributions within an
underlay tier also compose strictly. Explicitly ordered tiers express intentional
precedence. Strict composition reports conflicting contributions within each tier.
Across precedence tiers, reuse the existing recursive mapping-overlay semantics.
Omitted children retain lower-priority input; existing scalar, sequence, null,
and cross-kind replacement semantics remain unchanged. Reusing these operations
keeps the combination rules consistent across the loading model.
### Keep source representation separate from composition semantics
Native `fs.FS` and programmatic inputs join the shared loading model.
Equivalent inputs using the same
operation have the same presence and precedence semantics regardless of medium.
Programmatic values enter directly as labeled configuration sources.
Retain the contributing source's identity and resource context through layering.
A retained underlay value keeps its originating source's resource context.
Consumers remain responsible for opening and interpreting their domain resources
under the supplied filesystem's lifetime and access semantics. Exact
context-access and input APIs remain roadmap decisions.
### Deliver one final configuration and its inspection metadata
Assemble effective input, then run the typed loading lifecycle once for the load.
Preserve explicit zero,
false, empty, and null input versus omission through configuration and metadata
together.
Extend existing provenance and history through underlays, defaults, and recorded
application resolution. The returned result includes the completed combination,
defaulting, and provenance work. Domain resolution and validation
remain application-owned within the recorded lifecycle, consistent with
[ADR 0003](0003-preserve-presence-and-resolution-history.md).
Preserve the existing opt-in schema-version ownership, path protections, and
check timing before precedence
assembly and typed decoding. The roadmap owns how layered input identifies that
owner when version checking is configured.
Favor near-linear processing in input, retained history, and output size through
incremental assembly of load-owned state. This is an engineering objective
subordinate to clarity and semantic consistency. Preserve the root-owned facade and
private implementation boundary established by [ADR 0006](0006-public-facade.md).
## Alternatives considered
- **Leave bundled defaults to consumers.** Avoids extending loading, but makes
consumers reproduce merging, presence tracking, and provenance reconciliation.
It does not meet the goal of obtaining a complete configuration in one load.
- **Offer recursive or atomic combination per layer.** Makes both behaviors
explicit and could serve more consumers, but expands the semantic surface and
its interactions with presence and provenance without a demonstrated need that
outweighs a single consistent rule. A future concrete requirement may justify
reconsideration; this feature does not add the option.
- **Flatten all documents into last-wins layers.** Simplifies ordering but turns
accidental main/import conflicts into overrides, weakening the intentional
distinction established by ADR 0001.
## Consequences
Consumers gain source-backed defaults within the shared loading lifecycle.
Strict contributions and intentional precedence remain distinct, with consistent
combination rules across source media. Presence and provenance remain
part of the same loading lifecycle as the final values.
Higher-priority mappings retain omitted lower-priority children, so application
validation assesses the final combined configuration.
Implementation must integrate source context and underlay history into the shared
engine with attention to assembly and history-retention costs. Current behavior
documentation will change when the feature lands. The roadmap retains ownership
of the remaining API decisions.

View File

@@ -0,0 +1,82 @@
# ADR 0008: Synchronous, Complete-on-Return Configuration Loading
## Status
Proposed draft for review. The
[configuration contract](../config.md#completion-and-resource-lifetime) describes
the implemented loading lifecycle and
[source resource contexts](../config.md#source-resource-contexts).
## Date
2026-09-29
## Context
Applications need a clear point at which configuration is ready for use.
Completing configuration processing and inspection metadata together gives
applications a coherent result whose values and origins agree. A bounded loading
lifecycle also makes resource ownership and startup failures straightforward to
reason about.
ConfigKit's [architecture goals](../policy/architecture.md#success-criteria)
emphasize complete configuration, inspectable provenance, predictable behavior,
and minimal consumer bookkeeping. Applications own their execution lifecycle and
the timing of configuration acquisition.
## Decision
Make loading a synchronous, complete-on-return operation. Within each load,
ConfigKit completes input assembly, defaulting, typed decoding, configured
application resolution, secret lookup, validation, and snapshotting before
returning the final configuration and metadata. Reported failures return
diagnostics and a nil result.
Release all library-owned loading handles before returning on success or failure.
The resulting configuration and metadata are independently usable, caller-owned
data with a retained inspection snapshot. Caller-supplied readers and filesystems
remain under caller ownership and lifetime control.
After the call completes, ConfigKit remains inactive until explicitly invoked
again. Applications normally load once at or near startup and may initiate
additional loads when needed. Each load follows the same lifecycle and source
reuse contracts. Inspection, rendering, and separately requested operations run
in response to explicit application calls.
For the planned resource-context capability, retain source context as passive
information or a borrowed caller-supplied filesystem. Subsequent resource access
acquires its own handles with explicit ownership. This preserves a clear boundary
between configuration completion and application resource use.
These choices give consumers one completion boundary for configuration and
provenance, predictable resource lifetimes, and control over when ConfigKit runs.
Application callbacks remain responsible for their own effects and resources.
## Alternatives considered
- **Background watching or refresh.** Can automate responses to source changes,
but adds scheduling, shutdown, publication, and resource-lifetime concerns to
the library. Explicit calls keep those responsibilities with the application.
- **Lazy completion after return.** Can defer work until particular values are
used, but moves configuration failures into application execution and makes
the readiness of values and metadata harder to establish.
- **Transactional atomic loading.** Could imply simultaneous observation of all
external inputs and rollback of callback effects. Filesystems, environment
variables, and application callbacks do not share a transaction mechanism.
Synchronous completion provides the required usable-result boundary without
promising those external guarantees.
- **Retain loading handles in results.** Can preserve open filesystem access,
but gives configuration results an ongoing resource lifecycle and cleanup
obligation. Passive source context supports later explicit resource access
while keeping loading resources scoped to the load call.
## Consequences
Applications can treat successful return as the point at which the final
configuration and its metadata are ready for use. Startup loading remains simple,
and additional loads have an explicit application-owned trigger.
Configuration inspection operates on retained data. Resource access observes the
backing resource when explicitly requested and has its own failure and ownership
boundary. Implementations of new source kinds and layering features preserve the
same completion boundary, including cleanup on reported load failures.

View File

@@ -0,0 +1,76 @@
# ADR 0009: Resolve Sibling Inheritance After Source Assembly
## Status
Accepted and implemented. The [configuration contract](../config.md#sibling-inheritance)
owns current behavior. The completed implementation plan is preserved in Git
history at `83c59ea`.
## Date
2026-09-29
## Context
Named configuration mappings often share settings while retaining individual
overrides. ConfigKit already distinguishes strict composition of independent
source contributions from recursive overlay between explicit precedence tiers.
Doing another untracked merge in applications would lose omission, source
context, and historical explanation information. A shared structural operation
must preserve the same presence and provenance model as the rest of loading.
Inheritance also interacts with source precedence, nested namespaces, version
ownership, and secrets. Resolving it before source assembly would validate
superseded references and copy values that a later tier should replace. Fully
resolving nested relationships inside a reusable parent would bind them to the
wrong sibling scope when copied into a child.
## Decision
Reserve `extends` as a built-in input key on mappings. A string names one exact
sibling mapping; null cancels the relationship. Complete source acquisition,
strict composition, selected overlay, and CLI precedence first. Then resolve
each effective relationship in the parser-independent document tree, before
typed defaults and decoding. A child recursively overlays its own payload on
its resolved parent using the existing mapping-combination rules. This creates
neither a source tier nor a configurable merge strategy.
Resolve enclosing sibling chains before nested relationships. Reuse memoized
parent templates with unresolved nested instructions; evaluate those instructions
at each destination. Preserve original source applications separately from
structural transfer/control evidence. Effective origins carry concrete
source-to-destination derivation, and inspection projects relevant inheritance
events between source replay and typed reconciliation. Inherited input counts as
explicit presence, while direct replacement retains its own effective origin.
The existing main-input version check remains at its original point. Reject
structural contributions or directive consumption affecting its protected path.
Classify sensitivity at corresponding source and destination paths and retain
historical redaction. Secret references remain unresolved input until the
existing lookup point. Keep all machinery behind the root-owned public facade.
## Alternatives considered
- **Application-owned inheritance after `Load`.** Leaves every consumer to
recreate ordering, presence, provenance, error reporting, and redaction after
ConfigKit has already declared a result complete.
- **Resolve each source before precedence assembly.** Makes superseded
references active and can copy a parent definition before higher-priority
input finishes changing it.
- **Resolve nested relationships in a cached parent subtree.** Shares more
ready-made output but binds nested sibling lookups to the parent's scope,
giving incorrect values at a child destination.
- **Registered collections, configurable keywords, or multiple-parent merge
modes.** Increase public setup and semantic branches without a requirement
that warrants diverging from one structural rule.
## Consequences
Consumers can load a configuration once and inspect direct, inherited,
defaulted, and application-derived values through the existing result surfaces.
`extends` is no longer an ordinary application field, so existing configurations
using that key as data must migrate. Expanding many children can materialize
substantially more output than the input contains; projection and retained-history
budgets bound that work without charging copies as newly acquired sources.
Applications still own domain validation, entry selection, and runtime resource
use.

View 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.

View File

@@ -0,0 +1,70 @@
# ADR 0011: Place Configuration Inputs at Mapping Destinations
## Status
Accepted. The [input-destinations roadmap](../roadmap/input-destinations.md)
owns implementation status and the [implementation plan](../roadmap/implementation.md)
owns sequencing and completion evidence.
## 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.

View File

@@ -12,13 +12,21 @@ ConfigKit exposes opaque sources through these constructors:
| Constructor | Current contract |
| --- | --- |
| `File(path)` | Requires a nonempty path, opens a regular file for each consumption, and closes only the library-owned file handle. Missing, inaccessible, unreadable, directory, and other nonregular inputs fail. |
| `NewFilesystem(label, filesystem).File(name)` | Borrows a caller-owned `fs.FS` under a safe nonempty display label. `name` must be a non-root slash-relative `fs.ValidPath`. Each load reopens the selected regular file; copies of the `Filesystem` preserve namespace identity. Separate constructors create separate namespaces even for the same label or filesystem. |
| `Bytes(label, data)` | Requires a nonempty, non-secret diagnostic label and snapshots bytes during construction. Later caller mutation cannot affect the source. |
| `Reader(label, input)` | Requires a nonempty, non-secret label and non-nil reader. The reader is claimed once across all copies of the source, consumed but never closed, and cannot be reused. |
| `Mapping(label, values)` | Requires a safe nonempty label and a non-nil root `map[string]any`. Snapshots supported built-in values into an owned mapping document at construction; `{}` is valid. Constructor conversion failures become safe deferred diagnostics. |
| `NoSource()` | Selects defaults-only loading, creates no source record, and marks no paths explicit. |
The zero `Source` is invalid. Constructor argument failures are retained inside the
opaque source and become safe structured diagnostics when the source is consumed.
File sources can observe file changes between consumptions; byte sources cannot.
File and filesystem sources can observe changes between consumptions; byte and
mapping sources cannot. Mapping accepts nil, built-in string, boolean, signed or
unsigned integer, finite float, `[]any`, and `map[string]any` values recursively.
Typed nil maps and slices represent explicit null; empty collections remain empty.
Keys are sorted for stable provenance, integer decimal spelling stays exact, and
cycles or unsupported types are rejected without exposing values. Mapping input
uses the same directive and version rules as YAML, not CLI override restrictions.
Each source is limited to 8 MiB. A load using imports or an active overlay is limited
to 64 MiB across all accepted sources, 1,024 accepted sources, 64 active import
@@ -26,6 +34,8 @@ edges, and 1,000,000 input nodes in aggregate. Glob expansion separately permits
1,000,000 visited directory entries, including nonmatches. Reads request at most the
remaining allowance plus one byte so excess data is rejected rather than silently
truncated. Data returned together with a read error is not accepted as partial input.
For mapping sources, key and string bytes use the same accounting as programmatic
overrides, and the converted tree uses the same node and depth bounds.
## Single-source loading
@@ -44,13 +54,14 @@ The current lifecycle is:
```text
validate options → parse the selected source → traverse/compose enabled imports
→ build schema → check the optional application version → apply a selected overlay
→ apply explicit CLI overrides → create and clone defaults → strictly decode
→ apply explicit CLI overrides → expand sibling inheritance
→ create and clone defaults → strictly decode
→ recorded application resolution
→ resolve typed secret references → semantic validation → classify secrets
→ snapshot configuration and metadata
```
`File`, `Bytes`, and `Reader` each contribute the first base source with deterministic
`File`, `Filesystem.File`, `Bytes`, `Reader`, and `Mapping` each contribute the first base source with deterministic
ID `source-1`; enabled imports continue in depth-first order as `source-2` onward.
`NoSource` skips parsing, supplies an absent version to a configured version checker,
contributes no source, and continues through defaults, decode, and projection. A
@@ -66,11 +77,193 @@ disabled.
Every source, options, schema, version, defaults, decode, resolution, or validation
failure returns a nil result and a `*LoadError`. Successful results contain caller-owned `Config`, detached
`Metadata`, a defensive source list, and defensive nonfatal diagnostics. Mutating
`Metadata`, a defensive source list, and defensive nonfatal diagnostics. Each source
record's `Kind` identifies file, filesystem, bytes, reader, mapping, or CLI acquisition
independently of its precedence `Layer`. Host file paths remain canonical;
filesystem paths are slash-relative within their namespace; synthetic inputs have
no path. Mutating
the returned configuration or inspection records cannot alter ConfigKit's private
snapshot. ConfigKit performs no logging. `Result.WriteEffective` exposes only the
frozen redacted projection.
### Completion and resource lifetime
`Load` is synchronous and complete on return. Success means input assembly,
defaulting, typed decoding, configured application resolution, secret lookup,
validation, and configuration/metadata snapshotting have completed. A reported
load failure returns diagnostics and a nil result, as described above.
Before returning on success or failure, ConfigKit closes the filesystem handles
it opened for loading, including files and directory/root handles. Caller-owned
filesystems are borrowed, never closed. The returned
configuration and inspection metadata require no close operation and remain
usable independently of the source files. A supplied `Reader` remains
caller-owned and is never closed by ConfigKit.
ConfigKit performs no background work after returning: it does not watch inputs,
refresh secrets, or mutate published results. Inspection and rendering run only
when explicitly called and use the retained snapshot. Applications normally load
once near startup, but may explicitly load again using sources that satisfy their
documented reuse rules. Additional loads produce independently owned results.
Completion is not a transaction over external state. Files and environment
variables are observed during processing rather than as one simultaneous external
snapshot. ConfigKit does not roll back application callback effects or manage work
or resources created by application callbacks. The explicit secrets-directory
bootstrap remains a separate operation invoked by the application.
## Layered loading
`LoadLayered[T](main, options...)` accepts zero or one `LayeredOptions[T]` value.
Its `main` is an `Input` containing a source, import opt-in, and optional
filesystem context. A zero source is invalid; `Input{Source: NoSource()}` omits
the main document. Each `Underlay` contains inputs that compose strictly as
one tier, including each input's enabled imports. Empty groups contribute
nothing. Underlay groups are listed from lowest to highest priority. Repeating
a file within one group remains an import/repetition error; the same reusable
source may be selected again in another tier.
ConfigKit acquires and composes the main input first, constructs the schema,
and checks the optional application version from the main tier only. Underlay
roots cannot claim that version path (`underlay.version`); imported fragments
retain `import.version` protection. It then acquires and composes underlay
groups in order, recursively combines the groups, and applies the main tier,
one selected overlay, and explicit CLI overrides. Scalar, sequence, and null
definitions replace lower values between tiers; mappings combine recursively.
Syntax and strict-composition errors inside a tier cannot be masked by a
higher-priority tier. Defaults, typed decoding, resolution, secret lookup,
validation, and publication run once on the final assembled input.
An active layered overlay requires `LayeredOptions.OverlayFilesystem` explicitly,
independent of the main source's namespace. Its name and root-relative lookup
follow the selected-overlay rules below; inactive overlays ignore the context.
The same path in a different filesystem namespace is not a repeated file.
Ordinary `Load` continues to infer its selected-overlay context from its source.
Source IDs follow acquisition order—main and its depth-first imports, then each
underlay tier's inputs and depth-first imports, then overlay and CLI—even though
history follows precedence order. Source `Layer` describes precedence while
`Kind` describes acquisition. Underlay records use `LayerUnderlay` and a
one-based `UnderlayIndex` giving their original tier position; all other records
have index zero. Explicit empty groups and `NoSource` consume no source ID.
Byte, source, node, and glob-entry budgets are shared across the load, while
the existing per-source and import-depth bounds still apply.
#### 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, and
each imported application contribution inherits its input's destination,
including transitive imports. 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 the placement for each acquired input source,
including imports. 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. Returned source
records are detached copies.
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.
### Sibling inheritance
Any input mapping may declare `extends: name` to inherit from the mapping named
`name` among its immediate siblings. Names are exact keys, including the empty
string; lookup does not search ancestor or unrelated scopes. The document root
and a sequence-element mapping have no sibling namespace for their own string
relationship, though mappings nested inside them can have one. An `extends`
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.
`extends` is reserved structural input across ordinary and layered loads, every
source kind, imports, selected overlays, and explicit CLI overrides. It is
consumed before typed decoding and need not appear in the application's Go
types. Applications previously using `extends` as an ordinary YAML field must
rename that key and its field. No collection registration or keyword setup is
required.
ConfigKit completes strict composition within tiers and precedence assembly
between tiers before resolving relationships. An omitted `extends` retains a
lower-priority relationship; `extends: null` cancels it while preserving the
child's own settings. Superseded references are historical evidence, not active
relationships to validate. Each child recursively overlays its own settings
over its resolved parent: explicit zero, false, empty string, sequence, and
null values replace inherited values, while mappings combine recursively.
The child retains its key. All effective relationships resolve, including
entries the application may never select for execution. Partial parent
definitions undergo normal typed decoding and application-owned validation,
not a special structural exemption.
Enclosing relationships resolve first. Nested instructions carried by a parent
are then resolved separately at each destination, so a child's nested sibling
override can change an inherited nested relationship. Typed defaults and
application-derived values do not start another inheritance pass. Inherited
input counts as explicit presence for `Metadata.IsExplicit`,
`Resolution.IsExplicit`, and absence-based resolver defaults, even for false,
zero, empty, or null input. `Origin.Inheritance` distinguishes it from a direct
declaration with concrete source, destination, and controlling directive paths.
A direct child replacement has its own effective origin; displaced ancestry
remains inspectable in history. An inherited relative resource keeps the
declaring source's passive `SourceContext`, not the child's directory.
The optional schema-version checker still runs once against the main input at
its original point. Inheritance cannot add, replace, or consume the protected
version path; an unrelated relationship inside an enclosing mapping does not
invalidate an unchanged version. Expansion uses the decoded projection
node/depth limits without charging copied values as acquired sources;
retained evidence uses the existing history-node limit. See the
[consumer example](consumers/README.md#named-sibling-inheritance) for one-load
usage.
### Source resource contexts
`Result.SourceContext(id)` and callback-scoped `Resolution.SourceContext(id)`
return a passive `ResourceContext` for a source with filesystem context, or
false for unknown and contextless IDs. The result query is nil-safe; the
resolution query follows the callback's lifetime guard. Queries perform no
filesystem access. Obtain IDs from exact origin locations rather than guessing
one context for a composite origin or an application-derived value.
`ResourceContext.FS` is rooted at the source's configured root, and `BaseDir`
is its slash-relative directory inside that root. This includes imported
sources, selected overlays, host files even without enabled imports, native
files, and byte, reader, or mapping inputs with an explicit context. CLI and
contextless synthetic inputs have no resource context. To open a relative
resource, join `BaseDir` with the application resource name using slash-path
operations and open that path through `FS`.
For a resource-valued field, query `result.Metadata().Lookup(fieldPath)`, inspect
its origin locations, choose a source ID only when the application's policy can
identify the intended contributor, then call `result.SourceContext(id)`. The
context belongs to that source even when the field survived from a lower-priority
underlay. A composite origin has no automatically selected resource base.
Native contexts borrow the caller's filesystem within the configured root.
Host contexts retain a canonical root path, not an open root handle; each
resource `Open` checks containment and opens a fresh handle that the returned
file's `Close` releases. Applications own and close the files they open.
Resources are application data, not configuration input, and do not consume
configuration byte or node budgets. The loaded configuration and metadata stay
usable if source files later change or disappear. Later resource operations
observe the backing filesystem then and may fail if files move, disappear, or
a caller-owned filesystem closes. This is not a snapshot or universal sandbox.
### Application schema versions
`VersionRule` declares a semantic path and non-nil checker. Before defaults or typed
@@ -108,8 +301,9 @@ successful result. Callback panics remain programming failures.
`Result.Metadata` describes every final canonical path. Queries retain explicit
key/index distinctions using actual effective and historical container shapes;
only parsed numeric tokens are resolved contextually. A descendant that never
existed is not historical merely because an atomic ancestor was replaced. Exact surviving input paths
are explicit and have a base or overlay origin; omitted paths have a default origin.
existed is not historical merely because an atomic ancestor was replaced. Exact
surviving direct or inherited input paths are explicit and retain their source
layer and derivation; omitted paths have a default origin.
An explicit parent mapping does not make omitted children explicit. Null state and
available source coordinates are retained. `History` records the Go-zero or application
default baseline followed by exact base or overlay definitions, including
@@ -124,8 +318,9 @@ domain ownership rules remain application responsibilities. The method's Go
documentation in [metadata.go](../metadata.go) defines enumeration and path-ordering
details.
History is chronological: a path can have a synthetic default record, its exact base
definition, an overlay replacement, and a recorded resolution operation. A descendant
History is chronological: a path can have a synthetic default record, its exact
base definition, an overlay replacement, structural inheritance, and a recorded
resolution operation. A descendant
query uses the ancestor's path for a whole-container or whole-sequence replacement;
the descendant can therefore retain history after `Lookup` stops reporting it as
effective. Equal-valued input is still an explicit base definition, while a derived
@@ -141,6 +336,15 @@ operation. Reasons and dependency paths retain first occurrence order with dupli
removed. Source records remain ordered as selected source, depth-first imports, the
selected overlay, then synthetic CLI records in supplied order.
Inheritance adds no acquired source or precedence layer. `Origin.Inheritance`
lists concrete transfers from contributor paths through destinations, with
each step's controlling `extends` path and location. `History` retains source
applications at their original paths and projects parent contributions, child
replacements, and consumed controls at destination paths after source assembly
and before typed decoding. `HistoricalPaths` includes consumed directives and
displaced inherited descendants. A control can have history even when `Lookup`
reports it absent. Queries return detached records.
### Secret classification and effective rendering
Secret classification combines `configkit:"secret"` field tags with the optional
@@ -156,6 +360,11 @@ Propagation reaches a fixed point, so record/map iteration order does not affect
result and dependency cycles terminate. A dependency on a classified descendant or
on a container that contains one is treated conservatively as sensitive. Applications
must classify secret-producing transformations whose dependencies are not recorded.
Inherited value and control paths link source and destination classifications
through chains and nested copies; unrelated sibling fields are not classified
merely because another field is secret. Displaced historical evidence remains
redacted. Secret references are copied only as unresolved names; credential
lookup stays after decoding and application resolution.
`Result.WriteEffective` writes the frozen resolved snapshot as deterministic redacted
YAML. Mappings sort by decoded key string, sequences retain order, indentation is two
@@ -190,7 +399,10 @@ parser values. `Present=false` with empty YAML means absence or deletion; explic
null is present and renders as `null`. The history starts with the canonical typed
application-default or Go-zero baseline, then includes only relevant base sources in
traversal order, the selected overlay, supplied CLI records, and audited net
resolution operations. Overlapping parent/child resolution records are rendered
resolution operations. Structural inheritance events appear after source replay
and before typed reconciliation, including consumed or canceled `extends` input
and equal-valued child replacements. Overlapping parent/child resolution records
are rendered
as one net subtree event with combined reasons and dependencies, rather than
pretending that the recorded assignments ran in lexical order. Explicit equal
writes remain visible. When input replay
@@ -238,6 +450,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
@@ -442,8 +694,8 @@ input document.
`[a-zA-Z0-9_-]+`; they are not paths. The empty name and exact lowercase name
`default` select no overlay, and `overlays/default.yml` is never loaded implicitly.
Every other name resolves only to `<configuration-root>/overlays/<name>.yml`, with
no extension fallback, case normalization, inheritance, or overlay stack. A selected
missing overlay is an error.
no extension fallback, case normalization, inheritance between overlay files,
or overlay stack. A selected missing overlay is an error.
The overlay is a separately policy-validated YAML mapping. It is loaded after the
base import graph is composed and its schema version is checked, but before defaults
@@ -557,18 +809,25 @@ walk failures, matched directories, invalid documents, escapes, and repeated tar
remain errors. Final candidates are bounded by the remaining source limit before
their contents are accumulated.
The configuration root defaults to a selected file's canonical directory. A file
source may supply `FilesystemContext.Root` to widen that root but may not supply
`BaseDir`. Whenever byte and reader sources supply filesystem context, they must
provide both `BaseDir` and `Root` as absolute existing directories; their diagnostic
labels, including URI-looking labels, never imply filesystem paths. The base and
selected file must be inside the canonical root. Nonregular targets are rejected before opening to avoid blocking on FIFOs;
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
may not supply `BaseDir` or a native `FS`. Byte, reader, and mapping inputs with host context
must provide both `BaseDir` and `Root` as absolute existing directories.
For `Filesystem.File`, the default root is `.` in its namespace and the base is
the selected file's directory. An explicit context must use the same `Filesystem`
namespace and may set `Root` to contain the file and its base; it may not supply
`BaseDir`. Byte, reader, and mapping inputs with native context provide both `BaseDir` and
`Root` as slash-relative `fs.ValidPath` directories in `FS` (`.` means its root),
with the base inside the root. Context paths are checked even when imports and
overlays are inactive. Diagnostic labels, including URI-looking labels, never
imply filesystem paths. Nonregular targets are rejected before opening to avoid blocking on FIFOs;
post-open regular-file checks remain in place under the stable-input assumption.
ConfigKit canonicalizes symlinks,
checks containment with relative paths, and opens the canonical root-relative target
through a library-owned root handle. Contained relative or absolute symlink targets
are accepted; escapes are rejected. Canonical paths define identity, so hard links at
distinct paths remain distinct sources.
distinct paths remain distinct sources. Native filesystems use slash-path operations
and their own symlink behavior; 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

View File

@@ -7,7 +7,7 @@ validation, schema-version policy, CLI parsing, runtime matching and conversion,
resource checks, remote acquisition, and service initialization.
The maintained [`examples/consumers`](../../examples/consumers) package demonstrates
four sanitized shapes using only ConfigKit and the Go standard library:
sanitized shapes using only ConfigKit and the Go standard library:
| Shape | Demonstrated fit | Application code deliberately retained |
| --- | --- | --- |
@@ -15,6 +15,11 @@ four sanitized shapes using only ConfigKit and the Go standard library:
| Notarius-like | Canonical object bindings, a string-backed schema version accepting string or integer YAML, strict dynamic options, and `int64` dynamic integers | Catalog selection, allowed option keys, range-checked conversion to runtime `int` |
| WeatherReporter-like | Defaults-only loading, report maps, canonical module objects, typed option unions, explicit CLI records, and typed secret startup | Report/module compatibility, rejection of explicitly empty domain lists, CLI parsing, and startup ordering |
| Seriatim-like | Independent strict ordered speaker and autocorrect rule files | First-match and ordered replacement algorithms; no central application configuration file |
| Bundled file | Simple native `fs.FS` source through ordinary `Load` | Selection and lifetime of the application-owned embedded filesystem |
| Layered catalog | Embedded defaults with an import, a mapping underlay, an independent host main file, explicit false/zero input, and source-relative resource access | Domain validation and opening/interpreting the template resource |
| Mounted collections | Literal input destinations, peer prompt/profile fragments, local inheritance, imported collection metadata, 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 |
The YAML under [`testdata`](../../examples/consumers/testdata) is loaded by the
executable examples and ordinary tests. It contains no private application data,
@@ -48,6 +53,93 @@ 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.
## 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
[`presets.yml`](../../examples/consumers/testdata/inheritance/presets.yml) in one
call. `custom` inherits `enabled: false` from `base`, while its nested
`settings.primary` changes the inherited `settings.secondary` result at the
custom destination. The application struct has no `extends` field; ConfigKit
consumes the relationship before strict typed decoding.
The [executable example](../../examples/consumers/consumers_test.go) reads
`Metadata.IsExplicit` for the inherited false value, then follows
`Origin.Inheritance` back to the parent path while `Origin.Locations` identifies
the declaring source. Applications still choose which preset to execute and
validate domain meaning. The [configuration contract](../config.md#sibling-inheritance)
owns exact lookup, precedence, cancellation, and migration rules.
## Native filesystem and layered input
[`LoadBundled`](../../examples/consumers/layered.go) keeps the simple `Load` shape:
it selects a file in an application-owned embedded filesystem and needs no
underlay setup. [`LoadCatalog`](../../examples/consumers/layered.go) uses
`LoadLayered` with an imported embedded-defaults group, a separate
`Mapping` group, and a host main file supplied by its caller. The groups
compose strictly within themselves and recursively across precedence boundaries;
the main file's explicit `false` and `0` replace lower values without being
mistaken for omissions. Its validator owns allowed modes and resource-name policy.
[`ReadWelcome`](../../examples/consumers/layered.go) follows the effective field's
origin location to a source ID, queries `Result.SourceContext`, and opens the
resource relative to that context's `BaseDir`. It does not assume the main file
owns a retained underlay value. A composite origin needs an application choice;
ConfigKit does not guess a directory. The context query is passive, while later
resource reads observe the caller-owned filesystem or a freshly opened host root
and may fail independently of the completed configuration load. The
[configuration contract](../config.md#source-resource-contexts) owns exact
lifetime and precedence rules.
## Input destinations and mounted collections
Main and underlay inputs use the same destination field; imported application
contributions inherit the declaring input's destination. A destination is an
application mapping path, 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 with a source-root import, separate prompt and
profile peers, and a higher-priority input that 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.
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-
@@ -126,6 +218,10 @@ focused behavioral owner:
| Source/import/selected-overlay pipeline rules and artifact defaults | Narratio-shaped executable example and fixture |
| Canonical module objects and dynamic option handling | WeatherReporter- and Notarius-shaped examples and migration tests |
| `LoadSecretsDir`, bootstrap reporting, typed lookup, CLI explanation | `Example_weatherStartup` |
| 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

View File

@@ -39,6 +39,7 @@ 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 |
| 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 |
@@ -54,6 +55,8 @@ Accepted decisions:
- [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)
For cross-cutting changes, follow every applicable row. Completed implementation
and remediation plans are preserved in Git history at `cb5b435`; use current
@@ -64,8 +67,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
@@ -80,14 +84,40 @@ and current facade ownership belongs to the [component overview](internal/overvi
## Baseline validation
The completed filesystem-source and underlay roadmap, original feature request,
implementation plan, and resolved remediation record are preserved in Git history
at `b604aa1` under `docs/roadmap/`. Current behavior belongs to the
[configuration contract](config.md#layered-loading). Built-in sibling
inheritance is now part of the [configuration contract](config.md#sibling-inheritance).
The completed request, roadmap, plan, and resolved review findings are preserved
in Git history at `83c59ea` under `docs/roadmap/`.
The completed overlay rename plan and release-workflow simplification record are
preserved in Git history at `73ce6e9` under `docs/roadmap/`. Current overlay
behavior belongs to the [configuration contract](config.md#selected-overlays),
and release operations belong to the [release procedure](release.md).
Install Go 1.25.0 or a supported patched/newer Go toolchain. Acquire declared
dependencies explicitly before entering an offline development or validation
environment:
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
[deferred scope](roadmap/deferred.md#deferred-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.
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
@@ -117,11 +147,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
@@ -133,15 +162,23 @@ code changes. Use the component overview to find the corresponding implementatio
| --- | --- | --- |
| YAML syntax, diagnostics, parser limits | [Adapter tests](../internal/yaml/parse_test.go), [source translation](../internal/engine/parse_test.go) | `go test ./internal/yaml` and `go test ./internal/engine -run '^TestParseSource'` |
| 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'` |
| 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 destinations, mounted sources, and source ownership | [Destination engine tests](../internal/engine/destination_test.go), [tier and import tests](../internal/engine/destination_tiers_test.go), [integration and provenance tests](../internal/engine/destination_integration_test.go), [public facade tests](../facade_destination_test.go), and [consumer examples](../examples/consumers/destinations_test.go) | `go test ./internal/engine -run 'Destination|Mounted'`, `go test . -run 'Destination|FacadeMounted'`, and `go test ./examples/consumers -run 'CollectionBundle|CollectionPeer'` |
| 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), [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 and layered examples](../examples/consumers/layered_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

View File

@@ -7,23 +7,34 @@ private state; public signatures and fields do not expose engine types.
Implemented component owners:
- `internal/engine` owns source acquisition, composition, schema inspection,
decoding, lifecycle coordination, callback auditing, provenance/history,
secret resolution and bootstrap, and safe inspection rendering. It never
imports the root package.
- Root `Source`, `Path`, `Secret`, records, lifecycle callbacks, results, and
bootstrap reports are root-defined wrappers or records. Callback wrappers pass
only live engine context during a callback; `Result.Config` is detached and its
private inspection remains frozen independently.
- `internal/engine` owns host and native-filesystem source acquisition,
grouped strict composition, recursive precedence assembly, per-input mapping
placement within that assembly, and destination-local sibling inheritance
before typed decoding. It retains source placement alongside physical source
identity and owns schema
inspection, lifecycle coordination, callback auditing,
provenance/history, passive source-context retention, secret resolution and
bootstrap, safe inspection rendering, and effective-value comparison over
frozen inspection snapshots. It never imports the root package.
- Root `Source`, `Filesystem`, `Input`, `Underlay`, `Path`, `Secret`, records,
lifecycle callbacks, results, and bootstrap reports are root-defined wrappers
or records. Callback wrappers pass only live engine context during a callback;
`Result.Config` is detached and its private inspection remains frozen
independently.
- Root conversion files explicitly cross the path, record, callback, secret, and
error boundaries. There are no public aliases, anonymous engine embeddings, or
public engine bridge methods.
- Root `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, decoding, callback, provenance,
secret, history, rendering, and bootstrap behavior. Root tests own public API
compatibility, facade integration, documentation links, and dependency/public
boundary checks. Consumer examples remain under `examples/consumers`.
- Engine mechanism tests own source, composition, inheritance, decoding,
callback, provenance,
source-context, secret, history, rendering, and bootstrap behavior. Root tests
own public API compatibility, facade integration, documentation links, and
dependency/public boundary checks. Consumer examples remain under
`examples/consumers`.
Dependency direction is root to engine to document/YAML support. The root may
import only `internal/engine` among project internal production packages; engine
@@ -32,8 +43,14 @@ 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) and
[effective configuration diffs](../config.md#effective-configuration-diffs).
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.
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.

View File

@@ -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`.

View File

@@ -2,7 +2,7 @@
**Base Policy Version:** 1.3
**Last Updated**: 2026-09-28
**Last Updated**: 2026-09-29
**Upstream Source:** [Standard Repository Policies](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/docs/policy/architecture.md)
@@ -34,6 +34,23 @@ Do not promote incidental implementation choices into architectural invariants.
## Success Criteria
ConfigKit prioritizes a coherent configuration model: consistency and
predictability take precedence over flexibility for accommodating individual
downstream conventions. Consumers should learn one set of rules, not different
combination or presence semantics depending on the source format or consuming
application. Distinct operations such as strict composition and intentional
overlay express distinct purposes within that model.
Consumers should be able to load once and receive a complete, validated
configuration with inspectable provenance for its final values, including
defaults and recorded application-derived values. Configuration and metadata
together must distinguish explicit zero, false, empty, and null input from
omission; ordinary Go values alone need not encode those distinctions.
Application-owned resolution belongs within the recorded loading lifecycle, so
consumers need no subsequent generic merging, defaulting, or provenance
reconstruction. This does not transfer domain execution or runtime resource
management into ConfigKit.
1. **Reduce application infrastructure:** Can applications delete substantial
configuration infrastructure while retaining clear ownership of their domain
rules?
@@ -41,8 +58,11 @@ Do not promote incidental implementation choices into architectural invariants.
configuration model, with only the flexibility needed to express genuine
application requirements?
Both criteria must hold. ConfigKit should not preserve divergent consumer
conventions merely for compatibility, nor impose restrictions that prevent useful
Both criteria must hold. Minimize consumer loading and processing code subject
to the consistency, presence, and provenance principles above, rather than
trading those principles for consumer-specific shortcuts. ConfigKit should not
preserve divergent consumer conventions merely for compatibility, nor impose
restrictions that prevent useful
domain information from being expressed. Prefer a canonical representation using
existing primitives before adding another mechanism. Extend the model when a
concrete requirement cannot be accommodated cleanly, not simply because a
@@ -79,6 +99,13 @@ 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 and retains that 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, and [ADR 0011](../adr/0011-input-destinations.md) records the
accepted decision.
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`.
@@ -88,6 +115,22 @@ must never run implicitly as part of configuration loading.
Remote data can enter through application-supplied input without introducing a
network provider into the library.
## Loading Lifecycle
Configuration loading is a synchronous, complete-on-return operation. A successful
load provides the final configuration and inspection metadata after the configured
processing and validation phases complete. A failed load reports diagnostics and
returns no usable partial result. The normal application pattern is one load near
startup; applications may explicitly initiate additional loads.
ConfigKit releases its library-owned loading handles before returning and remains
inactive until the application explicitly invokes it again. It performs no
background watching, refresh, or mutation of published results. Caller-owned
resources and application callback effects remain the caller's responsibility.
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).
## Public API And Dependency Direction
Keep one small, coherent public surface for the supported lifecycle. Simple
@@ -116,18 +159,41 @@ framework on this library.
## Configuration Integrity
Composition and intentional overlay are distinct operations. Composition must
not silently become last-wins override through file ordering. The decision and
alternatives 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, 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).
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.
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.
ConfigKit must define supported YAML semantics explicitly rather than inherit
parser defaults. Processing order and diagnostics must be deterministic for the
same inputs and options. Invalid input must not produce a successful usable
configuration. Do not run application hooks on invalid typed configuration.
User-controlled precedence is application defaults, composed base, selected
overlay, then explicitly supplied CLI overrides. Applications own flag parsing;
ConfigKit owns overlay semantics and provenance. Domain resolution follows these
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.
Preserve node-based composition until the effective input is ready for typed
@@ -142,6 +208,13 @@ 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.
## Ownership And Confidentiality
Returned configuration and metadata must not share mutable state across loads.

View File

@@ -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,

View File

@@ -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"

53
docs/releases/v0.5.0.md Normal file
View File

@@ -0,0 +1,53 @@
# ConfigKit v0.5.0
This minor release adds source-backed layered configuration and built-in sibling
inheritance, so applications can load one complete configuration while retaining
presence, source context, and explainable derivation.
## Summary
- `LoadLayered` combines ordered underlay groups beneath the main input, selected
overlay, and explicit CLI overrides. Inputs within a group still compose
strictly; precedence tiers combine mappings recursively. The main input
remains the owner of the optional schema-version check.
- `NewFilesystem(...).File(...)` accepts caller-owned `fs.FS` sources, and
`Mapping(...)` snapshots supported built-in values as a labeled source.
`Result.SourceContext` and `Resolution.SourceContext` expose passive resource
contexts for values from file-backed sources.
- `extends` now resolves a mapping against one named sibling after source
precedence and before typed decoding. Inherited values count as explicit
input. Origins, history, explanations, and redacted rendering retain the
source and the structural path by which a value reached its destination.
Review corrections preserve immutable nested-history evidence and bounded
expansion work.
See the [configuration contract](../config.md#layered-loading),
[inheritance rules](../config.md#sibling-inheritance), and
[consumer examples](../consumers/README.md) for current behavior.
## Compatibility and upgrade
This is an intentional pre-v1 incompatible minor release for configurations
that use `extends` as ordinary data. That key is now reserved wherever an input
mapping occurs; it is consumed before decoding. Rename any such YAML key and
corresponding Go field, then rebuild. No collection registration or opt-in flag
is available. Configurations without `extends` retain their existing meaning.
Applications adopting the new features may move source-backed defaults into
explicit `Underlay` groups, use `SourceContext` for relative resources, or
replace application-owned sibling merging with one `extends` relationship per
mapping. Adoption is optional. The maintained
[named-preset example](../consumers/README.md#named-sibling-inheritance)
shows inherited `false` and destination-local nested resolution.
Select the new module version:
```sh
go get gitea.maximumdirect.net/eric/ConfigKit@v0.5.0
```
The minimum Go line remains 1.25. Linux amd64 and arm64 remain the
release-validated targets; macOS remains best-effort. The YAML dependency is
unchanged at `go.yaml.in/yaml/v4 v4.0.0-rc.6`.
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.5.0/COPYRIGHT) | [LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.5.0/LICENSE) | [ATTRIBUTION](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/v0.5.0/docs/oss/attribution.md)

63
docs/releases/v0.6.0.md Normal file
View 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 [deferred work](../roadmap/deferred.md).
## 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
View 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
View 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)

View File

@@ -11,18 +11,47 @@ General environment-variable configuration overlays are not supported. Typed sec
references and explicit directory bootstrap are the environment convention. Literal
credential configuration and implicit secret-directory discovery are unsupported.
The initial release deliberately excludes:
The following remain deliberately excluded:
- automatic `conf.d` discovery;
- remote imports, arbitrary providers, and additional configuration formats;
- custom merge strategies, sequence merging, reset operators, and overlay inheritance;
- 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.
- configuration 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.
Effective configuration diffs are implemented over frozen successful-load
inspections; their current comparison and disclosure contract is in the
[configuration contract](../config.md#effective-configuration-diffs), with
consumer workflows in the [adoption guide](../consumers/README.md#effective-configuration-diffs).
Fingerprints remain deferred.
Built-in sibling inheritance using `extends` is implemented as described in the
[configuration contract](../config.md#sibling-inheritance). It is distinct from
inheritance between selected overlay files, which remains excluded above.
## Deferred performance experiments
The completed performance audit left these internal optimization candidates for
separate investigation:
- branch-local explanation replay instead of the current whole-tree replay;
- redesigning the private metadata-copy handoff while preserving detached public
records;
- caching schema descriptors across loads; and
- indexing complex contributor-trace membership while preserving its equality
rules and compact linked traces.
These are not committed implementation work. Reconsider them with evidence that
the benefit justifies the ownership, retained-memory, or equivalence risks. The
completed audit and implementation evidence are preserved in Git history at
`8e91a2b` under `docs/roadmap/`.

View File

@@ -0,0 +1,580 @@
# Implement configuration input destinations
Status: complete. Stages 1–12 are implemented and audited. The
[feature roadmap](input-destinations.md) owns the approved behavior and rationale; the
[consumer request](promptkit-feature-request.md#public-acceptance-scenarios)
supplies the public acceptance scenarios. This document owns execution and
completion evidence. Planning baseline: `6353598`.
## Working instructions
- Start each stage with `AGENTS.md`, [development.md](../development.md), the
applicable policies, and working-tree inspection. Use graph-first code
discovery. Preserve unrelated changes and previously completed stages.
- Implement only that stage and its supporting tests/documentation. Mark it
complete here with actual commands and evidence, then commit the nonempty
validated unit with a short plain-English message. Subsequent stages remain
planned. A stage is not complete solely because its narrow tests pass.
- Keep early mechanics private until the complete engine behavior is ready.
Root destination fields land in Stage 10. Ordinary root loading must remain
working throughout. Do not publish a partially supported public placement API.
- Follow the existing engine/document/YAML boundaries. Reuse strict composition,
recursive overlay, sibling inheritance, paths, history, and sensitivity.
No dependencies, toolchain changes, alternate mergers, mount registry,
per-fragment lifecycle, import-destination syntax, resource discovery, or new
release gates are needed. These are scope boundaries, not new prohibitions
on separately approved future work.
- Update current-behavior documentation when public behavior lands; keep future
claims here or in the roadmap until then. Add the ADR in Stage 1 under the
documentation policy's accepted-decision exception.
- Use focused offline tests while iterating; run `./scripts/check.sh` before
committing production changes. Documentation-only stages need whitespace and
local-link checks. Stage 12 adds full ownership/race validation. Tests protect
observable behavior, not wrapper allocation or helper-call counts. Reuse
coverage rather than repeat an entire loading matrix at every layer.
- No benchmark campaign or public complexity guarantee is required. Prefer
straightforward bounded walks and wrapping each acquired contribution once;
avoid rescanning all preceding contributions for every new input.
- Do not push, tag, publish, update downstream dependencies, or retire unfinished
records. Release compatibility review is separate from publication.
## Resolved implementation decisions
1. Add root-owned `Input.Destination Path` and `SourceInfo.Destination Path`,
mirrored privately in engine input/source records. Extend `loadInput` and
explicit root/engine conversion and cloning. No new loading entry point,
public wrapper type, option, or origin field is needed. Ordinary `Load`
continues to supply root placement.
2. The zero path means root. Validate `Path.valid()` and reject every explicit
index segment, including a negative index. Normalize parsed tokens to literal
mapping-key segments: `/0` means the key `0`, not sequence element zero.
Empty, dot, dot-dot, slash-containing, tilde-containing, and numeric keys keep
existing semantic-path meaning. Do not split dots, interpret filesystem
traversal, enforce identifier grammar, or add namespace uniqueness.
3. Reject destination segments equal to `configkit` or `extends`: placement
would introduce a reserved mapping key with a mapping value. Substrings such
as `myextends` are ordinary keys. This preserves the approved structural
rules rather than adding an application naming policy. Source-root import
directives are still processed before placement.
4. Validate destination shape/reservations for all declared inputs during
grouped option preflight, before reading any source. Reuse
`CodeOptionsInvalid` for invalid destination declarations; include the
offending semantic path when representable, with no fabricated physical
location. An invalid source still follows existing source-option validation.
A valid destination on `NoSource` contributes no nodes, source record, or
budget charges. No extra error type or exact message contract is introduced.
5. Separate acquired source syntax from application placement. Retain original
parsed source metadata for filesystem/directive handling. Strip source-root
directives, then wrap each application body at the normalized destination
before fragment version checks and strict composition. Imports inherit the
same destination transitively. Do not wrap both a source application and its
recomposed result; every retained application must already use effective
coordinates. Content roots remain mappings under existing source contracts.
6. Wrapping a body at `Keys("a", "b")` creates `{a: {b: body}}` as private
document mappings. Actual body nodes and entries keep their physical source
coordinates. Introduced mappings/entries use the contributing document's root
location as a documented source fallback, not purported authored coordinates.
They have the same contributing source ID and create no synthetic source.
Retained history trees remain immutable and separate from assembly mutation.
7. A present empty body creates explicit destination mappings, including an
import-only source whose application body is empty. It still cannot clear
lower mappings. A missing optional import and `NoSource` remain absent. Empty
source text remains the existing invalid document, not an empty mapping.
8. `VersionRule.Path` stays an effective application path. Check main once after
its own imports compose and before underlays/overlay/CLI, at the existing
phase. Imported and underlay claims are checked against placed application
content, including scalar/null/sequence ancestors replacing a protected path.
A mapping ancestor with no protected descendant is not a version claim.
Mounted domain `version` fields are ordinary data. Existing inheritance,
selected-overlay, CLI protection and exact-path numeric conversion remain;
no nested blanket conversion or additional fragment checker is introduced.
9. Placement does not alter physical source identity, filesystem namespace,
source IDs, acquisition order, imported-from locations, or resource contexts.
Repeated physical sources in one strict group still fail even at different
destinations. The same physical source in separate tiers retains the existing
independent source applications/records and each record's own destination.
Overlay and CLI source records have root `Destination`; they are not mounted
merely because main is mounted. Active layered overlays retain their explicit
independent filesystem selection and application-root meaning.
10. Value-level paths use effective coordinates. Keep genuine source-local
import-directive diagnostics and parse/read failures at their original
locations; do not invent a logical child path when parsing cannot identify
one. Prefix existing application-relative diagnostic paths exactly once
where available. Preserve structured codes, related physical locations,
deterministic ordering, and value-free messages.
11. Use current depth/node limits, not a new destination cap. With root depth
zero, each destination segment adds one level and, under `countInputNodes`,
one mapping node plus one key. Retain current raw input accounting (including
directives); charge only the additional `2 * segmentCount` synthetic nodes
per acquired body, once, to the shared input budget. Placement adds no bytes,
acquired sources, import nesting, or glob visits. Validate placed application
depth and per-document node bounds before composition; share accounting
across inputs and imports. Precheck wrapper depth/count before allocating
wrappers. Use existing source/import limit diagnostics in their acquisition
contexts; retain projection, expansion, and history bounds independently.
Root placement adds no charge or new rejection. NoSource adds none either.
12. Cloned source records include independently owned destination paths at every
existing publication boundary: results, load errors, metadata/inspection,
callback input information, and root conversion. Passive resource contexts
remain keyed by the real source identity; inspection must work without an
open configuration handle after load. This does not extend caller-owned
filesystem or callback resource lifetimes.
## Stage 1 — Record the decision and capture the baseline
Status: complete. Scope: decision record and evidence, no feature code.
1. Read the roadmap, request, relevant policies, and ADRs 0001, 0003, 0006,
0008, and 0009. Inspect the owners identified in the roadmap and the function
map below. Confirm the baseline and worktree before starting.
2. Run `./scripts/check.sh`; record the commit, Go version, and results here.
Locate existing coverage for root loading, strict underlays, version ownership,
repeated sources, history, resource contexts, and detached records.
3. Write `docs/adr/0011-input-destinations.md` after confirming the number is
unused. Record the approved input-placement decision as Accepted, with the
roadmap owning implementation status. Follow the repository ADR format.
Focus on ConfigKit, put rejected options only in Alternatives Considered,
and avoid negative parallelism elsewhere. No PromptKit-specific semantics.
4. Record the compatibility assessment under the release policy: adding fields
preserves zero-value/keyed-literal use, but callers using unkeyed literals
for `Input` or `SourceInfo` must migrate to keyed literals. Treat the feature
as minor-release work; select a version and write its note only during
separately authorized release preparation.
Validated: `./scripts/check.sh` (go1.26.8 linux/amd64) passed on candidate
`d3d468a8b47d780787fca329ff65b107d98ca59f`; this includes formatting,
whitespace, local documentation links, tests, vet, and build. The planning
baseline `6353598bd6cce2eb78a5fe990e144cc0cadf1280` is present in history. The
worktree was clean before this stage.
Coverage located for later destination work:
- Root and equivalent source loading: `TestEquivalentSourceFormsPreserveInputSemantics`
(`facade_equivalence_test.go`) and
`TestFacadeLayeredZeroUnderlaysMatchesOrdinaryLoad` (`facade_layered_test.go`).
- Strict underlays and group budgets: `TestGroupedLoadStrictConflictsRepetitionAndBudgets`
and `TestGroupedLoadPrecedenceSourceOrderAndSingleLifecycle`
(`internal/engine/load_request_test.go`).
- Version ownership and imported claims: `TestLoadRejectsImportConflictsAndFragmentVersions`
(`internal/engine/imports_test.go`) and version-path coverage in
`internal/engine/load_test.go`.
- Repeated physical sources: `TestRepeatedImportsAcrossTiersAndReaderConsumption`
(`facade_equivalence_test.go`).
- History and source contexts: `TestUnderlayHistoryRetainsLaterMappingContributors`
(`facade_underlay_origins_test.go`) and
`TestFacadeSourceContextsFollowOriginsWithoutQueryIO`
(`facade_resource_context_test.go`).
- Detached records: `TestLoadMetadataReflectsDefaultsInputAndDetachedRecords`
(`internal/engine/load_test.go`) and
`TestMetadataQueriesReturnDetachedRecords` (`internal/engine/metadata_test.go`).
The decision is recorded in [ADR 0011](../adr/0011-input-destinations.md).
Compatibility assessment: adding the two public fields breaks callers using
unkeyed literals for `Input` or `SourceInfo`; keyed and zero-value use remains
compatible. The release policy permits the intentional pre-1.0 contract change
in a minor release when the release note states impact and migration. Version
selection and release-note preparation remain out of scope until separately
authorized. No feature code or current behavior contract changed.
## Stage 2 — Add private destination normalization and wrapping
Status: complete. Scope: pure private mechanics with bounded unit tests.
1. Add a small engine helper file for normalized mapping-only destinations and
constructing placed document bodies. Follow decisions 2–7; keep helpers
private and avoid introducing a second path abstraction.
2. Validate invalid paths, explicit indices, and exact reserved tokens. Normalize
parsed numeric tokens into keys, preserving literal text and pointer escaping.
3. Build wrappers without mutating the supplied tree. Preserve body locations;
give introduced entries/mappings the source-root fallback. Implement depth and
node measurement/prechecks usable with existing limits, including the extra
synthetic-node count; aggregate charging is wired in Stage 7.
4. Cover root identity/equivalence, nested empty mapping, literal/escaped/numeric
keys, invalid index/path, exact versus substring reserved keys, owned paths,
unchanged body coordinates, and bounded wrapper checks. Do not count helper
invocations or fix an allocation strategy in tests.
Validated with `go test ./internal/engine -run 'Destination|PlaceDocument'`
and `./scripts/check.sh` (go1.26.8 linux/amd64); both passed. The private
helpers normalize mapping-only destinations, preserve authored nodes and source
coordinates, and check per-document depth/node bounds before allocating wrappers.
No loader accepts a new public field yet.
## Stage 3 — Integrate private non-import input placement
Status: complete. Scope: engine request plumbing and ordinary acquired bodies.
1. Add destination fields to engine `Input`, `loadInput`, and `SourceInfo`.
Preserve ownership through engine `LoadLayered` request construction,
`cloneSourceInfo`, and source-record copies. Keep the root facade unchanged.
2. Extend grouped preflight to validate/normalize destinations before source
reads. Use the existing diagnostic accumulator and options-invalid category.
Ordinary `Load` supplies root placement through its existing request adapter.
3. Thread placement into `assembleBaseInputTracked` without confusing raw
`parsedSource` with effective `assembledInput.document`. Preserve the root
path through existing wrappers/callers. For imports-disabled acquisition,
validate disabled directives on raw content, place the body, and retain the
destination on the acquired source record. NoSource still short-circuits.
4. Use engine loads to cover mounted main and underlay bodies, omitted versus
explicit root destinations, empty mapping versus NoSource, and preflight
rejection without consuming a test-owned reader. Reuse current source-kind
acquisition tests; full source equivalence is verified publicly in Stage 10.
Validated with `go test ./internal/engine -run 'Destination|PlaceDocument|Grouped'`
and `./scripts/check.sh` (go1.26.8 linux/amd64); both passed. Private non-import
placement reaches assembly, with root behavior unchanged. Imports are the next
private integration step.
## Stage 4 — Carry placement through import acquisition
Status: complete. Scope: source-local directives and placed imported applications.
1. Thread a normalized destination through `loadImportedDocumentsTracked`,
`loadImportedWithAccessTracked`, and `importTraversal`, retaining root defaults
for current private callers. Place the selected body's application content
after `splitImportDirectives`; do the same for every imported body.
2. Set placement on selected/imported source records, including error records
once a source has been acquired. Keep `ImportedFrom` at the physical directive.
Run imported `fragmentVersionClaim` against placed bodies and retain placed
bodies in `state.documents` before `composeDocuments` builds applications.
3. Leave filesystem joins, containment, glob traversal, optional missing inputs,
active cycles, visited identities, and acquisition IDs/order unchanged.
Destination must not become part of the repeated-source identity key.
4. Test native and host transitive imports, nested source-root directives,
import-only bodies, and mounted imported protected versus ordinary version
paths. Verify a repeated file at two destinations in one group still fails,
including an import/direct-input alias. Reuse cycle/containment regressions.
Validated with `go test ./internal/engine -run 'Destination|PlaceDocument|Import|Grouped'`
and `./scripts/check.sh` (go1.26.8 linux/amd64); both passed. Retained imported
contributions have effective placement and physical source identity, with no
double wrapping or application-level directive leakage.
## Stage 5 — Complete strict tiers and main version ownership
Status: complete. Scope: assembly and version interactions, not a new merger.
1. Review `acquireUnderlayGroup`: version checks must use placed content, and
documents taken from `acquired.composition.applications` must already be
placed. Recompose strict peers with ordinary `composeDocuments`; preserve
per-source applications, origins, UnderlayIndex, and input accounting.
2. In `executeLoadRequest`, main's version check uses its placed main/import
composition at the existing point. `assembleLayeredInput` recursively overlays
completed underlay groups beneath main, selected overlay, and CLI as before.
Do not mount the final assembly or prepend main's destination to upper tiers.
3. Test disjoint same-destination peers, different destinations, equal and unequal
conflicts, ancestor/descendant destinations, and a strict conflict despite an
upper override. Verify recursive mapping overlays, atomic sequences, null,
explicit false/zero, and version-only domain overrides retaining lower fields.
4. Exercise mounted main with a nested VersionRule, absent root declarations,
imported/underlay direct and ancestor claims, and nonclaiming mapping ancestors.
Check the hook runs once and does not inspect each fragment independently.
Preserve exact configured-path integer conversion and ordinary nested typing.
Validate: destination/composition/overlay/version/grouped tests and everyday
check. Validated with `go test ./internal/engine -run 'LayeredDestination'`,
`go test ./internal/engine -run 'Destination|Composition|Overlay|Version|Grouped'`,
and `./scripts/check.sh` (go1.26.8 linux/amd64); all passed. The new mounted
cases verify strict same-tier conflicts and disjoint composition, independent
destinations and nested paths, recursive/atomic overlay behavior, explicit
null/false/zero, and main-only nested version checking after imports compose.
Done: placed and authored equivalents agree on values and conflicts; main
ownership and lifecycle timing are preserved.
## Stage 6 — Reconcile diagnostic coordinate systems
Status: complete. Scope: useful paths without fictional source locations.
1. Audit early acquisition, import, fragment-version, and composition failures.
Use one narrow path-prefix translation where diagnostics genuinely identify
application content; do not blanket-prefix every diagnostic or double-prefix
diagnostics already produced by placed trees.
2. Preserve syntax/read/directive locations and related import sites. Value
conflicts and later typed/resolver/validation failures use effective paths
but original content coordinates. Invalid introduced structure identifies
the destination declaration rather than a nonexistent source wrapper line.
3. Add focused assertions for a mounted conflict with two real sources, a mounted
decode or validation error, an imported version error, invalid destination,
and malformed source-local directives. Test stable codes/paths/locations and
nil results, not whole messages or callback choreography.
Validate: destination/diagnostic/import/version/decode tests and everyday check.
Validated with `go test ./internal/engine -run 'LayeredDestinationDiagnostics'`,
`go test ./internal/engine -run 'Destination|Diagnostic|Import|Version|Decode'`,
and `./scripts/check.sh` (go1.26.8 linux/amd64); all passed. The mounted
conflict, decode, and imported-version assertions use effective application
paths with authored source locations; invalid destinations have no physical
location; malformed source-root directives retain their authored path and
location. Later value diagnostics are produced from placed trees, while directive
diagnostics retain their raw source coordinates. Post-review remediation also
rebases known Mapping value-builder failure paths, which are produced before
placement. `TestFacadeDestinationMappingFailurePaths` covers mounted main and
underlay failures, escaped keys and sequence-index intent, cycles, unchanged
root paths, and unchanged source-constructor failures. Its mounted value cases
failed before the fix and pass afterward.
Done: diagnostics consistently distinguish application paths from source syntax.
## Stage 7 — Complete shared placement accounting
Status: complete. Scope: resource bounds with no new public limit system.
1. Wire Stage 2's prechecks and synthetic-node charges into both tracked ordinary
acquisition and import traversal, using `importAccounting` and current limit
structures. Charge each source body once, including empty import bodies.
2. Preserve existing raw byte/source/node accounting and imported directive
inspection. Additional wrappers count in per-body depth/node checks and
shared aggregate nodes; recomposition must not charge them again. Ensure
selected overlays and CLI start from totals including placement charges.
3. Test below/at/above relevant bounds using small private limit fixtures where
existing seams permit; add a narrow private seam if necessary. Cover deep
body plus destination, many shallow mounted inputs, imported wrappers, and
empty mappings. Verify root placement and NoSource do not add charges.
Preserve history/expansion limits; do not turn fixture values into defaults.
Validated with `go test ./internal/engine -run 'Destination|Import|Grouped|Overlay|Override'`
and `./scripts/check.sh` (go1.26.8 linux/amd64); both passed. New coverage in
`TestDestinationPlacementCountsAgainstSharedNodeBudget`,
`TestMultipleMountedInputsSharePlacementCharges`,
`TestImportedEmptyBodiesChargeEachDestinationWrapper`,
`TestSelectedOverlayAndCLIUsePlacementNodeCharges`, and
`TestNoSourceAndRootPlacementAddNoWrapperCharges` checks totals below, at, and
above the boundary, multiple shallow inputs, imported empty bodies, and charging
of upper tiers from mounted inputs. `TestPlaceDocumentRootAndBounds` continues
to cover per-document depth/node boundaries; existing grouped accounting tests
cover unchanged raw source/byte charging. Done: placement cannot bypass current
budgets and introduces no unrelated caps.
## Stage 8 — Preserve provenance, history, and source resource contexts
Status: complete. Scope: engine inspection and retained evidence.
1. Audit `compositionOrigins`, `completeCompositionOrigins`, application replay,
metadata construction/finalization, and source-record cloning against placed
applications. Prefer feeding placed trees into existing processing rather
than adding another path-offset index or rewriting final metadata afterward.
2. Verify explicit destination containers, actual leaves, composite origins,
superseded/removed descendants, and root/source fallbacks. Historical and
effective indexes keep independently owned paths and existing shape semantics.
3. Preserve source-ID resource contexts through imports, surviving lower-tier
values, and inheritance. Test completed-result and resolution-time context
queries against real relative files, without making ConfigKit open the
referenced application resource or retain loading handles.
4. Exercise the same physical source in separate tiers with different placement,
inspect each source record, and verify independent destination paths. Repeat
inspection after comparison and independent loads to detect retained mutation.
Validated with `go test ./internal/engine -run 'Destination|Metadata|Provenance|History|Resource|SourceContext'`,
`go test -race ./internal/engine`, and `./scripts/check.sh` (go1.26.8
linux/amd64); all passed. `TestLayeredDestinationsRetainOriginsHistoryAndDetachedRecords`
checks synthetic-container fallback locations, authored leaf coordinates,
composite origins, removed-descendant history, and the same host file retained
at distinct destinations in separate tiers before and after comparison and an
independent load. `TestLayeredImportedInheritedValuesRetainResourceContexts`
uses real temporary files to verify resolution-time and completed-result
relative-resource lookup through an imported surviving parent and its inherited
child. Existing metadata and resource-context tests continue to cover detached
records and loading-handle closure. Done: inspection explains effective
placement and physical origin without consumer reconstruction.
## Stage 9 — Verify final inheritance, sensitivity, and lifecycle integration
Status: complete. Scope: complete private engine behavior before facade exposure.
1. Resolve sibling inheritance only after full source precedence using the
existing expansion path. Cover a child and parent supplied by separate peers,
nested destination-local siblings, cancellation, and an inherited relative
resource retaining its declaring context. Cross-collection search is unchanged.
2. Reuse existing inheritance-version, overlay, and CLI guards; add targeted
mounted cases for actual protected paths and their ancestors. Do not reject
unrelated inheritance merely because it shares a mapping ancestor.
3. Test effective-path secret predicates, field tags and recorded dependencies,
superseded classified values, safe effective output/history/explanation, and
bilateral diff redaction using disposable canaries. Compare mounted versus
explicitly wrapped loads for equal effective values and no differences.
4. Verify defaults, decoding, resolution, secret lookup, and validation execute
once on the final shape; failures return no usable result. Reuse existing
ordinary-load lifetime/ownership coverage and add mounted protection where
placement creates a distinct risk.
Validate: destination/inheritance/secret/sensitivity/explain/diff/lifecycle tests
and everyday check. Validated with
`go test ./internal/engine -run 'Destination|Inheritance|Secret|Sensitivity|Explain|Diff|Load|Version|Grouped' -count=1`,
`go test -race ./internal/engine`, and `./scripts/check.sh` (go1.26.8
linux/amd64); all passed.
`TestLayeredDestinationInheritanceUsesFinalSiblingScopes` verifies parent and
child contributions from separate peers, a higher-tier parent update, nested
destination-local siblings, and cancellation. `TestLayeredDestinationInheritanceDoesNotSearchOtherCollections`
keeps sibling lookup local. `TestLayeredMountedVersionProtectionCoversInheritanceAndUpperTiers`
protects inherited values, scalar/null ancestors, consumed directives, selected
overlays, and CLI overrides at mounted paths while allowing nonclaiming mapping
ancestors and unrelated inheritance. The coverage exposed an ancestor gap in
the existing inheritance-version check; it now applies the shared fragment claim
logic to the transferred parent subtree.
`TestLayeredDestinationLifecycleAndSensitiveInspectionUseFinalShape` checks one
defaults/resolution/secret-lookup/validation lifecycle, final mounted decoding,
field tags and effective-path predicates, superseded history, and safe effective
output/explanation against canaries; it compares mounted and explicitly wrapped
loads for equal configuration and no diff. `TestLayeredDestinationDiffUsesBilateralSensitivity`
checks redaction from either side and recorded dependencies, and
`TestLayeredDestinationDecodeFailureReturnsNoResultOrLaterCallbacks` checks a
mounted decode failure returns no result. Stage 8's
`TestLayeredImportedInheritedValuesRetainResourceContexts` continues to verify
the declaring context for inherited relative resources. Done: approved engine
semantics integrate without per-fragment loading or a post-return processing
pass.
## Stage 10 — Expose the root-owned facade and public acceptance tests
Status: complete. Scope: the two public fields and complete consumer behavior.
1. Add `Input.Destination Path` and `SourceInfo.Destination Path` to root types.
Extend `toEngineInput`, `fromEngineSourceInfo`, `toEngineSourceInfo`, and all
source publication paths with owned conversion. Run existing public/import
boundary tests; expose no engine nodes, aliases, or bridge methods.
2. Document zero/root behavior, mapping-only paths, main/underlay parity,
inherited import placement, source-coordinate fallback, and source ownership
beside declarations. Update the configuration contract for implemented input
destinations, preserving canonical ownership of shared semantics.
3. Add external-package public-load coverage for equivalent host file, native
fs.FS file, byte, reader, and Mapping sources. Cover default/root destinations,
literal escaped/numeric keys, empty mapping, NoSource, and invalid declarations.
Include one public mounted main case, not just underlays.
4. Add representative public tests tying final configuration to metadata,
source placement, history, resources, callbacks, and safe diffs. Mutate returned
record copies and repeat queries/loads to confirm detachment. Reuse engine
matrices; public tests demonstrate supported integration rather than every
private branch. Confirm failed loads expose detached source destinations.
Validated with `go test . -run 'Destination|FacadeLayered|PublicFacadeBoundary|DependencyImportBoundary' -count=1`,
`go test -race ./...`, and `./scripts/check.sh` (go1.26.8 linux/amd64); all
passed.
`TestFacadeInputDestinationsAcrossSourceKinds` exercises host, native
filesystem, bytes, reader, and mapping inputs with literal escaped and
numeric-looking mapping keys. `TestFacadeDestinationRootEmptyMappingNoSourceAndInvalidPaths`
covers omitted/empty root placement, empty mapping presence, absent `NoSource`,
invalid index/reserved destinations, and reader preflight. `TestFacadeImportedSourcesRetainInputDestination`
checks public transitive import placement. `TestFacadeMountedMainAndUnderlayInspectionResourcesAndDiff`
ties mounted main/underlay values to metadata, both source destinations, callback
source context, relative-resource access during and after loading, history, safe
effective/explanation rendering, and bilateral diff redaction; it also mutates
returned records and confirms later queries and comparisons remain unchanged.
`TestFacadeFailedLoadExposesDetachedSourceDestination` verifies failure records,
and `TestSourceInfoDestinationConversionsAndCopies` covers root/engine source
record conversion. Existing public-boundary tests pass with the new root-owned
fields. Done: destination selection and inspection are available through the
supported ConfigKit import path.
## Stage 11 — Add maintained simple and collection-style consumer examples
Status: complete. Scope: executable adoption examples with consumer-owned types.
1. Add a minimal mounted-fragment example using `Input.Destination` in an
ordinary layered load. Show literal keys and root-default compatibility
without introducing consumer parsing or generic merge helpers.
2. Add one representative collection assembly under `examples/consumers`: peer
prompt/profile fragments, collection metadata with ordinary `version`, a
source-root import, local sibling inheritance, and an intentional higher-tier
version-only override retaining the body. Keep the application VersionRule
main-owned. Use a controlled native filesystem or temporary files.
3. Demonstrate effective-path inspection plus source-relative resource lookup
during resolution and from the result, including an inherited/surviving field.
Application code owns opening/interpreting the resource and any qualified IDs.
4. Add tests for final values, presence, actual source placement/coordinates,
resource context, and expected conflict behavior. Link maintained examples
from the consumer guide; add a small executable root example if useful for
discovery without duplicating the complete scenario.
Validated with `go test ./examples/consumers -count=1`,
`go test . -run '^Example' -count=1`, and `./scripts/check.sh` (go1.26.8
linux/amd64); all passed, including local documentation links.
`Example_inputDestination` demonstrates a literal mounted mapping path and an
ordinary root input without consumer-side parsing or merging.
`LoadCollectionBundle` and `TestCollectionBundleAssemblesMountedPeersAndResources`
exercise prompt/profile peer sources, imported collection metadata with an
ordinary version label, main-owned schema-version checking, local sibling
inheritance, and a higher-tier prompt version-only change that retains the rest
of its body. The resolver and `ReadCollectionPromptContent` follow direct and
inherited `content_file` provenance to the declaring native-filesystem context.
The test checks final values, explicit presence, source destinations and authored
coordinates, relative resources, and the inherited field's origin.
`TestCollectionPeerPromptConflictsRemainStrict` preserves expected conflict
behavior. The examples are linked from the consumer adoption guide. Done: simple
and representative consumers substantiate the same model without a compensating
structural pass.
## Stage 12 — Reconcile documentation and audit the complete implementation
Status: complete. Scope: final integration audit and evidence, not release work.
1. Update README navigation, consumer guidance, the component overview, and
development reading/test maps. Add ADR 0011 to navigation. Update architecture
policy only as needed to recognize placement within existing assembly and
source ownership, linking precise behavior to the configuration contract.
Do not change unrelated policies, deferred boundaries, or published notes.
2. Audit every resolved decision, roadmap behavior, and request acceptance row
against implementation and tests. Record the corresponding test names here;
for reused coverage explain the behavior it proves. Do not mark complete
based on green tests that do not exercise mounting.
3. Review the complete baseline-to-candidate diff for accidental strengthening:
extra naming rules, namespace uniqueness, main-only restrictions, changed
merge/version semantics, invented source-read ordering guarantees, new limits,
or expanded secret policies. Remove temporary helpers/unsupported branches
left by staged integration. Confirm original source coordinates remain real.
4. Run `./scripts/check.sh` and
`GOTOOLCHAIN=local GOPROXY=off go test -race ./...`. Review source-kind and
placement ownership coverage, error disclosure, complete-on-return handling,
and root/private boundaries. Record commit, Go version, results, and any
limitations. No release command, CI waiting, benchmark, or downstream upgrade.
5. Mark this plan and the roadmap implemented only when code, public examples,
and owning contracts agree. Retain completed planning evidence until separately
authorized release preparation.
Audit evidence for the approved decisions and request acceptance surface:
| Behavior reviewed | Evidence and what it proves |
| --- | --- |
| Root-owned `Input.Destination` and `SourceInfo.Destination`; detached conversions and publication | `TestSourceInfoDestinationConversionsAndCopies`, `TestFacadeFailedLoadExposesDetachedSourceDestination`, and `TestFacadeMountedMainAndUnderlayInspectionResourcesAndDiff` verify root/engine conversion, error records, and returned record detachment. `TestPublicFacadeBoundary` and `TestDependencyImportBoundary` verify no engine types cross the facade. |
| Literal mapping keys, parsed numeric keys, reserved structural tokens, and invalid-path preflight | `TestNormalizeDestinationUsesLiteralMappingKeys`, `TestNormalizeDestinationRejectsIndicesAndReservedKeys`, and `TestLayeredLoadPreflightsDestinationsBeforeReadingSources` cover mapping-only normalization and rejection before reader consumption; `TestFacadeDestinationRootEmptyMappingNoSourceAndInvalidPaths` verifies the public error boundary. |
| Placement of acquired bodies, empty mappings versus absence, imports, all source kinds, physical identity, and authored coordinates | `TestFacadeInputDestinationsAcrossSourceKinds` covers host/native/bytes/reader/mapping sources; `TestFacadeSameFragmentMountsAtSeparateCollectionDestinations` proves independent public collections can retain the same local key. `TestLayeredImportOnlyInputMaterializesDestination`, `TestLayeredHostImportsInheritInputDestinationTransitively`, `TestLayeredNativeFilesystemImportsInheritInputDestination`, and `TestLayeredDestinationsDoNotChangeRepeatedSourceIdentity` cover inherited import placement and unchanged identity. `TestPlaceDocumentWrapsEmptyMappingAndPreservesSourceCoordinates`, `TestPlaceDocumentPreservesAuthoredEntryCoordinates`, and `TestLayeredDestinationDiagnosticsUseEffectivePathsAndAuthoredLocations` distinguish fallback wrapper locations from authored coordinates. |
| Strict composition, explicit-wrapper equivalence, precedence, empty values, and domain `version` overrides | `TestLayeredDestinationStrictCompositionBoundaries`, `TestLayeredDestinationMatchesExplicitlyWrappedInput`, `TestEmptyMountedMappingRetainsLowerChildren`, and `TestLayeredDestinationOverlayRetainsOrdinaryValueSemantics` prove existing group and overlay semantics are retained; the latter includes ordinary field-only updates rather than whole-record replacement. |
| Main-owned version checks and imported, underlay, inheritance, overlay, CLI, and ancestor protection | `TestLayeredDestinationChecksVersionOnceOnComposedMain`, `TestLayeredDestinationVersionClaimsUsePlacedApplicationPaths`, `TestLayeredImportedVersionClaimsUseEffectiveDestination`, and `TestLayeredMountedVersionProtectionCoversInheritanceAndUpperTiers` cover effective paths, one main check, protected-path claims/ancestors, and nonclaiming mapping ancestors. `TestLoadVersionDestinationTypesKeepOrdinaryDecoding` preserves exact configured-path conversion and ordinary nested decoding. |
| Final-shape sibling inheritance, no cross-collection lookup, one lifecycle, and failure without a usable partial result | `TestLayeredDestinationInheritanceUsesFinalSiblingScopes`, `TestLayeredDestinationInheritanceDoesNotSearchOtherCollections`, `TestLayeredDestinationLifecycleAndSensitiveInspectionUseFinalShape`, and `TestLayeredDestinationDecodeFailureReturnsNoResultOrLaterCallbacks` exercise these behaviors. Reused inheritance, lifecycle, and callback tests cover generic behavior that destination placement does not alter. |
| Effective paths, presence, provenance/history, safe rendering, sensitivity, bilateral diff, and source-relative resources | `TestLayeredDestinationsRetainOriginsHistoryAndDetachedRecords`, `TestLayeredDestinationLifecycleAndSensitiveInspectionUseFinalShape`, `TestLayeredDestinationDiffUsesBilateralSensitivity`, `TestLayeredImportedInheritedValuesRetainResourceContexts`, and `TestFacadeMountedMainAndUnderlayInspectionResourcesAndDiff` connect mounted values to inspection. The imported-inheritance test specifically proves the inherited value keeps the declaring source's passive resource context. |
| Shared depth/node limits and unchanged root/absence accounting | `TestPlaceDocumentRootAndBounds`, `TestDestinationPlacementCountsAgainstSharedNodeBudget`, `TestMultipleMountedInputsSharePlacementCharges`, `TestImportedEmptyBodiesChargeEachDestinationWrapper`, `TestSelectedOverlayAndCLIUsePlacementNodeCharges`, and `TestNoSourceAndRootPlacementAddNoWrapperCharges` cover per-document and aggregate limits without adding a new limit class. |
| Maintained consumer usage and strict peer behavior | `Example_inputDestination`, `TestCollectionBundleAssemblesMountedPeersAndResources`, and `TestCollectionPeerPromptConflictsRemainStrict` verify the simple and representative collection examples against actual values, resources, source records, and conflict behavior. |
The wider baseline-to-candidate review covered all 38 changed files (`6353598..2d8df22`): production changes are limited to the root facade fields/conversions, private input/import assembly and accounting, source record ownership, and the inheritance version-claim correction described above. The contract and tests do not impose identifier grammar, namespace uniqueness, main-only placement, source-read ordering, new budgets, nested version conversion, or expanded secret policy. The implementation wraps only acquired application bodies after processing source-root directives; placement does not change physical source identity, authored node locations, filesystem resolution, or later-tier semantics. Stage evidence above reuses existing general composition, resource, inheritance, version, lifecycle, sensitivity, and boundary tests where those behaviors are unchanged; mounted-specific cases are named separately.
Completion record: audited source candidate `2d8df22dd1cb9fde8e60853da3e2260731acf2bb`. On `go1.26.8 linux/amd64`, `./scripts/check.sh` passed, including formatting, whitespace, documentation links, tests, vet, and build. `GOTOOLCHAIN=local GOPROXY=off go test -race ./...` also passed with dependency downloads disabled. No remaining implementation limitations were found; release preparation, CI, benchmark campaigns, and downstream upgrades remain separately scoped.
Done: one complete layered load supports input destinations with coherent values,
diagnostics, inspection, limits, and source ownership across the approved surface.
## Implementation owner map
Use graph tools to locate these symbols rather than assume line numbers:
- Public input forwarding: `layered.go`, `records.go`, `conversion.go`
(source conversions), and existing facade tests.
- Private request and lifecycle: `internal/engine/layered.go`, `load_request.go`
(`loadInput`, `acquireUnderlayGroup`, `assembleLayeredInput`), `load.go`
(`executeLoadRequest`), and `load_input.go` (`assembleBaseInputTracked`).
- Directives and imports: `directives.go` (`splitImportDirectives`,
`fragmentVersionClaim`), and `imports.go` (`visitPath`).
- Shared document semantics: `compose.go`, `document_walk.go`
(`countInputNodes`), `path.go`, `parse.go`, and `value_operation.go` limits.
- Inspection: `metadata.go`, `history.go`, `explain.go`, `resource_context.go`,
`inheritance_origin.go`, `render.go`, and `diff.go`; source copies live in
engine `records.go`. Confirm current filenames through the component overview.
## Open Questions
None required for implementation. The four policy choices are approved; this
plan selects API spelling, validation timing/error category, coordinate handling,
and accounting within them. Surface a newly discovered incompatibility or scope
change with evidence rather than resolving it by silently narrowing the feature
or strengthening an unrelated contract.

View File

@@ -0,0 +1,171 @@
# Configuration Input Destinations Roadmap
Status: implemented. This roadmap records the approved behavior and rationale
for the [PromptKit feature request](promptkit-feature-request.md). The
[implementation plan](implementation.md) records the completed sequence and
audit evidence; the [configuration contract](../config.md#input-destinations)
owns current behavior.
## Targeted end state
A consumer can assign a mapping destination to a configuration input and perform
one ordinary `LoadLayered` call. The input's application content contributes
beneath that destination while retaining normal composition, precedence,
inheritance, typed loading, diagnostics, and inspection. PromptKit can assemble
collections without parsing and rewriting sources, merging values, or rebuilding
provenance itself.
For example, a fragment defining `summarize.alerts` can contribute beneath
`collections`, `weatherreporter`, `prompts`. An independent source with the same
local key can contribute beneath another collection without conflict. Collection
and prompt names remain ordinary application keys, not ConfigKit namespaces.
The public surface adds an optional `Destination Path` field on `Input`, not a
new loading entry point or an operation on completed results. Content-bearing
main inputs and underlay inputs support
the same destination model; root placement remains the default.
The following behavior defines the target:
- Omitted and empty destinations retain root placement. Destinations consist of
literal mapping keys, including dots, slashes, and numeric-looking text.
Explicit sequence-index destinations are outside the requested capability.
- A valid destination on `NoSource` contributes nothing. An actual empty
application mapping materializes its destination mappings as explicit input,
matching authored wrappers, without clearing lower-tier children.
- Existing structural reservations apply to introduced mappings. Destinations
that materialize `configkit` or a mapping-valued `extends` contrary to existing
contracts fail through the structured loading-error boundary. Keys merely
containing those words remain ordinary keys.
- Host files, native filesystem files, bytes, readers, and mapping sources all
support placement under their existing acquisition and ownership contracts.
Filesystem context, import opt-in, and precedence remain independent choices.
- Mounted contributions have the same application values and conflict behavior
as explicitly wrapped contributions, with accurate original source locations.
Disjoint mappings combine; overlapping non-mapping values within a group
conflict even when equal. A later tier cannot repair a strict-group conflict.
- Different destinations can coexist in one group, and the same destination can
occur in different tiers. Ancestor/descendant destinations follow ordinary
composition and overlay rules without namespace uniqueness restrictions.
Later tiers recursively overlay mappings and replace other values. Empty
mappings do not clear lower values; sequences remain atomic.
- A prompt's ordinary `version` override changes that field and retains omitted
lower fields. It neither identifies a merge record nor replaces a whole prompt.
- Source-root `configkit.imports` remains structural. Imported application data
inherits the input destination transitively. Import paths still resolve from
their declaring source; containment, optionality, cycles, and repeated-source
rules remain applicable. Placement does not change physical source identity.
- Sibling inheritance runs after assembly in the effective destination scope.
Peer fragments can supply a child and its sibling parent. Qualified PromptKit
references do not acquire cross-collection inheritance meaning.
- Version ownership is enforced at effective application paths. A collection's
mounted `version` is ordinary data, while underlay/import contributions to the
actual protected path, or replacements of its ancestors, remain invalid.
There is one main-owned version check and no blanket nested numeric conversion.
- Defaults, decoding, resolution, secret lookup, and validation run once on the
assembled application, not independently on fragments. Callbacks and value
diagnostics use effective paths. Failures use structured load errors and yield
no usable partial result.
- Presence, provenance, history, explanations, effective rendering, and diffs
agree with the final values. Sensitivity is evaluated at effective application
paths under the existing historical and dependency rules.
- Existing source records expose placement, including inherited import
placement. Introduced ancestor mappings retain the contributing source's
identity; any root-location fallback identifies that document, not an authored
wrapper. Actual content retains its real source coordinates.
- Original source locations and passive resource contexts survive placement,
overrides, and inheritance. `Result.SourceContext` and
`Resolution.SourceContext` still identify the declaring filesystem and base
directory for a field such as `content_file: ./summary.md`.
- Destinations and the structure they introduce participate in existing load
limits; placement cannot bypass depth or node accounting. Source ownership,
complete-on-return loading, and detached result behavior remain intact.
Import-destination syntax, independent collection specification headers, and
cross-collection inheritance are separate possible follow-ups. This work does
not add directory discovery, collection installation, remote acquisition,
namespace ownership enforcement, dependency solving, live reload, filename-based
identity, arbitrary transformations, or new merge strategies. PromptKit owns its
collection inventory, qualified IDs, and execution semantics.
## Implementation baseline (v0.7.1)
The request reviewed v0.7.1. At that baseline, the relevant boundaries were:
- [Input](../../layered.go) holds a source, import opt-in, and filesystem context,
but no destination. Root types delegate to the engine through the existing
[facade boundary](../internal/overview.md).
- [Layered loading](../config.md#layered-loading) already provides strict groups,
ordered precedence, shared acquisition budgets, and one final lifecycle.
[Underlay acquisition](../../internal/engine/load_request.go) gathers source
applications and recomposes them into their strict group.
- [Input acquisition](../../internal/engine/load_input.go) and
[import traversal](../../internal/engine/imports.go) process source-root
directives and source identity before final assembly. Imported version claims
are checked during traversal; underlay claims are checked before group
composition. These currently operate without a destination.
- [Version claim checks](../../internal/engine/directives.go) inspect a document
using the configured application path. Wrapping only an already acquired
result would leave early fragment checks and diagnostic paths incorrect.
- [Sibling inheritance](../adr/0009-sibling-inheritance.md) already runs after
source precedence and preserves source-to-destination derivation. Placement
must feed that existing mechanism rather than resolve fragments independently.
- Existing source records identify acquisition and precedence but do not expose
input placement. Existing history retains source applications, so moving only
the final tree would be insufficient for coherent inspection.
The architectural fit is an extension of input assembly, not a parser extension
or a second configuration model. The
[architecture policy](../policy/architecture.md) supplies the ownership and
consistency constraints; [deferred scope](deferred.md) continues to govern the
unrequested features above.
## Selected Design Decisions
### Main-input destinations and absent inputs
Allow the same destination model for content-bearing main inputs and underlays,
with `VersionRule.Path` remaining an effective application path and any
specification declaration still owned by main. The checker retains its existing
handling of an absent declaration and its existing lifecycle phase. `NoSource`
contributes nothing even with a valid destination. This keeps `Input` coherent
across its roles and preserves absence without per-fragment version checks.
### Public placement inspection and synthetic ancestors
Expose effective placement as `SourceInfo.Destination Path` on existing source
records, including inherited import placement.
Use original source identity for introduced ancestor mappings, documenting that
any root-location fallback identifies the contributing document rather than a
physically authored wrapper. Actual content keeps its real coordinates. This
allows consumers to connect placement to sources without a new query system.
Preserve that relationship through inherited fields, composite origins, and the
same source selected in separate tiers. Placement is per source application;
consumers can inspect it without inferring it from final paths.
### Reserved structural keys in destinations
Use existing `Path` conventions for literal keys and parsed
numeric tokens, reject explicit index segments, and preserve current structural
reservations in the effective tree. Reject destination segments that would
materialize `configkit` or a mapping-valued `extends` contrary to those contracts,
with a clear structured destination error. Ordinary keys containing those words,
dots, or slashes remain ordinary keys. Source-root directives are processed before
placement and are not rejected simply because their application body is mounted.
Mounted and authored wrappers therefore retain the same structural meaning.
Destination diagnostics identify placement as the cause of invalid introduced
structure, using existing error categories where suitable.
### Empty mappings and presence of introduced containers
Mounting an actual empty application mapping materializes its
destination mappings as explicit contributions, matching authored wrappers.
This preserves the request's equivalence principle and lets consumers inspect
an intentionally empty collection. Such mappings still do not clear lower-tier
children. An absent source contributes nothing.
This applies equally to empty imported application bodies and inputs containing
only import directives. Presence follows the contribution's meaning rather than
whether an implementation happened to allocate wrapper nodes.

View File

@@ -0,0 +1,319 @@
# PromptKit Feature Request: Configuration Input Destinations
Status: consumer feature request; not accepted or implemented.
## Requested Capability
Provide a public API through which a consumer can select a destination mapping
path for each configuration input. The input's application content contributes
beneath that path while retaining ConfigKit's normal composition, precedence,
inheritance, validation, diagnostics, and inspection behavior.
PromptKit needs this capability for its proposal 3, **Collections With Mounted
Configuration Fragments**, recorded in PromptKit's
`docs/roadmap/configuration-collections-mounted-fragments.md`. This request
describes the public API needs and observable acceptance criteria. It does not
prescribe ConfigKit's internal design or an implementation plan.
The reviewed public baseline is ConfigKit v0.7.1. The current
[layered-loading contract](../config.md#layered-loading) lets consumers choose
sources, import policy, filesystem context, and precedence groups, but does not
provide an input destination. Existing behavior remains authoritative until an
extension is adopted.
## Consumer Problem
PromptKit applications assemble independently maintained collections of prompts,
inference profiles, and backends. Collections can contain identical local names,
such as `summarize.alerts`, without identifying the same resource. PromptKit wants
an effective configuration organized by collection, while letting authors omit
repeated collection and resource-container wrappers from individual files.
A file selected from a collection's `prompts` directory would contain:
```yaml
summarize.alerts:
version: "1"
default_profile: provider/brief
messages:
- role: user
content: Summarize the weather alerts.
output:
format: text
validation_mode: none
```
Given destination `[collections, weatherreporter, prompts]`, its contribution
would appear in the final application configuration as:
```yaml
collections:
weatherreporter:
prompts:
summarize.alerts:
version: "1"
default_profile: provider/brief
messages:
- role: user
content: Summarize the weather alerts.
output:
format: text
validation_mode: none
```
Another collection can contribute `summarize.alerts` beneath
`[collections, homewatch, prompts]` without conflicting. After final handoff,
PromptKit interprets these as `weatherreporter/summarize.alerts` and
`homewatch/summarize.alerts`. The profile reference is illustrative and must be
supplied separately by the application configuration.
PromptKit will support one installed definition per prompt. Its optional flat
`version` is ordinary domain metadata, not a merge identity. This request does
not need identified-record sequences, sequence-root documents, or a custom
merge policy.
Authors could write the complete wrappers today, but that repeats assembly
details in every fragment. Consumer-side parsing and wrapping would put generic
source processing back into PromptKit and complicate source attribution.
PromptKit instead wants to select sources and destinations, then receive one
final configuration through ConfigKit's supported loading lifecycle.
## Minimum Public API
### Per-Input Destination
Allow a destination to be attached to each `Input` passed to `LoadLayered`.
One possible public spelling is an optional `Destination Path` field on `Input`.
The name and exact signature are suggestions; equivalent public API designs
are welcome.
Illustrative consumer code using the proposed field, which does not exist in
v0.7.1:
```go
promptInput := configkit.Input{
Source: configkit.File("/usr/local/share/collections/weatherreporter/prompts/alerts.yml"),
Destination: configkit.Keys("collections", "weatherreporter", "prompts"),
}
peers := configkit.Underlay{
Inputs: []configkit.Input{promptInput},
}
```
The consumer would include `peers` in its existing `LayeredOptions` and invoke
`LoadLayered` with its normal root main input and application `Definition`.
This is a partial API sketch, not an executable example or a proposed new
loading entry point. A single public layered load is sufficient for PromptKit;
it does not require a separate mounting operation on completed results.
The API must let the consumer:
- Express a destination relative to the application configuration root using
literal mapping-key segments. Dots, slashes, and numeric-looking text inside
a key are not implicit path separators or array indexes. PromptKit's namespace
grammar remains its own domain rule.
- Omit the destination to retain existing root placement. An explicitly empty
destination should have the same meaning. Mapping destinations are sufficient;
sequence-index destinations are not needed by this request.
- Assign different destinations to inputs in the same strict composition group,
and assign the same destination to inputs in separate precedence groups.
- Use the destination with existing content-bearing source kinds: host files,
native filesystem files, bytes, readers, and mapping sources, subject to their
existing acquisition and ownership contracts.
- Retain the existing import opt-in and filesystem context independently of the
destination. A configuration destination is not a filesystem root or base
directory.
PromptKit can continue to enumerate an explicitly selected directory and attach
the same destination to its returned inputs. ConfigKit does not need a new
directory scanner, namespace registry, filename convention, or PromptKit schema.
The namespace comes from the application's assembly declaration, not from an
implicit interpretation of a directory basename.
### Representative Assembly
These are application choices, not ConfigKit-specific reserved paths:
| Selected inputs | Destination key segments |
| --- | --- |
| Application main document | Root |
| `weatherreporter/collection.yml` | `collections`, `weatherreporter` |
| YAML files under `weatherreporter/prompts` | `collections`, `weatherreporter`, `prompts` |
| YAML files under `homewatch/prompts` | `collections`, `homewatch`, `prompts` |
| YAML files under `provider/profiles` | `collections`, `provider`, `profiles` |
| YAML files under `provider/backends` | `collections`, `provider`, `backends` |
Peer collection contributions belong to a single strict underlay group when the
application wants conflict detection among them. Intentional operator overrides
use later groups or main. Destination selection must not establish precedence
or make source traversal order authorize replacement.
## Required Observable Behavior
### Composition And Precedence
Mounted and explicitly wrapped contributions must have equivalent application
values and conflict behavior at their effective destinations. Actual source
locations naturally differ and must remain accurate.
Retain the [strict composition](../config.md#strict-document-composition) and
[layering](../config.md#layered-loading) rules:
- Disjoint mapping entries at the same destination combine.
- Overlapping non-mapping values within a strict group conflict, including equal
scalars, nulls, and sequences. Different destinations do not collide merely
because the source documents share a local key.
- Later precedence layers recursively override mappings and replace other
values. A strict conflict cannot be repaired by an upper layer.
- Empty mappings do not clear lower definitions; ordinary arrays remain atomic.
In particular, `messages` does not acquire sequence-merging behavior.
- Overlapping destination paths and contributions at their ancestors follow
those same rules. No namespace-level uniqueness restriction is requested.
Changing only a prompt's `version` in a later layer changes that field while
retaining omitted lower-layer fields. ConfigKit must not treat a domain version
label as a request to replace an entire definition.
### Imports And Existing Structural Directives
An input with imports enabled must be able to use its existing source-root
`configkit.imports` directive while contributing application data at a nested
destination. The directive remains structural configuration, not an application
field below that destination.
For the minimum API, imports inherit their importing input's destination,
including transitive imports. Import paths continue to resolve relative to
their declaring source's filesystem context. A mounted document must not send
its imported application fields back to the configuration root accidentally.
Existing opt-in, containment, missing-input, cycle, and repeated-source behavior
must remain applicable. A different destination is not a request to bypass the
current rejection of a repeated physical source within a strict group.
Sibling `extends` must resolve against mappings at their effective destinations.
For example, two independently supplied profile fragments mounted beneath the
same collection's `profiles` mapping can establish `extends: brief` once they
compose. Inheritance remains ConfigKit-owned; PromptKit does not perform another
relationship-resolution pass.
### Protected Specification Paths
The distinction between source-local field names and effective application
paths is essential. PromptKit's main document will declare `version: 1` at the
configuration root. A separate collection metadata file can contain:
```yaml
version: "2.4.0"
```
Mounted at `[collections, weatherreporter]`, that field is an ordinary collection
release label. It must not be rejected as an attempt to contribute the protected
root specification field. The same applies to a prompt's nested optional
version. Only the main-owned specification declaration uses the application's
configured [VersionRule](../config.md#application-schema-versions).
Conversely, placement must not permit an underlay or imported fragment to claim
or replace the actual protected specification path or an ancestor that would
replace it. The public contract must define protection in terms of effective
application paths, rather than reserving the spelling `version` in every source.
Protection must remain valid with imports, inheritance, overlays, and explicit
CLI overrides.
PromptKit needs one main-owned specification check with the existing lifecycle.
It does not need main mounted away from the root, integer-to-string conversion
for every nested `version`, or separate compatibility checks for every fragment.
### Typed Loading, Diagnostics, And Inspection
The destination must be visible consistently through the ordinary public
loading and inspection surface:
- Final typed configuration and application callbacks use effective destination
paths. Defaults, resolution, and validation retain the existing single-load
lifecycle; fragments are not independently validated as whole applications.
- Metadata and presence queries distinguish omission, explicit null, and other
explicit values at the mounted paths.
- Diagnostics identify the effective conflicting or invalid path and the real
contributing source locations. Consumers can determine which source and
placement produced a value without recreating that relationship themselves.
- Effective rendering, explanations, history, and configuration comparison agree
with the final configuration. Existing sensitivity and disclosure rules apply
to mounted fields, including application path-based secret classification.
- Unsupported destination forms and destination-related loading failures use
the normal structured error boundary. Exact new diagnostic codes, if needed,
are for ConfigKit to define.
PromptKit does not require a separate mount registry or new inspection facade.
Existing inspection APIs are sufficient if they expose accurate effective paths,
source attribution, and placement information. ConfigKit should document how
source-local locations relate to effective paths.
### Source-Relative Resources And Lifecycle
For a mounted field such as `content_file: ./summary.md`, obtaining its effective
origin and calling `Result.SourceContext` or `Resolution.SourceContext` must
still identify the declaring source's filesystem and base directory. The same
holds when that field survives an override or is inherited into another mapping.
Its configuration destination must not change the meaning of the relative file
reference.
Retain existing source ownership, load completion, result lifetime, containment,
and resource-access behavior. Destinations must participate in documented load
limits without creating a way to bypass them; this request does not prescribe
new limit values or their accounting machinery.
## Optional Follow-Ups, Not Prerequisites
**Import destinations in YAML.** A destination on an import directive could let
publishers describe assembly in configuration rather than Go. Proposal 3
recommends destinations relative to the importing document's destination, with
omission retaining that destination. This is useful, but per-input placement
through the public API is sufficient for initial PromptKit adoption. No new
directive spelling is selected by this request.
**Independent specification headers.** A collection entry document might later
declare its own supported specification version. That needs a distinct contract
for checking the header and determining its role in the composed configuration.
Mounting collection release metadata does not supply this capability. Keep it a
separate request if PromptKit decides it is necessary.
**Cross-collection inheritance.** Nested collection profiles are not immediate
siblings across collections. Current sibling inheritance remains sufficient for
the initial proposal, with authored definitions flattened where necessary.
Generic cross-path inheritance could be considered separately if consumer needs
justify revisiting the [deferred scope](deferred.md). Mounting must not silently
reinterpret `extends: provider/brief` as a cross-collection reference.
PromptKit also does not request collection installation, automatic directory
discovery, remote fetching, namespace ownership enforcement, dependency solving,
live reload, filename-derived identity, or arbitrary document transformations.
Its qualified resource IDs, optional prompt version semantics, collection
inventory, and execution APIs remain PromptKit responsibilities.
## Public Acceptance Scenarios
Evaluate the extension through supported public loading and inspection APIs:
| Scenario | Expected consumer-visible result |
| --- | --- |
| Destination omitted or empty | Existing root-placement behavior |
| Same fragment shape mounted under two distinct collection keys from independent sources | Two separate effective resources |
| Disjoint inputs at the same destination in one group | Mapping contributions combine |
| Equal or unequal overlapping scalar fields in one group | Structured composition conflict with both source locations |
| Same destination across deliberate precedence tiers | Existing recursive overlay behavior |
| Version-only prompt override | Label changes; omitted prompt body fields survive |
| Nested mounted import without a destination directive | Imported fields contribute at the inherited destination |
| Local profile inheritance across mounted peer fragments | Parent resolves at the effective sibling scope |
| Collection metadata containing `version` mounted below root | Ordinary nested field, independent of root VersionRule |
| Mounted/imported contribution actually targeting the protected version path | Rejected under version ownership rules |
| Mounted field using source-relative content, including inheritance | Original declaring resource context remains available |
| Mounted explicit null and omitted field | Distinct effective presence information |
| Mounted validation error or secret field | Effective paths, physical locations, and existing disclosure rules agree |
| File, native filesystem, byte, reader, or mapping input with equivalent content | Equivalent application values, subject to existing source contracts |
| Unsupported destination or failed load | Normal structured failure, no usable partial result |
The minimum usable outcome is a consumer that selects collection sources and
destinations, performs one ordinary layered load, and receives final typed
configuration with reliable diagnostics and inspection. It should need no
consumer-side source rewriting, generic merging, or provenance reconstruction.

View File

@@ -0,0 +1,89 @@
# Input destination review findings
Reviewed `d3d468a..945e111` against the approved
[input-destinations roadmap](input-destinations.md),
[implementation plan](implementation.md), and
[ADR 0011](../adr/0011-input-destinations.md).
The root-owned destination fields, placement before strict composition,
transitive import placement, effective-path version ownership, final lifecycle,
source records, resource contexts, retained history, and existing disclosure
mechanisms broadly match the selected architecture. The required correction
below concerns an early diagnostic path, not loading or composition semantics.
No additional unintended strengthening of contracts was identified.
## 1. Rebase known Mapping-source failure paths to the input destination (P2)
Status: complete. Known Mapping value-builder paths are now prefixed with the
normalized destination at acquisition, preserving semantic key/index segments.
Public regressions cover mounted main and underlay failures, escaped keys,
sequence indices, cyclic values, unchanged root paths, and source-constructor
errors retaining source coordinates. The mounted value cases failed before the
fix and pass afterward. Existing directive/syntax checks remain unchanged.
Focused destination/source/diagnostic/import tests, `./scripts/check.sh`, and
`GOTOOLCHAIN=local GOPROXY=off go test -race ./...` passed on
`go1.26.8 linux/amd64`.
Affected: `assembleBaseInputTracked` in
[load_input.go](../../internal/engine/load_input.go), lines 27–32 at the reviewed
commit; the Mapping failure originates in `readSource` in
[source.go](../../internal/engine/source.go), lines 206–208.
`Mapping` snapshots its input through the existing value builder. When a nested
value is unsupported, cyclic, or exceeds a value-builder limit, the retained
issue already identifies a semantic path inside that application mapping.
`readSource` turns it into a structured diagnostic. The early return from
`assembleBaseInputTracked` annotates the source record with its destination but
returns those diagnostic paths unchanged.
For example, select this input as either main or an underlay:
```go
configkit.Input{
Source: configkit.Mapping("bad mapping", map[string]any{
"item": map[string]any{"bad": func() {}},
}),
Destination: configkit.Keys("catalog", "entries"),
}
```
The load correctly fails with `source.invalid`, but `Diagnostic.Path` is
`/item/bad` rather than `/catalog/entries/item/bad`. The source record correctly
reports `/catalog/entries`, so the two inspection records use inconsistent
application coordinates. A consumer displaying or associating the failure with
the final application shape would point to the wrong configuration field.
This diverges from implementation decision 10 and Stage 6: existing paths that
genuinely identify application content must be translated to effective paths
exactly once. A Mapping value-builder issue has that information; it is not a
source-read or YAML syntax failure for which a logical child path is unavailable.
Required remediation:
- Prefix known application-relative Mapping value-builder diagnostic paths with
the normalized input destination at the acquisition boundary. Preserve key
escaping and sequence-index intent in the existing semantic Path model.
- Keep source IDs, original primary/related locations, codes, value-free
messages, source destinations, and nil-result behavior unchanged. Root
placement must retain its current paths.
- Do not blanket-prefix parse/read failures, source-constructor errors, or
structural import-directive diagnostics. Do not invent logical child paths
for failures that do not identify application content.
- Add bounded regression coverage through the public API for mounted main and
underlay Mapping failures, alongside unchanged root placement. Reuse existing
syntax/directive tests to protect their different coordinate convention.
- Run the focused destination/source/diagnostic checks and the everyday check.
Reconcile the completed plan's diagnostic evidence with this correction;
do not redefine the agreed effective-path behavior to accept the gap.
## Review validation
- `GOTOOLCHAIN=local ./scripts/check.sh` passed at `945e111` on
`go1.26.8 linux/amd64` (formatting, whitespace, tests, vet, and build).
- `GOTOOLCHAIN=local GOPROXY=off go test -race ./...` passed.
- `GOTOOLCHAIN=local GOPROXY=off go test -count=1 ./...` passed.
- An additional external-package probe in a disposable checkout outside the
repository failed for both main and underlay placement, observing `/item/bad`
instead of `/catalog/entries/item/bad` with `source.invalid`.
- No production fixes or regression tests were added to this repository during
the review. No performance measurements or new guarantees were inferred.

View File

@@ -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"`

View File

@@ -52,6 +52,22 @@ func Example_seriatim() {
// Output: visitor true the event occurred <nil> <nil>
}
func Example_inheritedPresets() {
result, err := consumers.LoadPresets(configkit.File(fixture("inheritance", "presets.yml")))
if err != nil {
fmt.Println(err)
return
}
path := configkit.Keys("presets", "custom", "enabled")
info, _ := result.Metadata().Lookup(path)
custom := result.Config.Presets["custom"]
fmt.Println(custom.Enabled, result.Metadata().IsExplicit(path), custom.Settings["secondary"]["width"])
fmt.Println(info.Origin.Locations[0].SourceID, info.Origin.Inheritance[0].From)
// Output:
// false true 20
// source-1 /presets/base/enabled
}
func Example_weatherStartup() {
const blockedName = "CONFIGKIT_WEATHER_EXAMPLE_BLOCKED"
const tokenName = "CONFIGKIT_WEATHER_EXAMPLE_TOKEN"

View File

@@ -0,0 +1,159 @@
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; imported collection metadata,
// prompt/profile peers, and an operator prompt-version override use ordinary
// destinations and domain fields.
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")
profileDestination := collectionDestination.Key("profiles")
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},
{Source: bundle.File("collections/weather/prompts/brief.yml"), Destination: promptDestination},
{Source: bundle.File("collections/weather/prompts/custom.yml"), Destination: promptDestination},
{Source: bundle.File("collections/weather/profiles/compact.yml"), Destination: profileDestination},
{Source: bundle.File("collections/weather/profiles/weather.yml"), Destination: profileDestination},
}},
{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
}

View File

@@ -0,0 +1,116 @@
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 parentPromptSource configkit.SourceInfo
var promptVersionSource configkit.SourceInfo
for _, source := range result.Sources() {
switch source.Path {
case "collections/weather/metadata.yml":
metadataSource = source
case "collections/weather/prompts/brief.yml":
parentPromptSource = source
case "operators/weather-prompt-version.yml":
promptVersionSource = source
}
}
collectionVersionLocation := collectionVersion.Origin.Locations[0]
if metadataSource.ID == "" || collectionVersionLocation.SourceID != metadataSource.ID ||
metadataSource.Destination.String() != "/collections/weatherreporter" || collectionVersionLocation.Line != 1 {
t.Fatalf("imported collection version source = %#v, %#v", metadataSource, collectionVersionLocation)
}
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
View 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)
}

View 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)
}
}

View File

@@ -0,0 +1,20 @@
package consumers
import configkit "gitea.maximumdirect.net/eric/ConfigKit"
// PresetCatalog is ordinary application data; no inheritance fields or
// registration hooks are required in the typed configuration.
type PresetCatalog struct {
Presets map[string]Preset `yaml:"presets"`
}
type Preset struct {
Enabled bool `yaml:"enabled"`
Settings map[string]map[string]int `yaml:"settings"`
}
// LoadPresets performs one load; callers may inspect inherited presence and
// source origins on the returned result before choosing a preset to execute.
func LoadPresets(source configkit.Source) (*configkit.Result[PresetCatalog], error) {
return configkit.Load[PresetCatalog](source)
}

View File

@@ -0,0 +1,35 @@
package consumers_test
import (
"testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
"gitea.maximumdirect.net/eric/ConfigKit/examples/consumers"
)
func TestPresetCatalogOneLoadAndDerivation(t *testing.T) {
result, err := consumers.LoadPresets(configkit.File(fixture("inheritance", "presets.yml")))
if err != nil {
t.Fatal(err)
}
if result.Config.Presets["base"].Settings["secondary"]["width"] != 10 ||
result.Config.Presets["custom"].Settings["secondary"]["width"] != 20 ||
result.Config.Presets["custom"].Enabled {
t.Fatalf("expanded presets = %#v", result.Config.Presets)
}
enabled := configkit.Keys("presets", "custom", "enabled")
if !result.Metadata().IsExplicit(enabled) {
t.Fatal("inherited false lost explicit presence")
}
info, found := result.Metadata().Lookup(enabled)
if !found || info.Origin.Locations[0].SourceID != "source-1" ||
len(info.Origin.Inheritance) != 1 || info.Origin.Inheritance[0].From.String() != "/presets/base/enabled" {
t.Fatalf("origin and derivation = %#v, %v", info, found)
}
nested := configkit.Keys("presets", "custom", "settings", "secondary", "width")
info, found = result.Metadata().Lookup(nested)
if !found || len(info.Origin.Inheritance) != 1 ||
info.Origin.Inheritance[0].From.String() != "/presets/custom/settings/primary/width" {
t.Fatalf("destination-local derivation = %#v, %v", info, found)
}
}

View File

@@ -0,0 +1,95 @@
package consumers
import (
"embed"
"errors"
"io/fs"
"path"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
//go:embed testdata/native-source testdata/layered-bundle testdata/collection-bundle
var bundledFiles embed.FS
// BundledConfig is a small configuration loaded directly from an embedded file.
type BundledConfig struct {
Name string `yaml:"name"`
Enabled bool `yaml:"enabled"`
}
// LoadBundled demonstrates a native filesystem source without layered options.
func LoadBundled() (*configkit.Result[BundledConfig], error) {
files, err := fs.Sub(bundledFiles, "testdata/native-source")
if err != nil {
return nil, err
}
return configkit.Load[BundledConfig](configkit.NewFilesystem("bundled", files).File("config.yml"))
}
// CatalogConfig keeps resource names and domain rules in the application.
type CatalogConfig struct {
Mode string `yaml:"mode"`
Enabled bool `yaml:"enabled"`
Workers int `yaml:"workers"`
Templates map[string]string `yaml:"templates"`
Labels map[string]string `yaml:"labels"`
}
// LoadCatalog combines two underlay tiers beneath an independently located
// host main file. ConfigKit assembles and validates the complete result once.
func LoadCatalog(mainPath string) (*configkit.Result[CatalogConfig], error) {
files, err := fs.Sub(bundledFiles, "testdata/layered-bundle")
if err != nil {
return nil, err
}
bundle := configkit.NewFilesystem("catalog bundle", files)
return configkit.LoadLayered(configkit.Input{Source: configkit.File(mainPath)},
configkit.LayeredOptions[CatalogConfig]{
Underlays: []configkit.Underlay{
{Inputs: []configkit.Input{{Source: bundle.File("defaults/config.yml"), Imports: true}}},
{Inputs: []configkit.Input{{Source: configkit.Mapping("local policy", map[string]any{
"workers": 2,
"labels": map[string]any{"tenant": "example"},
})}}},
},
Definition: configkit.Definition[CatalogConfig]{
Defaults: func() CatalogConfig { return CatalogConfig{Mode: "demo"} },
Validate: validateCatalog,
},
})
}
func validateCatalog(configuration *CatalogConfig, validation *configkit.Validation) {
if configuration.Mode != "demo" && configuration.Mode != "production" {
validation.Error(configkit.Keys("mode"), "must be demo or production")
}
if configuration.Workers < 0 {
validation.Error(configkit.Keys("workers"), "must be nonnegative")
}
if name := configuration.Templates["welcome"]; name == "." || !fs.ValidPath(name) {
validation.Error(configkit.Keys("templates", "welcome"), "must be a relative resource name")
}
}
// ReadWelcome follows the effective template field's one declaring source to
// its retained resource context. Opening and interpreting the resource remain
// application work; this does not reopen configuration or change metadata.
func ReadWelcome(result *configkit.Result[CatalogConfig]) (string, error) {
if result == nil {
return "", errors.New("catalog result is nil")
}
info, found := result.Metadata().Lookup(configkit.Keys("templates", "welcome"))
if !found || len(info.Origin.Locations) != 1 {
return "", errors.New("welcome template has no single declaring source")
}
context, found := result.SourceContext(info.Origin.Locations[0].SourceID)
if !found {
return "", errors.New("welcome template has no resource context")
}
data, err := fs.ReadFile(context.FS, path.Join(context.BaseDir, result.Config.Templates["welcome"]))
if err != nil {
return "", err
}
return string(data), nil
}

View File

@@ -0,0 +1,71 @@
package consumers_test
import (
"errors"
"fmt"
"strings"
"testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
"gitea.maximumdirect.net/eric/ConfigKit/examples/consumers"
)
func Example_nativeFilesystem() {
result, err := consumers.LoadBundled()
if err != nil {
fmt.Println(err)
return
}
fmt.Println(result.Config.Name, result.Config.Enabled)
// Output: embedded false
}
func Example_layeredCatalog() {
result, err := consumers.LoadCatalog(fixture("layered-main", "config.yml"))
if err != nil {
fmt.Println(err)
return
}
welcome, err := consumers.ReadWelcome(result)
fmt.Println(result.Config.Mode, result.Config.Enabled, result.Config.Workers,
result.Config.Labels["origin"], result.Config.Labels["tenant"], strings.TrimSpace(welcome), err)
// Output: production false 0 bundle example Welcome from the bundle. <nil>
}
func TestCatalogRetainsPresenceAndSourceContext(t *testing.T) {
result, err := consumers.LoadCatalog(fixture("layered-main", "config.yml"))
if err != nil {
t.Fatal(err)
}
for _, key := range []string{"enabled", "workers"} {
info, found := result.Metadata().Lookup(configkit.Keys(key))
if !found || !info.Explicit || info.Origin.Layer != configkit.LayerBase {
t.Fatalf("%s presence = %#v, %v", key, info, found)
}
}
info, found := result.Metadata().Lookup(configkit.Keys("templates", "welcome"))
if !found || info.Origin.Layer != configkit.LayerUnderlay || len(info.Origin.Locations) != 1 {
t.Fatalf("welcome origin = %#v, %v", info, found)
}
context, found := result.SourceContext(info.Origin.Locations[0].SourceID)
if !found || context.BaseDir != "defaults" {
t.Fatalf("welcome context = %#v, %v", context, found)
}
welcome, err := consumers.ReadWelcome(result)
if err != nil || strings.TrimSpace(welcome) != "Welcome from the bundle." {
t.Fatalf("welcome = %q, %v", welcome, err)
}
if result.Config.Labels["tenant"] != "example" || result.Config.Labels["origin"] != "bundle" {
t.Fatalf("recursive underlay labels = %#v", result.Config.Labels)
}
}
func TestCatalogValidationRejectsInvalidMode(t *testing.T) {
_, err := consumers.LoadCatalog(fixture("layered-main", "invalid.yml"))
var loadError *configkit.LoadError
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) == 0 ||
loadError.Diagnostics()[0].Code != configkit.CodeValidateError ||
loadError.Diagnostics()[0].Path.String() != "/mode" {
t.Fatalf("invalid mode error = %v", err)
}
}

View File

@@ -0,0 +1,4 @@
configkit:
imports:
- path: metadata.yml
name: Weather Reporter

View File

@@ -0,0 +1,2 @@
version: "2.4.0"
description: Imported collection metadata.

View File

@@ -0,0 +1,3 @@
compact:
format: text
temperature: 0.2

View File

@@ -0,0 +1,3 @@
weather:
extends: compact
temperature: 0.4

View File

@@ -0,0 +1,7 @@
brief:
version: "1"
default_profile: compact
content_file: welcome.txt
messages:
- role: user
content: Write a concise weather summary.

View File

@@ -0,0 +1,3 @@
custom:
extends: brief
default_profile: weather

View File

@@ -0,0 +1 @@
Welcome to the weather collection.

View File

@@ -0,0 +1,2 @@
brief:
version: "2"

View File

@@ -0,0 +1,10 @@
presets:
base:
enabled: false
settings:
primary: {width: 10}
secondary: {extends: primary}
custom:
extends: base
settings:
primary: {width: 20}

View File

@@ -0,0 +1,7 @@
configkit:
imports:
- path: ../shared.yml
enabled: true
workers: 4
templates:
welcome: messages/welcome.txt

View File

@@ -0,0 +1 @@
Welcome from the bundle.

View File

@@ -0,0 +1,2 @@
labels:
origin: bundle

View File

@@ -0,0 +1,3 @@
mode: production
enabled: false
workers: 0

View File

@@ -0,0 +1 @@
mode: unsupported

View File

@@ -0,0 +1,2 @@
name: embedded
enabled: false

View File

@@ -31,6 +31,9 @@ func TestFacadeValuesRemainRootOwned(t *testing.T) {
reflect.TypeFor[configkit.Resolution](), reflect.TypeFor[configkit.Validation](),
reflect.TypeFor[configkit.Version](), reflect.TypeFor[configkit.VersionRule](),
reflect.TypeFor[configkit.FilesystemContext](), reflect.TypeFor[configkit.Override](),
reflect.TypeFor[configkit.Filesystem](), reflect.TypeFor[configkit.ResourceContext](),
reflect.TypeFor[configkit.Input](), reflect.TypeFor[configkit.Underlay](),
reflect.TypeFor[configkit.LayeredOptions[struct{}]](),
reflect.TypeFor[configkit.Definition[struct{}]](), reflect.TypeFor[configkit.Options[struct{}]](),
reflect.TypeFor[configkit.Result[struct{}]](),
reflect.TypeFor[configkit.ValueSnapshot](), reflect.TypeFor[configkit.ValueChange](),

View File

@@ -26,6 +26,7 @@ func TestFacadeInactiveContextsPreserveClosedFailure(t *testing.T) {
{"IsExplicit", func() { state.resolution.IsExplicit(path) }},
{"InputInfo", func() { state.resolution.InputInfo(path) }},
{"Source", func() { state.resolution.Source("source") }},
{"SourceContext", func() { state.resolution.SourceContext("source") }},
{"Record", func() { state.resolution.Record(path, configkit.ChangeNormalization, "reason") }},
{"Resolution.Error", func() { state.resolution.Error(path, "message") }},
{"Resolution.Warning", func() { state.resolution.Warning(path, "message") }},

443
facade_destination_test.go Normal file
View File

@@ -0,0 +1,443 @@
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 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 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
View 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)
}
}

164
facade_equivalence_test.go Normal file
View File

@@ -0,0 +1,164 @@
package configkit_test
import (
"bytes"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"testing/fstest"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
type equivalentConfig struct {
Version uint64 `yaml:"version"`
Name string `yaml:"name"`
Enabled bool `yaml:"enabled"`
Count int `yaml:"count"`
Items []string `yaml:"items"`
Optional *string `yaml:"optional"`
}
func TestEquivalentSourceFormsPreserveInputSemantics(t *testing.T) {
const yaml = "version: 1\nname: retained\nenabled: false\ncount: 0\nitems: []\noptional: null\n"
hostPath := filepath.Join(t.TempDir(), "main.yml")
if err := os.WriteFile(hostPath, []byte(yaml), 0o600); err != nil {
t.Fatal(err)
}
namespace := configkit.NewFilesystem("bundle", fstest.MapFS{"main.yml": &fstest.MapFile{Data: []byte(yaml)}})
context := &configkit.FilesystemContext{Root: ".", BaseDir: ".", FS: &namespace}
for _, test := range []struct {
name string
source configkit.Source
context *configkit.FilesystemContext
kind configkit.SourceKind
}{
{"host", configkit.File(hostPath), nil, configkit.SourceKindFile},
{"native", namespace.File("main.yml"), nil, configkit.SourceKindFilesystem},
{"bytes", configkit.Bytes("memory", []byte(yaml)), context, configkit.SourceKindBytes},
{"reader", configkit.Reader("stream", strings.NewReader(yaml)), context, configkit.SourceKindReader},
{"mapping", configkit.Mapping("programmatic", map[string]any{
"version": uint64(1), "name": "retained", "enabled": false, "count": 0,
"items": []any{}, "optional": nil,
}), context, configkit.SourceKindMapping},
} {
t.Run(test.name, func(t *testing.T) {
checks := 0
result, err := configkit.LoadLayered(configkit.Input{Source: test.source, Filesystem: test.context}, configkit.LayeredOptions[equivalentConfig]{
Definition: configkit.Definition[equivalentConfig]{Version: &configkit.VersionRule{
Path: configkit.Keys("version"), Check: func(version configkit.Version, validation *configkit.Validation) error {
checks++
if !version.Present || version.Text != "1" {
t.Errorf("version = %#v", version)
}
return nil
},
}},
})
if err != nil {
t.Fatal(err)
}
want := equivalentConfig{Version: 1, Name: "retained", Items: []string{}}
if !reflect.DeepEqual(result.Config, want) || checks != 1 {
t.Fatalf("config = %#v, checks %d", result.Config, checks)
}
for _, field := range []string{"version", "name", "enabled", "count", "items", "optional"} {
info, ok := result.Metadata().Lookup(configkit.Keys(field))
if !ok || !info.Explicit || info.Origin.Layer != configkit.LayerBase || info.Null != (field == "optional") {
t.Errorf("%s input info = %#v, %v", field, info, ok)
}
}
sources := result.Sources()
if len(sources) != 1 || sources[0].Kind != test.kind || sources[0].ID != "source-1" || sources[0].UnderlayIndex != 0 {
t.Fatalf("sources = %#v", sources)
}
resource, ok := result.SourceContext("source-1")
if !ok || resource.BaseDir != "." || resource.FS == nil {
t.Fatalf("source context = %#v, %v", resource, ok)
}
})
}
}
func TestRepeatedImportsAcrossTiersAndReaderConsumption(t *testing.T) {
namespace := configkit.NewFilesystem("bundle", fstest.MapFS{
"left.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: shared.yml}]}\n")},
"right.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: shared.yml}]}\n")},
"shared.yml": &fstest.MapFile{Data: []byte("name: retained\n")},
})
type config struct {
Name string `yaml:"name"`
}
left := configkit.Input{Source: namespace.File("left.yml"), Imports: true}
right := configkit.Input{Source: namespace.File("right.yml"), Imports: true}
_, err := configkit.LoadLayered(configkit.Input{Source: configkit.NoSource()}, configkit.LayeredOptions[config]{
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{left, right}}},
})
if got := layeredErrorCode(t, err); got != configkit.CodeImportRepeated {
t.Fatalf("same-tier repeated import = %s", got)
}
result, err := configkit.LoadLayered(configkit.Input{Source: configkit.NoSource()}, configkit.LayeredOptions[config]{
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{left}}, {Inputs: []configkit.Input{right}}},
})
if err != nil || result.Config.Name != "retained" || len(result.Sources()) != 4 {
t.Fatalf("cross-tier repeated import = %#v, %v", result, err)
}
reader := configkit.Reader("single-use", strings.NewReader("name: retained\n"))
_, err = configkit.LoadLayered(configkit.Input{Source: configkit.NoSource()}, configkit.LayeredOptions[config]{
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: reader}}}, {Inputs: []configkit.Input{{Source: reader}}}},
})
if got := layeredErrorCode(t, err); got != configkit.CodeSourceReaderUsed {
t.Fatalf("reader re-consumption = %s", got)
}
}
func TestReplacedSecretRemainsAbsentFromSafeOutput(t *testing.T) {
type config struct {
Secret string `yaml:"secret"`
}
result, err := configkit.LoadLayered(configkit.Input{Source: configkit.Bytes("main", []byte("secret: new-secret-canary\n"))}, configkit.LayeredOptions[config]{
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.Mapping("lower", map[string]any{"secret": "old-secret-canary"})}}}},
Definition: configkit.Definition[config]{Secret: func(path configkit.Path) bool { return path.String() == "/secret" }},
})
if err != nil || result.Config.Secret != "new-secret-canary" {
t.Fatalf("secret load = %#v, %v", result, err)
}
var effective, history bytes.Buffer
if err := result.WriteEffective(&effective); err != nil {
t.Fatal(err)
}
if err := result.WriteExplanation(&history, configkit.Keys("secret")); err != nil {
t.Fatal(err)
}
if strings.Contains(effective.String()+history.String(), "old-secret-canary") || strings.Contains(effective.String()+history.String(), "new-secret-canary") {
t.Fatalf("secret output leaked: %q %q", effective.String(), history.String())
}
_, err = configkit.LoadLayered(configkit.Input{Source: configkit.Bytes("main", []byte("secret: new-secret-canary\n"))}, configkit.LayeredOptions[config]{
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.Mapping("lower", map[string]any{"secret": "old-secret-canary"})}}}},
Definition: configkit.Definition[config]{Validate: func(config *config, validation *configkit.Validation) {
validation.Error(configkit.Keys("secret"), "invalid secret")
}},
})
loadError, ok := err.(*configkit.LoadError)
if !ok {
t.Fatalf("validation error = %#v", err)
}
var diagnostics bytes.Buffer
if err := configkit.WriteDiagnostics(&diagnostics, loadError.Diagnostics()); err != nil {
t.Fatal(err)
}
if strings.Contains(diagnostics.String(), "secret-canary") {
t.Fatalf("diagnostic leaked secret: %q", diagnostics.String())
}
}
func layeredErrorCode(t *testing.T, err error) configkit.Code {
t.Helper()
loadError, ok := err.(*configkit.LoadError)
if !ok || len(loadError.Diagnostics()) == 0 {
t.Fatalf("load error = %#v", err)
}
return loadError.Diagnostics()[0].Code
}

142
facade_filesystem_test.go Normal file
View File

@@ -0,0 +1,142 @@
package configkit_test
import (
"embed"
"io/fs"
"reflect"
"strconv"
"strings"
"testing"
"testing/fstest"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
//go:embed testdata/filesystem
var filesystemFixtures embed.FS
type filesystemFacadeConfig struct {
Items map[string]string `yaml:"items"`
}
func TestFacadeFilesystemSourceImportsOverlayAndReuse(t *testing.T) {
fixture, err := fs.Sub(filesystemFixtures, "testdata/filesystem")
if err != nil {
t.Fatal(err)
}
namespace := configkit.NewFilesystem("embedded", fixture)
copyOfNamespace := namespace
source := namespace.File("config/main.yml")
for range 2 {
result, err := configkit.Load(source, configkit.Options[filesystemFacadeConfig]{
Imports: true, Overlay: "testing", Filesystem: &configkit.FilesystemContext{FS: &copyOfNamespace},
})
if err != nil {
t.Fatal(err)
}
if want := (map[string]string{"main": "selected", "shared": "default"}); !reflect.DeepEqual(result.Config.Items, want) {
t.Fatalf("items = %#v, want %#v", result.Config.Items, want)
}
sources := result.Sources()
if len(sources) != 3 {
t.Fatalf("sources = %#v", sources)
}
for index, want := range []string{"config/main.yml", "shared.yml", "overlays/testing.yml"} {
if sources[index].ID != "source-"+strconv.Itoa(index+1) || sources[index].Path != want || sources[index].Kind != configkit.SourceKindFilesystem {
t.Errorf("source %d = %#v", index, sources[index])
}
if sources[index].Label != "embedded/"+want {
t.Errorf("source label = %q", sources[index].Label)
}
}
if sources[1].ImportedFrom == nil || sources[1].ImportedFrom.SourceID != sources[0].ID || sources[1].ImportedFrom.Line == 0 {
t.Fatalf("import location = %#v", sources[1].ImportedFrom)
}
}
}
func TestFacadeFilesystemContextsAndIdentity(t *testing.T) {
first := configkit.NewFilesystem("same", fstest.MapFS{
"root/config/main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: ../shared.yml}]}\n")},
"root/shared.yml": &fstest.MapFile{Data: []byte("items: {value: first}\n")},
"root/overlays/testing.yml": &fstest.MapFile{Data: []byte("items: {overlay: first}\n")},
})
second := configkit.NewFilesystem("same", fstest.MapFS{
"root/config/main.yml": &fstest.MapFile{Data: []byte("items: {value: second}\n")},
})
context := &configkit.FilesystemContext{Root: "root", FS: &first}
result, err := configkit.Load(first.File("root/config/main.yml"), configkit.Options[filesystemFacadeConfig]{Imports: true, Overlay: "testing", Filesystem: context})
if err != nil || result.Config.Items["value"] != "first" || result.Config.Items["overlay"] != "first" {
t.Fatalf("contained load = %#v, %v", result, err)
}
result, err = configkit.Load[filesystemFacadeConfig](second.File("root/config/main.yml"))
if err != nil || result.Config.Items["value"] != "second" {
t.Fatalf("same-label independent namespace = %#v, %v", result, err)
}
checkCode(t, second.File("root/config/main.yml"), configkit.Options[filesystemFacadeConfig]{Filesystem: context}, configkit.CodeOptionsInvalid)
checkCode(t, first.File("root/config/main.yml"), configkit.Options[filesystemFacadeConfig]{Filesystem: &configkit.FilesystemContext{Root: "root/config", FS: &first}, Imports: true}, configkit.CodeImportOutsideRoot)
checkCode(t, configkit.File("testdata/filesystem/config/main.yml"), configkit.Options[filesystemFacadeConfig]{Filesystem: context}, configkit.CodeOptionsInvalid)
for _, input := range []struct {
name string
source configkit.Source
kind configkit.SourceKind
}{
{"bytes", configkit.Bytes("memory", []byte("configkit: {imports: [{path: shared.yml}]}\n")), configkit.SourceKindBytes},
{"reader", configkit.Reader("stream", strings.NewReader("configkit: {imports: [{path: shared.yml}]}\n")), configkit.SourceKindReader},
} {
t.Run(input.name, func(t *testing.T) {
result, err := configkit.Load(input.source, configkit.Options[filesystemFacadeConfig]{Imports: true, Filesystem: &configkit.FilesystemContext{Root: "root", BaseDir: "root", FS: &first}})
if err != nil || result.Config.Items["value"] != "first" {
t.Fatalf("synthetic import = %#v, %v", result, err)
}
if got := result.Sources(); len(got) != 2 || got[0].Kind != input.kind || got[0].Path != "" || got[1].Kind != configkit.SourceKindFilesystem {
t.Fatalf("sources = %#v", got)
}
})
}
checkCode(t, configkit.Bytes("memory", []byte("items: {}\n")), configkit.Options[filesystemFacadeConfig]{Filesystem: &configkit.FilesystemContext{Root: "root", BaseDir: ".", FS: &first}}, configkit.CodeImportOutsideRoot)
result, err = configkit.Load(configkit.Bytes("memory", []byte("items: {value: main}\n")), configkit.Options[filesystemFacadeConfig]{
Overlay: "testing", Filesystem: &configkit.FilesystemContext{Root: "root", BaseDir: "root", FS: &first},
})
if err != nil || result.Config.Items["value"] != "main" || result.Config.Items["overlay"] != "first" {
t.Fatalf("synthetic selected overlay = %#v, %v", result, err)
}
}
func TestFacadeSourceKindsIncludeHostAndCLI(t *testing.T) {
result, err := configkit.Load(configkit.File("testdata/filesystem/shared.yml"), configkit.Options[filesystemFacadeConfig]{
Overrides: []configkit.Override{{Path: configkit.Keys("items", "shared"), Value: "changed", Flag: "--shared"}},
})
if err != nil {
t.Fatal(err)
}
sources := result.Sources()
if len(sources) != 2 || sources[0].Kind != configkit.SourceKindFile || sources[0].Path == "" || sources[1].Kind != configkit.SourceKindCLI || sources[1].Path != "" {
t.Fatalf("host and CLI sources = %#v", sources)
}
}
func TestFacadeFilesystemConstructorDiagnostics(t *testing.T) {
var nilFilesystem fs.FS
var zero configkit.Filesystem
for _, source := range []configkit.Source{
zero.File("main.yml"),
configkit.NewFilesystem("", fstest.MapFS{}).File("main.yml"),
configkit.NewFilesystem("bad\nlabel", fstest.MapFS{}).File("main.yml"),
configkit.NewFilesystem("good", nilFilesystem).File("main.yml"),
configkit.NewFilesystem("good", fstest.MapFS{}).File("."),
configkit.NewFilesystem("good", fstest.MapFS{}).File("../outside.yml"),
} {
checkCode(t, source, configkit.Options[filesystemFacadeConfig]{}, configkit.CodeSourceInvalid)
}
}
func checkCode(t *testing.T, source configkit.Source, options configkit.Options[filesystemFacadeConfig], want configkit.Code) {
t.Helper()
_, err := configkit.Load(source, options)
loadError, ok := err.(*configkit.LoadError)
if !ok || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != want {
t.Fatalf("error = %#v, want %s", err, want)
}
}

284
facade_inheritance_test.go Normal file
View File

@@ -0,0 +1,284 @@
package configkit_test
import (
"bytes"
"io/fs"
"reflect"
"strings"
"testing"
"testing/fstest"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
type inheritedEntry struct {
Enabled bool `yaml:"enabled"`
Count int `yaml:"count"`
Resource string `yaml:"resource"`
Settings map[string]map[string]string `yaml:"settings"`
}
type inheritedConfiguration struct {
Version string `yaml:"version"`
Entries map[string]inheritedEntry `yaml:"entries"`
}
func TestFacadeInheritancePreservesPresenceOriginsAndHistory(t *testing.T) {
input := []byte(`entries:
parent:
enabled: false
count: 0
settings:
primary: {value: original}
secondary: {extends: primary}
child:
extends: parent
settings:
primary: {value: custom}
`)
defaultRan := false
result, err := configkit.Load(configkit.Bytes("config.yml", input), configkit.Options[inheritedConfiguration]{Definition: configkit.Definition[inheritedConfiguration]{
Resolve: func(config *inheritedConfiguration, resolution *configkit.Resolution) error {
if !resolution.IsExplicit(configkit.Keys("entries", "child", "enabled")) {
t.Error("inherited false is not explicit")
}
info, found := resolution.InputInfo(configkit.Keys("entries", "child", "enabled"))
if !found || len(info.Origin.Inheritance) != 1 {
t.Errorf("resolver inherited origin = %#v, %v", info, found)
}
child := config.Entries["child"]
configkit.Default(resolution, configkit.Keys("entries", "child", "enabled"), &child.Enabled, true, "use fallback")
defaultRan = child.Enabled
config.Entries["child"] = child
return nil
},
}})
if err != nil {
t.Fatal(err)
}
child := result.Config.Entries["child"]
if defaultRan || child.Enabled || child.Count != 0 || child.Settings["secondary"]["value"] != "custom" {
t.Fatalf("inherited values = %#v, default ran %v", child, defaultRan)
}
path := configkit.Keys("entries", "child", "settings", "secondary", "value")
info, found := result.Metadata().Lookup(path)
if !found || !info.Explicit || len(info.Origin.Inheritance) != 1 ||
info.Origin.Inheritance[0].From.String() != "/entries/child/settings/primary/value" ||
info.Origin.Inheritance[0].To.String() != path.String() {
t.Fatalf("nested destination origin = %#v, %v", info, found)
}
info.Origin.Inheritance[0].Locations[0].SourceID = "mutated"
info, _ = result.Metadata().Lookup(path)
if info.Origin.Inheritance[0].Locations[0].SourceID == "mutated" {
t.Fatal("public inheritance record aliases inspection state")
}
if _, found := result.Metadata().Lookup(configkit.Keys("entries", "child", "extends")); found {
t.Fatal("consumed directive remained effective")
}
control, found := result.Explain(configkit.Keys("entries", "child", "extends"))
if !found || control.Effective.Present || len(control.History) < 2 ||
control.History[len(control.History)-1].Origin.Operation != configkit.ChangeInheritance {
t.Fatalf("consumed directive = %#v, %v", control, found)
}
explanation, found := result.Explain(path)
if !found || explanation.Effective.YAML != "custom\n" {
t.Fatalf("nested explanation = %#v, %v", explanation, found)
}
for _, change := range explanation.History {
if change.Origin.Operation == configkit.ChangeDecoding {
t.Fatalf("inherited value attributed to decoding: %#v", explanation.History)
}
}
}
func TestFacadeInheritanceLayeredPrecedenceAndSourceContext(t *testing.T) {
filesystem := configkit.NewFilesystem("bundle", fstest.MapFS{
"config/main.yml": &fstest.MapFile{Data: []byte("entries: {child: {extends: parent}}\n")},
"shared/parent.yml": &fstest.MapFile{Data: []byte("entries: {parent: {resource: icon.txt, count: 1}}\n")},
"shared/flags.yml": &fstest.MapFile{Data: []byte("entries: {parent: {enabled: false}}\n")},
"shared/icon.txt": &fstest.MapFile{Data: []byte("parent-resource")},
"config/overlays/test.yml": &fstest.MapFile{Data: []byte("entries: {parent: {count: 2}}\n")},
})
result, err := configkit.LoadLayered(configkit.Input{Source: filesystem.File("config/main.yml")}, configkit.LayeredOptions[inheritedConfiguration]{
Underlays: []configkit.Underlay{
{Inputs: []configkit.Input{{Source: filesystem.File("shared/parent.yml")}}},
{Inputs: []configkit.Input{{Source: filesystem.File("shared/flags.yml")}}},
},
Overlay: "test", OverlayFilesystem: &configkit.FilesystemContext{Root: "config", BaseDir: "config", FS: &filesystem},
Overrides: []configkit.Override{{Path: configkit.Keys("entries", "child", "count"), Value: 3, Flag: "--count"}},
})
if err != nil {
t.Fatal(err)
}
child := result.Config.Entries["child"]
if child.Count != 3 || child.Resource != "icon.txt" {
t.Fatalf("layered inheritance = %#v", child)
}
info, found := result.Metadata().Lookup(configkit.Keys("entries", "child", "resource"))
if !found || len(info.Origin.Inheritance) != 1 || len(info.Origin.Locations) != 1 {
t.Fatalf("resource origin = %#v, %v", info, found)
}
context, found := result.SourceContext(info.Origin.Locations[0].SourceID)
if !found || context.BaseDir != "shared" || context.FS == nil {
t.Fatalf("source context = %#v, %v", context, found)
}
data, err := fs.ReadFile(context.FS, context.BaseDir+"/"+child.Resource)
if err != nil || string(data) != "parent-resource" {
t.Fatalf("source-relative resource = %q, %v", data, err)
}
count, found := result.Metadata().Lookup(configkit.Keys("entries", "child", "count"))
if !found || count.Origin.Layer != configkit.LayerCLI || len(count.Origin.Inheritance) != 0 {
t.Fatalf("CLI replacement origin = %#v, %v", count, found)
}
var rendered bytes.Buffer
if err := result.WriteEffective(&rendered); err != nil || strings.Contains(rendered.String(), "extends:") {
t.Fatalf("effective rendering = %q, %v", rendered.String(), err)
}
}
func TestFacadeInheritanceMappingMatchesBytes(t *testing.T) {
values := map[string]any{"entries": map[string]any{
"parent": map[string]any{"enabled": false, "count": 0},
"child": map[string]any{"extends": "parent"},
}}
mapped, err := configkit.Load[inheritedConfiguration](configkit.Mapping("mapping", values))
if err != nil {
t.Fatal(err)
}
fromYAML, err := configkit.Load[inheritedConfiguration](configkit.Bytes("config.yml", []byte("entries: {parent: {enabled: false, count: 0}, child: {extends: parent}}\n")))
if err != nil || !reflect.DeepEqual(mapped.Config, fromYAML.Config) {
t.Fatalf("mapping and bytes differ: %#v, %#v, %v", mapped, fromYAML, err)
}
}
func TestFacadeInheritanceProtectsVersionOwnership(t *testing.T) {
for _, test := range []struct {
name, input string
path configkit.Path
wantFailure bool
}{
{"inherited version", "entries: {parent: {count: 1}, child: {extends: parent}}\n", configkit.Keys("entries", "child", "count"), true},
{"overridden version", "entries: {parent: {count: 1}, child: {extends: parent, count: 1}}\n", configkit.Keys("entries", "child", "count"), true},
{"direct unchanged version", "entries: {parent: {enabled: false}, child: {extends: parent, count: 1}}\n", configkit.Keys("entries", "child", "count"), false},
{"consumed checked directive", "entries: {parent: {count: 1}, child: {extends: parent}}\n", configkit.Keys("entries", "child", "extends"), true},
{"independent top-level version", "version: v1\nentries: {parent: {count: 1}, child: {extends: parent}}\n", configkit.Keys("version"), false},
} {
t.Run(test.name, func(t *testing.T) {
checks := struct{ version, defaults, resolution, validation int }{}
result, err := configkit.Load(configkit.Bytes("version.yml", []byte(test.input)), configkit.Options[inheritedConfiguration]{Definition: configkit.Definition[inheritedConfiguration]{
Version: &configkit.VersionRule{Path: test.path, Check: func(configkit.Version, *configkit.Validation) error {
checks.version++
return nil
}},
Defaults: func() inheritedConfiguration { checks.defaults++; return inheritedConfiguration{} },
Resolve: func(*inheritedConfiguration, *configkit.Resolution) error { checks.resolution++; return nil },
Validate: func(*inheritedConfiguration, *configkit.Validation) { checks.validation++ },
}})
if test.wantFailure {
if result != nil || err == nil {
t.Fatalf("protected version = %#v, %v", result, err)
}
loadError, ok := err.(*configkit.LoadError)
if !ok || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != configkit.CodeInheritanceVersion ||
checks != (struct{ version, defaults, resolution, validation int }{version: 1}) {
t.Fatalf("version failure = %v, callbacks %#v", err, checks)
}
} else if err != nil || result == nil || checks != (struct{ version, defaults, resolution, validation int }{1, 1, 1, 1}) {
t.Fatalf("unchanged version = %#v, %v, callbacks %#v", result, err, checks)
}
})
}
}
func TestFacadeInheritanceFailureStopsLaterCallbacks(t *testing.T) {
checks := struct{ defaults, resolution, validation int }{}
result, err := configkit.Load(configkit.Bytes("invalid.yml", []byte("entries: {child: {extends: PRIVATE-REFERENCE-CANARY}}\n")), configkit.Options[inheritedConfiguration]{Definition: configkit.Definition[inheritedConfiguration]{
Defaults: func() inheritedConfiguration { checks.defaults++; return inheritedConfiguration{} },
Resolve: func(*inheritedConfiguration, *configkit.Resolution) error { checks.resolution++; return nil },
Validate: func(*inheritedConfiguration, *configkit.Validation) { checks.validation++ },
}})
if result != nil || err == nil || checks != (struct{ defaults, resolution, validation int }{}) || strings.Contains(err.Error(), "PRIVATE-REFERENCE-CANARY") {
t.Fatalf("invalid inheritance = %#v, %v, callbacks %#v", result, err, checks)
}
loadError, ok := err.(*configkit.LoadError)
if !ok || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != configkit.CodeInheritanceMissing {
t.Fatalf("missing reference diagnostics = %v", err)
}
}
func TestFacadeInheritanceResolvesAfterImportsAndCLIReferenceChange(t *testing.T) {
filesystem := configkit.NewFilesystem("bundle", fstest.MapFS{
"main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: parent.yml}]}\nentries: {child: {extends: first}}\n")},
"parent.yml": &fstest.MapFile{Data: []byte("entries: {first: {count: 1}, second: {count: 2}}\n")},
})
result, err := configkit.Load(filesystem.File("main.yml"), configkit.Options[inheritedConfiguration]{
Imports: true, Overrides: []configkit.Override{{Path: configkit.Keys("entries", "child", "extends"), Value: "second", Flag: "--extends"}},
})
if err != nil || result.Config.Entries["child"].Count != 2 {
t.Fatalf("import and CLI reference = %#v, %v", result, err)
}
history := result.Metadata().History(configkit.Keys("entries", "child", "extends"))
if len(history) < 3 || history[len(history)-1].Origin.Operation != configkit.ChangeInheritance {
t.Fatalf("superseded reference history = %#v", history)
}
conflicting := configkit.NewFilesystem("bundle", fstest.MapFS{
"main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: parent.yml}]}\nentries: {child: {extends: first}, first: {count: 3}}\n")},
"parent.yml": &fstest.MapFile{Data: []byte("entries: {first: {count: 1}}\n")},
})
_, err = configkit.Load[inheritedConfiguration](conflicting.File("main.yml"), configkit.Options[inheritedConfiguration]{Imports: true})
loadError, ok := err.(*configkit.LoadError)
if !ok || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != configkit.CodeComposeConflict {
t.Fatalf("strict conflict before expansion = %v", err)
}
}
func TestFacadeInheritanceSelectedOverlayCancelsRelationship(t *testing.T) {
filesystem := configkit.NewFilesystem("bundle", fstest.MapFS{
"main.yml": &fstest.MapFile{Data: []byte("entries: {parent: {count: 7}, child: {extends: parent, enabled: false}}\n")},
"overlays/test.yml": &fstest.MapFile{Data: []byte("entries: {child: {extends: null}}\n")},
})
result, err := configkit.Load(filesystem.File("main.yml"), configkit.Options[inheritedConfiguration]{
Overlay: "test", Filesystem: &configkit.FilesystemContext{Root: ".", FS: &filesystem},
})
if err != nil || result.Config.Entries["child"].Count != 0 || result.Config.Entries["child"].Enabled {
if loadError, ok := err.(*configkit.LoadError); ok {
t.Fatalf("cancelled relationship = %#v, %v, diagnostics %#v", result, err, loadError.Diagnostics())
}
t.Fatalf("cancelled relationship = %#v, %v", result, err)
}
history := result.Metadata().History(configkit.Keys("entries", "child", "extends"))
if len(history) < 3 || history[len(history)-1].Origin.Operation != configkit.ChangeInheritance {
t.Fatalf("cancellation history = %#v", history)
}
}
func TestFacadeInheritedSecretsStayRedactedAfterLookup(t *testing.T) {
const canary = "INHERITED-RELEASE-TEST-CREDENTIAL"
t.Setenv("CONFIGKIT_INHERITED_TEST_SECRET", canary)
type entry struct {
Credential configkit.Secret `yaml:"credential"`
Plain string `yaml:"plain"`
}
type configuration struct {
Entries map[string]entry `yaml:"entries"`
}
result, err := configkit.Load[configuration](configkit.Bytes("secret.yml", []byte("entries: {parent: {credential: CONFIGKIT_INHERITED_TEST_SECRET, plain: visible}, child: {extends: parent}}\n")), configkit.Options[configuration]{Definition: configkit.Definition[configuration]{
Secret: func(path configkit.Path) bool { return path.String() == "/entries/parent/credential" },
}})
if err != nil || result.Config.Entries["child"].Credential.Value() != canary {
t.Fatalf("inherited credential = %#v, %v", result, err)
}
path := configkit.Keys("entries", "child", "credential")
explanation, found := result.Explain(path)
if !found || !explanation.Effective.Redacted || strings.Contains(explanation.Effective.YAML, canary) {
t.Fatalf("credential explanation = %#v, %v", explanation, found)
}
var rendered bytes.Buffer
if err := result.WriteEffective(&rendered); err != nil || strings.Contains(rendered.String(), canary) || !strings.Contains(rendered.String(), "visible") {
t.Fatalf("effective secret rendering = %q, %v", rendered.String(), err)
}
rendered.Reset()
if err := result.WriteExplanation(&rendered, path); err != nil || strings.Contains(rendered.String(), canary) {
t.Fatalf("historical secret rendering = %q, %v", rendered.String(), err)
}
}

243
facade_layered_test.go Normal file
View File

@@ -0,0 +1,243 @@
package configkit_test
import (
"bytes"
"os"
"path/filepath"
"reflect"
"strconv"
"strings"
"testing"
"testing/fstest"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
type layeredFacadeConfig struct {
Settings map[string]string `yaml:"settings"`
Count int `yaml:"count"`
Enabled bool `yaml:"enabled"`
List []string `yaml:"list"`
Optional *string `yaml:"optional"`
Derived string `yaml:"derived"`
}
func TestFacadeLayeredMixedSourcesAndInspection(t *testing.T) {
mainPath := filepath.Join(t.TempDir(), "main.yml")
if err := os.WriteFile(mainPath, []byte("settings: {shared: main, secret: new-canary}\ncount: 0\nenabled: false\nlist: []\noptional: null\n"), 0o600); err != nil {
t.Fatal(err)
}
lower := configkit.NewFilesystem("bundle", fstest.MapFS{
"main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: defaults.yml}]}\nsettings: {shared: lower, native: retained, secret: old-canary}\nlist: [lower]\n")},
"defaults.yml": &fstest.MapFile{Data: []byte("settings: {imported: retained}\n")},
"overlays/testing.yml": &fstest.MapFile{Data: []byte("settings: {from_lower_namespace: retained}\n")},
})
selected := configkit.NewFilesystem("bundle", fstest.MapFS{
"overlays/testing.yml": &fstest.MapFile{Data: []byte("settings: {shared: overlay}\n")},
})
checks := struct{ defaults, resolution, validation int }{}
result, err := configkit.LoadLayered(configkit.Input{Source: configkit.File(mainPath)}, configkit.LayeredOptions[layeredFacadeConfig]{
Underlays: []configkit.Underlay{
{Inputs: []configkit.Input{
{Source: lower.File("main.yml"), Imports: true},
{Source: configkit.Mapping("programmatic-low", map[string]any{"settings": map[string]any{"mapping": "retained"}, "count": 7, "enabled": true})},
{Source: lower.File("overlays/testing.yml")},
}},
{Inputs: []configkit.Input{{Source: configkit.Mapping("programmatic-high", map[string]any{
"settings": map[string]any{"shared": "middle", "second": "retained"}, "list": []any{"middle"},
})}}},
},
Overlay: "testing", OverlayFilesystem: &configkit.FilesystemContext{Root: ".", BaseDir: ".", FS: &selected},
Overrides: []configkit.Override{{Path: configkit.Keys("settings", "shared"), Value: "cli", Flag: "--shared"}},
Definition: configkit.Definition[layeredFacadeConfig]{
Defaults: func() layeredFacadeConfig { checks.defaults++; return layeredFacadeConfig{} },
Resolve: func(config *layeredFacadeConfig, resolution *configkit.Resolution) error {
checks.resolution++
if source, ok := resolution.Source("source-2"); !ok || source.Layer != configkit.LayerUnderlay || source.UnderlayIndex != 1 || source.Kind != configkit.SourceKindFilesystem {
t.Errorf("resolver source = %#v, %v", source, ok)
}
configkit.Default(resolution, configkit.Keys("derived"), &config.Derived, config.Settings["native"], "derive from native", configkit.Keys("settings", "native"))
return nil
},
Validate: func(config *layeredFacadeConfig, validation *configkit.Validation) { checks.validation++ },
Secret: func(path configkit.Path) bool { return path.String() == "/settings/secret" },
},
})
if err != nil {
t.Fatalf("layered load: %v, diagnostics %#v", err, err.(*configkit.LoadError).Diagnostics())
}
wantSettings := map[string]string{
"shared": "cli", "secret": "new-canary", "native": "retained", "imported": "retained",
"mapping": "retained", "from_lower_namespace": "retained", "second": "retained",
}
if !reflect.DeepEqual(result.Config.Settings, wantSettings) || result.Config.Count != 0 || result.Config.Enabled || result.Config.List == nil || len(result.Config.List) != 0 || result.Config.Optional != nil || result.Config.Derived != "retained" {
t.Fatalf("configuration = %#v", result.Config)
}
if checks != (struct{ defaults, resolution, validation int }{1, 1, 1}) {
t.Fatalf("lifecycle calls = %#v", checks)
}
sources := result.Sources()
if len(sources) != 8 {
t.Fatalf("sources = %#v", sources)
}
wantLayers := []configkit.Layer{configkit.LayerBase, configkit.LayerUnderlay, configkit.LayerUnderlay, configkit.LayerUnderlay, configkit.LayerUnderlay, configkit.LayerUnderlay, configkit.LayerOverlay, configkit.LayerCLI}
wantIndexes := []int{0, 1, 1, 1, 1, 2, 0, 0}
wantKinds := []configkit.SourceKind{configkit.SourceKindFile, configkit.SourceKindFilesystem, configkit.SourceKindFilesystem, configkit.SourceKindMapping, configkit.SourceKindFilesystem, configkit.SourceKindMapping, configkit.SourceKindFilesystem, configkit.SourceKindCLI}
for index, source := range sources {
if source.ID != "source-"+strconv.Itoa(index+1) || source.Layer != wantLayers[index] || source.UnderlayIndex != wantIndexes[index] || source.Kind != wantKinds[index] {
t.Errorf("source %d = %#v", index, source)
}
}
if sources[4].Path != sources[6].Path || sources[4].Label != sources[6].Label {
t.Fatalf("namespace-collision fixture changed: %#v, %#v", sources[4], sources[6])
}
sources[1].Label = "changed"
if result.Sources()[1].Label != "bundle/main.yml" {
t.Fatal("source records alias retained metadata")
}
for _, test := range []struct {
path string
want configkit.Layer
}{
{"/settings/native", configkit.LayerUnderlay},
{"/settings/shared", configkit.LayerCLI},
{"/derived", configkit.LayerResolution},
} {
path, err := configkit.ParsePath(test.path)
if err != nil {
t.Fatal(err)
}
info, ok := result.Metadata().Lookup(path)
if !ok || info.Origin.Layer != test.want {
t.Errorf("%s metadata = %#v, %v", test.path, info, ok)
}
}
container, ok := result.Metadata().Lookup(configkit.Keys("settings"))
if !ok || len(container.Origin.Locations) < 4 {
t.Fatalf("composite mapping origin = %#v, %v", container, ok)
}
explanation, ok := result.Explain(configkit.Keys("settings", "shared"))
if !ok || len(explanation.History) < 5 || explanation.Origin.Layer != configkit.LayerCLI {
t.Fatalf("shared history = %#v, %v", explanation, ok)
}
var effective, rendered bytes.Buffer
if err := result.WriteEffective(&effective); err != nil {
t.Fatal(err)
}
if err := result.WriteExplanation(&rendered, configkit.Keys("settings", "secret")); err != nil {
t.Fatal(err)
}
if strings.Contains(effective.String()+rendered.String(), "old-canary") || strings.Contains(effective.String()+rendered.String(), "new-canary") || !strings.Contains(rendered.String(), "redacted") {
t.Fatalf("secret rendering leaked: effective %q, explanation %q", effective.String(), rendered.String())
}
}
func TestFacadeLayeredDisplacedDescendantRemainsInspectable(t *testing.T) {
type config struct {
Choice string `yaml:"choice"`
}
result, err := configkit.LoadLayered(configkit.Input{Source: configkit.Mapping("main", map[string]any{"choice": "flat"})}, configkit.LayeredOptions[config]{
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.Mapping("lower", map[string]any{
"choice": map[string]any{"nested": "historical"},
})}}}},
})
if err != nil || result.Config.Choice != "flat" {
t.Fatalf("replacement load = %#v, %v", result, err)
}
path := configkit.Keys("choice", "nested")
explanation, ok := result.Explain(path)
if !ok || explanation.Effective.Present || len(explanation.History) == 0 || explanation.History[0].Origin.Layer != configkit.LayerUnderlay {
t.Fatalf("deleted descendant = %#v, %v", explanation, ok)
}
}
func TestFacadeLayeredZeroUnderlaysMatchesOrdinaryLoad(t *testing.T) {
filesystem := configkit.NewFilesystem("bundle", fstest.MapFS{
"overlays/testing.yml": &fstest.MapFile{Data: []byte("value: selected\n")},
})
context := &configkit.FilesystemContext{Root: ".", BaseDir: ".", FS: &filesystem}
source := configkit.Bytes("main", []byte("value: base\n"))
type config struct {
Value string `yaml:"value"`
}
ordinary, err := configkit.Load(source, configkit.Options[config]{Overlay: "testing", Filesystem: context})
if err != nil {
t.Fatal(err)
}
layered, err := configkit.LoadLayered(configkit.Input{Source: source}, configkit.LayeredOptions[config]{Overlay: "testing", OverlayFilesystem: context})
if err != nil {
t.Fatal(err)
}
if ordinary.Config != layered.Config || !reflect.DeepEqual(ordinary.Sources(), layered.Sources()) {
t.Fatalf("ordinary = %#v, %#v; layered = %#v, %#v", ordinary.Config, ordinary.Sources(), layered.Config, layered.Sources())
}
var first, second bytes.Buffer
if err := ordinary.WriteEffective(&first); err != nil {
t.Fatal(err)
}
if err := layered.WriteEffective(&second); err != nil {
t.Fatal(err)
}
if first.String() != second.String() {
t.Fatalf("effective ordinary %q, layered %q", first.String(), second.String())
}
if ordinaryExplanation, ok := ordinary.Explain(configkit.Keys("value")); !ok {
t.Fatal("ordinary explanation missing")
} else if layeredExplanation, ok := layered.Explain(configkit.Keys("value")); !ok || !reflect.DeepEqual(ordinaryExplanation, layeredExplanation) {
t.Fatalf("explanations differ: %#v, %#v", ordinaryExplanation, layeredExplanation)
}
}
func TestFacadeLayeredOptionAndVersionFailures(t *testing.T) {
type config struct {
Version string `yaml:"version"`
}
namespace := configkit.NewFilesystem("bundle", fstest.MapFS{
"overlays/testing.yml": &fstest.MapFile{Data: []byte("{}\n")},
})
for _, test := range []struct {
name string
main configkit.Input
options []configkit.LayeredOptions[config]
want configkit.Code
}{
{"zero source", configkit.Input{}, nil, configkit.CodeSourceInvalid},
{"multiple options", configkit.Input{Source: configkit.NoSource()}, []configkit.LayeredOptions[config]{{}, {}}, configkit.CodeOptionsInvalid},
{"missing overlay context", configkit.Input{Source: configkit.NoSource()}, []configkit.LayeredOptions[config]{{Overlay: "testing"}}, configkit.CodeOptionsInvalid},
{"selected file already acquired", configkit.Input{Source: configkit.NoSource()}, []configkit.LayeredOptions[config]{{
Overlay: "testing", OverlayFilesystem: &configkit.FilesystemContext{Root: ".", BaseDir: ".", FS: &namespace},
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: namespace.File("overlays/testing.yml")}}}},
}}, configkit.CodeOverlayRepeated},
{"underlay version", configkit.Input{Source: configkit.NoSource()}, []configkit.LayeredOptions[config]{{
Definition: configkit.Definition[config]{Version: &configkit.VersionRule{Path: configkit.Keys("version"), Check: func(configkit.Version, *configkit.Validation) error { return nil }}},
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.Mapping("lower", map[string]any{"version": "forbidden"})}}}},
}}, configkit.CodeUnderlayVersion},
} {
t.Run(test.name, func(t *testing.T) {
_, err := configkit.LoadLayered(test.main, test.options...)
loadError, ok := err.(*configkit.LoadError)
if !ok || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != test.want {
t.Fatalf("error = %#v, want %s", err, test.want)
}
})
}
}
func TestFacadeLayeredExplicitHostOverlayWithoutMain(t *testing.T) {
root := t.TempDir()
if err := os.Mkdir(filepath.Join(root, "overlays"), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(root, "overlays", "testing.yml"), []byte("value: selected\n"), 0o600); err != nil {
t.Fatal(err)
}
type config struct {
Value string `yaml:"value"`
}
result, err := configkit.LoadLayered(configkit.Input{Source: configkit.NoSource()}, configkit.LayeredOptions[config]{
Overlay: "testing", OverlayFilesystem: &configkit.FilesystemContext{Root: root, BaseDir: root},
})
if err != nil || result.Config.Value != "selected" || len(result.Sources()) != 1 || result.Sources()[0].Kind != configkit.SourceKindFile {
t.Fatalf("explicit host overlay = %#v, %v", result, err)
}
}

133
facade_mapping_test.go Normal file
View File

@@ -0,0 +1,133 @@
package configkit_test
import (
"math"
"reflect"
"strings"
"testing"
"testing/fstest"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
type mappingFacadeConfig struct {
Enabled bool `yaml:"enabled"`
Count uint64 `yaml:"count"`
Optional *string `yaml:"optional"`
Items []string `yaml:"items"`
Name string `yaml:"name"`
}
func TestFacadeMappingSnapshotsAndMatchesYAML(t *testing.T) {
items := []any{"one", "two"}
values := map[string]any{
"name": "original", "enabled": false, "count": ^uint64(0),
"optional": nil, "items": items,
}
source := configkit.Mapping("programmatic", values)
values["name"] = "mutated"
items[0] = "mutated"
for range 2 {
result, err := configkit.Load[mappingFacadeConfig](source)
if err != nil {
t.Fatal(err)
}
want := mappingFacadeConfig{Count: ^uint64(0), Items: []string{"one", "two"}, Name: "original"}
if !reflect.DeepEqual(result.Config, want) {
t.Fatalf("mapping config = %#v, want %#v", result.Config, want)
}
if sources := result.Sources(); len(sources) != 1 || sources[0].ID != "source-1" || sources[0].Kind != configkit.SourceKindMapping || sources[0].Layer != configkit.LayerBase || sources[0].Path != "" {
t.Fatalf("mapping sources = %#v", sources)
}
info, ok := result.Metadata().Lookup(configkit.Keys("name"))
if !ok || len(info.Origin.Locations) != 1 || info.Origin.Locations[0].SourceID != "source-1" || info.Origin.Locations[0].Line != 0 {
t.Fatalf("mapping name origin = %#v, %v", info, ok)
}
fromYAML, err := configkit.Load[mappingFacadeConfig](configkit.Bytes("equivalent", []byte("enabled: false\ncount: 18446744073709551615\noptional: null\nitems: [one, two]\nname: original\n")))
if err != nil || !reflect.DeepEqual(result.Config, fromYAML.Config) {
t.Fatalf("equivalent YAML = %#v, %v", fromYAML, err)
}
}
}
func TestFacadeMappingNullEmptyAndVersion(t *testing.T) {
result, err := configkit.Load[mappingFacadeConfig](configkit.Mapping("empty", map[string]any{}))
if err != nil || len(result.Sources()) != 1 {
t.Fatalf("empty mapping = %#v, %v", result, err)
}
var nilSequence []any
var nilMap map[string]any
var checked string
result, err = configkit.Load(configkit.Mapping("nullable", map[string]any{
"items": nilSequence, "optional": nilMap, "enabled": false, "count": uint64(0),
}), configkit.Options[mappingFacadeConfig]{Definition: configkit.Definition[mappingFacadeConfig]{
Version: &configkit.VersionRule{Path: configkit.Keys("count"), Check: func(version configkit.Version, validation *configkit.Validation) error {
checked = version.Text
return nil
}},
}})
if err != nil || checked != "0" || result.Config.Items != nil || result.Config.Optional != nil {
t.Fatalf("null and zero mapping = %#v, checked %q, %v", result, checked, err)
}
info, ok := result.Metadata().Lookup(configkit.Keys("items"))
if !ok || !info.Null || !info.Explicit {
t.Fatalf("null metadata = %#v, %v", info, ok)
}
}
func TestFacadeMappingImportsUseExplicitContext(t *testing.T) {
namespace := configkit.NewFilesystem("bundle", fstest.MapFS{
"root/shared.yml": &fstest.MapFile{Data: []byte("name: imported\n")},
})
source := configkit.Mapping("programmatic", map[string]any{
"configkit": map[string]any{"imports": []any{map[string]any{"path": "shared.yml"}}},
"enabled": true,
})
result, err := configkit.Load(source, configkit.Options[mappingFacadeConfig]{
Imports: true, Filesystem: &configkit.FilesystemContext{Root: "root", BaseDir: "root", FS: &namespace},
})
if err != nil || result.Config.Name != "imported" || !result.Config.Enabled {
t.Fatalf("mapping import = %#v, %v", result, err)
}
if sources := result.Sources(); len(sources) != 2 || sources[0].Kind != configkit.SourceKindMapping || sources[1].Kind != configkit.SourceKindFilesystem || sources[1].Layer != configkit.LayerBase {
t.Fatalf("mapping import sources = %#v", sources)
}
checkMappingCode(t, source, configkit.Options[mappingFacadeConfig]{Imports: true}, configkit.CodeOptionsInvalid)
checkMappingCode(t, source, configkit.Options[mappingFacadeConfig]{}, configkit.CodeImportDisabled)
}
func TestFacadeMappingDeferredValueDiagnostics(t *testing.T) {
cycle := map[string]any{}
cycle["secret"] = cycle
for _, test := range []struct {
name string
source configkit.Source
want configkit.Code
path string
}{
{"nil root", configkit.Mapping("nil", nil), configkit.CodeSourceInvalid, ""},
{"bad label", configkit.Mapping("bad\nlabel", map[string]any{}), configkit.CodeSourceInvalid, ""},
{"unsupported", configkit.Mapping("bad", map[string]any{"secret": struct{ Value string }{"canary-value"}}), configkit.CodeSourceInvalid, "/secret"},
{"nonfinite", configkit.Mapping("bad", map[string]any{"count": math.NaN()}), configkit.CodeSourceInvalid, "/count"},
{"cycle", configkit.Mapping("bad", cycle), configkit.CodeSourceInvalid, "/secret"},
{"too many bytes", configkit.Mapping("bad", map[string]any{"name": strings.Repeat("x", 8<<20+1)}), configkit.CodeSourceLimitExceeded, "/name"},
} {
t.Run(test.name, func(t *testing.T) {
loadError := checkMappingCode(t, test.source, configkit.Options[mappingFacadeConfig]{}, test.want)
diagnostic := loadError.Diagnostics()[0]
if diagnostic.Path.String() != test.path || diagnostic.Primary == nil || diagnostic.Primary.SourceID != "source-1" || strings.Contains(diagnostic.Message, "canary-value") {
t.Fatalf("diagnostic = %#v", diagnostic)
}
})
}
}
func checkMappingCode(t *testing.T, source configkit.Source, options configkit.Options[mappingFacadeConfig], want configkit.Code) *configkit.LoadError {
t.Helper()
_, err := configkit.Load(source, options)
loadError, ok := err.(*configkit.LoadError)
if !ok || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != want {
t.Fatalf("error = %#v, want %s", err, want)
}
return loadError
}

View File

@@ -0,0 +1,221 @@
package configkit_test
import (
"errors"
"io/fs"
"os"
"path"
"path/filepath"
"strings"
"testing"
"testing/fstest"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
type observedResourceFS struct {
files fstest.MapFS
opens int
closed bool
}
func (f *observedResourceFS) Open(name string) (fs.File, error) {
f.opens++
if f.closed {
return nil, fs.ErrClosed
}
return f.files.Open(name)
}
type resourceFacadeConfig struct {
Native string `yaml:"native"`
Shared string `yaml:"shared"`
Host string `yaml:"host"`
Selected string `yaml:"selected"`
}
func TestFacadeSourceContextsFollowOriginsWithoutQueryIO(t *testing.T) {
host := t.TempDir()
if err := os.WriteFile(filepath.Join(host, "main.yml"), []byte("host: host.txt\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(host, "host.txt"), []byte("host-v1"), 0o600); err != nil {
t.Fatal(err)
}
borrowed := &observedResourceFS{files: fstest.MapFS{
"bundle/config/defaults.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: ../shared.yml}]}\nnative: icon.txt\n")},
"bundle/shared.yml": &fstest.MapFile{Data: []byte("shared: shared.txt\n")},
"bundle/overlays/test.yml": &fstest.MapFile{Data: []byte("selected: overlay.txt\n")},
"bundle/config/icon.txt": &fstest.MapFile{Data: []byte("icon-v1")},
"bundle/shared.txt": &fstest.MapFile{Data: []byte("shared-v1")},
"bundle/overlays/overlay.txt": &fstest.MapFile{Data: []byte("overlay-v1")},
}}
namespace := configkit.NewFilesystem("bundle", borrowed)
var resolverContext configkit.ResourceContext
result, err := configkit.LoadLayered(configkit.Input{Source: configkit.File(filepath.Join(host, "main.yml"))}, configkit.LayeredOptions[resourceFacadeConfig]{
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{
Source: namespace.File("bundle/config/defaults.yml"), Imports: true,
Filesystem: &configkit.FilesystemContext{Root: "bundle", FS: &namespace},
}}}},
Overlay: "test", OverlayFilesystem: &configkit.FilesystemContext{Root: "bundle", BaseDir: "bundle", FS: &namespace},
Definition: configkit.Definition[resourceFacadeConfig]{Resolve: func(config *resourceFacadeConfig, resolution *configkit.Resolution) error {
before := borrowed.opens
var ok bool
resolverContext, ok = resolution.SourceContext("source-2")
if !ok || resolverContext.BaseDir != "config" || resolverContext.FS == nil {
t.Errorf("resolver context = %#v, %v", resolverContext, ok)
}
if _, ok := resolution.SourceContext("missing"); ok || borrowed.opens != before {
t.Error("resolution context query performed I/O or guessed an unknown source")
}
return nil
}},
})
if err != nil {
t.Fatalf("load: %v, diagnostics %#v", err, err.(*configkit.LoadError).Diagnostics())
}
queriesBefore := borrowed.opens
for _, test := range []struct {
field, wantBase, wantData string
}{
{"native", "config", "icon-v1"},
{"shared", ".", "shared-v1"},
{"selected", "overlays", "overlay-v1"},
{"host", ".", "host-v1"},
} {
origin, ok := result.Metadata().Lookup(configkit.Keys(test.field))
if !ok || len(origin.Origin.Locations) != 1 {
t.Fatalf("%s origin = %#v, %v", test.field, origin, ok)
}
context, ok := result.SourceContext(origin.Origin.Locations[0].SourceID)
if !ok || context.FS == nil || context.BaseDir != test.wantBase {
t.Fatalf("%s context = %#v, %v", test.field, context, ok)
}
if borrowed.opens != queriesBefore {
t.Fatal("result SourceContext query performed filesystem I/O")
}
resourceName := path.Join(context.BaseDir, map[string]string{
"native": result.Config.Native, "shared": result.Config.Shared,
"selected": result.Config.Selected, "host": result.Config.Host,
}[test.field])
data, err := fs.ReadFile(context.FS, resourceName)
if err != nil || string(data) != test.wantData {
t.Fatalf("%s resource = %q, %v", test.field, data, err)
}
queriesBefore = borrowed.opens
}
if _, ok := result.SourceContext(""); ok {
t.Fatal("empty source ID returned a context")
}
var nilResult *configkit.Result[resourceFacadeConfig]
if _, ok := nilResult.SourceContext("source-2"); ok {
t.Fatal("nil result returned a context")
}
if _, err := fs.ReadFile(resolverContext.FS, "../shared.txt"); !errors.Is(err, fs.ErrInvalid) {
t.Fatalf("native root escape = %v", err)
}
borrowed.files["bundle/config/icon.txt"].Data = []byte("icon-v2")
data, err := fs.ReadFile(resolverContext.FS, "config/icon.txt")
if err != nil || string(data) != "icon-v2" {
t.Fatalf("updated native resource = %q, %v", data, err)
}
borrowed.closed = true
if result.Config.Native != "icon.txt" {
t.Fatal("closing caller filesystem altered loaded configuration")
}
if _, err := fs.ReadFile(resolverContext.FS, "config/icon.txt"); !errors.Is(err, fs.ErrClosed) {
t.Fatalf("closed caller filesystem error = %v", err)
}
}
func TestFacadeHostContextSurvivesConfigurationDeletion(t *testing.T) {
root := t.TempDir()
main := filepath.Join(root, "main.yml")
resource := filepath.Join(root, "asset.txt")
if err := os.WriteFile(main, []byte("native: asset.txt\n"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(resource, []byte("first"), 0o600); err != nil {
t.Fatal(err)
}
result, err := configkit.Load[resourceFacadeConfig](configkit.File(main))
if err != nil {
t.Fatal(err)
}
context, ok := result.SourceContext("source-1")
if !ok || context.BaseDir != "." {
t.Fatalf("host context = %#v, %v", context, ok)
}
if err := os.Remove(main); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(resource, []byte("second"), 0o600); err != nil {
t.Fatal(err)
}
data, err := fs.ReadFile(context.FS, path.Join(context.BaseDir, result.Config.Native))
if err != nil || string(data) != "second" || result.Config.Native != "asset.txt" {
t.Fatalf("resource after source deletion = %q, %v", data, err)
}
if _, err := fs.ReadFile(context.FS, "../asset.txt"); !errors.Is(err, fs.ErrInvalid) {
t.Fatalf("invalid host path = %v", err)
}
if err := os.Remove(resource); err != nil {
t.Fatal(err)
}
if _, err := fs.ReadFile(context.FS, "asset.txt"); !errors.Is(err, fs.ErrNotExist) {
t.Fatalf("missing later resource = %v", err)
}
outside := filepath.Join(t.TempDir(), "outside.txt")
if err := os.WriteFile(outside, []byte("outside"), 0o600); err != nil {
t.Fatal(err)
}
if err := os.Symlink(outside, filepath.Join(root, "escape")); err == nil {
if _, err := fs.ReadFile(context.FS, "escape"); !errors.Is(err, fs.ErrPermission) {
t.Fatalf("host symlink escape = %v", err)
}
}
}
func TestFacadeSyntheticSourceContextsAndContextlessSources(t *testing.T) {
namespace := configkit.NewFilesystem("bundle", fstest.MapFS{
"bundle/config/icon.txt": &fstest.MapFile{Data: []byte("icon")},
})
context := &configkit.FilesystemContext{Root: "bundle", BaseDir: "bundle/config", FS: &namespace}
for _, test := range []struct {
name string
source configkit.Source
}{
{"bytes", configkit.Bytes("memory", []byte("native: icon.txt\n"))},
{"reader", configkit.Reader("stream", strings.NewReader("native: icon.txt\n"))},
{"mapping", configkit.Mapping("values", map[string]any{"native": "icon.txt"})},
} {
t.Run(test.name, func(t *testing.T) {
result, err := configkit.Load(test.source, configkit.Options[resourceFacadeConfig]{Filesystem: context})
if err != nil {
t.Fatal(err)
}
resource, ok := result.SourceContext("source-1")
if !ok || resource.BaseDir != "config" {
t.Fatalf("synthetic context = %#v, %v", resource, ok)
}
data, err := fs.ReadFile(resource.FS, path.Join(resource.BaseDir, result.Config.Native))
if err != nil || string(data) != "icon" {
t.Fatalf("synthetic resource = %q, %v", data, err)
}
})
}
result, err := configkit.Load[resourceFacadeConfig](configkit.Bytes("plain", []byte("native: none\n")))
if err != nil {
t.Fatal(err)
}
if _, ok := result.SourceContext("source-1"); ok {
t.Fatal("contextless byte source returned a context")
}
result, err = configkit.Load(configkit.NoSource(), configkit.Options[resourceFacadeConfig]{Overrides: []configkit.Override{{Path: configkit.Keys("native"), Value: "from-cli", Flag: "--native"}}})
if err != nil {
t.Fatal(err)
}
if _, ok := result.SourceContext("source-1"); ok {
t.Fatal("CLI source returned a context")
}
}

View File

@@ -0,0 +1,107 @@
package configkit_test
import (
"reflect"
"testing"
"testing/fstest"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
func TestUnderlaySequenceDescendantOrigins(t *testing.T) {
for _, later := range []bool{false, true} {
name := "first tier"
if later {
name = "later tier"
}
t.Run(name, func(t *testing.T) {
bundle := configkit.NewFilesystem("bundle", fstest.MapFS{
"main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: items.yml}]}\n")},
"items.yml": &fstest.MapFile{Data: []byte("items: [{name: first, nested: [false]}]\n")},
})
tiers := []configkit.Underlay{}
if later {
tiers = append(tiers, configkit.Underlay{Inputs: []configkit.Input{{Source: configkit.Mapping("lower", map[string]any{"other": true})}}})
}
tiers = append(tiers, configkit.Underlay{Inputs: []configkit.Input{{Source: bundle.File("main.yml"), Imports: true}}})
paths := []string{"/items", "/items/0", "/items/0/name", "/items/0/nested", "/items/0/nested/0"}
entry := map[string]configkit.Origin{}
result, err := configkit.LoadLayered[map[string]any](configkit.Input{Source: configkit.Mapping("main", map[string]any{"main": true})}, configkit.LayeredOptions[map[string]any]{
Underlays: tiers,
Definition: configkit.Definition[map[string]any]{Resolve: func(_ *map[string]any, resolution *configkit.Resolution) error {
for _, pointer := range paths {
path, _ := configkit.ParsePath(pointer)
info, ok := resolution.InputInfo(path)
if !ok {
t.Errorf("missing resolver input %s", pointer)
}
entry[pointer] = info.Origin
}
return nil
}},
})
if err != nil {
t.Fatal(err)
}
var sourceID string
for _, source := range result.Sources() {
if source.Path == "items.yml" {
sourceID = source.ID
}
}
if sourceID == "" {
t.Fatal("missing imported source")
}
for _, pointer := range paths {
path, _ := configkit.ParsePath(pointer)
info, ok := result.Metadata().Lookup(path)
if !ok || info.Origin.Layer != configkit.LayerUnderlay || len(info.Origin.Locations) != 1 || info.Origin.Locations[0].SourceID != sourceID {
t.Errorf("%s origin = %+v", pointer, info.Origin)
}
if !reflect.DeepEqual(entry[pointer], info.Origin) {
t.Errorf("%s resolver origin differs", pointer)
}
explanation, ok := result.Explain(path)
if !ok || !reflect.DeepEqual(explanation.Origin, info.Origin) {
t.Errorf("%s explanation origin differs", pointer)
}
history := result.Metadata().History(path)
if len(history) == 0 || !reflect.DeepEqual(history[len(history)-1].Origin, info.Origin) {
t.Errorf("%s history = %+v", pointer, history)
}
}
})
}
}
func TestUnderlayHistoryRetainsLaterMappingContributors(t *testing.T) {
result, err := configkit.LoadLayered[map[string]any](configkit.Input{Source: configkit.NoSource()}, configkit.LayeredOptions[map[string]any]{Underlays: []configkit.Underlay{
{Inputs: []configkit.Input{{Source: configkit.Mapping("one", map[string]any{"x": 1})}}},
{Inputs: []configkit.Input{{Source: configkit.Mapping("two", map[string]any{"x": map[string]any{}})}}},
{Inputs: []configkit.Input{{Source: configkit.Mapping("three", map[string]any{"x": map[string]any{"y": 2}})}}},
}})
if err != nil {
t.Fatal(err)
}
path := configkit.Keys("x")
info, _ := result.Metadata().Lookup(path)
wantOrigin := configkit.Origin{Layer: configkit.LayerUnderlay, Locations: []configkit.Location{{SourceID: "source-2"}, {SourceID: "source-3"}}}
if !reflect.DeepEqual(info.Origin, wantOrigin) {
t.Fatalf("effective origin = %+v, want %+v", info.Origin, wantOrigin)
}
history := result.Metadata().History(path)
if len(history) != 3 || !reflect.DeepEqual(history[len(history)-1].Origin, info.Origin) {
t.Fatalf("history = %+v, effective origin = %+v", history, info.Origin)
}
explanation, ok := result.Explain(path)
if !ok || !reflect.DeepEqual(explanation.Origin, info.Origin) {
t.Fatal("explanation and metadata disagree")
}
if len(explanation.History) != 3 {
t.Fatalf("explanation history = %+v", explanation.History)
}
last := explanation.History[len(explanation.History)-1].Origin
if last.Layer != configkit.LayerUnderlay || len(last.Locations) != 1 || last.Locations[0].SourceID != "source-3" {
t.Fatalf("last explanation origin = %+v", last)
}
}

10
go.mod
View File

@@ -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
View File

@@ -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=

View File

@@ -39,7 +39,8 @@ type Explanation struct {
}
// Explain returns safe effective and chronological values for a known final or
// historical path. Unknown and malformed paths return false.
// historical path. Structural inheritance appears between source applications
// and typed reconciliation. Unknown and malformed paths return false.
func (r *Result[T]) Explain(path Path) (Explanation, bool) {
if r == nil {
return Explanation{}, false

View File

@@ -0,0 +1,119 @@
package engine
import (
"strconv"
"testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
func BenchmarkComposeWideMappings(b *testing.B) {
for _, width := range []int{128, 256, 512} {
b.Run(strconv.Itoa(width), func(b *testing.B) {
left := benchmarkCatalog("source-1", 0, width)
right := benchmarkCatalog("source-2", width, width)
b.ReportAllocs()
b.ResetTimer()
for range b.N {
if _, diagnostics := composeDocuments(left, right); len(diagnostics) != 0 {
b.Fatal(diagnostics)
}
}
})
}
}
func BenchmarkOverlayRecursiveMappings(b *testing.B) {
for _, width := range []int{128, 256, 512} {
b.Run(strconv.Itoa(width), func(b *testing.B) {
base, diagnostics := composeDocuments(benchmarkCatalog("source-1", 0, width))
if len(diagnostics) != 0 {
b.Fatal(diagnostics)
}
overlay := benchmarkCatalog("source-2", width/2, width)
b.ReportAllocs()
b.ResetTimer()
for range b.N {
if _, diagnostics := overlayDocuments(base, overlay); len(diagnostics) != 0 {
b.Fatal(diagnostics)
}
}
})
}
}
func BenchmarkOverlayManyApplications(b *testing.B) {
for _, count := range []int{32, 64, 128} {
inputs := make([]*document.Node, count)
for index := range inputs {
inputs[index] = benchmarkCatalog("source-2", index, 1)
}
benchmarkOverlayApplications(b, count, inputs)
}
}
func BenchmarkOverlayRepeatedReplacement(b *testing.B) {
for _, count := range []int{32, 64, 128} {
inputs := make([]*document.Node, count)
for index := range inputs {
location := document.Location{SourceID: "source-2"}
value := &document.Node{Kind: document.String, Text: "replacement", Location: location}
if index%2 == 0 {
value = &document.Node{Kind: document.Mapping, Location: location, Mapping: []document.Entry{{
Key: "enabled", Value: &document.Node{Kind: document.Boolean, Text: "false", Location: location},
}}}
}
inputs[index] = &document.Node{Kind: document.Mapping, Location: location, Mapping: []document.Entry{{
Key: "catalog", Value: &document.Node{Kind: document.Mapping, Location: location, Mapping: []document.Entry{{
Key: "item-0", Value: value,
}}},
}}}
}
benchmarkOverlayApplications(b, count, inputs)
}
}
func benchmarkOverlayApplications(b *testing.B, count int, inputs []*document.Node) {
base, diagnostics := composeDocuments(benchmarkCatalog("source-1", 0, 1))
if len(diagnostics) != 0 {
b.Fatal(diagnostics)
}
for _, mode := range []string{"owned", "copy-preserving"} {
b.Run(mode+"/"+strconv.Itoa(count), func(b *testing.B) {
b.ReportAllocs()
b.ResetTimer()
for range b.N {
if mode == "owned" {
assembly := newAssemblyAccumulator(cloneComposedDocument(base))
for _, input := range inputs {
if diagnostics := assembly.applyOverlay(input, nil, nil, LayerOverlay); len(diagnostics) != 0 {
b.Fatal(diagnostics)
}
}
continue
}
current := base
for _, input := range inputs {
current, diagnostics = overlayDocuments(current, input)
if len(diagnostics) != 0 {
b.Fatal(diagnostics)
}
}
}
})
}
}
func benchmarkCatalog(source string, first, width int) *document.Node {
location := document.Location{SourceID: source}
entries := make([]document.Entry, width)
for index := range entries {
entries[index] = document.Entry{
Key: "item-" + strconv.Itoa(first+index),
Value: &document.Node{Kind: document.Mapping, Location: location, Mapping: []document.Entry{{Key: "enabled", Value: &document.Node{Kind: document.Boolean, Text: "true", Location: location}}}},
}
}
return &document.Node{Kind: document.Mapping, Location: location, Mapping: []document.Entry{{
Key: "catalog", Value: &document.Node{Kind: document.Mapping, Location: location, Mapping: entries},
}}}
}

View File

@@ -15,6 +15,17 @@ type composedDocument struct {
inputNodes int
}
// assemblyAccumulator owns one mutable working document. Acquired trees held by
// history applications are separate from its root and remain unchanged.
type assemblyAccumulator struct {
current *composedDocument
index mappingIndex
}
func newAssemblyAccumulator(owned *composedDocument) *assemblyAccumulator {
return &assemblyAccumulator{current: owned}
}
// cloneComposedDocument detaches mutable composition and provenance state;
// history application trees are immutable and retain their existing sharing.
func cloneComposedDocument(base *composedDocument) *composedDocument {
@@ -58,8 +69,14 @@ func composeDocuments(inputs ...*document.Node) (*composedDocument, []Diagnostic
collected.append(input, Path{})
}
checked := make(map[string]struct{}, len(collected.byPath))
for _, claim := range collected.ordered {
claims := collected.byPath[claim.path.String()]
pointer := claim.path.String()
if _, seen := checked[pointer]; seen {
continue
}
checked[pointer] = struct{}{}
claims := collected.byPath[pointer]
if len(claims) < 2 || allMappingClaims(claims) {
continue
}
@@ -67,21 +84,43 @@ func composeDocuments(inputs ...*document.Node) (*composedDocument, []Diagnostic
}
root := document.Clone(inputs[0])
index := new(mappingIndex)
for _, input := range inputs[1:] {
mergeDisjointMappings(root, input)
mergeDisjointMappings(root, input, index)
}
applications := make([]historyApplication, 0, len(inputs))
for _, input := range inputs {
applications = append(applications, historyApplication{layer: LayerBase, value: input})
}
origins := compositionOrigins(collected.ordered)
completeCompositionOrigins(root, Path{}, origins)
return &composedDocument{
root: root,
claims: cloneCompositionClaims(collected.ordered),
origins: compositionOrigins(collected.ordered),
origins: origins,
applications: applications,
}, nil
}
// Conflict claims treat sequences atomically, but inspection needs origins for
// every descendant. Preserve composite mapping claims while filling those gaps.
func completeCompositionOrigins(node *document.Node, path Path, origins map[string]Origin) {
pointer := path.String()
if _, present := origins[pointer]; !present {
origins[pointer] = Origin{Layer: LayerBase, Locations: []Location{publicLocation(node.Location)}}
}
switch node.Kind {
case document.Mapping:
for _, entry := range node.Mapping {
completeCompositionOrigins(entry.Value, path.Key(entry.Key), origins)
}
case document.Sequence:
for index, child := range node.Sequence {
completeCompositionOrigins(child, path.Index(index), origins)
}
}
}
func (c *compositionClaims) append(node *document.Node, path Path) {
claim := compositionClaim{
path: path.clone(),
@@ -109,18 +148,18 @@ func allMappingClaims(claims []compositionClaim) bool {
return true
}
func mergeDisjointMappings(target, incoming *document.Node) {
func mergeDisjointMappings(target, incoming *document.Node, index *mappingIndex) {
for _, incomingEntry := range incoming.Mapping {
index := documentEntryIndex(target.Mapping, incomingEntry.Key)
if index < 0 {
position := index.find(target, incomingEntry.Key)
if position < 0 {
cloned := incomingEntry
cloned.Value = document.Clone(incomingEntry.Value)
target.Mapping = append(target.Mapping, cloned)
index.append(target, cloned)
continue
}
// Claim validation guarantees that the only remaining overlap is a
// mapping pair. Empty mappings naturally contribute no child entries.
mergeDisjointMappings(target.Mapping[index].Value, incomingEntry.Value)
mergeDisjointMappings(target.Mapping[position].Value, incomingEntry.Value, index)
}
}

View File

@@ -116,6 +116,31 @@ func TestComposeDocumentsReportsAllClaimantsAtExactLocations(t *testing.T) {
assertCompositionConflict(t, diagnostics, "/group", []string{"source-1", "source-2", "source-3"})
}
func TestComposeDocumentsKeepsFirstConflictAndAppendedEntryOrder(t *testing.T) {
first := parseCompositionDocument(t, "source-1", "catalog:\n first: 1\n shared: one\n later: one\n")
second := parseCompositionDocument(t, "source-2", "catalog:\n second: 2\n shared: two\n later: two\n")
third := parseCompositionDocument(t, "source-3", "catalog:\n third: 3\n shared: three\n")
_, diagnostics := composeDocuments(first, second, third)
assertCompositionConflict(t, diagnostics, "/catalog/shared", []string{"source-1", "source-2", "source-3"})
composed, diagnostics := composeDocuments(
parseCompositionDocument(t, "source-1", "catalog:\n first: 1\n"),
parseCompositionDocument(t, "source-2", "catalog:\n second: 2\n"),
parseCompositionDocument(t, "source-3", "catalog:\n third: 3\n"),
)
if len(diagnostics) != 0 {
t.Fatalf("composeDocuments() diagnostics = %#v", diagnostics)
}
entries := composed.root.Mapping[0].Value.Mapping
got := make([]string, len(entries))
for index, entry := range entries {
got[index] = entry.Key
}
if want := []string{"first", "second", "third"}; !reflect.DeepEqual(got, want) {
t.Fatalf("catalog keys = %#v, want %#v", got, want)
}
}
func TestComposeDocumentsTreatsEmptyMappingsAsChildlessContributions(t *testing.T) {
empty, diagnostics := composeDocuments(
parseCompositionDocument(t, "source-1", "{}\n"),

View File

@@ -0,0 +1,127 @@
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")
)
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
}
func invalidDestinationDiagnostic(destination Path) Diagnostic {
return Diagnostic{
Code: CodeOptionsInvalid, Severity: SeverityError,
Path: destination.clone(),
Message: "input destination must contain only non-reserved mapping keys",
}
}
func setSourceDestinations(sources []SourceInfo, destination Path) {
for index := range sources {
sources[index].Destination = destination.clone()
}
}
// 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) {
normalized, err := normalizeDestination(destination)
if err != nil {
return Path{}, nil, err
}
if body == nil {
return normalized, nil, nil
}
if !destinationWithinLimits(body, normalized, maxDepth, maxNodes) {
return normalized, nil, errDestinationLimit
}
placed := body
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 {
if body == nil {
return true
}
if maxDepth < 0 || maxNodes < 0 || !destination.valid() {
return false
}
segments := len(destination.segments)
if segments > maxDepth || segments > maxNodes/2 {
return false
}
wrapperNodes := destinationWrapperNodeCount(destination)
for _, segment := range destination.segments {
if segment.kind != pathSegmentKey {
return false
}
}
if documentDepth(body) > maxDepth-segments {
return false
}
return countInputNodes(body) <= maxNodes-wrapperNodes
}
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
}

View File

@@ -0,0 +1,176 @@
package engine
import (
"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 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 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)
}
})
}

View 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]
}

View File

@@ -0,0 +1,208 @@
package engine
import (
"path/filepath"
"testing"
"testing/fstest"
)
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 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
optional: true
`)
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}]}\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)
}
}

View File

@@ -0,0 +1,463 @@
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)
}
})
}
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()
}

View File

@@ -0,0 +1,118 @@
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"`
}
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)
}
}

View 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)
}
}
}

View File

@@ -0,0 +1,178 @@
package engine
import (
"errors"
"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 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)
}
}

View 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]]
}

View File

@@ -85,15 +85,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 {

View File

@@ -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()

98
internal/engine/diff.go Normal file
View File

@@ -0,0 +1,98 @@
package engine
import (
"bytes"
"errors"
"io"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
yamladapter "gitea.maximumdirect.net/eric/ConfigKit/internal/yaml"
)
var errDiffInspectionUnavailable = errors.New("configkit: diff inspection unavailable")
var errDiffSnapshotUnavailable = errors.New("configkit: diff snapshot unavailable")
// Difference describes one canonical changed region with safe snapshots.
type Difference struct {
Path Path
Left ValueSnapshot
Right ValueSnapshot
HiddenChanged bool
}
type diffRegion struct {
path Path
left *document.Node
right *document.Node
}
func prepareDiffRegions(left, right *Inspection) ([]diffRegion, error) {
if left == nil || left.final == nil || right == nil || right.final == nil {
return nil, errDiffInspectionUnavailable
}
deltas := compareConfigurationValues(left.final, right.final)
regions := make([]diffRegion, 0, len(deltas))
for _, delta := range deltas {
leftNode, leftPresent := documentAtPathWithLookup(left.final, delta.path, left.immutableLookup)
if !leftPresent {
leftNode = nil
}
rightNode, rightPresent := documentAtPathWithLookup(right.final, delta.path, right.immutableLookup)
if !rightPresent {
rightNode = nil
}
regions = append(regions, diffRegion{path: delta.path, left: leftNode, right: rightNode})
}
return regions, nil
}
// Diff compares the final frozen values retained by two successful inspections.
// Values are serialized only after applying the combined sensitivity of both loads.
func Diff(left, right *Inspection) ([]Difference, error) {
regions, err := prepareDiffRegions(left, right)
if err != nil {
return nil, err
}
sensitivity := newDiffSensitivity(left.secrets, right.secrets)
differences := make([]Difference, 0, len(regions))
for _, region := range regions {
paired, supported := preparePairedDiffSnapshot(region.left, region.right, region.path, sensitivity)
if !supported {
return nil, errDiffSnapshotUnavailable
}
leftSnapshot, err := serializeDiffSnapshot(paired.left, paired.leftRedacted)
if err != nil {
return nil, errDiffSnapshotUnavailable
}
rightSnapshot, err := serializeDiffSnapshot(paired.right, paired.rightRedacted)
if err != nil {
return nil, errDiffSnapshotUnavailable
}
differences = append(differences, Difference{
Path: region.path,
Left: leftSnapshot,
Right: rightSnapshot,
HiddenChanged: paired.hiddenChanged,
})
}
return differences, nil
}
func serializeDiffSnapshot(node *document.Node, redacted bool) (ValueSnapshot, error) {
if node == nil {
return ValueSnapshot{}, nil
}
var output bytes.Buffer
if err := writeDiffDocument(&output, node); err != nil {
return ValueSnapshot{}, errDiffSnapshotUnavailable
}
return ValueSnapshot{Present: true, Redacted: redacted, YAML: output.String()}, nil
}
func writeDiffDocument(output io.Writer, node *document.Node) error {
if err := yamladapter.Write(output, node); err != nil {
return errDiffSnapshotUnavailable
}
return nil
}

View File

@@ -0,0 +1,130 @@
package engine
import (
"testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
func TestPreparePairedDiffSnapshotReportsWhollyMaskedChanges(t *testing.T) {
left := &document.Node{Kind: document.String, Text: "private-before"}
right := &document.Node{Kind: document.String, Text: "private-after"}
paired, supported := preparePairedDiffSnapshot(
left, right, Keys("credential"), newDiffSensitivity(nil, map[string]bool{"/credential": true}),
)
if !supported || !paired.hiddenChanged || !paired.leftRedacted || !paired.rightRedacted {
t.Fatalf("masked change = %#v, supported=%v", paired, supported)
}
assertDiffStringNode(t, paired.left, "[REDACTED]")
assertDiffStringNode(t, paired.right, "[REDACTED]")
}
func TestPreparePairedDiffSnapshotDetectsRawChangesBehindIdenticalMarkers(t *testing.T) {
left := parseCompositionDocument(t, "left", "token: old-canary\n")
right := parseCompositionDocument(t, "right", "token: new-canary\n")
paired, supported := preparePairedDiffSnapshot(
mappingValue(t, left, "token"), mappingValue(t, right, "token"), Keys("token"),
newDiffSensitivity(map[string]bool{"/token": true}, nil),
)
if !supported || !paired.hiddenChanged || paired.left.Text != paired.right.Text {
t.Fatalf("identically rendered masked values lost raw change: %#v, supported=%v", paired, supported)
}
}
func TestPreparePairedDiffSnapshotCombinesPublicAndHiddenChanges(t *testing.T) {
left := parseCompositionDocument(t, "left", "public: before\ntoken: old\n")
right := parseCompositionDocument(t, "right", "public: after\ntoken: new\n")
paired, supported := preparePairedDiffSnapshot(
left, right, Path{}, newDiffSensitivity(nil, map[string]bool{"/token": true}),
)
if !supported || !paired.hiddenChanged {
t.Fatalf("mixed public and hidden change = %#v, supported=%v", paired, supported)
}
assertDiffStringNode(t, mappingValue(t, paired.left, "public"), "before")
assertDiffStringNode(t, mappingValue(t, paired.right, "public"), "after")
assertDiffStringNode(t, mappingValue(t, paired.left, "token"), "[REDACTED]")
assertDiffStringNode(t, mappingValue(t, paired.right, "token"), "[REDACTED]")
}
func TestPreparePairedDiffSnapshotDoesNotFlagUnchangedMaskedValues(t *testing.T) {
left := parseCompositionDocument(t, "left", "public: before\ntoken: unchanged\n")
right := parseCompositionDocument(t, "right", "public: after\ntoken: unchanged\n")
regions, err := prepareDiffRegions(diffTestInspection(left), diffTestInspection(right))
if err != nil || len(regions) != 1 || regions[0].path.String() != "/public" {
t.Fatalf("changed regions = %#v, err = %v; want only /public", regions, err)
}
paired, supported := preparePairedDiffSnapshot(
left, right, Path{}, newDiffSensitivity(map[string]bool{"/token": true}, nil),
)
if !supported || !paired.leftRedacted || !paired.rightRedacted || paired.hiddenChanged {
t.Fatalf("unchanged masked values affected hidden-change state: %#v, supported=%v", paired, supported)
}
leftSequence := parseCompositionDocument(t, "left-sequence", "items: [unchanged, before]\n")
rightSequence := parseCompositionDocument(t, "right-sequence", "items: [unchanged, after]\n")
sequence, supported := preparePairedDiffSnapshot(
mappingValue(t, leftSequence, "items"), mappingValue(t, rightSequence, "items"), Keys("items"),
newDiffSensitivity(nil, map[string]bool{"/items/0": true}),
)
if !supported || !sequence.leftRedacted || !sequence.rightRedacted || sequence.hiddenChanged {
t.Fatalf("unchanged masked sequence member affected hidden-change state: %#v, supported=%v", sequence, supported)
}
}
func TestPreparePairedDiffSnapshotReportsMaskedAdditionAndRemoval(t *testing.T) {
value := &document.Node{Kind: document.String, Text: "private"}
tests := []struct {
name string
left, right *document.Node
leftHidden bool
rightHidden bool
}{
{name: "addition", right: value, rightHidden: true},
{name: "removal", left: value, leftHidden: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
paired, supported := preparePairedDiffSnapshot(
test.left, test.right, Keys("token"), newDiffSensitivity(nil, map[string]bool{"/token": true}),
)
if !supported || !paired.hiddenChanged || paired.leftRedacted != test.leftHidden || paired.rightRedacted != test.rightHidden {
t.Fatalf("masked presence change = %#v, supported=%v", paired, supported)
}
if test.left == nil && paired.left != nil || test.right == nil && paired.right != nil {
t.Fatalf("absence became a value: %#v", paired)
}
})
}
}
func TestPreparePairedDiffSnapshotReportsHiddenSequenceLengthChange(t *testing.T) {
left := parseCompositionDocument(t, "left", "items: [public]\n")
right := parseCompositionDocument(t, "right", "items: [public, private]\n")
paired, supported := preparePairedDiffSnapshot(
mappingValue(t, left, "items"), mappingValue(t, right, "items"), Keys("items"),
newDiffSensitivity(nil, map[string]bool{"/items/1": true}),
)
if !supported || !paired.hiddenChanged || paired.leftRedacted || !paired.rightRedacted {
t.Fatalf("masked sequence length change = %#v, supported=%v", paired, supported)
}
if len(paired.left.Sequence) != 1 || len(paired.right.Sequence) != 2 {
t.Fatalf("safe sequence lengths = %d, %d; want 1, 2", len(paired.left.Sequence), len(paired.right.Sequence))
}
assertDiffStringNode(t, paired.right.Sequence[1], "[REDACTED]")
}
func TestPreparePairedDiffSnapshotClassificationAloneDoesNotCreateHiddenChange(t *testing.T) {
left := parseCompositionDocument(t, "left", "token: same\n")
right := parseCompositionDocument(t, "right", "token: same\n")
regions, err := prepareDiffRegions(diffTestInspection(left), diffTestInspection(right))
if err != nil || len(regions) != 0 {
t.Fatalf("classification-independent changed regions = %#v, err = %v", regions, err)
}
paired, supported := preparePairedDiffSnapshot(
mappingValue(t, left, "token"), mappingValue(t, right, "token"), Keys("token"),
newDiffSensitivity(map[string]bool{"/token": true}, nil),
)
if !supported || !paired.leftRedacted || !paired.rightRedacted || paired.hiddenChanged {
t.Fatalf("classification alone reported hidden change: %#v, supported=%v", paired, supported)
}
}

View File

@@ -0,0 +1,282 @@
package engine
import (
"bytes"
"errors"
"strings"
"testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
type diffIntegrationConfig struct {
Defaulted string `yaml:"defaulted"`
Dynamic map[string]any `yaml:"dynamic"`
Credential Secret `yaml:"credential" configkit:"secret"`
Derived string `yaml:"derived"`
}
type diffOptionalSecretConfig struct {
Optional *Secret `yaml:"optional" configkit:"secret"`
}
func TestDiffUsesFinalValuesAndDependencyDerivedSensitivity(t *testing.T) {
var resolveCalls int
options := Options[diffIntegrationConfig]{Definition: Definition[diffIntegrationConfig]{
Defaults: func() diffIntegrationConfig {
return diffIntegrationConfig{Defaulted: "from-default", Dynamic: map[string]any{"defaulted": "retained"}}
},
Resolve: func(configuration *diffIntegrationConfig, resolution *Resolution) error {
resolveCalls++
configuration.Derived = configuration.Dynamic["visible"].(string)
resolution.Record(Keys("derived"), ChangeNormalization, "derive from effective values", Keys("credential"))
return nil
},
}}
input := func(value string) Source {
return Bytes("effective.yml", []byte("dynamic: {visible: "+value+"}\ncredential: DIFF_ENGINE_SECRET\n"))
}
t.Setenv("DIFF_ENGINE_SECRET", "credential-left-canary")
left, err := testLoad(input("before"), options)
if err != nil {
t.Fatal(err)
}
t.Setenv("DIFF_ENGINE_SECRET", "credential-right-canary")
right, err := testLoad(input("after"), options)
if err != nil {
t.Fatal(err)
}
if left.Config.Defaulted != "from-default" || right.Config.Defaulted != "from-default" {
t.Fatalf("defaulted values = %q, %q", left.Config.Defaulted, right.Config.Defaulted)
}
if !left.Inspection.secrets["/credential"] || !left.Inspection.secrets["/derived"] ||
!right.Inspection.secrets["/credential"] || !right.Inspection.secrets["/derived"] {
t.Fatalf("typed or dependency-derived sensitivity was not retained: left=%#v right=%#v", left.Inspection.secrets, right.Inspection.secrets)
}
if node, present := documentAtPathWithLookup(left.Inspection.final, Keys("derived"), left.Inspection.immutableLookup); !present || node.Text != "before" {
t.Fatalf("left frozen derived value = %#v, present=%v", node, present)
}
differences, err := Diff(left.Inspection, right.Inspection)
if err != nil {
t.Fatal(err)
}
if len(differences) != 2 || differences[0].Path.String() != "/derived" || differences[1].Path.String() != "/dynamic/visible" {
t.Fatalf("differences = %#v", differences)
}
if !differences[0].Left.Redacted || !differences[0].Right.Redacted || !differences[0].HiddenChanged ||
differences[0].Left.YAML != "'[REDACTED]'\n" || differences[0].Right.YAML != "'[REDACTED]'\n" {
t.Fatalf("dependency-derived difference = %#v", differences[0])
}
if differences[1].Left.Redacted || differences[1].Right.Redacted || differences[1].HiddenChanged ||
differences[1].Left.YAML != "before\n" || differences[1].Right.YAML != "after\n" {
t.Fatalf("public dynamic difference = %#v", differences[1])
}
for _, value := range differences {
if strings.Contains(value.Left.YAML+value.Right.YAML, "credential-") {
t.Fatal("diff snapshot exposed a resolved credential")
}
}
var effective bytes.Buffer
if err := left.Inspection.WriteEffective(&effective); err != nil {
t.Fatal(err)
}
if strings.Contains(effective.String(), "credential-left-canary") {
t.Fatal("standalone effective rendering exposed a credential after diff")
}
explanation, ok := left.Inspection.Explain(Keys("derived"))
if !ok || !explanation.Effective.Redacted || explanation.Effective.YAML != "'[REDACTED]'\n" {
t.Fatalf("explanation changed after diff: %#v, ok=%v", explanation, ok)
}
if _, ok := left.Inspection.Metadata().Lookup(Keys("dynamic", "visible")); !ok {
t.Fatal("metadata lookup failed after diff")
}
if resolveCalls != 2 {
t.Fatalf("resolution callbacks ran during diff: %d calls", resolveCalls)
}
// Returned records can be modified without affecting a later comparison.
differences[0].Path.segments[0].key = "changed-by-caller"
differences[0].Left.YAML = "changed-by-caller"
t.Setenv("DIFF_ENGINE_SECRET", "credential-after-load-canary")
repeated, err := Diff(left.Inspection, right.Inspection)
if err != nil || len(repeated) != 2 || repeated[0].Path.String() != "/derived" || repeated[0].Left.YAML != "'[REDACTED]'\n" {
t.Fatalf("repeated diff = %#v, err = %v", repeated, err)
}
if resolveCalls != 2 {
t.Fatalf("repeated diff reran resolution: %d calls", resolveCalls)
}
independent, err := testLoad(input("before"), options)
if err != nil {
t.Fatal(err)
}
if resolveCalls != 3 {
t.Fatalf("independent load did not retain normal resolution behavior: %d calls", resolveCalls)
}
if _, err := Diff(left.Inspection, independent.Inspection); err != nil {
t.Fatalf("independent load comparison = %v", err)
}
}
func TestDiffUsesReferenceNamesAndFrozenSecretState(t *testing.T) {
loadReference := func(name string) *testResult[diffOptionalSecretConfig] {
t.Helper()
result, err := testLoad[diffOptionalSecretConfig](Bytes("secret-reference.yml", []byte("optional: "+name+"\n")))
if err != nil {
t.Fatal(err)
}
return result
}
t.Setenv("DIFF_ROTATING_SECRET", "first-credential-canary")
left := loadReference("DIFF_ROTATING_SECRET")
t.Setenv("DIFF_ROTATING_SECRET", "second-credential-canary")
right := loadReference("DIFF_ROTATING_SECRET")
t.Setenv("DIFF_ROTATING_SECRET", "changed-after-load-canary")
differences, err := Diff(left.Inspection, right.Inspection)
if err != nil || len(differences) != 0 {
t.Fatalf("credential rotation behind one reference = %#v, err = %v", differences, err)
}
if node, present := documentAtPathWithLookup(left.Inspection.final, Keys("optional"), left.Inspection.immutableLookup); !present || node.Text != "DIFF_ROTATING_SECRET" {
t.Fatalf("frozen reference = %#v, present=%v", node, present)
}
if node, present := documentAtPathWithLookup(right.Inspection.final, Keys("optional"), right.Inspection.immutableLookup); !present || node.Text != "DIFF_ROTATING_SECRET" {
t.Fatalf("right frozen reference = %#v, present=%v", node, present)
}
t.Setenv("DIFF_OTHER_SECRET", "other-credential-canary")
other := loadReference("DIFF_OTHER_SECRET")
differences, err = Diff(left.Inspection, other.Inspection)
if err != nil || len(differences) != 1 || differences[0].Path.String() != "/optional" {
t.Fatalf("reference-name change = %#v, err = %v", differences, err)
}
if !differences[0].Left.Present || !differences[0].Right.Present || !differences[0].Left.Redacted ||
!differences[0].Right.Redacted || !differences[0].HiddenChanged ||
differences[0].Left.YAML != "'[REDACTED]'\n" || differences[0].Right.YAML != "'[REDACTED]'\n" {
t.Fatalf("classified reference-name diff = %#v", differences[0])
}
absent, err := testLoad[diffOptionalSecretConfig](Bytes("absent-secret.yml", []byte("{}\n")))
if err != nil {
t.Fatal(err)
}
if absent.Config.Optional != nil {
t.Fatalf("missing optional reference decoded as %#v", absent.Config.Optional)
}
if node, present := documentAtPathWithLookup(absent.Inspection.final, Keys("optional"), absent.Inspection.immutableLookup); !present || node.Kind != document.Null {
t.Fatalf("optional nil projection = %#v, present=%v", node, present)
}
differences, err = Diff(absent.Inspection, left.Inspection)
if err != nil || len(differences) != 1 || differences[0].Path.String() != "/optional" {
t.Fatalf("optional absence diff = %#v, err = %v", differences, err)
}
if !differences[0].Left.Present || !differences[0].Left.Redacted ||
!differences[0].Right.Present || !differences[0].Right.Redacted || !differences[0].HiddenChanged {
t.Fatalf("optional reference change snapshots = %#v", differences[0])
}
for _, value := range differences {
if strings.Contains(value.Left.YAML+value.Right.YAML, "credential-canary") {
t.Fatal("reference diff exposed a resolved credential")
}
}
}
func TestDiffProtectsHistoricalSensitiveRemovalAndReplacement(t *testing.T) {
type configuration struct {
Dynamic map[string]any `yaml:"dynamic"`
}
secretPath := Keys("dynamic", "token")
secretSelector := func(path Path) bool { return path.String() == secretPath.String() }
load := func(input string, resolve func(*configuration, *Resolution) error) *testResult[configuration] {
t.Helper()
result, err := testLoad(Bytes("historical-secret.yml", []byte(input)), Options[configuration]{Definition: Definition[configuration]{
Secret: secretSelector,
Resolve: resolve,
}})
if err != nil {
t.Fatal(err)
}
return result
}
t.Run("removed historical value", func(t *testing.T) {
left := load("dynamic: {token: removed-canary}\n", func(configuration *configuration, resolution *Resolution) error {
delete(configuration.Dynamic, "token")
resolution.Record(secretPath, ChangeNormalization, "remove token")
return nil
})
right := load("dynamic: {token: current-canary}\n", nil)
if !left.Inspection.secrets[secretPath.String()] {
t.Fatal("removed historical selector was not retained")
}
if _, present := documentAtPathWithLookup(left.Inspection.final, secretPath, left.Inspection.immutableLookup); present {
t.Fatal("removed token remains in the effective left tree")
}
differences, err := Diff(left.Inspection, right.Inspection)
if err != nil || len(differences) != 1 || differences[0].Path.String() != secretPath.String() {
t.Fatalf("historical removal diff = %#v, err = %v", differences, err)
}
if differences[0].Left.Present || !differences[0].Right.Present || !differences[0].Right.Redacted || !differences[0].HiddenChanged {
t.Fatalf("historical removal snapshots = %#v", differences[0])
}
if strings.Contains(differences[0].Left.YAML+differences[0].Right.YAML, "canary") {
t.Fatal("historical removal diff exposed a sensitive value")
}
})
t.Run("replaced historical value", func(t *testing.T) {
left := load("dynamic: {token: original-canary}\n", func(configuration *configuration, resolution *Resolution) error {
configuration.Dynamic["token"] = "replacement-canary"
resolution.Record(secretPath, ChangeNormalization, "replace token")
return nil
})
right := load("dynamic: {token: other-canary}\n", nil)
if !left.Inspection.secrets[secretPath.String()] {
t.Fatal("replaced historical selector was not retained")
}
differences, err := Diff(left.Inspection, right.Inspection)
if err != nil || len(differences) != 1 || differences[0].Path.String() != secretPath.String() {
t.Fatalf("historical replacement diff = %#v, err = %v", differences, err)
}
if !differences[0].Left.Present || !differences[0].Right.Present || !differences[0].Left.Redacted ||
!differences[0].Right.Redacted || !differences[0].HiddenChanged {
t.Fatalf("historical replacement snapshots = %#v", differences[0])
}
if strings.Contains(differences[0].Left.YAML+differences[0].Right.YAML, "canary") {
t.Fatal("historical replacement diff exposed a sensitive value")
}
})
}
func TestDiffReturnsSafeErrorsWithoutRecords(t *testing.T) {
valid := diffTestInspection(&document.Node{Kind: document.String, Text: "safe"})
for _, test := range []struct {
name string
left, right *Inspection
}{
{name: "nil left", right: valid},
{name: "nil right", left: valid},
{name: "missing final tree", left: &Inspection{}, right: valid},
} {
t.Run(test.name, func(t *testing.T) {
differences, err := Diff(test.left, test.right)
if differences != nil || err == nil || err != errDiffInspectionUnavailable || strings.Contains(err.Error(), "safe") {
t.Fatalf("differences, err = %#v, %v", differences, err)
}
})
}
}
func TestDiffSerializationErrorDoesNotExposeWriterDetails(t *testing.T) {
err := writeDiffDocument(diffErrorWriter{}, &document.Node{Kind: document.String, Text: "safe"})
if err != errDiffSnapshotUnavailable || strings.Contains(err.Error(), "CANARY") {
t.Fatalf("serialization error = %v", err)
}
}
type diffErrorWriter struct{}
func (diffErrorWriter) Write([]byte) (int, error) {
return 0, errors.New("CANARY_WRITER_ERROR")
}

View File

@@ -0,0 +1,48 @@
package engine
type diffSensitivity struct {
prefixes pathPrefixIndex
pointers []string
}
func newDiffSensitivity(left, right map[string]bool) *diffSensitivity {
sensitivity := &diffSensitivity{}
seen := make(map[string]struct{}, len(left)+len(right))
for _, classifications := range []map[string]bool{left, right} {
for pointer, classified := range classifications {
if !classified {
continue
}
if _, found := seen[pointer]; found {
continue
}
seen[pointer] = struct{}{}
id := len(sensitivity.pointers)
sensitivity.pointers = append(sensitivity.pointers, pointer)
sensitivity.prefixes.insert(pointer, id)
}
}
return sensitivity
}
func (sensitivity *diffSensitivity) isSensitiveAtOrAbove(pointer string) bool {
if sensitivity == nil {
return false
}
found := false
sensitivity.prefixes.visitAncestors(pointer, func(int) { found = true })
return found
}
func (sensitivity *diffSensitivity) hasStrictDescendant(pointer string) bool {
if sensitivity == nil {
return false
}
found := false
sensitivity.prefixes.visitDescendants(pointer, func(id int) {
if sensitivity.pointers[id] != pointer {
found = true
}
})
return found
}

View File

@@ -0,0 +1,90 @@
package engine
import (
"maps"
"testing"
)
func TestDiffSensitivityCombinesClassificationsWithoutMutation(t *testing.T) {
left := map[string]bool{
"/a": true,
"/a/b": true,
"/historical/removed": true,
"/cross~1key": true,
"/literal~01slash": true,
"/items/01": true,
"/items/+1": false,
"/false-left": false,
}
right := map[string]bool{
"/a/b": true,
"/right-only": true,
"/false-right": false,
}
leftBefore, rightBefore := maps.Clone(left), maps.Clone(right)
sensitivity := newDiffSensitivity(left, right)
for _, test := range []struct {
pointer string
want bool
}{
{pointer: "/a", want: true},
{pointer: "/a/b", want: true},
{pointer: "/a/b/c", want: true},
{pointer: "/ab", want: false},
{pointer: "/ab/c", want: false},
{pointer: "/historical/removed", want: true},
{pointer: "/historical/removed/child", want: true},
{pointer: "/historical", want: false},
{pointer: "/right-only", want: true},
{pointer: "/false-left", want: false},
{pointer: "/false-right", want: false},
{pointer: "/cross~1key", want: true},
{pointer: "/cross/key", want: false},
{pointer: "/literal~01slash", want: true},
{pointer: "/literal~1slash", want: false},
{pointer: "/items/01", want: true},
{pointer: "/items/1", want: false},
{pointer: "/items/+1", want: false},
} {
if got := sensitivity.isSensitiveAtOrAbove(test.pointer); got != test.want {
t.Errorf("isSensitiveAtOrAbove(%q) = %v, want %v", test.pointer, got, test.want)
}
}
for _, test := range []struct {
pointer string
want bool
}{
{pointer: "", want: true},
{pointer: "/a", want: true},
{pointer: "/a/b", want: false},
{pointer: "/ab", want: false},
{pointer: "/historical", want: true},
{pointer: "/right-only", want: false},
{pointer: "/cross~1key", want: false},
} {
if got := sensitivity.hasStrictDescendant(test.pointer); got != test.want {
t.Errorf("hasStrictDescendant(%q) = %v, want %v", test.pointer, got, test.want)
}
}
if !maps.Equal(left, leftBefore) || !maps.Equal(right, rightBefore) {
t.Fatal("classification maps were modified")
}
}
func TestDiffSensitivityDistinguishesRootAndEmptyKey(t *testing.T) {
root := newDiffSensitivity(map[string]bool{"": true}, nil)
if !root.isSensitiveAtOrAbove("") || !root.isSensitiveAtOrAbove("/empty") ||
root.hasStrictDescendant("") {
t.Fatal("root classification did not remain distinct from strict descendants")
}
emptyKey := newDiffSensitivity(map[string]bool{"/": true}, nil)
if emptyKey.isSensitiveAtOrAbove("") || !emptyKey.isSensitiveAtOrAbove("/") ||
emptyKey.isSensitiveAtOrAbove("/other") || !emptyKey.hasStrictDescendant("") ||
emptyKey.hasStrictDescendant("/") {
t.Fatal("empty-key classification was treated as the root or an unrelated path")
}
}

View File

@@ -0,0 +1,195 @@
package engine
import (
"sort"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
type pairedDiffSnapshot struct {
left *document.Node
right *document.Node
leftRedacted bool
rightRedacted bool
requiresFallback bool
hiddenChanged bool
}
func preparePairedDiffSnapshot(left, right *document.Node, path Path, sensitivity *diffSensitivity) (pairedDiffSnapshot, bool) {
paired, supported := preparePairedDiffNodes(left, right, path, sensitivity)
if !supported || !paired.requiresFallback {
return paired, supported
}
return pairedDiffSnapshot{
left: redactedDiffNode(left),
right: redactedDiffNode(right),
leftRedacted: left != nil,
rightRedacted: right != nil,
hiddenChanged: true,
}, true
}
func preparePairedDiffNodes(left, right *document.Node, path Path, sensitivity *diffSensitivity) (pairedDiffSnapshot, bool) {
if left == nil && right == nil {
return pairedDiffSnapshot{}, true
}
if sensitivity.isSensitiveAtOrAbove(path.String()) {
return pairedDiffSnapshot{
left: redactedDiffNode(left),
right: redactedDiffNode(right),
leftRedacted: left != nil,
rightRedacted: right != nil,
hiddenChanged: !configurationValuesEqual(left, right),
}, true
}
if left != nil && right != nil && left.Kind != right.Kind {
if sensitivity.hasStrictDescendant(path.String()) {
return pairedDiffSnapshot{requiresFallback: true}, true
}
return pairedDiffSnapshot{left: left, right: right}, true
}
kind := document.Null
if left != nil {
kind = left.Kind
} else if right != nil {
kind = right.Kind
}
switch kind {
case document.Mapping:
return preparePairedDiffMapping(left, right, path, sensitivity)
case document.Sequence:
return preparePairedDiffSequence(left, right, path, sensitivity)
default:
return pairedDiffSnapshot{left: left, right: right}, true
}
}
func preparePairedDiffMapping(left, right *document.Node, path Path, sensitivity *diffSensitivity) (pairedDiffSnapshot, bool) {
leftEntries := diffMappingEntries(left)
rightEntries := diffMappingEntries(right)
keys := make([]string, 0, len(leftEntries)+len(rightEntries))
seen := make(map[string]struct{}, len(leftEntries)+len(rightEntries))
for key := range leftEntries {
seen[key] = struct{}{}
keys = append(keys, key)
}
for key := range rightEntries {
if _, found := seen[key]; found {
continue
}
keys = append(keys, key)
}
sort.Strings(keys)
var leftCopy, rightCopy *document.Node
if left != nil {
copy := *left
copy.Mapping = make([]document.Entry, 0, len(left.Mapping))
leftCopy = &copy
}
if right != nil {
copy := *right
copy.Mapping = make([]document.Entry, 0, len(right.Mapping))
rightCopy = &copy
}
redacted := pairedDiffSnapshot{}
for _, key := range keys {
leftEntry, leftPresent := leftEntries[key]
rightEntry, rightPresent := rightEntries[key]
var leftChild, rightChild *document.Node
if leftPresent {
leftChild = leftEntry.Value
}
if rightPresent {
rightChild = rightEntry.Value
}
child, supported := preparePairedDiffNodes(leftChild, rightChild, path.Key(key), sensitivity)
if !supported {
return pairedDiffSnapshot{}, false
}
if child.requiresFallback {
return pairedDiffSnapshot{requiresFallback: true}, true
}
if leftPresent {
leftEntry.Value = child.left
leftCopy.Mapping = append(leftCopy.Mapping, leftEntry)
}
if rightPresent {
rightEntry.Value = child.right
rightCopy.Mapping = append(rightCopy.Mapping, rightEntry)
}
redacted.leftRedacted = redacted.leftRedacted || child.leftRedacted
redacted.rightRedacted = redacted.rightRedacted || child.rightRedacted
redacted.hiddenChanged = redacted.hiddenChanged || child.hiddenChanged
}
redacted.left = leftCopy
redacted.right = rightCopy
return redacted, true
}
func preparePairedDiffSequence(left, right *document.Node, path Path, sensitivity *diffSensitivity) (pairedDiffSnapshot, bool) {
var leftCopy, rightCopy *document.Node
if left != nil {
copy := *left
copy.Sequence = make([]*document.Node, len(left.Sequence))
leftCopy = &copy
}
if right != nil {
copy := *right
copy.Sequence = make([]*document.Node, len(right.Sequence))
rightCopy = &copy
}
length := 0
if left != nil {
length = len(left.Sequence)
}
if right != nil && len(right.Sequence) > length {
length = len(right.Sequence)
}
result := pairedDiffSnapshot{left: leftCopy, right: rightCopy}
for index := 0; index < length; index++ {
var leftChild, rightChild *document.Node
if left != nil && index < len(left.Sequence) {
leftChild = left.Sequence[index]
}
if right != nil && index < len(right.Sequence) {
rightChild = right.Sequence[index]
}
child, supported := preparePairedDiffNodes(leftChild, rightChild, path.Index(index), sensitivity)
if !supported {
return pairedDiffSnapshot{}, false
}
if child.requiresFallback {
return pairedDiffSnapshot{requiresFallback: true}, true
}
if left != nil && index < len(left.Sequence) {
leftCopy.Sequence[index] = child.left
}
if right != nil && index < len(right.Sequence) {
rightCopy.Sequence[index] = child.right
}
result.leftRedacted = result.leftRedacted || child.leftRedacted
result.rightRedacted = result.rightRedacted || child.rightRedacted
result.hiddenChanged = result.hiddenChanged || child.hiddenChanged
}
return result, true
}
func diffMappingEntries(node *document.Node) map[string]document.Entry {
entries := make(map[string]document.Entry)
if node == nil {
return entries
}
for _, entry := range node.Mapping {
entries[entry.Key] = entry
}
return entries
}
func redactedDiffNode(node *document.Node) *document.Node {
if node == nil {
return nil
}
return &document.Node{Kind: document.String, Text: "[REDACTED]"}
}

View File

@@ -0,0 +1,351 @@
package engine
import (
"testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
func TestPreparePairedDiffSnapshotRedactsAlignedMappingFromEitherSide(t *testing.T) {
left := parseCompositionDocument(t, "left", "credential: alpha\npublic: before\n")
right := parseCompositionDocument(t, "right", "credential: bravo\npublic: after\n")
sensitivity := newDiffSensitivity(map[string]bool{"/credential": true}, nil)
paired, supported := preparePairedDiffSnapshot(left, right, Path{}, sensitivity)
if !supported {
t.Fatal("compatible mappings were not supported")
}
if !paired.leftRedacted || !paired.rightRedacted {
t.Fatalf("redacted flags = %v, %v; want true on both sides", paired.leftRedacted, paired.rightRedacted)
}
assertDiffStringNode(t, mappingValue(t, paired.left, "credential"), "[REDACTED]")
assertDiffStringNode(t, mappingValue(t, paired.right, "credential"), "[REDACTED]")
assertDiffStringNode(t, mappingValue(t, paired.left, "public"), "before")
assertDiffStringNode(t, mappingValue(t, paired.right, "public"), "after")
assertDiffStringNode(t, mappingValue(t, left, "credential"), "alpha")
assertDiffStringNode(t, mappingValue(t, right, "credential"), "bravo")
if paired.left == left || paired.right == right || &paired.left.Mapping[0] == &left.Mapping[0] {
t.Fatal("safe mapping containers or entries share mutable storage with retained trees")
}
}
func TestPreparePairedDiffSnapshotPreservesSequencePositionsAndLengths(t *testing.T) {
left := parseCompositionDocument(t, "left", "items: [left-public, hidden, old-tail]\n")
right := parseCompositionDocument(t, "right", "items: [right-public, hidden, old-tail, extra]\n")
sensitivity := newDiffSensitivity(nil, map[string]bool{"/items/1": true})
paired, supported := preparePairedDiffSnapshot(
mappingValue(t, left, "items"), mappingValue(t, right, "items"), Keys("items"), sensitivity,
)
if !supported {
t.Fatal("compatible sequences were not supported")
}
if !paired.leftRedacted || !paired.rightRedacted {
t.Fatalf("redacted flags = %v, %v; want true on both sides", paired.leftRedacted, paired.rightRedacted)
}
if len(paired.left.Sequence) != 3 || len(paired.right.Sequence) != 4 {
t.Fatalf("sequence lengths = %d, %d; want 3, 4", len(paired.left.Sequence), len(paired.right.Sequence))
}
assertDiffStringNode(t, paired.left.Sequence[0], "left-public")
assertDiffStringNode(t, paired.right.Sequence[0], "right-public")
assertDiffStringNode(t, paired.left.Sequence[1], "[REDACTED]")
assertDiffStringNode(t, paired.right.Sequence[1], "[REDACTED]")
assertDiffStringNode(t, paired.right.Sequence[3], "extra")
assertDiffStringNode(t, mappingValue(t, left, "items").Sequence[1], "hidden")
if &paired.left.Sequence[0] == &mappingValue(t, left, "items").Sequence[0] {
t.Fatal("safe sequence shares mutable storage with retained tree")
}
}
func TestPreparePairedDiffSnapshotMasksSensitiveDescendantsOfAbsentSubtrees(t *testing.T) {
tests := []struct {
name string
left, right string
absentSide string
}{
{
name: "removed subtree",
left: "removed: {token: old-secret, public: retained}\n",
right: "other: value\n",
absentSide: "right",
},
{
name: "added subtree",
left: "other: value\n",
right: "added: {token: new-secret, public: retained}\n",
absentSide: "left",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
left := parseCompositionDocument(t, "left", test.left)
right := parseCompositionDocument(t, "right", test.right)
paired, supported := preparePairedDiffSnapshot(
mappingValueIfPresent(left, "removed", "added"),
mappingValueIfPresent(right, "removed", "added"),
Keys("subtree"),
newDiffSensitivity(map[string]bool{"/subtree/token": true}, nil),
)
if !supported {
t.Fatal("compatible subtree and absence were not supported")
}
if test.absentSide == "left" {
if paired.left != nil || paired.leftRedacted || !paired.rightRedacted {
t.Fatalf("paired absence/redaction = %#v", paired)
}
assertDiffStringNode(t, mappingValue(t, paired.right, "token"), "[REDACTED]")
assertDiffStringNode(t, mappingValue(t, paired.right, "public"), "retained")
} else {
if paired.right != nil || paired.rightRedacted || !paired.leftRedacted {
t.Fatalf("paired absence/redaction = %#v", paired)
}
assertDiffStringNode(t, mappingValue(t, paired.left, "token"), "[REDACTED]")
assertDiffStringNode(t, mappingValue(t, paired.left, "public"), "retained")
}
})
}
}
func TestPreparePairedDiffSnapshotMasksSelectedRootAndContainer(t *testing.T) {
tests := []struct {
name string
left, right *document.Node
path Path
selector string
}{
{
name: "root",
left: parseCompositionDocument(t, "left", "token: old\n"),
right: parseCompositionDocument(t, "right", "token: new\n"),
path: Path{},
selector: "",
},
{
name: "container",
left: parseCompositionDocument(t, "left", "group: {public: old}\n"),
right: parseCompositionDocument(t, "right", "group: {public: new}\n"),
path: Keys("group"),
selector: "/group",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
paired, supported := preparePairedDiffSnapshot(
test.left, test.right, test.path,
newDiffSensitivity(map[string]bool{test.selector: true}, nil),
)
if !supported {
t.Fatal("selected nodes were not supported")
}
if !paired.leftRedacted || !paired.rightRedacted {
t.Fatalf("redacted flags = %v, %v; want true on both sides", paired.leftRedacted, paired.rightRedacted)
}
assertDiffStringNode(t, paired.left, "[REDACTED]")
assertDiffStringNode(t, paired.right, "[REDACTED]")
})
}
}
func TestPreparePairedDiffSnapshotDoesNotInterpretMarkerLookingInput(t *testing.T) {
left := parseCompositionDocument(t, "left", "label: '[REDACTED]'\n")
right := parseCompositionDocument(t, "right", "label: '[REDACTED]'\n")
paired, supported := preparePairedDiffSnapshot(
mappingValue(t, left, "label"), mappingValue(t, right, "label"), Keys("label"),
newDiffSensitivity(nil, nil),
)
if !supported {
t.Fatal("ordinary scalar values were not supported")
}
if paired.leftRedacted || paired.rightRedacted {
t.Fatalf("redacted flags = %v, %v; want false", paired.leftRedacted, paired.rightRedacted)
}
if paired.left != mappingValue(t, left, "label") || paired.right != mappingValue(t, right, "label") {
t.Fatal("unselected scalar nodes should remain borrowed immutable nodes")
}
assertDiffStringNode(t, paired.left, "[REDACTED]")
assertDiffStringNode(t, paired.right, "[REDACTED]")
}
func TestPreparePairedDiffSnapshotMasksSensitiveStructuralReplacements(t *testing.T) {
tests := []struct {
name string
left, right string
path Path
selector string
}{
{
name: "mapping to scalar",
left: "changed: {token: old-secret, public: old}\n",
right: "changed: new-scalar\n",
path: Keys("changed"),
selector: "/changed/token",
},
{
name: "mapping to scalar with historical-only descendant",
left: "changed: {public: old}\n",
right: "changed: new-scalar\n",
path: Keys("changed"),
selector: "/changed/removed/secret",
},
{
name: "scalar to mapping",
left: "changed: old-scalar\n",
right: "changed: {token: new-secret, public: new}\n",
path: Keys("changed"),
selector: "/changed/token",
},
{
name: "mapping to sequence with numeric paths",
left: "changed: {'0': {token: old-secret}}\n",
right: "changed: [{token: new-secret}]\n",
path: Keys("changed"),
selector: "/changed/0/token",
},
{
name: "sequence to mapping with numeric paths",
left: "changed: [{token: old-secret}]\n",
right: "changed: {'0': {token: new-secret}}\n",
path: Keys("changed"),
selector: "/changed/0/token",
},
{
name: "null to container",
left: "changed: null\n",
right: "changed: {token: new-secret}\n",
path: Keys("changed"),
selector: "/changed/token",
},
{
name: "container to null",
left: "changed: {token: old-secret}\n",
right: "changed: null\n",
path: Keys("changed"),
selector: "/changed/token",
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
leftRoot := parseCompositionDocument(t, "left", test.left)
rightRoot := parseCompositionDocument(t, "right", test.right)
left := mappingValue(t, leftRoot, test.path.segments[0].key)
right := mappingValue(t, rightRoot, test.path.segments[0].key)
paired, supported := preparePairedDiffSnapshot(
left, right, test.path, newDiffSensitivity(nil, map[string]bool{test.selector: true}),
)
assertWholeRegionFallback(t, paired, supported, left, right)
})
}
}
func TestPreparePairedDiffSnapshotMasksOriginalRegionForNestedSequenceMismatch(t *testing.T) {
leftRoot := parseCompositionDocument(t, "left", "items: [{branch: {token: old-secret, public: old}}]\n")
rightRoot := parseCompositionDocument(t, "right", "items: [{branch: new-scalar}]\n")
left := mappingValue(t, leftRoot, "items")
right := mappingValue(t, rightRoot, "items")
paired, supported := preparePairedDiffSnapshot(
left, right, Keys("items"), newDiffSensitivity(nil, map[string]bool{"/items/0/branch/token": true}),
)
assertWholeRegionFallback(t, paired, supported, left, right)
}
func TestPreparePairedDiffSnapshotKeepsPublicScalarsWithHistoricalDescendants(t *testing.T) {
leftRoot := parseCompositionDocument(t, "left", "changed: before\n")
rightRoot := parseCompositionDocument(t, "right", "changed: after\n")
left := mappingValue(t, leftRoot, "changed")
right := mappingValue(t, rightRoot, "changed")
paired, supported := preparePairedDiffSnapshot(
left, right, Keys("changed"), newDiffSensitivity(map[string]bool{"/changed/removed/secret": true}, nil),
)
if !supported || paired.leftRedacted || paired.rightRedacted || paired.hiddenChanged {
t.Fatalf("public scalar replacement = %#v, supported=%v", paired, supported)
}
assertDiffStringNode(t, paired.left, "before")
assertDiffStringNode(t, paired.right, "after")
}
func TestPreparePairedDiffSnapshotKeepsPublicContentAcrossAbsence(t *testing.T) {
right := parseCompositionDocument(t, "right", "branch: scalar\n")
paired, supported := preparePairedDiffSnapshot(
nil, right, Keys("added"), newDiffSensitivity(nil, map[string]bool{"/added/branch/secret": true}),
)
if !supported || paired.left != nil || paired.leftRedacted || paired.rightRedacted || paired.hiddenChanged {
t.Fatalf("public addition presence/redaction = %#v, supported=%v", paired, supported)
}
assertDiffStringNode(t, mappingValue(t, paired.right, "branch"), "scalar")
}
func TestPreparePairedDiffSnapshotPreservesPublicReplacementBesideSensitiveSibling(t *testing.T) {
left := parseCompositionDocument(t, "left", "child: before\n")
right := &document.Node{Kind: document.String, Text: "after"}
paired, supported := preparePairedDiffSnapshot(
mappingValue(t, left, "child"), right, Keys("public"),
newDiffSensitivity(nil, map[string]bool{"/private/token": true}),
)
if !supported || paired.left != mappingValue(t, left, "child") || paired.right != right || paired.leftRedacted || paired.rightRedacted {
t.Fatalf("unrelated classification changed public replacement: %#v, supported=%v", paired, supported)
}
}
func TestPreparePairedDiffSnapshotTargetsRedactionAcrossAbsence(t *testing.T) {
left := parseCompositionDocument(t, "left", "items: []\n")
right := parseCompositionDocument(t, "right", "items: [{token: new-secret, public: visible}]\n")
leftItems := mappingValue(t, left, "items")
rightItems := mappingValue(t, right, "items")
paired, supported := preparePairedDiffSnapshot(
leftItems, rightItems, Keys("items"), newDiffSensitivity(nil, map[string]bool{"/items/0/token": true}),
)
if !supported || paired.left == nil || paired.right == nil || paired.leftRedacted || !paired.rightRedacted {
t.Fatalf("targeted sequence redaction = %#v, supported=%v", paired, supported)
}
if len(paired.left.Sequence) != 0 || len(paired.right.Sequence) != 1 {
t.Fatalf("sequence lengths = %d, %d; want 0, 1", len(paired.left.Sequence), len(paired.right.Sequence))
}
assertDiffStringNode(t, mappingValue(t, paired.right.Sequence[0], "token"), "[REDACTED]")
assertDiffStringNode(t, mappingValue(t, paired.right.Sequence[0], "public"), "visible")
}
func assertWholeRegionFallback(t *testing.T, paired pairedDiffSnapshot, supported bool, left, right *document.Node) {
t.Helper()
if !supported {
t.Fatal("sensitive incompatible shape was not handled")
}
if !paired.hiddenChanged {
t.Fatal("fallback for a changed reported region did not mark hidden change")
}
if (left == nil) != (paired.left == nil) || (right == nil) != (paired.right == nil) {
t.Fatalf("fallback changed root presence: %#v", paired)
}
if left != nil {
assertDiffStringNode(t, paired.left, "[REDACTED]")
if !paired.leftRedacted {
t.Fatal("present left region was not marked redacted")
}
}
if right != nil {
assertDiffStringNode(t, paired.right, "[REDACTED]")
if !paired.rightRedacted {
t.Fatal("present right region was not marked redacted")
}
}
}
func mappingValueIfPresent(node *document.Node, keys ...string) *document.Node {
if node == nil {
return nil
}
for _, key := range keys {
for _, entry := range node.Mapping {
if entry.Key == key {
return entry.Value
}
}
}
return nil
}
func assertDiffStringNode(t *testing.T, node *document.Node, want string) {
t.Helper()
if node == nil || node.Kind != document.String || node.Text != want {
t.Fatalf("node = %#v, want string %q", node, want)
}
}

View File

@@ -0,0 +1,144 @@
package engine
import (
"testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
func TestPrepareDiffRegionsUsesCanonicalConfigurationComparison(t *testing.T) {
tests := []struct {
name string
left string
right string
want []expectedDiffRegion
}{
{
name: "mapping order is ignored for equal values",
left: "first: 1\nsecond: true\n",
right: "second: true\nfirst: 1\n",
},
{
name: "addition removal null and empty values",
left: "removed: null\n",
right: "added: null\nempty_map: {}\nempty_sequence: []\n",
want: []expectedDiffRegion{
{path: "/added", rightKind: document.Null, rightPresent: true},
{path: "/empty_map", rightKind: document.Mapping, rightPresent: true},
{path: "/empty_sequence", rightKind: document.Sequence, rightPresent: true},
{path: "/removed", leftKind: document.Null, leftPresent: true},
},
},
{
name: "scalar kinds and atomic sequences",
left: "scalar: 1\nitems: [one, two]\n",
right: "scalar: '1'\nitems: [one, changed]\n",
want: []expectedDiffRegion{
{path: "/items", leftKind: document.Sequence, rightKind: document.Sequence, leftPresent: true, rightPresent: true},
{path: "/scalar", leftKind: document.Number, rightKind: document.String, leftPresent: true, rightPresent: true},
},
},
{
name: "escaped empty and numeric mapping keys sort by pointer",
left: `"": old` + "\n'0': old\n'01': old\n'a/b': old\n'a~b': old\n",
right: `"": new` + "\n'0': new\n'01': new\n'a/b': new\n'a~b': new\n",
want: []expectedDiffRegion{
{path: "/", leftKind: document.String, rightKind: document.String, leftPresent: true, rightPresent: true},
{path: "/0", leftKind: document.String, rightKind: document.String, leftPresent: true, rightPresent: true},
{path: "/01", leftKind: document.String, rightKind: document.String, leftPresent: true, rightPresent: true},
{path: "/a~0b", leftKind: document.String, rightKind: document.String, leftPresent: true, rightPresent: true},
{path: "/a~1b", leftKind: document.String, rightKind: document.String, leftPresent: true, rightPresent: true},
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
leftRoot := parseCompositionDocument(t, "left", test.left)
rightRoot := parseCompositionDocument(t, "right", test.right)
regions, err := prepareDiffRegions(diffTestInspection(leftRoot), diffTestInspection(rightRoot))
if err != nil {
t.Fatal(err)
}
if len(regions) != len(test.want) {
t.Fatalf("regions = %#v, want %#v", regions, test.want)
}
for index, want := range test.want {
region := regions[index]
if region.path.String() != want.path {
t.Fatalf("region[%d].path = %q, want %q", index, region.path.String(), want.path)
}
assertDiffRegionNode(t, "left", region.left, want.leftPresent, want.leftKind)
assertDiffRegionNode(t, "right", region.right, want.rightPresent, want.rightKind)
}
})
}
}
func TestPrepareDiffRegionsHandlesRootReplacementAndBorrowsNodes(t *testing.T) {
leftRoot := &document.Node{Kind: document.String, Text: "before"}
rightRoot := &document.Node{Kind: document.Mapping, Mapping: []document.Entry{{
Key: "value", Value: &document.Node{Kind: document.Number, Text: "1"},
}}}
regions, err := prepareDiffRegions(diffTestInspection(leftRoot), diffTestInspection(rightRoot))
if err != nil {
t.Fatal(err)
}
if len(regions) != 1 || regions[0].path.String() != "" || regions[0].left != leftRoot || regions[0].right != rightRoot {
t.Fatalf("root region = %#v", regions)
}
leftRoot = parseCompositionDocument(t, "left-child", "value: before\n")
rightRoot = parseCompositionDocument(t, "right-child", "value: after\n")
regions, err = prepareDiffRegions(diffTestInspection(leftRoot), diffTestInspection(rightRoot))
if err != nil {
t.Fatal(err)
}
if len(regions) != 1 || regions[0].left != mappingValue(t, leftRoot, "value") || regions[0].right != mappingValue(t, rightRoot, "value") {
t.Fatalf("child nodes were not borrowed from the frozen trees: %#v", regions)
}
}
func TestPrepareDiffRegionsRejectsUnavailableInspections(t *testing.T) {
valid := diffTestInspection(&document.Node{Kind: document.String, Text: "safe"})
tests := []struct {
name string
left, right *Inspection
}{
{name: "nil left", left: nil, right: valid},
{name: "nil right", left: valid, right: nil},
{name: "missing left tree", left: &Inspection{}, right: valid},
{name: "missing right tree", left: valid, right: &Inspection{}},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
regions, err := prepareDiffRegions(test.left, test.right)
if err == nil || regions != nil {
t.Fatalf("regions, err = %#v, %v", regions, err)
}
})
}
}
type expectedDiffRegion struct {
path string
leftKind, rightKind document.Kind
leftPresent, rightPresent bool
}
func diffTestInspection(root *document.Node) *Inspection {
return &Inspection{final: root, immutableLookup: buildDocumentLookup(root)}
}
func assertDiffRegionNode(t *testing.T, side string, node *document.Node, wantPresent bool, wantKind document.Kind) {
t.Helper()
if !wantPresent {
if node != nil {
t.Fatalf("%s node = %#v, want absence", side, node)
}
return
}
if node == nil || node.Kind != wantKind {
t.Fatalf("%s node = %#v, want kind %v", side, node, wantKind)
}
}

View File

@@ -24,12 +24,23 @@ type documentDelta struct {
func compareConfigurationValues(before, after *document.Node) []documentDelta {
var deltas []documentDelta
compareConfigurationValue(before, after, Path{}, &deltas)
slices.SortFunc(deltas, func(left, right documentDelta) int {
if order := cmp.Compare(left.path.String(), right.path.String()); order != 0 {
type orderedDelta struct {
delta documentDelta
pointer string
}
ordered := make([]orderedDelta, len(deltas))
for i, delta := range deltas {
ordered[i] = orderedDelta{delta: delta, pointer: delta.path.String()}
}
slices.SortFunc(ordered, func(left, right orderedDelta) int {
if order := cmp.Compare(left.pointer, right.pointer); order != 0 {
return order
}
return int(left.kind) - int(right.kind)
return int(left.delta.kind) - int(right.delta.kind)
})
for i, item := range ordered {
deltas[i] = item.delta
}
return deltas
}

View File

@@ -0,0 +1,54 @@
package engine
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
// documentLookup contains read-only mapping positions for a completed document.
type documentLookup struct {
byNode map[*document.Node]map[string]int
}
func buildDocumentLookup(roots ...*document.Node) *documentLookup {
lookup := &documentLookup{byNode: make(map[*document.Node]map[string]int)}
visited := make(map[*document.Node]struct{})
var visit func(*document.Node)
visit = func(node *document.Node) {
if node == nil {
return
}
if _, ok := visited[node]; ok {
return
}
visited[node] = struct{}{}
switch node.Kind {
case document.Mapping:
entries := make(map[string]int, len(node.Mapping))
for index, entry := range node.Mapping {
if _, exists := entries[entry.Key]; !exists {
entries[entry.Key] = index
}
visit(entry.Value)
}
lookup.byNode[node] = entries
case document.Sequence:
for _, child := range node.Sequence {
visit(child)
}
}
}
for _, root := range roots {
visit(root)
}
return lookup
}
func (lookup *documentLookup) find(node *document.Node, key string) int {
if lookup == nil {
return -1
}
if entries := lookup.byNode[node]; entries != nil {
if index, ok := entries[key]; ok {
return index
}
}
return -1
}

View File

@@ -31,6 +31,10 @@ func indexDocument(node *document.Node, path Path, index map[string]*document.No
}
func documentAtPath(root *document.Node, path Path) (*document.Node, bool) {
return documentAtPathWithLookup(root, path, nil)
}
func documentAtPathWithLookup(root *document.Node, path Path, lookup *documentLookup) (*document.Node, bool) {
if root == nil || !path.valid() {
return nil, false
}
@@ -41,17 +45,21 @@ func documentAtPath(root *document.Node, path Path) (*document.Node, bool) {
if segment.kind == pathSegmentIndex {
return nil, false
}
found := false
for _, entry := range current.Mapping {
if entry.Key == segment.key {
current = entry.Value
found = true
break
entryIndex := -1
if lookup != nil {
entryIndex = lookup.find(current, segment.key)
} else {
for index, entry := range current.Mapping {
if entry.Key == segment.key {
entryIndex = index
break
}
}
}
if !found {
if entryIndex < 0 || entryIndex >= len(current.Mapping) {
return nil, false
}
current = current.Mapping[entryIndex].Value
case document.Sequence:
index, ok := schemaIndex(segment)
if !ok || index >= len(current.Sequence) {

View File

@@ -40,7 +40,7 @@ func (r *Inspection) Explain(path Path) (Explanation, bool) {
if r == nil || r.final == nil || r.baseline == nil || !r.metadata.hasHistoricalPath(path) {
return Explanation{}, false
}
explanation := Explanation{Path: path.clone(), Effective: valueSnapshot(r.final, path, r.secrets)}
explanation := Explanation{Path: path.clone(), Effective: valueSnapshotWithLookup(r.final, path, r.secrets, r.immutableLookup)}
if info, ok := r.metadata.Lookup(path); ok {
explanation.Origin = cloneOrigin(info.Origin)
}
@@ -51,13 +51,14 @@ func (r *Inspection) Explain(path Path) (Explanation, bool) {
After: valueSnapshot(working, path, r.secrets), Origin: Origin{Layer: LayerDefault},
})
}
replayIndex := new(mappingIndex)
for _, application := range r.applications {
operationPath, relevant := applicationOperationPath(application, path)
operationPath, relevant := applicationOperationPath(application, path, r.immutableLookup)
var before ValueSnapshot
if relevant {
before = valueSnapshot(working, path, r.secrets)
}
applyHistoryApplication(&working, application)
applyHistoryApplication(&working, application, replayIndex)
if !relevant {
continue
}
@@ -68,28 +69,29 @@ func (r *Inspection) Explain(path Path) (Explanation, bool) {
explanation.History = append(explanation.History, ValueChange{
Before: before,
After: after,
Origin: historyApplicationOrigin(application, operationPath),
Origin: historyApplicationOrigin(application, operationPath, r.immutableLookup),
})
}
appendInheritanceExplanation(&explanation, &working, path, r.inheritance, r.secrets)
// Intermediate input replacements do not permanently suppress defaults:
// decoding applies them to the final input tree. Show that reconciliation
// explicitly, including canonical scalar conversions, before domain changes.
replayed, _ := documentAtPath(working, path)
decoded, _ := documentAtPath(r.input, path)
decoded, _ := documentAtPathWithLookup(r.input, path, r.immutableLookup)
if !configurationValuesEqual(replayed, decoded) {
origin := cloneOrigin(r.inputOrigins[path.String()])
origin.Operation = ChangeDecoding
explanation.History = append(explanation.History, ValueChange{
Before: valueSnapshot(working, path, r.secrets),
After: valueSnapshot(r.input, path, r.secrets),
After: valueSnapshotWithLookup(r.input, path, r.secrets, r.immutableLookup),
Origin: origin,
Reasons: []string{"applied retained defaults and canonical typed decoding"},
})
}
working = document.Clone(r.input)
for _, snapshot := range coalescedResolutionSnapshots(r.resolution) {
for _, snapshot := range r.resolutionReplay {
relevant := pathsIntersect(snapshot.change.Path, path)
var before ValueSnapshot
if relevant {

View File

@@ -23,10 +23,14 @@ func (r *Inspection) WriteExplanation(output io.Writer, path Path) error {
return errExplanationUnknownPath
}
var rendered strings.Builder
labels := make(map[string]string, len(r.sources))
for _, source := range r.sources {
labels[source.ID] = source.Label
}
rendered.WriteString("path: ")
rendered.WriteString(strconv.Quote(explanation.Path.String()))
rendered.WriteByte('\n')
writeExplanationOrigin(&rendered, "effective source", explanation.Origin, r.sources)
writeExplanationOrigin(&rendered, "effective source", explanation.Origin, labels)
writeExplanationSnapshot(&rendered, "effective (canonical typed)", explanation.Effective, 0)
if explanation.SecretReference != nil {
rendered.WriteString("secret reference: ")
@@ -38,12 +42,14 @@ func (r *Inspection) WriteExplanation(output io.Writer, path Path) error {
rendered.WriteString(" - representation: ")
if change.Origin.Operation == ChangeDecoding {
rendered.WriteString("input to canonical typed\n")
} else if change.Origin.Layer == LayerBase || change.Origin.Layer == LayerOverlay || change.Origin.Layer == LayerCLI {
} else if change.Origin.Operation == ChangeInheritance {
rendered.WriteString("structural inheritance\n")
} else if change.Origin.Layer == LayerBase || change.Origin.Layer == LayerUnderlay || change.Origin.Layer == LayerOverlay || change.Origin.Layer == LayerCLI {
rendered.WriteString("policy-validated input\n")
} else {
rendered.WriteString("canonical typed\n")
}
writeExplanationOrigin(&rendered, " source", change.Origin, r.sources)
writeExplanationOrigin(&rendered, " source", change.Origin, labels)
writeExplanationSnapshot(&rendered, " before", change.Before, 4)
writeExplanationSnapshot(&rendered, " after", change.After, 4)
for _, reason := range change.Reasons {
@@ -65,7 +71,7 @@ func (r *Inspection) WriteExplanation(output io.Writer, path Path) error {
return err
}
func writeExplanationOrigin(output *strings.Builder, label string, origin Origin, sources []SourceInfo) {
func writeExplanationOrigin(output *strings.Builder, label string, origin Origin, labels map[string]string) {
output.WriteString(label)
output.WriteString(": layer=")
output.WriteString(strconv.Quote(string(origin.Layer)))
@@ -77,7 +83,7 @@ func writeExplanationOrigin(output *strings.Builder, label string, origin Origin
for _, location := range origin.Locations {
output.WriteString(" location: source=")
output.WriteString(strconv.Quote(location.SourceID))
if label := sourceLabel(sources, location.SourceID); label != "" {
if label := labels[location.SourceID]; label != "" {
output.WriteString(" label=")
output.WriteString(strconv.Quote(label))
}
@@ -87,15 +93,15 @@ func writeExplanationOrigin(output *strings.Builder, label string, origin Origin
output.WriteString(strconv.Itoa(location.Column))
output.WriteByte('\n')
}
}
func sourceLabel(sources []SourceInfo, sourceID string) string {
for _, source := range sources {
if source.ID == sourceID {
return source.Label
}
for _, step := range origin.Inheritance {
output.WriteString(" inherited: from=")
output.WriteString(strconv.Quote(step.From.String()))
output.WriteString(" to=")
output.WriteString(strconv.Quote(step.To.String()))
output.WriteString(" reference=")
output.WriteString(strconv.Quote(step.Reference.String()))
output.WriteByte('\n')
}
return ""
}
func writeExplanationSnapshot(output *strings.Builder, label string, snapshot ValueSnapshot, indent int) {

View File

@@ -10,6 +10,7 @@ import (
"path/filepath"
"reflect"
"strings"
"sync"
"testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
@@ -520,3 +521,95 @@ func TestExplanationReplaysResolutionAdditionsAndOverlappingIntent(t *testing.T)
})
}
}
func TestExplanationReplayDescriptorsRemainDetachedAcrossQueries(t *testing.T) {
type config struct {
Count int `yaml:"count"`
}
result, err := testLoad(Bytes("resolution-explanation.yml", []byte("count: 1\n")), Options[config]{
Definition: Definition[config]{Resolve: func(value *config, resolution *Resolution) error {
value.Count = 2
resolution.Record(Keys("count"), ChangeNormalization, "normalized", Keys("count"))
return nil
}},
})
if err != nil {
t.Fatal(err)
}
first, ok := result.Explain(Keys("count"))
if !ok {
t.Fatal("missing explanation")
}
var firstResolution *ValueChange
for index := range first.History {
if first.History[index].Origin.Layer == LayerResolution {
firstResolution = &first.History[index]
break
}
}
if firstResolution == nil || len(firstResolution.Reasons) != 1 || len(firstResolution.Dependencies) != 1 {
t.Fatalf("resolution history = %#v", first.History)
}
firstResolution.Reasons[0] = "mutated"
firstResolution.Dependencies[0].segments[0].key = "mutated"
second, ok := result.Explain(Keys("count"))
if !ok {
t.Fatal("missing repeated explanation")
}
for _, change := range second.History {
if change.Origin.Layer == LayerResolution {
if !reflect.DeepEqual(change.Reasons, []string{"normalized"}) || len(change.Dependencies) != 1 || change.Dependencies[0].String() != "/count" {
t.Fatalf("repeated explanation observed caller mutation: %#v", change)
}
return
}
}
t.Fatalf("resolution change missing from repeated explanation: %#v", second.History)
}
func TestConcurrentReadOnlyInspectionQueries(t *testing.T) {
type config struct {
Group struct {
Value string `yaml:"value"`
} `yaml:"group"`
}
result, err := testLoad(Bytes("concurrent-inspection.yml", []byte("group: {value: input}\n")), Options[config]{
Definition: Definition[config]{Resolve: func(value *config, resolution *Resolution) error {
value.Group.Value = "resolved"
resolution.Record(Keys("group", "value"), ChangeNormalization, "normalized", Keys("group", "value"))
return nil
}},
})
if err != nil {
t.Fatal(err)
}
paths := []Path{Keys("group"), Keys("group", "value")}
var wait sync.WaitGroup
errors := make(chan string, 8)
for worker := 0; worker < cap(errors); worker++ {
wait.Add(1)
go func() {
defer wait.Done()
for iteration := 0; iteration < 8; iteration++ {
for _, path := range paths {
explanation, found := result.Explain(path)
if !found || !explanation.Effective.Present {
errors <- "missing effective explanation for " + path.String()
return
}
metadata := result.Metadata()
if _, found := metadata.Lookup(path); !found || len(metadata.History(path)) == 0 {
errors <- "missing metadata for " + path.String()
return
}
}
}
}()
}
wait.Wait()
close(errors)
for message := range errors {
t.Error(message)
}
}

View File

@@ -1,12 +1,21 @@
package engine
import (
"io/fs"
"os"
"path"
"path/filepath"
"strings"
)
func validateFilesystemContext(source Source, context *FilesystemContext, main parsedSource) *Diagnostic {
if nativeContext(source, context) {
access, basePath, mainPath, diagnostic := nativeFilesystemAccess(source, context)
if diagnostic != nil {
return diagnostic
}
return validateNativeDirectories(access, basePath, mainPath)
}
rootPath, _, _, diagnostic := importFilesystem(source, context, main)
if diagnostic != nil {
return diagnostic
@@ -29,20 +38,87 @@ func filesystemOptionDiagnostics(source Source, context *FilesystemContext) []Di
case sourceNone:
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "NoSource cannot use filesystem context")}
case sourceFile:
if context.FS != nil {
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "host file sources cannot use a native filesystem context")}
}
if context.BaseDir != "" {
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "file sources determine their own import base directory")}
}
case sourceBytes, sourceReader:
case sourceFilesystem:
if context.FS == nil || context.FS.namespace == nil || source.namespace == nil || context.FS.namespace.token != source.namespace.token {
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "filesystem file context must use the source namespace")}
}
if context.BaseDir != "" {
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "file sources determine their own import base directory")}
}
if context.Root != "" && !fs.ValidPath(context.Root) {
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "filesystem root must be a slash-relative valid path")}
}
case sourceBytes, sourceReader, sourceMapping:
if context.BaseDir == "" || context.Root == "" {
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "byte and reader filesystem context requires base and root directories")}
}
if !filepath.IsAbs(context.BaseDir) || !filepath.IsAbs(context.Root) {
if context.FS != nil {
if context.FS.namespace == nil || !fs.ValidPath(context.BaseDir) || !fs.ValidPath(context.Root) {
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "filesystem base and root must be slash-relative valid paths")}
}
} else if !filepath.IsAbs(context.BaseDir) || !filepath.IsAbs(context.Root) {
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "byte and reader filesystem base and root directories must be absolute")}
}
}
return nil
}
func nativeContext(source Source, context *FilesystemContext) bool {
return source.kind == sourceFilesystem || context != nil && context.FS != nil
}
// nativeFilesystemAccess resolves namespace context without opening caller
// resources. File inputs always derive their base from the selected file.
func nativeFilesystemAccess(source Source, context *FilesystemContext) (*filesystemAccess, string, string, *Diagnostic) {
var namespace *nativeNamespace
rootPath := "."
basePath := "."
mainPath := ""
if source.kind == sourceFilesystem {
namespace = source.namespace
mainPath = source.path
basePath = path.Dir(source.path)
if context != nil && context.Root != "" {
rootPath = context.Root
}
} else if context != nil && context.FS != nil {
namespace = context.FS.namespace
rootPath, basePath = context.Root, context.BaseDir
}
if namespace == nil || nilFilesystem(namespace.filesystem) || !fs.ValidPath(rootPath) || !fs.ValidPath(basePath) {
value := optionDiagnostic(CodeOptionsInvalid, "filesystem context is invalid")
return nil, "", "", &value
}
access := newNativeAccess(namespace.filesystem, rootPath, namespace.token)
access.label = namespace.label
if !access.contains(basePath) || mainPath != "" && !access.contains(mainPath) {
value := importDiagnostic(CodeImportOutsideRoot, "selected source and import base must stay inside the configuration root", nil)
return nil, "", "", &value
}
return access, basePath, mainPath, nil
}
func validateNativeDirectories(access *filesystemAccess, basePath, mainPath string) *Diagnostic {
if !access.contains(basePath) || mainPath != "" && !access.contains(mainPath) {
value := importDiagnostic(CodeImportOutsideRoot, "selected source and import base must stay inside the configuration root", nil)
return &value
}
for _, directory := range []string{access.rootPath, basePath} {
info, err := access.stat(directory)
if err != nil || !info.IsDir() {
value := importDiagnostic(CodeImportInvalid, "configuration root and import base must be directories", nil)
return &value
}
}
return nil
}
func importFilesystem(source Source, context *FilesystemContext, main parsedSource) (rootPath, basePath, mainPath string, diagnostic *Diagnostic) {
if main.source == nil || main.document == nil {
value := importDiagnostic(CodeImportInvalid, "imports require a selected configuration source", nil)

View File

@@ -0,0 +1,185 @@
package engine
import (
"errors"
"io"
"io/fs"
"os"
"path"
"path/filepath"
"strings"
)
// namespaceToken identifies one caller-selected filesystem without comparing
// arbitrary fs.FS values. A token can be shared by copies of a source wrapper.
type namespaceToken struct{ marker byte }
type nativeNamespace struct {
filesystem fs.FS
label string
token *namespaceToken
}
func newNativeNamespace(label string, filesystem fs.FS) *nativeNamespace {
return &nativeNamespace{filesystem: filesystem, label: label, token: &namespaceToken{}}
}
type resourceIdentity struct {
namespace *namespaceToken
path string
occurrence *namespaceToken
}
// filesystemAccess is a load-scoped view of either a contained host root or a
// borrowed native filesystem. Names are canonical host paths or fs.FS paths.
type filesystemAccess struct {
rootPath string
root *os.Root
native fs.FS
token *namespaceToken
label string
}
func openHostAccess(rootPath string) (*filesystemAccess, error) {
root, err := os.OpenRoot(rootPath)
if err != nil {
return nil, err
}
return &filesystemAccess{rootPath: rootPath, root: root, token: &namespaceToken{}}, nil
}
func newNativeAccess(filesystem fs.FS, root string, token *namespaceToken) *filesystemAccess {
return &filesystemAccess{rootPath: root, native: filesystem, token: token}
}
func (a *filesystemAccess) close() error {
if a.root != nil {
return a.root.Close()
}
return nil
}
func (a *filesystemAccess) identity(name string) resourceIdentity {
return resourceIdentity{namespace: a.token, path: name}
}
func (a *filesystemAccess) syntheticIdentity() resourceIdentity {
return resourceIdentity{namespace: a.token, occurrence: &namespaceToken{}}
}
func (a *filesystemAccess) join(base, name string) string {
if a.native != nil {
return path.Join(base, name)
}
return filepath.Join(base, filepath.FromSlash(path.Clean(name)))
}
func (a *filesystemAccess) dir(name string) string {
if a.native != nil {
return path.Dir(name)
}
return filepath.Dir(name)
}
func (a *filesystemAccess) contains(name string) bool {
if a.native == nil {
return containedPath(a.rootPath, filepath.Clean(name))
}
if !fs.ValidPath(name) || !fs.ValidPath(a.rootPath) {
return false
}
return a.rootPath == "." || name == a.rootPath || strings.HasPrefix(name, a.rootPath+"/")
}
func (a *filesystemAccess) canonical(name string) (string, error) {
if a.native == nil {
return canonicalExistingPath(name)
}
if !fs.ValidPath(name) {
return "", fs.ErrInvalid
}
_, err := fs.Stat(a.native, name)
if err != nil {
return "", err
}
return name, nil
}
func (a *filesystemAccess) relative(name string) (string, error) {
if a.native != nil {
if !a.contains(name) {
return "", fs.ErrInvalid
}
return name, nil
}
relative, err := filepath.Rel(a.rootPath, name)
if err != nil || filepath.IsAbs(relative) || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
return "", fs.ErrInvalid
}
return relative, nil
}
func (a *filesystemAccess) stat(name string) (fs.FileInfo, error) {
if a.native != nil {
return fs.Stat(a.native, name)
}
relative, err := a.relative(name)
if err != nil {
return nil, err
}
return a.root.Stat(relative)
}
func (a *filesystemAccess) open(name string) (fs.File, error) {
if a.native != nil {
return a.native.Open(name)
}
relative, err := a.relative(name)
if err != nil {
return nil, err
}
return a.root.Open(relative)
}
func (a *filesystemAccess) readDirectory(name string, visit func(fs.DirEntry) bool) error {
file, err := a.open(name)
if err != nil {
return err
}
if reader, ok := file.(fs.ReadDirFile); ok {
for {
entries, readErr := reader.ReadDir(globReadBatch)
for _, entry := range entries {
if !visit(entry) {
return file.Close()
}
}
if errors.Is(readErr, io.EOF) {
return file.Close()
}
if readErr != nil {
_ = file.Close()
return readErr
}
if len(entries) == 0 {
_ = file.Close()
return io.ErrNoProgress
}
}
}
if err := file.Close(); err != nil {
return err
}
// fs.ReadDir provides the standard fallback for implementations whose opened
// files do not implement fs.ReadDirFile. It may allocate all entries itself.
entries, err := fs.ReadDir(a.native, name)
if err != nil {
return err
}
for _, entry := range entries {
if !visit(entry) {
break
}
}
return nil
}

View File

@@ -2,11 +2,8 @@ package engine
import (
"errors"
"io"
"io/fs"
"os"
"path"
"path/filepath"
"slices"
"strings"
)
@@ -42,29 +39,29 @@ func (s *importTraversal) expandImportGlob(basePath string, declaration importDe
names = []string{literal}
}
for _, name := range names {
absolute := filepath.Join(candidate.absolute, filepath.FromSlash(name))
absolute := s.access.join(candidate.absolute, name)
relative := path.Join(candidate.relative, name)
if !containedPath(s.rootPath, filepath.Clean(absolute)) {
if !s.access.contains(absolute) {
return nil, []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "glob traversal is outside the configuration root", &location)}
}
canonical, err := canonicalExistingPath(absolute)
canonical, err := s.access.canonical(absolute)
if err != nil {
if errors.Is(err, fs.ErrNotExist) && !patterned {
continue
}
return nil, []Diagnostic{globFilesystemDiagnostic(err, "glob match could not be canonicalized", location)}
}
if !containedPath(s.rootPath, canonical) {
if !s.access.contains(canonical) {
return nil, []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "glob traversal resolves outside the configuration root", &location)}
}
if last {
next = append(next, globCandidate{absolute: canonical, relative: relative})
if len(s.sources)+len(next) > s.limits.sources {
if s.accounting.sources+len(next) > s.limits.sources {
return nil, []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "glob matches exceed the remaining source-count limit", &location)}
}
continue
}
info, err := os.Stat(canonical)
info, err := s.access.stat(canonical)
if err != nil {
return nil, []Diagnostic{globFilesystemDiagnostic(err, "glob traversal target could not be inspected", location)}
}
@@ -88,56 +85,47 @@ func (s *importTraversal) expandImportGlob(basePath string, declaration importDe
}
func (s *importTraversal) readGlobMatches(directory, pattern string, location Location) ([]string, []Diagnostic) {
canonical, err := canonicalExistingPath(directory)
canonical, err := s.access.canonical(directory)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return nil, nil
}
return nil, []Diagnostic{globFilesystemDiagnostic(err, "glob directory could not be canonicalized", location)}
}
if !containedPath(s.rootPath, canonical) {
if !s.access.contains(canonical) {
return nil, []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "glob directory resolves outside the configuration root", &location)}
}
relative, err := filepath.Rel(s.rootPath, canonical)
if err != nil || filepath.IsAbs(relative) || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
if _, err := s.access.relative(canonical); err != nil {
return nil, []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "glob directory could not be addressed inside the configuration root", &location)}
}
directoryFile, err := s.root.Open(relative)
if err != nil {
if errors.Is(err, fs.ErrNotExist) {
return nil, nil
}
return nil, []Diagnostic{globFilesystemDiagnostic(err, "glob directory could not be opened", location)}
}
var matches []string
for {
entries, readErr := directoryFile.ReadDir(globReadBatch)
for _, entry := range entries {
s.usedEntries++
if s.usedEntries > s.limits.entries {
_ = directoryFile.Close()
return nil, []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "glob traversal exceeds the directory-entry visit limit", &location)}
}
matched, matchErr := path.Match(pattern, entry.Name())
if matchErr != nil {
_ = directoryFile.Close()
return nil, []Diagnostic{importDiagnostic(CodeImportInvalid, "import glob pattern is invalid", &location)}
}
if matched {
matches = append(matches, entry.Name())
}
var problem *Diagnostic
err = s.access.readDirectory(canonical, func(entry fs.DirEntry) bool {
s.accounting.entries++
if s.accounting.entries > s.limits.entries {
value := importDiagnostic(CodeImportLimitExceeded, "glob traversal exceeds the directory-entry visit limit", &location)
problem = &value
return false
}
if errors.Is(readErr, io.EOF) {
break
matched, matchErr := path.Match(pattern, entry.Name())
if matchErr != nil {
value := importDiagnostic(CodeImportInvalid, "import glob pattern is invalid", &location)
problem = &value
return false
}
if readErr != nil {
_ = directoryFile.Close()
return nil, []Diagnostic{globFilesystemDiagnostic(readErr, "glob directory could not be read", location)}
if matched {
matches = append(matches, entry.Name())
}
return true
})
if problem != nil {
return nil, []Diagnostic{*problem}
}
if err := directoryFile.Close(); err != nil {
return nil, []Diagnostic{globFilesystemDiagnostic(err, "glob directory could not be closed", location)}
if errors.Is(err, fs.ErrNotExist) {
return nil, nil
}
if err != nil {
return nil, []Diagnostic{globFilesystemDiagnostic(err, "glob directory could not be traversed", location)}
}
slices.Sort(matches)
return matches, nil

View File

@@ -6,6 +6,40 @@ import (
const historyNodeLimit = 4_000_000
type retainedNodeCounter struct {
seen map[*document.Node]bool
count int
limit int
}
func newRetainedNodeCounter(limit int) *retainedNodeCounter {
return &retainedNodeCounter{seen: make(map[*document.Node]bool), limit: limit}
}
func (c *retainedNodeCounter) add(root *document.Node) bool {
if c == nil {
return true
}
stack := []*document.Node{root}
for len(stack) != 0 {
node := stack[len(stack)-1]
stack = stack[:len(stack)-1]
if node == nil || c.seen[node] {
continue
}
c.seen[node] = true
c.count++
if c.limit >= 0 && c.count > c.limit {
return false
}
for _, entry := range node.Mapping {
stack = append(stack, entry.Value)
}
stack = append(stack, node.Sequence...)
}
return true
}
// History applications borrow immutable policy-validated input trees. Metadata
// records answer presence and origin queries; replay reconstructs value changes.
// Replay reconciles with canonical decoding before domain resolution so both
@@ -31,7 +65,7 @@ func cloneHistoryApplications(values []historyApplication) []historyApplication
return cloned
}
func applicationOperationPath(application historyApplication, query Path) (Path, bool) {
func applicationOperationPath(application historyApplication, query Path, lookup *documentLookup) (Path, bool) {
if !application.path.valid() || application.value == nil {
return Path{}, false
}
@@ -46,7 +80,7 @@ func applicationOperationPath(application historyApplication, query Path) (Path,
}
for length := 1; length <= len(query.segments); length++ {
prefix := Path{segments: clonePathSegments(query.segments[:length], 0)}
node, present := documentAtPath(application.value, prefix)
node, present := documentAtPathWithLookup(application.value, prefix, lookup)
if !present {
return Path{}, false
}
@@ -57,7 +91,7 @@ func applicationOperationPath(application historyApplication, query Path) (Path,
return Path{}, false
}
func historyApplicationOrigin(application historyApplication, operationPath Path) Origin {
func historyApplicationOrigin(application historyApplication, operationPath Path, lookup *documentLookup) Origin {
if application.origin.Layer != "" {
return cloneOrigin(application.origin)
}
@@ -66,7 +100,7 @@ func historyApplicationOrigin(application historyApplication, operationPath Path
if !ok {
return origin
}
if node, present := documentAtPath(application.value, relative); present {
if node, present := documentAtPathWithLookup(application.value, relative, lookup); present {
origin.Locations = []Location{publicLocation(node.Location)}
}
return origin
@@ -83,15 +117,15 @@ func pathsIntersect(left, right Path) bool {
return pathContains(left, right) || pathContains(right, left)
}
func applyHistoryApplication(root **document.Node, application historyApplication) {
func applyHistoryApplication(root **document.Node, application historyApplication, index *mappingIndex) {
if application.path.String() == "" {
mergeHistoryDocument(root, application.value)
mergeHistoryDocument(root, application.value, index)
return
}
mergeHistoryAtPath(root, application.path, application.value)
mergeHistoryAtPath(root, application.path, application.value, index)
}
func mergeHistoryDocument(target **document.Node, incoming *document.Node) {
func mergeHistoryDocument(target **document.Node, incoming *document.Node, index *mappingIndex) {
if incoming == nil {
return
}
@@ -100,39 +134,39 @@ func mergeHistoryDocument(target **document.Node, incoming *document.Node) {
return
}
for _, entry := range incoming.Mapping {
position := documentEntryIndex((*target).Mapping, entry.Key)
position := index.find(*target, entry.Key)
if position < 0 {
cloned := entry
cloned.Value = document.Clone(entry.Value)
(*target).Mapping = append((*target).Mapping, cloned)
index.append(*target, cloned)
continue
}
mergeHistoryDocument(&(*target).Mapping[position].Value, entry.Value)
mergeHistoryDocument(&(*target).Mapping[position].Value, entry.Value, index)
}
}
func mergeHistoryAtPath(root **document.Node, path Path, incoming *document.Node) {
func mergeHistoryAtPath(root **document.Node, path Path, incoming *document.Node, index *mappingIndex) {
if root == nil || *root == nil || len(path.segments) == 0 {
mergeHistoryDocument(root, incoming)
mergeHistoryDocument(root, incoming, index)
return
}
current := *root
for index, segment := range path.segments {
for segmentIndex, segment := range path.segments {
if current.Kind != document.Mapping || segment.kind == pathSegmentIndex {
return
}
position := documentEntryIndex(current.Mapping, segment.key)
if index == len(path.segments)-1 {
position := index.find(current, segment.key)
if segmentIndex == len(path.segments)-1 {
if position < 0 {
current.Mapping = append(current.Mapping, document.Entry{Key: segment.key, Value: document.Clone(incoming)})
index.append(current, document.Entry{Key: segment.key, Value: document.Clone(incoming)})
} else {
mergeHistoryDocument(&current.Mapping[position].Value, incoming)
mergeHistoryDocument(&current.Mapping[position].Value, incoming, index)
}
return
}
if position < 0 {
child := &document.Node{Kind: document.Mapping}
current.Mapping = append(current.Mapping, document.Entry{Key: segment.key, Value: child})
index.append(current, document.Entry{Key: segment.key, Value: child})
current = child
} else {
current = current.Mapping[position].Value
@@ -145,28 +179,50 @@ func mergeHistoryAtPath(root **document.Node, path Path, incoming *document.Node
// intent into that event rather than replay a child assignment a second time.
func coalescedResolutionSnapshots(snapshots []resolutionSnapshot) []resolutionSnapshot {
groups := make([]resolutionSnapshot, 0, len(snapshots))
reasons := make([]map[string]struct{}, 0, len(snapshots))
dependencies := make([]map[string]struct{}, 0, len(snapshots))
var coverage pathPrefixIndex
for _, snapshot := range snapshots {
merged := false
for i := range groups {
if !pathContains(groups[i].change.Path, snapshot.change.Path) {
continue
pointer := snapshot.change.Path.String()
groupIndex := -1
coverage.visitAncestors(pointer, func(id int) {
if groupIndex < 0 || id < groupIndex {
groupIndex = id
}
})
if groupIndex >= 0 {
group := &groups[groupIndex]
for _, reason := range snapshot.change.Reasons {
groups[i].change.Reasons = appendUniqueString(groups[i].change.Reasons, reason)
if _, found := reasons[groupIndex][reason]; !found {
reasons[groupIndex][reason] = struct{}{}
group.change.Reasons = append(group.change.Reasons, reason)
}
}
for _, dependency := range snapshot.change.Dependencies {
groups[i].change.Dependencies = appendUniquePath(groups[i].change.Dependencies, dependency)
dependencyPointer := dependency.String()
if _, found := dependencies[groupIndex][dependencyPointer]; !found {
dependencies[groupIndex][dependencyPointer] = struct{}{}
group.change.Dependencies = append(group.change.Dependencies, dependency.clone())
}
}
if snapshot.change.Origin.Operation == ChangeNormalization {
groups[i].change.Origin.Operation = ChangeNormalization
group.change.Origin.Operation = ChangeNormalization
}
merged = true
break
continue
}
if !merged {
snapshot.change = cloneChange(snapshot.change)
groups = append(groups, snapshot)
snapshot.change = cloneChange(snapshot.change)
groups = append(groups, snapshot)
reasonSet := make(map[string]struct{}, len(snapshot.change.Reasons))
for _, reason := range snapshot.change.Reasons {
reasonSet[reason] = struct{}{}
}
reasons = append(reasons, reasonSet)
dependencySet := make(map[string]struct{}, len(snapshot.change.Dependencies))
for _, dependency := range snapshot.change.Dependencies {
dependencySet[dependency.String()] = struct{}{}
}
dependencies = append(dependencies, dependencySet)
coverage.insert(pointer, len(groups)-1)
}
return groups
}
@@ -230,11 +286,11 @@ func replaceHistoryPath(root **document.Node, path Path, replacement *document.N
}
}
func historyNodesWithinLimit(baseline, input, final *document.Node, applications []historyApplication, resolutions []resolutionSnapshot) bool {
return historyNodesWithin(historyNodeLimit, baseline, input, final, applications, resolutions)
func historyNodesWithinLimit(baseline, input, final *document.Node, applications []historyApplication, resolutions []resolutionSnapshot, inherited ...*inheritanceScope) bool {
return historyNodesWithin(historyNodeLimit, baseline, input, final, applications, resolutions, inherited...)
}
func historyNodesWithin(limit int, baseline, input, final *document.Node, applications []historyApplication, resolutions []resolutionSnapshot) bool {
func historyNodesWithin(limit int, baseline, input, final *document.Node, applications []historyApplication, resolutions []resolutionSnapshot, inherited ...*inheritanceScope) bool {
seen := make(map[*document.Node]bool)
count := 0
var visit func(*document.Node) bool
@@ -272,5 +328,23 @@ func historyNodesWithin(limit int, baseline, input, final *document.Node, applic
return false
}
}
for _, expansion := range inherited {
if expansion == nil {
continue
}
if !visit(expansion.root) {
return false
}
for _, operation := range expansion.operations {
if !visit(operation.parent) || !visit(operation.effective) {
return false
}
}
for _, replacement := range expansion.replacements {
if !visit(replacement.before) || !visit(replacement.after) {
return false
}
}
}
return true
}

View File

@@ -4,9 +4,6 @@ import (
"bytes"
"errors"
"io/fs"
"os"
"path"
"path/filepath"
"strconv"
"strings"
@@ -40,47 +37,99 @@ type importRoute struct {
locations []Location
}
type importTraversal struct {
rootPath string
root *os.Root
limits importLimits
rule *VersionRule
type importAccounting struct {
sources int
bytes int64
nodes int
entries int
}
documents []*document.Node
sources []SourceInfo
active map[string]int
visited map[string]importRoute
stack []importRoute
usedBytes int64
usedNodes int
usedEntries int
type importTraversal struct {
access *filesystemAccess
accounting *importAccounting
limits importLimits
rule *VersionRule
destination Path
documents []*document.Node
sources []SourceInfo
active map[resourceIdentity]int
visited map[resourceIdentity]importRoute
stack []importRoute
}
func loadImportedDocuments(source Source, context *FilesystemContext, main parsedSource, rule *VersionRule, limits importLimits) (*composedDocument, []SourceInfo, []Diagnostic) {
return loadImportedDocumentsTracked(source, context, main, Path{}, rule, limits, &importAccounting{}, nil, nil)
}
func loadImportedDocumentsTracked(source Source, context *FilesystemContext, main parsedSource, destination Path, rule *VersionRule, limits importLimits, accounting *importAccounting, visited map[resourceIdentity]importRoute, hostToken *namespaceToken) (*composedDocument, []SourceInfo, []Diagnostic) {
if nativeContext(source, context) {
access, basePath, mainPath, diagnostic := nativeFilesystemAccess(source, context)
if diagnostic != nil {
return nil, sourceInfos(main.source), []Diagnostic{*diagnostic}
}
return loadImportedWithAccessTracked(access, basePath, mainPath, main, destination, rule, limits, accounting, visited)
}
rootPath, basePath, mainPath, diagnostic := importFilesystem(source, context, main)
if diagnostic != nil {
return nil, sourceInfos(main.source), []Diagnostic{*diagnostic}
}
root, err := os.OpenRoot(rootPath)
access, err := openHostAccess(rootPath)
if err != nil {
diagnostic := globFilesystemDiagnostic(err, "configuration root could not be opened", Location{})
diagnostic.Primary = nil
return nil, sourceInfos(main.source), []Diagnostic{diagnostic}
}
if hostToken != nil {
access.token = hostToken
}
return loadImportedWithAccessTracked(access, basePath, mainPath, main, destination, rule, limits, accounting, visited)
}
func loadImportedWithAccess(access *filesystemAccess, basePath, mainPath string, main parsedSource, rule *VersionRule, limits importLimits, accounting *importAccounting) (*composedDocument, []SourceInfo, []Diagnostic) {
return loadImportedWithAccessTracked(access, basePath, mainPath, main, Path{}, rule, limits, accounting, nil)
}
func loadImportedWithAccessTracked(access *filesystemAccess, basePath, mainPath string, main parsedSource, destination Path, rule *VersionRule, limits importLimits, accounting *importAccounting, visited map[resourceIdentity]importRoute) (*composedDocument, []SourceInfo, []Diagnostic) {
if !access.contains(basePath) || (mainPath != "" && !access.contains(mainPath)) {
_ = access.close()
return nil, sourceInfos(main.source), []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "selected source and import base must stay inside the configuration root", nil)}
}
for _, directory := range []string{access.rootPath, basePath} {
info, err := access.stat(directory)
if err != nil || !info.IsDir() {
_ = access.close()
return nil, sourceInfos(main.source), []Diagnostic{importDiagnostic(CodeImportInvalid, "configuration root and import base must be directories", nil)}
}
}
mainInfo := cloneSourceInfo(*main.source)
mainInfo.Destination = destination.clone()
if mainPath != "" {
mainInfo.Path = mainPath
}
mainKey := access.identity(mainPath)
if mainPath == "" {
mainKey = access.syntheticIdentity()
}
if previous, repeated := visited[mainKey]; repeated {
_ = access.close()
return nil, sourceInfos(main.source), []Diagnostic{importRepeatedDiagnostic(Location{SourceID: main.source.ID}, previous, importRoute{})}
}
accounting.sources++
accounting.bytes += main.bytes
accounting.nodes += countInputNodes(main.document)
state := importTraversal{
rootPath: rootPath, root: root, limits: limits, rule: rule,
documents: make([]*document.Node, 0, 1),
sources: []SourceInfo{mainInfo},
active: make(map[string]int), visited: make(map[string]importRoute),
usedBytes: main.bytes, usedNodes: countInputNodes(main.document),
access: access, accounting: accounting, limits: limits, rule: rule,
destination: destination.clone(),
documents: make([]*document.Node, 0, 1),
sources: []SourceInfo{mainInfo},
active: make(map[resourceIdentity]int), visited: visited,
}
if state.visited == nil {
state.visited = make(map[resourceIdentity]importRoute)
}
if diagnostic := state.checkInitialLimits(); diagnostic != nil {
_ = root.Close()
_ = access.close()
return nil, cloneSourceInfos(state.sources), []Diagnostic{*diagnostic}
}
@@ -89,16 +138,25 @@ func loadImportedDocuments(source Source, context *FilesystemContext, main parse
mainIdentity = "<selected-source>"
}
mainRoute := importRoute{canonical: mainIdentity}
state.active[mainIdentity] = 0
state.visited[mainIdentity] = mainRoute
state.active[mainKey] = 0
state.visited[mainKey] = mainRoute
state.stack = append(state.stack, mainRoute)
body, declarations, diagnostics := splitImportDirectives(main.document)
if len(diagnostics) == 0 {
state.documents = append(state.documents, body)
diagnostics = state.walkDeclarations(basePath, declarations, 0)
_, placed, err := placeDocument(body, state.destination, body.Location, maxDocumentDepth, maxDocumentNodes)
placementNodes := destinationWrapperNodeCount(state.destination)
if err != nil {
diagnostics = []Diagnostic{sourceDiagnostic(CodeSourceLimitExceeded, main.source.ID, "configuration exceeds the source depth or node limit after destination placement")}
} else if accounting.nodes+placementNodes > limits.nodes {
diagnostics = []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", nil)}
} else {
accounting.nodes += placementNodes
state.documents = append(state.documents, placed)
diagnostics = state.walkDeclarations(basePath, declarations, 0)
}
}
closeErr := root.Close()
closeErr := access.close()
if len(diagnostics) != 0 {
return nil, cloneSourceInfos(state.sources), diagnostics
}
@@ -107,22 +165,22 @@ func loadImportedDocuments(source Source, context *FilesystemContext, main parse
}
composed, diagnostics := composeDocuments(state.documents...)
if composed != nil {
composed.inputBytes = state.usedBytes
composed.inputNodes = state.usedNodes
composed.inputBytes = accounting.bytes
composed.inputNodes = accounting.nodes
}
return composed, cloneSourceInfos(state.sources), diagnostics
}
func (s *importTraversal) checkInitialLimits() *Diagnostic {
if s.limits.sources < 1 {
if s.accounting.sources > s.limits.sources {
value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the source-count limit", nil)
return &value
}
if s.usedBytes > s.limits.bytes {
if s.accounting.bytes > s.limits.bytes {
value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate byte limit", nil)
return &value
}
if s.usedNodes > s.limits.nodes {
if s.accounting.nodes > s.limits.nodes {
value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", nil)
return &value
}
@@ -169,11 +227,11 @@ func (s *importTraversal) visitPath(basePath string, declaration importDeclarati
return []Diagnostic{importDiagnostic(CodeImportInvalid, "import path must be a nonempty slash-relative path without a network scheme", &location)}
}
candidate := filepath.Join(basePath, filepath.FromSlash(path.Clean(declaration.path)))
if !containedPath(s.rootPath, filepath.Clean(candidate)) {
candidate := s.access.join(basePath, declaration.path)
if !s.access.contains(candidate) {
return []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "import target is outside the configuration root", &location)}
}
canonical, err := canonicalExistingPath(candidate)
canonical, err := s.access.canonical(candidate)
if err != nil {
if declaration.optional && errors.Is(err, fs.ErrNotExist) {
return nil
@@ -187,30 +245,39 @@ func (s *importTraversal) visitPath(basePath string, declaration importDeclarati
}
return []Diagnostic{importDiagnostic(code, message, &location)}
}
if !containedPath(s.rootPath, canonical) {
if !s.access.contains(canonical) {
return []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "import target resolves outside the configuration root", &location)}
}
currentRoute := s.currentRoute(location)
if activeIndex, ok := s.active[canonical]; ok {
identity := s.access.identity(canonical)
if activeIndex, ok := s.active[identity]; ok {
cycle := append([]importRoute(nil), s.stack[activeIndex:]...)
return []Diagnostic{importCycleDiagnostic(canonical, location, cycle)}
}
if previous, ok := s.visited[canonical]; ok {
if previous, ok := s.visited[identity]; ok {
return []Diagnostic{importRepeatedDiagnostic(location, previous, currentRoute)}
}
if len(s.sources)+1 > s.limits.sources {
if s.accounting.sources+1 > s.limits.sources {
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the source-count limit", &location)}
}
relative, err := filepath.Rel(s.rootPath, canonical)
if err != nil || filepath.IsAbs(relative) || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
if _, err := s.access.relative(canonical); err != nil {
return []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "import target could not be addressed inside the configuration root", &location)}
}
sourceID := "source-" + strconv.Itoa(len(s.sources)+1)
info := SourceInfo{ID: sourceID, Label: declaration.path, Path: canonical, Layer: LayerBase, ImportedFrom: &location}
sourceID := "source-" + strconv.Itoa(s.accounting.sources+1)
kind := SourceKindFile
label := declaration.path
if s.access.native != nil {
kind = SourceKindFilesystem
label = s.access.label + "/" + canonical
}
info := SourceInfo{ID: sourceID, Label: label, Path: canonical, Destination: s.destination.clone(), Layer: LayerBase, Kind: kind, ImportedFrom: &location}
if kind == SourceKindFilesystem {
info.namespace = s.access.token
}
s.sources = append(s.sources, info)
data, skipped, diagnostic := s.readImport(relative, sourceID, declaration.optional)
data, skipped, diagnostic := s.readImport(canonical, sourceID, declaration.optional)
if diagnostic != nil {
return []Diagnostic{*diagnostic}
}
@@ -218,6 +285,7 @@ func (s *importTraversal) visitPath(basePath string, declaration importDeclarati
s.sources = s.sources[:len(s.sources)-1]
return nil
}
s.accounting.sources++
root, issues := yamladapter.Parse(bytes.NewReader(data), sourceID, yamladapter.Limits{
Depth: maxDocumentDepth, Nodes: maxDocumentNodes,
@@ -226,16 +294,25 @@ func (s *importTraversal) visitPath(basePath string, declaration importDeclarati
return parserDiagnostics(sourceID, issues)
}
nodeCount := countInputNodes(root)
if s.usedNodes+nodeCount > s.limits.nodes {
if s.accounting.nodes+nodeCount > s.limits.nodes {
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", &location)}
}
s.usedNodes += nodeCount
s.accounting.nodes += nodeCount
body, declarations, diagnostics := splitImportDirectives(root)
if len(diagnostics) != 0 {
return diagnostics
}
if versionNode := fragmentVersionClaim(body, s.rule); versionNode != nil {
_, placed, err := placeDocument(body, s.destination, body.Location, maxDocumentDepth, maxDocumentNodes)
if err != nil {
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the source depth or node limit after destination placement", &location)}
}
placementNodes := destinationWrapperNodeCount(s.destination)
if s.accounting.nodes+placementNodes > s.limits.nodes {
return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", &location)}
}
s.accounting.nodes += placementNodes
if versionNode := fragmentVersionClaim(placed, s.rule); versionNode != nil {
versionLocation := publicLocation(versionNode.Location)
return []Diagnostic{{
Code: CodeImportVersion, Severity: SeverityError, Path: s.rule.Path.clone(),
@@ -244,23 +321,23 @@ func (s *importTraversal) visitPath(basePath string, declaration importDeclarati
}
route := importRoute{canonical: canonical, locations: currentRoute.locations}
s.documents = append(s.documents, body)
s.active[canonical] = len(s.stack)
s.visited[canonical] = route
s.documents = append(s.documents, placed)
s.active[identity] = len(s.stack)
s.visited[identity] = route
s.stack = append(s.stack, route)
diagnostics = s.walkDeclarations(filepath.Dir(canonical), declarations, depth)
delete(s.active, canonical)
diagnostics = s.walkDeclarations(s.access.dir(canonical), declarations, depth)
delete(s.active, identity)
s.stack = s.stack[:len(s.stack)-1]
return diagnostics
}
func (s *importTraversal) readImport(relative, sourceID string, optional bool) ([]byte, bool, *Diagnostic) {
remaining := s.limits.bytes - s.usedBytes
func (s *importTraversal) readImport(name, sourceID string, optional bool) ([]byte, bool, *Diagnostic) {
remaining := s.limits.bytes - s.accounting.bytes
limit := min(int64(maxSourceBytes), remaining)
if limit < 0 {
limit = 0
}
data, failure := readRegularFile(relative, s.root.Stat, s.root.Open, limit)
data, failure := readRegularFile(name, s.access.stat, s.access.open, limit)
if failure != nil {
if optional && failure.missingTarget {
return nil, true, nil
@@ -268,7 +345,7 @@ func (s *importTraversal) readImport(relative, sourceID string, optional bool) (
value := sourceReadDiagnostic(failure, sourceID, CodeImportLimitExceeded, "configuration exceeds a source or aggregate byte limit")
return nil, false, &value
}
s.usedBytes += int64(len(data))
s.accounting.bytes += int64(len(data))
return data, false, nil
}

View File

@@ -0,0 +1,313 @@
package engine
import (
"slices"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
const inheritanceKey = "extends"
// These private records keep structural evidence separate from acquired source
// records. Later inspection can project a transfer onto individual descendants.
type inheritanceTransfer struct {
from Path
to Path
reference Path
locations []Location
}
type inheritanceControl struct {
path Path
locations []Location
cancelled bool
}
type inheritanceScope struct {
root *document.Node
origins map[string]Origin
transfers []inheritanceTransfer
controls []inheritanceControl
replacements []inheritanceReplacement
operations []inheritanceOperation
}
type inheritanceOperation struct {
control *inheritanceControl
transfer *inheritanceTransfer
controlOrigin Origin
parent *document.Node
effective *document.Node
parentOrigins map[string]Origin
effectiveOrigins map[string]Origin
}
// A replacement retains the displaced source/derivation record while the
// effective origin belongs to the child. Value trees are borrowed immutable
// inputs or detached templates and are budgeted with retained history later.
type inheritanceReplacement struct {
path Path
before *document.Node
after *document.Node
previous []displacedDefinition
effective Origin
}
// A link represents one shared relationship edge. Each contributor retains
// its original semantic path independently of source line/column coordinates.
type inheritanceTrace struct {
previous *inheritanceTrace
transfer *inheritanceTransfer
}
type inheritanceContributor struct {
source Path
location Location
trace *inheritanceTrace
}
type siblingFrame struct {
name string
parent string
hasParent bool
entered bool
}
// expandSiblingDefinitions resolves only the mappings directly in scope.
// Nested instructions remain in the returned templates for destination-local
// resolution. This private entry point does not participate in public loading.
func expandSiblingDefinitions(scope *document.Node, scopePath Path) (*inheritanceScope, []Diagnostic) {
return expandSiblingDefinitionsWithLimits(scope, scopePath, defaultProjectionLimits())
}
func expandSiblingDefinitionsWithLimits(scope *document.Node, scopePath Path, limits projectionLimits) (*inheritanceScope, []Diagnostic) {
return expandSiblingDefinitionsWithOrigins(scope, scopePath, limits, nil)
}
func expandSiblingDefinitionsWithOrigins(scope *document.Node, scopePath Path, limits projectionLimits, inputOrigins map[string]Origin, budget ...*retainedNodeCounter) (*inheritanceScope, []Diagnostic) {
if scope == nil || scope.Kind != document.Mapping {
return nil, []Diagnostic{{
Code: CodeInheritanceInvalid, Severity: SeverityError, Path: scopePath,
Message: "inheritance scope must be a mapping",
}}
}
byName := make(map[string]document.Entry, len(scope.Mapping))
names := make([]string, 0, len(scope.Mapping))
for _, entry := range scope.Mapping {
byName[entry.Key] = entry
names = append(names, entry.Key)
}
slices.Sort(names)
resolved := make(map[string]*document.Node, len(byName))
resolvedOrigins := make(map[string]map[string]Origin, len(byName))
state := make(map[string]uint8, len(byName))
result := &inheritanceScope{}
counter := newRetainedNodeCounter(historyNodeLimit)
if len(budget) != 0 {
counter = budget[0]
}
retained := 1 // scope mapping node
for _, entry := range scope.Mapping {
if entry.Value.Kind == document.Mapping {
continue
}
remaining := limits.nodes - retained - 1
if limits.nodes >= 0 && remaining < 0 {
return nil, []Diagnostic{inheritanceBudgetDiagnostic(scopePath.Key(entry.Key), entry.Value.Location)}
}
count, within := inheritanceLowerBoundCount(entry.Value, remaining)
if !within {
return nil, []Diagnostic{inheritanceBudgetDiagnostic(scopePath.Key(entry.Key), entry.Value.Location)}
}
retained += 1 + count // key and value subtree
}
for _, name := range names {
if byName[name].Value.Kind != document.Mapping || state[name] == 2 {
continue
}
stack := []siblingFrame{{name: name}}
for len(stack) != 0 {
frame := &stack[len(stack)-1]
entry := byName[frame.name]
path := scopePath.Key(frame.name)
if !frame.entered {
state[frame.name] = 1
frame.entered = true
reference, found := inheritanceDirective(entry.Value)
if found {
referencePath := path.Key(inheritanceKey)
location := publicLocation(reference.Value.Location)
result.controls = append(result.controls, inheritanceControl{
path: referencePath, locations: []Location{location},
cancelled: reference.Value.Kind == document.Null,
})
switch reference.Value.Kind {
case document.Null:
case document.String:
frame.parent, frame.hasParent = reference.Value.Text, true
parent, exists := byName[frame.parent]
if !exists {
return nil, []Diagnostic{inheritanceDiagnostic(CodeInheritanceMissing, referencePath, location, nil,
"referenced sibling does not exist")}
}
if parent.Value.Kind != document.Mapping {
related := []Location{publicLocation(parent.Value.Location)}
return nil, []Diagnostic{inheritanceDiagnostic(CodeInheritanceInvalid, referencePath, location, related,
"referenced sibling is not a mapping")}
}
if state[frame.parent] == 1 {
related := []Location{publicLocation(parent.Value.Location)}
return nil, []Diagnostic{inheritanceDiagnostic(CodeInheritanceCycle, referencePath, location, related,
"sibling inheritance contains a cycle")}
}
if state[frame.parent] == 0 {
stack = append(stack, siblingFrame{name: frame.parent})
continue
}
default:
return nil, []Diagnostic{inheritanceDiagnostic(CodeInheritanceInvalid, referencePath, location, nil,
"inheritance reference must be a sibling name or null")}
}
}
}
payload := withoutInheritanceDirective(entry.Value)
operationStart, replacementStart := len(result.operations), len(result.replacements)
childOrigins := directInheritanceOrigins(payload, path, inputOrigins)
if reference, found := inheritanceDirective(entry.Value); found {
control := inheritanceControl{
path: path.Key(inheritanceKey),
locations: []Location{publicLocation(reference.Value.Location)},
cancelled: reference.Value.Kind == document.Null,
}
controlOrigins := directInheritanceOrigins(reference.Value, control.path, inputOrigins)
result.operations = append(result.operations, inheritanceOperation{
control: &control, controlOrigin: controlOrigins[control.path.String()],
})
}
if limits.nodes >= 0 {
remaining := limits.nodes - retained - 1 // the sibling key
within := false
if frame.hasParent {
within = inheritanceOverlayLowerBoundWithin(resolved[frame.parent], payload, remaining)
} else {
within = inheritanceLowerBoundWithin(payload, remaining)
}
if !within {
return nil, []Diagnostic{inheritanceBudgetDiagnostic(path, entry.Value.Location)}
}
}
if frame.hasParent {
parent := resolved[frame.parent]
merged := document.Clone(parent)
control := inheritanceDirectiveLocation(entry.Value)
transfer := inheritanceTransfer{
from: scopePath.Key(frame.parent), to: path,
reference: path.Key(inheritanceKey), locations: []Location{control},
}
mergedOrigins := transferredInheritanceOrigins(resolvedOrigins[frame.parent], transfer.from, path, transfer)
mergeInheritanceOrigin(mergedOrigins, path, childOrigins[path.String()])
mergeRecursiveMappings(merged, payload, path, new(mappingIndex), recursiveMergeHooks{
added: func(childPath Path, incoming *document.Node) {
copyInheritanceSubtree(mergedOrigins, childOrigins, incoming, childPath)
},
combined: func(childPath Path, _ *document.Node) {
mergeInheritanceOrigin(mergedOrigins, childPath, childOrigins[childPath.String()])
},
replaced: func(childPath Path, existing, incoming *document.Node) {
previous := displacedOrigins(existing, mergedOrigins, childPath)
result.replacements = append(result.replacements, inheritanceReplacement{
path: childPath.clone(), before: existing, after: incoming,
previous: previous, effective: cloneOrigin(childOrigins[childPath.String()]),
})
deleteOriginSubtree(mergedOrigins, childPath, existing)
copyInheritanceSubtree(mergedOrigins, childOrigins, incoming, childPath)
},
})
merged.Location = entry.Value.Location
resolved[frame.name] = merged
resolvedOrigins[frame.name] = mergedOrigins
result.transfers = append(result.transfers, transfer)
result.operations = append(result.operations, inheritanceOperation{
transfer: &transfer, parent: parent, effective: merged,
parentOrigins: resolvedOrigins[frame.parent], effectiveOrigins: mergedOrigins,
})
} else {
resolved[frame.name] = document.Clone(payload)
resolvedOrigins[frame.name] = childOrigins
}
count, _ := inheritanceLowerBoundCount(resolved[frame.name], limits.nodes)
retained += 1 + count
for _, operation := range result.operations[operationStart:] {
if !counter.add(operation.parent) || !counter.add(operation.effective) {
return nil, []Diagnostic{inheritanceHistoryBudgetDiagnostic(path, entry.Value.Location)}
}
}
for _, replacement := range result.replacements[replacementStart:] {
if !counter.add(replacement.before) || !counter.add(replacement.after) {
return nil, []Diagnostic{inheritanceHistoryBudgetDiagnostic(path, entry.Value.Location)}
}
}
state[frame.name] = 2
stack = stack[:len(stack)-1]
}
}
root := *scope
root.Mapping = make([]document.Entry, len(scope.Mapping))
rootOrigin := directInheritanceOrigins(&document.Node{Kind: document.Mapping, Location: scope.Location}, scopePath, inputOrigins)
result.origins = rootOrigin
for index, entry := range scope.Mapping {
root.Mapping[index] = entry
if resolved[entry.Key] != nil {
root.Mapping[index].Value = resolved[entry.Key]
for pointer, origin := range resolvedOrigins[entry.Key] {
result.origins[pointer] = origin
}
} else {
root.Mapping[index].Value = document.Clone(entry.Value)
for pointer, origin := range directInheritanceOrigins(entry.Value, scopePath.Key(entry.Key), inputOrigins) {
result.origins[pointer] = origin
}
}
}
result.root = &root
if !inheritanceLowerBoundWithin(result.root, limits.nodes) {
return nil, []Diagnostic{inheritanceBudgetDiagnostic(scopePath, scope.Location)}
}
return result, nil
}
func inheritanceDirective(node *document.Node) (document.Entry, bool) {
for _, entry := range node.Mapping {
if entry.Key == inheritanceKey {
return entry, true
}
}
return document.Entry{}, false
}
func inheritanceDirectiveLocation(node *document.Node) Location {
entry, _ := inheritanceDirective(node)
return publicLocation(entry.Value.Location)
}
func withoutInheritanceDirective(node *document.Node) *document.Node {
payload := *node
payload.Mapping = make([]document.Entry, 0, len(node.Mapping))
for _, entry := range node.Mapping {
if entry.Key != inheritanceKey {
payload.Mapping = append(payload.Mapping, entry)
}
}
return &payload
}
func inheritanceDiagnostic(code Code, path Path, primary Location, related []Location, message string) Diagnostic {
return Diagnostic{
Code: code, Severity: SeverityError, Path: path, Message: message,
Primary: &primary, Related: related,
}
}

View File

@@ -0,0 +1,110 @@
package engine
import (
"strconv"
"strings"
"testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
func BenchmarkInheritanceExpansion(b *testing.B) {
for _, shape := range []string{"chain", "wide", "nested", "replacement"} {
for _, size := range []int{16, 32, 64} {
b.Run(shape+"/"+strconv.Itoa(size), func(b *testing.B) {
input := inheritanceBenchmarkInput(shape, size)
b.ReportAllocs()
b.ResetTimer()
for range b.N {
if _, diagnostics := expandInheritanceWithOrigins(input, nil, defaultProjectionLimits()); len(diagnostics) != 0 {
b.Fatal(diagnostics)
}
}
})
}
}
}
func BenchmarkInheritanceInspectedLoad(b *testing.B) {
type entry struct {
Value string `yaml:"value"`
}
type configuration struct {
Entries map[string]entry `yaml:"entries"`
}
for _, size := range []int{16, 32, 64} {
b.Run(strconv.Itoa(size), func(b *testing.B) {
var input strings.Builder
input.WriteString("entries:\n item-0: {value: base}\n")
for index := 1; index <= size; index++ {
input.WriteString(" item-")
input.WriteString(strconv.Itoa(index))
input.WriteString(": {extends: item-0}\n")
}
source := Bytes("inheritance-benchmark", []byte(input.String()))
query := Keys("entries", "item-"+strconv.Itoa(size), "value")
b.ReportAllocs()
b.ResetTimer()
for range b.N {
loaded, err := Load[configuration](source, testSecretBinding())
if err != nil {
b.Fatal(err)
}
if _, found := loaded.Inspection.Explain(query); !found {
b.Fatal("inherited value is not explainable")
}
}
})
}
}
// Each scope has constant-sized output and evidence. This distinguishes budget
// accounting overhead from legitimate growth in expanded parent payloads.
func BenchmarkInheritanceIndependentScopes(b *testing.B) {
for _, size := range []int{256, 512, 1024} {
b.Run(strconv.Itoa(size), func(b *testing.B) {
input := &document.Node{Kind: document.Mapping}
for index := 0; index < size; index++ {
input.Mapping = append(input.Mapping, document.Entry{
Key: strconv.Itoa(index), Value: inheritanceBenchmarkInput("wide", 1),
})
}
b.ReportAllocs()
b.ResetTimer()
for range b.N {
if _, diagnostics := expandInheritanceTree(input, defaultProjectionLimits()); len(diagnostics) != 0 {
b.Fatal(diagnostics)
}
}
})
}
}
func inheritanceBenchmarkInput(shape string, size int) *document.Node {
value := func(text string) *document.Node { return &document.Node{Kind: document.String, Text: text} }
mapping := func(entries ...document.Entry) *document.Node {
return &document.Node{Kind: document.Mapping, Mapping: entries}
}
entry := func(key string, node *document.Node) document.Entry { return document.Entry{Key: key, Value: node} }
base := mapping(entry("value", value("base")), entry("settings", mapping(entry("enabled", &document.Node{Kind: document.Boolean, Text: "false"}))))
if shape == "nested" {
base = mapping(entry("settings", mapping(entry("first", mapping(entry("value", value("base")))), entry("second", mapping(entry("extends", value("first")))))))
}
definitions := make([]document.Entry, 0, size+1)
definitions = append(definitions, entry("item-0", base))
for index := 1; index <= size; index++ {
parent := "item-0"
if shape == "chain" {
parent = "item-" + strconv.Itoa(index-1)
}
payload := []document.Entry{entry("extends", value(parent))}
if shape == "nested" {
payload = append(payload, entry("settings", mapping(entry("first", mapping(entry("value", value("custom")))))))
}
if shape == "replacement" {
payload = append(payload, entry("value", value("replacement")))
}
definitions = append(definitions, entry("item-"+strconv.Itoa(index), mapping(payload...)))
}
return mapping(entry("entries", mapping(definitions...)))
}

Some files were not shown because too many files have changed in this diff Show More