kernel: deterministic static_check stage (§B-§5) + critique-outcome producer (§B-§6)

Two reviewer-reliability tracks, both deterministic and unit-verified (no model/network).

§B-§5 — static_check stage seam. The StaticAnalysisRunner + StaticFindingsRecordedEvent +
reviewer-context filter already existed; what was missing was a stage that runs the tool and
emits the event. Added:
- StaticCheckStageExecutor: reads stage metadata (static_tool/static_argv), runs the configured
  command via StaticAnalysisRunner, returns a StaticFindingsRecordedEvent. No-op (empty findings)
  when no runner is wired or no command is configured — safe to carry unconfigured.
- A deterministic-stage seam in DefaultSessionOrchestrator.enterStage: any stage with
  metadata["stage_type"] == "static_check" is run by the executor instead of the LLM subagent,
  then advances on its (unconditional) exit edge.
- TomlWorkflowLoader: stage_type/static_tool/static_argv fields → StageConfig.metadata.
- role_pipeline.toml: a static_check stage between implementer and reviewer (no-op until
  static_argv + a CommandRunner are set; activation is live-QA-gated, see StaticAnalysisRunner doc).

§B-§6 — critique-outcome producer. The CritiqueFinding type + CriticCalibrationProjection existed
but nothing fed them. Added:
- CritiqueFindingsRecordedEvent (+ CritiqueVerdict): the producing side — a critic's findings +
  verdict for one review iteration, carrying modelHash for per-model calibration.
- CritiqueOutcomeCorrelator: pure loop-resolution logic deciding UPHELD (fixed between rounds) /
  DISMISSED (persisted into an approved final) / INCONCLUSIVE (open at a non-approved terminal),
  per critic (role + modelHash) and per finding id.
- A hook in completeWorkflow/failWorkflow that correlates recorded findings into
  CritiqueOutcomeCorrelatedEvents at loop resolution — no-op when none recorded, idempotent.
  (LLM-side finding emission stays a separate model-gated activation.)

