/** * 文件差异对比工具(1 个) * * diff_viewer — 对比两个文件或两段文本的差异 * * 生成 unified diff 格式输出,支持行级差异检测。 * 用于 Agent 在编辑文件前后对比变化,或对比两个配置文件。 */ import { readFile } from 'fs/promises'; import type { IMetonaTool, ToolExecutionContext } from '../../types/metona-tool'; import type { MetonaToolDef } from '../../../harness/types'; import { MetonaToolCategory, MetonaRiskLevel } from '../../../harness/types'; import { safeResolvePath, extractErrorMessage, decodeBufferWithDetection, } from './file-guard'; interface DiffLine { type: 'context' | 'added' | 'removed'; oldLineNo: number | null; newLineNo: number | null; content: string; } /** 最长公共子序列(LCS)diff 算法 */ function computeDiff(oldLines: string[], newLines: string[]): DiffLine[] { // 构建 LCS 表(限制内存:大文件截断到 5000 行) const maxLines = 5000; const oldSliced = oldLines.slice(0, maxLines); const newSliced = newLines.slice(0, maxLines); const sm = oldSliced.length; const sn = newSliced.length; // F4-1: 使用 Uint16Array 一维数组替代 Uint32Array,节省一半内存 // LCS 长度最大值为 min(sm, sn) <= 5000,远小于 Uint16Array 上限 65535,安全 const lcs = new Uint16Array((sm + 1) * (sn + 1)); const idx = (i: number, j: number): number => i * (sn + 1) + j; for (let i = 1; i <= sm; i++) { for (let j = 1; j <= sn; j++) { if (oldSliced[i - 1] === newSliced[j - 1]) { lcs[idx(i, j)] = lcs[idx(i - 1, j - 1)] + 1; } else { lcs[idx(i, j)] = Math.max(lcs[idx(i - 1, j)], lcs[idx(i, j - 1)]); } } } // 回溯生成 diff const result: DiffLine[] = []; let i = sm, j = sn; while (i > 0 || j > 0) { if (i > 0 && j > 0 && oldSliced[i - 1] === newSliced[j - 1]) { result.unshift({ type: 'context', oldLineNo: i, newLineNo: j, content: oldSliced[i - 1] }); i--; j--; } else if (j > 0 && (i === 0 || lcs[idx(i, j - 1)] >= lcs[idx(i - 1, j)])) { result.unshift({ type: 'added', oldLineNo: null, newLineNo: j, content: newSliced[j - 1] }); j--; } else { result.unshift({ type: 'removed', oldLineNo: i, newLineNo: null, content: oldSliced[i - 1] }); i--; } } return result; } /** 生成 unified diff 格式字符串 */ function formatUnifiedDiff(diffLines: DiffLine[], oldLabel: string, newLabel: string, contextLines: number = 3): string { const lines: string[] = []; lines.push(`--- ${oldLabel}`); lines.push(`+++ ${newLabel}`); let oldLine = 1; let newLine = 1; let hunkLines: string[] = []; let hunkStartOld = 1; let hunkStartNew = 1; let inHunk = false; let contextSinceChange = 0; const flushHunk = () => { if (hunkLines.length > 0) { // 移除尾部多余的 context 行 const trimmed: string[] = [...hunkLines]; while (trimmed.length > 0 && trimmed[trimmed.length - 1].startsWith(' ') && contextSinceChange > 0) { trimmed.pop(); contextSinceChange--; } if (trimmed.length > 0) { const oldCount = trimmed.filter((l) => l.startsWith('-') || l.startsWith(' ')).length; const newCount = trimmed.filter((l) => l.startsWith('+') || l.startsWith(' ')).length; lines.push(`@@ -${hunkStartOld},${oldCount} +${hunkStartNew},${newCount} @@`); lines.push(...trimmed); } } hunkLines = []; inHunk = false; contextSinceChange = 0; }; for (const dl of diffLines) { if (dl.type === 'context') { if (inHunk) { hunkLines.push(` ${dl.content}`); contextSinceChange++; // 超过 contextLines 行连续 context,结束当前 hunk if (contextSinceChange > contextLines) { flushHunk(); } } oldLine++; newLine++; } else if (dl.type === 'added') { if (!inHunk) { inHunk = true; hunkStartOld = oldLine; hunkStartNew = newLine; } hunkLines.push(`+${dl.content}`); contextSinceChange = 0; newLine++; } else if (dl.type === 'removed') { if (!inHunk) { inHunk = true; hunkStartOld = oldLine; hunkStartNew = newLine; } hunkLines.push(`-${dl.content}`); contextSinceChange = 0; oldLine++; } } flushHunk(); return lines.join('\n'); } export class DiffViewerTool implements IMetonaTool { readonly definition: MetonaToolDef = { name: 'diff_viewer', description: 'Compare two files or two text snippets and show differences. Generates unified diff format output. Useful for reviewing changes before applying or comparing configurations.', parameters: { type: 'object', properties: { mode: { type: 'string', description: 'Comparison mode', enum: ['files', 'text'], }, file_a: { type: 'string', description: 'First file path (for files mode)' }, file_b: { type: 'string', description: 'Second file path (for files mode)' }, text_a: { type: 'string', description: 'First text content (for text mode)' }, text_b: { type: 'string', description: 'Second text content (for text mode)' }, context_lines: { type: 'number', description: 'Context lines around changes (default 3, max 10)' }, }, required: ['mode'], }, category: MetonaToolCategory.FILESYSTEM, riskLevel: MetonaRiskLevel.SAFE, requiresPermission: false, timeoutMs: 15_000, }; async execute(args: Record, context: ToolExecutionContext): Promise { try { const mode = args.mode as 'files' | 'text'; const contextLines = Math.min(10, Math.max(0, (args.context_lines as number) ?? 3)); // D4.5: 校验 mode if (mode !== 'files' && mode !== 'text') { return { success: false, error: `Invalid mode: ${mode}. Must be 'files' or 'text'` }; } let contentA: string; let contentB: string; let labelA: string; let labelB: string; if (mode === 'files') { const fileA = args.file_a as string; const fileB = args.file_b as string; if (!fileA || !fileB) { return { success: false, error: 'file_a and file_b are required for files mode' }; } // F4-1: 用 safeResolvePath 统一路径校验(含 isPathWithinWorkspace + MEMORY.md 拦截) let pathA: string; let pathB: string; try { pathA = safeResolvePath(fileA, context.workspacePath); pathB = safeResolvePath(fileB, context.workspacePath); } catch (error) { return { success: false, error: extractErrorMessage(error) }; } try { // F4-1: 用智能编码检测读取文件(支持 GBK/UTF-16 等非 UTF-8 编码) const bufferA = await readFile(pathA); const bufferB = await readFile(pathB); contentA = decodeBufferWithDetection(bufferA).content; contentB = decodeBufferWithDetection(bufferB).content; } catch (err) { return { success: false, error: `Failed to read files: ${extractErrorMessage(err)}` }; } labelA = fileA; labelB = fileB; } else { contentA = (args.text_a as string) ?? ''; contentB = (args.text_b as string) ?? ''; labelA = 'text_a'; labelB = 'text_b'; } const linesA = contentA.split('\n'); const linesB = contentB.split('\n'); const diff = computeDiff(linesA, linesB); const unifiedDiff = formatUnifiedDiff(diff, labelA, labelB, contextLines); const addedCount = diff.filter((d) => d.type === 'added').length; const removedCount = diff.filter((d) => d.type === 'removed').length; const contextCount = diff.filter((d) => d.type === 'context').length; // D4.3: similarity 计算使用截断后的长度作为分母 const maxLines = 5000; const oldSlicedLen = Math.min(linesA.length, maxLines); const newSlicedLen = Math.min(linesB.length, maxLines); const oldTruncated = linesA.length > maxLines; const newTruncated = linesB.length > maxLines; const truncated = oldTruncated || newTruncated; // 生成摘要 const summary = { files_compared: mode === 'files' ? 2 : 0, lines_added: addedCount, lines_removed: removedCount, lines_unchanged: contextCount, total_changes: addedCount + removedCount, similarity: (oldSlicedLen + newSlicedLen) > 0 ? Math.round((contextCount * 2 / (oldSlicedLen + newSlicedLen)) * 100) / 100 : 1, truncated, }; // D4.6: unifiedDiff 大小限制 const MAX_DIFF_CHARS = 50_000; const truncatedDiff = unifiedDiff.length > MAX_DIFF_CHARS ? unifiedDiff.slice(0, MAX_DIFF_CHARS) + '\n... (diff truncated)' : unifiedDiff; return { success: true, summary, diff: truncatedDiff, diff_lines: diff.slice(0, 500), }; } catch (err) { // F4-2: 统一错误处理,避免 non-Error 抛出值被 String 强转丢失信息 return { success: false, error: extractErrorMessage(err) }; } } }