wip(freestyle/acr): grounding & edit-tool fixes + ACR-compiler experiment
This branch's uncommitted WIP, committed together (entangled at file level). Distinct pieces of work: Freestyle QA fixes (this session): - FileEditTool: pre-validate replace anchor in validateRequest — reject a missing/ambiguous target BEFORE the approval gate, mirroring read/write's file-not-found / read-before-write pre-checks. Shared not-found/ambiguous messages between validate and execute so they can't drift. - PlanGrounder: add `scanned` flag; when no RepoMapComputedEvent was recorded, repoMapPaths is "unknown" not "empty workspace" — skip scope grounding (which proves a path ABSENT) so real paths (apps/server/**) aren't falsely rejected. Build-manifest check still runs. - FreestyleDriver: wire scanned=(repoMap!=null); on plan rejection emit a session-terminal WorkflowFailedEvent so a rejected run reads FAILED, not the COMPLETED-lie (last verdict was the planning-phase WorkflowCompleted). - ServerModule: resolve project-memory workspace root from the session's bound workspace (sessionWorkspaceRoot) instead of boot-static pm.repoRoot(), fixing the workspace-binding divergence (correx vs empty scratch dir). Retire tracked in Vikunja #266. - LaunchRegistrationRaceTest: join registered jobs before asserting launchCount — computeIfAbsent returns the Job immediately but the fire-and-forget launch body lagged awaitAll (the 49-vs-50 flake). ACR concept-compiler experiment (pre-existing WIP on this branch): - ExecutionPlanCompiler/Model/PlanLinter, #264 needs-seam (sessionArtifacts), LSP diagnostics subsystem (LspDiagnosticEvents/Runner/Lsp4j), BootWorkspace, config surface, workflow prompts/schemas, orchestrator advance-don't-rerun. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+44
-4
@@ -19,6 +19,8 @@ import java.nio.file.Path
|
||||
|
||||
private const val TERMINAL = "done"
|
||||
private const val RECOVERY_STAGE = "recovery"
|
||||
private const val DOD_ARTIFACT = "dod"
|
||||
private const val STATIC_FLOOR_COMMAND = "correx-static-floor"
|
||||
|
||||
// Synthesized recovery stage prompt. Freestyle plans are LLM-emitted and never declare a recovery
|
||||
// stage, but the kernel's retry-agency guard routes a write-less stage's unfixable gate failure to
|
||||
@@ -85,7 +87,16 @@ class ExecutionPlanCompiler(
|
||||
.disable(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES)
|
||||
.build()
|
||||
|
||||
fun compile(planJson: String, workflowId: String): WorkflowGraph {
|
||||
/**
|
||||
* @param sessionArtifactIds artifact ids already produced earlier in the same freestyle session
|
||||
* (e.g. the planning-phase `dod`). The execution plan never re-declares these, so they must be
|
||||
* passed in for the cross-workflow `needs` seam (#264) to thread the DoD into impl/review stages.
|
||||
*/
|
||||
fun compile(
|
||||
planJson: String,
|
||||
workflowId: String,
|
||||
sessionArtifactIds: Set<String> = emptySet(),
|
||||
): WorkflowGraph {
|
||||
val plan = runCatching { mapper.readValue<ExecutionPlanModel>(planJson) }
|
||||
.getOrElse { throw WorkflowValidationException("Failed to parse execution_plan JSON: ${it.message}") }
|
||||
if (plan.stages.isEmpty()) throw WorkflowValidationException("execution_plan has no stages")
|
||||
@@ -115,10 +126,13 @@ class ExecutionPlanCompiler(
|
||||
// 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 }) {
|
||||
plan.stages.filter { it.writes.any { path -> path.isNotBlank() } }.map { it.id }.toSet()
|
||||
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 =
|
||||
plan.stages.mapNotNull { it.produces.takeIf(String::isNotBlank) }.toSet() + sessionArtifactIds
|
||||
|
||||
val stageMap = plan.stages.associate { s ->
|
||||
// `produces` is the unique slot name referenced by needs/edges; `kind` selects the
|
||||
@@ -137,7 +151,7 @@ class ExecutionPlanCompiler(
|
||||
).sorted()
|
||||
StageId(s.id) to StageConfig(
|
||||
produces = listOf(TypedArtifactSlot(name = ArtifactId(s.produces), kind = kind)),
|
||||
needs = s.needs.map { ArtifactId(it) }.toSet(),
|
||||
needs = (s.needs + requiredSessionArtifacts(s, sessionArtifacts)).map { ArtifactId(it) }.toSet(),
|
||||
// Every stage gets the full registered tool universe. The architect's per-stage
|
||||
// `tools` pick was advisory and mis-scoped stages (e.g. an edit-shaped stage granted
|
||||
// file_write but not file_edit → forced full-file rewrites; a repair stage unable to
|
||||
@@ -146,6 +160,7 @@ class ExecutionPlanCompiler(
|
||||
// architect's pick only when no universe was supplied (bare-constructor unit tests).
|
||||
allowedTools = knownTools.ifEmpty { s.tools.toSet() },
|
||||
writeManifest = writeManifest,
|
||||
staticAnalysis = staticFloorCommands(writeManifest),
|
||||
// Option A: the concrete (non-glob) subset of the declared writes are expected files
|
||||
// the contract gate will require to exist. Globs (src/**) are write-permission scopes,
|
||||
// not existence guarantees, so they are excluded here.
|
||||
@@ -156,7 +171,7 @@ class ExecutionPlanCompiler(
|
||||
semanticReview = s.semanticReview,
|
||||
tokenBudget = DEFAULT_STAGE_TOKEN_BUDGET,
|
||||
generationConfig = defaultStageGeneration,
|
||||
metadata = mapOf("promptInline" to s.prompt),
|
||||
metadata = mapOf("role" to s.role, "promptInline" to reviewerBoundPrompt(s, DOD_ARTIFACT in sessionArtifacts)),
|
||||
)
|
||||
}
|
||||
// Inject a recovery stage unless the plan already declares one. Reached only by the kernel's
|
||||
@@ -291,6 +306,31 @@ class ExecutionPlanCompiler(
|
||||
private fun terminalStageId(plan: ExecutionPlanModel): String =
|
||||
plan.edges.firstOrNull { it.to == TERMINAL }?.from ?: plan.stages.last().id
|
||||
|
||||
private fun requiredSessionArtifacts(stage: PlanStage, sessionArtifacts: Set<String>): List<String> =
|
||||
if (
|
||||
DOD_ARTIFACT in sessionArtifacts &&
|
||||
stage.role.lowercase() in setOf("impl", "implementer", "review", "reviewer")
|
||||
) {
|
||||
listOf(DOD_ARTIFACT)
|
||||
} else {
|
||||
emptyList()
|
||||
}
|
||||
|
||||
private fun reviewerBoundPrompt(stage: PlanStage, dodPresent: Boolean): String {
|
||||
if (!dodPresent || stage.role.lowercase() !in setOf("review", "reviewer")) return stage.prompt
|
||||
return stage.prompt + "\n\n" +
|
||||
"The `dod` input artifact is your sole acceptance rubric. Judge only criteria tagged " +
|
||||
"`verified_by: reviewer`; deterministic gates own `verified_by: gate`. Approve if and only if " +
|
||||
"every reviewer criterion is met and no `out_of_scope` item was introduced. On rejection, " +
|
||||
"cite the unmet criterion ids. Never add or imply criteria outside the DoD."
|
||||
}
|
||||
|
||||
private fun staticFloorCommands(writeManifest: List<String>): List<String> {
|
||||
return writeManifest.takeIf { it.isNotEmpty() }
|
||||
?.let { listOf("$STATIC_FLOOR_COMMAND -- ${it.joinToString(" ")}") }
|
||||
?: emptyList()
|
||||
}
|
||||
|
||||
private fun isGlob(path: String): Boolean = path.any { it == '*' || it == '?' || it == '[' }
|
||||
|
||||
private fun validateReachability(
|
||||
|
||||
+1
@@ -10,6 +10,7 @@ data class ExecutionPlanModel(
|
||||
|
||||
data class PlanStage(
|
||||
val id: String = "",
|
||||
val role: String = "",
|
||||
val prompt: String = "",
|
||||
val produces: String = "",
|
||||
val kind: String? = null,
|
||||
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
package com.correx.infrastructure.workflow
|
||||
|
||||
import com.correx.core.events.events.LspDiagnostic
|
||||
import com.correx.core.kernel.orchestration.LspDiagnosticsRequest
|
||||
import com.correx.core.kernel.orchestration.LspDiagnosticsResult
|
||||
import com.correx.core.kernel.orchestration.LspDiagnosticsRunner
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.eclipse.lsp4j.ClientCapabilities
|
||||
import org.eclipse.lsp4j.DidOpenTextDocumentParams
|
||||
import org.eclipse.lsp4j.DocumentDiagnosticParams
|
||||
import org.eclipse.lsp4j.InitializeParams
|
||||
import org.eclipse.lsp4j.InitializedParams
|
||||
import org.eclipse.lsp4j.MessageActionItem
|
||||
import org.eclipse.lsp4j.MessageParams
|
||||
import org.eclipse.lsp4j.PublishDiagnosticsParams
|
||||
import org.eclipse.lsp4j.ShowMessageRequestParams
|
||||
import org.eclipse.lsp4j.TextDocumentIdentifier
|
||||
import org.eclipse.lsp4j.TextDocumentItem
|
||||
import org.eclipse.lsp4j.launch.LSPLauncher
|
||||
import org.eclipse.lsp4j.services.LanguageClient
|
||||
import java.nio.file.Files
|
||||
import java.util.concurrent.CompletableFuture
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
data class LspServerSpec(val id: String, val command: List<String>, val languageId: String)
|
||||
|
||||
class LspServerRegistry(
|
||||
private val byExtension: Map<String, LspServerSpec> = defaults,
|
||||
) {
|
||||
fun serverFor(path: String): LspServerSpec? = byExtension[path.substringAfterLast('.', "").lowercase()]
|
||||
|
||||
companion object {
|
||||
private val typescript = LspServerSpec(
|
||||
"tsserver",
|
||||
listOf("typescript-language-server", "--stdio"),
|
||||
"typescriptreact",
|
||||
)
|
||||
private val rust = LspServerSpec("rust-analyzer", listOf("rust-analyzer"), "rust")
|
||||
private val go = LspServerSpec("gopls", listOf("gopls"), "go")
|
||||
private val clangd = LspServerSpec("clangd", listOf("clangd"), "cpp")
|
||||
val defaults = mapOf(
|
||||
"ts" to typescript.copy(languageId = "typescript"),
|
||||
"tsx" to typescript,
|
||||
"js" to typescript.copy(languageId = "javascript"),
|
||||
"jsx" to typescript.copy(languageId = "javascriptreact"),
|
||||
"rs" to rust,
|
||||
"go" to go,
|
||||
"c" to clangd.copy(languageId = "c"),
|
||||
"cc" to clangd,
|
||||
"cpp" to clangd,
|
||||
"h" to clangd.copy(languageId = "c"),
|
||||
"hpp" to clangd,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/** LSP 3.17 pull-diagnostics adapter. Missing servers degrade to the static one-shot floor. */
|
||||
class Lsp4jDiagnosticsRunner(
|
||||
private val registry: LspServerRegistry = LspServerRegistry(),
|
||||
private val timeoutSeconds: Long = 30,
|
||||
) : LspDiagnosticsRunner {
|
||||
override suspend fun pull(request: LspDiagnosticsRequest): LspDiagnosticsResult = withContext(Dispatchers.IO) {
|
||||
val groups = request.paths
|
||||
.mapNotNull { path -> registry.serverFor(path)?.let { it to path } }
|
||||
.groupBy({ it.first }, { it.second })
|
||||
if (groups.isEmpty()) {
|
||||
return@withContext LspDiagnosticsResult(skippedReason = "no language server registered for touched files")
|
||||
}
|
||||
val diagnostics = mutableListOf<LspDiagnostic>()
|
||||
val skipped = mutableListOf<String>()
|
||||
groups.forEach { (server, paths) ->
|
||||
runCatching { diagnostics += pullFromServer(request, server, paths) }
|
||||
.onFailure {
|
||||
if (it is CancellationException) throw it
|
||||
skipped += "${server.id}: ${it.message ?: it::class.simpleName}"
|
||||
}
|
||||
}
|
||||
LspDiagnosticsResult(
|
||||
server = groups.keys.joinToString(",") { it.id },
|
||||
diagnostics = diagnostics,
|
||||
skippedReason = skipped.takeIf { it.isNotEmpty() }?.joinToString("; "),
|
||||
)
|
||||
}
|
||||
|
||||
private fun pullFromServer(
|
||||
request: LspDiagnosticsRequest,
|
||||
spec: LspServerSpec,
|
||||
paths: List<String>,
|
||||
): List<LspDiagnostic> {
|
||||
val process = ProcessBuilder(spec.command).directory(request.workspaceRoot.toFile()).start()
|
||||
val stderrDrain = Thread.ofVirtual().start { process.errorStream.bufferedReader().use { it.readText() } }
|
||||
val launcher = LSPLauncher.createClientLauncher(QuietLanguageClient, process.inputStream, process.outputStream)
|
||||
val listening = launcher.startListening()
|
||||
val server = launcher.remoteProxy
|
||||
return try {
|
||||
server.initialize(
|
||||
InitializeParams().apply {
|
||||
rootUri = request.workspaceRoot.toUri().toString()
|
||||
capabilities = ClientCapabilities()
|
||||
},
|
||||
).get(timeoutSeconds, TimeUnit.SECONDS)
|
||||
server.initialized(InitializedParams())
|
||||
paths.flatMap { path ->
|
||||
val file = request.workspaceRoot.resolve(path).normalize()
|
||||
val uri = file.toUri().toString()
|
||||
server.textDocumentService.didOpen(
|
||||
DidOpenTextDocumentParams(TextDocumentItem(uri, spec.languageId, 1, Files.readString(file))),
|
||||
)
|
||||
val report = server.textDocumentService.diagnostic(
|
||||
DocumentDiagnosticParams(TextDocumentIdentifier(uri)),
|
||||
).get(timeoutSeconds, TimeUnit.SECONDS)
|
||||
report.relatedFullDocumentDiagnosticReport.items.orEmpty().map { diagnostic ->
|
||||
LspDiagnostic(
|
||||
path = path,
|
||||
line = diagnostic.range.start.line,
|
||||
character = diagnostic.range.start.character,
|
||||
severity = diagnostic.severity?.name?.lowercase() ?: "error",
|
||||
code = diagnostic.code?.let { if (it.isLeft) it.left else it.right.toString() },
|
||||
message = diagnostic.message,
|
||||
)
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
runCatching { server.shutdown().get(timeoutSeconds, TimeUnit.SECONDS) }
|
||||
server.exit()
|
||||
listening.cancel(true)
|
||||
process.destroy()
|
||||
stderrDrain.join(TimeUnit.SECONDS.toMillis(1))
|
||||
}
|
||||
}
|
||||
|
||||
private object QuietLanguageClient : LanguageClient {
|
||||
override fun telemetryEvent(`object`: Any?) = Unit
|
||||
override fun publishDiagnostics(diagnostics: PublishDiagnosticsParams) = Unit
|
||||
override fun showMessage(messageParams: MessageParams) = Unit
|
||||
override fun showMessageRequest(requestParams: ShowMessageRequestParams): CompletableFuture<MessageActionItem> =
|
||||
CompletableFuture.completedFuture(null)
|
||||
override fun logMessage(message: MessageParams) = Unit
|
||||
}
|
||||
}
|
||||
+17
-8
@@ -35,6 +35,13 @@ object PlanGrounder {
|
||||
graph: WorkflowGraph,
|
||||
repoMapPaths: Set<String>,
|
||||
profileCommands: Map<String, String>,
|
||||
// False = no repo scan was recorded for this session, so [repoMapPaths] is "unknown", not
|
||||
// "empty workspace". Scope grounding proves a path ABSENT, which an unknown set can't do —
|
||||
// running it against a missing scan flags every real path as nonexistent (the apps/server
|
||||
// "doesn't exist?!" false reject). Skip scope grounding then; the build-manifest check still
|
||||
// runs (it degrades safely via manifestProducedByPlan). A recorded scan with 0 entries is a
|
||||
// genuine greenfield workspace — scanned=true, so scope grounding still catches a missing scaffold.
|
||||
scanned: Boolean = true,
|
||||
): PlanGroundingResult {
|
||||
val manifestInWorkspace = repoMapPaths.any(::isBuildManifest)
|
||||
val manifestProducedByPlan = graph.stages.values
|
||||
@@ -60,14 +67,16 @@ object PlanGrounder {
|
||||
// scope that matches NOTHING in the recorded repo map AND that no plan stage creates a file
|
||||
// under is grounded on a directory that will never exist — the exact session-a35cf1e3
|
||||
// "scoped to EXISTING frontend/ that isn't there" failure. Caught at stage 0, not stage 11.
|
||||
cfg.touches
|
||||
.filter { glob -> !scopeSatisfied(glob, repoMapPaths, createdPaths) }
|
||||
.forEach { glob ->
|
||||
add(
|
||||
"stage ${id.value}: scoped to '$glob' but nothing under it exists in the repo map " +
|
||||
"and no plan stage creates a file there — scaffold that path first or fix the scope.",
|
||||
)
|
||||
}
|
||||
if (scanned) {
|
||||
cfg.touches
|
||||
.filter { glob -> !scopeSatisfied(glob, repoMapPaths, createdPaths) }
|
||||
.forEach { glob ->
|
||||
add(
|
||||
"stage ${id.value}: scoped to '$glob' but nothing under it exists in the repo map " +
|
||||
"and no plan stage creates a file there — scaffold that path first or fix the scope.",
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+1
-1
@@ -37,7 +37,7 @@ object PlanLinter {
|
||||
* planning phase, not by any plan stage. The architect prompt explicitly allows `needs` to
|
||||
* reference these (notably `analysis`), so the linter must treat them as available producers.
|
||||
*/
|
||||
private val seedArtifacts = setOf("analysis")
|
||||
private val seedArtifacts = setOf("analysis", "dod")
|
||||
|
||||
fun lint(graph: WorkflowGraph, seeds: Set<String> = seedArtifacts): PlanLintResult =
|
||||
PlanLintResult(
|
||||
|
||||
+57
@@ -499,6 +499,63 @@ class ExecutionPlanCompilerTest {
|
||||
graph.stages[StageId("entry")]!!.autoBuildGate,
|
||||
"implementation stages must not defer compiler coverage to a terminal verifier",
|
||||
)
|
||||
assertTrue(graph.stages[StageId("entry")]!!.staticAnalysis.single().contains("src/main.tsx"))
|
||||
assertTrue(graph.stages[StageId("views")]!!.staticAnalysis.single().contains("src/App.tsx"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `non-writing review terminal receives the auto build gate`() {
|
||||
val planned = """
|
||||
{
|
||||
"goal": "implement then review",
|
||||
"stages": [
|
||||
{ "id": "impl", "role": "implementer", "prompt": "write code", "produces": "code",
|
||||
"kind": "react_entry", "writes": ["src/main.tsx"] },
|
||||
{ "id": "review", "role": "reviewer", "prompt": "review against dod", "produces": "verdict",
|
||||
"kind": "react_component", "needs": ["code"] }
|
||||
],
|
||||
"edges": [
|
||||
{ "from": "impl", "to": "review", "condition": { "type": "always_true" } },
|
||||
{ "from": "review", "to": "done", "condition": { "type": "always_true" } }
|
||||
]
|
||||
}
|
||||
""".trimIndent()
|
||||
val graph = codeCompiler.compile(planned, "wf")
|
||||
assertTrue(graph.stages.getValue(StageId("review")).autoBuildGate)
|
||||
// No dod in session → nothing to thread (execution plan never re-produces it).
|
||||
assertTrue(graph.stages.getValue(StageId("review")).needs.none { it.value == "dod" })
|
||||
|
||||
// Planning-phase dod present in the session → threaded into impl + review needs (#264 seam).
|
||||
val withDod = codeCompiler.compile(planned, "wf", sessionArtifactIds = setOf("dod"))
|
||||
assertTrue(withDod.stages.getValue(StageId("review")).needs.any { it.value == "dod" })
|
||||
assertTrue(withDod.stages.getValue(StageId("impl")).needs.any { it.value == "dod" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `implementation and review consume dod only when the plan produces it`() {
|
||||
val planned = """
|
||||
{
|
||||
"goal": "define, implement, and review",
|
||||
"stages": [
|
||||
{ "id": "analyst", "role": "analyst", "prompt": "define dod", "produces": "dod",
|
||||
"kind": "react_component" },
|
||||
{ "id": "impl", "role": "implementer", "prompt": "write code", "produces": "code",
|
||||
"kind": "react_entry", "writes": ["src/main.tsx"] },
|
||||
{ "id": "review", "role": "reviewer", "prompt": "review", "produces": "verdict",
|
||||
"kind": "react_component", "needs": ["code"] }
|
||||
],
|
||||
"edges": [
|
||||
{ "from": "analyst", "to": "impl", "condition": { "type": "always_true" } },
|
||||
{ "from": "impl", "to": "review", "condition": { "type": "always_true" } },
|
||||
{ "from": "review", "to": "done", "condition": { "type": "always_true" } }
|
||||
]
|
||||
}
|
||||
""".trimIndent()
|
||||
|
||||
val graph = codeCompiler.compile(planned, "wf-with-dod")
|
||||
|
||||
assertTrue(graph.stages.getValue(StageId("impl")).needs.any { it.value == "dod" })
|
||||
assertTrue(graph.stages.getValue(StageId("review")).needs.any { it.value == "dod" })
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+2
-1
@@ -28,6 +28,7 @@ class FreestylePlanningWorkflowTest {
|
||||
private val registry = DefaultArtifactKindRegistry().apply {
|
||||
register(llmKind("discovery"))
|
||||
register(llmKind("analysis"))
|
||||
register(llmKind("dod"))
|
||||
register(llmKind("execution_plan"))
|
||||
}
|
||||
|
||||
@@ -64,7 +65,7 @@ class FreestylePlanningWorkflowTest {
|
||||
"architect must produce execution_plan",
|
||||
)
|
||||
|
||||
assertTrue(architect.needs.map { it.value }.contains("analysis"), "architect needs analysis")
|
||||
assertTrue(architect.needs.map { it.value }.contains("dod"), "architect needs the fixed DoD")
|
||||
|
||||
val analystToArchitect = graph.transitions.first { it.from == StageId("analyst") }
|
||||
assertEquals(StageId("architect"), analystToArchitect.to)
|
||||
|
||||
+22
@@ -0,0 +1,22 @@
|
||||
package com.correx.infrastructure.workflow
|
||||
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
|
||||
class LspServerRegistryTest {
|
||||
private val registry = LspServerRegistry()
|
||||
|
||||
@Test
|
||||
fun `registry maps language extensions to servers`() {
|
||||
assertEquals("tsserver", registry.serverFor("src/App.tsx")?.id)
|
||||
assertEquals("rust-analyzer", registry.serverFor("src/main.rs")?.id)
|
||||
assertEquals("gopls", registry.serverFor("cmd/main.go")?.id)
|
||||
assertEquals("clangd", registry.serverFor("src/main.cpp")?.id)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `unknown extension falls through`() {
|
||||
assertNull(registry.serverFor("README.md"))
|
||||
}
|
||||
}
|
||||
+7
@@ -65,6 +65,13 @@ class PlanGrounderTest {
|
||||
assertTrue(r.findings.single().contains("frontend/**"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `scanned=false skips scope grounding — a missing scan can't prove a path absent`() {
|
||||
val g = graph("impl" to StageConfig(touches = listOf("frontend/**", "apps/server/**")))
|
||||
val r = PlanGrounder.ground(g, repoMapPaths = emptySet(), profileCommands = cmds, scanned = false)
|
||||
assertEquals(PlanGroundingVerdict.PASS, r.verdict)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `a scope satisfied by an existing repo path grounds`() {
|
||||
val g = graph("impl" to StageConfig(touches = listOf("frontend/**")))
|
||||
|
||||
Reference in New Issue
Block a user