Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b3e7b62119 |
@@ -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
|
metadata while retaining their base definition and the parent replacement operation
|
||||||
in history. All metadata queries and returned nested records are detached.
|
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
|
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
|
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;
|
query uses the ancestor's path for a whole-container or whole-sequence replacement;
|
||||||
|
|||||||
50
docs/releases/v0.1.2.md
Normal file
50
docs/releases/v0.1.2.md
Normal file
@@ -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.
|
||||||
32
metadata.go
32
metadata.go
@@ -1,6 +1,10 @@
|
|||||||
package configkit
|
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
|
// Metadata provides detached effective presence/origin and chronological history
|
||||||
// queries for a load. Its zero value contains no paths.
|
// queries for a load. Its zero value contains no paths.
|
||||||
@@ -50,6 +54,32 @@ func (m Metadata) History(path Path) []Change {
|
|||||||
return cloned
|
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
|
// 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 {
|
||||||
|
|||||||
@@ -2,9 +2,95 @@ package configkit
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
|
"reflect"
|
||||||
"testing"
|
"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) {
|
func TestMetadataQueriesReturnDetachedRecords(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user