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:
2026-06-15 00:26:22 +04:00
parent 618f6e91c0
commit 68136e77d7
7 changed files with 494 additions and 0 deletions
@@ -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+"), " ")
}
@@ -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)
}
}