Book 第八章:Edit 为什么是补丁协议
第三部分:本地执行面

第八章:Edit 为什么是补丁协议

解释 Edit 为什么本质上是一套精确补丁协议,而不是普通字符串替换。

1. 为什么 Edit 也值得单独拆

很多人第一次看 FileEditTool,会把它理解成:

  • 找到一段字符串
  • 替换成另一段字符串

这个理解不算错,但只对了一层皮。

Claude Code 里的 Edit,本质上不是“文本替换函数”,而是一个带前置事实约束、带并发防护、带 patch 生成、带宿主联动的精确编辑协议

它解决的其实是三个问题:

  1. 模型怎么安全地描述“我要改哪一段”
  2. 运行时怎么确认“这次修改仍然基于最新事实”
  3. 宿主怎么把一次修改同步到历史、LSP、IDE 和 transcript

所以这不是一个小工具,而是 Claude Code 本地编码能力的核心组成部分。

2. 先说结论

我对这份实现的判断是:

FileEditTool 的核心不是“改文件”,而是“以 Read 结果为前提的、可验证的精确字符串补丁协议”。

它最重要的设计点有这些:

  • 强制“先读后改”
  • old_string 做定位,而不是直接让模型传行号或 AST patch
  • 对歧义匹配、过时读取、并发改动、settings 文件、team memory 泄密做前置防护
  • 写入时保留原编码和换行风格
  • 修改后主动通知 LSP 和 VS Code
  • 输出不是“新文件全文”,而是结构化 patch + 摘要结果

换句话说,Claude Code 对 Edit 的理解不是:

“写字符串到磁盘”

而是:

“把模型的一次局部改动意图,安全地落成一次真实文件补丁”。

3. 源码锚点

建议先看这些位置:

  • src/tools/FileEditTool/FileEditTool.ts:86
  • src/tools/FileEditTool/FileEditTool.ts:115
  • src/tools/FileEditTool/FileEditTool.ts:125
  • src/tools/FileEditTool/FileEditTool.ts:137
  • src/tools/FileEditTool/FileEditTool.ts:266
  • src/tools/FileEditTool/FileEditTool.ts:387
  • src/tools/FileEditTool/FileEditTool.ts:425
  • src/tools/FileEditTool/FileEditTool.ts:465
  • src/tools/FileEditTool/FileEditTool.ts:497
  • src/tools/FileEditTool/FileEditTool.ts:517
  • src/tools/FileEditTool/FileEditTool.ts:575
  • src/tools/FileEditTool/prompt.ts:7
  • src/tools/FileEditTool/utils.ts:73
  • src/tools/FileEditTool/utils.ts:104
  • src/tools/FileEditTool/utils.ts:262
  • src/tools/FileEditTool/utils.ts:732

4. 整体结构图

flowchart TD
    A["模型发起 Edit(file_path, old_string, new_string, replace_all)"] --> B["backfillObservableInput()<br/>规范化路径"]
    B --> C["checkPermissions()<br/>write 权限"]
    C --> D["validateInput()"]

    D --> D1["team memory secret guard"]
    D --> D2["deny rule / UNC / 大文件"]
    D --> D3["文件是否存在"]
    D --> D4["必须先 Read"]
    D --> D5["是否已过时"]
    D --> D6["old_string 是否能唯一匹配"]
    D --> D7["settings 文件额外校验"]

    D7 --> E["call()"]
    E --> F["skill discovery / diagnosticTracker / fileHistory"]
    F --> G["同步读当前文件并保留编码、换行"]
    G --> H["再次做 stale check"]
    H --> I["findActualString + preserveQuoteStyle"]
    I --> J["getPatchForEdit()"]
    J --> K["writeTextContent()"]
    K --> L["通知 LSP / VSCode"]
    L --> M["更新 readFileState / telemetry / git diff"]
    M --> N["tool_result + structuredPatch"]

这个流程最重要的一点是:

Claude Code 把“编辑文件”拆成了“校验意图 -> 确认前提 -> 原子写入 -> 通知外围系统”四段。

5. Prompt 已经把它定义成“精确替换协议”

