Skip to content

Search-Replace Editor

本课交付结果

你将交付 editFile({ path, oldText, newText }):只有旧文本恰好出现一次才编辑;写入同目录临时文件后原子替换,并返回变更前后的 SHA-256。缺失、多匹配和 no-op 都不会改变原文件。

岗位问题

模型给出的“把 X 改成 Y”常常过于宽泛。直接 replace() 会悄悄只改第一个匹配,批量正则又可能改错多个位置;直接覆盖文件则会在进程中断时留下半写状态。编辑器必须先证明目标唯一,再让文件变化成为一次可审计提交。

前置检查

前置知识快照

先通过安全路径课:

bash
pnpm --filter @learn-traeai/coding-agent-bootcamp verify:lesson -- 08

理解 UTF-8、文件 mode、同一文件系统内的 rename 语义和内容哈希。真实总项目还要把本课编辑器接入工作区 realpath 边界。

Search-Replace 不是普通字符串便利函数,而是带前置条件的编辑事务。oldText 表示模型声称当前文件中唯一存在的锚点;只有磁盘事实与声明完全一致,编辑器才提交。缺失意味着上下文过期,多匹配意味着定位不充分,两者都应在写入前失败。

上一周建立命令执行边界,本周开始处理文件副作用。编辑器仍必须复用第 8 课安全路径:模型只能提供工作区相对路径,真实目标不得穿过链接逃逸。本 Lab 接收临时绝对路径以聚焦原子替换,正式项目由上层先完成路径解析。

原理拆解

流程固定为:读取原始字节 → 验证 old/new 非空且不同 → 统计唯一匹配 → 构造新字节 → 同目录写临时文件 → 保留 mode → rename → 返回双哈希。任何验证失败都发生在首次写入之前。

mermaid
flowchart TD
  A[path + oldText + newText] --> B[读取原始 Buffer]
  B --> C{old 非空且 new 不同?}
  C -- 否 --> D[失败,原文件不变]
  C -- 是 --> E[查找第一次与第二次]
  E --> F{恰好一次?}
  F -- 否 --> D
  F -- 是 --> G[构造 next Buffer]
  G --> H[同目录临时文件]
  H --> I[保留 mode]
  I --> J[rename 原子替换]
  J --> K[before/after SHA-256]

唯一匹配是一种乐观并发控制。模型读取文件后到真正编辑前,用户或其他 Agent 可能已经修改内容;旧锚点消失时,编辑器拒绝把过期意图强行套到新文件。锚点出现多次则无法证明目标位置,要求模型扩大上下文,而不是猜第一个。

String.replace 默认只替换首个匹配,看起来满足许多快乐路径,却会把歧义隐藏成成功。显式用第一次 indexOf 找位置,再从第一段之后查第二次,可以快速判定唯一;需要错误显示准确次数时再计数。正确性优先于少一次扫描。

no-op 也要拒绝。oldText === newText 若返回成功,会在 Trace 中生成伪变更、浪费后续测试与评审,还可能让恢复循环误以为采取了新策略。编辑结果必须代表真实字节变化,beforeHash 与 afterHash 也应不同。

直接对目标 writeFile 会先 truncate,进程崩溃或磁盘写满时留下半文件。更稳做法是在目标同目录写完整临时文件,设置权限,再 rename 替换。POSIX 同一文件系统内 rename 对目录项原子,读者看到旧文件或新文件,不会看到中间内容。

临时文件放在系统 /tmp 可能与仓库跨设备,rename 返回 EXDEV,无法提供相同原子保证。同目录还继承相同挂载与权限语境。临时名包含进程 id 与随机 UUID,避免并发编辑碰撞;以点开头便于识别,但不应依赖隐藏语义保证安全。

文件 mode 属于内容之外的状态。用默认权限写临时文件再 rename,可能让可执行脚本失去执行位或敏感配置变得过宽。先 stat 原文件,将 mode用于临时文件并明确 chmod。生产实现还要考虑所有者、ACL、扩展属性与 symlink 策略。

