106 Commits

Author SHA1 Message Date
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
144 changed files with 12885 additions and 1580 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 Linux is release-validated; macOS support is best-effort. See the
[platform support policy](docs/policy/release.md#platform-support) for details. [platform support policy](docs/policy/release.md#platform-support) for details.
ConfigKit's initial loader accepts one strict YAML ConfigKit's simple `Load` accepts one strict YAML source (or explicit
source (or explicit defaults-only input), optionally traverses explicit path and glob defaults-only input). `LoadLayered` additionally combines explicit lower-priority
imports, strictly composes their disjoint definitions, applies typed defaults, and source groups with an independently selected main input. Sources can be host
can overlay one selected fixed overlay and check an application schema version. It files, native `fs.FS` files, labeled bytes or readers, or snapshotted mappings.
returns detached metadata and safe diagnostics. 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, Applications may perform audited defaulting and normalization through a resolver,
then report domain errors and warnings through read-only semantic validation. Secret then report domain errors and warnings through read-only semantic validation. Secret
tags and a path predicate drive deterministic redacted effective YAML. Typed secret tags and a path predicate drive deterministic redacted effective YAML. Typed secret
references resolve environment names before validation, and explicit references resolve environment names before validation, and explicit
secrets-directory bootstrap is available as a separate startup operation. Explicit secrets-directory bootstrap is available as a separate startup operation. Explicit
CLI override records apply above overlays. Value explanation is available through CLI override records apply 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 ```go
type Config struct { type Config struct {
@@ -33,11 +41,37 @@ func ReadConfig(path string) (Config, error) {
} }
``` ```
## 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. The The [configuration contract](docs/config.md) describes implemented behavior. The
[consumer adoption guide](docs/consumers/README.md) provides maintained Narratio-, [consumer adoption guide](docs/consumers/README.md) provides maintained simple
Notarius-, WeatherReporter-, and Seriatim-shaped examples and migration guidance. native-filesystem, layered, and sibling-inheritance examples alongside the migration shapes.
The [deferred scope](docs/roadmap/deferred.md) records deliberate exclusions. See the See the [development guide](docs/development.md) for repository orientation and the
[development guide](docs/development.md) for repository orientation and the
[release procedure](docs/release.md) for maintainer-only candidate and publication [release procedure](docs/release.md) for maintainer-only candidate and publication
steps. steps.

View File

@@ -43,16 +43,36 @@ func toEngineLocations(values []Location) []engine.Location {
return result return result
} }
func fromEngineSourceInfo(value engine.SourceInfo) SourceInfo { func fromEngineSourceInfo(value engine.SourceInfo) SourceInfo {
return SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Layer: Layer(value.Layer), ImportedFrom: fromEngineLocationPointer(value.ImportedFrom)} return SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Layer: Layer(value.Layer), Kind: SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex, ImportedFrom: fromEngineLocationPointer(value.ImportedFrom)}
} }
func toEngineSourceInfo(value SourceInfo) engine.SourceInfo { 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, Layer: engine.Layer(value.Layer), Kind: engine.SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex, ImportedFrom: toEngineLocationPointer(value.ImportedFrom)}
} }
func fromEngineOrigin(value engine.Origin) Origin { 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 { 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 { func fromEngineDiagnostic(value engine.Diagnostic) Diagnostic {
return Diagnostic{Code: Code(value.Code), Severity: Severity(value.Severity), Path: fromEnginePath(value.Path), Message: value.Message, Primary: fromEngineLocationPointer(value.Primary), Related: fromEngineLocations(value.Related)} return Diagnostic{Code: Code(value.Code), Severity: Severity(value.Severity), Path: fromEnginePath(value.Path), Message: value.Message, Primary: fromEngineLocationPointer(value.Primary), Related: fromEngineLocations(value.Related)}

View File

@@ -66,7 +66,7 @@ func TestDevelopmentCheckHandlesFilenamesAndStopsOnFailure(t *testing.T) {
t.Fatal(err) t.Fatal(err)
} }
cmd := exec.Command("/bin/bash", filepath.Join(root, "scripts", scriptName)) 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. // Log only targets selected by the script, not inherited host settings.
cmd.Env = append(cmd.Env, "GOOS=", "GOARCH=") cmd.Env = append(cmd.Env, "GOOS=", "GOARCH=")
if name == "failed-go-check" { 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

@@ -12,13 +12,21 @@ ConfigKit exposes opaque sources through these constructors:
| Constructor | Current contract | | 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. | | `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. | | `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. | | `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. | | `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 The zero `Source` is invalid. Constructor argument failures are retained inside the
opaque source and become safe structured diagnostics when the source is consumed. 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 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 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 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 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. 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 ## Single-source loading
@@ -44,13 +54,14 @@ The current lifecycle is:
```text ```text
validate options → parse the selected source → traverse/compose enabled imports validate options → parse the selected source → traverse/compose enabled imports
→ build schema → check the optional application version → apply a selected overlay → 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 → recorded application resolution
→ resolve typed secret references → semantic validation → classify secrets → resolve typed secret references → semantic validation → classify secrets
→ snapshot configuration and metadata → 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. 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, `NoSource` skips parsing, supplies an absent version to a configured version checker,
contributes no source, and continues through defaults, decode, and projection. A contributes no source, and continues through defaults, decode, and projection. A
@@ -66,11 +77,166 @@ disabled.
Every source, options, schema, version, defaults, decode, resolution, or validation 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 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 the returned configuration or inspection records cannot alter ConfigKit's private
snapshot. ConfigKit performs no logging. `Result.WriteEffective` exposes only the snapshot. ConfigKit performs no logging. `Result.WriteEffective` exposes only the
frozen redacted projection. 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.
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 ### Application schema versions
`VersionRule` declares a semantic path and non-nil checker. Before defaults or typed `VersionRule` declares a semantic path and non-nil checker. Before defaults or typed
@@ -108,8 +274,9 @@ successful result. Callback panics remain programming failures.
`Result.Metadata` describes every final canonical path. Queries retain explicit `Result.Metadata` describes every final canonical path. Queries retain explicit
key/index distinctions using actual effective and historical container shapes; key/index distinctions using actual effective and historical container shapes;
only parsed numeric tokens are resolved contextually. A descendant that never 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 existed is not historical merely because an atomic ancestor was replaced. Exact
are explicit and have a base or overlay origin; omitted paths have a default origin. 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 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 available source coordinates are retained. `History` records the Go-zero or application
default baseline followed by exact base or overlay definitions, including default baseline followed by exact base or overlay definitions, including
@@ -124,8 +291,9 @@ domain ownership rules remain application responsibilities. The method's Go
documentation in [metadata.go](../metadata.go) defines enumeration and path-ordering documentation in [metadata.go](../metadata.go) defines enumeration and path-ordering
details. details.
History is chronological: a path can have a synthetic default record, its exact base History is chronological: a path can have a synthetic default record, its exact
definition, an overlay replacement, and a recorded resolution operation. A descendant 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; 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 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 effective. Equal-valued input is still an explicit base definition, while a derived
@@ -141,6 +309,15 @@ operation. Reasons and dependency paths retain first occurrence order with dupli
removed. Source records remain ordered as selected source, depth-first imports, the removed. Source records remain ordered as selected source, depth-first imports, the
selected overlay, then synthetic CLI records in supplied order. 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 and effective rendering
Secret classification combines `configkit:"secret"` field tags with the optional Secret classification combines `configkit:"secret"` field tags with the optional
@@ -156,6 +333,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 result and dependency cycles terminate. A dependency on a classified descendant or
on a container that contains one is treated conservatively as sensitive. Applications on a container that contains one is treated conservatively as sensitive. Applications
must classify secret-producing transformations whose dependencies are not recorded. 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 `Result.WriteEffective` writes the frozen resolved snapshot as deterministic redacted
YAML. Mappings sort by decoded key string, sequences retain order, indentation is two YAML. Mappings sort by decoded key string, sequences retain order, indentation is two
@@ -190,7 +372,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 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 application-default or Go-zero baseline, then includes only relevant base sources in
traversal order, the selected overlay, supplied CLI records, and audited net 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 as one net subtree event with combined reasons and dependencies, rather than
pretending that the recorded assignments ran in lexical order. Explicit equal pretending that the recorded assignments ran in lexical order. Explicit equal
writes remain visible. When input replay writes remain visible. When input replay
@@ -238,6 +423,46 @@ an explicit `LoadSecretsDir` bootstrap, then use `WriteDiagnostics`,
ConfigKit does not infer bootstrap outcomes from a secret reference and does not ConfigKit does not infer bootstrap outcomes from a secret reference and does not
bundle these independent reports into a command framework. 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 ## Typed secret references
Credential-bearing fields use the opaque `Secret` type and store an environment Credential-bearing fields use the opaque `Secret` type and store an environment
@@ -442,8 +667,8 @@ input document.
`[a-zA-Z0-9_-]+`; they are not paths. The empty name and exact lowercase name `[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. `default` select no overlay, and `overlays/default.yml` is never loaded implicitly.
Every other name resolves only to `<configuration-root>/overlays/<name>.yml`, with Every other name resolves only to `<configuration-root>/overlays/<name>.yml`, with
no extension fallback, case normalization, inheritance, or overlay stack. A selected no extension fallback, case normalization, inheritance between overlay files,
missing overlay is an error. or overlay stack. A selected missing overlay is an error.
The overlay is a separately policy-validated YAML mapping. It is loaded after the 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 base import graph is composed and its schema version is checked, but before defaults
@@ -557,18 +782,25 @@ walk failures, matched directories, invalid documents, escapes, and repeated tar
remain errors. Final candidates are bounded by the remaining source limit before remain errors. Final candidates are bounded by the remaining source limit before
their contents are accumulated. their contents are accumulated.
The configuration root defaults to a selected file's canonical directory. A file For host files, the configuration root defaults to the selected file's canonical
source may supply `FilesystemContext.Root` to widen that root but may not supply directory. A host file may supply `FilesystemContext.Root` to widen that root but
`BaseDir`. Whenever byte and reader sources supply filesystem context, they must may not supply `BaseDir` or a native `FS`. Byte, reader, and mapping inputs with host context
provide both `BaseDir` and `Root` as absolute existing directories; their diagnostic must provide both `BaseDir` and `Root` as absolute existing directories.
labels, including URI-looking labels, never imply filesystem paths. The base and For `Filesystem.File`, the default root is `.` in its namespace and the base is
selected file must be inside the canonical root. Nonregular targets are rejected before opening to avoid blocking on FIFOs; 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. post-open regular-file checks remain in place under the stable-input assumption.
ConfigKit canonicalizes symlinks, ConfigKit canonicalizes symlinks,
checks containment with relative paths, and opens the canonical root-relative target checks containment with relative paths, and opens the canonical root-relative target
through a library-owned root handle. Contained relative or absolute symlink targets 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 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 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 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. resource checks, remote acquisition, and service initialization.
The maintained [`examples/consumers`](../../examples/consumers) package demonstrates 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 | | Shape | Demonstrated fit | Application code deliberately retained |
| --- | --- | --- | | --- | --- | --- |
@@ -15,6 +15,10 @@ 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` | | 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 | | 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 | | 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 |
| 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 The YAML under [`testdata`](../../examples/consumers/testdata) is loaded by the
executable examples and ordinary tests. It contains no private application data, executable examples and ordinary tests. It contains no private application data,
@@ -48,6 +52,62 @@ both without redefining their syntax. ConfigKit overlays are configuration overl
they do not select or replace an application's LLM, model, pipeline, report, or they do not select or replace an application's LLM, model, pipeline, report, or
execution profile concepts. 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.
Known module catalogs should use canonical object entries with a module identifier Known module catalogs should use canonical object entries with a module identifier
and typed options. A small union struct is sufficient when the available option and typed options. A small union struct is sufficient when the available option
fields are known; validation selects which fields apply to each module. Runtime- fields are known; validation selects which fields apply to each module. Runtime-
@@ -126,6 +186,9 @@ focused behavioral owner:
| Source/import/selected-overlay pipeline rules and artifact defaults | Narratio-shaped executable example and fixture | | Source/import/selected-overlay pipeline rules and artifact defaults | Narratio-shaped executable example and fixture |
| Canonical module objects and dynamic option handling | WeatherReporter- and Notarius-shaped examples and migration tests | | Canonical module objects and dynamic option handling | WeatherReporter- and Notarius-shaped examples and migration tests |
| `LoadSecretsDir`, bootstrap reporting, typed lookup, CLI explanation | `Example_weatherStartup` | | `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 |
| 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 | | 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 The package-level import-boundary test covers the consumer examples too: supported

View File

@@ -54,6 +54,7 @@ Accepted decisions:
- [Secret references and explicit bootstrap](adr/0004-secret-references-and-explicit-bootstrap.md) - [Secret references and explicit bootstrap](adr/0004-secret-references-and-explicit-bootstrap.md)
- [Explicit overrides and safe explanation](adr/0005-explicit-overrides-and-safe-explanation.md) - [Explicit overrides and safe explanation](adr/0005-explicit-overrides-and-safe-explanation.md)
- [Keep the public facade root-owned](adr/0006-public-facade.md) - [Keep the public facade root-owned](adr/0006-public-facade.md)
- [Compare frozen effective configuration snapshots](adr/0010-effective-configuration-diff.md)
For cross-cutting changes, follow every applicable row. Completed implementation For cross-cutting changes, follow every applicable row. Completed implementation
and remediation plans are preserved in Git history at `cb5b435`; use current and remediation plans are preserved in Git history at `cb5b435`; use current
@@ -64,8 +65,9 @@ git show cb5b435:docs/roadmap/remediation.md
``` ```
Release-candidate, publication, and verification work follows the operational Release-candidate, publication, and verification work follows the operational
[release procedure](release.md). Its single publication command validates the [release procedure](release.md). Its module-selected ReleaseKit `publish`
committed candidate and publishes only the intended refs. 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 The completed string-and-integer schema-version implementation and remediation
records are preserved in Git history at `244104f`. The records are preserved in Git history at `244104f`. The
@@ -80,14 +82,40 @@ and current facade ownership belongs to the [component overview](internal/overvi
## Baseline validation ## 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 The completed overlay rename plan and release-workflow simplification record are
preserved in Git history at `73ce6e9` under `docs/roadmap/`. Current overlay preserved in Git history at `73ce6e9` under `docs/roadmap/`. Current overlay
behavior belongs to the [configuration contract](config.md#selected-overlays), behavior belongs to the [configuration contract](config.md#selected-overlays),
and release operations belong to the [release procedure](release.md). and release operations belong to the [release procedure](release.md).
Install Go 1.25.0 or a supported patched/newer Go toolchain. Acquire declared The completed ReleaseKit adoption, startup performance request and experiments,
dependencies explicitly before entering an offline development or validation benchmark evidence, performance audit, implementation plan, and remediation
environment: 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 ```sh
go mod download go mod download
@@ -117,11 +145,10 @@ Use focused checks while iterating, with validation proportionate to the change
under the testing policy. Documentation-only work normally needs the local-link under the testing policy. Documentation-only work normally needs the local-link
check below and whitespace review; behavior-bearing examples and commands also check below and whitespace review; behavior-bearing examples and commands also
need execution. Use `go test -race ./...` for ownership or concurrency changes. need execution. Use `go test -race ./...` for ownership or concurrency changes.
The [publication command](release.md#4-publish-the-committed-candidate) invokes The [release procedure](release.md#4-publish-the-committed-candidate) performs
the release checker once. It performs one minimum-toolchain offline race-suite its selected validation through one `publish` invocation. The everyday checker
run, one full vet pass, and Linux cross-builds using readonly module mode. remains a development gate and is not a separate release prerequisite. CI never
It already covers examples, links, and package boundaries; do not additionally gates a release.
run the everyday checker just to repeat those checks. CI never gates a release.
## Task-to-test map ## Task-to-test map
@@ -133,15 +160,22 @@ code changes. Use the component overview to find the corresponding implementatio
| --- | --- | --- | | --- | --- | --- |
| YAML syntax, diagnostics, parser limits | [Adapter tests](../internal/yaml/parse_test.go), [source translation](../internal/engine/parse_test.go) | `go test ./internal/yaml` and `go test ./internal/engine -run '^TestParseSource'` | | YAML syntax, diagnostics, parser limits | [Adapter tests](../internal/yaml/parse_test.go), [source translation](../internal/engine/parse_test.go) | `go test ./internal/yaml` and `go test ./internal/engine -run '^TestParseSource'` |
| File/reader access, imports, globs, containment | [Sources](../internal/engine/source_test.go), [FIFO](../internal/engine/source_fifo_test.go), [imports](../internal/engine/imports_test.go), [globs](../internal/engine/glob_test.go) | `go test ./internal/engine -run 'Source\|Import\|Glob\|Filesystem\|Nonregular\|RegularFile'` | | File/reader access, imports, globs, containment | [Sources](../internal/engine/source_test.go), [FIFO](../internal/engine/source_fifo_test.go), [imports](../internal/engine/imports_test.go), [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'` | | 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'` |
| Schema, defaults, projection, cloning | [Schema](../internal/engine/schema_test.go), [decoding](../internal/engine/decode_test.go), [projection](../internal/engine/projection_test.go) | `go test ./internal/engine -run 'Schema\|Decode\|Projection\|Clone\|Inline\|CompareDocument'` | | Schema, defaults, projection, cloning | [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'` | | Lifecycle, resolution, validation, callback ownership | [Loading](../internal/engine/load_test.go), [resolution](../internal/engine/resolution_test.go), [validation](../internal/engine/validation_test.go), [ownership](../internal/engine/callback_ownership_test.go), [independence](../internal/engine/independence_test.go) | `go test ./internal/engine -run 'Load\|Resolution\|Validation\|Validator\|Hook\|Callback\|Parallel'` |
| Paths, metadata, provenance | [Paths](../internal/engine/path_test.go), [metadata](../internal/engine/metadata_test.go), [provenance](../internal/engine/provenance_test.go) | `go test ./internal/engine -run 'Path\|Metadata\|Provenance\|Queries'` | | Paths, metadata, provenance | [Paths](../internal/engine/path_test.go), [metadata](../internal/engine/metadata_test.go), [provenance](../internal/engine/provenance_test.go) | `go test ./internal/engine -run 'Path\|Metadata\|Provenance\|Queries'` |
| Passive resource contexts | [Engine contexts](../internal/engine/resource_context_test.go), [public contexts](../facade_resource_context_test.go) | `go test ./internal/engine -run 'Resource'` and `go test . -run 'SourceContext'` |
| Secrets, bootstrap, redaction, explanation | [References](../internal/engine/secret_reference_test.go), [bootstrap](../internal/engine/secrets_test.go), [rendering](../internal/engine/render_test.go), [explanation](../internal/engine/explain_test.go) | `go test ./internal/engine -run 'Secret\|Bootstrap\|Effective\|Explain\|Explanation\|History\|Sensitivity'` | | Secrets, bootstrap, redaction, explanation | [References](../internal/engine/secret_reference_test.go), [bootstrap](../internal/engine/secrets_test.go), [rendering](../internal/engine/render_test.go), [explanation](../internal/engine/explain_test.go) | `go test ./internal/engine -run 'Secret\|Bootstrap\|Effective\|Explain\|Explanation\|History\|Sensitivity'` |
| 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'` | | 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'` | | Development check behavior | [Development checker test](../development_check_test.go) | `go test . -run '^TestDevelopmentCheckHandlesFilenamesAndStopsOnFailure$'` |
| Guarded release publication and recovery | [Publication regression](../release_publish_test.go) | `go test . -run '^TestReleasePublication'` | | Release profile wiring | [Release profile test](../releasekit_profile_test.go) | `go test . -run '^TestReleaseKitProfile$'` |
## Fuzz validation ## Fuzz validation

View File

@@ -7,23 +7,32 @@ private state; public signatures and fields do not expose engine types.
Implemented component owners: Implemented component owners:
- `internal/engine` owns source acquisition, composition, schema inspection, - `internal/engine` owns host and native-filesystem source acquisition,
decoding, lifecycle coordination, callback auditing, provenance/history, grouped strict composition, recursive precedence assembly, and destination-local
secret resolution and bootstrap, and safe inspection rendering. It never sibling inheritance before typed decoding. It also owns schema
imports the root package. inspection, lifecycle coordination, callback auditing,
- Root `Source`, `Path`, `Secret`, records, lifecycle callbacks, results, and provenance/history, passive source-context retention, secret resolution and
bootstrap reports are root-defined wrappers or records. Callback wrappers pass bootstrap, safe inspection rendering, and effective-value comparison over
only live engine context during a callback; `Result.Config` is detached and its frozen inspection snapshots. It never imports the root package.
private inspection remains frozen independently. - 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 - Root conversion files explicitly cross the path, record, callback, secret, and
error boundaries. There are no public aliases, anonymous engine embeddings, or error boundaries. There are no public aliases, anonymous engine embeddings, or
public engine bridge methods. 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/document` owns the parser-independent ordered tree and cloning.
`internal/yaml` is the sole upstream YAML adapter and parser/dumper boundary. `internal/yaml` is the sole upstream YAML adapter and parser/dumper boundary.
- Engine mechanism tests own source, composition, decoding, callback, provenance, - Engine mechanism tests own source, composition, inheritance, decoding,
secret, history, rendering, and bootstrap behavior. Root tests own public API callback, provenance,
compatibility, facade integration, documentation links, and dependency/public source-context, secret, history, rendering, and bootstrap behavior. Root tests
boundary checks. Consumer examples remain under `examples/consumers`. 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 Dependency direction is root to engine to document/YAML support. The root may
import only `internal/engine` among project internal production packages; engine import only `internal/engine` among project internal production packages; engine
@@ -32,8 +41,12 @@ these constraints and rejects exported aliases, fields, or signatures involving
internal types. internal types.
The [configuration contract](../config.md) owns implemented YAML and lifecycle The [configuration contract](../config.md) owns implemented YAML and lifecycle
semantics. The [consumer adoption guide](../consumers/README.md) owns application semantics, including [effective configuration diffs](../config.md#effective-configuration-diffs).
guidance. The [architecture policy](../policy/architecture.md) owns contributor The [consumer adoption guide](../consumers/README.md) owns application guidance.
boundaries, and [ADR 0006](../adr/0006-public-facade.md) records the facade 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. See the [development guide](../development.md) for validation commands 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 ## 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/documentation.md`, `docs/policy/agents.md`,
`docs/policy/architecture.md`, `docs/policy/testing.md`, and `docs/policy/architecture.md`, `docs/policy/testing.md`; root `AGENTS.md`; and the
`docs/policy/release.md`; root `AGENTS.md`; and the Policy Reading Guide in Policy Reading Guide in `docs/development.md`, adapted from
`docs/development.md`, adapted for this repository. `d84a38ebab26f048cafbe59a2246700c35d29843` for this repository.
Copyright (C) 2026 Eric Rakestraw 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) | License and notices: [CC BY-SA 4.0](licenses/CC-BY-SA-4.0.txt) |
[COPYRIGHT](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/COPYRIGHT) | [COPYRIGHT](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/COPYRIGHT) |
[LICENSE](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/LICENSE). [LICENSE](https://gitea.maximumdirect.net/eric/policy/src/commit/d84a38ebab26f048cafbe59a2246700c35d29843/LICENSE) |
[COPYRIGHT](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348/COPYRIGHT) |
[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/d84a38ebab26f048cafbe59a2246700c35d29843).
[Source](https://gitea.maximumdirect.net/eric/policy/src/commit/e833faddf120c789a9b45348edaf75720de16348).
## go-yaml v4 ## go-yaml v4
Selected module: `go.yaml.in/yaml/v4` at `v4.0.0-rc.6`. Selected module: `go.yaml.in/yaml/v4` at `v4.0.0-rc.6`.

View File

@@ -2,7 +2,7 @@
**Base Policy Version:** 1.3 **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) **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 ## 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 1. **Reduce application infrastructure:** Can applications delete substantial
configuration infrastructure while retaining clear ownership of their domain configuration infrastructure while retaining clear ownership of their domain
rules? rules?
@@ -41,8 +58,11 @@ Do not promote incidental implementation choices into architectural invariants.
configuration model, with only the flexibility needed to express genuine configuration model, with only the flexibility needed to express genuine
application requirements? application requirements?
Both criteria must hold. ConfigKit should not preserve divergent consumer Both criteria must hold. Minimize consumer loading and processing code subject
conventions merely for compatibility, nor impose restrictions that prevent useful 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 domain information from being expressed. Prefer a canonical representation using
existing primitives before adding another mechanism. Extend the model when a existing primitives before adding another mechanism. Extend the model when a
concrete requirement cannot be accommodated cleanly, not simply because a concrete requirement cannot be accommodated cleanly, not simply because a
@@ -88,6 +108,22 @@ must never run implicitly as part of configuration loading.
Remote data can enter through application-supplied input without introducing a Remote data can enter through application-supplied input without introducing a
network provider into the library. 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 ## Public API And Dependency Direction
Keep one small, coherent public surface for the supported lifecycle. Simple Keep one small, coherent public surface for the supported lifecycle. Simple
@@ -116,18 +152,41 @@ framework on this library.
## Configuration Integrity ## Configuration Integrity
Composition and intentional overlay are distinct operations. Composition must Within a configuration tier, independent contributions use strict composition:
not silently become last-wins override through file ordering. The decision and shared mappings combine recursively, while overlapping non-mapping definitions
alternatives are recorded in [ADR 0001](../adr/0001-separate-composition-and-overlay.md). 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 ConfigKit must define supported YAML semantics explicitly rather than inherit
parser defaults. Processing order and diagnostics must be deterministic for the parser defaults. Processing order and diagnostics must be deterministic for the
same inputs and options. Invalid input must not produce a successful usable same inputs and options. Invalid input must not produce a successful usable
configuration. Do not run application hooks on invalid typed configuration. configuration. Do not run application hooks on invalid typed configuration.
User-controlled precedence is application defaults, composed base, selected User-controlled precedence is application defaults, ordered underlays, composed
overlay, then explicitly supplied CLI overrides. Applications own flag parsing; main input, selected overlay, then explicitly supplied CLI overrides.
ConfigKit owns overlay semantics and provenance. Domain resolution follows these 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. inputs and must remain recorded, not become a hidden override channel.
Preserve node-based composition until the effective input is ready for typed Preserve node-based composition until the effective input is ready for typed
@@ -142,6 +201,13 @@ See [ADR 0005](../adr/0005-explicit-overrides-and-safe-explanation.md).
Detailed syntax and operation tables belong to the Detailed syntax and operation tables belong to the
[current configuration contract](../config.md). [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 ## Ownership And Confidentiality
Returned configuration and metadata must not share mutable state across loads. Returned configuration and metadata must not share mutable state across loads.

View File

@@ -1,10 +1,10 @@
# Release Policy # 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 ## 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 intended source revision under a fresh version. Releases should require little
routine intervention, avoid repeated work, and preserve published contents. 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 The repository-specific commands belong in `docs/release.md`. Historical release
summaries belong in `docs/releases/`. The procedure must agree with the adopted summaries belong in `docs/releases/`. The procedure must agree with the adopted
policy and link to it rather than introduce additional release requirements. 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. pushed before the tag or published together with it through an atomic push.
The procedure must choose and document the supported approach. The procedure must choose and document the supported approach.
ConfigKit uses sequential explicit pushes: first the captured candidate to ConfigKit uses sequential publication: first push the committed candidate to
authoritative `main`, then the annotated tag. Its authoritative `main`, then invoke ReleaseKit once to validate that candidate and
[release procedure](../release.md) names `origin` and checks that its fetch and publish its annotated tag. The [release procedure](../release.md) names
push destinations resolve to the authoritative URL. `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 Release commands must finish at successful publication. They must not wait for
or poll CI, require successful CI status, inspect downloadable artifacts, or 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 responsibility of their automation. Published deliverables must be traceable
to that source and identify the same version. to that source and identify the same version.
ConfigKit currently declares no additional deliverables. Its source-tag push ConfigKit currently declares no additional deliverables. Confirmed publication
is the completion point. 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 ## 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`: For releases at or after `v1.0.0`:
- a major release may make intentional incompatible changes; - 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. - a patch release may make backward-compatible fixes.
Before `v1.0.0`, a minor release may change a documented public, operational, Before `v1.0.0`, a minor release may change a documented public, operational,
@@ -108,13 +114,53 @@ Additional compatibility promises and deliberate exceptions belong in the
adopted policy or canonical contract documentation. Release notes explain adopted policy or canonical contract documentation. Release notes explain
changes; they do not redefine current compatibility contracts. 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 ## Release Documentation And Preparation
Every new release must include `docs/releases/<version>.md` in the tagged 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 source tree. Use the complete version tag as the filename and begin with a
title containing the product name and that exact version. 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 Include a short summary and any relevant compatibility impact or upgrade
instructions. Choose sections suited to the release and keep the note 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 mode or command may be provided for diagnosis, but must not be a required step
before publication repeats the same checks. before publication repeats the same checks.
ConfigKit uses `./scripts/release.sh vMAJOR.MINOR.PATCH`. The separate ConfigKit uses the module-selected ReleaseKit CLI's `publish
`scripts/release-check.sh` remains an optional diagnostic command; the vMAJOR.MINOR.PATCH` command. Its `check` command is optional for diagnosis; it
publication command invokes it once and does not use a persistent candidate is not a required preliminary pass to publication.
record.
Run the complete deterministic, offline default test suite once for the final Run the complete deterministic, offline default test suite once for the final
candidate, following the [testing policy](testing.md). For repositories that 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 the application's actual support commitments; native testing on each platform
and access to particular hardware are not default release prerequisites. 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 Prepare dependencies and build the tool before offline validation; the
detector and test-time vet disabled. This protects library behavior, ownership, publication command is the required validation run, with no separate successful
and concurrent use; the suite already includes examples, consumer fixtures, test or CI run required before it.
documentation links, and package boundaries. One full vet pass adds static
checks beyond the test-time subset. CGO-disabled Linux amd64 and arm64 builds The selected profile runs the complete deterministic, offline default suite
cover supported configurations distinct from native race compilation. macOS once with the race detector and test-time vet disabled. The suite already
and newer-toolchain checks are optional. 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 Additional gates must address a concrete risk that the existing checks do not
adequately cover. Do not automatically require linting, race testing, module 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 checks when their distinct protection justifies their cost. A one-time migration
check must not silently become a permanent release requirement. check must not silently become a permanent release requirement.
The selected Go commands use readonly module mode with `GOPROXY=off`; acquire ReleaseKit runs its selected Go commands with readonly module mode and
the baseline toolchain and dependencies before validation. Formatting and `GOPROXY=off`; acquire dependencies and build the tool before candidate
whitespace remain ordinary development checks in `scripts/check.sh`. validation. Formatting and whitespace remain ordinary development checks in
`scripts/check.sh`.
Review module tidiness when dependencies change, not as a release gate. Review module tidiness when dependencies change, not as a release gate.
Release tests must not require real credentials, mutable application services, 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; authoritative remote;
- the worktree and index are clean on the primary release branch; - the worktree and index are clean on the primary release branch;
- the source being published is the exact candidate that passed validation, - 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 - the remote primary branch can be advanced to that candidate without
discarding remote history, and is published before the tag; and discarding remote history, and is published before the tag; and
- the tag identifies the validated candidate and only the intended refs are - the tag identifies the validated candidate and only the intended refs are
published. published.
ConfigKit also checks that `origin` has exactly the authoritative fetch and ReleaseKit checks that `origin`'s effective push URL exactly matches the
push destination, including configured push URLs. The command checks the authoritative URL before candidate validation. This does not assert the
candidate and unused version again after validation, and checks local state configured fetch URL or require the release-note H1 to be the first line; the
before creating the tag. Ignored local files do not block publication. 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. 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 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 ## Repository Release Procedure
Maintain `docs/release.md` as a short, executable maintainer runbook. It must Maintain `docs/release.md` as a short, executable maintainer runbook. It must
identify prerequisites, the primary branch and authoritative remote, version identify prerequisites, the selected release toolchain, the primary branch and
and note preparation, one publication command with local checks and guards, authoritative remote, version and note preparation, one publication command with
source-only deliverables, the successful-tag-push completion point, and local checks and guards, source-only deliverables, the successful-tag-push
partial or unknown-state recovery. completion point, and partial or unknown-state recovery.
Use copyable commands where practical. Keep optional diagnostics and consumer Use copyable commands where practical. Keep optional diagnostics and consumer
verification clearly optional. The procedure must not turn preparation work, verification clearly optional. The procedure must not turn preparation work,

View File

@@ -1,7 +1,7 @@
# ConfigKit Release Procedure # ConfigKit Release Procedure
This is the maintainer runbook for ConfigKit's [release policy](policy/release.md). This is the maintainer runbook for ConfigKit's [release policy](policy/release.md).
ConfigKit publishes 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, matching checked-in release note are the deliverables; there is no binary,
container image, generated package, or separate registry publication. 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` - Module: `gitea.maximumdirect.net/eric/ConfigKit`
- Primary branch: `main` - Primary branch: `main`
- Authoritative remote: `origin`, with fetch and push destination - Authoritative remote: `origin`
`https://gitea.maximumdirect.net/eric/ConfigKit.git` - Authoritative push URL: `https://gitea.maximumdirect.net/eric/ConfigKit.git`
- Tags: stable Semantic Versions such as `v0.5.0`; no prereleases - Tags: stable Semantic Versions such as `v0.5.0`; no prereleases
- Minimum Go 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 - Release-gated targets: Linux amd64 and arm64
- Best-effort targets: macOS amd64 and arm64, under the - Best-effort targets: macOS amd64 and arm64, under the
[platform support policy](policy/release.md#platform-support) [platform support policy](policy/release.md#platform-support)
The checker runs one offline race suite, one full vet pass, and CGO-disabled Release validation runs one offline race-enabled Go suite, one full vet pass,
builds for both Linux targets. It reports skipped filesystem cases. macOS checks and CGO-disabled package compilation for both Linux targets. macOS and CI are
and newer-toolchain tests are optional. CI is never a readiness or completion not release gates; do not wait for or poll CI.
gate; do not wait for or poll it.
## 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, Use a clean checkout with Git, Bash 3.2 or newer, and a native compiler for race
and a native compiler for race tests. Configure private-host access through the tests. Choose the release type before selecting Go. For a minor or major release,
normal credential manager. Keep credentials and environment dumps outside the select the newest stable Go series available in the development environment.
repository and command transcript. 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 ```sh
git remote get-url origin export GOTOOLCHAIN=local
git ls-remote origin HEAD go version
go env GOVERSION
command -v go
``` ```
The fetch URL and all configured push destinations must resolve to the Only when a minor or major release advances the consumer minimum to a newer Go
authoritative URL above. The publication command checks this again. Acquire the series, derive that series' initial stable release from the selected executable:
selected toolchain and dependencies explicitly before offline validation:
```sh ```sh
export CONFIGKIT_BASELINE_TOOLCHAIN=go1.25.14 selected_go=$(GOTOOLCHAIN=local go env GOVERSION)
GOTOOLCHAIN="$CONFIGKIT_BASELINE_TOOLCHAIN" go version selected_series=${selected_go#go}
GOTOOLCHAIN="$CONFIGKIT_BASELINE_TOOLCHAIN" go mod download 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 If a dependency or identified correctness fix requires a higher patch minimum,
survey is required. Dependencies must be cached; the checker sets `GOPROXY=off`. use that required version instead and document the reason. When the series is
Publication requires user authorization. Standing authorization for local unchanged, leave the minimum unchanged unless a minor or major release
commits does not authorize a push or 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. ```sh
Review exported Go signatures and ownership, YAML and scalar semantics, go mod download
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.
When dependencies change, review parser behavior and migration impact as part Build the tool selected by `go.mod` into temporary storage outside the checkout.
of that change. Module tidiness belongs to dependency-change review, not the Ensure `$TMPDIR` names a system temporary location, not the repository. These
release checker. Do not repeat a dependency-version survey for every release. 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 The module-selected executable is ReleaseKit v0.4.1. Use the built executable
must be `# ConfigKit <version>`. Give a meaningful summary and include for publication to preserve its exit status and publication-state diagnostics.
compatibility impact or upgrade instructions when applicable. Link to current `go run` maps a nonzero child exit status to 1. ReleaseKit's child Go commands
contracts for exact behavior. Review the note and affected canonical documents use the `go` found on `PATH` with `GOTOOLCHAIN=local`; keep the selected
as ordinary preparation, then commit the complete candidate. 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 ```markdown
[COPYRIGHT](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/VERSION/COPYRIGHT) | [LICENSE](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/VERSION/LICENSE) | [ATTRIBUTION](https://gitea.maximumdirect.net/eric/ConfigKit/src/tag/VERSION/docs/oss/attribution.md) [COPYRIGHT](https://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 Replace `VERSION` with the full tag and confirm the three target files exist in
the URLs. The future tag need not resolve remotely yet; this review adds no the candidate. Retire completed roadmaps, preserve unfinished and deferred
network gate. ConfigKit uses checked-in notes without hosted release objects. work, and repair affected links. Review the complete candidate for unintended
If a hosted page is used later, reuse the note and preserve its footer. Do not files and credentials, then commit it on `main`. These are ordinary preparation
rewrite historical notes or add downloadable license assets. 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 ## 4. Publish the committed candidate
Before publication, review compatibility, release-note accuracy, and the file With the authorized candidate already on authoritative `main`, invoke the
list for unintended content or credentials. Retire completed roadmaps as part of module-selected native executable exactly once:
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:
```sh ```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 Replace the placeholder with the selected version. ReleaseKit checks the
and on the authoritative remote, and a matching note in the captured commit. effective push URL against `expected_push_url`, verifies the candidate on
Ignored local files do not block it. It checks remote ancestry, runs `main`, and checks the release-note H1 for the product and version. It then
[`release-check.sh`](../scripts/release-check.sh) once against that commit, runs the selected race-enabled standard suite once with test-time vet disabled,
rechecks candidate and version state, pushes only that commit to `main`, then one full vet pass, and CGO-disabled package compilation for Linux amd64 and
creates and pushes one annotated tag. The tag message is the checked-in note. arm64. It publishes only the annotated tag for that candidate. Confirmed
It never commits or repairs the checkout. 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.
A successful intended tag push completes publication. The command reports the The optional `check`
version and commit immediately. No CI, module retrieval, remote read-back, or command is available for diagnosis but is not a separate required pass:
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:
```sh ```sh
release_version=v0.5.0 "$releasekit_bin" check vMAJOR.MINOR.PATCH
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
``` ```
A query failure is not proof that the tag is absent. Compare the remote tag Status 0 confirms publication, status 1 reports a known publication failure or
object ID with the local annotated tag object ID; its peeled commit alone is conflict, status 2 reports usage, profile, or version errors, and status 3 means
insufficient to establish identical publication. A conflicting published tag the push outcome remains unknown. Preserve both the native exit status and its
must not be moved or replaced. If remote main advanced but the tag was not diagnostics.
published, report that partial progress. Only after confirming the local tag
was never published or used by another channel may a maintainer remove it: 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 ```sh
git tag -d "$release_version" git tag -d "$release_version"
"$releasekit_bin" publish "$release_version"
``` ```
A subsequent invocation revalidates the candidate; a correctly published tag A validation failure after the candidate reached `main` leaves an untagged
needs no rerun. Do not roll back main or automatically delete a tag. Transport candidate there. Correct it with a new commit rather than rewriting or rolling
or authentication failure alone does not require a new version. A defect in back `main`. A conflicting published tag must never be moved or reused. A
published source requires a corrected commit, a new semantically appropriate defect in published source requires a corrected commit, a new semantically
version and note, and the release procedure again. appropriate version, and a matching release note.
## 6. Optional consumer verification ## 6. Optional consumer verification
Only when useful or requested, read back the remote tag or retrieve the exact When useful or requested, read back the remote tag or retrieve the exact
version into a new module without a local `replace` directive. These checks version into a new module without a local `replace` directive. These checks are
are optional and non-gating. Do not poll CI or wait for module propagation. optional and non-gating. Do not poll CI or wait for module propagation. For
Private-host credentials must already be configured. For direct-origin direct-origin verification, use the exact published version in a temporary
verification, set the version to the release just published (replace the sample consumer module:
below), then use a temporary consumer module and check the exact module path
and version reported by `go list -m`:
```sh ```sh
release_version=v0.5.0 release_version=vMAJOR.MINOR.PATCH
consumer_dir=$(mktemp -d) consumer_dir=$(mktemp -d)
( (
cd "$consumer_dir" 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 references and explicit directory bootstrap are the environment convention. Literal
credential configuration and implicit secret-directory discovery are unsupported. credential configuration and implicit secret-directory discovery are unsupported.
The initial release deliberately excludes: The following remain deliberately excluded:
- automatic `conf.d` discovery; - automatic `conf.d` discovery;
- remote imports, arbitrary providers, and additional configuration formats; - 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; - parser-specific or custom structural-decoding extension APIs;
- generic dynamic-subtree conversion and module-registry APIs; - generic dynamic-subtree conversion and module-registry APIs;
- automatic schema migrations; - automatic schema migrations;
- hot reload, runtime resource replacement, and remote resource validation; - hot reload, runtime resource replacement, and remote resource validation;
- a bundled CLI framework or application commands; and - 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 Applications may build their own validate, effective-value, source, bootstrap, and
explanation commands around the library. Broadening these boundaries requires explanation commands around the library. Broadening these boundaries requires
concrete consumer evidence, compatibility review, and an explicit design change. 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

@@ -30,6 +30,32 @@ func ExampleNoSource() {
// Output: 4 0 <nil> // 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() { func ExampleValidation_Error() {
type Config struct { type Config struct {
First string `yaml:"first"` First string `yaml:"first"`

View File

@@ -52,6 +52,22 @@ func Example_seriatim() {
// Output: visitor true the event occurred <nil> <nil> // 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() { func Example_weatherStartup() {
const blockedName = "CONFIGKIT_WEATHER_EXAMPLE_BLOCKED" const blockedName = "CONFIGKIT_WEATHER_EXAMPLE_BLOCKED"
const tokenName = "CONFIGKIT_WEATHER_EXAMPLE_TOKEN" const tokenName = "CONFIGKIT_WEATHER_EXAMPLE_TOKEN"

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
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,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.Resolution](), reflect.TypeFor[configkit.Validation](),
reflect.TypeFor[configkit.Version](), reflect.TypeFor[configkit.VersionRule](), reflect.TypeFor[configkit.Version](), reflect.TypeFor[configkit.VersionRule](),
reflect.TypeFor[configkit.FilesystemContext](), reflect.TypeFor[configkit.Override](), 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.Definition[struct{}]](), reflect.TypeFor[configkit.Options[struct{}]](),
reflect.TypeFor[configkit.Result[struct{}]](), reflect.TypeFor[configkit.Result[struct{}]](),
reflect.TypeFor[configkit.ValueSnapshot](), reflect.TypeFor[configkit.ValueChange](), 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) }}, {"IsExplicit", func() { state.resolution.IsExplicit(path) }},
{"InputInfo", func() { state.resolution.InputInfo(path) }}, {"InputInfo", func() { state.resolution.InputInfo(path) }},
{"Source", func() { state.resolution.Source("source") }}, {"Source", func() { state.resolution.Source("source") }},
{"SourceContext", func() { state.resolution.SourceContext("source") }},
{"Record", func() { state.resolution.Record(path, configkit.ChangeNormalization, "reason") }}, {"Record", func() { state.resolution.Record(path, configkit.ChangeNormalization, "reason") }},
{"Resolution.Error", func() { state.resolution.Error(path, "message") }}, {"Resolution.Error", func() { state.resolution.Error(path, "message") }},
{"Resolution.Warning", func() { state.resolution.Warning(path, "message") }}, {"Resolution.Warning", func() { state.resolution.Warning(path, "message") }},

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 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 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 h1:1h7H1ohdUh93/FyE4YaDa1Zh64K6VVbjF4K6WUxMtH4=
go.yaml.in/yaml/v4 v4.0.0-rc.6/go.mod h1:aZqd9kCMsGL7AuUv/m/PvWLdg5sjJsZ4oHDEnfPPfY0= 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 // 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) { func (r *Result[T]) Explain(path Path) (Explanation, bool) {
if r == nil { if r == nil {
return Explanation{}, false 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 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; // cloneComposedDocument detaches mutable composition and provenance state;
// history application trees are immutable and retain their existing sharing. // history application trees are immutable and retain their existing sharing.
func cloneComposedDocument(base *composedDocument) *composedDocument { func cloneComposedDocument(base *composedDocument) *composedDocument {
@@ -58,8 +69,14 @@ func composeDocuments(inputs ...*document.Node) (*composedDocument, []Diagnostic
collected.append(input, Path{}) collected.append(input, Path{})
} }
checked := make(map[string]struct{}, len(collected.byPath))
for _, claim := range collected.ordered { 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) { if len(claims) < 2 || allMappingClaims(claims) {
continue continue
} }
@@ -67,21 +84,43 @@ func composeDocuments(inputs ...*document.Node) (*composedDocument, []Diagnostic
} }
root := document.Clone(inputs[0]) root := document.Clone(inputs[0])
index := new(mappingIndex)
for _, input := range inputs[1:] { for _, input := range inputs[1:] {
mergeDisjointMappings(root, input) mergeDisjointMappings(root, input, index)
} }
applications := make([]historyApplication, 0, len(inputs)) applications := make([]historyApplication, 0, len(inputs))
for _, input := range inputs { for _, input := range inputs {
applications = append(applications, historyApplication{layer: LayerBase, value: input}) applications = append(applications, historyApplication{layer: LayerBase, value: input})
} }
origins := compositionOrigins(collected.ordered)
completeCompositionOrigins(root, Path{}, origins)
return &composedDocument{ return &composedDocument{
root: root, root: root,
claims: cloneCompositionClaims(collected.ordered), claims: cloneCompositionClaims(collected.ordered),
origins: compositionOrigins(collected.ordered), origins: origins,
applications: applications, applications: applications,
}, nil }, 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) { func (c *compositionClaims) append(node *document.Node, path Path) {
claim := compositionClaim{ claim := compositionClaim{
path: path.clone(), path: path.clone(),
@@ -109,18 +148,18 @@ func allMappingClaims(claims []compositionClaim) bool {
return true return true
} }
func mergeDisjointMappings(target, incoming *document.Node) { func mergeDisjointMappings(target, incoming *document.Node, index *mappingIndex) {
for _, incomingEntry := range incoming.Mapping { for _, incomingEntry := range incoming.Mapping {
index := documentEntryIndex(target.Mapping, incomingEntry.Key) position := index.find(target, incomingEntry.Key)
if index < 0 { if position < 0 {
cloned := incomingEntry cloned := incomingEntry
cloned.Value = document.Clone(incomingEntry.Value) cloned.Value = document.Clone(incomingEntry.Value)
target.Mapping = append(target.Mapping, cloned) index.append(target, cloned)
continue continue
} }
// Claim validation guarantees that the only remaining overlap is a // Claim validation guarantees that the only remaining overlap is a
// mapping pair. Empty mappings naturally contribute no child entries. // 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"}) 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) { func TestComposeDocumentsTreatsEmptyMappingsAsChildlessContributions(t *testing.T) {
empty, diagnostics := composeDocuments( empty, diagnostics := composeDocuments(
parseCompositionDocument(t, "source-1", "{}\n"), parseCompositionDocument(t, "source-1", "{}\n"),

View File

@@ -85,15 +85,28 @@ func canonicalDiagnostics(diagnostics []Diagnostic) []Diagnostic {
for i := range canonical { for i := range canonical {
slices.SortFunc(canonical[i].Related, compareLocation) 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 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 { if order := compareOptionalLocation(left.Primary, right.Primary); order != 0 {
return order return order
} }
if order := cmp.Compare(left.Path.String(), right.Path.String()); order != 0 { if order := cmp.Compare(leftPath, rightPath); order != 0 {
return order return order
} }
if order := cmp.Compare(left.Code, right.Code); order != 0 { 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) { func TestWriteDiagnosticsEscapesControls(t *testing.T) {
t.Parallel() 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 { func compareConfigurationValues(before, after *document.Node) []documentDelta {
var deltas []documentDelta var deltas []documentDelta
compareConfigurationValue(before, after, Path{}, &deltas) compareConfigurationValue(before, after, Path{}, &deltas)
slices.SortFunc(deltas, func(left, right documentDelta) int { type orderedDelta struct {
if order := cmp.Compare(left.path.String(), right.path.String()); order != 0 { 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 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 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) { 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() { if root == nil || !path.valid() {
return nil, false return nil, false
} }
@@ -41,17 +45,21 @@ func documentAtPath(root *document.Node, path Path) (*document.Node, bool) {
if segment.kind == pathSegmentIndex { if segment.kind == pathSegmentIndex {
return nil, false return nil, false
} }
found := false entryIndex := -1
for _, entry := range current.Mapping { if lookup != nil {
entryIndex = lookup.find(current, segment.key)
} else {
for index, entry := range current.Mapping {
if entry.Key == segment.key { if entry.Key == segment.key {
current = entry.Value entryIndex = index
found = true
break break
} }
} }
if !found { }
if entryIndex < 0 || entryIndex >= len(current.Mapping) {
return nil, false return nil, false
} }
current = current.Mapping[entryIndex].Value
case document.Sequence: case document.Sequence:
index, ok := schemaIndex(segment) index, ok := schemaIndex(segment)
if !ok || index >= len(current.Sequence) { 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) { if r == nil || r.final == nil || r.baseline == nil || !r.metadata.hasHistoricalPath(path) {
return Explanation{}, false 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 { if info, ok := r.metadata.Lookup(path); ok {
explanation.Origin = cloneOrigin(info.Origin) 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}, After: valueSnapshot(working, path, r.secrets), Origin: Origin{Layer: LayerDefault},
}) })
} }
replayIndex := new(mappingIndex)
for _, application := range r.applications { for _, application := range r.applications {
operationPath, relevant := applicationOperationPath(application, path) operationPath, relevant := applicationOperationPath(application, path, r.immutableLookup)
var before ValueSnapshot var before ValueSnapshot
if relevant { if relevant {
before = valueSnapshot(working, path, r.secrets) before = valueSnapshot(working, path, r.secrets)
} }
applyHistoryApplication(&working, application) applyHistoryApplication(&working, application, replayIndex)
if !relevant { if !relevant {
continue continue
} }
@@ -68,28 +69,29 @@ func (r *Inspection) Explain(path Path) (Explanation, bool) {
explanation.History = append(explanation.History, ValueChange{ explanation.History = append(explanation.History, ValueChange{
Before: before, Before: before,
After: after, 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: // Intermediate input replacements do not permanently suppress defaults:
// decoding applies them to the final input tree. Show that reconciliation // decoding applies them to the final input tree. Show that reconciliation
// explicitly, including canonical scalar conversions, before domain changes. // explicitly, including canonical scalar conversions, before domain changes.
replayed, _ := documentAtPath(working, path) replayed, _ := documentAtPath(working, path)
decoded, _ := documentAtPath(r.input, path) decoded, _ := documentAtPathWithLookup(r.input, path, r.immutableLookup)
if !configurationValuesEqual(replayed, decoded) { if !configurationValuesEqual(replayed, decoded) {
origin := cloneOrigin(r.inputOrigins[path.String()]) origin := cloneOrigin(r.inputOrigins[path.String()])
origin.Operation = ChangeDecoding origin.Operation = ChangeDecoding
explanation.History = append(explanation.History, ValueChange{ explanation.History = append(explanation.History, ValueChange{
Before: valueSnapshot(working, path, r.secrets), Before: valueSnapshot(working, path, r.secrets),
After: valueSnapshot(r.input, path, r.secrets), After: valueSnapshotWithLookup(r.input, path, r.secrets, r.immutableLookup),
Origin: origin, Origin: origin,
Reasons: []string{"applied retained defaults and canonical typed decoding"}, Reasons: []string{"applied retained defaults and canonical typed decoding"},
}) })
} }
working = document.Clone(r.input) working = document.Clone(r.input)
for _, snapshot := range coalescedResolutionSnapshots(r.resolution) { for _, snapshot := range r.resolutionReplay {
relevant := pathsIntersect(snapshot.change.Path, path) relevant := pathsIntersect(snapshot.change.Path, path)
var before ValueSnapshot var before ValueSnapshot
if relevant { if relevant {

View File

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

View File

@@ -10,6 +10,7 @@ import (
"path/filepath" "path/filepath"
"reflect" "reflect"
"strings" "strings"
"sync"
"testing" "testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document" "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 package engine
import ( import (
"io/fs"
"os" "os"
"path"
"path/filepath" "path/filepath"
"strings" "strings"
) )
func validateFilesystemContext(source Source, context *FilesystemContext, main parsedSource) *Diagnostic { 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) rootPath, _, _, diagnostic := importFilesystem(source, context, main)
if diagnostic != nil { if diagnostic != nil {
return diagnostic return diagnostic
@@ -29,20 +38,87 @@ func filesystemOptionDiagnostics(source Source, context *FilesystemContext) []Di
case sourceNone: case sourceNone:
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "NoSource cannot use filesystem context")} return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "NoSource cannot use filesystem context")}
case sourceFile: case sourceFile:
if context.FS != nil {
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "host file sources cannot use a native filesystem context")}
}
if context.BaseDir != "" { if context.BaseDir != "" {
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "file sources determine their own import base directory")} 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 == "" { if context.BaseDir == "" || context.Root == "" {
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "byte and reader filesystem context requires base and root directories")} 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 []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "byte and reader filesystem base and root directories must be absolute")}
} }
} }
return nil 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) { func importFilesystem(source Source, context *FilesystemContext, main parsedSource) (rootPath, basePath, mainPath string, diagnostic *Diagnostic) {
if main.source == nil || main.document == nil { if main.source == nil || main.document == nil {
value := importDiagnostic(CodeImportInvalid, "imports require a selected configuration source", 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 ( import (
"errors" "errors"
"io"
"io/fs" "io/fs"
"os"
"path" "path"
"path/filepath"
"slices" "slices"
"strings" "strings"
) )
@@ -42,29 +39,29 @@ func (s *importTraversal) expandImportGlob(basePath string, declaration importDe
names = []string{literal} names = []string{literal}
} }
for _, name := range names { 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) 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)} 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 err != nil {
if errors.Is(err, fs.ErrNotExist) && !patterned { if errors.Is(err, fs.ErrNotExist) && !patterned {
continue continue
} }
return nil, []Diagnostic{globFilesystemDiagnostic(err, "glob match could not be canonicalized", location)} 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)} return nil, []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "glob traversal resolves outside the configuration root", &location)}
} }
if last { if last {
next = append(next, globCandidate{absolute: canonical, relative: relative}) 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)} return nil, []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "glob matches exceed the remaining source-count limit", &location)}
} }
continue continue
} }
info, err := os.Stat(canonical) info, err := s.access.stat(canonical)
if err != nil { if err != nil {
return nil, []Diagnostic{globFilesystemDiagnostic(err, "glob traversal target could not be inspected", location)} 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) { 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 err != nil {
if errors.Is(err, fs.ErrNotExist) { if errors.Is(err, fs.ErrNotExist) {
return nil, nil return nil, nil
} }
return nil, []Diagnostic{globFilesystemDiagnostic(err, "glob directory could not be canonicalized", location)} 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)} return nil, []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "glob directory resolves outside the configuration root", &location)}
} }
relative, err := filepath.Rel(s.rootPath, canonical) if _, err := s.access.relative(canonical); err != nil {
if err != nil || filepath.IsAbs(relative) || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
return nil, []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "glob directory could not be addressed inside the configuration root", &location)} 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 var matches []string
for { var problem *Diagnostic
entries, readErr := directoryFile.ReadDir(globReadBatch) err = s.access.readDirectory(canonical, func(entry fs.DirEntry) bool {
for _, entry := range entries { s.accounting.entries++
s.usedEntries++ if s.accounting.entries > s.limits.entries {
if s.usedEntries > s.limits.entries { value := importDiagnostic(CodeImportLimitExceeded, "glob traversal exceeds the directory-entry visit limit", &location)
_ = directoryFile.Close() problem = &value
return nil, []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "glob traversal exceeds the directory-entry visit limit", &location)} return false
} }
matched, matchErr := path.Match(pattern, entry.Name()) matched, matchErr := path.Match(pattern, entry.Name())
if matchErr != nil { if matchErr != nil {
_ = directoryFile.Close() value := importDiagnostic(CodeImportInvalid, "import glob pattern is invalid", &location)
return nil, []Diagnostic{importDiagnostic(CodeImportInvalid, "import glob pattern is invalid", &location)} problem = &value
return false
} }
if matched { if matched {
matches = append(matches, entry.Name()) matches = append(matches, entry.Name())
} }
return true
})
if problem != nil {
return nil, []Diagnostic{*problem}
} }
if errors.Is(readErr, io.EOF) { if errors.Is(err, fs.ErrNotExist) {
break return nil, nil
} }
if readErr != nil { if err != nil {
_ = directoryFile.Close() return nil, []Diagnostic{globFilesystemDiagnostic(err, "glob directory could not be traversed", location)}
return nil, []Diagnostic{globFilesystemDiagnostic(readErr, "glob directory could not be read", location)}
}
}
if err := directoryFile.Close(); err != nil {
return nil, []Diagnostic{globFilesystemDiagnostic(err, "glob directory could not be closed", location)}
} }
slices.Sort(matches) slices.Sort(matches)
return matches, nil return matches, nil

View File

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

View File

@@ -4,9 +4,6 @@ import (
"bytes" "bytes"
"errors" "errors"
"io/fs" "io/fs"
"os"
"path"
"path/filepath"
"strconv" "strconv"
"strings" "strings"
@@ -40,47 +37,96 @@ type importRoute struct {
locations []Location locations []Location
} }
type importAccounting struct {
sources int
bytes int64
nodes int
entries int
}
type importTraversal struct { type importTraversal struct {
rootPath string access *filesystemAccess
root *os.Root accounting *importAccounting
limits importLimits limits importLimits
rule *VersionRule rule *VersionRule
documents []*document.Node documents []*document.Node
sources []SourceInfo sources []SourceInfo
active map[string]int active map[resourceIdentity]int
visited map[string]importRoute visited map[resourceIdentity]importRoute
stack []importRoute stack []importRoute
usedBytes int64
usedNodes int
usedEntries int
} }
func loadImportedDocuments(source Source, context *FilesystemContext, main parsedSource, rule *VersionRule, limits importLimits) (*composedDocument, []SourceInfo, []Diagnostic) { func loadImportedDocuments(source Source, context *FilesystemContext, main parsedSource, rule *VersionRule, limits importLimits) (*composedDocument, []SourceInfo, []Diagnostic) {
return loadImportedDocumentsTracked(source, context, main, rule, limits, &importAccounting{}, nil, nil)
}
func loadImportedDocumentsTracked(source Source, context *FilesystemContext, main parsedSource, 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, rule, limits, accounting, visited)
}
rootPath, basePath, mainPath, diagnostic := importFilesystem(source, context, main) rootPath, basePath, mainPath, diagnostic := importFilesystem(source, context, main)
if diagnostic != nil { if diagnostic != nil {
return nil, sourceInfos(main.source), []Diagnostic{*diagnostic} return nil, sourceInfos(main.source), []Diagnostic{*diagnostic}
} }
root, err := os.OpenRoot(rootPath) access, err := openHostAccess(rootPath)
if err != nil { if err != nil {
diagnostic := globFilesystemDiagnostic(err, "configuration root could not be opened", Location{}) diagnostic := globFilesystemDiagnostic(err, "configuration root could not be opened", Location{})
diagnostic.Primary = nil diagnostic.Primary = nil
return nil, sourceInfos(main.source), []Diagnostic{diagnostic} return nil, sourceInfos(main.source), []Diagnostic{diagnostic}
} }
if hostToken != nil {
access.token = hostToken
}
return loadImportedWithAccessTracked(access, basePath, mainPath, main, 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, rule, limits, accounting, nil)
}
func loadImportedWithAccessTracked(access *filesystemAccess, basePath, mainPath string, main parsedSource, 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 := cloneSourceInfo(*main.source)
if mainPath != "" { if mainPath != "" {
mainInfo.Path = 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{ state := importTraversal{
rootPath: rootPath, root: root, limits: limits, rule: rule, access: access, accounting: accounting, limits: limits, rule: rule,
documents: make([]*document.Node, 0, 1), documents: make([]*document.Node, 0, 1),
sources: []SourceInfo{mainInfo}, sources: []SourceInfo{mainInfo},
active: make(map[string]int), visited: make(map[string]importRoute), active: make(map[resourceIdentity]int), visited: visited,
usedBytes: main.bytes, usedNodes: countInputNodes(main.document), }
if state.visited == nil {
state.visited = make(map[resourceIdentity]importRoute)
} }
if diagnostic := state.checkInitialLimits(); diagnostic != nil { if diagnostic := state.checkInitialLimits(); diagnostic != nil {
_ = root.Close() _ = access.close()
return nil, cloneSourceInfos(state.sources), []Diagnostic{*diagnostic} return nil, cloneSourceInfos(state.sources), []Diagnostic{*diagnostic}
} }
@@ -89,8 +135,8 @@ func loadImportedDocuments(source Source, context *FilesystemContext, main parse
mainIdentity = "<selected-source>" mainIdentity = "<selected-source>"
} }
mainRoute := importRoute{canonical: mainIdentity} mainRoute := importRoute{canonical: mainIdentity}
state.active[mainIdentity] = 0 state.active[mainKey] = 0
state.visited[mainIdentity] = mainRoute state.visited[mainKey] = mainRoute
state.stack = append(state.stack, mainRoute) state.stack = append(state.stack, mainRoute)
body, declarations, diagnostics := splitImportDirectives(main.document) body, declarations, diagnostics := splitImportDirectives(main.document)
if len(diagnostics) == 0 { if len(diagnostics) == 0 {
@@ -98,7 +144,7 @@ func loadImportedDocuments(source Source, context *FilesystemContext, main parse
diagnostics = state.walkDeclarations(basePath, declarations, 0) diagnostics = state.walkDeclarations(basePath, declarations, 0)
} }
closeErr := root.Close() closeErr := access.close()
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
return nil, cloneSourceInfos(state.sources), diagnostics return nil, cloneSourceInfos(state.sources), diagnostics
} }
@@ -107,22 +153,22 @@ func loadImportedDocuments(source Source, context *FilesystemContext, main parse
} }
composed, diagnostics := composeDocuments(state.documents...) composed, diagnostics := composeDocuments(state.documents...)
if composed != nil { if composed != nil {
composed.inputBytes = state.usedBytes composed.inputBytes = accounting.bytes
composed.inputNodes = state.usedNodes composed.inputNodes = accounting.nodes
} }
return composed, cloneSourceInfos(state.sources), diagnostics return composed, cloneSourceInfos(state.sources), diagnostics
} }
func (s *importTraversal) checkInitialLimits() *Diagnostic { 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) value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the source-count limit", nil)
return &value 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) value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate byte limit", nil)
return &value 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) value := importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", nil)
return &value return &value
} }
@@ -169,11 +215,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)} 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))) candidate := s.access.join(basePath, declaration.path)
if !containedPath(s.rootPath, filepath.Clean(candidate)) { if !s.access.contains(candidate) {
return []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "import target is outside the configuration root", &location)} 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 err != nil {
if declaration.optional && errors.Is(err, fs.ErrNotExist) { if declaration.optional && errors.Is(err, fs.ErrNotExist) {
return nil return nil
@@ -187,30 +233,39 @@ func (s *importTraversal) visitPath(basePath string, declaration importDeclarati
} }
return []Diagnostic{importDiagnostic(code, message, &location)} 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)} return []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "import target resolves outside the configuration root", &location)}
} }
currentRoute := s.currentRoute(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:]...) cycle := append([]importRoute(nil), s.stack[activeIndex:]...)
return []Diagnostic{importCycleDiagnostic(canonical, location, cycle)} 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)} 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)} return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the source-count limit", &location)}
} }
relative, err := filepath.Rel(s.rootPath, canonical) if _, err := s.access.relative(canonical); err != nil {
if err != nil || filepath.IsAbs(relative) || relative == ".." || strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
return []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "import target could not be addressed inside the configuration root", &location)} return []Diagnostic{importDiagnostic(CodeImportOutsideRoot, "import target could not be addressed inside the configuration root", &location)}
} }
sourceID := "source-" + strconv.Itoa(len(s.sources)+1) sourceID := "source-" + strconv.Itoa(s.accounting.sources+1)
info := SourceInfo{ID: sourceID, Label: declaration.path, Path: canonical, Layer: LayerBase, ImportedFrom: &location} 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, Layer: LayerBase, Kind: kind, ImportedFrom: &location}
if kind == SourceKindFilesystem {
info.namespace = s.access.token
}
s.sources = append(s.sources, info) 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 { if diagnostic != nil {
return []Diagnostic{*diagnostic} return []Diagnostic{*diagnostic}
} }
@@ -218,6 +273,7 @@ func (s *importTraversal) visitPath(basePath string, declaration importDeclarati
s.sources = s.sources[:len(s.sources)-1] s.sources = s.sources[:len(s.sources)-1]
return nil return nil
} }
s.accounting.sources++
root, issues := yamladapter.Parse(bytes.NewReader(data), sourceID, yamladapter.Limits{ root, issues := yamladapter.Parse(bytes.NewReader(data), sourceID, yamladapter.Limits{
Depth: maxDocumentDepth, Nodes: maxDocumentNodes, Depth: maxDocumentDepth, Nodes: maxDocumentNodes,
@@ -226,10 +282,10 @@ func (s *importTraversal) visitPath(basePath string, declaration importDeclarati
return parserDiagnostics(sourceID, issues) return parserDiagnostics(sourceID, issues)
} }
nodeCount := countInputNodes(root) 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)} return []Diagnostic{importDiagnostic(CodeImportLimitExceeded, "configuration exceeds the aggregate node limit", &location)}
} }
s.usedNodes += nodeCount s.accounting.nodes += nodeCount
body, declarations, diagnostics := splitImportDirectives(root) body, declarations, diagnostics := splitImportDirectives(root)
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
@@ -245,22 +301,22 @@ func (s *importTraversal) visitPath(basePath string, declaration importDeclarati
route := importRoute{canonical: canonical, locations: currentRoute.locations} route := importRoute{canonical: canonical, locations: currentRoute.locations}
s.documents = append(s.documents, body) s.documents = append(s.documents, body)
s.active[canonical] = len(s.stack) s.active[identity] = len(s.stack)
s.visited[canonical] = route s.visited[identity] = route
s.stack = append(s.stack, route) s.stack = append(s.stack, route)
diagnostics = s.walkDeclarations(filepath.Dir(canonical), declarations, depth) diagnostics = s.walkDeclarations(s.access.dir(canonical), declarations, depth)
delete(s.active, canonical) delete(s.active, identity)
s.stack = s.stack[:len(s.stack)-1] s.stack = s.stack[:len(s.stack)-1]
return diagnostics return diagnostics
} }
func (s *importTraversal) readImport(relative, sourceID string, optional bool) ([]byte, bool, *Diagnostic) { func (s *importTraversal) readImport(name, sourceID string, optional bool) ([]byte, bool, *Diagnostic) {
remaining := s.limits.bytes - s.usedBytes remaining := s.limits.bytes - s.accounting.bytes
limit := min(int64(maxSourceBytes), remaining) limit := min(int64(maxSourceBytes), remaining)
if limit < 0 { if limit < 0 {
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 failure != nil {
if optional && failure.missingTarget { if optional && failure.missingTarget {
return nil, true, nil return nil, true, nil
@@ -268,7 +324,7 @@ func (s *importTraversal) readImport(relative, sourceID string, optional bool) (
value := sourceReadDiagnostic(failure, sourceID, CodeImportLimitExceeded, "configuration exceeds a source or aggregate byte limit") value := sourceReadDiagnostic(failure, sourceID, CodeImportLimitExceeded, "configuration exceeds a source or aggregate byte limit")
return nil, false, &value return nil, false, &value
} }
s.usedBytes += int64(len(data)) s.accounting.bytes += int64(len(data))
return data, false, nil 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...)))
}

View File

@@ -0,0 +1,60 @@
package engine
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
func appendInheritanceExplanation(explanation *Explanation, working **document.Node, query Path, operations []inheritanceOperation, secrets map[string]bool) {
for _, operation := range operations {
changes := projectedInheritanceChanges(query, operation)
if operation.control != nil {
if !pathsIntersect(operation.control.path, query) {
continue
}
before := valueSnapshot(*working, query, secrets)
replaceHistoryPath(working, operation.control.path, nil)
after := valueSnapshot(*working, query, secrets)
if len(changes) != 0 {
explanation.History = append(explanation.History, ValueChange{
Before: before, After: after, Origin: cloneOrigin(changes[0].Origin),
Dependencies: clonePaths(changes[0].Dependencies),
})
}
continue
}
if operation.transfer == nil {
continue
}
before := valueSnapshot(*working, query, secrets)
replaceHistoryPath(working, operation.transfer.to, operation.parent)
afterParent := valueSnapshot(*working, query, secrets)
parent, parentPresent := transferredOperationOrigin(operation, operationPathForQuery(operation.transfer.to, query))
if pathsIntersect(operation.transfer.to, query) && parentPresent {
parent.Operation = ChangeInheritance
parent.Inheritance = projectInheritanceSteps(parent)
explanation.History = append(explanation.History, ValueChange{
Before: before, After: afterParent, Origin: parent,
Dependencies: []Path{operation.transfer.from.clone(), operation.transfer.reference.clone()},
})
}
replaceHistoryPath(working, operation.transfer.to, operation.effective)
if !pathsIntersect(operation.transfer.to, query) {
continue
}
afterChild := valueSnapshot(*working, query, secrets)
if len(changes) != 0 {
last := changes[len(changes)-1]
if !parentPresent || len(changes) > 1 {
explanation.History = append(explanation.History, ValueChange{
Before: afterParent, After: afterChild, Origin: cloneOrigin(last.Origin),
Dependencies: clonePaths(last.Dependencies),
})
}
}
}
}
func operationPathForQuery(root, query Path) Path {
if pathContains(query, root) {
return root
}
return query
}

View File

@@ -0,0 +1,170 @@
package engine
import (
"sort"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
type inheritanceHistoryIndex struct {
paths pathPrefixIndex
}
func newInheritanceHistoryIndex(operations []inheritanceOperation) *inheritanceHistoryIndex {
if len(operations) == 0 {
return nil
}
index := &inheritanceHistoryIndex{}
for operationID, operation := range operations {
if operation.control != nil {
index.paths.insert(operation.control.path.String(), operationID)
continue
}
if operation.transfer != nil {
index.paths.insert(operation.transfer.to.String(), operationID)
}
}
return index
}
func (index *inheritanceHistoryIndex) matchingOperations(path Path, operations []inheritanceOperation) []inheritanceOperation {
if index == nil {
return operations
}
var ids []int
seen := make(map[int]struct{})
collect := func(id int) {
if _, found := seen[id]; found {
return
}
seen[id] = struct{}{}
ids = append(ids, id)
}
pointer := path.String()
index.paths.visitAncestors(pointer, collect)
index.paths.visitDescendants(pointer, collect)
sort.Ints(ids)
matching := make([]inheritanceOperation, 0, len(ids))
for _, id := range ids {
if id >= 0 && id < len(operations) {
matching = append(matching, operations[id])
}
}
return matching
}
// Retain source history at original paths. Structural operations are held once
// per relationship and projected onto a requested destination path only when
// History or Explain is queried.
func applyInheritanceMetadata(metadata *Metadata, raw *document.Node, rawOrigins map[string]Origin, overlays []overlayRecord, expansion *inheritanceScope) {
if metadata == nil || expansion == nil {
return
}
sourceView := Metadata{
fields: make(map[string]FieldInfo),
history: metadata.history,
}
walkDocument(raw, Path{}, func(path Path, node *document.Node) {
origin, found := rawOrigins[path.String()]
if !found {
origin = Origin{Layer: LayerBase, Locations: []Location{publicLocation(node.Location)}}
}
sourceView.fields[path.String()] = FieldInfo{Explicit: true, Null: node.Kind == document.Null, Origin: cloneOrigin(origin)}
})
applyOverlayMetadata(&sourceView, overlays)
metadata.history = sourceView.history
metadata.inheritance = append([]inheritanceOperation(nil), expansion.operations...)
}
func historyWithInheritance(path Path, stored []Change, operations []inheritanceOperation) []Change {
split := len(stored)
for index, change := range stored {
if change.Origin.Layer == LayerResolution {
split = index
break
}
}
history := cloneChanges(stored[:split])
for _, operation := range operations {
for _, change := range projectedInheritanceChanges(path, operation) {
if len(history) != 0 {
change.Previous = []Origin{cloneOrigin(history[len(history)-1].Origin)}
}
history = append(history, change)
}
}
for _, change := range stored[split:] {
cloned := cloneChange(change)
if len(history) != 0 {
cloned.Previous = []Origin{cloneOrigin(history[len(history)-1].Origin)}
}
history = append(history, cloned)
}
return history
}
func projectedInheritanceChanges(query Path, operation inheritanceOperation) []Change {
if operation.control != nil {
if !pathsIntersect(operation.control.path, query) {
return nil
}
origin := cloneOrigin(operation.controlOrigin)
origin.Operation = ChangeInheritance
origin.Inheritance = projectInheritanceSteps(origin)
return []Change{{Path: operation.control.path.clone(), Origin: origin,
Dependencies: []Path{operation.control.path.clone()}}}
}
if operation.transfer == nil || !pathsIntersect(operation.transfer.to, query) {
return nil
}
path := query
if pathContains(query, operation.transfer.to) {
path = operation.transfer.to
}
var changes []Change
parent, parentFound := transferredOperationOrigin(operation, path)
if parentFound {
parent.Operation = ChangeInheritance
parent.Inheritance = projectInheritanceSteps(parent)
changes = append(changes, Change{
Path: path.clone(), Origin: parent,
Dependencies: []Path{operation.transfer.from.clone(), operation.transfer.reference.clone()},
})
}
effective, effectiveFound := operation.effectiveOrigins[path.String()]
if effectiveFound {
effective = cloneOrigin(effective)
effective.Operation = ChangeInheritance
}
if effectiveFound && (!parentFound || !originContainedBy(effective, parent)) {
effective.Inheritance = projectInheritanceSteps(effective)
changes = append(changes, Change{
Path: path.clone(), Origin: effective,
Dependencies: []Path{operation.transfer.reference.clone()},
})
} else if parentFound && !effectiveFound {
removed := cloneOrigin(operation.effectiveOrigins[operation.transfer.to.String()])
removed.Operation = ChangeInheritance
removed.Inheritance = projectInheritanceSteps(removed)
changes = append(changes, Change{
Path: operation.transfer.to.clone(), Origin: removed,
Dependencies: []Path{operation.transfer.reference.clone()},
})
}
return changes
}
func transferredOperationOrigin(operation inheritanceOperation, destination Path) (Origin, bool) {
relative, ok := relativeHistoryPath(operation.transfer.to, destination)
if !ok {
return Origin{}, false
}
source := appendInheritancePath(operation.transfer.from, relative)
origin, found := operation.parentOrigins[source.String()]
if !found {
return Origin{}, false
}
transferred := transferredInheritanceOrigins(map[string]Origin{source.String(): origin},
operation.transfer.from, operation.transfer.to, *operation.transfer)
return transferred[destination.String()], true
}

View File

@@ -0,0 +1,410 @@
package engine
import (
"bytes"
"reflect"
"strings"
"testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
func TestNestedInheritanceHistoryRetainsUnresolvedTemplates(t *testing.T) {
input := `presets:
base:
settings:
primary: {width: 10}
secondary: {extends: primary}
custom:
extends: base
settings:
primary: {width: 20}
grandchild:
extends: custom
settings:
primary: {width: 30}
`
loaded, err := Load[map[string]any](Bytes("nested", []byte(input)), testSecretBinding())
if err != nil {
t.Fatal(err)
}
for name, width := range map[string]string{"custom": "20", "grandchild": "30"} {
t.Run(name, func(t *testing.T) {
root := Keys("presets", name)
value, _ := loaded.Inspection.Explain(root.Key("settings").Key("secondary").Key("width"))
introduced := false
for _, change := range value.History {
if change.Origin.Operation == ChangeInheritance && !change.Before.Present && change.After.YAML == width+"\n" {
introduced = true
}
}
if !introduced || value.Effective.YAML != width+"\n" {
t.Fatalf("missing nested introduction: %#v", value)
}
controlPath := root.Key("settings").Key("secondary").Key("extends")
control, _ := loaded.Inspection.Explain(controlPath)
arrived, consumed := false, false
for _, change := range control.History {
arrived = arrived || change.After.YAML == "primary\n"
consumed = consumed || (change.Before.YAML == "primary\n" && !change.After.Present)
}
if !arrived || !consumed || control.Effective.Present {
t.Fatalf("missing control history: %#v", control)
}
container, _ := loaded.Inspection.Explain(root)
unresolved := false
for _, change := range container.History {
if change.Origin.Operation == ChangeInheritance && strings.Contains(change.After.YAML, "extends: primary") {
unresolved = true
}
}
if !unresolved || strings.Contains(container.Effective.YAML, "extends:") {
t.Fatalf("invalid enclosing snapshot: %#v", container)
}
if _, present := loaded.Inspection.Metadata().Lookup(controlPath); present {
t.Fatal("control is effective")
}
if len(loaded.Inspection.Metadata().History(controlPath)) != len(control.History) {
t.Fatal("metadata and explanation disagree")
}
})
}
}
func TestNestedInheritanceHistoryInsideSequence(t *testing.T) {
input := `base:
items:
- first: {value: inherited}
second: {extends: first}
child: {extends: base}
`
loaded, err := Load[map[string]any](Bytes("sequence", []byte(input)), testSecretBinding())
if err != nil {
t.Fatal(err)
}
path := Keys("child", "items").Index(0).Key("second")
control, _ := loaded.Inspection.Explain(path.Key("extends"))
consumed := false
for _, change := range control.History {
consumed = consumed || (change.Before.YAML == "first\n" && !change.After.Present)
}
if !consumed || control.Effective.Present {
t.Fatalf("sequence control history = %#v", control)
}
value, _ := loaded.Inspection.Explain(path.Key("value"))
introduced := false
for _, change := range value.History {
introduced = introduced || (!change.Before.Present && change.After.YAML == "inherited\n")
}
if !introduced || value.Effective.YAML != "inherited\n" {
t.Fatalf("sequence value history = %#v", value)
}
}
func inheritanceInspectionForTest(t *testing.T, sources ...*document.Node) (*Inspection, *inheritanceScope) {
t.Helper()
composed, diagnostics := composeDocuments(sources...)
if len(diagnostics) != 0 {
t.Fatal(diagnostics)
}
return inheritanceInspectionFromComposition(t, composed)
}
func inheritanceInspectionFromComposition(t *testing.T, composed *composedDocument) (*Inspection, *inheritanceScope) {
t.Helper()
expanded, diagnostics := expandInheritanceWithOrigins(composed.root, composed.origins, defaultProjectionLimits())
if len(diagnostics) != 0 {
t.Fatal(diagnostics)
}
baseline := &document.Node{Kind: document.Mapping}
presence := make(map[string]inputPresence)
collectPresence(expanded.root, Path{}, presence)
decoded := decodedConfiguration{
baseline: baseline, final: expanded.root, presence: presence, origins: expanded.origins,
}
metadata := metadataFromDecoded(decoded)
applyInheritanceMetadata(&metadata, composed.root, composed.origins, composed.overlays, expanded)
finalizeMetadataPaths(&metadata, expanded.root, composed.applications, baseline, composed.root)
return &Inspection{
metadata: metadata, baseline: baseline, input: expanded.root,
inputOrigins: expanded.origins, final: expanded.root,
applications: composed.applications, inheritance: expanded.operations,
}, expanded
}
func TestInheritanceHistoryFollowsFinalParentAfterSourceOverlay(t *testing.T) {
base, diagnostics := composeDocuments(parseCompositionDocument(t, "source-1", "parent: {value: original}\nchild: {extends: parent}\n"))
if len(diagnostics) != 0 {
t.Fatal(diagnostics)
}
assembly := newAssemblyAccumulator(base)
if diagnostics := assembly.applyOverlay(parseCompositionDocument(t, "source-2", "parent: {value: latest}\n"), nil, nil, LayerOverlay); len(diagnostics) != 0 {
t.Fatal(diagnostics)
}
inspection, _ := inheritanceInspectionFromComposition(t, assembly.current)
path := Keys("child", "value")
explanation, found := inspection.Explain(path)
if !found || explanation.Effective.YAML != "latest\n" ||
len(explanation.History) == 0 || explanation.History[len(explanation.History)-1].Origin.Locations[0].SourceID != "source-2" {
t.Fatalf("overlaid parent contribution = %#v, %v", explanation, found)
}
parentHistory := inspection.Metadata().History(Keys("parent", "value"))
if len(parentHistory) < 2 || parentHistory[0].Origin.Layer != LayerBase ||
parentHistory[len(parentHistory)-1].Origin.Layer != LayerOverlay {
t.Fatalf("parent source history = %#v", parentHistory)
}
}
func TestInheritanceHistoryPreservesCancellationAndSupersededDirective(t *testing.T) {
base, diagnostics := composeDocuments(parseCompositionDocument(t, "source-1", "parent: {value: one}\nchild: {extends: missing, value: own}\n"))
if len(diagnostics) != 0 {
t.Fatal(diagnostics)
}
assembly := newAssemblyAccumulator(base)
if diagnostics := assembly.applyOverlay(parseCompositionDocument(t, "source-2", "child: {extends: null}\n"), nil, nil, LayerOverlay); len(diagnostics) != 0 {
t.Fatal(diagnostics)
}
inspection, _ := inheritanceInspectionFromComposition(t, assembly.current)
control := Keys("child", inheritanceKey)
history := inspection.Metadata().History(control)
if len(history) < 3 || history[0].Origin.Locations[0].SourceID != "source-1" ||
history[1].Origin.Locations[0].SourceID != "source-2" ||
history[len(history)-1].Origin.Operation != ChangeInheritance {
t.Fatalf("superseded and cancelled directive history = %#v", history)
}
explanation, found := inspection.Explain(control)
if !found || explanation.Effective.Present || len(explanation.History) < 3 ||
explanation.History[len(explanation.History)-1].After.Present {
t.Fatalf("cancelled directive explanation = %#v, %v", explanation, found)
}
value, found := inspection.Explain(Keys("child", "value"))
if !found || value.Effective.YAML != "own\n" {
t.Fatalf("cancelled relationship changed own value = %#v, %v", value, found)
}
}
func TestInheritanceHistoryProjectsNestedDestinationValues(t *testing.T) {
inspection, _ := inheritanceInspectionForTest(t, parseCompositionDocument(t, "source-1", `base:
settings:
primary: {value: old}
secondary: {extends: primary}
custom:
extends: base
settings:
primary: {value: new}
`))
path := Keys("custom", "settings", "secondary", "value")
history := inspection.Metadata().History(path)
structural := 0
for _, change := range history {
if change.Origin.Operation == ChangeInheritance {
structural++
}
}
if structural == 0 {
t.Fatalf("nested destination history = %#v", history)
}
explanation, found := inspection.Explain(path)
if !found || explanation.Effective.YAML != "new\n" ||
len(explanation.Origin.Inheritance) == 0 ||
explanation.Origin.Inheritance[len(explanation.Origin.Inheritance)-1].To.String() != path.String() {
t.Fatalf("nested destination explanation = %#v, %v", explanation, found)
}
for _, change := range explanation.History {
if change.Origin.Operation == ChangeDecoding {
t.Fatalf("nested inheritance was attributed to decoding: %#v", explanation.History)
}
}
}
func TestInheritanceHistoryShowsParentAndEqualChildContribution(t *testing.T) {
inspection, _ := inheritanceInspectionForTest(t,
parseCompositionDocument(t, "source-1", "parent: {value: same}\n"),
parseCompositionDocument(t, "source-2", "child: {extends: parent, value: same}\n"))
path := Keys("child", "value")
history := inspection.Metadata().History(path)
var structural []Change
for _, change := range history {
if change.Origin.Operation == ChangeInheritance {
structural = append(structural, change)
}
}
if len(structural) != 2 || structural[0].Origin.Locations[0].SourceID != "source-1" ||
structural[1].Origin.Locations[0].SourceID != "source-2" ||
len(structural[0].Dependencies) != 2 || structural[0].Dependencies[0].String() != "/parent" ||
len(structural[0].Origin.Inheritance) != 1 {
t.Fatalf("equal-valued structural history = %#v", history)
}
explanation, found := inspection.Explain(path)
if !found || explanation.Effective.YAML != "same\n" {
t.Fatalf("explanation = %#v, %v", explanation, found)
}
var equalEvents int
for _, change := range explanation.History {
if change.Origin.Operation == ChangeDecoding {
t.Fatalf("inheritance was attributed to decoding: %#v", explanation.History)
}
if change.Origin.Operation == ChangeInheritance && change.Before.YAML == change.After.YAML {
equalEvents++
}
}
if equalEvents != 2 {
t.Fatalf("equal parent and child operations = %#v", explanation.History)
}
parentHistory := inspection.Metadata().History(Keys("parent", "value"))
for _, change := range parentHistory {
if change.Origin.Operation == ChangeInheritance {
t.Fatalf("source definition acquired a destination operation: %#v", parentHistory)
}
}
}
func TestInheritanceHistoryRetainsControlsAndDisplacedPaths(t *testing.T) {
inspection, _ := inheritanceInspectionForTest(t,
parseCompositionDocument(t, "source-1", "parent: {branch: {detail: inherited}}\n"),
parseCompositionDocument(t, "source-2", "child: {extends: parent, branch: replaced}\n"))
control := Keys("child", inheritanceKey)
controlHistory := inspection.Metadata().History(control)
if len(controlHistory) < 2 || controlHistory[len(controlHistory)-1].Origin.Operation != ChangeInheritance {
t.Fatalf("control history = %#v", controlHistory)
}
controlExplanation, found := inspection.Explain(control)
if !found || controlExplanation.Effective.Present ||
controlExplanation.History[len(controlExplanation.History)-1].After.Present {
t.Fatalf("consumed control = %#v, %v", controlExplanation, found)
}
path := Keys("child", "branch", "detail")
if _, found := inspection.Metadata().Lookup(path); found {
t.Fatal("displaced descendant remained effective")
}
listed := false
for _, historical := range inspection.Metadata().HistoricalPaths() {
if historical.String() == path.String() {
listed = true
}
}
if !listed {
t.Fatal("displaced inherited descendant was not enumerable")
}
history := inspection.Metadata().History(path)
if len(history) < 2 || history[len(history)-1].Origin.Operation != ChangeInheritance {
t.Fatalf("displaced descendant history = %#v", history)
}
explanation, found := inspection.Explain(path)
if !found || explanation.Effective.Present {
t.Fatalf("displaced descendant explanation = %#v, %v", explanation, found)
}
var sawInherited, sawRemoval bool
for _, change := range explanation.History {
if change.Origin.Operation != ChangeInheritance {
continue
}
sawInherited = sawInherited || change.After.Present
sawRemoval = sawRemoval || (change.Before.Present && !change.After.Present)
}
if !sawInherited || !sawRemoval {
t.Fatalf("displaced value sequence = %#v", explanation.History)
}
var rendered bytes.Buffer
if err := inspection.WriteExplanation(&rendered, path); err != nil ||
!strings.Contains(rendered.String(), "structural inheritance") {
t.Fatalf("rendered structural explanation = %q, %v", rendered.String(), err)
}
}
func TestIndexedInheritanceHistoryMatchesOperationScan(t *testing.T) {
inspection, _ := inheritanceInspectionForTest(t,
parseCompositionDocument(t, "source-1", "parent: {branch: {detail: inherited, retained: present}}\n"),
parseCompositionDocument(t, "source-2", "child: {extends: parent, branch: replaced}\n"),
parseCompositionDocument(t, "source-3", "sibling: {extends: parent}\n"))
metadata := inspection.Metadata()
if len(metadata.inheritance) < 4 {
t.Fatalf("inheritance operations = %d, want multiple control and transfer scopes", len(metadata.inheritance))
}
queries := []Path{
Keys("child"),
Keys("child", inheritanceKey),
Keys("child", "branch", "detail"),
Keys("child", "branch"),
Keys("sibling", "branch", "detail"),
Path{},
}
for _, path := range queries {
if !metadata.hasHistoricalPath(path) {
continue
}
stored := metadata.history[path.String()]
want := historyWithInheritance(path, stored, metadata.inheritance)
if got := metadata.History(path); !reflect.DeepEqual(got, want) {
t.Errorf("indexed history for %s differs from operation scan:\n got: %#v\nwant: %#v", path, got, want)
}
unprepared := Metadata{history: metadata.history, historicalPaths: metadata.historicalPaths, inheritance: metadata.inheritance}
if got := unprepared.History(path); !reflect.DeepEqual(got, want) {
t.Errorf("unprepared history for %s differs from operation scan:\n got: %#v\nwant: %#v", path, got, want)
}
}
removed := Keys("child", "branch", "detail")
first := metadata.History(removed)
if len(first) == 0 || len(first[0].Path.segments) == 0 {
t.Fatalf("removed-path history = %#v", first)
}
first[0].Path.segments[0].key = "mutated"
second := cloneMetadata(metadata).History(removed)
want := historyWithInheritance(removed, metadata.history[removed.String()], metadata.inheritance)
if !reflect.DeepEqual(second, want) {
t.Fatalf("returned history mutation affected a repeated or cloned query:\n got: %#v\nwant: %#v", second, want)
}
}
func TestInheritanceHistoryPrecedesApplicationResolution(t *testing.T) {
inspection, _ := inheritanceInspectionForTest(t,
parseCompositionDocument(t, "source-1", "parent: {value: original}\nchild: {extends: parent}\n"))
path := Keys("child", "value")
replacement := &document.Node{Kind: document.String, Text: "derived"}
snapshot := resolutionSnapshot{
change: Change{Path: path, Origin: Origin{Layer: LayerResolution, Operation: ChangeNormalization}},
after: replacement,
}
final := document.Clone(inspection.final)
replaceHistoryPath(&final, path, replacement)
inspection.final = final
inspection.resolution = []resolutionSnapshot{snapshot}
inspection.resolutionReplay = coalescedResolutionSnapshots(inspection.resolution)
inspection.metadata.history[path.String()] = append(inspection.metadata.history[path.String()], snapshot.change)
inspection.metadata.fields[path.String()] = FieldInfo{Explicit: true, Origin: snapshot.change.Origin}
history := inspection.Metadata().History(path)
if len(history) < 2 || history[len(history)-2].Origin.Operation != ChangeInheritance ||
history[len(history)-1].Origin.Operation != ChangeNormalization {
t.Fatalf("resolution order = %#v", history)
}
explanation, found := inspection.Explain(path)
if !found || explanation.Effective.YAML != "derived\n" ||
explanation.History[len(explanation.History)-1].Origin.Operation != ChangeNormalization {
t.Fatalf("resolved explanation = %#v, %v", explanation, found)
}
}
func TestHistoryNodeBudgetCountsSharedInheritanceEvidenceOnce(t *testing.T) {
shared := &document.Node{Kind: document.String, Text: "shared"}
extra := &document.Node{Kind: document.String, Text: "extra"}
expansion := &inheritanceScope{
root: shared,
operations: []inheritanceOperation{{parent: shared, effective: shared}},
replacements: []inheritanceReplacement{{before: shared, after: extra}},
}
if !historyNodesWithin(2, nil, nil, nil, nil, nil, expansion) ||
historyNodesWithin(1, nil, nil, nil, nil, nil, expansion) {
t.Fatal("shared evidence node accounting is incorrect")
}
}
func TestInheritanceExpansionEnforcesRetainedHistoryBudget(t *testing.T) {
input := parseCompositionDocument(t, "source-1", "parent: {value: original}\nchild: {extends: parent, value: replacement}\n")
if _, diagnostics := expandInheritanceWithBudgets(input, nil, defaultProjectionLimits(), 2); len(diagnostics) != 1 || diagnostics[0].Code != CodeExplainLimitExceeded {
t.Fatalf("retained history limit = %#v", diagnostics)
}
if _, diagnostics := expandInheritanceWithBudgets(input, nil, defaultProjectionLimits(), 100); len(diagnostics) != 0 {
t.Fatalf("sufficient retained history budget = %#v", diagnostics)
}
}

View File

@@ -0,0 +1,67 @@
package engine
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
func containsInheritanceDirective(root *document.Node) bool {
var visit func(*document.Node) bool
visit = func(node *document.Node) bool {
if node == nil {
return false
}
switch node.Kind {
case document.Mapping:
for _, entry := range node.Mapping {
if entry.Key == inheritanceKey || visit(entry.Value) {
return true
}
}
case document.Sequence:
for _, child := range node.Sequence {
if visit(child) {
return true
}
}
}
return false
}
return visit(root)
}
func inheritanceVersionDiagnostic(rule *VersionRule, expanded *inheritanceScope) *Diagnostic {
if rule == nil || expanded == nil {
return nil
}
for _, operation := range expanded.operations {
var location Location
var affected bool
if operation.control != nil {
affected = pathsIntersect(operation.control.path, rule.Path)
if len(operation.control.locations) != 0 {
location = operation.control.locations[0]
}
} else if operation.transfer != nil {
if relative, within := relativeHistoryPath(operation.transfer.to, rule.Path); within {
_, affected = documentAtPath(operation.parent, relative)
} else if pathContains(rule.Path, operation.transfer.to) {
affected = true
}
if len(operation.transfer.locations) != 0 {
location = operation.transfer.locations[0]
}
}
if affected {
return &Diagnostic{
Code: CodeInheritanceVersion, Severity: SeverityError, Path: rule.Path.clone(),
Message: "inheritance cannot change the schema-version path", Primary: &location,
}
}
}
return nil
}
func inheritanceOperations(expanded *inheritanceScope) []inheritanceOperation {
if expanded == nil {
return nil
}
return expanded.operations
}

View File

@@ -0,0 +1,158 @@
package engine
import (
"slices"
"strconv"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
func directInheritanceOrigins(node *document.Node, root Path, input map[string]Origin) map[string]Origin {
result := make(map[string]Origin)
walkDocument(node, root, func(path Path, value *document.Node) {
origin, found := input[path.String()]
if !found {
origin = Origin{Layer: LayerBase, Locations: []Location{publicLocation(value.Location)}}
}
origin = cloneOrigin(origin)
if len(origin.contributors) == 0 {
origin.contributors = make([]inheritanceContributor, len(origin.Locations))
for index, location := range origin.Locations {
origin.contributors[index] = inheritanceContributor{
source: path.clone(), location: location,
}
}
}
result[path.String()] = origin
})
return result
}
func transferredInheritanceOrigins(parent map[string]Origin, from, to Path, control inheritanceTransfer) map[string]Origin {
result := make(map[string]Origin, len(parent))
transfer := control // one immutable edge shared by every matching contributor
links := make(map[*inheritanceTrace]*inheritanceTrace)
for pointer, origin := range parent {
path, err := ParsePath(pointer)
if err != nil {
continue
}
relative, ok := relativeHistoryPath(from, path)
if !ok {
continue
}
destination := appendInheritancePath(to, relative)
origin = cloneOrigin(origin)
for index, contributor := range origin.contributors {
link, found := links[contributor.trace]
if !found {
link = &inheritanceTrace{previous: contributor.trace, transfer: &transfer}
links[contributor.trace] = link
}
origin.contributors[index].trace = link
}
result[destination.String()] = origin
}
return result
}
func appendInheritancePath(root, relative Path) Path {
return Path{segments: append(clonePathSegments(root.segments, len(relative.segments)), relative.segments...)}
}
func mergeInheritanceOrigin(origins map[string]Origin, path Path, incoming Origin) {
pointer := path.String()
current := origins[pointer]
current.Layer = incoming.Layer
current.Locations = append(current.Locations, incoming.Locations...)
current.contributors = append(current.contributors, incoming.contributors...)
origins[pointer] = current
}
func copyInheritanceSubtree(target, incoming map[string]Origin, node *document.Node, root Path) {
walkDocument(node, root, func(path Path, _ *document.Node) {
target[path.String()] = cloneOrigin(incoming[path.String()])
})
}
type inheritanceStepKey struct {
from string
to string
reference string
locations string
}
func inheritanceStepKeyForPaths(from, to, reference Path, locations []Location) inheritanceStepKey {
return inheritanceStepKey{
from: from.String(), to: to.String(), reference: reference.String(),
locations: inheritanceLocationsKey(locations),
}
}
func inheritanceLocationsKey(locations []Location) string {
encoded := strconv.AppendInt(nil, int64(len(locations)), 10)
encoded = append(encoded, ';')
for _, location := range locations {
encoded = strconv.AppendInt(encoded, int64(len(location.SourceID)), 10)
encoded = append(encoded, ':')
encoded = append(encoded, location.SourceID...)
encoded = append(encoded, ';')
encoded = strconv.AppendInt(encoded, int64(location.Line), 10)
encoded = append(encoded, ';')
encoded = strconv.AppendInt(encoded, int64(location.Column), 10)
encoded = append(encoded, ';')
}
return string(encoded)
}
// Materialize concrete steps only for an inspection record. The linked private
// traces remain compact when one parent supplies many descendant values.
func projectInheritanceSteps(origin Origin) []InheritanceStep {
steps := cloneInheritanceSteps(origin.Inheritance)
seen := make(map[inheritanceStepKey]struct{}, len(steps))
for _, step := range steps {
seen[inheritanceStepKeyForPaths(step.From, step.To, step.Reference, step.Locations)] = struct{}{}
}
for _, contributor := range origin.contributors {
var reverse []*inheritanceTrace
for link := contributor.trace; link != nil; link = link.previous {
reverse = append(reverse, link)
}
current := contributor.source
for index := len(reverse) - 1; index >= 0; index-- {
transfer := reverse[index].transfer
relative, ok := relativeHistoryPath(transfer.from, current)
if !ok {
continue
}
next := appendInheritancePath(transfer.to, relative)
key := inheritanceStepKeyForPaths(current, next, transfer.reference, transfer.locations)
if _, found := seen[key]; !found {
seen[key] = struct{}{}
steps = append(steps, InheritanceStep{
From: current.clone(), To: next.clone(), Reference: transfer.reference.clone(),
Locations: cloneLocations(transfer.locations),
})
}
current = next
}
}
return steps
}
func sameInheritanceTrace(left, right *inheritanceTrace) bool {
for left != nil && right != nil {
if left == right {
return true
}
l, r := left.transfer, right.transfer
if !slices.Equal(l.from.segments, r.from.segments) ||
!slices.Equal(l.to.segments, r.to.segments) ||
!slices.Equal(l.reference.segments, r.reference.segments) ||
!slices.Equal(l.locations, r.locations) {
return false
}
left, right = left.previous, right.previous
}
return left == nil && right == nil
}

View File

@@ -0,0 +1,302 @@
package engine
import (
"io/fs"
"reflect"
"slices"
"testing"
"testing/fstest"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
func TestInheritedOriginsKeepSourcePathsPresenceAndIndependentReplacement(t *testing.T) {
parent := parseCompositionDocument(t, "source-1", `parent:
enabled: false
count: 0
optional: null
items: [{file: message.txt}]
settings: {shared: parent}
`)
child := parseCompositionDocument(t, "source-2", `child:
extends: parent
count: 3
settings: {own: child}
`)
composed, diagnostics := composeDocuments(parent, child)
if len(diagnostics) != 0 {
t.Fatal(diagnostics)
}
result, diagnostics := expandInheritanceWithOrigins(composed.root, composed.origins, defaultProjectionLimits())
if len(diagnostics) != 0 {
t.Fatal(diagnostics)
}
for _, key := range []string{"enabled", "optional"} {
path := Keys("child", key)
value, found := documentAtPath(result.root, path)
origin, hasOrigin := result.origins[path.String()]
if !found || !hasOrigin || value == nil || len(origin.Locations) != 1 || origin.Locations[0].SourceID != "source-1" ||
len(projectInheritanceSteps(origin)) != 1 {
t.Fatalf("inherited %s = %#v, %#v", key, value, origin)
}
}
count := result.origins["/child/count"]
if len(count.Locations) != 1 || count.Locations[0].SourceID != "source-2" || len(projectInheritanceSteps(count)) != 0 {
t.Fatalf("direct replacement origin = %#v", count)
}
if len(result.replacements) != 1 || result.replacements[0].path.String() != "/child/count" ||
result.replacements[0].before.Text != "0" || result.replacements[0].after.Text != "3" ||
len(result.replacements[0].previous) != 1 ||
len(projectInheritanceSteps(result.replacements[0].previous[0].origin)) != 1 {
t.Fatalf("displaced inheritance = %#v", result.replacements)
}
settings := result.origins["/child/settings"]
if len(settings.Locations) != 2 || settings.Locations[0].SourceID != "source-1" ||
settings.Locations[1].SourceID != "source-2" || len(projectInheritanceSteps(settings)) != 1 {
t.Fatalf("composite mapping origin = %#v", settings)
}
if originContainedBy(result.origins["/child/enabled"], result.origins["/parent/enabled"]) ||
originContainedBy(result.origins["/parent/enabled"], result.origins["/child/enabled"]) {
t.Fatal("direct and inherited evidence collapsed despite matching source coordinates")
}
original := result.origins["/child/enabled"]
detached := cloneOrigin(original)
detached.Locations[0].SourceID = "changed"
detached.contributors[0].source.segments[0].key = "changed"
stepsCopy := projectInheritanceSteps(original)
stepsCopy[0].Locations[0].SourceID = "changed"
if original.Locations[0].SourceID != "source-1" ||
original.contributors[0].source.String() != "/parent/enabled" ||
projectInheritanceSteps(original)[0].Locations[0].SourceID != "source-2" {
t.Fatal("cloned origin or projected steps changed retained evidence")
}
resource := result.origins["/child/items/0/file"]
steps := projectInheritanceSteps(resource)
if len(steps) != 1 || steps[0].From.String() != "/parent/items/0/file" ||
steps[0].To.String() != "/child/items/0/file" || steps[0].Reference.String() != "/child/extends" ||
len(resource.Locations) != 1 || resource.Locations[0].SourceID != "source-1" {
t.Fatalf("sequence resource origin = %#v, steps = %#v", resource, steps)
}
contexts := map[string]fs.FS{
"source-1": fstest.MapFS{"message.txt": &fstest.MapFile{Data: []byte("from parent")}},
"source-2": fstest.MapFS{"message.txt": &fstest.MapFile{Data: []byte("from child")}},
}
data, err := fs.ReadFile(contexts[resource.Locations[0].SourceID], "message.txt")
if err != nil || string(data) != "from parent" {
t.Fatalf("inherited resource = %q, %v", data, err)
}
}
func TestOriginMembershipPreservesLocationAndSourceCoverageRules(t *testing.T) {
location := Location{SourceID: "source", Line: 4, Column: 2}
otherCoordinates := location
otherCoordinates.Line++
container := Origin{
Layer: LayerOverlay,
Operation: ChangeNormalization,
Locations: []Location{location},
}
if !originContainedBy(container, container) {
t.Fatal("identical location evidence was not contained")
}
coordinateDifference := container
coordinateDifference.Locations = []Location{otherCoordinates}
if originContainedBy(coordinateDifference, container) {
t.Fatal("location containment ignored differing coordinates")
}
parent := Keys("parent")
child := parent.Key("child")
change := Change{Path: parent, Origin: container}
descendantOrigin := Origin{
Layer: container.Layer,
Operation: container.Operation,
Locations: []Location{otherCoordinates},
}
if !originRepresentedByChange(child, descendantOrigin, change) {
t.Fatal("descendant source coverage rejected matching SourceID with different coordinates")
}
descendantOrigin.Locations[0].SourceID = "another-source"
if originRepresentedByChange(child, descendantOrigin, change) {
t.Fatal("descendant source coverage accepted an unrecorded SourceID")
}
withContributor := container
withContributor.contributors = []inheritanceContributor{{source: parent, location: location}}
if originContainedBy(withContributor, container) {
t.Fatal("exact location containment ignored the contributor-empty mismatch")
}
if !originRepresentedByChange(child, withContributor, change) {
t.Fatal("descendant source coverage was conflated with contributor containment")
}
}
func TestProjectedInheritanceStepsMatchScanAndRetainOwnership(t *testing.T) {
sourcePath := Keys("source", "a/b", "01")
firstLocations := []Location{{SourceID: "first;:source", Line: -1, Column: 2}}
secondLocations := []Location{
{SourceID: "second:;source", Line: 3, Column: -4},
{SourceID: "", Line: -5, Column: 6},
}
firstTransfer := inheritanceTransfer{
from: Keys("source"), to: Keys("middle"), reference: Keys("middle", "extends"), locations: firstLocations,
}
secondTransfer := inheritanceTransfer{
from: Keys("middle"), to: Keys("target"), reference: Keys("target", "extends"), locations: secondLocations,
}
firstTrace := &inheritanceTrace{transfer: &firstTransfer}
chain := &inheritanceTrace{previous: firstTrace, transfer: &secondTransfer}
firstStep := InheritanceStep{
From: sourcePath, To: Keys("middle", "a/b", "01"), Reference: firstTransfer.reference, Locations: firstLocations,
}
duplicateFirstTransfer := firstTransfer
duplicateSecondTransfer := secondTransfer
duplicateChain := &inheritanceTrace{
previous: &inheritanceTrace{transfer: &duplicateFirstTransfer},
transfer: &duplicateSecondTransfer,
}
reversedLocations := []Location{secondLocations[1], secondLocations[0]}
reorderedSecondTransfer := secondTransfer
reorderedSecondTransfer.locations = reversedLocations
reorderedChain := &inheritanceTrace{
previous: &inheritanceTrace{transfer: &duplicateFirstTransfer},
transfer: &reorderedSecondTransfer,
}
origin := Origin{
Inheritance: []InheritanceStep{firstStep, firstStep},
contributors: []inheritanceContributor{
{source: sourcePath, trace: chain},
{source: sourcePath, trace: duplicateChain},
{source: sourcePath, trace: reorderedChain},
{source: sourcePath, trace: chain},
},
}
want := projectInheritanceStepsByScan(origin)
got := projectInheritanceSteps(origin)
if !reflect.DeepEqual(got, want) {
t.Fatalf("projected steps differ from ordered scan:\n got: %#v\nwant: %#v", got, want)
}
if len(got) != 4 || got[0].From.String() != "/source/a~1b/01" || got[0].To.String() != "/middle/a~1b/01" ||
!reflect.DeepEqual(got[0], got[1]) || got[2].To.String() != "/target/a~1b/01" ||
!reflect.DeepEqual(got[2].Locations, secondLocations) || !reflect.DeepEqual(got[3].Locations, reversedLocations) {
t.Fatalf("unexpected duplicate/order/escape projection: %#v", got)
}
got[0].From.segments[0].key = "mutated"
got[2].Locations[0].SourceID = "mutated"
if repeated := projectInheritanceSteps(origin); !reflect.DeepEqual(repeated, want) {
t.Fatalf("returned steps mutated retained evidence:\n got: %#v\nwant: %#v", repeated, want)
}
}
func TestInheritanceLocationsKeyEncodesOrderedLengthPrefixedValues(t *testing.T) {
if inheritanceLocationsKey(nil) != inheritanceLocationsKey([]Location{}) {
t.Fatal("nil and empty location lists should encode equally")
}
values := [][]Location{
{{SourceID: "a;1;-2;", Line: -3, Column: 4}},
{{SourceID: "a", Line: 1, Column: -2}, {SourceID: ";1;-2;", Line: -3, Column: 4}},
{{SourceID: ";1;-2;", Line: -3, Column: 4}, {SourceID: "a", Line: 1, Column: -2}},
{{SourceID: "é", Line: -3, Column: 4}},
{{SourceID: "e", Line: -3, Column: 4}},
}
keys := make(map[string]int, len(values))
for index, locations := range values {
key := inheritanceLocationsKey(locations)
if previous, found := keys[key]; found {
t.Fatalf("location lists %d and %d encoded identically", previous, index)
}
keys[key] = index
}
}
func projectInheritanceStepsByScan(origin Origin) []InheritanceStep {
steps := cloneInheritanceSteps(origin.Inheritance)
for _, contributor := range origin.contributors {
var reverse []*inheritanceTrace
for link := contributor.trace; link != nil; link = link.previous {
reverse = append(reverse, link)
}
current := contributor.source
for index := len(reverse) - 1; index >= 0; index-- {
transfer := reverse[index].transfer
relative, ok := relativeHistoryPath(transfer.from, current)
if !ok {
continue
}
next := appendInheritancePath(transfer.to, relative)
step := InheritanceStep{
From: current.clone(), To: next.clone(), Reference: transfer.reference.clone(),
Locations: cloneLocations(transfer.locations),
}
if !slices.ContainsFunc(steps, func(existing InheritanceStep) bool {
return existing.From.String() == step.From.String() &&
existing.To.String() == step.To.String() &&
existing.Reference.String() == step.Reference.String() &&
slices.Equal(existing.Locations, step.Locations)
}) {
steps = append(steps, step)
}
current = next
}
}
return steps
}
func TestInheritedOriginsRetainChainsAndNestedDestinations(t *testing.T) {
first := parseCompositionDocument(t, "source-1", `first:
enabled: false
settings:
primary: {value: original}
secondary: {extends: primary}
`)
second := parseCompositionDocument(t, "source-2", `first: {other: present}
middle: {extends: first}
last:
extends: middle
settings:
primary: {value: replaced}
`)
composed, diagnostics := composeDocuments(first, second)
if len(diagnostics) != 0 {
t.Fatal(diagnostics)
}
result, diagnostics := expandInheritanceWithOrigins(composed.root, composed.origins, defaultProjectionLimits())
if len(diagnostics) != 0 {
t.Fatal(diagnostics)
}
chain := projectInheritanceSteps(result.origins["/last/enabled"])
if len(chain) != 2 || chain[0].From.String() != "/first/enabled" ||
chain[0].To.String() != "/middle/enabled" || chain[1].From.String() != "/middle/enabled" ||
chain[1].To.String() != "/last/enabled" {
t.Fatalf("chain = %#v", chain)
}
composite := result.origins["/last"]
if len(composite.Locations) < 2 || len(composite.contributors) != len(composite.Locations) {
t.Fatalf("composite contributors = %#v", composite)
}
seenSources := make(map[string]bool)
for _, contributor := range composite.contributors {
seenSources[contributor.location.SourceID] = true
}
if !seenSources["source-1"] || !seenSources["source-2"] {
t.Fatalf("same-layer source contributors = %#v", composite.contributors)
}
nested := projectInheritanceSteps(result.origins["/last/settings/secondary/value"])
if len(nested) != 1 || nested[0].From.String() != "/last/settings/primary/value" ||
nested[0].To.String() != "/last/settings/secondary/value" {
t.Fatalf("destination-specific nested transfer = %#v", nested)
}
if got := result.origins["/last/settings/secondary/value"].Locations[0].SourceID; got != "source-2" {
t.Fatalf("nested replacement source = %q", got)
}
if original, found := documentAtPath(first, Keys("first", "settings", "primary", "value")); !found || original.Text != "original" {
t.Fatal("expansion changed its input")
}
if inherited, found := documentAtPath(result.root, Keys("last", "settings", "secondary", "value")); !found || inherited.Kind != document.String || inherited.Text != "replaced" {
t.Fatalf("nested inherited value = %#v, %v", inherited, found)
}
}

View File

@@ -0,0 +1,136 @@
package engine
import (
"bytes"
"reflect"
"strings"
"testing"
)
func TestInheritanceSensitivityFollowsCopiesWithoutMaskingNeighbors(t *testing.T) {
const canary = "INHERITED-CANARY-CREDENTIAL"
input := parseCompositionDocument(t, "source-1", "parent: {token: "+canary+", plain: visible}\nchild: {extends: parent}\nnext: {extends: child}\nother: {extends: parent}\n")
for _, classified := range []Path{Keys("parent", "token"), Keys("next", "token")} {
t.Run(classified.String(), func(t *testing.T) {
inspection, _ := inheritanceInspectionForTest(t, input)
inspection.secrets = classifySecretPaths(nil, func(path Path) bool {
return path.String() == classified.String()
}, inspection.metadata, inspection.final, inspection.applications, inspection.input)
for _, path := range []Path{Keys("parent", "token"), Keys("child", "token"), Keys("next", "token"), Keys("other", "token")} {
explanation, found := inspection.Explain(path)
if !found || !explanation.Effective.Redacted || strings.Contains(explanation.Effective.YAML, canary) {
t.Fatalf("sensitive copy %s = %#v, %v", path, explanation, found)
}
for _, change := range explanation.History {
if strings.Contains(change.Before.YAML, canary) || strings.Contains(change.After.YAML, canary) {
t.Fatalf("historical canary at %s: %#v", path, change)
}
}
}
plain, found := inspection.Explain(Keys("other", "plain"))
if !found || plain.Effective.Redacted || plain.Effective.YAML != "visible\n" {
t.Fatalf("unrelated field = %#v, %v", plain, found)
}
var output bytes.Buffer
if err := inspection.WriteEffective(&output); err != nil || strings.Contains(output.String(), canary) || !strings.Contains(output.String(), "visible") {
t.Fatalf("effective rendering = %q, %v", output.String(), err)
}
})
}
}
func TestInheritanceSensitivityPreservesDisplacedHistoryAndControl(t *testing.T) {
const canary = "DISPLACED-CANARY-CREDENTIAL"
inspection, _ := inheritanceInspectionForTest(t, parseCompositionDocument(t, "source-1", "parent: {branch: {token: "+canary+"}}\nchild: {extends: parent, branch: public}\n"))
inspection.secrets = classifySecretPaths(nil, func(path Path) bool {
return path.String() == Keys("parent", "branch", "token").String() || path.String() == Keys("child", inheritanceKey).String()
}, inspection.metadata, inspection.final, inspection.applications, inspection.input)
path := Keys("child", "branch", "token")
explanation, found := inspection.Explain(path)
if !found || explanation.Effective.Present {
t.Fatalf("displaced sensitive path = %#v, %v", explanation, found)
}
for _, change := range explanation.History {
if strings.Contains(change.Before.YAML, canary) || strings.Contains(change.After.YAML, canary) ||
(change.After.Present && !change.After.Redacted) {
t.Fatalf("displaced secret leaked: %#v", explanation.History)
}
}
var rendered bytes.Buffer
if err := inspection.WriteExplanation(&rendered, path); err != nil || strings.Contains(rendered.String(), canary) {
t.Fatalf("displaced explanation rendering = %q, %v", rendered.String(), err)
}
control, found := inspection.Explain(Keys("child", inheritanceKey))
if !found || control.Effective.Present || len(control.History) == 0 || !control.History[0].After.Redacted {
t.Fatalf("sensitive control history = %#v, %v", control, found)
}
info, found := inspection.Metadata().Lookup(Keys("child", "branch"))
if !found || len(info.Origin.Inheritance) != 0 {
t.Fatalf("independent replacement provenance = %#v, %v", info, found)
}
}
func TestInheritanceSensitivityIncludesSchemaTaggedSource(t *testing.T) {
type entry struct {
Token string `yaml:"token"`
}
type configuration struct {
Parent struct {
Token string `yaml:"token" configkit:"secret"`
} `yaml:"parent"`
Child entry `yaml:"child"`
}
schema, issue := buildSchema(reflect.TypeFor[configuration](), testSecretBinding())
if issue != nil {
t.Fatal(issue)
}
inspection, _ := inheritanceInspectionForTest(t, parseCompositionDocument(t, "source-1", "parent: {token: TAGGED-CANARY}\nchild: {extends: parent}\n"))
inspection.secrets = classifySecretPaths(schema, nil, inspection.metadata, inspection.final, inspection.applications, inspection.input)
child, found := inspection.Explain(Keys("child", "token"))
if !found || !child.Effective.Redacted || strings.Contains(child.Effective.YAML, "TAGGED-CANARY") {
t.Fatalf("tagged source copy = %#v, %v", child, found)
}
}
func TestInheritedSecretReferencesResolveOnlyAfterExpansion(t *testing.T) {
type entry struct {
Credential Secret `yaml:"credential"`
}
type configuration struct {
Entries map[string]entry `yaml:"entries"`
}
schema, issue := buildSchema(reflect.TypeFor[configuration](), testSecretBinding())
if issue != nil {
t.Fatal(issue)
}
composed, diagnostics := composeDocuments(parseCompositionDocument(t, "source-1", "entries:\n parent: {credential: INHERITED_SECRET_NAME}\n child: {extends: parent}\n"))
if len(diagnostics) != 0 {
t.Fatal(diagnostics)
}
expanded, diagnostics := expandInheritanceWithOrigins(composed.root, composed.origins, defaultProjectionLimits())
if len(diagnostics) != 0 {
t.Fatal(diagnostics)
}
lookupCount := 0
decoded, issues := decodeConfiguration(schema, reflect.Zero(reflect.TypeFor[configuration]()), expanded.root)
if len(issues) != 0 || lookupCount != 0 {
t.Fatalf("decoding and lookup order = %#v, %d", issues, lookupCount)
}
resolved := resolveSecretReferences(decoded.value, schema, expanded.root, func(name string) (string, bool) {
lookupCount++
if name != "INHERITED_SECRET_NAME" {
t.Fatalf("inherited reference name = %q", name)
}
return "RESOLVED-CREDENTIAL-CANARY", true
})
if len(resolved.diagnostics) != 0 || lookupCount != 1 ||
decoded.value.Interface().(configuration).Entries["child"].Credential.Value() != "RESOLVED-CREDENTIAL-CANARY" {
t.Fatalf("inherited reference resolution = %#v, %d", resolved.diagnostics, lookupCount)
}
inspection, _ := inheritanceInspectionFromComposition(t, composed)
inspection.secrets = classifySecretPaths(schema, nil, inspection.metadata, inspection.final, inspection.applications, inspection.input)
var rendered bytes.Buffer
if err := inspection.WriteEffective(&rendered); err != nil || strings.Contains(rendered.String(), "RESOLVED-CREDENTIAL-CANARY") {
t.Fatalf("reference rendering = %q, %v", rendered.String(), err)
}
}

View File

@@ -0,0 +1,159 @@
package engine
import (
"reflect"
"strconv"
"strings"
"testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
func TestSiblingInheritanceResolvesReferencesAndKeepsInputs(t *testing.T) {
tests := []struct {
name, input string
path Path
text string
transfers int
controls int
}{
{
name: "forward reference", input: "child: {extends: parent, value: own}\nparent: {value: parent, kept: preserved}\n",
path: Keys("child", "kept"), text: "preserved", transfers: 1, controls: 1,
},
{
name: "backward reference", input: "parent: {value: parent, kept: preserved}\nchild: {extends: parent, value: own}\n",
path: Keys("child", "value"), text: "own", transfers: 1, controls: 1,
},
{
name: "chain and shared parent", input: "last: {extends: middle, enabled: false}\nfirst: {value: original}\nmiddle: {extends: first, extra: middle}\nother: {extends: first}\n",
path: Keys("last", "value"), text: "original", transfers: 3, controls: 3,
},
{
name: "exact empty sibling name", input: "child: {extends: \"\"}\n\"\": {value: empty}\n",
path: Keys("child", "value"), text: "empty", transfers: 1, controls: 1,
},
{
name: "exact unusual sibling name", input: "child: {extends: \"a/b~c\"}\n\"a/b~c\": {value: unusual}\n",
path: Keys("child", "value"), text: "unusual", transfers: 1, controls: 1,
},
{
name: "null cancellation", input: "child: {extends: null, value: own}\nparent: {value: parent}\n",
path: Keys("child", "value"), text: "own", controls: 1,
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
input := parseCompositionDocument(t, "source-1", test.input)
before := document.Clone(input)
result, diagnostics := expandSiblingDefinitions(input, Path{})
if len(diagnostics) != 0 {
t.Fatalf("diagnostics = %#v", diagnostics)
}
if len(result.transfers) != test.transfers || len(result.controls) != test.controls {
t.Fatalf("evidence = %#v, %#v", result.transfers, result.controls)
}
node, found := documentAtPath(result.root, test.path)
if !found || node.Text != test.text {
t.Fatalf("%s = %#v, %v", test.path.String(), node, found)
}
if _, found := documentAtPath(result.root, Keys("child", inheritanceKey)); found {
t.Fatal("child directive remained in expanded payload")
}
if !reflect.DeepEqual(input, before) {
t.Fatal("resolver changed input")
}
node.Text = "changed"
if !reflect.DeepEqual(input, before) {
t.Fatal("expanded payload aliases input")
}
})
}
}
func TestSiblingInheritanceTracksTransferAndIndependentChildren(t *testing.T) {
input := parseCompositionDocument(t, "source-1", "first: {value: parent}\nsecond: {extends: first}\nthird: {extends: first}\n")
result, diagnostics := expandSiblingDefinitions(input, Keys("catalog"))
if len(diagnostics) != 0 {
t.Fatal(diagnostics)
}
if len(result.transfers) != 2 || result.transfers[0].from.String() != "/catalog/first" ||
result.transfers[0].to.String() != "/catalog/second" ||
result.transfers[0].reference.String() != "/catalog/second/extends" ||
len(result.transfers[0].locations) != 1 || result.transfers[0].locations[0].SourceID != "source-1" {
t.Fatalf("transfers = %#v", result.transfers)
}
second, _ := documentAtPath(result.root, Keys("second", "value"))
third, _ := documentAtPath(result.root, Keys("third", "value"))
second.Text = "changed"
if third.Text != "parent" {
t.Fatal("siblings share mutable expanded values")
}
}
func TestSiblingInheritanceRejectsInvalidRelationships(t *testing.T) {
tests := []struct {
name, input, path string
code Code
related bool
}{
{"missing", "child: {extends: private-reference-canary}\n", "/child/extends", CodeInheritanceMissing, false},
{"nonmapping parent", "child: {extends: parent}\nparent: null\n", "/child/extends", CodeInheritanceInvalid, true},
{"boolean reference", "child: {extends: true}\n", "/child/extends", CodeInheritanceInvalid, false},
{"sequence reference", "child: {extends: [parent]}\n", "/child/extends", CodeInheritanceInvalid, false},
{"self reference", "child: {extends: child}\n", "/child/extends", CodeInheritanceCycle, true},
{"longer cycle", "a: {extends: b}\nb: {extends: c}\nc: {extends: a}\n", "/c/extends", CodeInheritanceCycle, true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
input := parseCompositionDocument(t, "source-1", test.input)
result, diagnostics := expandSiblingDefinitions(input, Path{})
if result != nil || len(diagnostics) != 1 {
t.Fatalf("result and diagnostics = %#v, %#v", result, diagnostics)
}
problem := diagnostics[0]
if problem.Code != test.code || problem.Path.String() != test.path || problem.Primary == nil ||
problem.Primary.SourceID != "source-1" || problem.Primary.Line < 1 ||
(len(problem.Related) != 0) != test.related || strings.Contains(problem.Message, "private-reference-canary") {
t.Fatalf("diagnostic = %#v", problem)
}
})
}
}
func TestLoadDetectsInvalidInheritanceInsideSequence(t *testing.T) {
input := []byte(`items:
- child: {extends: true}
`)
result, err := testLoad[map[string]any](Bytes("nested-inheritance.yml", input), Options[map[string]any]{})
if result != nil || err == nil {
t.Fatalf("result, err = %#v, %v; want invalid nested inheritance", result, err)
}
loadError, ok := err.(*LoadError)
if !ok {
t.Fatalf("error type = %T, want *LoadError", err)
}
diagnostics := loadError.Diagnostics()
if len(diagnostics) != 1 || diagnostics[0].Code != CodeInheritanceInvalid || diagnostics[0].Path.String() != "/items/0/child/extends" {
t.Fatalf("diagnostics = %#v", diagnostics)
}
}
func TestSiblingInheritanceDetectsDeepCycleWithoutRecursiveDependencyWalk(t *testing.T) {
const count = 2048
entries := make([]document.Entry, count)
for index := range entries {
location := document.Location{SourceID: "source-1", Line: index + 1, Column: 1}
parent := strconv.Itoa((index + 1) % count)
entries[index] = document.Entry{
Key: strconv.Itoa(index), Value: &document.Node{Kind: document.Mapping, Location: location,
Mapping: []document.Entry{{Key: inheritanceKey, Value: &document.Node{
Kind: document.String, Text: parent, Location: location,
}}}},
}
}
result, diagnostics := expandSiblingDefinitions(&document.Node{Kind: document.Mapping, Mapping: entries}, Path{})
if result != nil || len(diagnostics) != 1 || diagnostics[0].Code != CodeInheritanceCycle {
t.Fatalf("deep cycle result = %#v, diagnostics = %#v", result, diagnostics)
}
}

View File

@@ -0,0 +1,309 @@
package engine
import (
"cmp"
"slices"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
type inheritanceWalker struct {
nodes int // logical non-directive nodes in the entire working tree
origins map[string]Origin
limits projectionLimits
retained *retainedNodeCounter
transfers []inheritanceTransfer
controls []inheritanceControl
replacements []inheritanceReplacement
operations []inheritanceOperation
}
// expandInheritanceTree resolves each destination scope after its enclosing
// relationships. The loading lifecycle uses the origin-aware entry point.
func expandInheritanceTree(input *document.Node, limits projectionLimits) (*inheritanceScope, []Diagnostic) {
return expandInheritanceWithOrigins(input, nil, limits)
}
func expandInheritanceWithOrigins(input *document.Node, inputOrigins map[string]Origin, limits projectionLimits) (*inheritanceScope, []Diagnostic) {
return expandInheritanceWithBudgets(input, inputOrigins, limits, historyNodeLimit)
}
func expandInheritanceWithBudgets(input *document.Node, inputOrigins map[string]Origin, limits projectionLimits, historyLimit int) (*inheritanceScope, []Diagnostic) {
if input == nil || input.Kind != document.Mapping {
return nil, []Diagnostic{{Code: CodeInheritanceInvalid, Severity: SeverityError,
Message: "inheritance requires a mapping document root"}}
}
root := document.Clone(input)
origins := cloneOriginIndex(inputOrigins)
if inputOrigins == nil {
origins = directInheritanceOrigins(input, Path{}, nil)
}
nodes, within := inheritanceLowerBoundCount(root, limits.nodes)
if !within {
return nil, []Diagnostic{inheritanceBudgetDiagnostic(Path{}, root.Location)}
}
walker := inheritanceWalker{
nodes: nodes, limits: limits, origins: origins,
retained: newRetainedNodeCounter(historyLimit),
}
if problem := walker.visit(&root, Path{}, true); problem != nil {
return nil, []Diagnostic{*problem}
}
if problem := inheritanceFinalBudget(root, limits); problem != nil {
return nil, []Diagnostic{*problem}
}
return &inheritanceScope{
root: root, origins: walker.origins, transfers: walker.transfers,
controls: walker.controls, replacements: walker.replacements, operations: walker.operations,
}, nil
}
func (w *inheritanceWalker) visit(node **document.Node, path Path, unscoped bool) *Diagnostic {
switch (*node).Kind {
case document.Mapping:
original := *node
if unscoped {
reference, found := inheritanceDirective(*node)
if found {
referencePath := path.Key(inheritanceKey)
location := publicLocation(reference.Value.Location)
if reference.Value.Kind != document.Null {
problem := inheritanceDiagnostic(CodeInheritanceInvalid, referencePath, location, nil,
"inheritance reference has no enclosing sibling scope")
return &problem
}
w.controls = append(w.controls, inheritanceControl{
path: referencePath, locations: []Location{location}, cancelled: true,
})
origin := directInheritanceOrigins(reference.Value, referencePath, w.origins)[referencePath.String()]
control := inheritanceControl{path: referencePath, locations: []Location{location}, cancelled: true}
w.operations = append(w.operations, inheritanceOperation{control: &control, controlOrigin: origin})
deleteOriginSubtree(w.origins, referencePath, reference.Value)
*node = withoutInheritanceDirective(*node)
}
}
if scopeHasInheritanceDefinitions(*node) {
// Only this scope can change the logical total. Reserve the nodes
// outside it so expansion checks the remaining budget before cloning.
before, _ := inheritanceLowerBoundCount(*node, -1)
limits := w.limits
if limits.nodes >= 0 {
limits.nodes -= w.nodes - before
}
resolved, diagnostics := expandSiblingDefinitionsWithOrigins(*node, path, limits, w.origins, w.retained)
if len(diagnostics) != 0 {
return &diagnostics[0]
}
*node = resolved.root
deleteOriginSubtree(w.origins, path, original)
for pointer, origin := range resolved.origins {
w.origins[pointer] = origin
}
w.transfers = append(w.transfers, resolved.transfers...)
w.controls = append(w.controls, resolved.controls...)
w.replacements = append(w.replacements, resolved.replacements...)
w.operations = append(w.operations, resolved.operations...)
after, _ := inheritanceLowerBoundCount(*node, -1)
w.nodes += after - before
}
// Operations retain immutable enclosing templates. Detach just this
// container before replacing descendants during destination resolution;
// untouched subtrees remain shared with the retained evidence.
working := **node
working.Mapping = slices.Clone(working.Mapping)
*node = &working
indices := make([]int, len((*node).Mapping))
for index := range indices {
indices[index] = index
}
slices.SortFunc(indices, func(left, right int) int {
return cmp.Compare((*node).Mapping[left].Key, (*node).Mapping[right].Key)
})
for _, index := range indices {
entry := &(*node).Mapping[index]
if problem := w.visit(&entry.Value, path.Key(entry.Key), false); problem != nil {
return problem
}
}
case document.Sequence:
working := **node
working.Sequence = slices.Clone(working.Sequence)
*node = &working
for index := range (*node).Sequence {
child := &(*node).Sequence[index]
if problem := w.visit(child, path.Index(index), (*child).Kind == document.Mapping); problem != nil {
return problem
}
}
}
return nil
}
func scopeHasInheritanceDefinitions(scope *document.Node) bool {
for _, entry := range scope.Mapping {
if entry.Value.Kind != document.Mapping {
continue
}
if _, found := inheritanceDirective(entry.Value); found {
return true
}
}
return false
}
// A directive may disappear during later destination resolution, so only
// non-directive nodes form a safe early lower bound on final output size.
func inheritanceLowerBoundWithin(root *document.Node, limit int) bool {
_, within := inheritanceLowerBoundCount(root, limit)
return within
}
func inheritanceLowerBoundCount(root *document.Node, limit int) (int, bool) {
if limit < 0 {
limit = int(^uint(0) >> 1)
}
stack := []*document.Node{root}
count := 0
for len(stack) != 0 {
node := stack[len(stack)-1]
stack = stack[:len(stack)-1]
if node == nil {
continue
}
count++
if count > limit {
return count, false
}
switch node.Kind {
case document.Mapping:
for _, entry := range node.Mapping {
if entry.Key == inheritanceKey {
continue
}
count++ // mapping key
stack = append(stack, entry.Value)
}
case document.Sequence:
stack = append(stack, node.Sequence...)
}
if count > limit {
return count, false
}
}
return count, true
}
// Count the materialized mapping shape before cloning a memoized parent.
// Relationship fields are excluded because later destination resolution
// consumes them; their omission makes this a safe lower bound.
func inheritanceOverlayLowerBoundWithin(lower, upper *document.Node, limit int) bool {
if limit < 0 {
return false
}
index := new(mappingIndex)
count := 0
var visit func(*document.Node, *document.Node) bool
visit = func(base, incoming *document.Node) bool {
if base.Kind != document.Mapping || incoming.Kind != document.Mapping {
added, within := inheritanceLowerBoundCount(incoming, limit-count)
count += added
return within
}
count++ // mapping node
if count > limit {
return false
}
for _, entry := range base.Mapping {
if entry.Key == inheritanceKey {
continue
}
count++ // mapping key
if count > limit {
return false
}
position := index.find(incoming, entry.Key)
if position >= 0 {
if !visit(entry.Value, incoming.Mapping[position].Value) {
return false
}
} else {
added, within := inheritanceLowerBoundCount(entry.Value, limit-count)
count += added
if !within {
return false
}
}
}
for _, entry := range incoming.Mapping {
if entry.Key == inheritanceKey || index.find(base, entry.Key) >= 0 {
continue
}
count++ // mapping key
if count > limit {
return false
}
added, within := inheritanceLowerBoundCount(entry.Value, limit-count)
count += added
if !within || count > limit {
return false
}
}
return true
}
return visit(lower, upper)
}
func inheritanceFinalBudget(root *document.Node, limits projectionLimits) *Diagnostic {
type pending struct {
node *document.Node
path Path
}
stack := []pending{{node: root}}
count := 0
for len(stack) != 0 {
item := stack[len(stack)-1]
stack = stack[:len(stack)-1]
if len(item.path.segments) > limits.depth {
problem := inheritanceBudgetDiagnostic(item.path, item.node.Location)
return &problem
}
count++
if limits.nodes >= 0 && count > limits.nodes {
problem := inheritanceBudgetDiagnostic(item.path, item.node.Location)
return &problem
}
switch item.node.Kind {
case document.Mapping:
count += len(item.node.Mapping) // mapping keys
for index := len(item.node.Mapping) - 1; index >= 0; index-- {
entry := item.node.Mapping[index]
stack = append(stack, pending{node: entry.Value, path: item.path.Key(entry.Key)})
}
case document.Sequence:
for index := len(item.node.Sequence) - 1; index >= 0; index-- {
stack = append(stack, pending{node: item.node.Sequence[index], path: item.path.Index(index)})
}
}
if limits.nodes >= 0 && count > limits.nodes {
problem := inheritanceBudgetDiagnostic(item.path, item.node.Location)
return &problem
}
}
return nil
}
func inheritanceBudgetDiagnostic(path Path, location document.Location) Diagnostic {
primary := publicLocation(location)
return Diagnostic{
Code: CodeSchemaProjectionLimit, Severity: SeverityError, Path: path,
Message: "expanded configuration exceeds the projection limit", Primary: &primary,
}
}
func inheritanceHistoryBudgetDiagnostic(path Path, location document.Location) Diagnostic {
primary := publicLocation(location)
return Diagnostic{
Code: CodeExplainLimitExceeded, Severity: SeverityError, Path: path,
Message: "retained inheritance history exceeds the node limit", Primary: &primary,
}
}

View File

@@ -0,0 +1,219 @@
package engine
import (
"reflect"
"testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
func TestNestedInheritanceUsesEachDestinationScope(t *testing.T) {
input := parseCompositionDocument(t, "source-1", `presets:
base:
settings:
primary: {width: 10}
secondary: {extends: primary}
custom:
extends: base
settings:
primary: {width: 20}
`)
before := document.Clone(input)
result, diagnostics := expandInheritanceTree(input, defaultProjectionLimits())
if len(diagnostics) != 0 {
t.Fatal(diagnostics)
}
for path, want := range map[string]string{
"/presets/base/settings/secondary/width": "10",
"/presets/custom/settings/secondary/width": "20",
} {
parsed, _ := ParsePath(path)
node, found := documentAtPath(result.root, parsed)
if !found || node.Text != want {
t.Errorf("%s = %#v, found %v", path, node, found)
}
}
if len(result.transfers) != 3 || !reflect.DeepEqual(input, before) {
t.Fatalf("transfers = %#v; input changed = %v", result.transfers, !reflect.DeepEqual(input, before))
}
if node, found := documentAtPath(result.root, Keys("presets", "custom", "settings", "secondary", inheritanceKey)); found {
t.Fatalf("nested directive survived: %#v", node)
}
}
func TestNestedInheritanceSupportsChainsCancellationAndContainerReplacement(t *testing.T) {
input := parseCompositionDocument(t, "source-1", `catalog:
alpha:
settings:
first: {value: one}
second: {extends: first}
beta:
extends: alpha
settings:
first: {value: two}
gamma:
extends: beta
settings:
first: {value: three}
cancelled:
extends: alpha
settings:
second: {extends: null, value: own}
replaced:
extends: alpha
settings: none
`)
result, diagnostics := expandInheritanceTree(input, defaultProjectionLimits())
if len(diagnostics) != 0 {
t.Fatal(diagnostics)
}
checks := map[string]string{
"/catalog/alpha/settings/second/value": "one",
"/catalog/beta/settings/second/value": "two",
"/catalog/gamma/settings/second/value": "three",
"/catalog/cancelled/settings/second/value": "own",
"/catalog/replaced/settings": "none",
}
for pointer, want := range checks {
path, _ := ParsePath(pointer)
node, found := documentAtPath(result.root, path)
if !found || node.Text != want {
t.Errorf("%s = %#v, found %v", pointer, node, found)
}
}
if _, found := documentAtPath(result.root, Keys("catalog", "replaced", "settings", "second")); found {
t.Fatal("replaced container retained an active child")
}
if _, found := documentAtPath(result.root, Keys("catalog", "cancelled", "settings", "second", inheritanceKey)); found {
t.Fatal("cancelled nested directive survived")
}
}
func TestNestedInheritanceCanReplaceAnInheritedRelationship(t *testing.T) {
input := parseCompositionDocument(t, "source-1", `base:
settings:
first: {value: old}
other: {value: new}
selected: {extends: first}
custom:
extends: base
settings:
selected: {extends: other}
`)
result, diagnostics := expandInheritanceTree(input, defaultProjectionLimits())
if len(diagnostics) != 0 {
t.Fatal(diagnostics)
}
base, found := documentAtPath(result.root, Keys("base", "settings", "selected", "value"))
if !found || base.Text != "old" {
t.Fatalf("base relationship = %#v, %v", base, found)
}
custom, found := documentAtPath(result.root, Keys("custom", "settings", "selected", "value"))
if !found || custom.Text != "new" {
t.Fatalf("replaced relationship = %#v, %v", custom, found)
}
}
func TestNestedInheritanceRejectsDestinationCycleAndInvalidPositions(t *testing.T) {
tests := []struct {
name, input, path string
code Code
}{
{
name: "destination-only cycle", input: `base:
settings:
a: {value: one}
b: {extends: a}
custom:
extends: base
settings:
a: {extends: b}
`, path: "/custom/settings/b/extends", code: CodeInheritanceCycle,
},
{"root reference", "extends: anything\nentry: {value: one}\n", "/extends", CodeInheritanceInvalid},
{"list element reference", "items: [{extends: anything, value: one}]\n", "/items/0/extends", CodeInheritanceInvalid},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
input := parseCompositionDocument(t, "source-1", test.input)
result, diagnostics := expandInheritanceTree(input, defaultProjectionLimits())
if result != nil || len(diagnostics) != 1 || diagnostics[0].Code != test.code ||
diagnostics[0].Path.String() != test.path || diagnostics[0].Primary == nil {
t.Fatalf("result = %#v, diagnostics = %#v", result, diagnostics)
}
})
}
}
func TestNestedInheritanceResolvesScopesInsideSequences(t *testing.T) {
input := parseCompositionDocument(t, "source-1", `items:
- primary: {value: first}
secondary: {extends: primary}
- extends: null
zeta: {extends: alpha}
alpha: {value: second}
`)
result, diagnostics := expandInheritanceTree(input, defaultProjectionLimits())
if len(diagnostics) != 0 {
t.Fatal(diagnostics)
}
first, found := documentAtPath(result.root, Keys("items").Index(0).Key("secondary").Key("value"))
if !found || first.Text != "first" {
t.Fatalf("first sequence scope = %#v, %v", first, found)
}
second, found := documentAtPath(result.root, Keys("items").Index(1).Key("zeta").Key("value"))
if !found || second.Text != "second" {
t.Fatalf("second sequence scope = %#v, %v", second, found)
}
if _, found := documentAtPath(result.root, Keys("items").Index(1).Key(inheritanceKey)); found {
t.Fatal("sequence-element cancellation survived")
}
}
func TestInheritanceExpansionHonorsOutputNodeAndDepthBudgets(t *testing.T) {
input := parseCompositionDocument(t, "source-1", "base: {value: one}\nchild: {extends: base}\n")
for _, test := range []struct {
name string
limits projectionLimits
valid bool
}{
{name: "at node boundary", limits: projectionLimits{nodes: 9, depth: 2}, valid: true},
{name: "beyond node boundary", limits: projectionLimits{nodes: 8, depth: 2}},
{name: "at depth boundary", limits: projectionLimits{nodes: 20, depth: 2}, valid: true},
{name: "beyond depth boundary", limits: projectionLimits{nodes: 20, depth: 1}},
} {
t.Run(test.name, func(t *testing.T) {
result, diagnostics := expandInheritanceTree(input, test.limits)
if test.valid {
if result == nil || len(diagnostics) != 0 {
t.Fatalf("result = %#v, diagnostics = %#v", result, diagnostics)
}
} else if result != nil || len(diagnostics) != 1 || diagnostics[0].Code != CodeSchemaProjectionLimit {
t.Fatalf("result = %#v, diagnostics = %#v", result, diagnostics)
}
})
}
}
func TestInheritanceBudgetIncludesOtherScopesAndSequenceItems(t *testing.T) {
input := parseCompositionDocument(t, "source-1", `extends: null
groups:
- a: {x: one}
b: {extends: a}
- a: {x: two}
b: {extends: a}
unchanged: {x: three}
`)
// Root (1), groups key/list (2), two expanded scopes (18), and
// unchanged key/mapping/key/value (4). Consumed controls cost nothing.
for _, limit := range []int{24, 25, 26} {
result, diagnostics := expandInheritanceTree(input, projectionLimits{nodes: limit, depth: 5})
if limit < 25 {
if result != nil || len(diagnostics) != 1 || diagnostics[0].Code != CodeSchemaProjectionLimit {
t.Fatalf("limit %d accepted or wrong error: %#v", limit, diagnostics)
}
} else if result == nil || len(diagnostics) != 0 {
t.Fatalf("limit %d rejected: %#v", limit, diagnostics)
}
}
}

View File

@@ -0,0 +1,50 @@
package engine
import "os"
// Input selects one source and its independent import policy.
type Input struct {
Source Source
Imports bool
Filesystem *FilesystemContext
}
// Underlay contains strictly composed contributions at one precedence tier.
type Underlay struct{ Inputs []Input }
// LayeredOptions selects lower-priority tiers and final application rules.
type LayeredOptions[T any] struct {
Definition Definition[T]
Underlays []Underlay
Overlay string
OverlayFilesystem *FilesystemContext
Overrides []Override
}
// LoadLayered loads a main input and lower-priority grouped inputs through one
// final decode, resolution, and validation lifecycle.
func LoadLayered[T any](main Input, secrets SecretBinding, supplied ...LayeredOptions[T]) (*Loaded[T], error) {
if len(supplied) > 1 {
return nil, (&diagnosticAccumulator{diagnostics: []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "LoadLayered accepts at most one options value")}}).loadError()
}
var options LayeredOptions[T]
if len(supplied) != 0 {
options = supplied[0]
}
request := loadRequest[T]{
main: loadInput{source: main.Source, imports: main.Imports, filesystem: main.Filesystem},
definition: options.Definition, overlay: options.Overlay,
overlayFilesystem: options.OverlayFilesystem,
overrides: append([]Override(nil), options.Overrides...),
underlays: make([]loadGroup, len(options.Underlays)),
}
for index, underlay := range options.Underlays {
request.underlays[index].inputs = make([]loadInput, len(underlay.Inputs))
for inputIndex, input := range underlay.Inputs {
request.underlays[index].inputs[inputIndex] = loadInput{
source: input.Source, imports: input.Imports, filesystem: input.Filesystem,
}
}
}
return loadGroupedWithSecretLookup(request, secrets, os.LookupEnv)
}

View File

@@ -33,6 +33,7 @@ type Options[T any] struct {
type FilesystemContext struct { type FilesystemContext struct {
BaseDir string BaseDir string
Root string Root string
FS *Filesystem
} }
// Override describes one explicitly supplied application override. Value uses // Override describes one explicitly supplied application override. Value uses
@@ -77,13 +78,17 @@ type Loaded[T any] struct {
type Inspection struct { type Inspection struct {
metadata Metadata metadata Metadata
sources []SourceInfo sources []SourceInfo
contexts map[string]ResourceContext
diagnostics []Diagnostic diagnostics []Diagnostic
baseline *document.Node baseline *document.Node
input *document.Node input *document.Node
inputOrigins map[string]Origin inputOrigins map[string]Origin
final *document.Node final *document.Node
immutableLookup *documentLookup
resolution []resolutionSnapshot resolution []resolutionSnapshot
resolutionReplay []resolutionSnapshot
applications []historyApplication applications []historyApplication
inheritance []inheritanceOperation
secrets map[string]bool secrets map[string]bool
secretReferences map[string]string secretReferences map[string]string
} }
@@ -104,6 +109,16 @@ func (r *Inspection) Sources() []SourceInfo {
return cloneSourceInfos(r.sources) return cloneSourceInfos(r.sources)
} }
// SourceContext returns a passive filesystem view for an acquired source.
// Unknown IDs and sources without a filesystem context return false.
func (r *Inspection) SourceContext(id string) (ResourceContext, bool) {
if r == nil {
return ResourceContext{}, false
}
context, ok := r.contexts[id]
return context, ok
}
// Diagnostics returns detached nonfatal diagnostics from the load. // Diagnostics returns detached nonfatal diagnostics from the load.
func (r *Inspection) Diagnostics() []Diagnostic { func (r *Inspection) Diagnostics() []Diagnostic {
if r == nil { if r == nil {
@@ -120,14 +135,27 @@ func Load[T any](source Source, secrets SecretBinding, supplied ...Options[T]) (
} }
func loadWithSecretLookup[T any](source Source, secrets SecretBinding, lookup secretLookup, supplied ...Options[T]) (*Loaded[T], error) { func loadWithSecretLookup[T any](source Source, secrets SecretBinding, lookup secretLookup, supplied ...Options[T]) (*Loaded[T], error) {
accumulator := diagnosticAccumulator{}
options, diagnostics := selectOptions(source, supplied) options, diagnostics := selectOptions(source, supplied)
accumulator.add(diagnostics...)
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
return nil, accumulator.loadError() return nil, (&diagnosticAccumulator{diagnostics: diagnostics}).loadError()
}
request := loadRequest[T]{
main: loadInput{source: source, imports: options.Imports, filesystem: options.Filesystem},
definition: options.Definition, overlay: options.Overlay,
overrides: options.Overrides, ordinary: true,
}
return executeLoadRequest(request, secrets, lookup)
}
func executeLoadRequest[T any](request loadRequest[T], secrets SecretBinding, lookup secretLookup) (*Loaded[T], error) {
accumulator := diagnosticAccumulator{}
accounting := &importAccounting{}
mainOptions := Options[T]{Definition: request.definition, Imports: request.main.imports, Filesystem: request.main.filesystem}
if request.ordinary {
mainOptions.Overlay = request.overlay
} }
input, diagnostics := assembleBaseInput(source, options) input, diagnostics := assembleBaseInputTracked(request.main.source, mainOptions, accounting, nil, nil)
accumulator.sources = input.sources accumulator.sources = input.sources
accumulator.add(diagnostics...) accumulator.add(diagnostics...)
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
@@ -138,29 +166,68 @@ func loadWithSecretLookup[T any](source Source, secrets SecretBinding, lookup se
accumulator.add(diagnosticFromSchemaIssue(schemaIssue)) accumulator.add(diagnosticFromSchemaIssue(schemaIssue))
return nil, accumulator.loadError() return nil, accumulator.loadError()
} }
versionDiagnostics := checkVersion(options.Definition.Version, input.document) versionDiagnostics := checkVersion(request.definition.Version, input.document)
accumulator.add(versionDiagnostics...) accumulator.add(versionDiagnostics...)
if hasErrors(versionDiagnostics) { if hasErrors(versionDiagnostics) {
return nil, accumulator.loadError() return nil, accumulator.loadError()
} }
input, diagnostics = applyInputLayers(source, options, input) if request.ordinary {
input, diagnostics = applyInputLayers(request.main.source, Options[T]{Definition: request.definition, Overlay: request.overlay, Filesystem: request.main.filesystem, Overrides: request.overrides}, input)
} else {
input, diagnostics = assembleLayeredInput(request, input, accounting)
}
accumulator.sources = input.sources accumulator.sources = input.sources
accumulator.add(diagnostics...) accumulator.add(diagnostics...)
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
return nil, accumulator.loadError() return nil, accumulator.loadError()
} }
if request.ordinary && input.parsed.source != nil {
baseSources := make([]SourceInfo, 0, len(input.sources))
for _, source := range input.sources {
if source.Layer == LayerBase {
baseSources = append(baseSources, source)
}
}
if len(baseSources) != 0 && input.contexts[baseSources[0].ID].FS == nil {
contexts, diagnostic := contextsForAcquiredSources(request.main.source, request.main.filesystem, input.parsed.source, baseSources)
if diagnostic != nil {
accumulator.add(*diagnostic)
return nil, accumulator.loadError()
}
mergeResourceContexts(&input.contexts, contexts)
}
}
if input.document != nil && containsInheritanceDirective(input.document) {
composition, problems := compositionForInput(input)
if len(problems) != 0 {
accumulator.add(problems...)
return nil, accumulator.loadError()
}
expanded, problems := expandInheritanceWithOrigins(composition.root, composition.origins, defaultProjectionLimits())
if len(problems) != 0 {
accumulator.add(problems...)
return nil, accumulator.loadError()
}
if problem := inheritanceVersionDiagnostic(request.definition.Version, expanded); problem != nil {
accumulator.add(*problem)
return nil, accumulator.loadError()
}
input.composition = composition
input.inheritance = expanded
input.document = expanded.root
}
decoded, diagnostics := decodeLoadInput(schema, options.Definition, input) decoded, diagnostics := decodeLoadInput(schema, request.definition, input)
accumulator.add(diagnostics...) accumulator.add(diagnostics...)
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
return nil, accumulator.loadError() return nil, accumulator.loadError()
} }
resolved, diagnostics := resolveLoadInput(schema, options.Definition, input, decoded) resolved, diagnostics := resolveLoadInput(schema, request.definition, input, decoded)
accumulator.add(diagnostics...) accumulator.add(diagnostics...)
if hasErrors(diagnostics) { if hasErrors(diagnostics) {
return nil, accumulator.loadError() return nil, accumulator.loadError()
} }
if !historyNodesWithinLimit(decoded.baseline, decoded.final, resolved.final, resolved.applications, resolved.snapshots) { if !historyNodesWithinLimit(decoded.baseline, decoded.final, resolved.final, resolved.applications, resolved.snapshots, input.inheritance) {
accumulator.add(Diagnostic{Code: CodeExplainLimitExceeded, Severity: SeverityError, Message: "retained configuration history exceeds the node limit"}) accumulator.add(Diagnostic{Code: CodeExplainLimitExceeded, Severity: SeverityError, Message: "retained configuration history exceeds the node limit"})
return nil, accumulator.loadError() return nil, accumulator.loadError()
} }
@@ -176,8 +243,8 @@ func loadWithSecretLookup[T any](source Source, secrets SecretBinding, lookup se
if hasErrors(secretResolution.diagnostics) { if hasErrors(secretResolution.diagnostics) {
return nil, accumulator.loadError() return nil, accumulator.loadError()
} }
if options.Definition.Validate != nil { if request.definition.Validate != nil {
diagnostics = runValidation(&configuration, options.Definition.Validate, schema, resolved.final, input.document, secretResolution.lookups) diagnostics = runValidation(&configuration, request.definition.Validate, schema, resolved.final, input.document, secretResolution.lookups)
accumulator.add(diagnostics...) accumulator.add(diagnostics...)
if hasErrors(diagnostics) { if hasErrors(diagnostics) {
return nil, accumulator.loadError() return nil, accumulator.loadError()
@@ -186,5 +253,5 @@ func loadWithSecretLookup[T any](source Source, secrets SecretBinding, lookup se
resolved.configuration = configuration resolved.configuration = configuration
// Publication detaches equal-valued validator replacements, preserving the // Publication detaches equal-valued validator replacements, preserving the
// validated secret state, then finalizes metadata and sensitivity. // validated secret state, then finalizes metadata and sensitivity.
return publishLoadResult(schema, options.Definition, resolved, secretResolution.references, &accumulator) return publishLoadResult(schema, request.definition, resolved, secretResolution.references, &accumulator)
} }

View File

@@ -1,24 +1,37 @@
package engine package engine
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document" import (
"strconv"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
// assembledInput is the source/document handoff across schema and version checks. // assembledInput is the source/document handoff across schema and version checks.
type assembledInput struct { type assembledInput struct {
parsed parsedSource parsed parsedSource
document *document.Node document *document.Node
composition *composedDocument composition *composedDocument
inheritance *inheritanceScope
sources []SourceInfo sources []SourceInfo
contexts map[string]ResourceContext
} }
func assembleBaseInput[T any](source Source, options Options[T]) (assembledInput, []Diagnostic) { func assembleBaseInput[T any](source Source, options Options[T]) (assembledInput, []Diagnostic) {
parsed, diagnostics := parseSource(source, "source-1", defaultParseLimits()) return assembleBaseInputTracked(source, options, &importAccounting{}, nil, nil)
}
func assembleBaseInputTracked[T any](source Source, options Options[T], accounting *importAccounting, visited map[resourceIdentity]importRoute, hostToken *namespaceToken) (assembledInput, []Diagnostic) {
limits := defaultImportLimits()
parseLimits := defaultParseLimits()
parseLimits.source.remaining = limits.bytes - accounting.bytes
parsed, diagnostics := parseSource(source, "source-"+strconv.Itoa(accounting.sources+1), parseLimits)
input := assembledInput{parsed: parsed, document: parsed.document} input := assembledInput{parsed: parsed, document: parsed.document}
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
input.sources = sourceInfos(parsed.source) input.sources = sourceInfos(parsed.source)
return input, diagnostics return input, diagnostics
} }
if options.Imports { if options.Imports {
input.composition, input.sources, diagnostics = loadImportedDocuments(source, options.Filesystem, parsed, options.Definition.Version, defaultImportLimits()) input.composition, input.sources, diagnostics = loadImportedDocumentsTracked(source, options.Filesystem, parsed, options.Definition.Version, limits, accounting, visited, hostToken)
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
return input, diagnostics return input, diagnostics
} }
@@ -35,10 +48,44 @@ func assembleBaseInput[T any](source Source, options Options[T]) (assembledInput
if diagnostics := disabledDirectiveDiagnostics(input.document, Path{}); len(diagnostics) != 0 { if diagnostics := disabledDirectiveDiagnostics(input.document, Path{}); len(diagnostics) != 0 {
return input, diagnostics return input, diagnostics
} }
if accounting.sources+1 > limits.sources || accounting.bytes+parsed.bytes > limits.bytes || accounting.nodes+countInputNodes(input.document) > limits.nodes {
return input, []Diagnostic{sourceDiagnostic(CodeSourceLimitExceeded, parsed.source.ID, "configuration exceeds the aggregate input limit")}
}
if diagnostic := registerAcquiredSource(source, options.Filesystem, parsed.source, visited, hostToken); diagnostic != nil {
return input, []Diagnostic{*diagnostic}
}
accounting.sources++
accounting.bytes += parsed.bytes
accounting.nodes += countInputNodes(input.document)
}
if !activeOverlay(options.Overlay) || options.Imports {
contexts, diagnostic := contextsForAcquiredSources(source, options.Filesystem, parsed.source, input.sources)
if diagnostic != nil {
return input, []Diagnostic{*diagnostic}
}
input.contexts = contexts
} }
return input, nil return input, nil
} }
func registerAcquiredSource(source Source, context *FilesystemContext, info *SourceInfo, visited map[resourceIdentity]importRoute, hostToken *namespaceToken) *Diagnostic {
if visited == nil || info == nil || info.Path == "" {
return nil
}
identity := resourceIdentity{namespace: hostToken, path: info.Path}
if source.kind == sourceFilesystem {
identity.namespace = source.namespace.token
} else if context != nil && context.FS != nil {
identity.namespace = context.FS.namespace.token
}
if previous, repeated := visited[identity]; repeated {
value := importRepeatedDiagnostic(Location{SourceID: info.ID}, previous, importRoute{})
return &value
}
visited[identity] = importRoute{canonical: info.Path}
return nil
}
func applyInputLayers[T any](source Source, options Options[T], input assembledInput) (assembledInput, []Diagnostic) { func applyInputLayers[T any](source Source, options Options[T], input assembledInput) (assembledInput, []Diagnostic) {
if !activeOverlay(options.Overlay) && len(options.Overrides) == 0 { if !activeOverlay(options.Overlay) && len(options.Overrides) == 0 {
return input, nil return input, nil
@@ -47,25 +94,31 @@ func applyInputLayers[T any](source Source, options Options[T], input assembledI
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
return input, diagnostics return input, diagnostics
} }
assembly := newAssemblyAccumulator(composition)
if activeOverlay(options.Overlay) { if activeOverlay(options.Overlay) {
composition, input.sources, diagnostics = loadSelectedOverlayDocument( input.sources, diagnostics = loadSelectedOverlayInto(
source, options.Filesystem, input.parsed, composition, input.sources, source, options.Filesystem, input.parsed, assembly, input.sources,
options.Overlay, options.Definition.Version, defaultImportLimits(), options.Overlay, options.Definition.Version, defaultImportLimits(),
) )
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
return input, diagnostics return input, diagnostics
} }
contexts, diagnostic := contextsForAcquiredSources(source, options.Filesystem, input.parsed.source, input.sources[len(input.sources)-1:])
if diagnostic != nil {
return input, []Diagnostic{*diagnostic}
}
mergeResourceContexts(&input.contexts, contexts)
} }
if len(options.Overrides) != 0 { if len(options.Overrides) != 0 {
composition, input.sources, diagnostics = applyOverrides( input.sources, diagnostics = assembly.applyOverrides(
composition, input.sources, options.Overrides, options.Definition.Version, defaultImportLimits(), input.sources, options.Overrides, options.Definition.Version, defaultImportLimits(),
) )
if len(diagnostics) != 0 { if len(diagnostics) != 0 {
return input, diagnostics return input, diagnostics
} }
} }
input.composition = composition input.composition = assembly.current
input.document = composition.root input.document = assembly.current.root
return input, nil return input, nil
} }

View File

@@ -12,9 +12,10 @@ import (
type resolutionInput struct { type resolutionInput struct {
metadata Metadata metadata Metadata
sources map[string]SourceInfo sources map[string]SourceInfo
contexts map[string]ResourceContext
} }
func newResolutionInput(metadata Metadata, final *document.Node, sources []SourceInfo) resolutionInput { func newResolutionInput(metadata Metadata, final *document.Node, sources []SourceInfo, contexts ...map[string]ResourceContext) resolutionInput {
frozen := Metadata{ frozen := Metadata{
fields: make(map[string]FieldInfo, len(metadata.fields)), fields: make(map[string]FieldInfo, len(metadata.fields)),
effectivePaths: effectivePathIndex(final), effectivePaths: effectivePathIndex(final),
@@ -32,7 +33,11 @@ func newResolutionInput(metadata Metadata, final *document.Node, sources []Sourc
} }
indexed[source.ID] = cloneSourceInfo(source) indexed[source.ID] = cloneSourceInfo(source)
} }
return resolutionInput{metadata: frozen, sources: indexed} result := resolutionInput{metadata: frozen, sources: indexed}
if len(contexts) != 0 {
result.contexts = cloneResourceContexts(contexts[0])
}
return result
} }
func (input resolutionInput) lookup(path Path) (FieldInfo, bool) { func (input resolutionInput) lookup(path Path) (FieldInfo, bool) {
@@ -47,6 +52,11 @@ func (input resolutionInput) source(id string) (SourceInfo, bool) {
return cloneSourceInfo(source), true return cloneSourceInfo(source), true
} }
func (input resolutionInput) sourceContext(id string) (ResourceContext, bool) {
context, ok := input.contexts[id]
return context, ok
}
func metadataFromDecoded(decoded decodedConfiguration) Metadata { func metadataFromDecoded(decoded decodedConfiguration) Metadata {
metadata := Metadata{ metadata := Metadata{
fields: make(map[string]FieldInfo), fields: make(map[string]FieldInfo),
@@ -115,9 +125,6 @@ func applyOverlayMetadata(metadata *Metadata, records []overlayRecord) {
if err != nil { if err != nil {
continue continue
} }
if history := events[pointer]; len(history) != 0 && history[len(history)-1].Origin.Layer == info.Origin.Layer && history[len(history)-1].Origin.Operation == info.Origin.Operation {
continue
}
appendInputOriginEvent(events, baseline, pointer, path, info.Origin) appendInputOriginEvent(events, baseline, pointer, path, info.Origin)
} }
metadata.history = make(map[string][]Change, len(baseline)+len(events)) metadata.history = make(map[string][]Change, len(baseline)+len(events))
@@ -134,12 +141,34 @@ func appendInputOriginEvent(events map[string][]Change, baseline map[string][]Ch
return return
} }
history := events[pointer] history := events[pointer]
if len(history) != 0 && originContainedBy(history[len(history)-1].Origin, origin) { if len(history) != 0 && originRepresentedByChange(path, origin, history[len(history)-1]) {
return return
} }
appendInputChangeEvent(events, baseline, pointer, Change{Path: path.clone(), Origin: cloneOrigin(origin)}) appendInputChangeEvent(events, baseline, pointer, Change{Path: path.clone(), Origin: cloneOrigin(origin)})
} }
func originRepresentedByChange(path Path, origin Origin, change Change) bool {
if change.Path.String() == path.String() {
return originContainedBy(origin, change.Origin)
}
if !pathContains(change.Path, path) || origin.Layer != change.Origin.Layer || origin.Operation != change.Origin.Operation {
return false
}
// A subtree replacement already records its descendants' contributions.
// Their coordinates differ from the container's, but their sources must be
// covered by the operation; matching only the precedence role is insufficient.
recordedSources := make(map[string]struct{}, len(change.Origin.Locations))
for _, recorded := range change.Origin.Locations {
recordedSources[recorded.SourceID] = struct{}{}
}
for _, location := range origin.Locations {
if _, found := recordedSources[location.SourceID]; !found {
return false
}
}
return true
}
func appendInputChangeEvent(events map[string][]Change, baseline map[string][]Change, pointer string, value Change) { func appendInputChangeEvent(events map[string][]Change, baseline map[string][]Change, pointer string, value Change) {
change := cloneChange(value) change := cloneChange(value)
history := events[pointer] history := events[pointer]
@@ -157,10 +186,24 @@ func originContainedBy(candidate, container Origin) bool {
if candidate.Layer != container.Layer || candidate.Operation != container.Operation { if candidate.Layer != container.Layer || candidate.Operation != container.Operation {
return false return false
} }
availableLocations := make(map[Location]struct{}, len(container.Locations))
for _, location := range container.Locations {
availableLocations[location] = struct{}{}
}
for _, location := range candidate.Locations { for _, location := range candidate.Locations {
if _, found := availableLocations[location]; !found {
return false
}
}
if (len(candidate.contributors) == 0) != (len(container.contributors) == 0) {
return false
}
for _, contributor := range candidate.contributors {
found := false found := false
for _, available := range container.Locations { for _, available := range container.contributors {
if location == available { if contributor.location == available.location &&
slices.Equal(contributor.source.segments, available.source.segments) &&
sameInheritanceTrace(contributor.trace, available.trace) {
found = true found = true
break break
} }
@@ -207,6 +250,8 @@ func cloneMetadata(metadata Metadata) Metadata {
cloned := Metadata{ cloned := Metadata{
fields: make(map[string]FieldInfo, len(metadata.fields)), fields: make(map[string]FieldInfo, len(metadata.fields)),
history: make(map[string][]Change, len(metadata.history)), history: make(map[string][]Change, len(metadata.history)),
inheritance: append([]inheritanceOperation(nil), metadata.inheritance...),
inheritanceIndex: metadata.inheritanceIndex,
} }
cloned.effectivePaths = make(map[string]Path, len(metadata.effectivePaths)) cloned.effectivePaths = make(map[string]Path, len(metadata.effectivePaths))
for pointer, path := range metadata.effectivePaths { for pointer, path := range metadata.effectivePaths {
@@ -230,8 +275,23 @@ func cloneMetadata(metadata Metadata) Metadata {
} }
func applyResolutionMetadata(metadata *Metadata, resolved resolutionResult) { func applyResolutionMetadata(metadata *Metadata, resolved resolutionResult) {
for _, snapshot := range resolved.snapshots { finalPaths := make(map[string]*document.Node)
pointer := snapshot.change.Path.String() indexDocument(resolved.final, Path{}, finalPaths)
snapshotCoverage, snapshotPointers := newResolutionCoverageIndexFromSnapshots(resolved.snapshots)
var deltaCoverage pathPrefixIndex
snapshotIDs := make([]int, len(resolved.deltas))
for id, delta := range resolved.deltas {
if delta.path.valid() {
deltaCoverage.insert(delta.path.String(), id)
}
snapshotIDs[id] = -1
if snapshotID, ok := snapshotCoverage.deepestCoveringID(delta.path); ok {
snapshotIDs[id] = snapshotID
}
}
for snapshotID, snapshot := range resolved.snapshots {
pointer := snapshotPointers[snapshotID]
change := cloneChange(snapshot.change) change := cloneChange(snapshot.change)
if info, ok := metadata.fields[pointer]; ok { if info, ok := metadata.fields[pointer]; ok {
change.Previous = []Origin{cloneOrigin(info.Origin)} change.Previous = []Origin{cloneOrigin(info.Origin)}
@@ -240,52 +300,40 @@ func applyResolutionMetadata(metadata *Metadata, resolved resolutionResult) {
} }
metadata.history[pointer] = append(metadata.history[pointer], change) metadata.history[pointer] = append(metadata.history[pointer], change)
} }
appendResolutionDescendantHistory(metadata, resolved)
for _, delta := range resolved.deltas { appendResolutionDescendantHistory(metadata, resolved.snapshots, snapshotPointers, finalPaths, &deltaCoverage, snapshotIDs)
record := coveringResolutionSnapshot(resolved.snapshots, delta.path) applyResolutionFieldOrigins(metadata.fields, resolved.snapshots, &deltaCoverage, snapshotIDs)
if record == nil { refreshResolutionFields(metadata.fields, finalPaths, resolved.snapshots, snapshotCoverage)
continue
}
for pointer, info := range metadata.fields {
path, err := ParsePath(pointer)
if err != nil || !pathContains(delta.path, path) {
continue
}
info.Origin = cloneOrigin(record.change.Origin)
metadata.fields[pointer] = info
}
}
refreshResolutionFields(metadata.fields, resolved.final, resolved.snapshots)
} }
func appendResolutionDescendantHistory(metadata *Metadata, resolved resolutionResult) { func appendResolutionDescendantHistory(metadata *Metadata, snapshots []resolutionSnapshot, snapshotPointers []string, finalPaths map[string]*document.Node, deltaIndex *pathPrefixIndex, snapshotIDs []int) {
finalPaths := make(map[string]*document.Node) allPointers := make(map[string]struct{}, len(metadata.history)+len(finalPaths))
indexDocument(resolved.final, Path{}, finalPaths)
for _, delta := range resolved.deltas {
record := coveringResolutionSnapshot(resolved.snapshots, delta.path)
if record == nil {
continue
}
affected := make(map[string]bool)
for pointer := range metadata.history { for pointer := range metadata.history {
if path, err := ParsePath(pointer); err == nil && pathContains(delta.path, path) { if _, err := ParsePath(pointer); err == nil {
affected[pointer] = true allPointers[pointer] = struct{}{}
} }
} }
for pointer := range finalPaths { for pointer := range finalPaths {
if path, err := ParsePath(pointer); err == nil && pathContains(delta.path, path) { allPointers[pointer] = struct{}{}
affected[pointer] = true
} }
buckets := make([][]string, len(snapshotIDs))
for pointer := range allPointers {
deltaIndex.visitAncestors(pointer, func(deltaID int) {
snapshotID := snapshotIDs[deltaID]
if snapshotID < 0 || pointer == snapshotPointers[snapshotID] {
return
} }
pointers := make([]string, 0, len(affected)) buckets[deltaID] = append(buckets[deltaID], pointer)
for pointer := range affected { })
if pointer != record.change.Path.String() {
pointers = append(pointers, pointer)
} }
for deltaID, pointers := range buckets {
snapshotID := snapshotIDs[deltaID]
if snapshotID < 0 {
continue
} }
slices.Sort(pointers) slices.Sort(pointers)
for _, pointer := range pointers { for _, pointer := range pointers {
change := cloneChange(record.change) change := cloneChange(snapshots[snapshotID].change)
if info, ok := metadata.fields[pointer]; ok { if info, ok := metadata.fields[pointer]; ok {
change.Previous = []Origin{cloneOrigin(info.Origin)} change.Previous = []Origin{cloneOrigin(info.Origin)}
} }
@@ -294,20 +342,33 @@ func appendResolutionDescendantHistory(metadata *Metadata, resolved resolutionRe
} }
} }
func coveringResolutionSnapshot(snapshots []resolutionSnapshot, path Path) *resolutionSnapshot { func applyResolutionFieldOrigins(fields map[string]FieldInfo, snapshots []resolutionSnapshot, deltaIndex *pathPrefixIndex, snapshotIDs []int) {
var covering *resolutionSnapshot buckets := make([][]string, len(snapshotIDs))
for index := range snapshots { for pointer := range fields {
snapshot := &snapshots[index] if _, err := ParsePath(pointer); err != nil {
if pathContains(snapshot.change.Path, path) && (covering == nil || len(snapshot.change.Path.segments) > len(covering.change.Path.segments)) { continue
covering = snapshot }
deltaIndex.visitAncestors(pointer, func(deltaID int) {
if snapshotIDs[deltaID] >= 0 {
buckets[deltaID] = append(buckets[deltaID], pointer)
}
})
}
for deltaID, pointers := range buckets {
snapshotID := snapshotIDs[deltaID]
if snapshotID < 0 {
continue
}
origin := snapshots[snapshotID].change.Origin
for _, pointer := range pointers {
info := fields[pointer]
info.Origin = cloneOrigin(origin)
fields[pointer] = info
} }
} }
return covering
} }
func refreshResolutionFields(fields map[string]FieldInfo, final *document.Node, snapshots []resolutionSnapshot) { func refreshResolutionFields(fields map[string]FieldInfo, finalPaths map[string]*document.Node, snapshots []resolutionSnapshot, snapshotCoverage *resolutionCoverageIndex) {
finalPaths := make(map[string]*document.Node)
indexDocument(final, Path{}, finalPaths)
for pointer := range fields { for pointer := range fields {
if _, ok := finalPaths[pointer]; !ok { if _, ok := finalPaths[pointer]; !ok {
delete(fields, pointer) delete(fields, pointer)
@@ -320,10 +381,8 @@ func refreshResolutionFields(fields map[string]FieldInfo, final *document.Node,
continue continue
} }
origin := Origin{Layer: LayerResolution} origin := Origin{Layer: LayerResolution}
if path, err := ParsePath(pointer); err == nil { if snapshotID, ok := snapshotCoverage.deepestCoveringPointerID(pointer); ok {
if covering := coveringResolutionSnapshot(snapshots, path); covering != nil { origin = cloneOrigin(snapshots[snapshotID].change.Origin)
origin = cloneOrigin(covering.change.Origin)
}
} }
fields[pointer] = FieldInfo{Null: node.Kind == document.Null, Origin: origin} fields[pointer] = FieldInfo{Null: node.Kind == document.Null, Origin: origin}
} }

View File

@@ -0,0 +1,182 @@
package engine
import (
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
// loadRequest keeps source acquisition separate from precedence assembly.
// The ordinary entry point and grouped entry point share the final lifecycle.
type loadRequest[T any] struct {
main loadInput
underlays []loadGroup
definition Definition[T]
overlay string
overlayFilesystem *FilesystemContext
overrides []Override
ordinary bool
}
type loadInput struct {
source Source
imports bool
filesystem *FilesystemContext
}
type loadGroup struct{ inputs []loadInput }
func loadGroupedWithSecretLookup[T any](request loadRequest[T], secrets SecretBinding, lookup secretLookup) (*Loaded[T], error) {
request.ordinary = false
var diagnostics []Diagnostic
_, mainDiagnostics := selectOptions(request.main.source, []Options[T]{{
Definition: request.definition, Imports: request.main.imports, Filesystem: request.main.filesystem,
}})
diagnostics = append(diagnostics, mainDiagnostics...)
for _, group := range request.underlays {
for _, input := range group.inputs {
_, inputDiagnostics := selectOptions(input.source, []Options[T]{{Imports: input.imports, Filesystem: input.filesystem}})
diagnostics = append(diagnostics, inputDiagnostics...)
}
}
if !validOverlayName(request.overlay) {
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "overlay name must contain only ASCII letters, digits, underscore, or hyphen"))
}
if activeOverlay(request.overlay) {
if request.overlayFilesystem == nil {
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "selected overlay requires explicit filesystem context"))
} else {
diagnostics = append(diagnostics, filesystemOptionDiagnostics(Source{kind: sourceBytes}, request.overlayFilesystem)...)
}
}
if len(diagnostics) != 0 {
return nil, (&diagnosticAccumulator{diagnostics: canonicalDiagnostics(diagnostics)}).loadError()
}
return executeLoadRequest(request, secrets, lookup)
}
func assembleLayeredInput[T any](request loadRequest[T], input assembledInput, accounting *importAccounting) (assembledInput, []Diagnostic) {
groups := make([]*composedDocument, 0, len(request.underlays))
for index, group := range request.underlays {
composition, sources, contexts, diagnostics := acquireUnderlayGroup(group, index+1, request.definition.Version, accounting)
input.sources = append(input.sources, sources...)
mergeResourceContexts(&input.contexts, contexts)
if len(diagnostics) != 0 {
return input, diagnostics
}
if composition != nil {
groups = append(groups, composition)
}
}
var assembly *assemblyAccumulator
for _, group := range groups {
if assembly == nil {
assembly = newAssemblyAccumulator(group)
continue
}
if diagnostics := assembly.applyOverlay(group.root, group.origins, group.applications, LayerUnderlay); len(diagnostics) != 0 {
return input, diagnostics
}
}
if input.document != nil {
mainComposition, diagnostics := compositionForInput(input)
if len(diagnostics) != 0 {
return input, diagnostics
}
if assembly == nil {
assembly = newAssemblyAccumulator(mainComposition)
} else if diagnostics := assembly.applyOverlay(mainComposition.root, mainComposition.origins, mainComposition.applications, LayerBase); len(diagnostics) != 0 {
return input, diagnostics
}
}
if assembly == nil && (activeOverlay(request.overlay) || len(request.overrides) != 0) {
composition, _ := compositionForInput(assembledInput{})
assembly = newAssemblyAccumulator(composition)
}
if assembly == nil {
return input, nil
}
assembly.current.inputBytes = accounting.bytes
assembly.current.inputNodes = accounting.nodes
if activeOverlay(request.overlay) {
// The selected overlay context is independent of the main input. This
// synthetic descriptor creates no source record or input contribution.
selection := Source{kind: sourceBytes}
selected := parsedSource{document: &document.Node{Kind: document.Mapping}, source: &SourceInfo{}}
var diagnostics []Diagnostic
input.sources, diagnostics = loadSelectedOverlayInto(selection, request.overlayFilesystem, selected, assembly, input.sources, request.overlay, request.definition.Version, defaultImportLimits())
if len(diagnostics) != 0 {
return input, diagnostics
}
contexts, diagnostic := contextsForAcquiredSources(selection, request.overlayFilesystem, selected.source, input.sources[len(input.sources)-1:])
if diagnostic != nil {
return input, []Diagnostic{*diagnostic}
}
mergeResourceContexts(&input.contexts, contexts)
}
if len(request.overrides) != 0 {
var diagnostics []Diagnostic
input.sources, diagnostics = assembly.applyOverrides(input.sources, request.overrides, request.definition.Version, defaultImportLimits())
if len(diagnostics) != 0 {
return input, diagnostics
}
}
input.composition = assembly.current
input.document = assembly.current.root
return input, nil
}
func acquireUnderlayGroup(group loadGroup, index int, rule *VersionRule, accounting *importAccounting) (*composedDocument, []SourceInfo, map[string]ResourceContext, []Diagnostic) {
initialBytes, initialNodes := accounting.bytes, accounting.nodes
visited := make(map[resourceIdentity]importRoute)
hostToken := &namespaceToken{}
var documents []*document.Node
var sources []SourceInfo
var contexts map[string]ResourceContext
for _, contribution := range group.inputs {
options := Options[struct{}]{Imports: contribution.imports, Filesystem: contribution.filesystem, Definition: Definition[struct{}]{Version: rule}}
acquired, diagnostics := assembleBaseInputTracked(contribution.source, options, accounting, visited, hostToken)
for _, source := range acquired.sources {
source.Layer = LayerUnderlay
source.UnderlayIndex = index
sources = append(sources, source)
}
mergeResourceContexts(&contexts, acquired.contexts)
if len(diagnostics) != 0 {
return nil, sources, contexts, diagnostics
}
if acquired.document == nil {
continue
}
if node := fragmentVersionClaim(acquired.document, rule); node != nil {
location := publicLocation(node.Location)
return nil, sources, contexts, []Diagnostic{{
Code: CodeUnderlayVersion, Severity: SeverityError, Path: rule.Path.clone(),
Message: "underlay inputs cannot contribute to the schema-version path", Primary: &location,
}}
}
if acquired.composition != nil {
for _, application := range acquired.composition.applications {
documents = append(documents, application.value)
}
} else {
documents = append(documents, acquired.document)
}
}
if len(documents) == 0 {
return nil, sources, contexts, nil
}
composition, diagnostics := composeDocuments(documents...)
if len(diagnostics) != 0 {
return nil, sources, contexts, diagnostics
}
composition.inputBytes = accounting.bytes - initialBytes
composition.inputNodes = accounting.nodes - initialNodes
for pointer, origin := range composition.origins {
origin.Layer = LayerUnderlay
composition.origins[pointer] = origin
}
for index := range composition.applications {
composition.applications[index].layer = LayerUnderlay
}
return composition, sources, contexts, nil
}

View File

@@ -0,0 +1,280 @@
package engine
import (
"reflect"
"strings"
"testing"
"testing/fstest"
)
type groupedConfig struct {
Version string `yaml:"version"`
Settings map[string]string `yaml:"settings"`
Port int `yaml:"port"`
Derived string `yaml:"derived"`
}
func testGroupedLoad[T any](request loadRequest[T]) (*testResult[T], error) {
loaded, err := loadGroupedWithSecretLookup(request, testSecretBinding(), func(string) (string, bool) { return "", false })
return testLoaded(loaded), err
}
func TestGroupedLoadPrecedenceSourceOrderAndSingleLifecycle(t *testing.T) {
filesystem := NewFilesystem("bundle", fstest.MapFS{
"main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: imported.yml}]}\nversion: v1\nport: 5\nsettings: {shared: main}\n")},
"imported.yml": &fstest.MapFile{Data: []byte("settings: {imported: included}\n")},
"overlays/test.yml": &fstest.MapFile{Data: []byte("settings: {shared: overlay}\n")},
})
checks := struct{ version, defaults, resolution, validation int }{}
request := loadRequest[groupedConfig]{
main: loadInput{source: filesystem.File("main.yml"), imports: true},
underlays: []loadGroup{
{inputs: []loadInput{
{source: Mapping("low-a", map[string]any{"settings": map[string]any{"shared": "low", "a": "one"}, "port": "invalid until replaced"})},
{source: Bytes("low-b", []byte("settings: {b: two}\n"))},
}},
{inputs: []loadInput{{source: Mapping("middle", map[string]any{"settings": map[string]any{"shared": "middle", "c": "three"}})}}},
},
definition: Definition[groupedConfig]{
Version: &VersionRule{Path: Keys("version"), Check: func(version Version, validation *Validation) error {
checks.version++
if version.Text != "v1" {
t.Errorf("version = %#v", version)
}
return nil
}},
Defaults: func() groupedConfig { checks.defaults++; return groupedConfig{Derived: "initial"} },
Resolve: func(config *groupedConfig, resolution *Resolution) error {
checks.resolution++
Default(resolution, Keys("derived"), &config.Derived, "resolved", "set derived")
return nil
},
Validate: func(config *groupedConfig, validation *Validation) { checks.validation++ },
},
overlay: "test", overlayFilesystem: &FilesystemContext{Root: ".", BaseDir: ".", FS: &filesystem},
overrides: []Override{{Path: Keys("settings", "shared"), Value: "cli", Flag: "--shared"}},
}
result, err := testGroupedLoad(request)
if err != nil {
t.Fatalf("load: %v, diagnostics %#v", err, err.(*LoadError).Diagnostics())
}
wantSettings := map[string]string{"shared": "cli", "a": "one", "b": "two", "c": "three", "imported": "included"}
if result.Config.Port != 5 || !reflect.DeepEqual(result.Config.Settings, wantSettings) {
t.Fatalf("config = %#v", result.Config)
}
if checks != (struct{ version, defaults, resolution, validation int }{1, 1, 1, 1}) {
t.Fatalf("lifecycle calls = %#v", checks)
}
sources := result.Sources()
if len(sources) != 7 {
t.Fatalf("sources = %#v", sources)
}
wantLayers := []Layer{LayerBase, LayerBase, LayerUnderlay, LayerUnderlay, LayerUnderlay, LayerOverlay, LayerCLI}
wantIndexes := []int{0, 0, 1, 1, 2, 0, 0}
for index, source := range sources {
if source.ID != "source-"+string(rune('1'+index)) || source.Layer != wantLayers[index] || source.UnderlayIndex != wantIndexes[index] {
t.Errorf("source %d = %#v", index, source)
}
}
info, ok := result.Metadata().Lookup(Keys("settings", "shared"))
if !ok || info.Origin.Layer != LayerCLI {
t.Fatalf("effective shared metadata = %#v, %v", info, ok)
}
explanation, ok := result.Explain(Keys("settings", "shared"))
if !ok || len(explanation.History) < 5 {
t.Fatalf("shared explanation = %#v, %v", explanation, ok)
}
var layers []Layer
for _, change := range explanation.History {
if change.Origin.Layer != LayerDefault && (len(layers) == 0 || layers[len(layers)-1] != change.Origin.Layer) {
layers = append(layers, change.Origin.Layer)
}
}
if !reflect.DeepEqual(layers, []Layer{LayerUnderlay, LayerBase, LayerOverlay, LayerCLI}) {
t.Fatalf("history layers = %#v", layers)
}
}
func TestGroupedLoadAbsentMainAndVersionProtection(t *testing.T) {
var observed Version
request := loadRequest[groupedConfig]{
main: loadInput{source: NoSource()},
underlays: []loadGroup{{}, {inputs: []loadInput{{source: Bytes("underlay", []byte("port: 7\n"))}}}},
definition: Definition[groupedConfig]{Version: &VersionRule{Path: Keys("version"), Check: func(version Version, validation *Validation) error {
observed = version
return nil
}}},
}
result, err := testGroupedLoad(request)
if err != nil || result.Config.Port != 7 || observed.Present || len(result.Sources()) != 1 || result.Sources()[0].UnderlayIndex != 2 {
t.Fatalf("underlay-only load = %#v, version %#v, %v", result, observed, err)
}
request.underlays[1].inputs[0].source = Bytes("underlay", []byte("version: forbidden\n"))
_, err = testGroupedLoad(request)
if got := groupedErrorCode(t, err); got != CodeUnderlayVersion {
t.Fatalf("underlay version code = %s", got)
}
filesystem := NewFilesystem("bundle", fstest.MapFS{
"main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: fragment.yml}]}\n")},
"fragment.yml": &fstest.MapFile{Data: []byte("version: forbidden\n")},
})
request.underlays[1].inputs[0] = loadInput{source: filesystem.File("main.yml"), imports: true}
_, err = testGroupedLoad(request)
if got := groupedErrorCode(t, err); got != CodeImportVersion {
t.Fatalf("import version code = %s", got)
}
request.underlays = []loadGroup{{}}
request.definition.Defaults = func() groupedConfig { return groupedConfig{Port: 11} }
result, err = testGroupedLoad(request)
if err != nil || result.Config.Port != 11 || len(result.Sources()) != 0 {
t.Fatalf("defaults-only load = %#v, %v", result, err)
}
}
func TestGroupedLoadStrictConflictsRepetitionAndBudgets(t *testing.T) {
main := loadInput{source: Bytes("main", []byte("port: 5\n"))}
request := loadRequest[groupedConfig]{
main: main,
underlays: []loadGroup{{inputs: []loadInput{
{source: Bytes("left", []byte("port: 1\n"))},
{source: Bytes("right", []byte("port: 2\n"))},
}}},
}
_, err := testGroupedLoad(request)
if got := groupedErrorCode(t, err); got != CodeComposeConflict {
t.Fatalf("strict conflict code = %s", got)
}
conflicting := NewFilesystem("bundle", fstest.MapFS{
"main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: fragment.yml}]}\nport: 1\n")},
"fragment.yml": &fstest.MapFile{Data: []byte("port: 2\n")},
})
request.main = loadInput{source: conflicting.File("main.yml"), imports: true}
request.underlays = []loadGroup{{inputs: []loadInput{{source: Bytes("lower", []byte("port: 3\n"))}}}}
_, err = testGroupedLoad(request)
if got := groupedErrorCode(t, err); got != CodeComposeConflict {
t.Fatalf("main/import conflict code = %s", got)
}
request.main = main
filesystem := NewFilesystem("bundle", fstest.MapFS{"same.yml": &fstest.MapFile{Data: []byte("port: 1\n")}})
file := filesystem.File("same.yml")
request.underlays = []loadGroup{{inputs: []loadInput{{source: file}, {source: file}}}}
_, err = testGroupedLoad(request)
if got := groupedErrorCode(t, err); got != CodeImportRepeated {
t.Fatalf("same-group repetition code = %s", got)
}
request.underlays = []loadGroup{{inputs: []loadInput{{source: file}}}, {inputs: []loadInput{{source: file}}}}
if result, err := testGroupedLoad(request); err != nil || result.Config.Port != 5 || len(result.Sources()) != 3 {
t.Fatalf("cross-group reuse = %#v, %v", result, err)
}
accounting := &importAccounting{sources: maxImportSources - 1}
group := loadGroup{inputs: []loadInput{{source: Mapping("first", map[string]any{})}, {source: Mapping("second", map[string]any{})}}}
_, _, _, diagnostics := acquireUnderlayGroup(group, 1, nil, accounting)
if len(diagnostics) == 0 || diagnostics[0].Code != CodeSourceLimitExceeded {
t.Fatalf("shared source budget = %#v", diagnostics)
}
accounting = &importAccounting{bytes: maxTotalBytes - 2}
group.inputs = []loadInput{{source: Mapping("first", map[string]any{"port": "one"})}}
_, _, _, diagnostics = acquireUnderlayGroup(group, 1, nil, accounting)
if len(diagnostics) == 0 || diagnostics[0].Code != CodeSourceLimitExceeded {
t.Fatalf("shared byte budget = %#v", diagnostics)
}
}
func TestGroupedLoadInvalidIntermediateTypeCanBeReplaced(t *testing.T) {
request := loadRequest[groupedConfig]{
main: loadInput{source: Bytes("main", []byte("port: 9\n"))},
underlays: []loadGroup{{inputs: []loadInput{{source: Bytes("low", []byte("port: wrong\n"))}}}},
}
result, err := testGroupedLoad(request)
if err != nil || result.Config.Port != 9 {
t.Fatalf("final typed decode = %#v, %v", result, err)
}
request.main.source = Bytes("main", []byte("port: wrong\n"))
_, err = testGroupedLoad(request)
if got := groupedErrorCode(t, err); got != CodeDecodeType && got != CodeDecodeRange {
t.Fatalf("final invalid type code = %s", got)
}
}
func groupedErrorCode(t *testing.T, err error) Code {
t.Helper()
loadError, ok := err.(*LoadError)
if !ok || len(loadError.Diagnostics()) == 0 {
t.Fatalf("error = %#v", err)
}
if strings.Contains(loadError.Diagnostics()[0].Message, "wrong") {
t.Fatal("raw value escaped diagnostics")
}
return loadError.Diagnostics()[0].Code
}
func TestGroupedAccountingChargesMixedOccurrencesOnce(t *testing.T) {
const (
one = "configkit: {imports: [{path: child-one.yml}]}\nleft: first\n"
child = "extra: imported\n"
two = "configkit: {imports: [{path: child-two.yml}]}\nright: second\n"
)
filesystem := NewFilesystem("bundle", fstest.MapFS{
"one.yml": &fstest.MapFile{Data: []byte(one)},
"child-one.yml": &fstest.MapFile{Data: []byte(child)},
"two.yml": &fstest.MapFile{Data: []byte(two)},
"child-two.yml": &fstest.MapFile{Data: []byte(child)},
})
accounting := &importAccounting{}
first, sources, _, diagnostics := acquireUnderlayGroup(loadGroup{inputs: []loadInput{{source: filesystem.File("one.yml"), imports: true}}}, 1, nil, accounting)
if len(diagnostics) != 0 || first == nil || len(sources) != 2 || accounting.sources != 2 || accounting.bytes != int64(len(one)+len(child)) {
t.Fatalf("first acquisition = %#v, %#v, %#v, accounting %#v", first, sources, diagnostics, accounting)
}
second, sources, _, diagnostics := acquireUnderlayGroup(loadGroup{inputs: []loadInput{{source: filesystem.File("two.yml"), imports: true}}}, 2, nil, accounting)
if len(diagnostics) != 0 || second == nil || len(sources) != 2 || sources[0].ID != "source-3" || sources[1].ID != "source-4" || accounting.sources != 4 || accounting.bytes != int64(len(one)+len(two)+2*len(child)) {
t.Fatalf("second acquisition = %#v, %#v, %#v, accounting %#v", second, sources, diagnostics, accounting)
}
var wantNodes int
for _, text := range []string{one, child, two, child} {
parsed, diagnostics := parseSource(Bytes("fixture", []byte(text)), "source-1", defaultParseLimits())
if len(diagnostics) != 0 {
t.Fatal(diagnostics)
}
wantNodes += countInputNodes(parsed.document)
}
if accounting.nodes != wantNodes {
t.Fatalf("charged nodes = %d, want %d", accounting.nodes, wantNodes)
}
if first.inputBytes != int64(len(one)+len(child)) || second.inputBytes != int64(len(two)+len(child)) {
t.Fatalf("composition costs = %d, %d, accounting %d", first.inputBytes, second.inputBytes, accounting.bytes)
}
}
func TestGroupedGlobEntryBudgetIsShared(t *testing.T) {
filesystem := NewFilesystem("bundle", fstest.MapFS{
"main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{glob: 'parts/*.yml'}]}\n")},
"parts/a.yml": &fstest.MapFile{Data: []byte("left: one\n")},
"parts/b.yml": &fstest.MapFile{Data: []byte("right: two\n")},
})
accounting := &importAccounting{entries: maxGlobEntries - 1}
_, _, _, diagnostics := acquireUnderlayGroup(loadGroup{inputs: []loadInput{{source: filesystem.File("main.yml"), imports: true}}}, 1, nil, accounting)
if len(diagnostics) == 0 || diagnostics[0].Code != CodeImportLimitExceeded || accounting.entries <= maxGlobEntries {
t.Fatalf("shared glob budget = %#v, accounting %#v", diagnostics, accounting)
}
}
func TestGroupedMixedSourcesShareCLIRecordLimit(t *testing.T) {
filesystem := NewFilesystem("bundle", fstest.MapFS{"main.yml": &fstest.MapFile{Data: []byte("value: base\n")}})
inputs := make([]Input, maxImportSources-1)
inputs[0] = Input{Source: Bytes("empty-bytes", []byte("{}\n"))}
for index := 1; index < len(inputs); index++ {
inputs[index] = Input{Source: Mapping("empty-map", map[string]any{})}
}
type config struct {
Value string `yaml:"value"`
}
_, err := LoadLayered(Input{Source: filesystem.File("main.yml")}, testSecretBinding(), LayeredOptions[config]{
Underlays: []Underlay{{Inputs: inputs}},
Overrides: []Override{{Path: Keys("value"), Value: "cli", Flag: "--value"}},
})
loadError, ok := err.(*LoadError)
if !ok || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != CodeOverrideLimitExceeded || len(loadError.Sources()) != maxImportSources {
t.Fatalf("mixed source/CLI limit = %#v", err)
}
}

View File

@@ -15,6 +15,8 @@ type resolvedLoad[T any] struct {
final *document.Node final *document.Node
snapshots []resolutionSnapshot snapshots []resolutionSnapshot
applications []historyApplication applications []historyApplication
inheritance *inheritanceScope
contexts map[string]ResourceContext
} }
func decodeLoadInput[T any](schema *schemaDescriptor, definition Definition[T], input assembledInput) (decodedConfiguration, []Diagnostic) { func decodeLoadInput[T any](schema *schemaDescriptor, definition Definition[T], input assembledInput) (decodedConfiguration, []Diagnostic) {
@@ -34,7 +36,9 @@ func decodeLoadInput[T any](schema *schemaDescriptor, definition Definition[T],
} }
return decodedConfiguration{}, diagnostics return decodedConfiguration{}, diagnostics
} }
if input.composition != nil { if input.inheritance != nil {
applyCompositionOrigins(&decoded, input.inheritance.origins)
} else if input.composition != nil {
applyCompositionOrigins(&decoded, input.composition.origins) applyCompositionOrigins(&decoded, input.composition.origins)
} }
@@ -45,13 +49,15 @@ func resolveLoadInput[T any](schema *schemaDescriptor, definition Definition[T],
var diagnostics []Diagnostic var diagnostics []Diagnostic
configuration := decoded.value.Interface().(T) configuration := decoded.value.Interface().(T)
metadata := metadataFromDecoded(decoded) metadata := metadataFromDecoded(decoded)
if input.composition != nil { if input.inheritance != nil {
applyInheritanceMetadata(&metadata, input.composition.root, input.composition.origins, input.composition.overlays, input.inheritance)
} else if input.composition != nil {
applyOverlayMetadata(&metadata, input.composition.overlays) applyOverlayMetadata(&metadata, input.composition.overlays)
} }
final := decoded.final final := decoded.final
var resolutionSnapshots []resolutionSnapshot var resolutionSnapshots []resolutionSnapshot
if definition.Resolve != nil { if definition.Resolve != nil {
entry := newResolutionInput(metadata, decoded.final, input.sources) entry := newResolutionInput(metadata, decoded.final, input.sources, input.contexts)
resolved := runResolution(&configuration, definition.Resolve, schema, decoded, input.document, entry) resolved := runResolution(&configuration, definition.Resolve, schema, decoded, input.document, entry)
diagnostics = append(diagnostics, resolved.diagnostics...) diagnostics = append(diagnostics, resolved.diagnostics...)
if hasErrors(resolved.diagnostics) { if hasErrors(resolved.diagnostics) {
@@ -67,7 +73,7 @@ func resolveLoadInput[T any](schema *schemaDescriptor, definition Definition[T],
} else if input.document != nil { } else if input.document != nil {
applications = []historyApplication{{layer: LayerBase, value: input.document}} applications = []historyApplication{{layer: LayerBase, value: input.document}}
} }
return resolvedLoad[T]{configuration: configuration, decoded: decoded, metadata: metadata, final: final, snapshots: resolutionSnapshots, applications: applications}, diagnostics return resolvedLoad[T]{configuration: configuration, decoded: decoded, metadata: metadata, final: final, snapshots: resolutionSnapshots, applications: applications, inheritance: input.inheritance, contexts: input.contexts}, diagnostics
} }
func detachLoadConfiguration[T any](schema *schemaDescriptor, configuration T, mode secretCloneMode) (T, *Diagnostic) { func detachLoadConfiguration[T any](schema *schemaDescriptor, configuration T, mode secretCloneMode) (T, *Diagnostic) {
@@ -92,16 +98,25 @@ func publishLoadResult[T any](schema *schemaDescriptor, definition Definition[T]
final, applications, resolutionSnapshots := resolved.final, resolved.applications, resolved.snapshots final, applications, resolutionSnapshots := resolved.final, resolved.applications, resolved.snapshots
finalizeMetadataPaths(&metadata, final, applications, decoded.baseline, decoded.final) finalizeMetadataPaths(&metadata, final, applications, decoded.baseline, decoded.final)
secrets := classifySecretPaths(schema, definition.Secret, metadata, final, applications, decoded.baseline, decoded.final) secrets := classifySecretPaths(schema, definition.Secret, metadata, final, applications, decoded.baseline, decoded.final)
retainedTrees := make([]*document.Node, 0, len(applications)+2)
retainedTrees = append(retainedTrees, decoded.final, final)
for _, application := range applications {
retainedTrees = append(retainedTrees, application.value)
}
inspection := &Inspection{ inspection := &Inspection{
metadata: metadata, metadata: metadata,
sources: cloneSourceInfos(accumulator.sources), sources: cloneSourceInfos(accumulator.sources),
contexts: cloneResourceContexts(resolved.contexts),
diagnostics: canonicalDiagnostics(accumulator.diagnostics), diagnostics: canonicalDiagnostics(accumulator.diagnostics),
baseline: decoded.baseline, baseline: decoded.baseline,
input: decoded.final, input: decoded.final,
inputOrigins: cloneOriginIndex(decoded.origins), inputOrigins: cloneOriginIndex(decoded.origins),
final: final, final: final,
immutableLookup: buildDocumentLookup(retainedTrees...),
resolution: resolutionSnapshots, resolution: resolutionSnapshots,
resolutionReplay: coalescedResolutionSnapshots(resolutionSnapshots),
applications: applications, applications: applications,
inheritance: inheritanceOperations(resolved.inheritance),
secrets: secrets, secrets: secrets,
secretReferences: cloneSecretReferences(references), secretReferences: cloneSecretReferences(references),
} }

View File

@@ -0,0 +1,37 @@
package engine
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
// mappingIndex keeps ordered document entries and their lookup positions in
// sync while one composition or overlay is assembled.
type mappingIndex struct {
byNode map[*document.Node]map[string]int
}
func (m *mappingIndex) entries(node *document.Node) map[string]int {
if m.byNode == nil {
m.byNode = make(map[*document.Node]map[string]int)
}
if entries, ok := m.byNode[node]; ok {
return entries
}
entries := make(map[string]int, len(node.Mapping))
for index, entry := range node.Mapping {
entries[entry.Key] = index
}
m.byNode[node] = entries
return entries
}
func (m *mappingIndex) find(node *document.Node, key string) int {
if index, ok := m.entries(node)[key]; ok {
return index
}
return -1
}
func (m *mappingIndex) append(node *document.Node, entry document.Entry) {
entries := m.entries(node)
entries[entry.Key] = len(node.Mapping)
node.Mapping = append(node.Mapping, entry)
}

View File

@@ -13,29 +13,43 @@ type Metadata struct {
historicalPaths map[string][]Path historicalPaths map[string][]Path
fields map[string]FieldInfo fields map[string]FieldInfo
history map[string][]Change history map[string][]Change
inheritance []inheritanceOperation
inheritanceIndex *inheritanceHistoryIndex
} }
// Lookup returns information for an effective path. Unknown and invalid paths // Lookup returns information for an effective path. Unknown and invalid paths
// return false. // return false.
func (m Metadata) Lookup(path Path) (FieldInfo, bool) { func (m Metadata) Lookup(path Path) (FieldInfo, bool) {
if !path.valid() || !matchesPathShape(path, m.effectivePaths[path.String()]) { info, ok := m.effectiveField(path)
return FieldInfo{}, false
}
info, ok := m.fields[path.String()]
if !ok { if !ok {
return FieldInfo{}, false return FieldInfo{}, false
} }
return cloneFieldInfo(info), true detached := cloneFieldInfo(info)
detached.Origin.Inheritance = projectInheritanceSteps(detached.Origin)
return detached, true
} }
// IsExplicit reports whether a surviving input contribution explicitly defined // IsExplicit reports whether a surviving input contribution, including one
// path. // inherited from a sibling, explicitly defined path.
func (m Metadata) IsExplicit(path Path) bool { func (m Metadata) IsExplicit(path Path) bool {
info, ok := m.Lookup(path) info, ok := m.effectiveField(path)
return ok && info.Explicit return ok && info.Explicit
} }
// History returns detached definition and resolution records for path. A record's func (m Metadata) effectiveField(path Path) (FieldInfo, bool) {
if !path.valid() {
return FieldInfo{}, false
}
pointer := path.String()
candidate, ok := m.effectivePaths[pointer]
if !ok || !matchesPathShape(path, candidate) {
return FieldInfo{}, false
}
info, ok := m.fields[pointer]
return info, ok
}
// History returns detached definition, inheritance, and resolution records for path. A record's
// Path can be an ancestor when that container operation replaced or removed path. // Path can be an ancestor when that container operation replaced or removed path.
// Removed historical paths can have history even though Lookup returns false. // Removed historical paths can have history even though Lookup returns false.
// Unknown and invalid paths return nil. // Unknown and invalid paths return nil.
@@ -43,19 +57,16 @@ func (m Metadata) History(path Path) []Change {
if !m.hasHistoricalPath(path) { if !m.hasHistoricalPath(path) {
return nil return nil
} }
history, ok := m.history[path.String()] history := m.history[path.String()]
if !ok { operations := m.inheritance
return nil if m.inheritanceIndex != nil {
operations = m.inheritanceIndex.matchingOperations(path, m.inheritance)
} }
cloned := make([]Change, len(history)) return historyWithInheritance(path, history, operations)
for i, change := range history {
cloned[i] = cloneChange(change)
}
return cloned
} }
// HistoricalPaths returns the paths observed during loading, including effective // HistoricalPaths returns the paths observed during loading, including effective
// paths and paths removed by overlays or recorded resolution. It includes default // paths and paths removed by overlays, inheritance, or recorded resolution. It includes default
// paths and the root, but not paths that never existed. The zero Metadata returns // paths and the root, but not paths that never existed. The zero Metadata returns
// an empty slice. // an empty slice.
// //
@@ -83,11 +94,14 @@ func (m Metadata) HistoricalPaths() []Path {
// Textual pointers are lookup keys, but explicit key/index kinds remain part of // Textual pointers are lookup keys, but explicit key/index kinds remain part of
// the query. Candidate paths are constructed from actual document containers. // the query. Candidate paths are constructed from actual document containers.
func matchesPathShape(query, candidate Path) bool { func matchesPathShape(query, candidate Path) bool {
if !query.valid() || query.String() != candidate.String() || len(query.segments) != len(candidate.segments) { if !query.valid() || len(query.segments) != len(candidate.segments) {
return false return false
} }
for i, segment := range query.segments { for i, segment := range query.segments {
actual := candidate.segments[i] actual := candidate.segments[i]
if !pathTokenSpellingEqual(segment, actual) {
return false
}
if actual.kind == pathSegmentIndex { if actual.kind == pathSegmentIndex {
index, ok := schemaIndex(segment) index, ok := schemaIndex(segment)
if !ok || index != actual.index { if !ok || index != actual.index {
@@ -113,10 +127,9 @@ func (m Metadata) hasHistoricalPath(path Path) bool {
} }
func finalizeMetadataPaths(m *Metadata, final *document.Node, applications []historyApplication, earlier ...*document.Node) { func finalizeMetadataPaths(m *Metadata, final *document.Node, applications []historyApplication, earlier ...*document.Node) {
m.effectivePaths = effectivePathIndex(final) m.effectivePaths = make(map[string]Path)
m.historicalPaths = make(map[string][]Path) m.historicalPaths = make(map[string][]Path)
add := func(path Path, _ *document.Node) { addHistorical := func(path Path, pointer string) {
pointer := path.String()
for _, prior := range m.historicalPaths[pointer] { for _, prior := range m.historicalPaths[pointer] {
if matchesPathShape(path, prior) { if matchesPathShape(path, prior) {
return return
@@ -124,7 +137,14 @@ func finalizeMetadataPaths(m *Metadata, final *document.Node, applications []his
} }
m.historicalPaths[pointer] = append(m.historicalPaths[pointer], path.clone()) m.historicalPaths[pointer] = append(m.historicalPaths[pointer], path.clone())
} }
walkDocument(final, Path{}, add) walkDocument(final, Path{}, func(path Path, _ *document.Node) {
pointer := path.String()
m.effectivePaths[pointer] = path.clone()
addHistorical(path, pointer)
})
add := func(path Path, _ *document.Node) {
addHistorical(path, path.String())
}
for _, tree := range earlier { for _, tree := range earlier {
walkDocument(tree, Path{}, add) walkDocument(tree, Path{}, add)
} }
@@ -136,6 +156,17 @@ func finalizeMetadataPaths(m *Metadata, final *document.Node, applications []his
} }
walkDocument(application.value, prefix, add) walkDocument(application.value, prefix, add)
} }
for _, operation := range m.inheritance {
if operation.control != nil {
add(operation.control.path, nil)
continue
}
if operation.transfer != nil {
walkDocument(operation.parent, operation.transfer.to, add)
walkDocument(operation.effective, operation.transfer.to, add)
}
}
m.inheritanceIndex = newInheritanceHistoryIndex(m.inheritance)
} }
// effectivePathIndex records document-derived paths with their contextual segment // effectivePathIndex records document-derived paths with their contextual segment

View File

@@ -47,6 +47,60 @@ func TestMetadataHistoricalPaths(t *testing.T) {
} }
} }
func TestMetadataPathIndexesOwnIndependentSegments(t *testing.T) {
t.Parallel()
final := parseDocumentForDecode(t, "service: {workers: [1]}\n")
var metadata Metadata
finalizeMetadataPaths(&metadata, final, nil)
pointer := "/service/workers/0"
effective := metadata.effectivePaths[pointer]
historical := metadata.historicalPaths[pointer]
if effective.String() != pointer || len(historical) != 1 || historical[0].String() != pointer {
t.Fatalf("path indexes = %#v, %#v", effective, historical)
}
effective.segments[0].key = "effective"
if historical[0].String() != pointer {
t.Fatal("effective path mutation changed historical path")
}
historical[0].segments[2].index = 1
if effective.String() != "/effective/workers/0" {
t.Fatal("historical path mutation changed effective path")
}
}
func TestMetadataPathIndexesRetainDistinctHistoricalShapes(t *testing.T) {
t.Parallel()
final := parseDocumentForDecode(t, "shape: [replacement]\n")
earlier := parseDocumentForDecode(t, "shape: {'0': base}\n")
var metadata Metadata
// Repeated trees must not add duplicate shapes to the historical index.
finalizeMetadataPaths(&metadata, final, nil, earlier, earlier, final)
keyPath := Keys("shape", "0")
indexPath := Keys("shape").Index(0)
pointer := indexPath.String()
if !reflect.DeepEqual(metadata.effectivePaths[pointer], indexPath) {
t.Fatal("effective index does not reflect the final sequence")
}
historical := metadata.historicalPaths[pointer]
if len(historical) != 2 {
t.Fatalf("historical shapes = %#v, want one key and one index", historical)
}
for _, want := range []Path{keyPath, indexPath} {
found := false
for _, path := range historical {
if reflect.DeepEqual(path, want) {
found = true
}
}
if !found || !metadata.hasHistoricalPath(want) {
t.Fatalf("historical index lost shape %#v", want)
}
}
}
func TestHistoricalPathsExposeHiddenBaseOwnershipWithoutResolvingBaseSecrets(t *testing.T) { func TestHistoricalPathsExposeHiddenBaseOwnershipWithoutResolvingBaseSecrets(t *testing.T) {
t.Setenv("CONFIGKIT_HISTORY_UNUSED_PROD_KEY", "") t.Setenv("CONFIGKIT_HISTORY_UNUSED_PROD_KEY", "")
t.Setenv("CONFIGKIT_HISTORY_TEST_KEY", "synthetic-test-key") t.Setenv("CONFIGKIT_HISTORY_TEST_KEY", "synthetic-test-key")
@@ -231,6 +285,75 @@ func TestResolutionInputKeepsDetachedEntryMetadata(t *testing.T) {
} }
} }
func TestMetadataIsExplicitAgreesWithLookup(t *testing.T) {
t.Parallel()
type entry struct {
Kept string `yaml:"kept"`
Set string `yaml:"set"`
Enabled bool `yaml:"enabled"`
Count int `yaml:"count"`
}
type config struct {
Nested entry `yaml:"nested"`
Nullable *string `yaml:"nullable"`
Nodes map[string]entry `yaml:"nodes"`
}
input := []byte(`nested: {set: configured, enabled: false, count: 0}
nullable: null
nodes:
base: {enabled: false, count: 0}
copy: {extends: base}
`)
result, err := testLoad(Bytes("metadata-presence.yml", input), Options[config]{Definition: Definition[config]{
Defaults: func() config {
return config{
Nested: entry{Kept: "default", Set: "default", Enabled: true, Count: 7},
Nodes: map[string]entry{"copy": {Kept: "node default"}},
}
},
}})
if err != nil {
t.Fatal(err)
}
metadata := result.Metadata()
tests := []struct {
name string
path Path
wantExplicit bool
wantNull bool
wantInherited bool
}{
{name: "default", path: Keys("nested", "kept")},
{name: "direct input", path: Keys("nested", "set"), wantExplicit: true},
{name: "explicit false", path: Keys("nested", "enabled"), wantExplicit: true},
{name: "explicit zero", path: Keys("nested", "count"), wantExplicit: true},
{name: "explicit null", path: Keys("nullable"), wantExplicit: true, wantNull: true},
{name: "inherited input", path: Keys("nodes", "copy", "enabled"), wantExplicit: true, wantInherited: true},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
info, found := metadata.Lookup(test.path)
if !found {
t.Fatalf("Lookup(%s) did not find an effective path", test.path)
}
if got := metadata.IsExplicit(test.path); got != info.Explicit || got != test.wantExplicit {
t.Fatalf("IsExplicit(%s) = %v, Lookup explicit = %v, want %v", test.path, got, info.Explicit, test.wantExplicit)
}
if info.Null != test.wantNull {
t.Fatalf("Lookup(%s).Null = %v, want %v", test.path, info.Null, test.wantNull)
}
if inherited := len(info.Origin.Inheritance) != 0; inherited != test.wantInherited {
t.Fatalf("Lookup(%s) inherited = %v, want %v", test.path, inherited, test.wantInherited)
}
})
}
if result.Config.Nested.Enabled || result.Config.Nested.Count != 0 || result.Config.Nullable != nil {
t.Fatalf("explicit values changed during loading: %#v", result.Config)
}
}
func TestQueriesRespectActualHistoricalAndEffectiveContainers(t *testing.T) { func TestQueriesRespectActualHistoricalAndEffectiveContainers(t *testing.T) {
result, err := testLoad[map[string]any](Bytes("base", []byte("items: [1]\nm: {'0': 2}\nx: 3\nretired: {child: 4}\n")), Options[map[string]any]{Overrides: []Override{{Path: Keys("retired"), Value: "removed", Flag: "--retired"}}}) result, err := testLoad[map[string]any](Bytes("base", []byte("items: [1]\nm: {'0': 2}\nx: 3\nretired: {child: 4}\n")), Options[map[string]any]{Overrides: []Override{{Path: Keys("retired"), Value: "removed", Flag: "--retired"}}})
if err != nil { if err != nil {

View File

@@ -0,0 +1,284 @@
package engine
import (
"errors"
"io/fs"
"reflect"
"strings"
"testing"
"testing/fstest"
)
func TestNativeFilesystemImportsAndOverlay(t *testing.T) {
filesystem := fstest.MapFS{
"config/main.yml": &fstest.MapFile{Data: []byte(`configkit:
imports:
- path: ../shared.yml
- glob: fragments/*.yml
items: {main: main}
`)},
"shared.yml": &fstest.MapFile{Data: []byte("items: {shared: shared}\n")},
"config/fragments/b.yml": &fstest.MapFile{Data: []byte("items: {beta: beta}\n")},
"config/fragments/a.yml": &fstest.MapFile{Data: []byte("items: {alpha: alpha}\n")},
"overlays/testing.yml": &fstest.MapFile{Data: []byte("items: {main: overlaid}\n")},
}
namespace := newNativeNamespace("bundle", filesystem)
result, err := testLoad[importTestConfig](filesystemFile(namespace, "config/main.yml"), Options[importTestConfig]{Imports: true, Overlay: "testing"})
if err != nil {
t.Fatal(err)
}
want := map[string]string{"main": "overlaid", "shared": "shared", "alpha": "alpha", "beta": "beta"}
if !reflect.DeepEqual(result.Config.Items, want) {
t.Fatalf("items = %#v, want %#v", result.Config.Items, want)
}
sources := result.Sources()
wantPaths := []string{"config/main.yml", "shared.yml", "config/fragments/a.yml", "config/fragments/b.yml", "overlays/testing.yml"}
gotPaths := make([]string, len(sources))
for index, source := range sources {
gotPaths[index] = source.Path
}
if !reflect.DeepEqual(gotPaths, wantPaths) {
t.Fatalf("source paths = %#v, want %#v", gotPaths, wantPaths)
}
}
func TestNativeFilesystemNamespaceAndPathFailures(t *testing.T) {
first := fstest.MapFS{"main.yml": &fstest.MapFile{Data: []byte("items: {name: first}\n")}}
second := fstest.MapFS{"main.yml": &fstest.MapFile{Data: []byte("items: {name: second}\n")}}
firstNamespace := newNativeNamespace("same", first)
secondNamespace := newNativeNamespace("same", second)
if reflect.TypeOf(first).Comparable() {
t.Fatal("test filesystem must have a non-comparable dynamic value")
}
if firstNamespace.token == secondNamespace.token {
t.Fatal("independent filesystem constructors shared identity")
}
if newNativeAccess(first, ".", firstNamespace.token).identity("main.yml") == newNativeAccess(second, ".", secondNamespace.token).identity("main.yml") {
t.Fatal("equal paths from separate filesystems have the same identity")
}
access := newNativeAccess(first, ".", firstNamespace.token)
if access.syntheticIdentity() == access.syntheticIdentity() || access.syntheticIdentity() == access.identity("<selected-source>") {
t.Fatal("independent synthetic sources shared a file identity")
}
for _, test := range []struct {
name string
namespace *nativeNamespace
want string
}{
{name: "first", namespace: firstNamespace, want: "first"},
{name: "second", namespace: secondNamespace, want: "second"},
} {
t.Run(test.name, func(t *testing.T) {
result, err := testLoad[importTestConfig](filesystemFile(test.namespace, "main.yml"))
if err != nil || result.Config.Items["name"] != test.want {
t.Fatalf("result = %#v, %v", result, err)
}
})
}
for _, name := range []string{".", "../main.yml", "/main.yml", "missing.yml"} {
_, diagnostics := readSource(filesystemFile(firstNamespace, name), "source-1", defaultSourceLimits())
if len(diagnostics) != 1 {
t.Errorf("%q diagnostics = %#v", name, diagnostics)
}
}
missingImport := fstest.MapFS{"main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: host-only.yml}]}\n")}}
_, err := testLoad[importTestConfig](filesystemFile(newNativeNamespace("bundle", missingImport), "main.yml"), Options[importTestConfig]{Imports: true})
if err == nil {
t.Fatal("missing virtual import fell back to the host filesystem")
}
loadError, ok := err.(*LoadError)
if !ok || loadError.Diagnostics()[0].Code != CodeImportMissing {
t.Fatalf("missing import error = %#v", err)
}
}
func TestNativeFilesystemClosesFilesOnSuccessAndFailure(t *testing.T) {
for _, fallback := range []bool{false, true} {
t.Run(map[bool]string{false: "ReadDirFile", true: "ReadDir fallback"}[fallback], func(t *testing.T) {
filesystem := &observedFilesystem{files: fstest.MapFS{
"main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{glob: 'parts/*.yml'}]}\nitems: {main: main}\n")},
"parts/one.yml": &fstest.MapFile{Data: []byte("items: {one: one}\n")},
}, fallback: fallback}
result, err := testLoad[importTestConfig](filesystemFile(newNativeNamespace("bundle", filesystem), "main.yml"), Options[importTestConfig]{Imports: true})
if err != nil || result.Config.Items["one"] != "one" {
t.Fatalf("result = %#v, %v", result, err)
}
filesystem.assertClosed(t)
})
}
for _, failure := range []struct {
name string
file string
failRead string
failStat string
failDir string
}{
{name: "read", file: "items: {one: one}\n", failRead: "parts/one.yml"},
{name: "stat", file: "items: {one: one}\n", failStat: "parts/one.yml"},
{name: "parse", file: "items: [unterminated\n"},
{name: "directory", file: "items: {one: one}\n", failDir: "parts"},
} {
t.Run(failure.name, func(t *testing.T) {
filesystem := &observedFilesystem{files: fstest.MapFS{
"main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{glob: 'parts/*.yml'}]}\n")},
"parts/one.yml": &fstest.MapFile{Data: []byte(failure.file)},
}, failRead: failure.failRead, failStat: failure.failStat, failDir: failure.failDir}
_, err := testLoad[importTestConfig](filesystemFile(newNativeNamespace("bundle", filesystem), "main.yml"), Options[importTestConfig]{Imports: true})
if err == nil {
t.Fatal("expected a load failure")
}
if strings.Contains(err.Error(), "tracking failure canary") {
t.Fatal("unsafe filesystem error escaped")
}
filesystem.assertClosed(t)
})
}
}
func TestNativeFilesystemRootAndOptionalImports(t *testing.T) {
filesystem := fstest.MapFS{
"config/sub/main.yml": &fstest.MapFile{Data: []byte(`configkit:
imports:
- path: ../shared.yml
- path: absent.yml
optional: true
- glob: absent/*.yml
optional: true
`)},
"config/shared.yml": &fstest.MapFile{Data: []byte("items: {shared: retained}\n")},
"outside.yml": &fstest.MapFile{Data: []byte("items: {outside: blocked}\n")},
}
namespace := newNativeNamespace("bundle", filesystem)
source := filesystemFile(namespace, "config/sub/main.yml")
parsed, diagnostics := parseSource(source, "source-1", defaultParseLimits())
if len(diagnostics) != 0 {
t.Fatal(diagnostics)
}
access := newNativeAccess(filesystem, "config", namespace.token)
composed, sources, diagnostics := loadImportedWithAccess(access, "config/sub", source.path, parsed, nil, defaultImportLimits(), &importAccounting{})
if len(diagnostics) != 0 || composed == nil || len(sources) != 2 || sources[1].Path != "config/shared.yml" {
t.Fatalf("contained imports = (%#v, %#v, %#v)", composed, sources, diagnostics)
}
outside := fstest.MapFS{
"config/sub/main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: ../../outside.yml}]}\n")},
"outside.yml": &fstest.MapFile{Data: []byte("items: {outside: blocked}\n")},
}
outsideNamespace := newNativeNamespace("bundle", outside)
parsed, diagnostics = parseSource(filesystemFile(outsideNamespace, "config/sub/main.yml"), "source-1", defaultParseLimits())
if len(diagnostics) != 0 {
t.Fatal(diagnostics)
}
_, _, diagnostics = loadImportedWithAccess(newNativeAccess(outside, "config", outsideNamespace.token), "config/sub", "config/sub/main.yml", parsed, nil, defaultImportLimits(), &importAccounting{})
if len(diagnostics) != 1 || diagnostics[0].Code != CodeImportOutsideRoot {
t.Fatalf("escaping import diagnostics = %#v", diagnostics)
}
}
func TestNativeFilesystemDetectsRepeatedAndCyclicImports(t *testing.T) {
for _, test := range []struct {
name string
files fstest.MapFS
code Code
}{
{
name: "repeated",
files: fstest.MapFS{
"main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: child.yml}, {path: child.yml}]}\n")},
"child.yml": &fstest.MapFile{Data: []byte("items: {child: child}\n")},
},
code: CodeImportRepeated,
},
{
name: "cycle",
files: fstest.MapFS{
"main.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: child.yml}]}\n")},
"child.yml": &fstest.MapFile{Data: []byte("configkit: {imports: [{path: main.yml}]}\n")},
},
code: CodeImportCycle,
},
} {
t.Run(test.name, func(t *testing.T) {
_, err := testLoad[importTestConfig](filesystemFile(newNativeNamespace("bundle", test.files), "main.yml"), Options[importTestConfig]{Imports: true})
loadError, ok := err.(*LoadError)
if !ok || len(loadError.Diagnostics()) == 0 || loadError.Diagnostics()[0].Code != test.code {
t.Fatalf("load error = %#v, want %q", err, test.code)
}
})
}
}
type observedFilesystem struct {
files fstest.MapFS
opened int
closed int
fallback bool
failRead string
failStat string
failDir string
}
func (f *observedFilesystem) Open(name string) (fs.File, error) {
inner, err := f.files.Open(name)
if err != nil {
return nil, err
}
f.opened++
file := &observedFile{File: inner, owner: f, name: name}
if directory, ok := inner.(fs.ReadDirFile); ok && !f.fallback {
return &observedDirectoryFile{observedFile: file, directory: directory}, nil
}
return file, nil
}
func (f *observedFilesystem) ReadDir(name string) ([]fs.DirEntry, error) {
if name == f.failDir {
return nil, errors.New("tracking failure canary")
}
return fs.ReadDir(f.files, name)
}
func (f *observedFilesystem) assertClosed(t *testing.T) {
t.Helper()
if f.opened == 0 || f.opened != f.closed {
t.Fatalf("filesystem opens=%d closes=%d", f.opened, f.closed)
}
}
type observedFile struct {
fs.File
owner *observedFilesystem
name string
}
func (f *observedFile) Read(data []byte) (int, error) {
if f.name == f.owner.failRead {
return 0, errors.New("tracking failure canary")
}
return f.File.Read(data)
}
func (f *observedFile) Stat() (fs.FileInfo, error) {
if f.name == f.owner.failStat {
return nil, errors.New("tracking failure canary")
}
return f.File.Stat()
}
func (f *observedFile) Close() error {
f.owner.closed++
return f.File.Close()
}
type observedDirectoryFile struct {
*observedFile
directory fs.ReadDirFile
}
func (f *observedDirectoryFile) ReadDir(count int) ([]fs.DirEntry, error) {
if f.name == f.owner.failDir {
return nil, errors.New("tracking failure canary")
}
return f.directory.ReadDir(count)
}

View File

@@ -22,8 +22,8 @@ func selectOptions[T any](source Source, supplied []Options[T]) (Options[T], []D
if source.kind == sourceNone && options.Overlay != "" && options.Overlay != "default" { if source.kind == sourceNone && options.Overlay != "" && options.Overlay != "default" {
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "NoSource cannot select an active overlay")) diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "NoSource cannot select an active overlay"))
} }
if options.Filesystem == nil && (source.kind == sourceBytes || source.kind == sourceReader) && (options.Imports || activeOverlay(options.Overlay)) { if options.Filesystem == nil && (source.kind == sourceBytes || source.kind == sourceReader || source.kind == sourceMapping) && (options.Imports || activeOverlay(options.Overlay)) {
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "byte and reader imports or overlays require filesystem context")) diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "synthetic-source imports or overlays require filesystem context"))
} }
if definition.Version != nil { if definition.Version != nil {
if !definition.Version.Path.valid() { if !definition.Version.Path.valid() {

View File

@@ -18,78 +18,105 @@ type displacedDefinition struct {
} }
func overlayDocuments(base *composedDocument, overlay *document.Node) (*composedDocument, []Diagnostic) { func overlayDocuments(base *composedDocument, overlay *document.Node) (*composedDocument, []Diagnostic) {
if base == nil || base.root == nil || base.root.Kind != document.Mapping || overlay == nil || overlay.Kind != document.Mapping { if base == nil {
return nil, []Diagnostic{{ return nil, invalidOverlayDocument()
}
assembly := newAssemblyAccumulator(cloneComposedDocument(base))
if diagnostics := assembly.applyOverlay(overlay, nil, nil, LayerOverlay); len(diagnostics) != 0 {
return nil, diagnostics
}
return assembly.current, nil
}
func invalidOverlayDocument() []Diagnostic {
return []Diagnostic{{
Code: CodeOverlayInvalid, Severity: SeverityError, Code: CodeOverlayInvalid, Severity: SeverityError,
Message: "overlay requires mapping document roots", Message: "overlay requires mapping document roots",
}} }}
}
result := cloneComposedDocument(base)
mergeOverlayOrigin(result.origins, Path{}, overlay.Location, LayerOverlay)
mergeOverlayMappings(result, result.root, overlay, Path{}, LayerOverlay, true)
result.applications = append(result.applications, historyApplication{layer: LayerOverlay, value: overlay})
return result, nil
} }
func mergeOverlayMappings(result *composedDocument, target, overlay *document.Node, current Path, layer Layer, recordReplacements bool) { func (a *assemblyAccumulator) applyOverlay(overlay *document.Node, incomingOrigins map[string]Origin, applications []historyApplication, layer Layer) []Diagnostic {
for _, incoming := range overlay.Mapping { if a == nil || a.current == nil || a.current.root == nil || a.current.root.Kind != document.Mapping || overlay == nil || overlay.Kind != document.Mapping {
path := current.Key(incoming.Key) return invalidOverlayDocument()
index := documentEntryIndex(target.Mapping, incoming.Key)
if index < 0 {
cloned := incoming
cloned.Value = document.Clone(incoming.Value)
target.Mapping = append(target.Mapping, cloned)
collectOverlayOrigins(result.origins, incoming.Value, path, layer)
continue
} }
mergeOriginContribution(a.current.origins, Path{}, incomingOrigin(incomingOrigins, Path{}, overlay, layer))
existing := target.Mapping[index].Value mergeOverlayMappings(a.current, a.current.root, overlay, Path{}, layer, true, incomingOrigins, &a.index)
if existing.Kind == document.Mapping && incoming.Value.Kind == document.Mapping { if len(applications) == 0 {
mergeOverlayOrigin(result.origins, path, incoming.Value.Location, layer) a.current.applications = append(a.current.applications, historyApplication{layer: layer, value: overlay})
mergeOverlayMappings(result, existing, incoming.Value, path, layer, recordReplacements) } else {
continue for _, application := range applications {
application.layer = layer
a.current.applications = append(a.current.applications, application)
} }
}
return nil
}
func mergeOverlayMappings(result *composedDocument, target, overlay *document.Node, current Path, layer Layer, recordReplacements bool, incomingOrigins map[string]Origin, index *mappingIndex) {
mergeRecursiveMappings(target, overlay, current, index, recursiveMergeHooks{
added: func(path Path, incoming *document.Node) {
collectOverlayOriginsFrom(result.origins, incomingOrigins, incoming, path, layer)
},
combined: func(path Path, incoming *document.Node) {
mergeOriginContribution(result.origins, path, incomingOrigin(incomingOrigins, path, incoming, layer))
},
replaced: func(path Path, existing, incoming *document.Node) {
displaced := displacedOrigins(existing, result.origins, path) displaced := displacedOrigins(existing, result.origins, path)
previous := cloneOrigin(result.origins[path.String()]) previous := cloneOrigin(result.origins[path.String()])
deleteOriginSubtree(result.origins, path) deleteOriginSubtree(result.origins, path, existing)
target.Mapping[index] = incoming collectOverlayOriginsFrom(result.origins, incomingOrigins, incoming, path, layer)
target.Mapping[index].Value = document.Clone(incoming.Value)
collectOverlayOrigins(result.origins, incoming.Value, path, layer)
if recordReplacements { if recordReplacements {
result.overlays = append(result.overlays, overlayRecord{ result.overlays = append(result.overlays, overlayRecord{
change: Change{ change: Change{
Path: path.clone(), Origin: overlayOrigin(layer, incoming.Value.Location), Path: path.clone(), Origin: incomingOrigin(incomingOrigins, path, incoming, layer),
Previous: previousOrigins(previous), Previous: previousOrigins(previous),
}, },
displaced: displaced, displaced: displaced,
}) })
} }
} },
})
} }
func collectOverlayOrigins(origins map[string]Origin, node *document.Node, path Path, layer Layer) { func collectOverlayOrigins(origins map[string]Origin, node *document.Node, path Path, layer Layer) {
origins[path.String()] = overlayOrigin(layer, node.Location) collectOverlayOriginsFrom(origins, nil, node, path, layer)
}
func collectOverlayOriginsFrom(origins, incomingOrigins map[string]Origin, node *document.Node, path Path, layer Layer) {
origins[path.String()] = incomingOrigin(incomingOrigins, path, node, layer)
switch node.Kind { switch node.Kind {
case document.Mapping: case document.Mapping:
for _, entry := range node.Mapping { for _, entry := range node.Mapping {
collectOverlayOrigins(origins, entry.Value, path.Key(entry.Key), layer) collectOverlayOriginsFrom(origins, incomingOrigins, entry.Value, path.Key(entry.Key), layer)
} }
case document.Sequence: case document.Sequence:
for index, child := range node.Sequence { for index, child := range node.Sequence {
collectOverlayOrigins(origins, child, path.Index(index), layer) collectOverlayOriginsFrom(origins, incomingOrigins, child, path.Index(index), layer)
} }
} }
} }
func mergeOverlayOrigin(origins map[string]Origin, path Path, location document.Location, layer Layer) { func mergeOverlayOrigin(origins map[string]Origin, path Path, location document.Location, layer Layer) {
mergeOriginContribution(origins, path, overlayOrigin(layer, location))
}
func mergeOriginContribution(origins map[string]Origin, path Path, incoming Origin) {
pointer := path.String() pointer := path.String()
origin := cloneOrigin(origins[pointer]) origin := origins[pointer]
origin.Layer = layer origin.Layer = incoming.Layer
origin.Locations = append(origin.Locations, publicLocation(location)) origin.Locations = append(origin.Locations, incoming.Locations...)
origins[pointer] = origin origins[pointer] = origin
} }
func incomingOrigin(origins map[string]Origin, path Path, node *document.Node, layer Layer) Origin {
if origin, ok := origins[path.String()]; ok {
origin = cloneOrigin(origin)
origin.Layer = layer
return origin
}
return overlayOrigin(layer, node.Location)
}
func overlayOrigin(layer Layer, location document.Location) Origin { func overlayOrigin(layer Layer, location document.Location) Origin {
return Origin{Layer: layer, Locations: []Location{publicLocation(location)}} return Origin{Layer: layer, Locations: []Location{publicLocation(location)}}
} }
@@ -104,9 +131,24 @@ func previousOrigins(origin Origin) []Origin {
func displacedOrigins(node *document.Node, origins map[string]Origin, path Path) []displacedDefinition { func displacedOrigins(node *document.Node, origins map[string]Origin, path Path) []displacedDefinition {
var displaced []displacedDefinition var displaced []displacedDefinition
collectDisplacedOrigins(&displaced, node, origins, path, Origin{}) collectDisplacedOrigins(&displaced, node, origins, path, Origin{})
slices.SortFunc(displaced, func(left, right displacedDefinition) int { return sortDisplacedDefinitions(displaced)
return strings.Compare(left.path.String(), right.path.String()) }
func sortDisplacedDefinitions(displaced []displacedDefinition) []displacedDefinition {
type orderedDefinition struct {
definition displacedDefinition
pointer string
}
ordered := make([]orderedDefinition, len(displaced))
for i, definition := range displaced {
ordered[i] = orderedDefinition{definition: definition, pointer: definition.path.String()}
}
slices.SortFunc(ordered, func(left, right orderedDefinition) int {
return strings.Compare(left.pointer, right.pointer)
}) })
for i, item := range ordered {
displaced[i] = item.definition
}
return displaced return displaced
} }
@@ -129,11 +171,16 @@ func collectDisplacedOrigins(displaced *[]displacedDefinition, node *document.No
} }
} }
func deleteOriginSubtree(origins map[string]Origin, path Path) { func deleteOriginSubtree(origins map[string]Origin, path Path, node *document.Node) {
prefix := path.String() delete(origins, path.String())
for pointer := range origins { switch node.Kind {
if pointer == prefix || strings.HasPrefix(pointer, prefix+"/") { case document.Mapping:
delete(origins, pointer) for _, entry := range node.Mapping {
deleteOriginSubtree(origins, path.Key(entry.Key), entry.Value)
}
case document.Sequence:
for index, child := range node.Sequence {
deleteOriginSubtree(origins, path.Index(index), child)
} }
} }
} }

View File

@@ -76,3 +76,67 @@ to_scalar: scalar
t.Fatal("overlay result aliases an input document") t.Fatal("overlay result aliases an input document")
} }
} }
func TestOwnedOverlayKeepsCompositeContributorsAndAcquiredInputs(t *testing.T) {
baseInput := parseCompositionDocument(t, "source-1", "catalog:\n base: true\n")
base, diagnostics := composeDocuments(baseInput)
if len(diagnostics) != 0 {
t.Fatal(diagnostics)
}
first := parseCompositionDocument(t, "source-2", "catalog:\n first: true\n")
second := parseCompositionDocument(t, "source-3", "catalog:\n second: true\n")
firstBefore, secondBefore := document.Clone(first), document.Clone(second)
incoming, diagnostics := composeDocuments(first, second)
if len(diagnostics) != 0 {
t.Fatal(diagnostics)
}
assembly := newAssemblyAccumulator(base)
if diagnostics := assembly.applyOverlay(incoming.root, incoming.origins, incoming.applications, LayerOverlay); len(diagnostics) != 0 {
t.Fatal(diagnostics)
}
for _, key := range []string{"base", "first", "second"} {
if _, ok := documentAtPath(assembly.current.root, Keys("catalog", key)); !ok {
t.Fatalf("catalog is missing %q", key)
}
}
origin := assembly.current.origins[Keys("catalog").String()]
if got, want := origin.Locations, []Location{{SourceID: "source-1", Line: 2, Column: 3}, {SourceID: "source-2", Line: 2, Column: 3}, {SourceID: "source-3", Line: 2, Column: 3}}; !reflect.DeepEqual(got, want) {
t.Fatalf("catalog origin locations = %#v, want %#v", got, want)
}
if len(assembly.current.applications) != 3 {
t.Fatalf("history applications = %d, want three source contributions", len(assembly.current.applications))
}
for _, application := range assembly.current.applications[1:] {
if application.layer != LayerOverlay {
t.Fatalf("incoming application layer = %q", application.layer)
}
}
assembly.current.root.Mapping[0].Value.Mapping[1].Value.Text = "false"
if !reflect.DeepEqual(first, firstBefore) || !reflect.DeepEqual(second, secondBefore) ||
!reflect.DeepEqual(first, assembly.current.applications[1].value) || !reflect.DeepEqual(second, assembly.current.applications[2].value) {
t.Fatal("working tree mutation changed an acquired input")
}
}
func TestDisplacedOriginsSortEscapedPointers(t *testing.T) {
input := parseCompositionDocument(t, "source-1", `values:
z: old
a/b: slash
a~b: tilde
nested: {x: old}
`)
composed, diagnostics := composeDocuments(input)
if len(diagnostics) != 0 {
t.Fatal(diagnostics)
}
displaced := displacedOrigins(composed.root, composed.origins, Path{})
want := []string{"", "/values", "/values/a~0b", "/values/a~1b", "/values/nested", "/values/nested/x", "/values/z"}
if len(displaced) != len(want) {
t.Fatalf("displaced definitions = %d, want %d", len(displaced), len(want))
}
for i, definition := range displaced {
if got := definition.path.String(); got != want[i] {
t.Fatalf("displaced[%d] path = %q, want %q", i, got, want[i])
}
}
}

