diff --git a/diagnostic.go b/diagnostic.go index 9e6ca4f..57e512b 100644 --- a/diagnostic.go +++ b/diagnostic.go @@ -50,6 +50,21 @@ func (e *LoadError) Sources() []SourceInfo { 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 // control characters and propagates writer errors, including short writes. func WriteDiagnostics(output io.Writer, diagnostics []Diagnostic) error { diff --git a/docs/config.md b/docs/config.md index a331e05..952e340 100644 --- a/docs/config.md +++ b/docs/config.md @@ -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 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 `Load` is synchronous and complete on return. Success means input assembly, diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 81762f8..6531607 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -1,6 +1,6 @@ # 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`. 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 -Status: pending. Implements A1 and N5. +Status: complete. Implements A1 and N5. 1. Add engine-local `lookupSourceInfo(sources []SourceInfo, id string) (SourceInfo, bool)` beside source cloning helpers. Return zero/false for diff --git a/internal/engine/diagnostic.go b/internal/engine/diagnostic.go index 27e0675..ecf3283 100644 --- a/internal/engine/diagnostic.go +++ b/internal/engine/diagnostic.go @@ -50,6 +50,16 @@ func (e *LoadError) Sources() []SourceInfo { 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 // control characters and propagates writer errors, including short writes. func WriteDiagnostics(output io.Writer, diagnostics []Diagnostic) error { diff --git a/internal/engine/load.go b/internal/engine/load.go index 85c92f2..1619138 100644 --- a/internal/engine/load.go +++ b/internal/engine/load.go @@ -119,6 +119,16 @@ func (r *Inspection) Sources() []SourceInfo { 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. // Unknown IDs and sources without a filesystem context return false. func (r *Inspection) SourceContext(id string) (ResourceContext, bool) { diff --git a/internal/engine/records.go b/internal/engine/records.go index b8179b0..3f377b1 100644 --- a/internal/engine/records.go +++ b/internal/engine/records.go @@ -92,7 +92,7 @@ const ( CodeDecodeUnknownField Code = "decode.unknown_field" CodeDecodeArrayLength Code = "decode.array_length" - CodeOptionsInvalid Code = "options.invalid" + CodeOptionsInvalid Code = "options.invalid" CodeImportDisabled Code = "import.disabled" CodeImportInvalid Code = "import.invalid" @@ -345,6 +345,18 @@ func cloneSourceInfo(source SourceInfo) SourceInfo { 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 { if value == nil { return nil diff --git a/lifecycle.go b/lifecycle.go index 1ab5092..0fd7a27 100644 --- a/lifecycle.go +++ b/lifecycle.go @@ -214,6 +214,20 @@ func (r *Result[T]) Sources() []SourceInfo { 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. func (r *Result[T]) Diagnostics() []Diagnostic { if r == nil || r.inspection == nil { diff --git a/source_lookup_test.go b/source_lookup_test.go new file mode 100644 index 0000000..6e90bfb --- /dev/null +++ b/source_lookup_test.go @@ -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 +}