Add semantic paths and safe diagnostics

This commit is contained in:
2026-09-06 19:19:24 +00:00
parent 8e3cc40c78
commit 14b10cb6a5
11 changed files with 936 additions and 45 deletions

239
diagnostic.go Normal file
View File

@@ -0,0 +1,239 @@
package configkit
import (
"cmp"
"fmt"
"io"
"slices"
"strconv"
"strings"
"unicode"
)
// LoadError reports safe structured diagnostics for a failed configuration load.
// It intentionally does not expose or unwrap parser and application errors.
type LoadError struct {
diagnostics []Diagnostic
sources []SourceInfo
}
// Error implements error without rendering diagnostic messages or input values.
func (e *LoadError) Error() string {
if e == nil {
return "configkit: configuration load failed"
}
count := 0
for _, diagnostic := range e.diagnostics {
if diagnostic.Severity == SeverityError {
count++
}
}
if count == 1 {
return "configkit: configuration load failed with 1 error"
}
return fmt.Sprintf("configkit: configuration load failed with %d errors", count)
}
// Diagnostics returns a deep copy of the error's canonical diagnostics.
func (e *LoadError) Diagnostics() []Diagnostic {
if e == nil {
return nil
}
return cloneDiagnostics(e.diagnostics)
}
// Sources returns a deep copy of sources discovered before loading failed.
func (e *LoadError) Sources() []SourceInfo {
if e == nil {
return nil
}
return cloneSourceInfos(e.sources)
}
// WriteDiagnostics writes diagnostics in canonical order. It escapes terminal
// control characters and propagates writer errors, including short writes.
func WriteDiagnostics(output io.Writer, diagnostics []Diagnostic) error {
canonical := canonicalDiagnostics(diagnostics)
for _, diagnostic := range canonical {
if err := writeDiagnostic(output, diagnostic); err != nil {
return err
}
}
return nil
}
type diagnosticAccumulator struct {
diagnostics []Diagnostic
sources []SourceInfo
}
func (a *diagnosticAccumulator) add(diagnostics ...Diagnostic) {
for _, diagnostic := range diagnostics {
a.diagnostics = append(a.diagnostics, cloneDiagnostic(diagnostic))
}
}
func (a *diagnosticAccumulator) loadError() *LoadError {
return &LoadError{
diagnostics: canonicalDiagnostics(a.diagnostics),
sources: cloneSourceInfos(a.sources),
}
}
func canonicalDiagnostics(diagnostics []Diagnostic) []Diagnostic {
canonical := cloneDiagnostics(diagnostics)
for i := range canonical {
slices.SortFunc(canonical[i].Related, compareLocation)
}
slices.SortFunc(canonical, compareDiagnostic)
return canonical
}
func compareDiagnostic(left, right Diagnostic) int {
if order := compareOptionalLocation(left.Primary, right.Primary); order != 0 {
return order
}
if order := cmp.Compare(left.Path.String(), right.Path.String()); order != 0 {
return order
}
if order := cmp.Compare(left.Code, right.Code); order != 0 {
return order
}
if order := cmp.Compare(left.Severity, right.Severity); order != 0 {
return order
}
if order := cmp.Compare(left.Message, right.Message); order != 0 {
return order
}
return compareLocations(left.Related, right.Related)
}
func compareOptionalLocation(left, right *Location) int {
if left == nil && right == nil {
return 0
}
if left == nil {
return -1
}
if right == nil {
return 1
}
return compareLocation(*left, *right)
}
func compareLocations(left, right []Location) int {
for i := 0; i < min(len(left), len(right)); i++ {
if order := compareLocation(left[i], right[i]); order != 0 {
return order
}
}
return cmp.Compare(len(left), len(right))
}
func compareLocation(left, right Location) int {
if order := compareSourceID(left.SourceID, right.SourceID); order != 0 {
return order
}
if order := cmp.Compare(left.Line, right.Line); order != 0 {
return order
}
return cmp.Compare(left.Column, right.Column)
}
func compareSourceID(left, right string) int {
leftOrder, leftOK := sourceSequence(left)
rightOrder, rightOK := sourceSequence(right)
if leftOK && rightOK {
return cmp.Compare(leftOrder, rightOrder)
}
return cmp.Compare(left, right)
}
func sourceSequence(sourceID string) (int, bool) {
const prefix = "source-"
if !strings.HasPrefix(sourceID, prefix) {
return 0, false
}
value, err := strconv.Atoi(strings.TrimPrefix(sourceID, prefix))
if err != nil || value < 1 {
return 0, false
}
return value, true
}
func writeDiagnostic(output io.Writer, diagnostic Diagnostic) error {
severity := diagnostic.Severity
if severity == "" {
severity = SeverityError
}
path := diagnostic.Path.String()
if path == "" {
path = "<root>"
}
var b strings.Builder
b.WriteString(escapeControls(string(severity)))
if diagnostic.Code != "" {
b.WriteString(" [")
b.WriteString(escapeControls(string(diagnostic.Code)))
b.WriteByte(']')
}
b.WriteByte(' ')
b.WriteString(escapeControls(path))
if diagnostic.Primary != nil {
b.WriteString(" (")
writeLocationText(&b, *diagnostic.Primary)
b.WriteByte(')')
}
if diagnostic.Message != "" {
b.WriteString(": ")
b.WriteString(escapeControls(diagnostic.Message))
}
b.WriteByte('\n')
for _, related := range diagnostic.Related {
b.WriteString(" related: ")
writeLocationText(&b, related)
b.WriteByte('\n')
}
return writeString(output, b.String())
}
func writeLocationText(output *strings.Builder, location Location) {
label := location.SourceID
if label == "" {
label = "<synthetic>"
}
output.WriteString(escapeControls(label))
if location.Line > 0 {
output.WriteByte(':')
output.WriteString(strconv.Itoa(location.Line))
if location.Column > 0 {
output.WriteByte(':')
output.WriteString(strconv.Itoa(location.Column))
}
}
}
func writeString(output io.Writer, value string) error {
written, err := io.WriteString(output, value)
if err != nil {
return err
}
if written != len(value) {
return io.ErrShortWrite
}
return nil
}
func escapeControls(value string) string {
var b strings.Builder
for _, r := range value {
if unicode.IsControl(r) {
quoted := strconv.QuoteRune(r)
b.WriteString(quoted[1 : len(quoted)-1])
continue
}
b.WriteRune(r)
}
return b.String()
}

