diff --git a/configuration_inspection_contract_test.go b/configuration_inspection_contract_test.go index 97bd1dd..774ff09 100644 --- a/configuration_inspection_contract_test.go +++ b/configuration_inspection_contract_test.go @@ -93,6 +93,28 @@ func TestConfigurationInspectionReportsLiteralKeysAndRedactedHistory(t *testing. } } +func TestConfigurationInspectionRetainsMainSpecificationDeclaration(t *testing.T) { + engine, err := promptkit.NewEngine(promptkit.Config{Sources: promptkit.ConfigurationSources{Main: &configkit.Input{ + Source: configkit.Mapping("main", map[string]any{"version": "1"}), + }}}) + if err != nil { + t.Fatalf("construct engine with accepted declaration: %v", err) + } + inspection, err := engine.InspectConfiguration() + if err != nil { + t.Fatalf("inspect configuration: %v", err) + } + path := configkit.Keys("version") + field, found := inspection.Explain(path) + if !found || !inspection.Metadata().IsExplicit(path) || !strings.Contains(field.Effective.YAML, "1") { + t.Fatalf("main specification declaration was not retained: (%+v, %t)", field, found) + } + var effective bytes.Buffer + if err := inspection.WriteEffective(&effective); err != nil || !strings.Contains(effective.String(), "version") { + t.Fatalf("effective inspection = (%q, %v), want retained version", effective.String(), err) + } +} + type failingInspectionWriter struct{ err error } func (writer failingInspectionWriter) Write([]byte) (int, error) { return 0, writer.err } diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 9726933..514cbcf 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -471,6 +471,13 @@ Validation: focused real-ConfigKit tests in `internal/configuration` and public construction/inspection tests. Do not replicate upstream's full version-type or import matrix. Run `GOWORK=off go test ./internal/configuration .`. +Completion: the optional root declaration is checked through ConfigKit's shared +source-version API, accepting only exact string or integer `1` while allowing +absence and preserving the main declaration in inspection. Accepted underlay +declarations are consumed; null and unsupported values produce safe diagnostics, +and headerless payloads still receive final-value validation. Validation passed: +`GOWORK=off go test ./internal/configuration .`. + ## Stage 8: Assemble And Index Final Collection Resources Scope: namespaced schema, final resource interpretation, and runtime indexes. diff --git a/internal/configuration/load.go b/internal/configuration/load.go index 481ab39..1e60149 100644 --- a/internal/configuration/load.go +++ b/internal/configuration/load.go @@ -17,6 +17,7 @@ import ( ) type document struct { + Version *string `yaml:"version"` Backends map[string]backendConfig `yaml:"backends"` Profiles map[string]profileConfig `yaml:"profiles"` Prompts map[string]promptConfig `yaml:"prompts"` @@ -152,8 +153,13 @@ func load(main configkit.Input, underlays []configkit.Underlay) (*loaded, error) promptMetadata: make(map[string][]promptInputPresence), } result, err := configkit.LoadLayered(main, configkit.LayeredOptions[document]{ - Underlays: underlays, + Underlays: underlays, + SourceVersionMode: configkit.SourceVersionModeIfPresent, Definition: configkit.Definition[document]{ + Version: &configkit.VersionRule{ + Path: configkit.Keys("version"), + Check: checkConfigurationVersion, + }, Secret: secretProviderPath, Resolve: func(config *document, resolution *configkit.Resolution) error { captureFields(config.Backends, bases.backends, backendFields, resolution, state.backends) @@ -186,6 +192,16 @@ func load(main configkit.Input, underlays []configkit.Underlay) (*loaded, error) return state, nil } +func checkConfigurationVersion(version configkit.Version, validation *configkit.Validation) error { + if !version.Present { + return nil + } + if version.Null || version.Text != "1" { + validation.Error(configkit.Keys("version"), "unsupported configuration specification version") + } + return nil +} + type promptInputPresence struct { contentType fieldState description fieldState diff --git a/internal/configuration/load_test.go b/internal/configuration/load_test.go index 1cfcb8d..51b5149 100644 --- a/internal/configuration/load_test.go +++ b/internal/configuration/load_test.go @@ -2,6 +2,7 @@ package configuration import ( "errors" + "strings" "testing" "gitea.maximumdirect.net/eric/ConfigKit" @@ -11,6 +12,77 @@ func mapping(label string, values map[string]any) configkit.Input { return configkit.Input{Source: configkit.Mapping(label, values)} } +func TestLoadChecksOptionalConfigurationVersion(t *testing.T) { + for _, test := range []struct { + name string + version any + include bool + valid bool + }{ + {name: "string one", version: "1", include: true, valid: true}, + {name: "integer one", version: int64(1), include: true, valid: true}, + {name: "omitted", valid: true}, + {name: "explicit null", include: true}, + {name: "unsupported string", version: "2", include: true}, + } { + t.Run(test.name, func(t *testing.T) { + values := map[string]any{} + if test.include { + values["version"] = test.version + } + loaded, err := load(mapping("main", values), nil) + if test.valid { + if err != nil { + t.Fatalf("load accepted version: %v", err) + } + if test.include && (loaded.result.Config.Version == nil || *loaded.result.Config.Version != "1") { + t.Fatalf("typed version = %v, want 1", loaded.result.Config.Version) + } + if !test.include && loaded.result.Config.Version != nil { + t.Fatalf("omitted version = %q, want nil", *loaded.result.Config.Version) + } + return + } + var loadErr *configkit.LoadError + if err == nil || !errors.As(err, &loadErr) { + t.Fatalf("load error = %v, want ConfigKit load error", err) + } + if strings.Contains(err.Error(), "2") { + t.Fatalf("load error disclosed the unsupported value: %v", err) + } + found := false + for _, diagnostic := range loadErr.Diagnostics() { + if diagnostic.Path.String() == "/version" && diagnostic.Message == "unsupported configuration specification version" { + found = true + } + } + if !found { + t.Fatalf("load diagnostics = %+v, want safe version diagnostic", loadErr.Diagnostics()) + } + }) + } +} + +func TestLoadConsumesAcceptedUnderlayVersionAndValidatesHeaderlessPayload(t *testing.T) { + underlay := mapping("declared underlay", map[string]any{ + "version": int64(1), + }) + loaded, err := load(mapping("headerless main", map[string]any{}), []configkit.Underlay{{Inputs: []configkit.Input{underlay}}}) + if err != nil { + t.Fatalf("load accepted non-main declaration: %v", err) + } + if loaded.result.Config.Version != nil { + t.Fatalf("consumed non-main declaration remained in effective payload: %q", *loaded.result.Config.Version) + } + + invalidFragment := mapping("headerless prompt fragment", map[string]any{ + "prompts": map[string]any{"incomplete": map[string]any{"description": "still validated"}}, + }) + if _, err := load(configkit.Input{Source: configkit.NoSource()}, []configkit.Underlay{{Inputs: []configkit.Input{invalidFragment}}}); err == nil || !strings.Contains(err.Error(), "at least one message is required") { + t.Fatalf("headerless fragment validation error = %v, want final prompt validation", err) + } +} + func TestLoadComposesLiteralKeysAndEffectivePresence(t *testing.T) { base := mapping("defaults", map[string]any{ "backends": map[string]any{