src/tools/FileEditTool/prompt.ts 有几条要求特别关键:

  • 编辑前必须至少用一次 Read
  • old_string / new_string 绝不能包含 Read 输出里的行号前缀
  • 应该尽量使用最小但足够唯一的上下文
  • 如果会命中多个位置,要么补更多上下文,要么显式 replace_all
  • 优先改已有文件,不要轻易新建文件

这几条看起来像使用说明,其实已经暴露了设计哲学:

Claude Code 不想让模型“自由写文件”,而是想让模型“提交一个可验证的局部替换意图”。

6. 输入校验阶段已经做了很多写前治理

6.1 路径先标准化,再进权限链路

Read 一样,Edit 也先 expandPath(file_path),避免:

  • ~
  • 相对路径
  • Windows 分隔符差异

把后面的权限匹配和 readFileState 对齐全部做乱。

6.2 Edit 走的是 write 权限,不是 edit 特权

checkPermissions() 最终调的是:

  • checkWritePermissionForTool(...)

这说明 Claude Code 在权限语义上并没有把“局部编辑”单独建成一类,而是把它放在统一的写入权限模型里。

6.3 先挡掉 team memory 泄密

一上来就有:

  • checkTeamMemSecrets(fullFilePath, new_string)

也就是说,某些路径不是不能改,而是不能借编辑把 secret 写进去

这类检查不是文件系统安全,而是产品语义安全。

6.4 old_string === new_string 不直接放行

如果旧串和新串一样,它会返回失败,而且行为是:

  • behavior: 'ask'

这说明 Claude Code 认为这种调用通常意味着模型搞错了,不是一个正常的 no-op。

6.5 deny rule 和 UNC 路径也都在前面处理

这里和 Read 很像:

  • deny rule 提前短路
  • UNC 路径避免过早触发 SMB 认证

这再次说明 Claude Code 把“路径本身的副作用”当成工具层第一等公民。

6.6 大文件编辑会被硬挡

它显式挡掉超过 1 GiB 的文件。

源码注释说得很清楚:

  • 这主要是为了避免 V8/Bun 的字符串上限和 OOM 风险

也就是说,它关心的不是“能不能编辑”,而是“运行时能不能稳住”。

6.7 文件是否存在,影响的是协议语义

这里的处理非常细:

  • 文件不存在且 old_string === '':允许,视为创建新文件
  • 文件不存在但 old_string !== '':拒绝,并给相似路径建议
  • 文件存在且 old_string === '':只有文件本来就是空文件才允许

这不是随手写的 if/else,而是在明确区分两种语义:

  1. 在现有文件里替换
  2. 用空旧串表达“从空文件开始写”

6.8 Notebook 不允许走 Edit

如果目标是 .ipynb,它会直接说:

  • 这是 notebook,请改用 NotebookEditTool

这说明 Claude Code 不允许模型把 notebook 当 JSON 文本乱改。

6.9 不先 Read 就不能 Edit

这是 Edit 最关键的一条约束。

如果 readFileState 里没有这个文件,或者读到的是 partial view,就直接报:

  • 文件还没读过,先读再写

这说明 Claude Code 在工具层内建了一条非常重要的工程原则:

修改必须基于已知事实。

6.10 文件一旦在读后被改动,就必须重新读

它会比较:

  • 上次读取时间
  • 当前文件修改时间

如果文件变了,一般直接拒绝。

但这里有一个很细的兼容逻辑:

  • Windows 上时间戳可能会被云同步、杀毒软件误触发
  • 如果上次是整文件读取,而且当前内容和上次读取内容完全一致,就允许继续

这个细节特别能说明作者的成熟度:

它既防止 stale write,也知道现实世界的文件时间戳并不总可靠。

6.11 old_string 不只是查找,还会做 quote normalization

它不是简单 file.includes(old_string),而是先走:

  • findActualString(...)

这个函数会在找不到时再试一轮 quote normalization,把花括号引号和直引号做归一化匹配。

这说明作者知道模型输出文本和真实文件文本之间,可能会在“视觉上相同、字节上不同”的层面出问题。

