【阶段一 · 紧急修复 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)
267 lines
9.1 KiB
TypeScript
267 lines
9.1 KiB
TypeScript
/**
|
||
* 文件差异对比工具(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<string, unknown>, context: ToolExecutionContext): Promise<unknown> {
|
||
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) };
|
||
}
|
||
}
|
||
}
|