SHA-256 双哈希建立审计证据:beforeHash 证明编辑基于哪份字节,afterHash 证明最终期望内容。哈希不泄露完整源码,适合 Trace、幂等检查与回放;它不替代 diff,人类评审仍需看具体修改。

代码实验

失败实现

最短实现没有唯一性与原子性:

ts
const source = await readFile(path, "utf8");
await writeFile(path, source.replace(oldText, newText));

核心实现先证明唯一,再创建完整临时文件:

ts
const before = await readFile(input.path);
const source = before.toString("utf8");
const first = source.indexOf(input.oldText);
if (first < 0) throw new Error("没有找到 oldText");
const second = source.indexOf(input.oldText, first + input.oldText.length);
if (second >= 0) throw new Error("oldText 必须唯一");

const next = Buffer.from(
  source.slice(0, first) + input.newText +
  source.slice(first + input.oldText.length),
);
const temporary = join(dirname(input.path), `.${basename(input.path)}.${randomUUID()}.tmp`);
await writeFile(temporary, next, { mode: (await stat(input.path)).mode });
await rename(temporary, input.path);

参考实现把写、chmod 与 rename 放入 try,并在 catch 中 best-effort unlink 临时文件。清理失败不应覆盖原始错误,但应进入内部诊断;否则磁盘权限问题会留下越来越多临时文件。

打开本课 Lab

bash
pnpm --dir bootcamps/coding-agent/labs/16-search-replace-editor/starter test
pnpm --dir bootcamps/coding-agent/labs/16-search-replace-editor/solution test

Starter 应显示 EXERCISE_NOT_IMPLEMENTED:实现原子唯一替换;Solution 应通过 5 项测试。

失败测试要比较原始 Buffer,而不是重新读取字符串:

ts
it("多匹配时保持字节不变", async () => {
  await writeFile(path, "x x");
  const before = await readFile(path);
  await expect(editFile({ path, oldText: "x", newText: "y" }))
    .rejects.toThrow("出现 2 次");
  expect(await readFile(path)).toEqual(before);
});

关键实现讲解

用第一次和第二次 indexOf 快速证明唯一性;需要错误中的准确次数时再计数。临时文件必须位于目标同目录,避免跨文件系统 rename 失去原子性。catch 分支清理临时文件,成功后目录中也只剩目标文件。

编辑 UTF-8 文本时,字符串索引是 UTF-16 code unit,但切片使用同一单位,因此替换仍自洽。读取包含非法 UTF-8 或二进制内容时,解码可能替换字节,重新编码无法保持未修改部分;正式工具应先验证文本编码,二进制文件交给专门工具。

rename 原子不等于持久化。断电后要保证数据落盘,可能需要 fsync 临时文件与父目录;这会增加延迟并具有平台差异。普通本地 Agent可接受 rename 级原子,高可靠服务应明确 durability 等级,不要把两个概念混淆。

运行与验证

真实运行输出

bash
pnpm --filter @learn-traeai/coding-agent-bootcamp verify:lesson -- 16
pnpm --filter @coding-agent/editor test

失败产物检查:测试在缺失与多匹配前读取 Buffer,失败后再次读取并做字节级相等断言;成功结果的两个 64 位十六进制哈希必须不同。

真实运行记录应同时保存 Starter 失败数和 Solution 五项全绿;关键结果如下:

text
solution: 5 passed, 0 failed
覆盖: 唯一替换与双哈希 / 缺失零修改 / 多匹配拒绝 / no-op / 无临时残留

还要检查 mode、换行与目录清单。成功后目录只剩目标文件,before/after 哈希都是 64 位小写十六进制且不同;失败后目标 Buffer、mode 与目录内容完全不变。

常见失败与排查

故障案例 1

编辑成功却改错了第一个同名片段。

症状:文件有两个相同函数调用,模型意图修改第二个,结果第一个被替换。

根因:直接使用 replace,没有把唯一锚点作为提交前置条件。

定位:记录 oldText 出现次数,使用含两个相同片段的夹具;若仍成功,定位约束缺失。

