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())