143
diagnostic_test.go Normal file
View File

@@ -0,0 +1,143 @@
package configkit
import (
"bytes"
"errors"
"io"
"strings"
"testing"
)
func TestDiagnosticsAreCanonicalAndDetached(t *testing.T) {
t.Parallel()
diagnostics := []Diagnostic{
{
Code: "decode.type",
Severity: SeverityError,
Path: Keys("later"),
Message: "later",
Primary: &Location{SourceID: "source-10", Line: 1, Column: 1},
},
{
Code: "schema.invalid",
Severity: SeverityError,
Path: Keys("root"),
Message: "source-less",
},
{
Code: "decode.type",
Severity: SeverityError,
Path: Keys("earlier"),
Message: "earlier",
Primary: &Location{SourceID: "source-2", Line: 3, Column: 4},
Related: []Location{
{SourceID: "source-10", Line: 1, Column: 1},
{SourceID: "source-2", Line: 9, Column: 1},
},
},
}
var forward bytes.Buffer
if err := WriteDiagnostics(&forward, diagnostics); err != nil {
t.Fatal(err)
}
reversed := []Diagnostic{diagnostics[2], diagnostics[1], diagnostics[0]}
var backward bytes.Buffer
if err := WriteDiagnostics(&backward, reversed); err != nil {
t.Fatal(err)
}
if forward.String() != backward.String() {
t.Fatalf("rendering depends on submission order:\n%s\n---\n%s", forward.String(), backward.String())
}
lines := strings.Split(forward.String(), "\n")
if !strings.Contains(lines[0], "source-less") || !strings.Contains(lines[1], "source-2:3:4") {
t.Fatalf("unexpected canonical order:\n%s", forward.String())
}
if !strings.Contains(lines[2], "source-2:9:1") || !strings.Contains(lines[3], "source-10:1:1") {
t.Fatalf("related locations not canonical:\n%s", forward.String())
}
accumulator := diagnosticAccumulator{
sources: []SourceInfo{{
ID: "source-1",
Label: "config.yml",
ImportedFrom: &Location{SourceID: "source-0", Line: 1},
}},
}
accumulator.add(diagnostics...)
loadErr := accumulator.loadError()
var found *LoadError
if !errors.As(error(loadErr), &found) || found != loadErr {
t.Fatal("errors.As did not find LoadError")
}
first := loadErr.Diagnostics()
first[1].Primary.SourceID = "mutated"
first[1].Path.segments[0].key = "mutated"
first[1].Related[0].SourceID = "mutated"
second := loadErr.Diagnostics()
if second[1].Primary.SourceID != "source-2" || second[1].Path.String() != "/earlier" || second[1].Related[0].SourceID != "source-2" {
t.Fatal("Diagnostics returned aliased records")
}
sources := loadErr.Sources()
sources[0].ImportedFrom.SourceID = "mutated"
if loadErr.Sources()[0].ImportedFrom.SourceID != "source-0" {
t.Fatal("Sources returned aliased records")
}
if got := loadErr.Error(); got != "configkit: configuration load failed with 3 errors" {
t.Fatalf("Error() = %q", got)
}
}
func TestWriteDiagnosticsEscapesControls(t *testing.T) {
t.Parallel()
diagnostic := Diagnostic{
Code: "validate.error",
Severity: SeverityError,
Path: Keys("line\nbreak"),
Message: "message\r\nnext",
Primary: &Location{SourceID: "source\tlabel", Line: 1},
}
var output bytes.Buffer
if err := WriteDiagnostics(&output, []Diagnostic{diagnostic}); err != nil {
t.Fatal(err)
}
got := output.String()
if strings.Count(got, "\n") != 1 {
t.Fatalf("control characters created output lines: %q", got)
}
for _, escaped := range []string{`line\nbreak`, `message\r\nnext`, `source\tlabel`} {
if !strings.Contains(got, escaped) {
t.Errorf("output %q does not contain %q", got, escaped)
}
}
}
func TestWriteDiagnosticsPropagatesWriterFailures(t *testing.T) {
t.Parallel()
diagnostics := []Diagnostic{{Severity: SeverityError, Message: "safe"}}
want := errors.New("write failed")
if err := WriteDiagnostics(errorWriter{err: want}, diagnostics); !errors.Is(err, want) {
t.Fatalf("error writer returned %v", err)
}
if err := WriteDiagnostics(shortWriter{}, diagnostics); !errors.Is(err, io.ErrShortWrite) {
t.Fatalf("short writer returned %v", err)
}
}
type errorWriter struct {
err error
}
func (w errorWriter) Write(p []byte) (int, error) {
return min(3, len(p)), w.err
}
type shortWriter struct{}
func (shortWriter) Write(p []byte) (int, error) {
return max(0, len(p)-1), nil
}

