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