【阶段一 · 紧急修复 F1】 - F1-1 file_editor regex 计数 bug:强制 g 标志 + 同一 RegExp 实例做 match+replace - F1-2 code_search 统一 safeResolvePath(含路径遍历 + MEMORY.md 拦截) - F1-3 git_commit amend 模式:message 可选 + --no-edit 防止编辑器 hang - F1-4 write_file append 模式原子化:显式 open(O_APPEND)+write+fsync+close 【阶段二 · 增强现有工具 F2】 - F2-1 read_file 智能编码检测:BOM(UTF-8/UTF-16 LE/BE) + GBK 降级 - F2-2 read_file 支持 tail 模式(读取末尾 N 行,适用日志) - F2-3 search_files 二进制过滤 + 智能编码检测 - F2-4 search_files 多 glob 匹配(逗号分隔,如 *.ts,*.js) - F2-5 file_editor 新增 find_replace 操作(字面量替换,规避正则歧义) - F2-6 file_editor regex 支持跨行匹配(multiline 参数) - F2-7 file_editor 新增 backup 参数(编辑前 .bak 备份) 【阶段三 · 新增工具 F3】 - F3-1 file_move:双路径校验 + overwrite + 自动建父目录 + rename 原子 - F3-2 file_info:大小/时间/类型/编码/二进制/权限位 【阶段四 · 优化 F4】 - F4-1 diff_viewer Uint32Array→Uint16Array(省一半内存)+ safeResolvePath + 智能编码 - F4-2 错误处理统一:diff-viewer/file-editor/code-search catch 块改用 extractErrorMessage 【阶段五 · 验证 F5】 - tsc --noEmit 类型检查通过 - 人工审查通过:路径校验/错误处理/原子性/资源释放/权限策略/导出注册 工具数量:13 → 15(新增 file_move / file_info)
228 lines
8.8 KiB
TypeScript
228 lines
8.8 KiB
TypeScript
/**
|
||
* 代码搜索工具(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<boolean> {
|
||
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<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
|
||
// 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<unknown> {
|
||
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<string, unknown>;
|
||
try {
|
||
entry = JSON.parse(line);
|
||
} catch {
|
||
continue;
|
||
}
|
||
|
||
const type = entry.type as string;
|
||
const data = entry.data as Record<string, unknown>;
|
||
|
||
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<unknown> {
|
||
// 动态导入以避免循环依赖
|
||
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);
|
||
}
|
||
}
|