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
@@ -0,0 +1,42 @@
package com.correx.core.events.events
import com.correx.core.events.types.SessionId
import com.correx.core.events.types.StageId
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/** The outcome of evaluating one contract assertion against a stage's produced file. */
@Serializable
data class ContractAssertionResult(
val assertionId: String,
/** Workspace-relative path the assertion was evaluated against. */
val target: String,
/** Contract layer the assertion came from: DERIVED / FRAMEWORK / PLANNER / HUMAN. */
val layer: String,
/** Evaluator that produced the verdict: FS / TEXT / COMPILER / AST. */
val evaluator: String,
val passed: Boolean,
/** Concrete reason the assertion failed (or a confirmation when it passed). Kept bounded. */
val evidence: String,
)
/**
* Environment observation (invariant #9): the Gate 2 contract assertions evaluated against a
* stage's produced files — each with the layer it came from, the evaluator, its verdict and
* evidence — captured at the moment the stage completed. Replay reads these recorded facts and
* never re-inspects the filesystem.
*
* Gate 2 (design §1) answers "did the stage accomplish its objective?" deterministically, over the
* files the stage declared it `produces`. A failed assertion fails the producing stage retryably,
* with the failing assertion ids + evidence fed back verbatim — so hand-back feedback is
* failing-test-name granular, not a vague "review said fix it".
*/
@Serializable
@SerialName("ContractGateEvaluated")
data class ContractGateEvaluatedEvent(
val sessionId: SessionId,
val stageId: StageId,
val results: List<ContractAssertionResult>,
) : EventPayload {
val passed: Boolean get() = results.all { it.passed }
}
@@ -15,6 +15,7 @@ import com.correx.core.events.events.ArtifactValidatingEvent
import com.correx.core.events.events.BriefEchoMismatchEvent
import com.correx.core.events.events.BriefGroundingCheckedEvent
import com.correx.core.events.events.StaticAnalysisCompletedEvent
import com.correx.core.events.events.ContractGateEvaluatedEvent
import com.correx.core.events.events.PlanLintCompletedEvent
import com.correx.core.events.events.ChatSessionStartedEvent
import com.correx.core.events.events.SessionWorkingTaskEvent
@@ -148,6 +149,7 @@ val eventModule = SerializersModule {
subclass(BriefGroundingCheckedEvent::class)
subclass(BriefEchoMismatchEvent::class)
subclass(StaticAnalysisCompletedEvent::class)
subclass(ContractGateEvaluatedEvent::class)
subclass(PlanLintCompletedEvent::class)
subclass(RiskAssessedEvent::class)
subclass(ChatSessionStartedEvent::class)