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:
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
+78
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user