Expose import defaults and selection evidence

This commit is contained in:
2026-10-19 21:06:31 +00:00
parent 05b67cbfcf
commit 24131617c7
12 changed files with 568 additions and 18 deletions

View File

@@ -43,12 +43,80 @@ func toEngineLocations(values []Location) []engine.Location {
return result return result
} }
func fromEngineSourceInfo(value engine.SourceInfo) SourceInfo { func fromEngineSourceInfo(value engine.SourceInfo) SourceInfo {
return SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Destination: fromEnginePath(value.Destination), PlacementMode: PlacementMode(engine.SourceInfoPlacementModeForFacade(value)), DestinationUnresolved: engine.SourceInfoDestinationUnresolvedForFacade(value), Layer: Layer(value.Layer), Kind: SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex, ImportedFrom: fromEngineLocationPointer(value.ImportedFrom), VersionCheck: fromEngineSourceVersionCheck(value.VersionCheck)} return SourceInfo{
ID: value.ID, Label: value.Label, Path: value.Path, Destination: fromEnginePath(value.Destination),
PlacementMode: PlacementMode(engine.SourceInfoPlacementModeForFacade(value)),
DestinationUnresolved: engine.SourceInfoDestinationUnresolvedForFacade(value),
Layer: Layer(value.Layer), Kind: SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex,
ImportedFrom: fromEngineLocationPointer(value.ImportedFrom), VersionCheck: fromEngineSourceVersionCheck(value.VersionCheck),
ImportSelections: fromEngineImportSelections(engine.SourceInfoImportSelectionsForFacade(value)),
}
} }
func toEngineSourceInfo(value SourceInfo) engine.SourceInfo { func toEngineSourceInfo(value SourceInfo) engine.SourceInfo {
result := engine.SourceInfo{ID: value.ID, Label: value.Label, Path: value.Path, Destination: toEnginePath(value.Destination), Layer: engine.Layer(value.Layer), Kind: engine.SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex, ImportedFrom: toEngineLocationPointer(value.ImportedFrom), VersionCheck: toEngineSourceVersionCheck(value.VersionCheck)} result := engine.SourceInfo{
ID: value.ID, Label: value.Label, Path: value.Path, Destination: toEnginePath(value.Destination),
Layer: engine.Layer(value.Layer), Kind: engine.SourceKind(value.Kind), UnderlayIndex: value.UnderlayIndex,
ImportedFrom: toEngineLocationPointer(value.ImportedFrom), VersionCheck: toEngineSourceVersionCheck(value.VersionCheck),
}
result = engine.SourceInfoWithImportSelections(result, toEngineImportSelections(value.ImportSelections))
return engine.SourceInfoWithPlacement(result, uint8(value.PlacementMode), value.DestinationUnresolved) return engine.SourceInfoWithPlacement(result, uint8(value.PlacementMode), value.DestinationUnresolved)
} }
func fromEngineImportSelections(values []engine.ImportSelectionForFacade) []ImportSelection {
if values == nil {
return nil
}
result := make([]ImportSelection, len(values))
for index, value := range values {
result[index] = ImportSelection{
Path: fromEnginePath(value.Path), Location: fromEngineLocation(value.Location),
Selector: ImportSelector(value.Selector), Outcome: ImportSelectionOutcome(value.Outcome),
}
}
return result
}
func toEngineImportSelections(values []ImportSelection) []engine.ImportSelectionForFacade {
if values == nil {
return nil
}
result := make([]engine.ImportSelectionForFacade, len(values))
for index, value := range values {
result[index] = engine.ImportSelectionForFacade{
Path: toEnginePath(value.Path), Location: toEngineLocation(value.Location),
Selector: string(value.Selector), Outcome: string(value.Outcome),
}
}
return result
}
func toEngineImportDefaults(value ImportDefaults) engine.ImportDefaults {
return engine.ImportDefaults{
Required: cloneImportDefaultBool(value.Required),
Follow: cloneImportDefaultBool(value.Follow),
Hidden: cloneImportDefaultBool(value.Hidden),
Recursive: cloneImportDefaultBool(value.Recursive),
Pattern: cloneImportDefaultStrings(value.Pattern),
}
}
func cloneImportDefaultBool(value *bool) *bool {
if value == nil {
return nil
}
cloned := *value
return &cloned
}
func cloneImportDefaultStrings(values []string) []string {
if values == nil {
return nil
}
cloned := make([]string, len(values))
copy(cloned, values)
return cloned
}
func fromEngineSourceVersionCheck(value *engine.SourceVersionCheck) *SourceVersionCheck { func fromEngineSourceVersionCheck(value *engine.SourceVersionCheck) *SourceVersionCheck {
if value == nil { if value == nil {
return nil return nil

View File

@@ -55,6 +55,24 @@ both without redefining their syntax. ConfigKit overlays are configuration overl
they do not select or replace an application's LLM, model, pipeline, report, or they do not select or replace an application's LLM, model, pipeline, report, or
execution profile concepts. execution profile concepts.
## Import defaults and selection inspection
`Options.ImportDefaults` and `LayeredOptions.ImportDefaults` apply one
load-wide policy to enabled main, underlay, and transitive imports. Each field
is resolved independently: a declaration's explicit value wins, then the
application default, then the library default. `Required`, `Follow`, `Hidden`,
and `Recursive` use nil pointers for unspecified values. `Pattern` applies to
directory searches: nil leaves filenames unrestricted, while a non-nil empty
slice selects no files. A declaration's own `pattern` replaces the application
default. The defaults do not enable imports or change selected-overlay behavior.
`Result.Sources`, `LoadError.Sources`, and `Resolution.Source` expose each
declaring source's `ImportSelections`. These records identify the source-local
declaration path, its authored location and selector, and the `none` outcome;
they do not include candidate names or a guessed reason for absence. Returned
records are detached snapshots. The [configuration contract](../config.md#explicit-imports)
owns selector applicability and exact absence semantics.
## Shared source-version checks ## Shared source-version checks
[`LoadVersionedService`](../../examples/consumers/source_version.go) demonstrates [`LoadVersionedService`](../../examples/consumers/source_version.go) demonstrates

View File

@@ -0,0 +1,230 @@
package configkit
import (
"errors"
"os"
"path/filepath"
"strings"
"testing"
"gitea.maximumdirect.net/eric/ConfigKit/internal/engine"
)
func TestImportDefaultsApplyToTransitiveAndUnderlayImports(t *testing.T) {
type config struct {
Main string `yaml:"main"`
Transitive string `yaml:"transitive"`
Exception string `yaml:"exception"`
Underlay string `yaml:"underlay"`
IgnoredDeep string `yaml:"ignored_deep"`
}
directory := t.TempDir()
mainPath := writeFacadeImportFile(t, directory, "main.yml", `configkit:
imports:
- path: parent/parent.yml
main: main
`)
writeFacadeImportFile(t, directory, "parent/parent.yml", `configkit:
imports:
- dir: generated
- dir: explicit
pattern: '*.yaml'
recursive: false
`)
transitivePath := writeFacadeImportFile(t, directory, "parent/generated/deep/transitive.cfg", "transitive: selected\n")
writeFacadeImportFile(t, directory, "parent/generated/ignored.yaml", "ignored_deep: wrong-pattern\n")
explicitPath := writeFacadeImportFile(t, directory, "parent/explicit/exception.yaml", "exception: declaration\n")
writeFacadeImportFile(t, directory, "parent/explicit/deep/ignored.yaml", "ignored_deep: wrong-depth\n")
writeFacadeImportFile(t, directory, "parent/explicit/ignored.cfg", "ignored_deep: wrong-explicit-pattern\n")
underlayPath := writeFacadeImportFile(t, directory, "underlay.yml", `configkit:
imports:
- dir: underlay-files
`)
underlayImportPath := writeFacadeImportFile(t, directory, "underlay-files/underlay.cfg", "underlay: selected\n")
recursive := true
loaded, err := LoadLayered(Input{Source: File(mainPath), Imports: true}, LayeredOptions[config]{
ImportDefaults: ImportDefaults{Recursive: &recursive, Pattern: []string{"*.cfg"}},
Underlays: []Underlay{{Inputs: []Input{{Source: File(underlayPath), Imports: true}}}},
})
if err != nil {
t.Fatalf("LoadLayered: %v", err)
}
if loaded.Config.Main != "main" || loaded.Config.Transitive != "selected" || loaded.Config.Exception != "declaration" || loaded.Config.Underlay != "selected" || loaded.Config.IgnoredDeep != "" {
t.Fatalf("configuration = %#v", loaded.Config)
}
paths := make(map[string]bool)
for _, source := range loaded.Sources() {
paths[source.Path] = true
}
for _, selected := range []string{transitivePath, explicitPath, underlayImportPath} {
if !paths[canonicalFacadeImportPath(t, selected)] {
t.Errorf("selected source %q missing from source records", selected)
}
}
}
func TestImportDefaultsPreserveEmptyPatternAndRequiredness(t *testing.T) {
type config struct {
Value string `yaml:"value"`
}
directory := t.TempDir()
mainPath := writeFacadeImportFile(t, directory, "main.yml", `configkit:
imports:
- dir: snippets
`)
writeFacadeImportFile(t, directory, "snippets/value.cfg", "value: imported\n")
unrestricted, err := Load(File(mainPath), Options[config]{Imports: true})
if err != nil || unrestricted.Config.Value != "imported" {
t.Fatalf("unspecified pattern = %#v, %v", unrestricted, err)
}
empty, err := Load(File(mainPath), Options[config]{Imports: true, ImportDefaults: ImportDefaults{Pattern: []string{}}})
if err != nil || empty.Config.Value != "" {
t.Fatalf("empty pattern = %#v, %v", empty, err)
}
emptySources := empty.Sources()
if len(emptySources) != 1 || len(emptySources[0].ImportSelections) != 1 || emptySources[0].ImportSelections[0].Path.String() != "/configkit/imports/0" || emptySources[0].ImportSelections[0].Selector != ImportSelectorDirectory || emptySources[0].ImportSelections[0].Outcome != ImportSelectionNone {
t.Fatalf("empty-pattern evidence = %#v", emptySources)
}
required := true
_, err = Load(File(mainPath), Options[config]{Imports: true, ImportDefaults: ImportDefaults{Required: &required, Pattern: []string{}}})
var loadError *LoadError
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != CodeImportMissing {
t.Fatalf("required empty selection error = %T %v", err, err)
}
if sources := loadError.Sources(); len(sources) != 1 || len(sources[0].ImportSelections) != 0 {
t.Fatalf("required empty selection was recorded as optional: %#v", sources)
}
recursiveMain := writeFacadeImportFile(t, directory, "recursive.yml", `configkit:
imports:
- dir: recursive-files
`)
writeFacadeImportFile(t, directory, "recursive-files/nested/value.cfg", "value: selected\n")
recursive := true
patterns := []string{"*.cfg"}
engineOptions := toEngineOptions(Options[config]{Imports: true, ImportDefaults: ImportDefaults{Recursive: &recursive, Pattern: patterns}})
recursive = false
patterns[0] = "["
loadedEngine, err := engine.Load(File(recursiveMain).toEngine(), secretBinding(), engineOptions)
if err != nil || loadedEngine.Config.Value != "selected" {
t.Fatalf("load after mutating caller defaults = %#v, %v", loadedEngine, err)
}
}
func TestInvalidImportDefaultsPreflightBeforeOrdinaryAndLayeredReads(t *testing.T) {
type config struct {
Value string `yaml:"value"`
}
for _, layered := range []bool{false, true} {
input := strings.NewReader("value: read\n")
underlayInput := strings.NewReader("value: underlay\n")
defaultsCalled := false
resolveCalled := false
definition := Definition[config]{
Defaults: func() config { defaultsCalled = true; return config{} },
Resolve: func(*config, *Resolution) error { resolveCalled = true; return nil },
}
defaults := ImportDefaults{Pattern: []string{"["}}
var err error
if layered {
_, err = LoadLayered(Input{Source: Reader("reader.yml", input)}, LayeredOptions[config]{
Definition: definition, ImportDefaults: defaults,
Underlays: []Underlay{{Inputs: []Input{{Source: Reader("underlay.yml", underlayInput)}}}},
})
} else {
_, err = Load(Reader("reader.yml", input), Options[config]{Definition: definition, ImportDefaults: defaults})
}
var loadError *LoadError
if !errors.As(err, &loadError) || len(loadError.Diagnostics()) != 1 || loadError.Diagnostics()[0].Code != CodeOptionsInvalid {
if loadError != nil {
t.Fatalf("layered=%v diagnostics = %#v", layered, loadError.Diagnostics())
}
t.Fatalf("layered=%v error = %T %v", layered, err, err)
}
if input.Len() != len("value: read\n") || underlayInput.Len() != len("value: underlay\n") || defaultsCalled || resolveCalled {
t.Fatalf("layered=%v consumed input or called application hooks: remaining=%d underlay=%d defaults=%v resolve=%v", layered, input.Len(), underlayInput.Len(), defaultsCalled, resolveCalled)
}
}
}
func TestPublicImportSelectionSnapshotsAreDetachedAcrossResultErrorAndResolution(t *testing.T) {
type config struct {
Value string `yaml:"value"`
}
directory := t.TempDir()
mainPath := writeFacadeImportFile(t, directory, "main.yml", `configkit:
imports:
- dir: absent
value: loaded
`)
callbackPath := ""
loaded, err := Load(File(mainPath), Options[config]{Imports: true, Definition: Definition[config]{Resolve: func(_ *config, resolution *Resolution) error {
first, ok := resolution.Source("source-1")
if !ok || len(first.ImportSelections) != 1 {
t.Fatalf("resolution source = %#v, %v", first, ok)
}
first.ImportSelections[0].Path = Keys("callback mutation")
second, ok := resolution.Source("source-1")
if !ok || second.ImportSelections[0].Path.String() != "/configkit/imports/0" {
t.Fatalf("resolution snapshot changed after mutation: %#v", second)
}
callbackPath = second.ImportSelections[0].Path.String()
return nil
}}})
if err != nil {
t.Fatal(err)
}
if callbackPath != "/configkit/imports/0" {
t.Fatalf("callback path = %q", callbackPath)
}
sources := loaded.Sources()
if len(sources) != 1 || len(sources[0].ImportSelections) != 1 || sources[0].ImportSelections[0].Location.SourceID != sources[0].ID || sources[0].ImportSelections[0].Selector != ImportSelectorDirectory {
t.Fatalf("result source evidence = %#v", sources)
}
sources[0].ImportSelections[0].Path = Keys("result mutation")
if got := loaded.Sources()[0].ImportSelections[0].Path.String(); got != "/configkit/imports/0" {
t.Fatalf("result snapshot changed after mutation: %q", got)
}
failurePath := writeFacadeImportFile(t, directory, "failure.yml", `configkit:
imports:
- dir: absent
- path: required-missing.yml
required: true
`)
_, err = Load(File(failurePath), Options[config]{Imports: true})
var loadError *LoadError
if !errors.As(err, &loadError) {
t.Fatalf("failed load error = %T %v", err, err)
}
errorSources := loadError.Sources()
if len(errorSources) != 1 || len(errorSources[0].ImportSelections) != 1 || errorSources[0].ImportSelections[0].Path.String() != "/configkit/imports/0" {
t.Fatalf("error source evidence = %#v", errorSources)
}
errorSources[0].ImportSelections[0].Path = Keys("error mutation")
if got := loadError.Sources()[0].ImportSelections[0].Path.String(); got != "/configkit/imports/0" {
t.Fatalf("error snapshot changed after mutation: %q", got)
}
}
func writeFacadeImportFile(t *testing.T, root, name, contents string) string {
t.Helper()
filename := filepath.Join(root, name)
if err := os.MkdirAll(filepath.Dir(filename), 0o700); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filename, []byte(contents), 0o600); err != nil {
t.Fatal(err)
}
return filename
}
func canonicalFacadeImportPath(t *testing.T, filename string) string {
t.Helper()
canonical, err := filepath.EvalSymlinks(filename)
if err != nil {
t.Fatal(err)
}
return filepath.ToSlash(canonical)
}

View File

@@ -52,6 +52,40 @@ type importDefaults struct {
pattern []string pattern []string
} }
// ImportDefaults transports root-owned load defaults across the facade
// boundary. Load options snapshot this value before source acquisition.
type ImportDefaults struct {
// Required controls absence handling when non-nil.
Required *bool
// Follow controls symlink following when non-nil.
Follow *bool
// Hidden controls hidden-entry discovery when non-nil.
Hidden *bool
// Recursive controls descendant directory traversal when non-nil.
Recursive *bool
// Pattern filters directory-search filenames; nil and empty differ.
Pattern []string
}
// OptionsWithImportDefaults copies root-owned defaults into ordinary options.
func OptionsWithImportDefaults[T any](options Options[T], value ImportDefaults) Options[T] {
options.importDefaults = privateImportDefaults(value)
return options
}
// LayeredOptionsWithImportDefaults copies root-owned defaults into grouped options.
func LayeredOptionsWithImportDefaults[T any](options LayeredOptions[T], value ImportDefaults) LayeredOptions[T] {
options.importDefaults = privateImportDefaults(value)
return options
}
func privateImportDefaults(value ImportDefaults) importDefaults {
return snapshotImportDefaults(importDefaults{
required: value.Required, follow: value.Follow, hidden: value.Hidden,
recursive: value.Recursive, pattern: value.Pattern,
})
}
type resolvedImportPolicy struct { type resolvedImportPolicy struct {
required bool required bool
follow bool follow bool

View File

@@ -35,6 +35,7 @@ type Options[T any] struct {
placementMode placementMode placementMode placementMode
sequencePolicy sequencePolicy sequencePolicy sequencePolicy
deferAssembly bool deferAssembly bool
importDefaultsValidated bool
} }
// FilesystemContext supplies filesystem roots for imports and overlays. // FilesystemContext supplies filesystem roots for imports and overlays.

View File

@@ -48,7 +48,8 @@ func loadGroupedWithSecretLookup[T any](request loadRequest[T], secrets SecretBi
request.definition.sequenceRegistrations = nil request.definition.sequenceRegistrations = nil
mainOptions, mainDiagnostics := selectOptions(request.main.source, []Options[T]{{ mainOptions, mainDiagnostics := selectOptions(request.main.source, []Options[T]{{
Definition: request.definition, Imports: request.main.imports, Filesystem: request.main.filesystem, destination: request.main.destination.clone(), Definition: request.definition, Imports: request.main.imports, Filesystem: request.main.filesystem, destination: request.main.destination.clone(),
SourceVersionMode: request.sourceVersionMode, placementMode: request.main.placementMode, importDefaults: request.importDefaults, SourceVersionMode: request.sourceVersionMode, placementMode: request.main.placementMode,
importDefaults: request.importDefaults, importDefaultsValidated: true,
}}) }})
request.main.destination = mainOptions.destination request.main.destination = mainOptions.destination
diagnostics = append(diagnostics, mainDiagnostics...) diagnostics = append(diagnostics, mainDiagnostics...)
@@ -56,7 +57,10 @@ func loadGroupedWithSecretLookup[T any](request loadRequest[T], secrets SecretBi
for groupIndex := range request.underlays { for groupIndex := range request.underlays {
for inputIndex := range request.underlays[groupIndex].inputs { for inputIndex := range request.underlays[groupIndex].inputs {
input := &request.underlays[groupIndex].inputs[inputIndex] input := &request.underlays[groupIndex].inputs[inputIndex]
inputOptions, inputDiagnostics := selectOptions(input.source, []Options[T]{{Imports: input.imports, Filesystem: input.filesystem, destination: input.destination.clone(), placementMode: input.placementMode, importDefaults: request.importDefaults}}) inputOptions, inputDiagnostics := selectOptions(input.source, []Options[T]{{
Imports: input.imports, Filesystem: input.filesystem, destination: input.destination.clone(), placementMode: input.placementMode,
importDefaults: request.importDefaults, importDefaultsValidated: true,
}})
input.destination = inputOptions.destination input.destination = inputOptions.destination
diagnostics = append(diagnostics, inputDiagnostics...) diagnostics = append(diagnostics, inputDiagnostics...)
} }

View File

@@ -9,7 +9,10 @@ func selectOptions[T any](source Source, supplied []Options[T]) (Options[T], []D
options = supplied[0] options = supplied[0]
} }
options.importDefaults = snapshotImportDefaults(options.importDefaults) options.importDefaults = snapshotImportDefaults(options.importDefaults)
diagnostics := validateImportDefaults(options.importDefaults) var diagnostics []Diagnostic
if !options.importDefaultsValidated {
diagnostics = validateImportDefaults(options.importDefaults)
}
if options.placementMode != placementModeMapping && options.placementMode != placementModeSequenceItem { if options.placementMode != placementModeMapping && options.placementMode != placementModeSequenceItem {
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "input placement mode is invalid")) diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "input placement mode is invalid"))
} }

View File

@@ -207,6 +207,90 @@ type importSelectionEvidence struct {
outcome importSelectionOutcome outcome importSelectionOutcome
} }
// ImportSelectionForFacade is a root-converted source-selection transport
// record. Its paths and enum spellings are converted by the facade.
type ImportSelectionForFacade struct {
Path Path // source-local import declaration path
Location Location // authored location and declaring source ID
Selector string // path, dir, or glob
Outcome string // none for a completed empty optional selection
}
// SourceInfoImportSelectionsForFacade returns detached selection records for
// explicit root-owned conversion.
func SourceInfoImportSelectionsForFacade(source SourceInfo) []ImportSelectionForFacade {
if source.importSelections == nil {
return nil
}
result := make([]ImportSelectionForFacade, len(source.importSelections))
for index, selection := range source.importSelections {
result[index] = ImportSelectionForFacade{
Path: selection.path.clone(), Location: selection.location,
Selector: importSelectorString(selection.selector),
Outcome: importSelectionOutcomeString(selection.outcome),
}
}
return result
}
// SourceInfoWithImportSelections copies converted root-owned records into
// private engine source evidence.
func SourceInfoWithImportSelections(source SourceInfo, selections []ImportSelectionForFacade) SourceInfo {
if selections == nil {
source.importSelections = nil
return source
}
source.importSelections = make([]importSelectionEvidence, len(selections))
for index, selection := range selections {
source.importSelections[index] = importSelectionEvidence{
path: selection.Path.clone(), location: selection.Location,
selector: importSelectorFromString(selection.Selector),
outcome: importSelectionOutcomeFromString(selection.Outcome),
}
}
return source
}
func importSelectorString(value importSelector) string {
switch value {
case importSelectorPath:
return "path"
case importSelectorDirectory:
return "dir"
case importSelectorGlob:
return "glob"
default:
return ""
}
}
func importSelectorFromString(value string) importSelector {
switch value {
case "path":
return importSelectorPath
case "dir":
return importSelectorDirectory
case "glob":
return importSelectorGlob
default:
return 0
}
}
func importSelectionOutcomeString(value importSelectionOutcome) string {
if value == importSelectionNone {
return "none"
}
return ""
}
func importSelectionOutcomeFromString(value string) importSelectionOutcome {
if value == "none" {
return importSelectionNone
}
return 0
}
// SourceInfoPlacementModeForFacade converts private placement evidence to its // SourceInfoPlacementModeForFacade converts private placement evidence to its
// root-owned numeric representation. // root-owned numeric representation.
func SourceInfoPlacementModeForFacade(source SourceInfo) uint8 { func SourceInfoPlacementModeForFacade(source SourceInfo) uint8 {

View File

@@ -35,6 +35,9 @@ type Underlay struct{ Inputs []Input }
type LayeredOptions[T any] struct { type LayeredOptions[T any] struct {
Definition Definition[T] Definition Definition[T]
SourceVersionMode SourceVersionMode SourceVersionMode SourceVersionMode
// ImportDefaults supplies one load-wide default policy for enabled main,
// underlay, and transitive declarations. Explicit declaration values win.
ImportDefaults ImportDefaults
Underlays []Underlay Underlays []Underlay
Overlay string Overlay string
OverlayFilesystem *FilesystemContext OverlayFilesystem *FilesystemContext
@@ -73,6 +76,7 @@ func toEngineLayeredOptions[T any](options LayeredOptions[T]) engine.LayeredOpti
Overlay: options.Overlay, Overlay: options.Overlay,
OverlayFilesystem: toEngineFilesystemContext(options.OverlayFilesystem), OverlayFilesystem: toEngineFilesystemContext(options.OverlayFilesystem),
} }
result = engine.LayeredOptionsWithImportDefaults(result, toEngineImportDefaults(options.ImportDefaults))
if options.Underlays != nil { if options.Underlays != nil {
result.Underlays = make([]engine.Underlay, len(options.Underlays)) result.Underlays = make([]engine.Underlay, len(options.Underlays))
for index, group := range options.Underlays { for index, group := range options.Underlays {

View File

@@ -72,6 +72,28 @@ const (
SourceVersionModeRequired SourceVersionModeRequired
) )
// ImportDefaults supplies load-wide defaults for enabled import declarations.
// Each field applies independently and an explicit declaration value takes
// precedence. Nil boolean fields are unspecified and leave the library defaults
// in effect: Required false, Follow true, Hidden true, and Recursive false.
// Pattern applies to directory searches; nil leaves filenames unrestricted,
// while a non-nil empty slice selects none. Defaults apply to main, underlay,
// and transitive imports, not selected overlays.
type ImportDefaults struct {
// Required defaults absence handling. For path, true requires every named
// file; for dir and glob, true requires at least one selected file.
Required *bool
// Follow controls discovered symbolic links for directory and glob search.
Follow *bool
// Hidden controls discovery of hidden files and directories.
Hidden *bool
// Recursive controls descendant traversal for directory searches.
Recursive *bool
// Pattern replaces the library's unrestricted filename matching for directory
// searches unless a declaration supplies its own pattern.
Pattern []string
}
// Options contains the application definition and per-load input selections. // Options contains the application definition and per-load input selections.
// Imports enables explicit import declarations; Overlay selects one fixed overlay; // Imports enables explicit import declarations; Overlay selects one fixed overlay;
// Overrides contains only values explicitly supplied by an application CLI. // Overrides contains only values explicitly supplied by an application CLI.
@@ -88,6 +110,10 @@ type Options[T any] struct {
// placement fail before acquisition. // placement fail before acquisition.
PlacementMode PlacementMode PlacementMode PlacementMode
Imports bool Imports bool
// ImportDefaults supplies one load-wide default policy for every enabled
// declaration, including transitive imports. Each field is independently
// overridden by an explicit declaration value.
ImportDefaults ImportDefaults
Overlay string Overlay string
Filesystem *FilesystemContext Filesystem *FilesystemContext
Overrides []Override Overrides []Override
@@ -301,6 +327,7 @@ func toEngineOptions[T any](options Options[T]) engine.Options[T] {
SourceVersionMode: engine.SourceVersionModeFromFacade(uint8(options.SourceVersionMode)), SourceVersionMode: engine.SourceVersionModeFromFacade(uint8(options.SourceVersionMode)),
} }
result = engine.OptionsWithPlacement(result, toEnginePath(options.Destination.clone()), uint8(options.PlacementMode)) result = engine.OptionsWithPlacement(result, toEnginePath(options.Destination.clone()), uint8(options.PlacementMode))
result = engine.OptionsWithImportDefaults(result, toEngineImportDefaults(options.ImportDefaults))
if options.Filesystem != nil { if options.Filesystem != nil {
result.Filesystem = toEngineFilesystemContext(options.Filesystem) result.Filesystem = toEngineFilesystemContext(options.Filesystem)
} }

View File

@@ -210,6 +210,39 @@ type SourceInfo struct {
// and failed loads, text is conservatively withheld; successful final source // and failed loads, text is conservatively withheld; successful final source
// records reflect completed sensitivity classification. // records reflect completed sensitivity classification.
VersionCheck *SourceVersionCheck VersionCheck *SourceVersionCheck
// ImportSelections records completed optional declarations that selected no
// sources, in declaration order. Paths are local to this declaring source.
ImportSelections []ImportSelection
}
// ImportSelector identifies the selector used by an import declaration.
type ImportSelector string
const (
// ImportSelectorPath identifies a named-file selector.
ImportSelectorPath ImportSelector = "path"
// ImportSelectorDirectory identifies a literal directory search.
ImportSelectorDirectory ImportSelector = "dir"
// ImportSelectorGlob identifies a path-glob selector.
ImportSelectorGlob ImportSelector = "glob"
)
// ImportSelectionOutcome identifies the established result of import selection.
type ImportSelectionOutcome string
const (
// ImportSelectionNone means the optional declaration selected no sources.
ImportSelectionNone ImportSelectionOutcome = "none"
)
// ImportSelection records an optional import declaration that selected no
// sources. Path is local to the declaring source; Location identifies its
// declaration mapping and source ID.
type ImportSelection struct {
Path Path
Location Location
Selector ImportSelector
Outcome ImportSelectionOutcome
} }
// SourceVersionCheck describes a source-local version declaration observed // SourceVersionCheck describes a source-local version declaration observed
@@ -295,9 +328,22 @@ func cloneSourceInfo(source SourceInfo) SourceInfo {
source.Destination = source.Destination.clone() source.Destination = source.Destination.clone()
source.ImportedFrom = cloneLocation(source.ImportedFrom) source.ImportedFrom = cloneLocation(source.ImportedFrom)
source.VersionCheck = cloneSourceVersionCheck(source.VersionCheck) source.VersionCheck = cloneSourceVersionCheck(source.VersionCheck)
source.ImportSelections = cloneImportSelections(source.ImportSelections)
return source return source
} }
func cloneImportSelections(selections []ImportSelection) []ImportSelection {
if selections == nil {
return nil
}
cloned := make([]ImportSelection, len(selections))
for index, selection := range selections {
selection.Path = selection.Path.clone()
cloned[index] = selection
}
return cloned
}
func cloneSourceVersionCheck(value *SourceVersionCheck) *SourceVersionCheck { func cloneSourceVersionCheck(value *SourceVersionCheck) *SourceVersionCheck {
if value == nil { if value == nil {
return nil return nil

View File

@@ -71,3 +71,34 @@ func TestSourceVersionCheckConversionsAndCopies(t *testing.T) {
t.Fatalf("source version check changed through a detached copy: %#v", converted.VersionCheck) t.Fatalf("source version check changed through a detached copy: %#v", converted.VersionCheck)
} }
} }
func TestImportSelectionConversionsAndCopies(t *testing.T) {
declaration, err := ParsePath("/configkit/imports/2")
if err != nil {
t.Fatal(err)
}
source := SourceInfo{ID: "source-4", ImportSelections: []ImportSelection{{
Path: declaration, Location: Location{SourceID: "source-4", Line: 7, Column: 5},
Selector: ImportSelectorGlob, Outcome: ImportSelectionNone,
}}}
internal := toEngineSourceInfo(source)
transport := engine.SourceInfoImportSelectionsForFacade(internal)
if len(transport) != 1 || transport[0].Path.String() != declaration.String() || transport[0].Location.SourceID != source.ID || transport[0].Selector != "glob" || transport[0].Outcome != "none" {
t.Fatalf("engine selection evidence = %#v", transport)
}
source.ImportSelections[0].Path = Keys("changed")
converted := fromEngineSourceInfo(internal)
if len(converted.ImportSelections) != 1 || converted.ImportSelections[0].Path.String() != declaration.String() || converted.ImportSelections[0].Location.Line != 7 || converted.ImportSelections[0].Selector != ImportSelectorGlob || converted.ImportSelections[0].Outcome != ImportSelectionNone {
t.Fatalf("converted selection evidence = %#v", converted.ImportSelections)
}
copy := cloneSourceInfo(converted)
copy.ImportSelections[0].Path = Keys("copy")
copy.ImportSelections[0].Selector = ImportSelectorPath
if converted.ImportSelections[0].Path.String() != declaration.String() || converted.ImportSelections[0].Selector != ImportSelectorGlob {
t.Fatalf("source selection evidence changed through copy: %#v", converted.ImportSelections)
}
empty := cloneSourceInfo(SourceInfo{ImportSelections: make([]ImportSelection, 0)})
if empty.ImportSelections == nil {
t.Fatal("cloning collapsed a non-nil empty selection slice")
}
}