Browse Source

Preserve worktree-safe apply_patch absolute paths (#278)

The previous absolute-path normalization fixed the reported failure for absolute paths inside the current root, but it also rejected absolute paths inside the same worktree when the session root was a subdirectory.

Keep the useful normalization behavior while preserving the existing root-or-worktree boundary model. Safe absolute paths are guarded first, then normalized to relative patch paths with portable separators.

Constraint: OpenCode treats Instance.directory OR Instance.worktree as the project boundary

Rejected: Keep PR #277's root-only normalization check | too restrictive for monorepos and subdirectory sessions

Confidence: high

Scope-risk: narrow

Tested: bunx biome check --write src/hooks/apply-patch

Tested: bun test src/hooks/apply-patch

Tested: bun run typecheck

Tested: bun run check:ci

Tested: git diff --check

Tested: bun test

Not-tested: Manual OpenCode TUI session

Related: https://github.com/alvinunreal/oh-my-opencode-slim/issues/276

Related: https://github.com/alvinunreal/oh-my-opencode-slim/pull/277
Raxxoor 1 day ago
parent
commit
fd1c20cb93

+ 11 - 11
src/hooks/apply-patch/codec.test.ts

@@ -10,7 +10,7 @@ import {
 import type { ParsedPatch } from './types';
 import type { ParsedPatch } from './types';
 
 
 describe('apply-patch/codec', () => {
 describe('apply-patch/codec', () => {
-  test('stripHeredoc extrae el contenido real del patch', () => {
+  test('stripHeredoc extracts the real patch content', () => {
     expect(
     expect(
       stripHeredoc(`cat <<'PATCH'
       stripHeredoc(`cat <<'PATCH'
 *** Begin Patch
 *** Begin Patch
@@ -19,7 +19,7 @@ PATCH`),
     ).toBe('*** Begin Patch\n*** End Patch');
     ).toBe('*** Begin Patch\n*** End Patch');
   });
   });
 
 
-  test('parsePatch reconoce add delete update y move', () => {
+  test('parsePatch recognizes add delete update and move', () => {
     const parsed = parsePatch(`*** Begin Patch
     const parsed = parsePatch(`*** Begin Patch
 *** Add File: added.txt
 *** Add File: added.txt
 +alpha
 +alpha
@@ -55,7 +55,7 @@ PATCH`),
     });
     });
   });
   });
 
 
-  test('parsePatch tolera heredoc con CRLF agresivo y conserva EOF', () => {
+  test('parsePatch tolerates heredocs with aggressive CRLF and preserves EOF', () => {
     const parsed = parsePatch(`cat <<'PATCH'\r
     const parsed = parsePatch(`cat <<'PATCH'\r
 *** Begin Patch\r
 *** Begin Patch\r
 *** Update File: sample.txt\r
 *** Update File: sample.txt\r
@@ -82,7 +82,7 @@ PATCH`);
     ]);
     ]);
   });
   });
 
 
-  test('parsePatchStrict falla con basura dentro de @@', () => {
+  test('parsePatchStrict fails on garbage inside @@', () => {
     expect(() =>
     expect(() =>
       parsePatchStrict(`*** Begin Patch
       parsePatchStrict(`*** Begin Patch
 *** Update File: sample.txt
 *** Update File: sample.txt
@@ -94,7 +94,7 @@ garbage
     ).toThrow('unexpected line in patch chunk');
     ).toThrow('unexpected line in patch chunk');
   });
   });
 
 
-  test('parsePatchStrict falla con basura dentro de Add File', () => {
+  test('parsePatchStrict fails on garbage inside Add File', () => {
     expect(() =>
     expect(() =>
       parsePatchStrict(`*** Begin Patch
       parsePatchStrict(`*** Begin Patch
 *** Add File: sample.txt
 *** Add File: sample.txt
@@ -104,7 +104,7 @@ garbage
     ).toThrow('unexpected line in Add File body');
     ).toThrow('unexpected line in Add File body');
   });
   });
 
 
-  test('parsePatchStrict falla con Delete File mal formado', () => {
+  test('parsePatchStrict fails on malformed Delete File', () => {
     expect(() =>
     expect(() =>
       parsePatchStrict(`*** Begin Patch
       parsePatchStrict(`*** Begin Patch
 *** Delete File: sample.txt
 *** Delete File: sample.txt
@@ -113,7 +113,7 @@ garbage
     ).toThrow('unexpected line between hunks');
     ).toThrow('unexpected line between hunks');
   });
   });
 
 
-  test('parsePatchStrict falla con basura después de End Patch', () => {
+  test('parsePatchStrict fails on garbage after End Patch', () => {
     expect(() =>
     expect(() =>
       parsePatchStrict(`*** Begin Patch
       parsePatchStrict(`*** Begin Patch
 *** Delete File: sample.txt
 *** Delete File: sample.txt
@@ -122,7 +122,7 @@ garbage`),
     ).toThrow('unexpected line after End Patch');
     ).toThrow('unexpected line after End Patch');
   });
   });
 
 
-  test('parsePatchStrict falla si Update File no trae chunks @@', () => {
+  test('parsePatchStrict fails when Update File has no @@ chunks', () => {
     expect(() =>
     expect(() =>
       parsePatchStrict(`*** Begin Patch
       parsePatchStrict(`*** Begin Patch
 *** Update File: sample.txt
 *** Update File: sample.txt
@@ -130,7 +130,7 @@ garbage`),
     ).toThrow('missing @@ chunk body');
     ).toThrow('missing @@ chunk body');
   });
   });
 
 
-  test('formatPatch permite roundtrip estable parse -> format -> parse', () => {
+  test('formatPatch allows stable parse -> format -> parse roundtrips', () => {
     const parsed: ParsedPatch = {
     const parsed: ParsedPatch = {
       hunks: [
       hunks: [
         {
         {
@@ -149,11 +149,11 @@ garbage`),
     expect(parsePatch(formatPatch(parsed))).toEqual(parsed);
     expect(parsePatch(formatPatch(parsed))).toEqual(parsed);
   });
   });
 
 
-  test('normalizeUnicode unifica variantes tipográficas esperadas', () => {
+  test('normalizeUnicode unifies expected typographic variants', () => {
     expect(normalizeUnicode('“uno”…\u00A0dos—tres')).toBe('"uno"... dos-tres');
     expect(normalizeUnicode('“uno”…\u00A0dos—tres')).toBe('"uno"... dos-tres');
   });
   });
 
 
-  test('normalizeUnicode cubre variantes tipográficas menos comunes', () => {
+  test('normalizeUnicode covers less common typographic variants', () => {
     expect(normalizeUnicode('‛uno‟―dos')).toBe(`'uno"-dos`);
     expect(normalizeUnicode('‛uno‟―dos')).toBe(`'uno"-dos`);
   });
   });
 });
 });

+ 19 - 89
src/hooks/apply-patch/execution-context.ts

@@ -207,83 +207,30 @@ function collectPatchTargets(root: string, hunks: PatchHunk[]): string[] {
   return [...targets];
   return [...targets];
 }
 }
 
 
-function validatePatchPaths(hunks: PatchHunk[]): void {
-  for (const hunk of hunks) {
-    if (path.isAbsolute(hunk.path)) {
-      throw createApplyPatchValidationError(
-        `absolute patch paths are not allowed: ${hunk.path}`,
-      );
-    }
-
-    if (
-      hunk.type === 'update' &&
-      hunk.move_path &&
-      path.isAbsolute(hunk.move_path)
-    ) {
-      throw createApplyPatchValidationError(
-        `absolute patch paths are not allowed: ${hunk.move_path}`,
-      );
-    }
-  }
-}
-
-function toPortablePatchPath(filePath: string): string {
-  return filePath.split(path.sep).join('/');
-}
-
 function toRelativePatchPath(root: string, target: string): string {
 function toRelativePatchPath(root: string, target: string): string {
   const relative = path.relative(root, target);
   const relative = path.relative(root, target);
-
-  return toPortablePatchPath(
-    relative.length === 0 ? path.basename(target) : relative,
-  );
+  return (relative.length === 0 ? '.' : relative).replaceAll('\\', '/');
 }
 }
 
 
-async function normalizeAbsolutePatchPath(
-  root: string,
-  worktree: string | undefined,
-  value: string,
-): Promise<string> {
-  if (!path.isAbsolute(value)) {
-    return value;
-  }
-
-  const guardContext = createPathGuardContext(root, worktree);
-  const target = path.resolve(value);
-
-  await guard(guardContext, target);
-
-  const [rootReal, targetReal] = await Promise.all([
-    guardContext.rootReal,
-    realCached(guardContext, target),
-  ]);
-
-  if (!inside(rootReal, targetReal)) {
-    throw createApplyPatchBlockedError(
-      `patch contains path outside workspace root: ${target}`,
-    );
-  }
-
-  return toRelativePatchPath(root, target);
+function normalizePatchPath(root: string, value: string): string {
+  return path.isAbsolute(value)
+    ? toRelativePatchPath(root, path.resolve(value))
+    : value;
 }
 }
 
 
-async function normalizeAbsolutePatchPaths(
+function normalizePatchPaths(
   root: string,
   root: string,
-  worktree: string | undefined,
   hunks: PatchHunk[],
   hunks: PatchHunk[],
-): Promise<{
+): {
   hunks: PatchHunk[];
   hunks: PatchHunk[];
   changed: boolean;
   changed: boolean;
-}> {
+} {
+  const resolvedRoot = path.resolve(root);
   const normalized: PatchHunk[] = [];
   const normalized: PatchHunk[] = [];
   let changed = false;
   let changed = false;
 
 
   for (const hunk of hunks) {
   for (const hunk of hunks) {
-    const normalizedPath = await normalizeAbsolutePatchPath(
-      root,
-      worktree,
-      hunk.path,
-    );
+    const normalizedPath = normalizePatchPath(resolvedRoot, hunk.path);
 
 
     if (hunk.type !== 'update') {
     if (hunk.type !== 'update') {
       changed ||= normalizedPath !== hunk.path;
       changed ||= normalizedPath !== hunk.path;
@@ -299,7 +246,7 @@ async function normalizeAbsolutePatchPaths(
     }
     }
 
 
     const normalizedMovePath = hunk.move_path
     const normalizedMovePath = hunk.move_path
-      ? await normalizeAbsolutePatchPath(root, worktree, hunk.move_path)
+      ? normalizePatchPath(resolvedRoot, hunk.move_path)
       : undefined;
       : undefined;
     changed ||=
     changed ||=
       normalizedPath !== hunk.path || normalizedMovePath !== hunk.move_path;
       normalizedPath !== hunk.path || normalizedMovePath !== hunk.move_path;
@@ -332,14 +279,7 @@ async function guardPatchTargets(
   return targets.length;
   return targets.length;
 }
 }
 
 
-export async function parseValidatedPatch(
-  root: string,
-  patchText: string,
-  worktree?: string,
-): Promise<{
-  hunks: PatchHunk[];
-  pathsNormalized: boolean;
-}> {
+export function parseValidatedPatch(patchText: string): PatchHunk[] {
   let hunks: PatchHunk[];
   let hunks: PatchHunk[];
 
 
   try {
   try {
@@ -357,18 +297,7 @@ export async function parseValidatedPatch(
     throw createApplyPatchValidationError('no hunks found');
     throw createApplyPatchValidationError('no hunks found');
   }
   }
 
 
-  const normalizedPatch = await normalizeAbsolutePatchPaths(
-    root,
-    worktree,
-    hunks,
-  );
-
-  validatePatchPaths(normalizedPatch.hunks);
-
-  return {
-    hunks: normalizedPatch.hunks,
-    pathsNormalized: normalizedPatch.changed,
-  };
+  return hunks;
 }
 }
 
 
 async function readPreparedFileText(
 async function readPreparedFileText(
@@ -396,12 +325,13 @@ export async function createPatchExecutionContext(
   patchText: string,
   patchText: string,
   worktree?: string,
   worktree?: string,
 ): Promise<PatchExecutionContext> {
 ): Promise<PatchExecutionContext> {
-  const { hunks, pathsNormalized } = await parseValidatedPatch(
+  const parsedHunks = parseValidatedPatch(patchText);
+  await guardPatchTargets(
     root,
     root,
-    patchText,
     worktree,
     worktree,
+    collectPatchTargets(root, parsedHunks),
   );
   );
-  await guardPatchTargets(root, worktree, collectPatchTargets(root, hunks));
+  const normalized = normalizePatchPaths(root, parsedHunks);
   const files = createFileCacheContext();
   const files = createFileCacheContext();
   const staged = new Map<string, PreparedFileState>();
   const staged = new Map<string, PreparedFileState>();
 
 
@@ -463,8 +393,8 @@ export async function createPatchExecutionContext(
   }
   }
 
 
   return {
   return {
-    hunks,
-    pathsNormalized,
+    hunks: normalized.hunks,
+    pathsNormalized: normalized.changed,
     staged,
     staged,
     getPreparedFileState,
     getPreparedFileState,
     assertPreparedPathMissing,
     assertPreparedPathMissing,

+ 30 - 28
src/hooks/apply-patch/hook.test.ts

@@ -16,7 +16,7 @@ function createHook() {
 }
 }
 
 
 describe('apply-patch/hook', () => {
 describe('apply-patch/hook', () => {
-  test('ignora tools distintos de apply_patch', async () => {
+  test('ignores tools other than apply_patch', async () => {
     const hook = createHook();
     const hook = createHook();
     const patchText = '*** Begin Patch\n*** End Patch';
     const patchText = '*** Begin Patch\n*** End Patch';
     const output = { args: { patchText } };
     const output = { args: { patchText } };
@@ -26,7 +26,7 @@ describe('apply-patch/hook', () => {
     expect(output.args.patchText).toBe(patchText);
     expect(output.args.patchText).toBe(patchText);
   });
   });
 
 
-  test('bloquea un patch no rescatable como verification antes del nativo', async () => {
+  test('blocks an unrecoverable patch as verification before native execution', async () => {
     const root = await createTempDir('apply-patch-hook-');
     const root = await createTempDir('apply-patch-hook-');
     await writeFixture(root, 'sample.txt', 'alpha\nbeta\ngamma\n');
     await writeFixture(root, 'sample.txt', 'alpha\nbeta\ngamma\n');
     const hook = createHook();
     const hook = createHook();
@@ -50,7 +50,7 @@ describe('apply-patch/hook', () => {
     expect(output.args.patchText).toBe(patchText);
     expect(output.args.patchText).toBe(patchText);
   });
   });
 
 
-  test('normaliza un patch exacto envuelto en heredoc antes del nativo', async () => {
+  test('normalizes an exact patch wrapped in a heredoc before native execution', async () => {
     const root = await createTempDir('apply-patch-hook-');
     const root = await createTempDir('apply-patch-hook-');
     await writeFixture(
     await writeFixture(
       root,
       root,
@@ -91,7 +91,7 @@ PATCH`,
     );
     );
   });
   });
 
 
-  test('normaliza paths absolutos dentro del root antes del nativo', async () => {
+  test('normalizes absolute paths inside root before native execution', async () => {
     const root = await createTempDir('apply-patch-hook-');
     const root = await createTempDir('apply-patch-hook-');
     const absolutePath = path.join(root, 'sample.txt');
     const absolutePath = path.join(root, 'sample.txt');
     await writeFixture(root, 'sample.txt', 'alpha\nbeta\n');
     await writeFixture(root, 'sample.txt', 'alpha\nbeta\n');
@@ -115,7 +115,7 @@ PATCH`,
     });
     });
   });
   });
 
 
-  test('reescribe stale patch de prefijo y sigue siendo aplicable', async () => {
+  test('rewrites a stale prefix patch and remains applicable', async () => {
     const root = await createTempDir('apply-patch-hook-');
     const root = await createTempDir('apply-patch-hook-');
     await writeFixture(
     await writeFixture(
       root,
       root,
@@ -160,7 +160,7 @@ PATCH`,
     );
     );
   });
   });
 
 
-  test('no altera new_lines durante la reescritura', async () => {
+  test('does not alter new_lines during rewrite', async () => {
     const root = await createTempDir('apply-patch-hook-');
     const root = await createTempDir('apply-patch-hook-');
     await writeFixture(
     await writeFixture(
       root,
       root,
@@ -194,7 +194,7 @@ PATCH`,
     ).toEqual(expected.type === 'update' ? expected.chunks[0]?.new_lines : []);
     ).toEqual(expected.type === 'update' ? expected.chunks[0]?.new_lines : []);
   });
   });
 
 
-  test('reescribe stale unicode-only y sigue siendo aplicable', async () => {
+  test('rewrites a unicode-only stale patch and remains applicable', async () => {
     const root = await createTempDir('apply-patch-hook-');
     const root = await createTempDir('apply-patch-hook-');
     await writeFixture(root, 'sample.txt', 'const title = “Hola”;\n');
     await writeFixture(root, 'sample.txt', 'const title = “Hola”;\n');
     const hook = createHook();
     const hook = createHook();
@@ -228,7 +228,7 @@ PATCH`,
     );
     );
   });
   });
 
 
-  test('reescribe stale trim-end y sigue siendo aplicable', async () => {
+  test('rewrites a trim-end stale patch and remains applicable', async () => {
     const root = await createTempDir('apply-patch-hook-');
     const root = await createTempDir('apply-patch-hook-');
     await writeFixture(root, 'sample.txt', 'alpha  \n');
     await writeFixture(root, 'sample.txt', 'alpha  \n');
     const hook = createHook();
     const hook = createHook();
@@ -262,7 +262,7 @@ PATCH`,
     );
     );
   });
   });
 
 
-  test('bloquea un stale trim-only como verification', async () => {
+  test('blocks a trim-only stale patch as verification', async () => {
     const root = await createTempDir('apply-patch-hook-');
     const root = await createTempDir('apply-patch-hook-');
     await writeFixture(root, 'sample.txt', '  alpha  \n');
     await writeFixture(root, 'sample.txt', '  alpha  \n');
     const hook = createHook();
     const hook = createHook();
@@ -286,7 +286,7 @@ PATCH`,
     expect(output.args.patchText).toBe(patchText);
     expect(output.args.patchText).toBe(patchText);
   });
   });
 
 
-  test('bloquea en runtime un @@ mal formado antes del nativo', async () => {
+  test('blocks a malformed @@ at runtime before native execution', async () => {
     const root = await createTempDir('apply-patch-hook-');
     const root = await createTempDir('apply-patch-hook-');
     await writeFixture(root, 'sample.txt', 'alpha\nbeta\n');
     await writeFixture(root, 'sample.txt', 'alpha\nbeta\n');
     const hook = createHook();
     const hook = createHook();
@@ -312,7 +312,7 @@ garbage
     expect(output.args.patchText).toBe(patchText);
     expect(output.args.patchText).toBe(patchText);
   });
   });
 
 
-  test('bloquea en runtime un Add File mal formado antes del nativo', async () => {
+  test('blocks a malformed Add File at runtime before native execution', async () => {
     const root = await createTempDir('apply-patch-hook-');
     const root = await createTempDir('apply-patch-hook-');
     const hook = createHook();
     const hook = createHook();
     const patchText = `*** Begin Patch
     const patchText = `*** Begin Patch
@@ -334,7 +334,7 @@ garbage
     expect(output.args.patchText).toBe(patchText);
     expect(output.args.patchText).toBe(patchText);
   });
   });
 
 
-  test('bloquea errores internos del guard antes del nativo', async () => {
+  test('blocks internal guard errors before native execution', async () => {
     const root = await createTempDir('apply-patch-hook-');
     const root = await createTempDir('apply-patch-hook-');
     const lockedDir = path.join(root, 'locked');
     const lockedDir = path.join(root, 'locked');
     await mkdir(lockedDir, { recursive: true });
     await mkdir(lockedDir, { recursive: true });
@@ -360,7 +360,7 @@ garbage
     }
     }
   });
   });
 
 
-  test('bloquea un caso indentado peligroso como verification', async () => {
+  test('blocks a dangerous indented case as verification', async () => {
     const root = await createTempDir('apply-patch-hook-');
     const root = await createTempDir('apply-patch-hook-');
     await writeFixture(
     await writeFixture(
       root,
       root,
@@ -388,7 +388,7 @@ garbage
     expect(output.args.patchText).toBe(patchText);
     expect(output.args.patchText).toBe(patchText);
   });
   });
 
 
-  test('reescribe inserción anclada para evitar EOF del nativo', async () => {
+  test('rewrites anchored insertion to avoid native EOF handling', async () => {
     const root = await createTempDir('apply-patch-hook-');
     const root = await createTempDir('apply-patch-hook-');
     await writeFixture(
     await writeFixture(
       root,
       root,
@@ -419,7 +419,7 @@ garbage
     );
     );
   });
   });
 
 
-  test('bloquea una inserción pura si falta el anchor', async () => {
+  test('blocks a pure insertion when the anchor is missing', async () => {
     const root = await createTempDir('apply-patch-hook-');
     const root = await createTempDir('apply-patch-hook-');
     await writeFixture(root, 'sample.txt', 'top\nafter-anchor\nend\n');
     await writeFixture(root, 'sample.txt', 'top\nafter-anchor\nend\n');
     const hook = createHook();
     const hook = createHook();
@@ -442,7 +442,7 @@ garbage
     expect(output.args.patchText).toBe(patchText);
     expect(output.args.patchText).toBe(patchText);
   });
   });
 
 
-  test('bloquea una inserción pura si el anchor es ambiguo', async () => {
+  test('blocks a pure insertion when the anchor is ambiguous', async () => {
     const root = await createTempDir('apply-patch-hook-');
     const root = await createTempDir('apply-patch-hook-');
     await writeFixture(
     await writeFixture(
       root,
       root,
@@ -469,7 +469,7 @@ garbage
     expect(output.args.patchText).toBe(patchText);
     expect(output.args.patchText).toBe(patchText);
   });
   });
 
 
-  test('bloquea ambigüedad real del patch antes del nativo', async () => {
+  test('blocks real patch ambiguity before native execution', async () => {
     const root = await createTempDir('apply-patch-hook-');
     const root = await createTempDir('apply-patch-hook-');
     await writeFixture(
     await writeFixture(
       root,
       root,
@@ -497,7 +497,7 @@ garbage
     expect(output.args.patchText).toBe(patchText);
     expect(output.args.patchText).toBe(patchText);
   });
   });
 
 
-  test('reescribe solo el hunk update en un patch con add + update', async () => {
+  test('rewrites only the update hunk in a patch with add + update', async () => {
     const root = await createTempDir('apply-patch-hook-');
     const root = await createTempDir('apply-patch-hook-');
     await writeFixture(
     await writeFixture(
       root,
       root,
@@ -555,7 +555,7 @@ garbage
     );
     );
   });
   });
 
 
-  test('aborta temprano si el patch solo apunta fuera del root/worktree', async () => {
+  test('aborts early when the patch only targets outside root/worktree', async () => {
     const root = await createTempDir('apply-patch-hook-');
     const root = await createTempDir('apply-patch-hook-');
     const outside = path.join(path.dirname(root), 'outside.txt');
     const outside = path.join(path.dirname(root), 'outside.txt');
     await writeFile(outside, 'outside\n', 'utf-8');
     await writeFile(outside, 'outside\n', 'utf-8');
@@ -581,7 +581,7 @@ garbage
     expect(await readFile(outside, 'utf-8')).toBe('outside\n');
     expect(await readFile(outside, 'utf-8')).toBe('outside\n');
   });
   });
 
 
-  test('bloquea un path absoluto dentro del worktree pero fuera del root', async () => {
+  test('normalizes an absolute path inside worktree even when it is outside root', async () => {
     const worktree = await createTempDir('apply-patch-worktree-');
     const worktree = await createTempDir('apply-patch-worktree-');
     const root = path.join(worktree, 'subdir');
     const root = path.join(worktree, 'subdir');
     await mkdir(root, { recursive: true });
     await mkdir(root, { recursive: true });
@@ -602,14 +602,16 @@ garbage
         { tool: 'apply_patch', directory: root },
         { tool: 'apply_patch', directory: root },
         output,
         output,
       ),
       ),
-    ).rejects.toThrow(
-      `apply_patch blocked: patch contains path outside workspace root: ${siblingPath}`,
-    );
+    ).resolves.toBeUndefined();
 
 
-    expect(output.args.patchText).toBe(patchText);
+    expect(parsePatch(output.args.patchText as string).hunks[0]).toMatchObject({
+      type: 'add',
+      path: '../shared.txt',
+      contents: 'fresh',
+    });
   });
   });
 
 
-  test('aborta temprano y no aplica nada si un patch mixto tiene rutas fuera', async () => {
+  test('aborts early and applies nothing when a mixed patch has outside paths', async () => {
     const root = await createTempDir('apply-patch-hook-');
     const root = await createTempDir('apply-patch-hook-');
     const outsideDir = await createTempDir('apply-patch-hook-outside-');
     const outsideDir = await createTempDir('apply-patch-hook-outside-');
     await writeFixture(root, 'sample.txt', 'prefix\nstale-value\nsuffix\n');
     await writeFixture(root, 'sample.txt', 'prefix\nstale-value\nsuffix\n');
@@ -648,7 +650,7 @@ garbage
     );
     );
   });
   });
 
 
-  test('mantiene el comportamiento normal para patches íntegramente dentro', async () => {
+  test('keeps normal behavior for patches entirely inside root/worktree', async () => {
     const root = await createTempDir('apply-patch-hook-');
     const root = await createTempDir('apply-patch-hook-');
     await writeFixture(root, 'sample.txt', 'alpha\nbeta\n');
     await writeFixture(root, 'sample.txt', 'alpha\nbeta\n');
     const hook = createHook();
     const hook = createHook();
@@ -671,13 +673,13 @@ garbage
     expect(output.args.patchText).toBe(patchText);
     expect(output.args.patchText).toBe(patchText);
   });
   });
 
 
-  test('no expone hook tool.execute.after', () => {
+  test('does not expose the tool.execute.after hook', () => {
     const hook = createHook() as Record<string, unknown>;
     const hook = createHook() as Record<string, unknown>;
 
 
     expect(hook['tool.execute.after']).toBeUndefined();
     expect(hook['tool.execute.after']).toBeUndefined();
   });
   });
 
 
-  test('no altera un patch exacto', async () => {
+  test('does not alter an exact patch', async () => {
     const root = await createTempDir('apply-patch-hook-');
     const root = await createTempDir('apply-patch-hook-');
     await writeFixture(root, 'sample.txt', 'alpha\nbeta\n');
     await writeFixture(root, 'sample.txt', 'alpha\nbeta\n');
     const hook = createHook();
     const hook = createHook();

+ 13 - 13
src/hooks/apply-patch/matching.test.ts

@@ -12,19 +12,19 @@ import {
 } from './matching';
 } from './matching';
 
 
 describe('apply-patch/matching', () => {
 describe('apply-patch/matching', () => {
-  test('seek encuentra coincidencias con unicode y trim-end', () => {
+  test('seek finds matches with unicode and trim-end', () => {
     expect(seek(['console.log(“hola”);  '], ['console.log("hola");'], 0)).toBe(
     expect(seek(['console.log(“hola”);  '], ['console.log("hola");'], 0)).toBe(
       0,
       0,
     );
     );
   });
   });
 
 
-  test('seek no rescata coincidencias trim-only con indentación distinta', () => {
+  test('seek does not rescue trim-only matches with different indentation', () => {
     expect(seek(['  console.log("hola");'], ['console.log("hola");'], 0)).toBe(
     expect(seek(['  console.log("hola");'], ['console.log("hola");'], 0)).toBe(
       -1,
       -1,
     );
     );
   });
   });
 
 
-  test('prefix y suffix detectan bordes comunes', () => {
+  test('prefix and suffix detect common edges', () => {
     const oldLines = [
     const oldLines = [
       'const title = "Hola";',
       'const title = "Hola";',
       'old-value',
       'old-value',
@@ -40,7 +40,7 @@ describe('apply-patch/matching', () => {
     expect(suffix(oldLines, newLines, 1)).toBe(1);
     expect(suffix(oldLines, newLines, 1)).toBe(1);
   });
   });
 
 
-  test('rescueByPrefixSuffix rescata un bloque stale único', () => {
+  test('rescueByPrefixSuffix rescues a single stale block', () => {
     const result = rescueByPrefixSuffix(
     const result = rescueByPrefixSuffix(
       ['top', 'const title = “Hola”;', 'stale-value', 'const footer = “Fin”;'],
       ['top', 'const title = “Hola”;', 'stale-value', 'const footer = “Fin”;'],
       ['const title = "Hola";', 'old-value', 'const footer = "Fin";'],
       ['const title = "Hola";', 'old-value', 'const footer = "Fin";'],
@@ -58,7 +58,7 @@ describe('apply-patch/matching', () => {
     });
     });
   });
   });
 
 
-  test('rescueByPrefixSuffix marca ambigüedad cuando hay varias ubicaciones', () => {
+  test('rescueByPrefixSuffix marks ambiguity when multiple locations exist', () => {
     expect(
     expect(
       rescueByPrefixSuffix(
       rescueByPrefixSuffix(
         ['left', 'stale-one', 'right', 'gap', 'left', 'stale-two', 'right'],
         ['left', 'stale-one', 'right', 'gap', 'left', 'stale-two', 'right'],
@@ -69,7 +69,7 @@ describe('apply-patch/matching', () => {
     ).toEqual({ kind: 'ambiguous', phase: 'prefix_suffix' });
     ).toEqual({ kind: 'ambiguous', phase: 'prefix_suffix' });
   });
   });
 
 
-  test('rescueByLcs respeta el start y encuentra un candidato único', () => {
+  test('rescueByLcs respects the start and finds a single candidate', () => {
     const result = rescueByLcs(
     const result = rescueByLcs(
       [
       [
         'head',
         'head',
@@ -100,7 +100,7 @@ describe('apply-patch/matching', () => {
     });
     });
   });
   });
 
 
-  test('rescueByLcs marca ambigüedad cuando dos ventanas empatan sin bordes comunes', () => {
+  test('rescueByLcs marks ambiguity when two windows tie without common edges', () => {
     expect(
     expect(
       rescueByLcs(
       rescueByLcs(
         ['head', 'alpha', 'beta', 'mid', 'alpha', 'beta', 'tail'],
         ['head', 'alpha', 'beta', 'mid', 'alpha', 'beta', 'tail'],
@@ -111,7 +111,7 @@ describe('apply-patch/matching', () => {
     ).toEqual({ kind: 'ambiguous', phase: 'lcs' });
     ).toEqual({ kind: 'ambiguous', phase: 'lcs' });
   });
   });
 
 
-  test('rescueByLcs rechaza ventanas con un solo borde coincidente aunque el score sea alto', () => {
+  test('rescueByLcs rejects windows with only one matching edge even when the score is high', () => {
     expect(
     expect(
       rescueByLcs(
       rescueByLcs(
         ['a', 'a', 'a', 'a', 'b', 'c'],
         ['a', 'a', 'a', 'a', 'b', 'c'],
@@ -122,7 +122,7 @@ describe('apply-patch/matching', () => {
     ).toEqual({ kind: 'miss' });
     ).toEqual({ kind: 'miss' });
   });
   });
 
 
-  test('rescueByLcs poda un chunk desproporcionado aunque tenga bordes compatibles', () => {
+  test('rescueByLcs prunes a disproportionate chunk even when it has compatible edges', () => {
     const oldLines = Array.from({ length: 49 }, (_, index) => `line-${index}`);
     const oldLines = Array.from({ length: 49 }, (_, index) => `line-${index}`);
     const lines = [...oldLines];
     const lines = [...oldLines];
     lines[24] = 'line-24-stale';
     lines[24] = 'line-24-stale';
@@ -137,7 +137,7 @@ describe('apply-patch/matching', () => {
     ).toEqual({ kind: 'miss' });
     ).toEqual({ kind: 'miss' });
   });
   });
 
 
-  test('rescueByLcs descarta una ventana poco plausible antes del scoring caro', () => {
+  test('rescueByLcs discards an implausible window before expensive scoring', () => {
     expect(
     expect(
       rescueByLcs(
       rescueByLcs(
         ['left', 'noise-a', 'keep', 'noise-b', 'right'],
         ['left', 'noise-a', 'keep', 'noise-b', 'right'],
@@ -148,7 +148,7 @@ describe('apply-patch/matching', () => {
     ).toEqual({ kind: 'miss' });
     ).toEqual({ kind: 'miss' });
   });
   });
 
 
-  test('seek empareja comillas curly y straight mezcladas', () => {
+  test('seek matches mixed curly and straight quotes', () => {
     expect(
     expect(
       seek(
       seek(
         ['const title = “it’s ready”;'],
         ['const title = “it’s ready”;'],
@@ -158,7 +158,7 @@ describe('apply-patch/matching', () => {
     ).toBe(0);
     ).toBe(0);
   });
   });
 
 
-  test('seekMatch informa cuando el match solo fue tolerante y seguro', () => {
+  test('seekMatch reports when the match was only tolerant and safe', () => {
     expect(
     expect(
       seekMatch(['console.log(“hola”);  '], ['console.log("hola");'], 0),
       seekMatch(['console.log(“hola”);  '], ['console.log("hola");'], 0),
     ).toEqual({
     ).toEqual({
@@ -168,7 +168,7 @@ describe('apply-patch/matching', () => {
     });
     });
   });
   });
 
 
-  test('separación de comparadores distingue rescate seguro y comparadores permisivos', () => {
+  test('comparator separation distinguishes safe rescue from permissive comparators', () => {
     expect(autoRescueComparators).toHaveLength(4);
     expect(autoRescueComparators).toHaveLength(4);
     expect(permissiveComparators).toHaveLength(6);
     expect(permissiveComparators).toHaveLength(6);
   });
   });

+ 98 - 61
src/hooks/apply-patch/operations.test.ts

@@ -23,7 +23,7 @@ import {
 } from './test-helpers';
 } from './test-helpers';
 
 
 describe('apply-patch/operations', () => {
 describe('apply-patch/operations', () => {
-  test('preparePatchChanges y applyPreparedChanges aplican un match exacto', async () => {
+  test('preparePatchChanges and applyPreparedChanges apply an exact match', async () => {
     const root = await createTempDir();
     const root = await createTempDir();
     await writeFixture(root, 'sample.txt', 'alpha\nbeta\ngamma\n');
     await writeFixture(root, 'sample.txt', 'alpha\nbeta\ngamma\n');
     await chmod(path.join(root, 'sample.txt'), 0o750);
     await chmod(path.join(root, 'sample.txt'), 0o750);
@@ -46,7 +46,7 @@ describe('apply-patch/operations', () => {
     );
     );
   });
   });
 
 
-  test('rewritePatchText deja intacto un patch sano', async () => {
+  test('rewritePatchText leaves a healthy patch intact', async () => {
     const root = await createTempDir();
     const root = await createTempDir();
     const patchText = `*** Begin Patch
     const patchText = `*** Begin Patch
 *** Update File: sample.txt
 *** Update File: sample.txt
@@ -72,7 +72,7 @@ describe('apply-patch/operations', () => {
     });
     });
   });
   });
 
 
-  test('rewritePatchText desenrolla un patch exacto envuelto en heredoc', async () => {
+  test('rewritePatchText unwraps an exact patch wrapped in a heredoc', async () => {
     const root = await createTempDir();
     const root = await createTempDir();
     const cleanPatchText = `*** Begin Patch
     const cleanPatchText = `*** Begin Patch
 *** Update File: sample.txt
 *** Update File: sample.txt
@@ -101,7 +101,7 @@ PATCH`;
     });
     });
   });
   });
 
 
-  test('rewritePatchText normaliza CRLF + heredoc exactos y el patch sigue funcionando', async () => {
+  test('rewritePatchText normalizes exact CRLF + heredoc input and the patch still works', async () => {
     const root = await createTempDir();
     const root = await createTempDir();
     const cleanPatchText = `*** Begin Patch
     const cleanPatchText = `*** Begin Patch
 *** Update File: sample.txt
 *** Update File: sample.txt
@@ -136,7 +136,7 @@ PATCH`;
     );
     );
   });
   });
 
 
-  test('rewritePatchText reescribe stale patch y preserva new_lines byte a byte', async () => {
+  test('rewritePatchText rewrites a stale patch and preserves new_lines byte-for-byte', async () => {
     const root = await createTempDir();
     const root = await createTempDir();
     await writeFixture(
     await writeFixture(
       root,
       root,
@@ -165,7 +165,7 @@ PATCH`;
     ).toEqual(['prefix', ' \tverbatim  ""  Ω  ', 'suffix']);
     ).toEqual(['prefix', ' \tverbatim  ""  Ω  ', 'suffix']);
   });
   });
 
 
-  test('rewritePatchText elimina EOF si un rescate mueve el chunk fuera del final real', async () => {
+  test('rewritePatchText removes EOF when a rescue moves the chunk away from the real end', async () => {
     const root = await createTempDir();
     const root = await createTempDir();
     await writeFixture(
     await writeFixture(
       root,
       root,
@@ -198,7 +198,7 @@ PATCH`;
     ).toBeUndefined();
     ).toBeUndefined();
   });
   });
 
 
-  test('rewritePatchText conserva EOF si el chunk resuelto sigue terminando al final real', async () => {
+  test('rewritePatchText keeps EOF when the resolved chunk still ends at the real end', async () => {
     const root = await createTempDir();
     const root = await createTempDir();
     await writeFixture(root, 'sample.txt', 'alpha\nstale\nomega');
     await writeFixture(root, 'sample.txt', 'alpha\nstale\nomega');
     const patchText = `*** Begin Patch
     const patchText = `*** Begin Patch
@@ -229,7 +229,7 @@ PATCH`;
     ).toBeTrue();
     ).toBeTrue();
   });
   });
 
 
-  test('rewritePatchText canoniza un stale unicode-only', async () => {
+  test('rewritePatchText canonicalizes a unicode-only stale patch', async () => {
     const root = await createTempDir();
     const root = await createTempDir();
     await writeFixture(root, 'sample.txt', 'const title = “Hola”;\n');
     await writeFixture(root, 'sample.txt', 'const title = “Hola”;\n');
     const patchText = `*** Begin Patch
     const patchText = `*** Begin Patch
@@ -252,7 +252,7 @@ PATCH`;
     ).toEqual(['const title = "Hola mundo";']);
     ).toEqual(['const title = "Hola mundo";']);
   });
   });
 
 
-  test('rewritePatchText canoniza un stale trim-end', async () => {
+  test('rewritePatchText canonicalizes a trim-end stale patch', async () => {
     const root = await createTempDir();
     const root = await createTempDir();
     await writeFixture(root, 'sample.txt', 'alpha  \n');
     await writeFixture(root, 'sample.txt', 'alpha  \n');
     const patchText = `*** Begin Patch
     const patchText = `*** Begin Patch
@@ -275,7 +275,7 @@ PATCH`;
     ).toEqual(['omega']);
     ).toEqual(['omega']);
   });
   });
 
 
-  test('rewritePatchText ya no rescata un stale trim-only', async () => {
+  test('rewritePatchText no longer rescues a trim-only stale patch', async () => {
     const root = await createTempDir();
     const root = await createTempDir();
     await writeFixture(root, 'sample.txt', '  alpha  \n');
     await writeFixture(root, 'sample.txt', '  alpha  \n');
     const patchText = `*** Begin Patch
     const patchText = `*** Begin Patch
@@ -292,7 +292,7 @@ PATCH`;
     );
     );
   });
   });
 
 
-  test('rewritePatchText ya no canoniza un caso indentado peligroso', async () => {
+  test('rewritePatchText no longer canonicalizes a dangerous indented case', async () => {
     const root = await createTempDir();
     const root = await createTempDir();
     await writeFixture(
     await writeFixture(
       root,
       root,
@@ -313,7 +313,7 @@ PATCH`;
     );
     );
   });
   });
 
 
-  test('rewritePatchText rechaza un @@ mal formado en vez de sanearlo silenciosamente', async () => {
+  test('rewritePatchText rejects malformed @@ instead of silently sanitizing it', async () => {
     const root = await createTempDir();
     const root = await createTempDir();
     await writeFixture(root, 'sample.txt', 'alpha\nbeta\n');
     await writeFixture(root, 'sample.txt', 'alpha\nbeta\n');
 
 
@@ -335,7 +335,7 @@ garbage
     );
     );
   });
   });
 
 
-  test('preparePatchChanges rechaza un Add File mal formado', async () => {
+  test('preparePatchChanges rejects a malformed Add File', async () => {
     const root = await createTempDir();
     const root = await createTempDir();
 
 
     await expect(
     await expect(
@@ -353,7 +353,7 @@ garbage
     );
     );
   });
   });
 
 
-  test('preparePatchChanges normaliza un Update File con path absoluto dentro del root', async () => {
+  test('preparePatchChanges normalizes an Update File with an absolute path inside root', async () => {
     const root = await createTempDir();
     const root = await createTempDir();
     const absolutePath = path.join(root, 'sample.txt');
     const absolutePath = path.join(root, 'sample.txt');
     await writeFixture(root, 'sample.txt', 'alpha\nbeta\n');
     await writeFixture(root, 'sample.txt', 'alpha\nbeta\n');
@@ -396,7 +396,7 @@ garbage
     ]);
     ]);
   });
   });
 
 
