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:
+21
-2
@@ -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())
|
||||
|
||||
+26
-17
@@ -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(" ")}") }
|
||||
|
||||
+37
-5
@@ -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",
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user