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
@@ -4,6 +4,9 @@ import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.ExecutionPlanLockedEvent import com.correx.core.events.events.ExecutionPlanLockedEvent
import com.correx.core.events.events.ExecutionPlanRejectedEvent import com.correx.core.events.events.ExecutionPlanRejectedEvent
import com.correx.core.events.events.NewEvent 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.stores.EventStore
import com.correx.core.events.types.ArtifactId import com.correx.core.events.types.ArtifactId
import com.correx.core.events.types.EventId import com.correx.core.events.types.EventId
@@ -43,6 +46,19 @@ class FreestyleDriver(
emitRejected(sessionId, "plan failed to compile: ${it.message}", "compile") emitRejected(sessionId, "plan failed to compile: ${it.message}", "compile")
return 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) val approved = requestPlanApproval(sessionId, json)
if (!approved) { if (!approved) {
log.info("freestyle: execution plan rejected by operator for session={}", sessionId.value) log.info("freestyle: execution plan rejected by operator for session={}", sessionId.value)
@@ -83,6 +99,28 @@ class FreestyleDriver(
.getOrNull() .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) { private suspend fun emitRejected(sessionId: SessionId, reason: String, source: String) {
eventStore.append( eventStore.append(
NewEvent( NewEvent(
@@ -5,6 +5,7 @@ import com.correx.core.artifacts.kind.DefaultArtifactKindRegistry
import com.correx.core.artifacts.kind.JsonSchema import com.correx.core.artifacts.kind.JsonSchema
import com.correx.core.events.events.ExecutionPlanLockedEvent import com.correx.core.events.events.ExecutionPlanLockedEvent
import com.correx.core.events.events.ExecutionPlanRejectedEvent 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.events.types.SessionId
import com.correx.core.kernel.execution.WorkflowResult import com.correx.core.kernel.execution.WorkflowResult
import com.correx.core.kernel.orchestration.OrchestrationConfig import com.correx.core.kernel.orchestration.OrchestrationConfig
@@ -13,6 +14,7 @@ import com.correx.infrastructure.persistence.InMemoryEventStore
import com.correx.infrastructure.workflow.ExecutionPlanCompiler import com.correx.infrastructure.workflow.ExecutionPlanCompiler
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertEquals 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.Assertions.assertTrue
import org.junit.jupiter.api.Test import org.junit.jupiter.api.Test
@@ -54,6 +56,22 @@ class FreestyleDriverTest {
private val malformedPlanJson = """{ "not": "a valid plan" }""" 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 = private fun buildRegistry(): DefaultArtifactKindRegistry =
DefaultArtifactKindRegistry().also { DefaultArtifactKindRegistry().also {
it.register(ConfigArtifactKind(id = "patch", schema = JsonSchema(type = "object"), llmEmitted = true)) it.register(ConfigArtifactKind(id = "patch", schema = JsonSchema(type = "object"), llmEmitted = true))
@@ -205,6 +223,63 @@ class FreestyleDriverTest {
assertEquals("operator", rejectedEvents.single().source) 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 @Test
fun `operator approval at plan gate locks and runs phase2`(): Unit = runBlocking { fun `operator approval at plan gate locks and runs phase2`(): Unit = runBlocking {
val sessionId = SessionId("driver-operator-approve-session") 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.BriefEchoMismatchEvent
import com.correx.core.events.events.BriefGroundingCheckedEvent import com.correx.core.events.events.BriefGroundingCheckedEvent
import com.correx.core.events.events.StaticAnalysisCompletedEvent 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.ChatSessionStartedEvent
import com.correx.core.events.events.ClarificationAnsweredEvent import com.correx.core.events.events.ClarificationAnsweredEvent
import com.correx.core.events.events.ClarificationRequestedEvent import com.correx.core.events.events.ClarificationRequestedEvent
@@ -112,6 +113,7 @@ val eventModule = SerializersModule {
subclass(BriefGroundingCheckedEvent::class) subclass(BriefGroundingCheckedEvent::class)
subclass(BriefEchoMismatchEvent::class) subclass(BriefEchoMismatchEvent::class)
subclass(StaticAnalysisCompletedEvent::class) subclass(StaticAnalysisCompletedEvent::class)
subclass(PlanLintCompletedEvent::class)
subclass(RiskAssessedEvent::class) subclass(RiskAssessedEvent::class)
subclass(ChatSessionStartedEvent::class) subclass(ChatSessionStartedEvent::class)
subclass(ChatTurnEvent::class) subclass(ChatTurnEvent::class)
@@ -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)
}
}
@@ -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)
}
}