chore: sprint handoffs + Lsp4j runner live-proof
Add per-agent sprint handoff docs (Sonnet/Codex/opencode) mapping the 1-week sprint's two goals to concrete Vikunja tasks. Kept in repo root (docs/ is gitignored). Includes hanging Lsp4jDiagnosticsRunner change + its live-proof test. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rdo9fe7SujNVeyZA8YkpkD
This commit is contained in:
+49
-13
@@ -7,20 +7,22 @@ import com.correx.core.kernel.orchestration.LspDiagnosticsRunner
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.eclipse.lsp4j.ClientCapabilities
|
||||
import org.eclipse.lsp4j.Diagnostic
|
||||
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.PublishDiagnosticsCapabilities
|
||||
import org.eclipse.lsp4j.PublishDiagnosticsParams
|
||||
import org.eclipse.lsp4j.ShowMessageRequestParams
|
||||
import org.eclipse.lsp4j.TextDocumentIdentifier
|
||||
import org.eclipse.lsp4j.TextDocumentClientCapabilities
|
||||
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.ConcurrentHashMap
|
||||
import java.util.concurrent.TimeUnit
|
||||
import kotlin.coroutines.cancellation.CancellationException
|
||||
|
||||
@@ -56,7 +58,11 @@ class LspServerRegistry(
|
||||
}
|
||||
}
|
||||
|
||||
/** LSP 3.17 pull-diagnostics adapter. Missing servers degrade to the static one-shot floor. */
|
||||
/**
|
||||
* LSP push-diagnostics adapter: `didOpen` each file, then collect the `publishDiagnostics`
|
||||
* the server pushes back. tsserver only supports push (rejects the 3.17 pull method);
|
||||
* rust-analyzer/gopls/clangd push on open too. Missing servers degrade to the static floor.
|
||||
*/
|
||||
class Lsp4jDiagnosticsRunner(
|
||||
private val registry: LspServerRegistry = LspServerRegistry(),
|
||||
private val timeoutSeconds: Long = 30,
|
||||
@@ -91,27 +97,34 @@ class Lsp4jDiagnosticsRunner(
|
||||
): 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 client = CollectingLanguageClient()
|
||||
val launcher = LSPLauncher.createClientLauncher(client, 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()
|
||||
capabilities = ClientCapabilities().apply {
|
||||
textDocument = TextDocumentClientCapabilities().apply {
|
||||
publishDiagnostics = PublishDiagnosticsCapabilities()
|
||||
}
|
||||
}
|
||||
},
|
||||
).get(timeoutSeconds, TimeUnit.SECONDS)
|
||||
server.initialized(InitializedParams())
|
||||
paths.flatMap { path ->
|
||||
val uriToPath = paths.associateBy { path ->
|
||||
request.workspaceRoot.resolve(path).normalize().toUri().toString()
|
||||
}
|
||||
uriToPath.forEach { (uri, 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 ->
|
||||
}
|
||||
awaitDiagnostics(client, uriToPath.keys)
|
||||
uriToPath.flatMap { (uri, path) ->
|
||||
client.latestFor(uri).map { diagnostic ->
|
||||
LspDiagnostic(
|
||||
path = path,
|
||||
line = diagnostic.range.start.line,
|
||||
@@ -131,12 +144,35 @@ class Lsp4jDiagnosticsRunner(
|
||||
}
|
||||
}
|
||||
|
||||
private object QuietLanguageClient : LanguageClient {
|
||||
/**
|
||||
* Block until every opened URI has received at least one push (or the timeout elapses), then
|
||||
* a short settle window so servers that push an empty report first, real diagnostics second
|
||||
* (tsserver does this after project load) land their final result before we read it.
|
||||
*/
|
||||
private fun awaitDiagnostics(client: CollectingLanguageClient, uris: Set<String>) {
|
||||
val deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(timeoutSeconds)
|
||||
while (System.nanoTime() < deadline && !client.hasAll(uris)) {
|
||||
Thread.sleep(POLL_MS)
|
||||
}
|
||||
Thread.sleep(SETTLE_MS)
|
||||
}
|
||||
|
||||
private class CollectingLanguageClient : LanguageClient {
|
||||
private val byUri = ConcurrentHashMap<String, List<Diagnostic>>()
|
||||
fun latestFor(uri: String): List<Diagnostic> = byUri[uri].orEmpty()
|
||||
fun hasAll(uris: Set<String>): Boolean = byUri.keys.containsAll(uris)
|
||||
override fun publishDiagnostics(diagnostics: PublishDiagnosticsParams) {
|
||||
byUri[diagnostics.uri] = diagnostics.diagnostics.orEmpty()
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
private companion object {
|
||||
const val POLL_MS = 100L
|
||||
const val SETTLE_MS = 750L
|
||||
}
|
||||
}
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package com.correx.infrastructure.workflow
|
||||
|
||||
import com.correx.core.kernel.orchestration.LspDiagnosticsRequest
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import org.junit.jupiter.api.Assumptions.assumeTrue
|
||||
import kotlin.io.path.createTempDirectory
|
||||
import kotlin.io.path.writeText
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class Lsp4jRunnerLiveProof {
|
||||
@Test
|
||||
fun `push diagnostics catch a TS syntax error`(): Unit = runBlocking {
|
||||
assumeTrue(onPath("typescript-language-server"), "typescript-language-server not installed")
|
||||
val dir = createTempDirectory("lspproof")
|
||||
dir.resolve("broken.ts").writeText(
|
||||
"""
|
||||
export const useArtifacts = () => {
|
||||
return 1
|
||||
// missing closing brace/semicolon
|
||||
const x: number = "string"
|
||||
""".trimIndent(),
|
||||
)
|
||||
val result = Lsp4jDiagnosticsRunner().pull(LspDiagnosticsRequest(dir, listOf("broken.ts")))
|
||||
println("skippedReason=${result.skippedReason} server=${result.server}")
|
||||
result.diagnostics.forEach { println(" ${it.line}:${it.character} ${it.severity} ${it.message}") }
|
||||
assertTrue(result.skippedReason == null, "gate must not skip: ${result.skippedReason}")
|
||||
assertTrue(result.diagnostics.any { it.severity == "error" }, "expected at least one error diagnostic")
|
||||
}
|
||||
|
||||
private fun onPath(cmd: String): Boolean =
|
||||
System.getenv("PATH").orEmpty().split(':').any { java.io.File(it, cmd).canExecute() }
|
||||
}
|
||||
Reference in New Issue
Block a user