From e433988962e4f09177645d9b7f8b81061dd42097 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Mon, 19 Oct 2026 20:57:41 +0000 Subject: [PATCH] Retain empty optional import selection evidence --- internal/engine/import_evidence_test.go | 260 ++++++++++++++++++++++++ internal/engine/imports.go | 19 ++ internal/engine/records.go | 18 ++ 3 files changed, 297 insertions(+) create mode 100644 internal/engine/import_evidence_test.go diff --git a/internal/engine/import_evidence_test.go b/internal/engine/import_evidence_test.go new file mode 100644 index 0000000..fe002af --- /dev/null +++ b/internal/engine/import_evidence_test.go @@ -0,0 +1,260 @@ +package engine + +import ( + "errors" + "path/filepath" + "reflect" + "testing" +) + +func TestOptionalEmptyImportSelectionsRemainSourceLocalAndDetached(t *testing.T) { + t.Parallel() + + type config struct { + Value string `yaml:"value"` + } + directory := t.TempDir() + mainPath := writeImportFile(t, directory, "main.yml", `configkit: + imports: + - dir: absent + - path: missing.yml + - path: [] + - dir: fragments + exclude: '*.yml' + - path: [partial-missing.yml, present.yml] + - path: [] + required: true +`) + writeImportFile(t, directory, "fragments/ignored.yml", "ignored: true\n") + writeImportFile(t, directory, "present.yml", "value: imported\n") + + result, err := testLoad[config](File(mainPath), Options[config]{Imports: true}) + if err != nil { + t.Fatal(err) + } + if result.Config.Value != "imported" { + t.Fatalf("value = %q", result.Config.Value) + } + sources := result.Sources() + if len(sources) != 2 { + t.Fatalf("sources = %#v", sources) + } + wantPaths := []string{"/configkit/imports/0", "/configkit/imports/1", "/configkit/imports/2", "/configkit/imports/3"} + wantSelectors := []importSelector{importSelectorDirectory, importSelectorPath, importSelectorPath, importSelectorDirectory} + evidence := sources[0].importSelections + if len(evidence) != len(wantPaths) { + t.Fatalf("empty-selection evidence = %#v", evidence) + } + for index, selection := range evidence { + if selection.path.String() != wantPaths[index] || selection.location.SourceID != sources[0].ID || + selection.selector != wantSelectors[index] || selection.outcome != importSelectionNone { + t.Errorf("selection %d = %#v", index, selection) + } + } + if len(sources[1].importSelections) != 0 { + t.Fatalf("imported source unexpectedly owns declaration evidence: %#v", sources[1]) + } + + sources[0].importSelections[0].path.segments[0].key = "changed" + sources[0].importSelections[0].location.SourceID = "changed" + if got := result.Sources()[0].importSelections[0]; got.path.String() != wantPaths[0] || got.location.SourceID != "source-1" { + t.Fatalf("mutating a query snapshot changed the retained record: %#v", got) + } + other, err := testLoad[config](File(mainPath), Options[config]{Imports: true}) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(other.Sources()[0].importSelections, result.Inspection.Sources()[0].importSelections) { + t.Fatal("one load's source snapshot affected another load") + } +} + +func TestFailedLoadRetainsOnlyCompletedEmptyImportSelections(t *testing.T) { + t.Parallel() + + type config struct { + Value string `yaml:"value"` + } + directory := t.TempDir() + mainPath := writeImportFile(t, directory, "main.yml", `configkit: + imports: + - glob: absent/*.yml + - path: required-missing.yml + required: true + - dir: never-visited +value: main +`) + _, err := testLoad[config](File(mainPath), Options[config]{Imports: true}) + var loadError *LoadError + if !errors.As(err, &loadError) { + t.Fatalf("error = %T %v", err, err) + } + sources := loadError.Sources() + if len(sources) != 1 || len(sources[0].importSelections) != 1 { + t.Fatalf("failed-load sources = %#v", sources) + } + selection := sources[0].importSelections[0] + if selection.path.String() != "/configkit/imports/0" || selection.location.SourceID != sources[0].ID || selection.selector != importSelectorGlob || selection.outcome != importSelectionNone { + t.Fatalf("retained selection = %#v", selection) + } + sources[0].importSelections[0].path.segments[0].key = "changed" + if got := loadError.Sources()[0].importSelections[0].path.String(); got != "/configkit/imports/0" { + t.Fatalf("mutating failed-load snapshot changed retained evidence: %q", got) + } +} + +func TestImportSelectionEvidenceSurvivesCompositionUnderlaysAndResolutionEntry(t *testing.T) { + t.Parallel() + + type item struct { + ID string `yaml:"id"` + Metadata map[string]string `yaml:"metadata"` + } + type config struct { + Value string `yaml:"value"` + Helper string `yaml:"helper"` + Items []item `yaml:"items"` + Overlay string `yaml:"overlay"` + } + directory := t.TempDir() + mainPath := writeImportFile(t, directory, "main.yml", `configkit: + imports: + - path: helper.yml + - path: item.yml + destination: [items] + placement: sequence-item + - dir: absent-main +value: main +`) + helperPath := writeImportFile(t, directory, "helper.yml", `configkit: + imports: + - glob: absent-helper/*.yml +helper: value +`) + itemPath := writeImportFile(t, directory, "item.yml", `configkit: + imports: + - path: [] + - path: item-helper.yml + destination: [metadata] +id: selected +`) + itemHelperPath := writeImportFile(t, directory, "item-helper.yml", `configkit: + imports: + - dir: absent-item-helper +key: value +`) + underlayPath := writeImportFile(t, directory, "underlay.yml", `configkit: + imports: + - path: [] +value: underlay +`) + overlayPath := writeImportFile(t, directory, filepath.Join("overlays", "selected.yml"), "overlay: value\n") + var resolutionSources map[string]SourceInfo + loaded, err := LoadLayered(Input{Source: File(mainPath), Imports: true}, testSecretBinding(), LayeredOptions[config]{ + Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error { + resolutionSources = make(map[string]SourceInfo) + for _, id := range []string{"source-1", "source-2", "source-3", "source-4", "source-5", "source-6"} { + if source, ok := resolution.Source(id); ok { + resolutionSources[id] = source + } + } + return nil + }}, + Underlays: []Underlay{{Inputs: []Input{{Source: File(underlayPath), Imports: true}}}}, + Overlay: "selected", OverlayFilesystem: &FilesystemContext{BaseDir: directory, Root: directory}, + }) + if err != nil { + if loadError, ok := err.(*LoadError); ok { + t.Fatalf("LoadLayered diagnostics: %#v", loadError.Diagnostics()) + } + t.Fatalf("LoadLayered: %v", err) + } + if loaded.Config.Value != "main" || loaded.Config.Overlay != "value" || len(loaded.Config.Items) != 1 || loaded.Config.Items[0].ID != "selected" { + t.Fatalf("configuration = %#v", loaded.Config) + } + want := map[string]string{ + "source-1": "/configkit/imports/2", + "source-2": "/configkit/imports/0", + "source-3": "/configkit/imports/0", + "source-4": "/configkit/imports/0", + "source-5": "/configkit/imports/0", + } + sources := loaded.Inspection.Sources() + if len(sources) != 6 { + t.Fatalf("sources = %#v", sources) + } + if sources[1].Path != canonicalTestPath(t, helperPath) || sources[2].Path != canonicalTestPath(t, itemPath) || + sources[3].Path != canonicalTestPath(t, itemHelperPath) || sources[4].Path != canonicalTestPath(t, underlayPath) { + t.Fatalf("acquired source order = %#v", sources) + } + if sources[3].destinationUnresolved || sources[3].Destination.String() != "/items/0/metadata" { + t.Fatalf("nested item helper destination = %#v", sources[3]) + } + if sources[5].Path != canonicalTestPath(t, overlayPath) || sources[5].Layer != LayerOverlay { + t.Fatalf("overlay source = %#v", sources[5]) + } + for id, path := range want { + var found *SourceInfo + for index := range sources { + if sources[index].ID == id { + found = &sources[index] + break + } + } + if found == nil || len(found.importSelections) != 1 || found.importSelections[0].path.String() != path || found.importSelections[0].location.SourceID != id { + t.Errorf("source %s evidence = %#v, want %s", id, found, path) + } + if callbackSource, ok := resolutionSources[id]; !ok || len(callbackSource.importSelections) != 1 || callbackSource.importSelections[0].path.String() != path { + t.Errorf("resolution source %s evidence = %#v, want %s", id, callbackSource, path) + } + } + if len(sources[5].importSelections) != 0 { + t.Fatalf("overlay unexpectedly has import-selection evidence: %#v", sources[5]) + } +} + +func TestFailedNestedItemLoadKeepsEvidenceWithUnresolvedDestination(t *testing.T) { + t.Parallel() + + type item struct { + ID string `yaml:"id"` + } + type config struct { + Items []item `yaml:"items"` + } + directory := t.TempDir() + mainPath := writeImportFile(t, directory, "main.yml", `configkit: + imports: + - path: item.yml + destination: [items] + placement: sequence-item +`) + writeImportFile(t, directory, "item.yml", `configkit: + imports: + - path: helper.yml + destination: [metadata] +`) + writeImportFile(t, directory, "helper.yml", `configkit: + imports: + - dir: absent + - path: missing.yml + required: true +key: value +`) + _, err := testLoad[config](File(mainPath), Options[config]{Imports: true}) + var loadError *LoadError + if !errors.As(err, &loadError) { + t.Fatalf("error = %T %v", err, err) + } + for _, source := range loadError.Sources() { + if source.Path != canonicalTestPath(t, filepath.Join(directory, "helper.yml")) { + continue + } + if !source.destinationUnresolved || source.Destination.String() != "/items" || len(source.importSelections) != 1 || + source.importSelections[0].path.String() != "/configkit/imports/0" || source.importSelections[0].location.SourceID != source.ID { + t.Fatalf("failed nested item source = %#v", source) + } + return + } + t.Fatalf("helper source missing from failed-load snapshot: %#v", loadError.Sources()) +} diff --git a/internal/engine/imports.go b/internal/engine/imports.go index 6b5ddfb..512c88a 100644 --- a/internal/engine/imports.go +++ b/internal/engine/imports.go @@ -425,15 +425,34 @@ func (s *importTraversal) walkDeclarations(basePath string, declarations []impor return diagnostics } childDestination := appendDestination(parentDestination, policy.destination) + // Count admitted records rather than lexical candidates: optional missing + // paths are omitted during admission, and a partially present list is not empty. + sourcesBefore := len(s.sources) for _, candidate := range selection.candidates { if diagnostics := s.visitPath(candidate, spec, policy, childDestination, policy.destination, depth+1); len(diagnostics) != 0 { return diagnostics } } + if !policy.required && len(s.sources) == sourcesBefore { + s.recordEmptyImportSelection(spec) + } } return nil } +func (s *importTraversal) recordEmptyImportSelection(spec importDeclarationSpec) { + for index := range s.sources { + if s.sources[index].ID != spec.location.SourceID { + continue + } + s.sources[index].importSelections = append(s.sources[index].importSelections, importSelectionEvidence{ + path: spec.path.clone(), location: spec.location, + selector: spec.selector, outcome: importSelectionNone, + }) + return + } +} + func (s *importTraversal) visitPath(candidate selectedImportCandidate, spec importDeclarationSpec, policy resolvedImportPolicy, destination, relativeDestination Path, depth int) []Diagnostic { location := candidate.site.location if location.SourceID == "" && location.Line == 0 && location.Column == 0 { diff --git a/internal/engine/records.go b/internal/engine/records.go index 889045e..c9906e3 100644 --- a/internal/engine/records.go +++ b/internal/engine/records.go @@ -192,11 +192,21 @@ type SourceInfo struct { UnderlayIndex int ImportedFrom *Location VersionCheck *SourceVersionCheck + importSelections []importSelectionEvidence namespace *namespaceToken placementMode placementMode destinationUnresolved bool } +// importSelectionEvidence records one completed optional declaration whose +// selected candidates produced no admitted source. +type importSelectionEvidence struct { + path Path + location Location + selector importSelector + outcome importSelectionOutcome +} + // SourceInfoPlacementModeForFacade converts private placement evidence to its // root-owned numeric representation. func SourceInfoPlacementModeForFacade(source SourceInfo) uint8 { @@ -293,6 +303,14 @@ func cloneSourceInfo(source SourceInfo) SourceInfo { source.ImportedFrom = cloneLocation(source.ImportedFrom) source.Destination = source.Destination.clone() source.VersionCheck = cloneSourceVersionCheck(source.VersionCheck) + if source.importSelections != nil { + selections := make([]importSelectionEvidence, len(source.importSelections)) + for index, selection := range source.importSelections { + selection.path = selection.path.clone() + selections[index] = selection + } + source.importSelections = selections + } return source }