feat(validation): staged-verification funnel — contract + execution gates

Gate 2 (contract) and Gate 4 (execution) of the 4-gate staged-verification
design, plus the two gate-quality fixes found in live QA.

- Gate 2: KindContractTable (kind→assertions registry, universal floor,
  additive project overrides) + KindInference (path→kind, pure/replay-safe)
  + ContractAssertion model; ContractGateEvaluatedEvent (registered);
  ContractAssertionEvaluator seam + FileSystemContractEvaluator (FS/TEXT,
  COMPILER skipped); runContractGate in runPostStageGates — a stage that
  produces file_written artifacts must have every declared+written file
  exist/non-empty/parse, else retryable hand-back with per-assertion feedback.
- Option A: ExecutionPlanCompiler puts concrete (non-glob) plan `writes` into
  StageConfig.expectedFiles, so a declared-but-unwritten file fails file_exists.
- Gate 4: BuildExpectation enum (none|module|project|tests) + plan field +
  closed-set compiler validation; runExecutionGate resolves the alias against
  the bound project profile commands and runs it as a deterministic gate.
- Live-QA fixes: react_entry kind so main.tsx/index.tsx aren't required to
  export a default component (was an unwinnable false-positive); JSONC
  tolerance (allowComments/allowTrailingComma) so tsconfig.json passes valid_json.