Tests: StaticCheckStageExecutorTest (4), StaticCheckStageTest integration (1, fake runner →
event + transition), CritiqueOutcomeCorrelatorTest (8), CritiqueCalibrationWiringTest integration
(1, seeded findings → outcomes at completion), updated RolePipelineWorkflowTest. Full suites for
core:events/kernel/critique, infrastructure:workflow, testing:integration green; detekt clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-22 21:14:00 +00:00
parent c2a1e6d76d
commit e46777e29f
14 changed files with 809 additions and 23 deletions
@@ -1,9 +1,38 @@
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
/** A critic's verdict for one review iteration. */
@Serializable
enum class CritiqueVerdict { APPROVED, CHANGES_REQUESTED }
/**
* The findings a critic (plan critic or code reviewer) raised in one review iteration, plus its
* [verdict] for that iteration. This is the **producing side** of critique calibration: the
* reviewer-loop runtime correlates the sequence of these across iterations into
* [CritiqueOutcomeCorrelatedEvent]s (see CritiqueOutcomeCorrelator), which the
* `:core:critique` projection then folds into per-(model, role) precision.
*
* [iteration] is the refinement-loop iteration (1-based) this review belongs to, so the
* correlator can order reviews and tell whether a finding was fixed between rounds. [modelHash]
* identifies the model that produced the findings (carried through to the outcome event so
* calibration is per-model).
*/
@Serializable
@SerialName("CritiqueFindingsRecorded")
data class CritiqueFindingsRecordedEvent(
val sessionId: SessionId,
val stageId: StageId,
val role: CritiqueRole,
val modelHash: String,
val iteration: Int,
val verdict: CritiqueVerdict,
val findings: List<CritiqueFinding>,
) : EventPayload
/**
* How a prior [CritiqueFinding] actually turned out once the surrounding loop resolved.
* UPHELD = the finding was confirmed/actioned; DISMISSED = rejected as a false positive;
@@ -15,6 +15,7 @@ import com.correx.core.events.events.ChatSessionStartedEvent
import com.correx.core.events.events.ClarificationAnsweredEvent
import com.correx.core.events.events.ClarificationRequestedEvent
import com.correx.core.events.events.ChatTurnEvent
import com.correx.core.events.events.CritiqueFindingsRecordedEvent
import com.correx.core.events.events.CritiqueOutcomeCorrelatedEvent
import com.correx.core.events.events.RouterNarrationEvent
import com.correx.core.events.events.OperatorProfileBoundEvent
@@ -142,6 +143,7 @@ val eventModule = SerializersModule {
subclass(IdeaDiscardedEvent::class)
subclass(IdeaPromotedEvent::class)
subclass(CritiqueOutcomeCorrelatedEvent::class)
subclass(CritiqueFindingsRecordedEvent::class)
subclass(StageCheckpointPassedEvent::class)
subclass(StageCheckpointFailedEvent::class)
subclass(PossibleContradictionFlaggedEvent::class)
@@ -0,0 +1,72 @@
package com.correx.core.kernel.orchestration
import com.correx.core.events.events.CritiqueFinding
import com.correx.core.events.events.CritiqueFindingsRecordedEvent
import com.correx.core.events.events.CritiqueOutcome
import com.correx.core.events.events.CritiqueOutcomeCorrelatedEvent
import com.correx.core.events.events.CritiqueVerdict
import com.correx.core.events.types.SessionId
/**
* Decides how each prior critique finding turned out across a review loop — the producer half of
* critique calibration (BACKLOG §B-§6 / pipeline-addenda A3). Pure over the recorded review
* iterations, so it is replay-deterministic and unit-testable; the orchestrator runs it once at
* loop resolution (see completeWorkflow / failWorkflow) and emits the results, which the
* `:core:critique` projection folds into per-(model, role) precision.
*
* Rules, per critic (grouped by role + modelHash) and per finding id:
* - **UPHELD** — the finding was raised in some iteration and is gone by the final review: the
* implementer fixed it between rounds, so the critic was right and the finding was actioned.
* - **DISMISSED** — the finding persists into the final review AND that review APPROVED: the
* reviewer signed off without it being addressed, i.e. treated it as non-blocking.
* - **INCONCLUSIVE** — the finding is still open at a non-approved terminal (loop exhausted): no
* signal either way.
*
* Findings carry a stable [CritiqueFinding.id] so the same logical finding is tracked across
* iterations.
*/
object CritiqueOutcomeCorrelator {
fun correlate(
sessionId: SessionId,
reviews: List<CritiqueFindingsRecordedEvent>,
): List<CritiqueOutcomeCorrelatedEvent> =
reviews
.groupBy { it.role to it.modelHash }
.flatMap { (_, group) -> correlateCritic(sessionId, group) }
private fun correlateCritic(
sessionId: SessionId,
critic: List<CritiqueFindingsRecordedEvent>,
): List<CritiqueOutcomeCorrelatedEvent> {
val ordered = critic.sortedBy { it.iteration }
val finalReview = ordered.last()
val finalIds = finalReview.findings.mapTo(mutableSetOf()) { it.id }
// Latest appearance per finding id (later iterations overwrite), used for severity and the
// owning critic's role/modelHash.
val latest = LinkedHashMap<String, Pair<CritiqueFindingsRecordedEvent, CritiqueFinding>>()
for (review in ordered) {
for (finding in review.findings) {
latest[finding.id] = review to finding
}
}
return latest.map { (id, appearance) ->
val (review, finding) = appearance
val outcome = when {
id !in finalIds -> CritiqueOutcome.UPHELD
finalReview.verdict == CritiqueVerdict.APPROVED -> CritiqueOutcome.DISMISSED
else -> CritiqueOutcome.INCONCLUSIVE
}
CritiqueOutcomeCorrelatedEvent(
sessionId = sessionId,
findingId = id,
role = review.role,
modelHash = review.modelHash,
severity = finding.severity,
outcome = outcome,
)
}
}
}
@@ -61,11 +61,15 @@ class DefaultSessionOrchestrator(
private val compactionService: JournalCompactionService? = null,
artifactKindRegistry: ArtifactKindRegistry? = null,
repoKnowledgeRetriever: RepoKnowledgeRetriever? = null,
commandRunner: CommandRunner? = null,
) : SessionOrchestrator(repositories, engines, artifactStore, decisionJournalRepository, artifactKindRegistry = artifactKindRegistry, repoKnowledgeRetriever = repoKnowledgeRetriever), ApprovalGateway {
override val tokenizer: Tokenizer? = tokenizer
override val cancellations: ConcurrentHashMap<SessionId, AtomicBoolean> =
ConcurrentHashMap<SessionId, AtomicBoolean>()
// Deterministic static_check stages (BACKLOG §B-§5) are run here, not via the LLM subagent.
private val staticCheckExecutor = StaticCheckStageExecutor(commandRunner)
override val subagentRunner: SubagentRunner = InSessionSubagentRunner(
executeStage = { sid, stg, graph, session, cfg ->
executeStage(sid, stg, graph, session, cfg, effectivesFor(cfg))
@@ -375,6 +379,20 @@ class DefaultSessionOrchestrator(
}
}
// Deterministic stage seam (§B-§5): a `static_check` stage runs a static-analysis tool and
// records its findings instead of invoking the LLM subagent. No retries/clarification apply.
ctx.graph.stages[stageId]?.let { stage ->
if (stage.metadata["stage_type"] == "static_check") {
val event = staticCheckExecutor.execute(ctx.sessionId, stageId, stage)
emit(ctx.sessionId, event)
log.info(
"[Orchestrator] static_check session={} stage={} tool={} findings={}",
ctx.sessionId.value, stageId.value, event.tool, event.findings.size,
)
return StepResult.Continue(ctx.copy(stageCount = ctx.stageCount + 1))
}
}
while (true) {
if (isCancelled(ctx.sessionId)) {
return StepResult.Terminal(handleCancellation(ctx.sessionId, stageId))
@@ -30,6 +30,8 @@ import com.correx.core.events.events.RefinementIterationEvent
import com.correx.core.events.events.ApprovalRequestedEvent
import com.correx.core.events.events.ArtifactContentStoredEvent
import com.correx.core.events.events.ArtifactCreatedEvent
import com.correx.core.events.events.CritiqueFindingsRecordedEvent
import com.correx.core.events.events.CritiqueOutcomeCorrelatedEvent
import com.correx.core.events.events.StaticFindingsRecordedEvent
import com.correx.core.events.events.BriefEchoMismatchEvent
import com.correx.core.events.events.BriefGroundingCheckedEvent
@@ -1734,6 +1736,7 @@ abstract class SessionOrchestrator(
"[Orchestrator] COMPLETED session={} terminalStage={} stages={}",
sessionId.value, terminalStageId.value, stageCount,
)
correlateCritiqueOutcomes(sessionId)
emit(sessionId, WorkflowCompletedEvent(sessionId, terminalStageId, stageCount))
cancellations.remove(sessionId)
return WorkflowResult.Completed(sessionId, terminalStageId)
@@ -1749,11 +1752,26 @@ abstract class SessionOrchestrator(
"[Orchestrator] FAILED session={} stage={} reason={} retryExhausted={}",
sessionId.value, stageId.value, reason, retryExhausted,
)
correlateCritiqueOutcomes(sessionId)
emit(sessionId, WorkflowFailedEvent(sessionId, stageId, reason, retryExhausted))
cancellations.remove(sessionId)
return WorkflowResult.Failed(sessionId, reason, retryExhausted)
}
/**
* At loop resolution, correlate any recorded critique findings (§B-§6) into outcome events that
* feed the critic-calibration projection. A no-op when no [CritiqueFindingsRecordedEvent]s were
* recorded (the LLM-side finding emission is a separate, model-gated activation), and idempotent
* — it skips when outcomes already exist — so it is safe on every terminal path.
*/
private suspend fun correlateCritiqueOutcomes(sessionId: SessionId) {
val events = eventStore.read(sessionId)
if (events.any { it.payload is CritiqueOutcomeCorrelatedEvent }) return
val reviews = events.mapNotNull { it.payload as? CritiqueFindingsRecordedEvent }
if (reviews.isEmpty()) return
CritiqueOutcomeCorrelator.correlate(sessionId, reviews).forEach { emit(sessionId, it) }
}
internal suspend fun handleCancellation(
sessionId: SessionId,
stageId: StageId,
@@ -25,18 +25,18 @@ interface CommandRunner {
* Both stdout and stderr are parsed — compilers write diagnostics to stderr, ktlint/detekt to
* stdout — and parsing is lenient: lines that don't match any known format are ignored.
*
* TODO(wiring): a real `static_check` stage in role_pipeline.toml (between `implementer` and
* `reviewer`) would invoke this runner and emit the findings as a first-class event:
* Wiring: the deterministic `static_check` stage now exists. [StaticCheckStageExecutor] invokes this
* runner and records a [com.correx.core.events.events.StaticFindingsRecordedEvent]; the orchestrator
* dispatches any stage with `metadata["stage_type"] == "static_check"` to it instead of the LLM
* subagent (see DefaultSessionOrchestrator.enterStage), and role_pipeline.toml carries the stage
* between `implementer` and `reviewer`. Once the event is on the journal,
* [excludeStaticFindingsFromReview] (already live-wired in SessionOrchestrator's context assembly)
* strips the matching entries from the reviewer's context.
*
* val findings = StaticAnalysisRunner(commandRunner).analyze("detekt", argv)
* eventDispatcher.emit(StaticFindingsRecordedEvent(sessionId, stageId, "detekt", findings), sessionId)
*
* Once that event is on the journal, [excludeStaticFindingsFromReview] (already live-wired in
* SessionOrchestrator's context assembly) strips the matching entries from the reviewer's context.
* That stage is intentionally NOT added here: workflow stages are LLM-driven and emit typed
* artifacts, so a deterministic event-emitting stage needs a new stage-type / tool-execution seam
* that doesn't yet exist — adding it now would be broad plumbing. The runner + event + filter are
* complete; only the stage that calls them is deferred.
* The stage is a no-op until a [CommandRunner] is wired into the orchestrator AND the stage sets
* `static_argv`. TODO(activation): wiring a *production* runner means resolving the command to the
* session's workspace (ShellTool-style ProcessBuilder + cwd + timeout) and is live-QA-gated — it is
* deferred so the deterministic seam can land and be unit-verified without a sandbox.
*/
class StaticAnalysisRunner(private val runner: CommandRunner) {
@@ -0,0 +1,72 @@
package com.correx.core.kernel.orchestration
import com.correx.core.events.events.StaticFindingsRecordedEvent
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.transitions.graph.StageConfig
/**
* Executes a deterministic `static_check` stage (BACKLOG §B-§5). Where an LLM stage runs a
* subagent, this stage shells out to a configured static-analysis tool, parses its console
* output via [StaticAnalysisRunner], and produces a [StaticFindingsRecordedEvent] — the event the
* reviewer-context filter ([excludeStaticFindingsFromReview], already live-wired) consumes so the
* reviewer's attention isn't spent re-finding what the build already reports.
*
* The stage is configured entirely through [StageConfig.metadata] (set from the workflow TOML):
* - `stage_type = "static_check"` — marks the stage deterministic (dispatched in `enterStage`).
* - `static_tool = "detekt"` — the label recorded on the findings (default `"static"`).
* - `static_argv = "<command>"` — the command to run, whitespace-split (simple quotes honoured).
*
* It is a **no-op (empty findings) when no runner is wired or no `static_argv` is configured**, so a
* workflow can carry the stage safely before a tool/command is set up — activation is opt-in.
*/
class StaticCheckStageExecutor(private val commandRunner: CommandRunner?) {
/** Runs the configured tool (if any) and returns the event to record; never throws on a tool miss. */
suspend fun execute(sessionId: SessionId, stageId: StageId, stage: StageConfig): StaticFindingsRecordedEvent {
val tool = stage.metadata["static_tool"]?.takeIf { it.isNotBlank() } ?: "static"
val argv = stage.metadata["static_argv"]?.let { tokenizeArgv(it) } ?: emptyList()
val findings = if (commandRunner != null && argv.isNotEmpty()) {
StaticAnalysisRunner(commandRunner).analyze(tool, argv)
} else {
emptyList()
}
return StaticFindingsRecordedEvent(
sessionId = sessionId,
stageId = stageId,
tool = tool,
findings = findings,
)
}
companion object {
/**
* Splits a command string into argv on whitespace, honouring simple single- or double-quoted
* runs so a path or flag value may contain spaces. Not a full shell parser (no escapes, no
* variable expansion) — deliberately minimal and deterministic for unit testing.
*/
fun tokenizeArgv(command: String): List<String> {
val tokens = mutableListOf<String>()
val current = StringBuilder()
var quote: Char? = null
var inToken = false
fun flush() {
if (inToken) {
tokens.add(current.toString())
current.clear()
inToken = false
}
}
for (ch in command) {
when {
quote != null -> if (ch == quote) quote = null else current.append(ch)
ch == '\'' || ch == '"' -> { quote = ch; inToken = true }
ch.isWhitespace() -> flush()
else -> { current.append(ch); inToken = true }
}
}
flush()
return tokens
}
}
}
@@ -0,0 +1,131 @@
package com.correx.core.kernel.orchestration
import com.correx.core.events.events.CritiqueFinding
import com.correx.core.events.events.CritiqueFindingsRecordedEvent
import com.correx.core.events.events.CritiqueOutcome
import com.correx.core.events.events.CritiqueOutcomeCorrelatedEvent
import com.correx.core.events.events.CritiqueRole
import com.correx.core.events.events.CritiqueSeverity
import com.correx.core.events.events.CritiqueVerdict
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class CritiqueOutcomeCorrelatorTest {
private val sessionId = SessionId("s1")
private fun finding(id: String, severity: CritiqueSeverity = CritiqueSeverity.MAJOR) = CritiqueFinding(
id = id,
role = CritiqueRole.CODE_REVIEWER,
severity = severity,
category = "correctness",
message = "msg-$id",
)
private fun review(
iteration: Int,
verdict: CritiqueVerdict,
findings: List<CritiqueFinding>,
role: CritiqueRole = CritiqueRole.CODE_REVIEWER,
modelHash: String = "model-A",
) = CritiqueFindingsRecordedEvent(
sessionId = sessionId,
stageId = StageId("reviewer"),
role = role,
modelHash = modelHash,
iteration = iteration,
verdict = verdict,
findings = findings,
)
private fun outcomeFor(events: List<CritiqueOutcomeCorrelatedEvent>, id: String) =
events.single { it.findingId == id }.outcome
@Test
fun `finding fixed between iterations is UPHELD`() {
val reviews = listOf(
review(1, CritiqueVerdict.CHANGES_REQUESTED, listOf(finding("f1"))),
review(2, CritiqueVerdict.APPROVED, emptyList()),
)
val out = CritiqueOutcomeCorrelator.correlate(sessionId, reviews)
assertEquals(1, out.size)
assertEquals(CritiqueOutcome.UPHELD, out.single().outcome)
}
@Test
fun `finding persisting into an approved final review is DISMISSED`() {
val reviews = listOf(
review(1, CritiqueVerdict.CHANGES_REQUESTED, listOf(finding("f1"))),
review(2, CritiqueVerdict.APPROVED, listOf(finding("f1"))),
)
val out = CritiqueOutcomeCorrelator.correlate(sessionId, reviews)
assertEquals(CritiqueOutcome.DISMISSED, outcomeFor(out, "f1"))
}
@Test
fun `finding still open at a non-approved terminal is INCONCLUSIVE`() {
val reviews = listOf(
review(1, CritiqueVerdict.CHANGES_REQUESTED, listOf(finding("f1"))),
review(2, CritiqueVerdict.CHANGES_REQUESTED, listOf(finding("f1"))),
)
val out = CritiqueOutcomeCorrelator.correlate(sessionId, reviews)
assertEquals(CritiqueOutcome.INCONCLUSIVE, outcomeFor(out, "f1"))
}
@Test
fun `a single approved review with a finding dismisses it`() {
val reviews = listOf(review(1, CritiqueVerdict.APPROVED, listOf(finding("f1"))))
val out = CritiqueOutcomeCorrelator.correlate(sessionId, reviews)
assertEquals(CritiqueOutcome.DISMISSED, outcomeFor(out, "f1"))
}
@Test
fun `mixed fates in one loop are resolved independently per finding`() {
val reviews = listOf(
// f1 and f2 raised; f1 fixed next round, f2 persists; f3 raised late and persists.
review(1, CritiqueVerdict.CHANGES_REQUESTED, listOf(finding("f1"), finding("f2"))),
review(2, CritiqueVerdict.APPROVED, listOf(finding("f2"), finding("f3"))),
)
val out = CritiqueOutcomeCorrelator.correlate(sessionId, reviews)
assertEquals(3, out.size)
assertEquals(CritiqueOutcome.UPHELD, outcomeFor(out, "f1"))
assertEquals(CritiqueOutcome.DISMISSED, outcomeFor(out, "f2"))
assertEquals(CritiqueOutcome.DISMISSED, outcomeFor(out, "f3"))
}
@Test
fun `findings carry their severity and the producing critic's identity`() {
val reviews = listOf(
review(
1, CritiqueVerdict.APPROVED, listOf(finding("f1", CritiqueSeverity.BLOCKER)),
role = CritiqueRole.PLAN_CRITIC, modelHash = "model-Z",
),
)
val event = CritiqueOutcomeCorrelator.correlate(sessionId, reviews).single()
assertEquals(CritiqueSeverity.BLOCKER, event.severity)
assertEquals(CritiqueRole.PLAN_CRITIC, event.role)
assertEquals("model-Z", event.modelHash)
}
@Test
fun `each critic (role + model) is calibrated separately`() {
val planCritic = CritiqueRole.PLAN_CRITIC
val reviewer = CritiqueRole.CODE_REVIEWER
val reviews = listOf(
review(1, CritiqueVerdict.CHANGES_REQUESTED, listOf(finding("a")), role = planCritic, modelHash = "m1"),
review(2, CritiqueVerdict.APPROVED, emptyList(), role = planCritic, modelHash = "m1"),
review(1, CritiqueVerdict.APPROVED, listOf(finding("b")), role = reviewer, modelHash = "m2"),
)
val out = CritiqueOutcomeCorrelator.correlate(sessionId, reviews)
assertEquals(CritiqueOutcome.UPHELD, outcomeFor(out, "a")) // plan critic's finding fixed
assertEquals(CritiqueOutcome.DISMISSED, outcomeFor(out, "b")) // reviewer's finding approved-through
}
@Test
fun `no reviews yields no outcomes`() {
assertTrue(CritiqueOutcomeCorrelator.correlate(sessionId, emptyList()).isEmpty())
}
}
@@ -0,0 +1,80 @@
package com.correx.core.kernel.orchestration
import com.correx.core.events.events.StaticFindingSeverity
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.transitions.graph.StageConfig
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
class StaticCheckStageExecutorTest {
private val sessionId = SessionId("s1")
private val stageId = StageId("static_check")
private fun fakeRunner(stdout: String = "", stderr: String = "") = object : CommandRunner {
var lastArgv: List<String>? = null
override suspend fun run(argv: List<String>): CommandOutput {
lastArgv = argv
return CommandOutput(exitCode = 0, stdout = stdout, stderr = stderr)
}
}
@Test
fun `tokenizeArgv splits on whitespace and honours quotes`() {
assertEquals(
listOf("./gradlew", "detekt", "--console=plain"),
StaticCheckStageExecutor.tokenizeArgv("./gradlew detekt --console=plain"),
)
assertEquals(
listOf("tool", "--path", "src/main with space/App.kt"),
StaticCheckStageExecutor.tokenizeArgv("""tool --path "src/main with space/App.kt""""),
)
assertEquals(
listOf("a", "b"),
StaticCheckStageExecutor.tokenizeArgv(" a b "),
)
assertTrue(StaticCheckStageExecutor.tokenizeArgv("").isEmpty())
}
@Test
fun `execute runs the configured tool and records parsed findings`() = runBlocking {
val runner = fakeRunner(stdout = "src/A.kt:10:4: warning: deprecated\nBUILD FAILED")
val stage = StageConfig(
metadata = mapOf(
"stage_type" to "static_check",
"static_tool" to "detekt",
"static_argv" to "./gradlew detekt",
),
)
val event = StaticCheckStageExecutor(runner).execute(sessionId, stageId, stage)
assertEquals("detekt", event.tool)
assertEquals(1, event.findings.size)
assertEquals("src/A.kt", event.findings[0].file)
assertEquals(StaticFindingSeverity.WARNING, event.findings[0].severity)
assertEquals(listOf("./gradlew", "detekt"), runner.lastArgv)
}
@Test
fun `execute is a no-op when no argv is configured`() = runBlocking {
val runner = fakeRunner(stdout = "src/A.kt:1:1: error: boom")
val stage = StageConfig(metadata = mapOf("stage_type" to "static_check", "static_tool" to "detekt"))
val event = StaticCheckStageExecutor(runner).execute(sessionId, stageId, stage)
assertEquals("detekt", event.tool)
assertTrue(event.findings.isEmpty(), "no static_argv → tool not run, empty findings")
assertEquals(null, runner.lastArgv, "the runner must not be invoked without a command")
}
@Test
fun `execute is a no-op with empty findings when no runner is wired`() = runBlocking {
val stage = StageConfig(metadata = mapOf("stage_type" to "static_check", "static_argv" to "./gradlew detekt"))
val event = StaticCheckStageExecutor(null).execute(sessionId, stageId, stage)
assertEquals("static", event.tool, "default tool label when unspecified")
assertTrue(event.findings.isEmpty())
}
}