/** * 代码搜索工具(1 个) * * code_search — 基于 ripgrep 的高速代码搜索 * * 相比 search_files 的纯 JS 实现,code_search 调用 ripgrep 子进程, * 性能提升 10-100 倍,支持正则、文件类型过滤、上下文行展示。 * 适合大型代码库的精准搜索。 * * @see standard/开发规范.md — 优先使用第三方成熟库 */ import { execFile } from 'child_process'; import { promisify } from 'util'; import log from 'electron-log'; import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool'; import type { MetonaToolDef } from '../../../harness/types'; import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types'; // F1-2: 统一用 safeResolvePath(含 isPathWithinWorkspace + MEMORY.md 拦截) // F4-2: 引入 extractErrorMessage 统一错误处理 import { safeResolvePath, extractErrorMessage } from './file-guard'; const execFileAsync = promisify(execFile); export class CodeSearchTool implements IMetonaTool { /** ripgrep 可用性缓存(实例级,便于测试重置) */ private rgAvailable: boolean | null = null; /** 检测系统是否安装了 ripgrep */ private async checkRipgrep(): Promise { if (this.rgAvailable !== null) return this.rgAvailable; try { await execFileAsync('rg', ['--version'], { timeout: 3_000 }); this.rgAvailable = true; } catch { this.rgAvailable = false; } return this.rgAvailable; } readonly definition: MetonaToolDef = { name: 'code_search', description: 'Search code using ripgrep. Supports regex patterns, file type filtering, and context lines. Much faster than search_files for large codebases. Falls back to JS implementation if ripgrep is not installed.', parameters: { type: 'object', properties: { pattern: { type: 'string', description: 'Regex pattern to search for' }, path: { type: 'string', description: 'Search directory (default: workspace root)' }, file_glob: { type: 'string', description: 'File name glob filter (e.g., "*.ts", "*.py")' }, case_sensitive: { type: 'boolean', description: 'Case sensitive search (default false)' }, context_before: { type: 'number', description: 'Lines of context before match (default 0, max 5)' }, context_after: { type: 'number', description: 'Lines of context after match (default 0, max 5)' }, max_results: { type: 'number', description: 'Maximum results (default 50, max 200)' }, }, required: ['pattern'], }, category: MetonaToolCategory.SEARCH, riskLevel: MetonaRiskLevel.SAFE, requiresPermission: false, timeoutMs: 30_000, }; async execute(args: Record, context: ToolExecutionContext): Promise { // F4-2: 外层 try-catch 防止 safeResolvePath 抛出异常向上传播 // (read_file/write_file/list_directory 均有外层 try-catch,code_search 此前缺失) try { const pattern = args.pattern as string; if (typeof pattern !== 'string' || !pattern) { return { results: [], count: 0, error: 'Pattern is required and must be a string' }; } if (pattern.length > 500) { return { results: [], count: 0, error: 'Pattern too long (max 500 chars)' }; } const searchPath = args.path ? safeResolvePath(args.path as string, context.workspacePath) : context.workspacePath; const fileGlob = args.file_glob as string | undefined; const caseSensitive = (args.case_sensitive as boolean) ?? false; const contextBefore = Math.min(5, Math.max(0, (args.context_before as number) ?? 0)); const contextAfter = Math.min(5, Math.max(0, (args.context_after as number) ?? 0)); const maxResults = Math.min(200, Math.max(1, (args.max_results as number) ?? 50)); const opts = { fileGlob, caseSensitive, contextBefore, contextAfter, maxResults }; // 优先使用 ripgrep,回退到 JS 实现 const hasRg = await this.checkRipgrep(); if (hasRg) { return this.searchWithRipgrep(pattern, searchPath, opts, context); } return this.searchWithJs(pattern, searchPath, opts, context); } catch (error) { return { results: [], count: 0, error: extractErrorMessage(error), success: false }; } } /** 使用 ripgrep 子进程搜索 */ private async searchWithRipgrep( pattern: string, searchPath: string, opts: { fileGlob?: string; caseSensitive: boolean; contextBefore: number; contextAfter: number; maxResults: number }, context: ToolExecutionContext, ): Promise { const rgArgs: string[] = ['--json']; if (!opts.caseSensitive) rgArgs.push('-i'); if (opts.contextBefore > 0) rgArgs.push('-B', String(opts.contextBefore)); if (opts.contextAfter > 0) rgArgs.push('-A', String(opts.contextAfter)); rgArgs.push('-g', '!MEMORY.md'); if (opts.fileGlob) rgArgs.push('-g', opts.fileGlob); rgArgs.push(pattern, searchPath); try { const { stdout } = await execFileAsync('rg', rgArgs, { maxBuffer: 10 * 1024 * 1024, timeout: 25_000, }); const results = this.parseRipgrepJsonOutput(stdout); return { results: results.slice(0, opts.maxResults), count: results.length, engine: 'ripgrep' }; } catch (error) { const err = error as { code?: number; signal?: string; stdout?: string; stderr?: string; killed?: boolean; message?: string }; // rg 退出码 1 = 无匹配,不是错误 if (err.code === 1) { return { results: [], count: 0, engine: 'ripgrep' }; } // 超时被 kill if (err.killed || err.signal === 'SIGTERM') { return { results: [], count: 0, error: 'ripgrep search timed out', engine: 'ripgrep' }; } // 其他错误回退到 JS log.warn('[CodeSearch] ripgrep failed, falling back to JS:', err.stderr || err.message); return this.searchWithJs(pattern, searchPath, opts, context); } } /** 解析 ripgrep --json 输出 */ private parseRipgrepJsonOutput(output: string): Array<{ path: string; line: number; column: number; match: string; before?: string[]; after?: string[]; }> { const results: Array<{ path: string; line: number; column: number; match: string; before?: string[]; after?: string[] }> = []; const lines = output.split('\n').filter((l) => l.trim()); let currentMatch: { path: string; line: number; column: number; match: string; before?: string[]; after?: string[] } | null = null; let beforeBuffer: string[] = []; let afterBuffer: string[] = []; for (const line of lines) { let entry: Record; try { entry = JSON.parse(line); } catch { continue; } const type = entry.type as string; const data = entry.data as Record; if (type === 'context') { const text = (data.lines as { text?: string } | undefined)?.text ?? ''; if (currentMatch) { // 当前有 match,这是 after context afterBuffer.push(text); } else { // 当前无 match,这是 before context beforeBuffer.push(text); } } else if (type === 'match') { // 新 match:先保存上一个 match 的 after context if (currentMatch) { if (afterBuffer.length > 0) currentMatch.after = [...afterBuffer]; results.push(currentMatch); afterBuffer = []; } const text = (data.lines as { text?: string } | undefined)?.text ?? ''; const submatches = (data.submatches as Array<{ match: { text?: string }; start?: number }> | undefined) ?? []; const matchText = submatches[0]?.match?.text ?? text; const column = (submatches[0]?.start ?? 0) + 1; currentMatch = { path: (data.path as { text?: string } | undefined)?.text ?? '', line: data.line_number as number, column, match: matchText, before: beforeBuffer.length > 0 ? [...beforeBuffer] : undefined, }; beforeBuffer = []; afterBuffer = []; } } // 保存最后一个 match if (currentMatch) { if (afterBuffer.length > 0) currentMatch.after = [...afterBuffer]; results.push(currentMatch); } return results; } /** JS 回退实现 */ private async searchWithJs( pattern: string, searchPath: string, opts: { fileGlob?: string; caseSensitive: boolean; contextBefore: number; contextAfter: number; maxResults: number }, context: ToolExecutionContext, ): Promise { // 动态导入以避免循环依赖 const { SearchFilesTool } = await import('./filesystem'); const searchTool = new SearchFilesTool(); return searchTool.execute({ pattern, target: 'content', path: searchPath, file_glob: opts.fileGlob, limit: opts.maxResults, }, context); } }