Add semantic paths and safe diagnostics
This commit is contained in:
239
diagnostic.go
Normal file
239
diagnostic.go
Normal 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
143
diagnostic_test.go
Normal 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
11
doc.go
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
45
metadata.go
Normal 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
58
metadata_test.go
Normal 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
148
path.go
Normal 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
88
path_test.go
Normal 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
167
records.go
Normal 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
|
||||
}
|
||||
Reference in New Issue
Block a user