diff --git a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt index 4e29f8ef..c6877a6e 100644 --- a/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt +++ b/apps/server/src/main/kotlin/com/correx/apps/server/Main.kt @@ -38,6 +38,7 @@ import com.correx.core.kernel.orchestration.OrchestrationProjector import com.correx.core.kernel.orchestration.OrchestrationRepository import com.correx.core.kernel.orchestration.OrchestratorEngines 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.WorkspaceContext import com.correx.core.kernel.orchestration.WorkspaceTools @@ -324,6 +325,7 @@ fun main() { workspacePolicy = workspacePolicy, workspaceToolRegistryProvider = wsToolRegistryProvider, staticAnalysisRunner = ProcessStaticAnalysisRunner(), + contractAssertionEvaluator = FileSystemContractEvaluator(), ) val decisionJournalRepository = InfrastructureModule.createDecisionJournalRepository(eventStore) val defaultOrchestrationConfig = OrchestrationConfig( diff --git a/core/artifacts/src/main/kotlin/com/correx/core/artifacts/kind/ContractAssertion.kt b/core/artifacts/src/main/kotlin/com/correx/core/artifacts/kind/ContractAssertion.kt new file mode 100644 index 00000000..372c5075 --- /dev/null +++ b/core/artifacts/src/main/kotlin/com/correx/core/artifacts/kind/ContractAssertion.kt @@ -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 = emptyMap(), +) diff --git a/core/artifacts/src/main/kotlin/com/correx/core/artifacts/kind/KindContractTable.kt b/core/artifacts/src/main/kotlin/com/correx/core/artifacts/kind/KindContractTable.kt new file mode 100644 index 00000000..fc2600e2 --- /dev/null +++ b/core/artifacts/src/main/kotlin/com/correx/core/artifacts/kind/KindContractTable.kt @@ -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 = 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 "> = TS_REACT + KOTLIN + GENERIC + + /** Project-supplied additive overrides, keyed by kind id. Set at wiring time; empty by default. */ + @Volatile + var projectOverrides: Map> = 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 { + 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 + } +} diff --git a/core/artifacts/src/main/kotlin/com/correx/core/artifacts/kind/KindInference.kt b/core/artifacts/src/main/kotlin/com/correx/core/artifacts/kind/KindInference.kt new file mode 100644 index 00000000..7d17eabf --- /dev/null +++ b/core/artifacts/src/main/kotlin/com/correx/core/artifacts/kind/KindInference.kt @@ -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" + } + } +} diff --git a/core/artifacts/src/test/kotlin/com/correx/core/artifacts/kind/KindContractTableTest.kt b/core/artifacts/src/test/kotlin/com/correx/core/artifacts/kind/KindContractTableTest.kt new file mode 100644 index 00000000..052acd2f --- /dev/null +++ b/core/artifacts/src/test/kotlin/com/correx/core/artifacts/kind/KindContractTableTest.kt @@ -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") + } +} diff --git a/core/events/src/main/kotlin/com/correx/core/events/events/ContractGateEvents.kt b/core/events/src/main/kotlin/com/correx/core/events/events/ContractGateEvents.kt new file mode 100644 index 00000000..25c7498a --- /dev/null +++ b/core/events/src/main/kotlin/com/correx/core/events/events/ContractGateEvents.kt @@ -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, +) : EventPayload { + val passed: Boolean get() = results.all { it.passed } +} diff --git a/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt b/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt index b46c13aa..abc3beb9 100644 --- a/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt +++ b/core/events/src/main/kotlin/com/correx/core/events/serialization/Serialization.kt @@ -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) diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/ContractAssertionEvaluator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/ContractAssertionEvaluator.kt new file mode 100644 index 00000000..b322be9f --- /dev/null +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/ContractAssertionEvaluator.kt @@ -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 +} diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/FileSystemContractEvaluator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/FileSystemContractEvaluator.kt new file mode 100644 index 00000000..4e1e3483 --- /dev/null +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/FileSystemContractEvaluator.kt @@ -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]""") + } +} diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/OrchestratorEngines.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/OrchestratorEngines.kt index f00fee64..d44bd503 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/OrchestratorEngines.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/OrchestratorEngines.kt @@ -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, ) diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/ReplayOrchestrator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/ReplayOrchestrator.kt index 1907aa39..18cf8403 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/ReplayOrchestrator.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/ReplayOrchestrator.kt @@ -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, ) } diff --git a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt index 80782435..7626d9c4 100644 --- a/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt +++ b/core/kernel/src/main/kotlin/com/correx/core/kernel/orchestration/SessionOrchestrator.kt @@ -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() 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, stagePrompt: String, ): List { - 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, ): StageExecutionResult { val produced = verifyProduces(sessionId, stageId, stageConfig) if (produced is StageExecutionResult.Failure) return produced @@ -2042,7 +2092,9 @@ abstract class SessionOrchestrator( val gates: List 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 { + 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, + ): 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( diff --git a/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/FileSystemContractEvaluatorTest.kt b/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/FileSystemContractEvaluatorTest.kt new file mode 100644 index 00000000..12b87e11 --- /dev/null +++ b/core/kernel/src/test/kotlin/com/correx/core/kernel/orchestration/FileSystemContractEvaluatorTest.kt @@ -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 = 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") + } +} diff --git a/core/transitions/src/main/kotlin/com/correx/core/transitions/graph/BuildExpectation.kt b/core/transitions/src/main/kotlin/com/correx/core/transitions/graph/BuildExpectation.kt new file mode 100644 index 00000000..3d584085 --- /dev/null +++ b/core/transitions/src/main/kotlin/com/correx/core/transitions/graph/BuildExpectation.kt @@ -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 + } + } +} diff --git a/core/transitions/src/main/kotlin/com/correx/core/transitions/graph/StageConfig.kt b/core/transitions/src/main/kotlin/com/correx/core/transitions/graph/StageConfig.kt index 2738ce51..ff1fc61f 100644 --- a/core/transitions/src/main/kotlin/com/correx/core/transitions/graph/StageConfig.kt +++ b/core/transitions/src/main/kotlin/com/correx/core/transitions/graph/StageConfig.kt @@ -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 = 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 = emptyList(), val metadata: Map = emptyMap(), ) diff --git a/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompiler.kt b/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompiler.kt index 482303e7..858f8a4d 100644 --- a/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompiler.kt +++ b/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompiler.kt @@ -5,6 +5,7 @@ import com.correx.core.artifacts.kind.TypedArtifactSlot import com.correx.core.events.types.ArtifactId import com.correx.core.events.types.StageId 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.TransitionEdge import com.correx.core.transitions.graph.WorkflowGraph @@ -58,11 +59,21 @@ class ExecutionPlanCompiler( staticManifest = emptyList(), planDerived = PlanDerivedManifest.deriveAllowedWrites(s), ).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( produces = listOf(TypedArtifactSlot(name = ArtifactId(s.produces), kind = kind)), needs = s.needs.map { ArtifactId(it) }.toSet(), allowedTools = s.tools.toSet(), 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, 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, transitions: Set, start: StageId) { // 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 diff --git a/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/ExecutionPlanModel.kt b/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/ExecutionPlanModel.kt index fbeeb742..9691595d 100644 --- a/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/ExecutionPlanModel.kt +++ b/infrastructure/workflow/src/main/kotlin/com/correx/infrastructure/workflow/ExecutionPlanModel.kt @@ -21,6 +21,10 @@ data class PlanStage( // contribution (the static manifest, if any, still applies). The planner emits this under // the JSON key `writes`. val writes: List = 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( diff --git a/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompilerTest.kt b/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompilerTest.kt index c0f9ed18..e01f0b66 100644 --- a/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompilerTest.kt +++ b/infrastructure/workflow/src/test/kotlin/com/correx/infrastructure/workflow/ExecutionPlanCompilerTest.kt @@ -322,4 +322,44 @@ class ExecutionPlanCompilerTest { val graph = compiler.compile(plan, "unvalidated-workflow") 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 { compiler.compile(bad, "wf") } + } }