/ utils / debugFilter.ts
debugFilter.ts
  1  import memoize from 'lodash-es/memoize.js'
  2  
  3  export type DebugFilter = {
  4    include: string[]
  5    exclude: string[]
  6    isExclusive: boolean
  7  }
  8  
  9  /**
 10   * Parse debug filter string into a filter configuration
 11   * Examples:
 12   * - "api,hooks" -> include only api and hooks categories
 13   * - "!1p,!file" -> exclude logging and file categories
 14   * - undefined/empty -> no filtering (show all)
 15   */
 16  export const parseDebugFilter = memoize(
 17    (filterString?: string): DebugFilter | null => {
 18      if (!filterString || filterString.trim() === '') {
 19        return null
 20      }
 21  
 22      const filters = filterString
 23        .split(',')
 24        .map(f => f.trim())
 25        .filter(Boolean)
 26  
 27      // If no valid filters remain, return null
 28      if (filters.length === 0) {
 29        return null
 30      }
 31  
 32      // Check for mixed inclusive/exclusive filters
 33      const hasExclusive = filters.some(f => f.startsWith('!'))
 34      const hasInclusive = filters.some(f => !f.startsWith('!'))
 35  
 36      if (hasExclusive && hasInclusive) {
 37        // For now, we'll treat this as an error case and show all messages
 38        // Log error using logForDebugging to avoid console.error lint rule
 39        // We'll import and use it later when the circular dependency is resolved
 40        // For now, just return null silently
 41        return null
 42      }
 43  
 44      // Clean up filters (remove ! prefix) and normalize
 45      const cleanFilters = filters.map(f => f.replace(/^!/, '').toLowerCase())
 46  
 47      return {
 48        include: hasExclusive ? [] : cleanFilters,
 49        exclude: hasExclusive ? cleanFilters : [],
 50        isExclusive: hasExclusive,
 51      }
 52    },
 53  )
 54  
 55  /**
 56   * Extract debug categories from a message
 57   * Supports multiple patterns:
 58   * - "category: message" -> ["category"]
 59   * - "[CATEGORY] message" -> ["category"]
 60   * - "MCP server \"name\": message" -> ["mcp", "name"]
 61   * - "[ANT-ONLY] 1P event: tengu_timer" -> ["ant-only", "1p"]
 62   *
 63   * Returns lowercase categories for case-insensitive matching
 64   */
 65  export function extractDebugCategories(message: string): string[] {
 66    const categories: string[] = []
 67  
 68    // Pattern 3: MCP server "servername" - Check this first to avoid false positives
 69    const mcpMatch = message.match(/^MCP server ["']([^"']+)["']/)
 70    if (mcpMatch && mcpMatch[1]) {
 71      categories.push('mcp')
 72      categories.push(mcpMatch[1].toLowerCase())
 73    } else {
 74      // Pattern 1: "category: message" (simple prefix) - only if not MCP pattern
 75      const prefixMatch = message.match(/^([^:[]+):/)
 76      if (prefixMatch && prefixMatch[1]) {
 77        categories.push(prefixMatch[1].trim().toLowerCase())
 78      }
 79    }
 80  
 81    // Pattern 2: [CATEGORY] at the start
 82    const bracketMatch = message.match(/^\[([^\]]+)]/)
 83    if (bracketMatch && bracketMatch[1]) {
 84      categories.push(bracketMatch[1].trim().toLowerCase())
 85    }
 86  
 87    // Pattern 4: Check for additional categories in the message
 88    // e.g., "[ANT-ONLY] 1P event: tengu_timer" should match both "ant-only" and "1p"
 89    if (message.toLowerCase().includes('1p event:')) {
 90      categories.push('1p')
 91    }
 92  
 93    // Pattern 5: Look for secondary categories after the first pattern
 94    // e.g., "AutoUpdaterWrapper: Installation type: development"
 95    const secondaryMatch = message.match(
 96      /:\s*([^:]+?)(?:\s+(?:type|mode|status|event))?:/,
 97    )
 98    if (secondaryMatch && secondaryMatch[1]) {
 99      const secondary = secondaryMatch[1].trim().toLowerCase()
100      // Only add if it's a reasonable category name (not too long, no spaces)
101      if (secondary.length < 30 && !secondary.includes(' ')) {
102        categories.push(secondary)
103      }
104    }
105  
106    // If no categories found, return empty array (uncategorized)
107    return Array.from(new Set(categories)) // Remove duplicates
108  }
109  
110  /**
111   * Check if debug message should be shown based on filter
112   * @param categories - Categories extracted from the message
113   * @param filter - Parsed filter configuration
114   * @returns true if message should be shown
115   */
116  export function shouldShowDebugCategories(
117    categories: string[],
118    filter: DebugFilter | null,
119  ): boolean {
120    // No filter means show everything
121    if (!filter) {
122      return true
123    }
124  
125    // If no categories found, handle based on filter mode
126    if (categories.length === 0) {
127      // In exclusive mode, uncategorized messages are excluded by default for security
128      // In inclusive mode, uncategorized messages are excluded (must match a category)
129      return false
130    }
131  
132    if (filter.isExclusive) {
133      // Exclusive mode: show if none of the categories are in the exclude list
134      return !categories.some(cat => filter.exclude.includes(cat))
135    } else {
136      // Inclusive mode: show if any of the categories are in the include list
137      return categories.some(cat => filter.include.includes(cat))
138    }
139  }
140  
141  /**
142   * Main function to check if a debug message should be shown
143   * Combines extraction and filtering
144   */
145  export function shouldShowDebugMessage(
146    message: string,
147    filter: DebugFilter | null,
148  ): boolean {
149    // Fast path: no filter means show everything
150    if (!filter) {
151      return true
152    }
153  
154    // Only extract categories if we have a filter
155    const categories = extractDebugCategories(message)
156    return shouldShowDebugCategories(categories, filter)
157  }