Seams are null on the replay harness (no-op) — recorded events are truth (#9).
This commit is contained in:
2026-07-06 01:25:51 +04:00
parent 33ea44d8cc
commit ff1a0ffdad
18 changed files with 935 additions and 5 deletions
@@ -0,0 +1,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
}
@@ -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]""")
}
}
@@ -31,4 +31,7 @@ data class OrchestratorEngines(
// Runs operator-configured static-analysis commands as a harness step (role-reliability §5).
// Null = no static-first step; a stage declaring `static_analysis` then no-ops with a warning.
val staticAnalysisRunner: StaticAnalysisRunner? = null,
// Evaluates Gate 2 contract assertions against a stage's produced files (design §1). Null = no
// contract gate; the deterministic funnel then rests on produces-presence + static analysis only.
val contractAssertionEvaluator: ContractAssertionEvaluator? = null,
)
@@ -183,6 +183,7 @@ class ReplayOrchestrator(
responseFormat: ResponseFormat,
effectives: RunEffectives,
withTools: Boolean,
forceWriteOnly: Boolean,
): InferenceResult = when (strategy) {
is ReplayStrategy.SkipInference -> {
// bypass router entirely — use recorded artifact
@@ -213,6 +214,7 @@ class ReplayOrchestrator(
}
else -> super.runInference(
sessionId, stageId, contextPack, stageConfig, timeoutMs, responseFormat, effectives, withTools,
forceWriteOnly,
)
}
@@ -14,6 +14,8 @@ import com.correx.core.approvals.model.ApprovalScopeIdentity
import com.correx.core.approvals.model.DomainApprovalRequest
import com.correx.core.artifacts.ArtifactState
import com.correx.core.artifacts.kind.ArtifactKindRegistry
import com.correx.core.artifacts.kind.KindContractTable
import com.correx.core.artifacts.kind.KindInference
import com.correx.core.artifacts.kind.JsonSchema
import com.correx.core.artifacts.kind.FileWrittenArtifact
import com.correx.core.artifacts.kind.ProcessResultArtifact
@@ -32,6 +34,8 @@ import com.correx.core.events.events.RefinementIterationEvent
import com.correx.core.events.events.ApprovalRequestedEvent
import com.correx.core.events.events.ArtifactContentStoredEvent
import com.correx.core.events.events.FileWrittenEvent
import com.correx.core.events.events.ContractGateEvaluatedEvent
import com.correx.core.events.events.ContractAssertionResult
import com.correx.core.events.events.ArtifactCreatedEvent
import com.correx.core.events.events.ArtifactRepairAttemptedEvent
import com.correx.core.events.events.ArtifactRepairFailedEvent
@@ -165,6 +169,13 @@ import java.util.concurrent.atomic.*
import kotlin.coroutines.cancellation.CancellationException
private const val MAX_TOOL_ROUNDS = 10
// Consecutive read-only tool rounds (no file_write/file_edit) that still owe a file_written
// artifact before we force the write nudge. A model that keeps calling read tools every round
// trips neither the prose nudge nor the stage_complete nudge, so without this it silently burns
// every round reading and never writes (2026-07-05: define_types read-looped 40 turns → failed).
private const val READ_LOOP_NUDGE_THRESHOLD = 3
private val WRITE_TOOL_NAMES = setOf("file_write", "file_edit")
private const val MAX_FEEDBACK_ISSUES = 3
private const val STAGE_COMPLETE_TOOL = "stage_complete"
private const val EMIT_ARTIFACT_TOOL = "emit_artifact"
@@ -208,6 +219,7 @@ private const val MAX_CLARIFICATION_ROUNDS = 3
// Static-analysis output caps: the tail retained in the recorded event vs. the (larger) slice fed
// back to the model so it can fix the failure. Tails, because the error summary sits at the end.
private const val CONTRACT_EVIDENCE_CAP = 400
private const val STATIC_ANALYSIS_SUMMARY_CAP = 2_000
private const val STATIC_ANALYSIS_FEEDBACK_CAP = 6_000
@@ -245,6 +257,7 @@ abstract class SessionOrchestrator(
private val workspacePolicy: WorkspacePolicy? = engines.workspacePolicy
private val worldProbe: WorldProbe = engines.worldProbe
private val staticAnalysisRunner: StaticAnalysisRunner? = engines.staticAnalysisRunner
private val contractAssertionEvaluator: ContractAssertionEvaluator? = engines.contractAssertionEvaluator
private val workspaceToolRegistryProvider: WorkspaceToolRegistryProvider? = engines.workspaceToolRegistryProvider
private val inferenceRepository: InferenceRepository = repositories.inferenceRepository
internal val orchestrationRepository: OrchestrationRepository = repositories.orchestrationRepository
@@ -462,8 +475,12 @@ abstract class SessionOrchestrator(
} ?: emptyList()
val projectProfileEntries = session.state.boundProjectProfile
?.let { listOf(buildProjectProfileEntry(it)) } ?: emptyList()
val agentInstructionsEntries = session.state.boundAgentInstructions
?.let { listOf(buildAgentInstructionsEntry(it)) } ?: emptyList()
// AGENTS.md injection is disabled for now. The workspace-root AGENTS.md (the DOX framework)
// carries a mandatory "Read Before Editing" walk — "read the whole AGENTS.md chain, do not
// rely on memory, re-read before editing" — which drives a small model into an endless
// read-loop that never writes (2026-07-05 root cause). Reincorporating DOX properly (scoped
// to the stage's subproject, not the repo root; not read-mandating) is deferred.
val agentInstructionsEntries = emptyList<ContextEntry>()
val retryFeedbackEntries = buildRetryFeedbackEntry(sessionEvents, stageId)
?.let { listOf(it) } ?: emptyList()
val vocabularyEntries = artifactKindRegistry
@@ -517,6 +534,7 @@ abstract class SessionOrchestrator(
sessionId, stageId, currentContext, stageConfig, config.stageTimeoutMs, responseFormat, effectives,
)
var toolRounds = 0
var consecutiveReadOnlyRounds = 0
// Set when the model produces its artifact via the emit_artifact tool instead of a final
// JSON message; overrides the post-loop capture of the (then-empty) assistant text.
var llmArtifactOverride: String? = null
@@ -548,7 +566,7 @@ abstract class SessionOrchestrator(
// Append a corrective tool-result and re-run inference (bounded by MAX_TOOL_ROUNDS).
// Returns the new result rather than mutating inferenceResult, to preserve smart casts.
suspend fun pushBack(nudge: String): InferenceResult {
suspend fun pushBack(nudge: String, forceWriteOnly: Boolean = false): InferenceResult {
accumulatedEntries = accumulatedEntries + ContextEntry(
id = ContextEntryId(UUID.randomUUID().toString()),
layer = ContextLayer.L2,
@@ -569,6 +587,7 @@ abstract class SessionOrchestrator(
toolRounds++
return runInference(
sessionId, stageId, currentContext, stageConfig, config.stageTimeoutMs, responseFormat, effectives,
forceWriteOnly = forceWriteOnly,
)
}
@@ -576,6 +595,10 @@ abstract class SessionOrchestrator(
"file_edit or file_write tool to write the change to the file before finishing — do " +
"not output the file contents as a message."
val readLoopNudge = "STOP reading. You have read enough files and this stage has produced " +
"nothing yet. You MUST now call the file_write tool to create the required file(s) for " +
"this stage. Do not call file_read or list_dir again before you have written a file."
while (
inferenceResult is InferenceResult.Success &&
toolRounds < MAX_TOOL_ROUNDS &&
@@ -640,6 +663,23 @@ abstract class SessionOrchestrator(
// loop as tool-result context so the model can see the error and adapt (bounded by
// MAX_TOOL_ROUNDS). Only FATAL: failures (handled above) abort the stage.
accumulatedEntries = accumulatedEntries + toolEntries
// Read-loop breaker: this round called only read-only tools yet the stage still owes a
// file_written artifact. Left alone the model keeps reading until MAX_TOOL_ROUNDS and
// never writes (F-018 nudges only cover a prose turn or a premature stage_complete, not
// an endless read-loop). After READ_LOOP_NUDGE_THRESHOLD such rounds, force the write
// nudge; a real write resets the counter so multi-file stages are unaffected.
if (owesFileWrite() &&
inferenceResult.response.toolCalls.none { it.function.name in WRITE_TOOL_NAMES }
) {
consecutiveReadOnlyRounds++
if (consecutiveReadOnlyRounds >= READ_LOOP_NUDGE_THRESHOLD) {
consecutiveReadOnlyRounds = 0
inferenceResult = pushBack(readLoopNudge, forceWriteOnly = true)
continue
}
} else {
consecutiveReadOnlyRounds = 0
}
currentContext = contextPackBuilder.build(
id = ContextPackId(UUID.randomUUID().toString()),
sessionId = sessionId,
@@ -752,7 +792,10 @@ abstract class SessionOrchestrator(
is StageExecutionResult.Success -> {
emitToolArtifacts(sessionId, stageId, stageConfig)
emitLlmArtifacts(sessionId, stageId, stageConfig)
runPostStageGates(sessionId, stageId, stageConfig, effectives)
runPostStageGates(
sessionId, stageId, stageConfig, effectives,
session.state.boundProjectProfile?.commands ?: emptyMap(),
)
}
is StageExecutionResult.Failure -> outcome
@@ -1617,13 +1660,19 @@ abstract class SessionOrchestrator(
* Zero-score hits carry no ranking signal (a noop embedder scores everything 0.0), so injecting
* them just feeds the agent arbitrary files and invites hallucinated paths. Drop them; when nothing
* useful survives, fall back to the deterministic repo map so the stage still has real grounding.
*
* Markdown/docs hits are gated exactly like the repo-map floor (buildRepoMapEntries): with the
* embedder live, semantic similarity happily ranks churny kernel docs (epics, ADRs, QA plans)
* above real source for a code task, re-poisoning the context the floor's gate was added to fix
* (2026-07-05 root cause). They only survive when the stage prompt explicitly asks about docs.
*/
private suspend fun repoEntriesOrMapFloor(
sessionId: SessionId,
hits: List<RepoKnowledgeHit>,
stagePrompt: String,
): List<ContextEntry> {
val useful = hits.filter { it.score > 0f }
val docsWanted = DOCS_REQUEST_REGEX.containsMatchIn(stagePrompt)
val useful = hits.filter { it.score > 0f && (docsWanted || !isDocPath(it.path)) }
return if (useful.isEmpty()) {
buildRepoMapEntries(sessionId, stagePrompt)
} else {
@@ -2033,6 +2082,7 @@ abstract class SessionOrchestrator(
stageId: StageId,
stageConfig: StageConfig,
effectives: RunEffectives,
profileCommands: Map<String, String>,
): StageExecutionResult {
val produced = verifyProduces(sessionId, stageId, stageConfig)
if (produced is StageExecutionResult.Failure) return produced
@@ -2042,7 +2092,9 @@ abstract class SessionOrchestrator(
val gates: List<suspend () -> StageExecutionResult> = listOf(
{ groundBriefReferences(sessionId, stageId, stageConfig, effectives) },
{ checkBriefEcho(sessionId, stageId, stageConfig) },
{ runContractGate(sessionId, stageId, stageConfig, effectives) },
{ runStaticAnalysis(sessionId, stageId, stageConfig, effectives) },
{ runExecutionGate(sessionId, stageId, stageConfig, effectives, profileCommands) },
)
for (gate in gates) {
val result = gate()
@@ -2051,6 +2103,83 @@ abstract class SessionOrchestrator(
return StageExecutionResult.Success(emptyList())
}
/**
* Gate 2 — contract (design 2026-07-06-staged-verification-and-review.md §1). For a stage that
* produces file_written artifacts, project the files it actually wrote (Option B: runtime
* manifest), infer each file's kind from its path, derive the [KindContractTable] assertions
* (universal floor for unknown kinds), and evaluate the FS/TEXT ones deterministically. Every
* verdict is recorded in a [ContractGateEvaluatedEvent] (invariant #9), so replay reads it back
* and never re-inspects the filesystem.
*
* A failed assertion fails the stage retryably with the failing assertion ids + evidence fed
* back verbatim — failing-test-name granular hand-back, so the implementer fixes exactly the
* empty/missing/malformed file rather than read-looping. COMPILER assertions are recorded as
* skipped here; the static-analysis command gate is their authoritative check.
*/
private suspend fun runContractGate(
sessionId: SessionId,
stageId: StageId,
stageConfig: StageConfig,
effectives: RunEffectives,
): StageExecutionResult {
val evaluator = contractAssertionEvaluator ?: return StageExecutionResult.Success(emptyList())
val workspaceRoot = effectives.policy?.workspaceRoot ?: return StageExecutionResult.Success(emptyList())
if (stageConfig.produces.none { it.kind.id == "file_written" }) return StageExecutionResult.Success(emptyList())
// Option A B: the files the stage actually wrote (runtime manifest) unioned with the
// concrete files the plan declared it would produce (expectedFiles). A declared file that
// was never written survives here and fails file_exists — that is the missing-file catch.
val paths = (stageWrittenPaths(sessionId, stageId) + stageConfig.expectedFiles).distinct()
if (paths.isEmpty()) return StageExecutionResult.Success(emptyList())
val assertions = paths.flatMap { path ->
KindContractTable.assertionsFor(KindInference.kindFor(path) ?: "", path)
}
val results = assertions.map { a ->
val v = contractAssertionEvaluator.evaluate(workspaceRoot, a)
ContractAssertionResult(
assertionId = a.id,
target = a.target,
layer = a.layer.name,
evaluator = a.evaluator.name,
passed = v.passed,
evidence = v.evidence.takeLast(CONTRACT_EVIDENCE_CAP),
)
}
emit(sessionId, ContractGateEvaluatedEvent(sessionId, stageId, results))
val failures = results.filterNot { it.passed }
if (failures.isEmpty()) return StageExecutionResult.Success(emptyList())
log.warn(
"[Orchestrator] contract gate failed session={} stage={} assertions={}",
sessionId.value, stageId.value,
failures.joinToString(", ") { "${it.assertionId}(${it.target})" },
)
val detail = failures.joinToString("\n") { "- ${it.assertionId} on ${it.target}: ${it.evidence}" }
return StageExecutionResult.Failure(
"stage ${stageId.value} did not satisfy its file contract. Fix these before review:\n$detail",
retryable = true,
)
}
/**
* The workspace-relative paths this stage actually wrote, projected from events (invariant #1):
* ToolInvocationRequested → this stage's invocation ids, FileWrittenEvent → the paths written
* under them. Pure projection, replay-safe.
*/
private fun stageWrittenPaths(sessionId: SessionId, stageId: StageId): List<String> {
val events = eventStore.read(sessionId)
val invocationIds = events.mapNotNull { it.payload as? ToolInvocationRequestedEvent }
.filter { it.stageId == stageId }
.map { it.invocationId }
.toSet()
return events.mapNotNull { it.payload as? FileWrittenEvent }
.filter { it.invocationId in invocationIds && it.postImageHash != null }
.map { it.path }
.distinct()
}
/**
* Static-first reviewer gate (role-reliability §5): for a stage that declares `static_analysis`
* commands, run them (compiler / detekt / formatters) against its just-produced output in the
@@ -2108,6 +2237,53 @@ abstract class SessionOrchestrator(
)
}
/**
* Gate 4 — execution (staged-verification §1/§2). A stage's [StageConfig.buildExpectation]
* (none/module/project/tests) resolves to the bound project profile's typecheck/build/test
* command and runs it as a deterministic build gate. NONE runs nothing (early stages where the
* project isn't yet runnable). A profile missing the alias skips with a warning — the vocabulary
* degrades safely rather than failing when the operator hasn't configured commands. A non-clean
* run fails the stage retryably with the build output fed back (recorded as a
* [StaticAnalysisCompletedEvent], invariant #9, so replay never re-runs it).
*/
private suspend fun runExecutionGate(
sessionId: SessionId,
stageId: StageId,
stageConfig: StageConfig,
effectives: RunEffectives,
profileCommands: Map<String, String>,
): StageExecutionResult {
val alias = stageConfig.buildExpectation.commandAlias ?: return StageExecutionResult.Success(emptyList())
val runner = staticAnalysisRunner
val workspaceRoot = effectives.policy?.workspaceRoot
if (runner == null || workspaceRoot == null) return StageExecutionResult.Success(emptyList())
val command = profileCommands[alias]
if (command.isNullOrBlank()) {
log.warn(
"[Orchestrator] stage {} declares build_expectation={} but project profile has no '{}' " +
"command — skipping execution gate",
stageId.value, stageConfig.buildExpectation, alias,
)
return StageExecutionResult.Success(emptyList())
}
val run = runner.run(workspaceRoot, command)
val finding = StaticAnalysisFinding(command, run.exitCode, run.output.takeLast(STATIC_ANALYSIS_SUMMARY_CAP))
emit(sessionId, StaticAnalysisCompletedEvent(sessionId, stageId, listOf(finding)))
if (finding.clean) return StageExecutionResult.Success(emptyList())
log.warn(
"[Orchestrator] execution gate failed session={} stage={} expectation={} command={}",
sessionId.value, stageId.value, stageConfig.buildExpectation, command,
)
return StageExecutionResult.Failure(
"stage ${stageId.value} did not pass its ${stageConfig.buildExpectation} build gate. " +
"Fix these before proceeding:\n\n$ $command (exit ${run.exitCode})\n" +
run.output.takeLast(STATIC_ANALYSIS_FEEDBACK_CAP),
retryable = true,
)
}
private suspend fun emitProcessResultEvents(
sessionId: SessionId,
stageId: StageId,
@@ -2185,6 +2361,11 @@ abstract class SessionOrchestrator(
// into the artifact text; a tools-less call yields clean JSON deterministically (and lets the
// JSON grammar constraint re-apply, since grammar+tools is rejected by llama.cpp).
withTools: Boolean = true,
// When true, offer ONLY write tools (+ stage_complete / emit_artifact) — read tools are
// stripped. Used to break a read-loop: a model that owes a file_written artifact but keeps
// calling read tools ignores a textual nudge (its output all goes to reasoning_content), so
// we structurally remove the option to read and leave writing as the only move.
forceWriteOnly: Boolean = false,
): InferenceResult {
// A stage that grants tools needs a tool-calling model, so request ToolCalling on top of any
// declared capabilities — the capability-aware strategy then ranks eligible providers by their
@@ -2213,6 +2394,11 @@ abstract class SessionOrchestrator(
// ponytail: filter write tools while read-before-write block is active; restored once a read completes
!isReadOnlyMode(sessionId) || ToolCapability.FILE_WRITE !in tool.requiredCapabilities
}
.filter { tool ->
// Read-loop break: keep only write tools (drop file_read/list_dir/shell) so
// the model cannot keep reading and must write or complete.
!forceWriteOnly || ToolCapability.FILE_WRITE in tool.requiredCapabilities
}
.map { tool ->
ToolDefinition(
function = ToolFunction(
@@ -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")
}
}