package pipeline import ( "context" "fmt" "reflect" "strings" "testing" "gitea.maximumdirect.net/eric/notarius/internal/core/source" "gitea.maximumdirect.net/eric/notarius/internal/framework/contracts" ) type warningChunker struct { key string plan source.ChunkPlan calls int } func (c *warningChunker) Key() string { return c.key } func (*warningChunker) ReferenceSlots() []contracts.ReferenceSlot { return nil } func (c *warningChunker) Plan(context.Context, contracts.ChunkRequest) (contracts.ChunkPlanResult, error) { c.calls++ return contracts.ChunkPlanResult{ Plan: source.CloneChunkPlan(c.plan), Warnings: []contracts.Warning{{Scope: fmt.Sprintf("operation-%d", c.calls), ReasonCode: "operation", Message: "operation warning"}}, }, nil } type chunkValidationFunc struct { name string validate func(context.Context, contracts.ChunkValidationRequest) (contracts.ValidationResult, error) } func (v chunkValidationFunc) Name() string { return v.name } func (chunkValidationFunc) ExecutionClass() contracts.ExecutionClass { return contracts.ExecutionClassDeterministic } func (v chunkValidationFunc) Validate(ctx context.Context, request contracts.ChunkValidationRequest) (contracts.ValidationResult, error) { return v.validate(ctx, request) } func TestRunnerPromotesOnlyTerminalRejectionWarnings(t *testing.T) { for _, target := range []ModuleStage{StageChunk, StageExtract, StageMerge, StageNormalize} { t.Run(string(target), func(t *testing.T) { prepared := preparedAttemptDebugPipeline(t) lane := &prepared.Steps[0].lanes[0] attempts := 0 first := func() contracts.ValidationResult { return contracts.ValidationResult{Approved: true, Warnings: []contracts.Warning{{Scope: fmt.Sprintf("validator-%d", attempts), ReasonCode: "validator", Message: "validator warning"}}} } reject := func() contracts.ValidationResult { return contracts.ValidationResult{Approved: false, ReasonCode: "rejected", Message: "rejected"} } debug := newCapturedDebugRecorder() recorder := &extractCaptureRecorder{CheckpointRecorder: NoopCheckpointRecorder()} switch target { case StageChunk: chunker := prepared.chunker.(*typedTestChunker) prepared.chunker = &warningChunker{key: prepared.resolved.Chunk.Module, plan: source.CloneChunkPlan(chunker.plan)} prepared.resolved.Chunk.Retries = 1 prepared.chunkValidators.validators = []preparedValidator{ {resolved: ResolvedValidator{Binding: Binding("warning-approval"), Target: ValidatorTargetChunk}, chunk: chunkValidationFunc{name: "warning-approval", validate: func(context.Context, contracts.ChunkValidationRequest) (contracts.ValidationResult, error) { return first(), nil }}}, {resolved: ResolvedValidator{Binding: Binding("warning-rejection"), Target: ValidatorTargetChunk}, chunk: chunkValidationFunc{name: "warning-rejection", validate: func(context.Context, contracts.ChunkValidationRequest) (contracts.ValidationResult, error) { return reject(), nil }}}, } chunkerWithWarnings := prepared.chunker.(*warningChunker) first = func() contracts.ValidationResult { return contracts.ValidationResult{Approved: true, Warnings: []contracts.Warning{{Scope: fmt.Sprintf("validator-%d", chunkerWithWarnings.calls), ReasonCode: "validator", Message: "validator warning"}}} } case StageExtract: lane.resolved.Extract.Retries = 1 installExtractOperation(prepared, 0, func(context.Context, contracts.TypedExtractionRequest) (erasedTypedResult, error) { attempts++ return erasedTypedResult{Value: codecNotes{Items: []string{"extract"}}, Warnings: []contracts.Warning{{Scope: fmt.Sprintf("operation-%d", attempts), ReasonCode: "operation", Message: "operation warning"}}}, nil }) lane.extractValidators.validators = rejectionWarningTypedValidators(first, reject) case StageMerge: lane.resolved.Merge.Retries = 1 lane.typed.merge = func(context.Context, any, contracts.TypedMergeRequest[any]) (erasedTypedResult, error) { attempts++ return erasedTypedResult{Value: codecNotes{Items: []string{"merge"}}, Warnings: []contracts.Warning{{Scope: fmt.Sprintf("operation-%d", attempts), ReasonCode: "operation", Message: "operation warning"}}}, nil } lane.mergeValidators.validators = rejectionWarningTypedValidators(first, reject) case StageNormalize: lane.resolved.Normalize.Retries = 1 lane.typed.normalize = func(context.Context, any, contracts.TypedNormalizeRequest[any]) (erasedTypedResult, error) { attempts++ return erasedTypedResult{Value: codecNotes{Items: []string{"normalize"}}, Warnings: []contracts.Warning{{Scope: fmt.Sprintf("operation-%d", attempts), ReasonCode: "operation", Message: "operation warning"}}}, nil } lane.normalizeValidators.validators = rejectionWarningTypedValidators(first, reject) } output, err := New().Run(context.Background(), RunInput{Prepared: prepared, RawInput: []byte("input"), Checkpoints: recorder, Debug: debug}) if err != nil { t.Fatalf("Run() error = %v, want nil", err) } wantScopes := []string{"operation-2", "validator-2"} if got := rejectionWarningScopes(output.Warnings); !reflect.DeepEqual(got, wantScopes) { t.Fatalf("published warning scopes = %#v, want %#v", got, wantScopes) } if len(output.Rejected) != 1 || output.Rejected[0].AttemptCount != 2 { t.Fatalf("rejections = %#v, want final rejection after two attempts", output.Rejected) } if target == StageExtract && !reflect.DeepEqual(rejectionWarningScopes(recorder.checkpoint.Warnings), wantScopes) { t.Fatalf("extract checkpoint warnings = %#v, want %#v", recorder.checkpoint.Warnings, wantScopes) } attemptPath := fmt.Sprintf("%s/notes/attempt-01.json", target) if target == StageChunk { attemptPath = "chunk/attempt-01.json" } else if target == StageExtract { attemptPath = "extract/notes/chunk-000001/attempt-01.json" } if !strings.Contains(string(debug.json[attemptPath]), "operation-1") || !strings.Contains(string(debug.json[attemptPath]), "validator-1") { t.Fatalf("first attempt debug = %s, want discarded warnings", debug.json[attemptPath]) } }) } } func rejectionWarningTypedValidators(first func() contracts.ValidationResult, reject func() contracts.ValidationResult) []preparedValidator { return []preparedValidator{ {resolved: ResolvedValidator{Binding: Binding("warning-approval"), Target: ValidatorTargetTyped, ArtifactKind: "test/notes"}, typedValidate: func(context.Context, any, typedValidationTarget) (contracts.ValidationResult, error) { return first(), nil }}, {resolved: ResolvedValidator{Binding: Binding("warning-rejection"), Target: ValidatorTargetTyped, ArtifactKind: "test/notes"}, typedValidate: func(context.Context, any, typedValidationTarget) (contracts.ValidationResult, error) { return reject(), nil }}, } } func rejectionWarningScopes(warnings []contracts.Warning) []string { scopes := make([]string, len(warnings)) for index := range warnings { scopes[index] = warnings[index].Scope } return scopes }