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:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user