Retain empty optional import selection evidence
This commit is contained in:
260
internal/engine/import_evidence_test.go
Normal file
260
internal/engine/import_evidence_test.go
Normal file
@@ -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())
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user