From 37ea670747d958c9010f74c8e5d9521f17f23dbb Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Mon, 9 Nov 2026 21:18:49 +0000 Subject: [PATCH] Cover search exhaustion and fallback selection --- docs/roadmap/remediation.md | 2 + facade_main_relative_integration_test.go | 73 ++++++++++++++++++++++++ 2 files changed, 75 insertions(+) diff --git a/docs/roadmap/remediation.md b/docs/roadmap/remediation.md index 700a36d..2348360 100644 --- a/docs/roadmap/remediation.md +++ b/docs/roadmap/remediation.md @@ -74,6 +74,8 @@ sequence-item, and budget checks to verify the extraction. ## Finding 3 — Exhaustion and lazy fallback lack focused regression coverage +**Status** — Complete. Public tests cover search exhaustion, root-only main context, and missing fallbacks when they win or are shadowed. + **Priority P3.** The context-selection test uses an existing fallback when checking that main derivation wins. It would still pass if the loader prematurely opened that fallback. The no-base tests use `NoSource()`, and diff --git a/facade_main_relative_integration_test.go b/facade_main_relative_integration_test.go index a743a71..93711b5 100644 --- a/facade_main_relative_integration_test.go +++ b/facade_main_relative_integration_test.go @@ -122,3 +122,76 @@ func TestMainRelativeSourceVersionEvidence(t *testing.T) { } } } + +func TestMainRelativeOptionalSearchExhaustion(t *testing.T) { + root := t.TempDir() + if err := os.Mkdir(filepath.Join(root, "overlays"), 0700); err != nil { + t.Fatal(err) + } + for name, content := range map[string]string{ + "fragment.yml": "settings: {companion: loaded}\n", + "overlays/live.yml": "count: 9\n", + } { + if err := os.WriteFile(filepath.Join(root, name), []byte(content), 0600); err != nil { + t.Fatal(err) + } + } + search := configkit.SearchConfig("", configkit.SearchPaths(filepath.Join(root, "absent.yml"))) + missing := filepath.Join(root, "missing-fallback") + fallback := &configkit.FilesystemContext{Root: missing, BaseDir: missing} + + for _, tc := range []struct { + name string + mainContext *configkit.FilesystemContext + bindingFallback *configkit.FilesystemContext + overlayFallback *configkit.FilesystemContext + overlay string + wantCount int + wantSources int + }{ + {name: "skip without context", wantCount: 3, wantSources: 1}, + { + name: "root-only main context wins", mainContext: &configkit.FilesystemContext{Root: root}, + bindingFallback: fallback, overlayFallback: fallback, overlay: "live", wantCount: 9, wantSources: 3, + }, + {name: "selected missing companion fallback", bindingFallback: fallback}, + {name: "selected missing overlay fallback", overlay: "live", overlayFallback: fallback}, + } { + t.Run(tc.name, func(t *testing.T) { + underlay := configkit.Input{ + Source: configkit.Bytes("companion", []byte("count: 3\nconfig: {imports: [{path: fragment.yml}]}\n")), + Imports: true, MainRelative: &configkit.MainRelativeContext{Fallback: tc.bindingFallback}, + } + result, err := configkit.LoadLayered( + configkit.Input{Source: search, Filesystem: tc.mainContext}, + configkit.LayeredOptions[layeredFacadeConfig]{ + Underlays: []configkit.Underlay{{Inputs: []configkit.Input{underlay}}}, + Overlay: tc.overlay, OverlayFallback: tc.overlayFallback, + }, + ) + if tc.wantSources == 0 { + if err == nil { + t.Fatal("missing selected fallback succeeded") + } + diagnostics := err.(*configkit.LoadError).Diagnostics() + if len(diagnostics) != 1 || diagnostics[0].Code != configkit.CodeImportInvalid { + t.Fatalf("diagnostics = %#v", diagnostics) + } + return + } + if err != nil { + t.Fatal(err) + } + sources := result.Sources() + if result.Config.Count != tc.wantCount || len(sources) != tc.wantSources { + t.Fatalf("config = %#v, sources = %#v", result.Config, sources) + } + if sources[0].Layer != configkit.LayerUnderlay || len(sources[0].EmptyImportSelections) != 0 { + t.Fatalf("declaring source = %#v", sources[0]) + } + if tc.mainContext != nil && (result.Config.Settings["companion"] != "loaded" || sources[2].Layer != configkit.LayerOverlay) { + t.Fatalf("config = %#v, sources = %#v", result.Config, sources) + } + }) + } +}