From 53ce6c63d8c72166f0118beb6b8edb158c65fa87 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sat, 24 Oct 2026 15:55:10 +0000 Subject: [PATCH] Allow absent inputs to use import contexts --- docs/config.md | 11 +++-- docs/roadmap/implementation.md | 4 +- internal/engine/filesystem.go | 49 +++++++++++++++++++++- internal/engine/glob_test.go | 42 +++++++++++++++++++ internal/engine/imports_test.go | 2 +- internal/engine/load.go | 9 ++-- internal/engine/load_input.go | 4 +- internal/engine/options.go | 3 -- internal/engine/override_lifecycle_test.go | 25 +++++++---- internal/engine/source.go | 5 ++- internal/engine/source_version_test.go | 35 ++++++++++++++++ lifecycle.go | 13 +++--- source.go | 5 ++- 13 files changed, 176 insertions(+), 31 deletions(-) diff --git a/docs/config.md b/docs/config.md index f9036bd..2b2278c 100644 --- a/docs/config.md +++ b/docs/config.md @@ -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 diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index f635cff..c8f9010 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -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. diff --git a/internal/engine/filesystem.go b/internal/engine/filesystem.go index d7d7c04..30a671a 100644 --- a/internal/engine/filesystem.go +++ b/internal/engine/filesystem.go @@ -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")} diff --git a/internal/engine/glob_test.go b/internal/engine/glob_test.go index 0a6cff7..7180838 100644 --- a/internal/engine/glob_test.go +++ b/internal/engine/glob_test.go @@ -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 := "" diff --git a/internal/engine/imports_test.go b/internal/engine/imports_test.go index 9f2aa73..e0fdf69 100644 --- a/internal/engine/imports_test.go +++ b/internal/engine/imports_test.go @@ -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, }}}}}, diff --git a/internal/engine/load.go b/internal/engine/load.go index fff6cde..517bad8 100644 --- a/internal/engine/load.go +++ b/internal/engine/load.go @@ -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 diff --git a/internal/engine/load_input.go b/internal/engine/load_input.go index 8e1eed7..a421c9c 100644 --- a/internal/engine/load_input.go +++ b/internal/engine/load_input.go @@ -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} } diff --git a/internal/engine/options.go b/internal/engine/options.go index e7b3a47..a040907 100644 --- a/internal/engine/options.go +++ b/internal/engine/options.go @@ -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")) } diff --git a/internal/engine/override_lifecycle_test.go b/internal/engine/override_lifecycle_test.go index 6e50544..bdc1f44 100644 --- a/internal/engine/override_lifecycle_test.go +++ b/internal/engine/override_lifecycle_test.go @@ -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) } } diff --git a/internal/engine/source.go b/internal/engine/source.go index 402130d..b4c93a4 100644 --- a/internal/engine/source.go +++ b/internal/engine/source.go @@ -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} } diff --git a/internal/engine/source_version_test.go b/internal/engine/source_version_test.go index ba42d61..72221d5 100644 --- a/internal/engine/source_version_test.go +++ b/internal/engine/source_version_test.go @@ -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) + } + } }) } } diff --git a/lifecycle.go b/lifecycle.go index be533dc..10b5657 100644 --- a/lifecycle.go +++ b/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 diff --git a/source.go b/source.go index 2de7062..f8e6645 100644 --- a/source.go +++ b/source.go @@ -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 }