fix(i18n): make translation contracts executable

This commit is contained in:
Tianyi Cui
2026-07-14 23:09:01 +08:00
parent 883948d1d5
commit 3caaa43797
25 changed files with 615 additions and 243 deletions

View File

@@ -14,7 +14,7 @@ These are authoritative; read them at the source so this skill never drifts out
- **[docs/i18n/README.md](../../../docs/i18n/README.md)** — the pairing contract: the three-file pair (`foo.md`, `foo.zh.md`, `foo.i18n.yaml`), the consistency record's both-side blob hashes, the language-switcher lines, scope/exclusions, and the rollout manifest. - **[docs/i18n/README.md](../../../docs/i18n/README.md)** — the pairing contract: the three-file pair (`foo.md`, `foo.zh.md`, `foo.i18n.yaml`), the consistency record's both-side blob hashes, the language-switcher lines, scope/exclusions, and the rollout manifest.
- **[docs/i18n/translation-rules.md](../../../docs/i18n/translation-rules.md)** — how to translate: faithfulness, structure preservation, terminology discipline, typography (MUST/SHOULD levels). - **[docs/i18n/translation-rules.md](../../../docs/i18n/translation-rules.md)** — how to translate: faithfulness, structure preservation, terminology discipline, typography (MUST/SHOULD levels).
- **[docs/i18n/terminology.md](../../../docs/i18n/terminology.md)** — the terminology table, binding in both directions. Load it BEFORE translating, not when a term feels uncertain; the terms you don't notice are the ones that drift. - **[docs/i18n/terminology.md](../../../docs/i18n/terminology.md)** — the terminology table, binding in both directions. Load it BEFORE translating, not when a term feels uncertain; the terms you don't notice are the ones that drift.
- **[docs/i18n/translation-prompt.md](../../../docs/i18n/translation-prompt.md)** — the automated pipeline's machine-consumed template. Agents using this skill do not render it; keep rules shared with this skill synchronized. - **[docs/i18n/translation-prompt.md](../../../docs/i18n/translation-prompt.md)** — the automated pipeline's machine-consumed template. Agents using this skill do not render it; the renderer injects `translation-rules.md` so rules have only one home.
- **[dsh-prose-standard](../dsh-prose-standard/SKILL.md)** — required prose coverage and editorial judgment. Apply it to both sides without adding or dropping source propositions. - **[dsh-prose-standard](../dsh-prose-standard/SKILL.md)** — required prose coverage and editorial judgment. Apply it to both sides without adding or dropping source propositions.
## Find the work ## Find the work
@@ -44,7 +44,7 @@ Do not process every file the same way:
- Write only the final text to the file, never drafts or notes. - Write only the final text to the file, never drafts or notes.
- Every term in [terminology.md](../../../docs/i18n/terminology.md) renders exactly as specified. For a Chinese target, use the Chinese and first-occurrence columns; an unlisted term needs a citable Chinese OSS/vendor precedent or stays English under 「待定术语」. For an English target, use the English column and an established English technical term; preserve an ambiguous source term with a short gloss and list it as pending. Never invent a rendering inline. - Every term in [terminology.md](../../../docs/i18n/terminology.md) renders exactly as specified. For a Chinese target, use the Chinese and first-occurrence columns; an unlisted term needs a citable Chinese OSS/vendor precedent or stays English under 「待定术语」. For an English target, use the English column and an established English technical term; preserve an ambiguous source term with a short gloss and list it as pending. Never invent a rendering inline.
- Code blocks are byte-identical across the pair, comments included. Relative links keep their `.md` targets; only the switcher line links `.zh.md`. - Code blocks are byte-identical across the pair, comments included. Relative links keep their `.md` targets; only the switcher line links `.zh.md`.
- The pairing gate checks heading depths, fenced blocks, table column counts, list kinds, and link targets. In Pass 2, manually verify list item counts and numbering, table row counts and order, inline code, emphasis, meaning, terminology, and tone. - The pairing gate checks heading depths, fenced blocks, table row and column counts, list kinds, ordered-list starts, list item counts, and link targets. In Pass 2, manually verify list and table order, noncanonical list numbering, inline code, emphasis, meaning, terminology, and tone.
## Finish the pair ## Finish the pair

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write
development.md: ca821f74461719a0f43ba4ea30eac8f5fb9b2bae development.md: b3c338f03f548b4de4b676850732731323d611f1
development.zh.md: 0d5c07d5f10e20f2632d353f6257e928b7c999a6 development.zh.md: 20f5c585dd378a7b0a3bd6b0af8ebf7dc5e0fd3c

View File

@@ -59,7 +59,7 @@ DEEPSEEK_BASE_URL=https://... # optional
lefthook is configured in `lefthook.yml` as an early local checkpoint before review: lefthook is configured in `lefthook.yml` as an early local checkpoint before review:
- `pre-commit` runs staged-file ESLint fixes, `pnpm run typecheck`, and the vendor manifest guard. - `pre-commit` runs staged-file ESLint fixes, `pnpm run typecheck`, and the vendor manifest guard.
- `pre-push` runs `pnpm run check:pre-push`, whose scheduler runs unit tests, snapshot tests, build, module-graph freshness, and the member gates of `pnpm run hygiene` and `pnpm run doc-sync` concurrently. - `pre-push` runs `pnpm run check:pre-push`, whose scheduler runs runtime-closure verification, unit tests, duplication detection, snapshot tests, build, module-graph freshness, and the member gates of `pnpm run hygiene` and `pnpm run doc-sync` concurrently.
The vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code. The vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.

View File

