From 0ad87389331c4b18bcecec74476853a2e16a8d10 Mon Sep 17 00:00:00 2001 From: Dax Raad Date: Mon, 26 May 2025 15:08:31 -0400 Subject: [PATCH] sync --- js/src/tool/patch.ts | 420 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 420 insertions(+) create mode 100644 js/src/tool/patch.ts diff --git a/js/src/tool/patch.ts b/js/src/tool/patch.ts new file mode 100644 index 00000000..9a02757d --- /dev/null +++ b/js/src/tool/patch.ts @@ -0,0 +1,420 @@ +import { z } from "zod"; +import * as path from "path"; +import * as fs from "fs/promises"; +import { Tool } from "./tool"; +import { FileTimes } from "./util/file-times"; + +const DESCRIPTION = `Applies a patch to multiple files in one operation. This tool is useful for making coordinated changes across multiple files. + +The patch text must follow this format: +*** Begin Patch +*** Update File: /path/to/file +@@ Context line (unique within the file) + Line to keep +-Line to remove ++Line to add + Line to keep +*** Add File: /path/to/new/file ++Content of the new file ++More content +*** Delete File: /path/to/file/to/delete +*** End Patch + +Before using this tool: +1. Use the FileRead tool to understand the files' contents and context +2. Verify all file paths are correct (use the LS tool) + +CRITICAL REQUIREMENTS FOR USING THIS TOOL: + +1. UNIQUENESS: Context lines MUST uniquely identify the specific sections you want to change +2. PRECISION: All whitespace, indentation, and surrounding code must match exactly +3. VALIDATION: Ensure edits result in idiomatic, correct code +4. PATHS: Always use absolute file paths (starting with /) + +The tool will apply all changes in a single atomic operation.`; + +const PatchParams = z.object({ + patch_text: z + .string() + .describe("The full patch text that describes all changes to be made"), +}); + +interface PatchResponseMetadata { + changed: string[]; + additions: number; + removals: number; +} + +interface Change { + type: "add" | "update" | "delete"; + old_content?: string; + new_content?: string; +} + +interface Commit { + changes: Record; +} + +interface PatchOperation { + type: "update" | "add" | "delete"; + filePath: string; + hunks?: PatchHunk[]; + content?: string; +} + +interface PatchHunk { + contextLine: string; + changes: PatchChange[]; +} + +interface PatchChange { + type: "keep" | "remove" | "add"; + content: string; +} + +function identifyFilesNeeded(patchText: string): string[] { + const files: string[] = []; + const lines = patchText.split("\n"); + for (const line of lines) { + if ( + line.startsWith("*** Update File:") || + line.startsWith("*** Delete File:") + ) { + const filePath = line.split(":", 2)[1]?.trim(); + if (filePath) files.push(filePath); + } + } + return files; +} + +function identifyFilesAdded(patchText: string): string[] { + const files: string[] = []; + const lines = patchText.split("\n"); + for (const line of lines) { + if (line.startsWith("*** Add File:")) { + const filePath = line.split(":", 2)[1]?.trim(); + if (filePath) files.push(filePath); + } + } + return files; +} + +function textToPatch( + patchText: string, + _currentFiles: Record, +): [PatchOperation[], number] { + const operations: PatchOperation[] = []; + const lines = patchText.split("\n"); + let i = 0; + let fuzz = 0; + + while (i < lines.length) { + const line = lines[i]; + + if (line.startsWith("*** Update File:")) { + const filePath = line.split(":", 2)[1]?.trim(); + if (!filePath) { + i++; + continue; + } + + const hunks: PatchHunk[] = []; + i++; + + while (i < lines.length && !lines[i].startsWith("***")) { + if (lines[i].startsWith("@@")) { + const contextLine = lines[i].substring(2).trim(); + const changes: PatchChange[] = []; + i++; + + while ( + i < lines.length && + !lines[i].startsWith("@@") && + !lines[i].startsWith("***") + ) { + const changeLine = lines[i]; + if (changeLine.startsWith(" ")) { + changes.push({ type: "keep", content: changeLine.substring(1) }); + } else if (changeLine.startsWith("-")) { + changes.push({ + type: "remove", + content: changeLine.substring(1), + }); + } else if (changeLine.startsWith("+")) { + changes.push({ type: "add", content: changeLine.substring(1) }); + } + i++; + } + + hunks.push({ contextLine, changes }); + } else { + i++; + } + } + + operations.push({ type: "update", filePath, hunks }); + } else if (line.startsWith("*** Add File:")) { + const filePath = line.split(":", 2)[1]?.trim(); + if (!filePath) { + i++; + continue; + } + + let content = ""; + i++; + + while (i < lines.length && !lines[i].startsWith("***")) { + if (lines[i].startsWith("+")) { + content += lines[i].substring(1) + "\n"; + } + i++; + } + + operations.push({ type: "add", filePath, content: content.slice(0, -1) }); + } else if (line.startsWith("*** Delete File:")) { + const filePath = line.split(":", 2)[1]?.trim(); + if (filePath) { + operations.push({ type: "delete", filePath }); + } + i++; + } else { + i++; + } + } + + return [operations, fuzz]; +} + +function patchToCommit( + operations: PatchOperation[], + currentFiles: Record, +): Commit { + const changes: Record = {}; + + for (const op of operations) { + if (op.type === "delete") { + changes[op.filePath] = { + type: "delete", + old_content: currentFiles[op.filePath] || "", + }; + } else if (op.type === "add") { + changes[op.filePath] = { + type: "add", + new_content: op.content || "", + }; + } else if (op.type === "update" && op.hunks) { + const originalContent = currentFiles[op.filePath] || ""; + const lines = originalContent.split("\n"); + + for (const hunk of op.hunks) { + const contextIndex = lines.findIndex((line) => + line.includes(hunk.contextLine), + ); + if (contextIndex === -1) { + throw new Error(`Context line not found: ${hunk.contextLine}`); + } + + let currentIndex = contextIndex; + for (const change of hunk.changes) { + if (change.type === "keep") { + currentIndex++; + } else if (change.type === "remove") { + lines.splice(currentIndex, 1); + } else if (change.type === "add") { + lines.splice(currentIndex, 0, change.content); + currentIndex++; + } + } + } + + changes[op.filePath] = { + type: "update", + old_content: originalContent, + new_content: lines.join("\n"), + }; + } + } + + return { changes }; +} + +function generateDiff( + oldContent: string, + newContent: string, + filePath: string, +): [string, number, number] { + // Mock implementation - would need actual diff generation + const lines1 = oldContent.split("\n"); + const lines2 = newContent.split("\n"); + const additions = Math.max(0, lines2.length - lines1.length); + const removals = Math.max(0, lines1.length - lines2.length); + return [`--- ${filePath}\n+++ ${filePath}\n`, additions, removals]; +} + +async function applyCommit( + commit: Commit, + writeFile: (path: string, content: string) => Promise, + deleteFile: (path: string) => Promise, +): Promise { + for (const [filePath, change] of Object.entries(commit.changes)) { + if (change.type === "delete") { + await deleteFile(filePath); + } else if (change.new_content !== undefined) { + await writeFile(filePath, change.new_content); + } + } +} + +export const patch = Tool.define({ + name: "patch", + description: DESCRIPTION, + parameters: PatchParams, + execute: async ({ patch_text }) => { + if (!patch_text) { + throw new Error("patch_text is required"); + } + + // Identify all files needed for the patch and verify they've been read + const filesToRead = identifyFilesNeeded(patch_text); + for (const filePath of filesToRead) { + let absPath = filePath; + if (!path.isAbsolute(absPath)) { + absPath = path.resolve(process.cwd(), absPath); + } + + if (!FileTimes.get(absPath)) { + throw new Error( + `you must read the file ${filePath} before patching it. Use the FileRead tool first`, + ); + } + + try { + const stats = await fs.stat(absPath); + if (stats.isDirectory()) { + throw new Error(`path is a directory, not a file: ${absPath}`); + } + + const lastRead = FileTimes.get(absPath); + if (lastRead && stats.mtime > lastRead) { + throw new Error( + `file ${absPath} has been modified since it was last read (mod time: ${stats.mtime.toISOString()}, last read: ${lastRead.toISOString()})`, + ); + } + } catch (error: any) { + if (error.code === "ENOENT") { + throw new Error(`file not found: ${absPath}`); + } + throw new Error(`failed to access file: ${error.message}`); + } + } + + // Check for new files to ensure they don't already exist + const filesToAdd = identifyFilesAdded(patch_text); + for (const filePath of filesToAdd) { + let absPath = filePath; + if (!path.isAbsolute(absPath)) { + absPath = path.resolve(process.cwd(), absPath); + } + + try { + await fs.stat(absPath); + throw new Error(`file already exists and cannot be added: ${absPath}`); + } catch (error: any) { + if (error.code !== "ENOENT") { + throw new Error(`failed to check file: ${error.message}`); + } + } + } + + // Load all required files + const currentFiles: Record = {}; + for (const filePath of filesToRead) { + let absPath = filePath; + if (!path.isAbsolute(absPath)) { + absPath = path.resolve(process.cwd(), absPath); + } + + try { + const content = await fs.readFile(absPath, "utf-8"); + currentFiles[filePath] = content; + } catch (error: any) { + throw new Error(`failed to read file ${absPath}: ${error.message}`); + } + } + + // Process the patch + const [patch, fuzz] = textToPatch(patch_text, currentFiles); + if (fuzz > 3) { + throw new Error( + `patch contains fuzzy matches (fuzz level: ${fuzz}). Please make your context lines more precise`, + ); + } + + // Convert patch to commit + const commit = patchToCommit(patch, currentFiles); + + // Apply the changes to the filesystem + await applyCommit( + commit, + async (filePath: string, content: string) => { + let absPath = filePath; + if (!path.isAbsolute(absPath)) { + absPath = path.resolve(process.cwd(), absPath); + } + + // Create parent directories if needed + const dir = path.dirname(absPath); + await fs.mkdir(dir, { recursive: true }); + await fs.writeFile(absPath, content, "utf-8"); + }, + async (filePath: string) => { + let absPath = filePath; + if (!path.isAbsolute(absPath)) { + absPath = path.resolve(process.cwd(), absPath); + } + await fs.unlink(absPath); + }, + ); + + // Calculate statistics + const changedFiles: string[] = []; + let totalAdditions = 0; + let totalRemovals = 0; + + for (const [filePath, change] of Object.entries(commit.changes)) { + let absPath = filePath; + if (!path.isAbsolute(absPath)) { + absPath = path.resolve(process.cwd(), absPath); + } + changedFiles.push(absPath); + + const oldContent = change.old_content || ""; + const newContent = change.new_content || ""; + + // Calculate diff statistics + const [, additions, removals] = generateDiff( + oldContent, + newContent, + filePath, + ); + totalAdditions += additions; + totalRemovals += removals; + + // Record file operations + FileTimes.write(absPath); + FileTimes.read(absPath); + } + + const result = `Patch applied successfully. ${changedFiles.length} files changed, ${totalAdditions} additions, ${totalRemovals} removals`; + const output = result; + + return { + metadata: { + changed: changedFiles, + additions: totalAdditions, + removals: totalRemovals, + } satisfies PatchResponseMetadata, + output, + }; + }, +});