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")
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
+30
@@ -0,0 +1,30 @@
|
||||
package com.correx.core.kernel.orchestration
|
||||
|
||||
import com.correx.core.artifacts.kind.ContractAssertion
|
||||
import java.nio.file.Path
|
||||
|
||||
/**
|
||||
* Verdict for one [ContractAssertion]. [skipped] marks an assertion this evaluator does not handle
|
||||
* — currently the `COMPILER` ones, which are covered by the static-analysis command gate (a real
|
||||
* `tsc`/`kotlinc` run), not re-implemented here. A skipped assertion is recorded but never fails a
|
||||
* stage.
|
||||
*/
|
||||
data class ContractAssertionVerdict(
|
||||
val passed: Boolean,
|
||||
val evidence: String,
|
||||
val skipped: Boolean = false,
|
||||
)
|
||||
|
||||
/**
|
||||
* Seam for evaluating a Gate 2 contract assertion against files in the workspace (design §1). Like
|
||||
* [StaticAnalysisRunner], the filesystem-touching implementation lives in infrastructure and is
|
||||
* injected, so the deterministic core never reads files itself and the gate stays unit-testable with
|
||||
* a fake.
|
||||
*
|
||||
* The orchestrator records every verdict in a
|
||||
* [com.correx.core.events.events.ContractGateEvaluatedEvent] (invariant #9 environment observation)
|
||||
* — replay reads the recorded facts and never re-inspects the filesystem.
|
||||
*/
|
||||
fun interface ContractAssertionEvaluator {
|
||||
suspend fun evaluate(workspaceRoot: Path, assertion: ContractAssertion): ContractAssertionVerdict
|
||||
}
|
||||
+115
@@ -0,0 +1,115 @@
|
||||
package com.correx.core.kernel.orchestration
|
||||
|
||||
import com.correx.core.artifacts.kind.AssertionEvaluator
|
||||
import com.correx.core.artifacts.kind.ContractAssertion
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.jsonObject
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
|
||||
/**
|
||||
* Filesystem/text [ContractAssertionEvaluator] for Gate 2 (design §1). Reads the assertion's target
|
||||
* file under the workspace root and returns a verdict with concrete evidence.
|
||||
*
|
||||
* Handles the `FS` and `TEXT` evaluators outright, and the `AST` ones as text heuristics (they ship
|
||||
* heuristic and get promoted to a real parse on demand, design §3.4). `COMPILER` assertions are
|
||||
* marked [ContractAssertionVerdict.skipped] — a real `tsc`/`kotlinc` run (the static-analysis command
|
||||
* gate) is the authoritative check for those, so re-implementing them here would only add a weaker
|
||||
* duplicate.
|
||||
*/
|
||||
class FileSystemContractEvaluator : ContractAssertionEvaluator {
|
||||
|
||||
// allowComments/allowTrailingComma make tsconfig.json (JSONC — comments + trailing commas are
|
||||
// legal and tsc/Vite parse them) pass valid_json instead of false-failing a valid config file.
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
isLenient = true
|
||||
allowComments = true
|
||||
allowTrailingComma = true
|
||||
}
|
||||
|
||||
override suspend fun evaluate(workspaceRoot: Path, assertion: ContractAssertion): ContractAssertionVerdict =
|
||||
withContext(Dispatchers.IO) {
|
||||
if (assertion.evaluator == AssertionEvaluator.COMPILER) {
|
||||
return@withContext skip("covered by static-analysis command gate")
|
||||
}
|
||||
val path = workspaceRoot.resolve(assertion.target)
|
||||
val exists = Files.exists(path) && Files.isRegularFile(path)
|
||||
val dirExists = Files.isDirectory(workspaceRoot.resolve(assertion.target))
|
||||
when (assertion.id) {
|
||||
"file_exists" -> verdict(exists, if (exists) "exists" else "file does not exist")
|
||||
"file_nonempty" -> nonEmpty(path, exists)
|
||||
"toolchain_installed" -> verdict(dirExists, if (dirExists) "present" else "toolchain dir absent")
|
||||
"valid_json" -> validJson(path, exists)
|
||||
"json_has_key" -> jsonKey(path, exists, assertion.args["key"].orEmpty())
|
||||
"script_defined" -> scriptDefined(path, exists, assertion.args["name"].orEmpty())
|
||||
"contains" -> contains(path, exists, assertion.args["pattern"].orEmpty())
|
||||
"has_test" -> textMatch(path, exists, "@Test", "no @Test found")
|
||||
"migration_present" -> migrationPresent(assertion.target, exists)
|
||||
"exports_hook" -> exportsHook(path, exists)
|
||||
"exports_default_component" -> textMatch(path, exists, "export default", "no default export")
|
||||
"registered_in_serialization" -> skip("cross-file check — deferred to compiler/Option A")
|
||||
else -> skip("no evaluator for '${assertion.id}'")
|
||||
}
|
||||
}
|
||||
|
||||
private fun readOrNull(path: Path, exists: Boolean): String? =
|
||||
if (!exists) null else runCatching { Files.readString(path) }.getOrNull()
|
||||
|
||||
private fun nonEmpty(path: Path, exists: Boolean): ContractAssertionVerdict {
|
||||
val text = readOrNull(path, exists) ?: return verdict(false, "file does not exist")
|
||||
return verdict(text.isNotBlank(), if (text.isNotBlank()) "non-empty" else "file is empty")
|
||||
}
|
||||
|
||||
private fun validJson(path: Path, exists: Boolean): ContractAssertionVerdict {
|
||||
val text = readOrNull(path, exists) ?: return verdict(false, "file does not exist")
|
||||
val ok = runCatching { json.parseToJsonElement(text) }.isSuccess
|
||||
return verdict(ok, if (ok) "valid json" else "not parseable as json")
|
||||
}
|
||||
|
||||
private fun jsonObj(path: Path, exists: Boolean): JsonObject? =
|
||||
readOrNull(path, exists)?.let { runCatching { json.parseToJsonElement(it).jsonObject }.getOrNull() }
|
||||
|
||||
private fun jsonKey(path: Path, exists: Boolean, key: String): ContractAssertionVerdict {
|
||||
val obj = jsonObj(path, exists) ?: return verdict(false, "not a json object")
|
||||
return verdict(obj.containsKey(key), if (obj.containsKey(key)) "key '$key' present" else "missing key '$key'")
|
||||
}
|
||||
|
||||
private fun scriptDefined(path: Path, exists: Boolean, name: String): ContractAssertionVerdict {
|
||||
val scripts = jsonObj(path, exists)?.get("scripts")?.let { runCatching { it.jsonObject }.getOrNull() }
|
||||
?: return verdict(false, "no scripts block")
|
||||
return verdict(scripts.containsKey(name), if (scripts.containsKey(name)) "script '$name' defined" else "missing script '$name'")
|
||||
}
|
||||
|
||||
private fun contains(path: Path, exists: Boolean, pattern: String): ContractAssertionVerdict {
|
||||
val text = readOrNull(path, exists) ?: return verdict(false, "file does not exist")
|
||||
return verdict(text.contains(pattern), if (text.contains(pattern)) "contains '$pattern'" else "missing '$pattern'")
|
||||
}
|
||||
|
||||
private fun textMatch(path: Path, exists: Boolean, needle: String, failMsg: String): ContractAssertionVerdict {
|
||||
val text = readOrNull(path, exists) ?: return verdict(false, "file does not exist")
|
||||
return verdict(text.contains(needle), if (text.contains(needle)) "found '$needle'" else failMsg)
|
||||
}
|
||||
|
||||
private fun exportsHook(path: Path, exists: Boolean): ContractAssertionVerdict {
|
||||
val text = readOrNull(path, exists) ?: return verdict(false, "file does not exist")
|
||||
val ok = HOOK_EXPORT.containsMatchIn(text)
|
||||
return verdict(ok, if (ok) "exports a use* function" else "no exported use* hook found")
|
||||
}
|
||||
|
||||
private fun migrationPresent(target: String, exists: Boolean): ContractAssertionVerdict {
|
||||
val name = target.substringAfterLast('/')
|
||||
val ok = exists && name.firstOrNull()?.isDigit() == true
|
||||
return verdict(ok, if (ok) "sequential migration file present" else "no sequential-id migration file")
|
||||
}
|
||||
|
||||
private fun verdict(passed: Boolean, evidence: String) = ContractAssertionVerdict(passed, evidence)
|
||||
private fun skip(reason: String) = ContractAssertionVerdict(passed = true, evidence = reason, skipped = true)
|
||||
|
||||
private companion object {
|
||||
val HOOK_EXPORT = Regex("""export\s+(?:default\s+)?(?:async\s+)?(?:function|const)\s+use[A-Z]""")
|
||||
}
|
||||
}
|
||||
+3
@@ -31,4 +31,7 @@ data class OrchestratorEngines(
|
||||
// Runs operator-configured static-analysis commands as a harness step (role-reliability §5).
|
||||
// Null = no static-first step; a stage declaring `static_analysis` then no-ops with a warning.
|
||||
val staticAnalysisRunner: StaticAnalysisRunner? = null,
|
||||
// Evaluates Gate 2 contract assertions against a stage's produced files (design §1). Null = no
|
||||
// contract gate; the deterministic funnel then rests on produces-presence + static analysis only.
|
||||
val contractAssertionEvaluator: ContractAssertionEvaluator? = null,
|
||||
)
|
||||
|
||||
@@ -183,6 +183,7 @@ class ReplayOrchestrator(
|
||||
responseFormat: ResponseFormat,
|
||||
effectives: RunEffectives,
|
||||
withTools: Boolean,
|
||||
forceWriteOnly: Boolean,
|
||||
): InferenceResult = when (strategy) {
|
||||
is ReplayStrategy.SkipInference -> {
|
||||
// bypass router entirely — use recorded artifact
|
||||
@@ -213,6 +214,7 @@ class ReplayOrchestrator(
|
||||
}
|
||||
else -> super.runInference(
|
||||
sessionId, stageId, contextPack, stageConfig, timeoutMs, responseFormat, effectives, withTools,
|
||||
forceWriteOnly,
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+191
-5
@@ -14,6 +14,8 @@ import com.correx.core.approvals.model.ApprovalScopeIdentity
|
||||
import com.correx.core.approvals.model.DomainApprovalRequest
|
||||
import com.correx.core.artifacts.ArtifactState
|
||||
import com.correx.core.artifacts.kind.ArtifactKindRegistry
|
||||
import com.correx.core.artifacts.kind.KindContractTable
|
||||
import com.correx.core.artifacts.kind.KindInference
|
||||
import com.correx.core.artifacts.kind.JsonSchema
|
||||
import com.correx.core.artifacts.kind.FileWrittenArtifact
|
||||
import com.correx.core.artifacts.kind.ProcessResultArtifact
|
||||
@@ -32,6 +34,8 @@ import com.correx.core.events.events.RefinementIterationEvent
|
||||
import com.correx.core.events.events.ApprovalRequestedEvent
|
||||
import com.correx.core.events.events.ArtifactContentStoredEvent
|
||||
import com.correx.core.events.events.FileWrittenEvent
|
||||
import com.correx.core.events.events.ContractGateEvaluatedEvent
|
||||
import com.correx.core.events.events.ContractAssertionResult
|
||||
import com.correx.core.events.events.ArtifactCreatedEvent
|
||||
import com.correx.core.events.events.ArtifactRepairAttemptedEvent
|
||||
import com.correx.core.events.events.ArtifactRepairFailedEvent
|
||||
@@ -165,6 +169,13 @@ import java.util.concurrent.atomic.*
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
private const val MAX_TOOL_ROUNDS = 10
|
||||
|
||||
// Consecutive read-only tool rounds (no file_write/file_edit) that still owe a file_written
|
||||
// artifact before we force the write nudge. A model that keeps calling read tools every round
|
||||
// trips neither the prose nudge nor the stage_complete nudge, so without this it silently burns
|
||||
// every round reading and never writes (2026-07-05: define_types read-looped 40 turns → failed).
|
||||
private const val READ_LOOP_NUDGE_THRESHOLD = 3
|
||||
private val WRITE_TOOL_NAMES = setOf("file_write", "file_edit")
|
||||
private const val MAX_FEEDBACK_ISSUES = 3
|
||||
private const val STAGE_COMPLETE_TOOL = "stage_complete"
|
||||
private const val EMIT_ARTIFACT_TOOL = "emit_artifact"
|
||||
@@ -208,6 +219,7 @@ private const val MAX_CLARIFICATION_ROUNDS = 3
|
||||
|
||||
// Static-analysis output caps: the tail retained in the recorded event vs. the (larger) slice fed
|
||||
// back to the model so it can fix the failure. Tails, because the error summary sits at the end.
|
||||
private const val CONTRACT_EVIDENCE_CAP = 400
|
||||
private const val STATIC_ANALYSIS_SUMMARY_CAP = 2_000
|
||||
private const val STATIC_ANALYSIS_FEEDBACK_CAP = 6_000
|
||||
|
||||
@@ -245,6 +257,7 @@ abstract class SessionOrchestrator(
|
||||
private val workspacePolicy: WorkspacePolicy? = engines.workspacePolicy
|
||||
private val worldProbe: WorldProbe = engines.worldProbe
|
||||
private val staticAnalysisRunner: StaticAnalysisRunner? = engines.staticAnalysisRunner
|
||||
private val contractAssertionEvaluator: ContractAssertionEvaluator? = engines.contractAssertionEvaluator
|
||||
private val workspaceToolRegistryProvider: WorkspaceToolRegistryProvider? = engines.workspaceToolRegistryProvider
|
||||
private val inferenceRepository: InferenceRepository = repositories.inferenceRepository
|
||||
internal val orchestrationRepository: OrchestrationRepository = repositories.orchestrationRepository
|
||||
@@ -462,8 +475,12 @@ abstract class SessionOrchestrator(
|
||||
} ?: emptyList()
|
||||
val projectProfileEntries = session.state.boundProjectProfile
|
||||
?.let { listOf(buildProjectProfileEntry(it)) } ?: emptyList()
|
||||
val agentInstructionsEntries = session.state.boundAgentInstructions
|
||||
?.let { listOf(buildAgentInstructionsEntry(it)) } ?: emptyList()
|
||||
// AGENTS.md injection is disabled for now. The workspace-root AGENTS.md (the DOX framework)
|
||||
// carries a mandatory "Read Before Editing" walk — "read the whole AGENTS.md chain, do not
|
||||
// rely on memory, re-read before editing" — which drives a small model into an endless
|
||||
// read-loop that never writes (2026-07-05 root cause). Reincorporating DOX properly (scoped
|
||||
// to the stage's subproject, not the repo root; not read-mandating) is deferred.
|
||||
val agentInstructionsEntries = emptyList<ContextEntry>()
|
||||
val retryFeedbackEntries = buildRetryFeedbackEntry(sessionEvents, stageId)
|
||||
?.let { listOf(it) } ?: emptyList()
|
||||
val vocabularyEntries = artifactKindRegistry
|
||||
@@ -517,6 +534,7 @@ abstract class SessionOrchestrator(
|
||||
sessionId, stageId, currentContext, stageConfig, config.stageTimeoutMs, responseFormat, effectives,
|
||||
)
|
||||
var toolRounds = 0
|
||||
var consecutiveReadOnlyRounds = 0
|
||||
// Set when the model produces its artifact via the emit_artifact tool instead of a final
|
||||
// JSON message; overrides the post-loop capture of the (then-empty) assistant text.
|
||||
var llmArtifactOverride: String? = null
|
||||
@@ -548,7 +566,7 @@ abstract class SessionOrchestrator(
|
||||
|
||||
// Append a corrective tool-result and re-run inference (bounded by MAX_TOOL_ROUNDS).
|
||||
// Returns the new result rather than mutating inferenceResult, to preserve smart casts.
|
||||
suspend fun pushBack(nudge: String): InferenceResult {
|
||||
suspend fun pushBack(nudge: String, forceWriteOnly: Boolean = false): InferenceResult {
|
||||
accumulatedEntries = accumulatedEntries + ContextEntry(
|
||||
id = ContextEntryId(UUID.randomUUID().toString()),
|
||||
layer = ContextLayer.L2,
|
||||
@@ -569,6 +587,7 @@ abstract class SessionOrchestrator(
|
||||
toolRounds++
|
||||
return runInference(
|
||||
sessionId, stageId, currentContext, stageConfig, config.stageTimeoutMs, responseFormat, effectives,
|
||||
forceWriteOnly = forceWriteOnly,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -576,6 +595,10 @@ abstract class SessionOrchestrator(
|
||||
"file_edit or file_write tool to write the change to the file before finishing — do " +
|
||||
"not output the file contents as a message."
|
||||
|
||||
val readLoopNudge = "STOP reading. You have read enough files and this stage has produced " +
|
||||
"nothing yet. You MUST now call the file_write tool to create the required file(s) for " +
|
||||
"this stage. Do not call file_read or list_dir again before you have written a file."
|
||||
|
||||
while (
|
||||
inferenceResult is InferenceResult.Success &&
|
||||
toolRounds < MAX_TOOL_ROUNDS &&
|
||||
@@ -640,6 +663,23 @@ abstract class SessionOrchestrator(
|
||||
// loop as tool-result context so the model can see the error and adapt (bounded by
|
||||
// MAX_TOOL_ROUNDS). Only FATAL: failures (handled above) abort the stage.
|
||||
accumulatedEntries = accumulatedEntries + toolEntries
|
||||
// Read-loop breaker: this round called only read-only tools yet the stage still owes a
|
||||
// file_written artifact. Left alone the model keeps reading until MAX_TOOL_ROUNDS and
|
||||
// never writes (F-018 nudges only cover a prose turn or a premature stage_complete, not
|
||||
// an endless read-loop). After READ_LOOP_NUDGE_THRESHOLD such rounds, force the write
|
||||
// nudge; a real write resets the counter so multi-file stages are unaffected.
|
||||
if (owesFileWrite() &&
|
||||
inferenceResult.response.toolCalls.none { it.function.name in WRITE_TOOL_NAMES }
|
||||
) {
|
||||
consecutiveReadOnlyRounds++
|
||||
if (consecutiveReadOnlyRounds >= READ_LOOP_NUDGE_THRESHOLD) {
|
||||
consecutiveReadOnlyRounds = 0
|
||||
inferenceResult = pushBack(readLoopNudge, forceWriteOnly = true)
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
consecutiveReadOnlyRounds = 0
|
||||
}
|
||||
currentContext = contextPackBuilder.build(
|
||||
id = ContextPackId(UUID.randomUUID().toString()),
|
||||
sessionId = sessionId,
|
||||
@@ -752,7 +792,10 @@ abstract class SessionOrchestrator(
|
||||
is StageExecutionResult.Success -> {
|
||||
emitToolArtifacts(sessionId, stageId, stageConfig)
|
||||
emitLlmArtifacts(sessionId, stageId, stageConfig)
|
||||
runPostStageGates(sessionId, stageId, stageConfig, effectives)
|
||||
runPostStageGates(
|
||||
sessionId, stageId, stageConfig, effectives,
|
||||
session.state.boundProjectProfile?.commands ?: emptyMap(),
|
||||
)
|
||||
}
|
||||
|
||||
is StageExecutionResult.Failure -> outcome
|
||||
@@ -1617,13 +1660,19 @@ abstract class SessionOrchestrator(
|
||||
* Zero-score hits carry no ranking signal (a noop embedder scores everything 0.0), so injecting
|
||||
* them just feeds the agent arbitrary files and invites hallucinated paths. Drop them; when nothing
|
||||
* useful survives, fall back to the deterministic repo map so the stage still has real grounding.
|
||||
*
|
||||
* Markdown/docs hits are gated exactly like the repo-map floor (buildRepoMapEntries): with the
|
||||
* embedder live, semantic similarity happily ranks churny kernel docs (epics, ADRs, QA plans)
|
||||
* above real source for a code task, re-poisoning the context the floor's gate was added to fix
|
||||
* (2026-07-05 root cause). They only survive when the stage prompt explicitly asks about docs.
|
||||
*/
|
||||
private suspend fun repoEntriesOrMapFloor(
|
||||
sessionId: SessionId,
|
||||
hits: List<RepoKnowledgeHit>,
|
||||
stagePrompt: String,
|
||||
): List<ContextEntry> {
|
||||
val useful = hits.filter { it.score > 0f }
|
||||
val docsWanted = DOCS_REQUEST_REGEX.containsMatchIn(stagePrompt)
|
||||
val useful = hits.filter { it.score > 0f && (docsWanted || !isDocPath(it.path)) }
|
||||
return if (useful.isEmpty()) {
|
||||
buildRepoMapEntries(sessionId, stagePrompt)
|
||||
} else {
|
||||
@@ -2033,6 +2082,7 @@ abstract class SessionOrchestrator(
|
||||
stageId: StageId,
|
||||
stageConfig: StageConfig,
|
||||
effectives: RunEffectives,
|
||||
profileCommands: Map<String, String>,
|
||||
): StageExecutionResult {
|
||||
val produced = verifyProduces(sessionId, stageId, stageConfig)
|
||||
if (produced is StageExecutionResult.Failure) return produced
|
||||
@@ -2042,7 +2092,9 @@ abstract class SessionOrchestrator(
|
||||
val gates: List<suspend () -> StageExecutionResult> = listOf(
|
||||
{ groundBriefReferences(sessionId, stageId, stageConfig, effectives) },
|
||||
{ checkBriefEcho(sessionId, stageId, stageConfig) },
|
||||
{ runContractGate(sessionId, stageId, stageConfig, effectives) },
|
||||
{ runStaticAnalysis(sessionId, stageId, stageConfig, effectives) },
|
||||
{ runExecutionGate(sessionId, stageId, stageConfig, effectives, profileCommands) },
|
||||
)
|
||||
for (gate in gates) {
|
||||
val result = gate()
|
||||
@@ -2051,6 +2103,83 @@ abstract class SessionOrchestrator(
|
||||
return StageExecutionResult.Success(emptyList())
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate 2 — contract (design 2026-07-06-staged-verification-and-review.md §1). For a stage that
|
||||
* produces file_written artifacts, project the files it actually wrote (Option B: runtime
|
||||
* manifest), infer each file's kind from its path, derive the [KindContractTable] assertions
|
||||
* (universal floor for unknown kinds), and evaluate the FS/TEXT ones deterministically. Every
|
||||
* verdict is recorded in a [ContractGateEvaluatedEvent] (invariant #9), so replay reads it back
|
||||
* and never re-inspects the filesystem.
|
||||
*
|
||||
* A failed assertion fails the stage retryably with the failing assertion ids + evidence fed
|
||||
* back verbatim — failing-test-name granular hand-back, so the implementer fixes exactly the
|
||||
* empty/missing/malformed file rather than read-looping. COMPILER assertions are recorded as
|
||||
* skipped here; the static-analysis command gate is their authoritative check.
|
||||
*/
|
||||
private suspend fun runContractGate(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
stageConfig: StageConfig,
|
||||
effectives: RunEffectives,
|
||||
): StageExecutionResult {
|
||||
val evaluator = contractAssertionEvaluator ?: return StageExecutionResult.Success(emptyList())
|
||||
val workspaceRoot = effectives.policy?.workspaceRoot ?: return StageExecutionResult.Success(emptyList())
|
||||
if (stageConfig.produces.none { it.kind.id == "file_written" }) return StageExecutionResult.Success(emptyList())
|
||||
|
||||
// Option A ∪ B: the files the stage actually wrote (runtime manifest) unioned with the
|
||||
// concrete files the plan declared it would produce (expectedFiles). A declared file that
|
||||
// was never written survives here and fails file_exists — that is the missing-file catch.
|
||||
val paths = (stageWrittenPaths(sessionId, stageId) + stageConfig.expectedFiles).distinct()
|
||||
if (paths.isEmpty()) return StageExecutionResult.Success(emptyList())
|
||||
|
||||
val assertions = paths.flatMap { path ->
|
||||
KindContractTable.assertionsFor(KindInference.kindFor(path) ?: "", path)
|
||||
}
|
||||
val results = assertions.map { a ->
|
||||
val v = contractAssertionEvaluator.evaluate(workspaceRoot, a)
|
||||
ContractAssertionResult(
|
||||
assertionId = a.id,
|
||||
target = a.target,
|
||||
layer = a.layer.name,
|
||||
evaluator = a.evaluator.name,
|
||||
passed = v.passed,
|
||||
evidence = v.evidence.takeLast(CONTRACT_EVIDENCE_CAP),
|
||||
)
|
||||
}
|
||||
emit(sessionId, ContractGateEvaluatedEvent(sessionId, stageId, results))
|
||||
|
||||
val failures = results.filterNot { it.passed }
|
||||
if (failures.isEmpty()) return StageExecutionResult.Success(emptyList())
|
||||
|
||||
log.warn(
|
||||
"[Orchestrator] contract gate failed session={} stage={} assertions={}",
|
||||
sessionId.value, stageId.value,
|
||||
failures.joinToString(", ") { "${it.assertionId}(${it.target})" },
|
||||
)
|
||||
val detail = failures.joinToString("\n") { "- ${it.assertionId} on ${it.target}: ${it.evidence}" }
|
||||
return StageExecutionResult.Failure(
|
||||
"stage ${stageId.value} did not satisfy its file contract. Fix these before review:\n$detail",
|
||||
retryable = true,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The workspace-relative paths this stage actually wrote, projected from events (invariant #1):
|
||||
* ToolInvocationRequested → this stage's invocation ids, FileWrittenEvent → the paths written
|
||||
* under them. Pure projection, replay-safe.
|
||||
*/
|
||||
private fun stageWrittenPaths(sessionId: SessionId, stageId: StageId): List<String> {
|
||||
val events = eventStore.read(sessionId)
|
||||
val invocationIds = events.mapNotNull { it.payload as? ToolInvocationRequestedEvent }
|
||||
.filter { it.stageId == stageId }
|
||||
.map { it.invocationId }
|
||||
.toSet()
|
||||
return events.mapNotNull { it.payload as? FileWrittenEvent }
|
||||
.filter { it.invocationId in invocationIds && it.postImageHash != null }
|
||||
.map { it.path }
|
||||
.distinct()
|
||||
}
|
||||
|
||||
/**
|
||||
* Static-first reviewer gate (role-reliability §5): for a stage that declares `static_analysis`
|
||||
* commands, run them (compiler / detekt / formatters) against its just-produced output in the
|
||||
@@ -2108,6 +2237,53 @@ abstract class SessionOrchestrator(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gate 4 — execution (staged-verification §1/§2). A stage's [StageConfig.buildExpectation]
|
||||
* (none/module/project/tests) resolves to the bound project profile's typecheck/build/test
|
||||
* command and runs it as a deterministic build gate. NONE runs nothing (early stages where the
|
||||
* project isn't yet runnable). A profile missing the alias skips with a warning — the vocabulary
|
||||
* degrades safely rather than failing when the operator hasn't configured commands. A non-clean
|
||||
* run fails the stage retryably with the build output fed back (recorded as a
|
||||
* [StaticAnalysisCompletedEvent], invariant #9, so replay never re-runs it).
|
||||
*/
|
||||
private suspend fun runExecutionGate(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
stageConfig: StageConfig,
|
||||
effectives: RunEffectives,
|
||||
profileCommands: Map<String, String>,
|
||||
): StageExecutionResult {
|
||||
val alias = stageConfig.buildExpectation.commandAlias ?: return StageExecutionResult.Success(emptyList())
|
||||
val runner = staticAnalysisRunner
|
||||
val workspaceRoot = effectives.policy?.workspaceRoot
|
||||
if (runner == null || workspaceRoot == null) return StageExecutionResult.Success(emptyList())
|
||||
val command = profileCommands[alias]
|
||||
if (command.isNullOrBlank()) {
|
||||
log.warn(
|
||||
"[Orchestrator] stage {} declares build_expectation={} but project profile has no '{}' " +
|
||||
"command — skipping execution gate",
|
||||
stageId.value, stageConfig.buildExpectation, alias,
|
||||
)
|
||||
return StageExecutionResult.Success(emptyList())
|
||||
}
|
||||
|
||||
val run = runner.run(workspaceRoot, command)
|
||||
val finding = StaticAnalysisFinding(command, run.exitCode, run.output.takeLast(STATIC_ANALYSIS_SUMMARY_CAP))
|
||||
emit(sessionId, StaticAnalysisCompletedEvent(sessionId, stageId, listOf(finding)))
|
||||
if (finding.clean) return StageExecutionResult.Success(emptyList())
|
||||
|
||||
log.warn(
|
||||
"[Orchestrator] execution gate failed session={} stage={} expectation={} command={}",
|
||||
sessionId.value, stageId.value, stageConfig.buildExpectation, command,
|
||||
)
|
||||
return StageExecutionResult.Failure(
|
||||
"stage ${stageId.value} did not pass its ${stageConfig.buildExpectation} build gate. " +
|
||||
"Fix these before proceeding:\n\n$ $command (exit ${run.exitCode})\n" +
|
||||
run.output.takeLast(STATIC_ANALYSIS_FEEDBACK_CAP),
|
||||
retryable = true,
|
||||
)
|
||||
}
|
||||
|
||||
private suspend fun emitProcessResultEvents(
|
||||
sessionId: SessionId,
|
||||
stageId: StageId,
|
||||
@@ -2185,6 +2361,11 @@ abstract class SessionOrchestrator(
|
||||
// into the artifact text; a tools-less call yields clean JSON deterministically (and lets the
|
||||
// JSON grammar constraint re-apply, since grammar+tools is rejected by llama.cpp).
|
||||
withTools: Boolean = true,
|
||||
// When true, offer ONLY write tools (+ stage_complete / emit_artifact) — read tools are
|
||||
// stripped. Used to break a read-loop: a model that owes a file_written artifact but keeps
|
||||
// calling read tools ignores a textual nudge (its output all goes to reasoning_content), so
|
||||
// we structurally remove the option to read and leave writing as the only move.
|
||||
forceWriteOnly: Boolean = false,
|
||||
): InferenceResult {
|
||||
// A stage that grants tools needs a tool-calling model, so request ToolCalling on top of any
|
||||
// declared capabilities — the capability-aware strategy then ranks eligible providers by their
|
||||
@@ -2213,6 +2394,11 @@ abstract class SessionOrchestrator(
|
||||
// ponytail: filter write tools while read-before-write block is active; restored once a read completes
|
||||
!isReadOnlyMode(sessionId) || ToolCapability.FILE_WRITE !in tool.requiredCapabilities
|
||||
}
|
||||
.filter { tool ->
|
||||
// Read-loop break: keep only write tools (drop file_read/list_dir/shell) so
|
||||
// the model cannot keep reading and must write or complete.
|
||||
!forceWriteOnly || ToolCapability.FILE_WRITE in tool.requiredCapabilities
|
||||
}
|
||||
.map { tool ->
|
||||
ToolDefinition(
|
||||
function = ToolFunction(
|
||||
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
package com.correx.core.kernel.orchestration
|
||||
|
||||
import com.correx.core.artifacts.kind.AssertionEvaluator
|
||||
import com.correx.core.artifacts.kind.ContractAssertion
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.jupiter.api.Assertions.assertFalse
|
||||
import org.junit.jupiter.api.Assertions.assertTrue
|
||||
import org.junit.jupiter.api.Test
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import kotlin.io.path.createDirectories
|
||||
import kotlin.io.path.writeText
|
||||
|
||||
class FileSystemContractEvaluatorTest {
|
||||
|
||||
private val evaluator = FileSystemContractEvaluator()
|
||||
|
||||
private fun workspace(build: (Path) -> Unit): Path {
|
||||
val root = Files.createTempDirectory("contract-gate")
|
||||
build(root)
|
||||
return root
|
||||
}
|
||||
|
||||
private fun assertion(id: String, target: String, ev: AssertionEvaluator, args: Map<String, String> = emptyMap()) =
|
||||
ContractAssertion(id, target, ev, args = args)
|
||||
|
||||
@Test
|
||||
fun `empty produced file fails file_nonempty`() = runBlocking {
|
||||
val root = workspace { r ->
|
||||
r.resolve("src/views").createDirectories()
|
||||
r.resolve("src/views/Sessions.tsx").writeText("")
|
||||
}
|
||||
val v = evaluator.evaluate(root, assertion("file_nonempty", "src/views/Sessions.tsx", AssertionEvaluator.FS))
|
||||
assertFalse(v.passed, "empty Sessions.tsx must fail file_nonempty")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `missing file fails file_exists`() = runBlocking {
|
||||
val root = workspace { }
|
||||
val v = evaluator.evaluate(root, assertion("file_exists", "src/App.tsx", AssertionEvaluator.FS))
|
||||
assertFalse(v.passed, "missing App.tsx must fail file_exists")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `hook file without a use-export fails exports_hook`() = runBlocking {
|
||||
val root = workspace { r ->
|
||||
r.resolve("src/hooks").createDirectories()
|
||||
r.resolve("src/hooks/useWebSocket.ts").writeText("export const connect = () => {}")
|
||||
}
|
||||
val v = evaluator.evaluate(root, assertion("exports_hook", "src/hooks/useWebSocket.ts", AssertionEvaluator.AST))
|
||||
assertFalse(v.passed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `real hook export passes`() = runBlocking {
|
||||
val root = workspace { r ->
|
||||
r.resolve("src/hooks").createDirectories()
|
||||
r.resolve("src/hooks/useWebSocket.ts").writeText("export const useWebSocket = () => {}")
|
||||
}
|
||||
val v = evaluator.evaluate(root, assertion("exports_hook", "src/hooks/useWebSocket.ts", AssertionEvaluator.AST))
|
||||
assertTrue(v.passed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `package_json missing build script fails script_defined`() = runBlocking {
|
||||
val root = workspace { r ->
|
||||
r.resolve("package.json").writeText("""{"name":"x","scripts":{"dev":"vite"}}""")
|
||||
}
|
||||
val v = evaluator.evaluate(
|
||||
root,
|
||||
assertion("script_defined", "package.json", AssertionEvaluator.TEXT, mapOf("name" to "build")),
|
||||
)
|
||||
assertFalse(v.passed)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `tsconfig with comments and trailing commas passes valid_json (JSONC)`() = runBlocking {
|
||||
// Regression: tsconfig.json is JSONC — tsc/Vite accept comments + trailing commas. A strict
|
||||
// valid_json false-failed a legitimate config and wasted a retry.
|
||||
val root = workspace { r ->
|
||||
r.resolve("tsconfig.json").writeText(
|
||||
"""
|
||||
{
|
||||
// compiler options for the app
|
||||
"compilerOptions": {
|
||||
"strict": true,
|
||||
"jsx": "react-jsx",
|
||||
},
|
||||
}
|
||||
""".trimIndent(),
|
||||
)
|
||||
}
|
||||
val v = evaluator.evaluate(root, assertion("valid_json", "tsconfig.json", AssertionEvaluator.TEXT))
|
||||
assertTrue(v.passed, "tsconfig JSONC must pass valid_json")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `compiler assertions are skipped, not failed`() = runBlocking {
|
||||
val root = workspace { }
|
||||
val v = evaluator.evaluate(root, assertion("parses", "whatever.ts", AssertionEvaluator.COMPILER))
|
||||
assertTrue(v.skipped)
|
||||
assertTrue(v.passed, "a skipped assertion must not fail the stage")
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package com.correx.core.transitions.graph
|
||||
|
||||
/**
|
||||
* Per-stage execution-gate scope (staged-verification design §2) — the closed set a planner picks
|
||||
* from to say how far the project should be runnable after this stage. Resolves at runtime to a
|
||||
* concrete command drawn from the bound project profile's command aliases, so the command itself
|
||||
* stays stack-specific and operator-owned while the *scope* is a fixed vocabulary the planner can't
|
||||
* invent.
|
||||
*
|
||||
* [commandAlias] names the `[project.commands]` alias to run for this scope (null = no gate):
|
||||
* NONE → nothing (early stages where the project is not yet runnable), MODULE → typecheck,
|
||||
* PROJECT → build, TESTS → test. A stage whose profile lacks the alias skips the gate with a warning
|
||||
* rather than failing — the vocabulary degrades safely when the operator hasn't configured commands.
|
||||
*/
|
||||
enum class BuildExpectation(val commandAlias: String?) {
|
||||
NONE(null),
|
||||
MODULE("typecheck"),
|
||||
PROJECT("build"),
|
||||
TESTS("test"),
|
||||
;
|
||||
|
||||
companion object {
|
||||
/** Parses a plan's `build_expectation` string; null for an unrecognized value so the compiler rejects it. */
|
||||
fun fromPlan(raw: String?): BuildExpectation? = when (raw?.trim()?.lowercase()) {
|
||||
null, "", "none" -> NONE
|
||||
"module" -> MODULE
|
||||
"project" -> PROJECT
|
||||
"tests", "test" -> TESTS
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -28,5 +28,15 @@ data class StageConfig(
|
||||
// A non-clean command fails the stage retryably with its output fed back, so only
|
||||
// static-clean output reaches the downstream reviewer. Empty = no static-first step.
|
||||
val staticAnalysis: List<String> = emptyList(),
|
||||
// Execution-gate scope for this stage (staged-verification §2). NONE = no build gate (default,
|
||||
// e.g. early scaffold/types stages); MODULE/PROJECT/TESTS resolve to the bound project profile's
|
||||
// typecheck/build/test command and run it as a deterministic Gate 4 after the stage produces.
|
||||
val buildExpectation: BuildExpectation = BuildExpectation.NONE,
|
||||
// Concrete (non-glob) workspace-relative paths this stage declares it will produce (Gate 2
|
||||
// Option A, staged-verification §3.2). The contract gate checks each declared file exists and
|
||||
// satisfies its inferred-kind contract, so a file the plan promised but the stage never wrote
|
||||
// FAILS the gate — catching missing files, which the runtime write-manifest alone (it only
|
||||
// inspects files that WERE written) cannot. Derived from the concrete entries of PlanStage.writes.
|
||||
val expectedFiles: List<String> = emptyList(),
|
||||
val metadata: Map<String, String> = emptyMap(),
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user