feat(workflow): deterministic plan lint (plan-pipeline §5, Slice 1)
First slice of the plan-generation pipeline epic. A pure-Kotlin PlanLinter runs over the compiled freestyle ExecutionPlan (WorkflowGraph) before it is locked, complementing ExecutionPlanCompiler (which throws on unreachable/unknown-kind/bad-edge) with the checks it does NOT make: - HARD unproduced_need — a stage needs an artifact no stage produces. Real gap: only the TOML loader checked this; the freestyle compiler did not, so such plans compiled and failed at runtime. - HARD trap_state — a stage with no path to the terminal (inescapable loop / dead end). Uses terminal-reachability, NOT "any cycle", so legitimate verdict-gated review loops pass. - SOFT stage_count / fan_out / empty_brief / duplicate_brief — scored, never blocking. FreestyleDriver lints after compile and records PlanLintCompletedEvent (pure function of the recorded plan → replay-safe, no env observation in v1); a hard failure rejects the plan (ExecutionPlanRejectedEvent source="lint") before the operator is asked or the plan locks, so a broken plan never executes. Pays off now for single-plan freestyle; becomes the per-candidate filter when multi-candidate generation (Slice 2) lands. Deferred to later slices: file-path grounding + symbol resolution (needs an index), token-budget/ADR-bypass checks, and a dedicated :core:planning module.
This commit is contained in:
@@ -4,6 +4,9 @@ import com.correx.core.events.events.EventMetadata
|
||||
import com.correx.core.events.events.ExecutionPlanLockedEvent
|
||||
import com.correx.core.events.events.ExecutionPlanRejectedEvent
|
||||
import com.correx.core.events.events.NewEvent
|
||||
import com.correx.core.events.events.PlanLintCompletedEvent
|
||||
import com.correx.infrastructure.workflow.PlanLintResult
|
||||
import com.correx.infrastructure.workflow.PlanLinter
|
||||
import com.correx.core.events.stores.EventStore
|
||||
import com.correx.core.events.types.ArtifactId
|
||||
import com.correx.core.events.types.EventId
|
||||
@@ -43,6 +46,19 @@ class FreestyleDriver(
|
||||
emitRejected(sessionId, "plan failed to compile: ${it.message}", "compile")
|
||||
return
|
||||
}
|
||||
// Deterministic lint (plan-pipeline-spec §5) before the plan is surfaced/locked: a hard
|
||||
// failure (unproduced need, trap state) means the plan would fail at runtime, so reject it
|
||||
// now rather than execute a broken plan. Soft findings are recorded for display only.
|
||||
val lint = PlanLinter.lint(graph)
|
||||
emitPlanLint(sessionId, graph.id, lint)
|
||||
if (!lint.passed) {
|
||||
val summary = lint.hardFailures.joinToString("; ") {
|
||||
"${it.code}${it.stageId?.let { s -> " @$s" } ?: ""}: ${it.detail}"
|
||||
}
|
||||
log.warn("freestyle: plan failed lint for session={}: {}", sessionId.value, summary)
|
||||
emitRejected(sessionId, "plan failed lint: $summary", "lint")
|
||||
return
|
||||
}
|
||||
val approved = requestPlanApproval(sessionId, json)
|
||||
if (!approved) {
|
||||
log.info("freestyle: execution plan rejected by operator for session={}", sessionId.value)
|
||||
@@ -83,6 +99,28 @@ class FreestyleDriver(
|
||||
.getOrNull()
|
||||
}
|
||||
|
||||
private suspend fun emitPlanLint(sessionId: SessionId, candidateId: String, lint: PlanLintResult) {
|
||||
eventStore.append(
|
||||
NewEvent(
|
||||
metadata = EventMetadata(
|
||||
eventId = EventId(UUID.randomUUID().toString()),
|
||||
sessionId = sessionId,
|
||||
timestamp = Clock.System.now(),
|
||||
schemaVersion = 1,
|
||||
causationId = null,
|
||||
correlationId = null,
|
||||
),
|
||||
payload = PlanLintCompletedEvent(
|
||||
sessionId = sessionId,
|
||||
candidateId = candidateId,
|
||||
hardFailures = lint.hardFailures,
|
||||
softFindings = lint.softFindings,
|
||||
score = lint.score,
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun emitRejected(sessionId: SessionId, reason: String, source: String) {
|
||||
eventStore.append(
|
||||
NewEvent(
|
||||
|
||||
@@ -5,6 +5,7 @@ import com.correx.core.artifacts.kind.DefaultArtifactKindRegistry
|
||||
import com.correx.core.artifacts.kind.JsonSchema
|
||||
import com.correx.core.events.events.ExecutionPlanLockedEvent
|
||||
import com.correx.core.events.events.ExecutionPlanRejectedEvent
|
||||
import com.correx.core.events.events.PlanLintCompletedEvent
|
||||
import com.correx.core.events.types.SessionId
|
||||
import com.correx.core.kernel.execution.WorkflowResult
|
||||
import com.correx.core.kernel.orchestration.OrchestrationConfig
|
||||
@@ -13,6 +14,7 @@ import com.correx.infrastructure.persistence.InMemoryEventStore
|
||||
import com.correx.infrastructure.workflow.ExecutionPlanCompiler
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.jupiter.api.Assertions.assertEquals
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
|
||||
@@ -54,6 +56,22 @@ class FreestyleDriverTest {
|
||||
|
||||
private val malformedPlanJson = """{ "not": "a valid plan" }"""
|
||||
|
||||
// Compiles cleanly, but "apply" needs "ghost", which no stage produces — a lint hard failure
|
||||
// (the compiler does not check needs satisfaction).
|
||||
private val lintFailingPlanJson = """
|
||||
{
|
||||
"goal": "broken plan",
|
||||
"stages": [
|
||||
{ "id": "analyse", "prompt": "Analyse", "produces": "patch", "needs": [], "tools": [] },
|
||||
{ "id": "apply", "prompt": "Apply", "produces": "patch", "needs": ["ghost"], "tools": [] }
|
||||
],
|
||||
"edges": [
|
||||
{ "from": "analyse", "to": "apply", "condition": { "type": "always_true" } },
|
||||
{ "from": "apply", "to": "done", "condition": { "type": "always_true" } }
|
||||
]
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
private fun buildRegistry(): DefaultArtifactKindRegistry =
|
||||
DefaultArtifactKindRegistry().also {
|
||||
it.register(ConfigArtifactKind(id = "patch", schema = JsonSchema(type = "object"), llmEmitted = true))
|
||||
@@ -205,6 +223,63 @@ class FreestyleDriverTest {
|
||||
assertEquals("operator", rejectedEvents.single().source)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `plan failing lint is rejected with source lint and never locks or runs phase2`(): Unit = runBlocking {
|
||||
val sessionId = SessionId("driver-lint-fail-session")
|
||||
val eventStore = InMemoryEventStore()
|
||||
val compiler = ExecutionPlanCompiler(buildRegistry())
|
||||
|
||||
var runPhase2Invocations = 0
|
||||
var approvalRequested = false
|
||||
|
||||
val driver = FreestyleDriver(
|
||||
eventStore = eventStore,
|
||||
compiler = compiler,
|
||||
planContent = { lintFailingPlanJson },
|
||||
config = OrchestrationConfig(),
|
||||
runPhase2 = { _, _, _ ->
|
||||
runPhase2Invocations++
|
||||
WorkflowResult.Completed(sessionId, com.correx.core.events.types.StageId("done"))
|
||||
},
|
||||
requestPlanApproval = { _, _ -> approvalRequested = true; true },
|
||||
)
|
||||
|
||||
driver.lockAndRun(sessionId)
|
||||
|
||||
val payloads = eventStore.read(sessionId).map { it.payload }
|
||||
assertTrue(payloads.filterIsInstance<ExecutionPlanLockedEvent>().isEmpty(), "broken plan must not lock")
|
||||
assertEquals(0, runPhase2Invocations, "runPhase2 must not run on a lint failure")
|
||||
assertFalse(approvalRequested, "operator must not be asked to approve a plan that already failed lint")
|
||||
|
||||
val lint = payloads.filterIsInstance<PlanLintCompletedEvent>().single()
|
||||
assertTrue(lint.hardFailures.any { it.code == "unproduced_need" }, "expected unproduced_need: ${lint.hardFailures}")
|
||||
|
||||
val rejected = payloads.filterIsInstance<ExecutionPlanRejectedEvent>().single()
|
||||
assertEquals("lint", rejected.source)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a clean plan records a passing PlanLintCompletedEvent before locking`(): Unit = runBlocking {
|
||||
val sessionId = SessionId("driver-lint-clean-session")
|
||||
val eventStore = InMemoryEventStore()
|
||||
val compiler = ExecutionPlanCompiler(buildRegistry())
|
||||
|
||||
val driver = FreestyleDriver(
|
||||
eventStore = eventStore,
|
||||
compiler = compiler,
|
||||
planContent = { validPlanJson },
|
||||
config = OrchestrationConfig(),
|
||||
runPhase2 = { sid, graph, _ -> WorkflowResult.Completed(sid, graph.start) },
|
||||
)
|
||||
|
||||
driver.lockAndRun(sessionId)
|
||||
|
||||
val payloads = eventStore.read(sessionId).map { it.payload }
|
||||
val lint = payloads.filterIsInstance<PlanLintCompletedEvent>().single()
|
||||
assertTrue(lint.hardFailures.isEmpty(), "clean plan should have no hard failures: ${lint.hardFailures}")
|
||||
assertEquals(1, payloads.filterIsInstance<ExecutionPlanLockedEvent>().size, "clean plan should lock")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `operator approval at plan gate locks and runs phase2`(): Unit = runBlocking {
|
||||
val sessionId = SessionId("driver-operator-approve-session")
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.correx.core.events.events
|
||||
|
||||
import com.correx.core.events.types.SessionId
|
||||
import kotlinx.serialization.SerialName
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
/**
|
||||
* One deterministic plan-lint finding. [code] is a stable machine code (e.g. "unproduced_need",
|
||||
* "trap_state", "fan_out"); [stageId] is the offending stage, or null for a plan-level finding.
|
||||
*/
|
||||
@Serializable
|
||||
data class PlanLintFinding(
|
||||
val code: String,
|
||||
val stageId: String?,
|
||||
val detail: String,
|
||||
)
|
||||
|
||||
/**
|
||||
* The deterministic lint of a compiled ExecutionPlan candidate (plan-pipeline-spec §5). Pure: a
|
||||
* function of the recorded plan artifact alone (no environment observation in v1 — graph-structural
|
||||
* checks only), so it is recomputable on replay without re-reading anything. Recorded so the operator
|
||||
* and downstream selection see why a candidate was discarded or down-ranked.
|
||||
*
|
||||
* A non-empty [hardFailures] means the candidate is unfit and is rejected (it must not be locked/run);
|
||||
* [softFindings] are scored, not blocking. [score] is the weighted soft-finding penalty (lower is
|
||||
* better) used by the future tournament ranking (§9).
|
||||
*/
|
||||
@Serializable
|
||||
@SerialName("PlanLintCompleted")
|
||||
data class PlanLintCompletedEvent(
|
||||
val sessionId: SessionId,
|
||||
val candidateId: String,
|
||||
val hardFailures: List<PlanLintFinding>,
|
||||
val softFindings: List<PlanLintFinding>,
|
||||
val score: Int,
|
||||
) : EventPayload
|
||||
@@ -11,6 +11,7 @@ import com.correx.core.events.events.ArtifactValidatingEvent
|
||||
import com.correx.core.events.events.BriefEchoMismatchEvent
|
||||
import com.correx.core.events.events.BriefGroundingCheckedEvent
|
||||
import com.correx.core.events.events.StaticAnalysisCompletedEvent
|
||||
import com.correx.core.events.events.PlanLintCompletedEvent
|
||||
import com.correx.core.events.events.ChatSessionStartedEvent
|
||||
import com.correx.core.events.events.ClarificationAnsweredEvent
|
||||
import com.correx.core.events.events.ClarificationRequestedEvent
|
||||
@@ -112,6 +113,7 @@ val eventModule = SerializersModule {
|
||||
subclass(BriefGroundingCheckedEvent::class)
|
||||
subclass(BriefEchoMismatchEvent::class)
|
||||
subclass(StaticAnalysisCompletedEvent::class)
|
||||
subclass(PlanLintCompletedEvent::class)
|
||||
subclass(RiskAssessedEvent::class)
|
||||
subclass(ChatSessionStartedEvent::class)
|
||||
subclass(ChatTurnEvent::class)
|
||||
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
package com.correx.core.events.serialization
|
||||
|
||||
import com.correx.core.events.events.EventPayload
|
||||
import com.correx.core.events.events.PlanLintCompletedEvent
|
||||
import com.correx.core.events.events.PlanLintFinding
|
||||
import com.correx.core.events.types.SessionId
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class PlanLintCompletedEventSerializationTest {
|
||||
|
||||
private val sample = PlanLintCompletedEvent(
|
||||
sessionId = SessionId("sess-1"),
|
||||
candidateId = "freestyle-sess-1",
|
||||
hardFailures = listOf(
|
||||
PlanLintFinding(code = "unproduced_need", stageId = "implement", detail = "needs 'design', produced by no stage"),
|
||||
PlanLintFinding(code = "trap_state", stageId = "loop", detail = "no path to 'done'"),
|
||||
),
|
||||
softFindings = listOf(
|
||||
PlanLintFinding(code = "fan_out", stageId = "analyze", detail = "5 outgoing edges"),
|
||||
),
|
||||
score = 3,
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `round-trips as polymorphic EventPayload`() {
|
||||
val encoded = eventJson.encodeToString(EventPayload.serializer(), sample)
|
||||
assertTrue(encoded.contains("\"type\":\"PlanLintCompleted\""), "SerialName must be present: $encoded")
|
||||
val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded)
|
||||
assertEquals(sample, decoded)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `decodes hand-written PlanLintCompleted JSON`() {
|
||||
val json = """
|
||||
{"type":"PlanLintCompleted","sessionId":"s","candidateId":"c1",
|
||||
"hardFailures":[{"code":"unproduced_need","stageId":"x","detail":"needs y"}],
|
||||
"softFindings":[],"score":0}
|
||||
""".trimIndent()
|
||||
val decoded = eventJson.decodeFromString(EventPayload.serializer(), json)
|
||||
assertTrue(decoded is PlanLintCompletedEvent)
|
||||
val event = decoded as PlanLintCompletedEvent
|
||||
assertEquals("unproduced_need", event.hardFailures.single().code)
|
||||
assertEquals("x", event.hardFailures.single().stageId)
|
||||
assertTrue(event.softFindings.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `plan-level finding allows null stageId`() {
|
||||
val withNull = sample.copy(
|
||||
hardFailures = listOf(PlanLintFinding(code = "trap_state", stageId = null, detail = "plan-level")),
|
||||
)
|
||||
val encoded = eventJson.encodeToString(EventPayload.serializer(), withNull)
|
||||
val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded)
|
||||
assertEquals(withNull, decoded)
|
||||
}
|
||||
}
|
||||
+140
@@ -0,0 +1,140 @@
|
||||
package com.correx.infrastructure.workflow
|
||||
|
||||
import com.correx.core.events.events.PlanLintFinding
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.transitions.graph.StageConfig
|
||||
import com.correx.core.transitions.graph.WorkflowGraph
|
||||
|
||||
/**
|
||||
* Result of linting a compiled ExecutionPlan. [passed] is false when any hard failure is present —
|
||||
* such a candidate is unfit and must not be locked/run. [score] is the weighted soft-finding penalty
|
||||
* (lower is better) used by the future tournament ranking (plan-pipeline-spec §9).
|
||||
*/
|
||||
data class PlanLintResult(
|
||||
val hardFailures: List<PlanLintFinding>,
|
||||
val softFindings: List<PlanLintFinding>,
|
||||
) {
|
||||
val passed: Boolean get() = hardFailures.isEmpty()
|
||||
val score: Int get() = softFindings.size
|
||||
}
|
||||
|
||||
/**
|
||||
* Deterministic, pure-Kotlin lint over a compiled plan graph (plan-pipeline-spec §5). Zero inference,
|
||||
* zero I/O — a function of the graph alone, so it is replay-safe by construction. It complements
|
||||
* [ExecutionPlanCompiler] (which already throws on unreachable-from-start / unknown-kind / bad-edge):
|
||||
* the lint adds the checks the compiler does NOT make.
|
||||
*
|
||||
* v1 is graph-structural only. File-path grounding and symbol resolution are deferred (symbols need a
|
||||
* real index; file grounding overlaps the analyst-brief grounding step).
|
||||
*/
|
||||
object PlanLinter {
|
||||
|
||||
private const val STAGE_CEILING = 12
|
||||
private const val FAN_OUT_THRESHOLD = 4
|
||||
|
||||
fun lint(graph: WorkflowGraph): PlanLintResult =
|
||||
PlanLintResult(
|
||||
hardFailures = unproducedNeeds(graph) + trapStates(graph),
|
||||
softFindings = stageCount(graph) + fanOut(graph) + emptyBriefs(graph) + duplicateBriefs(graph),
|
||||
)
|
||||
|
||||
/** H1: a stage `needs` an artifact that no stage `produces`. */
|
||||
private fun unproducedNeeds(graph: WorkflowGraph): List<PlanLintFinding> {
|
||||
val produced = graph.stages.values.flatMap { it.produces }.map { it.name.value }.toSet()
|
||||
return graph.stages.entries.flatMap { (id, stage) ->
|
||||
stage.needs.map { it.value }.filterNot { it in produced }.map { need ->
|
||||
PlanLintFinding(
|
||||
code = "unproduced_need",
|
||||
stageId = id.value,
|
||||
detail = "needs artifact '$need', which no stage produces",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* H2: a stage with no path to the terminal (`done` / any edge target not in `stages`). Subsumes
|
||||
* inescapable cycles and dead-end sinks — only "no exit" fails, so legitimate verdict-gated review
|
||||
* loops (which DO have an exit edge) pass.
|
||||
*/
|
||||
private fun trapStates(graph: WorkflowGraph): List<PlanLintFinding> {
|
||||
val outTargets: Map<StageId, List<StageId>> =
|
||||
graph.transitions.groupBy({ it.from }, { it.to })
|
||||
val escapable = mutableSetOf<StageId>()
|
||||
var changed = true
|
||||
while (changed) {
|
||||
changed = false
|
||||
for (id in graph.stages.keys) {
|
||||
if (id in escapable) continue
|
||||
val targets = outTargets[id].orEmpty()
|
||||
if (targets.any { it !in graph.stages || it in escapable }) {
|
||||
escapable.add(id)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
}
|
||||
return (graph.stages.keys - escapable).map { id ->
|
||||
PlanLintFinding(
|
||||
code = "trap_state",
|
||||
stageId = id.value,
|
||||
detail = "no path to the terminal — an inescapable loop or dead-end stage",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** S1: more stages than the ceiling. */
|
||||
private fun stageCount(graph: WorkflowGraph): List<PlanLintFinding> =
|
||||
if (graph.stages.size > STAGE_CEILING) {
|
||||
listOf(
|
||||
PlanLintFinding(
|
||||
code = "stage_count",
|
||||
stageId = null,
|
||||
detail = "${graph.stages.size} stages exceeds the ceiling of $STAGE_CEILING",
|
||||
),
|
||||
)
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
/** S2: a stage with more outgoing edges than the fan-out threshold. */
|
||||
private fun fanOut(graph: WorkflowGraph): List<PlanLintFinding> {
|
||||
val outDegree = graph.transitions.groupingBy { it.from }.eachCount()
|
||||
return graph.stages.keys.mapNotNull { id ->
|
||||
val degree = outDegree[id] ?: 0
|
||||
if (degree > FAN_OUT_THRESHOLD) {
|
||||
PlanLintFinding(
|
||||
code = "fan_out",
|
||||
stageId = id.value,
|
||||
detail = "$degree outgoing edges exceeds the threshold of $FAN_OUT_THRESHOLD",
|
||||
)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** S3: a stage with a blank inline brief. */
|
||||
private fun emptyBriefs(graph: WorkflowGraph): List<PlanLintFinding> =
|
||||
graph.stages.entries.filter { (_, stage) -> briefOf(stage).isBlank() }.map { (id, _) ->
|
||||
PlanLintFinding(code = "empty_brief", stageId = id.value, detail = "stage has an empty inline brief")
|
||||
}
|
||||
|
||||
/** S4: two or more stages sharing an identical normalized brief. */
|
||||
private fun duplicateBriefs(graph: WorkflowGraph): List<PlanLintFinding> =
|
||||
graph.stages.entries
|
||||
.filterNot { (_, stage) -> briefOf(stage).isBlank() }
|
||||
.groupBy({ (_, stage) -> normalize(briefOf(stage)) }, { (id, _) -> id.value })
|
||||
.values
|
||||
.filter { it.size > 1 }
|
||||
.map { ids ->
|
||||
PlanLintFinding(
|
||||
code = "duplicate_brief",
|
||||
stageId = null,
|
||||
detail = "stages share an identical brief: ${ids.sorted().joinToString(", ")}",
|
||||
)
|
||||
}
|
||||
|
||||
private fun briefOf(stage: StageConfig): String = stage.metadata["promptInline"] ?: ""
|
||||
|
||||
private fun normalize(text: String): String = text.trim().lowercase().replace(Regex("\\s+"), " ")
|
||||
}
|
||||
+145
@@ -0,0 +1,145 @@
|
||||
package com.correx.infrastructure.workflow
|
||||
|
||||
import com.correx.core.artifacts.kind.ConfigArtifactKind
|
||||
import com.correx.core.artifacts.kind.JsonSchema
|
||||
import com.correx.core.artifacts.kind.TypedArtifactSlot
|
||||
import com.correx.core.events.types.ArtifactId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.events.types.TransitionId
|
||||
import com.correx.core.transitions.conditions.AlwaysTrue
|
||||
import com.correx.core.transitions.graph.StageConfig
|
||||
import com.correx.core.transitions.graph.TransitionEdge
|
||||
import com.correx.core.transitions.graph.WorkflowGraph
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class PlanLinterTest {
|
||||
|
||||
private fun kind(id: String) =
|
||||
ConfigArtifactKind(id, JsonSchema(type = "object", properties = emptyMap(), additionalProperties = true))
|
||||
|
||||
private fun stage(
|
||||
produces: List<String> = emptyList(),
|
||||
needs: List<String> = emptyList(),
|
||||
prompt: String = "do the thing",
|
||||
) = StageConfig(
|
||||
produces = produces.map { TypedArtifactSlot(ArtifactId(it), kind(it)) },
|
||||
needs = needs.map { ArtifactId(it) }.toSet(),
|
||||
metadata = mapOf("promptInline" to prompt),
|
||||
)
|
||||
|
||||
private fun graph(
|
||||
stages: Map<String, StageConfig>,
|
||||
edges: List<Pair<String, String>>,
|
||||
start: String,
|
||||
) = WorkflowGraph(
|
||||
id = "test",
|
||||
stages = stages.mapKeys { StageId(it.key) },
|
||||
transitions = edges.mapIndexed { i, (f, t) ->
|
||||
TransitionEdge(TransitionId("e$i"), StageId(f), StageId(t), AlwaysTrue)
|
||||
}.toSet(),
|
||||
start = StageId(start),
|
||||
)
|
||||
|
||||
private fun codes(findings: List<com.correx.core.events.events.PlanLintFinding>) = findings.map { it.code }.toSet()
|
||||
|
||||
@Test
|
||||
fun `a clean linear plan passes with no findings`() {
|
||||
val g = graph(
|
||||
stages = mapOf(
|
||||
"a" to stage(produces = listOf("x"), prompt = "analyze"),
|
||||
"b" to stage(produces = listOf("y"), needs = listOf("x"), prompt = "build"),
|
||||
),
|
||||
edges = listOf("a" to "b", "b" to "done"),
|
||||
start = "a",
|
||||
)
|
||||
val result = PlanLinter.lint(g)
|
||||
assertTrue(result.passed, "expected pass, hard=${result.hardFailures}")
|
||||
assertTrue(result.softFindings.isEmpty(), "expected no soft, got ${result.softFindings}")
|
||||
assertEquals(0, result.score)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a need produced by no stage is a hard failure`() {
|
||||
val g = graph(
|
||||
stages = mapOf(
|
||||
"a" to stage(produces = listOf("x"), prompt = "analyze"),
|
||||
"b" to stage(needs = listOf("x", "z"), prompt = "build"),
|
||||
),
|
||||
edges = listOf("a" to "b", "b" to "done"),
|
||||
start = "a",
|
||||
)
|
||||
val result = PlanLinter.lint(g)
|
||||
assertFalse(result.passed)
|
||||
val h = result.hardFailures.single { it.code == "unproduced_need" }
|
||||
assertEquals("b", h.stageId)
|
||||
assertTrue(h.detail.contains("z"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a cycle with no exit is a trap-state hard failure`() {
|
||||
val g = graph(
|
||||
stages = mapOf("a" to stage(), "b" to stage()),
|
||||
edges = listOf("a" to "b", "b" to "a"),
|
||||
start = "a",
|
||||
)
|
||||
val result = PlanLinter.lint(g)
|
||||
assertFalse(result.passed)
|
||||
assertEquals(setOf("a", "b"), result.hardFailures.filter { it.code == "trap_state" }.map { it.stageId }.toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a verdict-gated review loop with an exit edge is NOT a trap`() {
|
||||
// implement <-> review, review -> done. The legitimate freestyle loop must pass.
|
||||
val g = graph(
|
||||
stages = mapOf(
|
||||
"implement" to stage(produces = listOf("patch"), prompt = "implement"),
|
||||
"review" to stage(produces = listOf("report"), needs = listOf("patch"), prompt = "review"),
|
||||
),
|
||||
edges = listOf("implement" to "review", "review" to "implement", "review" to "done"),
|
||||
start = "implement",
|
||||
)
|
||||
val result = PlanLinter.lint(g)
|
||||
assertTrue(result.hardFailures.none { it.code == "trap_state" }, "loop with exit must not trap: ${result.hardFailures}")
|
||||
assertTrue(result.passed, "hard=${result.hardFailures}")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `excess fan-out is a soft finding`() {
|
||||
val g = graph(
|
||||
stages = mapOf(
|
||||
"a" to stage(prompt = "fork"),
|
||||
"b" to stage(prompt = "b"), "c" to stage(prompt = "c"),
|
||||
"d" to stage(prompt = "d"), "e" to stage(prompt = "e"),
|
||||
),
|
||||
edges = listOf("a" to "b", "a" to "c", "a" to "d", "a" to "e", "a" to "done",
|
||||
"b" to "done", "c" to "done", "d" to "done", "e" to "done"),
|
||||
start = "a",
|
||||
)
|
||||
val result = PlanLinter.lint(g)
|
||||
assertTrue(result.passed)
|
||||
val f = result.softFindings.single { it.code == "fan_out" }
|
||||
assertEquals("a", f.stageId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty and duplicate briefs are soft findings`() {
|
||||
val g = graph(
|
||||
stages = mapOf(
|
||||
"a" to stage(prompt = " "),
|
||||
"b" to stage(prompt = "same work"),
|
||||
"c" to stage(prompt = "same work"),
|
||||
),
|
||||
edges = listOf("a" to "b", "b" to "c", "c" to "done"),
|
||||
start = "a",
|
||||
)
|
||||
val result = PlanLinter.lint(g)
|
||||
assertTrue(result.passed)
|
||||
assertTrue(codes(result.softFindings).containsAll(setOf("empty_brief", "duplicate_brief")))
|
||||
val dup = result.softFindings.single { it.code == "duplicate_brief" }
|
||||
assertTrue(dup.detail.contains("b") && dup.detail.contains("c"))
|
||||
assertEquals(result.softFindings.size, result.score)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user