Dax Raad 9 months ago
parent
commit
0ad8738933
1 changed files with 420 additions and 0 deletions
  1. 420 0
      js/src/tool/patch.ts

+ 420 - 0
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<string, Change>;
+}
+
+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<string, string>,
+): [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<string, string>,
+): Commit {
+  const changes: Record<string, Change> = {};
+
+  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<void>,
+  deleteFile: (path: string) => Promise<void>,
+): Promise<void> {
+  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<string, string> = {};
+    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,
+    };
+  },
+});