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:
+72
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+18
@@ -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))
|
||||
|
||||
+18
@@ -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,
|
||||
|
||||
+11
-11
@@ -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) {
|
||||
|
||||
|
||||
+72
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
+131
@@ -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())
|
||||
}
|
||||
}
|
||||
+80
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user