From 68136e77d702388e2b00464950f7441b219a0d63 Mon Sep 17 00:00:00 2001 From: kami Date: Mon, 15 Jun 2026 00:26:22 +0400 Subject: [PATCH] =?UTF-8?q?feat(workflow):=20deterministic=20plan=20lint?= =?UTF-8?q?=20(plan-pipeline=20=C2=A75,=20Slice=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- .../apps/server/freestyle/FreestyleDriver.kt | 38 +++++ .../server/freestyle/FreestyleDriverTest.kt | 75 +++++++++ .../core/events/events/PlanLintEvents.kt | 36 +++++ .../events/serialization/Serialization.kt | 2 + ...PlanLintCompletedEventSerializationTest.kt | 58 +++++++ .../infrastructure/workflow/PlanLinter.kt | 140 +++++++++++++++++ .../infrastructure/workflow/PlanLinterTest.kt | 145 ++++++++++++++++++ 7 files changed, 494 insertions(+) create mode 100644 core/events/src/main/kotlin/com/correx/core/events/events/PlanLintEvents.kt create mode 100644 core/events/src/test/kotlin/com/correx/core/events/serialization/PlanLintCompletedEventSerializationTest.kt create mode 100644 infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/PlanLinter.kt create mode 100644 infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/PlanLinterTest.kt diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/freestyle/FreestyleDriver.kt b/apps/server/src/main/kotlin/com/correx/apps/server/freestyle/FreestyleDriver.kt index 09bfea91..a5483b99 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/freestyle/FreestyleDriver.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/freestyle/FreestyleDriver.kt @@ -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( diff --git a/apps/server/src/test/kotlin/com/correx/apps/server/freestyle/FreestyleDriverTest.kt b/apps/server/src/test/kotlin/com/correx/apps/server/freestyle/FreestyleDriverTest.kt index 58930a37..61056cd3 100644 --- a/apps/server/src/test/kotlin/com/correx/apps/server/freestyle/FreestyleDriverTest.kt +++ b/apps/server/src/test/kotlin/com/correx/apps/server/freestyle/FreestyleDriverTest.kt @@ -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().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().single() + assertTrue(lint.hardFailures.any { it.code == "unproduced_need" }, "expected unproduced_need: ${lint.hardFailures}") + + val rejected = payloads.filterIsInstance().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().single() + assertTrue(lint.hardFailures.isEmpty(), "clean plan should have no hard failures: ${lint.hardFailures}") + assertEquals(1, payloads.filterIsInstance().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") diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/PlanLintEvents.kt b/core/events/src/main/kotlin/com/correx/core/events/events/PlanLintEvents.kt new file mode 100644 index 00000000..0d056bd7 --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/events/events/PlanLintEvents.kt @@ -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, + val softFindings: List, + val score: Int, +) : EventPayload diff --git a/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt b/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt index d6847b7b..aa71d5a5 100644 --- a/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt +++ b/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt @@ -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) diff --git a/core/events/src/test/kotlin/com/correx/core/events/serialization/PlanLintCompletedEventSerializationTest.kt b/core/events/src/test/kotlin/com/correx/core/events/serialization/PlanLintCompletedEventSerializationTest.kt new file mode 100644 index 00000000..aaf3eef6 --- /dev/null +++ b/core/events/src/test/kotlin/com/correx/core/events/serialization/PlanLintCompletedEventSerializationTest.kt @@ -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) + } +} diff --git a/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/PlanLinter.kt b/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/PlanLinter.kt new file mode 100644 index 00000000..c554a724 --- /dev/null +++ b/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/PlanLinter.kt @@ -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, + val softFindings: List, +) { + 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 { + 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 { + val outTargets: Map> = + graph.transitions.groupBy({ it.from }, { it.to }) + val escapable = mutableSetOf() + 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 = + 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 { + 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 = + 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 = + 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+"), " ") +} diff --git a/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/PlanLinterTest.kt b/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/PlanLinterTest.kt new file mode 100644 index 00000000..9bcb8795 --- /dev/null +++ b/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/PlanLinterTest.kt @@ -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 = emptyList(), + needs: List = 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, + edges: List>, + 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) = 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) + } +}