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> =
|
internal fun SessionOrchestrator.sessionWrittenPaths(sessionId: SessionId): List<String> =
|
||||||
eventStore.read(sessionId)
|
eventStore.read(sessionId)
|
||||||
.mapNotNull { it.payload as? com.correx.core.events.events.FileWrittenEvent }
|
.mapNotNull { it.payload as? com.correx.core.events.events.FileWrittenEvent }
|
||||||
@@ -145,8 +157,15 @@ internal suspend fun SessionOrchestrator.runExecutionGate(
|
|||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
// LSP pull diagnostics are the compiler-front-end/typecheck gate. Keep PROJECT bundlers and
|
// 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.
|
// TESTS as real commands, but do not invoke the profile's flat `typecheck` alias as well —
|
||||||
if (expectation == BuildExpectation.MODULE && lspDiagnosticsRunner != null) {
|
// 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())
|
return StageExecutionResult.Success(emptyList())
|
||||||
}
|
}
|
||||||
val alias = expectation?.commandAlias ?: 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
|
val autoGateStages = autoGateStages(plan, declaredExpectations)
|
||||||
// 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 sessionArtifacts =
|
val sessionArtifacts =
|
||||||
plan.stages.mapNotNull { it.produces.takeIf(String::isNotBlank) }.toSet() + sessionArtifactIds
|
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."
|
"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> {
|
private fun staticFloorCommands(writeManifest: List<String>): List<String> {
|
||||||
return writeManifest.takeIf { it.isNotEmpty() }
|
return writeManifest.takeIf { it.isNotEmpty() }
|
||||||
?.let { listOf("$STATIC_FLOOR_COMMAND -- ${it.joinToString(" ")}") }
|
?.let { listOf("$STATIC_FLOOR_COMMAND -- ${it.joinToString(" ")}") }
|
||||||
|
|||||||
+37
-5
@@ -559,16 +559,18 @@ class ExecutionPlanCompilerTest {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `an explicitly declared gate suppresses the auto gate`() {
|
fun `a MODULE declaration does not suppress the auto build gate`() {
|
||||||
// Planner set a MODULE gate on the entry stage; the compiler must not add its own.
|
// 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 = """
|
val planned = """
|
||||||
{
|
{
|
||||||
"goal": "scaffold a react app",
|
"goal": "scaffold a react app",
|
||||||
"stages": [
|
"stages": [
|
||||||
{ "id": "entry", "prompt": "write main.tsx", "produces": "app_entry", "kind": "react_entry",
|
{ "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",
|
{ "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": [
|
"edges": [
|
||||||
{ "from": "entry", "to": "views", "condition": { "type": "always_true" } },
|
{ "from": "entry", "to": "views", "condition": { "type": "always_true" } },
|
||||||
@@ -581,9 +583,39 @@ class ExecutionPlanCompilerTest {
|
|||||||
com.correx.core.transitions.graph.BuildExpectation.MODULE,
|
com.correx.core.transitions.graph.BuildExpectation.MODULE,
|
||||||
graph.stages[StageId("entry")]!!.buildExpectation,
|
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(
|
assertTrue(
|
||||||
graph.stages.values.none { it.autoBuildGate },
|
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