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:
@@ -38,6 +38,7 @@ import com.correx.core.kernel.orchestration.OrchestrationProjector
|
|||||||
import com.correx.core.kernel.orchestration.OrchestrationRepository
|
import com.correx.core.kernel.orchestration.OrchestrationRepository
|
||||||
import com.correx.core.kernel.orchestration.OrchestratorEngines
|
import com.correx.core.kernel.orchestration.OrchestratorEngines
|
||||||
import com.correx.core.kernel.orchestration.ProcessStaticAnalysisRunner
|
import com.correx.core.kernel.orchestration.ProcessStaticAnalysisRunner
|
||||||
|
import com.correx.core.kernel.orchestration.FileSystemContractEvaluator
|
||||||
import com.correx.core.kernel.orchestration.OrchestratorRepositories
|
import com.correx.core.kernel.orchestration.OrchestratorRepositories
|
||||||
import com.correx.core.kernel.orchestration.WorkspaceContext
|
import com.correx.core.kernel.orchestration.WorkspaceContext
|
||||||
import com.correx.core.kernel.orchestration.WorkspaceTools
|
import com.correx.core.kernel.orchestration.WorkspaceTools
|
||||||
@@ -324,6 +325,7 @@ fun main() {
|
|||||||
workspacePolicy = workspacePolicy,
|
workspacePolicy = workspacePolicy,
|
||||||
workspaceToolRegistryProvider = wsToolRegistryProvider,
|
workspaceToolRegistryProvider = wsToolRegistryProvider,
|
||||||
staticAnalysisRunner = ProcessStaticAnalysisRunner(),
|
staticAnalysisRunner = ProcessStaticAnalysisRunner(),
|
||||||
|
contractAssertionEvaluator = FileSystemContractEvaluator(),
|
||||||
)
|
)
|
||||||
val decisionJournalRepository = InfrastructureModule.createDecisionJournalRepository(eventStore)
|
val decisionJournalRepository = InfrastructureModule.createDecisionJournalRepository(eventStore)
|
||||||
val defaultOrchestrationConfig = OrchestrationConfig(
|
val defaultOrchestrationConfig = OrchestrationConfig(
|
||||||
|
|||||||
@@ -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.BriefEchoMismatchEvent
|
||||||
import com.correx.core.events.events.BriefGroundingCheckedEvent
|
import com.correx.core.events.events.BriefGroundingCheckedEvent
|
||||||
import com.correx.core.events.events.StaticAnalysisCompletedEvent
|
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.PlanLintCompletedEvent
|
||||||
import com.correx.core.events.events.ChatSessionStartedEvent
|
import com.correx.core.events.events.ChatSessionStartedEvent
|
||||||
import com.correx.core.events.events.SessionWorkingTaskEvent
|
import com.correx.core.events.events.SessionWorkingTaskEvent
|
||||||
@@ -148,6 +149,7 @@ val eventModule = SerializersModule {
|
|||||||
subclass(BriefGroundingCheckedEvent::class)
|
subclass(BriefGroundingCheckedEvent::class)
|
||||||
subclass(BriefEchoMismatchEvent::class)
|
subclass(BriefEchoMismatchEvent::class)
|
||||||
subclass(StaticAnalysisCompletedEvent::class)
|
subclass(StaticAnalysisCompletedEvent::class)
|
||||||
|
subclass(ContractGateEvaluatedEvent::class)
|
||||||
subclass(PlanLintCompletedEvent::class)
|
subclass(PlanLintCompletedEvent::class)
|
||||||
subclass(RiskAssessedEvent::class)
|
subclass(RiskAssessedEvent::class)
|
||||||
subclass(ChatSessionStartedEvent::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).
|
// 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.
|
// Null = no static-first step; a stage declaring `static_analysis` then no-ops with a warning.
|
||||||
val staticAnalysisRunner: StaticAnalysisRunner? = null,
|
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,
|
responseFormat: ResponseFormat,
|
||||||
effectives: RunEffectives,
|
effectives: RunEffectives,
|
||||||
withTools: Boolean,
|
withTools: Boolean,
|
||||||
|
forceWriteOnly: Boolean,
|
||||||
): InferenceResult = when (strategy) {
|
): InferenceResult = when (strategy) {
|
||||||
is ReplayStrategy.SkipInference -> {
|
is ReplayStrategy.SkipInference -> {
|
||||||
// bypass router entirely — use recorded artifact
|
// bypass router entirely — use recorded artifact
|
||||||
@@ -213,6 +214,7 @@ class ReplayOrchestrator(
|
|||||||
}
|
}
|
||||||
else -> super.runInference(
|
else -> super.runInference(
|
||||||
sessionId, stageId, contextPack, stageConfig, timeoutMs, responseFormat, effectives, withTools,
|
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.approvals.model.DomainApprovalRequest
|
||||||
import com.correx.core.artifacts.ArtifactState
|
import com.correx.core.artifacts.ArtifactState
|
||||||
import com.correx.core.artifacts.kind.ArtifactKindRegistry
|
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.JsonSchema
|
||||||
import com.correx.core.artifacts.kind.FileWrittenArtifact
|
import com.correx.core.artifacts.kind.FileWrittenArtifact
|
||||||
import com.correx.core.artifacts.kind.ProcessResultArtifact
|
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.ApprovalRequestedEvent
|
||||||
import com.correx.core.events.events.ArtifactContentStoredEvent
|
import com.correx.core.events.events.ArtifactContentStoredEvent
|
||||||
import com.correx.core.events.events.FileWrittenEvent
|
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.ArtifactCreatedEvent
|
||||||
import com.correx.core.events.events.ArtifactRepairAttemptedEvent
|
import com.correx.core.events.events.ArtifactRepairAttemptedEvent
|
||||||
import com.correx.core.events.events.ArtifactRepairFailedEvent
|
import com.correx.core.events.events.ArtifactRepairFailedEvent
|
||||||
@@ -165,6 +169,13 @@ import java.util.concurrent.atomic.*
|
|||||||
import kotlin.coroutines.cancellation.CancellationException
|
import kotlin.coroutines.cancellation.CancellationException
|
||||||
|
|
||||||
private const val MAX_TOOL_ROUNDS = 10
|
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 MAX_FEEDBACK_ISSUES = 3
|
||||||
private const val STAGE_COMPLETE_TOOL = "stage_complete"
|
private const val STAGE_COMPLETE_TOOL = "stage_complete"
|
||||||
private const val EMIT_ARTIFACT_TOOL = "emit_artifact"
|
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
|
// 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.
|
// 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_SUMMARY_CAP = 2_000
|
||||||
private const val STATIC_ANALYSIS_FEEDBACK_CAP = 6_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 workspacePolicy: WorkspacePolicy? = engines.workspacePolicy
|
||||||
private val worldProbe: WorldProbe = engines.worldProbe
|
private val worldProbe: WorldProbe = engines.worldProbe
|
||||||
private val staticAnalysisRunner: StaticAnalysisRunner? = engines.staticAnalysisRunner
|
private val staticAnalysisRunner: StaticAnalysisRunner? = engines.staticAnalysisRunner
|
||||||
|
private val contractAssertionEvaluator: ContractAssertionEvaluator? = engines.contractAssertionEvaluator
|
||||||
private val workspaceToolRegistryProvider: WorkspaceToolRegistryProvider? = engines.workspaceToolRegistryProvider
|
private val workspaceToolRegistryProvider: WorkspaceToolRegistryProvider? = engines.workspaceToolRegistryProvider
|
||||||
private val inferenceRepository: InferenceRepository = repositories.inferenceRepository
|
private val inferenceRepository: InferenceRepository = repositories.inferenceRepository
|
||||||
internal val orchestrationRepository: OrchestrationRepository = repositories.orchestrationRepository
|
internal val orchestrationRepository: OrchestrationRepository = repositories.orchestrationRepository
|
||||||
@@ -462,8 +475,12 @@ abstract class SessionOrchestrator(
|
|||||||
} ?: emptyList()
|
} ?: emptyList()
|
||||||
val projectProfileEntries = session.state.boundProjectProfile
|
val projectProfileEntries = session.state.boundProjectProfile
|
||||||
?.let { listOf(buildProjectProfileEntry(it)) } ?: emptyList()
|
?.let { listOf(buildProjectProfileEntry(it)) } ?: emptyList()
|
||||||
val agentInstructionsEntries = session.state.boundAgentInstructions
|
// AGENTS.md injection is disabled for now. The workspace-root AGENTS.md (the DOX framework)
|
||||||
?.let { listOf(buildAgentInstructionsEntry(it)) } ?: emptyList()
|
// 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)
|
val retryFeedbackEntries = buildRetryFeedbackEntry(sessionEvents, stageId)
|
||||||
?.let { listOf(it) } ?: emptyList()
|
?.let { listOf(it) } ?: emptyList()
|
||||||
val vocabularyEntries = artifactKindRegistry
|
val vocabularyEntries = artifactKindRegistry
|
||||||
@@ -517,6 +534,7 @@ abstract class SessionOrchestrator(
|
|||||||
sessionId, stageId, currentContext, stageConfig, config.stageTimeoutMs, responseFormat, effectives,
|
sessionId, stageId, currentContext, stageConfig, config.stageTimeoutMs, responseFormat, effectives,
|
||||||
)
|
)
|
||||||
var toolRounds = 0
|
var toolRounds = 0
|
||||||
|
var consecutiveReadOnlyRounds = 0
|
||||||
// Set when the model produces its artifact via the emit_artifact tool instead of a final
|
// 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.
|
// JSON message; overrides the post-loop capture of the (then-empty) assistant text.
|
||||||
var llmArtifactOverride: String? = null
|
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).
|
// 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.
|
// 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(
|
accumulatedEntries = accumulatedEntries + ContextEntry(
|
||||||
id = ContextEntryId(UUID.randomUUID().toString()),
|
id = ContextEntryId(UUID.randomUUID().toString()),
|
||||||
layer = ContextLayer.L2,
|
layer = ContextLayer.L2,
|
||||||
@@ -569,6 +587,7 @@ abstract class SessionOrchestrator(
|
|||||||
toolRounds++
|
toolRounds++
|
||||||
return runInference(
|
return runInference(
|
||||||
sessionId, stageId, currentContext, stageConfig, config.stageTimeoutMs, responseFormat, effectives,
|
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 " +
|
"file_edit or file_write tool to write the change to the file before finishing — do " +
|
||||||
"not output the file contents as a message."
|
"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 (
|
while (
|
||||||
inferenceResult is InferenceResult.Success &&
|
inferenceResult is InferenceResult.Success &&
|
||||||
toolRounds < MAX_TOOL_ROUNDS &&
|
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
|
// 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.
|
// MAX_TOOL_ROUNDS). Only FATAL: failures (handled above) abort the stage.
|
||||||
accumulatedEntries = accumulatedEntries + toolEntries
|
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(
|
currentContext = contextPackBuilder.build(
|
||||||
id = ContextPackId(UUID.randomUUID().toString()),
|
id = ContextPackId(UUID.randomUUID().toString()),
|
||||||
sessionId = sessionId,
|
sessionId = sessionId,
|
||||||
@@ -752,7 +792,10 @@ abstract class SessionOrchestrator(
|
|||||||
is StageExecutionResult.Success -> {
|
is StageExecutionResult.Success -> {
|
||||||
emitToolArtifacts(sessionId, stageId, stageConfig)
|
emitToolArtifacts(sessionId, stageId, stageConfig)
|
||||||
emitLlmArtifacts(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
|
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
|
* 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
|
* 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.
|
* 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(
|
private suspend fun repoEntriesOrMapFloor(
|
||||||
sessionId: SessionId,
|
sessionId: SessionId,
|
||||||
hits: List<RepoKnowledgeHit>,
|
hits: List<RepoKnowledgeHit>,
|
||||||
stagePrompt: String,
|
stagePrompt: String,
|
||||||
): List<ContextEntry> {
|
): 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()) {
|
return if (useful.isEmpty()) {
|
||||||
buildRepoMapEntries(sessionId, stagePrompt)
|
buildRepoMapEntries(sessionId, stagePrompt)
|
||||||
} else {
|
} else {
|
||||||
@@ -2033,6 +2082,7 @@ abstract class SessionOrchestrator(
|
|||||||
stageId: StageId,
|
stageId: StageId,
|
||||||
stageConfig: StageConfig,
|
stageConfig: StageConfig,
|
||||||
effectives: RunEffectives,
|
effectives: RunEffectives,
|
||||||
|
profileCommands: Map<String, String>,
|
||||||
): StageExecutionResult {
|
): StageExecutionResult {
|
||||||
val produced = verifyProduces(sessionId, stageId, stageConfig)
|
val produced = verifyProduces(sessionId, stageId, stageConfig)
|
||||||
if (produced is StageExecutionResult.Failure) return produced
|
if (produced is StageExecutionResult.Failure) return produced
|
||||||
@@ -2042,7 +2092,9 @@ abstract class SessionOrchestrator(
|
|||||||
val gates: List<suspend () -> StageExecutionResult> = listOf(
|
val gates: List<suspend () -> StageExecutionResult> = listOf(
|
||||||
{ groundBriefReferences(sessionId, stageId, stageConfig, effectives) },
|
{ groundBriefReferences(sessionId, stageId, stageConfig, effectives) },
|
||||||
{ checkBriefEcho(sessionId, stageId, stageConfig) },
|
{ checkBriefEcho(sessionId, stageId, stageConfig) },
|
||||||
|
{ runContractGate(sessionId, stageId, stageConfig, effectives) },
|
||||||
{ runStaticAnalysis(sessionId, stageId, stageConfig, effectives) },
|
{ runStaticAnalysis(sessionId, stageId, stageConfig, effectives) },
|
||||||
|
{ runExecutionGate(sessionId, stageId, stageConfig, effectives, profileCommands) },
|
||||||
)
|
)
|
||||||
for (gate in gates) {
|
for (gate in gates) {
|
||||||
val result = gate()
|
val result = gate()
|
||||||
@@ -2051,6 +2103,83 @@ abstract class SessionOrchestrator(
|
|||||||
return StageExecutionResult.Success(emptyList())
|
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`
|
* 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
|
* 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(
|
private suspend fun emitProcessResultEvents(
|
||||||
sessionId: SessionId,
|
sessionId: SessionId,
|
||||||
stageId: StageId,
|
stageId: StageId,
|
||||||
@@ -2185,6 +2361,11 @@ abstract class SessionOrchestrator(
|
|||||||
// into the artifact text; a tools-less call yields clean JSON deterministically (and lets the
|
// 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).
|
// JSON grammar constraint re-apply, since grammar+tools is rejected by llama.cpp).
|
||||||
withTools: Boolean = true,
|
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 {
|
): InferenceResult {
|
||||||
// A stage that grants tools needs a tool-calling model, so request ToolCalling on top of any
|
// 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
|
// 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
|
// ponytail: filter write tools while read-before-write block is active; restored once a read completes
|
||||||
!isReadOnlyMode(sessionId) || ToolCapability.FILE_WRITE !in tool.requiredCapabilities
|
!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 ->
|
.map { tool ->
|
||||||
ToolDefinition(
|
ToolDefinition(
|
||||||
function = ToolFunction(
|
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
|
// 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.
|
// static-clean output reaches the downstream reviewer. Empty = no static-first step.
|
||||||
val staticAnalysis: List<String> = emptyList(),
|
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(),
|
val metadata: Map<String, String> = emptyMap(),
|
||||||
)
|
)
|
||||||
|
|||||||
+13
@@ -5,6 +5,7 @@ import com.correx.core.artifacts.kind.TypedArtifactSlot
|
|||||||
import com.correx.core.events.types.ArtifactId
|
import com.correx.core.events.types.ArtifactId
|
||||||
import com.correx.core.events.types.StageId
|
import com.correx.core.events.types.StageId
|
||||||
import com.correx.core.events.types.TransitionId
|
import com.correx.core.events.types.TransitionId
|
||||||
|
import com.correx.core.transitions.graph.BuildExpectation
|
||||||
import com.correx.core.transitions.graph.StageConfig
|
import com.correx.core.transitions.graph.StageConfig
|
||||||
import com.correx.core.transitions.graph.TransitionEdge
|
import com.correx.core.transitions.graph.TransitionEdge
|
||||||
import com.correx.core.transitions.graph.WorkflowGraph
|
import com.correx.core.transitions.graph.WorkflowGraph
|
||||||
@@ -58,11 +59,21 @@ class ExecutionPlanCompiler(
|
|||||||
staticManifest = emptyList(),
|
staticManifest = emptyList(),
|
||||||
planDerived = PlanDerivedManifest.deriveAllowedWrites(s),
|
planDerived = PlanDerivedManifest.deriveAllowedWrites(s),
|
||||||
).sorted()
|
).sorted()
|
||||||
|
val buildExpectation = BuildExpectation.fromPlan(s.buildExpectation)
|
||||||
|
?: throw WorkflowValidationException(
|
||||||
|
"stage '${s.id}' has invalid build_expectation '${s.buildExpectation}' — " +
|
||||||
|
"use one of none|module|project|tests",
|
||||||
|
)
|
||||||
StageId(s.id) to StageConfig(
|
StageId(s.id) to StageConfig(
|
||||||
produces = listOf(TypedArtifactSlot(name = ArtifactId(s.produces), kind = kind)),
|
produces = listOf(TypedArtifactSlot(name = ArtifactId(s.produces), kind = kind)),
|
||||||
needs = s.needs.map { ArtifactId(it) }.toSet(),
|
needs = s.needs.map { ArtifactId(it) }.toSet(),
|
||||||
allowedTools = s.tools.toSet(),
|
allowedTools = s.tools.toSet(),
|
||||||
writeManifest = writeManifest,
|
writeManifest = writeManifest,
|
||||||
|
// Option A: the concrete (non-glob) subset of the declared writes are expected files
|
||||||
|
// the contract gate will require to exist. Globs (src/**) are write-permission scopes,
|
||||||
|
// not existence guarantees, so they are excluded here.
|
||||||
|
expectedFiles = s.writes.map { it.trim() }.filter { it.isNotEmpty() && !isGlob(it) },
|
||||||
|
buildExpectation = buildExpectation,
|
||||||
tokenBudget = DEFAULT_STAGE_TOKEN_BUDGET,
|
tokenBudget = DEFAULT_STAGE_TOKEN_BUDGET,
|
||||||
metadata = mapOf("promptInline" to s.prompt),
|
metadata = mapOf("promptInline" to s.prompt),
|
||||||
)
|
)
|
||||||
@@ -134,6 +145,8 @@ class ExecutionPlanCompiler(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun isGlob(path: String): Boolean = path.any { it == '*' || it == '?' || it == '[' }
|
||||||
|
|
||||||
private fun validateReachability(declared: Set<String>, transitions: Set<TransitionEdge>, start: StageId) {
|
private fun validateReachability(declared: Set<String>, transitions: Set<TransitionEdge>, start: StageId) {
|
||||||
// A declared stage with no path from start can never run — typical LLM topology
|
// A declared stage with no path from start can never run — typical LLM topology
|
||||||
// failure: every stage wired straight to "done" instead of chained, which silently
|
// failure: every stage wired straight to "done" instead of chained, which silently
|
||||||
|
|||||||
+4
@@ -21,6 +21,10 @@ data class PlanStage(
|
|||||||
// contribution (the static manifest, if any, still applies). The planner emits this under
|
// contribution (the static manifest, if any, still applies). The planner emits this under
|
||||||
// the JSON key `writes`.
|
// the JSON key `writes`.
|
||||||
val writes: List<String> = emptyList(),
|
val writes: List<String> = emptyList(),
|
||||||
|
// Execution-gate scope (staged-verification §2): none|module|project|tests. Absent = none. The
|
||||||
|
// planner emits this under the JSON key `build_expectation`; it resolves at runtime to the
|
||||||
|
// project profile's typecheck/build/test command run as a deterministic Gate 4.
|
||||||
|
@param:JsonProperty("build_expectation") val buildExpectation: String? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
data class PlanEdge(
|
data class PlanEdge(
|
||||||
|
|||||||
+40
@@ -322,4 +322,44 @@ class ExecutionPlanCompilerTest {
|
|||||||
val graph = compiler.compile(plan, "unvalidated-workflow")
|
val graph = compiler.compile(plan, "unvalidated-workflow")
|
||||||
assertEquals(2, graph.stages.size)
|
assertEquals(2, graph.stages.size)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `Option A - concrete declared writes become expectedFiles, globs excluded`() {
|
||||||
|
val plan = """
|
||||||
|
{
|
||||||
|
"goal": "x", "stages": [
|
||||||
|
{ "id": "impl", "prompt": "p", "produces": "patch", "tools": ["file_write"],
|
||||||
|
"writes": ["src/App.tsx", "src/hooks/**", "package.json"] }
|
||||||
|
],
|
||||||
|
"edges": [ { "from": "impl", "to": "done", "condition": { "type": "always_true" } } ]
|
||||||
|
}
|
||||||
|
""".trimIndent()
|
||||||
|
val stage = compiler.compile(plan, "wf").stages.values.single()
|
||||||
|
assertEquals(setOf("src/App.tsx", "package.json"), stage.expectedFiles.toSet())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `build_expectation is parsed into the closed-set enum`() {
|
||||||
|
val plan = validPlan.replace(
|
||||||
|
"\"tools\": [\"ShellTool\"]",
|
||||||
|
"\"tools\": [\"ShellTool\"], \"build_expectation\": \"project\"",
|
||||||
|
)
|
||||||
|
val stage = compiler.compile(plan, "wf").stages[compiler.compile(plan, "wf").start]!!
|
||||||
|
assertEquals(com.correx.core.transitions.graph.BuildExpectation.PROJECT, stage.buildExpectation)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `absent build_expectation defaults to NONE`() {
|
||||||
|
val stage = compiler.compile(validPlan, "wf").stages.values.first()
|
||||||
|
assertEquals(com.correx.core.transitions.graph.BuildExpectation.NONE, stage.buildExpectation)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `invalid build_expectation is rejected at compile time`() {
|
||||||
|
val bad = validPlan.replace(
|
||||||
|
"\"tools\": [\"ShellTool\"]",
|
||||||
|
"\"tools\": [\"ShellTool\"], \"build_expectation\": \"deploy\"",
|
||||||
|
)
|
||||||
|
assertThrows<WorkflowValidationException> { compiler.compile(bad, "wf") }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user