diff --git a/.changeset/fix-apply-symlink-conflict.md b/.changeset/fix-apply-symlink-conflict.md new file mode 100644 index 0000000..366dfbf --- /dev/null +++ b/.changeset/fix-apply-symlink-conflict.md @@ -0,0 +1,5 @@ +--- +"@stainless-code/codemap": patch +--- + +Reject apply targets that are symlinks so phase-2 rename cannot replace a link with a regular file. diff --git a/src/application/apply-engine.test.ts b/src/application/apply-engine.test.ts index 758d9b9..0091a14 100644 --- a/src/application/apply-engine.test.ts +++ b/src/application/apply-engine.test.ts @@ -1,10 +1,12 @@ import { describe, expect, it } from "bun:test"; import { chmodSync, + lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, + symlinkSync, writeFileSync, } from "node:fs"; import { tmpdir } from "node:os"; @@ -597,6 +599,38 @@ describe("applyDiffPayload", () => { expect(result.conflicts[0]?.reason).toBe("path escapes project root"); }); + + it("rejects symlinked file_path and leaves the symlink intact", () => { + const root = tmpProject(); + writeSource(root, "real.ts", "const foo = 1;\n"); + symlinkSync(join(root, "real.ts"), join(root, "link.ts")); + + const result = applyDiffPayload({ + rows: [ + { + file_path: "link.ts", + line_start: 1, + before_pattern: "foo", + after_pattern: "bar", + }, + ], + projectRoot: root, + dryRun: false, + }); + + expect(result.applied).toBe(false); + expect(result.conflicts).toEqual([ + { + file_path: "link.ts", + line_start: 1, + before_pattern: "foo", + actual_at_line: "", + reason: "path is a symlink", + }, + ]); + expect(lstatSync(join(root, "link.ts")).isSymbolicLink()).toBe(true); + expect(readSource(root, "real.ts")).toBe("const foo = 1;\n"); + }); }); describe("overlap detection (F2 — triangulated review 2026-05-06)", () => { diff --git a/src/application/apply-engine.ts b/src/application/apply-engine.ts index b0dde9b..1794038 100644 --- a/src/application/apply-engine.ts +++ b/src/application/apply-engine.ts @@ -30,7 +30,7 @@ */ import { randomBytes } from "node:crypto"; -import { readFileSync, renameSync, writeFileSync } from "node:fs"; +import { lstatSync, readFileSync, renameSync, writeFileSync } from "node:fs"; import { isAbsolute, resolve } from "node:path"; import { @@ -82,6 +82,7 @@ export type ConflictReason = | "line out of range" | "line content drifted" | "path escapes project root" + | "path is a symlink" | "duplicate edit on same line"; /** Q5 envelope shape — single shape across `dry-run` and `apply` modes. */ @@ -172,8 +173,23 @@ export function applyDiffPayload(opts: ApplyDiffPayloadOpts): ApplyJsonPayload { let source = sourceCache.get(canonicalPath); if (source === undefined) { + const absPath = resolve(resolvedRoot, canonicalPath); try { - source = readFileSync(resolve(resolvedRoot, canonicalPath), "utf8"); + if (lstatSync(absPath).isSymbolicLink()) { + conflicts.push({ + file_path: canonicalPath, + line_start: lineStart, + before_pattern: before, + actual_at_line: "", + reason: "path is a symlink", + }); + continue; + } + } catch { + // Missing path — readFileSync below reports "file missing". + } + try { + source = readFileSync(absPath, "utf8"); } catch { conflicts.push({ file_path: canonicalPath,