feat: add file.patch tool for multi-hunk structured patches
Implements apply_patch equivalent: a single tool call can make multiple line-based edits (replacements, insertions, deletions) across one or more files. Hunks are applied bottom-up to preserve line numbers. Includes 10 tests covering replacement, multi-hunk, insertion, deletion, multi-file, overlapping hunks error, OOB error, and edge cases.
This commit is contained in:
@@ -0,0 +1,197 @@
|
|||||||
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||||
|
import { filePatchTool } from './file-patch.js';
|
||||||
|
import { writeFileSync, readFileSync, mkdtempSync, rmSync } from 'fs';
|
||||||
|
import { join } from 'path';
|
||||||
|
import { tmpdir } from 'os';
|
||||||
|
|
||||||
|
let testDir: string;
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
testDir = mkdtempSync(join(tmpdir(), 'flynn-patch-test-'));
|
||||||
|
});
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
rmSync(testDir, { recursive: true, force: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
describe('file.patch tool', () => {
|
||||||
|
it('replaces lines in a single hunk', async () => {
|
||||||
|
const filePath = join(testDir, 'replace.txt');
|
||||||
|
writeFileSync(filePath, 'line1\nline2\nline3\nline4\nline5');
|
||||||
|
|
||||||
|
const result = await filePatchTool.execute({
|
||||||
|
patches: [
|
||||||
|
{
|
||||||
|
path: filePath,
|
||||||
|
hunks: [{ start_line: 2, end_line: 3, content: 'new2\nnew3' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.output).toBe('Applied 1 hunk(s) to 1 file(s)');
|
||||||
|
expect(readFileSync(filePath, 'utf-8')).toBe('line1\nnew2\nnew3\nline4\nline5');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies multiple hunks to a single file', async () => {
|
||||||
|
const filePath = join(testDir, 'multi-hunk.txt');
|
||||||
|
writeFileSync(filePath, 'line1\nline2\nline3\nline4\nline5');
|
||||||
|
|
||||||
|
const result = await filePatchTool.execute({
|
||||||
|
patches: [
|
||||||
|
{
|
||||||
|
path: filePath,
|
||||||
|
hunks: [
|
||||||
|
{ start_line: 1, end_line: 1, content: 'FIRST' },
|
||||||
|
{ start_line: 5, end_line: 5, content: 'LAST' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.output).toBe('Applied 2 hunk(s) to 1 file(s)');
|
||||||
|
expect(readFileSync(filePath, 'utf-8')).toBe('FIRST\nline2\nline3\nline4\nLAST');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('inserts lines when end_line < start_line', async () => {
|
||||||
|
const filePath = join(testDir, 'insert.txt');
|
||||||
|
writeFileSync(filePath, 'line1\nline2\nline3\nline4');
|
||||||
|
|
||||||
|
const result = await filePatchTool.execute({
|
||||||
|
patches: [
|
||||||
|
{
|
||||||
|
path: filePath,
|
||||||
|
hunks: [{ start_line: 3, end_line: 2, content: 'inserted' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(readFileSync(filePath, 'utf-8')).toBe('line1\nline2\ninserted\nline3\nline4');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('deletes lines when content is empty', async () => {
|
||||||
|
const filePath = join(testDir, 'delete.txt');
|
||||||
|
writeFileSync(filePath, 'line1\nline2\nline3\nline4\nline5');
|
||||||
|
|
||||||
|
const result = await filePatchTool.execute({
|
||||||
|
patches: [
|
||||||
|
{
|
||||||
|
path: filePath,
|
||||||
|
hunks: [{ start_line: 2, end_line: 3, content: '' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(readFileSync(filePath, 'utf-8')).toBe('line1\nline4\nline5');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('patches multiple files in one call', async () => {
|
||||||
|
const file1 = join(testDir, 'file1.txt');
|
||||||
|
const file2 = join(testDir, 'file2.txt');
|
||||||
|
writeFileSync(file1, 'aaa\nbbb\nccc');
|
||||||
|
writeFileSync(file2, 'xxx\nyyy\nzzz');
|
||||||
|
|
||||||
|
const result = await filePatchTool.execute({
|
||||||
|
patches: [
|
||||||
|
{
|
||||||
|
path: file1,
|
||||||
|
hunks: [{ start_line: 2, end_line: 2, content: 'BBB' }],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: file2,
|
||||||
|
hunks: [{ start_line: 1, end_line: 1, content: 'XXX' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.output).toBe('Applied 2 hunk(s) to 2 file(s)');
|
||||||
|
expect(readFileSync(file1, 'utf-8')).toBe('aaa\nBBB\nccc');
|
||||||
|
expect(readFileSync(file2, 'utf-8')).toBe('XXX\nyyy\nzzz');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails on overlapping hunks', async () => {
|
||||||
|
const filePath = join(testDir, 'overlap.txt');
|
||||||
|
writeFileSync(filePath, 'line1\nline2\nline3\nline4\nline5');
|
||||||
|
|
||||||
|
const result = await filePatchTool.execute({
|
||||||
|
patches: [
|
||||||
|
{
|
||||||
|
path: filePath,
|
||||||
|
hunks: [
|
||||||
|
{ start_line: 2, end_line: 4, content: 'a' },
|
||||||
|
{ start_line: 3, end_line: 5, content: 'b' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toContain('Overlapping hunks');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails on out of bounds line numbers', async () => {
|
||||||
|
const filePath = join(testDir, 'oob.txt');
|
||||||
|
writeFileSync(filePath, 'line1\nline2\nline3');
|
||||||
|
|
||||||
|
const result = await filePatchTool.execute({
|
||||||
|
patches: [
|
||||||
|
{
|
||||||
|
path: filePath,
|
||||||
|
hunks: [{ start_line: 10, end_line: 10, content: 'nope' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toContain('out of bounds');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails on file not found', async () => {
|
||||||
|
const result = await filePatchTool.execute({
|
||||||
|
patches: [
|
||||||
|
{
|
||||||
|
path: join(testDir, 'nonexistent.txt'),
|
||||||
|
hunks: [{ start_line: 1, end_line: 1, content: 'nope' }],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fails on empty patches array', async () => {
|
||||||
|
const result = await filePatchTool.execute({ patches: [] });
|
||||||
|
|
||||||
|
expect(result.success).toBe(false);
|
||||||
|
expect(result.error).toContain('empty');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('applies hunks bottom-up preserving line numbers', async () => {
|
||||||
|
const filePath = join(testDir, 'bottomup.txt');
|
||||||
|
writeFileSync(filePath, 'line1\nline2\nline3\nline4\nline5');
|
||||||
|
|
||||||
|
// Replace line 2 with two lines (expanding the file) and replace line 4
|
||||||
|
// If hunks were applied top-down, the line 4 replacement would target the wrong line
|
||||||
|
const result = await filePatchTool.execute({
|
||||||
|
patches: [
|
||||||
|
{
|
||||||
|
path: filePath,
|
||||||
|
hunks: [
|
||||||
|
{ start_line: 2, end_line: 2, content: 'new2a\nnew2b' },
|
||||||
|
{ start_line: 4, end_line: 4, content: 'new4' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(result.success).toBe(true);
|
||||||
|
// Line 4 was replaced first (bottom-up), so it correctly targets original line 4
|
||||||
|
// Then line 2 is replaced, expanding to two lines
|
||||||
|
expect(readFileSync(filePath, 'utf-8')).toBe('line1\nnew2a\nnew2b\nline3\nnew4\nline5');
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
import { readFileSync, writeFileSync } from 'fs';
|
||||||
|
import type { Tool, ToolResult } from '../types.js';
|
||||||
|
|
||||||
|
interface Hunk {
|
||||||
|
start_line: number;
|
||||||
|
end_line: number;
|
||||||
|
content: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FilePatch {
|
||||||
|
path: string;
|
||||||
|
hunks: Hunk[];
|
||||||
|
}
|
||||||
|
|
||||||
|
interface FilePatchArgs {
|
||||||
|
patches: FilePatch[];
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check whether two hunks overlap.
|
||||||
|
* A hunk with end_line < start_line is a pure insertion (zero-width),
|
||||||
|
* so it only overlaps if another hunk covers the same insertion point.
|
||||||
|
*/
|
||||||
|
function hunksOverlap(a: Hunk, b: Hunk): boolean {
|
||||||
|
// For insertion hunks (end < start), treat them as a zero-width point at start_line
|
||||||
|
const aStart = a.start_line;
|
||||||
|
const aEnd = a.end_line < a.start_line ? a.start_line - 1 : a.end_line;
|
||||||
|
const bStart = b.start_line;
|
||||||
|
const bEnd = b.end_line < b.start_line ? b.start_line - 1 : b.end_line;
|
||||||
|
|
||||||
|
// Two ranges [aStart, aEnd] and [bStart, bEnd] overlap if aStart <= bEnd && bStart <= aEnd
|
||||||
|
return aStart <= bEnd && bStart <= aEnd;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const filePatchTool: Tool = {
|
||||||
|
name: 'file.patch',
|
||||||
|
description:
|
||||||
|
'Apply structured multi-hunk patches to one or more files. Each patch specifies a file path and an array of hunks. Hunks are applied in reverse line order to preserve line numbers.',
|
||||||
|
inputSchema: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
patches: {
|
||||||
|
type: 'array',
|
||||||
|
description: 'Array of file patches to apply',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
path: { type: 'string', description: 'Absolute path to the file' },
|
||||||
|
hunks: {
|
||||||
|
type: 'array',
|
||||||
|
description: 'Array of hunks to apply to this file',
|
||||||
|
items: {
|
||||||
|
type: 'object',
|
||||||
|
properties: {
|
||||||
|
start_line: { type: 'number', description: 'First line to replace (1-based, inclusive)' },
|
||||||
|
end_line: {
|
||||||
|
type: 'number',
|
||||||
|
description:
|
||||||
|
'Last line to replace (1-based, inclusive). Use start_line - 1 for pure insertion before start_line.',
|
||||||
|
},
|
||||||
|
content: { type: 'string', description: 'Replacement content (empty string to delete lines)' },
|
||||||
|
},
|
||||||
|
required: ['start_line', 'end_line', 'content'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['path', 'hunks'],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
required: ['patches'],
|
||||||
|
},
|
||||||
|
|
||||||
|
execute: async (rawArgs: unknown): Promise<ToolResult> => {
|
||||||
|
const args = rawArgs as FilePatchArgs;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Validate that patches array is not empty
|
||||||
|
if (!args.patches || args.patches.length === 0) {
|
||||||
|
return { success: false, output: '', error: 'Patches array must not be empty' };
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalHunks = 0;
|
||||||
|
|
||||||
|
for (const patch of args.patches) {
|
||||||
|
// Read the file
|
||||||
|
const content = readFileSync(patch.path, 'utf-8');
|
||||||
|
const lines = content.split('\n');
|
||||||
|
const lineCount = lines.length;
|
||||||
|
|
||||||
|
if (!patch.hunks || patch.hunks.length === 0) {
|
||||||
|
return { success: false, output: '', error: `No hunks provided for ${patch.path}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort hunks by start_line descending (apply from bottom to top)
|
||||||
|
const sortedHunks = [...patch.hunks].sort((a, b) => b.start_line - a.start_line);
|
||||||
|
|
||||||
|
// Validate: check for overlapping hunks
|
||||||
|
for (let i = 0; i < sortedHunks.length; i++) {
|
||||||
|
for (let j = i + 1; j < sortedHunks.length; j++) {
|
||||||
|
if (hunksOverlap(sortedHunks[i], sortedHunks[j])) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
output: '',
|
||||||
|
error: `Overlapping hunks in ${patch.path}: lines ${sortedHunks[j].start_line}-${sortedHunks[j].end_line} and ${sortedHunks[i].start_line}-${sortedHunks[i].end_line}`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Validate line numbers are within bounds
|
||||||
|
for (const hunk of sortedHunks) {
|
||||||
|
const isInsertion = hunk.end_line < hunk.start_line;
|
||||||
|
|
||||||
|
if (isInsertion) {
|
||||||
|
// For insertions, start_line must be between 1 and lineCount + 1
|
||||||
|
// (inserting after the last line is valid)
|
||||||
|
if (hunk.start_line < 1 || hunk.start_line > lineCount + 1) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
output: '',
|
||||||
|
error: `Line number out of bounds in ${patch.path}: start_line ${hunk.start_line} (file has ${lineCount} lines)`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// For replacement/deletion, both start and end must be within [1, lineCount]
|
||||||
|
if (hunk.start_line < 1 || hunk.start_line > lineCount) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
output: '',
|
||||||
|
error: `Line number out of bounds in ${patch.path}: start_line ${hunk.start_line} (file has ${lineCount} lines)`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (hunk.end_line < 1 || hunk.end_line > lineCount) {
|
||||||
|
return {
|
||||||
|
success: false,
|
||||||
|
output: '',
|
||||||
|
error: `Line number out of bounds in ${patch.path}: end_line ${hunk.end_line} (file has ${lineCount} lines)`,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply hunks in reverse order (bottom to top)
|
||||||
|
for (const hunk of sortedHunks) {
|
||||||
|
const isInsertion = hunk.end_line < hunk.start_line;
|
||||||
|
const isDeletion = !isInsertion && hunk.content === '';
|
||||||
|
|
||||||
|
if (isInsertion) {
|
||||||
|
// Insert new content lines at position start_line - 1
|
||||||
|
const newLines = hunk.content.split('\n');
|
||||||
|
lines.splice(hunk.start_line - 1, 0, ...newLines);
|
||||||
|
} else if (isDeletion) {
|
||||||
|
// Delete lines from start_line to end_line
|
||||||
|
lines.splice(hunk.start_line - 1, hunk.end_line - hunk.start_line + 1);
|
||||||
|
} else {
|
||||||
|
// Replacement: splice out old lines and insert new content lines
|
||||||
|
const newLines = hunk.content.split('\n');
|
||||||
|
lines.splice(hunk.start_line - 1, hunk.end_line - hunk.start_line + 1, ...newLines);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Write back
|
||||||
|
writeFileSync(patch.path, lines.join('\n'), 'utf-8');
|
||||||
|
totalHunks += patch.hunks.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
output: `Applied ${totalHunks} hunk(s) to ${args.patches.length} file(s)`,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, output: '', error: error instanceof Error ? error.message : String(error) };
|
||||||
|
}
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -2,6 +2,7 @@ export { shellExecTool } from './shell.js';
|
|||||||
export { fileReadTool } from './file-read.js';
|
export { fileReadTool } from './file-read.js';
|
||||||
export { fileWriteTool } from './file-write.js';
|
export { fileWriteTool } from './file-write.js';
|
||||||
export { fileEditTool } from './file-edit.js';
|
export { fileEditTool } from './file-edit.js';
|
||||||
|
export { filePatchTool } from './file-patch.js';
|
||||||
export { fileListTool } from './file-list.js';
|
export { fileListTool } from './file-list.js';
|
||||||
export { webFetchTool } from './web-fetch.js';
|
export { webFetchTool } from './web-fetch.js';
|
||||||
export { createMediaSendTool } from './media-send.js';
|
export { createMediaSendTool } from './media-send.js';
|
||||||
@@ -28,6 +29,7 @@ import { shellExecTool } from './shell.js';
|
|||||||
import { fileReadTool } from './file-read.js';
|
import { fileReadTool } from './file-read.js';
|
||||||
import { fileWriteTool } from './file-write.js';
|
import { fileWriteTool } from './file-write.js';
|
||||||
import { fileEditTool } from './file-edit.js';
|
import { fileEditTool } from './file-edit.js';
|
||||||
|
import { filePatchTool } from './file-patch.js';
|
||||||
import { fileListTool } from './file-list.js';
|
import { fileListTool } from './file-list.js';
|
||||||
import { webFetchTool } from './web-fetch.js';
|
import { webFetchTool } from './web-fetch.js';
|
||||||
import { createMediaSendTool } from './media-send.js';
|
import { createMediaSendTool } from './media-send.js';
|
||||||
@@ -43,6 +45,7 @@ export const allBuiltinTools: Tool[] = [
|
|||||||
fileReadTool,
|
fileReadTool,
|
||||||
fileWriteTool,
|
fileWriteTool,
|
||||||
fileEditTool,
|
fileEditTool,
|
||||||
|
filePatchTool,
|
||||||
fileListTool,
|
fileListTool,
|
||||||
webFetchTool,
|
webFetchTool,
|
||||||
];
|
];
|
||||||
|
|||||||
+2
-1
@@ -29,6 +29,7 @@ const PROFILE_TOOLS: Record<ToolProfile, Set<string>> = {
|
|||||||
'web.search',
|
'web.search',
|
||||||
'file.write',
|
'file.write',
|
||||||
'file.edit',
|
'file.edit',
|
||||||
|
'file.patch',
|
||||||
'shell.exec',
|
'shell.exec',
|
||||||
'process.start',
|
'process.start',
|
||||||
'process.status',
|
'process.status',
|
||||||
@@ -49,7 +50,7 @@ const PROFILE_TOOLS: Record<ToolProfile, Set<string>> = {
|
|||||||
|
|
||||||
/** Named groups for use in allow/deny lists (e.g. 'group:fs'). */
|
/** Named groups for use in allow/deny lists (e.g. 'group:fs'). */
|
||||||
export const TOOL_GROUPS: Record<string, string[]> = {
|
export const TOOL_GROUPS: Record<string, string[]> = {
|
||||||
'group:fs': ['file.read', 'file.write', 'file.edit', 'file.list'],
|
'group:fs': ['file.read', 'file.write', 'file.edit', 'file.patch', 'file.list'],
|
||||||
'group:runtime': ['shell.exec', 'process.start', 'process.output', 'process.status', 'process.kill', 'process.list'],
|
'group:runtime': ['shell.exec', 'process.start', 'process.output', 'process.status', 'process.kill', 'process.list'],
|
||||||
'group:web': ['web.fetch', 'web.search', 'browser.navigate', 'browser.screenshot', 'browser.click', 'browser.type', 'browser.content', 'browser.eval'],
|
'group:web': ['web.fetch', 'web.search', 'browser.navigate', 'browser.screenshot', 'browser.click', 'browser.type', 'browser.content', 'browser.eval'],
|
||||||
'group:memory': ['memory.read', 'memory.write', 'memory.search'],
|
'group:memory': ['memory.read', 'memory.write', 'memory.search'],
|
||||||
|
|||||||
Reference in New Issue
Block a user