diff --git a/src/review/tools/code-search-tool.ts b/src/review/tools/code-search-tool.ts new file mode 100644 index 0000000..db325db --- /dev/null +++ b/src/review/tools/code-search-tool.ts @@ -0,0 +1,79 @@ +import { z } from 'zod'; +import { Tool } from './types'; +import { SandboxExec } from '../context/sandbox-exec'; + +export function createCodeSearchTool(sandbox: SandboxExec): Tool { + return { + name: 'search_code', + description: + '在代码库中搜索匹配给定模式的代码,支持正则表达式。用于发现相似问题或影响范围。', + parameters: z.object({ + pattern: z.string().describe('要搜索的正则表达式模式'), + file_types: z + .array(z.string()) + .optional() + .describe('限制搜索的文件类型,如["ts", "js"]'), + max_results: z.number().default(20).describe('最大返回结果数'), + }), + execute: async (params, context) => { + const { pattern, file_types, max_results } = params; + + // 构建ripgrep参数:选项必须在--之前,--之后只能是pattern和路径等位置参数 + const args = ['--json', '--max-count', String(max_results || 20)]; + + if (file_types && file_types.length > 0) { + args.push('--type-add', `custom:*.{${file_types.join(',')}}`); + args.push('--type', 'custom'); + } + + // 使用--分隔选项和pattern,防止pattern以-开头时被误解析为ripgrep选项 + args.push('--', pattern, context.workspacePath); + + try { + const result = await sandbox.run('rg', args, { + cwd: context.workspacePath, + timeoutMs: 10000, + }); + + if (!result.stdout.trim()) { + return { matches: [], message: '未找到匹配结果' }; + } + + // 解析ripgrep JSON输出并过滤只保留match事件(排除begin/end/summary) + const matches = result.stdout + .split('\n') + .filter((line) => line.trim()) + .map((line) => { + try { + return JSON.parse(line); + } catch { + return null; + } + }) + .filter((event) => event && event.type === 'match') + .slice(0, max_results || 20); + + return { + matches: matches.map((m: any) => ({ + path: m.data?.path?.text || '', + line: m.data?.line_number || 0, + content: m.data?.lines?.text || '', + })), + total: matches.length, + }; + } catch (error) { + // ripgrep返回exit code 1表示无匹配(正常情况),不应视为错误 + const errorMessage = error instanceof Error ? error.message : String(error); + if (errorMessage.includes('code=1')) { + return { matches: [], message: '未找到匹配结果' }; + } + + // 其他错误(超时、权限等)才是真正的失败 + return { + error: errorMessage, + matches: [], + }; + } + }, + }; +} diff --git a/src/review/tools/file-read-tool.ts b/src/review/tools/file-read-tool.ts new file mode 100644 index 0000000..d29a144 --- /dev/null +++ b/src/review/tools/file-read-tool.ts @@ -0,0 +1,59 @@ +import { z } from 'zod'; +import { Tool } from './types'; +import { readFile, realpath } from 'node:fs/promises'; +import path from 'node:path'; + +export function createFileReadTool(): Tool { + return { + name: 'read_file', + description: '读取指定文件的完整内容,用于深入分析代码逻辑。', + parameters: z.object({ + file_path: z.string().describe('相对文件路径'), + start_line: z.number().optional().describe('起始行号(可选)'), + end_line: z.number().optional().describe('结束行号(可选)'), + }), + execute: async (params, context) => { + const { file_path, start_line, end_line } = params; + + // 安全性:规范化路径并验证是否在workspace内 + const normalizedPath = path.normalize(file_path).replace(/^(\.\.[\/\\])+/, ''); + const fullPath = path.resolve(context.workspacePath, normalizedPath); + + try { + // 使用realpath解析完整路径(跟随所有符号链接) + const realPath = await realpath(fullPath); + const workspaceRealPath = await realpath(context.workspacePath); + + // 验证解析后的真实路径必须在workspace目录下 + if (!realPath.startsWith(workspaceRealPath + path.sep) && realPath !== workspaceRealPath) { + return { + error: `安全错误:路径 "${file_path}" 解析到workspace外部 (${realPath})`, + path: file_path, + }; + } + + const content = await readFile(realPath, 'utf-8'); + + if (start_line !== undefined && end_line !== undefined) { + const lines = content.split('\n'); + return { + path: file_path, + content: lines.slice(start_line - 1, end_line).join('\n'), + lines: `${start_line}-${end_line}`, + }; + } + + return { + path: file_path, + content, + totalLines: content.split('\n').length, + }; + } catch (error) { + return { + error: error instanceof Error ? error.message : String(error), + path: file_path, + }; + } + }, + }; +} diff --git a/src/review/tools/function-reference-search-tool.ts b/src/review/tools/function-reference-search-tool.ts new file mode 100644 index 0000000..7a5bda9 --- /dev/null +++ b/src/review/tools/function-reference-search-tool.ts @@ -0,0 +1,179 @@ +import { z } from 'zod'; +import { Tool } from './types'; +import { SandboxExec } from '../context/sandbox-exec'; + +// 转义正则元字符,将identifier中的特殊字符转义为字面量 +function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +export function createFunctionReferenceSearchTool(sandbox: SandboxExec): Tool { + return { + name: 'search_function_references', + description: '搜索指定函数、方法或类的所有引用和定义(支持所有编程语言)。用于理解代码影响范围和调用关系。', + parameters: z.object({ + identifier: z.string().describe('要搜索的标识符(函数名、类名、方法名等)'), + file_types: z + .array(z.string()) + .optional() + .describe('限制搜索的文件类型,如["ts", "go", "py", "java"]'), + search_type: z + .enum(['calls', 'definitions', 'all']) + .default('all') + .describe('搜索类型:calls=仅调用,definitions=仅定义,all=全部'), + max_results: z.number().default(30).describe('最大返回结果数'), + }), + execute: async (params, context) => { + const { identifier, file_types, search_type, max_results } = params; + + // 转义identifier中的正则元字符,避免被解释为正则语法 + const escapedId = escapeRegex(identifier); + + // 定义调用模式(适配多种语言) + const callPatterns: string[] = [ + `${escapedId}\\s*\\(`, // 直接调用: functionName( + `\\.${escapedId}\\s*\\(`, // 方法调用: obj.methodName( + `::${escapedId}\\s*\\(`, // C++/Rust静态调用: Class::method( + `${escapedId}\\s*<[^>]+>\\s*\\(`, // 泛型调用: functionName( (修复:限制<>内容) + ]; + + // 定义声明模式(多语言) + const definitionPatterns: string[] = [ + `func\\s+${escapedId}\\s*\\(`, // Go: func functionName( + `fn\\s+${escapedId}\\s*\\(`, // Rust: fn functionName( + `def\\s+${escapedId}\\s*\\(`, // Python: def functionName( + `function\\s+${escapedId}\\s*\\(`, // JavaScript: function functionName( + `${escapedId}\\s*:\\s*function`, // JS对象方法: methodName: function + `${escapedId}\\s*=\\s*\\([^)]*\\)\\s*=>`, // Arrow function: const fn = () => (修复:限制参数) + `class\\s+${escapedId}\\s*[{<]`, // 类定义: class ClassName { + `interface\\s+${escapedId}\\s*[{<]`, // 接口: interface InterfaceName { + `type\\s+${escapedId}\\s*=`, // 类型别名: type TypeName = + `struct\\s+${escapedId}\\s*[{]`, // Go/Rust struct: struct StructName { + `public\\s+[^(]*\\s+${escapedId}\\s*\\(`, // Java方法: public void methodName( + `private\\s+[^(]*\\s+${escapedId}\\s*\\(`, // Java私有方法 + ]; + + // 根据search_type选择模式 + interface SearchTask { + patterns: string[]; + type: 'call' | 'definition'; + } + + const tasks: SearchTask[] = []; + if (search_type === 'calls' || search_type === 'all') { + tasks.push({ patterns: callPatterns, type: 'call' }); + } + if (search_type === 'definitions' || search_type === 'all') { + tasks.push({ patterns: definitionPatterns, type: 'definition' }); + } + + // 分别执行搜索任务 + const allReferences: Array<{ + path: string; + line: number; + content: string; + type: 'call' | 'definition'; + }> = []; + + for (const task of tasks) { + const pattern = task.patterns.join('|'); + const args = [ + '--json', + // 移除 --ignore-case,保持大小写敏感(大多数语言都是case-sensitive) + '--max-count', + String(max_results || 30), + '-e', + pattern, + context.workspacePath, + ]; + + if (file_types && file_types.length > 0) { + args.push('--type-add', `custom:*.{${file_types.join(',')}}`); + args.push('--type', 'custom'); + } + + try { + const result = await sandbox.run('rg', args, { + cwd: context.workspacePath, + timeoutMs: 15000, + }); + + if (result.stdout.trim()) { + // 解析ripgrep JSON输出 + const matches = result.stdout + .split('\n') + .filter((line) => line.trim()) + .map((line) => { + try { + return JSON.parse(line); + } catch { + return null; + } + }) + .filter((event) => event && event.type === 'match'); + + // 转换为统一格式,使用task.type作为分类 + for (const m of matches) { + allReferences.push({ + path: m.data?.path?.text || '', + line: m.data?.line_number || 0, + content: (m.data?.lines?.text || '').trim(), + type: task.type, + }); + } + } + } catch (error) { + // ripgrep返回exit code 1表示无匹配,这是正常的,继续处理 + const errorMessage = error instanceof Error ? error.message : String(error); + if (!errorMessage.includes('code=1')) { + // 非"无匹配"的错误才需要记录 + console.warn(`Search ${task.type} failed:`, errorMessage); + } + } + } + + // 去重(同一位置可能同时匹配调用和定义模式) + const uniqueRefs = new Map(); + for (const ref of allReferences) { + const key = `${ref.path}:${ref.line}`; + if (!uniqueRefs.has(key)) { + uniqueRefs.set(key, ref); + } else { + // 如果重复,优先保留definition类型 + const existing = uniqueRefs.get(key)!; + if (ref.type === 'definition' && existing.type === 'call') { + uniqueRefs.set(key, ref); + } + } + } + + const references = Array.from(uniqueRefs.values()).slice(0, max_results || 30); + + if (references.length === 0) { + return { + identifier, + references: [], + total: 0, + message: `未找到 ${identifier} 的引用`, + note: '这是基于正则模式的近似搜索,可能遗漏动态调用或同名符号', + }; + } + + // 统计 + const callCount = references.filter((r) => r.type === 'call').length; + const defCount = references.filter((r) => r.type === 'definition').length; + + return { + identifier, + references, + total: references.length, + statistics: { + calls: callCount, + definitions: defCount, + }, + summary: `找到 ${defCount} 个定义,${callCount} 个调用`, + note: '⚠️ 基于正则的近似搜索,可能包含字符串/注释中的匹配。建议查看实际代码确认。', + }; + }, + }; +} diff --git a/src/review/tools/registry.ts b/src/review/tools/registry.ts new file mode 100644 index 0000000..a129659 --- /dev/null +++ b/src/review/tools/registry.ts @@ -0,0 +1,52 @@ +import zodToJsonSchema from 'zod-to-json-schema'; +import type { JsonSchema7Type } from 'zod-to-json-schema'; +import { z } from 'zod'; +import { Tool } from './types'; + +export class ToolRegistry { + private tools = new Map(); + + register(tool: Tool): void { + this.tools.set(tool.name, tool); + } + + get(name: string): Tool | undefined { + return this.tools.get(name); + } + + getAll(): Tool[] { + return Array.from(this.tools.values()); + } + + // 转换为OpenAI Function定义 + toOpenAIFunctions() { + return this.getAll().map((tool) => ({ + type: 'function' as const, + function: { + name: tool.name, + description: tool.description, + parameters: this.zodToJsonSchema(tool.parameters), + }, + })); + } + + private zodToJsonSchema(schema: z.ZodTypeAny): JsonSchema7Type { + /** + * 使用zod-to-json-schema库转换Zod schema为JSON Schema。 + * + * 注意:该库v3.25.1使用了复杂的条件类型推断,会导致 TS2589 + * "Type instantiation is excessively deep" 错误。这是库的已知限制, + * 见 https://github.com/StefanTerdell/zod-to-json-schema/issues + * + * 类型安全保证: + * - 输入:z.ZodTypeAny 确保只接受Zod schema + * - 输出:JsonSchema7Type 明确返回类型 + * - 运行时行为:库本身经过充分测试,转换逻辑正确 + */ + // @ts-expect-error TS2589: zod-to-json-schema v3.25.1 的条件类型过于复杂 + return zodToJsonSchema(schema, { + target: 'openApi3', + $refStrategy: 'none', + }); + } +} diff --git a/src/review/tools/types.ts b/src/review/tools/types.ts new file mode 100644 index 0000000..8893f93 --- /dev/null +++ b/src/review/tools/types.ts @@ -0,0 +1,27 @@ +import { z } from 'zod'; + +export interface Tool { + name: string; + description: string; + parameters: z.ZodTypeAny; + execute: (params: any, context: ToolExecutionContext) => Promise; +} + +export interface ToolExecutionContext { + workspacePath: string; + mirrorPath: string; + runId: string; +} + +export interface ToolCall { + id: string; + toolName: string; + parameters: any; +} + +export interface ToolResult { + toolCallId: string; + success: boolean; + result?: any; + error?: string; +}