feat(validation): staged-verification funnel — contract + execution gates
Gate 2 (contract) and Gate 4 (execution) of the 4-gate staged-verification design, plus the two gate-quality fixes found in live QA. - Gate 2: KindContractTable (kind→assertions registry, universal floor, additive project overrides) + KindInference (path→kind, pure/replay-safe) + ContractAssertion model; ContractGateEvaluatedEvent (registered); ContractAssertionEvaluator seam + FileSystemContractEvaluator (FS/TEXT, COMPILER skipped); runContractGate in runPostStageGates — a stage that produces file_written artifacts must have every declared+written file exist/non-empty/parse, else retryable hand-back with per-assertion feedback. - Option A: ExecutionPlanCompiler puts concrete (non-glob) plan `writes` into StageConfig.expectedFiles, so a declared-but-unwritten file fails file_exists. - Gate 4: BuildExpectation enum (none|module|project|tests) + plan field + closed-set compiler validation; runExecutionGate resolves the alias against the bound project profile commands and runs it as a deterministic gate. - Live-QA fixes: react_entry kind so main.tsx/index.tsx aren't required to export a default component (was an unwinnable false-positive); JSONC tolerance (allowComments/allowTrailingComma) so tsconfig.json passes valid_json. Seams are null on the replay harness (no-op) — recorded events are truth (#9).
This commit is contained in:
+13
@@ -5,6 +5,7 @@ import com.correx.core.artifacts.kind.TypedArtifactSlot
|
||||
import com.correx.core.events.types.ArtifactId
|
||||
import com.correx.core.events.types.StageId
|
||||
import com.correx.core.events.types.TransitionId
|
||||
import com.correx.core.transitions.graph.BuildExpectation
|
||||
import com.correx.core.transitions.graph.StageConfig
|
||||
import com.correx.core.transitions.graph.TransitionEdge
|
||||
import com.correx.core.transitions.graph.WorkflowGraph
|
||||
@@ -58,11 +59,21 @@ class ExecutionPlanCompiler(
|
||||
staticManifest = emptyList(),
|
||||
planDerived = PlanDerivedManifest.deriveAllowedWrites(s),
|
||||
).sorted()
|
||||
val buildExpectation = BuildExpectation.fromPlan(s.buildExpectation)
|
||||
?: throw WorkflowValidationException(
|
||||
"stage '${s.id}' has invalid build_expectation '${s.buildExpectation}' — " +
|
||||
"use one of none|module|project|tests",
|
||||
)
|
||||
StageId(s.id) to StageConfig(
|
||||
produces = listOf(TypedArtifactSlot(name = ArtifactId(s.produces), kind = kind)),
|
||||
needs = s.needs.map { ArtifactId(it) }.toSet(),
|
||||
allowedTools = s.tools.toSet(),
|
||||
writeManifest = writeManifest,
|
||||
// Option A: the concrete (non-glob) subset of the declared writes are expected files
|
||||
// the contract gate will require to exist. Globs (src/**) are write-permission scopes,
|
||||
// not existence guarantees, so they are excluded here.
|
||||
expectedFiles = s.writes.map { it.trim() }.filter { it.isNotEmpty() && !isGlob(it) },
|
||||
buildExpectation = buildExpectation,
|
||||
tokenBudget = DEFAULT_STAGE_TOKEN_BUDGET,
|
||||
metadata = mapOf("promptInline" to s.prompt),
|
||||
)
|
||||
@@ -134,6 +145,8 @@ class ExecutionPlanCompiler(
|
||||
}
|
||||
}
|
||||
|
||||
private fun isGlob(path: String): Boolean = path.any { it == '*' || it == '?' || it == '[' }
|
||||
|
||||
private fun validateReachability(declared: Set<String>, transitions: Set<TransitionEdge>, start: StageId) {
|
||||
// A declared stage with no path from start can never run — typical LLM topology
|
||||
// failure: every stage wired straight to "done" instead of chained, which silently
|
||||
|
||||
+4
@@ -21,6 +21,10 @@ data class PlanStage(
|
||||
// contribution (the static manifest, if any, still applies). The planner emits this under
|
||||
// the JSON key `writes`.
|
||||
val writes: List<String> = emptyList(),
|
||||
// Execution-gate scope (staged-verification §2): none|module|project|tests. Absent = none. The
|
||||
// planner emits this under the JSON key `build_expectation`; it resolves at runtime to the
|
||||
// project profile's typecheck/build/test command run as a deterministic Gate 4.
|
||||
@param:JsonProperty("build_expectation") val buildExpectation: String? = null,
|
||||
)
|
||||
|
||||
data class PlanEdge(
|
||||
|
||||
+40
@@ -322,4 +322,44 @@ class ExecutionPlanCompilerTest {
|
||||
val graph = compiler.compile(plan, "unvalidated-workflow")
|
||||
assertEquals(2, graph.stages.size)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Option A - concrete declared writes become expectedFiles, globs excluded`() {
|
||||
val plan = """
|
||||
{
|
||||
"goal": "x", "stages": [
|
||||
{ "id": "impl", "prompt": "p", "produces": "patch", "tools": ["file_write"],
|
||||
"writes": ["src/App.tsx", "src/hooks/**", "package.json"] }
|
||||
],
|
||||
"edges": [ { "from": "impl", "to": "done", "condition": { "type": "always_true" } } ]
|
||||
}
|
||||
""".trimIndent()
|
||||
val stage = compiler.compile(plan, "wf").stages.values.single()
|
||||
assertEquals(setOf("src/App.tsx", "package.json"), stage.expectedFiles.toSet())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `build_expectation is parsed into the closed-set enum`() {
|
||||
val plan = validPlan.replace(
|
||||
"\"tools\": [\"ShellTool\"]",
|
||||
"\"tools\": [\"ShellTool\"], \"build_expectation\": \"project\"",
|
||||
)
|
||||
val stage = compiler.compile(plan, "wf").stages[compiler.compile(plan, "wf").start]!!
|
||||
assertEquals(com.correx.core.transitions.graph.BuildExpectation.PROJECT, stage.buildExpectation)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `absent build_expectation defaults to NONE`() {
|
||||
val stage = compiler.compile(validPlan, "wf").stages.values.first()
|
||||
assertEquals(com.correx.core.transitions.graph.BuildExpectation.NONE, stage.buildExpectation)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `invalid build_expectation is rejected at compile time`() {
|
||||
val bad = validPlan.replace(
|
||||
"\"tools\": [\"ShellTool\"]",
|
||||
"\"tools\": [\"ShellTool\"], \"build_expectation\": \"deploy\"",
|
||||
)
|
||||
assertThrows<WorkflowValidationException> { compiler.compile(bad, "wf") }
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user