-  test('preparePatchChanges normaliza un Add File con path absoluto dentro del root', async () => {
+  test('preparePatchChanges normalizes an Add File with an absolute path inside root', async () => {
     const root = await createTempDir();
     const root = await createTempDir();
     const absolutePath = path.join(root, 'added.txt');
     const absolutePath = path.join(root, 'added.txt');
 
 
@@ -434,7 +434,7 @@ garbage
     ]);
     ]);
   });
   });
 
 
-  test('preparePatchChanges normaliza un Move to con path absoluto dentro del root', async () => {
+  test('preparePatchChanges normalizes a Move to with an absolute path inside root', async () => {
     const root = await createTempDir();
     const root = await createTempDir();
     const absoluteMovePath = path.join(root, 'nested/after.txt');
     const absoluteMovePath = path.join(root, 'nested/after.txt');
 
 
@@ -483,7 +483,7 @@ garbage
     ]);
     ]);
   });
   });
 
 
-  test('preparePatchChanges bloquea un path absoluto fuera del root/worktree', async () => {
+  test('preparePatchChanges blocks an absolute path outside root/worktree', async () => {
     const root = await createTempDir();
     const root = await createTempDir();
     const outsidePath = path.join(path.dirname(root), 'outside.txt');
     const outsidePath = path.join(path.dirname(root), 'outside.txt');
 
 
@@ -503,12 +503,13 @@ garbage
     );
     );
   });
   });
 
 