修复:缺失与多匹配都拒绝,提示模型扩展 oldText 直到唯一;失败前不得写磁盘。

故障案例 2

进程中断后文件只剩半段。

症状:磁盘写满或进程被终止后,目标文件被截空或含部分新内容。

根因:直接打开目标覆盖,truncate 已发生而新内容尚未完整写入。

定位:注入 write/rename 故障并比较目标 Buffer;检查实现是否先写目标路径。

修复:同目录临时文件完整写入、权限设置成功后再 rename;失败清理临时文件。

故障案例 3

脚本内容正确却无法执行。

症状:编辑后的脚本丢失执行位,CI 报 permission denied。

根因:临时文件采用默认 mode,rename 后替换了原文件元数据。

定位:编辑前后 stat 比较 mode,使用带执行位夹具运行测试。

修复:创建临时文件时复制原 mode并显式 chmod;扩展审计 ACL 与其他元数据需求。

课后作业

加入换行风格保留、文件 mode 测试与 fsync 策略;模拟 rename 失败,证明临时文件被清理且原始字节和哈希不变。

进阶要求是把 beforeHash 作为可选 compare-and-swap 条件,在 rename 前重新读取或通过更强文件句柄验证,拒绝并发外部修改。再实现可注入文件系统适配器,精确模拟 write、chmod、fsync、rename 和 cleanup 各阶段失败。

验收 Rubric

维度通过标准常见扣分
定位oldText 必须唯一缺失或多匹配仍写入
原子性同目录临时文件后 rename直接覆盖目标
证据返回前后内容哈希只返回成功布尔值
清理成功失败均无残留临时文件留下脏文件

总项目增量

总项目包:@coding-agent/editor

总项目路径:packages/editor/src/search-replace.ts

总项目验证命令:pnpm --filter @coding-agent/editor test

本课解决单文件唯一替换;下一课把新增、删除和多文件修改组合成可回滚事务。

延伸阅读

方案对比与工程取舍

按行号编辑最紧凑,但文件插入一行就会错位;正则替换表达力强,却更容易过匹配与受转义影响;唯一文本锚点直观、可审计,缺点是格式变化就冲突;语法树编辑理解语言结构,成本高且依赖解析器。Coding Agent常用唯一文本替换作为安全基线,复杂重构再调用专用 AST 工具。

整文件重写可以让模型自由生成最终内容,却难证明只改意图范围,也更容易破坏换行、编码与未读部分。search-replace把变更限制在明确锚点,适合小修改;补丁事务适合多段和多文件。工具选择应按变更规模升级,不要用最强能力处理所有任务。

内容哈希可以在调用前由模型上下文携带,形成 CAS:只有当前 beforeHash 等于读取时哈希才编辑。oldText 唯一性只检查局部锚点,文件其他位置仍可能被用户修改;全文件哈希能防止覆盖并发改动,但也会因无关格式变化产生冲突。产品可根据协作风险选择局部或全局前置条件。

并发两个编辑器都读取同一旧文件,分别验证后写临时文件,后 rename 者可能覆盖先提交者。要真正串行化,可按路径加锁、使用版本哈希在提交前复检,或让所有编辑经过单一事务队列。原子 rename只保护单次替换完整,不自动提供并发隔离。

锁文件本身也要防崩溃残留与跨进程语义。基于内存 mutex 只适合同一进程,文件锁或操作系统锁需要超时与所有者记录。更简单的 Agent CLI通常串行执行工具,并用 beforeHash检测外部变化;服务端并发则需要正式事务设计。

换行风格应自然保留,因为只拼接原字符串未修改部分;newText 若使用不同换行,文件会混合。工具可检测原文件主换行并规范化 newText,或明确按调用方原样插入。自动转换可能改变字符串字面量,必须只作用于结构换行并写测试。

BOM 与编码更敏感。Buffer.toString("utf8") 会把 BOM转成字符,重新 Buffer 后通常保留,但非法字节会丢失。工具应只接受确认的 UTF-8 文本,哈希基于原始 Buffer;UTF-16、二进制和未知编码明确拒绝,不要“尽力编辑”后损坏文件。

