diff --git a/docs/roadmap/remediation.md b/docs/roadmap/remediation.md index 8350c8e..6773ca8 100644 --- a/docs/roadmap/remediation.md +++ b/docs/roadmap/remediation.md @@ -1,6 +1,6 @@ # Configuration Discovery Review Findings -Status: five open findings. Implementation has not been changed by these reviews. +Status: finding 1 complete; findings 2–5 remain open. Reviewed commit: `e50fee2`, including the discovery implementation commits after `424c897`. The review compared the implementation with the @@ -10,7 +10,13 @@ configuration and architecture contracts. ## 1. Defer Absence-Only Context Requirements Until The Search Outcome Is Known -Priority: P2. Status: open. +Priority: P2. Status: complete. + +**Completed:** search prevalidation now defers absence-only root requirements +until selection. Ordinary, layered-main, and underlay regression cases compare +searched files with explicit files under an empty context and retain errors for +absent inputs. The regression failed before the fix. Focused context/absence +tests and `./scripts/check.sh` passed with Go 1.26.10 on Linux. **Location:** [filesystem.go](../../internal/engine/filesystem.go), lines 77–123, and the outcome-specific validation in diff --git a/internal/engine/discovery_load_test.go b/internal/engine/discovery_load_test.go index 264189b..c9752c7 100644 --- a/internal/engine/discovery_load_test.go +++ b/internal/engine/discovery_load_test.go @@ -204,6 +204,53 @@ func TestSearchContextCompatibilityUsesSelectedOutcome(t *testing.T) { } } +func TestSearchEmptyContextUsesSelectedOutcome(t *testing.T) { + t.Parallel() + + root := t.TempDir() + selected := writeImportFile(t, root, "selected.yml", "value: selected\n") + missing := filepath.Join(root, "missing.yml") + context := &FilesystemContext{} + for _, entry := range []struct { + name string + load func(Source) (*Loaded[discoveryLoadConfig], error) + }{ + {"ordinary", func(source Source) (*Loaded[discoveryLoadConfig], error) { + return Load(source, testSecretBinding(), Options[discoveryLoadConfig]{Imports: true, Filesystem: context}) + }}, + {"layered main", func(source Source) (*Loaded[discoveryLoadConfig], error) { + return LoadLayered(Input{Source: source, Imports: true, Filesystem: context}, testSecretBinding(), LayeredOptions[discoveryLoadConfig]{}) + }}, + {"underlay", func(source Source) (*Loaded[discoveryLoadConfig], error) { + return LoadLayered(Input{Source: NoSource()}, testSecretBinding(), LayeredOptions[discoveryLoadConfig]{ + Underlays: []Underlay{{Inputs: []Input{{Source: source, Imports: true, Filesystem: context}}}}, + }) + }}, + } { + t.Run(entry.name, func(t *testing.T) { + explicit, err := entry.load(File(selected)) + if err != nil { + t.Fatal(err) + } + searched, err := entry.load(SearchConfig("", Paths(missing, selected))) + if err != nil { + t.Fatal(err) + } + assertEquivalentDiscoveryLoads(t, searched.Config, searched.Inspection, explicit.Config, explicit.Inspection, Keys("value")) + if searched.Config.Value != "selected" { + t.Fatalf("selected value = %q", searched.Config.Value) + } + for _, source := range []Source{NoSource(), SearchConfig("", Paths(missing))} { + result, err := entry.load(source) + var failure *LoadError + if result != nil || !errors.As(err, &failure) || len(failure.Diagnostics()) != 1 || failure.Diagnostics()[0].Code != CodeOptionsInvalid { + t.Fatalf("absent input with empty context = %#v, %v", result, err) + } + } + }) + } +} + func TestSearchSourceObservesFileChangesBetweenLoads(t *testing.T) { t.Parallel() diff --git a/internal/engine/filesystem.go b/internal/engine/filesystem.go index 4067ed2..2e35483 100644 --- a/internal/engine/filesystem.go +++ b/internal/engine/filesystem.go @@ -79,7 +79,14 @@ func filesystemOptionDiagnostics(source Source, context *FilesystemContext) []Di case sourceNone: return absentFilesystemOptionDiagnostics(context) case sourceSearch: - return absentFilesystemOptionDiagnostics(context) + if context.FS != nil { + return absentFilesystemOptionDiagnostics(context) + } + // A selected host file can derive its root; absence requires an + // explicit root. Check that compatibility only after selection. + if context.BaseDir != "" && !filepath.IsAbs(context.BaseDir) { + return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "filesystem base directory must be an absolute host path")} + } case sourceFile: if context.FS != nil { return []Diagnostic{optionDiagnostic(CodeOptionsInvalid, "host file sources cannot use a native filesystem context")}