-  test('preparePatchChanges bloquea un path absoluto dentro del worktree pero fuera del root', async () => {
+  test('preparePatchChanges allows an absolute path inside worktree even when it is outside root', async () => {
     const worktree = await createTempDir();
     const worktree = await createTempDir();
     const root = path.join(worktree, 'subdir');
     const root = path.join(worktree, 'subdir');
+    await mkdir(root, { recursive: true });
     const siblingPath = path.join(worktree, 'shared.txt');
     const siblingPath = path.join(worktree, 'shared.txt');
 
 
-    const error = await preparePatchChanges(
+    const rewritten = await rewritePatch(
       root,
       root,
       `*** Begin Patch
       `*** Begin Patch
 *** Add File: ${siblingPath}
 *** Add File: ${siblingPath}
@@ -516,16 +517,52 @@ garbage
 *** End Patch`,
 *** End Patch`,
       DEFAULT_OPTIONS,
       DEFAULT_OPTIONS,
       worktree,
       worktree,
-    ).catch((caughtError) => caughtError);
+    );
 
 
-    expect(isApplyPatchBlockedError(error)).toBeTrue();
-    expect(error).toBeInstanceOf(Error);
-    expect((error as Error).message).toBe(
-      `apply_patch blocked: patch contains path outside workspace root: ${siblingPath}`,
+    expect(rewritten.changed).toBeTrue();
+    expect(parsePatch(rewritten.patchText).hunks[0]).toMatchObject({
+      type: 'add',
+      path: '../shared.txt',
+      contents: 'fresh',
+    });
+
+    await expect(
+      preparePatchChanges(
+        root,
+        `*** Begin Patch
+*** Add File: ${siblingPath}
++fresh
+*** End Patch`,
+        DEFAULT_OPTIONS,
+        worktree,
+      ),
+    ).resolves.toEqual([
+      {
+        type: 'add',
+        file: siblingPath,
+        text: 'fresh\n',
+      },
+    ]);
+  });
+
+  test('preparePatchChanges does not redirect an absolute root target to its basename', async () => {
+    const root = await createTempDir();
+
+    await expect(
+      preparePatchChanges(
+        root,
+        `*** Begin Patch
+*** Add File: ${root}
++fresh
+*** End Patch`,
+        DEFAULT_OPTIONS,
+      ),
+    ).rejects.toThrow(
+      `apply_patch verification failed: Add File target already exists: ${root}`,
     );
     );
   });
   });
 
 
