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:
2026-07-06 01:25:51 +04:00
parent 33ea44d8cc
commit ff1a0ffdad
18 changed files with 935 additions and 5 deletions
@@ -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
@@ -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(
@@ -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") }
}
}