Implement closed-loop workspace follow-ups

This commit is contained in:
2026-07-15 23:48:12 +04:00
parent 3a48ecd24f
commit ed7efb6072
51 changed files with 702 additions and 54 deletions
+2
View File
@@ -19,6 +19,8 @@ All sources under `apps/server/src/`.
- `GET /health` — health report (probes: event-store, llama-server, disk watermark)
- `GET /stats` — metrics report (MetricsProjection)
- `GET /metrics/tool-reliability` — per-model tool-call validity across the event log (`ToolReliabilityInspectionService`); groundwork for capability-aware routing
- Optional `[git]` transport creates `run/<sessionId>` from a server-local checkout and pushes it at terminal state; clients review with ordinary Git and never supply a remote URL as `cwd`.
- Repo-map L3 embeddings use bounded, recorded source descriptors (module/package, imports, leading purpose comment, symbols); raw file bodies are never embedded. Their versioned `repomap:v2` namespace forces a one-time re-embed when the semantic document format changes.
### WebSocket protocol (`/ws`)
- **ServerMessage** (server → client): sealed hierarchy — `SessionMessage` (event-derived, carries `sequence` + `sessionSequence`) and `NonEventMessage` (control/infra). Variants include session lifecycle, approval requests, clarification requests, narration, proposed workflows, health/metrics pushes.
@@ -654,6 +654,11 @@ fun main() {
taskArtifactResolver = taskArtifactResolver,
taskSessionResolver = taskSessionResolver,
gitCommitReader = gitCommitReader,
gitRunBranchTransport = if (correxConfig.git.enabled) {
com.correx.apps.server.git.GitRunBranchTransport(eventStore, correxConfig.git)
} else {
null
},
)
// Wire live config editing: persist to TOML, swap the holder, and rebuild config-derived
// services. Built after the module so the rebuild hook can swap them in place.
@@ -133,6 +133,9 @@ class ServerModule(
// Reads recent commits for POST /tasks/sync-git (git-driven status). Null disables the repo read
// (the endpoint then only acts on commits supplied in the request body).
val gitCommitReader: com.correx.core.tasks.GitCommitReader? = null,
// Optional plain-Git transport for a server-owned checkout. It creates and pushes a per-run
// branch; null preserves the ordinary local-workspace lifecycle.
private val gitRunBranchTransport: com.correx.apps.server.git.GitRunBranchTransport? = null,
) {
val approvalCoordinator: ApprovalCoordinator = approvalCoordinator ?: ApprovalCoordinator(
orchestrator = orchestrator,
@@ -409,17 +412,25 @@ class ServerModule(
bindProjectProfile(sessionId)
bindAgentInstructions(sessionId)
runCatching {
val result = orchestrator.run(sessionId, graph, sessionConfig)
freestyleHandoff(sessionId, graph, result)
// Distil this run's decisions into durable project memory on completion.
projectMemory?.let { pm -> pm.persist(sessionId, pm.repoRoot()) }
// Propose learned profile adaptations based on session journal (opt-in, never auto-applied).
operatorProfile?.let { profile ->
profileAdaptationService?.let { svc ->
runCatching { svc.proposeAdaptation(sessionId, profile) }
.onFailure { log.warn("Profile adaptation failed: {}", it.message) }
suspend fun runAndFinalize() {
val result = orchestrator.run(sessionId, graph, sessionConfig)
freestyleHandoff(sessionId, graph, result)
// Distil this run's decisions into durable project memory on completion.
projectMemory?.let { pm -> pm.persist(sessionId, pm.repoRoot()) }
// Propose learned profile adaptations based on session journal (opt-in, never auto-applied).
operatorProfile?.let { profile ->
profileAdaptationService?.let { svc ->
runCatching { svc.proposeAdaptation(sessionId, profile) }
.onFailure { log.warn("Profile adaptation failed: {}", it.message) }
}
}
}
val workspaceRoot = sessionConfig.workspace?.workspaceRoot
if (gitRunBranchTransport != null && workspaceRoot != null) {
gitRunBranchTransport.onRunBranch(sessionId, workspaceRoot) { runAndFinalize() }
} else {
runAndFinalize()
}
}.onFailure { ex -> recordUnhandledFailure(sessionId, graph, sessionConfig, ex) }
activeSessionJobs.remove(sessionId)
}
@@ -0,0 +1,120 @@
package com.correx.apps.server.git
import com.correx.core.config.GitConfig
import com.correx.core.events.events.EventMetadata
import com.correx.core.events.events.NewEvent
import com.correx.core.events.events.RunBranchPushedEvent
import com.correx.core.events.events.WorkflowCompletedEvent
import com.correx.core.events.events.WorkflowFailedEvent
import com.correx.core.events.stores.EventStore
import com.correx.core.events.types.EventId
import com.correx.core.events.types.SessionId
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import kotlinx.datetime.Clock
import java.nio.file.Path
import java.util.UUID
import java.util.concurrent.TimeUnit
private const val GIT_TIMEOUT_SECONDS = 30L
/**
* Plain-Git transport for a server-owned checkout. The mutex intentionally spans a whole run:
* checkout is process-global state, so parallel sessions cannot safely share one working tree.
*/
class GitRunBranchTransport(
private val eventStore: EventStore,
private val config: GitConfig,
) {
private val checkoutLock = Mutex()
suspend fun <T> onRunBranch(
sessionId: SessionId,
workspaceRoot: Path,
block: suspend () -> T,
): T = checkoutLock.withLock {
val prepared = prepare(sessionId, workspaceRoot)
try {
block()
} finally {
val terminalStage = eventStore.read(sessionId).lastOrNull { event ->
event.payload is WorkflowCompletedEvent || event.payload is WorkflowFailedEvent
}?.payload?.let { payload ->
when (payload) {
is WorkflowCompletedEvent -> payload.terminalStageId.value
is WorkflowFailedEvent -> payload.stageId.value
else -> null
}
} ?: "unknown"
pushTerminalBranch(sessionId, workspaceRoot, prepared, terminalStage)
}
}
private suspend fun prepare(sessionId: SessionId, workspaceRoot: Path): PreparedBranch = withContext(Dispatchers.IO) {
val branch = "run/${sessionId.value}"
runGit(workspaceRoot, "fetch", config.remote, config.baseBranch)
val baseRef = "${config.remote}/${config.baseBranch}"
val baseSha = runGit(workspaceRoot, "rev-parse", baseRef).trim()
runGit(workspaceRoot, "checkout", "-B", branch, baseRef)
PreparedBranch(branch, baseSha)
}
private suspend fun pushTerminalBranch(
sessionId: SessionId,
workspaceRoot: Path,
prepared: PreparedBranch,
terminalStage: String,
) =
withContext(Dispatchers.IO) {
runGit(workspaceRoot, "add", "-A")
if (runGitExitCode(workspaceRoot, "diff", "--cached", "--quiet") != 0) {
val args = mutableListOf("commit", "-m", "correx run ${sessionId.value} terminal $terminalStage")
if (config.author.isNotBlank()) args += "--author=${config.author}"
runGit(workspaceRoot, *args.toTypedArray())
}
val headSha = runGit(workspaceRoot, "rev-parse", "HEAD").trim()
runGit(workspaceRoot, "push", config.remote, "${prepared.branch}:${prepared.branch}")
eventStore.append(
NewEvent(
metadata = EventMetadata(
eventId = EventId(UUID.randomUUID().toString()),
sessionId = sessionId,
timestamp = Clock.System.now(),
schemaVersion = 1,
causationId = null,
correlationId = null,
),
payload = RunBranchPushedEvent(sessionId, prepared.branch, prepared.baseSha, headSha),
),
)
}
private fun runGit(root: Path, vararg args: String): String {
val process = ProcessBuilder(listOf("git", "-C", root.toString()) + args)
.redirectErrorStream(true)
.start()
val output = process.inputStream.bufferedReader().use { it.readText() }
if (!process.waitFor(GIT_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
process.destroyForcibly()
error("git ${args.joinToString(" ")} timed out")
}
check(process.exitValue() == 0) { "git ${args.joinToString(" ")} failed: ${output.trim()}" }
return output
}
private fun runGitExitCode(root: Path, vararg args: String): Int {
val process = ProcessBuilder(listOf("git", "-C", root.toString()) + args)
.redirectErrorStream(true)
.start()
process.inputStream.bufferedReader().use { it.readText() }
if (!process.waitFor(GIT_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
process.destroyForcibly()
error("git ${args.joinToString(" ")} timed out")
}
return process.exitValue()
}
private data class PreparedBranch(val branch: String, val baseSha: String)
}
@@ -19,7 +19,7 @@ import com.correx.core.talkie.l3.L3Query
* [ProjectMemoryService] under `turnId = "project:<repoRoot>"` (trailing-`:` delimiter). This is
* the only decision-bearing L3 namespace that exists today, so [decisionNamespacePrefix] defaults
* to `"project:"` — a `startsWith` prefix, matching the trailing-`:` delimiter convention used by
* [L3RepoKnowledgeRetriever]'s `"repomap:<repoRoot>:"` filter. Hits are also constrained to PRIOR
* [L3RepoKnowledgeRetriever]'s versioned `"repomap:v2:<repoRoot>:"` filter. Hits are also constrained to PRIOR
* sessions (`entry.sessionId != sessionId`) so the architect never flags its own in-flight run.
*/
class ArchitectContradictionChecker(
@@ -35,8 +35,9 @@ class L3RepoKnowledgeRetriever(
override suspend fun retrieve(sessionId: SessionId, query: String, k: Int): List<RepoKnowledgeHit> {
val vector = embedder.embed(query)
val candidates = l3MemoryStore.query(L3Query(vector = vector, k = k * RETRIEVAL_OVERSAMPLE_FACTOR))
// Trailing ':' so "/repo" does not also match "/repo2" turnIds (prefix collision).
.filter { it.entry.turnId.startsWith("repomap:$repoRoot:") }
// Versioned trailing delimiter keeps sibling repo roots isolated and stale semantic
// documents out after a repo-map descriptor format change.
.filter { it.entry.turnId.startsWith(repoMapEmbeddingPrefix(repoRoot)) }
val (kept, dropped) = candidates.partition { it.score >= MIN_SIMILARITY_SCORE }
if (dropped.isNotEmpty()) recordDropped(sessionId, query, dropped.map { it.toHit() })
return kept.take(k).map { it.toHit() }
@@ -110,13 +110,13 @@ class ProjectMemoryService(
stateKey: String?,
entries: List<RepoMapEntry>,
) {
if (stateKey != null && l3MemoryStore.existsByTurnIdPrefix("repomap:$repoRoot:$stateKey")) {
val tag = repoMapEmbeddingTag(repoRoot, stateKey)
if (stateKey != null && l3MemoryStore.existsByTurnIdPrefix(tag)) {
log.debug("repo-map already embedded for {}@{} — skipping L3 store", repoRoot, stateKey)
return
}
// Trailing ':' delimiter so a repoRoot prefix can't collide with a longer sibling
// (/repo vs /repo2) under the retriever's startsWith filter.
val tag = if (stateKey != null) "repomap:$repoRoot:$stateKey" else "repomap:$repoRoot:"
// Docs are already surfaced via the "Docs available" catalog built straight from
// RepoMapComputedEvent (SessionOrchestrator, 2026-07-07 doc-injection rework) — embedding
// them into the same L3 namespace as code let generic textual similarity (e.g. "analyst",
@@ -126,7 +126,11 @@ class ProjectMemoryService(
// rather than the repo-map floor that was fixed then).
entries.filterNot { it.path.endsWith(".md", ignoreCase = true) }.forEach { entry ->
runCatching {
val text = entry.path + if (entry.symbols.isEmpty()) "" else ": ${entry.symbols.joinToString(", ")}"
val text = buildString {
append(entry.path)
if (entry.descriptor.isNotBlank()) append(": ").append(entry.descriptor)
if (entry.symbols.isNotEmpty()) append("; symbols: ").append(entry.symbols.joinToString(", "))
}
l3MemoryStore.store(
L3MemoryEntry(
id = UUID.randomUUID().toString(),
@@ -0,0 +1,14 @@
package com.correx.apps.server.memory
/**
* Version the serialized repo-map embedding namespace. A descriptor change alters the semantic
* document, so a new version deliberately ignores stale vectors and causes one re-embed per
* recorded workspace state. The recorded repo-map event remains backwards compatible.
*/
internal const val REPO_MAP_EMBEDDING_VERSION = "v2"
internal fun repoMapEmbeddingPrefix(repoRoot: String): String =
"repomap:$REPO_MAP_EMBEDDING_VERSION:$repoRoot:"
internal fun repoMapEmbeddingTag(repoRoot: String, stateKey: String?): String =
stateKey?.let { repoMapEmbeddingPrefix(repoRoot) + it } ?: repoMapEmbeddingPrefix(repoRoot)
@@ -20,7 +20,8 @@ interface RepoMapIndexerPort {
* Walks a repo and produces a ranked file/symbol index. Reads the filesystem (a
* nondeterministic environment observation) — the caller records the result as a
* [com.correx.core.events.events.RepoMapComputedEvent] so replay reads recorded facts and
* never re-scans (invariant #9). Paths + top-level symbol names only, never file bodies.
* never re-scans (invariant #9). Entries retain paths, top-level symbols, and a bounded,
* deterministic purpose descriptor; full file bodies are never stored in the repo map.
*
* Scoring is recency-based: the most-recently-modified source file scores 1.0, the oldest
* ~0.0, linearly between. Recency is a cheap centrality proxy — files under active work rank
@@ -53,6 +54,7 @@ class RepoMapIndexer(
path = path.relativeTo(repoRoot).toString(),
score = (mtimes.getValue(path) - min).toDouble() / span,
symbols = extractSymbols(path),
descriptor = sourceDescriptor(path),
)
}
.sortedByDescending { it.score }
@@ -104,6 +106,35 @@ class RepoMapIndexer(
} ?: emptyList()
}
/**
* A small semantic bridge between an intent ("context packing") and code whose class name
* alone lacks those words. Package/module identity, leading KDoc/comments, and imports are
* stable source facts; they are capped before the event and L3 embedding are recorded.
*/
private fun sourceDescriptor(path: Path): String {
if (path.extension.equals("md", ignoreCase = true)) return docDescriptor(path).orEmpty()
val lines = runCatching { path.readText().lineSequence().take(DESCRIPTOR_SCAN_LINES).toList() }
.getOrDefault(emptyList())
if (lines.isEmpty()) return ""
val packageOrModule = lines.firstNotNullOfOrNull { line ->
PACKAGE_OR_MODULE.find(line.trim())?.groupValues?.get(1)
}
val imports = lines.asSequence()
.mapNotNull { IMPORT.find(it.trim())?.groupValues?.get(1) }
.take(MAX_DESCRIPTOR_IMPORTS)
.toList()
val comment = lines.asSequence()
.map { raw -> raw.trim() }
.filter(::isCommentLine)
.map { it.removePrefix("/**").removePrefix("*").removePrefix("//").removePrefix("#").trim() }
.firstOrNull { it.length >= MIN_COMMENT_CHARS }
return buildList {
packageOrModule?.let { add("module $it") }
if (imports.isNotEmpty()) add("uses ${imports.joinToString(", ")}")
comment?.let { add(it) }
}.joinToString("; ").truncateDescriptor()
}
/**
* One-line "what is this doc about" descriptor, preferred in order: YAML frontmatter
* `description`/`summary`/`title`, then the first `# H1` heading, then the first prose line.
@@ -133,13 +164,21 @@ class RepoMapIndexer(
private fun String.truncateDescriptor(): String =
if (length <= MAX_DESCRIPTOR_CHARS) this else take(MAX_DESCRIPTOR_CHARS).trimEnd() + ""
private fun isCommentLine(line: String): Boolean =
line.startsWith("/**") || line.startsWith("*") || line.startsWith("//") || line.startsWith("#")
companion object {
/** File extensions considered for indexing — reused by [WorkspaceStateProbe] for fingerprinting. */
val INDEXED_EXTENSIONS: Set<String> get() = SYMBOL_PATTERNS.keys
private const val MAX_SYMBOLS_PER_FILE = 40
private const val MAX_DESCRIPTOR_CHARS = 120
private const val DESCRIPTOR_SCAN_LINES = 80
private const val MAX_DESCRIPTOR_IMPORTS = 6
private const val MIN_COMMENT_CHARS = 16
private val FRONTMATTER_KEYS = listOf("description", "summary", "title")
private val PACKAGE_OR_MODULE = Regex("""(?:package|module|namespace)\s+([A-Za-z0-9_.$/-]+)""")
private val IMPORT = Regex("""(?:import|using)\s+([A-Za-z0-9_.$/*-]+)""")
// Top-level declarations only — best-effort per language. Bodies/locals are never matched.
val SYMBOL_PATTERNS: Map<String, Regex> = mapOf(
@@ -0,0 +1,60 @@
package com.correx.apps.server.git
import com.correx.core.config.GitConfig
import com.correx.core.events.events.RunBranchPushedEvent
import com.correx.core.events.types.SessionId
import com.correx.infrastructure.persistence.InMemoryEventStore
import kotlinx.coroutines.runBlocking
import org.junit.jupiter.api.Test
import java.nio.file.Files
import java.nio.file.Path
import java.util.concurrent.TimeUnit
import kotlin.io.path.writeText
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class GitRunBranchTransportTest {
@Test
fun `creates a run branch, pushes it, and records the pushed ref`(): Unit = runBlocking {
val root = Files.createTempDirectory("correx-git-transport")
val remote = Files.createTempDirectory("correx-git-remote")
git(root, "init", "-b", "main")
git(root, "config", "user.name", "Test User")
git(root, "config", "user.email", "test@example.test")
root.resolve("README.md").writeText("base\n")
git(root, "add", "README.md")
git(root, "commit", "-m", "base")
git(remote, "init", "--bare")
git(root, "remote", "add", "origin", remote.toString())
git(root, "push", "origin", "main")
val store = InMemoryEventStore()
val sessionId = SessionId("git-transport")
val transport = GitRunBranchTransport(
store,
GitConfig(enabled = true, remote = "origin", baseBranch = "main"),
)
transport.onRunBranch(sessionId, root) {
root.resolve("agent-output.txt").writeText("generated\n")
}
assertEquals("run/git-transport", git(root, "branch", "--show-current").trim())
assertTrue(git(root, "ls-remote", "--heads", "origin", "run/git-transport").isNotBlank())
val pushed = store.read(sessionId).single().payload as RunBranchPushedEvent
assertEquals("run/git-transport", pushed.branch)
assertTrue(pushed.baseSha.isNotBlank())
assertEquals(git(root, "rev-parse", "HEAD").trim(), pushed.headSha)
}
private fun git(root: Path, vararg args: String): String {
val process = ProcessBuilder(listOf("git", "-C", root.toString()) + args)
.redirectErrorStream(true)
.start()
val output = process.inputStream.bufferedReader().use { it.readText() }
check(process.waitFor(20, TimeUnit.SECONDS)) { "git ${args.joinToString(" ")} timed out" }
check(process.exitValue() == 0) { "git ${args.joinToString(" ")} failed: $output" }
return output
}
}
@@ -87,7 +87,7 @@ class ArchitectContradictionCheckerTest {
val store = CannedL3MemoryStore(
listOf(
// Above threshold but a repo-map entry, not a decision — must be excluded.
hit("Foo.kt: ClassA, funcB", score = 0.95f, turnId = "repomap:/repo:abc"),
hit("Foo.kt: ClassA, funcB", score = 0.95f, turnId = "repomap:v2:/repo:abc"),
),
)
val checker = ArchitectContradictionChecker(ContradictionOnesEmbedder(), store)
@@ -19,8 +19,8 @@ class L3RepoKnowledgeRetrieverTest {
@Test
fun `retriever for a repoRoot does not match a sibling whose path it prefixes`(): Unit = runBlocking {
val l3 = InMemoryL3MemoryStore()
l3.store(L3MemoryEntry("1", SessionId("s"), "repomap:/repo:git:h", "repo/A.kt: Foo", vec, 0L))
l3.store(L3MemoryEntry("2", SessionId("s"), "repomap:/repo2:git:h", "repo2/B.kt: Bar", vec, 0L))
l3.store(L3MemoryEntry("1", SessionId("s"), "repomap:v2:/repo:git:h", "repo/A.kt: Foo", vec, 0L))
l3.store(L3MemoryEntry("2", SessionId("s"), "repomap:v2:/repo2:git:h", "repo2/B.kt: Bar", vec, 0L))
val hits = L3RepoKnowledgeRetriever(RetrieverConstantEmbedder(), l3, "/repo")
.retrieve(SessionId("s"), "anything", 10)
@@ -31,8 +31,8 @@ class L3RepoKnowledgeRetrieverTest {
@Test
fun `retriever matches its own repoRoot entries with or without a stateKey suffix`(): Unit = runBlocking {
val l3 = InMemoryL3MemoryStore()
l3.store(L3MemoryEntry("1", SessionId("s"), "repomap:/repo:git:h1", "repo/A.kt", vec, 0L))
l3.store(L3MemoryEntry("2", SessionId("s"), "repomap:/repo:", "repo/B.kt", vec, 0L))
l3.store(L3MemoryEntry("1", SessionId("s"), "repomap:v2:/repo:git:h1", "repo/A.kt", vec, 0L))
l3.store(L3MemoryEntry("2", SessionId("s"), "repomap:v2:/repo:", "repo/B.kt", vec, 0L))
val hits = L3RepoKnowledgeRetriever(RetrieverConstantEmbedder(), l3, "/repo")
.retrieve(SessionId("s"), "anything", 10)
@@ -118,7 +118,7 @@ class ProjectMemoryServiceReuseTest {
svc.indexAndRecord(sessionId, "/repo")
assertTrue(
l3.existsByTurnIdPrefix("repomap:/repo:git:hash1"),
l3.existsByTurnIdPrefix("repomap:v2:/repo:git:hash1"),
"entries should be embedded with stateKey tag",
)
}
@@ -131,7 +131,7 @@ class ProjectMemoryServiceReuseTest {
val embedder = ConstantEmbedderReuse()
// Index /repo and /repo2 into the same shared L3 store with the same stateKey.
// The trailing-':' delimiter on the repomap: tag must keep their entries from bleeding
// The versioned trailing-':' delimiter on the repomap tag must keep their entries from bleeding
// across roots: "/repo" must NOT match tags written for "/repo2".
val repoEntries = listOf(RepoMapEntry(path = "src/Repo.kt", score = 1.0, symbols = listOf("RepoClass")))
val repo2Entries = listOf(RepoMapEntry(path = "src/Repo2.kt", score = 1.0, symbols = listOf("Repo2Class")))
@@ -142,8 +142,8 @@ class ProjectMemoryServiceReuseTest {
.indexAndRecord(SessionId("session-collide-repo2"), "/repo2")
// existsByTurnIdPrefix with the delimiter must not match the other root.
assertTrue(l3.existsByTurnIdPrefix("repomap:/repo:"), "tag for /repo should exist")
assertTrue(l3.existsByTurnIdPrefix("repomap:/repo2:"), "tag for /repo2 should exist")
assertTrue(l3.existsByTurnIdPrefix("repomap:v2:/repo:"), "tag for /repo should exist")
assertTrue(l3.existsByTurnIdPrefix("repomap:v2:/repo2:"), "tag for /repo2 should exist")
// The retriever scoped to /repo must return only /repo's entry, never /repo2's.
val hits = L3RepoKnowledgeRetriever(embedder = embedder, l3MemoryStore = l3, repoRoot = "/repo")
@@ -41,6 +41,40 @@ class RepoMapIndexerTest {
assertTrue(entries.first { it.path == "Old.kt" }.symbols.contains("Old"))
}
@Test
fun `source descriptor retains package imports and leading purpose comment`(@TempDir root: Path) {
root.resolve("src").createDirectories()
root.resolve("src/Context.kt").writeText(
"""
/** Builds the context packing pipeline for stage inference. */
package com.correx.context
import com.correx.events.EventStore
class DefaultContextPackBuilder
""".trimIndent(),
)
val entry = RepoMapIndexer().index(root).single()
assertTrue(entry.descriptor.contains("module com.correx.context"))
assertTrue(entry.descriptor.contains("EventStore"))
assertTrue(entry.descriptor.contains("context packing pipeline"))
}
@Test
fun `source descriptor never uses a declaration as a purpose comment`(@TempDir root: Path) {
root.resolve("Plain.kt").writeText(
"""
package com.correx.example
import com.correx.events.EventStore
class ALongEnoughDeclarationToHaveLookedLikeAComment
""".trimIndent(),
)
val entry = RepoMapIndexer().index(root).single()
assertFalse(entry.descriptor.contains("class ALongEnough"), entry.descriptor)
}
@Test
fun `extracts GDScript top-level symbols`(@TempDir root: Path) {
root.resolve("player.gd").writeText(