diff --git a/docs/roadmap/implementation.md b/docs/roadmap/implementation.md index 118b15d..0dddb07 100644 --- a/docs/roadmap/implementation.md +++ b/docs/roadmap/implementation.md @@ -16,7 +16,7 @@ different contract. | Stage | Outcome | Status | | ---: | --- | --- | -| 1 | Add the reference-selector and configuration vocabulary, including optional spell-catalog inputs. | Pending | +| 1 | Add the reference-selector and configuration vocabulary, including optional spell-catalog inputs. | Completed | | 2 | Materialize and inventory the optional spell catalog through the prepare and operator lifecycle. | Pending | | 3 | Centralize manifest-authoritative prepared-input resolution and migrate analyze to it. | Pending | | 4 | Add deterministic Notarius v0.6 reference arguments at the subprocess adapter boundary. | Pending | diff --git a/internal/artifactpolicy/policy.go b/internal/artifactpolicy/policy.go index c5cafcc..306f9bc 100644 --- a/internal/artifactpolicy/policy.go +++ b/internal/artifactpolicy/policy.go @@ -13,9 +13,10 @@ import ( const ( SourceBoundsSession = "narratio.bounds.session" - SourceInputPlayers = "narratio.input.players" - SourceInputParty = "narratio.input.party" - SourceInputGlossary = "narratio.input.glossary" + SourceInputPlayers = "narratio.input.players" + SourceInputParty = "narratio.input.party" + SourceInputGlossary = "narratio.input.glossary" + SourceInputSpellCatalog = "narratio.input.spell_catalog" configuredSourcePrefix = "narratio.artifact." extractionSourcePrefix = "narratio.extraction." @@ -52,9 +53,18 @@ type Source struct { // ScriptoriumInputSourceDescriptor describes one validated Scriptorium input source. type ScriptoriumInputSourceDescriptor struct { Source Source + PreparedInput *PreparedInputSourceDescriptor PreviousSession *PreviousSessionSourceDescriptor } +// PreparedInputSourceDescriptor describes a prepared stable input's source, +// manifest kind, and canonical staged filename. +type PreparedInputSourceDescriptor struct { + SourceID string + ManifestKind string + Filename string +} + // PreviousSessionSourceDescriptor describes one canonical previous-session input source. type PreviousSessionSourceDescriptor struct { SourceID string @@ -158,9 +168,10 @@ func DescribeScriptoriumInputSource(source string) (ScriptoriumInputSourceDescri if trimmed == "" { return ScriptoriumInputSourceDescriptor{}, ErrUnsupportedScriptoriumInputSource } - if IsStableInputSource(trimmed) { + if prepared, ok := DescribePreparedInputSource(trimmed); ok { return ScriptoriumInputSourceDescriptor{ - Source: Source{ID: trimmed, Kind: SourceKindStableInput}, + Source: Source{ID: prepared.SourceID, Kind: SourceKindStableInput}, + PreparedInput: &prepared, }, nil } if strings.HasPrefix(trimmed, "narratio.previous_session.artifact") { @@ -188,12 +199,38 @@ func DescribeScriptoriumInputSource(source string) (ScriptoriumInputSourceDescri // IsStableInputSource reports whether source is a prepared stable input source // available only to Scriptorium input resolution. func IsStableInputSource(source string) bool { - switch strings.TrimSpace(source) { - case SourceInputPlayers, SourceInputParty, SourceInputGlossary: - return true - default: - return false - } + _, ok := DescribePreparedInputSource(source) + return ok +} + +var preparedInputSources = map[string]PreparedInputSourceDescriptor{ + SourceInputPlayers: { + SourceID: SourceInputPlayers, + ManifestKind: "players", + Filename: "players.yml", + }, + SourceInputParty: { + SourceID: SourceInputParty, + ManifestKind: "party", + Filename: "party.yml", + }, + SourceInputGlossary: { + SourceID: SourceInputGlossary, + ManifestKind: "glossary", + Filename: "glossary.yml", + }, + SourceInputSpellCatalog: { + SourceID: SourceInputSpellCatalog, + ManifestKind: "spell_catalog", + Filename: "spell_catalog.json", + }, +} + +// DescribePreparedInputSource returns the canonical descriptor for a prepared +// stable input source. +func DescribePreparedInputSource(source string) (PreparedInputSourceDescriptor, bool) { + descriptor, ok := preparedInputSources[strings.TrimSpace(source)] + return descriptor, ok } // DescribePreviousSessionSource validates a canonical previous-session source id diff --git a/internal/artifactpolicy/policy_test.go b/internal/artifactpolicy/policy_test.go index 19b0e4b..887b790 100644 --- a/internal/artifactpolicy/policy_test.go +++ b/internal/artifactpolicy/policy_test.go @@ -170,6 +170,7 @@ func TestDescribeScriptoriumInputSource(t *testing.T) { {name: "prepared players input", source: "narratio.input.players", wantKind: SourceKindStableInput}, {name: "prepared party input", source: "narratio.input.party", wantKind: SourceKindStableInput}, {name: "prepared glossary input", source: "narratio.input.glossary", wantKind: SourceKindStableInput}, + {name: "prepared spell catalog input", source: "narratio.input.spell_catalog", wantKind: SourceKindStableInput}, {name: "configured", source: "narratio.artifact.session_recap", wantKind: SourceKindConfiguredArtifact, wantKey: "session_recap"}, {name: "previous", source: "narratio.previous_session.artifact.session_recap", wantKind: SourceKindPreviousArtifact, wantKey: "session_recap", wantPrev: true}, {name: "invalid previous", source: "narratio.previous_session.artifact.", wantErr: ErrInvalidPreviousSessionSource}, @@ -211,6 +212,43 @@ func TestDescribeScriptoriumInputSource(t *testing.T) { } } +func TestDescribePreparedInputSource(t *testing.T) { + tests := []struct { + source string + manifestKind string + filename string + }{ + {source: SourceInputPlayers, manifestKind: "players", filename: "players.yml"}, + {source: SourceInputParty, manifestKind: "party", filename: "party.yml"}, + {source: SourceInputGlossary, manifestKind: "glossary", filename: "glossary.yml"}, + {source: SourceInputSpellCatalog, manifestKind: "spell_catalog", filename: "spell_catalog.json"}, + } + + for _, tt := range tests { + t.Run(tt.manifestKind, func(t *testing.T) { + descriptor, ok := DescribePreparedInputSource(" " + tt.source + " ") + if !ok { + t.Fatalf("DescribePreparedInputSource(%q) ok = false", tt.source) + } + if descriptor.SourceID != tt.source || descriptor.ManifestKind != tt.manifestKind || descriptor.Filename != tt.filename { + t.Fatalf("DescribePreparedInputSource(%q) = %#v", tt.source, descriptor) + } + + scriptoriumDescriptor, err := DescribeScriptoriumInputSource(tt.source) + if err != nil { + t.Fatalf("DescribeScriptoriumInputSource(%q) error = %v", tt.source, err) + } + if scriptoriumDescriptor.PreparedInput == nil || *scriptoriumDescriptor.PreparedInput != descriptor { + t.Fatalf("prepared input descriptor = %#v, want %#v", scriptoriumDescriptor.PreparedInput, descriptor) + } + }) + } + + if _, ok := DescribePreparedInputSource("narratio.input.unknown"); ok { + t.Fatal("DescribePreparedInputSource(unknown) ok = true") + } +} + func TestValidateInputConfiguredReference(t *testing.T) { configured := map[string]struct{}{"session_recap": {}} diff --git a/internal/config/campaign_config_test.go b/internal/config/campaign_config_test.go index 6843128..5147804 100644 --- a/internal/config/campaign_config_test.go +++ b/internal/config/campaign_config_test.go @@ -138,6 +138,74 @@ func TestCampaignSessionMergeSessionOverridesStableInputs(t *testing.T) { assertResolvedStableInput(t, cfg.StableInputs.PartyFile, "./session-party.yml", sessionPath, "session_config") } +func TestCampaignSessionMergeSpellCatalog(t *testing.T) { + tests := []struct { + name string + campaignValue string + sessionValue string + wantPath string + wantSource string + }{ + {name: "omitted", wantPath: "", wantSource: "campaign_config"}, + {name: "campaign inherited", campaignValue: "./campaign-spells.json", wantPath: "./campaign-spells.json", wantSource: "campaign_config"}, + {name: "session override", campaignValue: "./campaign-spells.json", sessionValue: "./session-spells.json", wantPath: "./session-spells.json", wantSource: "session_config"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + campaignSpell := "" + if tt.campaignValue != "" { + campaignSpell = " spell_catalog_file: " + tt.campaignValue + "\n" + } + sessionSpell := "" + if tt.sessionValue != "" { + sessionSpell = " spell_catalog_file: " + tt.sessionValue + "\n" + } + pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t, + "campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n players_file: ./players.yml\n party_file: ./party.yml\n"+campaignSpell, + "session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n"+sessionSpell, + ) + + cfg, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{}) + if err != nil { + t.Fatalf("LoadWithSessionOptions() error = %v", err) + } + wantConfigPath := campaignPath + if tt.wantSource == "session_config" { + wantConfigPath = sessionPath + } + assertResolvedStableInput(t, cfg.StableInputs.SpellCatalogFile, tt.wantPath, wantConfigPath, tt.wantSource) + if cfg.Session.Inputs.SpellCatalogFile != tt.wantPath { + t.Fatalf("session spell_catalog_file = %q, want %q", cfg.Session.Inputs.SpellCatalogFile, tt.wantPath) + } + }) + } +} + +func TestCampaignSessionMergeRejectsWhitespaceSpellCatalog(t *testing.T) { + tests := []struct { + name string + campaignLine string + sessionLine string + wantErr string + }{ + {name: "campaign", campaignLine: " spell_catalog_file: ' '\n", wantErr: "campaign.inputs.spell_catalog_file"}, + {name: "session", sessionLine: " spell_catalog_file: ' '\n", wantErr: "session.inputs.spell_catalog_file"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t, + "campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n players_file: ./players.yml\n party_file: ./party.yml\n"+tt.campaignLine, + "session_id: 2026-05-03\ninputs:\n audio_dir: ./audio\n"+tt.sessionLine, + ) + _, err := LoadWithSessionOptions(pipelinePath, campaignPath, sessionPath, SessionLoadOptions{}) + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("LoadWithSessionOptions() error = %v, want containing %q", err, tt.wantErr) + } + }) + } +} + func TestCampaignRequiresPlayersAndPartyInputs(t *testing.T) { pipelinePath, campaignPath, sessionPath := writeCampaignConfigTestFiles(t, "campaign_id: sample-campaign\ninputs:\n speakers_file: ./speakers.yml\n autocorrect_file: ./autocorrect.yml\n glossary_file: ./glossary.yml\n", diff --git a/internal/config/config.go b/internal/config/config.go index 94febfe..3e3b4ca 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -49,11 +49,12 @@ type CampaignConfig struct { // CampaignInputsConfig contains stable campaign-level input file references. type CampaignInputsConfig struct { - SpeakersFile string `yaml:"speakers_file"` - AutocorrectFile string `yaml:"autocorrect_file"` - GlossaryFile string `yaml:"glossary_file"` - PlayersFile string `yaml:"players_file"` - PartyFile string `yaml:"party_file"` + SpeakersFile string `yaml:"speakers_file"` + AutocorrectFile string `yaml:"autocorrect_file"` + GlossaryFile string `yaml:"glossary_file"` + PlayersFile string `yaml:"players_file"` + PartyFile string `yaml:"party_file"` + SpellCatalogFile string `yaml:"spell_catalog_file"` } // SessionConfig contains per-session inputs and metadata. @@ -266,6 +267,7 @@ type NotariusConfig struct { PipelineID string `yaml:"pipeline_id"` Timeout string `yaml:"timeout"` WorkingDirectory string `yaml:"working_directory"` + References map[string]string `yaml:"references"` Outputs map[string]NotariusOutputConfig `yaml:"outputs"` } @@ -285,14 +287,15 @@ type NotificationConfig struct { // SessionInputsConfig contains per-session input references. type SessionInputsConfig struct { - AudioDir string `yaml:"audio_dir"` - AudioFiles []string `yaml:"audio_files"` - AudioS3 *SessionAudioS3Input `yaml:"audio_s3"` - SpeakersFile string `yaml:"speakers_file"` - AutocorrectFile string `yaml:"autocorrect_file"` - GlossaryFile string `yaml:"glossary_file"` - PlayersFile string `yaml:"players_file"` - PartyFile string `yaml:"party_file"` + AudioDir string `yaml:"audio_dir"` + AudioFiles []string `yaml:"audio_files"` + AudioS3 *SessionAudioS3Input `yaml:"audio_s3"` + SpeakersFile string `yaml:"speakers_file"` + AutocorrectFile string `yaml:"autocorrect_file"` + GlossaryFile string `yaml:"glossary_file"` + PlayersFile string `yaml:"players_file"` + PartyFile string `yaml:"party_file"` + SpellCatalogFile string `yaml:"spell_catalog_file"` } // SessionAudioS3Input configures S3 session-audio input discovery. @@ -303,11 +306,12 @@ type SessionAudioS3Input struct { // ResolvedStableInputs records where stable input file paths came from after // campaign/session merge. type ResolvedStableInputs struct { - SpeakersFile ResolvedInputFile - AutocorrectFile ResolvedInputFile - GlossaryFile ResolvedInputFile - PlayersFile ResolvedInputFile - PartyFile ResolvedInputFile + SpeakersFile ResolvedInputFile + AutocorrectFile ResolvedInputFile + GlossaryFile ResolvedInputFile + PlayersFile ResolvedInputFile + PartyFile ResolvedInputFile + SpellCatalogFile ResolvedInputFile } // ResolvedInputFile records one merged config path and its source config file. diff --git a/internal/config/defaults.go b/internal/config/defaults.go index 1dfea5b..2d98e38 100644 --- a/internal/config/defaults.go +++ b/internal/config/defaults.go @@ -35,6 +35,9 @@ const ( DefaultAuditaReport = true DefaultNotariusBinary = "notarius" DefaultNotariusTimeout = "3h" + // MaxNotariusReferenceBindings is the maximum number of CLI reference + // bindings accepted for one Notarius invocation. + MaxNotariusReferenceBindings = 256 DefaultScriptoriumBinary = "scriptorium" DefaultScriptoriumTimeout = "10m" diff --git a/internal/config/load.go b/internal/config/load.go index 2f179b8..8402b63 100644 --- a/internal/config/load.go +++ b/internal/config/load.go @@ -209,6 +209,12 @@ func mergeCampaignSession(campaignCfg *CampaignConfig, sessionCfg *SessionConfig if sessionCfg == nil { return ResolvedStableInputs{}, fmt.Errorf("session config is required") } + if campaignCfg.Inputs.SpellCatalogFile != "" && strings.TrimSpace(campaignCfg.Inputs.SpellCatalogFile) == "" { + return ResolvedStableInputs{}, fmt.Errorf("campaign.inputs.spell_catalog_file must be non-empty when provided") + } + if sessionCfg.Inputs.SpellCatalogFile != "" && strings.TrimSpace(sessionCfg.Inputs.SpellCatalogFile) == "" { + return ResolvedStableInputs{}, fmt.Errorf("session.inputs.spell_catalog_file must be non-empty when provided") + } campaignName := CampaignID(campaignCfg) sessionCampaign := strings.TrimSpace(sessionCfg.Campaign) @@ -254,6 +260,12 @@ func mergeCampaignSession(campaignCfg *CampaignConfig, sessionCfg *SessionConfig campaignPath, sessionPath, ), + SpellCatalogFile: selectStableInput( + campaignCfg.Inputs.SpellCatalogFile, + sessionCfg.Inputs.SpellCatalogFile, + campaignPath, + sessionPath, + ), } sessionCfg.Inputs.SpeakersFile = stable.SpeakersFile.Path @@ -261,6 +273,7 @@ func mergeCampaignSession(campaignCfg *CampaignConfig, sessionCfg *SessionConfig sessionCfg.Inputs.GlossaryFile = stable.GlossaryFile.Path sessionCfg.Inputs.PlayersFile = stable.PlayersFile.Path sessionCfg.Inputs.PartyFile = stable.PartyFile.Path + sessionCfg.Inputs.SpellCatalogFile = stable.SpellCatalogFile.Path return stable, nil } diff --git a/internal/config/notarius_test.go b/internal/config/notarius_test.go index 16ab104..e57bdc3 100644 --- a/internal/config/notarius_test.go +++ b/internal/config/notarius_test.go @@ -1,6 +1,7 @@ package config import ( + "fmt" "os" "path/filepath" "strings" @@ -108,6 +109,7 @@ func TestNotariusStrictYAML(t *testing.T) { {name: "unknown output field", yaml: "notarius:\n outputs:\n npc_registry:\n lane_id: npc-registry\n unknown: true\n"}, {name: "unsupported session id", yaml: "notarius:\n session_id: forbidden\n"}, {name: "unsupported model", yaml: "notarius:\n model: forbidden\n"}, + {name: "duplicate reference selector", yaml: "notarius:\n references:\n party: narratio.input.party\n party: narratio.input.players\n"}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { @@ -122,6 +124,134 @@ func TestNotariusStrictYAML(t *testing.T) { } } +func TestNotariusReferenceValidationAndNormalization(t *testing.T) { + cfg := validNotariusConfig() + cfg.References = map[string]string{ + " party ": " narratio.input.party ", + " chunk . players ": "narratio.input.players", + " npc-registry . extract . glossary ": "narratio.input.glossary", + "spells": "narratio.input.spell_catalog", + } + + if err := validateNotarius(cfg, nil); err != nil { + t.Fatalf("validateNotarius() error = %v", err) + } + want := map[string]string{ + "party": "narratio.input.party", + "chunk.players": "narratio.input.players", + "npc-registry.extract.glossary": "narratio.input.glossary", + "spells": "narratio.input.spell_catalog", + } + if len(cfg.References) != len(want) { + t.Fatalf("normalized references = %#v, want %#v", cfg.References, want) + } + for selector, source := range want { + if cfg.References[selector] != source { + t.Fatalf("references[%q] = %q, want %q", selector, cfg.References[selector], source) + } + } +} + +func TestNotariusReferenceValidationRejectsInvalidBindings(t *testing.T) { + tests := []struct { + name string + references map[string]string + wantErr string + }{ + {name: "empty selector", references: map[string]string{" ": "narratio.input.party"}, wantErr: "selector is required"}, + {name: "equals in selector", references: map[string]string{"party=x": "narratio.input.party"}, wantErr: "must not contain"}, + {name: "invalid stage", references: map[string]string{"lane.prepare.party": "narratio.input.party"}, wantErr: "middle component"}, + {name: "empty source", references: map[string]string{"party": " "}, wantErr: "source is required"}, + {name: "unsupported source", references: map[string]string{"party": "narratio.input.unknown"}, wantErr: "not a supported prepared input source"}, + { + name: "normalized collision", + references: map[string]string{ + "chunk.party": "narratio.input.party", + " chunk . party ": "narratio.input.players", + }, + wantErr: "normalize to", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + cfg := validNotariusConfig() + cfg.References = tt.references + err := validateNotarius(cfg, nil) + if err == nil || !strings.Contains(err.Error(), tt.wantErr) { + t.Fatalf("validateNotarius() error = %v, want containing %q", err, tt.wantErr) + } + }) + } +} + +func TestNotariusReferenceLimit(t *testing.T) { + for _, count := range []int{MaxNotariusReferenceBindings, MaxNotariusReferenceBindings + 1} { + t.Run(fmt.Sprintf("count_%d", count), func(t *testing.T) { + cfg := validNotariusConfig() + cfg.References = make(map[string]string, count) + for i := 0; i < count; i++ { + cfg.References[fmt.Sprintf("lane-%03d.party", i)] = "narratio.input.party" + } + err := validateNotarius(cfg, nil) + if count == MaxNotariusReferenceBindings { + if err != nil { + t.Fatalf("validateNotarius() at limit error = %v", err) + } + return + } + if err == nil || !strings.Contains(err.Error(), "at most 256 bindings") { + t.Fatalf("validateNotarius() above limit error = %v", err) + } + }) + } +} + +func TestNotariusNilAndEmptyReferencesAreValid(t *testing.T) { + for _, references := range []map[string]string{nil, {}} { + cfg := validNotariusConfig() + cfg.References = references + if err := validateNotarius(cfg, nil); err != nil { + t.Fatalf("validateNotarius(%#v) error = %v", references, err) + } + } +} + +func TestNotariusSpellCatalogReferenceRequiresEffectiveInput(t *testing.T) { + cfg := loadedValidConfig(t) + cfg.Pipeline.Notarius = validNotariusConfig() + cfg.Pipeline.Notarius.References = map[string]string{"spells": "narratio.input.spell_catalog"} + + err := Validate(cfg) + if err == nil || !strings.Contains(err.Error(), "requires campaign.inputs.spell_catalog_file or session.inputs.spell_catalog_file") { + t.Fatalf("Validate() error = %v, want missing spell catalog input", err) + } + + cfg.StableInputs.SpellCatalogFile = ResolvedInputFile{Path: "./spells.json", Source: "campaign_config"} + if err := Validate(cfg); err != nil { + t.Fatalf("Validate() with spell catalog error = %v", err) + } +} + +func validNotariusConfig() *NotariusConfig { + return &NotariusConfig{ + Enabled: true, + Binary: "notarius", + ConfigPath: "./notarius.yml", + PipelineID: "dnd-session", + Timeout: "45m", + WorkingDirectory: ".", + Outputs: map[string]NotariusOutputConfig{ + "npc_registry": { + LaneID: "npc-registry", + MediaType: "application/json", + SchemaID: "notarius.dnd.npc_registry", + SchemaVersion: "v1", + }, + }, + } +} + func TestNotariusEnabledValidation(t *testing.T) { valid := `notarius: enabled: true diff --git a/internal/config/validate.go b/internal/config/validate.go index a5781f7..fae1e48 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -12,6 +12,7 @@ import ( "gitea.maximumdirect.net/eric/narratio/internal/artifactmodel" "gitea.maximumdirect.net/eric/narratio/internal/artifactpolicy" + "gitea.maximumdirect.net/eric/narratio/internal/notariusref" "gitea.maximumdirect.net/eric/narratio/internal/pathsafe" ) @@ -39,7 +40,7 @@ func Validate(cfg *Config) error { if err := validateSession(cfg.Session); err != nil { return fmt.Errorf("session config %q invalid: %w", shortName(cfg.SessionPath, "session.yml"), err) } - if err := validateCrossConfig(cfg.Pipeline, cfg.Session); err != nil { + if err := validateCrossConfig(cfg.Pipeline, cfg.Session, cfg.StableInputs); err != nil { return fmt.Errorf("pipeline/session config invalid: %w", err) } @@ -71,6 +72,9 @@ func validateCampaign(cfg *CampaignConfig) error { if strings.TrimSpace(cfg.Inputs.PartyFile) == "" { return fmt.Errorf("campaign.inputs.party_file is required") } + if cfg.Inputs.SpellCatalogFile != "" && strings.TrimSpace(cfg.Inputs.SpellCatalogFile) == "" { + return fmt.Errorf("campaign.inputs.spell_catalog_file must be non-empty when provided") + } return nil } @@ -547,6 +551,39 @@ func validateNotarius(cfg *NotariusConfig, scriptorium *ScriptoriumConfig) error if strings.TrimSpace(cfg.WorkingDirectory) == "" { return fmt.Errorf("pipeline.notarius.working_directory is required when pipeline.notarius.enabled is true") } + if len(cfg.References) > MaxNotariusReferenceBindings { + return fmt.Errorf("pipeline.notarius.references must contain at most %d bindings", MaxNotariusReferenceBindings) + } + + referenceKeys := make([]string, 0, len(cfg.References)) + for selector := range cfg.References { + referenceKeys = append(referenceKeys, selector) + } + sort.Strings(referenceKeys) + var normalizedReferences map[string]string + if cfg.References != nil { + normalizedReferences = make(map[string]string, len(cfg.References)) + } + referenceOwners := make(map[string]string, len(cfg.References)) + for _, rawSelector := range referenceKeys { + selector, err := notariusref.NormalizeSelector(rawSelector) + if err != nil { + return fmt.Errorf("pipeline.notarius.references selector %q is invalid: %w", rawSelector, err) + } + if previous, ok := referenceOwners[selector]; ok { + return fmt.Errorf("pipeline.notarius.references selectors %q and %q normalize to %q", previous, rawSelector, selector) + } + referenceOwners[selector] = rawSelector + + source := strings.TrimSpace(cfg.References[rawSelector]) + if source == "" { + return fmt.Errorf("pipeline.notarius.references.%s source is required", selector) + } + if _, ok := artifactpolicy.DescribePreparedInputSource(source); !ok { + return fmt.Errorf("pipeline.notarius.references.%s source %q is not a supported prepared input source", selector, source) + } + normalizedReferences[selector] = source + } reservedSources := map[string]string{} for _, spec := range artifactmodel.RuntimeTranscriptArtifacts() { @@ -613,6 +650,7 @@ func validateNotarius(cfg *NotariusConfig, scriptorium *ScriptoriumConfig) error cfg.PipelineID = strings.TrimSpace(cfg.PipelineID) cfg.Timeout = strings.TrimSpace(cfg.Timeout) cfg.WorkingDirectory = filepath.Clean(cfg.WorkingDirectory) + cfg.References = normalizedReferences cfg.Outputs = normalizedOutputs return nil } @@ -762,6 +800,9 @@ func validateSession(cfg *SessionConfig) error { if strings.TrimSpace(cfg.Inputs.PartyFile) == "" { return fmt.Errorf("session.inputs.party_file is required") } + if cfg.Inputs.SpellCatalogFile != "" && strings.TrimSpace(cfg.Inputs.SpellCatalogFile) == "" { + return fmt.Errorf("session.inputs.spell_catalog_file must be non-empty when provided") + } hasAudioDir := strings.TrimSpace(cfg.Inputs.AudioDir) != "" hasAudioFiles := len(cfg.Inputs.AudioFiles) > 0 @@ -797,10 +838,17 @@ func validateSessionIdentifier(fieldName, value string, required bool) error { return nil } -func validateCrossConfig(pipeline *PipelineConfig, session *SessionConfig) error { +func validateCrossConfig(pipeline *PipelineConfig, session *SessionConfig, stableInputs ResolvedStableInputs) error { if pipeline == nil || session == nil { return nil } + if pipeline.Notarius != nil && pipeline.Notarius.Enabled { + for selector, source := range pipeline.Notarius.References { + if source == artifactpolicy.SourceInputSpellCatalog && strings.TrimSpace(stableInputs.SpellCatalogFile.Path) == "" { + return fmt.Errorf("pipeline.notarius.references.%s requires campaign.inputs.spell_catalog_file or session.inputs.spell_catalog_file", selector) + } + } + } audioS3Enabled := session.Inputs.AudioS3 != nil publishUploadEnabled := publishUploadConfiguredForS3(pipeline) diff --git a/internal/notariusref/selector.go b/internal/notariusref/selector.go new file mode 100644 index 0000000..56caf0c --- /dev/null +++ b/internal/notariusref/selector.go @@ -0,0 +1,43 @@ +// Package notariusref owns Narratio's Notarius CLI reference-selector +// vocabulary without depending on Notarius implementation packages. +package notariusref + +import ( + "fmt" + "strings" +) + +// NormalizeSelector validates and normalizes a Notarius v0.6 reference +// selector. It intentionally validates selector structure only; Notarius owns +// target, slot, and media compatibility. +func NormalizeSelector(value string) (string, error) { + selector := strings.TrimSpace(value) + if selector == "" { + return "", fmt.Errorf("reference selector is required") + } + if strings.Contains(selector, "=") { + return "", fmt.Errorf("reference selector must not contain '='") + } + + parts := strings.Split(selector, ".") + for index := range parts { + parts[index] = strings.TrimSpace(parts[index]) + if parts[index] == "" { + return "", fmt.Errorf("reference selector components must not be empty") + } + } + + switch len(parts) { + case 1, 2: + return strings.Join(parts, "."), nil + case 3: + switch parts[1] { + case "extract", "merge", "normalize": + return strings.Join(parts, "."), nil + default: + return "", fmt.Errorf("three-component reference selector must use extract, merge, or normalize as its middle component") + } + default: + return "", fmt.Errorf("reference selector must use slot, chunk.slot, lane.slot, or lane.stage.slot") + } +} diff --git a/internal/notariusref/selector_test.go b/internal/notariusref/selector_test.go new file mode 100644 index 0000000..de2009a --- /dev/null +++ b/internal/notariusref/selector_test.go @@ -0,0 +1,58 @@ +package notariusref + +import ( + "strings" + "testing" +) + +func TestNormalizeSelectorAcceptsDocumentedForms(t *testing.T) { + tests := []struct { + name string + value string + want string + }{ + {name: "pipeline", value: "party", want: "party"}, + {name: "chunk", value: "chunk.party", want: "chunk.party"}, + {name: "lane", value: "npc-registry.party", want: "npc-registry.party"}, + {name: "extract", value: "npc-registry.extract.party", want: "npc-registry.extract.party"}, + {name: "merge", value: "npc-registry.merge.party", want: "npc-registry.merge.party"}, + {name: "normalize", value: "npc-registry.normalize.party", want: "npc-registry.normalize.party"}, + {name: "whitespace", value: " npc-registry . extract . party ", want: "npc-registry.extract.party"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + got, err := NormalizeSelector(test.value) + if err != nil { + t.Fatalf("NormalizeSelector(%q) error = %v", test.value, err) + } + if got != test.want { + t.Fatalf("NormalizeSelector(%q) = %q, want %q", test.value, got, test.want) + } + }) + } +} + +func TestNormalizeSelectorRejectsInvalidForms(t *testing.T) { + tests := []struct { + name string + value string + wantErr string + }{ + {name: "empty", value: "", wantErr: "required"}, + {name: "whitespace", value: " ", wantErr: "required"}, + {name: "empty first", value: ".party", wantErr: "components"}, + {name: "empty middle", value: "lane..party", wantErr: "components"}, + {name: "empty final", value: "lane.", wantErr: "components"}, + {name: "equals", value: "party=/tmp/party.yml", wantErr: "must not contain"}, + {name: "invalid stage", value: "lane.chunk.party", wantErr: "extract, merge, or normalize"}, + {name: "too many components", value: "lane.extract.party.extra", wantErr: "must use"}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + _, err := NormalizeSelector(test.value) + if err == nil || !strings.Contains(err.Error(), test.wantErr) { + t.Fatalf("NormalizeSelector(%q) error = %v, want containing %q", test.value, err, test.wantErr) + } + }) + } +} diff --git a/internal/stage/analyze.go b/internal/stage/analyze.go index 275bf80..a91b507 100644 --- a/internal/stage/analyze.go +++ b/internal/stage/analyze.go @@ -728,16 +728,8 @@ func resolvePreparedStableInput(sourceID string, paths artifacts.SessionPaths) ( } func preparedStableInputFilename(sourceID string) (string, bool) { - switch strings.TrimSpace(sourceID) { - case artifactpolicy.SourceInputPlayers: - return "players.yml", true - case artifactpolicy.SourceInputParty: - return "party.yml", true - case artifactpolicy.SourceInputGlossary: - return "glossary.yml", true - default: - return "", false - } + descriptor, ok := artifactpolicy.DescribePreparedInputSource(sourceID) + return descriptor.Filename, ok } func buildAnalyzeRuntimeArtifactCatalog(