diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 355f178..0978689 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -210,6 +210,8 @@ No change to Audita or PromptKit is needed. ## Stage 7 — Close remaining integration and evidence gaps +**Status:** Complete. Focused discovery, source evidence, version, optional-resource, budget, and cleanup checks passed. + Fill remaining public integration gaps without repeating every engine test: first and later search candidates, explicit file and mapping/bytes/reader sources, only the file, only the directory, both, neither and an empty diff --git a/examples/consumers/main_relative_test.go b/examples/consumers/main_relative_test.go index 12e0cc5..6e7e01b 100644 --- a/examples/consumers/main_relative_test.go +++ b/examples/consumers/main_relative_test.go @@ -44,3 +44,43 @@ func TestMainRelativeStrictPeers(t *testing.T) { } t.Fatalf("diagnostics = %#v", err.(*configkit.LoadError).Diagnostics()) } + +func TestMainRelativeOptionalResources(t *testing.T) { + for _, tc := range []struct { + name string + file, directory, empty bool + count int + }{ + {"neither", false, false, false, 2}, {"file", true, false, false, 3}, + {"directory", false, true, false, 3}, {"both", true, true, false, 4}, {"empty directory", false, false, true, 2}, + } { + t.Run(tc.name, func(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "main.yml"), []byte("mode: main\n"), 0600); err != nil { + t.Fatal(err) + } + if tc.file { + if err := os.WriteFile(filepath.Join(root, "prompts.yml"), []byte("id: file\ntext: file\n"), 0600); err != nil { + t.Fatal(err) + } + } + if tc.directory || tc.empty { + if err := os.Mkdir(filepath.Join(root, "operator"), 0700); err != nil { + t.Fatal(err) + } + } + if tc.directory { + if err := os.WriteFile(filepath.Join(root, "operator", "item.yaml"), []byte("id: directory\ntext: directory\n"), 0600); err != nil { + t.Fatal(err) + } + } + result, err := consumers.LoadCompanionConfig(filepath.Join(root, "main.yml"), "") + if err != nil { + t.Fatalf("%v: %#v", err, err.(*configkit.LoadError).Diagnostics()) + } + if len(result.Config.Prompts) != tc.count { + t.Fatalf("prompts = %#v", result.Config.Prompts) + } + }) + } +} diff --git a/facade_main_relative_integration_test.go b/facade_main_relative_integration_test.go new file mode 100644 index 0000000..a743a71 --- /dev/null +++ b/facade_main_relative_integration_test.go @@ -0,0 +1,124 @@ +package configkit_test + +import ( + "os" + "path/filepath" + "strings" + "testing" + + configkit "go.maximumdirect.net/configkit" +) + +func TestMainRelativeDiscoveryAndEvidence(t *testing.T) { + root := t.TempDir() + first, second := filepath.Join(root, "first"), filepath.Join(root, "second") + for _, dir := range []string{first, second} { + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "main.yml"), []byte("count: 1\n"), 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "fragment.yml"), []byte("settings: {selected: "+filepath.Base(dir)+"}\n"), 0600); err != nil { + t.Fatal(err) + } + } + binding := configkit.MainRelative() + underlay := configkit.Input{Source: configkit.Bytes("declaration", []byte("config: {imports: [{path: fragment.yml}]}\n")), Imports: true, MainRelative: binding} + for _, test := range []struct { + name string + paths []string + want string + }{ + {"first", []string{filepath.Join(first, "main.yml"), filepath.Join(second, "main.yml")}, "first"}, + {"later", []string{filepath.Join(root, "missing.yml"), filepath.Join(second, "main.yml")}, "second"}, + } { + t.Run(test.name, func(t *testing.T) { + var callbacks int + result, err := configkit.LoadLayered(configkit.Input{Source: configkit.SearchConfig("", configkit.SearchPaths(test.paths...))}, configkit.LayeredOptions[layeredFacadeConfig]{Underlays: []configkit.Underlay{{Inputs: []configkit.Input{underlay}}}, Definition: configkit.Definition[layeredFacadeConfig]{Validate: func(*layeredFacadeConfig, *configkit.Validation) { callbacks++ }}}) + if err != nil { + t.Fatalf("%v: %#v", err, err.(*configkit.LoadError).Diagnostics()) + } + if result.Config.Settings["selected"] != test.want || callbacks != 1 { + t.Fatalf("config = %#v, callbacks = %d", result.Config, callbacks) + } + sources := result.Sources() + if len(sources) != 3 || sources[0].Layer != configkit.LayerMain || sources[1].Layer != configkit.LayerUnderlay || sources[2].Layer != configkit.LayerUnderlay || sources[2].UnderlayIndex != 1 || filepath.Base(sources[2].Path) != "fragment.yml" || sources[1].Path != "" { + t.Fatalf("sources = %#v", sources) + } + if _, ok := result.SourceContext(sources[1].ID); !ok { + t.Fatal("bound synthetic source lacks context") + } + if _, ok := result.SourceContext(sources[2].ID); !ok { + t.Fatal("import source lacks context") + } + }) + } +} + +func TestMainRelativeSyntheticMainContexts(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "fragment.yml"), []byte("settings: {selected: synthetic}\n"), 0600); err != nil { + t.Fatal(err) + } + ctx := &configkit.FilesystemContext{Root: root, BaseDir: root} + mainSources := []configkit.Source{configkit.Bytes("main bytes", []byte("count: 1\n")), configkit.Reader("main reader", strings.NewReader("count: 1\n")), configkit.Mapping("main mapping", map[string]any{"count": 1})} + for _, source := range mainSources { + input := configkit.Input{Source: configkit.Bytes("underlay", []byte("config: {imports: [{path: fragment.yml}]}\n")), Imports: true, MainRelative: configkit.MainRelative()} + result, err := configkit.LoadLayered(configkit.Input{Source: source, Filesystem: ctx}, configkit.LayeredOptions[layeredFacadeConfig]{Underlays: []configkit.Underlay{{Inputs: []configkit.Input{input}}}}) + if err != nil { + t.Fatalf("%v: %#v", err, err.(*configkit.LoadError).Diagnostics()) + } + if result.Config.Settings["selected"] != "synthetic" { + t.Fatalf("config = %#v", result.Config) + } + } +} + +func TestMainRelativeSelectedMainFailureIsTerminal(t *testing.T) { + root := t.TempDir() + bad, good := filepath.Join(root, "bad.yml"), filepath.Join(root, "good.yml") + if err := os.WriteFile(bad, []byte("count: [\n"), 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(good, []byte("count: 1\n"), 0600); err != nil { + t.Fatal(err) + } + called := false + input := configkit.Input{Source: configkit.Bytes("underlay", []byte("count: 3\n")), MainRelative: &configkit.MainRelativeContext{Fallback: &configkit.FilesystemContext{Root: root, BaseDir: root}}} + _, err := configkit.LoadLayered(configkit.Input{Source: configkit.SearchConfig("", configkit.SearchPaths(bad, good))}, configkit.LayeredOptions[layeredFacadeConfig]{Underlays: []configkit.Underlay{{Inputs: []configkit.Input{input}}}, Definition: configkit.Definition[layeredFacadeConfig]{Validate: func(*layeredFacadeConfig, *configkit.Validation) { called = true }}}) + if err == nil || called { + t.Fatalf("error = %v, callback = %v", err, called) + } +} + +func TestMainRelativeSourceVersionEvidence(t *testing.T) { + root := t.TempDir() + if err := os.WriteFile(filepath.Join(root, "main.yml"), []byte("version: 1\ncount: 1\n"), 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, "fragment.yml"), []byte("version: 2\nsettings: {loaded: fragment}\n"), 0600); err != nil { + t.Fatal(err) + } + input := configkit.Input{Source: configkit.Bytes("companion", []byte("version: 3\nconfig: {imports: [{path: fragment.yml}]}\n")), Imports: true, MainRelative: configkit.MainRelative()} + type versioned struct { + Version int `yaml:"version"` + Count int `yaml:"count"` + Settings map[string]string `yaml:"settings"` + } + definition := configkit.Definition[versioned]{Version: &configkit.VersionRule{Path: configkit.Keys("version"), Check: func(configkit.Version, *configkit.Validation) error { return nil }}} + result, err := configkit.LoadLayered(configkit.Input{Source: configkit.File(filepath.Join(root, "main.yml"))}, configkit.LayeredOptions[versioned]{Definition: definition, SourceVersionMode: configkit.SourceVersionModeRequired, Underlays: []configkit.Underlay{{Inputs: []configkit.Input{input}}}}) + if err != nil { + t.Fatalf("%v: %#v", err, err.(*configkit.LoadError).Diagnostics()) + } + sources := result.Sources() + if len(sources) != 3 { + t.Fatalf("sources = %#v", sources) + } + for i, want := range []string{"1", "3", "2"} { + evidence := sources[i].VersionEvidence + if evidence == nil || evidence.Version.Text != want || evidence.Location == nil || evidence.Location.SourceID != sources[i].ID { + t.Fatalf("version evidence = %#v", sources) + } + } +} diff --git a/internal/engine/main_relative_integration_test.go b/internal/engine/main_relative_integration_test.go new file mode 100644 index 0000000..2815d5d --- /dev/null +++ b/internal/engine/main_relative_integration_test.go @@ -0,0 +1,44 @@ +package engine + +import ( + "testing" + "testing/fstest" +) + +func TestMainRelativeAcquisitionClosesHandlesOnFailure(t *testing.T) { + for _, fail := range []string{"parse", "read"} { + t.Run(fail, func(t *testing.T) { + body := "items: [invalid\n" + if fail == "read" { + body = "items: {key: value}\n" + } + filesystem := &observedFilesystem{files: fstest.MapFS{"dir/main.yml": {Data: []byte("{}\n")}, "dir/child.yml": {Data: []byte(body)}}} + if fail == "read" { + filesystem.failRead = "dir/child.yml" + } + namespace := newNativeNamespace("bundle", filesystem) + main := Input{Source: filesystemFile(namespace, "dir/main.yml")} + underlay := Input{Source: Bytes("declarations", []byte("config: {imports: [{path: child.yml}]}\n")), Imports: true, MainRelative: &MainRelativeContext{}} + _, err := LoadLayered[importTestConfig](main, SecretBinding{}, LayeredOptions[importTestConfig]{Underlays: []Underlay{{Inputs: []Input{underlay}}}}) + if err == nil { + t.Fatal("invalid selected child succeeded") + } + filesystem.assertClosed(t) + }) + } +} + +func TestMainRelativeSharesAcquisitionBudget(t *testing.T) { + limits := defaultImportLimits() + limits.sources = 1 + accounting := &importAccounting{} + state := &acquisitionState{accounting: accounting} + group := loadGroup{inputs: []loadInput{ + {source: Bytes("one", []byte("items: {one: value}\n")), mainRelative: &MainRelativeContext{}}, + {source: Bytes("two", []byte("items: {two: value}\n")), imports: true, mainRelative: &MainRelativeContext{}}, + }} + _, diagnostics := acquireUnderlayGroupScopes(group, 1, nil, state, acquisitionPolicy{importLimits: limits}, nil) + if len(diagnostics) == 0 || diagnostics[0].Code != CodeImportLimitExceeded { + t.Fatalf("diagnostics = %#v", diagnostics) + } +}