-  test('preparePatchChanges rechaza Add File sobre un path existente', async () => {
+  test('preparePatchChanges rejects Add File on an existing path', async () => {
     const root = await createTempDir();
     const root = await createTempDir();
     await writeFixture(root, 'added.txt', 'legacy\n');
     await writeFixture(root, 'added.txt', 'legacy\n');
 
 
@@ -543,7 +580,7 @@ garbage
     );
     );
   });
   });
 
 
-  test('preparePatchChanges rechaza Move to sobre un destino existente distinto', async () => {
+  test('preparePatchChanges rejects Move to on a different existing destination', async () => {
     const root = await createTempDir();
     const root = await createTempDir();
     await writeFixture(root, 'before.txt', 'alpha\nbeta\n');
     await writeFixture(root, 'before.txt', 'alpha\nbeta\n');
     await writeFixture(root, 'nested/after.txt', 'legacy\n');
     await writeFixture(root, 'nested/after.txt', 'legacy\n');
@@ -566,7 +603,7 @@ garbage
     );
     );
   });
   });
 
 
-  test('rewritePatchText rechaza Delete File inexistente igual que preparePatchChanges', async () => {
+  test('rewritePatchText rejects a missing Delete File like preparePatchChanges', async () => {
     const root = await createTempDir();
     const root = await createTempDir();
     const patchText = `*** Begin Patch
     const patchText = `*** Begin Patch
 *** Delete File: missing.txt
 *** Delete File: missing.txt
@@ -581,7 +618,7 @@ garbage
     ).rejects.toThrow(expectedMessage);
     ).rejects.toThrow(expectedMessage);
   });
   });
 
 
