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