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,53 @@
package com.correx.core.artifacts.kind
/**
* Which mechanism evaluates an assertion. The *initial* choice is the cheapest that works;
* `AST`-tagged assertions ship as `TEXT` heuristics and are promoted to a real structured parse
* only when the benchmark projection shows the heuristic is too noisy (design §3.4). The id is
* stable across a promotion; only the evaluator strengthens.
*/
enum class AssertionEvaluator {
/** Filesystem stat/read — existence, non-empty. No parse. */
FS,
/** Regex / JSON parse — line-oriented, cheap. */
TEXT,
/** Read out of a compiler/linter run the stage already invokes (authoritative, whole-project). */
COMPILER,
/** Structured parse. Starts life as a `TEXT` heuristic; promoted on demand. */
AST,
}
/**
* The contract layer an assertion came from. Priority increases downward. Monotonicity is scoped
* to the LLM-authored layers: [DERIVED], [FRAMEWORK] and [PLANNER] may only strengthen the
* contract (they are concatenated, never edited). [HUMAN] is the single trusted layer with
* weakening authority (design §3.1). Enforced structurally — the planner emits only its own
* layer and has no syntax for removal — so weakening is inexpressible, not detected-and-rejected.
*/
enum class AssertionLayer {
DERIVED,
FRAMEWORK,
PLANNER,
HUMAN,
}
/**
* One deterministic (or, for [AssertionEvaluator.AST], deterministic-once-promoted) requirement on
* a stage's produced file. Instantiated from the [KindContractTable] against a concrete path.
*
* A contract assertion is Gate 2 (design §1): "did the stage accomplish its objective?" — answered
* by filesystem/AST inspection over what the stage declared it produces, never by an LLM. The
* result of evaluating each assertion is recorded in a
* [com.correx.core.events.events.ContractGateEvaluatedEvent] so hand-back feedback is
* failing-test-name granular.
*/
data class ContractAssertion(
val id: String,
val target: String,
val evaluator: AssertionEvaluator,
val layer: AssertionLayer = AssertionLayer.DERIVED,
val args: Map<String, String> = emptyMap(),
)
@@ -0,0 +1,150 @@
package com.correx.core.artifacts.kind
/**
* The checked-in `kind → assertions` registry (design doc 2026-07-06-kind-assertion-table.md).
*
* Most contracts are *derived*: a stage's `produces[].kind` maps to a fixed assertion set here, and
* the planner writes nothing. An unrecognized kind never yields an empty contract — it degrades to
* the [UNIVERSAL_FLOOR] (`file_exists · file_nonempty · parses`), so e.g. an empty `Sessions.tsx`
* fails regardless of table coverage.
*
* A project may supply an **additive** override ([projectOverrides]) — add assertions to a kind,
* never remove the checked-in ones (monotonic, one level up). Merge keeps the checked-in set and
* appends project entries not already present by id.
*
* This is deliberately data, not behaviour: it is environment-independent and must replay
* identically. Evaluation lives behind the `ContractAssertionEvaluator` seam in the kernel/
* infrastructure; this table only says *what* to check, never *how*.
*/
object KindContractTable {
/** A single assertion template — a kind's requirement before it is bound to a concrete path. */
private data class Template(val id: String, val evaluator: AssertionEvaluator, val args: Map<String, String> = emptyMap())
private val FLOOR = listOf(
Template("file_exists", AssertionEvaluator.FS),
Template("file_nonempty", AssertionEvaluator.FS),
Template("parses", AssertionEvaluator.COMPILER),
)
// TS / React ---------------------------------------------------------------------------------
private val TS_REACT = mapOf(
"react_hook" to FLOOR + listOf(
Template("imports_resolve", AssertionEvaluator.COMPILER),
Template("exports_hook", AssertionEvaluator.AST),
),
"react_component" to FLOOR + listOf(
Template("imports_resolve", AssertionEvaluator.COMPILER),
Template("exports_default_component", AssertionEvaluator.AST),
),
"react_view" to FLOOR + listOf(
Template("imports_resolve", AssertionEvaluator.COMPILER),
Template("exports_default_component", AssertionEvaluator.AST),
),
// App entry point (main.tsx / index.tsx): mounts the tree, exports nothing. The honest bar
// is parse + imports resolve; a default-export assertion here false-fails a correct file.
"react_entry" to FLOOR + listOf(
Template("imports_resolve", AssertionEvaluator.COMPILER),
),
"ts_module" to FLOOR + listOf(
Template("imports_resolve", AssertionEvaluator.COMPILER),
),
// pure type module: may legitimately export only types, which erase at runtime. `parses`
// (in the floor) is the honest bar; an export assertion here would false-fail.
"ts_types" to FLOOR,
"api_client" to FLOOR + listOf(
Template("imports_resolve", AssertionEvaluator.COMPILER),
Template("exports_symbol", AssertionEvaluator.COMPILER),
),
"html_entry" to listOf(
Template("file_exists", AssertionEvaluator.FS),
Template("file_nonempty", AssertionEvaluator.FS),
Template("contains", AssertionEvaluator.TEXT, mapOf("pattern" to "id=\"root\"")),
Template("contains", AssertionEvaluator.TEXT, mapOf("pattern" to "<script")),
),
"package_json" to listOf(
Template("file_exists", AssertionEvaluator.FS),
Template("valid_json", AssertionEvaluator.TEXT),
Template("json_has_key", AssertionEvaluator.TEXT, mapOf("key" to "name")),
Template("script_defined", AssertionEvaluator.TEXT, mapOf("name" to "build")),
),
"tsconfig" to listOf(
Template("file_exists", AssertionEvaluator.FS),
Template("valid_json", AssertionEvaluator.TEXT),
),
"vite_config" to FLOOR,
"css_entry" to listOf(
Template("file_exists", AssertionEvaluator.FS),
Template("file_nonempty", AssertionEvaluator.FS),
),
"router_config" to FLOOR + listOf(
Template("imports_resolve", AssertionEvaluator.COMPILER),
),
)
// Kotlin (correx itself) ---------------------------------------------------------------------
private val KOTLIN = mapOf(
// Promotes the CLAUDE.md SILENT FAILURE TRAP into a deterministic gate: every EventPayload
// subclass must be registered in Serialization.kt or deserialization fails silently at runtime.
"kotlin_event" to FLOOR + listOf(
Template("implements_interface", AssertionEvaluator.COMPILER, mapOf("interface" to "EventPayload")),
Template("registered_in_serialization", AssertionEvaluator.AST),
),
"kotlin_reducer" to FLOOR + listOf(
Template("implements_interface", AssertionEvaluator.COMPILER, mapOf("interface" to "Reducer")),
),
"kotlin_projector" to FLOOR + listOf(
Template("implements_interface", AssertionEvaluator.COMPILER, mapOf("interface" to "Projection")),
),
"kotlin_repository" to FLOOR,
"kotlin_service" to FLOOR,
"kotlin_test" to FLOOR + listOf(
Template("has_test", AssertionEvaluator.TEXT),
),
"gradle_module" to listOf(
Template("file_exists", AssertionEvaluator.FS),
Template("valid_json", AssertionEvaluator.TEXT),
),
)
// Generic / cross-stack ----------------------------------------------------------------------
private val GENERIC = mapOf(
"config" to FLOOR,
"json_config" to listOf(
Template("file_exists", AssertionEvaluator.FS),
Template("valid_json", AssertionEvaluator.TEXT),
),
"migration" to listOf(
Template("file_exists", AssertionEvaluator.FS),
Template("migration_present", AssertionEvaluator.TEXT),
),
"toolchain" to listOf(
Template("toolchain_installed", AssertionEvaluator.FS),
),
"docs" to listOf(
Template("file_exists", AssertionEvaluator.FS),
Template("file_nonempty", AssertionEvaluator.FS),
),
)
private val CHECKED_IN: Map<String, List<Template>> = TS_REACT + KOTLIN + GENERIC
/** Project-supplied additive overrides, keyed by kind id. Set at wiring time; empty by default. */
@Volatile
var projectOverrides: Map<String, List<ContractAssertion>> = emptyMap()
/**
* The derived-layer contract for a produced file of [kindId] at [path]. Falls back to the
* universal floor for an unknown kind, then appends any project override assertions not already
* present by id (additive/monotonic).
*/
fun assertionsFor(kindId: String, path: String): List<ContractAssertion> {
val base = (CHECKED_IN[kindId] ?: FLOOR).map {
ContractAssertion(it.id, path, it.evaluator, AssertionLayer.DERIVED, it.args)
}
val overrides = projectOverrides[kindId].orEmpty()
.filter { ov -> base.none { it.id == ov.id } }
.map { it.copy(target = path) }
return base + overrides
}
}
@@ -0,0 +1,64 @@
package com.correx.core.artifacts.kind
/**
* Infers a [KindContractTable] key from a written file's workspace-relative path (Gate 2, Option B:
* runtime-manifest-driven — the plan declares a generic `file_written` slot, so the semantic kind is
* recovered from the path at gate time).
*
* Pure and deterministic over the path string — no filesystem access — so it is replay-safe. An
* unrecognized path returns `null`; the caller then falls back to the universal floor, so an
* unknown file is still checked for existence + non-emptiness + parse, never skipped.
*
* This is heuristic by nature. Option A (planner declares `{path, kind}` targets) supersedes it for
* stages that opt in; until then this recovers the common cases for the stacks correx exercises.
*/
object KindInference {
fun kindFor(path: String): String? {
val name = path.substringAfterLast('/')
val lower = name.lowercase()
return when {
lower == "package.json" -> "package_json"
lower.startsWith("tsconfig") && lower.endsWith(".json") -> "tsconfig"
lower.startsWith("vite.config.") -> "vite_config"
lower.endsWith(".html") -> "html_entry"
lower.endsWith(".css") || lower.endsWith(".scss") -> "css_entry"
lower.endsWith(".gradle.kts") || lower == "build.gradle" -> "gradle_module"
name.endsWith(".tsx") || name.endsWith(".ts") -> tsKind(path, name)
name.endsWith(".kt") -> kotlinKind(name)
lower.endsWith(".json") -> "json_config"
lower.endsWith(".md") -> "docs"
else -> null
}
}
private fun tsKind(path: String, name: String): String {
val stem = name.substringBeforeLast('.')
return when {
// App entry points (src/main.tsx, index.tsx) mount the tree via createRoot/render and
// legitimately export nothing — requiring a default component (react_view) makes the
// stage unwinnable for a correct file. Lenient contract: exists · non-empty · imports.
name.endsWith(".tsx") && (stem == "main" || stem == "index") -> "react_entry"
stem.startsWith("use") && stem.length > 3 && stem[3].isUpperCase() -> "react_hook"
"/types/" in path || stem == "types" -> "ts_types"
"/views/" in path || stem.endsWith("View") -> "react_view"
"router" in stem.lowercase() || "routes" in stem.lowercase() -> "router_config"
name.endsWith(".tsx") && stem.isNotEmpty() && stem[0].isUpperCase() -> "react_component"
name.endsWith(".tsx") -> "react_view"
else -> "ts_module"
}
}
private fun kotlinKind(name: String): String {
val stem = name.removeSuffix(".kt")
return when {
stem.endsWith("Event") || stem.endsWith("Events") -> "kotlin_event"
stem.endsWith("Reducer") -> "kotlin_reducer"
stem.endsWith("Projector") || stem.endsWith("Projection") -> "kotlin_projector"
stem.endsWith("Repository") -> "kotlin_repository"
stem.endsWith("Test") || stem.endsWith("Tests") -> "kotlin_test"
stem.endsWith("Service") || stem.endsWith("Facade") -> "kotlin_service"
else -> "kotlin_service"
}
}
}
@@ -0,0 +1,78 @@
package com.correx.core.artifacts.kind
import kotlin.test.AfterTest
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class KindContractTableTest {
@AfterTest
fun reset() {
KindContractTable.projectOverrides = emptyMap()
}
@Test
fun `infers common TS and Kotlin kinds from path`() {
assertEquals("react_hook", KindInference.kindFor("frontend/src/hooks/useWebSocket.ts"))
assertEquals("react_view", KindInference.kindFor("frontend/src/views/Sessions.tsx"))
assertEquals("react_component", KindInference.kindFor("frontend/src/components/Navbar.tsx"))
assertEquals("ts_types", KindInference.kindFor("frontend/src/types/index.ts"))
assertEquals("package_json", KindInference.kindFor("frontend/package.json"))
assertEquals("tsconfig", KindInference.kindFor("frontend/tsconfig.json"))
assertEquals("html_entry", KindInference.kindFor("frontend/index.html"))
assertEquals("kotlin_event", KindInference.kindFor("core/events/FooEvent.kt"))
assertEquals("kotlin_reducer", KindInference.kindFor("core/foo/BarReducer.kt"))
}
@Test
fun `unknown kind falls back to universal floor, never empty`() {
val assertions = KindContractTable.assertionsFor(KindInference.kindFor("weird.xyz") ?: "", "weird.xyz")
assertEquals(listOf("file_exists", "file_nonempty", "parses"), assertions.map { it.id })
}
@Test
fun `app entry files are react_entry, not react_view`() {
// Regression: main.tsx / index.tsx mount the tree and export nothing. Classifying them as
// react_view (which requires exports_default_component) made a correct file unwinnable.
assertEquals("react_entry", KindInference.kindFor("frontend/src/main.tsx"))
assertEquals("react_entry", KindInference.kindFor("frontend/src/index.tsx"))
// A non-entry .tsx entry point name in .ts form is unaffected.
assertEquals("ts_types", KindInference.kindFor("frontend/src/types/index.ts"))
}
@Test
fun `react_entry contract does not require a default component export`() {
val ids = KindContractTable.assertionsFor("react_entry", "frontend/src/main.tsx").map { it.id }
assertTrue("file_exists" in ids && "file_nonempty" in ids)
assertTrue("exports_default_component" !in ids, "entry file must not require a default export")
}
@Test
fun `react_hook contract includes floor plus exports_hook`() {
val ids = KindContractTable.assertionsFor("react_hook", "useWebSocket.ts").map { it.id }
assertTrue("file_exists" in ids && "file_nonempty" in ids)
assertTrue("exports_hook" in ids)
}
@Test
fun `kotlin_event promotes the serialization-registration trap into an assertion`() {
val ids = KindContractTable.assertionsFor("kotlin_event", "FooEvent.kt").map { it.id }
assertTrue("registered_in_serialization" in ids)
}
@Test
fun `project override is additive and cannot remove checked-in assertions`() {
KindContractTable.projectOverrides = mapOf(
"css_entry" to listOf(
ContractAssertion("contains", "", AssertionEvaluator.TEXT, args = mapOf("pattern" to "@tailwind")),
// duplicate of a checked-in id — must not double up
ContractAssertion("file_exists", "", AssertionEvaluator.FS),
),
)
val ids = KindContractTable.assertionsFor("css_entry", "src/index.css").map { it.id }
assertEquals(1, ids.count { it == "file_exists" }, "checked-in file_exists must not be duplicated")
assertTrue("contains" in ids, "override assertion must be appended")
assertTrue("file_nonempty" in ids, "checked-in assertions remain")
}
}