-  test('rewritePatchText rechaza doble Delete File sobre el mismo path', async () => {
+  test('rewritePatchText rejects duplicate Delete File on the same path', async () => {
     const root = await createTempDir();
     const root = await createTempDir();
     await writeFixture(root, 'obsolete.txt', 'legacy\n');
     await writeFixture(root, 'obsolete.txt', 'legacy\n');
 
 
@@ -599,7 +636,7 @@ garbage
     );
     );
   });
   });
 
 
-  test('rewritePatchText rechaza Delete File del origen tras un move previo', async () => {
+  test('rewritePatchText rejects Delete File on the source after a previous move', async () => {
     const root = await createTempDir();
     const root = await createTempDir();
     await writeFixture(root, 'before.txt', 'alpha\nbeta\n');
     await writeFixture(root, 'before.txt', 'alpha\nbeta\n');
 
 
@@ -622,7 +659,7 @@ garbage
     );
     );
   });
   });
 
 
-  test('rewritePatchText mantiene un Delete File válido y el apply sigue funcionando', async () => {
+  test('rewritePatchText keeps a valid Delete File and apply still works', async () => {
     const root = await createTempDir();
     const root = await createTempDir();
     await writeFixture(root, 'obsolete.txt', 'legacy\n');
     await writeFixture(root, 'obsolete.txt', 'legacy\n');
     const patchText = `*** Begin Patch
     const patchText = `*** Begin Patch
@@ -637,7 +674,7 @@ garbage
     await expect(readText(root, 'obsolete.txt')).rejects.toThrow();
     await expect(readText(root, 'obsolete.txt')).rejects.toThrow();
   });
   });
 
 
-  test('applyPreparedChanges rechaza add directo sobre un path existente', async () => {
+  test('applyPreparedChanges rejects direct add on an existing path', async () => {
     const root = await createTempDir();
     const root = await createTempDir();
     const target = path.join(root, 'added.txt');
     const target = path.join(root, 'added.txt');
     await writeFixture(root, 'added.txt', 'legacy\n');
     await writeFixture(root, 'added.txt', 'legacy\n');
@@ -657,7 +694,7 @@ garbage
     expect(await readText(root, 'added.txt')).toBe('legacy\n');
     expect(await readText(root, 'added.txt')).toBe('legacy\n');
   });
   });
 
 
