Add public facade value wrappers
This commit is contained in:
@@ -323,6 +323,26 @@ Acceptance: candidate wrapper tests and engine tests pass. Run
|
|||||||
`go test ./internal/facade ./internal/engine` and the everyday check. Lifecycle
|
`go test ./internal/facade ./internal/engine` and the everyday check. Lifecycle
|
||||||
entry points are added in Stage 6; do not add nonfunctional stubs here.
|
entry points are added in Stage 6; do not add nonfunctional stubs here.
|
||||||
|
|
||||||
|
### Completion
|
||||||
|
|
||||||
|
Status: complete (2026-09-12).
|
||||||
|
|
||||||
|
Created the temporary `internal/facade` package (named `configkit`) with
|
||||||
|
root-owned `Path`, `Source`, and `Secret` wrappers; root-owned scalar and
|
||||||
|
provenance records; explicit private conversions; `LoadError`; and
|
||||||
|
`WriteDiagnostics`. Public fields and signatures contain only facade types.
|
||||||
|
`Path` carries its engine value privately without text round-tripping, `Source`
|
||||||
|
retains the engine's source value so copied reader sources preserve their shared
|
||||||
|
claim, and the facade builds the exact-type secret binding locally. No lifecycle
|
||||||
|
entry point or placeholder was added.
|
||||||
|
|
||||||
|
Candidate tests cover semantic path distinctions, source byte snapshotting and
|
||||||
|
copied-reader ownership, external-package type ownership and interfaces, and
|
||||||
|
safe secret formatting/JSON. Linux validation with Go 1.26.7 passed
|
||||||
|
`go test ./internal/facade ./internal/engine` and `./scripts/check.sh`. There
|
||||||
|
were no deviations. The next handoff separates engine publication from its
|
||||||
|
generic result before lifecycle delegation begins.
|
||||||
|
|
||||||
## Stage 5 — Separate engine publication and inspection
|
## Stage 5 — Separate engine publication and inspection
|
||||||
|
|
||||||
Status: not started.
|
Status: not started.
|
||||||
|
|||||||
91
internal/facade/conversion.go
Normal file
91
internal/facade/conversion.go
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
package configkit
|
||||||
|
|
||||||
|
import "gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
|
||||||
|
|
||||||
|
func fromEngineLocation(value engine.Location) Location {
|
||||||
|
return Location{SourceID: value.SourceID, Line: value.Line, Column: value.Column}
|
||||||
|
}
|
||||||
|
func toEngineLocation(value Location) engine.Location {
|
||||||
|
return engine.Location{SourceID: value.SourceID, Line: value.Line, Column: value.Column}
|
||||||
|
}
|
||||||
|
func fromEngineLocationPointer(value *engine.Location) *Location {
|
||||||
|
if value == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := fromEngineLocation(*value)
|
||||||
|
return &result
|
||||||
|
}
|
||||||
|
func toEngineLocationPointer(value *Location) *engine.Location {
|
||||||
|
if value == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := toEngineLocation(*value)
|
||||||
|
return &result
|
||||||
|
}
|
||||||
|
func fromEngineLocations(values []engine.Location) []Location {
|
||||||
|
if values == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := make([]Location, len(values))
|
||||||
|
for i := range values {
|
||||||
|
result[i] = fromEngineLocation(values[i])
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
func toEngineLocations(values []Location) []engine.Location {
|
||||||
|
if values == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := make([]engine.Location, len(values))
|
||||||
|
for i := range values {
|
||||||
|
result[i] = toEngineLocation(values[i])
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
func fromEngineSourceInfo(value engine.SourceInfo) SourceInfo {
|
||||||
|
return SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Layer: Layer(value.Layer), ImportedFrom: fromEngineLocationPointer(value.ImportedFrom)}
|
||||||
|
}
|
||||||
|
func toEngineSourceInfo(value SourceInfo) engine.SourceInfo {
|
||||||
|
return engine.SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Layer: engine.Layer(value.Layer), ImportedFrom: toEngineLocationPointer(value.ImportedFrom)}
|
||||||
|
}
|
||||||
|
func fromEngineOrigin(value engine.Origin) Origin {
|
||||||
|
return Origin{Layer: Layer(value.Layer), Locations: fromEngineLocations(value.Locations), Operation: ChangeKind(value.Operation)}
|
||||||
|
}
|
||||||
|
func toEngineOrigin(value Origin) engine.Origin {
|
||||||
|
return engine.Origin{Layer: engine.Layer(value.Layer), Locations: toEngineLocations(value.Locations), Operation: engine.ChangeKind(value.Operation)}
|
||||||
|
}
|
||||||
|
func fromEngineDiagnostic(value engine.Diagnostic) Diagnostic {
|
||||||
|
return Diagnostic{Code: Code(value.Code), Severity: Severity(value.Severity), Path: fromEnginePath(value.Path), Message: value.Message, Primary: fromEngineLocationPointer(value.Primary), Related: fromEngineLocations(value.Related)}
|
||||||
|
}
|
||||||
|
func toEngineDiagnostic(value Diagnostic) engine.Diagnostic {
|
||||||
|
return engine.Diagnostic{Code: engine.Code(value.Code), Severity: engine.Severity(value.Severity), Path: toEnginePath(value.Path), Message: value.Message, Primary: toEngineLocationPointer(value.Primary), Related: toEngineLocations(value.Related)}
|
||||||
|
}
|
||||||
|
func toEngineDiagnostics(values []Diagnostic) []engine.Diagnostic {
|
||||||
|
if values == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
result := make([]engine.Diagnostic, len(values))
|
||||||
|
for i := range values {
|
||||||
|
result[i] = toEngineDiagnostic(values[i])
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
func fromEngineFieldInfo(value engine.FieldInfo) FieldInfo {
|
||||||
|
return FieldInfo{Explicit: value.Explicit, Null: value.Null, Origin: fromEngineOrigin(value.Origin)}
|
||||||
|
}
|
||||||
|
func fromEngineChange(value engine.Change) Change {
|
||||||
|
result := Change{Path: fromEnginePath(value.Path), Origin: fromEngineOrigin(value.Origin), Reasons: append([]string(nil), value.Reasons...)}
|
||||||
|
if value.Previous != nil {
|
||||||
|
result.Previous = make([]Origin, len(value.Previous))
|
||||||
|
for i := range value.Previous {
|
||||||
|
result.Previous[i] = fromEngineOrigin(value.Previous[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if value.Dependencies != nil {
|
||||||
|
result.Dependencies = make([]Path, len(value.Dependencies))
|
||||||
|
for i := range value.Dependencies {
|
||||||
|
result.Dependencies[i] = fromEnginePath(value.Dependencies[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
44
internal/facade/diagnostic.go
Normal file
44
internal/facade/diagnostic.go
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
package configkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
|
||||||
|
)
|
||||||
|
|
||||||
|
// LoadError reports safe structured diagnostics for a failed configuration load.
|
||||||
|
type LoadError struct{ value *engine.LoadError }
|
||||||
|
|
||||||
|
func (e *LoadError) Error() string {
|
||||||
|
if e == nil || e.value == nil {
|
||||||
|
return "configkit: configuration load failed"
|
||||||
|
}
|
||||||
|
return e.value.Error()
|
||||||
|
}
|
||||||
|
func (e *LoadError) Diagnostics() []Diagnostic {
|
||||||
|
if e == nil || e.value == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
values := e.value.Diagnostics()
|
||||||
|
result := make([]Diagnostic, len(values))
|
||||||
|
for i := range values {
|
||||||
|
result[i] = fromEngineDiagnostic(values[i])
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
func (e *LoadError) Sources() []SourceInfo {
|
||||||
|
if e == nil || e.value == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
values := e.value.Sources()
|
||||||
|
result := make([]SourceInfo, len(values))
|
||||||
|
for i := range values {
|
||||||
|
result[i] = fromEngineSourceInfo(values[i])
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// WriteDiagnostics writes diagnostics in canonical order.
|
||||||
|
func WriteDiagnostics(output io.Writer, diagnostics []Diagnostic) error {
|
||||||
|
return engine.WriteDiagnostics(output, toEngineDiagnostics(diagnostics))
|
||||||
|
}
|
||||||
43
internal/facade/facade_external_test.go
Normal file
43
internal/facade/facade_external_test.go
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
package configkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"reflect"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
configkit "gitea.maximumdirect.net/eric/ConfigKit/internal/facade"
|
||||||
|
)
|
||||||
|
|
||||||
|
var (
|
||||||
|
_ error = (*configkit.LoadError)(nil)
|
||||||
|
_ fmt.Formatter = configkit.Secret{}
|
||||||
|
_ fmt.Stringer = configkit.Path{}
|
||||||
|
_ json.Marshaler = configkit.Secret{}
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestFacadeValuesRemainRootOwned(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
const packagePath = "gitea.maximumdirect.net/eric/ConfigKit/internal/facade"
|
||||||
|
for _, candidate := range []reflect.Type{
|
||||||
|
reflect.TypeFor[configkit.Path](), reflect.TypeFor[configkit.Source](),
|
||||||
|
reflect.TypeFor[configkit.Secret](), reflect.TypeFor[configkit.Diagnostic](),
|
||||||
|
reflect.TypeFor[configkit.LoadError](),
|
||||||
|
} {
|
||||||
|
if candidate.PkgPath() != packagePath {
|
||||||
|
t.Fatalf("type %s belongs to %q", candidate, candidate.PkgPath())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
secret := configkit.SecretEnv("SAFE_NAME")
|
||||||
|
formatted := fmt.Sprintf("%#v", secret)
|
||||||
|
encoded, err := json.Marshal(secret)
|
||||||
|
if formatted == "" || err != nil || string(encoded) != `"SAFE_NAME"` {
|
||||||
|
t.Fatalf("secret rendering = %q, %s, %v", formatted, encoded, err)
|
||||||
|
}
|
||||||
|
if got := configkit.Keys("a/b~").Index(0).String(); got != "/a~1b~0/0" {
|
||||||
|
t.Fatalf("path = %q", got)
|
||||||
|
}
|
||||||
|
if got := (&configkit.LoadError{}).Error(); got != "configkit: configuration load failed" {
|
||||||
|
t.Fatalf("zero LoadError = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
52
internal/facade/facade_test.go
Normal file
52
internal/facade/facade_test.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
package configkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestWrappedSourcesPreserveEngineOwnership(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
type config struct {
|
||||||
|
Value string `yaml:"value"`
|
||||||
|
}
|
||||||
|
data := []byte("value: retained\n")
|
||||||
|
source := Bytes("snapshot.yml", data)
|
||||||
|
data[7] = 'X'
|
||||||
|
result, err := engine.Load[config](source.toEngine(), engine.SecretBinding{})
|
||||||
|
if err != nil || result.Config.Value != "retained" {
|
||||||
|
t.Fatalf("snapshotted source = %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
readerSource := Reader("reader.yml", strings.NewReader("value: once\n"))
|
||||||
|
first := readerSource
|
||||||
|
if _, err := engine.Load[config](first.toEngine(), engine.SecretBinding{}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
_, err = engine.Load[config](readerSource.toEngine(), engine.SecretBinding{})
|
||||||
|
var loadError *engine.LoadError
|
||||||
|
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != engine.CodeSourceReaderUsed {
|
||||||
|
t.Fatalf("copied reader source error = %#v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestWrappedPathsRetainEngineSegmentIntent(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
parsed, err := ParsePath("/0")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
keyed, indexed := Keys("0"), Path{}.Index(0)
|
||||||
|
if parsed.String() != "/0" || keyed.String() != "/0" || indexed.String() != "/0" {
|
||||||
|
t.Fatalf("path spellings = %q, %q, %q", parsed, keyed, indexed)
|
||||||
|
}
|
||||||
|
if reflect.DeepEqual(parsed.value, keyed.value) || reflect.DeepEqual(parsed.value, indexed.value) || reflect.DeepEqual(keyed.value, indexed.value) {
|
||||||
|
t.Fatal("path wrappers collapsed parsed, mapping-key, and index segments")
|
||||||
|
}
|
||||||
|
}
|
||||||
33
internal/facade/path.go
Normal file
33
internal/facade/path.go
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
package configkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Path identifies a configuration value using YAML field names. Its textual form
|
||||||
|
// is a JSON Pointer. Paths are immutable; the zero value is the root path.
|
||||||
|
type Path struct {
|
||||||
|
value engine.Path
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keys constructs a path of mapping keys. Keys with no arguments returns the root path.
|
||||||
|
func Keys(keys ...string) Path { return Path{value: engine.Keys(keys...)} }
|
||||||
|
|
||||||
|
// ParsePath parses a JSON Pointer while preserving context-dependent numeric tokens.
|
||||||
|
func ParsePath(pointer string) (Path, error) {
|
||||||
|
value, err := engine.ParsePath(pointer)
|
||||||
|
return Path{value: value}, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Key returns a new path with key appended as a mapping-key segment.
|
||||||
|
func (p Path) Key(key string) Path { return Path{value: p.value.Key(key)} }
|
||||||
|
|
||||||
|
// Index returns a new path with index appended as a sequence-index segment.
|
||||||
|
func (p Path) Index(index int) Path { return Path{value: p.value.Index(index)} }
|
||||||
|
|
||||||
|
// String returns the path's canonical JSON Pointer spelling.
|
||||||
|
func (p Path) String() string { return p.value.String() }
|
||||||
|
|
||||||
|
func (p Path) clone() Path { return Path{value: p.value} }
|
||||||
|
func toEnginePath(path Path) engine.Path { return path.value }
|
||||||
|
func fromEnginePath(path engine.Path) Path { return Path{value: path} }
|
||||||
286
internal/facade/records.go
Normal file
286
internal/facade/records.go
Normal file
@@ -0,0 +1,286 @@
|
|||||||
|
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 a recorded operation. The zero value is
|
||||||
|
// used for input and default origins.
|
||||||
|
type ChangeKind string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// ChangeDecoding records the built-in transition to canonical typed values.
|
||||||
|
// It is inspection metadata, not an operation accepted by Resolution.Record.
|
||||||
|
ChangeDecoding ChangeKind = "decoding"
|
||||||
|
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
|
||||||
|
|
||||||
|
const (
|
||||||
|
CodeSourceInvalid Code = "source.invalid"
|
||||||
|
CodeSourceMissing Code = "source.missing"
|
||||||
|
CodeSourcePermission Code = "source.permission"
|
||||||
|
CodeSourceRead Code = "source.read"
|
||||||
|
CodeSourceNotRegular Code = "source.not_regular"
|
||||||
|
CodeSourceLimitExceeded Code = "source.limit_exceeded"
|
||||||
|
CodeSourceReaderUsed Code = "source.reader_used"
|
||||||
|
|
||||||
|
CodeYAMLInvalid Code = "yaml.invalid"
|
||||||
|
CodeYAMLEmpty Code = "yaml.empty"
|
||||||
|
CodeYAMLMultipleDocuments Code = "yaml.multiple_documents"
|
||||||
|
CodeYAMLRootType Code = "yaml.root_type"
|
||||||
|
CodeYAMLDuplicateKey Code = "yaml.duplicate_key"
|
||||||
|
CodeYAMLTag Code = "yaml.tag"
|
||||||
|
CodeYAMLAnchor Code = "yaml.anchor"
|
||||||
|
CodeYAMLAlias Code = "yaml.alias"
|
||||||
|
CodeYAMLMergeKey Code = "yaml.merge_key"
|
||||||
|
CodeYAMLScalar Code = "yaml.scalar"
|
||||||
|
CodeYAMLDepthLimit Code = "yaml.depth_limit"
|
||||||
|
CodeYAMLNodeLimit Code = "yaml.node_limit"
|
||||||
|
|
||||||
|
CodeSchemaRoot Code = "schema.root"
|
||||||
|
CodeSchemaUnsupportedType Code = "schema.unsupported_type"
|
||||||
|
CodeSchemaAmbiguousField Code = "schema.ambiguous_field"
|
||||||
|
CodeSchemaTag Code = "schema.tag"
|
||||||
|
CodeSchemaTextCodec Code = "schema.text_codec"
|
||||||
|
CodeSchemaInline Code = "schema.inline"
|
||||||
|
CodeSchemaCycle Code = "schema.cycle"
|
||||||
|
CodeSchemaProjectionLimit Code = "schema.projection_limit"
|
||||||
|
CodeSchemaCodec Code = "schema.codec"
|
||||||
|
CodeSchemaNonFinite Code = "schema.non_finite"
|
||||||
|
CodeSchemaDynamicType Code = "schema.dynamic_type"
|
||||||
|
|
||||||
|
CodeDecodeType Code = "decode.type"
|
||||||
|
CodeDecodeNull Code = "decode.null"
|
||||||
|
CodeDecodeRange Code = "decode.range"
|
||||||
|
CodeDecodeDuration Code = "decode.duration"
|
||||||
|
CodeDecodeText Code = "decode.text"
|
||||||
|
CodeDecodeUnknownField Code = "decode.unknown_field"
|
||||||
|
CodeDecodeArrayLength Code = "decode.array_length"
|
||||||
|
|
||||||
|
CodeOptionsInvalid Code = "options.invalid"
|
||||||
|
CodeOptionsUnavailable Code = "options.unavailable"
|
||||||
|
|
||||||
|
CodeImportDisabled Code = "import.disabled"
|
||||||
|
CodeImportInvalid Code = "import.invalid"
|
||||||
|
CodeImportMissing Code = "import.missing"
|
||||||
|
CodeImportPermission Code = "import.permission"
|
||||||
|
CodeImportRead Code = "import.read"
|
||||||
|
CodeImportOutsideRoot Code = "import.outside_root"
|
||||||
|
CodeImportCycle Code = "import.cycle"
|
||||||
|
CodeImportRepeated Code = "import.repeated"
|
||||||
|
CodeImportLimitExceeded Code = "import.limit_exceeded"
|
||||||
|
CodeImportVersion Code = "import.version"
|
||||||
|
|
||||||
|
CodeProfileInvalid Code = "profile.invalid"
|
||||||
|
CodeProfileMissing Code = "profile.missing"
|
||||||
|
CodeProfileOutsideRoot Code = "profile.outside_root"
|
||||||
|
CodeProfileRepeated Code = "profile.repeated"
|
||||||
|
CodeProfileDirective Code = "profile.directive"
|
||||||
|
CodeProfileVersion Code = "profile.version"
|
||||||
|
CodeProfileLimitExceeded Code = "profile.limit_exceeded"
|
||||||
|
|
||||||
|
CodeComposeInvalid Code = "compose.invalid"
|
||||||
|
CodeComposeConflict Code = "compose.conflict"
|
||||||
|
|
||||||
|
CodeVersionType Code = "version.type"
|
||||||
|
CodeVersionError Code = "version.error"
|
||||||
|
CodeVersionWarning Code = "version.warning"
|
||||||
|
CodeVersionHookFailed Code = "version.hook_failed"
|
||||||
|
|
||||||
|
CodeResolveError Code = "resolve.error"
|
||||||
|
CodeResolveWarning Code = "resolve.warning"
|
||||||
|
CodeResolveHookFailed Code = "resolve.hook_failed"
|
||||||
|
CodeResolveInvalidPath Code = "resolve.invalid_path"
|
||||||
|
CodeResolveInvalidKind Code = "resolve.invalid_kind"
|
||||||
|
CodeResolveInvalidRecord Code = "resolve.invalid_record"
|
||||||
|
CodeResolveInvalidTarget Code = "resolve.invalid_target"
|
||||||
|
CodeResolveInvalidValue Code = "resolve.invalid_value"
|
||||||
|
CodeResolveUnrecordedChange Code = "resolve.unrecorded_change"
|
||||||
|
|
||||||
|
CodeValidateError Code = "validate.error"
|
||||||
|
CodeValidateWarning Code = "validate.warning"
|
||||||
|
CodeValidateInvalidPath Code = "validate.invalid_path"
|
||||||
|
CodeValidateMutation Code = "validate.mutation"
|
||||||
|
|
||||||
|
CodeSecretsInvalidPath Code = "secrets.invalid_path"
|
||||||
|
CodeSecretsMissing Code = "secrets.missing"
|
||||||
|
CodeSecretsPermission Code = "secrets.permission"
|
||||||
|
CodeSecretsRead Code = "secrets.read"
|
||||||
|
CodeSecretsNotRegular Code = "secrets.not_regular"
|
||||||
|
CodeSecretsInvalidName Code = "secrets.invalid_name"
|
||||||
|
CodeSecretsLimitExceeded Code = "secrets.limit_exceeded"
|
||||||
|
CodeSecretsInvalidValue Code = "secrets.invalid_value"
|
||||||
|
CodeSecretsApplyFailed Code = "secrets.apply_failed"
|
||||||
|
|
||||||
|
CodeSecretRequired Code = "secret.required"
|
||||||
|
CodeSecretInvalidName Code = "secret.invalid_name"
|
||||||
|
CodeSecretUnset Code = "secret.unset"
|
||||||
|
CodeSecretEmpty Code = "secret.empty"
|
||||||
|
|
||||||
|
CodeOverrideInvalidPath Code = "override.invalid_path"
|
||||||
|
CodeOverrideInvalidLabel Code = "override.invalid_label"
|
||||||
|
CodeOverrideInvalidValue Code = "override.invalid_value"
|
||||||
|
CodeOverrideDuplicate Code = "override.duplicate"
|
||||||
|
CodeOverrideOverlap Code = "override.overlap"
|
||||||
|
CodeOverrideDirective Code = "override.directive"
|
||||||
|
CodeOverrideVersion Code = "override.version"
|
||||||
|
CodeOverrideLimitExceeded Code = "override.limit_exceeded"
|
||||||
|
|
||||||
|
CodeExplainLimitExceeded Code = "explain.limit_exceeded"
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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; resolution origins are synthetic and identify their
|
||||||
|
// operation instead of a file location.
|
||||||
|
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. Path can name
|
||||||
|
// an ancestor operation affecting the queried descendant. 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
|
||||||
|
}
|
||||||
52
internal/facade/secret.go
Normal file
52
internal/facade/secret.go
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
package configkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"reflect"
|
||||||
|
"strconv"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Secret is an opaque reference to an environment variable. Its name is public
|
||||||
|
// configuration; its resolved value is available only through Value.
|
||||||
|
type Secret struct {
|
||||||
|
name, value string
|
||||||
|
specified, resolved bool
|
||||||
|
}
|
||||||
|
|
||||||
|
// SecretEnv constructs an unresolved environment-backed secret reference.
|
||||||
|
func SecretEnv(name string) Secret { return Secret{name: name, specified: true} }
|
||||||
|
|
||||||
|
// EnvName returns the public environment-variable name.
|
||||||
|
func (s Secret) EnvName() string { return s.name }
|
||||||
|
|
||||||
|
// Value returns the resolved credential, or an empty string when unresolved.
|
||||||
|
func (s Secret) Value() string {
|
||||||
|
if !s.resolved {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return s.value
|
||||||
|
}
|
||||||
|
|
||||||
|
// IsResolved reports whether this reference resolved to a nonempty value during its configuration load.
|
||||||
|
func (s Secret) IsResolved() bool { return s.resolved }
|
||||||
|
|
||||||
|
// Format renders only the public reference name for every formatting verb.
|
||||||
|
func (s Secret) Format(state fmt.State, _ rune) {
|
||||||
|
_, _ = io.WriteString(state, "configkit.SecretEnv("+strconv.Quote(s.name)+")")
|
||||||
|
}
|
||||||
|
|
||||||
|
// MarshalJSON emits only the public environment-variable name.
|
||||||
|
func (s Secret) MarshalJSON() ([]byte, error) { return json.Marshal(s.name) }
|
||||||
|
|
||||||
|
func secretBinding() engine.SecretBinding {
|
||||||
|
return engine.SecretBinding{Type: reflect.TypeFor[Secret](), Read: func(value reflect.Value) engine.SecretState {
|
||||||
|
secret := value.Interface().(Secret)
|
||||||
|
return engine.SecretState{Name: secret.name, Value: secret.value, Specified: secret.specified, Resolved: secret.resolved}
|
||||||
|
}, Make: func(state engine.SecretState) reflect.Value {
|
||||||
|
return reflect.ValueOf(Secret{name: state.Name, value: state.Value, specified: state.Specified, resolved: state.Resolved})
|
||||||
|
}}
|
||||||
|
}
|
||||||
25
internal/facade/source.go
Normal file
25
internal/facade/source.go
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
package configkit
|
||||||
|
|
||||||
|
import (
|
||||||
|
"io"
|
||||||
|
|
||||||
|
"gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Source is an opaque configuration input. Construct sources with File, Bytes,
|
||||||
|
// Reader, or NoSource; the zero value is invalid.
|
||||||
|
type Source struct{ value engine.Source }
|
||||||
|
|
||||||
|
// File returns a source that reopens path for every load attempt.
|
||||||
|
func File(path string) Source { return Source{value: engine.File(path)} }
|
||||||
|
|
||||||
|
// Bytes snapshots data immediately.
|
||||||
|
func Bytes(label string, data []byte) Source { return Source{value: engine.Bytes(label, data)} }
|
||||||
|
|
||||||
|
// Reader returns a single-use source. Copies share the same single-use claim.
|
||||||
|
func Reader(label string, input io.Reader) Source { return Source{value: engine.Reader(label, input)} }
|
||||||
|
|
||||||
|
// NoSource explicitly selects defaults-only input.
|
||||||
|
func NoSource() Source { return Source{value: engine.NoSource()} }
|
||||||
|
|
||||||
|
func (s Source) toEngine() engine.Source { return s.value }
|
||||||
Reference in New Issue
Block a user