11
doc.go
View File

@@ -1,7 +1,8 @@
// Package configkit will provide deterministic, application-owned configuration
// loading for Go programs.
// Package configkit provides foundational types for deterministic,
// application-owned configuration loading in Go programs.
//
// The package currently establishes the ConfigKit module boundary only. Loading,
// decoding, composition, provenance, and inspection APIs will be introduced with
// their implemented contracts; no usable configuration loader is available yet.
// Semantic paths, metadata and provenance records, safe diagnostics, and structured
// load errors are available. Source, parsing, decoding, composition, and loading
// operations will be introduced with their implemented contracts; no usable
// configuration loader is available yet.
package configkit

View File

@@ -1,8 +1,14 @@
# Internal Component Overview
ConfigKit currently contains the root `configkit` package, which establishes the
module and package documentation boundary. It intentionally exposes no loading API
or implemented configuration behavior yet.
module and package documentation boundary. Its implemented public primitives are:
- immutable semantic `Path` construction and strict JSON Pointer parsing;
- detached provenance and diagnostic record types with empty `Metadata` queries;
- canonical, control-safe diagnostic rendering and structured `LoadError` access.
It intentionally exposes no loading, source, parsing, or decoding API yet. Private
metadata population will be integrated with loading when that lifecycle exists.
The source-extraction inventory records the application foundations assigned to
future implementation work:

