Allow absent inputs to use import contexts
This commit is contained in:
@@ -78,9 +78,14 @@ non-root destination. Unknown modes, invalid destinations, and invalid or
|
||||
conflicting sequence registrations fail before source acquisition. Overlay and
|
||||
CLI coordinates remain rooted at the application root. Ordinary `Load` recognizes
|
||||
source-root collection declarations whether or not imports are enabled; an
|
||||
`imports` declaration still requires imports to be enabled. `NoSource` rejects
|
||||
imports, active overlays, and filesystem context as invalid combinations but
|
||||
accepts overrides and remains an absent source rather than an empty document.
|
||||
`imports` declaration still requires imports to be enabled. `NoSource` may enable
|
||||
imports, but it has no main declarations to traverse. It accepts an explicit
|
||||
filesystem context: a host context requires an absolute existing `Root` and may
|
||||
omit `BaseDir` to use that root as its internal import base; a native context
|
||||
requires its namespace, `BaseDir`, and `Root`. Supplied context is validated even
|
||||
when imports are disabled. Active ordinary overlays remain invalid for
|
||||
`NoSource`. It accepts overrides and remains absent rather than contributing an
|
||||
empty document.
|
||||
|
||||
Every source, options, schema, version, defaults, decode, resolution, or validation
|
||||
failure returns a nil result and a `*LoadError`. Successful results contain caller-owned `Config`, detached
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Configuration Discovery Implementation Plan
|
||||
|
||||
Status: in progress. Stages 1, 2, and 3 are complete.
|
||||
Status: in progress. Stages 1, 2, 3, and 4 are complete.
|
||||
|
||||
Implement the target behavior in [the discovery roadmap](discovery.md). That
|
||||
document owns the selected conventions and OD-1 through OD-5; this plan owns the
|
||||
@@ -271,7 +271,7 @@ running defaults/resolution. A selected source's later read failure is not a
|
||||
fallback signal. Run focused discovery/source tests and the everyday check;
|
||||
mark and commit.
|
||||
|
||||
## Stage 4 — Unify Absent Input Acquisition And Context Validation
|
||||
## Stage 4 — Unify Absent Input Acquisition And Context Validation (Complete)
|
||||
|
||||
This stage changes `NoSource()` deliberately, before attaching discovery to loads.
|
||||
|
||||
|
||||
@@ -9,6 +9,9 @@ import (
|
||||
)
|
||||
|
||||
func validateFilesystemContext(source Source, context *FilesystemContext, main parsedSource) *Diagnostic {
|
||||
if source.kind == sourceNone && context.FS == nil {
|
||||
return validateAbsentHostFilesystemContext(context)
|
||||
}
|
||||
if nativeContext(source, context) {
|
||||
access, basePath, mainPath, diagnostic := nativeFilesystemAccess(source, context)
|
||||
if diagnostic != nil {
|
||||
@@ -33,10 +36,54 @@ func validateFilesystemContext(source Source, context *FilesystemContext, main p
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateAbsentHostFilesystemContext(context *FilesystemContext) *Diagnostic {
|
||||
rootPath, err := canonicalExistingPath(context.Root)
|
||||
if err != nil {
|
||||
value := importDiagnostic(CodeImportInvalid, "configuration root could not be canonicalized", nil)
|
||||
return &value
|
||||
}
|
||||
basePath := rootPath
|
||||
if context.BaseDir != "" {
|
||||
basePath, err = canonicalExistingPath(context.BaseDir)
|
||||
if err != nil {
|
||||
value := importDiagnostic(CodeImportInvalid, "import base directory could not be canonicalized", nil)
|
||||
return &value
|
||||
}
|
||||
}
|
||||
if !containedPath(rootPath, basePath) {
|
||||
value := importDiagnostic(CodeImportOutsideRoot, "import base must stay inside the configuration root", nil)
|
||||
return &value
|
||||
}
|
||||
for _, candidate := range []string{rootPath, basePath} {
|
||||
info, err := os.Stat(candidate)
|
||||
if err != nil || !info.IsDir() {
|
||||
value := importDiagnostic(CodeImportInvalid, "configuration root and import base must be directories", nil)
|
||||
return &value
|
||||
}
|
||||
}
|
||||
root, err := os.OpenRoot(rootPath)
|
||||
if err != nil {
|
||||
value := globFilesystemDiagnostic(err, "configuration root could not be opened", Location{})
|
||||
value.Primary = nil
|
||||
return &value
|
||||
}
|
||||
if err := root.Close(); err != nil {
|
||||
value := importDiagnostic(CodeImportRead, "configuration root could not be closed", nil)
|
||||
return &value
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func filesystemOptionDiagnostics(source Source, context *FilesystemContext) []Diagnostic {
|
||||
switch source.kind {
|
||||
case sourceNone:
|
||||
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "NoSource cannot use filesystem context")}
|
||||
if context.FS != nil {
|
||||
if context.FS.namespace == nil || nilFilesystem(context.FS.namespace.filesystem) || context.BaseDir == "" || context.Root == "" || !fs.ValidPath(context.BaseDir) || !fs.ValidPath(context.Root) {
|
||||
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "native filesystem context requires a namespace, base, and root directory")}
|
||||
}
|
||||
} else if context.Root == "" || !filepath.IsAbs(context.Root) || context.BaseDir != "" && !filepath.IsAbs(context.BaseDir) {
|
||||
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "absent-input filesystem root and base directories must be absolute host paths")}
|
||||
}
|
||||
case sourceFile:
|
||||
if context.FS != nil {
|
||||
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "host file sources cannot use a native filesystem context")}
|
||||
|
||||
@@ -268,6 +268,48 @@ func TestFilesystemContextIsValidatedWithoutImportsOrActiveOverlay(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNoSourceImportsAndFilesystemContext(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
directory := t.TempDir()
|
||||
for _, imports := range []bool{false, true} {
|
||||
name := "imports disabled"
|
||||
if imports {
|
||||
name = "imports enabled"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
result, err := testLoad[importTestConfig](NoSource(), Options[importTestConfig]{
|
||||
Imports: imports, Filesystem: &FilesystemContext{Root: directory},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sources := result.Sources(); len(sources) != 0 {
|
||||
t.Fatalf("NoSource sources = %#v", sources)
|
||||
}
|
||||
if _, ok := result.Metadata().Lookup(Keys("items", "root")); ok {
|
||||
t.Fatal("NoSource contributed explicit input")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
outside := t.TempDir()
|
||||
for _, test := range []struct {
|
||||
name string
|
||||
context FilesystemContext
|
||||
code Code
|
||||
}{
|
||||
{name: "missing root", context: FilesystemContext{BaseDir: directory}, code: CodeOptionsInvalid},
|
||||
{name: "relative root", context: FilesystemContext{Root: "."}, code: CodeOptionsInvalid},
|
||||
{name: "missing root directory", context: FilesystemContext{Root: filepath.Join(directory, "missing")}, code: CodeImportInvalid},
|
||||
{name: "base outside root", context: FilesystemContext{BaseDir: outside, Root: directory}, code: CodeImportOutsideRoot},
|
||||
} {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
requireLoadCode(t, NoSource(), Options[importTestConfig]{Filesystem: &test.context}, test.code)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func writeGlobRoot(t *testing.T, directory, pattern string, required bool) string {
|
||||
t.Helper()
|
||||
requiredLine := ""
|
||||
|
||||
@@ -398,7 +398,7 @@ label: helper
|
||||
childPath := writeImportFile(t, directory, "records/children/child.yml", "value: nested\n")
|
||||
|
||||
loaded, err := LoadLayered(
|
||||
Input{Source: NoSource()}, testSecretBinding(),
|
||||
Input{Source: NoSource(), Imports: true}, testSecretBinding(),
|
||||
LayeredOptions[config]{Underlays: []Underlay{{Inputs: []Input{{
|
||||
Source: File(itemPath), Imports: true, Destination: Keys("catalog", "entries"), placementMode: placementModeSequenceItem,
|
||||
}}}}},
|
||||
|
||||
@@ -21,8 +21,9 @@ type Definition[T any] struct {
|
||||
}
|
||||
|
||||
// Options contains the application definition and per-load input selections.
|
||||
// Imports enables explicit import declarations; Overlay selects one fixed overlay;
|
||||
// Overrides contains only values explicitly supplied by an application CLI.
|
||||
// Imports enables traversal of declarations in a selected main document; an
|
||||
// absent source has no main declarations to traverse. Overlay selects one fixed
|
||||
// overlay. Overrides contain only values explicitly supplied by an application CLI.
|
||||
type Options[T any] struct {
|
||||
Definition Definition[T]
|
||||
Imports bool
|
||||
@@ -39,7 +40,9 @@ type Options[T any] struct {
|
||||
importDefaultsValidated bool
|
||||
}
|
||||
|
||||
// FilesystemContext supplies filesystem roots for imports and overlays.
|
||||
// FilesystemContext supplies filesystem roots for imports and overlays. For an
|
||||
// absent host input, Root is required and BaseDir may be omitted to use Root as
|
||||
// the import base. Native contexts still require both BaseDir and Root.
|
||||
type FilesystemContext struct {
|
||||
BaseDir string
|
||||
Root string
|
||||
|
||||
@@ -200,7 +200,7 @@ func assembleBaseInputTrackedForRole[T any](source Source, options Options[T], d
|
||||
versionState.mainProcessing = &processed
|
||||
}
|
||||
}
|
||||
if options.Imports {
|
||||
if options.Imports && source.kind != sourceNone {
|
||||
if options.deferAssembly {
|
||||
input.composition, input.sources, diagnostics = loadImportedDocumentsTrackedWithProcessingDeferred(source, options.Filesystem, parsed, destination, options.Definition.Version, limits, accounting, visited, hostToken, versionState, versionProcessing, options.sequencePolicy, true)
|
||||
} else {
|
||||
@@ -218,7 +218,7 @@ func assembleBaseInputTrackedForRole[T any](source Source, options Options[T], d
|
||||
if versionProcessing != nil {
|
||||
input.document = versionProcessing.payload
|
||||
}
|
||||
if options.Filesystem != nil && !activeOverlay(options.Overlay) {
|
||||
if options.Filesystem != nil && (source.kind == sourceNone || !activeOverlay(options.Overlay)) {
|
||||
if diagnostic := validateFilesystemContext(source, options.Filesystem, parsed); diagnostic != nil {
|
||||
return input, []Diagnostic{*diagnostic}
|
||||
}
|
||||
|
||||
@@ -31,9 +31,6 @@ func selectOptions[T any](source Source, supplied []Options[T]) (Options[T], []D
|
||||
if options.Filesystem != nil {
|
||||
diagnostics = append(diagnostics, filesystemOptionDiagnostics(source, options.Filesystem)...)
|
||||
}
|
||||
if source.kind == sourceNone && options.Imports {
|
||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "NoSource cannot enable imports"))
|
||||
}
|
||||
if source.kind == sourceNone && options.Overlay != "" && options.Overlay != "default" {
|
||||
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "NoSource cannot select an active overlay"))
|
||||
}
|
||||
|
||||
@@ -130,6 +130,7 @@ func TestNoSourceOverridesReachResolutionAfterDefaults(t *testing.T) {
|
||||
t.Parallel()
|
||||
type config struct {
|
||||
Count int `yaml:"count"`
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Derived int `yaml:"derived"`
|
||||
}
|
||||
calls := 0
|
||||
@@ -137,27 +138,33 @@ func TestNoSourceOverridesReachResolutionAfterDefaults(t *testing.T) {
|
||||
Definition: Definition[config]{
|
||||
Defaults: func() config {
|
||||
calls++
|
||||
return config{Count: 2}
|
||||
return config{Count: 2, Enabled: true}
|
||||
},
|
||||
Resolve: func(value *config, resolution *Resolution) error {
|
||||
info, ok := resolution.InputInfo(Keys("count"))
|
||||
for _, key := range []string{"count", "enabled"} {
|
||||
info, ok := resolution.InputInfo(Keys(key))
|
||||
if !ok || !info.Explicit || info.Origin.Layer != LayerCLI {
|
||||
t.Errorf("resolution count info = %#v, %v", info, ok)
|
||||
t.Errorf("resolution %s info = %#v, %v", key, info, ok)
|
||||
}
|
||||
}
|
||||
Default(resolution, Keys("count"), &value.Count, 10, "preserve CLI value")
|
||||
Default(resolution, Keys("enabled"), &value.Enabled, true, "preserve CLI value")
|
||||
Default(resolution, Keys("derived"), &value.Derived, value.Count*3, "derive from CLI count")
|
||||
return nil
|
||||
},
|
||||
},
|
||||
Overrides: []Override{{Path: Keys("count"), Value: 4, Flag: "--count"}},
|
||||
Overrides: []Override{
|
||||
{Path: Keys("count"), Value: 0, Flag: "--count"},
|
||||
{Path: Keys("enabled"), Value: false, Flag: "--enabled"},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if calls != 1 || result.Config.Count != 4 || result.Config.Derived != 12 {
|
||||
if calls != 1 || result.Config.Count != 0 || result.Config.Enabled || result.Config.Derived != 0 {
|
||||
t.Fatalf("defaults-only CLI result = %#v, defaults calls %d", result.Config, calls)
|
||||
}
|
||||
if sources := result.Sources(); len(sources) != 1 || sources[0].Layer != LayerCLI {
|
||||
if sources := result.Sources(); len(sources) != 2 || sources[0].Layer != LayerCLI || sources[1].Layer != LayerCLI {
|
||||
t.Fatalf("defaults-only CLI sources = %#v", sources)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -174,7 +174,10 @@ func Mapping(label string, values map[string]any) Source {
|
||||
return source
|
||||
}
|
||||
|
||||
// NoSource explicitly selects a defaults-only input. It creates no source record.
|
||||
// NoSource selects an absent input for defaults and resolution. It creates no
|
||||
// source record and has no main declarations to traverse when imports are enabled.
|
||||
// An explicit filesystem context may be supplied; active overlays are not
|
||||
// available for absent inputs.
|
||||
func NoSource() Source {
|
||||
return Source{kind: sourceNone}
|
||||
}
|
||||
|
||||
@@ -539,6 +539,41 @@ func TestSourceVersionNoSourceMainRetainsAbsentCallback(t *testing.T) {
|
||||
if !called || len(result.diagnostics) != 0 || result.evidence != nil || result.payload != nil {
|
||||
t.Fatalf("NoSource result = %#v, callback called = %v", result, called)
|
||||
}
|
||||
|
||||
type config struct{}
|
||||
for _, reject := range []bool{false, true} {
|
||||
called = false
|
||||
definition := Definition[config]{Version: &VersionRule{
|
||||
Path: Keys("version"),
|
||||
Check: func(version Version, _ *Validation) error {
|
||||
called = true
|
||||
if version != (Version{}) {
|
||||
t.Errorf("NoSource load version = %#v", version)
|
||||
}
|
||||
if reject {
|
||||
return errors.New("rejected absent main")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}}
|
||||
loaded, loadErr := testLoad(NoSource(), Options[config]{
|
||||
Definition: definition, Imports: true, SourceVersionMode: test.mode,
|
||||
})
|
||||
if !called {
|
||||
t.Fatal("NoSource load did not call the source-version checker")
|
||||
}
|
||||
if reject {
|
||||
var failure *LoadError
|
||||
if !errors.As(loadErr, &failure) {
|
||||
t.Fatalf("rejected NoSource load = %v, want *LoadError", loadErr)
|
||||
}
|
||||
requireSourceVersionDiagnostic(t, failure.Diagnostics(), CodeVersionHookFailed, "/version")
|
||||
continue
|
||||
}
|
||||
if loadErr != nil || loaded == nil || len(loaded.Sources()) != 0 {
|
||||
t.Fatalf("accepted NoSource load = %#v, %v", loaded, loadErr)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
13
lifecycle.go
13
lifecycle.go
@@ -83,8 +83,9 @@ type ImportDefaults struct {
|
||||
}
|
||||
|
||||
// Options contains the application definition and per-load input selections.
|
||||
// Imports enables explicit import declarations; Overlay selects one fixed overlay;
|
||||
// Overrides contains only values explicitly supplied by an application CLI.
|
||||
// Imports enables traversal of declarations in a selected main document; an
|
||||
// absent source has no main declarations to traverse. Overlay selects one fixed
|
||||
// overlay. Overrides contain only values explicitly supplied by an application CLI.
|
||||
// Enabled source-version modes interpret Definition.Version.Path in each
|
||||
// document's local coordinates. Placement projects checks and main protection
|
||||
// through the input destination; item placement adds the source's item
|
||||
@@ -109,9 +110,11 @@ type Options[T any] struct {
|
||||
}
|
||||
|
||||
// FilesystemContext supplies roots for imports and overlays. With FS nil,
|
||||
// BaseDir and Root use absolute host paths. With FS set, they use slash-relative
|
||||
// paths in that namespace ("." denotes its root). File sources derive BaseDir
|
||||
// from the selected file; a native file's explicit Root limits its scope.
|
||||
// BaseDir and Root use absolute host paths. For NoSource, Root is required and
|
||||
// BaseDir may be omitted to use Root as the import base. With FS set, BaseDir
|
||||
// and Root use slash-relative paths in that namespace ("." denotes its root).
|
||||
// File sources derive BaseDir from the selected file; a native file's explicit
|
||||
// Root limits its scope.
|
||||
type FilesystemContext struct {
|
||||
BaseDir string
|
||||
Root string
|
||||
|
||||
@@ -47,7 +47,10 @@ func Mapping(label string, values map[string]any) Source {
|
||||
return Source{value: engine.Mapping(label, values)}
|
||||
}
|
||||
|
||||
// NoSource explicitly selects a defaults-only input. It creates no source record.
|
||||
// NoSource selects an absent input for defaults and resolution. It creates no
|
||||
// source record and has no main declarations to traverse when imports are enabled.
|
||||
// An explicit filesystem context may be supplied; active overlays are not
|
||||
// available for absent inputs.
|
||||
func NoSource() Source { return Source{value: engine.NoSource()} }
|
||||
|
||||
func (s Source) toEngine() engine.Source { return s.value }
|
||||
|
||||
Reference in New Issue
Block a user