i18n 翻译配对与质量门禁:dsh 双语文档怎么不腐烂
dsh把"两份文档必须说同一件事"拆成两半:机械可查的半边交给门禁,语义对错的半边留给人。三文件配对加 blob hash 指纹让任何单边改动立刻可见,结构签名把漏译从 review 里挪进 CI,merge driver 让两个分支各自确认的配对自动合流。最重要的边界是官方文档的原话:绿色门禁意味着配对在这些确切内容下被确认一致,不意味着确认本身是合理的。后半句永远归 reviewer。
双语文档为什么会腐烂
dsh 的文档被两类读者消费:公司内外的人,和干活中的 agent。范围内的每一份文档都维护英文和简体中文两个版本,两种语言权威相等,先写哪边都合法,一份中文先写的 Agent Note 和一份英文先写的完全平权。绑定它们的只有一条纪律:必须说同一件事。
这条纪律的敌人不是疏忽,是默认动力学。翻译天然是异步的:改英文的人正在赶一个修复,翻译可以"稍后";每个"稍后"都没有提醒机制,积累到某一天,中文版停在三个版本之前。分叉一旦发生就没有回头路:读者不知道哪个版本是对的,只能猜,或者干脆都不信。文档从资产变成负债,就是这个瞬间。
agent 作为读者把代价放大了一档。人会闻到陈旧味,agent 不会。一个 agent 读到过期的中文文档,会把里面的命令、参数、承诺当成当前事实去执行,错得笃定又安静。所以双语文档的腐烂在这个仓库里不是观感问题,是正确性问题,值得一套机械化系统来对付。
系统的思路是承认人力不可靠,然后把"说同一件事"这个模糊要求切成两半。可以机械判定的部分(文件齐不齐、指纹对不对、结构镜像不镜像)交给脚本和 CI;只能人判定的部分(译得准不准、术语对不对、通不通顺)明确留给 review,并且写清楚门禁对这部分无能为力。整套设计就是这条分工线的落地。
配对是三个文件,不是两个
一个配对是同一目录下的三个兄弟文件:foo.md 是英文版,foo.zh.md 是中文版,foo.i18n.yaml 是一致性记录。没有 locale 目录,没有独立翻译仓库,没有一段英文一段中文的交替双语文件。配对整体合并:一个 PR 永远不落地一种语言而不带另外两个文件。
三文件结构的用意是让配对成为原子单元。改英文、改中文、改记录这三个动作被捆在一次确认里,你没法只做其中一个。两个内容文件互为对方的锚,记录文件是锚的公证人,缺一个,剩下两个的存在意义就悬空。
范围是显式圈定的。根目录的 CONTRIBUTING 和 BRAND_GUIDELINES、每个非 vendor 的 README、.agents/notes/**、docs/**、python/** 下的活动文档,全部在配对范围内。README 发现对 basename 大小写不敏感,missions/readme.md 和其他文档根一样算数,不给小写文件名留缝隙。
排除清单同样显式,而且每一项都有说得出口的理由。cordis-api/inherited.md 是生成物,没有经过 review 的中文对应版。各级 AGENTS.md 和它们的 CLAUDE.md 符号链接是只给 agent 读的英文指令,翻译它们没有消费者。terminology.md 和 style-samples.md 本身就是双语对照的产物,天生自证。translation-prompt.md 被机器逐字消费,改一个字节都会改变流水线行为,配对机制反而危险。.agents/notes/archived/ 整目录冻结,有专门的归档校验器管完整性和封条,永不重写。
这份清单的设计语言本身就在传递政策:manifest 里只有显式排除,没有 rollout 名单,没有日期线,没有"这类 README 暂缓"的政策分类。一个新文档落进范围之内,从第一天起就是完整配对,不存在"先单语上线、以后补翻译"的中间态。中间态就是分叉的温床,这里直接不提供。
一致性记录:给内容拍指纹
foo.i18n.yaml 持有每一侧在上次确认一致时的完整 git blob hash。整个文件就是文件名到 hash 的映射,两条记录,一边一条,没有别的字段。
选 blob hash 而不是 commit hash 是整套机制能转起来的前提。blob hash 对内容拍照,git hash-object foo.md 在同一个 PR 里就能算,不需要等任何 commit 存在;一致性因此是纯内容比较,和工作树状态、分支拓扑、提交顺序全部无关。一个人刚改完文件还没暂存,指纹就已经能算出来对不上了。
失效检测就是一次不等式。你改了 foo.md 但没重新确认配对,foo.md 的当前 blob hash 和记录里的不一致,门禁变红,信息精确到"哪个文件的哪一侧失步了"。没有模糊的"文档可能过期",只有可计算的 yes 或 no。
记录还带一套恢复机制。--write 在写入记录前,把两侧快照存进本地 Git object database(包括未提交的工作树内容),并在 refs/dsh/translation-pairing/snapshots/ 下为每个不同的 blob 钉一个 content-addressed ref,让垃圾回收收不走它们。这意味着记录里的 hash 随时能恢复任一侧上次确认时的确切文本。一个失步的配对,修复方式是对着编辑侧的 diff 给对侧打最小补丁,而不是重新翻译整个文件:英文加了一个段落,就给中文补那一个段落的翻译;改了一个参数名,就同步那个词。修完跑 pnpm run verify-translation-pairing --write <pair> 重新记录两边。
确认动作本身是可 review 的。--write 产生的 yaml diff 就是"我确认这两份内容一致"的书面记录,reviewer 在 PR 里看得见指纹变了、谁确认的。--write 必须点名配对,不许无差别刷;全语料重记录是 --write --all,留给明确的批量场景。这个约束防止有人用一条命令把整条语料的确认时间戳悄悄平移,让确认重新变成不可见的行为。
结构签名:数什么,不数什么
配对不是文字对文字,是结构对结构。门禁给每份文档算一个结构签名,两侧必须逐项相等:标题层级和顺序、列表类型、有序列表的起始序号、列表项数量、表格的行数和列数、链接的语义目标(连 query 和 fragment 后缀都要一致)、代码围栏的 info string 和内容。
每一条对应一种真实的漏译事故。漏译一个列表项,列表项数量对不上;翻译时顺手合并两段,段落结构变了。表格多译一行少译一列,行列数对不上。链接目标被翻译或者改写,语义目标对不上。这些错误在人眼 review 里是著名的漏网之鱼,两个 80% 相似的文档页扫过去,少的那一行不跳出来;在签名比较里是一次确定的不等。
签名最精妙的边界是它不数什么:段落。中英文的段落切分可以不同,英文一段在中文里拆成两段是合法翻译,因为段落是排版和行宽的奴隶,不是文档结构的骨头。数的是标题、列表、表格、代码这些承载文档骨架的元素,不数受语言排版习惯影响的元素。这条边界画在哪里,决定了门禁是"抓漏译的利器"还是"强迫译文贴合原文排版的暴政",画在段落上正好。
代码围栏的要求最硬:字节一致,连注释都算。中文版的代码块必须和英文版逐字节相同,因为代码不翻译。这里有个工程上的顺水推舟:源码导向的代码门禁消费 .zh.md 的围栏序列时,直接把它当成无后缀兄弟文件的派生物,不再编译第二遍;序列的长度、顺序、围栏种类、字节内容全等时走这条捷径,一旦不等,两份各自独立检查,配对门禁同时报失配。一份校验成本,两份文件覆盖。
行内代码同样逐字:命令、flag、配置键、路径、事件和 API 名、版本号,不翻译、不重排格式。链接文本可以翻译,链接目标保持语义不变;指向语料库内部的相对链接,英文用 .md、中文用 .zh.md,中文侧指向一个不存在的对应物是配对完整性错误,不是可以回退容忍的情况。
中文侧还有一层排版纪律由 translation-rules.md 固定,也进校验:中英文之间和中文与数字之间留半角空格(每个 plugin 注册 3 个 tool 这种形态),全角标点用于正文,顿号用于并列列表项,人称用"你"不用"您",禁止全角数字和字母,强调标记落在相同的 span 上。规则里还有一条和本系列文风不谋而合:中文里倾向用冒号和句号,不用破折号。
语言切换器:唯一被豁免的跨语言链接
中文文件在 H1 后面紧跟一行切换器:链接文字 English 指向同目录的 foo.md,一个竖线,然后是纯文本 中文。手工写的英文文件对称地反过来:纯文本 English 在前,链接文字 中文 指向 foo.zh.md 在后。
这行是"链接目标保持语义一致"规则的显式例外:它是全文唯一一个故意指向另一语言的链接,规则明确豁免它。生成出来的英文源省略这一行,保持和生成器输出字节一致,机器产物不为人类导航妥协。在 GitHub 之外渲染的 README(比如 PyPI 元数据页)可以用指向仓库里确切对应文件的 canonical URL,这是对渲染环境不吃相对链接的现实让步。
门禁:verify-translation-pairing
pnpm run verify-translation-pairing 是 doc-sync 的一部分,本地由贡献者跑文档变更相关的部分,CI 跑穷尽的全语料版。它机械地执行三件事。
第一,范围内的每个文档有完整配对。缺 .zh.md 或缺 .i18n.yaml 都是失败,大小写混写的 README 不豁免。第二,每个存在的配对 artifact 完整且一致:三个文件都在,每一侧的当前 blob hash 等于记录值,中文侧和每个 authored 英文源带正确的语言切换器,结构签名按序匹配。第三,列为 excluded 的文件干净:不许有 .zh.md,不许有 .i18n.yaml,排除状态本身也被校验。
日常用得最多的是三个变体。--list 打印范围内每个文档的当前状态,missing、out-of-sync、ok 三种,从不失败,适合当体检报告读。带配对名运行只检查命名的配对,一个更新循环几秒内验证自己的改动,不用重扫全语料。--write 重新记录确认过的配对。
这里有一条容易被走捷径绕过的规矩:命名配对的 scoped green 不能替代 PR 级的全语料校验。你只验证自己改的那对,CI 在 PR 上跑的是全量;本地 scoped 检查是为迭代速度存在的,不是为豁免存在的。门禁创建的实际规则一句话能说完:一个 PR 编辑了配对文档的任一侧,同一个 PR 必须直接更新对应版本并重新记录,否则 CI 红。
一次失步的一生
把一次典型事故从头走到尾,看这套机制在哪个环节出手。
一个工程师在下午改了 foo.md:给某个章节加了半段说明,顺手修了一个参数名。他没碰 foo.zh.md,也没碰记录。本地 --list 此刻就能看出问题:这个配对的状态是 out-of-sync,foo.md 的当前 blob hash 和记录对不上。他没跑,直接 push。
CI 接手。doc-sync 跑到配对校验,同一个不等式再算一遍,红了。错误信息指名道姓:哪个配对、哪一侧失步。没有排查过程可言,从"文档可能有问题"到"这个文件的英文侧指纹不符"之间没有搜索空间。
修复动作是机械的。他 checkout 记录里的 snapshot 恢复英文侧上次确认的文本,和当前版本 diff,看见自己改的那半段和参数名,把这两处变更翻译过去,打到 foo.zh.md 上。没有重读全文,没有重新翻译没动的部分,补丁的粒度和变更的粒度相同。然后 verify-translation-pairing --write foo,两边的 hash 进记录,yaml diff 出现在 PR 里,reviewer 看见指纹变了、谁确认的。重新 push,CI 绿。
再看并行的分支。同一天,另一个人在另一条分支确认了同一个配对的中文侧。两条分支先后合进 main,第二条合并时 foo.i18n.yaml 冲突,merge driver 上场:六 blob 两路三方合并,两边 owner 文本都干净,切换器和签名完好,新记录自动写出,合并继续。整个过程没有人手工算过一次 hash。如果两边恰好改了英文侧的同一行,owner 冲突留给人工,driver 退开。事故的两种形态,两种自动化,都不需要理解对方。
生成物的配对
范围里的文档不全是手写的。生成的英文参考和图,只有在存在经过 review 的中文对应版时才配对;生成器再跑一次把英文刷出新版,配对随之失步,停在 out-of-sync 直到有人更新对侧。这条规则防止"生成物天生豁免"变成后门:生成不豁免确认,只是确认的触发点从人工编辑变成了生成器运行。
更有意思的是两边都归生成器所有的形态,比如 Cordis 子系统区域文档。生成器按 locale 投影出成对的路径,两侧除 locale 差异外字节全等。生成出来的中文对应版有一条受控的例外:可以改写自指的生成维护声明("本文件由脚本生成"这类在译文语境下会变假话的句子),其他内容照旧。机器产物也要遵守配对纪律,只是纪律里预埋了机器视角的翻译豁免。
门禁还顺带管格式。既有的 Markdown 门禁对 .zh.md 一视同仁:一段一个物理行的折行检查、相对链接解析、恰好一个结尾换行。中文文件不因为是翻译就豁免排版检查。
merge driver:并行确认的自动合流
配对系统最深的坑不在单线程更新,在合并。场景是具体的:两个分支各自确认了同一个配对,A 分支改了英文侧并记录,B 分支改了中文侧并记录。合并时两份 Markdown 文本 Git 都能干净合掉,但 foo.i18n.yaml 里两条 hash 行必然冲突,因为双方记录的都是合并前各自看到的内容。手工解决这个冲突没有智力含量,只有出错空间:取任何一边都留下过期 hash,手写新 hash 又要自己算两遍 blob。
dsh-translation-pairing merge driver 吃掉的就是这块。它在合并时解析祖先、当前、对方三份记录,按 hash 取出六个 owner blob(三种状态乘英文中文两侧),对英文三元组和中文三元组各做一次独立的标准三方文本合并,两次都必须干净。都干净之后,校验合并结果保留了必需的语言切换器、结构签名完好,然后把合并后的两个 blob 存回 object database,把它们的 hash 写成新的规范记录。关键纪律是它只组合双方父母里已经存在的确认,绝不替一边的编辑凭空造记录:单边改了内容没确认过,driver 不会替它"顺便确认"。
fail-closed 落在好几层。运行时缺失(没装 Node 或 tsx),launcher 让 Git 落普通文本冲突结果,但仍然返回冲突状态,未合并的 index 阶位保留,Git 绝不接受未验证的元数据。结构校验不过的,留给普通冲突人工处理。owner 内容本身冲突的,那是人的事,driver 不碰。add/delete 和 rename 形态保持手动,因为它们的路径所有权语义和三记录操作不同。driver 进程崩溃(退出码大于 128)会让整个 merge strategy 中止,连 MERGE_HEAD 都不留下,修好运行时重新合并就是。
对已经停下的 merge,pnpm run resolve-translation-pairing-conflicts 跑同一套算法的批处理版:校验 sidecar 里还是 Git 未动过的冲突结果、暂存 blob 和自己的合并结果一致,然后批量写入所有安全的配对记录。混合场景(一部分配对安全、一部分 owner 冲突)下它把安全的批量暂存,然后以非零退出,让调用者无法把部分解决误当成合并完成。
安装侧的防御同样细。配置留在 worktree 本地而不是提交 .gitattributes,因为跟踪的属性文件能命名一个 driver,带不了可执行文件的路径。launcher 每次调用前探测 Node 和 tsx 是否在,不要求装了 Node 才能"启动"合并。installer 遇到已存在的自定义 merge.dsh-translation-pairing.* 配置会拒绝覆盖而不是抢注;安装失败时的回滚如果也失败,两个错误一起报出来,要求人来看一眼残留状态,不做静默的部分安装。这套 driver 有自己的测试面:launcher 组装、运行时缺失的降级、installer 探测回滚、拒绝被改过的 hook、被编辑过的 sidecar、非文本合并配置,每一层失败路径都有对应用例钉住。
几个被否决过的替代方案把这套设计的必要性衬托得很清楚。"取我方"或 union 驱动不行,两条 hash 记录的都是合并前内容,union 出来是重复且无序的键。post-merge 钩子或 commit 钩子里再生不行,post-merge 在有冲突的合并上根本不跑,commit 钩子要等 index 解析完才轮到它,都太晚。把每次合并包进仓库自定义命令不行,raw git、rebase、cherry-pick 还是会提前停下来,driver 是 Git 文件级的共享扩展点,所有路径都经过它。用 GitHub Actions 或 app 在远端解决不行,那要引入凭证、并发控制和分支突变,而本地 agent 本来就握着 push 权限。
还有一个现实约束值得单独记:GitHub 的 mergeability 计算跑不了 worktree-local 的可执行文件,远端算不出这个 driver 的结果。所以并行的配对确认必须有人或 agent 在本地合并、确认干净、再推上去,远端冲突 badge 才会消。指望在 PR 页面上点一下就解决配对冲突,这条路不存在。
lefthook:快检查点,慢门禁留给 CI
lefthook.yml 定义本地 Git hooks,安装由 scripts/install-lefthook.mjs 在 postinstall 自动完成,worktree-local 的 hooks 和 merge driver 一起配置。设计意图写在配置头部:本地检查点保持快,CI 拥有完整的仓库级门禁矩阵。
pre-commit 有六个 job。配对校验对暂存的 .i18n.yaml 跑,改了记录没改内容,提交动作本身就被拦下。归档笔记校验守住冻结目录。Oxlint 对暂存的 TS/TSX 做 lint,带自动修复并把修复重新暂存,vendor 源码排除在外。第三方声明是个"再生而非拒绝"的设计:package.json 或 lockfile 变化时自动重新生成 THIRD_PARTY_NOTICES.md 并暂存,一个忘改声明的依赖编辑在提交时被自动补全,而不是被打回。这个设计有个已知盲区:lefthook 只检查磁盘上存在的文件,删掉 manifest 触发不了这个 job,那个场景由测试 lane 的新鲜度断言兜底。最后是空白检查和 vendor manifest 守卫。
pre-merge-commit 重复配对和归档两个检查。pre-push 只跑 typecheck。测试、快照、文档全量检查、构建,全部故意不在 hooks 里:贡献者在本地跑的是和本次变更相关的最小集合,穷尽覆盖是 CI 的事。hooks 本质上可绕过(--no-verify 谁都会打),所以它们的姿态也相应克制:只验证,不再生,不做任何写动作。配对相关的 hook 校验暂存 sidecar 和 owner 在 index 里的字节,对不上就拒绝,但绝不在 hook 里替你重写记录。权威始终在 CI 的全语料 doc-sync。
绿色门禁证明什么,不证明什么
整套系统最重要的边界,官方文档用粗体钉死:绿色门禁意味着配对在这些确切内容下被确认一致,不意味着确认是合理的。
门禁检查 hash 和 Markdown 结构。它不能判断两边是否真的说了同一件事,措辞准不准,术语对不对,译文自然不自然。一个重新记录过的配对,如果中文版翻译得很差,门禁会过,而且应该过,因为它的职责到结构为止。语义等价是 reviewer 的半边契约:门禁抓结构不一致,reviewer 抓语义不一致,两边都绿了才算数。假装机械检查能覆盖语义,就是在用绿色徽章给人担保机器给不了的承诺。
翻译执行侧同样有刻意的克制。常规的对应版本更新由工作中的 agent 一次完成:加载 terminology.md(术语的唯一真相来源),对着编辑侧的 diff 一个 pass 直译过去,然后重记录。不调用翻译 skill,不生成 briefing,不跑单独的翻译 review pass,不委托 subagent。这些仪式对常规更新是纯开销。扩展的 dsh-translate-docs workflow 保留给显式用户调用,那里才有 briefing 生成(gen-translation-brief 按最窄安全对齐粒度组装更新)和代码围栏专用补丁(结构校验通过后才拼接)这类重装备。
权衡与局限
每个 PR 的摩擦是实打实的。编辑任何一侧都要拖着对侧一起走,翻译成了改动路径上的同步成本。这笔账在这个仓库算得过来,前提是双语文档是硬需求(人和 agent 两类读者都是一等公民);如果一个仓库的翻译只是锦上添花,这套机制的摩擦会超过收益,先想清楚要不要付。
指纹机制挡得住单边改动,挡不住两边一起改错。一个人认真翻译了一个错误的理解,两边同步更新、指纹吻合、结构镜像,门禁全绿,错误内容照样上线。机械半边的可靠性不向语义半边传导,这是分工的代价,不是缺陷。
merge driver 的自动化依赖本地环境。Node 或 tsx 缺位的机器上,配对合并退回普通冲突,没装环境的贡献者会多一截手工活。远端合并的缺失也是同一主题:GitHub 界面解决不了配对冲突,本地合并再推是唯一路径,这条对习惯纯 PR 界面工作流的贡献者是行为改变。
排除清单是永久债。AGENTS.md 永远单语,inherited 参考永远没有中文版,这些豁免每一次豁免都在收窄双语的覆盖面。清单显式、有理由、被校验,是管理这笔债的最好姿势,但债本身还在。
结论
双语文档不腐烂,靠的是把"两边说同一件事"拆到可机械执行的粒度。三文件配对让更新成为原子单元,blob hash 让失步变成一次可计算的不等式,snapshot refs 让每次确认可恢复,失步修复退化为对 diff 的最小补丁。结构签名数骨架不数段落,把漏译从 review 的盲区挪进 CI 的必检项。merge driver 组合双方已有的确认、拒绝替单边编辑造记录,把并行协作里最乏味的冲突自动化,同时用 fail-closed 保住"没验证的元数据绝不 silently 落地"的底线。
语义等价始终在门外。门禁的绿色只覆盖指纹和结构,翻译质量和术语正确性永远属于 reviewer,这套系统从设计上就没有越界假装覆盖它。评估要不要抄这套机制时,先看两件事:双语文档是不是硬需求,贡献者能否接受每个编辑都带着对侧更新走的摩擦。语料规模反而是可以妥协的变量,小语料只是让这套系统的回本来得晚一些。两件事都成立,这套设计就是模板;有一件不成立,先解决那件事再说。
延伸阅读
- Bilingual documentation 契约
- Translation Rules
- Terminology
- lefthook.yml
- Automatic Pairing Merges Agent Note
上一篇:文档即代码:dsh 用脚本生成图、目录和校验门禁 下一篇:Cordis 生态溯源:从 Koishi 到 DeepSeek Harness 的插件框架谱系
评论
EMPTY