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 package com.correx.core.events.events
import com.correx.core.events.types.SessionId import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable 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. * How a prior [CritiqueFinding] actually turned out once the surrounding loop resolved.
* UPHELD = the finding was confirmed/actioned; DISMISSED = rejected as a false positive; * 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.ClarificationAnsweredEvent
import com.correx.core.events.events.ClarificationRequestedEvent import com.correx.core.events.events.ClarificationRequestedEvent
import com.correx.core.events.events.ChatTurnEvent 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.CritiqueOutcomeCorrelatedEvent
import com.correx.core.events.events.RouterNarrationEvent import com.correx.core.events.events.RouterNarrationEvent
import com.correx.core.events.events.OperatorProfileBoundEvent import com.correx.core.events.events.OperatorProfileBoundEvent
@@ -142,6 +143,7 @@ val eventModule = SerializersModule {
subclass(IdeaDiscardedEvent::class) subclass(IdeaDiscardedEvent::class)
subclass(IdeaPromotedEvent::class) subclass(IdeaPromotedEvent::class)
subclass(CritiqueOutcomeCorrelatedEvent::class) subclass(CritiqueOutcomeCorrelatedEvent::class)
subclass(CritiqueFindingsRecordedEvent::class)
subclass(StageCheckpointPassedEvent::class) subclass(StageCheckpointPassedEvent::class)
subclass(StageCheckpointFailedEvent::class) subclass(StageCheckpointFailedEvent::class)
subclass(PossibleContradictionFlaggedEvent::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, private val compactionService: JournalCompactionService? = null,
artifactKindRegistry: ArtifactKindRegistry? = null, artifactKindRegistry: ArtifactKindRegistry? = null,
repoKnowledgeRetriever: RepoKnowledgeRetriever? = null, repoKnowledgeRetriever: RepoKnowledgeRetriever? = null,
commandRunner: CommandRunner? = null,
) : SessionOrchestrator(repositories, engines, artifactStore, decisionJournalRepository, artifactKindRegistry = artifactKindRegistry, repoKnowledgeRetriever = repoKnowledgeRetriever), ApprovalGateway { ) : SessionOrchestrator(repositories, engines, artifactStore, decisionJournalRepository, artifactKindRegistry = artifactKindRegistry, repoKnowledgeRetriever = repoKnowledgeRetriever), ApprovalGateway {
override val tokenizer: Tokenizer? = tokenizer override val tokenizer: Tokenizer? = tokenizer
override val cancellations: ConcurrentHashMap<SessionId, AtomicBoolean> = override val cancellations: ConcurrentHashMap<SessionId, AtomicBoolean> =
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( override val subagentRunner: SubagentRunner = InSessionSubagentRunner(
executeStage = { sid, stg, graph, session, cfg -> executeStage = { sid, stg, graph, session, cfg ->
executeStage(sid, stg, graph, session, cfg, effectivesFor(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) { while (true) {
if (isCancelled(ctx.sessionId)) { if (isCancelled(ctx.sessionId)) {
return StepResult.Terminal(handleCancellation(ctx.sessionId, stageId)) 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.ApprovalRequestedEvent
import com.correx.core.events.events.ArtifactContentStoredEvent import com.correx.core.events.events.ArtifactContentStoredEvent
import com.correx.core.events.events.ArtifactCreatedEvent 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.StaticFindingsRecordedEvent
import com.correx.core.events.events.BriefEchoMismatchEvent import com.correx.core.events.events.BriefEchoMismatchEvent
import com.correx.core.events.events.BriefGroundingCheckedEvent import com.correx.core.events.events.BriefGroundingCheckedEvent
@@ -1734,6 +1736,7 @@ abstract class SessionOrchestrator(
"[Orchestrator] COMPLETED session={} terminalStage={} stages={}", "[Orchestrator] COMPLETED session={} terminalStage={} stages={}",
sessionId.value, terminalStageId.value, stageCount, sessionId.value, terminalStageId.value, stageCount,
) )
correlateCritiqueOutcomes(sessionId)
emit(sessionId, WorkflowCompletedEvent(sessionId, terminalStageId, stageCount)) emit(sessionId, WorkflowCompletedEvent(sessionId, terminalStageId, stageCount))
cancellations.remove(sessionId) cancellations.remove(sessionId)
return WorkflowResult.Completed(sessionId, terminalStageId) return WorkflowResult.Completed(sessionId, terminalStageId)
@@ -1749,11 +1752,26 @@ abstract class SessionOrchestrator(
"[Orchestrator] FAILED session={} stage={} reason={} retryExhausted={}", "[Orchestrator] FAILED session={} stage={} reason={} retryExhausted={}",
sessionId.value, stageId.value, reason, retryExhausted, sessionId.value, stageId.value, reason, retryExhausted,
) )
correlateCritiqueOutcomes(sessionId)
emit(sessionId, WorkflowFailedEvent(sessionId, stageId, reason, retryExhausted)) emit(sessionId, WorkflowFailedEvent(sessionId, stageId, reason, retryExhausted))
cancellations.remove(sessionId) cancellations.remove(sessionId)
return WorkflowResult.Failed(sessionId, reason, retryExhausted) 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( internal suspend fun handleCancellation(
sessionId: SessionId, sessionId: SessionId,
stageId: StageId, stageId: StageId,
@@ -25,18 +25,18 @@ interface CommandRunner {
* Both stdout and stderr are parsed — compilers write diagnostics to stderr, ktlint/detekt to * 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. * 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 * Wiring: the deterministic `static_check` stage now exists. [StaticCheckStageExecutor] invokes this
* `reviewer`) would invoke this runner and emit the findings as a first-class event: * 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) * The stage is a no-op until a [CommandRunner] is wired into the orchestrator AND the stage sets
* eventDispatcher.emit(StaticFindingsRecordedEvent(sessionId, stageId, "detekt", findings), sessionId) * `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
* Once that event is on the journal, [excludeStaticFindingsFromReview] (already live-wired in * deferred so the deterministic seam can land and be unit-verified without a sandbox.
* 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.
*/ */
class StaticAnalysisRunner(private val runner: CommandRunner) { 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())
}
}
+24 -3
View File
@@ -1,4 +1,4 @@
# Role pipeline: analyst → architect → planner → implementer ⇄ reviewer # Role pipeline: analyst → architect → planner → implementer → static_check ⇄ reviewer
# #
# Each stage produces a typed artifact that the next stage `needs`, so work flows forward # Each stage produces a typed artifact that the next stage `needs`, so work flows forward
# without a human relaying notes. The decision journal (pinned into every stage's context) # without a human relaying notes. The decision journal (pinned into every stage's context)
@@ -69,6 +69,19 @@ allowed_tools = ["file_read", "file_write", "file_edit", "ShellTool"]
token_budget = 32768 token_budget = 32768
max_retries = 3 max_retries = 3
# 4b. Deterministic static analysis (NO LLM). Runs a configured tool (compiler/ktlint/detekt)
# over the patch and records its findings as a StaticFindingsRecordedEvent, which the
# reviewer-context filter then strips from the reviewer's context so the LLM reviewer spends
# its attention on semantic review rather than re-finding what the build already reports.
# No-op until `static_argv` is set (and a CommandRunner is wired) — safe to carry unconfigured.
# Activate e.g. with: static_argv = "./gradlew detekt --console=plain"
[[stages]]
id = "static_check"
stage_type = "static_check"
static_tool = "detekt"
token_budget = 0
max_retries = 0
# 5. Review the patch against the plan AND the analyst's acceptance criteria. The reviewer # 5. Review the patch against the plan AND the analyst's acceptance criteria. The reviewer
# needs `analysis` so it judges the diff against concrete, pre-stated criteria (§5 narrow # needs `analysis` so it judges the diff against concrete, pre-stated criteria (§5 narrow
# question) rather than whole files against taste. # question) rather than whole files against taste.
@@ -103,13 +116,21 @@ to = "implementer"
condition_type = "artifact_validated" condition_type = "artifact_validated"
condition_artifact_id = "impl_plan" condition_artifact_id = "impl_plan"
# implementer → static_check (once the patch is validated) → reviewer. The static_check stage
# produces no artifact, so its exit edge is unconditional (always_true).
[[transitions]] [[transitions]]
id = "implementer-to-reviewer" id = "implementer-to-static_check"
from = "implementer" from = "implementer"
to = "reviewer" to = "static_check"
condition_type = "artifact_validated" condition_type = "artifact_validated"
condition_artifact_id = "patch" condition_artifact_id = "patch"
[[transitions]]
id = "static_check-to-reviewer"
from = "static_check"
to = "reviewer"
condition_type = "always_true"
# --- verdict-gated loop exit / re-entry --- # --- verdict-gated loop exit / re-entry ---
[[transitions]] [[transitions]]
@@ -47,6 +47,12 @@ private data class StageSection(
@param:JsonProperty("requires_approval") val requiresApproval: Boolean = false, @param:JsonProperty("requires_approval") val requiresApproval: Boolean = false,
@param:JsonProperty("inject_artifact_kinds") val injectArtifactKinds: Boolean = false, @param:JsonProperty("inject_artifact_kinds") val injectArtifactKinds: Boolean = false,
@param:JsonProperty("ground_references") val groundReferences: Boolean = false, @param:JsonProperty("ground_references") val groundReferences: Boolean = false,
// Deterministic stage marker (§B-§5): `stage_type = "static_check"` runs a static-analysis
// tool instead of an LLM subagent; `static_tool`/`static_argv` configure it (see
// StaticCheckStageExecutor). Absent for ordinary LLM stages.
@param:JsonProperty("stage_type") val stageType: String? = null,
@param:JsonProperty("static_tool") val staticTool: String? = null,
@param:JsonProperty("static_argv") val staticArgv: String? = null,
) )
// condition fields flattened into the transition row // condition fields flattened into the transition row
@@ -93,6 +99,19 @@ class TomlWorkflowLoader(
return if (candidate != null && candidate.exists()) candidate.toString() else raw return if (candidate != null && candidate.exists()) candidate.toString() else raw
} }
// Flattens a stage's TOML flags into the StageConfig.metadata string map the runtime reads
// (prompt paths, approval/inject/ground flags, and the §B-§5 deterministic-stage markers).
private fun stageMetadata(s: StageSection, workflowDir: Path?): Map<String, String> = buildMap {
s.prompt?.let { put("prompt", resolvePromptPath(it, workflowDir)) }
s.systemPrompt?.let { put("systemPrompt", resolvePromptPath(it, workflowDir)) }
if (s.requiresApproval) put("requiresApproval", "true")
if (s.injectArtifactKinds) put("injectArtifactKinds", "true")
if (s.groundReferences) put("groundReferences", "true")
s.stageType?.let { put("stage_type", it) }
s.staticTool?.let { put("static_tool", it) }
s.staticArgv?.let { put("static_argv", it) }
}
private fun WorkflowFile.toWorkflowGraph(workflowDir: Path?): WorkflowGraph { private fun WorkflowFile.toWorkflowGraph(workflowDir: Path?): WorkflowGraph {
val startId = StageId(start) val startId = StageId(start)
val stageMap = stages.associate { s -> val stageMap = stages.associate { s ->
@@ -115,13 +134,7 @@ class TomlWorkflowLoader(
maxTokens = s.tokenBudget, maxTokens = s.tokenBudget,
), ),
maxRetries = s.maxRetries, maxRetries = s.maxRetries,
metadata = buildMap { metadata = stageMetadata(s, workflowDir),
s.prompt?.let { put("prompt", resolvePromptPath(it, workflowDir)) }
s.systemPrompt?.let { put("systemPrompt", resolvePromptPath(it, workflowDir)) }
if (s.requiresApproval) put("requiresApproval", "true")
if (s.injectArtifactKinds) put("injectArtifactKinds", "true")
if (s.groundReferences) put("groundReferences", "true")
},
) )
} }
@@ -53,10 +53,21 @@ class RolePipelineWorkflowTest {
val graph = TomlWorkflowLoader(registry).load(path!!) val graph = TomlWorkflowLoader(registry).load(path!!)
assertEquals( assertEquals(
setOf("analyst", "architect", "planner", "implementer", "reviewer"), setOf("analyst", "architect", "planner", "implementer", "static_check", "reviewer"),
graph.stages.keys.map { it.value }.toSet(), graph.stages.keys.map { it.value }.toSet(),
) )
assertEquals(6, graph.transitions.size) assertEquals(7, graph.transitions.size)
// The deterministic static_check stage sits between implementer and reviewer.
assertEquals("static_check", graph.stages[StageId("static_check")]!!.metadata["stage_type"])
assertTrue(
graph.transitions.any { it.from == StageId("implementer") && it.to == StageId("static_check") },
"implementer should hand off to static_check",
)
assertTrue(
graph.transitions.any { it.from == StageId("static_check") && it.to == StageId("reviewer") },
"static_check should hand off to reviewer",
)
// Forward chaining via needs. // Forward chaining via needs.
assertTrue(graph.stages[StageId("reviewer")]!!.needs.map { it.value }.containsAll(listOf("patch", "impl_plan"))) assertTrue(graph.stages[StageId("reviewer")]!!.needs.map { it.value }.containsAll(listOf("patch", "impl_plan")))
@@ -0,0 +1,161 @@
import com.correx.core.approvals.ApprovalProjector
import com.correx.core.approvals.DefaultApprovalReducer
import com.correx.core.approvals.DefaultApprovalRepository
import com.correx.core.approvals.domain.DefaultApprovalEngine
import com.correx.core.artifacts.DefaultArtifactReducer
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.events.EventMetadata
import com.correx.core.events.events.NewEvent
import com.correx.core.events.types.EventId
import com.correx.core.events.execution.RetryPolicy
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.events.types.TransitionId
import com.correx.core.inference.InferenceRepository
import com.correx.core.inference.InferenceState
import com.correx.core.journal.DecisionJournalProjector
import com.correx.core.journal.DefaultDecisionJournalReducer
import com.correx.core.journal.DefaultDecisionJournalRepository
import com.correx.core.kernel.orchestration.DefaultOrchestrationReducer
import com.correx.core.kernel.orchestration.DefaultSessionOrchestrator
import com.correx.core.kernel.orchestration.OrchestrationConfig
import com.correx.core.kernel.orchestration.OrchestrationProjector
import com.correx.core.kernel.orchestration.OrchestrationRepository
import com.correx.core.kernel.orchestration.OrchestratorEngines
import com.correx.core.kernel.orchestration.OrchestratorRepositories
import com.correx.core.kernel.retry.DefaultRetryCoordinator
import com.correx.core.risk.DefaultRiskAssessor
import com.correx.core.sessions.DefaultSessionRepository
import com.correx.core.sessions.projections.replay.DefaultEventReplayer
import com.correx.core.sessions.projections.replay.EventReplayer
import com.correx.core.transitions.graph.StageConfig
import com.correx.core.transitions.graph.TransitionEdge
import com.correx.core.transitions.graph.WorkflowGraph
import com.correx.core.validation.pipeline.ValidationPipeline
import com.correx.infrastructure.persistence.InMemoryEventStore
import com.correx.infrastructure.persistence.artifact.LiveArtifactRepository
import com.correx.testing.contracts.fixtures.artifactstore.NoopArtifactStore
import com.correx.testing.fixtures.InferenceFixtures
import com.correx.testing.fixtures.context.ContextFixtures
import com.correx.testing.fixtures.cyclePolicyMissingValidator
import com.correx.testing.fixtures.transitions.TransitionFixtures
import com.correx.testing.kernel.MockSessionEventReplayer
import kotlinx.coroutines.runBlocking
import kotlinx.datetime.Clock
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
/**
* End-to-end proof that the reviewer-loop runtime correlates recorded critique findings into
* outcome events at loop resolution (BACKLOG §B-§6), and that those events feed the
* `:core:critique` calibration projection. The LLM-side emission of findings is mocked here by
* seeding [CritiqueFindingsRecordedEvent]s directly (that half is a separate model-gated step).
*/
class CritiqueCalibrationWiringTest {
private val eventStore = InMemoryEventStore()
private val sessionReplayer = MockSessionEventReplayer()
private val sessionRepository = DefaultSessionRepository(sessionReplayer)
private val orchestrationRepository = OrchestrationRepository(
DefaultEventReplayer(eventStore, OrchestrationProjector(DefaultOrchestrationReducer())),
)
private val inferenceRepository = InferenceRepository(
object : EventReplayer<InferenceState> {
override fun rebuild(sessionId: SessionId) = InferenceState()
},
)
private val approvalRepository = DefaultApprovalRepository(
DefaultEventReplayer(eventStore, ApprovalProjector(DefaultApprovalReducer())),
)
private val decisionJournalRepository = DefaultDecisionJournalRepository(
DefaultEventReplayer(eventStore, DecisionJournalProjector(DefaultDecisionJournalReducer())),
)
private val retryCoordinator = DefaultRetryCoordinator(eventStore)
private val repositories = OrchestratorRepositories(
eventStore = eventStore,
inferenceRepository = inferenceRepository,
orchestrationRepository = orchestrationRepository,
sessionRepository = sessionRepository,
artifactRepository = LiveArtifactRepository(eventStore, DefaultArtifactReducer()),
approvalRepository = approvalRepository,
)
private val orchestrator = DefaultSessionOrchestrator(
repositories = repositories,
engines = OrchestratorEngines(
transitionResolver = TransitionFixtures.simpleResolver(),
contextPackBuilder = ContextFixtures.simpleBuilder(),
inferenceRouter = InferenceFixtures.fixedRouter(),
validationPipeline = ValidationPipeline(validators = listOf(cyclePolicyMissingValidator())),
approvalEngine = DefaultApprovalEngine(),
riskAssessor = DefaultRiskAssessor(),
),
retryCoordinator = retryCoordinator,
artifactStore = NoopArtifactStore(),
decisionJournalRepository = decisionJournalRepository,
)
private val defaultConfig = OrchestrationConfig(retryPolicy = RetryPolicy(maxAttempts = 1, backoffMs = 0))
private fun finding(id: String) = CritiqueFinding(
id = id,
role = CritiqueRole.CODE_REVIEWER,
severity = CritiqueSeverity.MAJOR,
category = "correctness",
message = "msg-$id",
)
private suspend fun seedReview(sessionId: SessionId, iteration: Int, verdict: CritiqueVerdict, findings: List<CritiqueFinding>) {
eventStore.append(
NewEvent(
EventMetadata(EventId("rev-$iteration"), sessionId, Clock.System.now(), 1, null, null),
CritiqueFindingsRecordedEvent(
sessionId = sessionId,
stageId = StageId("reviewer"),
role = CritiqueRole.CODE_REVIEWER,
modelHash = "model-A",
iteration = iteration,
verdict = verdict,
findings = findings,
),
),
)
}
@Test
fun `recorded findings are correlated at completion and feed the calibration projection`(): Unit = runBlocking {
val sessionId = SessionId("calib-1")
// Two review rounds: f1 fixed between them (→ UPHELD); f2 persists to an approved final (→ DISMISSED).
seedReview(sessionId, 1, CritiqueVerdict.CHANGES_REQUESTED, listOf(finding("f1"), finding("f2")))
seedReview(sessionId, 2, CritiqueVerdict.APPROVED, listOf(finding("f2")))
val stageA = StageId("A")
val done = StageId("done")
val graph = WorkflowGraph(
id = "calib-wiring",
stages = mapOf(stageA to StageConfig(), done to StageConfig()),
transitions = setOf(TransitionEdge(id = TransitionId("t1"), from = stageA, to = done, condition = { true })),
start = stageA,
)
orchestrator.run(sessionId, graph, defaultConfig)
// The orchestrator emits one outcome per recorded finding at completion — the events the
// :core:critique calibration projection consumes (its folding is covered by that module's
// DefaultCriticCalibrationReducerTest).
val outcomes = eventStore.read(sessionId).mapNotNull { it.payload as? CritiqueOutcomeCorrelatedEvent }
assertEquals(2, outcomes.size, "one outcome per recorded finding")
assertEquals(CritiqueOutcome.UPHELD, outcomes.single { it.findingId == "f1" }.outcome)
assertEquals(CritiqueOutcome.DISMISSED, outcomes.single { it.findingId == "f2" }.outcome)
assertTrue(outcomes.all { it.modelHash == "model-A" && it.role == CritiqueRole.CODE_REVIEWER })
}
}
@@ -0,0 +1,158 @@
import com.correx.core.approvals.ApprovalProjector
import com.correx.core.approvals.DefaultApprovalReducer
import com.correx.core.approvals.DefaultApprovalRepository
import com.correx.core.approvals.domain.DefaultApprovalEngine
import com.correx.core.artifacts.DefaultArtifactReducer
import com.correx.core.events.events.StaticFindingsRecordedEvent
import com.correx.core.events.events.WorkflowCompletedEvent
import com.correx.core.events.execution.RetryPolicy
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import com.correx.core.events.types.TransitionId
import com.correx.core.inference.InferenceRepository
import com.correx.core.inference.InferenceState
import com.correx.core.journal.DecisionJournalProjector
import com.correx.core.journal.DefaultDecisionJournalReducer
import com.correx.core.journal.DefaultDecisionJournalRepository
import com.correx.core.kernel.orchestration.CommandOutput
import com.correx.core.kernel.orchestration.CommandRunner
import com.correx.core.kernel.orchestration.DefaultOrchestrationReducer
import com.correx.core.kernel.orchestration.DefaultSessionOrchestrator
import com.correx.core.kernel.orchestration.OrchestrationConfig
import com.correx.core.kernel.orchestration.OrchestrationProjector
import com.correx.core.kernel.orchestration.OrchestrationRepository
import com.correx.core.kernel.orchestration.OrchestratorEngines
import com.correx.core.kernel.orchestration.OrchestratorRepositories
import com.correx.core.kernel.retry.DefaultRetryCoordinator
import com.correx.core.risk.DefaultRiskAssessor
import com.correx.core.sessions.DefaultSessionRepository
import com.correx.core.sessions.projections.replay.DefaultEventReplayer
import com.correx.core.sessions.projections.replay.EventReplayer
import com.correx.core.transitions.graph.StageConfig
import com.correx.core.transitions.graph.TransitionEdge
import com.correx.core.transitions.graph.WorkflowGraph
import com.correx.core.validation.pipeline.ValidationPipeline
import com.correx.infrastructure.persistence.InMemoryEventStore
import com.correx.infrastructure.persistence.artifact.LiveArtifactRepository
import com.correx.testing.contracts.fixtures.artifactstore.NoopArtifactStore
import com.correx.testing.fixtures.InferenceFixtures
import com.correx.testing.fixtures.context.ContextFixtures
import com.correx.testing.fixtures.cyclePolicyMissingValidator
import com.correx.testing.fixtures.transitions.TransitionFixtures
import com.correx.testing.kernel.MockSessionEventReplayer
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotNull
import org.junit.jupiter.api.Test
/**
* End-to-end proof of the deterministic static_check stage seam (BACKLOG §B-§5): a stage marked
* `stage_type = "static_check"` runs the configured tool via an injected [CommandRunner] (no LLM
* subagent), records a [StaticFindingsRecordedEvent] with the parsed findings, and the workflow
* advances on the unconditional exit edge.
*/
class StaticCheckStageTest {
private val eventStore = InMemoryEventStore()
private val sessionReplayer = MockSessionEventReplayer()
private val sessionRepository = DefaultSessionRepository(sessionReplayer)
private val orchestrationRepository = OrchestrationRepository(
DefaultEventReplayer(eventStore, OrchestrationProjector(DefaultOrchestrationReducer())),
)
private val inferenceRepository = InferenceRepository(
object : EventReplayer<InferenceState> {
override fun rebuild(sessionId: SessionId) = InferenceState()
},
)
private val approvalRepository = DefaultApprovalRepository(
DefaultEventReplayer(eventStore, ApprovalProjector(DefaultApprovalReducer())),
)
private val decisionJournalRepository = DefaultDecisionJournalRepository(
DefaultEventReplayer(eventStore, DecisionJournalProjector(DefaultDecisionJournalReducer())),
)
private val retryCoordinator = DefaultRetryCoordinator(eventStore)
private val repositories = OrchestratorRepositories(
eventStore = eventStore,
inferenceRepository = inferenceRepository,
orchestrationRepository = orchestrationRepository,
sessionRepository = sessionRepository,
artifactRepository = LiveArtifactRepository(eventStore, DefaultArtifactReducer()),
approvalRepository = approvalRepository,
)
// Canned tool output: one stderr error + one stdout detekt finding (+ noise that must be ignored).
private val fakeRunner = object : CommandRunner {
var lastArgv: List<String>? = null
override suspend fun run(argv: List<String>): CommandOutput {
lastArgv = argv
return CommandOutput(
exitCode = 1,
stdout = "src/Foo.kt:42:9: Magic number found [MagicNumber]\nBUILD FAILED",
stderr = "src/Bar.kt:5:9: error: type mismatch",
)
}
}
private val orchestrator = DefaultSessionOrchestrator(
repositories = repositories,
engines = OrchestratorEngines(
transitionResolver = TransitionFixtures.simpleResolver(),
contextPackBuilder = ContextFixtures.simpleBuilder(),
inferenceRouter = InferenceFixtures.fixedRouter(),
validationPipeline = ValidationPipeline(validators = listOf(cyclePolicyMissingValidator())),
approvalEngine = DefaultApprovalEngine(),
riskAssessor = DefaultRiskAssessor(),
),
retryCoordinator = retryCoordinator,
artifactStore = NoopArtifactStore(),
decisionJournalRepository = decisionJournalRepository,
commandRunner = fakeRunner,
)
private val defaultConfig = OrchestrationConfig(
retryPolicy = RetryPolicy(maxAttempts = 1, backoffMs = 0),
)
@Test
fun `static_check stage runs the tool deterministically and records findings`(): Unit = runBlocking {
val sessionId = SessionId("static-check-1")
val staticCheck = StageId("static_check")
val done = StageId("done")
val graph = WorkflowGraph(
id = "static-check-seam",
stages = mapOf(
staticCheck to StageConfig(
metadata = mapOf(
"stage_type" to "static_check",
"static_tool" to "detekt",
"static_argv" to "./gradlew detekt --console=plain",
),
),
done to StageConfig(),
),
transitions = setOf(
TransitionEdge(id = TransitionId("t1"), from = staticCheck, to = done, condition = { true }),
),
start = staticCheck,
)
orchestrator.run(sessionId, graph, defaultConfig)
val events = eventStore.read(sessionId)
val recorded = events.mapNotNull { it.payload as? StaticFindingsRecordedEvent }
assertEquals(1, recorded.size, "the static_check stage must record exactly one findings event")
val event = recorded.single()
assertEquals("detekt", event.tool)
assertEquals(2, event.findings.size, "both the stdout detekt finding and the stderr error parse")
assertEquals("MagicNumber", event.findings[0].ruleId)
assertEquals("src/Bar.kt", event.findings[1].file)
assertEquals(listOf("./gradlew", "detekt", "--console=plain"), fakeRunner.lastArgv)
assertNotNull(
events.find { it.payload is WorkflowCompletedEvent },
"workflow advances past static_check on the unconditional exit edge and completes",
)
}
}