View File

@@ -44,18 +44,22 @@ func prepareOverrides(base *composedDocument, sources []SourceInfo, overrides []
allSources := cloneSourceInfos(sources) allSources := cloneSourceInfos(sources)
for index, override := range overrides { for index, override := range overrides {
sourceID := "source-" + strconv.Itoa(len(sources)+index+1) sourceID := "source-" + strconv.Itoa(len(sources)+index+1)
info := SourceInfo{ID: sourceID, Label: override.Flag, Layer: LayerCLI} info := SourceInfo{ID: sourceID, Label: override.Flag, Layer: LayerCLI, Kind: SourceKindCLI}
allSources = append(allSources, info) allSources = append(allSources, info)
prepared[index].source = info prepared[index].source = info
} }
seen := make(map[string]int, len(overrides)) seen := make(map[string]int, len(overrides))
pathAccepted := make([]bool, len(overrides)) var acceptedPaths pathPrefixIndex
var baseLookup *documentLookup
if len(overrides) != 0 {
baseLookup = buildDocumentLookup(base.root)
}
var usedBytes int64 var usedBytes int64
usedNodes := 0 usedNodes := 0
for index, override := range overrides { for index, override := range overrides {
location := &Location{SourceID: prepared[index].source.ID} location := &Location{SourceID: prepared[index].source.ID}
if issue := validateOverridePath(override.Path, base.root); issue != nil { if issue := validateOverridePath(override.Path, base.root, baseLookup); issue != nil {
issue.Primary = location issue.Primary = location
diagnostics = append(diagnostics, *issue) diagnostics = append(diagnostics, *issue)
continue continue
@@ -70,7 +74,7 @@ func prepareOverrides(base *composedDocument, sources []SourceInfo, overrides []
continue continue
} }
seen[pointer] = index seen[pointer] = index
if conflicting := overlappingOverride(overrides[:index], pathAccepted[:index], override.Path); conflicting >= 0 { if conflicting, overlaps := acceptedPaths.earliestIntersectingID(pointer); overlaps {
diagnostics = append(diagnostics, Diagnostic{ diagnostics = append(diagnostics, Diagnostic{
Code: CodeOverrideOverlap, Severity: SeverityError, Path: override.Path.clone(), Code: CodeOverrideOverlap, Severity: SeverityError, Path: override.Path.clone(),
Message: "configuration override paths overlap", Primary: location, Message: "configuration override paths overlap", Primary: location,
@@ -92,13 +96,14 @@ func prepareOverrides(base *composedDocument, sources []SourceInfo, overrides []
}) })
continue continue
} }
pathAccepted[index] = true acceptedPaths.insert(pointer, index)
builder := overrideValueBuilder{sourceID: prepared[index].source.ID, active: make(map[valueVisit]bool)} builder := valueBuilder{sourceID: prepared[index].source.ID, active: make(map[valueVisit]bool)}
value, issue := builder.build(override.Value, override.Path, len(override.Path.segments)) value, issue := builder.build(override.Value, override.Path, len(override.Path.segments))
if issue != nil { if issue != nil {
issue.Primary = location diagnostic := issue.overrideDiagnostic()
diagnostics = append(diagnostics, *issue) diagnostic.Primary = location
diagnostics = append(diagnostics, diagnostic)
continue continue
} }
if relative, ok := overrideDirectivePath(value, Path{}); ok { if relative, ok := overrideDirectivePath(value, Path{}); ok {
@@ -149,7 +154,7 @@ func validOverrideLabel(label string) bool {
return true return true
} }
func validateOverridePath(path Path, root *document.Node) *Diagnostic { func validateOverridePath(path Path, root *document.Node, lookup *documentLookup) *Diagnostic {
if !path.valid() || len(path.segments) == 0 { if !path.valid() || len(path.segments) == 0 {
return &Diagnostic{Code: CodeOverrideInvalidPath, Severity: SeverityError, Path: path.clone(), Message: "configuration override path must be a valid non-root mapping path"} return &Diagnostic{Code: CodeOverrideInvalidPath, Severity: SeverityError, Path: path.clone(), Message: "configuration override path must be a valid non-root mapping path"}
} }
@@ -164,7 +169,7 @@ func validateOverridePath(path Path, root *document.Node) *Diagnostic {
if current.Kind != document.Mapping { if current.Kind != document.Mapping {
return &Diagnostic{Code: CodeOverrideInvalidPath, Severity: SeverityError, Path: path.clone(), Message: "configuration override path traverses a non-mapping value"} return &Diagnostic{Code: CodeOverrideInvalidPath, Severity: SeverityError, Path: path.clone(), Message: "configuration override path traverses a non-mapping value"}
} }
position := documentEntryIndex(current.Mapping, segment.key) position := lookup.find(current, segment.key)
if index == len(path.segments)-1 { if index == len(path.segments)-1 {
continue continue
} }
@@ -177,15 +182,6 @@ func validateOverridePath(path Path, root *document.Node) *Diagnostic {
return nil return nil
} }
func overlappingOverride(previous []Override, valid []bool, path Path) int {
for index, override := range previous {
if valid[index] && (pathContains(override.Path, path) || pathContains(path, override.Path)) {
return index
}
}
return -1
}
func pathContainsDirective(path Path) bool { func pathContainsDirective(path Path) bool {
for _, segment := range path.segments { for _, segment := range path.segments {
if segment.key == directiveKey { if segment.key == directiveKey {

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