From b3e7b62119e792f33af2d72336bacfbf767ddf3f Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Mon, 7 Sep 2026 13:02:22 +0000 Subject: [PATCH] Prepare ConfigKit v0.1.2 --- docs/config.md | 7 ++++ docs/releases/v0.1.2.md | 50 ++++++++++++++++++++++++ metadata.go | 32 ++++++++++++++- metadata_test.go | 86 +++++++++++++++++++++++++++++++++++++++++ 4 files changed, 174 insertions(+), 1 deletion(-) create mode 100644 docs/releases/v0.1.2.md diff --git a/docs/config.md b/docs/config.md index 4ed4bc4..249fadb 100644 --- a/docs/config.md +++ b/docs/config.md @@ -99,6 +99,13 @@ equal-valued input. Profile replacement removes displaced descendants from effec metadata while retaining their base definition and the parent replacement operation in history. All metadata queries and returned nested records are detached. +`Metadata.HistoricalPaths` enumerates observed paths, including removed paths, +without reloading input or resolving unselected credentials. Consumers can use +it with `History` to validate ownership of declarations hidden by an overlay; +domain ownership rules remain application responsibilities. The method's Go +documentation in [metadata.go](../metadata.go) defines enumeration and path-ordering +details. + History is chronological: a path can have a synthetic default record, its exact base definition, a profile replacement, and a recorded resolution operation. A descendant query uses the ancestor's path for a whole-container or whole-sequence replacement; diff --git a/docs/releases/v0.1.2.md b/docs/releases/v0.1.2.md new file mode 100644 index 0000000..318db1e --- /dev/null +++ b/docs/releases/v0.1.2.md @@ -0,0 +1,50 @@ +# ConfigKit v0.1.2 + +This release adds safe enumeration of effective and removed metadata paths. + +## Summary + +`Metadata.HistoricalPaths` returns the semantic paths observed during a load, +including paths removed by profiles, CLI overrides, or recorded resolution. This +allows applications to discover hidden declarations and inspect their ownership +through the existing `Metadata.History` API without reloading configuration or +resolving unselected credentials. + +See the [layered metadata contract](../config.md#layered-metadata) for current +behavior. + +## Compatibility + +This is a backward-compatible API addition. Existing APIs, configuration syntax, +diagnostic codes, minimum Go version, and supported platforms are unchanged. +Existing consumers do not need to change their code or configuration. + +Returned paths are detached, unique by canonical JSON Pointer spelling, and sorted +by that spelling. The enumeration includes defaults and the root, exposes no +configuration values, and performs no I/O or secret resolution. + +The private YAML dependency remains pinned to +`go.yaml.in/yaml/v4 v4.0.0-rc.6`, confirmed as the newest available v4 release during +candidate preparation. + +## Upgrade + +No action required. Consumers that want historical path discovery can update with: + +```sh +go get gitea.maximumdirect.net/eric/ConfigKit@v0.1.2 +``` + +Then iterate over `result.Metadata().HistoricalPaths()` and use `History` for +chronological definitions or `Lookup` to determine whether each path remains +effective. + +## Changes + +- Added `Metadata.HistoricalPaths` with detached, deterministic path enumeration. +- Included effective, default, and removed paths while excluding paths that never + existed. +- Added coverage for parent removal, mapping-to-sequence shape replacement, escaped + mapping keys, detached results, zero metadata, composite base ownership, and + avoidance of superseded secret lookup. +- Documented historical path enumeration in the current configuration contract. diff --git a/metadata.go b/metadata.go index eb95624..1d57c23 100644 --- a/metadata.go +++ b/metadata.go @@ -1,6 +1,10 @@ package configkit -import "gitea.maximumdirect.net/eric/ConfigKit/internal/document" +import ( + "sort" + + "gitea.maximumdirect.net/eric/ConfigKit/internal/document" +) // Metadata provides detached effective presence/origin and chronological history // queries for a load. Its zero value contains no paths. @@ -50,6 +54,32 @@ func (m Metadata) History(path Path) []Change { return cloned } +// HistoricalPaths returns the paths observed during loading, including effective +// paths and paths removed by overlays or recorded resolution. It includes default +// paths and the root, but not paths that never existed. The zero Metadata returns +// an empty slice. +// +// Paths are detached, unique by canonical JSON Pointer, and sorted by that +// spelling. Like ParsePath results, numeric tokens are resolved contextually; +// one pointer can identify a mapping key in an earlier layer and a sequence index +// in a later one. Use History to inspect definitions and Lookup to check whether +// a path remains effective. Enumeration exposes no configuration values and does +// not perform another load or resolve secrets. +func (m Metadata) HistoricalPaths() []Path { + pointers := make([]string, 0, len(m.historicalPaths)) + for pointer := range m.historicalPaths { + pointers = append(pointers, pointer) + } + sort.Strings(pointers) + paths := make([]Path, 0, len(pointers)) + for _, pointer := range pointers { + // The index contains only canonical pointers produced by Path.String. + path, _ := ParsePath(pointer) + paths = append(paths, path) + } + return paths +} + // Textual pointers are lookup keys, but explicit key/index kinds remain part of // the query. Candidate paths are constructed from actual document containers. func matchesPathShape(query, candidate Path) bool { diff --git a/metadata_test.go b/metadata_test.go index da86d9d..fa80ecc 100644 --- a/metadata_test.go +++ b/metadata_test.go @@ -2,9 +2,95 @@ package configkit import ( "bytes" + "reflect" "testing" ) +func TestMetadataHistoricalPaths(t *testing.T) { + t.Parallel() + result, err := Load[map[string]any](Bytes("base", []byte("retired: {child: 1}\nshape: {'0': base}\n'a/b~': {'': 2}\n")), Options[map[string]any]{ + Overrides: []Override{ + {Path: Keys("retired"), Value: nil, Flag: "--retired"}, + {Path: Keys("shape"), Value: []any{"replacement"}, Flag: "--shape"}, + }, + }) + if err != nil { + t.Fatal(err) + } + metadata := result.Metadata() + paths := metadata.HistoricalPaths() + var got []string + for _, path := range paths { + got = append(got, path.String()) + if len(metadata.History(path)) == 0 { + t.Fatalf("enumerated path %s has no history", path) + } + } + want := []string{"", "/a~1b~0", "/a~1b~0/", "/retired", "/retired/child", "/shape", "/shape/0"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("historical paths = %v, want %v", got, want) + } + if _, ok := metadata.Lookup(paths[4]); ok { + t.Fatal("removed descendant remains effective") + } + if _, ok := metadata.Lookup(paths[6]); !ok { + t.Fatal("enumerated numeric path cannot query the new sequence shape") + } + // Returned slices and nested paths must not alias retained metadata. + paths[1].segments[0].key = "mutated" + paths[2] = Keys("replacement") + if again := metadata.HistoricalPaths(); again[1].String() != want[1] || again[2].String() != want[2] { + t.Fatal("enumeration returned aliased paths") + } + if len((Metadata{}).HistoricalPaths()) != 0 { + t.Fatal("zero metadata contains paths") + } +} + +func TestHistoricalPathsExposeHiddenBaseOwnershipWithoutResolvingBaseSecrets(t *testing.T) { + t.Setenv("CONFIGKIT_HISTORY_UNUSED_PROD_KEY", "") + t.Setenv("CONFIGKIT_HISTORY_TEST_KEY", "synthetic-test-key") + type config struct { + Credential *Secret `yaml:"credential"` + Definitions map[string]map[string]string `yaml:"definitions"` + } + for _, credential := range []string{"CONFIGKIT_HISTORY_TEST_KEY", "null"} { + t.Run(credential, func(t *testing.T) { + dir := t.TempDir() + root := writeImportFile(t, dir, "main.yml", "configkit:\n imports: [{path: first.yml}, {path: second.yml}]\ncredential: CONFIGKIT_HISTORY_UNUSED_PROD_KEY\n") + writeImportFile(t, dir, "first.yml", "definitions: {recap: {prompt: recap}}\n") + writeImportFile(t, dir, "second.yml", "definitions: {recap: {output: recap.md}}\n") + writeImportFile(t, dir, "profiles/testing.yml", "definitions: null\ncredential: "+credential+"\n") + result, err := Load[config](File(root), Options[config]{Imports: true, Profile: "testing"}) + if err != nil { + t.Fatal(err) + } + metadata := result.Metadata() + found := false + for _, path := range metadata.HistoricalPaths() { + if path.String() != "/definitions/recap" { + continue + } + found = true + owners := map[string]bool{} + for _, change := range metadata.History(path) { + if change.Origin.Layer == LayerBase { + for _, location := range change.Origin.Locations { + owners[location.SourceID] = true + } + } + } + if len(owners) != 2 { + t.Fatalf("hidden declaration owners = %v", owners) + } + } + if !found { + t.Fatal("hidden base declaration was not discoverable") + } + }) + } +} + func TestMetadataQueriesReturnDetachedRecords(t *testing.T) { t.Parallel()