e8372e0643
Flags when a workflow stage's allowedTools references a tool name that is not registered in the tool registry (config drift), surfaced as a non-blocking WARNING. - Add ValidationContext.availableTools (nullable; null = registry unavailable, skip the check) - MissingToolRule emits one MISSING_TOOL warning per (stage, tool) pair, deterministically ordered - Populate availableTools from the ToolRegistry at the orchestrator construction site; wire the rule into the prod SemanticValidator
208 lines
8.0 KiB
Kotlin
208 lines
8.0 KiB
Kotlin
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()
|
|
)
|
|
}
|
|
}
|