From 6e844ef1e142f795e18fffd53b2ab7323e0c1a17 Mon Sep 17 00:00:00 2001 From: kami Date: Tue, 21 Jul 2026 02:18:04 +0400 Subject: [PATCH] fix(build-gate): resolve commands by toolchain (#40) --- core/artifacts/AGENTS.md | 1 + .../core/artifacts/kind/KindContractTable.kt | 12 +++++++ .../artifacts/kind/KindContractTableTest.kt | 9 ++++++ core/config/AGENTS.md | 1 + .../core/config/ProjectProfileLoader.kt | 8 ++++- .../core/config/ProjectProfileWriter.kt | 14 ++++++-- .../core/config/ProjectProfileLoaderTest.kt | 25 +++++++++++++++ .../core/config/ProjectProfileWriterTest.kt | 11 +++++++ core/kernel/AGENTS.md | 1 + .../orchestration/BuildGateToolchain.kt | 20 ++++++++++++ .../SessionOrchestratorGates2.kt | 6 ++-- .../BuildPrerequisiteDecisionTest.kt | 7 ++++ core/transitions/AGENTS.md | 1 + .../transitions/graph/BuildExpectation.kt | 15 ++++++++- .../transitions/graph/BuildExpectationTest.kt | 32 +++++++++++++++++++ 15 files changed, 157 insertions(+), 6 deletions(-) create mode 100644 core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/BuildGateToolchain.kt create mode 100644 core/transitions/src/test/kotlin/com/correx/core/transitions/graph/BuildExpectationTest.kt diff --git a/core/artifacts/AGENTS.md b/core/artifacts/AGENTS.md index 07d1b80e..6a506f8a 100644 --- a/core/artifacts/AGENTS.md +++ b/core/artifacts/AGENTS.md @@ -16,6 +16,7 @@ CORREX kernel team. - `ArtifactState` rebuilt from events via `DefaultArtifactReducer` (in `core:events` `ArtifactEvents.kt`) + `ArtifactProjector`. - `TypedArtifactSlot` — typed accessor for well-known artifact slots. - `ArtifactSerializationModule` — registers artifact polymorphic types; must be included in serialization setup. +- `KindContractTable.toolchainFor` maps checked-in Node and JVM kinds to the execution-gate command namespace. - Hard Invariant #1: artifact state is always rebuilt from events. `ArtifactRepository` wraps `EventReplayer`. ## Work Guidance diff --git a/core/artifacts/src/main/kotlin/com/correx/core/artifacts/kind/KindContractTable.kt b/core/artifacts/src/main/kotlin/com/correx/core/artifacts/kind/KindContractTable.kt index 0929652a..8db21487 100644 --- a/core/artifacts/src/main/kotlin/com/correx/core/artifacts/kind/KindContractTable.kt +++ b/core/artifacts/src/main/kotlin/com/correx/core/artifacts/kind/KindContractTable.kt @@ -18,6 +18,12 @@ package com.correx.core.artifacts.kind */ object KindContractTable { + /** Toolchain selected by the build gate for a produced source or manifest kind. */ + enum class Toolchain(val profileKey: String) { + NODE("node"), + JVM("jvm"), + } + /** A single assertion template — a kind's requirement before it is bound to a concrete path. */ private data class Template( val id: String, @@ -133,6 +139,9 @@ object KindContractTable { private val CHECKED_IN: Map> = TS_REACT + KOTLIN + GENERIC + private val TOOLCHAINS = TS_REACT.keys.associateWith { Toolchain.NODE } + + KOTLIN.keys.associateWith { Toolchain.JVM } + /** Project-supplied additive overrides, keyed by kind id. Set at wiring time; empty by default. */ @Volatile var projectOverrides: Map> = emptyMap() @@ -157,5 +166,8 @@ object KindContractTable { return (base + overrides).filterNot { it.id == "file_nonempty" && isDotfile(path) } } + /** The build toolchain implied by a checked-in kind, or null for stack-neutral kinds. */ + fun toolchainFor(kindId: String): Toolchain? = TOOLCHAINS[kindId] + private fun isDotfile(path: String): Boolean = path.substringAfterLast('/').startsWith(".") } diff --git a/core/artifacts/src/test/kotlin/com/correx/core/artifacts/kind/KindContractTableTest.kt b/core/artifacts/src/test/kotlin/com/correx/core/artifacts/kind/KindContractTableTest.kt index 7bf51435..3a03c51c 100644 --- a/core/artifacts/src/test/kotlin/com/correx/core/artifacts/kind/KindContractTableTest.kt +++ b/core/artifacts/src/test/kotlin/com/correx/core/artifacts/kind/KindContractTableTest.kt @@ -101,4 +101,13 @@ class KindContractTableTest { assertTrue("contains" in ids, "override assertion must be appended") assertTrue("file_nonempty" in ids, "checked-in assertions remain") } + + @Test + fun `toolchain follows checked in source and manifest kinds`() { + assertEquals(KindContractTable.Toolchain.NODE, KindContractTable.toolchainFor("react_entry")) + assertEquals(KindContractTable.Toolchain.NODE, KindContractTable.toolchainFor("package_json")) + assertEquals(KindContractTable.Toolchain.JVM, KindContractTable.toolchainFor("kotlin_service")) + assertEquals(KindContractTable.Toolchain.JVM, KindContractTable.toolchainFor("gradle_module")) + assertEquals(null, KindContractTable.toolchainFor("docs")) + } } diff --git a/core/config/AGENTS.md b/core/config/AGENTS.md index de837e49..8d7c23a6 100644 --- a/core/config/AGENTS.md +++ b/core/config/AGENTS.md @@ -16,6 +16,7 @@ CORREX kernel team. Config schema changes affect all consumers — coordinate wi - `EditableConfig` — partial config for live patch operations (used by the TUI config editor). - `OperatorProfile` — operator-level persona and behavior settings. - `ProjectProfile` / `ProjectProfileLoader` / `ProjectProfileWriter` — project-scoped profile; loaded at session bind time. +- Project-profile commands support flat aliases plus toolchain-specific TOML tables such as `[commands.node]`; nested entries bind into replay-safe dotted keys (for example `node.build`). - `AgentInstructions` / `AgentInstructionsLoader` — per-role prompt fragments loaded from config. ## Work Guidance diff --git a/core/config/src/main/kotlin/com/correx/core/config/ProjectProfileLoader.kt b/core/config/src/main/kotlin/com/correx/core/config/ProjectProfileLoader.kt index 95f82143..3e07607c 100644 --- a/core/config/src/main/kotlin/com/correx/core/config/ProjectProfileLoader.kt +++ b/core/config/src/main/kotlin/com/correx/core/config/ProjectProfileLoader.kt @@ -23,7 +23,13 @@ object ProjectProfileLoader { return ProjectProfile( about = SimpleToml.asString(rootKeys["about"], ""), conventions = SimpleToml.asStringList(rootKeys["conventions"]), - commands = commandsSection.mapValues { (_, v) -> v.toString() }, + commands = commandsSection.mapValues { (_, v) -> v.toString() } + + sections.filterKeys { it.startsWith("commands.") } + .flatMap { (section, values) -> + val toolchain = section.removePrefix("commands.") + values.map { (alias, value) -> "$toolchain.$alias" to value.toString() } + } + .toMap(), ) } } diff --git a/core/config/src/main/kotlin/com/correx/core/config/ProjectProfileWriter.kt b/core/config/src/main/kotlin/com/correx/core/config/ProjectProfileWriter.kt index 40808484..e63af39f 100644 --- a/core/config/src/main/kotlin/com/correx/core/config/ProjectProfileWriter.kt +++ b/core/config/src/main/kotlin/com/correx/core/config/ProjectProfileWriter.kt @@ -26,13 +26,23 @@ object ProjectProfileWriter { b.append("conventions = ").append(list(profile.conventions)).append('\n') } - if (profile.commands.isNotEmpty()) { + val flatCommands = profile.commands.filterKeys { '.' !in it } + val scopedCommands = profile.commands.filterKeys { '.' in it } + .entries.groupBy({ it.key.substringBefore('.') }, { it.key.substringAfter('.') to it.value }) + if (flatCommands.isNotEmpty()) { if (b.isNotEmpty()) b.append('\n') b.append("[commands]\n") - profile.commands.forEach { (key, value) -> + flatCommands.forEach { (key, value) -> b.append(key).append(" = ").append(str(value)).append('\n') } } + scopedCommands.forEach { (toolchain, commands) -> + if (b.isNotEmpty()) b.append('\n') + b.append("[commands.").append(toolchain).append("]\n") + commands.forEach { (alias, value) -> + b.append(alias).append(" = ").append(str(value)).append('\n') + } + } return b.toString() } diff --git a/core/config/src/test/kotlin/com/correx/core/config/ProjectProfileLoaderTest.kt b/core/config/src/test/kotlin/com/correx/core/config/ProjectProfileLoaderTest.kt index 0f70c217..914e83c3 100644 --- a/core/config/src/test/kotlin/com/correx/core/config/ProjectProfileLoaderTest.kt +++ b/core/config/src/test/kotlin/com/correx/core/config/ProjectProfileLoaderTest.kt @@ -62,6 +62,31 @@ class ProjectProfileLoaderTest { assertEquals(mapOf("test" to "./gradlew check"), p.commands) } + @Test + fun `toolchain command tables are represented as dotted command keys`() { + val root = tempRoot() + Files.writeString( + Paths.get(root, ".correx", "project.toml"), + """ + [commands] + build = "./gradlew assemble" + + [commands.node] + build = "npm --prefix frontend run build" + test = "npm --prefix frontend test" + """.trimIndent(), + ) + + assertEquals( + mapOf( + "build" to "./gradlew assemble", + "node.build" to "npm --prefix frontend run build", + "node.test" to "npm --prefix frontend test", + ), + ProjectProfileLoader.load(root).commands, + ) + } + @Test fun `malformed file returns default without throwing`() { val root = tempRoot() diff --git a/core/config/src/test/kotlin/com/correx/core/config/ProjectProfileWriterTest.kt b/core/config/src/test/kotlin/com/correx/core/config/ProjectProfileWriterTest.kt index c8ab2ead..743d3e9d 100644 --- a/core/config/src/test/kotlin/com/correx/core/config/ProjectProfileWriterTest.kt +++ b/core/config/src/test/kotlin/com/correx/core/config/ProjectProfileWriterTest.kt @@ -24,6 +24,7 @@ class ProjectProfileWriterTest { commands = mapOf( "build" to "./gradlew build", "test" to "./gradlew check", + "node.build" to "npm --prefix frontend run build", ), ) @@ -36,6 +37,16 @@ class ProjectProfileWriterTest { assertEquals(profile, loaded) } + @Test + fun `writer serializes scoped commands as TOML subtables`() { + val serialized = ProjectProfileWriter.serialize( + ProjectProfile(commands = mapOf("node.build" to "npm run build")), + ) + + assertTrue(serialized.contains("[commands.node]")) + assertTrue(serialized.contains("build = \"npm run build\"")) + } + @Test fun `an empty profile serializes to an empty string and skips empty sections`() { val serialized = ProjectProfileWriter.serialize(ProjectProfile()) diff --git a/core/kernel/AGENTS.md b/core/kernel/AGENTS.md index df356a8b..e2a6f02d 100644 --- a/core/kernel/AGENTS.md +++ b/core/kernel/AGENTS.md @@ -20,6 +20,7 @@ CORREX kernel team. This is the integration point for all other `core/` modules. - `SubagentRunner` / `InSessionSubagentRunner` — runs sub-agent invocations within an active session. - `StaticAnalysisRunner` / `ProcessStaticAnalysisRunner` — runs static analysis tools and records results as events. - `LspDiagnosticsRunner` — injected pull-diagnostics seam; diagnostics are filtered to stage-written files, recorded, and enforced before build/review. +- Execution gates infer the written stage's Node/JVM toolchain from its recorded file kinds and prefer the matching bound-profile command namespace, falling back to flat aliases. - Review→rework loops use the configured three-cycle default, then route accumulated notes to recovery once and fail if the fixed DoD still cannot be approved. - `StageCheckpointReconciler` — reconciles checkpoint state across stage transitions. - Capability-gated failures first retry in place when the stage holds the required tool; an unchanged-fingerprint gate-budget exhaustion routes to the recovery/intent-holder stage when one is available, so capability possession alone cannot cause a frozen owner loop to fail the workflow. diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/BuildGateToolchain.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/BuildGateToolchain.kt new file mode 100644 index 00000000..81154eee --- /dev/null +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/BuildGateToolchain.kt @@ -0,0 +1,20 @@ +package com.correx.core.kernel.orchestration + +import com.correx.core.artifacts.kind.KindContractTable +import com.correx.core.artifacts.kind.KindInference +import com.correx.core.events.types.SessionId +import com.correx.core.events.types.StageId + +/** + * Recovers the produced kind from this stage's recorded file manifest, then maps it to the + * toolchain-specific build command. File writes are event facts, so this remains replay-safe. + */ +internal fun SessionOrchestrator.stageProducedToolchain( + sessionId: SessionId, + stageId: StageId, +): KindContractTable.Toolchain? = toolchainForPaths(stageWrittenPaths(sessionId, stageId)) + +internal fun toolchainForPaths(paths: List): KindContractTable.Toolchain? = + paths.asReversed().firstNotNullOfOrNull { path -> + KindInference.kindFor(path)?.let(KindContractTable::toolchainFor) + } diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorGates2.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorGates2.kt index 9f9969ee..5c9cd521 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorGates2.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestratorGates2.kt @@ -172,12 +172,14 @@ internal suspend fun SessionOrchestrator.runExecutionGate( val runner = staticAnalysisRunner val workspaceRoot = effectives.policy?.workspaceRoot if (runner == null || workspaceRoot == null) return StageExecutionResult.Success(emptyList()) - val command = profileCommands[alias] + val toolchain = stageProducedToolchain(sessionId, stageId)?.profileKey + val command = expectation.commandFor(profileCommands, toolchain) if (command.isNullOrBlank()) { log.warn( - "[Orchestrator] stage {} needs a {} build gate but project profile has no '{}' " + + "[Orchestrator] stage {} needs a {} build gate but project profile has no '{}'{} " + "command — skipping execution gate", stageId.value, expectation, alias, + toolchain?.let { " for toolchain '$it'" }.orEmpty(), ) return StageExecutionResult.Success(emptyList()) } diff --git a/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/BuildPrerequisiteDecisionTest.kt b/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/BuildPrerequisiteDecisionTest.kt index f3b36914..c98014aa 100644 --- a/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/BuildPrerequisiteDecisionTest.kt +++ b/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/BuildPrerequisiteDecisionTest.kt @@ -1,5 +1,6 @@ package com.correx.core.kernel.orchestration +import com.correx.core.artifacts.kind.KindContractTable import com.correx.core.events.events.BuildPrerequisiteBootstrapAttemptedEvent import com.correx.core.events.events.EventMetadata import com.correx.core.events.events.EventPayload @@ -17,6 +18,12 @@ import org.junit.jupiter.api.Test class BuildPrerequisiteDecisionTest { + @Test + fun `build gate toolchain follows the stage produced kind`() { + assertEquals(KindContractTable.Toolchain.NODE, toolchainForPaths(listOf("frontend/package.json"))) + assertEquals(KindContractTable.Toolchain.JVM, toolchainForPaths(listOf("core/kernel/FooService.kt"))) + } + private val reason = "stage impl repeatedly referenced missing build prerequisite 'frontend/package.json' " + "(3 blocked attempts). Create or repair the project setup before continuing." diff --git a/core/transitions/AGENTS.md b/core/transitions/AGENTS.md index a5b07e20..c5402de1 100644 --- a/core/transitions/AGENTS.md +++ b/core/transitions/AGENTS.md @@ -28,6 +28,7 @@ CORREX kernel team. - Stage events are recorded by `DefaultStageExecutionEventMapper`; the mapper must emit events for every outcome (success, failure, skip). - `WorkflowGraph` is built from config/TOML at session start; it is immutable during a session. - `StageConfig.autoBuildGate` is a compiler-set request for a runtime build gate; it is used on write-declaring freestyle stages only when no explicit build expectation exists. +- `BuildExpectation` resolves toolchain-scoped command aliases (`node.build`, `jvm.build`) before legacy flat aliases, keeping profile snapshots replayable. - Cycle detection (`CycleExtractor`) runs during graph validation (`core:validation`), not at runtime. ## Verification diff --git a/core/transitions/src/main/kotlin/com/correx/core/transitions/graph/BuildExpectation.kt b/core/transitions/src/main/kotlin/com/correx/core/transitions/graph/BuildExpectation.kt index 3d584085..bb8ba681 100644 --- a/core/transitions/src/main/kotlin/com/correx/core/transitions/graph/BuildExpectation.kt +++ b/core/transitions/src/main/kotlin/com/correx/core/transitions/graph/BuildExpectation.kt @@ -7,7 +7,10 @@ package com.correx.core.transitions.graph * stays stack-specific and operator-owned while the *scope* is a fixed vocabulary the planner can't * invent. * - * [commandAlias] names the `[project.commands]` alias to run for this scope (null = no gate): + * [commandAlias] names the `[commands]` alias to run for this scope (null = no gate). A + * toolchain-specific command is stored as `.` in the replay-bound profile map; + * this corresponds to TOML such as `[commands.node]`. The resolver prefers that scoped command and + * falls back to the flat alias for existing profiles. * NONE → nothing (early stages where the project is not yet runnable), MODULE → typecheck, * PROJECT → build, TESTS → test. A stage whose profile lacks the alias skips the gate with a warning * rather than failing — the vocabulary degrades safely when the operator hasn't configured commands. @@ -19,6 +22,16 @@ enum class BuildExpectation(val commandAlias: String?) { TESTS("test"), ; + /** + * Resolves this gate's command from a profile snapshot. The profile remains a flat map in the + * event vocabulary, so nested TOML command tables are represented by dotted keys such as + * `node.build`. This makes the toolchain choice replayable without changing session events. + */ + fun commandFor(profileCommands: Map, toolchain: String?): String? { + val alias = commandAlias ?: return null + return toolchain?.let { profileCommands["$it.$alias"] } ?: profileCommands[alias] + } + companion object { /** Parses a plan's `build_expectation` string; null for an unrecognized value so the compiler rejects it. */ fun fromPlan(raw: String?): BuildExpectation? = when (raw?.trim()?.lowercase()) { diff --git a/core/transitions/src/test/kotlin/com/correx/core/transitions/graph/BuildExpectationTest.kt b/core/transitions/src/test/kotlin/com/correx/core/transitions/graph/BuildExpectationTest.kt new file mode 100644 index 00000000..3b29bf1e --- /dev/null +++ b/core/transitions/src/test/kotlin/com/correx/core/transitions/graph/BuildExpectationTest.kt @@ -0,0 +1,32 @@ +package com.correx.core.transitions.graph + +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNull +import org.junit.jupiter.api.Test + +class BuildExpectationTest { + + @Test + fun `toolchain command overrides flat command for matching produced kind`() { + val commands = mapOf( + "build" to "./gradlew assemble", + "node.build" to "npm --prefix frontend run build", + ) + + assertEquals("npm --prefix frontend run build", BuildExpectation.PROJECT.commandFor(commands, "node")) + assertEquals("./gradlew assemble", BuildExpectation.PROJECT.commandFor(commands, "jvm")) + } + + @Test + fun `flat command remains compatible when a toolchain has no override`() { + assertEquals( + "./gradlew check", + BuildExpectation.TESTS.commandFor(mapOf("test" to "./gradlew check"), "jvm"), + ) + } + + @Test + fun `none has no command`() { + assertNull(BuildExpectation.NONE.commandFor(mapOf("build" to "ignored"), "node")) + } +}