71 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
73 changed files with 5101 additions and 1076 deletions

View File

@@ -20,7 +20,11 @@ secrets-directory bootstrap is available as a separate startup operation. Explic
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. Built-in detached structured records and deterministic redacted output. Built-in
`extends` relationships let a mapping inherit from a named sibling during the `extends` relationships let a mapping inherit from a named sibling during the
same load, retaining explicit presence and source/derivation history. same load, retaining explicit presence and source/derivation history. The root
`Diff` API compares frozen effective values from two successful same-type loads;
the [configuration contract](docs/config.md#effective-configuration-diffs) and
[consumer guide](docs/consumers/README.md#effective-configuration-diffs) describe
its safe changed-region records and maintained workflows.
```go ```go
type Config struct { type Config struct {

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

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

View File

@@ -18,6 +18,7 @@ sanitized shapes using only ConfigKit and the Go standard library:
| Bundled file | Simple native `fs.FS` source through ordinary `Load` | Selection and lifetime of the application-owned embedded filesystem | | 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 | | 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 | | 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,
@@ -51,6 +52,25 @@ 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 ## Named sibling inheritance
[`LoadPresets`](../../examples/consumers/inheritance.go) loads the maintained [`LoadPresets`](../../examples/consumers/inheritance.go) loads the maintained
@@ -168,6 +188,7 @@ focused behavioral owner:
| `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 | | 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` | | 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
@@ -93,9 +95,27 @@ 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
@@ -125,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
@@ -150,12 +169,13 @@ code changes. Use the component overview to find the corresponding implementatio
| Paths, metadata, provenance | [Paths](../internal/engine/path_test.go), [metadata](../internal/engine/metadata_test.go), [provenance](../internal/engine/provenance_test.go) | `go test ./internal/engine -run 'Path\|Metadata\|Provenance\|Queries'` | | Paths, metadata, provenance | [Paths](../internal/engine/path_test.go), [metadata](../internal/engine/metadata_test.go), [provenance](../internal/engine/provenance_test.go) | `go test ./internal/engine -run 'Path\|Metadata\|Provenance\|Queries'` |
| Passive resource contexts | [Engine contexts](../internal/engine/resource_context_test.go), [public contexts](../facade_resource_context_test.go) | `go test ./internal/engine -run 'Resource'` and `go test . -run 'SourceContext'` | | Passive resource contexts | [Engine contexts](../internal/engine/resource_context_test.go), [public contexts](../facade_resource_context_test.go) | `go test ./internal/engine -run 'Resource'` and `go test . -run 'SourceContext'` |
| Secrets, bootstrap, redaction, explanation | [References](../internal/engine/secret_reference_test.go), [bootstrap](../internal/engine/secrets_test.go), [rendering](../internal/engine/render_test.go), [explanation](../internal/engine/explain_test.go) | `go test ./internal/engine -run 'Secret\|Bootstrap\|Effective\|Explain\|Explanation\|History\|Sensitivity'` | | Secrets, bootstrap, redaction, explanation | [References](../internal/engine/secret_reference_test.go), [bootstrap](../internal/engine/secrets_test.go), [rendering](../internal/engine/render_test.go), [explanation](../internal/engine/explain_test.go) | `go test ./internal/engine -run 'Secret\|Bootstrap\|Effective\|Explain\|Explanation\|History\|Sensitivity'` |
| Effective configuration diff | [Engine comparison and masking tests](../internal/engine/diff_test.go), [engine operation tests](../internal/engine/diff_operation_test.go), [root facade tests](../facade_diff_test.go), and [consumer examples](../examples/consumers/diff_test.go) | `go test ./internal/engine -run 'Diff|Explain|History|Secret|Independent'`, `go test . -run 'Diff|FacadeInspection|PublicFacadeBoundary|DependencyImportBoundary'`, `go test ./examples/consumers`, and `go test . -run '^ExampleDiff$'` |
| 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'` | | 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` | | 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` | | 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

@@ -12,7 +12,8 @@ Implemented component owners:
sibling inheritance before typed decoding. It also owns schema sibling inheritance before typed decoding. It also owns schema
inspection, lifecycle coordination, callback auditing, inspection, lifecycle coordination, callback auditing,
provenance/history, passive source-context retention, secret resolution and provenance/history, passive source-context retention, secret resolution and
bootstrap, and safe inspection rendering. It never imports the root package. bootstrap, safe inspection rendering, and effective-value comparison over
frozen inspection snapshots. It never imports the root package.
- Root `Source`, `Filesystem`, `Input`, `Underlay`, `Path`, `Secret`, records, - Root `Source`, `Filesystem`, `Input`, `Underlay`, `Path`, `Secret`, records,
lifecycle callbacks, results, and bootstrap reports are root-defined wrappers lifecycle callbacks, results, and bootstrap reports are root-defined wrappers
or records. Callback wrappers pass only live engine context during a callback; or records. Callback wrappers pass only live engine context during a callback;
@@ -21,6 +22,9 @@ Implemented component owners:
- 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, inheritance, decoding, - Engine mechanism tests own source, composition, inheritance, decoding,
@@ -37,9 +41,11 @@ 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. [ADR and [release procedure](../release.md) for source-candidate gates. [ADR
0009](../adr/0009-sibling-inheritance.md) records why inheritance is a built-in 0009](../adr/0009-sibling-inheritance.md) records why inheritance is a built-in

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

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

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

@@ -23,12 +23,35 @@ The following remain deliberately excluded:
- 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 Built-in sibling inheritance using `extends` is implemented as described in the
[configuration contract](../config.md#sibling-inheritance). It is distinct from [configuration contract](../config.md#sibling-inheritance). It is distinct from
inheritance between selected overlay files, which remains excluded above. 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"`

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

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

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

@@ -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)
} }
@@ -53,7 +53,7 @@ func (r *Inspection) Explain(path Path) (Explanation, bool) {
} }
replayIndex := new(mappingIndex) 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)
@@ -69,7 +69,7 @@ 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) appendInheritanceExplanation(&explanation, &working, path, r.inheritance, r.secrets)
@@ -78,20 +78,20 @@ func (r *Inspection) Explain(path Path) (Explanation, bool) {
// 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

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

@@ -65,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
} }
@@ -80,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
} }
@@ -91,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)
} }
@@ -100,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
@@ -179,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
} }

View File

@@ -1,6 +1,57 @@
package engine package engine
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document" 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 // Retain source history at original paths. Structural operations are held once
// per relationship and projected onto a requested destination path only when // per relationship and projected onto a requested destination path only when

View File

@@ -2,6 +2,7 @@ package engine
import ( import (
"bytes" "bytes"
"reflect"
"strings" "strings"
"testing" "testing"
@@ -311,6 +312,51 @@ func TestInheritanceHistoryRetainsControlsAndDisplacedPaths(t *testing.T) {
} }
} }
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) { func TestInheritanceHistoryPrecedesApplicationResolution(t *testing.T) {
inspection, _ := inheritanceInspectionForTest(t, inspection, _ := inheritanceInspectionForTest(t,
parseCompositionDocument(t, "source-1", "parent: {value: original}\nchild: {extends: parent}\n")) parseCompositionDocument(t, "source-1", "parent: {value: original}\nchild: {extends: parent}\n"))
@@ -324,6 +370,7 @@ func TestInheritanceHistoryPrecedesApplicationResolution(t *testing.T) {
replaceHistoryPath(&final, path, replacement) replaceHistoryPath(&final, path, replacement)
inspection.final = final inspection.final = final
inspection.resolution = []resolutionSnapshot{snapshot} inspection.resolution = []resolutionSnapshot{snapshot}
inspection.resolutionReplay = coalescedResolutionSnapshots(inspection.resolution)
inspection.metadata.history[path.String()] = append(inspection.metadata.history[path.String()], snapshot.change) 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} inspection.metadata.fields[path.String()] = FieldInfo{Explicit: true, Origin: snapshot.change.Origin}
history := inspection.Metadata().History(path) history := inspection.Metadata().History(path)

View File

@@ -3,14 +3,28 @@ package engine
import "gitea.maximumdirect.net/eric/ConfigKit/internal/document" import "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
func containsInheritanceDirective(root *document.Node) bool { func containsInheritanceDirective(root *document.Node) bool {
found := false var visit func(*document.Node) bool
walkDocument(root, Path{}, func(path Path, _ *document.Node) { visit = func(node *document.Node) bool {
if len(path.segments) != 0 && path.segments[len(path.segments)-1].kind == pathSegmentKey && if node == nil {
path.segments[len(path.segments)-1].key == inheritanceKey { return false
found = true
} }
}) switch node.Kind {
return found 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 { func inheritanceVersionDiagnostic(rule *VersionRule, expanded *inheritanceScope) *Diagnostic {

View File

@@ -2,6 +2,7 @@ package engine
import ( import (
"slices" "slices"
"strconv"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document" "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
) )
@@ -74,10 +75,44 @@ func copyInheritanceSubtree(target, incoming map[string]Origin, node *document.N
}) })
} }
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 // Materialize concrete steps only for an inspection record. The linked private
// traces remain compact when one parent supplies many descendant values. // traces remain compact when one parent supplies many descendant values.
func projectInheritanceSteps(origin Origin) []InheritanceStep { func projectInheritanceSteps(origin Origin) []InheritanceStep {
steps := cloneInheritanceSteps(origin.Inheritance) 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 { for _, contributor := range origin.contributors {
var reverse []*inheritanceTrace var reverse []*inheritanceTrace
for link := contributor.trace; link != nil; link = link.previous { for link := contributor.trace; link != nil; link = link.previous {
@@ -91,17 +126,13 @@ func projectInheritanceSteps(origin Origin) []InheritanceStep {
continue continue
} }
next := appendInheritancePath(transfer.to, relative) next := appendInheritancePath(transfer.to, relative)
step := InheritanceStep{ 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(), From: current.clone(), To: next.clone(), Reference: transfer.reference.clone(),
Locations: cloneLocations(transfer.locations), 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 current = next
} }

View File

@@ -2,6 +2,8 @@ package engine
import ( import (
"io/fs" "io/fs"
"reflect"
"slices"
"testing" "testing"
"testing/fstest" "testing/fstest"
@@ -85,6 +87,165 @@ func TestInheritedOriginsKeepSourcePathsPresenceAndIndependentReplacement(t *tes
} }
} }
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) { func TestInheritedOriginsRetainChainsAndNestedDestinations(t *testing.T) {
first := parseCompositionDocument(t, "source-1", `first: first := parseCompositionDocument(t, "source-1", `first:
enabled: false enabled: false

View File

@@ -121,6 +121,24 @@ func TestSiblingInheritanceRejectsInvalidRelationships(t *testing.T) {
} }
} }
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) { func TestSiblingInheritanceDetectsDeepCycleWithoutRecursiveDependencyWalk(t *testing.T) {
const count = 2048 const count = 2048
entries := make([]document.Entry, count) entries := make([]document.Entry, count)

View File

@@ -84,7 +84,9 @@ type Inspection struct {
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 inheritance []inheritanceOperation
secrets map[string]bool secrets map[string]bool

View File

@@ -157,15 +157,12 @@ func originRepresentedByChange(path Path, origin Origin, change Change) bool {
// A subtree replacement already records its descendants' contributions. // A subtree replacement already records its descendants' contributions.
// Their coordinates differ from the container's, but their sources must be // Their coordinates differ from the container's, but their sources must be
// covered by the operation; matching only the precedence role is insufficient. // covered by the operation; matching only the precedence role is insufficient.
for _, location := range origin.Locations { recordedSources := make(map[string]struct{}, len(change.Origin.Locations))
found := false
for _, recorded := range change.Origin.Locations { for _, recorded := range change.Origin.Locations {
if location.SourceID == recorded.SourceID { recordedSources[recorded.SourceID] = struct{}{}
found = true
break
} }
} for _, location := range origin.Locations {
if !found { if _, found := recordedSources[location.SourceID]; !found {
return false return false
} }
} }
@@ -189,15 +186,12 @@ 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 {
found := false if _, found := availableLocations[location]; !found {
for _, available := range container.Locations {
if location == available {
found = true
break
}
}
if !found {
return false return false
} }
} }
@@ -257,6 +251,7 @@ func cloneMetadata(metadata Metadata) 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...), 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 {
@@ -280,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)}
@@ -290,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)}
} }
@@ -344,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)
@@ -370,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

@@ -98,6 +98,11 @@ 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),
@@ -107,7 +112,9 @@ func publishLoadResult[T any](schema *schemaDescriptor, definition Definition[T]
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), inheritance: inheritanceOperations(resolved.inheritance),
secrets: secrets, secrets: secrets,

View File