文件末尾换行也是仓库约定。唯一替换若锚点包含末尾结构可保持,newText 可能意外去掉。评审 diff 能发现,但编辑器也可以返回行数变化与换行标记。基础工具不擅自添加末尾换行,以免改变用户意图。

临时文件命名需要避免被项目 watcher 当作源码编译。点文件通常被忽略,但不是保证;可以使用专用后缀,并在极短窗口完成写与 rename。Watcher仍可能观察创建事件,构建系统应忽略模式。真正要求无中间可见对象的环境需要更底层文件 API。

临时文件权限先按原 mode创建,再 chmod,是为了抵御 umask 与平台差异。敏感文件不应在任何瞬间以过宽权限存在;创建选项必须尽可能一次给出正确 mode。所有者与 ACL 无法简单由普通用户复制时,工具应拒绝或采用原文件目录内受控替换策略。

rename 覆盖语义在 Windows 与 POSIX 可能不同,打开文件、杀毒软件和权限都可能导致失败。跨平台测试要真实运行,不只 mock;失败时原目标应仍在,临时文件被清理或进入可识别恢复队列。不要在 catch 中删除目标以“帮助”rename,那会破坏原子保证。

cleanup 是 best effort,但残留应可追踪。启动时可以扫描本工具命名格式、验证目标与年龄后清理,不能删除任意点文件。残留临时文件不等于目标已提交,恢复逻辑根据 Trace 和哈希判断,避免把未完成内容误当正式文件。

哈希返回应基于实际写入 Buffer,不应在 rename 后再次读取并接受外部变化作为 afterHash。事务内 next 是预期提交内容;需要证明磁盘事实,可在 rename 后校验一次,发现不一致报告并进入更高层恢复。区分 expected hash 与 observed hash 能定位并发或文件系统异常。

错误消息不要回显完整 oldText 或文件内容,锚点可能含秘密。返回代码 MATCH_NOT_FOUNDMATCH_NOT_UNIQUE、计数和规范化相对路径足以让 Agent恢复;内部 Trace保存哈希与受限摘要。结构化错误也能接入第 19 课 Failure Taxonomy。

测试要覆盖空 oldText。空字符串在每个位置都匹配,若不先拒绝,计数与替换语义失控。newText 可以为空,表示删除唯一片段;old/new相同拒绝。参数边界写成明确合同,比依赖字符串库的特殊行为可靠。

成功后无临时残留的测试需要排序目录结果,避免平台遍历顺序噪声;失败后也应检查。再检查文件 mode、内容 Buffer和双哈希。快乐路径只证明替换文本正确,无法证明原子性与清理。

故障注入是验证原子协议的关键。文件系统适配器在临时 write、chmod、rename 分别抛错,预期目标始终保持 before Buffer,临时文件最终不存在。rename 已成功后 cleanup不应再删除目标;状态转换要明确提交点。

审计可以记录 edit id、工作区、相对路径、before/after哈希、锚点哈希、字节与行数变化、持续时间和结果码。无需长期保存完整内容,实际 diff由 Git review生成。多层证据共同回答意图、提交事实与最终工作树差异。

用课程夹具完整演练一次。原文件是 const a = 1;,锚点 a = 1 第一次索引有效,第二次查找失败,说明唯一。编辑器构造 const a = 2; 的 Buffer,读取原 mode,在同目录生成随机临时名,完整写入并 rename。结果返回两个不同的六十四位哈希,目录仍只有 a.ts。这一条路径同时证明定位、提交和证据。

缺失路径则在首次 indexOf 得到负值时停止。测试先保存 Buffer,捕获 MATCH_NOT_FOUND 后逐字节比较,没有临时文件,没有 mode变化。重要的是实现不能为了生成更友好 diff 先写任何内容;所有诊断都从内存里的 source计算。

多匹配路径先找到第二处,再为了错误消息准确统计总数。即使文件有一万个匹配,计数也只在即将失败时付出成本;成功路径保持两次搜索。优化不能改变语义,若文件极大应在读取工具层已有尺寸限制,编辑器不接收无限文本。

