From 131d23989ccd33be55f7fba1d38c83e835592b89 Mon Sep 17 00:00:00 2001 From: William Valentin Date: Sat, 7 Feb 2026 15:39:15 -0800 Subject: [PATCH] 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. --- src/tools/builtin/file-patch.test.ts | 197 +++++++++++++++++++++++++++ src/tools/builtin/file-patch.ts | 176 ++++++++++++++++++++++++ src/tools/builtin/index.ts | 3 + src/tools/policy.ts | 3 +- 4 files changed, 378 insertions(+), 1 deletion(-) create mode 100644 src/tools/builtin/file-patch.test.ts create mode 100644 src/tools/builtin/file-patch.ts diff --git a/src/tools/builtin/file-patch.test.ts b/src/tools/builtin/file-patch.test.ts new file mode 100644 index 0000000..093fa7b --- /dev/null +++ b/src/tools/builtin/file-patch.test.ts @@ -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'); + }); +}); diff --git a/src/tools/builtin/file-patch.ts b/src/tools/builtin/file-patch.ts new file mode 100644 index 0000000..2b1f4c8 --- /dev/null +++ b/src/tools/builtin/file-patch.ts @@ -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 => { + 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) }; + } + }, +}; diff --git a/src/tools/builtin/index.ts b/src/tools/builtin/index.ts index aecfa33..4065b8c 100644 --- a/src/tools/builtin/index.ts +++ b/src/tools/builtin/index.ts @@ -2,6 +2,7 @@ export { shellExecTool } from './shell.js'; export { fileReadTool } from './file-read.js'; export { fileWriteTool } from './file-write.js'; export { fileEditTool } from './file-edit.js'; +export { filePatchTool } from './file-patch.js'; export { fileListTool } from './file-list.js'; export { webFetchTool } from './web-fetch.js'; export { createMediaSendTool } from './media-send.js'; @@ -28,6 +29,7 @@ import { shellExecTool } from './shell.js'; import { fileReadTool } from './file-read.js'; import { fileWriteTool } from './file-write.js'; import { fileEditTool } from './file-edit.js'; +import { filePatchTool } from './file-patch.js'; import { fileListTool } from './file-list.js'; import { webFetchTool } from './web-fetch.js'; import { createMediaSendTool } from './media-send.js'; @@ -43,6 +45,7 @@ export const allBuiltinTools: Tool[] = [ fileReadTool, fileWriteTool, fileEditTool, + filePatchTool, fileListTool, webFetchTool, ]; diff --git a/src/tools/policy.ts b/src/tools/policy.ts index 16fef39..d7473b1 100644 --- a/src/tools/policy.ts +++ b/src/tools/policy.ts @@ -29,6 +29,7 @@ const PROFILE_TOOLS: Record> = { 'web.search', 'file.write', 'file.edit', + 'file.patch', 'shell.exec', 'process.start', 'process.status', @@ -49,7 +50,7 @@ const PROFILE_TOOLS: Record> = { /** Named groups for use in allow/deny lists (e.g. 'group:fs'). */ export const TOOL_GROUPS: Record = { - '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: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'],