fix(lsp-gate): lint-tagged diagnostics never fail a stage (#311)

tsserver tags TS6133 (unused import) Unnecessary, but a tsconfig with
noUnusedLocals promotes it to error severity — which hard-failed whole
runs no rewrite could clear. Carry LSP DiagnosticTag through the
LspDiagnostic event and gate on untagged errors only; lint diagnostics
stay recorded and visible.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-26 21:16:33 +04:00
parent c32445b8a8
commit f61864ff1d
4 changed files with 38 additions and 2 deletions
@@ -13,7 +13,12 @@ data class LspDiagnostic(
val severity: String, val severity: String,
val code: String? = null, val code: String? = null,
val message: String, val message: String,
) /** LSP `DiagnosticTag` names, lowercased ("unnecessary", "deprecated"). Lint class, not severity. */
val tags: List<String> = emptyList(),
) {
/** Lint-class diagnostic: reported and recorded, but never a reason to fail a stage. */
val isLint: Boolean get() = tags.isNotEmpty()
}
/** Recorded LSP 3.17 pull-diagnostic observation; replay never re-queries a language server. */ /** Recorded LSP 3.17 pull-diagnostic observation; replay never re-queries a language server. */
@Serializable @Serializable
@@ -0,0 +1,28 @@
package com.correx.core.events.events
import kotlin.test.Test
import kotlin.test.assertFalse
import kotlin.test.assertTrue
class LspDiagnosticTest {
private fun diagnostic(tags: List<String>) = LspDiagnostic(
path = "src/App.tsx",
line = 0,
character = 0,
severity = "error",
code = "6133",
message = "'React' is declared but its value is never read.",
tags = tags,
)
@Test
fun `tagged diagnostic is lint class even at error severity`() {
assertTrue(diagnostic(listOf("unnecessary")).isLint)
assertTrue(diagnostic(listOf("deprecated")).isLint)
}
@Test
fun `untagged diagnostic still gates`() {
assertFalse(diagnostic(emptyList()).isLint)
}
}
@@ -97,7 +97,9 @@ internal suspend fun SessionOrchestrator.runLspDiagnostics(
sessionId, sessionId,
LspDiagnosticsCompletedEvent(sessionId, stageId, result.server, diagnostics, result.skippedReason), LspDiagnosticsCompletedEvent(sessionId, stageId, result.server, diagnostics, result.skippedReason),
) )
val errors = diagnostics.filter { it.severity.equals("error", ignoreCase = true) } // Lint-class diagnostics (unused import, deprecated) are recorded above but never gate: a
// tsconfig with noUnusedLocals promotes them to "error" severity, which no rewrite can clear.
val errors = diagnostics.filter { it.severity.equals("error", ignoreCase = true) && !it.isLint }
if (errors.isEmpty()) return StageExecutionResult.Success(emptyList()) if (errors.isEmpty()) return StageExecutionResult.Success(emptyList())
val detail = errors.joinToString("\n") { val detail = errors.joinToString("\n") {
"- ${it.path}:${it.line + 1}:${it.character + 1} ${it.code.orEmpty()} ${it.message}".trim() "- ${it.path}:${it.line + 1}:${it.character + 1} ${it.code.orEmpty()} ${it.message}".trim()
@@ -132,6 +132,7 @@ class Lsp4jDiagnosticsRunner(
severity = diagnostic.severity?.name?.lowercase() ?: "error", severity = diagnostic.severity?.name?.lowercase() ?: "error",
code = diagnostic.code?.let { if (it.isLeft) it.left else it.right.toString() }, code = diagnostic.code?.let { if (it.isLeft) it.left else it.right.toString() },
message = diagnostic.message, message = diagnostic.message,
tags = diagnostic.tags.orEmpty().map { it.name.lowercase() },
) )
} }
} }