source dump of claude code
at main 397 lines 12 kB view raw
1import figures from 'figures' 2import { logError } from 'src/utils/log.js' 3import { callIdeRpc } from '../services/mcp/client.js' 4import type { MCPServerConnection } from '../services/mcp/types.js' 5import { ClaudeError } from '../utils/errors.js' 6import { normalizePathForComparison, pathsEqual } from '../utils/file.js' 7import { getConnectedIdeClient } from '../utils/ide.js' 8import { jsonParse } from '../utils/slowOperations.js' 9 10class DiagnosticsTrackingError extends ClaudeError {} 11 12const MAX_DIAGNOSTICS_SUMMARY_CHARS = 4000 13 14export interface Diagnostic { 15 message: string 16 severity: 'Error' | 'Warning' | 'Info' | 'Hint' 17 range: { 18 start: { line: number; character: number } 19 end: { line: number; character: number } 20 } 21 source?: string 22 code?: string 23} 24 25export interface DiagnosticFile { 26 uri: string 27 diagnostics: Diagnostic[] 28} 29 30export class DiagnosticTrackingService { 31 private static instance: DiagnosticTrackingService | undefined 32 private baseline: Map<string, Diagnostic[]> = new Map() 33 34 private initialized = false 35 private mcpClient: MCPServerConnection | undefined 36 37 // Track when files were last processed/fetched 38 private lastProcessedTimestamps: Map<string, number> = new Map() 39 40 // Track which files have received right file diagnostics and if they've changed 41 // Map<normalizedPath, lastClaudeFsRightDiagnostics> 42 private rightFileDiagnosticsState: Map<string, Diagnostic[]> = new Map() 43 44 static getInstance(): DiagnosticTrackingService { 45 if (!DiagnosticTrackingService.instance) { 46 DiagnosticTrackingService.instance = new DiagnosticTrackingService() 47 } 48 return DiagnosticTrackingService.instance 49 } 50 51 initialize(mcpClient: MCPServerConnection) { 52 if (this.initialized) { 53 return 54 } 55 56 // TODO: Do not cache the connected mcpClient since it can change. 57 this.mcpClient = mcpClient 58 this.initialized = true 59 } 60 61 async shutdown(): Promise<void> { 62 this.initialized = false 63 this.baseline.clear() 64 this.rightFileDiagnosticsState.clear() 65 this.lastProcessedTimestamps.clear() 66 } 67 68 /** 69 * Reset tracking state while keeping the service initialized. 70 * This clears all tracked files and diagnostics. 71 */ 72 reset() { 73 this.baseline.clear() 74 this.rightFileDiagnosticsState.clear() 75 this.lastProcessedTimestamps.clear() 76 } 77 78 private normalizeFileUri(fileUri: string): string { 79 // Remove our protocol prefixes 80 const protocolPrefixes = [ 81 'file://', 82 '_claude_fs_right:', 83 '_claude_fs_left:', 84 ] 85 86 let normalized = fileUri 87 for (const prefix of protocolPrefixes) { 88 if (fileUri.startsWith(prefix)) { 89 normalized = fileUri.slice(prefix.length) 90 break 91 } 92 } 93 94 // Use shared utility for platform-aware path normalization 95 // (handles Windows case-insensitivity and path separators) 96 return normalizePathForComparison(normalized) 97 } 98 99 /** 100 * Ensure a file is opened in the IDE before processing. 101 * This is important for language services like diagnostics to work properly. 102 */ 103 async ensureFileOpened(fileUri: string): Promise<void> { 104 if ( 105 !this.initialized || 106 !this.mcpClient || 107 this.mcpClient.type !== 'connected' 108 ) { 109 return 110 } 111 112 try { 113 // Call the openFile tool to ensure the file is loaded 114 await callIdeRpc( 115 'openFile', 116 { 117 filePath: fileUri, 118 preview: false, 119 startText: '', 120 endText: '', 121 selectToEndOfLine: false, 122 makeFrontmost: false, 123 }, 124 this.mcpClient, 125 ) 126 } catch (error) { 127 logError(error as Error) 128 } 129 } 130 131 /** 132 * Capture baseline diagnostics for a specific file before editing. 133 * This is called before editing a file to ensure we have a baseline to compare against. 134 */ 135 async beforeFileEdited(filePath: string): Promise<void> { 136 if ( 137 !this.initialized || 138 !this.mcpClient || 139 this.mcpClient.type !== 'connected' 140 ) { 141 return 142 } 143 144 const timestamp = Date.now() 145 146 try { 147 const result = await callIdeRpc( 148 'getDiagnostics', 149 { uri: `file://${filePath}` }, 150 this.mcpClient, 151 ) 152 const diagnosticFile = this.parseDiagnosticResult(result)[0] 153 if (diagnosticFile) { 154 // Compare normalized paths (handles protocol prefixes and Windows case-insensitivity) 155 if ( 156 !pathsEqual( 157 this.normalizeFileUri(filePath), 158 this.normalizeFileUri(diagnosticFile.uri), 159 ) 160 ) { 161 logError( 162 new DiagnosticsTrackingError( 163 `Diagnostics file path mismatch: expected ${filePath}, got ${diagnosticFile.uri})`, 164 ), 165 ) 166 return 167 } 168 169 // Store with normalized path key for consistent lookups on Windows 170 const normalizedPath = this.normalizeFileUri(filePath) 171 this.baseline.set(normalizedPath, diagnosticFile.diagnostics) 172 this.lastProcessedTimestamps.set(normalizedPath, timestamp) 173 } else { 174 // No diagnostic file returned, store an empty baseline 175 const normalizedPath = this.normalizeFileUri(filePath) 176 this.baseline.set(normalizedPath, []) 177 this.lastProcessedTimestamps.set(normalizedPath, timestamp) 178 } 179 } catch (_error) { 180 // Fail silently if IDE doesn't support diagnostics 181 } 182 } 183 184 /** 185 * Get new diagnostics from file://, _claude_fs_right, and _claude_fs_ URIs that aren't in the baseline. 186 * Only processes diagnostics for files that have been edited. 187 */ 188 async getNewDiagnostics(): Promise<DiagnosticFile[]> { 189 if ( 190 !this.initialized || 191 !this.mcpClient || 192 this.mcpClient.type !== 'connected' 193 ) { 194 return [] 195 } 196 197 // Check if we have any files with diagnostic changes 198 let allDiagnosticFiles: DiagnosticFile[] = [] 199 try { 200 const result = await callIdeRpc( 201 'getDiagnostics', 202 {}, // Empty params fetches all diagnostics 203 this.mcpClient, 204 ) 205 allDiagnosticFiles = this.parseDiagnosticResult(result) 206 } catch (_error) { 207 // If fetching all diagnostics fails, return empty 208 return [] 209 } 210 const diagnosticsForFileUrisWithBaselines = allDiagnosticFiles 211 .filter(file => this.baseline.has(this.normalizeFileUri(file.uri))) 212 .filter(file => file.uri.startsWith('file://')) 213 214 const diagnosticsForClaudeFsRightUrisWithBaselinesMap = new Map< 215 string, 216 DiagnosticFile 217 >() 218 allDiagnosticFiles 219 .filter(file => this.baseline.has(this.normalizeFileUri(file.uri))) 220 .filter(file => file.uri.startsWith('_claude_fs_right:')) 221 .forEach(file => { 222 diagnosticsForClaudeFsRightUrisWithBaselinesMap.set( 223 this.normalizeFileUri(file.uri), 224 file, 225 ) 226 }) 227 228 const newDiagnosticFiles: DiagnosticFile[] = [] 229 230 // Process file:// protocol diagnostics 231 for (const file of diagnosticsForFileUrisWithBaselines) { 232 const normalizedPath = this.normalizeFileUri(file.uri) 233 const baselineDiagnostics = this.baseline.get(normalizedPath) || [] 234 235 // Get the _claude_fs_right file if it exists 236 const claudeFsRightFile = 237 diagnosticsForClaudeFsRightUrisWithBaselinesMap.get(normalizedPath) 238 239 // Determine which file to use based on the state of right file diagnostics 240 let fileToUse = file 241 242 if (claudeFsRightFile) { 243 const previousRightDiagnostics = 244 this.rightFileDiagnosticsState.get(normalizedPath) 245 246 // Use _claude_fs_right if: 247 // 1. We've never gotten right file diagnostics for this file (previousRightDiagnostics === undefined) 248 // 2. OR the right file diagnostics have just changed 249 if ( 250 !previousRightDiagnostics || 251 !this.areDiagnosticArraysEqual( 252 previousRightDiagnostics, 253 claudeFsRightFile.diagnostics, 254 ) 255 ) { 256 fileToUse = claudeFsRightFile 257 } 258 259 // Update our tracking of right file diagnostics 260 this.rightFileDiagnosticsState.set( 261 normalizedPath, 262 claudeFsRightFile.diagnostics, 263 ) 264 } 265 266 // Find new diagnostics that aren't in the baseline 267 const newDiagnostics = fileToUse.diagnostics.filter( 268 d => !baselineDiagnostics.some(b => this.areDiagnosticsEqual(d, b)), 269 ) 270 271 if (newDiagnostics.length > 0) { 272 newDiagnosticFiles.push({ 273 uri: file.uri, 274 diagnostics: newDiagnostics, 275 }) 276 } 277 278 // Update baseline with current diagnostics 279 this.baseline.set(normalizedPath, fileToUse.diagnostics) 280 } 281 282 return newDiagnosticFiles 283 } 284 285 private parseDiagnosticResult(result: unknown): DiagnosticFile[] { 286 if (Array.isArray(result)) { 287 const textBlock = result.find(block => block.type === 'text') 288 if (textBlock && 'text' in textBlock) { 289 const parsed = jsonParse(textBlock.text) 290 return parsed 291 } 292 } 293 return [] 294 } 295 296 private areDiagnosticsEqual(a: Diagnostic, b: Diagnostic): boolean { 297 return ( 298 a.message === b.message && 299 a.severity === b.severity && 300 a.source === b.source && 301 a.code === b.code && 302 a.range.start.line === b.range.start.line && 303 a.range.start.character === b.range.start.character && 304 a.range.end.line === b.range.end.line && 305 a.range.end.character === b.range.end.character 306 ) 307 } 308 309 private areDiagnosticArraysEqual(a: Diagnostic[], b: Diagnostic[]): boolean { 310 if (a.length !== b.length) return false 311 312 // Check if every diagnostic in 'a' exists in 'b' 313 return ( 314 a.every(diagA => 315 b.some(diagB => this.areDiagnosticsEqual(diagA, diagB)), 316 ) && 317 b.every(diagB => a.some(diagA => this.areDiagnosticsEqual(diagA, diagB))) 318 ) 319 } 320 321 /** 322 * Handle the start of a new query. This method: 323 * - Initializes the diagnostic tracker if not already initialized 324 * - Resets the tracker if already initialized (for new query loops) 325 * - Automatically finds the IDE client from the provided clients list 326 * 327 * @param clients Array of MCP clients that may include an IDE client 328 * @param shouldQuery Whether a query is actually being made (not just a command) 329 */ 330 async handleQueryStart(clients: MCPServerConnection[]): Promise<void> { 331 // Only proceed if we should query and have clients 332 if (!this.initialized) { 333 // Find the connected IDE client 334 const connectedIdeClient = getConnectedIdeClient(clients) 335 336 if (connectedIdeClient) { 337 this.initialize(connectedIdeClient) 338 } 339 } else { 340 // Reset diagnostic tracking for new query loops 341 this.reset() 342 } 343 } 344 345 /** 346 * Format diagnostics into a human-readable summary string. 347 * This is useful for displaying diagnostics in messages or logs. 348 * 349 * @param files Array of diagnostic files to format 350 * @returns Formatted string representation of the diagnostics 351 */ 352 static formatDiagnosticsSummary(files: DiagnosticFile[]): string { 353 const truncationMarker = '…[truncated]' 354 const result = files 355 .map(file => { 356 const filename = file.uri.split('/').pop() || file.uri 357 const diagnostics = file.diagnostics 358 .map(d => { 359 const severitySymbol = DiagnosticTrackingService.getSeveritySymbol( 360 d.severity, 361 ) 362 363 return ` ${severitySymbol} [Line ${d.range.start.line + 1}:${d.range.start.character + 1}] ${d.message}${d.code ? ` [${d.code}]` : ''}${d.source ? ` (${d.source})` : ''}` 364 }) 365 .join('\n') 366 367 return `${filename}:\n${diagnostics}` 368 }) 369 .join('\n\n') 370 371 if (result.length > MAX_DIAGNOSTICS_SUMMARY_CHARS) { 372 return ( 373 result.slice( 374 0, 375 MAX_DIAGNOSTICS_SUMMARY_CHARS - truncationMarker.length, 376 ) + truncationMarker 377 ) 378 } 379 return result 380 } 381 382 /** 383 * Get the severity symbol for a diagnostic 384 */ 385 static getSeveritySymbol(severity: Diagnostic['severity']): string { 386 return ( 387 { 388 Error: figures.cross, 389 Warning: figures.warning, 390 Info: figures.info, 391 Hint: figures.star, 392 }[severity] || figures.bullet 393 ) 394 } 395} 396 397export const diagnosticTracker = DiagnosticTrackingService.getInstance()