diff --git a/framework/configstore/complexityconfig.go b/framework/configstore/complexityconfig.go index f83aae62ee9..c98a6f036d7 100644 --- a/framework/configstore/complexityconfig.go +++ b/framework/configstore/complexityconfig.go @@ -156,6 +156,66 @@ func MergeComplexityAnalyzerConfig(base, file *ComplexityAnalyzerConfig) (*Compl return &merged, nil } +// MergeComplexityAnalyzerConfigWithFileSnapshot overlays file boundaries, removes +// keywords removed from config.json since the previous file snapshot, and keeps +// runtime-only keywords. +func MergeComplexityAnalyzerConfigWithFileSnapshot(base, previousFile, file *ComplexityAnalyzerConfig) (*ComplexityAnalyzerConfig, error) { + if previousFile == nil { + return MergeComplexityAnalyzerConfig(base, file) + } + if file == nil { + return MergeComplexityAnalyzerConfig(base, nil) + } + + normalizedFile := file.Normalized() + if err := normalizedFile.Validate(); err != nil { + return nil, err + } + normalizedPreviousFile := previousFile.Normalized() + if err := normalizedPreviousFile.Validate(); err != nil { + return nil, err + } + + var normalizedBase ComplexityAnalyzerConfig + if base != nil { + normalizedBase = base.Normalized() + if err := normalizedBase.Validate(); err != nil { + return nil, err + } + } + + merged := ComplexityAnalyzerConfig{ + TierBoundaries: normalizedFile.TierBoundaries, + Keywords: ComplexityEditableKeywordConfig{ + CodeKeywords: mergeComplexityKeywordListsWithFileSnapshot( + normalizedBase.Keywords.CodeKeywords, + normalizedPreviousFile.Keywords.CodeKeywords, + normalizedFile.Keywords.CodeKeywords, + ), + ReasoningKeywords: mergeComplexityKeywordListsWithFileSnapshot( + normalizedBase.Keywords.ReasoningKeywords, + normalizedPreviousFile.Keywords.ReasoningKeywords, + normalizedFile.Keywords.ReasoningKeywords, + ), + TechnicalKeywords: mergeComplexityKeywordListsWithFileSnapshot( + normalizedBase.Keywords.TechnicalKeywords, + normalizedPreviousFile.Keywords.TechnicalKeywords, + normalizedFile.Keywords.TechnicalKeywords, + ), + SimpleKeywords: mergeComplexityKeywordListsWithFileSnapshot( + normalizedBase.Keywords.SimpleKeywords, + normalizedPreviousFile.Keywords.SimpleKeywords, + normalizedFile.Keywords.SimpleKeywords, + ), + }, + ConfigHash: normalizedFile.ConfigHash, + } + if err := merged.Validate(); err != nil { + return nil, err + } + return &merged, nil +} + // DecodeComplexityAnalyzerConfig decodes raw JSON into a normalized, validated config. func DecodeComplexityAnalyzerConfig(data []byte) (*ComplexityAnalyzerConfig, error) { if len(data) == 0 { @@ -202,3 +262,26 @@ func mergeComplexityKeywordLists(base, overlay []string) []string { values = append(values, overlay...) return normalizeComplexityKeywordList(values) } + +func mergeComplexityKeywordListsWithFileSnapshot(current []string, previousFile []string, file []string) []string { + normalizedFile := normalizeComplexityKeywordList(file) + fileSet := make(map[string]struct{}, len(normalizedFile)) + for _, value := range normalizedFile { + fileSet[value] = struct{}{} + } + + removedFromFile := make(map[string]struct{}, len(previousFile)) + for _, value := range normalizeComplexityKeywordList(previousFile) { + if _, stillInFile := fileSet[value]; !stillInFile { + removedFromFile[value] = struct{}{} + } + } + + preserved := make([]string, 0, len(current)) + for _, value := range normalizeComplexityKeywordList(current) { + if _, removed := removedFromFile[value]; !removed { + preserved = append(preserved, value) + } + } + return mergeComplexityKeywordLists(preserved, normalizedFile) +} diff --git a/framework/configstore/rdb.go b/framework/configstore/rdb.go index 9eb45136fb6..bfe7732e315 100644 --- a/framework/configstore/rdb.go +++ b/framework/configstore/rdb.go @@ -2299,6 +2299,17 @@ func (s *RDBConfigStore) UpdateConfig(ctx context.Context, config *tables.TableG return txDB.WithContext(ctx).Save(config).Error } +// DeleteConfig deletes a specific config key from the database. +func (s *RDBConfigStore) DeleteConfig(ctx context.Context, key string, tx ...*gorm.DB) error { + var txDB *gorm.DB + if len(tx) > 0 && tx[0] != nil { + txDB = tx[0] + } else { + txDB = s.DB() + } + return txDB.WithContext(ctx).Delete(&tables.TableGovernanceConfig{}, "key = ?", key).Error +} + // GetModelPrices retrieves all model pricing records from the database. func (s *RDBConfigStore) GetModelPrices(ctx context.Context) ([]tables.TableModelPricing, error) { var modelPrices []tables.TableModelPricing @@ -5065,6 +5076,36 @@ func (s *RDBConfigStore) UpdateComplexityAnalyzerConfig(ctx context.Context, con return s.updateComplexityAnalyzerConfigTx(ctx, normalized, tx...) } +// ApplyComplexityAnalyzerConfigFromFile persists a file-reconciled analyzer config and its file snapshot. +func (s *RDBConfigStore) ApplyComplexityAnalyzerConfigFromFile(ctx context.Context, config *ComplexityAnalyzerConfig, fileSnapshot *ComplexityAnalyzerConfig, tx ...*gorm.DB) error { + if config == nil { + return fmt.Errorf("complexity analyzer config is nil") + } + if fileSnapshot == nil { + return fmt.Errorf("complexity analyzer file snapshot is nil") + } + + normalized := config.Normalized() + if err := normalized.Validate(); err != nil { + return err + } + if normalized.ConfigHash == "" { + return fmt.Errorf("complexity analyzer config hash is required for file apply") + } + normalizedSnapshot := fileSnapshot.Normalized() + if err := normalizedSnapshot.Validate(); err != nil { + return err + } + normalizedSnapshot.ConfigHash = "" + + if len(tx) == 0 || tx[0] == nil { + return s.DB().WithContext(ctx).Transaction(func(transaction *gorm.DB) error { + return s.applyComplexityAnalyzerConfigFromFileTx(ctx, normalized, normalizedSnapshot, transaction) + }) + } + return s.applyComplexityAnalyzerConfigFromFileTx(ctx, normalized, normalizedSnapshot, tx...) +} + func (s *RDBConfigStore) updateComplexityAnalyzerConfigTx(ctx context.Context, normalized ComplexityAnalyzerConfig, tx ...*gorm.DB) error { raw, err := json.Marshal(normalized) if err != nil { @@ -5085,6 +5126,20 @@ func (s *RDBConfigStore) updateComplexityAnalyzerConfigTx(ctx context.Context, n }, tx...) } +func (s *RDBConfigStore) applyComplexityAnalyzerConfigFromFileTx(ctx context.Context, normalized ComplexityAnalyzerConfig, snapshot ComplexityAnalyzerConfig, tx ...*gorm.DB) error { + if err := s.updateComplexityAnalyzerConfigTx(ctx, normalized, tx...); err != nil { + return err + } + rawSnapshot, err := json.Marshal(snapshot) + if err != nil { + return fmt.Errorf("failed to marshal complexity analyzer config snapshot: %w", err) + } + return s.UpdateConfig(ctx, &tables.TableGovernanceConfig{ + Key: tables.ConfigComplexityAnalyzerConfigSnapshotKey, + Value: string(rawSnapshot), + }, tx...) +} + // GetAuthConfig retrieves the auth configuration from the database. func (s *RDBConfigStore) GetAuthConfig(ctx context.Context) (*AuthConfig, error) { var username *string diff --git a/framework/configstore/rdb_test.go b/framework/configstore/rdb_test.go index 6cff40b566c..c468211fdd5 100644 --- a/framework/configstore/rdb_test.go +++ b/framework/configstore/rdb_test.go @@ -113,7 +113,7 @@ func TestRDBConfigStore_UpdateComplexityAnalyzerConfigPreservesExistingHashOnRun fileConfig := testComplexityAnalyzerConfig() fileConfig.ConfigHash = "file-hash-1" - require.NoError(t, store.UpdateComplexityAnalyzerConfig(ctx, fileConfig)) + require.NoError(t, store.ApplyComplexityAnalyzerConfigFromFile(ctx, fileConfig, fileConfig)) runtimeConfig := testComplexityAnalyzerConfig() runtimeConfig.TierBoundaries.SimpleMedium = 0.12 @@ -125,6 +125,13 @@ func TestRDBConfigStore_UpdateComplexityAnalyzerConfigPreservesExistingHashOnRun require.NotNil(t, got) assert.Equal(t, 0.12, got.TierBoundaries.SimpleMedium) assert.Equal(t, "file-hash-1", got.ConfigHash) + + snapshot, err := store.GetConfig(ctx, tables.ConfigComplexityAnalyzerConfigSnapshotKey) + require.NoError(t, err) + require.NotNil(t, snapshot) + var snapshotCfg ComplexityAnalyzerConfig + require.NoError(t, json.Unmarshal([]byte(snapshot.Value), &snapshotCfg)) + assert.Equal(t, 0.10, snapshotCfg.TierBoundaries.SimpleMedium) } func TestRDBConfigStore_GetGovernanceConfigIncludesComplexityAnalyzerConfig(t *testing.T) { @@ -182,6 +189,61 @@ func TestMergeComplexityAnalyzerConfigAddsKeywordsAndOverlaysBoundaries(t *testi assert.Equal(t, []string{"hello", "thanks"}, merged.Keywords.SimpleKeywords) } +func TestMergeComplexityAnalyzerConfigWithFileSnapshotRemovesFileOwnedKeywords(t *testing.T) { + tests := []struct { + name string + current []string + previousFile []string + file []string + want []string + }{ + { + name: "removes file-owned keyword removed from file", + current: []string{"api", "function", "ui-code"}, + previousFile: []string{"api", "function"}, + file: []string{"function", "new-file-code"}, + want: []string{"function", "new-file-code", "ui-code"}, + }, + { + name: "keeps current-only keyword when file is unchanged", + current: []string{"api", "ui-code"}, + previousFile: []string{"api"}, + file: []string{"api"}, + want: []string{"api", "ui-code"}, + }, + { + name: "removes all stale file-owned keywords and adds new file keyword", + current: []string{"api", "function", "ui-code"}, + previousFile: []string{"api", "function"}, + file: []string{"new-file-code"}, + want: []string{"new-file-code", "ui-code"}, + }, + { + name: "normalizes case and duplicates", + current: []string{"API", "api", "Ui-Code"}, + previousFile: []string{"API"}, + file: []string{"api", "GraphQL", "graphql"}, + want: []string{"api", "graphql", "ui-code"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + current := testComplexityAnalyzerConfig() + current.Keywords.CodeKeywords = tt.current + previousFile := testComplexityAnalyzerConfig() + previousFile.Keywords.CodeKeywords = tt.previousFile + file := testComplexityAnalyzerConfig() + file.Keywords.CodeKeywords = tt.file + + merged, err := MergeComplexityAnalyzerConfigWithFileSnapshot(current, previousFile, file) + require.NoError(t, err) + require.NotNil(t, merged) + assert.Equal(t, tt.want, merged.Keywords.CodeKeywords) + }) + } +} + func TestRDBConfigStore_UpdateComplexityAnalyzerConfigRejectsInvalidConfig(t *testing.T) { store := setupRDBTestStore(t) ctx := context.Background() diff --git a/framework/configstore/store.go b/framework/configstore/store.go index 444c3bda710..0f72712598a 100644 --- a/framework/configstore/store.go +++ b/framework/configstore/store.go @@ -55,15 +55,15 @@ type MCPClientsQueryParams struct { // parameters for MCP library catalog queries. All fields are optional — an // empty struct returns the first default-sized page ordered by name. type MCPLibraryQueryParams struct { - Limit int - Offset int - Search string // matches name/description/publisher (case-insensitive) - Categories []string // exact category filter(s), OR semantics + Limit int + Offset int + Search string // matches name/description/publisher (case-insensitive) + Categories []string // exact category filter(s), OR semantics ConnectionTypes []string // exact connection_type filter(s) (http | stdio | sse) - AuthTypes []string // exact auth_type filter(s) - Tags []string // match rows carrying any of these tags - SortBy string // name, category, publisher, created_at, updated_at (default: name) - Order string // asc, desc (default: asc) + AuthTypes []string // exact auth_type filter(s) + Tags []string // match rows carrying any of these tags + SortBy string // name, category, publisher, created_at, updated_at (default: name) + Order string // asc, desc (default: asc) } // MCPLibraryFilterData holds the distinct facet values surfaced by the filter @@ -215,10 +215,13 @@ type ConfigStore interface { // Config CRUD GetConfig(ctx context.Context, key string) (*tables.TableGovernanceConfig, error) UpdateConfig(ctx context.Context, config *tables.TableGovernanceConfig, tx ...*gorm.DB) error + DeleteConfig(ctx context.Context, key string, tx ...*gorm.DB) error // GetComplexityAnalyzerConfig retrieves the persisted analyzer config, if configured. GetComplexityAnalyzerConfig(ctx context.Context) (*ComplexityAnalyzerConfig, error) // UpdateComplexityAnalyzerConfig persists the normalized analyzer config. UpdateComplexityAnalyzerConfig(ctx context.Context, config *ComplexityAnalyzerConfig, tx ...*gorm.DB) error + // ApplyComplexityAnalyzerConfigFromFile persists the reconciled analyzer config and file snapshot. + ApplyComplexityAnalyzerConfigFromFile(ctx context.Context, config *ComplexityAnalyzerConfig, fileSnapshot *ComplexityAnalyzerConfig, tx ...*gorm.DB) error // Plugins CRUD GetPlugins(ctx context.Context) ([]*tables.TablePlugin, error) diff --git a/framework/configstore/tables/config.go b/framework/configstore/tables/config.go index 228ace846c6..f28347c8a11 100644 --- a/framework/configstore/tables/config.go +++ b/framework/configstore/tables/config.go @@ -11,8 +11,10 @@ const ( ConfigComplexityAnalyzerConfigKey = "complexity_analyzer_config" // ConfigComplexityAnalyzerConfigHashKey stores the last config.json hash synced for the analyzer config. ConfigComplexityAnalyzerConfigHashKey = "complexity_analyzer_config_hash" - ConfigRestartRequiredKey = "restart_required" - ConfigHeaderFilterKey = "header_filter_config" + // ConfigComplexityAnalyzerConfigSnapshotKey stores the last config.json analyzer config used for split-mode reconciliation. + ConfigComplexityAnalyzerConfigSnapshotKey = "complexity_analyzer_config_snapshot" + ConfigRestartRequiredKey = "restart_required" + ConfigHeaderFilterKey = "header_filter_config" ) // Keys for the ClientConfig.MetadataJSON blob. diff --git a/transports/bifrost-http/lib/config.go b/transports/bifrost-http/lib/config.go index 56be6b59047..3898bcd1535 100644 --- a/transports/bifrost-http/lib/config.go +++ b/transports/bifrost-http/lib/config.go @@ -314,6 +314,8 @@ func (cd *ConfigData) governanceSectionPresent(name string) bool { return cd.Governance.RoutingRules != nil case "pricing_overrides": return cd.Governance.PricingOverrides != nil + case "complexity_analyzer_config": + return cd.Governance.ComplexityAnalyzerConfig != nil default: return false } @@ -2403,26 +2405,30 @@ func reconcileComplexityAnalyzerConfig(ctx context.Context, config *Config, conf if !ok { return } - current := config.GovernanceConfig.ComplexityAnalyzerConfig if configData.isConfigJSONSourceOfTruth() { fileConfig.ConfigHash = fileHash - syncComplexityAnalyzerConfig(ctx, config, current, fileConfig) + applyComplexityAnalyzerConfigFromFile(ctx, config, current, fileConfig, fileConfig, false) return } + previousFileConfig, snapshotFound := complexityAnalyzerConfigFileSnapshot(ctx, config.ConfigStore) + if current != nil && current.ConfigHash == fileHash { + if !snapshotFound { + applyComplexityAnalyzerConfigFromFile(ctx, config, current, current, fileConfig, true) + } logger.Debug("complexity analyzer config hash matches, keeping DB config") return } - merged, err := mergeComplexityAnalyzerConfigFromFile(current, fileConfig) + merged, err := mergeComplexityAnalyzerConfigFromFile(current, previousFileConfig, fileConfig) if err != nil { logger.Warn("failed to merge complexity analyzer config from config file: %v", err) return } merged.ConfigHash = fileHash - syncComplexityAnalyzerConfig(ctx, config, current, merged) + applyComplexityAnalyzerConfigFromFile(ctx, config, current, merged, fileConfig, false) } // complexityAnalyzerConfigFromFile validates the file-backed analyzer config and @@ -2444,27 +2450,91 @@ func complexityAnalyzerConfigFromFile(configData *ConfigData) (*configstore.Comp // mergeComplexityAnalyzerConfigFromFile overlays file boundaries and merges file // keywords into the current runtime config. On first startup, defaults are the // base so a partial seed does not erase the built-in keyword coverage. -func mergeComplexityAnalyzerConfigFromFile(current, fileConfig *configstore.ComplexityAnalyzerConfig) (*configstore.ComplexityAnalyzerConfig, error) { +func mergeComplexityAnalyzerConfigFromFile(current, previousFileConfig, fileConfig *configstore.ComplexityAnalyzerConfig) (*configstore.ComplexityAnalyzerConfig, error) { base := current if base == nil { defaults := complexity.DefaultAnalyzerConfig() base = &defaults } - return configstore.MergeComplexityAnalyzerConfig(base, fileConfig) + return configstore.MergeComplexityAnalyzerConfigWithFileSnapshot(base, previousFileConfig, fileConfig) } -// syncComplexityAnalyzerConfig updates the in-memory config and persists it when -// the stored value changed. -func syncComplexityAnalyzerConfig(ctx context.Context, config *Config, current, next *configstore.ComplexityAnalyzerConfig) { +// complexityAnalyzerConfigFileSnapshot loads the previous config.json analyzer config. +func complexityAnalyzerConfigFileSnapshot(ctx context.Context, store configstore.ConfigStore) (*configstore.ComplexityAnalyzerConfig, bool) { + if store == nil { + return nil, false + } + entry, err := store.GetConfig(ctx, configstoreTables.ConfigComplexityAnalyzerConfigSnapshotKey) + if err != nil { + if !errors.Is(err, configstore.ErrNotFound) { + logger.Warn("failed to load complexity analyzer config file snapshot: %v", err) + } + return nil, false + } + if entry == nil || strings.TrimSpace(entry.Value) == "" { + return nil, false + } + decoded, err := configstore.DecodeComplexityAnalyzerConfig([]byte(entry.Value)) + if err != nil { + logger.Warn("failed to decode complexity analyzer config file snapshot: %v", err) + return nil, false + } + return decoded, true +} + +// applyComplexityAnalyzerConfigFromFile persists file reconciliation metadata and +// swaps the in-memory config after persistence succeeds. +func applyComplexityAnalyzerConfigFromFile(ctx context.Context, config *Config, current, next, fileSnapshot *configstore.ComplexityAnalyzerConfig, forceMetadataSync bool) { + if config.ConfigStore == nil { + config.GovernanceConfig.ComplexityAnalyzerConfig = next + return + } + if !forceMetadataSync && current != nil && reflect.DeepEqual(current, next) { + config.GovernanceConfig.ComplexityAnalyzerConfig = next + return + } + if err := config.ConfigStore.ApplyComplexityAnalyzerConfigFromFile(ctx, next, fileSnapshot); err != nil { + logger.Warn("failed to sync complexity analyzer config from config file: %v", err) + return + } config.GovernanceConfig.ComplexityAnalyzerConfig = next - if config.ConfigStore != nil { - if current != nil && reflect.DeepEqual(current, next) { - return +} + +// pruneComplexityAnalyzerConfig deletes the singleton analyzer config and its +// file-reconciliation metadata when config.json is authoritative. +func pruneComplexityAnalyzerConfig(ctx context.Context, config *Config, tx *gorm.DB) error { + for _, key := range []string{ + configstoreTables.ConfigComplexityAnalyzerConfigKey, + configstoreTables.ConfigComplexityAnalyzerConfigHashKey, + configstoreTables.ConfigComplexityAnalyzerConfigSnapshotKey, + } { + if err := config.ConfigStore.DeleteConfig(ctx, key, tx); err != nil { + return fmt.Errorf("failed to delete %s: %w", key, err) + } + } + return nil +} + +// hasComplexityAnalyzerConfigFileMetadata reports whether the stored analyzer has +// complete config.json reconciliation metadata. +func hasComplexityAnalyzerConfigFileMetadata(ctx context.Context, store configstore.ConfigStore) bool { + if store == nil { + return false + } + for _, key := range []string{ + configstoreTables.ConfigComplexityAnalyzerConfigHashKey, + configstoreTables.ConfigComplexityAnalyzerConfigSnapshotKey, + } { + entry, err := store.GetConfig(ctx, key) + if err != nil && !errors.Is(err, configstore.ErrNotFound) { + logger.Warn("failed to inspect complexity analyzer config metadata %s: %v", key, err) + return false } - if err := config.ConfigStore.UpdateComplexityAnalyzerConfig(ctx, next); err != nil { - logger.Warn("failed to sync complexity analyzer config from config file: %v", err) + if err != nil || entry == nil || strings.TrimSpace(entry.Value) == "" { + return false } } + return true } // pruneGovernanceConfigToFile removes DB-only governance rows for file-present collections. @@ -2473,7 +2543,14 @@ func pruneGovernanceConfigToFile(ctx context.Context, config *Config, configData return } logger.Debug("source_of_truth=config.json: pruning governance rows not present in config file") + shouldPruneComplexityAnalyzerConfig := !configData.governanceSectionPresent("complexity_analyzer_config") && + hasComplexityAnalyzerConfigFileMetadata(ctx, config.ConfigStore) err := config.ConfigStore.ExecuteTransaction(ctx, func(tx *gorm.DB) error { + if shouldPruneComplexityAnalyzerConfig { + if err := pruneComplexityAnalyzerConfig(ctx, config, tx); err != nil { + return err + } + } if configData.governanceSectionPresent("virtual_keys") { keep := make(map[string]bool, len(configData.Governance.VirtualKeys)) for i := range configData.Governance.VirtualKeys { @@ -2617,6 +2694,10 @@ func pruneGovernanceConfigToFile(ctx context.Context, config *Config, configData }) if err != nil { logger.Fatal("failed to prune governance config: %v", err) + return + } + if shouldPruneComplexityAnalyzerConfig { + config.GovernanceConfig.ComplexityAnalyzerConfig = nil } } @@ -3502,8 +3583,13 @@ func createGovernanceConfigInStore(ctx context.Context, config *Config) { if err != nil { logger.Warn("invalid complexity analyzer config in config file: %v", err) } else if normalized != nil { + fileHash, err := configstore.GenerateComplexityAnalyzerConfigHash(normalized) + if err != nil { + return fmt.Errorf("failed to generate complexity analyzer config hash: %w", err) + } + normalized.ConfigHash = fileHash config.GovernanceConfig.ComplexityAnalyzerConfig = normalized - if err := config.ConfigStore.UpdateComplexityAnalyzerConfig(ctx, normalized, tx); err != nil { + if err := config.ConfigStore.ApplyComplexityAnalyzerConfigFromFile(ctx, normalized, normalized, tx); err != nil { return fmt.Errorf("failed to create complexity analyzer config: %w", err) } } diff --git a/transports/bifrost-http/lib/config_test.go b/transports/bifrost-http/lib/config_test.go index 2cee439b989..f37358c3ae6 100644 --- a/transports/bifrost-http/lib/config_test.go +++ b/transports/bifrost-http/lib/config_test.go @@ -391,6 +391,7 @@ type MockConfigStore struct { vectorConfig *vectorstore.Config logsConfig *logstore.Config plugins []*tables.TablePlugin + configEntries map[string]string // Track update calls for verification clientConfigUpdated bool @@ -413,7 +414,8 @@ type MockConfigStore struct { // NewMockConfigStore creates a new mock config store func NewMockConfigStore() *MockConfigStore { return &MockConfigStore{ - providers: make(map[schemas.ModelProvider]configstore.ProviderConfig), + providers: make(map[schemas.ModelProvider]configstore.ProviderConfig), + configEntries: make(map[string]string), } } @@ -965,10 +967,22 @@ func (m *MockConfigStore) GetLogsStoreConfig(ctx context.Context) (*logstore.Con // Config func (m *MockConfigStore) GetConfig(ctx context.Context, key string) (*tables.TableGovernanceConfig, error) { - return nil, nil + if value, ok := m.configEntries[key]; ok { + return &tables.TableGovernanceConfig{Key: key, Value: value}, nil + } + return nil, configstore.ErrNotFound } func (m *MockConfigStore) UpdateConfig(ctx context.Context, config *tables.TableGovernanceConfig, tx ...*gorm.DB) error { + if m.configEntries == nil { + m.configEntries = make(map[string]string) + } + m.configEntries[config.Key] = config.Value + return nil +} + +func (m *MockConfigStore) DeleteConfig(ctx context.Context, key string, tx ...*gorm.DB) error { + delete(m.configEntries, key) return nil } @@ -992,6 +1006,48 @@ func (m *MockConfigStore) UpdateComplexityAnalyzerConfig(ctx context.Context, co return nil } +func (m *MockConfigStore) ApplyComplexityAnalyzerConfigFromFile(ctx context.Context, config *configstore.ComplexityAnalyzerConfig, fileSnapshot *configstore.ComplexityAnalyzerConfig, tx ...*gorm.DB) error { + if config == nil { + return fmt.Errorf("complexity analyzer config is nil") + } + if fileSnapshot == nil { + return fmt.Errorf("complexity analyzer file snapshot is nil") + } + + normalized := config.Normalized() + if err := normalized.Validate(); err != nil { + return err + } + if normalized.ConfigHash == "" { + return fmt.Errorf("complexity analyzer config hash is required for file apply") + } + + normalizedSnapshot := fileSnapshot.Normalized() + if err := normalizedSnapshot.Validate(); err != nil { + return err + } + normalizedSnapshot.ConfigHash = "" + + if err := m.UpdateComplexityAnalyzerConfig(ctx, &normalized, tx...); err != nil { + return err + } + if m.configEntries == nil { + m.configEntries = make(map[string]string) + } + rawConfig, err := json.Marshal(normalized) + if err != nil { + return err + } + rawSnapshot, err := json.Marshal(normalizedSnapshot) + if err != nil { + return err + } + m.configEntries[tables.ConfigComplexityAnalyzerConfigKey] = string(rawConfig) + m.configEntries[tables.ConfigComplexityAnalyzerConfigHashKey] = normalized.ConfigHash + m.configEntries[tables.ConfigComplexityAnalyzerConfigSnapshotKey] = string(rawSnapshot) + return nil +} + // Plugins func (m *MockConfigStore) GetPlugins(ctx context.Context) ([]*tables.TablePlugin, error) { return m.plugins, nil @@ -1568,6 +1624,50 @@ func TestMergeGovernanceConfig_MergesComplexityKeywordsWhenFileHashChanges(t *te require.Equal(t, fileHash, stored.ConfigHash) } +func TestMergeGovernanceConfig_RemovesComplexityKeywordsRemovedFromConfigJSON(t *testing.T) { + initTestLogger() + + store := NewMockConfigStore() + previousFileConfig := testFileComplexityAnalyzerConfig() + previousFileConfig.Keywords.CodeKeywords = []string{"file-keep", "file-remove"} + previousSnapshot := previousFileConfig.Normalized() + previousSnapshot.ConfigHash = "" + rawPreviousSnapshot, err := json.Marshal(previousSnapshot) + require.NoError(t, err) + + dbConfig := testRuntimeComplexityAnalyzerConfig() + dbConfig.ConfigHash = "old-file-hash" + dbConfig.Keywords.CodeKeywords = []string{"file-keep", "file-remove", "ui-code"} + dbGovernance := &configstore.GovernanceConfig{ComplexityAnalyzerConfig: dbConfig} + store.governanceConfig = dbGovernance + store.configEntries[tables.ConfigComplexityAnalyzerConfigSnapshotKey] = string(rawPreviousSnapshot) + + config := &Config{ + ConfigStore: store, + GovernanceConfig: dbGovernance, + } + fileConfig := testFileComplexityAnalyzerConfig() + fileConfig.Keywords.CodeKeywords = []string{"file-keep", "file-new"} + configData := &ConfigData{ + Governance: &configstore.GovernanceConfig{ + ComplexityAnalyzerConfig: fileConfig, + }, + } + + mergeGovernanceConfig(context.Background(), config, configData, dbGovernance) + + stored, err := store.GetComplexityAnalyzerConfig(context.Background()) + require.NoError(t, err) + require.NotNil(t, stored) + require.ElementsMatch(t, []string{"file-keep", "file-new", "ui-code"}, stored.Keywords.CodeKeywords) + require.NotContains(t, stored.Keywords.CodeKeywords, "file-remove") + + snapshotEntry, err := store.GetConfig(context.Background(), tables.ConfigComplexityAnalyzerConfigSnapshotKey) + require.NoError(t, err) + require.Contains(t, snapshotEntry.Value, "file-new") + require.NotContains(t, snapshotEntry.Value, "file-remove") +} + func TestMergeGovernanceConfig_SourceOfTruthConfigJSONUsesComplexityFileConfig(t *testing.T) { initTestLogger() @@ -1601,6 +1701,36 @@ func TestMergeGovernanceConfig_SourceOfTruthConfigJSONUsesComplexityFileConfig(t require.Equal(t, fileHash, stored.ConfigHash) } +func TestMergeGovernanceConfig_SourceOfTruthConfigJSONPrunesRemovedComplexityConfig(t *testing.T) { + initTestLogger() + + store := NewMockConfigStore() + dbConfig := testRuntimeComplexityAnalyzerConfig() + dbConfig.ConfigHash = "old-file-hash" + dbGovernance := &configstore.GovernanceConfig{ComplexityAnalyzerConfig: dbConfig} + store.governanceConfig = dbGovernance + store.configEntries[tables.ConfigComplexityAnalyzerConfigHashKey] = dbConfig.ConfigHash + store.configEntries[tables.ConfigComplexityAnalyzerConfigSnapshotKey] = `{"tier_boundaries":{"simple_medium":0.1,"medium_complex":0.3,"complex_reasoning":0.7},"keywords":{"code_keywords":["runtime-code"],"reasoning_keywords":["runtime-reason"],"technical_keywords":["runtime-tech"],"simple_keywords":["runtime-simple"]}}` + config := &Config{ + ConfigStore: store, + GovernanceConfig: dbGovernance, + } + configData := &ConfigData{ + SourceOfTruth: SourceOfTruthConfigJSON, + Governance: &configstore.GovernanceConfig{}, + } + + mergeGovernanceConfig(context.Background(), config, configData, dbGovernance) + + require.Nil(t, config.GovernanceConfig.ComplexityAnalyzerConfig) + _, err := store.GetConfig(context.Background(), tables.ConfigComplexityAnalyzerConfigKey) + require.ErrorIs(t, err, configstore.ErrNotFound) + _, err = store.GetConfig(context.Background(), tables.ConfigComplexityAnalyzerConfigHashKey) + require.ErrorIs(t, err, configstore.ErrNotFound) + _, err = store.GetConfig(context.Background(), tables.ConfigComplexityAnalyzerConfigSnapshotKey) + require.ErrorIs(t, err, configstore.ErrNotFound) +} + func testRuntimeComplexityAnalyzerConfig() *configstore.ComplexityAnalyzerConfig { return &configstore.ComplexityAnalyzerConfig{ TierBoundaries: configstore.ComplexityTierBoundaries{