From 8642337966fd90e80e92a494cda70e80832f673c Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sat, 24 Oct 2026 15:59:39 +0000 Subject: [PATCH] Load ordinary overlays without a main source --- docs/config.md | 15 +++-- docs/roadmap/implementation.md | 4 +- internal/engine/load.go | 3 +- internal/engine/options.go | 4 +- internal/engine/selected_overlay.go | 21 ++++++- internal/engine/selected_overlay_test.go | 78 ++++++++++++++++++++++++ internal/engine/source.go | 3 +- internal/engine/source_context.go | 12 ++-- lifecycle.go | 3 +- source.go | 3 +- 10 files changed, 125 insertions(+), 21 deletions(-) diff --git a/docs/config.md b/docs/config.md index 2b2278c..3c09ae8 100644 --- a/docs/config.md +++ b/docs/config.md @@ -83,9 +83,9 @@ 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. +when imports are disabled. An active ordinary overlay requires this explicit +context. `NoSource` 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 @@ -154,7 +154,8 @@ An active layered overlay requires `LayeredOptions.OverlayFilesystem` explicitly independent of the main source's namespace. Its name and root-relative lookup follow the selected-overlay rules below; inactive overlays ignore the context. The same path in a different filesystem namespace is not a repeated file. -Ordinary `Load` continues to infer its selected-overlay context from its source. +Ordinary `Load` infers overlay context from sources that provide one; with +`NoSource`, it requires the explicit `Options.Filesystem` context. Source IDs follow acquisition order—main and its depth-first imports, then each underlay tier's inputs and depth-first imports, then overlay and CLI—even though @@ -974,6 +975,12 @@ Every other name resolves only to `/overlays/.yml`, wi no extension fallback, case normalization, inheritance between overlay files, or overlay stack. A selected missing overlay is an error. +With `NoSource`, the main input stays absent and the explicit filesystem context +supplies the overlay root. The overlay is recorded as the first acquired source +when no other source exists. Host contexts may use the root-only form described +under [single-source loading](#single-source-loading); native contexts retain +their complete namespace, base, and root requirements. + The overlay is a separately policy-validated YAML mapping. It is loaded after the base import graph is composed and its schema version is checked, but before defaults and typed decoding. Overlay bytes, nodes, and source records count toward the same diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index c8f9010..d45dab4 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, 3, and 4 are complete. +Status: in progress. Stages 1, 2, 3, 4, and 5 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 @@ -297,7 +297,7 @@ Acceptance: imports can be enabled on absent input without synthetic sources, while invalid supplied contexts still fail. Run focused load, filesystem, version, and grouped tests, then the everyday check; mark and commit. -## Stage 5 — Load Ordinary Overlays With An Absent Main +## Stage 5 — Load Ordinary Overlays With An Absent Main (Complete) 1. Replace the ordinary `NoSource` active-overlay rejection with the explicit context requirement. Resolve absent-input overlay access from its context; diff --git a/internal/engine/load.go b/internal/engine/load.go index 517bad8..a0192d6 100644 --- a/internal/engine/load.go +++ b/internal/engine/load.go @@ -23,7 +23,8 @@ type Definition[T any] struct { // Options contains the application definition and per-load input selections. // 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. +// overlay and requires filesystem context for NoSource. Overrides contain only +// values explicitly supplied by an application CLI. type Options[T any] struct { Definition Definition[T] Imports bool diff --git a/internal/engine/options.go b/internal/engine/options.go index a040907..802be28 100644 --- a/internal/engine/options.go +++ b/internal/engine/options.go @@ -31,8 +31,8 @@ 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.Overlay != "" && options.Overlay != "default" { - diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "NoSource cannot select an active overlay")) + if source.kind == sourceNone && activeOverlay(options.Overlay) && options.Filesystem == nil { + diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "NoSource overlays require explicit filesystem context")) } if options.Filesystem == nil && (source.kind == sourceBytes || source.kind == sourceReader || source.kind == sourceMapping) && (options.Imports || activeOverlay(options.Overlay)) { diagnostics = append(diagnostics, optionDiagnostic(CodeOptionsInvalid, "synthetic-source imports or overlays require filesystem context")) diff --git a/internal/engine/selected_overlay.go b/internal/engine/selected_overlay.go index 46cf990..9d352d1 100644 --- a/internal/engine/selected_overlay.go +++ b/internal/engine/selected_overlay.go @@ -59,9 +59,24 @@ func loadSelectedOverlayIntoWithVersion(source Source, context *FilesystemContex } return loadSelectedOverlayWithAccessAndVersion(access, base, sources, name, rule, limits, versionState, policy) } - rootPath, _, _, diagnostic := importFilesystem(source, context, main) - if diagnostic != nil { - return cloneSourceInfos(sources), []Diagnostic{*diagnostic} + var rootPath string + if source.kind == sourceNone { + if context == nil { + value := optionDiagnostic(CodeOptionsInvalid, "NoSource overlays require explicit filesystem context") + return cloneSourceInfos(sources), []Diagnostic{value} + } + var err error + rootPath, err = canonicalExistingPath(context.Root) + if err != nil { + value := importDiagnostic(CodeImportInvalid, "configuration root could not be canonicalized", nil) + return cloneSourceInfos(sources), []Diagnostic{value} + } + } else { + var diagnostic *Diagnostic + rootPath, _, _, diagnostic = importFilesystem(source, context, main) + if diagnostic != nil { + return cloneSourceInfos(sources), []Diagnostic{*diagnostic} + } } access, err := openHostAccess(rootPath) if err != nil { diff --git a/internal/engine/selected_overlay_test.go b/internal/engine/selected_overlay_test.go index 78603c8..8258adc 100644 --- a/internal/engine/selected_overlay_test.go +++ b/internal/engine/selected_overlay_test.go @@ -2,11 +2,14 @@ package engine import ( "errors" + "io/fs" "os" + "path" "path/filepath" "reflect" "runtime" "testing" + "testing/fstest" ) type overlayTestConfig struct { @@ -21,6 +24,81 @@ type overlayTestConfig struct { Schema map[string]string `yaml:"schema"` } +func TestNoSourceSelectedOverlayUsesRootedHostContext(t *testing.T) { + t.Parallel() + type config struct { + Resource string `yaml:"resource"` + } + + root := t.TempDir() + overlayPath := writeImportFile(t, root, "overlays/testing.yml", "resource: asset.txt\n") + writeImportFile(t, root, "overlays/asset.txt", "host asset\n") + loaded, err := testLoad[config](NoSource(), Options[config]{ + Overlay: "testing", Filesystem: &FilesystemContext{Root: root}, + }) + if err != nil { + t.Fatal(err) + } + sources := loaded.Sources() + if len(sources) != 1 || sources[0].ID != "source-1" || sources[0].Path != canonicalTestPath(t, overlayPath) || sources[0].Layer != LayerOverlay || sources[0].Kind != SourceKindFile { + t.Fatalf("NoSource overlay sources = %#v", sources) + } + context, ok := loaded.Inspection.SourceContext("source-1") + if !ok || context.BaseDir != "overlays" { + t.Fatalf("overlay resource context = %#v, %v", context, ok) + } + data, err := fs.ReadFile(context.FS, path.Join(context.BaseDir, loaded.Config.Resource)) + if err != nil || string(data) != "host asset\n" { + t.Fatalf("overlay resource = %q, %v", data, err) + } +} + +func TestNoSourceSelectedOverlayRequiresContextAndSelection(t *testing.T) { + t.Parallel() + + requireLoadCode(t, NoSource(), Options[importTestConfig]{Overlay: "testing"}, CodeOptionsInvalid) + + root := t.TempDir() + requireLoadCode(t, NoSource(), Options[importTestConfig]{ + Overlay: "testing", Filesystem: &FilesystemContext{Root: root}, + }, CodeOverlayMissing) + + outside := t.TempDir() + requireLoadCode(t, NoSource(), Options[importTestConfig]{ + Overlay: "testing", Filesystem: &FilesystemContext{Root: root, BaseDir: outside}, + }, CodeImportOutsideRoot) +} + +func TestNoSourceSelectedOverlayUsesCompleteNativeContext(t *testing.T) { + t.Parallel() + type config struct { + Resource string `yaml:"resource"` + } + + filesystem := NewFilesystem("embedded", fstest.MapFS{ + "overlays/testing.yml": &fstest.MapFile{Data: []byte("resource: asset.txt\n")}, + "overlays/asset.txt": &fstest.MapFile{Data: []byte("native asset\n")}, + }) + loaded, err := testLoad[config](NoSource(), Options[config]{ + Overlay: "testing", Filesystem: &FilesystemContext{FS: &filesystem, BaseDir: ".", Root: "."}, + }) + if err != nil { + t.Fatal(err) + } + sources := loaded.Sources() + if len(sources) != 1 || sources[0].ID != "source-1" || sources[0].Path != "overlays/testing.yml" || sources[0].Layer != LayerOverlay || sources[0].Kind != SourceKindFilesystem { + t.Fatalf("native NoSource overlay sources = %#v", sources) + } + context, ok := loaded.Inspection.SourceContext("source-1") + if !ok || context.BaseDir != "overlays" { + t.Fatalf("native overlay resource context = %#v, %v", context, ok) + } + data, err := fs.ReadFile(context.FS, path.Join(context.BaseDir, loaded.Config.Resource)) + if err != nil || string(data) != "native asset\n" { + t.Fatalf("native overlay resource = %q, %v", data, err) + } +} + func TestLoadAppliesSelectedOverlayAfterBaseAndBeforeDefaults(t *testing.T) { directory := t.TempDir() mainPath := writeImportFile(t, directory, "main.yml", `configkit: diff --git a/internal/engine/source.go b/internal/engine/source.go index b4c93a4..5b7a89d 100644 --- a/internal/engine/source.go +++ b/internal/engine/source.go @@ -176,8 +176,7 @@ func Mapping(label string, values map[string]any) Source { // 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. +// An active overlay requires explicit filesystem context. func NoSource() Source { return Source{kind: sourceNone} } diff --git a/internal/engine/source_context.go b/internal/engine/source_context.go index ba61ffe..451164c 100644 --- a/internal/engine/source_context.go +++ b/internal/engine/source_context.go @@ -51,10 +51,14 @@ func contextsForAcquiredSources(source Source, context *FilesystemContext, baseS value := importDiagnostic(CodeImportInvalid, "configuration root could not be canonicalized", nil) return nil, &value } - basePath, err = canonicalExistingPath(context.BaseDir) - if err != nil { - value := importDiagnostic(CodeImportInvalid, "import base directory could not be canonicalized", nil) - return nil, &value + if source.kind == sourceNone && context.BaseDir == "" { + basePath = rootPath + } else { + basePath, err = canonicalExistingPath(context.BaseDir) + if err != nil { + value := importDiagnostic(CodeImportInvalid, "import base directory could not be canonicalized", nil) + return nil, &value + } } } else { return nil, nil diff --git a/lifecycle.go b/lifecycle.go index 10b5657..03ca545 100644 --- a/lifecycle.go +++ b/lifecycle.go @@ -85,7 +85,8 @@ type ImportDefaults struct { // Options contains the application definition and per-load input selections. // 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. +// overlay and requires filesystem context for NoSource. 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 diff --git a/source.go b/source.go index f8e6645..febe1c5 100644 --- a/source.go +++ b/source.go @@ -49,8 +49,7 @@ func Mapping(label string, values map[string]any) Source { // 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. +// An active overlay requires explicit filesystem context. func NoSource() Source { return Source{value: engine.NoSource()} } func (s Source) toEngine() engine.Source { return s.value }