6.12 匹配到多处但没开 replace_all,直接拒绝

如果同一个 old_string 在文件里命中多个位置,而 replace_all 不是 true,它会明确拒绝,并告诉模型:

  • 要么提供更多上下文,做到唯一
  • 要么显式声明全量替换

这里体现的是一个很稳的原则:

有歧义的编辑意图,不自动猜。

6.13 Claude settings 文件还有专门校验

validateInputForSettingsFileEdit(...) 会模拟应用修改后的最终内容,再判断是否合法。

这说明 Claude Code 不只是做“文本替换成功没成功”的校验,还会对某些关键配置文件做语义后验校验

7. call() 才是真正的写入编排器

7.1 一开头先做 skill 发现

Read 一样,Edit 也会根据路径:

  • 发现 skill 目录
  • 动态加载 skill
  • 激活条件 skill

这说明 Claude Code 把“正在修改哪个文件”也视为能力上下文的一部分。

7.2 写入前先通知诊断系统和文件历史系统

这里先做了两件事:

  • diagnosticTracker.beforeFileEdited(...)
  • fileHistoryTrackEdit(...)

尤其 `fileHistoryTrackEdit(…)“ 很关键,它说明 Claude Code 在 edit 之前就先留备份,不等出事了再想回滚。

7.3 先确保目录存在,但故意放在关键区外面

源码注释特别强调:

  • mkdir
  • fileHistoryTrackEdit

这些 await 都必须放在“关键区”外。

因为一旦在“stale check 和真正写盘之间”再插入异步等待,并发编辑就可能穿插进来。

这其实是在手工维护一个小型原子区。

7.4 读取当前文件时保留编码和换行风格

readFileForEdit(...) 读出来的不只是内容,还有:

  • encoding
  • lineEndings

后面 writeTextContent(...) 会用这些信息写回。

这说明 Edit 的目标不是“让内容变对”,而是“让内容变对,同时不要顺手把文件编码/换行风格改坏”。

7.5 写前还要再做一轮 stale check

validateInput() 里已经做过一次“你是不是基于旧内容在改”。

call() 里还是会再做一次。

原因很简单:

  • 校验通过到真正写盘之间,文件还可能再变

所以它再次比较:

  • 最新修改时间
  • readFileState
  • 必要时回退到内容比较

这说明 Claude Code 把 stale protection 当成写入阶段约束,不是表面校验。

8. 真正的 patch 生成也有不少门道

8.1 findActualString() 先找到“文件里真实存在的旧串”

如果模型给的是直引号,而文件里是弯引号,findActualString() 会把真实命中的那段提出来。

8.2 preserveQuoteStyle() 会把新串也改成同样的引号风格

这步特别漂亮。

如果旧串是通过 quote normalization 才命中的,说明文件里原本用的是:

  • “ ”
  • ‘ ’

那新串也会被转成同样风格,而不是把整个文件的排版习惯悄悄打乱。

这说明 Claude Code 对“编辑正确”的定义不仅是逻辑正确,还包括:

  • 风格保持
  • 排版保持

8.3 getPatchForEdit() 不是为了写盘,而是为了补丁表达

这个函数会:

  • 应用编辑
  • 生成 structuredPatch
  • 返回 updatedFile

也就是说,Claude Code 把“补丁表示”作为编辑结果的一部分,而不是 UI 临时算出来的附属物。

8.4 多次编辑的等价判断,不只看字面参数

areFileEditsInputsEquivalent(...) 的逻辑也很有意思。

它不仅会看:

  • 文件是不是同一个
  • old/new/replace_all 字面是不是一样

必要时还会尝试把两组 edits 真正应用到内容上,再比较结果是不是等价。

这说明 Claude Code 对“两个编辑是不是同一件事”的理解,是语义等价,不是字面等价。

9. 写盘之后,Claude Code 还会联动一大圈系统

9.1 LSP 诊断链路会被主动刷新

写完以后,它会:

  • 清掉旧的已投递诊断
  • changeFile(...)
  • saveFile(...)

也就是说,Edit 不只是改文件,还负责把语言服务生态同步起来。

9.2 VS Code diff 也会收到通知

notifyVscodeFileUpdated(...) 说明 Claude Code 在 bridge / IDE 集成场景下,会把“修改前”和“修改后”同步给外部宿主。

9.3 readFileState 会被更新成新事实

写完后会立刻把:

  • 新内容
  • 新时间戳

写回 readFileState

这样下一次编辑就不会基于旧快照。

9.4 还有一层 telemetry 和远程 git diff

它会打:

  • CLAUDE.md 专项事件
  • 字符串长度事件
  • 某些远程条件下的单文件 git diff 采集

这说明 Claude Code 把 edit 视为一个非常值得观测的高价值动作。

10. 结果返回给模型时,仍然是摘要,不是全文

mapToolResultToToolResultBlockParam(...) 返回的是很短的结果:

  • 某文件更新成功
  • 如果 replace_all,会显式说“全部替换成功”
  • 如果用户接受前手动改过,还会补一句说明

也就是说,模型在 edit 之后拿到的主要不是“新文件全文”,而是:

  1. 成功确认
  2. 必要的行为提示

真正详细的变更信息在结构化输出、宿主 UI、文件本身和后续 Read 里。

这和 Read 一样,再次说明 Claude Code 区分:

  • 模型该知道什么
  • UI 该展示什么
  • 文件系统已经发生了什么

11. Claude Code 在 Edit 上做了哪些额外处理

把这些零散逻辑收一下,可以总结成五层增强。

11.1 写前事实约束

  • 必须先 Read
  • 读后变更要重读
  • partial read 不允许直接写

11.2 安全与产品约束

  • write 权限
  • deny rule
  • UNC 风险规避
  • team memory secret guard
  • settings 文件语义校验

11.3 文本定位增强

  • quote normalization
  • quote style preservation
  • 唯一匹配要求
  • replace_all 显式化

11.4 文件写入增强

  • 保留原编码
  • 保留原换行风格
  • 文件历史备份
  • 小型原子区设计

11.5 宿主联动增强

  • LSP 通知
  • VS Code diff 通知
  • git diff 采集
  • telemetry
  • 结构化 patch 输出

这说明 Edit 其实已经不只是“文件工具”,而是本地编码子系统的核心网关。

12. 对 C# 版的翻译建议

如果你要做 C# 版,我建议不要直接做一个 EditFile(path, old, @new) 的静态函数。

更合理的拆法是:

12.1 协议层

  • FileEditToolDefinition
  • FileEditRequest
  • FileEditResult
  • StructuredPatch

12.2 前置校验层

  • IEditPermissionService
  • IEditIntentValidator
  • IReadStateGuard
  • ISettingsEditPolicy
  • ISecretEditGuard

12.3 文本处理层

  • IStringLocator
  • IQuoteNormalizer
  • IQuoteStylePreserver
  • IPatchBuilder

12.4 写入事务层

  • IEditableFileLoader
  • IAtomicEditCoordinator
  • ITextWriterWithEncoding
  • IFileHistoryService

12.5 宿主同步层

  • ILanguageServerNotifier
  • IIdeDiffNotifier
  • IEditTelemetry

这里我最建议你保留两件事:

  1. Read 事实前置
  2. 写前再做一次 stale check

这两条非常值钱,因为它们直接决定“模型改代码”是不是容易把用户现场搞乱。

13. 我对这套设计的评价

FileEditTool 最值得学的地方,不是它支持 exact string replace,而是它把这个能力做成了一条完整的、安全的、可验证的工程链路。

它防的不是单一 bug,而是一整类常见事故:

  • 模型没读文件就乱改
  • 模型基于旧内容改
  • 同一段文字命中多个位置却误改
  • 风格被悄悄破坏
  • IDE / LSP 状态不同步
  • 配置文件被改坏

这也是我为什么觉得,Claude Code 的本地能力最值得优先迁移的不是 UI,而是:

  • Read
  • Edit
  • Write
  • Bash

因为这四个工具几乎定义了整个 agent 在本地世界里“怎么看、怎么改、怎么执行”。