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 conflicting sequence registrations fail before source acquisition. Overlay and
CLI coordinates remain rooted at the application root. Ordinary `Load` recognizes CLI coordinates remain rooted at the application root. Ordinary `Load` recognizes
source-root collection declarations whether or not imports are enabled; an source-root collection declarations whether or not imports are enabled; an
`imports` declaration still requires imports to be enabled. `NoSource` rejects `imports` declaration still requires imports to be enabled. `NoSource` may enable
imports, active overlays, and filesystem context as invalid combinations but imports, but it has no main declarations to traverse. It accepts an explicit
accepts overrides and remains an absent source rather than an empty document. 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 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 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 # 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 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 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; fallback signal. Run focused discovery/source tests and the everyday check;
mark and commit. 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. 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 { func validateFilesystemContext(source Source, context *FilesystemContext, main parsedSource) *Diagnostic {
if source.kind == sourceNone && context.FS == nil {
return validateAbsentHostFilesystemContext(context)
}
if nativeContext(source, context) { if nativeContext(source, context) {
access, basePath, mainPath, diagnostic := nativeFilesystemAccess(source, context) access, basePath, mainPath, diagnostic := nativeFilesystemAccess(source, context)
if diagnostic != nil { if diagnostic != nil {
@@ -33,10 +36,54 @@ func validateFilesystemContext(source Source, context *FilesystemContext, main p
return nil 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 { func filesystemOptionDiagnostics(source Source, context *FilesystemContext) []Diagnostic {
switch source.kind { switch source.kind {
case sourceNone: 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: case sourceFile:
if context.FS != nil { if context.FS != nil {
return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "host file sources cannot use a native filesystem context")} 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 { func writeGlobRoot(t *testing.T, directory, pattern string, required bool) string {
t.Helper() t.Helper()
requiredLine := "" requiredLine := ""

View File

@@ -398,7 +398,7 @@ label: helper
childPath := writeImportFile(t, directory, "records/children/child.yml", "value: nested\n") childPath := writeImportFile(t, directory, "records/children/child.yml", "value: nested\n")
loaded, err := LoadLayered( loaded, err := LoadLayered(
Input{Source: NoSource()}, testSecretBinding(), Input{Source: NoSource(), Imports: true}, testSecretBinding(),
LayeredOptions[config]{Underlays: []Underlay{{Inputs: []Input{{ LayeredOptions[config]{Underlays: []Underlay{{Inputs: []Input{{
Source: File(itemPath), Imports: true, Destination: Keys("catalog", "entries"), placementMode: placementModeSequenceItem, 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. // Options contains the application definition and per-load input selections.
// Imports enables explicit import declarations; Overlay selects one fixed overlay; // Imports enables traversal of declarations in a selected main document; an
// Overrides contains only values explicitly supplied by an application CLI. // 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 { type Options[T any] struct {
Definition Definition[T] Definition Definition[T]
Imports bool Imports bool
@@ -39,7 +40,9 @@ type Options[T any] struct {
importDefaultsValidated bool 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 { type FilesystemContext struct {
BaseDir string BaseDir string
Root string Root string

View File

@@ -200,7 +200,7 @@ func assembleBaseInputTrackedForRole[T any](source Source, options Options[T], d
versionState.mainProcessing = &processed versionState.mainProcessing = &processed
} }
} }
if options.Imports { if options.Imports && source.kind != sourceNone {
if options.deferAssembly { 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) input.composition, input.sources, diagnostics = loadImportedDocumentsTrackedWithProcessingDeferred(source, options.Filesystem, parsed, destination, options.Definition.Version, limits, accounting, visited, hostToken, versionState, versionProcessing, options.sequencePolicy, true)
} else { } else {
@@ -218,7 +218,7 @@ func assembleBaseInputTrackedForRole[T any](source Source, options Options[T], d
if versionProcessing != nil { if versionProcessing != nil {
input.document = versionProcessing.payload 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 { if diagnostic := validateFilesystemContext(source, options.Filesystem, parsed); diagnostic != nil {
return input, []Diagnostic{*diagnostic} 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 { if options.Filesystem != nil {
diagnostics = append(diagnostics, filesystemOptionDiagnostics(source, options.Filesystem)...) 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" { if source.kind == sourceNone && options.Overlay != "" && options.Overlay != "default" {
diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "NoSource cannot select an active overlay")) diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "NoSource cannot select an active overlay"))
} }

View File

@@ -130,6 +130,7 @@ func TestNoSourceOverridesReachResolutionAfterDefaults(t *testing.T) {
t.Parallel() t.Parallel()
type config struct { type config struct {
Count int `yaml:"count"` Count int `yaml:"count"`
Enabled bool `yaml:"enabled"`
Derived int `yaml:"derived"` Derived int `yaml:"derived"`
} }
calls := 0 calls := 0
@@ -137,27 +138,33 @@ func TestNoSourceOverridesReachResolutionAfterDefaults(t *testing.T) {
Definition: Definition[config]{ Definition: Definition[config]{
Defaults: func() config { Defaults: func() config {
calls++ calls++
return config{Count: 2} return config{Count: 2, Enabled: true}
}, },
Resolve: func(value *config, resolution *Resolution) error { 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 { 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("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") Default(resolution, Keys("derived"), &value.Derived, value.Count*3, "derive from CLI count")
return nil 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 { if err != nil {
t.Fatal(err) 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) 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) 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 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 { func NoSource() Source {
return Source{kind: sourceNone} 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 { if !called || len(result.diagnostics) != 0 || result.evidence != nil || result.payload != nil {
t.Fatalf("NoSource result = %#v, callback called = %v", result, called) 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. // Options contains the application definition and per-load input selections.
// Imports enables explicit import declarations; Overlay selects one fixed overlay; // Imports enables traversal of declarations in a selected main document; an
// Overrides contains only values explicitly supplied by an application CLI. // 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 // Enabled source-version modes interpret Definition.Version.Path in each
// document's local coordinates. Placement projects checks and main protection // document's local coordinates. Placement projects checks and main protection
// through the input destination; item placement adds the source's item // 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, // FilesystemContext supplies roots for imports and overlays. With FS nil,
// BaseDir and Root use absolute host paths. With FS set, they use slash-relative // BaseDir and Root use absolute host paths. For NoSource, Root is required and
// paths in that namespace ("." denotes its root). File sources derive BaseDir // BaseDir may be omitted to use Root as the import base. With FS set, BaseDir
// from the selected file; a native file's explicit Root limits its scope. // 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 { type FilesystemContext struct {
BaseDir string BaseDir string
Root 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)} 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 NoSource() Source { return Source{value: engine.NoSource()} }
func (s Source) toEngine() engine.Source { return s.value } func (s Source) toEngine() engine.Source { return s.value }