@@ -14,15 +14,13 @@ type Metadata struct {
fields map[string]FieldInfo fields map[string]FieldInfo
history map[string][]Change history map[string][]Change
inheritance []inheritanceOperation 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
} }
@@ -34,10 +32,23 @@ func (m Metadata) Lookup(path Path) (FieldInfo, bool) {
// IsExplicit reports whether a surviving input contribution, including one // IsExplicit reports whether a surviving input contribution, including one
// inherited from a sibling, explicitly defined 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
} }
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 // 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.
@@ -47,7 +58,11 @@ func (m Metadata) History(path Path) []Change {
return nil return nil
} }
history := m.history[path.String()] history := m.history[path.String()]
return historyWithInheritance(path, history, m.inheritance) operations := m.inheritance
if m.inheritanceIndex != nil {
operations = m.inheritanceIndex.matchingOperations(path, m.inheritance)
}
return historyWithInheritance(path, history, operations)
} }
// HistoricalPaths returns the paths observed during loading, including effective // HistoricalPaths returns the paths observed during loading, including effective
@@ -79,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 {
@@ -109,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
@@ -120,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)
} }
@@ -142,6 +166,7 @@ func finalizeMetadataPaths(m *Metadata, final *document.Node, applications []his
walkDocument(operation.effective, 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

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

View File

@@ -117,3 +117,26 @@ func TestOwnedOverlayKeepsCompositeContributorsAndAcquiredInputs(t *testing.T) {
t.Fatal("working tree mutation changed an acquired input") 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

@@ -50,12 +50,16 @@ func prepareOverrides(base *composedDocument, sources []SourceInfo, overrides []
} }
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,7 +96,7 @@ func prepareOverrides(base *composedDocument, sources []SourceInfo, overrides []
}) })
continue continue
} }
pathAccepted[index] = true acceptedPaths.insert(pointer, index)
builder := valueBuilder{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))
@@ -150,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"}
} }
@@ -165,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
} }
@@ -178,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 {

View File

@@ -1,9 +1,6 @@
package engine package engine
import ( import (
"slices"
"strings"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document" "gitea.maximumdirect.net/eric/ConfigKit/internal/document"
) )
@@ -86,8 +83,17 @@ func applyPreparedOverride(result *composedDocument, override preparedOverride,
} }
func affectedOverrideOrigins(existing, incoming *document.Node, origins map[string]Origin, path Path, index *mappingIndex) []displacedDefinition { func affectedOverrideOrigins(existing, incoming *document.Node, origins map[string]Origin, path Path, index *mappingIndex) []displacedDefinition {
displaced := []displacedDefinition{{path: path.clone(), origin: cloneOrigin(origins[path.String()])}} var displaced []displacedDefinition
if existing.Kind == document.Mapping && incoming.Kind == document.Mapping { collectAffectedOverrideOrigins(&displaced, existing, incoming, origins, path, index)
return sortDisplacedDefinitions(displaced)
}
func collectAffectedOverrideOrigins(displaced *[]displacedDefinition, existing, incoming *document.Node, origins map[string]Origin, path Path, index *mappingIndex) {
if existing.Kind != document.Mapping || incoming.Kind != document.Mapping {
collectDisplacedOrigins(displaced, existing, origins, path, Origin{})
return
}
*displaced = append(*displaced, displacedDefinition{path: path.clone(), origin: cloneOrigin(origins[path.String()])})
for _, entry := range incoming.Mapping { for _, entry := range incoming.Mapping {
position := index.find(existing, entry.Key) position := index.find(existing, entry.Key)
if position < 0 { if position < 0 {
@@ -96,16 +102,9 @@ func affectedOverrideOrigins(existing, incoming *document.Node, origins map[stri
childPath := path.Key(entry.Key) childPath := path.Key(entry.Key)
childExisting := existing.Mapping[position].Value childExisting := existing.Mapping[position].Value
if childExisting.Kind == document.Mapping && entry.Value.Kind == document.Mapping { if childExisting.Kind == document.Mapping && entry.Value.Kind == document.Mapping {
displaced = append(displaced, affectedOverrideOrigins(childExisting, entry.Value, origins, childPath, index)...) collectAffectedOverrideOrigins(displaced, childExisting, entry.Value, origins, childPath, index)
} else { } else {
displaced = append(displaced, displacedOrigins(childExisting, origins, childPath)...) collectDisplacedOrigins(displaced, childExisting, origins, childPath, Origin{})
} }
} }
} else {
displaced = displacedOrigins(existing, origins, path)
}
slices.SortFunc(displaced, func(left, right displacedDefinition) int {
return strings.Compare(left.path.String(), right.path.String())
})
return displaced
} }

View File

@@ -206,6 +206,132 @@ func TestOverrideValidationRejectsPathsOverlapValuesAndProtectedTargets(t *testi
} }
} }
func TestOverrideOverlapIndexPreservesFirstAcceptedSource(t *testing.T) {
type config struct {
Nested map[string]any `yaml:"nested"`
}
base := Bytes("override-overlap.yml", []byte("nested: {}\n"))
_, err := testLoad(base, Options[config]{Overrides: []Override{
{Path: Keys("nested"), Value: map[string]any{}, Flag: "--nested"},
{Path: Keys("nested", "first"), Value: "one", Flag: "--first"},
{Path: Keys("nested", "second"), Value: "two", Flag: "--second"},
}})
if err == nil {
t.Fatal("load succeeded with overlapping overrides")
}
var loadErr *LoadError
if !errors.As(err, &loadErr) {
t.Fatalf("error = %T %v, want *LoadError", err, err)
}
diagnostics := loadErr.Diagnostics()
var conflicts []Diagnostic
for _, diagnostic := range diagnostics {
if diagnostic.Code == CodeOverrideOverlap {
conflicts = append(conflicts, diagnostic)
}
}
if len(conflicts) != 2 {
t.Fatalf("overlap diagnostics = %#v, want two conflicts", diagnostics)
}
for index, path := range []string{"/nested/first", "/nested/second"} {
if conflicts[index].Path.String() != path || len(conflicts[index].Related) != 1 || conflicts[index].Related[0].SourceID != "source-2" {
t.Fatalf("conflict[%d] = %#v, want path %q related to first CLI source", index, conflicts[index], path)
}
}
}
func TestOverrideOverlapIndexIncludesAcceptedPathWithInvalidValue(t *testing.T) {
type config struct {
Nested map[string]any `yaml:"nested"`
}
base := Bytes("override-value-overlap.yml", []byte("nested: {}\n"))
_, err := testLoad(base, Options[config]{Overrides: []Override{
{Path: Keys("nested"), Value: time.Second, Flag: "--nested"},
{Path: Keys("nested", "child"), Value: "value", Flag: "--child"},
}})
if err == nil {
t.Fatal("load succeeded with an invalid value and overlapping path")
}
var loadErr *LoadError
if !errors.As(err, &loadErr) {
t.Fatalf("error = %T %v, want *LoadError", err, err)
}
var invalidValue, overlap bool
for _, diagnostic := range loadErr.Diagnostics() {
switch diagnostic.Code {
case CodeOverrideInvalidValue:
invalidValue = diagnostic.Path.String() == "/nested"
case CodeOverrideOverlap:
overlap = diagnostic.Path.String() == "/nested/child" && len(diagnostic.Related) == 1 && diagnostic.Related[0].SourceID == "source-2"
}
}
if !invalidValue || !overlap {
t.Fatalf("diagnostics = %#v, want invalid parent value and child overlap related to its source", loadErr.Diagnostics())
}
}
func TestRejectedOverridePathDoesNotCauseOverlap(t *testing.T) {
type config struct {
Scalar string `yaml:"scalar"`
}
base := Bytes("override-rejected-overlap.yml", []byte("scalar: value\n"))
_, err := testLoad(base, Options[config]{Overrides: []Override{
{Path: Keys("scalar", "child"), Value: "invalid", Flag: "--child"},
{Path: Keys("scalar"), Value: "valid", Flag: "--scalar"},
}})
if err == nil {
t.Fatal("load succeeded with a path traversing a scalar")
}
var loadErr *LoadError
if !errors.As(err, &loadErr) {
t.Fatalf("error = %T %v, want *LoadError", err, err)
}
diagnostics := loadErr.Diagnostics()
if len(diagnostics) != 1 || diagnostics[0].Code != CodeOverrideInvalidPath || diagnostics[0].Path.String() != "/scalar/child" {
t.Fatalf("diagnostics = %#v, want only the invalid descendant path", diagnostics)
}
}
func TestVersionRejectedOverridePathDoesNotCauseOverlap(t *testing.T) {
type config struct {
Nested map[string]any `yaml:"nested"`
}
base := Bytes("override-version-overlap.yml", []byte("nested: {}\n"))
version := &VersionRule{Path: Keys("nested", "locked"), Check: func(Version, *Validation) error { return nil }}
_, err := testLoad(base, Options[config]{
Definition: Definition[config]{Version: version},
Overrides: []Override{
{Path: Keys("nested"), Value: map[string]any{}, Flag: "--nested"},
{Path: Keys("nested", "other"), Value: "allowed", Flag: "--other"},
},
})
if err == nil {
t.Fatal("load succeeded despite an override of a schema-version ancestor")
}
var loadErr *LoadError
if !errors.As(err, &loadErr) {
t.Fatalf("error = %T %v, want *LoadError", err, err)
}
diagnostics := loadErr.Diagnostics()
if len(diagnostics) != 1 || diagnostics[0].Code != CodeOverrideVersion || diagnostics[0].Path.String() != "/nested" {
t.Fatalf("diagnostics = %#v, want only the protected ancestor path", diagnostics)
}
}
func TestEscapedOverrideKeysStayDistinctFromMultipleSegments(t *testing.T) {
type config struct {
Nested map[string]any `yaml:"nested"`
}
base := Bytes("override-escaped-keys.yml", []byte("nested: {}\n"))
_, err := testLoad(base, Options[config]{Overrides: []Override{
{Path: Keys("nested", "a/b"), Value: "single key", Flag: "--single"},
{Path: Keys("nested", "a", "b"), Value: "nested key", Flag: "--nested"},
}})
if err != nil {
t.Fatalf("load rejected distinct escaped path tokens: %v", err)
}
}
func TestOverrideCannotReplaceSchemaVersionAncestor(t *testing.T) { func TestOverrideCannotReplaceSchemaVersionAncestor(t *testing.T) {
type schemaConfig struct { type schemaConfig struct {
Version string `yaml:"version"` Version string `yaml:"version"`
@@ -271,6 +397,43 @@ func TestOverrideEqualReplacementStillAppearsInHistory(t *testing.T) {
} }
} }
func TestAffectedOverrideOriginsSortEscapedPointers(t *testing.T) {
existingInput := parseCompositionDocument(t, "source-1", `values:
z: old
a/b: slash
a~b: tilde
nested: {x: old}
`)
existing, diagnostics := composeDocuments(existingInput)
if len(diagnostics) != 0 {
t.Fatal(diagnostics)
}
incoming := parseCompositionDocument(t, "source-2", `values:
z: new
a/b: new
a~b: new
nested: {x: new}
`)
existingValues, ok := documentAtPath(existing.root, Keys("values"))
if !ok {
t.Fatal("existing values mapping is missing")
}
incomingValues, ok := documentAtPath(incoming, Keys("values"))
if !ok {
t.Fatal("incoming values mapping is missing")
}
displaced := affectedOverrideOrigins(existingValues, incomingValues, existing.origins, Keys("values"), &mappingIndex{})
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])
}
}
}
func TestOverrideAggregateLimitsFailBeforeApplication(t *testing.T) { func TestOverrideAggregateLimitsFailBeforeApplication(t *testing.T) {
base, diagnostics := composeDocuments(&document.Node{Kind: document.Mapping}) base, diagnostics := composeDocuments(&document.Node{Kind: document.Mapping})
if len(diagnostics) != 0 { if len(diagnostics) != 0 {

View File

@@ -86,7 +86,20 @@ func (p Path) String() string {
return "" return ""
} }
// Include each separator and the extra byte introduced by escaping a token.
// Use stack storage for sizing indexes without allocating another string.
size := len(p.segments)
var indexText [20]byte
for _, segment := range p.segments {
if segment.kind == pathSegmentIndex {
size += len(strconv.AppendInt(indexText[:0], int64(segment.index), 10))
} else {
size += len(segment.key) + strings.Count(segment.key, "~") + strings.Count(segment.key, "/")
}
}
var b strings.Builder var b strings.Builder
b.Grow(size)
for _, segment := range p.segments { for _, segment := range p.segments {
b.WriteByte('/') b.WriteByte('/')
if segment.kind == pathSegmentIndex { if segment.kind == pathSegmentIndex {
@@ -115,6 +128,33 @@ func clonePathSegments(segments []pathSegment, extra int) []pathSegment {
return cloned return cloned
} }
func pathTokenSpellingEqual(left, right pathSegment) bool {
switch {
case left.kind == pathSegmentIndex && right.kind == pathSegmentIndex:
return left.index == right.index
case left.kind == pathSegmentIndex:
return pathIndexSpellingEqual(left.index, right.key)
case right.kind == pathSegmentIndex:
return pathIndexSpellingEqual(right.index, left.key)
default:
return left.key == right.key
}
}
func pathIndexSpellingEqual(index int, token string) bool {
var scratch [20]byte
spelling := strconv.AppendInt(scratch[:0], int64(index), 10)
if len(spelling) != len(token) {
return false
}
for i, character := range spelling {
if character != token[i] {
return false
}
}
return true
}
func escapePathToken(token string) string { func escapePathToken(token string) string {
token = strings.ReplaceAll(token, "~", "~0") token = strings.ReplaceAll(token, "~", "~0")
return strings.ReplaceAll(token, "/", "~1") return strings.ReplaceAll(token, "/", "~1")

View File

@@ -0,0 +1,138 @@
package engine
type pathPrefixIndex struct {
root pathPrefixNode
}
type pathPrefixNode struct {
children map[string]*pathPrefixNode
ids []int
minimumID int
hasMinimum bool
}
func (node *pathPrefixNode) includeID(id int) {
if !node.hasMinimum || id < node.minimumID {
node.minimumID = id
node.hasMinimum = true
}
}
func (index *pathPrefixIndex) insert(pointer string, id int) {
node := &index.root
node.includeID(id)
visitCanonicalPointerTokens(pointer, func(token string) bool {
if node.children == nil {
node.children = make(map[string]*pathPrefixNode)
}
child := node.children[token]
if child == nil {
child = &pathPrefixNode{}
node.children[token] = child
}
node = child
node.includeID(id)
return true
})
node.ids = append(node.ids, id)
}
func (index *pathPrefixIndex) visitAncestors(pointer string, visit func(int)) {
node := &index.root
for _, id := range node.ids {
visit(id)
}
visitCanonicalPointerTokens(pointer, func(token string) bool {
child := node.children[token]
if child == nil {
return false
}
node = child
for _, id := range node.ids {
visit(id)
}
return true
})
}
func (index *pathPrefixIndex) visitDescendants(pointer string, visit func(int)) {
root := index.nodeAt(pointer)
if root == nil {
return
}
var walk func(*pathPrefixNode)
walk = func(node *pathPrefixNode) {
for _, id := range node.ids {
visit(id)
}
for _, child := range node.children {
walk(child)
}
}
walk(root)
}
func (index *pathPrefixIndex) earliestIntersectingID(pointer string) (int, bool) {
bestID, found := 0, false
consider := func(id int) {
if !found || id < bestID {
bestID = id
found = true
}
}
node := &index.root
for _, id := range node.ids {
consider(id)
}
complete := visitCanonicalPointerTokens(pointer, func(token string) bool {
child := node.children[token]
if child == nil {
return false
}
node = child
for _, id := range node.ids {
consider(id)
}
return true
})
if complete && node.hasMinimum {
consider(node.minimumID)
}
return bestID, found
}
func (index *pathPrefixIndex) nodeAt(pointer string) *pathPrefixNode {
node := &index.root
complete := visitCanonicalPointerTokens(pointer, func(token string) bool {
child := node.children[token]
if child == nil {
return false
}
node = child
return true
})
if !complete {
return nil
}
return node
}
func visitCanonicalPointerTokens(pointer string, visit func(string) bool) bool {
if pointer == "" {
return true
}
start := 1
for {
end := start
for end < len(pointer) && pointer[end] != '/' {
end++
}
if !visit(pointer[start:end]) {
return false
}
if end == len(pointer) {
return true
}
start = end + 1
}
}

View File

@@ -0,0 +1,81 @@
package engine
import (
"slices"
"strings"
"testing"
)
func TestPathPrefixIndexMatchesPointerScans(t *testing.T) {
entries := []struct {
pointer string
id int
}{
{pointer: "", id: 12},
{pointer: "/", id: 9},
{pointer: "/a", id: 6},
{pointer: "/a", id: 2},
{pointer: "/a/b", id: 4},
{pointer: "/a/b/c", id: 0},
{pointer: "/a/bc", id: 10},
{pointer: "/a~1b", id: 3},
{pointer: "/a~0b", id: 1},
{pointer: "/a~01b", id: 5},
{pointer: "/items/0", id: 8},
{pointer: "/items/01", id: 7},
{pointer: "/items/+1", id: 11},
}
var index pathPrefixIndex
for _, entry := range entries {
index.insert(entry.pointer, entry.id)
}
queries := []string{
"", "/", "/a", "/a/b", "/a/b/c", "/a/b/c/d", "/a/bc", "/a/bc/d",
"/a~1b", "/a~0b", "/a~01b", "/items", "/items/0", "/items/01", "/items/1", "/missing",
}
for _, query := range queries {
gotAncestors := make([]int, 0)
index.visitAncestors(query, func(id int) { gotAncestors = append(gotAncestors, id) })
wantAncestors := make([]int, 0)
gotDescendants := make([]int, 0)
wantDescendants := make([]int, 0)
wantEarliest, foundEarliest := 0, false
for _, entry := range entries {
if pointerContainsByString(entry.pointer, query) {
wantAncestors = append(wantAncestors, entry.id)
}
if pointerContainsByString(query, entry.pointer) {
wantDescendants = append(wantDescendants, entry.id)
}
if pointerContainsByString(entry.pointer, query) || pointerContainsByString(query, entry.pointer) {
if !foundEarliest || entry.id < wantEarliest {
wantEarliest, foundEarliest = entry.id, true
}
}
}
index.visitDescendants(query, func(id int) { gotDescendants = append(gotDescendants, id) })
if !slices.Equal(sortedPrefixIDs(gotAncestors), sortedPrefixIDs(wantAncestors)) {
t.Errorf("ancestors(%q) = %v, want %v", query, sortedPrefixIDs(gotAncestors), sortedPrefixIDs(wantAncestors))
}
if !slices.Equal(sortedPrefixIDs(gotDescendants), sortedPrefixIDs(wantDescendants)) {
t.Errorf("descendants(%q) = %v, want %v", query, sortedPrefixIDs(gotDescendants), sortedPrefixIDs(wantDescendants))
}
gotEarliest, gotFound := index.earliestIntersectingID(query)
if gotEarliest != wantEarliest || gotFound != foundEarliest {
t.Errorf("earliestIntersectingID(%q) = %d, %v, want %d, %v", query, gotEarliest, gotFound, wantEarliest, foundEarliest)
}
}
}
func pointerContainsByString(container, descendant string) bool {
if container == "" {
return true
}
return descendant == container || strings.HasPrefix(descendant, container+"/")
}
func sortedPrefixIDs(ids []int) []int {
result := slices.Clone(ids)
slices.Sort(result)
return result
}

View File

@@ -1,6 +1,9 @@
package engine package engine
import "testing" import (
"strconv"
"testing"
)
func TestPathRoundTripsSemanticKeys(t *testing.T) { func TestPathRoundTripsSemanticKeys(t *testing.T) {
t.Parallel() t.Parallel()
@@ -36,6 +39,73 @@ func TestPathRoundTripsSemanticKeys(t *testing.T) {
if got := Keys("items").Index(12).Key("name").String(); got != "/items/12/name" { if got := Keys("items").Index(12).Key("name").String(); got != "/items/12/name" {
t.Fatalf("indexed path = %q", got) t.Fatalf("indexed path = %q", got)
} }
maximum := int(^uint(0) >> 1)
for _, index := range []int{100, maximum, -1, -maximum - 1} {
want := "/items/" + strconv.Itoa(index)
if got := Keys("items").Index(index).String(); got != want {
t.Fatalf("indexed path = %q, want %q", got, want)
}
}
}
func TestPathContainmentMatchesPointerSpelling(t *testing.T) {
parse := func(pointer string) Path {
path, err := ParsePath(pointer)
if err != nil {
t.Fatalf("ParsePath(%q) error = %v", pointer, err)
}
return path
}
maximum := int(^uint(0) >> 1)
minimum := -maximum - 1
paths := []Path{
{},
Keys(""),
Keys("a"),
Keys("ab"),
Keys("a", "b"),
Keys("a/b"),
Keys("a~1b"),
Keys("~"),
Keys("0"),
Keys("01"),
Keys("+1"),
Keys("items").Index(0),
Keys("items").Index(maximum),
Keys("items").Index(minimum),
parse("/"),
parse("/a~1b"),
parse("/a~01b"),
parse("/~0"),
parse("/0"),
parse("/01"),
parse("/+1"),
parse("/items/0"),
parse("/items/" + strconv.Itoa(maximum)),
parse("/items/" + strconv.Itoa(minimum)),
Path{}.Index(-1),
}
for _, container := range paths {
for _, descendant := range paths {
got := pathContains(container, descendant)
want := pathContainsByPointer(container, descendant)
if got != want {
t.Errorf("pathContains(%q, %q) = %v, want %v", container.String(), descendant.String(), got, want)
}
}
}
}
func pathContainsByPointer(container, descendant Path) bool {
if !container.valid() || !descendant.valid() || len(container.segments) > len(descendant.segments) {
return false
}
prefix := container.String()
if prefix == "" {
return true
}
value := descendant.String()
return value == prefix || len(value) > len(prefix) && value[:len(prefix)] == prefix && value[len(prefix)] == '/'
} }
func TestPathPreservesSegmentIntentAndImmutability(t *testing.T) { func TestPathPreservesSegmentIntentAndImmutability(t *testing.T) {

View File

@@ -357,6 +357,21 @@ func TestCompareDocumentTreesTracksSemanticChanges(t *testing.T) {
} }
} }
func TestCompareConfigurationValuesSortEscapedPointers(t *testing.T) {
before := parseCompositionDocument(t, "before", `values: {z: old, 'a/b': old, 'a~b': old, nested: {x: old}}`)
after := parseCompositionDocument(t, "after", `values: {z: new, 'a/b': new, 'a~b': new, nested: {x: new}}`)
deltas := compareConfigurationValues(before, after)
want := []string{"/values/a~0b", "/values/a~1b", "/values/nested/x", "/values/z"}
if len(deltas) != len(want) {
t.Fatalf("deltas = %#v", deltas)
}
for i, delta := range deltas {
if got := delta.path.String(); got != want[i] {
t.Fatalf("delta[%d] path = %q, want %q", i, got, want[i])
}
}
}
func mustSchemaFor[T any](t *testing.T) *schemaDescriptor { func mustSchemaFor[T any](t *testing.T) *schemaDescriptor {
t.Helper() t.Helper()
schema, issue := testBuildSchema(reflect.TypeFor[T]()) schema, issue := testBuildSchema(reflect.TypeFor[T]())

View File

@@ -26,6 +26,37 @@ type resolutionRecord struct {
reasons []string reasons []string
dependencies []Path dependencies []Path
helperExpected *document.Node helperExpected *document.Node
reasonSet map[string]struct{}
dependencySet map[string]struct{}
}
func (record *resolutionRecord) addReason(reason string) {
if record.reasonSet == nil {
record.reasonSet = make(map[string]struct{}, len(record.reasons)+1)
for _, existing := range record.reasons {
record.reasonSet[existing] = struct{}{}
}
}
if _, found := record.reasonSet[reason]; found {
return
}
record.reasonSet[reason] = struct{}{}
record.reasons = append(record.reasons, reason)
}
func (record *resolutionRecord) addDependency(path Path) {
pointer := path.String()
if record.dependencySet == nil {
record.dependencySet = make(map[string]struct{}, len(record.dependencies)+1)
for _, existing := range record.dependencies {
record.dependencySet[existing.String()] = struct{}{}
}
}
if _, found := record.dependencySet[pointer]; found {
return
}
record.dependencySet[pointer] = struct{}{}
record.dependencies = append(record.dependencies, path.clone())
} }
type resolutionSnapshot struct { type resolutionSnapshot struct {
@@ -207,9 +238,9 @@ func (state *resolutionState) addRecord(path Path, kind ChangeKind, reason strin
if kind == ChangeNormalization { if kind == ChangeNormalization {
record.kind = ChangeNormalization record.kind = ChangeNormalization
} }
record.reasons = appendUniqueString(record.reasons, reason) record.addReason(reason)
for _, dependency := range dependencies { for _, dependency := range dependencies {
record.dependencies = appendUniquePath(record.dependencies, dependency) record.addDependency(dependency)
} }
if expected != nil { if expected != nil {
record.helperExpected = document.Clone(expected) record.helperExpected = document.Clone(expected)
@@ -272,14 +303,17 @@ func runResolution[T any](configuration *T, resolver func(*T, *Resolution) error
} }
deltas := compareConfigurationValues(decoded.final, after) deltas := compareConfigurationValues(decoded.final, after)
ordered := orderedResolutionRecords(state.records) ordered := orderedResolutionRecords(state.records)
coverage := newResolutionCoverageIndexFromRecords(ordered)
beforeLookup := buildDocumentLookup(decoded.final)
afterLookup := buildDocumentLookup(after)
for _, delta := range deltas { for _, delta := range deltas {
if coveringResolutionRecord(ordered, delta.path) == nil { if _, ok := coverage.deepestCoveringID(delta.path); !ok {
state.addDiagnostic(CodeResolveUnrecordedChange, delta.path, "application resolution changed a value without a covering record") state.addDiagnostic(CodeResolveUnrecordedChange, delta.path, "application resolution changed a value without a covering record")
} }
} }
for _, record := range ordered { for _, record := range ordered {
_, beforePresent := documentAtPath(decoded.final, record.path) _, beforePresent := documentAtPathWithLookup(decoded.final, record.path, beforeLookup)
afterNode, afterPresent := documentAtPath(after, record.path) afterNode, afterPresent := documentAtPathWithLookup(after, record.path, afterLookup)
if !beforePresent && !afterPresent { if !beforePresent && !afterPresent {
state.addDiagnostic(CodeResolveInvalidRecord, record.path, "resolution record does not identify an existing or removed value") state.addDiagnostic(CodeResolveInvalidRecord, record.path, "resolution record does not identify an existing or removed value")
continue continue
@@ -294,8 +328,8 @@ func runResolution[T any](configuration *T, resolver func(*T, *Resolution) error
snapshots := make([]resolutionSnapshot, 0, len(ordered)) snapshots := make([]resolutionSnapshot, 0, len(ordered))
for _, record := range ordered { for _, record := range ordered {
beforeNode, _ := documentAtPath(decoded.final, record.path) beforeNode, _ := documentAtPathWithLookup(decoded.final, record.path, beforeLookup)
afterNode, _ := documentAtPath(after, record.path) afterNode, _ := documentAtPathWithLookup(after, record.path, afterLookup)
snapshots = append(snapshots, resolutionSnapshot{ snapshots = append(snapshots, resolutionSnapshot{
change: Change{ change: Change{
Path: record.path.clone(), Path: record.path.clone(),
@@ -313,52 +347,82 @@ func runResolution[T any](configuration *T, resolver func(*T, *Resolution) error
} }
func orderedResolutionRecords(records map[string]*resolutionRecord) []*resolutionRecord { func orderedResolutionRecords(records map[string]*resolutionRecord) []*resolutionRecord {
ordered := make([]*resolutionRecord, 0, len(records)) type orderedRecord struct {
for _, record := range records { record *resolutionRecord
ordered = append(ordered, record) pointer string
} }
slices.SortFunc(ordered, func(left, right *resolutionRecord) int { ordered := make([]orderedRecord, 0, len(records))
return strings.Compare(left.path.String(), right.path.String()) for _, record := range records {
ordered = append(ordered, orderedRecord{record: record, pointer: record.path.String()})
}
slices.SortFunc(ordered, func(left, right orderedRecord) int {
return strings.Compare(left.pointer, right.pointer)
}) })
return ordered result := make([]*resolutionRecord, len(ordered))
for i, item := range ordered {
result[i] = item.record
}
return result
} }
func coveringResolutionRecord(records []*resolutionRecord, path Path) *resolutionRecord { type resolutionCoverageIndex struct {
var covering *resolutionRecord prefix pathPrefixIndex
for _, record := range records { depths []int
if pathContains(record.path, path) && (covering == nil || len(record.path.segments) > len(covering.path.segments)) { }
covering = record
func newResolutionCoverageIndexFromRecords(records []*resolutionRecord) *resolutionCoverageIndex {
index := &resolutionCoverageIndex{depths: make([]int, len(records))}
for id, record := range records {
index.depths[id] = len(record.path.segments)
if record.path.valid() {
index.prefix.insert(record.path.String(), id)
} }
} }
return covering return index
}
func newResolutionCoverageIndexFromSnapshots(snapshots []resolutionSnapshot) (*resolutionCoverageIndex, []string) {
index := &resolutionCoverageIndex{depths: make([]int, len(snapshots))}
pointers := make([]string, len(snapshots))
for id, snapshot := range snapshots {
path := snapshot.change.Path
index.depths[id] = len(path.segments)
pointers[id] = path.String()
if path.valid() {
index.prefix.insert(pointers[id], id)
}
}
return index, pointers
}
func (index *resolutionCoverageIndex) deepestCoveringID(path Path) (int, bool) {
if !path.valid() {
return 0, false
}
return index.deepestCoveringPointerID(path.String())
}
func (index *resolutionCoverageIndex) deepestCoveringPointerID(pointer string) (int, bool) {
bestID, bestDepth, found := 0, -1, false
index.prefix.visitAncestors(pointer, func(id int) {
depth := index.depths[id]
if !found || depth > bestDepth || (depth == bestDepth && id < bestID) {
bestID, bestDepth, found = id, depth, true
}
})
return bestID, found
} }
func pathContains(container, descendant Path) bool { func pathContains(container, descendant Path) bool {
if !container.valid() || !descendant.valid() || len(container.segments) > len(descendant.segments) { if !container.valid() || !descendant.valid() || len(container.segments) > len(descendant.segments) {
return false return false
} }
if container.String() == "" { for i, segment := range container.segments {
if !pathTokenSpellingEqual(segment, descendant.segments[i]) {
return false
}
}
return true return true
}
prefix := container.String()
value := descendant.String()
return value == prefix || strings.HasPrefix(value, prefix+"/")
}
func appendUniqueString(values []string, value string) []string {
if !slices.Contains(values, value) {
return append(values, value)
}
return values
}
func appendUniquePath(paths []Path, path Path) []Path {
for _, existing := range paths {
if existing.String() == path.String() {
return paths
}
}
return append(paths, path.clone())
} }
func clonePaths(paths []Path) []Path { func clonePaths(paths []Path) []Path {

View File

@@ -0,0 +1,247 @@
package engine
import (
"reflect"
"slices"
"testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/document"
)
func TestCoalescedResolutionSnapshotsMatchOrderedScan(t *testing.T) {
parsedDependency := parseOverrideTestPath(t, "/dependencies/item")
snapshots := []resolutionSnapshot{
{change: Change{Path: Keys("a", "b"), Reasons: []string{"child", "shared", "child"}, Dependencies: []Path{Keys("dependencies", "item")}, Origin: Origin{Operation: ChangeDerivedDefault}}},
{change: Change{Path: Keys("a", "c"), Reasons: []string{"other"}, Dependencies: []Path{Keys("other")}, Origin: Origin{Operation: ChangeDerivedDefault}}},
{change: Change{Path: Keys("a"), Reasons: []string{"parent"}, Dependencies: []Path{Keys("parent")}, Origin: Origin{Operation: ChangeDerivedDefault}}},
{change: Change{Path: Keys("a", "b", "grand"), Reasons: []string{"deep", "shared"}, Dependencies: []Path{parsedDependency, Keys("deep")}, Origin: Origin{Operation: ChangeDerivedDefault}}},
{change: Change{Path: Path{}, Reasons: []string{"root"}, Origin: Origin{Operation: ChangeDerivedDefault}}},
{change: Change{Path: Keys("outside"), Reasons: []string{"root child"}, Origin: Origin{Operation: ChangeDerivedDefault}}},
{change: Change{Path: Keys("a", "b", "normalization"), Reasons: []string{"normalized"}, Origin: Origin{Operation: ChangeNormalization}}},
{change: Change{Path: Keys("a", "none"), Reasons: []string{"parent child"}, Origin: Origin{Operation: ChangeDerivedDefault}}},
}
want := coalescedResolutionSnapshotsByScan(snapshots)
got := coalescedResolutionSnapshots(snapshots)
if !reflect.DeepEqual(got, want) {
t.Fatalf("coalesced snapshots differ from ordered scan:\n got: %#v\nwant: %#v", got, want)
}
if len(got) != 4 || got[0].change.Origin.Operation != ChangeNormalization ||
!reflect.DeepEqual(got[0].change.Reasons, []string{"child", "shared", "child", "deep", "normalized"}) ||
len(got[0].change.Dependencies) != 2 || got[3].change.Path.String() != "" {
t.Fatalf("coalesced ordering or unions changed: %#v", got)
}
}
func coalescedResolutionSnapshotsByScan(snapshots []resolutionSnapshot) []resolutionSnapshot {
groups := make([]resolutionSnapshot, 0, len(snapshots))
for _, snapshot := range snapshots {
merged := false
for i := range groups {
if !pathContainsByPointer(groups[i].change.Path, snapshot.change.Path) {
continue
}
for _, reason := range snapshot.change.Reasons {
groups[i].change.Reasons = appendUniqueStringByScan(groups[i].change.Reasons, reason)
}
for _, dependency := range snapshot.change.Dependencies {
groups[i].change.Dependencies = appendUniquePathByPointer(groups[i].change.Dependencies, dependency)
}
if snapshot.change.Origin.Operation == ChangeNormalization {
groups[i].change.Origin.Operation = ChangeNormalization
}
merged = true
break
}
if !merged {
snapshot.change = cloneChange(snapshot.change)
groups = append(groups, snapshot)
}
}
return groups
}
func appendUniqueStringByScan(values []string, value string) []string {
if !slices.Contains(values, value) {
return append(values, value)
}
return values
}
func appendUniquePathByPointer(paths []Path, path Path) []Path {
for _, existing := range paths {
if existing.String() == path.String() {
return paths
}
}
return append(paths, path.clone())
}
func TestResolutionDependencyMembershipMatchesPointerSpelling(t *testing.T) {
parse := func(pointer string) Path {
path, err := ParsePath(pointer)
if err != nil {
t.Fatalf("ParsePath(%q) error = %v", pointer, err)
}
return path
}
paths := []Path{
Keys("a/b"), parse("/a~1b"),
Keys("a~1b"), parse("/a~01b"),
Keys("items").Index(0), parse("/items/0"), Keys("items", "0"),
Keys("items", "01"), parse("/items/01"),
Keys("-1"), parse("/-1"),
Keys("a", "b"), Keys("a/b"),
}
var record resolutionRecord
var want []Path
for _, path := range paths {
record.addDependency(path)
want = appendUniquePathByPointer(want, path)
}
if !reflect.DeepEqual(record.dependencies, want) {
t.Fatalf("resolution dependencies = %#v, want %#v", record.dependencies, want)
}
}
func TestResolutionCoverageIndexMatchesOrderedScan(t *testing.T) {
paths := []Path{
Keys("group"),
Keys("group", "field"),
Keys("group", "field"),
Keys("group", "field", "nested"),
Keys("other"),
}
records := make([]*resolutionRecord, len(paths))
snapshots := make([]resolutionSnapshot, len(paths))
for id, path := range paths {
records[id] = &resolutionRecord{path: path}
snapshots[id] = resolutionSnapshot{change: Change{Path: path}}
}
snapshotIndex, pointers := newResolutionCoverageIndexFromSnapshots(snapshots)
for id, pointer := range pointers {
if pointer != paths[id].String() {
t.Errorf("snapshot pointer %d = %q, want %q", id, pointer, paths[id].String())
}
}
queries := []Path{
Path{},
Keys("group"),
Keys("group", "field"),
Keys("group", "field", "nested", "leaf"),
Keys("group", "missing"),
Keys("other", "child"),
Keys("group").Index(-1),
}
for _, test := range []struct {
name string
index *resolutionCoverageIndex
}{
{"records", newResolutionCoverageIndexFromRecords(records)},
{"snapshots", snapshotIndex},
} {
t.Run(test.name, func(t *testing.T) {
for _, query := range queries {
wantID, wantDepth, wantFound := 0, -1, false
for id, candidate := range paths {
if pathContainsByPointer(candidate, query) && (!wantFound || len(candidate.segments) > wantDepth) {
wantID, wantDepth, wantFound = id, len(candidate.segments), true
}
}
gotID, gotFound := test.index.deepestCoveringID(query)
if gotID != wantID || gotFound != wantFound {
t.Errorf("deepestCoveringID(%s) = %d, %v; want %d, %v", query.String(), gotID, gotFound, wantID, wantFound)
}
}
})
}
}
func TestDocumentLookupMatchesPathScan(t *testing.T) {
name := &document.Node{Kind: document.String, Text: "item"}
sequence := &document.Node{Kind: document.Sequence, Sequence: []*document.Node{
{Kind: document.String, Text: "zero"},
{Kind: document.Mapping, Mapping: []document.Entry{{Key: "name", Value: name}}},
}}
root := &document.Node{Kind: document.Mapping, Mapping: []document.Entry{
{Key: "0", Value: &document.Node{Kind: document.String, Text: "numeric key"}},
{Key: "01", Value: &document.Node{Kind: document.String, Text: "leading zero key"}},
{Key: "items", Value: sequence},
}}
applicationRoot := &document.Node{Kind: document.Mapping, Mapping: []document.Entry{
{Key: "shared", Value: sequence},
{Key: "application", Value: &document.Node{Kind: document.Mapping, Mapping: []document.Entry{{Key: "value", Value: &document.Node{Kind: document.String, Text: "application"}}}}},
}}
lookup := buildDocumentLookup(root, applicationRoot)
paths := []Path{
Path{},
Keys("0"),
Keys("01"),
Keys("items"),
Keys("items").Index(0),
Keys("items").Index(1).Key("name"),
Keys("items").Index(2),
Keys("items").Key("name"),
Keys("missing"),
Keys("items").Index(-1),
(Path{}).Index(0),
}
for _, path := range paths {
wantNode, wantFound := documentAtPath(root, path)
gotNode, gotFound := documentAtPathWithLookup(root, path, lookup)
if gotNode != wantNode || gotFound != wantFound {
t.Errorf("indexed lookup %s = (%p, %v), want (%p, %v)", path.String(), gotNode, gotFound, wantNode, wantFound)
}
}
for _, path := range []Path{Path{}, Keys("shared"), Keys("shared").Index(1).Key("name"), Keys("application", "value")} {
wantNode, wantFound := documentAtPath(applicationRoot, path)
gotNode, gotFound := documentAtPathWithLookup(applicationRoot, path, lookup)
if gotNode != wantNode || gotFound != wantFound {
t.Errorf("indexed application lookup %s = (%p, %v), want (%p, %v)", path.String(), gotNode, gotFound, wantNode, wantFound)
}
}
}
func TestApplyResolutionMetadataPreservesOverlappingDeltaOrder(t *testing.T) {
oldOrigin := Origin{Layer: LayerBase}
parentOrigin := Origin{Layer: LayerResolution, Operation: ChangeNormalization}
childOrigin := Origin{Layer: LayerResolution, Operation: ChangeDerivedDefault}
leaf := &document.Node{Kind: document.String, Text: "value"}
final := &document.Node{Kind: document.Mapping, Mapping: []document.Entry{{
Key: "group",
Value: &document.Node{Kind: document.Mapping, Mapping: []document.Entry{{
Key: "item",
Value: &document.Node{Kind: document.Mapping, Mapping: []document.Entry{{Key: "leaf", Value: leaf}}},
}}},
}}}
metadata := &Metadata{
fields: map[string]FieldInfo{"/group/item/leaf": {Origin: oldOrigin}},
history: make(map[string][]Change),
}
resolved := resolutionResult{
final: final,
deltas: []documentDelta{{path: Keys("group")}, {path: Keys("group", "item")}},
snapshots: []resolutionSnapshot{
{change: Change{Path: Keys("group"), Origin: parentOrigin}},
{change: Change{Path: Keys("group", "item"), Origin: childOrigin}},
},
}
applyResolutionMetadata(metadata, resolved)
field := metadata.fields["/group/item/leaf"]
if field.Origin.Operation != childOrigin.Operation {
t.Fatalf("final leaf origin operation = %q, want %q", field.Origin.Operation, childOrigin.Operation)
}
changes := metadata.history["/group/item/leaf"]
if len(changes) != 2 {
t.Fatalf("leaf history has %d entries, want 2: %#v", len(changes), changes)
}
if changes[0].Origin.Operation != parentOrigin.Operation || changes[1].Origin.Operation != childOrigin.Operation {
t.Fatalf("leaf history operations = %q, %q; want %q, %q", changes[0].Origin.Operation, changes[1].Origin.Operation, parentOrigin.Operation, childOrigin.Operation)
}
for index, change := range changes {
if len(change.Previous) != 1 || change.Previous[0].Layer != oldOrigin.Layer {
t.Errorf("leaf history[%d] Previous = %#v, want original origin", index, change.Previous)
}
}
}

View File

@@ -122,6 +122,11 @@ func TestResolutionPreservesExplicitNullAndZeroWhileReplacingAbsentBaseline(t *t
func TestResolutionCoalescesEqualAndRepeatedHelperIntent(t *testing.T) { func TestResolutionCoalescesEqualAndRepeatedHelperIntent(t *testing.T) {
t.Parallel() t.Parallel()
parsedOther, err := ParsePath("/other")
if err != nil {
t.Fatal(err)
}
type config struct { type config struct {
Value int `yaml:"value"` Value int `yaml:"value"`
Other int `yaml:"other"` Other int `yaml:"other"`
@@ -130,8 +135,9 @@ func TestResolutionCoalescesEqualAndRepeatedHelperIntent(t *testing.T) {
Defaults: func() config { return config{Value: 5} }, Defaults: func() config { return config{Value: 5} },
Resolve: func(configuration *config, resolution *Resolution) error { Resolve: func(configuration *config, resolution *Resolution) error {
Default(resolution, Keys("value"), &configuration.Value, 5, "equal derived intent", Keys("other")) Default(resolution, Keys("value"), &configuration.Value, 5, "equal derived intent", Keys("other"))
Default(resolution, Keys("value"), &configuration.Value, 7, "updated derived intent", Keys("other")) Default(resolution, Keys("value"), &configuration.Value, 7, "updated derived intent", parsedOther)
Normalize(resolution, Keys("value"), &configuration.Value, 7, "canonicalized value", Keys("other")) Normalize(resolution, Keys("value"), &configuration.Value, 7, "canonicalized value", Keys("other"))
Normalize(resolution, Keys("value"), &configuration.Value, 7, "updated derived intent", parsedOther)
return nil return nil
}, },
}}) }})
@@ -206,6 +212,31 @@ func TestResolutionSequenceReplacementUpdatesDescendantOrigins(t *testing.T) {
} }
} }
func TestOrderedResolutionRecordsSortEscapedPointers(t *testing.T) {
parsedIndex, err := ParsePath("/items/0")
if err != nil {
t.Fatal(err)
}
records := map[string]*resolutionRecord{
"tilde": {path: Keys("a~b")},
"slash": {path: Keys("a/b")},
"nested": {path: Keys("a", "b")},
"index": {path: Keys("items").Index(0)},
"parsed": {path: parsedIndex},
"last": {path: Keys("z")},
}
ordered := orderedResolutionRecords(records)
want := []string{"/a/b", "/a~0b", "/a~1b", "/items/0", "/items/0", "/z"}
if len(ordered) != len(want) {
t.Fatalf("ordered records = %d, want %d", len(ordered), len(want))
}
for i, record := range ordered {
if got := record.path.String(); got != want[i] {
t.Fatalf("record[%d] path = %q, want %q", i, got, want[i])
}
}
}
func TestResolutionRejectsUnrecordedAndMismatchedMutations(t *testing.T) { func TestResolutionRejectsUnrecordedAndMismatchedMutations(t *testing.T) {
t.Parallel() t.Parallel()

View File

@@ -9,17 +9,10 @@ type secretDependency struct {
func classifySecretPaths(schema *schemaDescriptor, predicate func(Path) bool, metadata Metadata, final *document.Node, applications []historyApplication, projections ...*document.Node) map[string]bool { func classifySecretPaths(schema *schemaDescriptor, predicate func(Path) bool, metadata Metadata, final *document.Node, applications []historyApplication, projections ...*document.Node) map[string]bool {
paths := make(map[string]Path) paths := make(map[string]Path)
finalPaths := make(map[string]*document.Node)
indexDocument(final, Path{}, finalPaths)
for pointer := range finalPaths {
addSecretPath(paths, pointer)
}
for _, application := range applications { for _, application := range applications {
applicationPaths := make(map[string]*document.Node) walkDocument(application.value, application.path, func(path Path, _ *document.Node) {
indexDocument(application.value, application.path, applicationPaths) addSecretPath(paths, path.String())
for pointer := range applicationPaths { })
addSecretPath(paths, pointer)
}
} }
var dependencies []secretDependency var dependencies []secretDependency
for pointer, history := range metadata.history { for pointer, history := range metadata.history {
@@ -65,16 +58,19 @@ func classifySecretPaths(schema *schemaDescriptor, predicate func(Path) bool, me
} }
secret := make(map[string]bool) secret := make(map[string]bool)
for _, tree := range append(projections, final) { classifyProjection := func(tree *document.Node) {
projected := make(map[string]*document.Node) walkDocument(tree, Path{}, func(path Path, node *document.Node) {
indexDocument(tree, Path{}, projected) pointer := path.String()
for pointer, node := range projected {
addSecretPath(paths, pointer) addSecretPath(paths, pointer)
if node.Sensitive { if node.Sensitive {
secret[pointer] = true secret[pointer] = true
} }
})
} }
for _, tree := range projections {
classifyProjection(tree)
} }
classifyProjection(final)
for pointer, path := range paths { for pointer, path := range paths {
if root, ok := schemaSecretRoot(schema, path); ok { if root, ok := schemaSecretRoot(schema, path); ok {
secret[root.String()] = true secret[root.String()] = true
@@ -83,49 +79,29 @@ func classifySecretPaths(schema *schemaDescriptor, predicate func(Path) bool, me
secret[pointer] = true secret[pointer] = true
} }
} }
for { return closeSecretDependencies(paths, secret, dependencies, inherited)
changed := false
for _, dependency := range dependencies {
if secretIntersectsPath(paths, secret, dependency.dependency) && !secret[dependency.target.String()] {
secret[dependency.target.String()] = true
changed = true
}
}
for _, link := range inherited {
if pathIsSecret(secret, link.dependency) && !secret[link.target.String()] {
secret[link.target.String()] = true
changed = true
}
if pathIsSecret(secret, link.target) && !secret[link.dependency.String()] {
secret[link.dependency.String()] = true
changed = true
}
}
if !changed {
return secret
}
}
} }
func addSecretPath(paths map[string]Path, pointer string) { func addSecretPath(paths map[string]Path, pointer string) {
if existing, found := paths[pointer]; found && existing.valid() {
// Accumulator keys already match their paths' canonical spelling. History
// can install typed segments, which still need the parsed replacement.
parsed := true
for _, segment := range existing.segments {
if segment.kind != pathSegmentParsed {
parsed = false
break
}
}
if parsed {
return
}
}
if path, err := ParsePath(pointer); err == nil { if path, err := ParsePath(pointer); err == nil {
paths[pointer] = path paths[pointer] = path
} }
} }
func secretIntersectsPath(paths map[string]Path, secret map[string]bool, candidate Path) bool {
for pointer := range secret {
classified, ok := paths[pointer]
if !ok {
continue
}
if pathContains(classified, candidate) || pathContains(candidate, classified) {
return true
}
}
return false
}
func schemaSecretRoot(schema *schemaDescriptor, path Path) (Path, bool) { func schemaSecretRoot(schema *schemaDescriptor, path Path) (Path, bool) {
if schema == nil || schema.root == nil || !path.valid() { if schema == nil || schema.root == nil || !path.valid() {
return Path{}, false return Path{}, false

View File

@@ -0,0 +1,79 @@
package engine
import "testing"
func TestSecretPathCollectionKeepsParsedContributions(t *testing.T) {
for _, pointer := range []string{"", "/", "/records/0", "/records/a~1b~0/01"} {
t.Run(pointer, func(t *testing.T) {
paths := make(map[string]Path)
for repeat := 0; repeat < 3; repeat++ {
addSecretPath(paths, pointer)
path, found := paths[pointer]
if !found || !path.valid() || path.String() != pointer {
t.Fatalf("collected path = %#v, found %v; want %q", path, found, pointer)
}
for _, segment := range path.segments {
if segment.kind != pathSegmentParsed {
t.Fatalf("collected path %q retained a typed segment: %#v", pointer, segment)
}
}
}
})
}
}
func TestSecretClassificationParsesTypedHistoryAfterNumericShapeChanges(t *testing.T) {
tests := []struct {
name string
earlier string
final string
historical Path
}{
{"mapping to sequence", "items: {'0': old}", "items: [new]", Keys("items", "0")},
{"sequence to mapping", "items: [old]", "items: {'0': new}", Keys("items").Index(0)},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
earlier := parseCompositionDocument(t, "earlier", test.earlier)
final := parseCompositionDocument(t, "final", test.final)
pointer := test.historical.String()
metadata := Metadata{history: map[string][]Change{pointer: {{Path: test.historical}}}}
seen := false
classified := classifySecretPaths(nil, func(path Path) bool {
if path.String() != pointer {
return false
}
seen = true
for _, segment := range path.segments {
if segment.kind != pathSegmentParsed {
t.Fatalf("predicate received typed history after final projection: %#v", path)
}
}
return true
}, metadata, final, []historyApplication{{value: earlier}}, earlier)
if !seen || !classified[pointer] {
t.Fatalf("historical/final numeric path was not classified: %#v", classified)
}
})
}
}
func TestSecretPredicateReceivesDetachedHistoricalPath(t *testing.T) {
path := Keys("records").Key("1")
metadata := Metadata{history: map[string][]Change{
path.String(): {{Path: path}},
}}
seen := false
classified := classifySecretPaths(nil, func(candidate Path) bool {
if candidate.String() != path.String() {
return false
}
seen = true
candidate.segments[0].key = "changed"
return true
}, metadata, nil, nil)
if !seen || !classified[path.String()] || classified["/changed/1"] {
t.Fatalf("classification after predicate mutation = %#v, predicate called: %v", classified, seen)
}
}

View File

@@ -0,0 +1,69 @@
package engine
type secretTrigger struct {
target string
ordinary bool
}
func closeSecretDependencies(paths map[string]Path, sensitive map[string]bool, dependencies, inherited []secretDependency) map[string]bool {
result := make(map[string]bool, len(sensitive))
queue := make([]string, 0, len(sensitive))
for pointer, classified := range sensitive {
if classified {
result[pointer] = true
queue = append(queue, pointer)
}
}
var triggers pathPrefixIndex
triggerValues := make([]secretTrigger, 0, len(dependencies)+2*len(inherited))
insert := func(dependency Path, target string, ordinary bool) {
if ordinary && !dependency.valid() {
return
}
id := len(triggerValues)
triggerValues = append(triggerValues, secretTrigger{target: target, ordinary: ordinary})
triggers.insert(dependency.String(), id)
}
for _, dependency := range dependencies {
insert(dependency.dependency, dependency.target.String(), true)
}
for _, link := range inherited {
insert(link.dependency, link.target.String(), false)
insert(link.target, link.dependency.String(), false)
}
fired := make([]bool, len(triggerValues))
activate := func(id int) {
if fired[id] {
return
}
fired[id] = true
target := triggerValues[id].target
if !result[target] {
result[target] = true
queue = append(queue, target)
}
}
for head := 0; head < len(queue); head++ {
pointer := queue[head]
if classified, ok := paths[pointer]; ok && classified.valid() {
triggers.visitAncestors(classified.String(), func(id int) {
if triggerValues[id].ordinary {
activate(id)
}
})
triggers.visitDescendants(classified.String(), func(id int) {
if triggerValues[id].ordinary {
activate(id)
}
})
}
triggers.visitDescendants(pointer, func(id int) {
if !triggerValues[id].ordinary {
activate(id)
}
})
}
return result
}

View File

@@ -0,0 +1,147 @@
package engine
import (
"reflect"
"testing"
)
func TestSecretDependencyClosureMatchesFixedPointScan(t *testing.T) {
tests := []struct {
name string
paths map[string]Path
seed map[string]bool
dependencies []secretDependency
inherited []secretDependency
wantSensitivity map[string]bool
}{
{
name: "forward reverse chains and cycles",
paths: secretPaths(Keys("input"), Keys("derived"), Keys("output"), Keys("cycle")),
seed: secretSet(Keys("input")),
dependencies: []secretDependency{
{dependency: Keys("input"), target: Keys("derived")},
{dependency: Keys("derived"), target: Keys("output")},
{dependency: Keys("output"), target: Keys("cycle")},
{dependency: Keys("cycle"), target: Keys("derived")},
{dependency: Keys("input"), target: Keys("derived")},
},
},
{
name: "ordinary ancestor descendant and root relations",
paths: secretPaths(Path{}, Keys("branch", "leaf"), Keys("parent", "child")),
seed: secretSet(Keys("branch", "leaf"), Keys("parent")),
dependencies: []secretDependency{
{dependency: Keys("branch"), target: Keys("from-descendant")},
{dependency: Keys("parent", "child"), target: Keys("from-ancestor")},
{dependency: Path{}, target: Keys("from-root")},
},
},
{
name: "unindexed seed only activates inherited relations",
paths: secretPaths(Keys("ordinary"), Keys("copy")),
seed: secretSet(Keys("untracked")),
dependencies: []secretDependency{
{dependency: Keys("untracked"), target: Keys("ordinary")},
},
inherited: []secretDependency{
{dependency: Keys("untracked"), target: Keys("copy")},
},
wantSensitivity: map[string]bool{"/copy": true, "/ordinary": false},
},
{
name: "inheritance direction root escapes and numeric spellings",
paths: secretPaths(Path{}, Keys("a/b", "01"), Keys("source", "value")),
seed: secretSet(Path{}),
dependencies: []secretDependency{
{dependency: Keys("a/b", "01"), target: Keys("ordinary-copy")},
},
inherited: []secretDependency{
{dependency: Keys("a/b", "01"), target: Keys("destination", "01")},
{dependency: Keys("source"), target: Keys("destination")},
},
},
{
name: "inherited links do not trigger from classified descendants",
paths: secretPaths(Keys("parent", "secret", "nested")),
seed: secretSet(Keys("parent", "secret", "nested")),
inherited: []secretDependency{
{dependency: Keys("parent", "secret"), target: Keys("child", "secret")},
},
},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
want := referenceSecretDependencyClosure(test.paths, test.seed, test.dependencies, test.inherited)
got := closeSecretDependencies(test.paths, test.seed, test.dependencies, test.inherited)
if !reflect.DeepEqual(got, want) {
t.Fatalf("closure = %#v, want %#v", got, want)
}
for pointer, sensitive := range test.wantSensitivity {
if got[pointer] != sensitive {
t.Errorf("sensitivity at %q = %v, want %v", pointer, got[pointer], sensitive)
}
}
})
}
}
func referenceSecretDependencyClosure(paths map[string]Path, seed map[string]bool, dependencies, inherited []secretDependency) map[string]bool {
secret := make(map[string]bool, len(seed))
for pointer, classified := range seed {
if classified {
secret[pointer] = true
}
}
for {
changed := false
for _, dependency := range dependencies {
if referenceSecretIntersectsPath(paths, secret, dependency.dependency) && !secret[dependency.target.String()] {
secret[dependency.target.String()] = true
changed = true
}
}
for _, link := range inherited {
if pathIsSecret(secret, link.dependency) && !secret[link.target.String()] {
secret[link.target.String()] = true
changed = true
}
if pathIsSecret(secret, link.target) && !secret[link.dependency.String()] {
secret[link.dependency.String()] = true
changed = true
}
}
if !changed {
return secret
}
}
}
func referenceSecretIntersectsPath(paths map[string]Path, secret map[string]bool, candidate Path) bool {
for pointer := range secret {
classified, ok := paths[pointer]
if !ok {
continue
}
if pathContains(classified, candidate) || pathContains(candidate, classified) {
return true
}
}
return false
}
func secretPaths(paths ...Path) map[string]Path {
result := make(map[string]Path, len(paths))
for _, path := range paths {
result[path.String()] = path
}
return result
}
func secretSet(paths ...Path) map[string]bool {
result := make(map[string]bool, len(paths))
for _, path := range paths {
result[path.String()] = true
}
return result
}

View File

@@ -12,7 +12,11 @@ import (
// Snapshot projection is the shared redaction boundary for effective YAML // Snapshot projection is the shared redaction boundary for effective YAML
// and explanation values. It borrows frozen trees and returns a detached tree. // and explanation values. It borrows frozen trees and returns a detached tree.
func valueSnapshot(root *document.Node, path Path, secrets map[string]bool) ValueSnapshot { func valueSnapshot(root *document.Node, path Path, secrets map[string]bool) ValueSnapshot {
node, present := documentAtPath(root, path) return valueSnapshotWithLookup(root, path, secrets, nil)
}
func valueSnapshotWithLookup(root *document.Node, path Path, secrets map[string]bool, lookup *documentLookup) ValueSnapshot {
node, present := documentAtPathWithLookup(root, path, lookup)
if !present { if !present {
return ValueSnapshot{} return ValueSnapshot{}
} }

View File

@@ -1,359 +0,0 @@
package configkit
import (
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
)
type releaseFixture struct {
t *testing.T
root string
remote string
base string
candidate string
validationLog string
realGit string
}
func newReleaseFixture(t *testing.T) *releaseFixture {
t.Helper()
realGit, err := exec.LookPath("git")
if err != nil {
t.Fatal(err)
}
f := &releaseFixture{
t: t,
root: t.TempDir(),
remote: filepath.Join(t.TempDir(), "remote.git"),
validationLog: filepath.Join(t.TempDir(), "validator.log"),
realGit: realGit,
}
f.git("init", "-q", "-b", "main")
f.git("init", "-q", "--bare", f.remote)
f.git("config", "user.name", "Test")
f.git("config", "user.email", "test@example.invalid")
f.git("config", "commit.gpgsign", "false")
f.git("config", "tag.gpgsign", "false")
f.git("config", "push.followTags", "true")
f.git("remote", "add", "origin", f.remote)
f.write(".gitignore", "ignored.log\n", 0600)
f.write("README.md", "fixture\n", 0600)
f.git("add", ".")
f.git("commit", "-qm", "Initial source")
f.base = f.git("rev-parse", "HEAD")
f.git("push", f.remote, "HEAD:refs/heads/main")
script, err := os.ReadFile("scripts/release.sh")
if err != nil {
t.Fatal(err)
}
// Only the fixture copy substitutes the authoritative endpoint.
local := strings.ReplaceAll(string(script), "https://gitea.maximumdirect.net/eric/ConfigKit.git", f.remote)
f.write("scripts/release.sh", local, 0700)
f.write("scripts/release-check.sh", `#!/bin/sh
printf 'validate\n' >> "$CONFIGKIT_VALIDATION_LOG"
case "${CONFIGKIT_VALIDATOR_ACTION-}" in
fail) exit 37 ;;
change) git commit --allow-empty -qm 'Changed during validation' ;;
dirty) printf 'changed\n' > unexpected.txt ;;
remote-tag) git --git-dir="$CONFIGKIT_REMOTE" update-ref refs/tags/v0.9.0 "$CONFIGKIT_BASE" ;;
esac
`, 0700)
f.write("docs/releases/v0.9.0.md", "# ConfigKit v0.9.0\n\nConfiguration loading fixes.\n\nCompatibility is unchanged.\n", 0600)
f.git("add", ".")
f.git("commit", "-qm", "Prepared source")
f.candidate = f.git("rev-parse", "HEAD")
return f
}
func (f *releaseFixture) write(path, contents string, mode os.FileMode) {
f.t.Helper()
target := filepath.Join(f.root, path)
if err := os.MkdirAll(filepath.Dir(target), 0700); err != nil {
f.t.Fatal(err)
}
if err := os.WriteFile(target, []byte(contents), mode); err != nil {
f.t.Fatal(err)
}
}
func (f *releaseFixture) git(args ...string) string {
f.t.Helper()
cmd := exec.Command(f.realGit, args...)
cmd.Dir = f.root
cmd.Env = append(os.Environ(), "GIT_ALLOW_PROTOCOL=file", "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null")
out, err := cmd.CombinedOutput()
if err != nil {
f.t.Fatalf("git %v: %v\n%s", args, err, out)
}
return strings.TrimSpace(string(out))
}
func (f *releaseFixture) gitResult(args ...string) (string, error) {
f.t.Helper()
cmd := exec.Command(f.realGit, args...)
cmd.Dir = f.root
cmd.Env = append(os.Environ(), "GIT_ALLOW_PROTOCOL=file", "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null")
out, err := cmd.CombinedOutput()
return strings.TrimSpace(string(out)), err
}
func (f *releaseFixture) run(version, behavior, validatorAction string) (string, error) {
f.t.Helper()
bin := f.t.TempDir()
wrapper := `#!/bin/sh
if [ "${CONFIGKIT_GIT_BEHAVIOR-}" = main-unknown ] || [ "${CONFIGKIT_GIT_BEHAVIOR-}" = tag-unknown ]; then
if [ "${1-}" = ls-remote ] && [ -f "$CONFIGKIT_GIT_MARKER" ]; then exit 37; fi
fi
case "$*" in
push\ *|-c\ remote.origin.mirror=false\ push\ *)
case "$*" in
*refs/heads/main*)
case "${CONFIGKIT_GIT_BEHAVIOR-}" in
main-lost) "$CONFIGKIT_REAL_GIT" "$@" >/dev/null 2>&1; exit 37 ;;
main-reject) exit 37 ;;
main-unknown) : > "$CONFIGKIT_GIT_MARKER"; exit 37 ;;
esac ;;
*refs/tags/v0.9.0*)
case "${CONFIGKIT_GIT_BEHAVIOR-}" in
tag-lost) "$CONFIGKIT_REAL_GIT" "$@" >/dev/null 2>&1; exit 37 ;;
tag-reject) exit 37 ;;
tag-conflict)
"$CONFIGKIT_REAL_GIT" --git-dir="$CONFIGKIT_REMOTE" update-ref refs/tags/v0.9.0 "$CONFIGKIT_BASE"
exit 37 ;;
tag-unknown) : > "$CONFIGKIT_GIT_MARKER"; exit 37 ;;
esac ;;
esac
;;
esac
exec "$CONFIGKIT_REAL_GIT" "$@"
`
if err := os.WriteFile(filepath.Join(bin, "git"), []byte(wrapper), 0700); err != nil {
f.t.Fatal(err)
}
nested := filepath.Join(f.root, "nested")
if err := os.Mkdir(nested, 0700); err != nil {
f.t.Fatal(err)
}
cmd := exec.Command("/bin/bash", filepath.Join(f.root, "scripts", "release.sh"), version)
cmd.Dir = nested
cmd.Env = append(os.Environ(),
"PATH="+bin+string(os.PathListSeparator)+os.Getenv("PATH"),
"GIT_ALLOW_PROTOCOL=file", "GIT_CONFIG_NOSYSTEM=1", "GIT_CONFIG_GLOBAL=/dev/null",
"CONFIGKIT_VALIDATION_LOG="+f.validationLog,
"CONFIGKIT_VALIDATOR_ACTION="+validatorAction,
"CONFIGKIT_GIT_BEHAVIOR="+behavior,
"CONFIGKIT_GIT_MARKER="+filepath.Join(bin, "push-seen"),
"CONFIGKIT_REAL_GIT="+f.realGit,
"CONFIGKIT_REMOTE="+f.remote,
"CONFIGKIT_BASE="+f.base,
)
out, err := cmd.CombinedOutput()
return string(out), err
}
func (f *releaseFixture) validations() int {
f.t.Helper()
data, err := os.ReadFile(f.validationLog)
if os.IsNotExist(err) {
return 0
}
if err != nil {
f.t.Fatal(err)
}
return strings.Count(string(data), "validate\n")
}
func (f *releaseFixture) remoteRef(ref string) string {
f.t.Helper()
value, err := f.gitResult("--git-dir="+f.remote, "rev-parse", "--verify", ref)
if err != nil {
return ""
}
return value
}
func TestReleasePublicationGuards(t *testing.T) {
for _, scenario := range []string{
"success", "main-already-current", "ignored-file", "unrelated-tag",
"malformed-version", "wrong-branch", "wrong-destination", "multiple-destinations",
"dirty-worktree", "changed-candidate", "missing-note", "mismatched-note",
"local-tag", "remote-tag", "remote-tag-after-validation",
"dirty-after-validation", "remote-query-failure",
"failed-validation", "non-fast-forward",
} {
t.Run(scenario, func(t *testing.T) {
f := newReleaseFixture(t)
version := "v0.9.0"
validatorAction := ""
switch scenario {
case "main-already-current":
f.git("push", f.remote, "HEAD:refs/heads/main")
case "ignored-file":
f.write("ignored.log", "local only\n", 0600)
case "unrelated-tag":
f.git("tag", "-a", "-m", "Unrelated", "v0.8.0", f.base)
case "malformed-version":
version = "v00.9.0"
case "wrong-branch":
f.git("checkout", "-qb", "feature")
case "wrong-destination":
other := filepath.Join(t.TempDir(), "other.git")
f.git("init", "-q", "--bare", other)
f.git("remote", "set-url", "--push", "origin", other)
case "multiple-destinations":
f.git("remote", "set-url", "--push", "origin", f.remote)
f.git("remote", "set-url", "--add", "--push", "origin", filepath.Join(t.TempDir(), "other.git"))
case "dirty-worktree":
f.write("untracked.txt", "unexpected\n", 0600)
case "changed-candidate":
validatorAction = "change"
case "dirty-after-validation":
validatorAction = "dirty"
case "missing-note":
f.git("rm", "docs/releases/v0.9.0.md")
f.git("commit", "-qm", "Remove note")
case "mismatched-note":
f.write("docs/releases/v0.9.0.md", "# ConfigKit v0.9.1\n", 0600)
f.git("add", ".")
f.git("commit", "-qm", "Mismatch note")
case "local-tag":
f.git("tag", "v0.9.0")
case "remote-tag":
f.git("tag", "v0.9.0")
f.git("push", f.remote, "refs/tags/v0.9.0")
f.git("tag", "-d", "v0.9.0")
case "remote-tag-after-validation":
validatorAction = "remote-tag"
case "remote-query-failure":
if err := os.Rename(f.remote, f.remote+".unavailable"); err != nil {
t.Fatal(err)
}
case "failed-validation":
validatorAction = "fail"
case "non-fast-forward":
f.git("checkout", "-qb", "other", f.base)
f.git("commit", "--allow-empty", "-qm", "Divergent source")
f.git("push", f.remote, "HEAD:refs/heads/main")
f.git("checkout", "main")
}
out, err := f.run(version, "", validatorAction)
success := scenario == "success" || scenario == "main-already-current" ||
scenario == "ignored-file" || scenario == "unrelated-tag"
if success != (err == nil) {
t.Fatalf("unexpected publication result: %v\n%s", err, out)
}
wantChecks := 1
switch scenario {
case "malformed-version", "wrong-branch", "wrong-destination",
"multiple-destinations", "dirty-worktree", "missing-note",
"mismatched-note", "local-tag", "remote-tag", "remote-query-failure",
"non-fast-forward":
wantChecks = 0
}
if got := f.validations(); got != wantChecks {
t.Fatalf("validator ran %d times, want %d", got, wantChecks)
}
if success {
if got := f.remoteRef("refs/heads/main"); got != f.candidate {
t.Fatalf("published main %s, want %s", got, f.candidate)
}
if got := f.remoteRef("refs/tags/v0.9.0^{}"); got != f.candidate {
t.Fatalf("tag points to %s, want %s", got, f.candidate)
}
if f.remoteRef("refs/tags/v0.8.0") != "" {
t.Fatal("unrelated local tag was published")
}
tagData := f.git("--git-dir="+f.remote, "cat-file", "-p", "refs/tags/v0.9.0")
_, message, ok := strings.Cut(tagData, "\n\n")
if !ok || strings.TrimSpace(message) != strings.TrimSpace(f.git("show", f.candidate+":docs/releases/v0.9.0.md")) {
t.Fatal("annotated tag does not contain the checked-in note")
}
} else if scenario != "remote-tag" && scenario != "remote-tag-after-validation" &&
f.remoteRef("refs/tags/v0.9.0") != "" {
t.Fatal("failed guard published a tag")
}
})
}
}
func TestReleasePublicationRecovery(t *testing.T) {
for _, scenario := range []string{
"main-lost", "main-reject", "main-unknown",
"tag-lost", "tag-reject", "tag-conflict", "tag-unknown",
} {
t.Run(scenario, func(t *testing.T) {
f := newReleaseFixture(t)
out, err := f.run("v0.9.0", scenario, "")
success := scenario == "main-lost" || scenario == "tag-lost"
if success != (err == nil) {
t.Fatalf("unexpected publication result: %v\n%s", err, out)
}
if got := f.validations(); got != 1 {
t.Fatalf("validator ran %d times", got)
}
remoteMain := f.remoteRef("refs/heads/main")
remoteTag := f.remoteRef("refs/tags/v0.9.0")
localTag, localTagErr := f.gitResult("rev-parse", "--verify", "refs/tags/v0.9.0")
if localTagErr != nil {
localTag = ""
}
switch scenario {
case "main-lost", "tag-lost":
if remoteMain != f.candidate || remoteTag == "" || remoteTag != localTag {
t.Fatal("confirmed publication did not preserve exact refs")
}
case "main-reject", "main-unknown":
if remoteMain != f.base || remoteTag != "" || localTag != "" {
t.Fatal("failed main push created publication state")
}
case "tag-reject", "tag-unknown":
if remoteMain != f.candidate || remoteTag != "" || localTag == "" {
t.Fatal("failed tag push did not retain branch-only state")
}
case "tag-conflict":
if remoteMain != f.candidate || remoteTag != f.base || localTag == "" {
t.Fatal("conflicting remote tag was overwritten")
}
}
})
}
}
func TestReleasePublicationUsesValidatedDestination(t *testing.T) {
for _, behavior := range []string{"success", "main-lost", "tag-lost"} {
t.Run(behavior, func(t *testing.T) {
f := newReleaseFixture(t)
other := filepath.Join(t.TempDir(), "other.git")
f.git("init", "-q", "--bare", other)
f.git("remote", "set-url", "--push", "origin", f.remote)
f.git("config", "url."+other+".pushInsteadOf", f.remote)
// These defaults must not broaden the explicit release refspecs when
// publishing through the validated named remote.
f.git("config", "remote.origin.mirror", "true")
f.git("config", "remote.origin.push", "refs/heads/*:refs/heads/*")
f.git("branch", "unrelated", f.base)
f.git("tag", "-a", "-m", "Unrelated", "v0.8.0", f.base)
f.git("--git-dir="+f.remote, "update-ref", "refs/heads/keep", f.base)
out, err := f.run("v0.9.0", behavior, "")
if err != nil {
t.Fatalf("publish: %v\n%s", err, out)
}
if f.validations() != 1 || f.remoteRef("refs/heads/main") != f.candidate ||
f.remoteRef("refs/tags/v0.9.0") != f.git("rev-parse", "refs/tags/v0.9.0") {
t.Fatal("publication did not reach the validated destination")
}
if f.remoteRef("refs/heads/keep") != f.base ||
f.remoteRef("refs/heads/unrelated") != "" || f.remoteRef("refs/tags/v0.8.0") != "" {
t.Fatal("publication changed unrelated refs")
}
if refs := f.git("--git-dir="+other, "for-each-ref", "--format=%(refname)"); refs != "" {
t.Fatalf("publication reached the rewrite destination: %s", refs)
}
})
}
}

12
releasekit.json Normal file
View File

@@ -0,0 +1,12 @@
{
"product": "ConfigKit",
"remote": "origin",
"expected_push_url": "https://gitea.maximumdirect.net/eric/ConfigKit.git",
"validation": {
"go": {
"race": true,
"full_vet": true,
"compile_targets": ["linux/amd64", "linux/arm64"]
}
}
}

View File

@@ -0,0 +1,57 @@
package configkit
import (
"bytes"
"encoding/json"
"os"
"reflect"
"testing"
)
func TestReleaseKitProfile(t *testing.T) {
data, err := os.ReadFile("releasekit.json")
if err != nil {
t.Fatal(err)
}
var profile struct {
Product string `json:"product"`
Remote string `json:"remote"`
ExpectedPushURL string `json:"expected_push_url"`
Validation struct {
Go struct {
Race bool `json:"race"`
FullVet bool `json:"full_vet"`
CompileTargets []string `json:"compile_targets"`
} `json:"go"`
} `json:"validation"`
}
decoder := json.NewDecoder(bytes.NewReader(data))
decoder.DisallowUnknownFields()
if err := decoder.Decode(&profile); err != nil {
t.Fatalf("decode release profile: %v", err)
}
if profile.Product != "ConfigKit" {
t.Errorf("product = %q, want ConfigKit", profile.Product)
}
if profile.Remote != "origin" {
t.Errorf("remote = %q, want origin", profile.Remote)
}
wantPushURL := "https://gitea.maximumdirect.net/eric/ConfigKit.git"
if fixturePushURL := os.Getenv("CONFIGKIT_TEST_EXPECTED_PUSH_URL"); fixturePushURL != "" {
wantPushURL = fixturePushURL
}
if profile.ExpectedPushURL != wantPushURL {
t.Errorf("expected push URL = %q, want %q", profile.ExpectedPushURL, wantPushURL)
}
goValidation := profile.Validation.Go
if !goValidation.Race || !goValidation.FullVet {
t.Errorf("race and full vet must be selected: race=%t full_vet=%t", goValidation.Race, goValidation.FullVet)
}
wantTargets := []string{"linux/amd64", "linux/arm64"}
if !reflect.DeepEqual(goValidation.CompileTargets, wantTargets) {
t.Errorf("compile targets = %v, want %v", goValidation.CompileTargets, wantTargets)
}
}

View File

@@ -1,29 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
repository_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
cd "$repository_dir"
baseline_toolchain=${CONFIGKIT_BASELINE_TOOLCHAIN:?set CONFIGKIT_BASELINE_TOOLCHAIN to a cached supported Go 1.25 patch release}
run_go() {
local toolchain=$1
shift
GOTOOLCHAIN="$toolchain" GOPROXY=off go "$@"
}
if ! GOTOOLCHAIN="$baseline_toolchain" GOPROXY=off go version >/dev/null; then
printf 'release toolchain %s is unavailable; acquire it before validation\n' "$baseline_toolchain" >&2
exit 1
fi
# Verbose output reports skipped cases without a separate evidence run.
run_go "$baseline_toolchain" test -mod=readonly -vet=off -race -v ./...
run_go "$baseline_toolchain" vet -mod=readonly ./...
# Only release-validated targets gate publication; macOS is best-effort.
for target in linux/amd64 linux/arm64; do
target_os=${target%/*}
target_arch=${target#*/}
GOTOOLCHAIN="$baseline_toolchain" GOPROXY=off CGO_ENABLED=0 GOOS="$target_os" GOARCH="$target_arch" go build -mod=readonly ./...
done

View File

@@ -1,133 +0,0 @@
#!/usr/bin/env bash
set -euo pipefail
repository_dir=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")/.." && pwd)
cd "$repository_dir"
remote_url=https://gitea.maximumdirect.net/eric/ConfigKit.git
fail() {
printf '%s\n' "$*" >&2
exit 1
}
check_candidate() {
local context=$1 current status
current=$(git branch --show-current) || fail "$context: cannot read the current branch"
[[ "$current" == main ]] || fail "$context: release requires the main branch"
status=$(git status --porcelain=v1 --untracked-files=all) || fail "$context: cannot inspect the worktree"
[[ -z "$status" ]] || fail "$context: release requires a clean index and worktree"
current=$(git rev-parse --verify HEAD) || fail "$context: cannot identify the candidate"
[[ "$current" == "$candidate" ]] || fail "$context: candidate changed; commit and validate it again"
}
check_unused_version() {
if git show-ref --verify --quiet "refs/tags/$version"; then
fail "version already has a local tag"
fi
local oid result
if oid=$(read_remote_ref "refs/tags/$version"); then
fail "version already has an authoritative tag"
else
result=$?
((result == 2)) || fail "cannot determine authoritative tag state; publication status is unknown"
fi
}
read_remote_ref() {
local ref=$1 response result
if response=$(git ls-remote --refs --exit-code origin "$ref" 2>/dev/null); then
[[ "$response" == *$'\t'"$ref" ]] || return 3
printf '%s\n' "${response%%$'\t'*}"
else
result=$?
((result == 2)) && return 2
return 3
fi
}
if (($# != 1)); then
fail "usage: ./scripts/release.sh vMAJOR.MINOR.PATCH"
fi
version=$1
[[ "$version" =~ ^v(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)\.(0|[1-9][0-9]*)$ ]] ||
fail "release version must be a stable vMAJOR.MINOR.PATCH without leading zeroes"
fetch_urls=$(git remote get-url --all origin 2>/dev/null) || fail "origin is unavailable"
push_urls=$(git remote get-url --push --all origin 2>/dev/null) || fail "origin push destination is unavailable"
[[ "$fetch_urls" == "$remote_url" && "$push_urls" == "$remote_url" ]] ||
fail "origin must have exactly the authoritative fetch and push destination"
candidate=$(git rev-parse --verify HEAD) || fail "cannot identify the candidate"
check_candidate "before validation"
check_unused_version
note_path="docs/releases/$version.md"
note_file=$(mktemp)
trap 'rm -f -- "$note_file"' EXIT
git show "$candidate:$note_path" >"$note_file" 2>/dev/null ||
fail "matching release note is absent from the candidate"
IFS= read -r title <"$note_file" || [[ -n "$title" ]] ||
fail "matching release note is empty"
[[ "$title" == "# ConfigKit $version" ]] || fail "release note title does not match the version"
if remote_main=$(read_remote_ref refs/heads/main); then
:
else
result=$?
((result == 2)) && fail "authoritative main is absent"
fail "cannot read authoritative main; publication status is unknown"
fi
git fetch --no-tags origin refs/heads/main >/dev/null ||
fail "cannot fetch authoritative main"
fetched_main=$(git rev-parse --verify FETCH_HEAD) || fail "cannot inspect fetched main"
git merge-base --is-ancestor "$fetched_main" "$candidate" ||
fail "candidate cannot advance authoritative main without discarding history"
./scripts/release-check.sh || fail "release validation failed; no refs were published"
check_candidate "after validation"
check_unused_version
# Use the named remote whose resolved destinations were checked above: a literal
# URL can apply pushInsteadOf even when origin's explicit push URL does not.
# Disable mirror defaults as well as implicit tags; publish only our refspec.
if git -c remote.origin.mirror=false push --no-follow-tags origin "$candidate:refs/heads/main"; then
:
else
if observed_main=$(read_remote_ref refs/heads/main); then
[[ "$observed_main" == "$candidate" ]] ||
fail "main push did not publish candidate $candidate; authoritative main differs"
else
result=$?
((result == 2)) && fail "main push failed for $candidate; authoritative main is absent"
fail "main push outcome is unknown for $candidate; inspect authoritative main before retrying"
fi
fi
check_candidate "main at $candidate; tag not yet published"
git tag -a -F "$note_file" --cleanup=verbatim "$version" "$candidate" ||
fail "main is at $candidate; could not create local tag $version"
tag_target=$(git rev-parse --verify "refs/tags/$version^{}") ||
fail "main is at $candidate; cannot verify local tag $version"
[[ "$tag_target" == "$candidate" ]] || fail "main is at $candidate; local tag $version targets another commit"
tag_type=$(git cat-file -t "refs/tags/$version") ||
fail "main is at $candidate; cannot inspect local tag $version"
[[ "$tag_type" == tag ]] || fail "main is at $candidate; local tag $version is not annotated"
tag_oid=$(git rev-parse --verify "refs/tags/$version") ||
fail "main is at $candidate; cannot identify local tag $version"
if git -c remote.origin.mirror=false push --no-follow-tags origin "refs/tags/$version:refs/tags/$version"; then
:
else
if observed_tag=$(read_remote_ref "refs/tags/$version"); then
[[ "$observed_tag" == "$tag_oid" ]] ||
fail "authoritative tag $version conflicts with the local tag at $candidate; never replace it"
else
result=$?
((result == 2)) &&
fail "tag push failed; main is at $candidate, but local tag $version is unpublished"
fail "tag $version push outcome is unknown for $candidate; retain the local tag and inspect authoritative state"
fi
fi
printf 'Published %s at %s\n' "$version" "$candidate"

159
startup_bench_test.go Normal file
View File

@@ -0,0 +1,159 @@
package configkit_test
import (
"fmt"
"net/url"
"slices"
"strings"
"testing"
configkit "gitea.maximumdirect.net/eric/ConfigKit"
)
// These synthetic types retain the optional-field shape of a catalog consumer
// without importing its adapter, template compiler, or runtime registries.
type startupCatalog struct {
Profiles map[string]startupProfile `yaml:"profiles"`
Prompts map[string]map[string]startupPrompt `yaml:"prompts"`
}
type startupProfile struct {
Backend *string `yaml:"backend"`
Endpoint *string `yaml:"endpoint"`
Model *string `yaml:"model"`
Temperature *float64 `yaml:"temperature"`
TopP *float64 `yaml:"top_p"`
MaxTokens *int `yaml:"max_tokens"`
TimeoutSeconds *int `yaml:"timeout_seconds"`
ServiceTier *string `yaml:"service_tier"`
ReasoningEffort *string `yaml:"reasoning_effort"`
APIKeyEnv *string `yaml:"api_key_env"`
APIKeyRequired *bool `yaml:"api_key_required"`
ExtraParams map[string]any `yaml:"extra_params"`
}
type startupPrompt struct {
DefaultProfile *string `yaml:"default_profile"`
Description *string `yaml:"description"`
SessionID *string `yaml:"session_id"`
Inputs []startupInput `yaml:"inputs"`
Messages []startupMessage `yaml:"messages"`
Output *startupOutput `yaml:"output"`
}
type startupInput struct {
Name string `yaml:"name"`
Required bool `yaml:"required"`
ContentType *string `yaml:"content_type"`
Description *string `yaml:"description"`
}
type startupMessage struct {
Role *string `yaml:"role"`
Content *string `yaml:"content"`
ContentFile *string `yaml:"content_file"`
}
type startupOutput struct {
Format *string `yaml:"format"`
ValidationMode *string `yaml:"validation_mode"`
SchemaPath *string `yaml:"schema_path"`
RepairAttempts int `yaml:"repair_attempts"`
}
type startupPresence struct{ explicit, null bool }
var startupResult *configkit.Result[startupCatalog]
func BenchmarkStartupConstruction(b *testing.B) {
for _, count := range []int{32, 128, 512} {
b.Run(fmt.Sprint(count), func(b *testing.B) {
profiles, prompts := make(map[string]any, count), make(map[string]any, count)
for index := range count {
profileID := fmt.Sprintf("profile-%03d", index)
promptID := fmt.Sprintf("prompt-%03d", index)
profiles[profileID] = map[string]any{"endpoint": "https://example.test/v1", "model": profileID}
prompts[promptID] = map[string]any{"1": map[string]any{
"default_profile": profileID,
"messages": []any{map[string]any{"role": "user", "content": "Hello {{.name}}"}},
"output": map[string]any{"format": "text", "validation_mode": "none"},
}}
}
// Mapping captures the source once outside timing, matching the
// downstream benchmark's reusable input. Each load still owns its result.
main := configkit.Input{Source: configkit.Mapping("startup catalog", map[string]any{
"profiles": profiles, "prompts": prompts,
})}
var captured map[string]map[string]startupPresence
validated := false
options := configkit.LayeredOptions[startupCatalog]{Definition: configkit.Definition[startupCatalog]{
// Include a simple path-based classifier, as catalog consumers use
// this callback even when the representative input has no credentials.
Secret: func(path configkit.Path) bool {
return strings.HasSuffix(path.String(), "/extra_params/token")
},
Resolve: func(catalog *startupCatalog, resolution *configkit.Resolution) error {
captured = make(map[string]map[string]startupPresence, len(catalog.Profiles))
for id, profile := range catalog.Profiles {
fields := make(map[string]startupPresence, 12)
for _, field := range []string{
"backend", "endpoint", "model", "temperature", "top_p", "max_tokens",
"timeout_seconds", "service_tier", "reasoning_effort", "api_key_env",
"api_key_required", "extra_params",
} {
info, exists := resolution.InputInfo(configkit.Keys("profiles", id, field))
fields[field] = startupPresence{explicit: exists && info.Explicit, null: exists && info.Null}
}
captured[id] = fields
// The representative endpoints are already canonical, so this
// callback, like the consumer's, records no normalization change.
if _, err := url.Parse(*profile.Endpoint); err != nil {
return err
}
}
return nil
},
Validate: func(catalog *startupCatalog, validation *configkit.Validation) {
validated = true
ids := make([]string, 0, len(catalog.Profiles))
for id := range catalog.Profiles {
ids = append(ids, id)
}
slices.Sort(ids)
for _, id := range ids {
profile := catalog.Profiles[id]
if profile.Model == nil || strings.TrimSpace(*profile.Model) == "" {
validation.Error(configkit.Keys("profiles", id, "model"), "model is required")
}
}
},
}}
// Check the measured workload before timing, including numeric mapping
// keys, sequence paths, callback presence capture, and published metadata.
result, err := configkit.LoadLayered(main, options)
if err != nil {
b.Fatal(err)
}
if !validated || len(result.Config.Profiles) != count || len(result.Config.Prompts) != count || len(captured) != count {
b.Fatal("startup fixture did not exercise the expected lifecycle")
}
fields := captured["profile-000"]
if !fields["endpoint"].explicit || !fields["model"].explicit || fields["temperature"].explicit {
b.Fatal("startup fixture presence capture disagrees with input")
}
path := configkit.Keys("prompts", "prompt-000", "1", "messages").Index(0).Key("content")
if info, exists := result.Metadata().Lookup(path); !exists || !info.Explicit || info.Null {
b.Fatal("startup fixture message provenance is missing")
}
b.ReportAllocs()
for b.Loop() {
result, err := configkit.LoadLayered(main, options)
if err != nil {
b.Fatal(err)
}
startupResult = result
}
b.ReportMetric(float64(count*2), "definitions/op")
})
}
}