diff --git a/internal/cli/run.go b/internal/cli/run.go index 9888e4ee..00ecd7eb 100644 --- a/internal/cli/run.go +++ b/internal/cli/run.go @@ -147,7 +147,7 @@ func runPipelineCommand(args []string, stdout, stderr io.Writer, opts Options) i machineOutput := fs.Bool("json", false, "write the successful run result as JSON") debug := fs.Bool("debug", false, "write a debug bundle") debugDir := fs.String("debug-dir", "", "debug bundle directory") - llmProfile := fs.String("llm-profile", "", "LLM profile override") + llmProfile := singleValueFlag{name: "--llm-profile"} reasoningEffort := singleValueFlag{name: "--reasoning-effort"} clearReasoningEffort := fs.Bool("clear-reasoning-effort", false, "clear the LLM profile reasoning effort") resume := fs.Bool("resume", false, "reuse compatible recorded checkpoints") @@ -157,6 +157,7 @@ func runPipelineCommand(args []string, stdout, stderr io.Writer, opts Options) i referenceFlags := stringListFlag{} withoutReferenceFlags := stringListFlag{} fs.Var(&requestedSessionID, "session-id", "prompt session identifier") + fs.Var(&llmProfile, "llm-profile", "LLM profile override") fs.Var(&reasoningEffort, "reasoning-effort", "reasoning effort override") fs.Var(&chunkCache, "chunk_cache", "chunk plan cache mode: auto, bypass, or refresh") fs.Var(&referenceFlags, "reference", "reference binding, as slot=path, chunk.slot=path, merge.slot=path, lane.slot=path, lane.extract.slot=path, lane.merge.slot=path, or lane.normalize.slot=path") @@ -203,6 +204,10 @@ func runPipelineCommand(args []string, stdout, stderr io.Writer, opts Options) i fmt.Fprintln(stderr, "notarius: --session-id must not be empty") return 2 } + if llmProfile.set && strings.TrimSpace(llmProfile.value) == "" { + fmt.Fprintln(stderr, "notarius: --llm-profile must not be empty") + return 2 + } if reasoningEffort.set && *clearReasoningEffort { fmt.Fprintln(stderr, "notarius: --reasoning-effort cannot be combined with --clear-reasoning-effort") return 2 @@ -338,7 +343,7 @@ func runPipelineCommand(args []string, stdout, stderr io.Writer, opts Options) i PipelineID: pipelineID, Only: only, Catalog: catalog, - LLMProfileOverride: *llmProfile, + LLMProfileOverride: strings.TrimSpace(llmProfile.value), ReferenceOverrides: referenceOverrides, ReferenceUnbinds: referenceUnbinds, }) @@ -426,7 +431,7 @@ func runPipelineCommand(args []string, stdout, stderr io.Writer, opts Options) i if err != nil { return failPipelineCommand(stderr, commandState, terminalWriter, err) } - checkpointRecorder, checkpointLoader, err := checkpointHandlersForRun(effective.Config.Cache.Checkpoints, opts, effective.ResolvedPipeline, prepared.CheckpointFingerprints(), llmFingerprints, rawInput, only, llmProfiles, strings.TrimSpace(*llmProfile), effectiveSessionID, runtimeOverrides, *resume) + checkpointRecorder, checkpointLoader, err := checkpointHandlersForRun(effective.Config.Cache.Checkpoints, opts, effective.ResolvedPipeline, prepared.CheckpointFingerprints(), llmFingerprints, rawInput, only, llmProfiles, strings.TrimSpace(llmProfile.value), effectiveSessionID, runtimeOverrides, *resume) if err != nil { return failPipelineCommand(stderr, commandState, terminalWriter, err) } diff --git a/internal/cli/run_contract_test.go b/internal/cli/run_contract_test.go index 336a5343..b7affe0b 100644 --- a/internal/cli/run_contract_test.go +++ b/internal/cli/run_contract_test.go @@ -43,6 +43,12 @@ func TestRunControlsRejectSyntaxWithoutAllocatingState(t *testing.T) { {name: "blank session ID", args: func(roots stateTestRoots) []string { return []string{"run", "sample", "--config", roots.config, "--input", roots.input, "--session-id", ""} }}, + {name: "blank LLM profile", args: func(roots stateTestRoots) []string { + return []string{"run", "sample", "--config", roots.config, "--input", roots.input, "--llm-profile", ""} + }}, + {name: "whitespace LLM profile", args: func(roots stateTestRoots) []string { + return []string{"run", "sample", "--config", roots.config, "--input", roots.input, "--llm-profile", " \t "} + }}, {name: "multiple pipeline IDs", args: func(roots stateTestRoots) []string { return []string{"run", "sample", "extra", "--config", roots.config, "--input", roots.input} }}, @@ -253,7 +259,7 @@ func TestRunLLMProfileOverrideAndValidationUseInjectedBoundaries(t *testing.T) { return nil, nil, nil } var stdout, stderr bytes.Buffer - code := RunWithOptions([]string{"run", "sample", "--config", roots.config, "--input", roots.input, "--chunk_cache", "bypass", "--llm-profile", "override-profile"}, &stdout, &stderr, opts) + code := RunWithOptions([]string{"run", "sample", "--config", roots.config, "--input", roots.input, "--chunk_cache", "bypass", "--llm-profile", " override-profile "}, &stdout, &stderr, opts) if code != 0 || stderr.Len() != 0 { t.Fatalf("code=%d stdout=%q stderr=%q", code, stdout.String(), stderr.String()) } diff --git a/internal/core/config/file_config.go b/internal/core/config/file_config.go index d49c32fd..909b6cc8 100644 --- a/internal/core/config/file_config.go +++ b/internal/core/config/file_config.go @@ -3,6 +3,7 @@ package config import ( "bytes" "fmt" + "io" "os" "path/filepath" "sort" @@ -349,6 +350,12 @@ func ParseFileConfigYAML(data []byte) (FileConfig, error) { if err := decoder.Decode(&fileCfg); err != nil { return FileConfig{}, fmt.Errorf("decode yaml: %w", err) } + var trailing any + if err := decoder.Decode(&trailing); err == nil { + return FileConfig{}, fmt.Errorf("config must contain exactly one YAML document") + } else if err != io.EOF { + return FileConfig{}, fmt.Errorf("decode trailing yaml document: %w", err) + } return fileCfg, nil } diff --git a/internal/core/config/file_config_contract_test.go b/internal/core/config/file_config_contract_test.go index 0512ef08..0f01c5eb 100644 --- a/internal/core/config/file_config_contract_test.go +++ b/internal/core/config/file_config_contract_test.go @@ -50,6 +50,28 @@ func TestFileConfigMinimalVersion4AppliesOverDefaults(t *testing.T) { } } +func TestParseFileConfigYAMLRejectsAdditionalDocuments(t *testing.T) { + tests := []struct { + name string + source string + wantErr bool + }{ + {name: "trailing whitespace", source: "version: 4\n\n \t", wantErr: false}, + {name: "trailing comment", source: "version: 4\n# trailing comment\n", wantErr: false}, + {name: "second valid document", source: "version: 4\n---\nversion: 4\n", wantErr: true}, + {name: "second empty document", source: "version: 4\n---\n", wantErr: true}, + {name: "second malformed document", source: "version: 4\n---\nversion: [\n", wantErr: true}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := ParseFileConfigYAML([]byte(tt.source)) + if (err != nil) != tt.wantErr { + t.Fatalf("ParseFileConfigYAML() error = %v, want error=%t", err, tt.wantErr) + } + }) + } +} + func TestFilePipelineLLMProfileIsPresenceAwareAndDetached(t *testing.T) { const pipelineYAML = `version: 4 pipelines: diff --git a/internal/framework/pipeline/profile.go b/internal/framework/pipeline/profile.go index 9732a948..ccc15736 100644 --- a/internal/framework/pipeline/profile.go +++ b/internal/framework/pipeline/profile.go @@ -305,7 +305,10 @@ func ResolvePipeline(profile PipelineProfile, options ResolveOptions, catalog Mo return ResolvedPipeline{}, err } - input := resolveBinding(profile.Input, "") + input, err := resolveBinding(profile.Input, "", fmt.Sprintf("pipeline %q input reference slot", pipelineID)) + if err != nil { + return ResolvedPipeline{}, err + } if input.Module == "" { return ResolvedPipeline{}, fmt.Errorf("pipeline %q input module must not be empty", pipelineID) } @@ -320,7 +323,10 @@ func ResolvePipeline(profile PipelineProfile, options ResolveOptions, catalog Mo } capabilities.add(inputModuleSpec.Provides...) - chunk := resolveBinding(profile.Chunk, DefaultChunkModule) + chunk, err := resolveBinding(profile.Chunk, DefaultChunkModule, fmt.Sprintf("pipeline %q chunk reference slot", pipelineID)) + if err != nil { + return ResolvedPipeline{}, err + } chunkSpec, err := chunkerSpec(catalog, chunk.Module) if err != nil { return ResolvedPipeline{}, moduleLookupError(pipelineID, "", StageChunk, chunk.Module, err) @@ -363,6 +369,10 @@ func ResolvePipeline(profile PipelineProfile, options ResolveOptions, catalog Mo if err != nil { return ResolvedPipeline{}, err } + output, err := resolveBinding(profile.Output, DefaultOutputModule, fmt.Sprintf("pipeline %q output reference slot", pipelineID)) + if err != nil { + return ResolvedPipeline{}, err + } resolved := ResolvedPipeline{ ID: pipelineID, Input: input, @@ -370,7 +380,7 @@ func ResolvePipeline(profile PipelineProfile, options ResolveOptions, catalog Mo Chunk: chunk, ChunkExecutionClass: chunkSpec.ExecutionClass, ChunkReferences: referenceTarget(StageChunk, "", chunk.Module, chunkReferences), - Output: resolveBinding(profile.Output, DefaultOutputModule), + Output: output, } chunkValidatorChain, err := resolveValidatorChain(pipelineID, "", StageChunk, chunk.Module, chunk.Validators, "", nil, catalog) if err != nil { @@ -485,13 +495,29 @@ func resolveArtifactLane( inherited capabilitySet, catalog ModuleCatalog, ) (ResolvedArtifactLane, []ResolvedValidatorChain, capabilitySet, error) { + extract, err := resolveBinding(profile.Extract, "", fmt.Sprintf("pipeline %q step %q lane %q extract reference slot", pipelineID, stepID, laneID)) + if err != nil { + return ResolvedArtifactLane{}, nil, nil, err + } + merge, err := resolveBinding(profile.Merge, DefaultMergeModule, fmt.Sprintf("pipeline %q step %q lane %q merge reference slot", pipelineID, stepID, laneID)) + if err != nil { + return ResolvedArtifactLane{}, nil, nil, err + } + normalize, err := resolveBinding(profile.Normalize, DefaultNormalizeModule, fmt.Sprintf("pipeline %q step %q lane %q normalize reference slot", pipelineID, stepID, laneID)) + if err != nil { + return ResolvedArtifactLane{}, nil, nil, err + } + validators, err := resolveBindings(profile.Validators, "", fmt.Sprintf("pipeline %q step %q lane %q validator reference slot", pipelineID, stepID, laneID)) + if err != nil { + return ResolvedArtifactLane{}, nil, nil, err + } lane := ResolvedArtifactLane{ StepID: strings.TrimSpace(stepID), ID: laneID, - Extract: resolveBinding(profile.Extract, ""), - Merge: resolveBinding(profile.Merge, DefaultMergeModule), - Normalize: resolveBinding(profile.Normalize, DefaultNormalizeModule), - Validators: resolveBindings(profile.Validators, ""), + Extract: extract, + Merge: merge, + Normalize: normalize, + Validators: validators, } if lane.Extract.Module == "" { return ResolvedArtifactLane{}, nil, nil, fmt.Errorf("pipeline %q lane %q extract module must not be empty", pipelineID, laneID) @@ -516,6 +542,7 @@ func resolveArtifactLane( extractReferences := mergeReferenceMaps(profile.References, lane.Extract.References) references, err := resolveReferenceTargetBindings(referenceResolutionTarget{ PipelineID: pipelineID, + StepID: strings.TrimSpace(stepID), LaneID: laneID, Stage: StageExtract, Module: lane.Extract.Module, @@ -541,6 +568,7 @@ func resolveArtifactLane( } mergeReferences, err := resolveReferenceTargetBindings(referenceResolutionTarget{ PipelineID: pipelineID, + StepID: strings.TrimSpace(stepID), LaneID: laneID, Stage: StageMerge, Module: lane.Merge.Module, @@ -566,6 +594,7 @@ func resolveArtifactLane( } normalizeReferences, err := resolveReferenceTargetBindings(referenceResolutionTarget{ PipelineID: pipelineID, + StepID: strings.TrimSpace(stepID), LaneID: laneID, Stage: StageNormalize, Module: lane.Normalize.Module, @@ -925,7 +954,7 @@ func validatePipelineReferenceDefaults( lanesByID map[string]ArtifactLaneProfile, catalog ModuleCatalog, ) error { - normalizedPipelineReferences, err := normalizedReferenceMap(pipelineReferences, fmt.Sprintf("pipeline %q reference slot", pipelineID)) + normalizedPipelineReferences, err := normalizeReferenceMap(pipelineReferences, fmt.Sprintf("pipeline %q reference slot", pipelineID)) if err != nil { return err } @@ -939,38 +968,38 @@ func validatePipelineReferenceDefaults( } for _, laneID := range sortedArtifactLaneProfileKeys(lanesByID) { laneProfile := lanesByID[laneID] - extract := resolveBinding(laneProfile.Extract, "") - if extract.Module == "" { + extractModule := resolveModuleKey(laneProfile.Extract.Module, "") + if extractModule == "" { return fmt.Errorf("pipeline %q lane %q extract module must not be empty", pipelineID, laneID) } - extractSpec, err := extractorSpec(catalog, extract.Module) + extractSpec, err := extractorSpec(catalog, extractModule) if err != nil { - return moduleLookupError(pipelineID, laneID, StageExtract, extract.Module, err) + return moduleLookupError(pipelineID, laneID, StageExtract, extractModule, err) } for _, slot := range extractSpec.ReferenceSlots { declaredByAnyTarget[slot.Name] = struct{}{} } - merge := resolveBinding(laneProfile.Merge, DefaultMergeModule) + mergeModule := resolveModuleKey(laneProfile.Merge.Module, DefaultMergeModule) var artifactType reflect.Type artifactKind := extractSpec.ArtifactKind if artifactKind != "" && catalog.Extractors != nil { - if entry, ok := catalog.Extractors.typedEntry(extract.Module); ok { + if entry, ok := catalog.Extractors.typedEntry(extractModule); ok { artifactType = entry.valueType } } - mergeSpec, err := mergerSpecForArtifact(catalog, merge.Module, artifactKind, artifactType) + mergeSpec, err := mergerSpecForArtifact(catalog, mergeModule, artifactKind, artifactType) if err != nil { - return moduleLookupError(pipelineID, laneID, StageMerge, merge.Module, err) + return moduleLookupError(pipelineID, laneID, StageMerge, mergeModule, err) } for _, slot := range mergeSpec.ReferenceSlots { declaredByAnyTarget[slot.Name] = struct{}{} } - normalize := resolveBinding(laneProfile.Normalize, DefaultNormalizeModule) - normalizeSpec, err := normalizerSpecForArtifact(catalog, normalize.Module, artifactKind, artifactType) + normalizeModule := resolveModuleKey(laneProfile.Normalize.Module, DefaultNormalizeModule) + normalizeSpec, err := normalizerSpecForArtifact(catalog, normalizeModule, artifactKind, artifactType) if err != nil { - return moduleLookupError(pipelineID, laneID, StageNormalize, normalize.Module, err) + return moduleLookupError(pipelineID, laneID, StageNormalize, normalizeModule, err) } for _, slot := range normalizeSpec.ReferenceSlots { declaredByAnyTarget[slot.Name] = struct{}{} @@ -987,6 +1016,7 @@ func validatePipelineReferenceDefaults( type referenceResolutionTarget struct { PipelineID string + StepID string LaneID string Stage ModuleStage Module string @@ -1046,7 +1076,7 @@ func resolveReferenceTargetBindings(target referenceResolutionTarget) ([]Referen return nil } - normalizedPipelineReferences, err := normalizedReferenceMap(target.PipelineReferences, fmt.Sprintf("pipeline %q reference slot", target.PipelineID)) + normalizedPipelineReferences, err := normalizeReferenceMap(target.PipelineReferences, fmt.Sprintf("pipeline %q reference slot", target.PipelineID)) if err != nil { return nil, err } @@ -1059,7 +1089,7 @@ func resolveReferenceTargetBindings(target referenceResolutionTarget) ([]Referen } } - normalizedLocalReferences, err := normalizedReferenceMap(target.LocalReferences, referenceTargetSlotLabel(target)) + normalizedLocalReferences, err := normalizeReferenceMap(target.LocalReferences, referenceTargetSlotLabel(target)) if err != nil { return nil, err } @@ -1166,6 +1196,9 @@ func sortedReferenceBindings(bindings map[string]ReferenceBinding) []ReferenceBi func referenceTargetErrorContext(target referenceResolutionTarget) string { if target.LaneID != "" { + if target.StepID != "" { + return fmt.Sprintf("pipeline %q step %q lane %q %s module %q", target.PipelineID, target.StepID, target.LaneID, target.Stage, target.Module) + } return fmt.Sprintf("pipeline %q lane %q %s module %q", target.PipelineID, target.LaneID, target.Stage, target.Module) } return fmt.Sprintf("pipeline %q %s module %q", target.PipelineID, target.Stage, target.Module) @@ -1173,17 +1206,22 @@ func referenceTargetErrorContext(target referenceResolutionTarget) string { func referenceTargetSlotLabel(target referenceResolutionTarget) string { if target.LaneID != "" { + if target.StepID != "" { + return fmt.Sprintf("pipeline %q step %q lane %q %s reference slot", target.PipelineID, target.StepID, target.LaneID, target.Stage) + } return fmt.Sprintf("pipeline %q lane %q %s reference slot", target.PipelineID, target.LaneID, target.Stage) } return fmt.Sprintf("pipeline %q %s reference slot", target.PipelineID, target.Stage) } -func normalizedReferenceMap(values map[string]ReferenceSource, keyName string) (map[string]ReferenceSource, error) { +func normalizeReferenceMap(values map[string]ReferenceSource, keyName string) (map[string]ReferenceSource, error) { if len(values) == 0 { return nil, nil } out := make(map[string]ReferenceSource, len(values)) - for rawSlotName, rawSource := range values { + rawSlotNames := sortedStringMapKeys(values) + for _, rawSlotName := range rawSlotNames { + rawSource := values[rawSlotName] slotName := strings.TrimSpace(rawSlotName) if slotName == "" { return nil, fmt.Errorf("%s must not be empty", keyName) @@ -1236,20 +1274,29 @@ func sortedReferenceBindingKeys(values map[string]ReferenceBinding) []string { return keys } -func resolveBinding(binding ModuleBinding, defaultModule string) ModuleBinding { - module := strings.TrimSpace(binding.Module) - if module == "" { - module = defaultModule - } +func resolveBinding(binding ModuleBinding, defaultModule string, referenceSlotLabel string) (ModuleBinding, error) { + module := resolveModuleKey(binding.Module, defaultModule) llmProfile := strings.TrimSpace(binding.LLMProfile) + references, err := normalizeReferenceMap(binding.References, referenceSlotLabel) + if err != nil { + return ModuleBinding{}, err + } return ModuleBinding{ Module: module, LLMProfile: llmProfile, Retries: binding.Retries, Options: cloneOptions(binding.Options), - References: normalizeReferenceMap(binding.References), + References: references, Validators: cloneValidatorOverride(binding.Validators), + }, nil +} + +func resolveModuleKey(module string, defaultModule string) string { + module = strings.TrimSpace(module) + if module == "" { + return defaultModule } + return module } func applyEffectiveLLMProfiles(resolved *ResolvedPipeline, pipelineProfile, overrideProfile string) error { @@ -1312,17 +1359,20 @@ func applyEffectiveLLMProfiles(resolved *ResolvedPipeline, pipelineProfile, over return nil } -func resolveBindings(bindings []ModuleBinding, defaultModule string) []ModuleBinding { +func resolveBindings(bindings []ModuleBinding, defaultModule string, referenceSlotLabel string) ([]ModuleBinding, error) { if len(bindings) == 0 { - return nil + return nil, nil } resolved := make([]ModuleBinding, 0, len(bindings)) - for _, binding := range bindings { - resolvedBinding := resolveBinding(binding, defaultModule) + for index, binding := range bindings { + resolvedBinding, err := resolveBinding(binding, defaultModule, fmt.Sprintf("%s %d", referenceSlotLabel, index)) + if err != nil { + return nil, err + } resolved = append(resolved, resolvedBinding) } - return resolved + return resolved, nil } func cloneOptions(options map[string]any) map[string]any { @@ -1358,25 +1408,6 @@ func cloneOptionValue(value any) any { } } -func normalizeReferenceMap(values map[string]ReferenceSource) map[string]ReferenceSource { - if len(values) == 0 { - return nil - } - out := make(map[string]ReferenceSource, len(values)) - keys := make([]string, 0, len(values)) - rawByNormalized := make(map[string]string, len(values)) - for rawKey := range values { - key := strings.TrimSpace(rawKey) - rawByNormalized[key] = rawKey - keys = append(keys, key) - } - sort.Strings(keys) - for _, key := range keys { - out[key] = cloneReferenceSource(values[rawByNormalized[key]]) - } - return out -} - func selectedArtifactLanes(pipelineID string, artifacts map[string]ArtifactLaneProfile, options ResolveOptions) (map[string]ArtifactLaneProfile, []string, error) { lanesByID := make(map[string]ArtifactLaneProfile, len(artifacts)) for rawLaneID, lane := range artifacts { diff --git a/internal/framework/pipeline/profile_test.go b/internal/framework/pipeline/profile_test.go index d948b1aa..8b8f7ea2 100644 --- a/internal/framework/pipeline/profile_test.go +++ b/internal/framework/pipeline/profile_test.go @@ -159,6 +159,85 @@ func TestResolvePipelineAppliesDefaults(t *testing.T) { } } +func TestResolvePipelineRejectsReferenceSlotCollisionsAfterTrimming(t *testing.T) { + for _, tt := range []struct { + name string + configure func(*PipelineProfile) + want []string + }{ + { + name: "chunk", + configure: func(profile *PipelineProfile) { + profile.Chunk = ModuleBinding{Module: DefaultChunkModule, References: map[string]ReferenceSource{" rules ": ExternalReference("rules.md"), "rules": ExternalReference("other.md")}} + }, + want: []string{`pipeline "references"`, "chunk", `reference slot "rules" is duplicated after trimming`}, + }, + { + name: "extract", + configure: func(profile *PipelineProfile) { + lane := profile.Artifacts["lane"] + lane.Extract.References = map[string]ReferenceSource{" rules ": ExternalReference("rules.md"), "rules": ExternalReference("other.md")} + profile.Artifacts["lane"] = lane + }, + want: []string{`pipeline "references"`, `step "default"`, `lane "lane"`, "extract", `reference slot "rules" is duplicated after trimming`}, + }, + { + name: "merge", + configure: func(profile *PipelineProfile) { + lane := profile.Artifacts["lane"] + lane.Merge = ModuleBinding{Module: DefaultMergeModule, References: map[string]ReferenceSource{" rules ": ExternalReference("rules.md"), "rules": ExternalReference("other.md")}} + profile.Artifacts["lane"] = lane + }, + want: []string{`pipeline "references"`, `step "default"`, `lane "lane"`, "merge", `reference slot "rules" is duplicated after trimming`}, + }, + { + name: "normalize", + configure: func(profile *PipelineProfile) { + lane := profile.Artifacts["lane"] + lane.Normalize = ModuleBinding{Module: DefaultNormalizeModule, References: map[string]ReferenceSource{" rules ": ExternalReference("rules.md"), "rules": ExternalReference("other.md")}} + profile.Artifacts["lane"] = lane + }, + want: []string{`pipeline "references"`, `step "default"`, `lane "lane"`, "normalize", `reference slot "rules" is duplicated after trimming`}, + }, + } { + t.Run(tt.name, func(t *testing.T) { + profile := PipelineProfile{ + ID: "references", + Input: Binding("text"), + Artifacts: map[string]ArtifactLaneProfile{ + "lane": {Extract: Binding("event-extractor")}, + }, + } + tt.configure(&profile) + _, err := ResolvePipeline(profile, ResolveOptions{}, newProfileCatalog(t)) + if err == nil { + t.Fatal("ResolvePipeline() error = nil, want normalized reference collision") + } + for _, fragment := range tt.want { + if !strings.Contains(err.Error(), fragment) { + t.Fatalf("ResolvePipeline() error = %q, want context %q", err, fragment) + } + } + }) + } +} + +func TestResolvePipelineRejectsEmptyReferenceSlotAfterTrimming(t *testing.T) { + _, err := ResolvePipeline(PipelineProfile{ + ID: "references", + Input: Binding("text"), + Chunk: ModuleBinding{Module: DefaultChunkModule, References: map[string]ReferenceSource{ + " \t ": ExternalReference("rules.md"), + }}, + Artifacts: map[string]ArtifactLaneProfile{ + "lane": {Extract: Binding("event-extractor")}, + }, + }, ResolveOptions{}, newProfileCatalog(t)) + if err == nil || !strings.Contains(err.Error(), `pipeline "references" chunk reference slot must not be empty`) { + t.Fatalf("ResolvePipeline() error = %v, want contextual empty reference-slot rejection", err) + } +} + func TestResolvePipelineAppliesEffectiveLLMProfiles(t *testing.T) { for _, test := range []struct { name string diff --git a/internal/framework/pipeline/validator_chain_registry.go b/internal/framework/pipeline/validator_chain_registry.go index 56191752..735a46fb 100644 --- a/internal/framework/pipeline/validator_chain_registry.go +++ b/internal/framework/pipeline/validator_chain_registry.go @@ -71,7 +71,11 @@ func normalizeValidatorChainMapping(mapping ValidatorChainMapping) (ValidatorCha if strings.TrimSpace(validator.Module) == "" { return ValidatorChainMapping{}, fmt.Errorf("validator chain for %q %q has empty validator key at index %d", normalized.Stage, normalized.Module, i) } - normalized.Validators[i] = resolveBinding(validator, "") + resolved, err := resolveBinding(validator, "", fmt.Sprintf("validator chain for %q %q validator %d reference slot", normalized.Stage, normalized.Module, i)) + if err != nil { + return ValidatorChainMapping{}, err + } + normalized.Validators[i] = resolved } return normalized, nil }