From 938bfe88c180bb676fbc457004e03a780c9533f8 Mon Sep 17 00:00:00 2001 From: Eric Rakestraw Date: Sat, 23 May 2026 17:39:13 +0000 Subject: [PATCH] Centralize output schema and module key validation catalogs --- internal/cli/run.go | 4 + internal/cli/run_test.go | 122 ++++++++++---------- internal/core/config/config.go | 4 +- internal/core/config/config_test.go | 35 ++++++ internal/core/config/validation.go | 17 +-- internal/core/modulecatalog/catalog.go | 35 ++++++ internal/core/modulecatalog/catalog_test.go | 24 ++++ internal/core/outputschema/registry.go | 20 +++- internal/core/outputschema/registry_test.go | 17 +++ internal/framework/modules/registry.go | 21 ++-- internal/framework/modules/registry_test.go | 3 +- internal/framework/validators/models.go | 9 +- internal/validators/chains.go | 18 +-- internal/validators/registry_test.go | 5 +- 14 files changed, 233 insertions(+), 101 deletions(-) create mode 100644 internal/core/modulecatalog/catalog.go create mode 100644 internal/core/modulecatalog/catalog_test.go diff --git a/internal/cli/run.go b/internal/cli/run.go index 953f95a..6d58f33 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -653,6 +653,10 @@ func runConfigValidate(args []string, stdout, stderr io.Writer) int { fmt.Fprintf(stderr, "audita config validate: %v\n", err) return 2 } + if err := cfg.Validate(); err != nil { + fmt.Fprintf(stderr, "audita config validate: %v\n", err) + return 2 + } fmt.Fprintln(stdout, "config is valid") return 0 } diff --git a/internal/cli/run_test.go b/internal/cli/run_test.go index 7b152e1..6239e6e 100644 --- a/internal/cli/run_test.go +++ b/internal/cli/run_test.go @@ -21,7 +21,6 @@ import ( "gitea.maximumdirect.net/eric/audita/internal/core/schema" "gitea.maximumdirect.net/eric/audita/internal/framework/contracts" "gitea.maximumdirect.net/eric/audita/internal/framework/llm" - "gitea.maximumdirect.net/eric/audita/internal/framework/modules" "gitea.maximumdirect.net/eric/audita/internal/framework/proposal_generation" "gitea.maximumdirect.net/eric/audita/internal/framework/proposals" "gitea.maximumdirect.net/eric/audita/internal/framework/runner" @@ -210,6 +209,23 @@ func TestRunConfigValidateUnknownField(t *testing.T) { } } +func TestRunConfigValidateUnsupportedModuleKey(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + cfgPath := writeFile(t, "config.yml", "version: 1\npipeline:\n modules: [made_up]\n") + + exitCode := Run([]string{"config", "validate", "--config", cfgPath}, &stdout, &stderr) + if exitCode == 0 { + t.Fatalf("expected failure for unsupported module key") + } + if stdout.Len() != 0 { + t.Fatalf("expected empty stdout on failure, got %q", stdout.String()) + } + if !strings.Contains(stderr.String(), "unsupported module key") { + t.Fatalf("expected unsupported module key error, got %q", stderr.String()) + } +} + func TestRunConfigPrintEffectiveOutputsRedactedJSON(t *testing.T) { var stdout bytes.Buffer var stderr bytes.Buffer @@ -369,8 +385,8 @@ diagnostics: func TestRunProcessEnvOverridesConfigFile(t *testing.T) { processModuleFactory = fakeModuleFactory{modules: map[string]contracts.TranscriptModule{ - "m": fakeModule{ - key: "m", + "grammar": fakeModule{ + key: "grammar", policy: proposals.ReplacementPolicyRequireUnique, validators: []contracts.Validator{ fakeValidator{name: "capture-config", validateF: func(req contracts.ValidationRequest) (validators.Result, error) { @@ -392,7 +408,7 @@ func TestRunProcessEnvOverridesConfigFile(t *testing.T) { cfgPath := writeFile(t, "config.yml", ` version: 1 pipeline: - modules: [m] + modules: [grammar] llm: proposal: model: file-model @@ -404,7 +420,7 @@ llm: fixturePath("tiny_transcript.json"), "--glossary", fixturePath("tiny_glossary.yaml"), "--config", cfgPath, - "--modules", "m", + "--modules", "grammar", }, &stdout, &stderr) if exitCode != 0 { t.Fatalf("expected success, got %d stderr=%q", exitCode, stderr.String()) @@ -413,8 +429,8 @@ llm: func TestRunProcessCLIOverridesEnvAndConfigFile(t *testing.T) { processModuleFactory = fakeModuleFactory{modules: map[string]contracts.TranscriptModule{ - "m": fakeModule{ - key: "m", + "grammar": fakeModule{ + key: "grammar", policy: proposals.ReplacementPolicyRequireUnique, validators: []contracts.Validator{ fakeValidator{name: "capture-config", validateF: func(req contracts.ValidationRequest) (validators.Result, error) { @@ -436,7 +452,7 @@ func TestRunProcessCLIOverridesEnvAndConfigFile(t *testing.T) { cfgPath := writeFile(t, "config.yml", ` version: 1 pipeline: - modules: [m] + modules: [grammar] llm: proposal: model: file-model @@ -448,7 +464,7 @@ llm: fixturePath("tiny_transcript.json"), "--glossary", fixturePath("tiny_glossary.yaml"), "--config", cfgPath, - "--modules", "m", + "--modules", "grammar", "--model", "cli-model", }, &stdout, &stderr) if exitCode != 0 { @@ -495,8 +511,8 @@ diagnostics: func TestRunProcessTranscriptDescriptionCLIOverridesConfigFileContextDescription(t *testing.T) { processModuleFactory = fakeModuleFactory{modules: map[string]contracts.TranscriptModule{ - "m": fakeModule{ - key: "m", + "grammar": fakeModule{ + key: "grammar", policy: proposals.ReplacementPolicyRequireUnique, validators: []contracts.Validator{ fakeValidator{name: "capture-config", validateF: func(req contracts.ValidationRequest) (validators.Result, error) { @@ -517,7 +533,7 @@ func TestRunProcessTranscriptDescriptionCLIOverridesConfigFileContextDescription cfgPath := writeFile(t, "config.yml", ` version: 1 pipeline: - modules: [m] + modules: [grammar] context: description: "file transcript description" `) @@ -528,7 +544,7 @@ context: fixturePath("tiny_transcript.json"), "--glossary", fixturePath("tiny_glossary.yaml"), "--config", cfgPath, - "--modules", "m", + "--modules", "grammar", "--transcript-description", "cli transcript description", }, &stdout, &stderr) if exitCode != 0 { @@ -692,8 +708,8 @@ func TestRunProcessCLIOverridesEnvironment(t *testing.T) { func TestRunProcessTranscriptDescriptionDefaultEmpty(t *testing.T) { processModuleFactory = fakeModuleFactory{modules: map[string]contracts.TranscriptModule{ - "m": fakeModule{ - key: "m", + "grammar": fakeModule{ + key: "grammar", policy: proposals.ReplacementPolicyRequireUnique, validators: []contracts.Validator{ fakeValidator{name: "capture-config", validateF: func(req contracts.ValidationRequest) (validators.Result, error) { @@ -720,7 +736,7 @@ func TestRunProcessTranscriptDescriptionDefaultEmpty(t *testing.T) { exitCode := Run([]string{ "process", transcriptPath, "--glossary", fixturePath("tiny_glossary.yaml"), - "--modules", "m", + "--modules", "grammar", }, &stdout, &stderr) if exitCode != 0 { t.Fatalf("expected success, got %d stderr=%q", exitCode, stderr.String()) @@ -729,8 +745,8 @@ func TestRunProcessTranscriptDescriptionDefaultEmpty(t *testing.T) { func TestRunProcessTranscriptDescriptionCLIOverrideAndTrim(t *testing.T) { processModuleFactory = fakeModuleFactory{modules: map[string]contracts.TranscriptModule{ - "m": fakeModule{ - key: "m", + "grammar": fakeModule{ + key: "grammar", policy: proposals.ReplacementPolicyRequireUnique, validators: []contracts.Validator{ fakeValidator{name: "capture-config", validateF: func(req contracts.ValidationRequest) (validators.Result, error) { @@ -757,7 +773,7 @@ func TestRunProcessTranscriptDescriptionCLIOverrideAndTrim(t *testing.T) { exitCode := Run([]string{ "process", transcriptPath, "--glossary", fixturePath("tiny_glossary.yaml"), - "--modules", "m", + "--modules", "grammar", "--transcript-description", " speaker background context ", }, &stdout, &stderr) if exitCode != 0 { @@ -823,8 +839,8 @@ func TestRunProcessRejectsValidationConcurrencyAboveTotalConcurrency(t *testing. func TestRunProcessTotalLLMConcurrencyDrivesEffectiveValidationConcurrencyWhenUnset(t *testing.T) { processModuleFactory = fakeModuleFactory{modules: map[string]contracts.TranscriptModule{ - "m": fakeModule{ - key: "m", + "grammar": fakeModule{ + key: "grammar", policy: proposals.ReplacementPolicyRequireUnique, validators: []contracts.Validator{ fakeValidator{name: "capture-config", validateF: func(req contracts.ValidationRequest) (validators.Result, error) { @@ -865,7 +881,7 @@ func TestRunProcessTotalLLMConcurrencyDrivesEffectiveValidationConcurrencyWhenUn "--glossary", fixturePath("tiny_glossary.yaml"), "--modules", - "m", + "grammar", "--total-llm-concurrency", "4", }, &stdout, &stderr) @@ -908,8 +924,8 @@ func TestRunProcessRejectsProposalConcurrencyAboveTotalConcurrency(t *testing.T) func TestRunProcessLegacyLLMConcurrencyAliasSetsTotalAndProposal(t *testing.T) { processModuleFactory = fakeModuleFactory{modules: map[string]contracts.TranscriptModule{ - "m": fakeModule{ - key: "m", + "grammar": fakeModule{ + key: "grammar", policy: proposals.ReplacementPolicyRequireUnique, validators: []contracts.Validator{ fakeValidator{name: "capture-config", validateF: func(req contracts.ValidationRequest) (validators.Result, error) { @@ -944,7 +960,7 @@ func TestRunProcessLegacyLLMConcurrencyAliasSetsTotalAndProposal(t *testing.T) { "--glossary", fixturePath("tiny_glossary.yaml"), "--modules", - "m", + "grammar", "--llm-concurrency", "3", }, &stdout, &stderr) @@ -955,8 +971,8 @@ func TestRunProcessLegacyLLMConcurrencyAliasSetsTotalAndProposal(t *testing.T) { func TestRunProcessLLMConcurrencyFlagsOverrideEnvironment(t *testing.T) { processModuleFactory = fakeModuleFactory{modules: map[string]contracts.TranscriptModule{ - "m": fakeModule{ - key: "m", + "grammar": fakeModule{ + key: "grammar", policy: proposals.ReplacementPolicyRequireUnique, validators: []contracts.Validator{ fakeValidator{name: "capture-config", validateF: func(req contracts.ValidationRequest) (validators.Result, error) { @@ -997,7 +1013,7 @@ func TestRunProcessLLMConcurrencyFlagsOverrideEnvironment(t *testing.T) { "--glossary", fixturePath("tiny_glossary.yaml"), "--modules", - "m", + "grammar", "--total-llm-concurrency", "4", "--proposal-llm-concurrency", @@ -1010,8 +1026,8 @@ func TestRunProcessLLMConcurrencyFlagsOverrideEnvironment(t *testing.T) { func TestRunProcessAcceptsLLMConcurrencyEnvironmentVariables(t *testing.T) { processModuleFactory = fakeModuleFactory{modules: map[string]contracts.TranscriptModule{ - "m": fakeModule{ - key: "m", + "grammar": fakeModule{ + key: "grammar", policy: proposals.ReplacementPolicyRequireUnique, validators: []contracts.Validator{ fakeValidator{name: "capture-config", validateF: func(req contracts.ValidationRequest) (validators.Result, error) { @@ -1052,7 +1068,7 @@ func TestRunProcessAcceptsLLMConcurrencyEnvironmentVariables(t *testing.T) { "--glossary", fixturePath("tiny_glossary.yaml"), "--modules", - "m", + "grammar", }, &stdout, &stderr) if exitCode != 0 { t.Fatalf("expected success, got %d stderr=%q", exitCode, stderr.String()) @@ -1883,12 +1899,12 @@ func TestRunProcessInjectedFactoryExecutesRunnerAndReportsModules(t *testing.T) return validators.Result{ValidatorName: "allow", Decisions: decisions}, nil }} processModuleFactory = fakeModuleFactory{modules: map[string]contracts.TranscriptModule{ - "m1": fakeModule{key: "m1", policy: proposals.ReplacementPolicyRequireUnique, validators: []contracts.Validator{allow}, proposeF: func(req contracts.ProposalRequest) ([]proposals.CorrectionProposal, error) { + "glossary": fakeModule{key: "glossary", policy: proposals.ReplacementPolicyRequireUnique, validators: []contracts.Validator{allow}, proposeF: func(req contracts.ProposalRequest) ([]proposals.CorrectionProposal, error) { return []proposals.CorrectionProposal{ {TargetSegmentID: 1, OriginalText: "Hello", CorrectedText: "Hi", Confidence: 1}, }, nil }}, - "m2": fakeModule{key: "m2", policy: proposals.ReplacementPolicyRequireUnique, validators: []contracts.Validator{allow}, proposeF: func(req contracts.ProposalRequest) ([]proposals.CorrectionProposal, error) { + "homophones": fakeModule{key: "homophones", policy: proposals.ReplacementPolicyRequireUnique, validators: []contracts.Validator{allow}, proposeF: func(req contracts.ProposalRequest) ([]proposals.CorrectionProposal, error) { if req.WorkingTranscript.Segments[0].Text != "Hi world" { t.Fatalf("expected module 2 to see module 1 changes, got %q", req.WorkingTranscript.Segments[0].Text) } @@ -1911,7 +1927,7 @@ func TestRunProcessInjectedFactoryExecutesRunnerAndReportsModules(t *testing.T) exitCode := Run([]string{ "process", transcriptPath, "--glossary", fixturePath("tiny_glossary.yaml"), - "--modules", "m1,m2", + "--modules", "glossary,homophones", "--output", outputPath, "--report-json", reportPath, "--work-dir", workDir, @@ -1970,7 +1986,7 @@ func TestRunProcessInjectedFactoryLLMValidatorResultsInReports(t *testing.T) { }}, } processModuleFactory = fakeModuleFactory{modules: map[string]contracts.TranscriptModule{ - "m": fakeModule{key: "m", policy: proposals.ReplacementPolicyRequireUnique, validators: []contracts.Validator{llmValidator}, proposeF: func(req contracts.ProposalRequest) ([]proposals.CorrectionProposal, error) { + "grammar": fakeModule{key: "grammar", policy: proposals.ReplacementPolicyRequireUnique, validators: []contracts.Validator{llmValidator}, proposeF: func(req contracts.ProposalRequest) ([]proposals.CorrectionProposal, error) { return []proposals.CorrectionProposal{{TargetSegmentID: 1, OriginalText: "Hello", CorrectedText: "Hi", Confidence: 1}}, nil }}, }} @@ -1990,7 +2006,7 @@ func TestRunProcessInjectedFactoryLLMValidatorResultsInReports(t *testing.T) { exitCode := Run([]string{ "process", transcriptPath, "--glossary", fixturePath("tiny_glossary.yaml"), - "--modules", "m", + "--modules", "grammar", "--output", outputPath, "--report-json", reportPath, "--work-dir", workDir, @@ -2011,7 +2027,7 @@ func TestRunProcessInjectedFactoryLLMValidatorResultsInReports(t *testing.T) { func TestRunProcessInjectedFactorySkippedKeepsAutoRetention(t *testing.T) { processModuleFactory = fakeModuleFactory{modules: map[string]contracts.TranscriptModule{ - "m1": fakeModule{key: "m1", policy: proposals.ReplacementPolicyRequireUnique, proposeF: func(req contracts.ProposalRequest) ([]proposals.CorrectionProposal, error) { + "glossary": fakeModule{key: "glossary", policy: proposals.ReplacementPolicyRequireUnique, proposeF: func(req contracts.ProposalRequest) ([]proposals.CorrectionProposal, error) { return []proposals.CorrectionProposal{ {TargetSegmentID: 1, OriginalText: "word", CorrectedText: "term", Confidence: 1}, }, nil @@ -2027,7 +2043,7 @@ func TestRunProcessInjectedFactorySkippedKeepsAutoRetention(t *testing.T) { exitCode := Run([]string{ "process", transcriptPath, "--glossary", fixturePath("tiny_glossary.yaml"), - "--modules", "m1", + "--modules", "glossary", "--work-dir", workDir, "--work-dir-retention", "auto", }, &stdout, &stderr) @@ -2041,7 +2057,7 @@ func TestRunProcessInjectedFactorySkippedKeepsAutoRetention(t *testing.T) { func TestRunProcessInjectedFactoryFailureWritesFailedReport(t *testing.T) { processModuleFactory = fakeModuleFactory{modules: map[string]contracts.TranscriptModule{ - "m1": fakeModule{key: "m1", policy: proposals.ReplacementPolicyRequireUnique, proposeF: func(req contracts.ProposalRequest) ([]proposals.CorrectionProposal, error) { + "glossary": fakeModule{key: "glossary", policy: proposals.ReplacementPolicyRequireUnique, proposeF: func(req contracts.ProposalRequest) ([]proposals.CorrectionProposal, error) { return nil, errors.New("test failure") }}, }} @@ -2057,7 +2073,7 @@ func TestRunProcessInjectedFactoryFailureWritesFailedReport(t *testing.T) { exitCode := Run([]string{ "process", transcriptPath, "--glossary", fixturePath("tiny_glossary.yaml"), - "--modules", "m1", + "--modules", "glossary", "--work-dir", workDir, "--work-dir-retention", "always", "--report-json", reportPath, @@ -2089,14 +2105,8 @@ func TestRunProcessInjectedFactoryFailureWritesFailedReport(t *testing.T) { } } -func TestRunProcessProductionRegistryUnsupportedModuleFailsCleanly(t *testing.T) { - cfg := modules.Dependencies{} - processModuleFactory = modules.NewFactory(cfg) - t.Cleanup(func() { processModuleFactory = nil }) - +func TestRunProcessUnsupportedModuleFailsDuringConfigValidation(t *testing.T) { var stdout, stderr bytes.Buffer - workDir := t.TempDir() - reportPath := filepath.Join(t.TempDir(), "report.json") transcriptPath := writeFile(t, "transcript.json", `[ {"id":1,"speaker":"Alice","start":0.0,"end":1.0,"text":"Hello"} ]`) @@ -2105,9 +2115,6 @@ func TestRunProcessProductionRegistryUnsupportedModuleFailsCleanly(t *testing.T) "process", transcriptPath, "--glossary", fixturePath("tiny_glossary.yaml"), "--modules", "made_up", - "--work-dir", workDir, - "--work-dir-retention", "always", - "--report-json", reportPath, }, &stdout, &stderr) if exitCode == 0 { t.Fatal("expected failure exit code") @@ -2115,23 +2122,12 @@ func TestRunProcessProductionRegistryUnsupportedModuleFailsCleanly(t *testing.T) if stdout.Len() != 0 { t.Fatalf("expected empty stdout on failure, got %q", stdout.String()) } - if !strings.Contains(stderr.String(), "runner_execution") { - t.Fatalf("expected runner_execution failure on stderr, got %q", stderr.String()) + if !strings.Contains(stderr.String(), "invalid CLI configuration") { + t.Fatalf("expected config validation failure on stderr, got %q", stderr.String()) } if !strings.Contains(stderr.String(), "unsupported module key") { t.Fatalf("expected explicit unsupported module message, got %q", stderr.String()) } - - report := readProcessReport(t, reportPath) - if report.Status != "failed" { - t.Fatalf("expected failed report status, got %q", report.Status) - } - if report.ErrorPhase != "runner_execution" { - t.Fatalf("expected runner_execution phase, got %q", report.ErrorPhase) - } - if !strings.Contains(report.ErrorMessage, "unsupported module key") { - t.Fatalf("expected report error message to mention unsupported module, got %q", report.ErrorMessage) - } } func TestRunProcessExplicitUnsupportedModulesFailClearly(t *testing.T) { diff --git a/internal/core/config/config.go b/internal/core/config/config.go index 2422414..4ac0417 100644 --- a/internal/core/config/config.go +++ b/internal/core/config/config.go @@ -3,6 +3,8 @@ package config import ( "fmt" "strings" + + "gitea.maximumdirect.net/eric/audita/internal/core/modulecatalog" ) type WorkDirRetention string @@ -14,7 +16,7 @@ const ( ) const ( - DefaultModulesCSV = "glossary,homophones,glossary,spoken_word,grammar" + DefaultModulesCSV = modulecatalog.KeyGlossary + "," + modulecatalog.KeyHomophones + "," + modulecatalog.KeyGlossary + "," + modulecatalog.KeySpokenWord + "," + modulecatalog.KeyGrammar DefaultOutputSchema = "bare-segments" DefaultPrimaryModel = "openrouter/google/gemma-4-31b-it" DefaultPrimaryBaseURL = "https://openrouter.ai/api/v1" diff --git a/internal/core/config/config_test.go b/internal/core/config/config_test.go index 4a499e5..94a8e48 100644 --- a/internal/core/config/config_test.go +++ b/internal/core/config/config_test.go @@ -4,6 +4,9 @@ import ( "reflect" "strings" "testing" + + "gitea.maximumdirect.net/eric/audita/internal/core/modulecatalog" + "gitea.maximumdirect.net/eric/audita/internal/core/outputschema" ) func TestDefaultConfigValues(t *testing.T) { @@ -318,6 +321,38 @@ func TestValidationFailures(t *testing.T) { } } +func TestValidationRejectsUnsupportedModuleKey(t *testing.T) { + cfg := Default() + cfg.Modules = []string{modulecatalog.KeyGlossary, "made_up"} + + err := cfg.Validate() + if err == nil { + t.Fatalf("expected validation error for unsupported module key") + } + if !strings.Contains(err.Error(), `unsupported module key "made_up"`) { + t.Fatalf("expected unsupported module key error, got %q", err.Error()) + } +} + +func TestValidationAllowsRepeatedSupportedModuleKeys(t *testing.T) { + cfg := Default() + cfg.Modules = []string{modulecatalog.KeyGlossary, modulecatalog.KeyGlossary, modulecatalog.KeyGrammar} + + if err := cfg.Validate(); err != nil { + t.Fatalf("expected repeated supported module keys to validate, got %v", err) + } +} + +func TestValidationAcceptsAllSupportedOutputSchemas(t *testing.T) { + for _, schemaKey := range outputschema.SupportedKeys() { + cfg := Default() + cfg.OutputSchema = schemaKey + if err := cfg.Validate(); err != nil { + t.Fatalf("expected output schema %q to validate, got %v", schemaKey, err) + } + } +} + func TestEffectiveValidationLLMInheritance(t *testing.T) { cfg := Default() cfg.PrimaryLLM.APIKey = "primary-key" diff --git a/internal/core/config/validation.go b/internal/core/config/validation.go index 06f5fc4..0e680f6 100644 --- a/internal/core/config/validation.go +++ b/internal/core/config/validation.go @@ -3,6 +3,9 @@ package config import ( "fmt" "strings" + + "gitea.maximumdirect.net/eric/audita/internal/core/modulecatalog" + "gitea.maximumdirect.net/eric/audita/internal/core/outputschema" ) func (c Config) Validate() error { @@ -12,19 +15,19 @@ func (c Config) Validate() error { issues = append(issues, "modules must not be empty") } for _, module := range c.Modules { - if strings.TrimSpace(module) == "" { + moduleKey := strings.TrimSpace(module) + if moduleKey == "" { issues = append(issues, "modules must not contain empty values") break } + if !modulecatalog.IsSupported(moduleKey) { + issues = append(issues, fmt.Sprintf("unsupported module key %q", moduleKey)) + } } if strings.TrimSpace(c.OutputSchema) == "" { issues = append(issues, "output schema must not be empty") - } else { - switch strings.TrimSpace(c.OutputSchema) { - case "bare-segments", "audita-v1": - default: - issues = append(issues, fmt.Sprintf("unsupported output schema %q", c.OutputSchema)) - } + } else if !outputschema.IsSupported(c.OutputSchema) { + issues = append(issues, fmt.Sprintf("unsupported output schema %q", c.OutputSchema)) } if c.PrimaryLLM.TimeoutSeconds <= 0 { diff --git a/internal/core/modulecatalog/catalog.go b/internal/core/modulecatalog/catalog.go new file mode 100644 index 0000000..1cdac66 --- /dev/null +++ b/internal/core/modulecatalog/catalog.go @@ -0,0 +1,35 @@ +package modulecatalog + +import "strings" + +const ( + KeyGlossary = "glossary" + KeyHomophones = "homophones" + KeySpokenWord = "spoken_word" + KeyGrammar = "grammar" +) + +var supportedKeys = []string{ + KeyGlossary, + KeyHomophones, + KeySpokenWord, + KeyGrammar, +} + +var supportedKeySet = map[string]struct{}{ + KeyGlossary: {}, + KeyHomophones: {}, + KeySpokenWord: {}, + KeyGrammar: {}, +} + +func SupportedKeys() []string { + out := make([]string, len(supportedKeys)) + copy(out, supportedKeys) + return out +} + +func IsSupported(key string) bool { + _, ok := supportedKeySet[strings.TrimSpace(key)] + return ok +} diff --git a/internal/core/modulecatalog/catalog_test.go b/internal/core/modulecatalog/catalog_test.go new file mode 100644 index 0000000..254c5c6 --- /dev/null +++ b/internal/core/modulecatalog/catalog_test.go @@ -0,0 +1,24 @@ +package modulecatalog + +import ( + "reflect" + "testing" +) + +func TestSupportedKeys(t *testing.T) { + want := []string{KeyGlossary, KeyHomophones, KeySpokenWord, KeyGrammar} + if got := SupportedKeys(); !reflect.DeepEqual(got, want) { + t.Fatalf("unexpected supported keys: got=%v want=%v", got, want) + } +} + +func TestIsSupported(t *testing.T) { + for _, key := range SupportedKeys() { + if !IsSupported(key) { + t.Fatalf("expected key %q to be supported", key) + } + } + if IsSupported("made_up") { + t.Fatalf("did not expect made_up to be supported") + } +} diff --git a/internal/core/outputschema/registry.go b/internal/core/outputschema/registry.go index 2e66a9e..3c46365 100644 --- a/internal/core/outputschema/registry.go +++ b/internal/core/outputschema/registry.go @@ -31,15 +31,31 @@ var definitions = map[string]Definition{ }, } +var supportedKeys = []string{ + SchemaBareSegments, + SchemaAuditaV1, +} + +func SupportedKeys() []string { + out := make([]string, len(supportedKeys)) + copy(out, supportedKeys) + return out +} + +func IsSupported(key string) bool { + _, ok := definitions[strings.TrimSpace(key)] + return ok +} + func Resolve(key string) (Definition, error) { normalized := strings.TrimSpace(key) if normalized == "" { return Definition{}, fmt.Errorf("output schema must not be empty") } - def, ok := definitions[normalized] - if !ok { + if !IsSupported(normalized) { return Definition{}, fmt.Errorf("unsupported output schema %q", normalized) } + def := definitions[normalized] return def, nil } diff --git a/internal/core/outputschema/registry_test.go b/internal/core/outputschema/registry_test.go index 4ef8063..9bd9004 100644 --- a/internal/core/outputschema/registry_test.go +++ b/internal/core/outputschema/registry_test.go @@ -2,6 +2,7 @@ package outputschema import ( "encoding/json" + "reflect" "strings" "testing" @@ -56,3 +57,19 @@ func TestResolveUnknown(t *testing.T) { t.Fatalf("expected unsupported output schema error, got %v", err) } } + +func TestSupportedKeysAndIsSupported(t *testing.T) { + want := []string{SchemaBareSegments, SchemaAuditaV1} + if got := SupportedKeys(); !reflect.DeepEqual(got, want) { + t.Fatalf("unexpected supported schema keys: got=%v want=%v", got, want) + } + + for _, key := range want { + if !IsSupported(key) { + t.Fatalf("expected schema key %q to be supported", key) + } + } + if IsSupported("seriatim-intermediate") { + t.Fatalf("did not expect unsupported schema to be reported as supported") + } +} diff --git a/internal/framework/modules/registry.go b/internal/framework/modules/registry.go index 926d659..7eeae8d 100644 --- a/internal/framework/modules/registry.go +++ b/internal/framework/modules/registry.go @@ -6,6 +6,7 @@ import ( "strings" "gitea.maximumdirect.net/eric/audita/internal/core/config" + "gitea.maximumdirect.net/eric/audita/internal/core/modulecatalog" "gitea.maximumdirect.net/eric/audita/internal/core/schema" "gitea.maximumdirect.net/eric/audita/internal/framework/contracts" glossarymodule "gitea.maximumdirect.net/eric/audita/internal/modules/glossary" @@ -15,28 +16,20 @@ import ( ) const ( - ModuleKeyGlossary = "glossary" - ModuleKeyHomophones = "homophones" - ModuleKeySpokenWord = "spoken_word" - ModuleKeyGrammar = "grammar" + ModuleKeyGlossary = modulecatalog.KeyGlossary + ModuleKeyHomophones = modulecatalog.KeyHomophones + ModuleKeySpokenWord = modulecatalog.KeySpokenWord + ModuleKeyGrammar = modulecatalog.KeyGrammar ) const ( ReasonUnsupportedModule = "unsupported_module" ) -var knownModuleKeys = map[string]struct{}{ - ModuleKeyGlossary: {}, - ModuleKeyHomophones: {}, - ModuleKeySpokenWord: {}, - ModuleKeyGrammar: {}, -} - // IsKnownModuleKey reports whether a module key is recognized by the production // registry scaffold. func IsKnownModuleKey(key string) bool { - _, ok := knownModuleKeys[strings.TrimSpace(key)] - return ok + return modulecatalog.IsSupported(key) } // Dependencies holds explicit constructor dependencies for module creation. @@ -69,7 +62,7 @@ type Factory struct { func NewFactory(deps Dependencies) *Factory { factory := &Factory{ deps: deps, - constructors: make(map[string]Constructor, len(knownModuleKeys)), + constructors: make(map[string]Constructor, len(modulecatalog.SupportedKeys())), } _ = factory.RegisterConstructor(ModuleKeyGlossary, constructGlossaryModule) _ = factory.RegisterConstructor(ModuleKeyHomophones, constructHomophonesModule) diff --git a/internal/framework/modules/registry_test.go b/internal/framework/modules/registry_test.go index 88b33c2..2acd34b 100644 --- a/internal/framework/modules/registry_test.go +++ b/internal/framework/modules/registry_test.go @@ -6,6 +6,7 @@ import ( "strings" "testing" + "gitea.maximumdirect.net/eric/audita/internal/core/modulecatalog" "gitea.maximumdirect.net/eric/audita/internal/framework/contracts" "gitea.maximumdirect.net/eric/audita/internal/framework/proposals" ) @@ -26,7 +27,7 @@ func (m noopModule) Propose(ctx context.Context, req contracts.ProposalRequest) } func TestKnownModuleKeyRecognition(t *testing.T) { - for _, key := range []string{ModuleKeyGlossary, ModuleKeyHomophones, ModuleKeySpokenWord, ModuleKeyGrammar} { + for _, key := range modulecatalog.SupportedKeys() { if !IsKnownModuleKey(key) { t.Fatalf("expected key %q to be recognized", key) } diff --git a/internal/framework/validators/models.go b/internal/framework/validators/models.go index c1f4f6d..59dc9ed 100644 --- a/internal/framework/validators/models.go +++ b/internal/framework/validators/models.go @@ -6,6 +6,7 @@ import ( "strings" "gitea.maximumdirect.net/eric/audita/internal/core/config" + "gitea.maximumdirect.net/eric/audita/internal/core/modulecatalog" "gitea.maximumdirect.net/eric/audita/internal/core/schema" "gitea.maximumdirect.net/eric/audita/internal/framework/proposals" stagewarnings "gitea.maximumdirect.net/eric/audita/internal/framework/warnings" @@ -118,13 +119,13 @@ func confidenceThresholdForModule(moduleKey string, cfg *config.Config) float64 return 0.0 } switch moduleKey { - case "glossary": + case modulecatalog.KeyGlossary: return cfg.Thresholds.Glossary - case "grammar": + case modulecatalog.KeyGrammar: return cfg.Thresholds.Grammar - case "homophones": + case modulecatalog.KeyHomophones: return cfg.Thresholds.Homophones - case "spoken_word": + case modulecatalog.KeySpokenWord: return cfg.Thresholds.SpokenWord default: return 0.0 diff --git a/internal/validators/chains.go b/internal/validators/chains.go index eec7549..060ba03 100644 --- a/internal/validators/chains.go +++ b/internal/validators/chains.go @@ -2,13 +2,15 @@ package validators import ( "fmt" + "strings" + "gitea.maximumdirect.net/eric/audita/internal/core/modulecatalog" "gitea.maximumdirect.net/eric/audita/internal/framework/contracts" "gitea.maximumdirect.net/eric/audita/internal/validators/protected_terms" ) var builtInChains = map[string][]string{ - "glossary": { + modulecatalog.KeyGlossary: { KeyProposalShape, KeyNoEffect, KeyOriginalTextPresence, @@ -18,7 +20,7 @@ var builtInChains = map[string][]string{ KeySpokenFormPlausibility, KeyMeaningReversalReview, }, - "homophones": { + modulecatalog.KeyHomophones: { KeyProposalShape, KeyNoEffect, KeyOriginalTextPresence, @@ -28,7 +30,7 @@ var builtInChains = map[string][]string{ KeySpokenFormPlausibility, KeyMeaningReversalReview, }, - "spoken_word": { + modulecatalog.KeySpokenWord: { KeyProposalShape, KeyNoEffect, KeyOriginalTextPresence, @@ -38,7 +40,7 @@ var builtInChains = map[string][]string{ KeyEditorialReview, KeyMeaningReversalReview, }, - "grammar": { + modulecatalog.KeyGrammar: { KeyProposalShape, KeyNoEffect, KeyOriginalTextPresence, @@ -51,9 +53,10 @@ var builtInChains = map[string][]string{ } func BuiltInChainKeys(moduleKey string) ([]string, error) { - keys, ok := builtInChains[moduleKey] + key := strings.TrimSpace(moduleKey) + keys, ok := builtInChains[key] if !ok { - return nil, fmt.Errorf("no built-in validator chain for module %q", moduleKey) + return nil, fmt.Errorf("no built-in validator chain for module %q", key) } out := make([]string, len(keys)) copy(out, keys) @@ -61,6 +64,7 @@ func BuiltInChainKeys(moduleKey string) ([]string, error) { } func ResolveBuiltInChain(moduleKey string, registry *Registry) ([]contracts.Validator, error) { + moduleKey = strings.TrimSpace(moduleKey) keys, err := BuiltInChainKeys(moduleKey) if err != nil { return nil, err @@ -71,7 +75,7 @@ func ResolveBuiltInChain(moduleKey string, registry *Registry) ([]contracts.Vali out := make([]contracts.Validator, 0, len(keys)) for _, key := range keys { - if moduleKey == "glossary" && key == KeyProtectedTerms { + if moduleKey == modulecatalog.KeyGlossary && key == KeyProtectedTerms { // Glossary stages preserve current stricter protection semantics while // reporting the stable protected_terms key. v, buildErr := protected_terms.NewGlossaryStage() diff --git a/internal/validators/registry_test.go b/internal/validators/registry_test.go index 0ef1ee8..285a70f 100644 --- a/internal/validators/registry_test.go +++ b/internal/validators/registry_test.go @@ -4,6 +4,7 @@ import ( "context" "testing" + "gitea.maximumdirect.net/eric/audita/internal/core/modulecatalog" "gitea.maximumdirect.net/eric/audita/internal/core/schema" "gitea.maximumdirect.net/eric/audita/internal/framework/contracts" "gitea.maximumdirect.net/eric/audita/internal/framework/proposals" @@ -200,7 +201,7 @@ func TestBuiltInRegistryUnknownKeyFails(t *testing.T) { } func TestBuiltInChainKeysResolveForProductionModules(t *testing.T) { - for _, moduleKey := range []string{"glossary", "homophones", "spoken_word", "grammar"} { + for _, moduleKey := range modulecatalog.SupportedKeys() { keys, err := BuiltInChainKeys(moduleKey) if err != nil { t.Fatalf("resolve keys for %q: %v", moduleKey, err) @@ -213,7 +214,7 @@ func TestBuiltInChainKeysResolveForProductionModules(t *testing.T) { func TestResolveBuiltInChainUsesRegisteredKeys(t *testing.T) { r := NewBuiltInRegistry() - for _, moduleKey := range []string{"glossary", "homophones", "spoken_word", "grammar"} { + for _, moduleKey := range modulecatalog.SupportedKeys() { chain, err := ResolveBuiltInChain(moduleKey, r) if err != nil { t.Fatalf("resolve chain for %q: %v", moduleKey, err)