fix(build-gate): resolve commands by toolchain (#40)

This commit is contained in:
kami
2026-07-21 02:18:04 +04:00
parent 119f59d637
commit 6e844ef1e1
15 changed files with 157 additions and 6 deletions
+1
View File
@@ -16,6 +16,7 @@ CORREX kernel team.
- `ArtifactState` rebuilt from events via `DefaultArtifactReducer` (in `core:events` `ArtifactEvents.kt`) + `ArtifactProjector`. - `ArtifactState` rebuilt from events via `DefaultArtifactReducer` (in `core:events` `ArtifactEvents.kt`) + `ArtifactProjector`.
- `TypedArtifactSlot<T>` — typed accessor for well-known artifact slots. - `TypedArtifactSlot<T>` — typed accessor for well-known artifact slots.
- `ArtifactSerializationModule` — registers artifact polymorphic types; must be included in serialization setup. - `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<ArtifactState>`. - Hard Invariant #1: artifact state is always rebuilt from events. `ArtifactRepository` wraps `EventReplayer<ArtifactState>`.
## Work Guidance ## Work Guidance
@@ -18,6 +18,12 @@ package com.correx.core.artifacts.kind
*/ */
object KindContractTable { 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. */ /** A single assertion template — a kind's requirement before it is bound to a concrete path. */
private data class Template( private data class Template(
val id: String, val id: String,
@@ -133,6 +139,9 @@ object KindContractTable {
private val CHECKED_IN: Map<String, List<Template>> = TS_REACT + KOTLIN + GENERIC private val CHECKED_IN: Map<String, List<Template>> = 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. */ /** Project-supplied additive overrides, keyed by kind id. Set at wiring time; empty by default. */
@Volatile @Volatile
var projectOverrides: Map<String, List<ContractAssertion>> = emptyMap() var projectOverrides: Map<String, List<ContractAssertion>> = emptyMap()
@@ -157,5 +166,8 @@ object KindContractTable {
return (base + overrides).filterNot { it.id == "file_nonempty" && isDotfile(path) } 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(".") private fun isDotfile(path: String): Boolean = path.substringAfterLast('/').startsWith(".")
} }
@@ -101,4 +101,13 @@ class KindContractTableTest {
assertTrue("contains" in ids, "override assertion must be appended") assertTrue("contains" in ids, "override assertion must be appended")
assertTrue("file_nonempty" in ids, "checked-in assertions remain") 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"))
}
} }
+1
View File
@@ -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). - `EditableConfig` — partial config for live patch operations (used by the TUI config editor).
- `OperatorProfile` — operator-level persona and behavior settings. - `OperatorProfile` — operator-level persona and behavior settings.
- `ProjectProfile` / `ProjectProfileLoader` / `ProjectProfileWriter` — project-scoped profile; loaded at session bind time. - `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. - `AgentInstructions` / `AgentInstructionsLoader` — per-role prompt fragments loaded from config.
## Work Guidance ## Work Guidance
@@ -23,7 +23,13 @@ object ProjectProfileLoader {
return ProjectProfile( return ProjectProfile(
about = SimpleToml.asString(rootKeys["about"], ""), about = SimpleToml.asString(rootKeys["about"], ""),
conventions = SimpleToml.asStringList(rootKeys["conventions"]), 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(),
) )
} }
} }
@@ -26,13 +26,23 @@ object ProjectProfileWriter {
b.append("conventions = ").append(list(profile.conventions)).append('\n') 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') if (b.isNotEmpty()) b.append('\n')
b.append("[commands]\n") b.append("[commands]\n")
profile.commands.forEach { (key, value) -> flatCommands.forEach { (key, value) ->
b.append(key).append(" = ").append(str(value)).append('\n') 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() return b.toString()
} }
@@ -62,6 +62,31 @@ class ProjectProfileLoaderTest {
assertEquals(mapOf("test" to "./gradlew check"), p.commands) 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 @Test
fun `malformed file returns default without throwing`() { fun `malformed file returns default without throwing`() {
val root = tempRoot() val root = tempRoot()
@@ -24,6 +24,7 @@ class ProjectProfileWriterTest {
commands = mapOf( commands = mapOf(
"build" to "./gradlew build", "build" to "./gradlew build",
"test" to "./gradlew check", "test" to "./gradlew check",
"node.build" to "npm --prefix frontend run build",
), ),
) )
@@ -36,6 +37,16 @@ class ProjectProfileWriterTest {
assertEquals(profile, loaded) 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 @Test
fun `an empty profile serializes to an empty string and skips empty sections`() { fun `an empty profile serializes to an empty string and skips empty sections`() {
val serialized = ProjectProfileWriter.serialize(ProjectProfile()) val serialized = ProjectProfileWriter.serialize(ProjectProfile())
+1
View File
@@ -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. - `SubagentRunner` / `InSessionSubagentRunner` — runs sub-agent invocations within an active session.
- `StaticAnalysisRunner` / `ProcessStaticAnalysisRunner` — runs static analysis tools and records results as events. - `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. - `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. - 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. - `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. - 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.
@@ -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<String>): KindContractTable.Toolchain? =
paths.asReversed().firstNotNullOfOrNull { path ->
KindInference.kindFor(path)?.let(KindContractTable::toolchainFor)
}
@@ -172,12 +172,14 @@ internal suspend fun SessionOrchestrator.runExecutionGate(
val runner = staticAnalysisRunner val runner = staticAnalysisRunner
val workspaceRoot = effectives.policy?.workspaceRoot val workspaceRoot = effectives.policy?.workspaceRoot
if (runner == null || workspaceRoot == null) return StageExecutionResult.Success(emptyList()) 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()) { if (command.isNullOrBlank()) {
log.warn( 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", "command — skipping execution gate",
stageId.value, expectation, alias, stageId.value, expectation, alias,
toolchain?.let { " for toolchain '$it'" }.orEmpty(),
) )
return StageExecutionResult.Success(emptyList()) return StageExecutionResult.Success(emptyList())
} }
@@ -1,5 +1,6 @@
package com.correx.core.kernel.orchestration 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.BuildPrerequisiteBootstrapAttemptedEvent
import com.correx.core.events.events.EventMetadata import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.EventPayload import com.correx.core.events.events.EventPayload
@@ -17,6 +18,12 @@ import org.junit.jupiter.api.Test
class BuildPrerequisiteDecisionTest { 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 = private val reason =
"stage impl repeatedly referenced missing build prerequisite 'frontend/package.json' " + "stage impl repeatedly referenced missing build prerequisite 'frontend/package.json' " +
"(3 blocked attempts). Create or repair the project setup before continuing." "(3 blocked attempts). Create or repair the project setup before continuing."
+1
View File
@@ -28,6 +28,7 @@ CORREX kernel team.
- Stage events are recorded by `DefaultStageExecutionEventMapper`; the mapper must emit events for every outcome (success, failure, skip). - 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. - `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. - `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. - Cycle detection (`CycleExtractor`) runs during graph validation (`core:validation`), not at runtime.
## Verification ## Verification
@@ -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 * stays stack-specific and operator-owned while the *scope* is a fixed vocabulary the planner can't
* invent. * 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 `<toolchain>.<alias>` 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, * 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 * 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. * 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"), 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<String, String>, toolchain: String?): String? {
val alias = commandAlias ?: return null
return toolchain?.let { profileCommands["$it.$alias"] } ?: profileCommands[alias]
}
companion object { companion object {
/** Parses a plan's `build_expectation` string; null for an unrecognized value so the compiler rejects it. */ /** 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()) { fun fromPlan(raw: String?): BuildExpectation? = when (raw?.trim()?.lowercase()) {
@@ -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"))
}
}