/ services / diagnosticTracking.ts
diagnosticTracking.ts
  1  import figures from 'figures'
  2  import { logError } from 'src/utils/log.js'
  3  import { callIdeRpc } from '../services/mcp/client.js'
  4  import type { MCPServerConnection } from '../services/mcp/types.js'
  5  import { ClaudeError } from '../utils/errors.js'
  6  import { normalizePathForComparison, pathsEqual } from '../utils/file.js'
  7  import { getConnectedIdeClient } from '../utils/ide.js'
  8  import { jsonParse } from '../utils/slowOperations.js'
  9  
 10  class DiagnosticsTrackingError extends ClaudeError {}
 11  
 12  const MAX_DIAGNOSTICS_SUMMARY_CHARS = 4000
 13  
 14  export 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  
 25  export interface DiagnosticFile {
 26    uri: string
 27    diagnostics: Diagnostic[]
 28  }
 29  
 30  export 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  
397  export const diagnosticTracker = DiagnosticTrackingService.getInstance()