feat(kernel): static-first reviewer gate (role-reliability §5)

Run operator-configured static-analysis commands (compiler/detekt/formatters) as a
deterministic harness step on the producing stage, before the LLM reviewer. A stage
declares `static_analysis = [commands]`; after it produces its artifact, each command
runs in the workspace root and the results are recorded in a StaticAnalysisCompletedEvent
(invariant #9 env observation — recorded live, folded on replay, never re-run). A
non-clean command fails the stage retryably with its output fed back verbatim (§2:
compiler/test output is ground truth), so the implementer fixes it and only static-clean
code ever reaches the reviewer. This makes §5's "reviewer context excludes static findings"
mechanical — the findings are resolved upstream, so the reviewer can't waste inference on
what detekt catches for free; no output-parsing, no context plumbing.

- StaticAnalysisCompletedEvent + StaticAnalysisFinding (core:events) + registration.
- StaticAnalysisRunner seam + ProcessStaticAnalysisRunner (whitespace-split argv, stderr
  merged, timeout→nonzero exit) in core:kernel; wired (nullable) into OrchestratorEngines
  and the server. Null runner / no workspace root → logged no-op, never blocks the run.
- StageConfig.staticAnalysis + `static_analysis` TOML field; runStaticAnalysis folded into
  a runPostStageGates sequence (produces → grounding → echo → static analysis).
- Documented `static_analysis` on the role_pipeline implementer stage (commented; commands
  are workspace-specific). The reviewer prompt half shipped in 3467826.
This commit is contained in:
2026-06-14 23:51:49 +04:00
parent 0d8df4a130
commit 365eb8a279
13 changed files with 354 additions and 9 deletions
@@ -0,0 +1,40 @@
package com.correx.core.events.events
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** The outcome of one operator-configured static-analysis command run against a stage's output. */
@Serializable
data class StaticAnalysisFinding(
val command: String,
val exitCode: Int,
/**
* Truncated tail of the command's combined stdout+stderr. The full output is fed back to the
* model verbatim at runtime but is not retained in the log — the tail carries the error summary
* and keeps the event bounded.
*/
val summary: String,
) {
val clean: Boolean get() = exitCode == 0
}
/**
* Environment observation (invariant #9): the static-analysis commands (compiler / detekt /
* formatters) a stage ran against its just-produced output, each with its exit code and a truncated
* output tail, captured at the moment the stage completed. Replay reads these recorded facts and
* never re-runs the commands.
*
* Realizes role-reliability §5 "static-first ordering": deterministic tools run before the LLM
* reviewer. A non-clean finding fails the producing stage retryably (the output is fed back so the
* implementer fixes it), so only static-clean output ever reaches the reviewer — its context thereby
* excludes anything static tools already catch, with no parsing or context plumbing.
*/
@Serializable
@SerialName("StaticAnalysisCompleted")
data class StaticAnalysisCompletedEvent(
val sessionId: SessionId,
val stageId: StageId,
val findings: List<StaticAnalysisFinding>,
) : EventPayload
@@ -10,6 +10,7 @@ import com.correx.core.events.events.ArtifactValidatedEvent
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.ChatSessionStartedEvent
import com.correx.core.events.events.ClarificationAnsweredEvent
import com.correx.core.events.events.ClarificationRequestedEvent
@@ -110,6 +111,7 @@ val eventModule = SerializersModule {
subclass(RepoKnowledgeRetrievedEvent::class)
subclass(BriefGroundingCheckedEvent::class)
subclass(BriefEchoMismatchEvent::class)
subclass(StaticAnalysisCompletedEvent::class)
subclass(RiskAssessedEvent::class)
subclass(ChatSessionStartedEvent::class)
subclass(ChatTurnEvent::class)
@@ -0,0 +1,51 @@
package com.correx.core.events.serialization
import com.correx.core.events.events.EventPayload
import com.correx.core.events.events.StaticAnalysisCompletedEvent
import com.correx.core.events.events.StaticAnalysisFinding
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class StaticAnalysisCompletedEventSerializationTest {
private val sample = StaticAnalysisCompletedEvent(
sessionId = SessionId("sess-1"),
stageId = StageId("implementer"),
findings = listOf(
StaticAnalysisFinding(command = "./gradlew compileKotlin", exitCode = 0, summary = "BUILD SUCCESSFUL"),
StaticAnalysisFinding(command = "./gradlew detekt", exitCode = 1, summary = "MagicNumber at Foo.kt:42"),
),
)
@Test
fun `round-trips as polymorphic EventPayload`() {
val encoded = eventJson.encodeToString(EventPayload.serializer(), sample)
assertTrue(encoded.contains("\"type\":\"StaticAnalysisCompleted\""), "SerialName must be present: $encoded")
val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded)
assertEquals(sample, decoded)
}
@Test
fun `decodes hand-written StaticAnalysisCompleted JSON`() {
val json = """
{"type":"StaticAnalysisCompleted","sessionId":"s","stageId":"implementer",
"findings":[{"command":"./gradlew detekt","exitCode":2,"summary":"3 violations"}]}
""".trimIndent()
val decoded = eventJson.decodeFromString(EventPayload.serializer(), json)
assertTrue(decoded is StaticAnalysisCompletedEvent)
val finding = (decoded as StaticAnalysisCompletedEvent).findings.single()
assertEquals("./gradlew detekt", finding.command)
assertEquals(2, finding.exitCode)
assertFalse(finding.clean)
}
@Test
fun `clean is derived from exit code zero`() {
assertTrue(StaticAnalysisFinding("c", 0, "").clean)
assertFalse(StaticAnalysisFinding("c", 1, "").clean)
}
}