diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt index 9e3647fa..5355b801 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt @@ -34,6 +34,7 @@ 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.ProcessStaticAnalysisRunner import com.correx.core.kernel.orchestration.OrchestratorRepositories import com.correx.core.kernel.orchestration.WorkspaceContext import com.correx.core.kernel.orchestration.WorkspaceTools @@ -277,6 +278,7 @@ fun main() { toolCallAssessor = toolCallAssessor, workspacePolicy = workspacePolicy, workspaceToolRegistryProvider = wsToolRegistryProvider, + staticAnalysisRunner = ProcessStaticAnalysisRunner(), ) val decisionJournalRepository = InfrastructureModule.createDecisionJournalRepository(eventStore) val defaultOrchestrationConfig = OrchestrationConfig( diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/StaticAnalysisEvents.kt b/core/events/src/main/kotlin/com/correx/core/events/events/StaticAnalysisEvents.kt new file mode 100644 index 00000000..3285c13b --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/events/events/StaticAnalysisEvents.kt @@ -0,0 +1,40 @@ +package com.correx.core.events.events + +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +/** The outcome of one operator-configured static-analysis command run against a stage's output. */ +@Serializable +data class StaticAnalysisFinding( + val command: String, + val exitCode: Int, + /** + * Truncated tail of the command's combined stdout+stderr. The full output is fed back to the + * model verbatim at runtime but is not retained in the log — the tail carries the error summary + * and keeps the event bounded. + */ + val summary: String, +) { + val clean: Boolean get() = exitCode == 0 +} + +/** + * Environment observation (invariant #9): the static-analysis commands (compiler / detekt / + * formatters) a stage ran against its just-produced output, each with its exit code and a truncated + * output tail, captured at the moment the stage completed. Replay reads these recorded facts and + * never re-runs the commands. + * + * Realizes role-reliability §5 "static-first ordering": deterministic tools run before the LLM + * reviewer. A non-clean finding fails the producing stage retryably (the output is fed back so the + * implementer fixes it), so only static-clean output ever reaches the reviewer — its context thereby + * excludes anything static tools already catch, with no parsing or context plumbing. + */ +@Serializable +@SerialName("StaticAnalysisCompleted") +data class StaticAnalysisCompletedEvent( + val sessionId: SessionId, + val stageId: StageId, + val findings: List, +) : EventPayload diff --git a/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt b/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt index 198e0ad2..d6847b7b 100644 --- a/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt +++ b/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt @@ -10,6 +10,7 @@ import com.correx.core.events.events.ArtifactValidatedEvent import com.correx.core.events.events.ArtifactValidatingEvent import com.correx.core.events.events.BriefEchoMismatchEvent import com.correx.core.events.events.BriefGroundingCheckedEvent +import com.correx.core.events.events.StaticAnalysisCompletedEvent import com.correx.core.events.events.ChatSessionStartedEvent import com.correx.core.events.events.ClarificationAnsweredEvent import com.correx.core.events.events.ClarificationRequestedEvent @@ -110,6 +111,7 @@ val eventModule = SerializersModule { subclass(RepoKnowledgeRetrievedEvent::class) subclass(BriefGroundingCheckedEvent::class) subclass(BriefEchoMismatchEvent::class) + subclass(StaticAnalysisCompletedEvent::class) subclass(RiskAssessedEvent::class) subclass(ChatSessionStartedEvent::class) subclass(ChatTurnEvent::class) diff --git a/core/events/src/test/kotlin/com/correx/core/events/serialization/StaticAnalysisCompletedEventSerializationTest.kt b/core/events/src/test/kotlin/com/correx/core/events/serialization/StaticAnalysisCompletedEventSerializationTest.kt new file mode 100644 index 00000000..4acf42c2 --- /dev/null +++ b/core/events/src/test/kotlin/com/correx/core/events/serialization/StaticAnalysisCompletedEventSerializationTest.kt @@ -0,0 +1,51 @@ +package com.correx.core.events.serialization + +import com.correx.core.events.events.EventPayload +import com.correx.core.events.events.StaticAnalysisCompletedEvent +import com.correx.core.events.events.StaticAnalysisFinding +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertFalse +import kotlin.test.assertTrue + +class StaticAnalysisCompletedEventSerializationTest { + + private val sample = StaticAnalysisCompletedEvent( + sessionId = SessionId("sess-1"), + stageId = StageId("implementer"), + findings = listOf( + StaticAnalysisFinding(command = "./gradlew compileKotlin", exitCode = 0, summary = "BUILD SUCCESSFUL"), + StaticAnalysisFinding(command = "./gradlew detekt", exitCode = 1, summary = "MagicNumber at Foo.kt:42"), + ), + ) + + @Test + fun `round-trips as polymorphic EventPayload`() { + val encoded = eventJson.encodeToString(EventPayload.serializer(), sample) + assertTrue(encoded.contains("\"type\":\"StaticAnalysisCompleted\""), "SerialName must be present: $encoded") + val decoded = eventJson.decodeFromString(EventPayload.serializer(), encoded) + assertEquals(sample, decoded) + } + + @Test + fun `decodes hand-written StaticAnalysisCompleted JSON`() { + val json = """ + {"type":"StaticAnalysisCompleted","sessionId":"s","stageId":"implementer", + "findings":[{"command":"./gradlew detekt","exitCode":2,"summary":"3 violations"}]} + """.trimIndent() + val decoded = eventJson.decodeFromString(EventPayload.serializer(), json) + assertTrue(decoded is StaticAnalysisCompletedEvent) + val finding = (decoded as StaticAnalysisCompletedEvent).findings.single() + assertEquals("./gradlew detekt", finding.command) + assertEquals(2, finding.exitCode) + assertFalse(finding.clean) + } + + @Test + fun `clean is derived from exit code zero`() { + assertTrue(StaticAnalysisFinding("c", 0, "").clean) + assertFalse(StaticAnalysisFinding("c", 1, "").clean) + } +} diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/OrchestratorEngines.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/OrchestratorEngines.kt index 0ef4393d..f00fee64 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/OrchestratorEngines.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/OrchestratorEngines.kt @@ -28,4 +28,7 @@ data class OrchestratorEngines( val workspacePolicy: WorkspacePolicy? = null, val worldProbe: WorldProbe = FileSystemWorldProbe(), val workspaceToolRegistryProvider: WorkspaceToolRegistryProvider? = null, + // Runs operator-configured static-analysis commands as a harness step (role-reliability §5). + // Null = no static-first step; a stage declaring `static_analysis` then no-ops with a warning. + val staticAnalysisRunner: StaticAnalysisRunner? = null, ) diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/ProcessStaticAnalysisRunner.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/ProcessStaticAnalysisRunner.kt new file mode 100644 index 00000000..0fa225a6 --- /dev/null +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/ProcessStaticAnalysisRunner.kt @@ -0,0 +1,55 @@ +package com.correx.core.kernel.orchestration + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.TimeoutCancellationException +import kotlinx.coroutines.async +import kotlinx.coroutines.withContext +import kotlinx.coroutines.withTimeout +import java.nio.file.Path + +/** + * Process-spawning [StaticAnalysisRunner]: splits each operator-configured command on whitespace + * into argv (no shell), runs it in the workspace root with stderr merged into stdout, and returns + * its exit code with the combined output. A spawn failure or timeout is reported as a non-zero exit + * so the gate treats a hung or unrunnable check as a failure rather than silently passing. + * + * Commands come from the operator's workflow config (trusted, like prompt files), not from the LLM, + * so they bypass the tool-approval pipeline by design. Mirrors the streaming/timeout shape of + * ShellTool to avoid pipe-buffer deadlock on large output. + */ +class ProcessStaticAnalysisRunner( + private val timeoutMs: Long = DEFAULT_TIMEOUT_MS, +) : StaticAnalysisRunner { + + override suspend fun run(workingDir: Path, command: String): StaticAnalysisRunResult = + withContext(Dispatchers.IO) { + val argv = command.trim().split(WHITESPACE).filter { it.isNotEmpty() } + if (argv.isEmpty()) return@withContext StaticAnalysisRunResult(EXIT_NOT_RUN, "empty command") + val process = runCatching { + ProcessBuilder(argv).directory(workingDir.toFile()).redirectErrorStream(true).start() + }.getOrElse { + return@withContext StaticAnalysisRunResult(EXIT_NOT_RUN, "failed to start '$command': ${it.message}") + } + runCatching { + withTimeout(timeoutMs) { + val outputDeferred = async { process.inputStream.bufferedReader().use { it.readText() } } + val exit = process.waitFor() + StaticAnalysisRunResult(exit, outputDeferred.await()) + } + }.getOrElse { + process.destroyForcibly() + val reason = if (it is TimeoutCancellationException) { + "static analysis '$command' timed out after ${timeoutMs}ms" + } else { + it.message ?: "error running '$command'" + } + StaticAnalysisRunResult(EXIT_NOT_RUN, reason) + } + } + + private companion object { + const val DEFAULT_TIMEOUT_MS = 300_000L + const val EXIT_NOT_RUN = -1 + val WHITESPACE = Regex("\\s+") + } +} diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt index ae0fa54b..64565a90 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt @@ -30,6 +30,8 @@ import com.correx.core.events.events.ArtifactContentStoredEvent import com.correx.core.events.events.ArtifactCreatedEvent import com.correx.core.events.events.BriefEchoMismatchEvent import com.correx.core.events.events.BriefGroundingCheckedEvent +import com.correx.core.events.events.StaticAnalysisCompletedEvent +import com.correx.core.events.events.StaticAnalysisFinding import com.correx.core.events.events.ContextTruncatedEvent import com.correx.core.events.events.ClarificationAnswer import com.correx.core.events.events.ClarificationAnsweredEvent @@ -151,6 +153,11 @@ private const val HTTP_TOO_MANY_REQUESTS = 429 // Cap on clarification rounds per stage, so a stage that keeps re-asking eventually proceeds. private const val MAX_CLARIFICATION_ROUNDS = 3 +// Static-analysis output caps: the tail retained in the recorded event vs. the (larger) slice fed +// back to the model so it can fix the failure. Tails, because the error summary sits at the end. +private const val STATIC_ANALYSIS_SUMMARY_CAP = 2_000 +private const val STATIC_ANALYSIS_FEEDBACK_CAP = 6_000 + @SuppressWarnings( "ForbiddenComment", "UnusedParameter", @@ -182,6 +189,7 @@ abstract class SessionOrchestrator( private val toolCallAssessor: ToolCallAssessor? = engines.toolCallAssessor private val workspacePolicy: WorkspacePolicy? = engines.workspacePolicy private val worldProbe: WorldProbe = engines.worldProbe + private val staticAnalysisRunner: StaticAnalysisRunner? = engines.staticAnalysisRunner private val workspaceToolRegistryProvider: WorkspaceToolRegistryProvider? = engines.workspaceToolRegistryProvider private val inferenceRepository: InferenceRepository = repositories.inferenceRepository internal val orchestrationRepository: OrchestrationRepository = repositories.orchestrationRepository @@ -549,15 +557,7 @@ abstract class SessionOrchestrator( is StageExecutionResult.Success -> { emitToolArtifacts(sessionId, stageId, stageConfig) emitLlmArtifacts(sessionId, stageId, stageConfig) - when (val produced = verifyProduces(sessionId, stageId, stageConfig)) { - is StageExecutionResult.Success -> - when (val grounded = groundBriefReferences(sessionId, stageId, stageConfig, effectives)) { - is StageExecutionResult.Success -> - checkBriefEcho(sessionId, stageId, stageConfig) - is StageExecutionResult.Failure -> grounded - } - is StageExecutionResult.Failure -> produced - } + runPostStageGates(sessionId, stageId, stageConfig, effectives) } is StageExecutionResult.Failure -> outcome @@ -1468,6 +1468,88 @@ abstract class SessionOrchestrator( } } + /** + * Post-validation stage gates, run in order; the first failure short-circuits and is returned. + * Cheap deterministic checks run first (produces presence, brief grounding, brief echo), then + * the heavier process-spawning static-analysis step last, so it only runs once a stage is + * otherwise sound. + */ + private suspend fun runPostStageGates( + sessionId: SessionId, + stageId: StageId, + stageConfig: StageConfig, + effectives: RunEffectives, + ): StageExecutionResult { + val gates: List StageExecutionResult> = listOf( + { verifyProduces(sessionId, stageId, stageConfig) }, + { groundBriefReferences(sessionId, stageId, stageConfig, effectives) }, + { checkBriefEcho(sessionId, stageId, stageConfig) }, + { runStaticAnalysis(sessionId, stageId, stageConfig, effectives) }, + ) + for (gate in gates) { + val result = gate() + if (result is StageExecutionResult.Failure) return result + } + return StageExecutionResult.Success(emptyList()) + } + + /** + * Static-first reviewer gate (role-reliability §5): for a stage that declares `static_analysis` + * commands, run them (compiler / detekt / formatters) against its just-produced output in the + * workspace root. Every result is recorded in a [StaticAnalysisCompletedEvent] — an environment + * observation captured at run time (invariant #9), so replay reads it back and never re-runs. + * + * A non-clean command fails the stage retryably with its output fed back verbatim (§2: compiler/ + * test output is ground truth, not LLM noise), so the producing stage fixes the issue and only + * static-clean output ever reaches the downstream LLM reviewer — its context thereby excludes + * anything static tools already catch, with no parsing or context plumbing. + */ + private suspend fun runStaticAnalysis( + sessionId: SessionId, + stageId: StageId, + stageConfig: StageConfig, + effectives: RunEffectives, + ): StageExecutionResult { + if (stageConfig.staticAnalysis.isEmpty()) return StageExecutionResult.Success(emptyList()) + val runner = staticAnalysisRunner + val workspaceRoot = effectives.policy?.workspaceRoot + if (runner == null || workspaceRoot == null) { + log.warn( + "[Orchestrator] stage declares static_analysis but no {} is wired — skipping " + + "session={} stage={}", + if (runner == null) "runner" else "workspace root", + sessionId.value, stageId.value, + ) + return StageExecutionResult.Success(emptyList()) + } + + val results = stageConfig.staticAnalysis.map { command -> + val run = runner.run(workspaceRoot, command) + StaticAnalysisFinding( + command = command, + exitCode = run.exitCode, + summary = run.output.takeLast(STATIC_ANALYSIS_SUMMARY_CAP), + ) to run.output + } + emit(sessionId, StaticAnalysisCompletedEvent(sessionId, stageId, results.map { it.first })) + + val failures = results.filterNot { it.first.clean } + if (failures.isEmpty()) return StageExecutionResult.Success(emptyList()) + + log.warn( + "[Orchestrator] static analysis failed session={} stage={} commands={}", + sessionId.value, stageId.value, + failures.joinToString(", ") { it.first.command }, + ) + val detail = failures.joinToString("\n\n") { (finding, output) -> + "$ ${finding.command} (exit ${finding.exitCode})\n${output.takeLast(STATIC_ANALYSIS_FEEDBACK_CAP)}" + } + return StageExecutionResult.Failure( + "stage ${stageId.value} did not pass static analysis. Fix these before review:\n\n$detail", + retryable = true, + ) + } + private suspend fun emitProcessResultEvents( sessionId: SessionId, stageId: StageId, diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/StaticAnalysisRunner.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/StaticAnalysisRunner.kt new file mode 100644 index 00000000..bb0e809b --- /dev/null +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/StaticAnalysisRunner.kt @@ -0,0 +1,22 @@ +package com.correx.core.kernel.orchestration + +import java.nio.file.Path + +/** Result of running one static-analysis command: its exit code and combined stdout+stderr. */ +data class StaticAnalysisRunResult( + val exitCode: Int, + val output: String, +) + +/** + * Seam for running operator-configured static-analysis commands (compiler / detekt / formatters) + * as a deterministic harness step (role-reliability §5 static-first ordering). Implemented by a + * process-spawning adapter in infrastructure and injected, so the deterministic core never spawns + * processes itself and the gate stays unit-testable with a fake. + * + * The orchestrator records each result as a [com.correx.core.events.events.StaticAnalysisCompletedEvent] + * (invariant #9 environment observation) — replay reads the recorded facts and never re-runs the command. + */ +fun interface StaticAnalysisRunner { + suspend fun run(workingDir: Path, command: String): StaticAnalysisRunResult +} diff --git a/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/ProcessStaticAnalysisRunnerTest.kt b/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/ProcessStaticAnalysisRunnerTest.kt new file mode 100644 index 00000000..37cdc32c --- /dev/null +++ b/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/ProcessStaticAnalysisRunnerTest.kt @@ -0,0 +1,54 @@ +package com.correx.core.kernel.orchestration + +import kotlinx.coroutines.runBlocking +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test +import java.nio.file.Path +import kotlin.io.path.createTempDirectory +import kotlin.io.path.writeText + +class ProcessStaticAnalysisRunnerTest { + + private val runner = ProcessStaticAnalysisRunner() + + private fun script(dir: Path, name: String, body: String): String { + val file = dir.resolve(name) + file.writeText("#!/bin/sh\n$body\n") + file.toFile().setExecutable(true) + return "./$name" + } + + @Test + fun `clean command reports exit zero and captures output`() = runBlocking { + val dir = createTempDirectory("sa-ok") + val cmd = script(dir, "ok.sh", "echo all-good") + val result = runner.run(dir, cmd) + assertEquals(0, result.exitCode) + assertTrue(result.output.contains("all-good"), "output: ${result.output}") + } + + @Test + fun `failing command reports nonzero exit and captures stderr`() = runBlocking { + val dir = createTempDirectory("sa-fail") + // stderr is merged into the captured output (redirectErrorStream). + val cmd = script(dir, "bad.sh", "echo boom 1>&2\nexit 2") + val result = runner.run(dir, cmd) + assertEquals(2, result.exitCode) + assertTrue(result.output.contains("boom"), "output: ${result.output}") + } + + @Test + fun `unrunnable command is a failure, not a silent pass`() = runBlocking { + val dir = createTempDirectory("sa-missing") + val result = runner.run(dir, "./does-not-exist-xyz") + assertTrue(result.exitCode != 0, "exit: ${result.exitCode}") + } + + @Test + fun `blank command is a failure`() = runBlocking { + val dir = createTempDirectory("sa-blank") + val result = runner.run(dir, " ") + assertTrue(result.exitCode != 0, "exit: ${result.exitCode}") + } +} diff --git a/core/transitions/src/main/kotlin/com/correx/core/transitions/graph/StageConfig.kt b/core/transitions/src/main/kotlin/com/correx/core/transitions/graph/StageConfig.kt index a63872bc..2738ce51 100644 --- a/core/transitions/src/main/kotlin/com/correx/core/transitions/graph/StageConfig.kt +++ b/core/transitions/src/main/kotlin/com/correx/core/transitions/graph/StageConfig.kt @@ -23,5 +23,10 @@ data class StageConfig( // the declared set is scope creep — the dominant local-implementer failure — and is // blocked by the plane-2 ManifestContainmentRule. val writeManifest: List = emptyList(), + // Operator-configured static-analysis commands (compiler/detekt/formatters) run as a + // deterministic harness step after this stage produces its output (role-reliability §5). + // A non-clean command fails the stage retryably with its output fed back, so only + // static-clean output reaches the downstream reviewer. Empty = no static-first step. + val staticAnalysis: List = emptyList(), val metadata: Map = emptyMap(), ) diff --git a/examples/workflows/role_pipeline.toml b/examples/workflows/role_pipeline.toml index 9c3380e5..d2289279 100644 --- a/examples/workflows/role_pipeline.toml +++ b/examples/workflows/role_pipeline.toml @@ -75,12 +75,20 @@ max_retries = 2 # stage may write — a FILE_WRITE outside it is blocked as scope creep. Left open # here because the targets are task-specific; a task-scoped workflow would set e.g. # writes = ["core/sessions/**", "testing/sessions/**"] +# Optional: `static_analysis` runs deterministic tools (compiler/detekt/formatters) +# against the patch BEFORE the reviewer (role-reliability §5). A non-clean command +# fails this stage retryably with its output fed back verbatim, so only static-clean +# code reaches the reviewer — the reviewer never spends inference on what tools catch +# for free. Commands are workspace-specific, so left commented; a Kotlin/Gradle repo +# would set e.g. +# static_analysis = ["./gradlew compileKotlin -q", "./gradlew detekt -q"] [[stages]] id = "implementer" prompt = "prompts/implementer.md" needs = ["impl_plan"] produces = [{ name = "patch", kind = "file_written" }] allowed_tools = ["file_read", "file_write", "file_edit", "ShellTool"] +# static_analysis = ["./gradlew compileKotlin -q", "./gradlew detekt -q"] token_budget = 32768 max_retries = 3 diff --git a/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/TomlWorkflowLoader.kt b/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/TomlWorkflowLoader.kt index 857de55d..a51a3de4 100644 --- a/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/TomlWorkflowLoader.kt +++ b/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/TomlWorkflowLoader.kt @@ -42,6 +42,7 @@ private data class StageSection( val needs: List = emptyList(), @param:JsonProperty("allowed_tools") val allowedTools: List = emptyList(), val writes: List = emptyList(), + @param:JsonProperty("static_analysis") val staticAnalysis: List = emptyList(), @param:JsonProperty("token_budget") val tokenBudget: Int = 4096, @param:JsonProperty("max_retries") val maxRetries: Int = 3, @param:JsonProperty("requires_approval") val requiresApproval: Boolean = false, @@ -106,6 +107,7 @@ class TomlWorkflowLoader( needs = s.needs.map { ArtifactId(it) }.toSet(), allowedTools = s.allowedTools.toSet(), writeManifest = s.writes, + staticAnalysis = s.staticAnalysis, tokenBudget = s.tokenBudget, // Propagate the declared token budget to the inference completion cap. // Without this the StageConfig default (maxTokens=2048) is used, truncating diff --git a/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/TomlWorkflowLoaderTest.kt b/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/TomlWorkflowLoaderTest.kt index 5e481c00..b5708af6 100644 --- a/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/TomlWorkflowLoaderTest.kt +++ b/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/TomlWorkflowLoaderTest.kt @@ -156,6 +156,25 @@ class TomlWorkflowLoaderTest { assertEquals(emptyList(), collectStage.writeManifest) } + @Test + fun `static_analysis maps to the stage static-analysis commands`() { + val toml = validToml.replace( + "allowed_tools = [\"ShellTool\"]", + "allowed_tools = [\"ShellTool\"]\nstatic_analysis = [\"./gradlew compileKotlin\", \"./gradlew detekt\"]", + ) + val path = Files.createTempFile("workflow", ".toml").also { it.writeText(toml) } + val graph = loader.load(path) + val collectStage = graph.stages[graph.start]!! + assertEquals(listOf("./gradlew compileKotlin", "./gradlew detekt"), collectStage.staticAnalysis) + } + + @Test + fun `static_analysis absent means no commands`() { + val path = Files.createTempFile("workflow", ".toml").also { it.writeText(validToml) } + val graph = loader.load(path) + assertEquals(emptyList(), graph.stages[graph.start]!!.staticAnalysis) + } + @Test fun `ground_references true maps to metadata key`() { val toml = validToml.replace(