fix(build-gate): close two frontend COMPLETE-lie holes (#277)

Run 771c0b96 marked COMPLETE with a frontend that did not build. Two
verification holes let a broken import through:

1. A MODULE build_expectation delegates to LSP and the execution gate
   returned Success trusting it — but tsserver failed to initialize every
   stage, so empty diagnostics read as clean. runExecutionGate now only
   trusts the MODULE->LSP short-circuit when the LSP run actually ran
   (new lspDiagnosticsSkipped projection); on skip it falls through to the
   real build command.

2. ExecutionPlanCompiler disabled the terminal whole-project auto build gate
   whenever ANY stage declared a build_expectation — so a MODULE (typecheck-
   only) declaration removed the real `npm run build` floor. Now only a real
   whole-project build (PROJECT/TESTS) suppresses the auto gate; MODULE/NONE
   do not. Extracted autoGateStages() helper. Two new compiler tests.

core:kernel + infrastructure:workflow tests green; no new detekt.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-20 11:13:22 +04:00
parent 9ac32c9e93
commit 159b3f1eb9
3 changed files with 84 additions and 24 deletions
@@ -109,6 +109,18 @@ internal suspend fun SessionOrchestrator.runLspDiagnostics(
)
}
/**
* True if the most recent LSP diagnostics run for [stageId] was skipped (e.g. tsserver failed to
* initialize) rather than actually run. A skipped run emits empty diagnostics, which the LSP gate
* treats as clean — so the execution gate must not trust MODULE→LSP delegation when this holds, and
* falls through to the real build command. Pure projection over recorded events (invariant #9).
*/
internal fun SessionOrchestrator.lspDiagnosticsSkipped(sessionId: SessionId, stageId: StageId): Boolean =
eventStore.read(sessionId)
.mapNotNull { it.payload as? LspDiagnosticsCompletedEvent }
.lastOrNull { it.stageId == stageId }
?.skippedReason != null
internal fun SessionOrchestrator.sessionWrittenPaths(sessionId: SessionId): List<String> =
eventStore.read(sessionId)
.mapNotNull { it.payload as? com.correx.core.events.events.FileWrittenEvent }
@@ -145,8 +157,15 @@ internal suspend fun SessionOrchestrator.runExecutionGate(
else -> null
}
// LSP pull diagnostics are the compiler-front-end/typecheck gate. Keep PROJECT bundlers and
// TESTS as real commands, but do not invoke the profile's flat `typecheck` alias as well.
if (expectation == BuildExpectation.MODULE && lspDiagnosticsRunner != null) {
// TESTS as real commands, but do not invoke the profile's flat `typecheck` alias as well
// UNLESS the LSP run for this stage was skipped (no tsserver, init failure): a skipped check
// verified nothing, and empty diagnostics then read as "clean", so fall through to the real
// build command instead of trusting a gate that never ran.
// ponytail: no unit test — first test on this path needs a full orchestrator + bound-profile +
// LSP-runner fixture (none exists); validated by live QA re-run instead. Add if it regresses.
if (expectation == BuildExpectation.MODULE && lspDiagnosticsRunner != null &&
!lspDiagnosticsSkipped(sessionId, stageId)
) {
return StageExecutionResult.Success(emptyList())
}
val alias = expectation?.commandAlias ?: return StageExecutionResult.Success(emptyList())
@@ -114,23 +114,7 @@ class ExecutionPlanCompiler(
)
)
}
// Deterministic build-gate floor. Code kinds carry an `imports_resolve` COMPILER-layer
// contract assertion, but COMPILER assertions are enforced only by the execution gate, which
// fires only when a stage sets build_expectation. The LLM planner emits every file-writing
// stage as the generic `file_written` kind (never a typed code kind) and rarely sets
// build_expectation, so a scaffold with a dangling import (`import './index.css'` for a file
// never written) sails through to COMPLETED. Close it: when no stage declares any gate, flag
// the terminal stage for an auto build gate — placed there, not per-stage, so it runs when the
// project is whole rather than failing legitimately-incomplete intermediate stages. Whether it
// ACTUALLY builds is decided at run time (SessionOrchestrator.runExecutionGate) from the real
// FileWritten manifest — the plan's declared kinds don't reveal code-ness, but the written
// paths do — so a docs-only plan is left alone and a code plan is always gated.
val autoGateStages: Set<String> = if (declaredExpectations.values.all { it == BuildExpectation.NONE }) {
val writing = plan.stages.filter { it.writes.any { path -> path.isNotBlank() } }.map { it.id }.toSet()
if (writing.isEmpty()) emptySet() else writing + terminalStageId(plan)
} else {
emptySet()
}
val autoGateStages = autoGateStages(plan, declaredExpectations)
val sessionArtifacts =
plan.stages.mapNotNull { it.produces.takeIf(String::isNotBlank) }.toSet() + sessionArtifactIds
@@ -325,6 +309,31 @@ class ExecutionPlanCompiler(
"cite the unmet criterion ids. Never add or imply criteria outside the DoD."
}
// Deterministic build-gate floor. Code kinds carry an `imports_resolve` COMPILER-layer contract
// assertion, but COMPILER assertions are enforced only by the execution gate, which fires only
// when a stage sets build_expectation. The LLM planner emits every file-writing stage as the
// generic `file_written` kind (never a typed code kind) and rarely sets build_expectation, so a
// scaffold with a dangling import (`import './index.css'` for a file never written) sails through
// to COMPLETED. Close it: unless a stage declares a real whole-project build (PROJECT/TESTS),
// flag every write-declaring stage plus the terminal stage for an auto build gate — the terminal
// one runs when the project is whole rather than failing legitimately-incomplete intermediate
// stages. A MODULE declaration is only a per-file typecheck (delegated to LSP, which can silently
// skip) and does NOT prove the assembled project builds, so it must not suppress the terminal
// floor. Whether it ACTUALLY builds is decided at run time (SessionOrchestrator.runExecutionGate)
// from the real FileWritten manifest — declared kinds don't reveal code-ness, written paths do —
// so a docs-only plan is left alone and a code plan is always gated.
private fun autoGateStages(
plan: ExecutionPlanModel,
declaredExpectations: Map<String, BuildExpectation>,
): Set<String> {
val ownsRealBuild = declaredExpectations.values.any {
it == BuildExpectation.PROJECT || it == BuildExpectation.TESTS
}
if (ownsRealBuild) return emptySet()
val writing = plan.stages.filter { it.writes.any { path -> path.isNotBlank() } }.map { it.id }.toSet()
return if (writing.isEmpty()) emptySet() else writing + terminalStageId(plan)
}
private fun staticFloorCommands(writeManifest: List<String>): List<String> {
return writeManifest.takeIf { it.isNotEmpty() }
?.let { listOf("$STATIC_FLOOR_COMMAND -- ${it.joinToString(" ")}") }
@@ -559,16 +559,18 @@ class ExecutionPlanCompilerTest {
}
@Test
fun `an explicitly declared gate suppresses the auto gate`() {
// Planner set a MODULE gate on the entry stage; the compiler must not add its own.
fun `a MODULE declaration does not suppress the auto build gate`() {
// MODULE is only a per-file typecheck (delegated to LSP, which can silently skip when
// tsserver is missing), so it does NOT prove the assembled project builds — the terminal
// whole-project floor must still be applied.
val planned = """
{
"goal": "scaffold a react app",
"stages": [
{ "id": "entry", "prompt": "write main.tsx", "produces": "app_entry", "kind": "react_entry",
"needs": [], "tools": ["file_write"], "build_expectation": "module" },
"needs": [], "tools": ["file_write"], "writes": ["src/main.tsx"], "build_expectation": "module" },
{ "id": "views", "prompt": "write components", "produces": "app_views", "kind": "react_component",
"needs": ["app_entry"], "tools": ["file_write"] }
"needs": ["app_entry"], "tools": ["file_write"], "writes": ["src/App.tsx"] }
],
"edges": [
{ "from": "entry", "to": "views", "condition": { "type": "always_true" } },
@@ -581,9 +583,39 @@ class ExecutionPlanCompilerTest {
com.correx.core.transitions.graph.BuildExpectation.MODULE,
graph.stages[StageId("entry")]!!.buildExpectation,
)
assertTrue(
graph.stages.values.any { it.autoBuildGate },
"MODULE (typecheck-only) must not remove the terminal project build floor",
)
}
@Test
fun `an explicitly declared PROJECT gate suppresses the auto gate`() {
// A PROJECT build is a real whole-project build the planner owns, so the compiler must
// not add its own terminal floor on top.
val planned = """
{
"goal": "scaffold a react app",
"stages": [
{ "id": "entry", "prompt": "write main.tsx", "produces": "app_entry", "kind": "react_entry",
"needs": [], "tools": ["file_write"], "writes": ["src/main.tsx"], "build_expectation": "project" },
{ "id": "views", "prompt": "write components", "produces": "app_views", "kind": "react_component",
"needs": ["app_entry"], "tools": ["file_write"], "writes": ["src/App.tsx"] }
],
"edges": [
{ "from": "entry", "to": "views", "condition": { "type": "always_true" } },
{ "from": "views", "to": "done", "condition": { "type": "always_true" } }
]
}
""".trimIndent()
val graph = codeCompiler.compile(planned, "wf")
assertEquals(
com.correx.core.transitions.graph.BuildExpectation.PROJECT,
graph.stages[StageId("entry")]!!.buildExpectation,
)
assertTrue(
graph.stages.values.none { it.autoBuildGate },
"no stage is auto-flagged when the planner already declares a gate anywhere",
"a real PROJECT build declared anywhere suppresses the auto gate",
)
}