fix(build-gate): resolve commands by toolchain (#40)
This commit is contained in:
@@ -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"))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
+20
@@ -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)
|
||||||
|
}
|
||||||
+4
-2
@@ -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())
|
||||||
}
|
}
|
||||||
|
|||||||
+7
@@ -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."
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+14
-1
@@ -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()) {
|
||||||
|
|||||||
+32
@@ -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"))
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user