import com.correx.core.events.types.StageId import com.correx.core.events.types.TransitionId import com.correx.core.transitions.graph.StageConfig import com.correx.core.transitions.graph.TransitionEdge import com.correx.core.transitions.graph.WorkflowGraph import com.correx.core.transitions.resolution.TransitionOrdering import com.correx.core.validation.approval.ApprovalTrigger import com.correx.core.validation.graph.GraphValidator import com.correx.core.validation.model.ValidationContext import com.correx.core.validation.pipeline.ValidationOutcome import com.correx.core.validation.pipeline.ValidationPipeline import com.correx.core.validation.semantic.SemanticValidator import com.correx.core.validation.semantic.rules.CycleExitRule import com.correx.core.validation.semantic.rules.MissingToolRule import com.correx.core.validation.transition.TransitionValidator import com.correx.testing.fixtures.CycleFixtures import com.correx.testing.fixtures.WorkflowFixtures import kotlinx.coroutines.runBlocking import org.junit.jupiter.api.Assertions.assertFalse import org.junit.jupiter.api.Assertions.assertInstanceOf import org.junit.jupiter.api.Assertions.assertEquals import org.junit.jupiter.api.Test class ValidationPipelineIntegrationTest { private val pipeline = ValidationPipeline( validators = listOf( GraphValidator(), TransitionValidator(TransitionOrdering.comparator), SemanticValidator( rules = listOf( CycleExitRule(requirePolicyForCycles = true), ), ), ), approvalTrigger = ApprovalTrigger() ) @Test fun `full validation pipeline returns NeedsApproval when cycles are unbound`(): Unit = runBlocking { val graph = WorkflowFixtures.simpleGraph() val cycles = listOf(CycleFixtures.simpleCycle()) val context = ValidationContext( graph = graph, detectedCycles = cycles, cyclePolicies = emptySet(), ) val outcome = pipeline.validate(context) assertInstanceOf(ValidationOutcome.NeedsApproval::class.java, outcome) } @Test fun `rejected outcome retryable is false - set by validator, not orchestrator`(): Unit = runBlocking { // A dangling transition triggers GraphValidator ERROR → Rejected(retryable=false). // Ownership rule: the validator sets retryable; the orchestrator must only read it. val a = StageId("A") val ghost = StageId("GHOST") val graphWithDanglingTransition = WorkflowGraph( id = "workflow-test", stages = mapOf(a to StageConfig()), transitions = setOf(TransitionEdge(TransitionId("t1"), from = a, to = ghost) { true }), start = a, ) val pipeline = ValidationPipeline(validators = listOf(GraphValidator()), approvalTrigger = null) val outcome = pipeline.validate(ValidationContext(graph = graphWithDanglingTransition)) assertInstanceOf(ValidationOutcome.Rejected::class.java, outcome) assertFalse((outcome as ValidationOutcome.Rejected).retryable) } @Test fun `SemanticValidator with requirePolicyForCycles = false ignores unbound cycles`(): Unit = runBlocking { // Verify that when requirePolicyForCycles is false, SemanticValidator returns no issues // even if there are unbound cycles. This is the production default behavior. val graph = WorkflowFixtures.simpleGraph() val cycles = listOf(CycleFixtures.simpleCycle()) val pipelineWithFalseFlagValidator = ValidationPipeline( validators = listOf( SemanticValidator( rules = listOf( CycleExitRule(requirePolicyForCycles = false), ), ), ), approvalTrigger = null ) val context = ValidationContext( graph = graph, detectedCycles = cycles, cyclePolicies = emptySet(), ) val outcome = pipelineWithFalseFlagValidator.validate(context) // With requirePolicyForCycles = false, the rule returns no issues, // so the outcome should be Passed (not NeedsApproval). assertInstanceOf(ValidationOutcome.Passed::class.java, outcome) } @Test fun `MissingToolRule detects unregistered tool in stage`(): Unit = runBlocking { // A stage allows tools "bash" and "ghost", but only "bash" is registered. // The rule should emit exactly one MISSING_TOOL WARNING for "ghost". val stageA = StageId("A") val graph = WorkflowGraph( id = "workflow-test", stages = mapOf(stageA to StageConfig(allowedTools = setOf("bash", "ghost"))), transitions = emptySet(), start = stageA, ) val validator = SemanticValidator(rules = listOf(MissingToolRule())) val context = ValidationContext( graph = graph, availableTools = setOf("bash"), ) val section = validator.validate(context) assertEquals(1, section.issues.size) val issue = section.issues.first() assertEquals("MISSING_TOOL", issue.code) assertEquals("Stage 'A' allows unregistered tool 'ghost'", issue.message) } @Test fun `MissingToolRule returns empty when all allowed tools are registered`(): Unit = runBlocking { val stageA = StageId("A") val graph = WorkflowGraph( id = "workflow-test", stages = mapOf(stageA to StageConfig(allowedTools = setOf("bash", "python"))), transitions = emptySet(), start = stageA, ) val validator = SemanticValidator(rules = listOf(MissingToolRule())) val context = ValidationContext( graph = graph, availableTools = setOf("bash", "python"), ) val section = validator.validate(context) assertEquals(0, section.issues.size) } @Test fun `MissingToolRule returns empty when availableTools is null`(): Unit = runBlocking { // When tool registry is not available (null), the rule should skip validation. val stageA = StageId("A") val graph = WorkflowGraph( id = "workflow-test", stages = mapOf(stageA to StageConfig(allowedTools = setOf("ghost"))), transitions = emptySet(), start = stageA, ) val validator = SemanticValidator(rules = listOf(MissingToolRule())) val context = ValidationContext( graph = graph, availableTools = null, ) val section = validator.validate(context) assertEquals(0, section.issues.size) } @Test fun `MissingToolRule detects missing tools across multiple stages`(): Unit = runBlocking { val stageA = StageId("A") val stageB = StageId("B") val graph = WorkflowGraph( id = "workflow-test", stages = mapOf( stageA to StageConfig(allowedTools = setOf("bash", "ghost")), stageB to StageConfig(allowedTools = setOf("phantom", "bash")), ), transitions = setOf(TransitionEdge(TransitionId("t1"), from = stageA, to = stageB) { true }), start = stageA, ) val validator = SemanticValidator(rules = listOf(MissingToolRule())) val context = ValidationContext( graph = graph, availableTools = setOf("bash"), ) val section = validator.validate(context) // Should emit two issues: one for "ghost" in stage A, one for "phantom" in stage B. assertEquals(2, section.issues.size) val codes = section.issues.map { it.code } assertEquals(listOf("MISSING_TOOL", "MISSING_TOOL"), codes) val messages = section.issues.map { it.message } assertEquals( setOf( "Stage 'A' allows unregistered tool 'ghost'", "Stage 'B' allows unregistered tool 'phantom'" ), messages.toSet() ) } }