删除片段是合法操作:newText为空,oldText唯一,next由前后切片连接。删除整个文件内容后会得到空文件,而不是删除文件;真正文件删除属于下一课 patch协议。工具能力越窄,调用者越不容易把空文本与不存在状态混淆。

输入路径的 basename 可能包含特殊字符,临时名拼接必须仍落在同目录,并避免把分隔符从模型输入带入。正式实现先拿到已验证 absolute target,再由 path库取得 dirname/basename;不要手写字符串切割。临时文件打开还可使用独占创建标志,防止极小概率碰撞或预置链接攻击。

symlink目标需要明确策略。若直接 read/write链接,rename通常替换链接目录项而不是链接目标,行为可能与用户预期不同;若解析 realpath,又可能离开工作区。安全默认是上层解析真实目标并拒绝工作区外链接,或完全拒绝编辑链接文件。策略必须通过真实链接测试。

硬链接是另一种别名。rename新文件会让当前路径指向新 inode,其他硬链接仍指向旧内容;直接覆盖则会修改所有链接。原子策略选择了路径级替换,通常更安全,却需在文档中说明。需要保持硬链接关系的场景不适合该工具。

文件在读取后被外部删除,临时写仍可能成功并由 rename创建新目标;这是否允许取决于并发策略。beforeHash CAS严格模式应在提交前确认目标身份和内容仍匹配,不匹配就删除临时并返回冲突。单用户串行 Lab省略,但生产协作必须考虑。

只校验内容哈希仍可能忽略 mode被外部修改。若权限变化也属于用户并发编辑,CAS快照可包含 inode、mtime、size与mode;这些元数据也可能自然变化,强度越高冲突越多。系统应根据不覆盖用户工作这一目标选择条件,而不是盲目比较所有 stat字段。

哈希计算两次线性扫描的成本对源码文件很小;巨型文件本来应由尺寸门禁拒绝。不要为了省哈希返回不稳定的 mtime,后者可碰撞且不代表内容。内容寻址为后续 Trace、重复失败检测与事务组合提供统一身份。

用户撤销不应依赖哈希反推出内容。Runtime可以在上层事务保存 before Buffer,或让 Git/worktree承担历史;本课只返回证据,不长期保存源码。职责分离避免一个小编辑工具变成无界备份系统。

编辑前后的日志若包含完整 old/new文本,会复制仓库秘密;只记录哈希与长度。需要调试时在用户本地、受控保留有限 diff,默认不开启远程上传。课程内容本身也应提醒:可审计不等于收集越多越好。

变异测试可以删掉第二次 indexOf、把临时目录改成系统 tmp、跳过 chmod、先写目标、允许 no-op。每种错误实现都应至少杀死一项测试。若五项基础测试没有覆盖 mode与故障注入,进阶套件必须补足,不能把“Solution全绿”夸大成完整生产证明。

最终验收还应在真实工作树中编辑一个带中文、不同换行和执行位的无害夹具,随后由 Git diff确认只有授权片段改变。再故意制造缺失、多匹配与 rename失败,证明三条失败路径都保持原哈希、原权限且无临时残留。单元测试证明算法,真实文件系统演练证明平台语义,两类证据共同支撑“可验证原子编辑”。

这套证据也要在升级Node或更换文件系统适配器后重跑,因为rename、mode与打开文件行为具有平台语义。编辑结果只有在当前支持环境中被真实验证,才能进入后续补丁事务。

稳健。

下一课衔接

唯一替换只覆盖一个已有文件的一处变更。真实功能往往同时修改实现与测试,新增文件、删除旧文件,并在校验失败时全部撤销。下一课实现 Unified Patch Transaction:先预检所有路径与 before内容,保存 Buffer/null 快照,再批量落盘;任何失败按逆序只恢复本事务触及的字节,不使用 Git reset。

  • POSIX rename 原子性与跨设备限制。
  • Compare-and-swap 思想在文本编辑中的应用。
  • 内容寻址与变更审计。

从零实现 Mini Code Agent Runtime