-  test('applyPreparedChanges rechaza move directo sobre un destino existente', async () => {
+  test('applyPreparedChanges rejects direct move on an existing destination', async () => {
     const root = await createTempDir();
     const root = await createTempDir();
     const source = path.join(root, 'before.txt');
     const source = path.join(root, 'before.txt');
     const target = path.join(root, 'nested/after.txt');
     const target = path.join(root, 'nested/after.txt');
@@ -681,7 +718,7 @@ garbage
     expect(await readText(root, 'nested/after.txt')).toBe('legacy\n');
     expect(await readText(root, 'nested/after.txt')).toBe('legacy\n');
   });
   });
 
 
-  test('applyPreparedChanges rechaza arrays legacy con paths relativos', async () => {
+  test('applyPreparedChanges rejects legacy arrays with relative paths', async () => {
     const error = await applyPreparedChanges([
     const error = await applyPreparedChanges([
       {
       {
         type: 'add',
         type: 'add',
@@ -697,7 +734,7 @@ garbage
     );
     );
   });
   });
 
 
-  test('rewritePatchText y preparePatchChanges comparten taxonomía validation/verification', async () => {
+  test('rewritePatchText and preparePatchChanges share the validation/verification taxonomy', async () => {
     const root = await createTempDir();
     const root = await createTempDir();
     await writeFixture(root, 'sample.txt', 'alpha\nbeta\n');
     await writeFixture(root, 'sample.txt', 'alpha\nbeta\n');
 
 
@@ -726,7 +763,7 @@ garbage
     expect(isApplyPatchValidationError(validationError)).toBeTrue();
     expect(isApplyPatchValidationError(validationError)).toBeTrue();
   });
   });
 
 
-  test('rewritePatchText canoniza inserción EOF con anchor tolerante', async () => {
+  test('rewritePatchText canonicalizes EOF insertion with a tolerant anchor', async () => {
     const root = await createTempDir();
     const root = await createTempDir();
     await writeFixture(root, 'sample.txt', 'top\n“anchor”\n');
     await writeFixture(root, 'sample.txt', 'top\n“anchor”\n');
 
 
@@ -753,7 +790,7 @@ garbage
     ).toEqual(['middle']);
     ).toEqual(['middle']);
   });
   });
 
 
-  test('rewritePatch agrupa dos Update File exactos sobre el mismo path', async () => {
+  test('rewritePatch groups two exact Update File hunks on the same path', async () => {
     const root = await createTempDir();
     const root = await createTempDir();
     await writeFixture(root, 'sample.txt', 'alpha\nbeta\ngamma\ndelta\n');
     await writeFixture(root, 'sample.txt', 'alpha\nbeta\ngamma\ndelta\n');
 
 
@@ -800,7 +837,7 @@ garbage
     });
     });
   });
   });
 
 
-  test('rewritePatch agrupa un segundo update dependiente del primero', async () => {
+  test('rewritePatch groups a second update that depends on the first', async () => {
     const root = await createTempDir();
     const root = await createTempDir();
     await writeFixture(root, 'sample.txt', 'alpha\nbeta\ngamma\n');
     await writeFixture(root, 'sample.txt', 'alpha\nbeta\ngamma\n');
 
 
@@ -848,7 +885,7 @@ garbage
     expect(await readText(root, 'sample.txt')).toBe('alpha\nBETA!\ngamma\n');
     expect(await readText(root, 'sample.txt')).toBe('alpha\nBETA!\ngamma\n');
   });
   });
 
 
-  test('rewritePatch colapsa Add File + Update File exacto a un add autónomo', async () => {
+  test('rewritePatch collapses Add File + exact Update File into a self-contained add', async () => {
     const root = await createTempDir();
     const root = await createTempDir();
 
 
     const result = await rewritePatch(
     const result = await rewritePatch(
@@ -877,7 +914,7 @@ garbage
     ]);
     ]);
   });
   });
 
 
-  test('rewritePatch colapsa Add File + Update File + Move to a un add final autónomo', async () => {
+  test('rewritePatch collapses Add File + Update File + Move to into a self-contained final add', async () => {
     const root = await createTempDir();
     const root = await createTempDir();
 
 
     const result = await rewritePatch(
     const result = await rewritePatch(
@@ -907,7 +944,7 @@ garbage
     ]);
     ]);
   });
   });
 
 
-  test('rewritePatch colapsa move exacto seguido de update sobre el destino', async () => {
+  test('rewritePatch collapses an exact move followed by an update on the destination', async () => {
     const root = await createTempDir();
     const root = await createTempDir();
     await writeFixture(root, 'before.txt', 'alpha\nbeta\ngamma\n');
     await writeFixture(root, 'before.txt', 'alpha\nbeta\ngamma\n');
 
 
@@ -950,7 +987,7 @@ garbage
     ]);
     ]);
   });
   });
 
 
-  test('rewritePatch minimiza el whole-file collapse cuando el fallback sigue siendo verificable', async () => {
+  test('rewritePatch minimizes whole-file collapse when the fallback remains verifiable', async () => {
     const root = await createTempDir();
     const root = await createTempDir();
     await writeFixture(root, 'before.txt', 'alpha\nbeta\ngamma\n');
     await writeFixture(root, 'before.txt', 'alpha\nbeta\ngamma\n');
 
 
@@ -997,7 +1034,7 @@ garbage
     );
     );
   });
   });
 
 
-  test('rewritePatch mantiene el orden correcto de cambios al agrupar same-file updates', async () => {
+  test('rewritePatch keeps the correct change order when grouping same-file updates', async () => {
     const root = await createTempDir();
     const root = await createTempDir();
     await writeFixture(root, 'sample.txt', 'one\ntwo\nthree\nfour\nfive\n');
     await writeFixture(root, 'sample.txt', 'one\ntwo\nthree\nfour\nfive\n');
 
 
@@ -1046,7 +1083,7 @@ garbage
     );
     );
   });
   });
 
 
-  test('preparePatchChanges falla cuando el rescate es ambiguo', async () => {
+  test('preparePatchChanges fails when rescue is ambiguous', async () => {
     const root = await createTempDir();
     const root = await createTempDir();
     await writeFixture(
     await writeFixture(
       root,
       root,
@@ -1070,7 +1107,7 @@ garbage
     ).rejects.toThrow('apply_patch verification failed:');
     ).rejects.toThrow('apply_patch verification failed:');
   });
   });
 
 
-  test('applyPreparedChanges revierte cambios previos si un apply posterior falla', async () => {
+  test('applyPreparedChanges reverts previous changes when a later apply fails', async () => {
     const root = await createTempDir();
     const root = await createTempDir();
     await writeFixture(root, 'first.txt', 'one\n');
     await writeFixture(root, 'first.txt', 'one\n');
     await writeFixture(root, 'blocker', 'not-a-dir\n');
     await writeFixture(root, 'blocker', 'not-a-dir\n');
@@ -1099,7 +1136,7 @@ garbage
     await expect(readText(root, 'blocker/second.txt')).rejects.toThrow();
     await expect(readText(root, 'blocker/second.txt')).rejects.toThrow();
   });
   });
 
 
-  test('applyPreparedChanges soporta update con move_path y preserva mode del origen', async () => {
+  test('applyPreparedChanges supports update with move_path and preserves the source mode', async () => {
     const root = await createTempDir();
     const root = await createTempDir();
     await writeFixture(root, 'before.txt', 'alpha\nbeta\ngamma\n');
     await writeFixture(root, 'before.txt', 'alpha\nbeta\ngamma\n');
     await chmod(path.join(root, 'before.txt'), 0o755);
     await chmod(path.join(root, 'before.txt'), 0o755);
@@ -1133,7 +1170,7 @@ garbage
     });
     });
   });
   });
 
 
-  test('applyPreparedChanges rechaza update directo sobre un source inexistente', async () => {
+  test('applyPreparedChanges rejects direct update on a missing source', async () => {
     const root = await createTempDir();
     const root = await createTempDir();
     const target = path.join(root, 'missing.txt');
     const target = path.join(root, 'missing.txt');
 
 
@@ -1150,7 +1187,7 @@ garbage
     );
     );
   });
   });
 
 
-  test('applyPreparedChanges rechaza delete directo sobre un source inexistente', async () => {
+  test('applyPreparedChanges rejects direct delete on a missing source', async () => {
     const root = await createTempDir();
     const root = await createTempDir();
     const target = path.join(root, 'missing.txt');
     const target = path.join(root, 'missing.txt');
 
 
@@ -1166,7 +1203,7 @@ garbage
     );
     );
   });
   });
 
 
-  test('applyPreparedChanges rechaza move directo con source inexistente', async () => {
+  test('applyPreparedChanges rejects direct move with a missing source', async () => {
     const root = await createTempDir();
     const root = await createTempDir();
     const source = path.join(root, 'missing.txt');
     const source = path.join(root, 'missing.txt');
     const target = path.join(root, 'nested/after.txt');
     const target = path.join(root, 'nested/after.txt');
@@ -1185,7 +1222,7 @@ garbage
     );
     );
   });
   });
 
 
-  test('applyPreparedChanges rechaza una transición inválida tras delete previo', async () => {
+  test('applyPreparedChanges rejects an invalid transition after a previous delete', async () => {
     const root = await createTempDir();
     const root = await createTempDir();
     const target = path.join(root, 'sample.txt');
     const target = path.join(root, 'sample.txt');
     await writeFixture(root, 'sample.txt', 'alpha\n');
     await writeFixture(root, 'sample.txt', 'alpha\n');
@@ -1209,7 +1246,7 @@ garbage
     expect(await readText(root, 'sample.txt')).toBe('alpha\n');
     expect(await readText(root, 'sample.txt')).toBe('alpha\n');
   });
   });
 
 
