Add detached source lookup to results and errors
This commit is contained in:
@@ -50,6 +50,21 @@ func (e *LoadError) Sources() []SourceInfo {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Source returns a detached record from the sanitized source evidence retained
|
||||||
|
// before loading failed, including placement evidence available at that point.
|
||||||
|
// IDs are local to the failed load. It does not acquire sources or access the
|
||||||
|
// filesystem. Empty or unknown IDs return false.
|
||||||
|
func (e *LoadError) Source(id string) (SourceInfo, bool) {
|
||||||
|
if e == nil || e.value == nil {
|
||||||
|
return SourceInfo{}, false
|
||||||
|
}
|
||||||
|
value, ok := e.value.Source(id)
|
||||||
|
if !ok {
|
||||||
|
return SourceInfo{}, false
|
||||||
|
}
|
||||||
|
return fromEngineSourceInfo(value), true
|
||||||
|
}
|
||||||
|
|
||||||
// WriteDiagnostics writes diagnostics in canonical order. It escapes terminal
|
// WriteDiagnostics writes diagnostics in canonical order. It escapes terminal
|
||||||
// control characters and propagates writer errors, including short writes.
|
// control characters and propagates writer errors, including short writes.
|
||||||
func WriteDiagnostics(output io.Writer, diagnostics []Diagnostic) error {
|
func WriteDiagnostics(output io.Writer, diagnostics []Diagnostic) error {
|
||||||
|
|||||||
@@ -172,6 +172,18 @@ the returned configuration or inspection records cannot alter ConfigKit's privat
|
|||||||
snapshot. ConfigKit performs no logging. `Result.WriteEffective` exposes only the
|
snapshot. ConfigKit performs no logging. `Result.WriteEffective` exposes only the
|
||||||
frozen redacted projection.
|
frozen redacted projection.
|
||||||
|
|
||||||
|
`Result.Source(id)` returns the detached record for an acquired source already
|
||||||
|
listed by `Result.Sources()`. Source IDs are local to one load; empty and unknown
|
||||||
|
IDs return false. This passive lookup uses retained evidence and does not reacquire
|
||||||
|
the source, access the filesystem, or look up credentials. A source ID can be
|
||||||
|
obtained from the source records or exact origin locations.
|
||||||
|
|
||||||
|
`LoadError.Source(id)` performs the same lookup over the partial, already-sanitized
|
||||||
|
inventory returned by `LoadError.Sources()`. It preserves the source evidence and
|
||||||
|
placement available when loading failed, which may be incomplete; it does not
|
||||||
|
promise a record for every attempted source. Nil or zero-valued errors and empty
|
||||||
|
or unknown IDs return false.
|
||||||
|
|
||||||
### Completion and resource lifetime
|
### Completion and resource lifetime
|
||||||
|
|
||||||
`Load` is synchronous and complete on return. Success means input assembly,
|
`Load` is synchronous and complete on return. Success means input assembly,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
# Pre-v1 Public API Cleanup Implementation Plan
|
# Pre-v1 Public API Cleanup Implementation Plan
|
||||||
|
|
||||||
Status: in progress; Stages 1–8 are complete and Stages 9–11 are pending.
|
Status: in progress; Stages 1–9 are complete and Stages 10–11 are pending.
|
||||||
Baseline: `07e4ae5d9236935389e81b207d4a8f00543fafce`.
|
Baseline: `07e4ae5d9236935389e81b207d4a8f00543fafce`.
|
||||||
Audience: a Luna coding agent executing the stages sequentially.
|
Audience: a Luna coding agent executing the stages sequentially.
|
||||||
|
|
||||||
@@ -356,7 +356,7 @@ D8's role check is resolved and its unproduced declaration is removed.
|
|||||||
|
|
||||||
## Stage 9 — Add Detached Source Lookup On Results And Load Errors
|
## Stage 9 — Add Detached Source Lookup On Results And Load Errors
|
||||||
|
|
||||||
Status: pending. Implements A1 and N5.
|
Status: complete. Implements A1 and N5.
|
||||||
|
|
||||||
1. Add engine-local `lookupSourceInfo(sources []SourceInfo, id string)
|
1. Add engine-local `lookupSourceInfo(sources []SourceInfo, id string)
|
||||||
(SourceInfo, bool)` beside source cloning helpers. Return zero/false for
|
(SourceInfo, bool)` beside source cloning helpers. Return zero/false for
|
||||||
|
|||||||
@@ -50,6 +50,16 @@ func (e *LoadError) Sources() []SourceInfo {
|
|||||||
return cloneSourceInfos(e.sources)
|
return cloneSourceInfos(e.sources)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Source returns a detached record from the source evidence retained before
|
||||||
|
// loading failed. IDs are local to the failed load. Empty or unknown IDs return
|
||||||
|
// false.
|
||||||
|
func (e *LoadError) Source(id string) (SourceInfo, bool) {
|
||||||
|
if e == nil {
|
||||||
|
return SourceInfo{}, false
|
||||||
|
}
|
||||||
|
return lookupSourceInfo(e.sources, id)
|
||||||
|
}
|
||||||
|
|
||||||
// WriteDiagnostics writes diagnostics in canonical order. It escapes terminal
|
// WriteDiagnostics writes diagnostics in canonical order. It escapes terminal
|
||||||
// control characters and propagates writer errors, including short writes.
|
// control characters and propagates writer errors, including short writes.
|
||||||
func WriteDiagnostics(output io.Writer, diagnostics []Diagnostic) error {
|
func WriteDiagnostics(output io.Writer, diagnostics []Diagnostic) error {
|
||||||
|
|||||||
@@ -119,6 +119,16 @@ func (r *Inspection) Sources() []SourceInfo {
|
|||||||
return cloneSourceInfos(r.sources)
|
return cloneSourceInfos(r.sources)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Source returns a detached record for an acquired source in this load. IDs are
|
||||||
|
// local to the load; the lookup does not reacquire the source or access the
|
||||||
|
// filesystem. Empty or unknown IDs return false.
|
||||||
|
func (r *Inspection) Source(id string) (SourceInfo, bool) {
|
||||||
|
if r == nil {
|
||||||
|
return SourceInfo{}, false
|
||||||
|
}
|
||||||
|
return lookupSourceInfo(r.sources, id)
|
||||||
|
}
|
||||||
|
|
||||||
// SourceContext returns a passive filesystem view for an acquired source.
|
// SourceContext returns a passive filesystem view for an acquired source.
|
||||||
// Unknown IDs and sources without a filesystem context return false.
|
// Unknown IDs and sources without a filesystem context return false.
|
||||||
func (r *Inspection) SourceContext(id string) (ResourceContext, bool) {
|
func (r *Inspection) SourceContext(id string) (ResourceContext, bool) {
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ const (
|
|||||||
CodeDecodeUnknownField Code = "decode.unknown_field"
|
CodeDecodeUnknownField Code = "decode.unknown_field"
|
||||||
CodeDecodeArrayLength Code = "decode.array_length"
|
CodeDecodeArrayLength Code = "decode.array_length"
|
||||||
|
|
||||||
CodeOptionsInvalid Code = "options.invalid"
|
CodeOptionsInvalid Code = "options.invalid"
|
||||||
|
|
||||||
CodeImportDisabled Code = "import.disabled"
|
CodeImportDisabled Code = "import.disabled"
|
||||||
CodeImportInvalid Code = "import.invalid"
|
CodeImportInvalid Code = "import.invalid"
|
||||||
@@ -345,6 +345,18 @@ func cloneSourceInfo(source SourceInfo) SourceInfo {
|
|||||||
return source
|
return source
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func lookupSourceInfo(sources []SourceInfo, id string) (SourceInfo, bool) {
|
||||||
|
if id == "" {
|
||||||
|
return SourceInfo{}, false
|
||||||
|
}
|
||||||
|
for _, source := range sources {
|
||||||
|
if source.ID == id {
|
||||||
|
return cloneSourceInfo(source), true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return SourceInfo{}, false
|
||||||
|
}
|
||||||
|
|
||||||
func cloneSourceVersionCheck(value *SourceVersionCheck) *SourceVersionCheck {
|
func cloneSourceVersionCheck(value *SourceVersionCheck) *SourceVersionCheck {
|
||||||
if value == nil {
|
if value == nil {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
14
lifecycle.go
14
lifecycle.go
@@ -214,6 +214,20 @@ func (r *Result[T]) Sources() []SourceInfo {
|
|||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Source returns a detached record for an acquired source in this load. IDs are
|
||||||
|
// local to the load; the lookup does not reacquire the source or access the
|
||||||
|
// filesystem. Empty or unknown IDs return false.
|
||||||
|
func (r *Result[T]) Source(id string) (SourceInfo, bool) {
|
||||||
|
if r == nil || r.inspection == nil {
|
||||||
|
return SourceInfo{}, false
|
||||||
|
}
|
||||||
|
value, ok := r.inspection.Source(id)
|
||||||
|
if !ok {
|
||||||
|
return SourceInfo{}, false
|
||||||
|
}
|
||||||
|
return fromEngineSourceInfo(value), true
|
||||||
|
}
|
||||||
|
|
||||||
// Diagnostics returns detached nonfatal diagnostics from the load.
|
// Diagnostics returns detached nonfatal diagnostics from the load.
|
||||||
func (r *Result[T]) Diagnostics() []Diagnostic {
|
func (r *Result[T]) Diagnostics() []Diagnostic {
|
||||||
if r == nil || r.inspection == nil {
|
if r == nil || r.inspection == nil {
|
||||||
|
|||||||
226
source_lookup_test.go
Normal file
226
source_lookup_test.go
Normal file
@@ -0,0 +1,226 @@
|
|||||||
|
package configkit_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"reflect"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
configkit "gitea.maximumdirect.net/eric/ConfigKit"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestResultSourceLookup(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
mainPath := writeSourceLookupFile(t, directory, "main.yml", `version: MAIN-SOURCE-CANARY
|
||||||
|
configkit:
|
||||||
|
imports:
|
||||||
|
- path: imported.yml
|
||||||
|
- dir: absent
|
||||||
|
main_value: main
|
||||||
|
`)
|
||||||
|
importedPath := writeSourceLookupFile(t, directory, "imported.yml", `version: IMPORTED-SOURCE-CANARY
|
||||||
|
imported_value: imported
|
||||||
|
`)
|
||||||
|
underlayPath := writeSourceLookupFile(t, directory, "underlay.yml", "underlay_value: underlay\n")
|
||||||
|
definition := configkit.Definition[map[string]any]{
|
||||||
|
Version: &configkit.VersionRule{
|
||||||
|
Path: configkit.Keys("version"),
|
||||||
|
Check: func(configkit.Version, *configkit.Validation) error { return nil },
|
||||||
|
},
|
||||||
|
Sensitive: func(path configkit.Path) bool { return path.String() == "/version" },
|
||||||
|
}
|
||||||
|
result, err := configkit.LoadLayered(configkit.Input{Source: configkit.File(mainPath), Imports: true}, configkit.LayeredOptions[map[string]any]{
|
||||||
|
Underlays: []configkit.Underlay{{Inputs: []configkit.Input{{Source: configkit.File(underlayPath)}}}},
|
||||||
|
Definition: definition, SourceVersionMode: configkit.SourceVersionModeIfPresent,
|
||||||
|
})
|
||||||
|
if err != nil || result == nil {
|
||||||
|
var loadError *configkit.LoadError
|
||||||
|
if errors.As(err, &loadError) {
|
||||||
|
t.Fatalf("LoadLayered = %#v, %v, diagnostics %#v", result, err, loadError.Diagnostics())
|
||||||
|
}
|
||||||
|
t.Fatalf("LoadLayered = %#v, %v", result, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sources := result.Sources()
|
||||||
|
if len(sources) != 3 {
|
||||||
|
t.Fatalf("sources = %#v", sources)
|
||||||
|
}
|
||||||
|
for _, want := range sources {
|
||||||
|
got, ok := result.Source(want.ID)
|
||||||
|
if !ok || !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("Source(%q) = %#v, %v; Sources entry = %#v", want.ID, got, ok, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
main, ok := result.Source(sources[0].ID)
|
||||||
|
if !ok || main.Layer != configkit.LayerMain || len(main.EmptyImportSelections) != 1 ||
|
||||||
|
main.VersionEvidence == nil || !main.VersionEvidence.Redacted || main.VersionEvidence.Version.Text != "" ||
|
||||||
|
main.VersionEvidence.Location == nil || main.VersionEvidence.Location.SourceID != main.ID {
|
||||||
|
t.Fatalf("main source evidence = %#v, %v", main, ok)
|
||||||
|
}
|
||||||
|
var imported, underlay bool
|
||||||
|
for _, source := range sources {
|
||||||
|
if source.Path == importedPath {
|
||||||
|
imported = source.Layer == configkit.LayerMain && source.ImportedFrom != nil &&
|
||||||
|
source.VersionEvidence != nil && source.VersionEvidence.Location != nil &&
|
||||||
|
source.VersionEvidence.Location.SourceID == source.ID
|
||||||
|
}
|
||||||
|
if source.Path == underlayPath {
|
||||||
|
underlay = source.Layer == configkit.LayerUnderlay
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !imported || !underlay {
|
||||||
|
t.Fatalf("imported or underlay source evidence missing: %#v", sources)
|
||||||
|
}
|
||||||
|
if got, ok := result.Source(""); ok || !reflect.DeepEqual(got, configkit.SourceInfo{}) {
|
||||||
|
t.Fatalf("empty source ID = %#v, %v", got, ok)
|
||||||
|
}
|
||||||
|
if got, ok := result.Source("unknown-source"); ok || !reflect.DeepEqual(got, configkit.SourceInfo{}) {
|
||||||
|
t.Fatalf("unknown source ID = %#v, %v", got, ok)
|
||||||
|
}
|
||||||
|
var nilResult *configkit.Result[map[string]any]
|
||||||
|
if got, ok := nilResult.Source("source-1"); ok || !reflect.DeepEqual(got, configkit.SourceInfo{}) {
|
||||||
|
t.Fatalf("nil result lookup = %#v, %v", got, ok)
|
||||||
|
}
|
||||||
|
if got, ok := (&configkit.Result[map[string]any]{}).Source("source-1"); ok || !reflect.DeepEqual(got, configkit.SourceInfo{}) {
|
||||||
|
t.Fatalf("zero result lookup = %#v, %v", got, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
main.EmptyImportSelections[0].Path = configkit.Keys("mutated")
|
||||||
|
main.EmptyImportSelections[0].Location.Line = 99
|
||||||
|
main.VersionEvidence.Path = configkit.Keys("mutated")
|
||||||
|
main.VersionEvidence.Location.Line = 99
|
||||||
|
main.VersionEvidence.Version.Text = "mutated"
|
||||||
|
for _, source := range sources {
|
||||||
|
if source.ImportedFrom != nil {
|
||||||
|
found, _ := result.Source(source.ID)
|
||||||
|
found.ImportedFrom.Line = 99
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got := result.Sources(); !reflect.DeepEqual(got, sources) {
|
||||||
|
t.Fatalf("lookup mutations changed enumeration: %#v", got)
|
||||||
|
}
|
||||||
|
for _, want := range sources {
|
||||||
|
if got, ok := result.Source(want.ID); !ok || !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("repeated Source(%q) = %#v, %v; want %#v", want.ID, got, ok, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, path := range []string{mainPath, importedPath, underlayPath} {
|
||||||
|
if err := os.Remove(path); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if got, ok := result.Source(sources[0].ID); !ok || !reflect.DeepEqual(got, sources[0]) {
|
||||||
|
t.Fatalf("source lookup after inputs were removed = %#v, %v", got, ok)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLoadErrorSourceLookup(t *testing.T) {
|
||||||
|
directory := t.TempDir()
|
||||||
|
mainPath := writeSourceLookupFile(t, directory, "main.yml", `configkit:
|
||||||
|
imports:
|
||||||
|
- path: item.yml
|
||||||
|
destination: [items]
|
||||||
|
placement: sequence-item
|
||||||
|
`)
|
||||||
|
writeSourceLookupFile(t, directory, "item.yml", `configkit:
|
||||||
|
imports:
|
||||||
|
- path: helper.yml
|
||||||
|
destination: [metadata]
|
||||||
|
`)
|
||||||
|
const canary = "FAILED-SOURCE-LOOKUP-CANARY"
|
||||||
|
helperPath := writeSourceLookupFile(t, directory, "helper.yml", `version: `+canary+`
|
||||||
|
configkit:
|
||||||
|
imports:
|
||||||
|
- dir: absent
|
||||||
|
- path: missing.yml
|
||||||
|
required: true
|
||||||
|
key: value
|
||||||
|
`)
|
||||||
|
definition := configkit.Definition[map[string]any]{
|
||||||
|
Version: &configkit.VersionRule{
|
||||||
|
Path: configkit.Keys("version"),
|
||||||
|
Check: func(configkit.Version, *configkit.Validation) error { return nil },
|
||||||
|
},
|
||||||
|
Sensitive: func(path configkit.Path) bool { return strings.HasSuffix(path.String(), "/version") },
|
||||||
|
}
|
||||||
|
_, err := configkit.Load(configkit.File(mainPath), configkit.Options[map[string]any]{
|
||||||
|
Imports: true, Definition: definition, SourceVersionMode: configkit.SourceVersionModeIfPresent,
|
||||||
|
})
|
||||||
|
var loadError *configkit.LoadError
|
||||||
|
if !errors.As(err, &loadError) {
|
||||||
|
t.Fatalf("load error = %T %v", err, err)
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), canary) {
|
||||||
|
t.Fatalf("load error disclosed source-version canary: %v", err)
|
||||||
|
}
|
||||||
|
sources := loadError.Sources()
|
||||||
|
if len(sources) < 3 {
|
||||||
|
t.Fatalf("partial source inventory = %#v", sources)
|
||||||
|
}
|
||||||
|
for _, want := range sources {
|
||||||
|
got, ok := loadError.Source(want.ID)
|
||||||
|
if !ok || !reflect.DeepEqual(got, want) {
|
||||||
|
t.Fatalf("Source(%q) = %#v, %v; Sources entry = %#v", want.ID, got, ok, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
canonicalHelper, err := filepath.EvalSymlinks(helperPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var helper configkit.SourceInfo
|
||||||
|
for _, source := range sources {
|
||||||
|
if source.Path == canonicalHelper {
|
||||||
|
helper = source
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if helper.ID == "" || !helper.DestinationIsFallback || helper.Destination.String() != "/items" ||
|
||||||
|
len(helper.EmptyImportSelections) != 1 || helper.VersionEvidence == nil ||
|
||||||
|
!helper.VersionEvidence.Redacted || helper.VersionEvidence.Version.Text != "" ||
|
||||||
|
helper.VersionEvidence.Location == nil {
|
||||||
|
t.Fatalf("partial helper source evidence = %#v", helper)
|
||||||
|
}
|
||||||
|
if got, ok := loadError.Source(""); ok || !reflect.DeepEqual(got, configkit.SourceInfo{}) {
|
||||||
|
t.Fatalf("empty source ID = %#v, %v", got, ok)
|
||||||
|
}
|
||||||
|
if got, ok := loadError.Source("unknown-source"); ok || !reflect.DeepEqual(got, configkit.SourceInfo{}) {
|
||||||
|
t.Fatalf("unknown source ID = %#v, %v", got, ok)
|
||||||
|
}
|
||||||
|
var nilError *configkit.LoadError
|
||||||
|
if got, ok := nilError.Source(helper.ID); ok || !reflect.DeepEqual(got, configkit.SourceInfo{}) {
|
||||||
|
t.Fatalf("nil load error lookup = %#v, %v", got, ok)
|
||||||
|
}
|
||||||
|
if got, ok := (&configkit.LoadError{}).Source(helper.ID); ok || !reflect.DeepEqual(got, configkit.SourceInfo{}) {
|
||||||
|
t.Fatalf("zero load error lookup = %#v, %v", got, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
mutated, ok := loadError.Source(helper.ID)
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("partial helper lookup missing")
|
||||||
|
}
|
||||||
|
mutated.Destination = configkit.Keys("mutated")
|
||||||
|
mutated.ImportedFrom.Line = 99
|
||||||
|
mutated.EmptyImportSelections[0].Path = configkit.Keys("mutated")
|
||||||
|
mutated.EmptyImportSelections[0].Location.Line = 99
|
||||||
|
mutated.VersionEvidence.Path = configkit.Keys("mutated")
|
||||||
|
mutated.VersionEvidence.Location.Line = 99
|
||||||
|
mutated.VersionEvidence.Version.Text = "mutated"
|
||||||
|
if got := loadError.Sources(); !reflect.DeepEqual(got, sources) {
|
||||||
|
t.Fatalf("lookup mutations changed error enumeration: %#v", got)
|
||||||
|
}
|
||||||
|
if got, ok := loadError.Source(helper.ID); !ok || !reflect.DeepEqual(got, helper) {
|
||||||
|
t.Fatalf("repeated partial lookup = %#v, %v; want %#v", got, ok, helper)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func writeSourceLookupFile(t *testing.T, directory, name, contents string) string {
|
||||||
|
t.Helper()
|
||||||
|
path := filepath.Join(directory, name)
|
||||||
|
if err := os.WriteFile(path, []byte(contents), 0o600); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user