From 761dd0a4e09b85a533a0a5c4dc4cc0a9192a4f9a Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Mon, 9 Nov 2026 20:40:40 +0000 Subject: [PATCH] Derive selected overlay context from the main input --- docs/roadmap/implementation.md | 2 + facade_main_relative_test.go | 62 +++++++++++++++++++++++++++++ internal/engine/load_request.go | 19 +++++++-- internal/engine/selected_overlay.go | 17 +++++--- 4 files changed, 91 insertions(+), 9 deletions(-) diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 54a05ed..c1cb48f 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -166,6 +166,8 @@ activate this path. ## Stage 5 — Derive layered overlay context and lookup base +**Status:** Complete. Focused host, native, fallback, and existing overlay checks passed. + In the existing overlay branch of `assembleLayeredInput`, select in order: explicit `OverlayFilesystem`, usable main context, `OverlayFallback`. Skip selection entirely for an inactive name. For an active name without a diff --git a/facade_main_relative_test.go b/facade_main_relative_test.go index 4ee823a..6a574bd 100644 --- a/facade_main_relative_test.go +++ b/facade_main_relative_test.go @@ -156,3 +156,65 @@ func TestMainRelativeWithoutBaseImportDefaults(t *testing.T) { }) } } + +func TestLayeredOverlayContextSelection(t *testing.T) { + root := t.TempDir() + base := filepath.Join(root, "base") + for _, dir := range []string{filepath.Join(root, "overlays"), filepath.Join(base, "overlays")} { + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + } + for path, content := range map[string]string{"base/main.yml": "count: 1\n", "base/overlays/test.yml": "count: 2\n", "overlays/test.yml": "count: 3\n"} { + if err := os.WriteFile(filepath.Join(root, path), []byte(content), 0600); err != nil { + t.Fatal(err) + } + } + main := configkit.Input{Source: configkit.File(filepath.Join(base, "main.yml")), Filesystem: &configkit.FilesystemContext{Root: root}} + result, err := configkit.LoadLayered(main, configkit.LayeredOptions[layeredFacadeConfig]{Overlay: "test"}) + if err != nil { + t.Fatalf("%v: %#v", err, err.(*configkit.LoadError).Diagnostics()) + } + if result.Config.Count != 2 { + t.Fatalf("derived overlay = %d", result.Config.Count) + } + result, err = configkit.LoadLayered(main, configkit.LayeredOptions[layeredFacadeConfig]{Overlay: "test", OverlayFilesystem: &configkit.FilesystemContext{Root: root, BaseDir: base}}) + if err != nil { + t.Fatalf("%v: %#v", err, err.(*configkit.LoadError).Diagnostics()) + } + if result.Config.Count != 3 { + t.Fatalf("explicit overlay = %d", result.Config.Count) + } + result, err = configkit.LoadLayered(configkit.Input{Source: configkit.NoSource()}, configkit.LayeredOptions[layeredFacadeConfig]{Overlay: "test", OverlayFallback: &configkit.FilesystemContext{Root: root, BaseDir: base}}) + if err != nil { + t.Fatalf("%v: %#v", err, err.(*configkit.LoadError).Diagnostics()) + } + if result.Config.Count != 2 { + t.Fatalf("fallback overlay = %d", result.Config.Count) + } +} + +func TestLayeredOverlayNativeContext(t *testing.T) { + filesystem := configkit.NewFilesystem("bundle", fstest.MapFS{ + "dir/main.yml": &fstest.MapFile{Data: []byte("count: 1\n")}, + "dir/overlays/test.yml": &fstest.MapFile{Data: []byte("count: 9\n")}, + }) + main := configkit.Input{Source: filesystem.File("dir/main.yml"), Filesystem: &configkit.FilesystemContext{Root: ".", FS: &filesystem}} + result, err := configkit.LoadLayered(main, configkit.LayeredOptions[layeredFacadeConfig]{Overlay: "test"}) + if err != nil { + t.Fatalf("%v: %#v", err, err.(*configkit.LoadError).Diagnostics()) + } + if result.Config.Count != 9 { + t.Fatalf("config = %#v", result.Config) + } + _, err = configkit.LoadLayered(main, configkit.LayeredOptions[layeredFacadeConfig]{Overlay: "missing", OverlayFallback: &configkit.FilesystemContext{Root: ".", BaseDir: ".", FS: &filesystem}}) + if err == nil { + t.Fatal("missing selected overlay succeeded") + } + for _, diagnostic := range err.(*configkit.LoadError).Diagnostics() { + if diagnostic.Code == configkit.CodeOverlayMissing { + return + } + } + t.Fatalf("diagnostics = %#v", err.(*configkit.LoadError).Diagnostics()) +} diff --git a/internal/engine/load_request.go b/internal/engine/load_request.go index ab1681a..abe7630 100644 --- a/internal/engine/load_request.go +++ b/internal/engine/load_request.go @@ -190,10 +190,20 @@ func assembleLayeredInput[T any](request loadRequest[T], input assembledInput, p assembly.current.inputBytes = state.accounting.bytes assembly.current.inputNodes = state.accounting.nodes if activeOverlay(request.overlay) { - if request.overlayFilesystem == nil { - return input, append(versionDiagnostics, optionDiagnostic(CodeOptionsInvalid, "selected overlay requires explicit filesystem context")) + var resolved resolvedFilesystemContext + var diagnostic *Diagnostic + useBase := false + if request.overlayFilesystem != nil { + resolved, diagnostic = resolveAbsentFilesystemContext(request.overlayFilesystem) + } else if input.resolvedFilesystem != nil { + resolved = *input.resolvedFilesystem + useBase = true + } else if request.overlayFallback != nil { + resolved, diagnostic = resolveAbsentFilesystemContext(request.overlayFallback) + useBase = true + } else { + return input, append(versionDiagnostics, optionDiagnostic(CodeOptionsInvalid, "selected overlay requires a filesystem context")) } - resolved, diagnostic := resolveAbsentFilesystemContext(request.overlayFilesystem) if diagnostic != nil { return input, append(versionDiagnostics, *diagnostic) } @@ -204,6 +214,9 @@ func assembleLayeredInput[T any](request loadRequest[T], input assembledInput, p name: request.overlay, protectionRule: request.protectionVersion(), sequencePolicy: policy.sequencePolicy, limits: defaultImportLimits(), versionState: input.versionState, } + if useBase { + overlayRequest.lookupDirectory = resolved.basePath + } var diagnostics []Diagnostic access, diagnostic := openSelectedOverlayAccess(resolved) if diagnostic != nil { diff --git a/internal/engine/selected_overlay.go b/internal/engine/selected_overlay.go index 90c08ec..4f5ba16 100644 --- a/internal/engine/selected_overlay.go +++ b/internal/engine/selected_overlay.go @@ -29,11 +29,12 @@ func validOverlayName(name string) bool { } type selectedOverlayRequest struct { - name string - protectionRule *VersionRule - sequencePolicy sequencePolicy - limits importLimits - versionState *sourceVersionState + name string + lookupDirectory string + protectionRule *VersionRule + sequencePolicy sequencePolicy + limits importLimits + versionState *sourceVersionState } func resolveOrdinaryOverlayFilesystem(source Source, context *FilesystemContext, main parsedSource, existing *resolvedFilesystemContext) (resolvedFilesystemContext, *Diagnostic) { @@ -70,7 +71,11 @@ func loadSelectedOverlay(request selectedOverlayRequest, access *filesystemAcces _ = access.close() } }() - overlayPath := access.join(access.rootPath, "overlays/"+request.name+".yml") + lookupDirectory := request.lookupDirectory + if lookupDirectory == "" { + lookupDirectory = access.rootPath + } + overlayPath := access.join(lookupDirectory, "overlays/"+request.name+".yml") canonical, err := access.canonical(overlayPath) if err != nil { code := CodeOverlayInvalid