Allow absent inputs to use import contexts

This commit is contained in:
2026-10-24 15:55:10 +00:00
parent 2f0900535d
commit 53ce6c63d8
13 changed files with 176 additions and 31 deletions

View File

@@ -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

View File

@@ -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.

View File

@@ -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")}

View File

@@ -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 := ""

View File

@@ -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,
}}}}},

View File

@@ -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

View File

@@ -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}
}

View File

@@ -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"))
}

View File

@@ -129,35 +129,42 @@ func TestPostDecodeOverrideLifecycleMatchesOrdinaryAndLayeredLoads(t *testing.T)
func TestNoSourceOverridesReachResolutionAfterDefaults(t *testing.T) {
t.Parallel()
type config struct {
Count int `yaml:"count"`
Derived int `yaml:"derived"`
Count int `yaml:"count"`
Enabled bool `yaml:"enabled"`
Derived int `yaml:"derived"`
}
calls := 0
result, err := testLoad(NoSource(), Options[config]{
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"))
if !ok || !info.Explicit || info.Origin.Layer != LayerCLI {
t.Errorf("resolution count info = %#v, %v", info, ok)
for _, key := range []string{"count", "enabled"} {
info, ok := resolution.InputInfo(Keys(key))
if !ok || !info.Explicit || info.Origin.Layer != LayerCLI {
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)
}
}

View File

@@ -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}
}

View File

@@ -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)
}
}
})
}
}

View File

@@ -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

View File

@@ -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 }