/ services / lsp / passiveFeedback.ts
passiveFeedback.ts
  1  import { fileURLToPath } from 'url'
  2  import type { PublishDiagnosticsParams } from 'vscode-languageserver-protocol'
  3  import { logForDebugging } from '../../utils/debug.js'
  4  import { toError } from '../../utils/errors.js'
  5  import { logError } from '../../utils/log.js'
  6  import { jsonStringify } from '../../utils/slowOperations.js'
  7  import type { DiagnosticFile } from '../diagnosticTracking.js'
  8  import { registerPendingLSPDiagnostic } from './LSPDiagnosticRegistry.js'
  9  import type { LSPServerManager } from './LSPServerManager.js'
 10  
 11  /**
 12   * Map LSP severity to Claude diagnostic severity
 13   *
 14   * Maps LSP severity numbers to Claude diagnostic severity strings.
 15   * Accepts numeric severity values (1=Error, 2=Warning, 3=Information, 4=Hint)
 16   * or undefined, defaulting to 'Error' for invalid/missing values.
 17   */
 18  function mapLSPSeverity(
 19    lspSeverity: number | undefined,
 20  ): 'Error' | 'Warning' | 'Info' | 'Hint' {
 21    // LSP DiagnosticSeverity enum:
 22    // 1 = Error, 2 = Warning, 3 = Information, 4 = Hint
 23    switch (lspSeverity) {
 24      case 1:
 25        return 'Error'
 26      case 2:
 27        return 'Warning'
 28      case 3:
 29        return 'Info'
 30      case 4:
 31        return 'Hint'
 32      default:
 33        return 'Error'
 34    }
 35  }
 36  
 37  /**
 38   * Convert LSP diagnostics to Claude diagnostic format
 39   *
 40   * Converts LSP PublishDiagnosticsParams to DiagnosticFile[] format
 41   * used by Claude's attachment system.
 42   */
 43  export function formatDiagnosticsForAttachment(
 44    params: PublishDiagnosticsParams,
 45  ): DiagnosticFile[] {
 46    // Parse URI (may be file:// or plain path) and normalize to file system path
 47    let uri: string
 48    try {
 49      // Handle both file:// URIs and plain paths
 50      uri = params.uri.startsWith('file://')
 51        ? fileURLToPath(params.uri)
 52        : params.uri
 53    } catch (error) {
 54      const err = toError(error)
 55      logError(err)
 56      logForDebugging(
 57        `Failed to convert URI to file path: ${params.uri}. Error: ${err.message}. Using original URI as fallback.`,
 58      )
 59      // Gracefully fallback to original URI - LSP servers may send malformed URIs
 60      uri = params.uri
 61    }
 62  
 63    const diagnostics = params.diagnostics.map(
 64      (diag: {
 65        message: string
 66        severity?: number
 67        range: {
 68          start: { line: number; character: number }
 69          end: { line: number; character: number }
 70        }
 71        source?: string
 72        code?: string | number
 73      }) => ({
 74        message: diag.message,
 75        severity: mapLSPSeverity(diag.severity),
 76        range: {
 77          start: {
 78            line: diag.range.start.line,
 79            character: diag.range.start.character,
 80          },
 81          end: {
 82            line: diag.range.end.line,
 83            character: diag.range.end.character,
 84          },
 85        },
 86        source: diag.source,
 87        code:
 88          diag.code !== undefined && diag.code !== null
 89            ? String(diag.code)
 90            : undefined,
 91      }),
 92    )
 93  
 94    return [
 95      {
 96        uri,
 97        diagnostics,
 98      },
 99    ]
100  }
101  
102  /**
103   * Handler registration result with tracking data
104   */
105  export type HandlerRegistrationResult = {
106    /** Total number of servers */
107    totalServers: number
108    /** Number of successful registrations */
109    successCount: number
110    /** Registration errors per server */
111    registrationErrors: Array<{ serverName: string; error: string }>
112    /** Runtime failure tracking (shared across all handler invocations) */
113    diagnosticFailures: Map<string, { count: number; lastError: string }>
114  }
115  
116  /**
117   * Register LSP notification handlers on all servers
118   *
119   * Sets up handlers to listen for textDocument/publishDiagnostics notifications
120   * from all LSP servers and routes them to Claude's diagnostic system.
121   * Uses public getAllServers() API for clean access to server instances.
122   *
123   * @returns Tracking data for registration status and runtime failures
124   */
125  export function registerLSPNotificationHandlers(
126    manager: LSPServerManager,
127  ): HandlerRegistrationResult {
128    // Register handlers on all configured servers to capture diagnostics from any language
129    const servers = manager.getAllServers()
130  
131    // Track partial failures - allow successful server registrations even if some fail
132    const registrationErrors: Array<{ serverName: string; error: string }> = []
133    let successCount = 0
134  
135    // Track consecutive failures per server to warn users after 3+ failures
136    const diagnosticFailures: Map<string, { count: number; lastError: string }> =
137      new Map()
138  
139    for (const [serverName, serverInstance] of servers.entries()) {
140      try {
141        // Validate server instance has onNotification method
142        if (
143          !serverInstance ||
144          typeof serverInstance.onNotification !== 'function'
145        ) {
146          const errorMsg = !serverInstance
147            ? 'Server instance is null/undefined'
148            : 'Server instance has no onNotification method'
149  
150          registrationErrors.push({ serverName, error: errorMsg })
151  
152          const err = new Error(`${errorMsg} for ${serverName}`)
153          logError(err)
154          logForDebugging(
155            `Skipping handler registration for ${serverName}: ${errorMsg}`,
156          )
157          continue // Skip this server but track the failure
158        }
159  
160        // Errors are isolated to avoid breaking other servers
161        serverInstance.onNotification(
162          'textDocument/publishDiagnostics',
163          (params: unknown) => {
164            logForDebugging(
165              `[PASSIVE DIAGNOSTICS] Handler invoked for ${serverName}! Params type: ${typeof params}`,
166            )
167            try {
168              // Validate params structure before casting
169              if (
170                !params ||
171                typeof params !== 'object' ||
172                !('uri' in params) ||
173                !('diagnostics' in params)
174              ) {
175                const err = new Error(
176                  `LSP server ${serverName} sent invalid diagnostic params (missing uri or diagnostics)`,
177                )
178                logError(err)
179                logForDebugging(
180                  `Invalid diagnostic params from ${serverName}: ${jsonStringify(params)}`,
181                )
182                return
183              }
184  
185              const diagnosticParams = params as PublishDiagnosticsParams
186              logForDebugging(
187                `Received diagnostics from ${serverName}: ${diagnosticParams.diagnostics.length} diagnostic(s) for ${diagnosticParams.uri}`,
188              )
189  
190              // Convert LSP diagnostics to Claude format (can throw on invalid URIs)
191              const diagnosticFiles =
192                formatDiagnosticsForAttachment(diagnosticParams)
193  
194              // Only send notification if there are diagnostics
195              const firstFile = diagnosticFiles[0]
196              if (
197                !firstFile ||
198                diagnosticFiles.length === 0 ||
199                firstFile.diagnostics.length === 0
200              ) {
201                logForDebugging(
202                  `Skipping empty diagnostics from ${serverName} for ${diagnosticParams.uri}`,
203                )
204                return
205              }
206  
207              // Register diagnostics for async delivery via attachment system
208              // Follows same pattern as AsyncHookRegistry for consistent async attachment delivery
209              try {
210                registerPendingLSPDiagnostic({
211                  serverName,
212                  files: diagnosticFiles,
213                })
214  
215                logForDebugging(
216                  `LSP Diagnostics: Registered ${diagnosticFiles.length} diagnostic file(s) from ${serverName} for async delivery`,
217                )
218  
219                // Success - reset failure counter for this server
220                diagnosticFailures.delete(serverName)
221              } catch (error) {
222                const err = toError(error)
223                logError(err)
224                logForDebugging(
225                  `Error registering LSP diagnostics from ${serverName}: ` +
226                    `URI: ${diagnosticParams.uri}, ` +
227                    `Diagnostic count: ${firstFile.diagnostics.length}, ` +
228                    `Error: ${err.message}`,
229                )
230  
231                // Track consecutive failures and warn after 3+
232                const failures = diagnosticFailures.get(serverName) || {
233                  count: 0,
234                  lastError: '',
235                }
236                failures.count++
237                failures.lastError = err.message
238                diagnosticFailures.set(serverName, failures)
239  
240                if (failures.count >= 3) {
241                  logForDebugging(
242                    `WARNING: LSP diagnostic handler for ${serverName} has failed ${failures.count} times consecutively. ` +
243                      `Last error: ${failures.lastError}. ` +
244                      `This may indicate a problem with the LSP server or diagnostic processing. ` +
245                      `Check logs for details.`,
246                  )
247                }
248              }
249            } catch (error) {
250              // Catch any unexpected errors from the entire handler to prevent breaking the notification loop
251              const err = toError(error)
252              logError(err)
253              logForDebugging(
254                `Unexpected error processing diagnostics from ${serverName}: ${err.message}`,
255              )
256  
257              // Track consecutive failures and warn after 3+
258              const failures = diagnosticFailures.get(serverName) || {
259                count: 0,
260                lastError: '',
261              }
262              failures.count++
263              failures.lastError = err.message
264              diagnosticFailures.set(serverName, failures)
265  
266              if (failures.count >= 3) {
267                logForDebugging(
268                  `WARNING: LSP diagnostic handler for ${serverName} has failed ${failures.count} times consecutively. ` +
269                    `Last error: ${failures.lastError}. ` +
270                    `This may indicate a problem with the LSP server or diagnostic processing. ` +
271                    `Check logs for details.`,
272                )
273              }
274  
275              // Don't re-throw - isolate errors to this server only
276            }
277          },
278        )
279  
280        logForDebugging(`Registered diagnostics handler for ${serverName}`)
281        successCount++
282      } catch (error) {
283        const err = toError(error)
284  
285        registrationErrors.push({
286          serverName,
287          error: err.message,
288        })
289  
290        logError(err)
291        logForDebugging(
292          `Failed to register diagnostics handler for ${serverName}: ` +
293            `Error: ${err.message}`,
294        )
295      }
296    }
297  
298    // Report overall registration status
299    const totalServers = servers.size
300    if (registrationErrors.length > 0) {
301      const failedServers = registrationErrors
302        .map(e => `${e.serverName} (${e.error})`)
303        .join(', ')
304      // Log aggregate failures for tracking
305      logError(
306        new Error(
307          `Failed to register diagnostics for ${registrationErrors.length} LSP server(s): ${failedServers}`,
308        ),
309      )
310      logForDebugging(
311        `LSP notification handler registration: ${successCount}/${totalServers} succeeded. ` +
312          `Failed servers: ${failedServers}. ` +
313          `Diagnostics from failed servers will not be delivered.`,
314      )
315    } else {
316      logForDebugging(
317        `LSP notification handlers registered successfully for all ${totalServers} server(s)`,
318      )
319    }
320  
321    // Return tracking data for monitoring and testing
322    return {
323      totalServers,
324      successCount,
325      registrationErrors,
326      diagnosticFailures,
327    }
328  }