第八章:Edit 为什么是补丁协议
解释 Edit 为什么本质上是一套精确补丁协议,而不是普通字符串替换。
1. 为什么 Edit 也值得单独拆
很多人第一次看 FileEditTool,会把它理解成:
- 找到一段字符串
- 替换成另一段字符串
这个理解不算错,但只对了一层皮。
Claude Code 里的 Edit,本质上不是“文本替换函数”,而是一个带前置事实约束、带并发防护、带 patch 生成、带宿主联动的精确编辑协议。
它解决的其实是三个问题:
- 模型怎么安全地描述“我要改哪一段”
- 运行时怎么确认“这次修改仍然基于最新事实”
- 宿主怎么把一次修改同步到历史、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:86src/tools/FileEditTool/FileEditTool.ts:115src/tools/FileEditTool/FileEditTool.ts:125src/tools/FileEditTool/FileEditTool.ts:137src/tools/FileEditTool/FileEditTool.ts:266src/tools/FileEditTool/FileEditTool.ts:387src/tools/FileEditTool/FileEditTool.ts:425src/tools/FileEditTool/FileEditTool.ts:465src/tools/FileEditTool/FileEditTool.ts:497src/tools/FileEditTool/FileEditTool.ts:517src/tools/FileEditTool/FileEditTool.ts:575src/tools/FileEditTool/prompt.ts:7src/tools/FileEditTool/utils.ts:73src/tools/FileEditTool/utils.ts:104src/tools/FileEditTool/utils.ts:262src/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,而是在明确区分两种语义:
- 在现有文件里替换
- 用空旧串表达“从空文件开始写”
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 先确保目录存在,但故意放在关键区外面
源码注释特别强调:
mkdirfileHistoryTrackEdit
这些 await 都必须放在“关键区”外。
因为一旦在“stale check 和真正写盘之间”再插入异步等待,并发编辑就可能穿插进来。
这其实是在手工维护一个小型原子区。
7.4 读取当前文件时保留编码和换行风格
readFileForEdit(...) 读出来的不只是内容,还有:
encodinglineEndings
后面 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 之后拿到的主要不是“新文件全文”,而是:
- 成功确认
- 必要的行为提示
真正详细的变更信息在结构化输出、宿主 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 协议层
FileEditToolDefinitionFileEditRequestFileEditResultStructuredPatch
12.2 前置校验层
IEditPermissionServiceIEditIntentValidatorIReadStateGuardISettingsEditPolicyISecretEditGuard
12.3 文本处理层
IStringLocatorIQuoteNormalizerIQuoteStylePreserverIPatchBuilder
12.4 写入事务层
IEditableFileLoaderIAtomicEditCoordinatorITextWriterWithEncodingIFileHistoryService
12.5 宿主同步层
ILanguageServerNotifierIIdeDiffNotifierIEditTelemetry
这里我最建议你保留两件事:
Read事实前置- 写前再做一次 stale check
这两条非常值钱,因为它们直接决定“模型改代码”是不是容易把用户现场搞乱。
13. 我对这套设计的评价
FileEditTool 最值得学的地方,不是它支持 exact string replace,而是它把这个能力做成了一条完整的、安全的、可验证的工程链路。
它防的不是单一 bug,而是一整类常见事故:
- 模型没读文件就乱改
- 模型基于旧内容改
- 同一段文字命中多个位置却误改
- 风格被悄悄破坏
- IDE / LSP 状态不同步
- 配置文件被改坏
这也是我为什么觉得,Claude Code 的本地能力最值得优先迁移的不是 UI,而是:
ReadEditWriteBash
因为这四个工具几乎定义了整个 agent 在本地世界里“怎么看、怎么改、怎么执行”。