View File

@@ -2,7 +2,7 @@
## Status and ownership
Status: implementation in progress; Stage 1 is complete and later stages are
Status: implementation in progress; Stages 1–2 are complete and later stages are
pending.
Audience: a `gpt-5.6-terra` coding agent implementing one stage per prompt, in
@@ -333,7 +333,25 @@ Acceptance:
## Stage 2 — Implement semantic paths and safe diagnostic records
Status: pending.
Status: complete.
Completion evidence (2026-09-06):
- Implemented immutable key/index paths, strict JSON Pointer parsing, canonical
spelling, parsed numeric-token preservation, and private traversal intent in
[`path.go`](../../path.go).
- Implemented detached metadata/provenance records and empty/unknown query behavior
in [`records.go`](../../records.go) and [`metadata.go`](../../metadata.go).
- Implemented canonical diagnostic ordering, related-location normalization,
control-safe rendering, short/error writer propagation, and defensive
`LoadError` access in [`diagnostic.go`](../../diagnostic.go). No raw values,
parser errors, source parsing, or loading surface was introduced.
- Added focused tests for escaped slash/tilde/empty/dotted/numeric keys, literal
dot/percent tokens, malformed escapes, builder immutability, detached nested
records, `errors.As`, ordering independence, control escaping, and failing/short
writers. Verified `go test ./...`, `go vet ./...`, `go build ./...`, and
`git diff --check` with Go 1.26.7 on linux/amd64. No deviations were required;
metadata population and configuration lifecycle behavior remain later work.
Read: feature [paths](initial.md#paths-and-presence),
[diagnostics](initial.md#provenance-diagnostics-and-redaction), public metadata API.

View File

@@ -451,16 +451,10 @@ large integers are not rounded through an intermediate floating-point value.
## Paths and presence
Use structured key/index segments internally. Public textual paths use
JSON Pointer escaping (`~0` for tilde and `~1` for slash), with the empty string
for the root. For example, `/transcription/concurrency` names a nested field;
`/outputs/a.b` addresses the literal key `a.b`. Numeric segments are interpreted
using the containing node's kind, distinguishing mapping keys from list indices.
Use YAML field names, not Go field names.
Semantic paths are not filesystem paths or URLs. Empty keys, literal `.`/`..`
keys, and percent sequences retain their meaning; do not clean path components
or percent-decode them. Only JSON Pointer escape processing applies.
Semantic path construction, JSON Pointer parsing, and current metadata query
declarations are implemented. Their current contracts are owned by the Go
documentation in [`path.go`](../../path.go) and [`metadata.go`](../../metadata.go).
The remaining presence behavior below is target lifecycle work.
Distinguish absent, explicitly null, explicitly zero/false/empty, and other
explicit values. An explicit value equal to its default remains explicit.
@@ -680,6 +674,11 @@ is outside this lifecycle. Do not run later hooks on an invalid typed value.
## Provenance, diagnostics, and redaction
Safe diagnostic/provenance value records, canonical diagnostic rendering, and
`LoadError` access are implemented. Their current contracts are owned by
[`records.go`](../../records.go) and [`diagnostic.go`](../../diagnostic.go).
Attaching those records to the loading lifecycle remains target work below.
Retain input sources, layer, line/column where available, import sites, effective
origins, overridden definition records, and resolution operations. Container
origins may be composite; never invent one file location for a mapping assembled
@@ -866,32 +865,11 @@ func ReadWorkerDefaults() (*configkit.Result[Config], error) {
### Paths and metadata
```go
func Keys(keys ...string) Path
func ParsePath(pointer string) (Path, error)
func (p Path) Key(key string) Path
func (p Path) Index(index int) Path
func (p Path) String() string
func (m Metadata) Lookup(path Path) (FieldInfo, bool)
func (m Metadata) IsExplicit(path Path) bool
func (m Metadata) History(path Path) []Change
```
`Path` is an immutable key/index representation with the textual form specified
in [paths and presence](#paths-and-presence). `Keys()` is the root. Key/index
builders escape dynamic names correctly; applications should not concatenate
pointer strings. Invalid indices and paths supplied to hooks become structured
errors. Path spelling uses YAML names throughout.
`FieldInfo` reports explicitness, null state, and effective `Origin`. An origin
identifies defaults, input, or application resolution and associated source
locations; it may be composite. `Change` reports layer/operation, prior origins,
safe reasons, dependency paths, and related locations. Safe historical values
are available through `Explain`, rather than unrestricted raw-value references. These are library-owned metadata types, never parser nodes. Queries
return copies or immutable views. Unknown paths yield no lookup/history and
false explicitness. Callback mutation/validation paths are checked more strictly:
misspelled struct fields are errors, while valid missing map entries are allowed.
The path API, detached metadata records, and empty/unknown query behavior are now
implemented; [`path.go`](../../path.go), [`records.go`](../../records.go), and
[`metadata.go`](../../metadata.go) own their current Go contracts. Populating
effective presence, origins, and history belongs to the loading stages. Callback
path checking and safe historical values remain target behavior below.
### Resolution and validation callbacks

45
metadata.go Normal file
View File

@@ -0,0 +1,45 @@
package configkit
// Metadata provides detached presence, origin, and history queries for a load.
// Its zero value contains no paths.
type Metadata struct {
fields map[string]FieldInfo
history map[string][]Change
}
// Lookup returns information for an effective path. Unknown and invalid paths
// return false.
func (m Metadata) Lookup(path Path) (FieldInfo, bool) {
if !path.valid() {
return FieldInfo{}, false
}
info, ok := m.fields[path.String()]
if !ok {
return FieldInfo{}, false
}
return cloneFieldInfo(info), true
}
// IsExplicit reports whether a surviving input contribution explicitly defined
// path.
func (m Metadata) IsExplicit(path Path) bool {
info, ok := m.Lookup(path)
return ok && info.Explicit
}
// History returns detached definition and resolution records for path. Unknown and
// invalid paths return nil.
func (m Metadata) History(path Path) []Change {
if !path.valid() {
return nil
}
history, ok := m.history[path.String()]
if !ok {
return nil
}
cloned := make([]Change, len(history))
for i, change := range history {
cloned[i] = cloneChange(change)
}
return cloned
}

58
metadata_test.go Normal file
View File

@@ -0,0 +1,58 @@
package configkit
import "testing"
func TestMetadataQueriesReturnDetachedRecords(t *testing.T) {
t.Parallel()
path := Keys("service", "workers")
metadata := Metadata{
fields: map[string]FieldInfo{
path.String(): {
Explicit: true,
Origin: Origin{
Layer: LayerBase,
Locations: []Location{{SourceID: "source-1", Line: 2, Column: 3}},
},
},
},
history: map[string][]Change{
path.String(): {{
Path: path,
Origin: Origin{Layer: LayerResolution, Locations: []Location{{SourceID: "source-1"}}},
Previous: []Origin{{Layer: LayerDefault, Locations: []Location{{SourceID: "default"}}}},
Reasons: []string{"bounded worker count"},
Dependencies: []Path{Keys("limits")},
}},
},
}
info, ok := metadata.Lookup(path)
if !ok || !info.Explicit || !metadata.IsExplicit(path) {
t.Fatalf("Lookup() = %#v, %v", info, ok)
}
info.Origin.Locations[0].SourceID = "mutated"
again, _ := metadata.Lookup(path)
if again.Origin.Locations[0].SourceID != "source-1" {
t.Fatal("Lookup returned aliased locations")
}
history := metadata.History(path)
history[0].Reasons[0] = "mutated"
history[0].Previous[0].Locations[0].SourceID = "mutated"
history[0].Dependencies[0].segments[0].key = "mutated"
againHistory := metadata.History(path)
if againHistory[0].Reasons[0] != "bounded worker count" ||
againHistory[0].Previous[0].Locations[0].SourceID != "default" ||
againHistory[0].Dependencies[0].String() != "/limits" {
t.Fatal("History returned aliased nested records")
}
missing := Keys("missing")
if _, ok := metadata.Lookup(missing); ok || metadata.IsExplicit(missing) || metadata.History(missing) != nil {
t.Fatal("unknown path produced metadata")
}
if _, ok := metadata.Lookup(Path{}.Index(-1)); ok {
t.Fatal("invalid path produced metadata")
}
}

148
path.go Normal file
View File

@@ -0,0 +1,148 @@
package configkit
import (
"errors"
"strconv"
"strings"
)
// Path identifies a configuration value using YAML field names. Its textual form
// is a JSON Pointer: the root is empty, ~ escapes as ~0, and / escapes as ~1.
// Paths are semantic rather than filesystem or URL paths: empty, dot, dot-dot,
// percent-containing, and numeric keys retain their literal meaning. Parsed
// numeric tokens are resolved only when traversal knows their containing type.
//
// Paths are immutable. The zero value is the root path.
type Path struct {
segments []pathSegment
invalid bool
}
type pathSegmentKind uint8
const (
pathSegmentKey pathSegmentKind = iota
pathSegmentIndex
pathSegmentParsed
)
type pathSegment struct {
kind pathSegmentKind
key string
index int
}
// Keys constructs a path of mapping keys. Keys with no arguments returns the
// root path.
func Keys(keys ...string) Path {
p := Path{}
for _, key := range keys {
p = p.Key(key)
}
return p
}
// ParsePath parses a JSON Pointer. Numeric tokens remain unresolved until schema
// or document traversal determines whether the containing value is a mapping or
// sequence.
func ParsePath(pointer string) (Path, error) {
if pointer == "" {
return Path{}, nil
}
if pointer[0] != '/' {
return Path{}, errors.New("configkit: path must be empty or begin with slash")
}
parts := strings.Split(pointer[1:], "/")
segments := make([]pathSegment, 0, len(parts))
for _, part := range parts {
key, ok := unescapePathToken(part)
if !ok {
return Path{}, errors.New("configkit: path contains an invalid tilde escape")
}
segments = append(segments, pathSegment{kind: pathSegmentParsed, key: key})
}
return Path{segments: segments}, nil
}
// Key returns a new path with key appended as a mapping-key segment.
func (p Path) Key(key string) Path {
segments := clonePathSegments(p.segments, 1)
segments = append(segments, pathSegment{kind: pathSegmentKey, key: key})
return Path{segments: segments, invalid: p.invalid}
}
// Index returns a new path with index appended as a sequence-index segment.
// A negative index produces an invalid path that configuration operations reject.
func (p Path) Index(index int) Path {
segments := clonePathSegments(p.segments, 1)
segments = append(segments, pathSegment{kind: pathSegmentIndex, index: index})
return Path{segments: segments, invalid: p.invalid || index < 0}
}
// String returns the path's canonical JSON Pointer spelling.
func (p Path) String() string {
if len(p.segments) == 0 {
return ""
}
var b strings.Builder
for _, segment := range p.segments {
b.WriteByte('/')
if segment.kind == pathSegmentIndex {
b.WriteString(strconv.Itoa(segment.index))
continue
}
b.WriteString(escapePathToken(segment.key))
}
return b.String()
}
func (p Path) clone() Path {
return Path{segments: clonePathSegments(p.segments, 0), invalid: p.invalid}
}
func (p Path) valid() bool {
return !p.invalid
}
func clonePathSegments(segments []pathSegment, extra int) []pathSegment {
if len(segments) == 0 && extra == 0 {
return nil
}
cloned := make([]pathSegment, len(segments), len(segments)+extra)
copy(cloned, segments)
return cloned
}
func escapePathToken(token string) string {
token = strings.ReplaceAll(token, "~", "~0")
return strings.ReplaceAll(token, "/", "~1")
}
func unescapePathToken(token string) (string, bool) {
if !strings.Contains(token, "~") {
return token, true
}
var b strings.Builder
for i := 0; i < len(token); i++ {
if token[i] != '~' {
b.WriteByte(token[i])
continue
}
if i+1 >= len(token) {
return "", false
}
i++
switch token[i] {
case '0':
b.WriteByte('~')
case '1':
b.WriteByte('/')
default:
return "", false
}
}
return b.String(), true
}

88
path_test.go Normal file
View File

@@ -0,0 +1,88 @@
package configkit
import "testing"
func TestPathRoundTripsSemanticKeys(t *testing.T) {
t.Parallel()
tests := []struct {
name string
pointer string
}{
{name: "root", pointer: ""},
{name: "slash and tilde", pointer: "/a~1b/c~0d"},
{name: "empty key", pointer: "/"},
{name: "dotted key", pointer: "/outputs/a.b"},
{name: "numeric key", pointer: "/outputs/0"},
{name: "dot keys", pointer: "/./.."},
{name: "percent text", pointer: "/%2F/%7E"},
}
for _, test := range tests {
t.Run(test.name, func(t *testing.T) {
parsed, err := ParsePath(test.pointer)
if err != nil {
t.Fatalf("ParsePath() error = %v", err)
}
if got := parsed.String(); got != test.pointer {
t.Fatalf("String() = %q, want %q", got, test.pointer)
}
})
}
if got := Keys("a/b", "c~d", "", "..").String(); got != "/a~1b/c~0d//.." {
t.Fatalf("Keys().String() = %q", got)
}
if got := Keys("items").Index(12).Key("name").String(); got != "/items/12/name" {
t.Fatalf("indexed path = %q", got)
}
}
func TestPathPreservesSegmentIntentAndImmutability(t *testing.T) {
t.Parallel()
key := Keys("items", "0")
index := Keys("items").Index(0)
parsed, err := ParsePath("/items/0")
if err != nil {
t.Fatal(err)
}
if key.String() != index.String() || index.String() != parsed.String() {
t.Fatal("equivalent textual paths should share a JSON Pointer spelling")
}
if key.segments[1].kind != pathSegmentKey {
t.Fatal("Keys numeric segment lost mapping-key intent")
}
if index.segments[1].kind != pathSegmentIndex {
t.Fatal("Index segment lost sequence intent")
}
if parsed.segments[1].kind != pathSegmentParsed {
t.Fatal("parsed numeric segment was resolved before traversal")
}
base := Keys("parent")
child := base.Key("child")
child.segments[0].key = "changed"
if got := base.String(); got != "/parent" {
t.Fatalf("builder mutated parent: %q", got)
}
negative := Path{}.Index(-1)
if negative.valid() {
t.Fatal("negative index must produce an invalid path")
}
}
func TestParsePathRejectsMalformedPointers(t *testing.T) {
t.Parallel()
for _, pointer := range []string{"relative", "~0", "/bad~", "/bad~2", "/bad~01~"} {
if _, err := ParsePath(pointer); err == nil {
t.Errorf("ParsePath(%q) succeeded", pointer)
}
}
if parsed, err := ParsePath("/missing"); err != nil || !parsed.valid() {
t.Fatalf("valid missing path rejected: path=%v error=%v", parsed, err)
}
}

167
records.go Normal file
View File

@@ -0,0 +1,167 @@
package configkit
// Layer identifies the precedence layer responsible for a configuration value.
type Layer string
const (
LayerDefault Layer = "default"
LayerBase Layer = "base"
LayerProfile Layer = "profile"
LayerCLI Layer = "cli"
LayerResolution Layer = "resolution"
)
// ChangeKind identifies an application resolution operation. The zero value is
// used for input and default origins.
type ChangeKind string
const (
ChangeDerivedDefault ChangeKind = "derived_default"
ChangeNormalization ChangeKind = "normalization"
)
// Severity classifies a diagnostic.
type Severity string
const (
SeverityError Severity = "error"
SeverityWarning Severity = "warning"
)
// Code is a stable, machine-readable diagnostic classification.
type Code string
// Location identifies a position in a configuration source. Zero line and column
// values mean that coordinates are unavailable or synthetic.
type Location struct {
SourceID string
Line int
Column int
}
// SourceInfo describes a source discovered during a load. Path is a canonical
// local path when one exists. ImportedFrom identifies the declaration that
// included this source.
type SourceInfo struct {
ID string
Label string
Path string
Layer Layer
ImportedFrom *Location
}
// Diagnostic is a safe structured problem report. Message and source labels must
// never contain configuration values or other secrets.
type Diagnostic struct {
Code Code
Severity Severity
Path Path
Message string
Primary *Location
Related []Location
}
// Origin describes where an effective definition came from. Multiple locations
// represent a composite origin.
type Origin struct {
Layer Layer
Locations []Location
Operation ChangeKind
}
// FieldInfo describes an effective configuration path.
type FieldInfo struct {
Explicit bool
Null bool
Origin Origin
}
// Change describes a definition or application-resolution operation. Historical
// values are intentionally not exposed by this record.
type Change struct {
Path Path
Origin Origin
Previous []Origin
Reasons []string
Dependencies []Path
}
func cloneLocation(location *Location) *Location {
if location == nil {
return nil
}
cloned := *location
return &cloned
}
func cloneLocations(locations []Location) []Location {
if locations == nil {
return nil
}
return append([]Location(nil), locations...)
}
func cloneSourceInfo(source SourceInfo) SourceInfo {
source.ImportedFrom = cloneLocation(source.ImportedFrom)
return source
}
func cloneSourceInfos(sources []SourceInfo) []SourceInfo {
if sources == nil {
return nil
}
cloned := make([]SourceInfo, len(sources))
for i, source := range sources {
cloned[i] = cloneSourceInfo(source)
}
return cloned
}
func cloneOrigin(origin Origin) Origin {
origin.Locations = cloneLocations(origin.Locations)
return origin
}
func cloneFieldInfo(info FieldInfo) FieldInfo {
info.Origin = cloneOrigin(info.Origin)
return info
}
func cloneChange(change Change) Change {
change.Path = change.Path.clone()
change.Origin = cloneOrigin(change.Origin)
if change.Previous != nil {
previous := make([]Origin, len(change.Previous))
for i, origin := range change.Previous {
previous[i] = cloneOrigin(origin)
}
change.Previous = previous
}
change.Reasons = append([]string(nil), change.Reasons...)
if change.Dependencies != nil {
dependencies := make([]Path, len(change.Dependencies))
for i, path := range change.Dependencies {
dependencies[i] = path.clone()
}
change.Dependencies = dependencies
}
return change
}
func cloneDiagnostic(diagnostic Diagnostic) Diagnostic {
diagnostic.Path = diagnostic.Path.clone()
diagnostic.Primary = cloneLocation(diagnostic.Primary)
diagnostic.Related = cloneLocations(diagnostic.Related)
return diagnostic
}
func cloneDiagnostics(diagnostics []Diagnostic) []Diagnostic {
if diagnostics == nil {
return nil
}
cloned := make([]Diagnostic, len(diagnostics))
for i, diagnostic := range diagnostics {
cloned[i] = cloneDiagnostic(diagnostic)
}
return cloned
}