-  test('applyPatch soporta move + update cuando el bloque está stale', async () => {
+  test('applyPatch supports move + update when the block is stale', async () => {
     const root = await createTempDir();
     const root = await createTempDir();
     await writeFixture(
     await writeFixture(
       root,
       root,
@@ -1236,7 +1273,7 @@ garbage
     await expect(readText(root, 'before.txt')).rejects.toThrow();
     await expect(readText(root, 'before.txt')).rejects.toThrow();
   });
   });
 
 
-  test('preparePatchChanges y applyPreparedChanges preservan CRLF con rescate stale + chunk exacto', async () => {
+  test('preparePatchChanges and applyPreparedChanges preserve CRLF with stale rescue + exact chunk', async () => {
     const root = await createTempDir();
     const root = await createTempDir();
     await writeFixture(
     await writeFixture(
       root,
       root,
@@ -1267,7 +1304,7 @@ garbage
     );
     );
   });
   });
 
 
-  test('preparePatchChanges y applyPreparedChanges soportan inserción pura al EOF', async () => {
+  test('preparePatchChanges and applyPreparedChanges support pure insertion at EOF', async () => {
     const root = await createTempDir();
     const root = await createTempDir();
     await writeFixture(root, 'sample.txt', 'top\nanchor\n');
     await writeFixture(root, 'sample.txt', 'top\nanchor\n');
 
 
@@ -1285,7 +1322,7 @@ garbage
     expect(await readText(root, 'sample.txt')).toBe('top\nanchor\nmiddle\n');
     expect(await readText(root, 'sample.txt')).toBe('top\nanchor\nmiddle\n');
   });
   });
 
 
-  test('preparePatchChanges y applyPreparedChanges acumulan dos Update File sobre el mismo path', async () => {
+  test('preparePatchChanges and applyPreparedChanges accumulate two Update File hunks on the same path', async () => {
     const root = await createTempDir();
     const root = await createTempDir();
     await writeFixture(root, 'sample.txt', 'alpha\nbeta\ngamma\n');
     await writeFixture(root, 'sample.txt', 'alpha\nbeta\ngamma\n');
 
 
@@ -1325,7 +1362,7 @@ garbage
     expect(await readText(root, 'sample.txt')).toBe('alpha\nBETA\nGAMMA\n');
     expect(await readText(root, 'sample.txt')).toBe('alpha\nBETA\nGAMMA\n');
   });
   });
 
 
-  test('preparePatchChanges y applyPreparedChanges preservan archivo sin newline final', async () => {
+  test('preparePatchChanges and applyPreparedChanges preserve a file without a final newline', async () => {
     const root = await createTempDir();
     const root = await createTempDir();
     await writeFixture(root, 'sample.txt', 'alpha\nbeta');
     await writeFixture(root, 'sample.txt', 'alpha\nbeta');
 
 
@@ -1346,7 +1383,7 @@ garbage
     expect(await readText(root, 'sample.txt')).toBe('alpha\nomega');
     expect(await readText(root, 'sample.txt')).toBe('alpha\nomega');
   });
   });
 
 
-  test('applyPatch aplica add + update en un mismo patch', async () => {
+  test('applyPatch applies add + update in the same patch', async () => {
     const root = await createTempDir();
     const root = await createTempDir();
     await writeFixture(root, 'sample.txt', 'alpha\nbeta\n');
     await writeFixture(root, 'sample.txt', 'alpha\nbeta\n');
 
 
@@ -1367,7 +1404,7 @@ garbage
     expect(await readText(root, 'sample.txt')).toBe('alpha\nBETA\n');
     expect(await readText(root, 'sample.txt')).toBe('alpha\nBETA\n');
   });
   });
 
 
-  test('applyPatch aplica update + delete en un mismo patch', async () => {
+  test('applyPatch applies update + delete in the same patch', async () => {
     const root = await createTempDir();
     const root = await createTempDir();
     await writeFixture(root, 'sample.txt', 'alpha\nbeta\n');
     await writeFixture(root, 'sample.txt', 'alpha\nbeta\n');
     await writeFixture(root, 'obsolete.txt', 'legacy\n');
     await writeFixture(root, 'obsolete.txt', 'legacy\n');
@@ -1388,7 +1425,7 @@ garbage
     await expect(readText(root, 'obsolete.txt')).rejects.toThrow();
     await expect(readText(root, 'obsolete.txt')).rejects.toThrow();
   });
   });
 
 
-  test('applyPatch aplica move + add en un mismo patch', async () => {
+  test('applyPatch applies move + add in the same patch', async () => {
     const root = await createTempDir();
     const root = await createTempDir();
     await writeFixture(root, 'before.txt', 'alpha\nbeta\n');
     await writeFixture(root, 'before.txt', 'alpha\nbeta\n');
 
 
@@ -1410,7 +1447,7 @@ garbage
     expect(await readText(root, 'before.txt')).toBe('replacement\n');
     expect(await readText(root, 'before.txt')).toBe('replacement\n');
   });
   });
 
 
-  test('rewritePatchText bloquea un patch si la ruta sale por symlink con ancestro faltante', async () => {
+  test('rewritePatchText blocks a patch when the path escapes through a symlink with a missing ancestor', async () => {
     const root = await createTempDir();
     const root = await createTempDir();
     const outside = await createTempDir();
     const outside = await createTempDir();
     await writeFixture(root, 'before.txt', 'alpha\nbeta\n');
     await writeFixture(root, 'before.txt', 'alpha\nbeta\n');
@@ -1432,7 +1469,7 @@ garbage
     );
     );
   });
   });
 
 
-  test('rewritePatchText bloquea el patch completo si cualquier add/delete sale de root aunque haya update reescribible', async () => {
+  test('rewritePatchText blocks the whole patch if any add/delete escapes root even when an update is rewritable', async () => {
     const root = await createTempDir();
     const root = await createTempDir();
     const outsideDir = await createTempDir();
     const outsideDir = await createTempDir();
     await writeFixture(root, 'sample.txt', 'prefix\nstale-value\nsuffix\n');
     await writeFixture(root, 'sample.txt', 'prefix\nstale-value\nsuffix\n');
@@ -1457,7 +1494,7 @@ garbage
     );
     );
   });
   });
 
 
-  test('preparePatchChanges mantiene como blocked un relativo que escapa de root', async () => {
+  test('preparePatchChanges keeps an escaping relative path as blocked', async () => {
     const root = await createTempDir();
     const root = await createTempDir();
 
 
     const error = await preparePatchChanges(
     const error = await preparePatchChanges(
@@ -1478,7 +1515,7 @@ garbage
     );
     );
   });
   });
 
 