@@ -9,7 +9,7 @@
- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26见 [Node 引擎下限 RFC](rfc/implemented/process/2026-07-06-node-engine-floor.md)。 - Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26见 [Node 引擎下限 RFC](rfc/implemented/process/2026-07-06-node-engine-floor.md)。
- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable` - 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`
- Git。 - Git。
- 可选:一个 DeepSeek API key用于 REPL/ACP agent智能体演示和真实 API 的 e2e 测试。 - 可选:一个 DeepSeek API key用于 REPL/ACPAgent Client Protocol agent智能体演示和真实 API 的 e2e 测试。
## 首次搭建 ## 首次搭建
@@ -59,7 +59,7 @@ DEEPSEEK_BASE_URL=https://... # optional
lefthook 在 `lefthook.yml` 中配置,作为评审前的本地早期检查点: lefthook 在 `lefthook.yml` 中配置,作为评审前的本地早期检查点:
- `pre-commit` 运行对暂存文件的 ESLint 修复、`pnpm run typecheck` 和 vendor manifest元数据清单守卫 - `pre-commit` 运行对暂存文件的 ESLint 修复、`pnpm run typecheck` 和 vendor manifest元数据清单守卫
- `pre-push` 运行 `pnpm run check:pre-push`,其调度器并发运行单元测试、快照测试、构建、module-graph 新鲜度,以及 `pnpm run hygiene``pnpm run doc-sync` 的各成员门禁。 - `pre-push` 运行 `pnpm run check:pre-push`,其调度器并发运行 runtime-closure 校验、单元测试、重复代码检查、快照测试、构建、module-graph 新鲜度,以及 `pnpm run hygiene``pnpm run doc-sync` 的各成员门禁。
vendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md` vendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write
README.md: 38474b353820876c4ec859d3cea863f5ef82658f README.md: 17bb1eeb67b4f5119a698fca23f12490c9378a7f
README.zh.md: e047680ad273a7066515de0b61c73b7e00ff96e3 README.zh.md: 2b44ad702c68c0bfb748b25a4f62252aa3dbdc98

View File

@@ -17,14 +17,14 @@ This repo's documentation is read by people and agents both inside and outside t
Blob hashes, not commit hashes, so the record is computable for files edited in the same PR (`git hash-object foo.md`) and consistency is a pure content comparison. The recorded hash also recovers the exact last-confirmed text of either side (`git cat-file -p <hash>`), so an out-of-sync pair is updated by diffing the edited side against its last-confirmed state and patching the counterpart minimally — never by re-translating whole files. After bringing the pair back in line, `pnpm run verify-translation-pairing --write` re-records both hashes; that yaml diff is the reviewable act of confirming consistency. Blob hashes, not commit hashes, so the record is computable for files edited in the same PR (`git hash-object foo.md`) and consistency is a pure content comparison. The recorded hash also recovers the exact last-confirmed text of either side (`git cat-file -p <hash>`), so an out-of-sync pair is updated by diffing the edited side against its last-confirmed state and patching the counterpart minimally — never by re-translating whole files. After bringing the pair back in line, `pnpm run verify-translation-pairing --write` re-records both hashes; that yaml diff is the reviewable act of confirming consistency.
- **Language switcher.** Both files link to each other immediately after their H1 heading: the English file carries `English | [中文](foo.zh.md)` and the Chinese file carries `[English](foo.md) | 中文`. - **Language switcher.** Both files link to each other immediately after their H1 heading: the English file carries `English | [中文](foo.zh.md)` and the Chinese file carries `[English](foo.md) | 中文`.
- **Structure mirrors the counterpart.** Heading depths and order, list kinds, table columns, link targets, and verbatim code blocks match one to one across the pair — see [translation-rules.md](translation-rules.md) for the full preservation rules. Existing Markdown gates apply to `.zh.md` files unchanged (`verify-md-wrap`, `verify-md-links`). - **Structure mirrors the counterpart.** Heading depths and order, list kinds, ordered-list starts, list item counts, table row and column counts, link targets, and verbatim code blocks match one to one across the pair — see [translation-rules.md](translation-rules.md) for the full preservation rules. Existing Markdown gates apply to `.zh.md` files unchanged (`verify-md-wrap`, `verify-md-links`).
## The gate: verify-translation-pairing ## The gate: verify-translation-pairing
`pnpm run verify-translation-pairing` (part of `doc-sync`, so CI and the pre-push hook run it) enforces the contract mechanically: `pnpm run verify-translation-pairing` (part of `doc-sync`, so CI and the pre-push hook run it) enforces the contract mechanically:
1. Every file listed as `required` in [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) has a complete pair. 1. Every file listed as `required` in [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) has a complete pair.
2. Every pair that exists at all — required or not — is complete and consistent: all three files present, each side's current blob hash equals the recorded one (editing either side without re-confirming the pair goes red), both sides carry the language switcher, and the structural signatures match in order — heading depths, verbatim code blocks (info string and content), table column counts, list kinds, and every link target apart from the switcher. 2. Every pair that exists at all — required or not — is complete and consistent: all three files present, each side's current blob hash equals the recorded one (editing either side without re-confirming the pair goes red), both sides carry the language switcher, and the structural signatures match in order — heading depths, verbatim code blocks (info string and content), table row and column counts, list kinds, ordered-list starts, item counts, and every link target apart from the switcher.
3. Files listed as `excluded` have no `.zh.md` and no `.i18n.yaml` at all. 3. Files listed as `excluded` have no `.zh.md` and no `.i18n.yaml` at all.
4. Every date-named document (`yyyy-mm-dd-*.md`) dated on or after the manifest's `requiredSince` cutoff has a complete pair — new date-named RFCs merge bilingual from birth. 4. Every date-named document (`yyyy-mm-dd-*.md`) dated on or after the manifest's `requiredSince` cutoff has a complete pair — new date-named RFCs merge bilingual from birth.
@@ -49,4 +49,4 @@ The gate's limit, stated plainly: **a green gate means the pair was confirmed co
## Division of labor ## Division of labor
Counterparts here are produced by an agent running [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) and reviewed by a human — inference is cheap here, review attention is the scarce resource. The gate checks pair completeness, recorded hashes, switchers, and its documented structural signature. Review still owns translation quality, terminology, and structural requirements that the signature does not encode. Counterparts here are produced by an agent running [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) and reviewed by a human — inference is cheap here, review attention is the scarce resource. The gate checks pair completeness, recorded hashes, switchers, and its documented structural signature. Review still owns translation quality, terminology, and structural requirements that the signature does not encode. The prompt contract is executable: [scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) renders the canonical rules into either direction and strictly parses the three-field XML response, while `verify-translation-prompt` exercises both render directions, the checked-in example, and the CDATA split rule in `doc-sync`.

View File

@@ -7,7 +7,7 @@
## 配对契约 ## 配对契约
- **两种语言同权。**一篇文档可以先用任一语言撰写和评审——先写中文的 RFC 与先写英文的一样正当——另一侧由它翻译而来。两个文件谁也不高于谁;约束它们的是二者必须说同样的话。 - **两种语言同权。**一篇文档可以先用任一语言撰写和评审——先写中文的 RFC 与先写英文的一样正当——另一侧由它翻译而来。两个文件谁也不高于谁;约束它们的是二者必须说同样的话。
- **一对文档是三个同目录文件。**英文 `foo.md`、中文 `foo.zh.md`,加一份一致性记录 `foo.i18n.yaml`都在同一目录。不用语言目录不用独立翻译仓库不用中英混排的单文件。配对整体合入PR 永远不会只带一种语言而缺其余两个文件。 - **一对文档是三个同目录文件。**英文 `foo.md`、中文 `foo.zh.md`,加一份一致性记录 `foo.i18n.yaml`都在同一目录。不用语言目录不用独立翻译仓库不用中英混排的单文件。配对整体合入PRPull Request永远不会只带一种语言而缺其余两个文件。
- **一致性记录。**`foo.i18n.yaml` 保存两侧文件在上一次被确认「说同样的话」时各自的完整 git blob hash - **一致性记录。**`foo.i18n.yaml` 保存两侧文件在上一次被确认「说同样的话」时各自的完整 git blob hash
```yaml ```yaml
@@ -17,14 +17,14 @@
用 blob hash 而不是 commit hash这样同一个 PR 里改动的文件也能算出记录(`git hash-object foo.md`),一致性是纯内容比较。记录的 hash 还能还原任一侧上次确认时的确切文本(`git cat-file -p <hash>`),所以失去同步的配对是「把被改的一侧与其上次确认状态做 diff、再最小化地修补另一侧」——从不整篇重译。两侧对齐后`pnpm run verify-translation-pairing --write` 重新记录两个 hash那份 yaml diff 就是「确认一致」这个动作本身,可以被评审。 用 blob hash 而不是 commit hash这样同一个 PR 里改动的文件也能算出记录(`git hash-object foo.md`),一致性是纯内容比较。记录的 hash 还能还原任一侧上次确认时的确切文本(`git cat-file -p <hash>`),所以失去同步的配对是「把被改的一侧与其上次确认状态做 diff、再最小化地修补另一侧」——从不整篇重译。两侧对齐后`pnpm run verify-translation-pairing --write` 重新记录两个 hash那份 yaml diff 就是「确认一致」这个动作本身,可以被评审。
- **语言切换行。**两个文件在各自 H1 标题之后立即互链:英文文件带 `English | [中文](foo.zh.md)`,中文文件带 `[English](foo.md) | 中文`。 - **语言切换行。**两个文件在各自 H1 标题之后立即互链:英文文件带 `English | [中文](foo.zh.md)`,中文文件带 `[English](foo.md) | 中文`。
- **结构与另一侧一一对应。**标题深度与顺序、列表类型、表格列、链接目标与逐字节一致的代码块在配对两侧一一对应——完整保持规则见 [translation-rules.md](translation-rules.md)。既有 Markdown 门禁对 `.zh.md` 文件原样生效(`verify-md-wrap`、`verify-md-links`)。 - **结构与另一侧一一对应。**标题深度与顺序、列表类型、有序列表起始编号、列表项数量、表格行列数、链接目标与逐字节一致的代码块在配对两侧一一对应——完整保持规则见 [translation-rules.md](translation-rules.md)。既有 Markdown 门禁对 `.zh.md` 文件原样生效(`verify-md-wrap`、`verify-md-links`)。
## 门禁verify-translation-pairing ## 门禁verify-translation-pairing
`pnpm run verify-translation-pairing``doc-sync`(文档同步门禁)的一环,因此 CI 和 pre-push 钩子都会运行)机械地强制执行这份契约: `pnpm run verify-translation-pairing``doc-sync`(文档同步门禁)的一环,因此 CI 和 pre-push 钩子都会运行)机械地强制执行这份契约:
1. [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) 中 `required` 列出的每个文件都有完整配对。 1. [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) 中 `required` 列出的每个文件都有完整配对。
2. 任何已存在的配对——无论是否 required——都完整且一致三个文件齐全、每一侧的当前 blob hash 等于记录值(改了任一侧而没重新确认配对就变红)、双方都带语言切换行、结构签名按序一致——标题深度、逐字节一致的代码块(信息字符串与内容)、表格列数、列表类型,以及除切换行之外的每个链接目标。 2. 任何已存在的配对——无论是否 required——都完整且一致三个文件齐全、每一侧的当前 blob hash 等于记录值(改了任一侧而没重新确认配对就变红)、双方都带语言切换行、结构签名按序一致——标题深度、逐字节一致的代码块(信息字符串与内容)、表格列数、列表类型、有序列表起始编号、列表项数量,以及除切换行之外的每个链接目标。
3. 列为 `excluded` 的文件完全没有 `.zh.md`,也没有 `.i18n.yaml`。 3. 列为 `excluded` 的文件完全没有 `.zh.md`,也没有 `.i18n.yaml`。
4. 日期等于或晚于 manifest元数据清单中 `requiredSince` 分界日期的每篇日期命名文档(`yyyy-mm-dd-*.md`)都有完整配对——新增的日期命名 RFC 从创建起就要求双语齐备。 4. 日期等于或晚于 manifest元数据清单中 `requiredSince` 分界日期的每篇日期命名文档(`yyyy-mm-dd-*.md`)都有完整配对——新增的日期命名 RFC 从创建起就要求双语齐备。
@@ -49,4 +49,4 @@
## 分工 ## 分工
这里的对侧译文由运行 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 的 agent 产出、由人评审——在这里推理inference很便宜评审注意力才是稀缺资源。门禁机械检查配对完整性、记录的 hash、切换行与文档所列的结构签名翻译质量、术语以及签名未编码的结构要求仍由评审把关。 这里的对侧译文由运行 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 的 agent 产出、由人评审——在这里推理inference很便宜评审注意力才是稀缺资源。门禁机械检查配对完整性、记录的 hash、切换行与文档所列的结构签名翻译质量、术语以及签名未编码的结构要求仍由评审把关。prompt 契约可以直接执行:[scripts/translation-prompt.ts](../../scripts/translation-prompt.ts) 将规范真源渲染到两个翻译方向,并严格解析含三个字段的 XML 响应;`doc-sync` 中的 `verify-translation-prompt` 会检查两个渲染方向、仓库内示例与 CDATA 拆分规则。

View File

@@ -30,7 +30,7 @@
> **Async state is not synchronous state** — `agent.send()` does not flip status before returning; a background task's completion races turn boundaries; `reader.close()` fires for both EOF and disposal. Never gate control flow on a status you only just requested — drive lifecycle off the events/promises that actually fire (`agent/status`, `task.done`), and observe the transition (saw `running` THEN `idle`) rather than counting actions you assume map 1:1 to turns. > **Async state is not synchronous state** — `agent.send()` does not flip status before returning; a background task's completion races turn boundaries; `reader.close()` fires for both EOF and disposal. Never gate control flow on a status you only just requested — drive lifecycle off the events/promises that actually fire (`agent/status`, `task.done`), and observe the transition (saw `running` THEN `idle`) rather than counting actions you assume map 1:1 to turns.
**异步状态不等同于同步瞬时状态**——调用 `agent.send()` 不会在返回前同步更新状态;后台任务完成时机与轮次边界存在竞态;调用 `reader.close()` 既可能是读到文件末尾,也可能是资源销毁触发。切勿仅凭刚查询到的状态来阻断流程;生命周期逻辑应基于真实触发的事件与 promise 驱动(`agent/status``task.done`),观测完整状态切换(先 `running`、再 `idle`),而非主观认定操作和执行轮次一一对应(主循环会批量处理排队消息) **异步状态不等同于同步瞬时状态**——调用 `agent.send()` 不会在返回前同步更新状态;后台任务完成时机与轮次边界存在竞态;调用 `reader.close()` 既可能是读到文件末尾,也可能是资源销毁触发。切勿根据刚刚请求切换的状态来控制流程;生命周期逻辑应基于真实触发的事件与 promise 驱动(`agent/status``task.done`),观测完整状态切换(先 `running`、再 `idle`),而非通过操作次数推断轮次一一对应
## ③ 测试政策清单 ## ③ 测试政策清单

View File

@@ -1,6 +1,6 @@
# Translation prompt (pipeline asset) # Translation prompt (pipeline asset)
本文件是自动翻译流水线的 prompt 模板;自 `# Translation Prompt` 起的正文逐字进入模型请求,因此不参与双语配对(见 [README.md](README.md) 排除清单)。渲染时,[terminology.md](terminology.md) 整表填入 `{{terminology}}`。[style-samples.md](style-samples.md) 定义文体,模板内嵌的 Examples 仅抽样问题类型;术语表、忠实性与结构规则优先于样例,样例在这些硬约束内决定文体。修改本文件即修改翻译行为,需按正常 PR 评审。 本文件是自动翻译流水线的 prompt 模板;自 `# Translation Prompt` 起的正文逐字进入模型请求,因此不参与双语配对(见 [README.md](README.md) 排除清单)。渲染时,[translation-rules.md](translation-rules.md) 全文填入 `{{translation_rules}}`[terminology.md](terminology.md) 整表填入 `{{terminology}}`,避免模板另存一份会漂移的规则副本。[style-samples.md](style-samples.md) 定义文体,模板内嵌的 Examples 仅抽样问题类型;术语表、忠实性与结构规则优先于样例,样例在这些硬约束内决定文体。修改本文件即修改翻译行为,需按正常 PR 评审。
## 占位符契约 ## 占位符契约
@@ -10,6 +10,7 @@
|---|---|---| |---|---|---|
| `{{source_lang}}` | 源语言名(`English` / `Chinese` | 由改动侧文件推断:`.zh.md` 被改则为 `Chinese` | | `{{source_lang}}` | 源语言名(`English` / `Chinese` | 由改动侧文件推断:`.zh.md` 被改则为 `Chinese` |
| `{{target_lang}}` | 目标语言名(`Chinese` / `English` | 与 `{{source_lang}}` 相对 | | `{{target_lang}}` | 目标语言名(`Chinese` / `English` | 与 `{{source_lang}}` 相对 |
| `{{translation_rules}}` | [translation-rules.md](translation-rules.md) 全文Markdown 原文) | 渲染时读取仓库当前版本,不缓存 |
| `{{terminology}}` | [terminology.md](terminology.md) 的完整表格Markdown 原文) | 渲染时读取仓库当前版本,不缓存 | | `{{terminology}}` | [terminology.md](terminology.md) 的完整表格Markdown 原文) | 渲染时读取仓库当前版本,不缓存 |
| `{{source_filename}}` | 源文档的 basename`foo.md``foo.zh.md` | 由流水线从待译文件路径取得 | | `{{source_filename}}` | 源文档的 basename`foo.md``foo.zh.md` | 由流水线从待译文件路径取得 |
| `{{source_filename_zh}}` | 中文侧 basename`foo.zh.md` | 英文源追加 `.zh`;中文源使用自身 basename | | `{{source_filename_zh}}` | 中文侧 basename`foo.zh.md` | 英文源追加 `.zh`;中文源使用自身 basename |
@@ -35,46 +36,20 @@
You are a senior technical translator specializing in LLM and agent development documentation. Translate the complete source document from {{source_lang}} to {{target_lang}} as natural, professional technical prose. You are a senior technical translator specializing in LLM and agent development documentation. Translate the complete source document from {{source_lang}} to {{target_lang}} as natural, professional technical prose.
## Quality Requirements ## Binding Translation Rules
### Structure and Format Preservation The canonical repository rules below are injected verbatim. Apply every direction-appropriate requirement. In those rules, the authored document is the source for this request and the generated document is its counterpart.
- Preserve the complete document frame: heading hierarchy, list item count and numbering, table row and column order, link targets, fenced code blocks, inline code spans, and emphasis spans.
- Fenced code blocks must be byte-identical to the source, including every comment, info string, and line break. Never translate a code-block comment.
- Inline code spans (commands, flags, paths, API names, event names, configuration keys, and version numbers) remain byte-identical and in the same order.
- Every relative link keeps the same target. Translate link text, not link targets.
- The source basename is `{{source_filename}}`. When translating into Chinese, write `[English]({{source_filename}}) | 中文` immediately after the H1. When translating into English, write `English | [中文]({{source_filename_zh}})` immediately after the H1. Emit the switcher for a new pair and flip an existing switcher; never copy it unchanged.
- Preserve every source emphasis marker on the corresponding translated span. Do not add italics, bold, quotation marks, or other emphasis absent from the source.
- After a closing bold marker `**`, add a half-width space only when the next character is a Latin letter or digit. Never add one before full-width punctuation.
### Faithfulness and Voice {{translation_rules}}
- Preserve every behavior, condition, prerequisite, warning, version claim, example, exception, and modal verb. Add none and drop none.
- Write as a native technical author in the target language, not as a word-for-word translator. Restructure sentences where target-language grammar requires it while preserving the author's register.
- Use precise, established developer terminology. Do not vary a term merely to avoid repetition, and do not collapse two distinct source concepts into one target term.
- Do not add politeness, certainty, emphasis, rationale, or examples that the source does not contain.
#### When translating into Chinese ## Request-Specific Structure
- Use institutional technical Chinese: complete sentences, explicit actors where a passive would be vague, and established Chinese engineering idiom rather than calques.
- When a number modifies a noun, include a natural classifier or measure word. Example: `three-package seam` → `由三个 package 构成的 seam`, not `三 package seam`.
- Use full-width Chinese punctuation in prose: `,。:;?!()「」`. Prefer colons, periods, commas, or parentheses over em dashes; use 顿号between parallel items.
- Put one half-width space between Chinese text and Latin words or numbers. Do not put spaces around full-width punctuation.
- Render RFC 2119 keywords as 必须、禁止、应当、可以 while preserving the source emphasis exactly; plain source text remains plain.
#### When translating into English - The source basename is `{{source_filename}}`. When translating into Chinese, write `[English]({{source_filename}}) | 中文` immediately after the H1. When translating into English, write `English | [中文]({{source_filename_zh}})` immediately after the H1.
- Use concise professional developer English. Convert Chinese topic-comment order, implicit subjects, and nominalizations into idiomatic English without dropping their meaning. - Emit the switcher for a new pair and flip an existing switcher; never copy it unchanged.
- Use normal half-width English punctuation and spacing. Convert enumeration commas (、) to English commas and 「」 quotation marks to English double quotes, except inside verbatim Chinese text.
- Render RFC 2119 keywords as MUST, MUST NOT, SHOULD, and MAY while preserving the source emphasis exactly.
- Use established English engineering idiom rather than literal transliteration (误报 → false positive, 执行红线 → enforcement frontier), consulting the terminology table first.
- Use direct English imperatives for instructions unless the source's politeness carries substantive meaning.
## Terminology ## Binding Terminology
The table below is binding: Apply the current table below exactly as required by the injected translation rules.
- For a Chinese target, use the `中文` column and apply the `首次出现` form once; later occurrences use the text before its parentheses.
- For an English target, use the `English` column. Do not copy Chinese first-occurrence glosses into English prose.
- Respect every `不要译作` prohibition in both directions.
- For an unlisted term in a Chinese target, use a citable established Chinese OSS or vendor rendering and record the precedent in `<review>`; otherwise keep the English term and record it as `[Pending term]` with a suggested rendering.
- For an unlisted term in an English target, use the established English technical term. If no unambiguous equivalent exists, preserve the source term with a short gloss and record it as `[Pending term]`.
- Never invent a technical rendering inline.
{{terminology}} {{terminology}}
@@ -99,32 +74,7 @@ Return exactly one well-formed XML document with this root and these three child
## Self-Review Instructions ## Self-Review Instructions
After writing `<translation>`, re-read it in the target language without looking at the source. Then compare it with the source clause by clause and record actual corrections in English inside `<review>`. After writing `<translation>`, re-read it in the target language without looking at the source. Then apply the injected translation rules as a clause-by-clause comparison against the source and record actual corrections in English inside `<review>`. Apply every recorded correction in `<final>`. If no correction is needed, write only `- [None] No corrections.` in `<review>` and copy `<translation>` unchanged into `<final>`.
**Structure**
- Do heading levels, list item counts and numbering, table rows and columns, links, code blocks, inline code spans, and emphasis spans correspond exactly?
- Are all fenced code blocks byte-identical, comments included?
- Is the language switcher present and pointed in the correct direction?
**Faithfulness**
- Did every condition, warning, modal verb, exception, and example survive?
- Did the translation add any claim, rationale, emphasis, or certainty absent from the source?
**Tone and sentences**
- Does every sentence read as native target-language developer documentation?
- Are passive constructions, topic chains, or run-on sentences unnatural in the target language?
**Terminology**
- Does every tabled term use the target-language column and avoid forbidden forms?
- For a Chinese target, are first-occurrence glosses present once and only once?
- Are unlisted terms handled under the direction-specific precedent and pending-term rules?
**Punctuation**
- For Chinese, are punctuation, mixed-script spacing, classifiers, and 顿号 correct?
- For English, are punctuation and spacing idiomatic and free of Chinese-only padding?
- Do RFC 2119 keywords preserve the source emphasis rather than adding italics?
Apply every recorded correction in `<final>`. If no correction is needed, write only `- [None] No corrections.` in `<review>` and copy `<translation>` unchanged into `<final>`.
## Examples ## Examples

View File

@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority; # side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write
translation-rules.md: b48b1680da2e7a9a4d744659339398af81d60308 translation-rules.md: 490f12d7fec929e5d3d5682deb92ed373fffbde5
translation-rules.zh.md: 6e8f26509e1db104eb341668c3e77bd6ea0780e1 translation-rules.zh.md: b013dcb2d8a892606ae5b1c0837152a771a57da5

View File

@@ -21,7 +21,7 @@ How to translate between the two sides of a documentation pair in this repo. Bot
## Structure preservation ## Structure preservation
The pairing gate checks heading depths, fenced code blocks, table column counts, list kinds, and link targets. Preserve the rest of the frame manually; the paired files MUST match one to one in: The pairing gate checks heading depths, fenced code blocks, table row and column counts, list kinds, ordered-list starts, list item counts, and link targets. Preserve the rest of the frame manually; the paired files MUST match one to one in:
- heading hierarchy (same levels, same order — heading TEXT is translated), - heading hierarchy (same levels, same order — heading TEXT is translated),
- list shape and numbering, - list shape and numbering,
@@ -54,7 +54,7 @@ These rules govern the Chinese side; the English side follows the repo's normal
- A pair is done when a bilingual engineer reading either file alone gets everything a reader of the other gets — same facts, same caveats, same tone — and nothing extra. - A pair is done when a bilingual engineer reading either file alone gets everything a reader of the other gets — same facts, same caveats, same tone — and nothing extra.
- Before handing off, self-check the result against this file and re-read the counterpart ALONE, without the source side by side; awkward phrasing is easier to hear without the source anchoring you. - Before handing off, self-check the result against this file and re-read the counterpart ALONE, without the source side by side; awkward phrasing is easier to hear without the source anchoring you.
- Run `pnpm run verify-translation-pairing` and the rest of `doc-sync` for records, switchers, heading depths, code blocks, table column counts, list kinds, links, and repository Markdown rules. Manually verify list item counts and numbering, table row counts and order, inline code, emphasis, meaning, terminology, and tone. - Run `pnpm run verify-translation-pairing` and the rest of `doc-sync` for records, switchers, heading depths, code blocks, table row and column counts, list kinds, ordered-list starts, list item counts, links, and repository Markdown rules. Manually verify list and table order, noncanonical list numbering, inline code, emphasis, meaning, terminology, and tone.
## References ## References

View File

@@ -21,7 +21,7 @@
## 结构保持 ## 结构保持
配对门禁检查标题深度、围栏代码块、表格列数、列表类型与链接目标。其余框架由译者手工保持;配对的两个文件必须在以下方面一一对应: 配对门禁检查标题深度、围栏代码块、表格列数、列表类型、有序列表起始编号、列表项数量与链接目标。其余框架由译者手工保持;配对的两个文件必须在以下方面一一对应:
- 标题层级(相同级别、相同顺序;标题的**文字**要翻译); - 标题层级(相同级别、相同顺序;标题的**文字**要翻译);
- 列表形态与编号; - 列表形态与编号;
@@ -54,7 +54,7 @@
- 一对文档的完成标准:一位双语工程师只读其中任一文件,能获得与另一文件读者完全相同的信息(相同的事实、相同的告诫、相同的语气),并且没有任何多余的内容。 - 一对文档的完成标准:一位双语工程师只读其中任一文件,能获得与另一文件读者完全相同的信息(相同的事实、相同的告诫、相同的语气),并且没有任何多余的内容。
- 交付前,请对照本文自查一遍,并**单独通读对侧文件**,不与源侧对照;不对照原文时,更容易察觉别扭的表达。 - 交付前,请对照本文自查一遍,并**单独通读对侧文件**,不与源侧对照;不对照原文时,更容易察觉别扭的表达。
- 请运行 `pnpm run verify-translation-pairing``doc-sync` 的其余门禁,检查一致性记录、切换行、标题深度、代码块、表格列数、列表类型、链接及仓库 Markdown 规则。列表项数量与编号、表格行数与顺序、行内代码、强调标记、语义、术语和语体仍需手工核对。 - 请运行 `pnpm run verify-translation-pairing``doc-sync` 的其余门禁,检查一致性记录、切换行、标题深度、代码块、表格列数、列表类型、有序列表起始编号、列表项数量、链接及仓库 Markdown 规则。列表与表格顺序、非常规列表编号、行内代码、强调标记、语义、术语和语体仍需手工核对。
## 参考资料 ## 参考资料

View File

@@ -3,4 +3,4 @@
# after editing either side, bring the other along and re-record with: # after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write # pnpm run verify-translation-pairing --write
2026-07-02-bilingual-docs-and-pairing-gate.md: 68c0f3bbc0472b0c96f9d64fc6b1b24ac7008795 2026-07-02-bilingual-docs-and-pairing-gate.md: 68c0f3bbc0472b0c96f9d64fc6b1b24ac7008795
2026-07-02-bilingual-docs-and-pairing-gate.zh.md: f93e3220fef720805c52023841f6f56f9e2834cf 2026-07-02-bilingual-docs-and-pairing-gate.zh.md: 66a24dbfa9e516bf341d42d10b797fb70dcd715d

View File

@@ -11,7 +11,7 @@ Status: implemented
## 决策 ## 决策
- **配对兄弟文件,两种语言同权。** 一对文档由三个兄弟文件组成:英文 `foo.md`、中文 `foo.zh.md`,以及一份一致性记录 `foo.i18n.yaml`。没有哪种语言是正典:一篇文档可以先用中文撰写和评审、之后再译成英文,反之亦可;约束配对的是:两侧必须表达相同的内容,且配对整体合并(两种语言加记录,绝不单独落一侧)。政策见 [docs/i18n/README.md](../../../i18n/README.md);翻译规则见 [docs/i18n/translation-rules.md](../../../i18n/translation-rules.md);术语真源见 [docs/i18n/terminology.md](../../../i18n/terminology.md)。 - **配对兄弟文件,两种语言同权。** 一对文档由三个兄弟文件组成:英文 `foo.md`、中文 `foo.zh.md`,以及一份一致性记录 `foo.i18n.yaml`。没有哪种语言是正典:一篇文档可以先用中文撰写和评审、之后再译成英文,反之亦可;约束配对的是:两侧必须表达相同的内容,且配对整体合并(两种语言加记录,绝不单独落一侧)。政策见 [docs/i18n/README.md](../../../i18n/README.md);翻译规则见 [docs/i18n/translation-rules.md](../../../i18n/translation-rules.md);术语真源见 [docs/i18n/terminology.md](../../../i18n/terminology.md)。
- **伴随记录保存两侧 blob hash使一致性可检查。** `foo.i18n.yaml` 保存两侧文件在上一次确认一致时各自的完整 git blob hash。此后修改了任一侧而未重新确认配对都能被机械检测出来纯内容比较无需查询历史而且同一个 PR 内改动的文件也能计算出 hashcommit hash 式的记录做不到这一点。重新记录(`verify-translation-pairing --write`)会产生一份可评审的 yaml diff确认一致在 PR 中是一个显式、可见的动作。 - **伴随记录保存两侧 blob hash使一致性可检查。** `foo.i18n.yaml` 保存两侧文件在上一次确认一致时各自的完整 git blob hash。此后修改了任一侧而未重新确认配对都能被机械检测出来纯内容比较无需查询历史而且同一个 PRPull Request内改动的文件也能计算出 hashcommit hash 式的记录做不到这一点。重新记录(`verify-translation-pairing --write`)会产生一份可评审的 yaml diff确认一致在 PR 中是一个显式、可见的动作。
- **`verify-translation-pairing` 加入 `doc-sync`。** 门禁([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts)强制执行以下规则required 的配对必须存在;任何已存在的配对必须完整(三个文件齐全)且一致(两个 hash 匹配、切换行双向互链、结构签名一致);被排除的文件(生成物或本身即双语的)保持不配对;日期等于或晚于 manifest元数据清单`requiredSince` 分界日期的文档必须具有完整配对。[scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) 中的 `required` 清单只进不退:每个合并的翻译批次将自己的文件加入其中,覆盖面只增不减。 - **`verify-translation-pairing` 加入 `doc-sync`。** 门禁([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts)强制执行以下规则required 的配对必须存在;任何已存在的配对必须完整(三个文件齐全)且一致(两个 hash 匹配、切换行双向互链、结构签名一致);被排除的文件(生成物或本身即双语的)保持不配对;日期等于或晚于 manifest元数据清单`requiredSince` 分界日期的文档必须具有完整配对。[scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) 中的 `required` 清单只进不退:每个合并的翻译批次将自己的文件加入其中,覆盖面只增不减。
- **翻译是 agent 的工作,由人评审。** 仓库内置的工作流是 [.agents/skills/dsh-translate-docs](../../../../.agents/skills/dsh-translate-docs/SKILL.md),与 [dsh-code-review](../../../../.agents/skills/dsh-code-review/SKILL.md) 模式相同skill技能承载工作流并将文档作为真源。 - **翻译是 agent 的工作,由人评审。** 仓库内置的工作流是 [.agents/skills/dsh-translate-docs](../../../../.agents/skills/dsh-translate-docs/SKILL.md),与 [dsh-code-review](../../../../.agents/skills/dsh-code-review/SKILL.md) 模式相同skill技能承载工作流并将文档作为真源。

View File

@@ -74,7 +74,7 @@ export default tseslint.config(
// --- tests: same rules, minus the friction that fights test ergonomics -- // --- tests: same rules, minus the friction that fights test ergonomics --
{ {
files: ['packages/*/*/tests/**/*.ts', 'examples/*/tests/**/*.ts'], files: ['packages/*/*/tests/**/*.ts', 'examples/*/tests/**/*.ts', 'scripts/**/*.spec.ts'],
extends: [ extends: [
...tseslint.configs.strictTypeChecked, ...tseslint.configs.strictTypeChecked,
], ],

View File

@@ -44,6 +44,7 @@
"verify-rfc-classification": "tsx scripts/verify-rfc-classification.ts", "verify-rfc-classification": "tsx scripts/verify-rfc-classification.ts",
"verify-rfc-format": "tsx scripts/verify-rfc-format.ts", "verify-rfc-format": "tsx scripts/verify-rfc-format.ts",
"verify-type-equiv": "tsx scripts/verify-type-equiv.ts", "verify-type-equiv": "tsx scripts/verify-type-equiv.ts",
"verify-translation-prompt": "tsx scripts/verify-translation-prompt.ts",
"verify-translation-pairing": "tsx scripts/verify-translation-pairing.ts", "verify-translation-pairing": "tsx scripts/verify-translation-pairing.ts",
"verify-doc-budgets": "tsx scripts/verify-doc-budgets.ts", "verify-doc-budgets": "tsx scripts/verify-doc-budgets.ts",
"verify-package-readme-limitations": "tsx scripts/verify-package-readme-limitations.ts", "verify-package-readme-limitations": "tsx scripts/verify-package-readme-limitations.ts",
@@ -67,7 +68,7 @@
"verify-scoped-dispatch": "tsx scripts/verify-scoped-dispatch.ts", "verify-scoped-dispatch": "tsx scripts/verify-scoped-dispatch.ts",
"verify-module-graph": "tsx scripts/gen-module-graph.ts --check", "verify-module-graph": "tsx scripts/gen-module-graph.ts --check",
"constraints": "tsx scripts/check-workspace-constraints.ts", "constraints": "tsx scripts/check-workspace-constraints.ts",
"doc-sync": "pnpm run doc-typecheck && pnpm run verify-cordis-catalog && pnpm run verify-cordis-api && pnpm run verify-export-jsdoc && pnpm run verify-tool-catalog && pnpm run verify-config-catalog && pnpm run verify-persistence-catalog && pnpm run verify-doc-graphs && pnpm run verify-scoped-dispatch && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-doc-refs && pnpm run verify-package-paths && pnpm run verify-package-readme-model-experience && pnpm run verify-mermaid && pnpm run verify-rfc-classification && pnpm run verify-rfc-format && pnpm run verify-type-equiv && pnpm run verify-translation-pairing && pnpm run verify-doc-budgets && pnpm run verify-package-readme-limitations", "doc-sync": "pnpm run doc-typecheck && pnpm run verify-cordis-catalog && pnpm run verify-cordis-api && pnpm run verify-export-jsdoc && pnpm run verify-tool-catalog && pnpm run verify-config-catalog && pnpm run verify-persistence-catalog && pnpm run verify-doc-graphs && pnpm run verify-scoped-dispatch && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-doc-refs && pnpm run verify-package-paths && pnpm run verify-package-readme-model-experience && pnpm run verify-mermaid && pnpm run verify-rfc-classification && pnpm run verify-rfc-format && pnpm run verify-type-equiv && pnpm run verify-translation-prompt && pnpm run verify-translation-pairing && pnpm run verify-doc-budgets && pnpm run verify-package-readme-limitations",
"hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-node-next-types && pnpm run verify-runtime-closure", "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-node-next-types && pnpm run verify-runtime-closure",
"demo:echo": "node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/echo-agent/cordis.yml", "demo:echo": "node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/echo-agent/cordis.yml",
"demo:repl": "node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/coding-agent/cordis.yml", "demo:repl": "node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/coding-agent/cordis.yml",
@@ -95,6 +96,7 @@
"mermaid": "11.16.0", "mermaid": "11.16.0",
"micromark-extension-gfm": "^3.0.0", "micromark-extension-gfm": "^3.0.0",
"publint": "^0.3.21", "publint": "^0.3.21",
"saxes": "^6.0.0",
"tsdown": "^0.22.2", "tsdown": "^0.22.2",
"tsx": "^4.22.4", "tsx": "^4.22.4",
"typescript": "^6.0.3", "typescript": "^6.0.3",

3
pnpm-lock.yaml generated
View File

@@ -62,6 +62,9 @@ importers:
publint: publint:
specifier: ^0.3.21 specifier: ^0.3.21
version: 0.3.21 version: 0.3.21
saxes:
specifier: ^6.0.0
version: 6.0.0
tsdown: tsdown:
specifier: ^0.22.2 specifier: ^0.22.2
version: 0.22.2(oxc-resolver@11.20.0)(publint@0.3.21)(tsx@4.22.4)(typescript@6.0.3) version: 0.22.2(oxc-resolver@11.20.0)(publint@0.3.21)(tsx@4.22.4)(typescript@6.0.3)

View File

@@ -283,6 +283,7 @@ function docSyncLeafGates(): Gate[] {
pnpmScript('rfc-classification', 'verify-rfc-classification', { label: 'rfc classification' }), pnpmScript('rfc-classification', 'verify-rfc-classification', { label: 'rfc classification' }),
pnpmScript('rfc-format', 'verify-rfc-format', { label: 'rfc format' }), pnpmScript('rfc-format', 'verify-rfc-format', { label: 'rfc format' }),
pnpmScript('type-equivalence', 'verify-type-equiv', { label: 'type equivalence' }), pnpmScript('type-equivalence', 'verify-type-equiv', { label: 'type equivalence' }),
pnpmScript('translation-prompt', 'verify-translation-prompt', { label: 'translation prompt' }),
pnpmScript('translation-pairing', 'verify-translation-pairing', { label: 'translation pairing' }), pnpmScript('translation-pairing', 'verify-translation-pairing', { label: 'translation pairing' }),
pnpmScript('doc-budgets', 'verify-doc-budgets', { label: 'doc budgets' }), pnpmScript('doc-budgets', 'verify-doc-budgets', { label: 'doc budgets' }),
pnpmScript('package-readme-limitations', 'verify-package-readme-limitations', { label: 'package README limitations' }), pnpmScript('package-readme-limitations', 'verify-package-readme-limitations', { label: 'package README limitations' }),

View File

@@ -0,0 +1,95 @@
/** Regression tests for the bilingual cutoff and structural signature. */
import { describe, expect, it } from 'vitest'
import {
datedDocumentDate,
isIsoDate,
parseTranslationMarkdown,
parseTranslationPairingManifest,
requiresPairByDate,
translationStructureDiff,
translationStructureSignature,
} from './translation-pairing.ts'
function signature(markdown: string) {
return translationStructureSignature(parseTranslationMarkdown(markdown), 'counterpart.zh.md')
}
describe('translation pairing manifest', () => {
it('accepts a real ISO cutoff and string-array fields', () => {
expect(parseTranslationPairingManifest(JSON.stringify({
requiredSince: '2026-07-14',
required: ['README.md'],
excluded: ['docs/generated/'],
}))).toEqual({
requiredSince: '2026-07-14',
required: ['README.md'],
excluded: ['docs/generated/'],
})
})
it.each(['2026-7-14', '2026-02-29', '2026-13-01', 'not-a-date'])('rejects invalid cutoff %s', (cutoff) => {
expect(isIsoDate(cutoff)).toBe(false)
expect(() => parseTranslationPairingManifest(JSON.stringify({
requiredSince: cutoff,
required: [],
excluded: [],
}))).toThrow('requiredSince must be a valid YYYY-MM-DD date')
})
it('rejects non-string manifest arrays', () => {
expect(() => parseTranslationPairingManifest(JSON.stringify({
requiredSince: '2026-07-14',
required: [42],
excluded: [],
}))).toThrow('required must be an array of strings')
})
})
describe('date-based pairing frontier', () => {
const cutoff = '2026-07-14'
it('enforces the cutoff day and every later day, but not the preceding day', () => {
expect(requiresPairByDate('docs/rfc/2026-07-13-before.md', cutoff)).toBe(false)
expect(requiresPairByDate('docs/rfc/2026-07-14-at-cutoff.md', cutoff)).toBe(true)
expect(requiresPairByDate('docs/rfc/2026-07-15-after.md', cutoff)).toBe(true)
})
it('matches only a date at the start of the basename', () => {
expect(datedDocumentDate('docs/rfc/2026-07-14-proposal.md')).toBe('2026-07-14')
expect(datedDocumentDate('docs/release-notes-2026-07-14-alpha.md')).toBeUndefined()
expect(requiresPairByDate('docs/release-notes-2026-07-14-alpha.md', cutoff)).toBe(false)
})
})
describe('translation structural signature', () => {
it('accepts matching list kinds, starts, and item counts', () => {
const source = signature('3. One\n4. Two\n\n- A\n- B\n')
const counterpart = signature('3. 一\n4. 二\n\n- 甲\n- 乙\n')
expect(translationStructureDiff(source, counterpart)).toEqual([])
})
it('rejects an altered ordered-list start', () => {
const source = signature('3. One\n4. Two\n\n- A\n- B\n')
const counterpart = signature('1. 一\n2. 二\n\n- 甲\n- 乙\n')
expect(translationStructureDiff(source, counterpart)).toEqual([
'list (kind, start, item count) #1 diverges between the pair: "ordered:start=3:items=2" vs "ordered:start=1:items=2"',
])
})
it('rejects a missing list item', () => {
const source = signature('- A\n- B\n')
const counterpart = signature('- 甲\n')
expect(translationStructureDiff(source, counterpart)).toEqual([
'list (kind, start, item count) #1 diverges between the pair: "bullet:items=2" vs "bullet:items=1"',
])
})
it('rejects altered table row or column counts', () => {
const source = signature('| A | B |\n|---|---|\n| 1 | 2 |\n| 3 | 4 |\n')
const counterpart = signature('| 甲 | 乙 |\n|---|---|\n| 一 | 二 |\n')
expect(translationStructureDiff(source, counterpart)).toEqual([
'table (row x column count) #1 diverges between the pair: "3x2" vs "2x2"',
])
})
})

View File

@@ -0,0 +1,164 @@
/**
* Pure parsing and structural helpers for the bilingual-document pairing
* gate. Kept separate from the CLI so cutoff and signature behavior can be
* regression-tested without reading or mutating the repository tree.
*/
import { fromMarkdown } from 'mdast-util-from-markdown'
import { gfmFromMarkdown } from 'mdast-util-gfm'
import { gfm } from 'micromark-extension-gfm'
import type { Nodes } from 'mdast'
/** Validated shape of `scripts/translation-pairing.manifest.json`. */
export interface TranslationPairingManifest {
required: string[]
excluded: string[]
/** Date-named documents on or after this day must merge bilingual. */
requiredSince: string
}
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/
const DATED_DOCUMENT = /(?:^|\/)(\d{4}-\d{2}-\d{2})-[^/]*\.md$/
/** Whether a string names one real calendar day in canonical ISO form. */
export function isIsoDate(value: string): boolean {
if (!ISO_DATE.test(value)) return false
const date = new Date(`${value}T00:00:00.000Z`)
return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value
}
/** Read one manifest string-array field or fail before enforcement starts. */
function stringArrayField(record: Record<string, unknown>, field: 'required' | 'excluded'): string[] {
const value = record[field]
if (!Array.isArray(value)) {
throw new Error(`translation-pairing.manifest.json: ${field} must be an array of strings`)
}
const entries: unknown[] = value
if (!entries.every((entry): entry is string => typeof entry === 'string')) {
throw new Error(`translation-pairing.manifest.json: ${field} must be an array of strings`)
}
return entries
}
/** Parse and validate the checked-in bilingual manifest. */
export function parseTranslationPairingManifest(content: string): TranslationPairingManifest {
const value: unknown = JSON.parse(content)
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new Error('translation-pairing.manifest.json: expected an object')
}
const record = value as Record<string, unknown>
const requiredSince = record.requiredSince
if (typeof requiredSince !== 'string' || !isIsoDate(requiredSince)) {
throw new Error(`translation-pairing.manifest.json: requiredSince must be a valid YYYY-MM-DD date; got ${JSON.stringify(requiredSince)}`)
}
return {
required: stringArrayField(record, 'required'),
excluded: stringArrayField(record, 'excluded'),
requiredSince,
}
}
/** Return the leading date of a `yyyy-mm-dd-*.md` basename, if present. */
export function datedDocumentDate(file: string): string | undefined {
return DATED_DOCUMENT.exec(file)?.[1]
}
/** Whether a date-named document falls on or after the pairing cutoff. */
export function requiresPairByDate(file: string, requiredSince: string): boolean {
const date = datedDocumentDate(file)
return date !== undefined && date >= requiredSince
}
/** The structural surface compared between the two sides of a pair. */
export interface TranslationStructureSignature {
/** Heading depths in document order (h2 -> 2). */
headings: number[]
/** Fenced code blocks verbatim: info string plus content, in order. */
code: string[]
/** Row and column count of each table, in order. */
tables: string[]
/** Kind, ordered-list start, and direct item count of each list, in order. */
lists: string[]
/** Every link target in order; the language switcher is excluded. */
links: string[]
}
/** Parse Markdown with the same GFM extensions used by the pairing gate. */
export function parseTranslationMarkdown(content: string): Nodes {
return fromMarkdown(content, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] })
}
/** Whether the tree contains a link to exactly `target`. */
export function linksTo(tree: Nodes, target: string): boolean {
let found = false
const visit = (node: Nodes): void => {
if (node.type === 'link' && node.url === target) found = true
if ('children' in node) for (const child of node.children) visit(child)
}
visit(tree)
return found
}
/** Collect the ordered structural signature, skipping one switcher target. */
export function translationStructureSignature(tree: Nodes, switcherTarget: string): TranslationStructureSignature {
const sig: TranslationStructureSignature = { headings: [], code: [], tables: [], lists: [], links: [] }
const visit = (node: Nodes): void => {
switch (node.type) {
case 'heading':
sig.headings.push(node.depth)
break
case 'code':
sig.code.push(`\`\`\`${node.lang ?? ''}${node.meta ? ` ${node.meta}` : ''}\n${node.value}`)
break
case 'table':
sig.tables.push(`${node.children.length}x${node.children[0]?.children.length ?? 0}`)
break
case 'list':
sig.lists.push(node.ordered
? `ordered:start=${node.start ?? 1}:items=${node.children.length}`
: `bullet:items=${node.children.length}`)
break
case 'link':
if (node.url !== switcherTarget) sig.links.push(node.url)
break
default:
// Every other node kind is prose or a container, not part of the signature.
break
}
if ('children' in node) for (const child of node.children) visit(child)
}
visit(tree)
return sig
}
/** Render a signature element for an error message, truncated for readability. */
function show(value: string | number | undefined): string {
if (value === undefined) return 'nothing'
const text = JSON.stringify(value)
return text.length > 72 ? `${text.slice(0, 72)}` : text
}
/** Return the first divergence for each structural field; empty means equal. */
export function translationStructureDiff(
source: TranslationStructureSignature,
zh: TranslationStructureSignature,
): string[] {
const out: string[] = []
const fields: [string, (string | number)[], (string | number)[]][] = [
['heading (depth)', source.headings, zh.headings],
['code block', source.code, zh.code],
['table (row x column count)', source.tables, zh.tables],
['list (kind, start, item count)', source.lists, zh.lists],
['link target', source.links, zh.links],
]
for (const [field, sourceValues, zhValues] of fields) {
const length = Math.max(sourceValues.length, zhValues.length)
for (let index = 0; index < length; index++) {
if (sourceValues[index] !== zhValues[index]) {
out.push(`${field} #${index + 1} diverges between the pair: ${show(sourceValues[index])} vs ${show(zhValues[index])}`)
break
}
}
}
return out
}

View File

@@ -0,0 +1,67 @@
/** Regression tests for the executable translation prompt contract. */
import { describe, expect, it } from 'vitest'
import {
parseTranslationResponse,
renderTranslationPrompt,
renderTranslationResponse,
} from './translation-prompt.ts'
const document = `# Wrapper
## 模板正文
\`\`\`\`text
{{source_lang}} to {{target_lang}}
{{translation_rules}}
{{terminology}}
[English]({{source_filename}}) | [中文]({{source_filename_zh}})
\`\`\`\`
`
describe('translation prompt rendering', () => {
it('renders every supported placeholder without recursively rewriting injected rules', () => {
const rendered = renderTranslationPrompt(document, {
sourceLanguage: 'English',
sourceFilename: 'guide.md',
translationRules: 'A literal {{source_lang}} in injected rules.',
terminology: '| English | 中文 |',
})
expect(rendered).toContain('English to Chinese')
expect(rendered).toContain('A literal {{source_lang}} in injected rules.')
expect(rendered).toContain('[English](guide.md) | [中文](guide.zh.md)')
})
it('rejects a filename whose suffix contradicts the source language', () => {
expect(() => renderTranslationPrompt(document, {
sourceLanguage: 'Chinese',
sourceFilename: 'guide.md',
translationRules: 'rules',
terminology: 'terms',
})).toThrow('does not match source language Chinese')
})
})
describe('translation response XML', () => {
it('round-trips Markdown and the CDATA terminator', () => {
const response = {
translation: '# Draft\n\nA ]]> marker.',
review: '- [Tone] Fixed.',
final: '# Final\n\nA ]]> marker.',
}
expect(parseTranslationResponse(renderTranslationResponse(response))).toEqual(response)
})
it('rejects missing, reordered, nested, attributed, or non-CDATA children', () => {
expect(() => parseTranslationResponse('<dsh-translation-response version="1"/>')).toThrow('translation, review, and final')
expect(() => parseTranslationResponse('<dsh-translation-response version="1"><review><![CDATA[x]]></review></dsh-translation-response>'))
.toThrow('expected translation, got review')
expect(() => parseTranslationResponse(renderTranslationResponse({ translation: 'x', review: 'y', final: 'z' })
.replace('<translation><![CDATA[x]]></translation>', '<translation><b><![CDATA[x]]></b></translation>')))
.toThrow('nested element b is not allowed')
expect(() => parseTranslationResponse(renderTranslationResponse({ translation: 'x', review: 'y', final: 'z' }).replace('<review>', '<review lang="en">')))
.toThrow('review must not have attributes')
expect(() => parseTranslationResponse(renderTranslationResponse({ translation: 'x', review: 'y', final: 'z' }).replace('<![CDATA[x]]>', 'x')))
.toThrow('all response field content must be inside CDATA')
})
})

View File

@@ -0,0 +1,167 @@
/**
* Executable renderer and strict response parser for the committed
* documentation-translation prompt contract.
*/
import { basename } from 'node:path'
import { SaxesParser } from 'saxes'
/** Placeholder names supported by the committed translation prompt. */
export const TRANSLATION_PROMPT_PLACEHOLDERS = [
'source_lang',
'target_lang',
'translation_rules',
'terminology',
'source_filename',
'source_filename_zh',
] as const
type TranslationPromptPlaceholder = (typeof TRANSLATION_PROMPT_PLACEHOLDERS)[number]
/** Languages accepted by the bidirectional prompt. */
type TranslationLanguage = 'English' | 'Chinese'
/** Inputs that vary for one rendered translation request. */
export interface TranslationPromptInput {
sourceLanguage: TranslationLanguage
/** Source basename, including `.md` or `.zh.md`. */
sourceFilename: string
/** Complete current `translation-rules.md` contents. */
translationRules: string
/** Complete current `terminology.md` contents. */
terminology: string
}
/** Parsed contents of the three-element XML response. */
export interface TranslationResponse {
translation: string
review: string
final: string
}
const PLACEHOLDER = /{{([a-z_]+)}}/g
const TEMPLATE_OPEN = '## 模板正文\n\n````text\n'
const TEMPLATE_CLOSE = '\n````'
const RESPONSE_CHILDREN = ['translation', 'review', 'final'] as const
/** Extract the machine-consumed text fence from `translation-prompt.md`. */
function extractTranslationPrompt(document: string): string {
const start = document.indexOf(TEMPLATE_OPEN)
if (start === -1) throw new Error('translation prompt: missing `## 模板正文` text fence')
const contentStart = start + TEMPLATE_OPEN.length
const end = document.indexOf(TEMPLATE_CLOSE, contentStart)
if (end === -1) throw new Error('translation prompt: missing closing four-backtick fence')
return document.slice(contentStart, end)
}
/** Read the placeholder names documented in the prompt's contract table. */
export function documentedTranslationPromptPlaceholders(document: string): string[] {
const preambleEnd = document.indexOf(TEMPLATE_OPEN)
if (preambleEnd === -1) throw new Error('translation prompt: missing template body')
return [...document.slice(0, preambleEnd).matchAll(/^\| `{{([a-z_]+)}}` \|/gm)].map(match => match[1] ?? '')
}
/** Render one system prompt from the checked-in template and canonical rules. */
export function renderTranslationPrompt(document: string, input: TranslationPromptInput): string {
if (basename(input.sourceFilename) !== input.sourceFilename) {
throw new Error(`translation prompt: sourceFilename must be a basename; got ${JSON.stringify(input.sourceFilename)}`)
}
const sourceIsChinese = input.sourceFilename.endsWith('.zh.md')
if (input.sourceLanguage === 'Chinese' ? !sourceIsChinese : sourceIsChinese || !input.sourceFilename.endsWith('.md')) {
throw new Error(`translation prompt: ${input.sourceFilename} does not match source language ${input.sourceLanguage}`)
}
const targetLanguage: TranslationLanguage = input.sourceLanguage === 'English' ? 'Chinese' : 'English'
const sourceFilenameZh = sourceIsChinese ? input.sourceFilename : input.sourceFilename.replace(/\.md$/, '.zh.md')
const values: Record<TranslationPromptPlaceholder, string> = {
source_lang: input.sourceLanguage,
target_lang: targetLanguage,
translation_rules: input.translationRules,
terminology: input.terminology,
source_filename: input.sourceFilename,
source_filename_zh: sourceFilenameZh,
}
const template = extractTranslationPrompt(document)
const names = [...template.matchAll(PLACEHOLDER)].map(match => match[1] ?? '')
const unknown = names.filter(name => !TRANSLATION_PROMPT_PLACEHOLDERS.includes(name as TranslationPromptPlaceholder))
if (unknown.length > 0) throw new Error(`translation prompt: unsupported placeholder(s): ${[...new Set(unknown)].join(', ')}`)
const missing = TRANSLATION_PROMPT_PLACEHOLDERS.filter(name => !names.includes(name))
if (missing.length > 0) throw new Error(`translation prompt: template does not use required placeholder(s): ${missing.join(', ')}`)
return template.replace(PLACEHOLDER, (_token, name: string) => values[name as TranslationPromptPlaceholder])
}
/** Escape one value so it remains byte-identical inside an XML CDATA field. */
function escapeTranslationCdata(value: string): string {
return value.replaceAll(']]>', ']]]]><![CDATA[>')
}
/** Serialize a response using the exact XML wire contract in the prompt. */
export function renderTranslationResponse(response: TranslationResponse): string {
return [
'<dsh-translation-response version="1">',
`<translation><![CDATA[${escapeTranslationCdata(response.translation)}]]></translation>`,
`<review><![CDATA[${escapeTranslationCdata(response.review)}]]></review>`,
`<final><![CDATA[${escapeTranslationCdata(response.final)}]]></final>`,
'</dsh-translation-response>',
].join('\n')
}
/** Parse and validate the exact XML response shape emitted by the model. */
export function parseTranslationResponse(xml: string): TranslationResponse {
const values: TranslationResponse = { translation: '', review: '', final: '' }
const stack: string[] = []
const cdataFields = new Set<string>()
let rootSeen = false
let childIndex = 0
const fail = (message: string): never => {
throw new Error(`translation response: ${message}`)
}
const parser = new SaxesParser({ xmlns: false })
parser.on('opentag', (tag) => {
if (stack.length === 0) {
if (rootSeen) fail('contains more than one root element')
if (tag.name !== 'dsh-translation-response') fail(`expected dsh-translation-response root, got ${tag.name}`)
const attributes = Object.keys(tag.attributes)
if (attributes.length !== 1 || tag.attributes.version !== '1') fail('root must have only version="1"')
rootSeen = true
} else if (stack.length === 1) {
const expected = RESPONSE_CHILDREN[childIndex]
if (tag.name !== expected) fail(`expected ${expected ?? 'no more children'}, got ${tag.name}`)
if (Object.keys(tag.attributes).length !== 0) fail(`${tag.name} must not have attributes`)
childIndex++
} else {
fail(`nested element ${tag.name} is not allowed`)
}
stack.push(tag.name)
})
parser.on('text', (value) => {
if (stack.length <= 1 && value.trim() === '') return
fail('all response field content must be inside CDATA')
})
parser.on('cdata', (value) => {
const field = stack.at(-1)
if (field === undefined || !RESPONSE_CHILDREN.includes(field as (typeof RESPONSE_CHILDREN)[number])) {
fail('CDATA is allowed only inside translation, review, or final')
}
const key = field as (typeof RESPONSE_CHILDREN)[number]
values[key] += value
cdataFields.add(key)
})
parser.on('closetag', (tag) => {
const expected = stack.pop()
if (expected !== tag.name) fail(`closing ${tag.name} does not match ${expected ?? 'nothing'}`)
})
parser.on('comment', () => fail('comments are not allowed'))
parser.on('doctype', () => fail('doctypes are not allowed'))
parser.on('processinginstruction', () => fail('processing instructions are not allowed'))
parser.on('error', error => fail(`invalid XML: ${error.message}`))
parser.write(xml).close()
if (childIndex !== RESPONSE_CHILDREN.length) fail('translation, review, and final must each appear exactly once and in order')
for (const field of RESPONSE_CHILDREN) {
if (!cdataFields.has(field)) fail(`${field} must contain a CDATA section`)
}
return values
}

View File

@@ -10,10 +10,15 @@
import { createHash } from 'node:crypto' import { createHash } from 'node:crypto'
import { existsSync, globSync, readFileSync, writeFileSync } from 'node:fs' import { existsSync, globSync, readFileSync, writeFileSync } from 'node:fs'
import { basename, join, resolve } from 'node:path' import { basename, join, resolve } from 'node:path'
import { fromMarkdown } from 'mdast-util-from-markdown' import {
import { gfmFromMarkdown } from 'mdast-util-gfm' datedDocumentDate,
import { gfm } from 'micromark-extension-gfm' linksTo,
import type { Nodes } from 'mdast' parseTranslationMarkdown,
parseTranslationPairingManifest,
requiresPairByDate,
translationStructureDiff,
translationStructureSignature,
} from './translation-pairing.ts'
const root = resolve(import.meta.dirname, '..') const root = resolve(import.meta.dirname, '..')
const listMode = process.argv.includes('--list') const listMode = process.argv.includes('--list')
@@ -22,55 +27,7 @@ const writeMode = process.argv.includes('--write')
/** Scope of the bilingual contract: the root README, the docs tree, and the Python SDK tree. */ /** Scope of the bilingual contract: the root README, the docs tree, and the Python SDK tree. */
const SCOPE_PATTERNS = ['README.md', 'README.zh.md', 'README.i18n.yaml', 'docs/**/*.md', 'docs/**/*.i18n.yaml', 'python/**/*.md', 'python/**/*.i18n.yaml'] const SCOPE_PATTERNS = ['README.md', 'README.zh.md', 'README.i18n.yaml', 'docs/**/*.md', 'docs/**/*.i18n.yaml', 'python/**/*.md', 'python/**/*.i18n.yaml']
/** The enforcement frontier and the never-paired set (docs/i18n/README.md § Scope). */ const manifest = parseTranslationPairingManifest(readFileSync(join(root, 'scripts/translation-pairing.manifest.json'), 'utf8'))
interface Manifest {
required: string[]
excluded: string[]
/** Date-named documents (yyyy-mm-dd-*.md, i.e. RFCs) dated on/after this day must merge bilingual. */
requiredSince: string
}
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/
/** Whether a string names one real calendar day in canonical ISO form. */
function isIsoDate(value: string): boolean {
if (!ISO_DATE.test(value)) return false
const date = new Date(`${value}T00:00:00.000Z`)
return !Number.isNaN(date.getTime()) && date.toISOString().slice(0, 10) === value
}
/** Read one manifest string-array field or fail before enforcement starts. */
function stringArrayField(record: Record<string, unknown>, field: 'required' | 'excluded'): string[] {
const value = record[field]
if (!Array.isArray(value)) {
throw new Error(`translation-pairing.manifest.json: ${field} must be an array of strings`)
}
const entries: unknown[] = value
if (!entries.every((entry): entry is string => typeof entry === 'string')) {
throw new Error(`translation-pairing.manifest.json: ${field} must be an array of strings`)
}
return entries
}
/** Parse and validate the checked-in bilingual manifest. */
function parseManifest(content: string): Manifest {
const value: unknown = JSON.parse(content)
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
throw new Error('translation-pairing.manifest.json: expected an object')
}
const record = value as Record<string, unknown>
const requiredSince = record.requiredSince
if (typeof requiredSince !== 'string' || !isIsoDate(requiredSince)) {
throw new Error(`translation-pairing.manifest.json: requiredSince must be a valid YYYY-MM-DD date; got ${JSON.stringify(requiredSince)}`)
}
return {
required: stringArrayField(record, 'required'),
excluded: stringArrayField(record, 'excluded'),
requiredSince,
}
}
const manifest = parseManifest(readFileSync(join(root, 'scripts/translation-pairing.manifest.json'), 'utf8'))
/** /**
* An excluded entry ending in `/` excludes the whole directory. The trailing * An excluded entry ending in `/` excludes the whole directory. The trailing
@@ -122,98 +79,6 @@ function renderMeta(source: string, sourceHash: string, zh: string, zhHash: stri
].join('\n') ].join('\n')
} }
/**
* The structural signature the two sides must share, as ordered sequences so
* a swap or a level change is caught, not just a count change. Prose is
* deliberately absent: the gate checks shape, never wording.
*/
interface Signature {
/** Heading depths in document order (h2 → 2). */
headings: number[]
/** Fenced code blocks verbatim: info string + content, in order. */
code: string[]
/** Column count of each table, in order. */
tables: number[]
/** Each list's kind (ordered vs bullet), in order. */
lists: string[]
/** Every link target in order, the language switcher's excluded. */
links: string[]
}
/** Whether the tree contains a link to exactly `target` (the switcher check). */
function linksTo(tree: Nodes, target: string): boolean {
let found = false
const visit = (node: Nodes): void => {
if (node.type === 'link' && node.url === target) found = true
if ('children' in node) for (const child of node.children) visit(child)
}
visit(tree)
return found
}
/** Collect the structural signature, skipping links to `switcherTarget`. */
function signatureOf(tree: Nodes, switcherTarget: string): Signature {
const sig: Signature = { headings: [], code: [], tables: [], lists: [], links: [] }
const visit = (node: Nodes): void => {
switch (node.type) {
case 'heading':
sig.headings.push(node.depth)
break
case 'code':
sig.code.push(`\`\`\`${node.lang ?? ''}${node.meta ? ` ${node.meta}` : ''}\n${node.value}`)
break
case 'table':
sig.tables.push(node.children[0]?.children.length ?? 0)
break
case 'list':
sig.lists.push(node.ordered ? 'ordered' : 'bullet')
break
case 'link':
if (node.url !== switcherTarget) sig.links.push(node.url)
break
default:
// Every other node kind is prose or container — not part of the signature.
break
}
if ('children' in node) for (const child of node.children) visit(child)
}
visit(tree)
return sig
}
/** Render a signature element for an error message, truncated for readability. */
function show(value: string | number | undefined): string {
if (value === undefined) return 'nothing'
const text = JSON.stringify(value)
return text.length > 72 ? `${text.slice(0, 72)}` : text
}
/** First divergence between two signatures, as messages; empty when identical. */
function signatureDiff(source: Signature, zh: Signature): string[] {
const out: string[] = []
const fields: [string, (string | number)[], (string | number)[]][] = [
['heading (depth)', source.headings, zh.headings],
['code block', source.code, zh.code],
['table (column count)', source.tables, zh.tables],
['list (kind)', source.lists, zh.lists],
['link target', source.links, zh.links],
]
for (const [field, s, z] of fields) {
const length = Math.max(s.length, z.length)
for (let i = 0; i < length; i++) {
if (s[i] !== z[i]) {
out.push(`${field} #${i + 1} diverges between the pair: ${show(s[i])} vs ${show(z[i])}`)
break
}
}
}
return out
}
function parse(content: string): Nodes {
return fromMarkdown(content, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] })
}
// Enumerate the scope once. // Enumerate the scope once.
const files = new Set<string>() const files = new Set<string>()
for (const pattern of SCOPE_PATTERNS) { for (const pattern of SCOPE_PATTERNS) {
@@ -259,14 +124,13 @@ for (const req of manifest.required) {
// 2. Date-named documents (RFCs) dated on/after the requiredSince cutoff merge // 2. Date-named documents (RFCs) dated on/after the requiredSince cutoff merge
// bilingual: a new RFC lands with its pair or not at all. Deterministic from // bilingual: a new RFC lands with its pair or not at all. Deterministic from
// the filename alone — no git history, so it holds on shallow CI checkouts. // the filename alone — no git history, so it holds on shallow CI checkouts.
const DATED = /(?:^|\/)(\d{4}-\d{2}-\d{2})-[^/]*\.md$/
for (const source of sources) { for (const source of sources) {
if (isExcluded(source)) continue if (isExcluded(source)) continue
const dated = DATED.exec(source) const date = datedDocumentDate(source)
if (!dated?.[1] || dated[1] < manifest.requiredSince) continue if (!requiresPairByDate(source, manifest.requiredSince) || date === undefined) continue
const { zh } = pairPaths(source) const { zh } = pairPaths(source)
if (!existsSync(join(root, zh))) { if (!existsSync(join(root, zh))) {
errors.push(`${source}: dated ${dated[1]} — documents dated on/after ${manifest.requiredSince} merge bilingual (docs/i18n/README.md); add the counterpart and record the pair`) errors.push(`${source}: dated ${date} — documents dated on/after ${manifest.requiredSince} merge bilingual (docs/i18n/README.md); add the counterpart and record the pair`)
state.set(source, 'missing') state.set(source, 'missing')
} }
} }
@@ -314,15 +178,18 @@ for (const source of [...pairAnchors].sort()) {
continue continue
} }
const sourceTree = parse(sourceContent.toString('utf8')) const sourceTree = parseTranslationMarkdown(sourceContent.toString('utf8'))
const zhTree = parse(zhContent.toString('utf8')) const zhTree = parseTranslationMarkdown(zhContent.toString('utf8'))
if (!linksTo(zhTree, basename(source))) { if (!linksTo(zhTree, basename(source))) {
errors.push(`${zh}: missing language switcher — no link to ${basename(source)}`) errors.push(`${zh}: missing language switcher — no link to ${basename(source)}`)
} }
if (!linksTo(sourceTree, basename(zh))) { if (!linksTo(sourceTree, basename(zh))) {
errors.push(`${source}: missing language switcher — no link back to ${basename(zh)}`) errors.push(`${source}: missing language switcher — no link back to ${basename(zh)}`)
} }
for (const divergence of signatureDiff(signatureOf(sourceTree, basename(zh)), signatureOf(zhTree, basename(source)))) { for (const divergence of translationStructureDiff(
translationStructureSignature(sourceTree, basename(zh)),
translationStructureSignature(zhTree, basename(source)),
)) {
errors.push(`${source}${zh}: ${divergence}`) errors.push(`${source}${zh}: ${divergence}`)
} }
if (!state.has(source)) state.set(source, 'ok') if (!state.has(source)) state.set(source, 'ok')
@@ -338,8 +205,7 @@ if (listMode) {
const rows = [...state.entries()].sort((a, b) => order[a[1]] - order[b[1]] || a[0].localeCompare(b[0])) const rows = [...state.entries()].sort((a, b) => order[a[1]] - order[b[1]] || a[0].localeCompare(b[0]))
for (const [file, status] of rows) { for (const [file, status] of rows) {
const required = manifest.required.includes(file) const required = manifest.required.includes(file)
const date = DATED.exec(file)?.[1] const tag = required ? ' (required)' : requiresPairByDate(file, manifest.requiredSince) ? ' (required by date)' : ' (backlog)'
const tag = required ? ' (required)' : date && date >= manifest.requiredSince ? ' (required by date)' : ' (backlog)'
console.log(`${status.padEnd(11)} ${file}${status === 'missing' ? tag : ''}`) console.log(`${status.padEnd(11)} ${file}${status === 'missing' ? tag : ''}`)
} }
const counts = { 'ok': 0, 'out-of-sync': 0, 'missing': 0 } const counts = { 'ok': 0, 'out-of-sync': 0, 'missing': 0 }

View File

@@ -0,0 +1,57 @@
/** Verify that the committed translation prompt renders and parses as documented. */
import { readFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import {
documentedTranslationPromptPlaceholders,
parseTranslationResponse,
renderTranslationPrompt,
renderTranslationResponse,
TRANSLATION_PROMPT_PLACEHOLDERS,
} from './translation-prompt.ts'
const root = resolve(import.meta.dirname, '..')
function read(path: string): string {
return readFileSync(join(root, path), 'utf8')
}
try {
const document = read('docs/i18n/translation-prompt.md')
const translationRules = read('docs/i18n/translation-rules.md')
const terminology = read('docs/i18n/terminology.md')
const documented = documentedTranslationPromptPlaceholders(document)
if (documented.join('\n') !== TRANSLATION_PROMPT_PLACEHOLDERS.join('\n')) {
throw new Error(`placeholder table must list exactly: ${TRANSLATION_PROMPT_PLACEHOLDERS.join(', ')}`)
}
const englishSource = renderTranslationPrompt(document, {
sourceLanguage: 'English',
sourceFilename: 'example.md',
translationRules,
terminology,
})
const chineseSource = renderTranslationPrompt(document, {
sourceLanguage: 'Chinese',
sourceFilename: 'example.zh.md',
translationRules,
terminology,
})
if (englishSource.includes('{{') || chineseSource.includes('{{')) throw new Error('rendered prompt contains an unresolved placeholder')
if (!englishSource.includes('[English](example.md) | 中文')) throw new Error('English-source render does not carry the Chinese switcher instruction')
if (!chineseSource.includes('English | [中文](example.zh.md)')) throw new Error('Chinese-source render does not carry the English switcher instruction')
const example = /```xml\n([\s\S]*?)\n```/.exec(englishSource)?.[1]
if (example === undefined) throw new Error('rendered prompt has no XML response example')
parseTranslationResponse(example)
const roundTrip = { translation: 'first ]]> pass', review: '- [None] No corrections.', final: 'final ]]> text' }
const parsed = parseTranslationResponse(renderTranslationResponse(roundTrip))
if (JSON.stringify(parsed) !== JSON.stringify(roundTrip)) throw new Error('CDATA split rule does not round-trip response content')
console.log('verify-translation-prompt: both directions render and the XML response contract parses.')
} catch (error) {
const message = error instanceof Error ? error.message : String(error)
console.error(`verify-translation-prompt: ${message}`)
process.exit(1)
}

View File

@@ -7,7 +7,7 @@ export default defineConfig({
// to source; native resolution would fall through to absent `lib/` outputs. // to source; native resolution would fall through to absent `lib/` outputs.
plugins: [tsconfigPaths({ projects: ['./tsconfig.json'] })], plugins: [tsconfigPaths({ projects: ['./tsconfig.json'] })],
test: { test: {
include: ['packages/*/*/tests/**/*.spec.ts', 'examples/*/tests/**/*.spec.ts'], include: ['packages/*/*/tests/**/*.spec.ts', 'examples/*/tests/**/*.spec.ts', 'scripts/**/*.spec.ts'],
coverage: { coverage: {
provider: 'v8', provider: 'v8',
// Coverage measures OUR runtime source. Types-only files carry no // Coverage measures OUR runtime source. Types-only files carry no