diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 97f4fb4..da12936 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -570,6 +570,14 @@ inspection does not acquire synthesized whole-list origins/defaults. Record unrelated old-fixture failures for their assigned stages rather than restoring mutating resolution to satisfy them. +Completion: backend and profile normalization and defaults now operate on +detached interpreted records during validation, with presence captured from the +unchanged ConfigKit tree. Runtime indexes consume those interpreted values. +Focused real-loader tests passed for padded endpoints, nested diagnostics, +profile settings, omitted/null queue defaults, inherited targets, and preserved +composed values and origins on Go 1.27.0. Prompt interpretation follows in the +next assignment; older fixtures remain assigned to later integration work. + ## Stage 12: Recover Prompt Interpretation And Preserve Inspection Origins Scope: prompt interpretation/materialization in `load.go`/`prompts.go` and the diff --git a/internal/configuration/interpretation_test.go b/internal/configuration/interpretation_test.go new file mode 100644 index 0000000..b467541 --- /dev/null +++ b/internal/configuration/interpretation_test.go @@ -0,0 +1,46 @@ +package configuration + +import ( + "context" + "testing" + + "go.maximumdirect.net/configkit" +) + +func TestTargetInterpretationPreservesComposedNestedValues(t *testing.T) { + state, err := load(mapping("padded target", map[string]any{ + "backends": []any{map[string]any{ + "id": "local", "endpoint": " HTTPS://example.invalid/v1 ", "concurrency_limit": 1, + "smoke_tests": map[string]any{ + "reachability": map[string]any{"endpoint": " https://example.invalid/health "}, + "authentication": map[string]any{"kind": " NONE ", "endpoint": " https://example.invalid/key "}, + }, + }}, + "profiles": []any{map[string]any{"id": "partial", "backend": "local", "service_tier": " standard ", "api_key_env": " TOKEN "}}, + }), nil) + if err != nil { + t.Fatal(err) + } + backend, err := state.GetBackend("local") + if err != nil || backend.Endpoint != "https://example.invalid/v1" || backend.QueueCapacity != 1024 || + backend.SmokeTests == nil || *backend.SmokeTests.Reachability.Endpoint != "https://example.invalid/health" || + *backend.SmokeTests.Authentication.Kind != "none" || *backend.SmokeTests.Authentication.Endpoint != "https://example.invalid/key" { + t.Fatalf("interpreted backend = %+v, %v", backend, err) + } + profile, err := state.GetProfile(context.Background(), "partial") + if err != nil || profile.ResolvedTarget.ServiceTier != "standard" || profile.ResolvedTarget.APIKeyEnv != "TOKEN" { + t.Fatalf("interpreted profile = %+v, %v", profile, err) + } + composed := state.result.Config.Backends[0] + if *composed.Endpoint != " HTTPS://example.invalid/v1 " || composed.QueueCapacity != nil || + *composed.SmokeTests.Reachability.Endpoint != " https://example.invalid/health " || + *composed.SmokeTests.Authentication.Kind != " NONE " || *composed.SmokeTests.Authentication.Endpoint != " https://example.invalid/key " || + *state.result.Config.Profiles[0].ServiceTier != " standard " || *state.result.Config.Profiles[0].APIKeyEnv != " TOKEN " { + t.Fatalf("composed values were mutated: %+v", state.result.Config) + } + path := configkit.Keys("backends").Index(0).Key("smoke_tests").Key("authentication").Key("endpoint") + field, found := state.result.Metadata().Lookup(path) + if !found || len(field.Origin.Locations) == 0 || field.Origin.Operation == configkit.ChangeNormalization { + t.Fatalf("nested field origin changed: %+v", field) + } +} diff --git a/internal/configuration/load.go b/internal/configuration/load.go index c182e63..d22da3a 100644 --- a/internal/configuration/load.go +++ b/internal/configuration/load.go @@ -120,6 +120,7 @@ const ( type loaded struct { result *configkit.Result[document] + interpreted document backends map[string]map[string]fieldState profiles map[string]map[string]fieldState promptMetadata map[string][]promptInputPresence @@ -221,6 +222,36 @@ func Load(main configkit.Input, underlays []configkit.Underlay) (*loaded, error) return load(main, underlays) } +func configurationDefinition(state *loaded) configkit.Definition[document] { + return configkit.Definition[document]{ + SequenceIdentities: []configkit.SequenceIdentity{ + configkit.SequenceIdentityAt(configkit.Keys("backends"), "id"), + configkit.SequenceIdentityAt(configkit.Keys("profiles"), "id"), + configkit.SequenceIdentityAt(configkit.Keys("prompts"), "id"), + configkit.SequenceIdentityMatching(configkit.SequenceSelector{configkit.SequenceLiteralKey("collections"), configkit.SequenceAnyMappingKey(), configkit.SequenceLiteralKey("backends")}, "id"), + configkit.SequenceIdentityMatching(configkit.SequenceSelector{configkit.SequenceLiteralKey("collections"), configkit.SequenceAnyMappingKey(), configkit.SequenceLiteralKey("profiles")}, "id"), + configkit.SequenceIdentityMatching(configkit.SequenceSelector{configkit.SequenceLiteralKey("collections"), configkit.SequenceAnyMappingKey(), configkit.SequenceLiteralKey("prompts")}, "id"), + }, + Version: &configkit.VersionRule{ + Path: configkit.Keys("version"), + Check: checkConfigurationVersion, + }, + Sensitive: secretProviderPath, + Resolve: func(config *document, resolution *configkit.Resolution) error { + for _, resources := range resourceSets(config) { + captureFields(resources.backends, func(v backendConfig) string { return v.ID }, resources.bases.backends, resources.namespace, backendFields, resolution, state.backends) + captureFields(resources.profiles, func(v profileConfig) string { return v.ID }, resources.bases.profiles, resources.namespace, profileFields, resolution, state.profiles) + capturePromptMetadata(resources.prompts, resources.bases.prompts, resources.namespace, resolution, state.promptMetadata) + } + return nil + }, + Validate: func(config *document, validation *configkit.Validation) { + state.interpreted = interpretTargets(config, validation) + validateEffective(&state.interpreted, validation) + }, + } +} + // load performs one ConfigKit lifecycle over the selected source tiers. func load(main configkit.Input, underlays []configkit.Underlay) (*loaded, error) { state := &loaded{ @@ -231,35 +262,7 @@ func load(main configkit.Input, underlays []configkit.Underlay) (*loaded, error) result, err := configkit.LoadLayered(main, configkit.LayeredOptions[document]{ Underlays: underlays, SourceVersionMode: configkit.SourceVersionModeIfPresent, - Definition: configkit.Definition[document]{ - SequenceIdentities: []configkit.SequenceIdentity{ - configkit.SequenceIdentityAt(configkit.Keys("backends"), "id"), - configkit.SequenceIdentityAt(configkit.Keys("profiles"), "id"), - configkit.SequenceIdentityAt(configkit.Keys("prompts"), "id"), - configkit.SequenceIdentityMatching(configkit.SequenceSelector{configkit.SequenceLiteralKey("collections"), configkit.SequenceAnyMappingKey(), configkit.SequenceLiteralKey("backends")}, "id"), - configkit.SequenceIdentityMatching(configkit.SequenceSelector{configkit.SequenceLiteralKey("collections"), configkit.SequenceAnyMappingKey(), configkit.SequenceLiteralKey("profiles")}, "id"), - configkit.SequenceIdentityMatching(configkit.SequenceSelector{configkit.SequenceLiteralKey("collections"), configkit.SequenceAnyMappingKey(), configkit.SequenceLiteralKey("prompts")}, "id"), - }, - Version: &configkit.VersionRule{ - Path: configkit.Keys("version"), - Check: checkConfigurationVersion, - }, - Sensitive: secretProviderPath, - Resolve: func(config *document, resolution *configkit.Resolution) error { - for _, resources := range resourceSets(config) { - captureFields(resources.backends, func(v backendConfig) string { return v.ID }, resources.bases.backends, resources.namespace, backendFields, resolution, state.backends) - captureFields(resources.profiles, func(v profileConfig) string { return v.ID }, resources.bases.profiles, resources.namespace, profileFields, resolution, state.profiles) - capturePromptMetadata(resources.prompts, resources.bases.prompts, resources.namespace, resolution, state.promptMetadata) - resolveBackends(resources.backends, resources.bases.backends, resolution) - resolveProfiles(resources.profiles, resources.bases.profiles, resolution) - resolvePrompts(resources.prompts, resources.bases.prompts, resolution) - } - return nil - }, - Validate: func(config *document, validation *configkit.Validation) { - validateEffective(config, validation) - }, - }, + Definition: configurationDefinition(state), }) if err != nil { return nil, fmt.Errorf("load configuration: %w", err) @@ -271,7 +274,7 @@ func load(main configkit.Input, underlays []configkit.Underlay) (*loaded, error) if err := state.buildExecutionProfiles(); err != nil { return nil, fmt.Errorf("load configuration: %w", err) } - state.backendIDs = sortedKeys(indexedBackends(&result.Config)) + state.backendIDs = sortedKeys(indexedBackends(&state.interpreted)) state.profileIDs = sortedKeys(state.indexedProfiles) for _, id := range sortedKeys(state.prompts) { state.promptIDs = append(state.promptIDs, domain.PromptIdentity{ID: id}) @@ -386,48 +389,71 @@ func normalizeString(value **string, path configkit.Path, resolution *configkit. return false } -func resolveBackends(backends []backendConfig, basePath configkit.Path, resolution *configkit.Resolution) { +func interpretTargets(config *document, validation *configkit.Validation) document { + interpreted := *config + interpreted.Backends = interpretBackends(config.Backends, configkit.Keys("backends"), validation) + interpreted.Profiles = interpretProfiles(config.Profiles, configkit.Keys("profiles"), validation) + if config.Collections != nil { + interpreted.Collections = make(map[string]collectionConfig, len(config.Collections)) + for namespace, original := range config.Collections { + collection := original + base := configkit.Keys("collections", namespace) + collection.Backends = interpretBackends(original.Backends, base.Key("backends"), validation) + collection.Profiles = interpretProfiles(original.Profiles, base.Key("profiles"), validation) + interpreted.Collections[namespace] = collection + } + } + return interpreted +} + +func interpretBackends(source []backendConfig, basePath configkit.Path, validation *configkit.Validation) []backendConfig { + if source == nil { + return nil + } + backends := append([]backendConfig{}, source...) for index := range backends { backend := &backends[index] base := basePath.Index(index) if backend.Endpoint != nil { endpoint, err := domain.NormalizeOpenAICompatibleBaseEndpoint(*backend.Endpoint) if err != nil { - resolution.Error(base.Key("endpoint"), err.Error()) + validation.Error(base.Key("endpoint"), err.Error()) } else { - normalizeString(&backend.Endpoint, base.Key("endpoint"), resolution, endpoint) + backend.Endpoint = &endpoint } } if backend.APIKeyEnv != nil { - normalizeString(&backend.APIKeyEnv, base.Key("api_key_env"), resolution, strings.TrimSpace(*backend.APIKeyEnv)) + value := strings.TrimSpace(*backend.APIKeyEnv) + backend.APIKeyEnv = &value } - if smokeTests := backend.SmokeTests; smokeTests != nil { + if backend.SmokeTests != nil { + smokeTests := *backend.SmokeTests + backend.SmokeTests = &smokeTests smokePath := base.Key("smoke_tests") if smokeTests.Reachability != nil { - normalizeDiagnosticEndpoint(&smokeTests.Reachability.Endpoint, smokePath.Key("reachability").Key("endpoint"), resolution) + reachability := *smokeTests.Reachability + smokeTests.Reachability = &reachability + normalizeDiagnosticEndpoint(&reachability.Endpoint, smokePath.Key("reachability").Key("endpoint"), validation) } if smokeTests.Authentication != nil { - if smokeTests.Authentication.Kind != nil { - normalizeString(&smokeTests.Authentication.Kind, - smokePath.Key("authentication").Key("kind"), resolution, - strings.ToLower(strings.TrimSpace(*smokeTests.Authentication.Kind))) + authentication := *smokeTests.Authentication + smokeTests.Authentication = &authentication + if authentication.Kind != nil { + kind := strings.ToLower(strings.TrimSpace(*authentication.Kind)) + authentication.Kind = &kind } - normalizeDiagnosticEndpoint(&smokeTests.Authentication.Endpoint, - smokePath.Key("authentication").Key("endpoint"), resolution) + normalizeDiagnosticEndpoint(&authentication.Endpoint, smokePath.Key("authentication").Key("endpoint"), validation) } } if backend.ConcurrencyLimit != nil && *backend.ConcurrencyLimit > 0 && backend.QueueCapacity == nil { capacity := 1024 - if resolution.IsExplicit(base.Key("queue_capacity")) { - configkit.Normalize(resolution, base.Key("queue_capacity"), &backend.QueueCapacity, &capacity, "select default queue capacity") - } else { - configkit.Default(resolution, base.Key("queue_capacity"), &backend.QueueCapacity, &capacity, "select default queue capacity") - } + backend.QueueCapacity = &capacity } } + return backends } -func normalizeDiagnosticEndpoint(value **string, path configkit.Path, resolution *configkit.Resolution) { +func normalizeDiagnosticEndpoint(value **string, path configkit.Path, validation *configkit.Validation) { if value == nil || *value == nil { return } @@ -436,37 +462,36 @@ func normalizeDiagnosticEndpoint(value **string, path configkit.Path, resolution if normalized == "" || err != nil || request.URL == nil || (strings.ToLower(request.URL.Scheme) != "http" && strings.ToLower(request.URL.Scheme) != "https") || request.URL.Hostname() == "" { - resolution.Error(path, "diagnostic endpoint must be an absolute HTTP(S) URL with a host") + validation.Error(path, "diagnostic endpoint must be an absolute HTTP(S) URL with a host") return } - normalizeString(value, path, resolution, normalized) + *value = &normalized } -func resolveProfiles(profiles []profileConfig, basePath configkit.Path, resolution *configkit.Resolution) { +func interpretProfiles(source []profileConfig, basePath configkit.Path, validation *configkit.Validation) []profileConfig { + if source == nil { + return nil + } + profiles := append([]profileConfig{}, source...) for index := range profiles { profile := &profiles[index] base := basePath.Index(index) if profile.Endpoint != nil && *profile.Endpoint != "" { endpoint, err := domain.NormalizeOpenAICompatibleBaseEndpoint(*profile.Endpoint) if err != nil { - resolution.Error(base.Key("endpoint"), err.Error()) + validation.Error(base.Key("endpoint"), err.Error()) } else { - normalizeString(&profile.Endpoint, base.Key("endpoint"), resolution, endpoint) + profile.Endpoint = &endpoint } } - for _, field := range []struct { - name string - value **string - }{ - {"service_tier", &profile.ServiceTier}, - {"reasoning_effort", &profile.ReasoningEffort}, - {"api_key_env", &profile.APIKeyEnv}, - } { - if *field.value != nil { - normalizeString(field.value, base.Key(field.name), resolution, strings.TrimSpace(**field.value)) + for _, value := range []**string{&profile.ServiceTier, &profile.ReasoningEffort, &profile.APIKeyEnv} { + if *value != nil { + normalized := strings.TrimSpace(**value) + *value = &normalized } } } + return profiles } func validateEffective(config *document, validation *configkit.Validation) { diff --git a/internal/configuration/load_test.go b/internal/configuration/load_test.go index 887adfe..a546b17 100644 --- a/internal/configuration/load_test.go +++ b/internal/configuration/load_test.go @@ -229,7 +229,7 @@ func TestLoadRejectsInvalidCollectionAndResourceKeys(t *testing.T) { } } -func TestLoadRecordsBackendNormalizationAndNullDefault(t *testing.T) { +func TestLoadInterpretsBackendNormalizationAndNullDefault(t *testing.T) { loaded, err := load(mapping("backend", map[string]any{"backends": []any{ map[string]any{"id": "local", "endpoint": " HTTP://localhost:8080/v1 ", "api_key_env": " TOKEN_NAME ", "concurrency_limit": 2, "queue_capacity": nil}, @@ -237,18 +237,22 @@ func TestLoadRecordsBackendNormalizationAndNullDefault(t *testing.T) { if err != nil { t.Fatal(err) } - backend := loaded.result.Config.Backends[0] - if backend.Endpoint == nil || *backend.Endpoint != "http://localhost:8080/v1" || - backend.APIKeyEnv == nil || *backend.APIKeyEnv != "TOKEN_NAME" || - backend.QueueCapacity == nil || *backend.QueueCapacity != 1024 { + backend, err := loaded.GetBackend("local") + if err != nil || backend.Endpoint != "http://localhost:8080/v1" || + backend.APIKeyEnv != "TOKEN_NAME" || backend.QueueCapacity != 1024 { t.Fatalf("backend normalization/default was not applied: %+v", backend) } + composed := loaded.result.Config.Backends[0] + if composed.Endpoint == nil || *composed.Endpoint != " HTTP://localhost:8080/v1 " || + composed.APIKeyEnv == nil || *composed.APIKeyEnv != " TOKEN_NAME " || composed.QueueCapacity != nil { + t.Fatalf("composed backend was changed: %+v", composed) + } if loaded.backends["local"]["queue_capacity"] != fieldNull { t.Fatalf("explicit null was not retained: %+v", loaded.backends["local"]) } field, found := loaded.result.Metadata().Lookup(configkit.Keys("backends").Index(0).Key("endpoint")) - if !found || field.Origin.Operation != configkit.ChangeNormalization { - t.Fatalf("endpoint normalization was not recorded at the list entry: %+v", field) + if !found || field.Origin.Operation == configkit.ChangeNormalization || len(field.Origin.Locations) == 0 { + t.Fatalf("endpoint origin was not preserved: %+v", field) } } @@ -415,8 +419,8 @@ func TestLoadDefaultsAbsentQueueCapacity(t *testing.T) { if err != nil { t.Fatal(err) } - backend := loaded.result.Config.Backends[0] - if backend.APIKeyEnv != nil || backend.QueueCapacity == nil || *backend.QueueCapacity != 1024 || loaded.backends["local"]["queue_capacity"] != fieldAbsent { + backend, err := loaded.GetBackend("local") + if err != nil || backend.APIKeyEnv != "" || backend.QueueCapacity != 1024 || loaded.result.Config.Backends[0].QueueCapacity != nil || loaded.backends["local"]["queue_capacity"] != fieldAbsent { t.Fatalf("absent queue capacity did not receive its default: %+v", backend) } } diff --git a/internal/configuration/targets.go b/internal/configuration/targets.go index 771d295..3853b34 100644 --- a/internal/configuration/targets.go +++ b/internal/configuration/targets.go @@ -12,8 +12,8 @@ import ( ) func (state *loaded) buildExecutionProfiles() error { - backendConfigs := indexedBackends(&state.result.Config) - profileConfigs := indexedProfiles(&state.result.Config) + backendConfigs := indexedBackends(&state.interpreted) + profileConfigs := indexedProfiles(&state.interpreted) definitions := make(map[string]domain.Backend, len(backendConfigs)) for _, id := range sortedKeys(backendConfigs) { config := backendConfigs[id]