-  test('preparePatchChanges rechaza una ruta que sale por symlink con ancestro faltante', async () => {
+  test('preparePatchChanges rejects a path that escapes through a symlink with a missing ancestor', async () => {
     const root = await createTempDir();
     const root = await createTempDir();
     const outside = await createTempDir();
     const outside = await createTempDir();
     await mkdir(path.join(outside, 'real-target'), { recursive: true });
     await mkdir(path.join(outside, 'real-target'), { recursive: true });

+ 20 - 20
src/hooks/apply-patch/resolution.test.ts

@@ -13,7 +13,7 @@ import { createTempDir, DEFAULT_OPTIONS, writeFixture } from './test-helpers';
 import type { PatchChunk } from './types';
 import type { PatchChunk } from './types';
 
 
 describe('apply-patch/resolution', () => {
 describe('apply-patch/resolution', () => {
-  test('readFileLines elimina la línea vacía sintética final', async () => {
+  test('readFileLines removes the final synthetic empty line', async () => {
     const root = await createTempDir();
     const root = await createTempDir();
     const file = path.join(root, 'sample.txt');
     const file = path.join(root, 'sample.txt');
     await writeFixture(root, 'sample.txt', 'alpha\nbeta\n');
     await writeFixture(root, 'sample.txt', 'alpha\nbeta\n');
@@ -21,7 +21,7 @@ describe('apply-patch/resolution', () => {
     expect(await readFileLines(file)).toEqual(['alpha', 'beta']);
     expect(await readFileLines(file)).toEqual(['alpha', 'beta']);
   });
   });
 
 
-  test('resolveChunkStart usa change_context como ancla cuando existe', () => {
+  test('resolveChunkStart uses change_context as an anchor when present', () => {
     const chunk: PatchChunk = {
     const chunk: PatchChunk = {
       old_lines: [],
       old_lines: [],
       new_lines: ['middle'],
       new_lines: ['middle'],
@@ -31,7 +31,7 @@ describe('apply-patch/resolution', () => {
     expect(resolveChunkStart(['top', 'anchor', 'bottom'], chunk, 0)).toBe(2);
     expect(resolveChunkStart(['top', 'anchor', 'bottom'], chunk, 0)).toBe(2);
   });
   });
 
 
-  test('locateChunk rescata prefijo/sufijo y conserva new_lines', () => {
+  test('locateChunk rescues prefix/suffix and preserves new_lines', () => {
     const chunk: PatchChunk = {
     const chunk: PatchChunk = {
       old_lines: [
       old_lines: [
         'const title = "Hola";',
         'const title = "Hola";',
@@ -62,7 +62,7 @@ describe('apply-patch/resolution', () => {
     expect(resolved.canonical_new_lines).toEqual(chunk.new_lines);
     expect(resolved.canonical_new_lines).toEqual(chunk.new_lines);
   });
   });
 
 
-  test('locateChunk canoniza un match unicode tolerante', () => {
+  test('locateChunk canonicalizes a tolerant unicode match', () => {
     const chunk: PatchChunk = {
     const chunk: PatchChunk = {
       old_lines: ['const title = "Hola";'],
       old_lines: ['const title = "Hola";'],
       new_lines: ['const title = "Hola mundo";'],
       new_lines: ['const title = "Hola mundo";'],
@@ -84,7 +84,7 @@ describe('apply-patch/resolution', () => {
     ]);
     ]);
   });
   });
 
 
-  test('locateChunk canoniza un match trim-end tolerante', () => {
+  test('locateChunk canonicalizes a tolerant trim-end match', () => {
     const chunk: PatchChunk = {
     const chunk: PatchChunk = {
       old_lines: ['alpha'],
       old_lines: ['alpha'],
       new_lines: ['omega'],
       new_lines: ['omega'],
@@ -104,7 +104,7 @@ describe('apply-patch/resolution', () => {
     expect(resolved.canonical_new_lines).toEqual(['omega']);
     expect(resolved.canonical_new_lines).toEqual(['omega']);
   });
   });
 
 
-  test('locateChunk ya no rescata un stale trim-only', () => {
+  test('locateChunk no longer rescues a trim-only stale patch', () => {
     const chunk: PatchChunk = {
     const chunk: PatchChunk = {
       old_lines: ['alpha'],
       old_lines: ['alpha'],
       new_lines: ['omega'],
       new_lines: ['omega'],
@@ -115,7 +115,7 @@ describe('apply-patch/resolution', () => {
     ).toThrow('Failed to find expected lines');
     ).toThrow('Failed to find expected lines');
   });
   });
 
 
-  test('locateChunk ya no canoniza un caso indentado peligroso', () => {
+  test('locateChunk no longer canonicalizes a dangerous indented case', () => {
     const chunk: PatchChunk = {
     const chunk: PatchChunk = {
       old_lines: ['enabled: false'],
       old_lines: ['enabled: false'],
       new_lines: ['enabled: true'],
       new_lines: ['enabled: true'],
@@ -132,7 +132,7 @@ describe('apply-patch/resolution', () => {
     ).toThrow('Failed to find expected lines');
     ).toThrow('Failed to find expected lines');
   });
   });
 
 
-  test('locateChunk conserva una blank line final real cuando existe en el archivo', () => {
+  test('locateChunk preserves a real final blank line when it exists in the file', () => {
     const chunk: PatchChunk = {
     const chunk: PatchChunk = {
       old_lines: ['alpha', ''],
       old_lines: ['alpha', ''],
       new_lines: ['omega', ''],
       new_lines: ['omega', ''],
@@ -150,7 +150,7 @@ describe('apply-patch/resolution', () => {
     expect(resolved.canonical_new_lines).toEqual(['omega', '']);
     expect(resolved.canonical_new_lines).toEqual(['omega', '']);
   });
   });
 
 
-  test('locateChunk falla si el patch agrega una blank line final inexistente', () => {
+  test('locateChunk fails if the patch adds a non-existent final blank line', () => {
     const chunk: PatchChunk = {
     const chunk: PatchChunk = {
       old_lines: ['alpha', ''],
       old_lines: ['alpha', ''],
       new_lines: ['omega', ''],
       new_lines: ['omega', ''],
@@ -161,7 +161,7 @@ describe('apply-patch/resolution', () => {
     ).toThrow('Failed to find expected lines');
     ).toThrow('Failed to find expected lines');
   });
   });
 
 
-  test('deriveNewContent resuelve actualizaciones EOF', async () => {
+  test('deriveNewContent resolves EOF updates', async () => {
     const root = await createTempDir();
     const root = await createTempDir();
     const file = path.join(root, 'sample.txt');
     const file = path.join(root, 'sample.txt');
     await writeFixture(root, 'sample.txt', 'alpha\nbeta');
     await writeFixture(root, 'sample.txt', 'alpha\nbeta');
@@ -181,7 +181,7 @@ describe('apply-patch/resolution', () => {
     ).toBe('alpha\nomega');
     ).toBe('alpha\nomega');
   });
   });
 
 
-  test('deriveNewContent preserva CRLF al recomponer contenido', async () => {
+  test('deriveNewContent preserves CRLF while rebuilding content', async () => {
     const root = await createTempDir();
     const root = await createTempDir();
     const file = path.join(root, 'sample.txt');
     const file = path.join(root, 'sample.txt');
     await writeFixture(root, 'sample.txt', 'alpha\r\nbeta\r\ngamma\r\n');
     await writeFixture(root, 'sample.txt', 'alpha\r\nbeta\r\ngamma\r\n');
@@ -200,7 +200,7 @@ describe('apply-patch/resolution', () => {
     ).toBe('alpha\r\nBETA\r\ngamma\r\n');
     ).toBe('alpha\r\nBETA\r\ngamma\r\n');
   });
   });
 
 
-  test('deriveNewContent inserta bloque anclado sin desplazarlo a EOF', async () => {
+  test('deriveNewContent inserts an anchored block without moving it to EOF', async () => {
     const root = await createTempDir();
     const root = await createTempDir();
     const file = path.join(root, 'sample.txt');
     const file = path.join(root, 'sample.txt');
     await writeFixture(root, 'sample.txt', 'top\nanchor\nbottom\n');
     await writeFixture(root, 'sample.txt', 'top\nanchor\nbottom\n');
@@ -220,7 +220,7 @@ describe('apply-patch/resolution', () => {
     ).toBe('top\nanchor\nmiddle\nbottom\n');
     ).toBe('top\nanchor\nmiddle\nbottom\n');
   });
   });
 
 
-  test('deriveNewContent soporta inserción pura al EOF con anchor único', async () => {
+  test('deriveNewContent supports pure insertion at EOF with a single anchor', async () => {
     const root = await createTempDir();
     const root = await createTempDir();
     const file = path.join(root, 'sample.txt');
     const file = path.join(root, 'sample.txt');
     await writeFixture(root, 'sample.txt', 'top\nanchor\n');
     await writeFixture(root, 'sample.txt', 'top\nanchor\n');
@@ -240,7 +240,7 @@ describe('apply-patch/resolution', () => {
     ).toBe('top\nanchor\nmiddle\n');
     ).toBe('top\nanchor\nmiddle\n');
   });
   });
 
 
-  test('resolveUpdateChunks canoniza inserción EOF con anchor tolerante', async () => {
+  test('resolveUpdateChunks canonicalizes EOF insertion with a tolerant anchor', async () => {
     const root = await createTempDir();
     const root = await createTempDir();
     const file = path.join(root, 'sample.txt');
     const file = path.join(root, 'sample.txt');
     await writeFixture(root, 'sample.txt', 'top\n“anchor”\n');
     await writeFixture(root, 'sample.txt', 'top\n“anchor”\n');
@@ -265,7 +265,7 @@ describe('apply-patch/resolution', () => {
     });
     });
   });
   });
 
 
-  test('deriveNewContent falla si una inserción pura no encuentra su anchor', async () => {
+  test('deriveNewContent fails if a pure insertion cannot find its anchor', async () => {
     const root = await createTempDir();
     const root = await createTempDir();
     const file = path.join(root, 'sample.txt');
     const file = path.join(root, 'sample.txt');
     await writeFixture(root, 'sample.txt', 'top\nbottom\n');
     await writeFixture(root, 'sample.txt', 'top\nbottom\n');
@@ -285,7 +285,7 @@ describe('apply-patch/resolution', () => {
     ).rejects.toThrow('Failed to find insertion anchor');
     ).rejects.toThrow('Failed to find insertion anchor');
   });
   });
 
 
-  test('deriveNewContent falla si una inserción pura tiene anchor ambiguo', async () => {
+  test('deriveNewContent fails if a pure insertion has an ambiguous anchor', async () => {
     const root = await createTempDir();
     const root = await createTempDir();
     const file = path.join(root, 'sample.txt');
     const file = path.join(root, 'sample.txt');
     await writeFixture(
     await writeFixture(
@@ -309,7 +309,7 @@ describe('apply-patch/resolution', () => {
     ).rejects.toThrow('Insertion anchor was ambiguous');
     ).rejects.toThrow('Insertion anchor was ambiguous');
   });
   });
 
 
-  test('deriveNewContent falla si un chunk posterior queda ambiguo', async () => {
+  test('deriveNewContent fails if a later chunk remains ambiguous', async () => {
     const root = await createTempDir();
     const root = await createTempDir();
     const file = path.join(root, 'sample.txt');
     const file = path.join(root, 'sample.txt');
     await writeFixture(
     await writeFixture(
@@ -336,7 +336,7 @@ describe('apply-patch/resolution', () => {
     ).rejects.toThrow('ambiguous');
     ).rejects.toThrow('ambiguous');
   });
   });
 
 
-  test('deriveNewContent rescata un EOF stale y conserva el update final', async () => {
+  test('deriveNewContent rescues a stale EOF and preserves the final update', async () => {
     const root = await createTempDir();
     const root = await createTempDir();
     const file = path.join(root, 'sample.txt');
     const file = path.join(root, 'sample.txt');
     await writeFixture(root, 'sample.txt', 'alpha\nstale\nomega');
     await writeFixture(root, 'sample.txt', 'alpha\nstale\nomega');
@@ -356,13 +356,13 @@ describe('apply-patch/resolution', () => {
     ).toBe('alpha\nnew\nomega');
     ).toBe('alpha\nnew\nomega');
   });
   });
 
 
-  test('applyHits preserva el salto de línea final', () => {
+  test('applyHits preserves the final newline', () => {
     expect(
     expect(
       applyHits(['start', 'end'], [{ start: 0, del: 1, add: ['next'] }]),
       applyHits(['start', 'end'], [{ start: 0, del: 1, add: ['next'] }]),
     ).toBe('next\nend\n');
     ).toBe('next\nend\n');
   });
   });
 
 
-  test('applyHits puede preservar un archivo sin newline final', () => {
+  test('applyHits can preserve a file without a final newline', () => {
     expect(
     expect(
       applyHits(
       applyHits(
         ['start', 'end'],
         ['start', 'end'],

+ 4 - 1
src/hooks/apply-patch/rewrite.ts

@@ -335,6 +335,9 @@ export async function rewritePatch(
         count + (hunk.type === 'update' ? hunk.chunks.length : 0),
         count + (hunk.type === 'update' ? hunk.chunks.length : 0),
       0,
       0,
     );
     );
+    if (pathsNormalized) {
+      rewriteModes.add('normalize:patch-paths');
+    }
 
 
     const dependencyGroups = new Map<string, RewriteDependencyGroup>();
     const dependencyGroups = new Map<string, RewriteDependencyGroup>();
 
 
@@ -496,7 +499,7 @@ export async function rewritePatch(
           changed: true,
           changed: true,
           rewrittenChunks: 0,
           rewrittenChunks: 0,
           totalChunks,
           totalChunks,
-          rewriteModes: ['normalize:patch-paths'],
+          rewriteModes: [...rewriteModes].sort(),
         };
         };
       }
       }