Files
metona-ai-desktop/electron/harness/tools/built-in/code-search.ts
T

231 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 代码搜索工具(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-catchcode_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);
// #22 修复: 在 pattern 前加 -- 终止选项解析,防止以 - 开头的 pattern 被解释为选项
// 攻击场景:pattern="--help" 输出帮助而非搜索,pattern="--ignore-file /etc/passwd" 可读取任意文件,
// pattern="--files" 列出所有文件。execFile 已用数组参数防 shell 注入,但 ripgrep 自身选项解析仍需防护
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);
}
}