diff --git a/.agents/skills/dsh-code-review/SKILL.md b/.agents/skills/dsh-code-review/SKILL.md index 54510133db..addc09f2ac 100644 --- a/.agents/skills/dsh-code-review/SKILL.md +++ b/.agents/skills/dsh-code-review/SKILL.md @@ -34,7 +34,7 @@ These come straight from the source docs above. They are not discretionary; abse 1. **Docs in sync.** If the PR changes a config key, default, error code, wire field, or event name, it must update the package README + module/JSDoc in the same diff. The `doc-sync` gate (check #4) does not catch prose drift in config keys, defaults, error codes, or wire fields — that is on the reviewer, but it is still required, not optional. 2. **Core-data-structures catalog in sync.** If the PR adds, removes, or reshapes a type the [core-data-structures catalog](../../../docs/core-data-structures/core.md) documents — a new `…Map` variant, a new content-block/session-event type, a field on `GenerateOptions`/`Agent`/`ToolDefinition`/a bash type, or a whole new core/seam type — it must update that catalog in the same diff (prose + any verbatim ` ```ts type-equiv ` block + the 1:1 `scripts/type-equiv.manifest.json`). The `verify-type-equiv` gate (part of `doc-sync`) catches a *drifted paste* of an already-documented type, but it cannot tell you a brand-new core type went undocumented — that judgment is yours. Confirm a genuinely spine-level type landed in core.md and a new capability's vocabulary on a sub-page, per the spine-vs-seam line in [core.md § What counts as "core"](../../../docs/core-data-structures/core.md#what-counts-as-core). A pure internal type with no cross-package reach needs no catalog entry — say so if it's a judgment call. 3. **HMR-safety test.** Any new registry/registration needs a test that disposes the contributing fiber and asserts cleanup (packages/AGENTS.md). Its absence blocks merge. -4. **Quality gates pass.** typecheck, lint, test, test:coverage (100% per-file on `packages/*/src`), knip, build, publint, constraints, `doc-sync` (doc-typecheck + verify-cordis-catalog + verify-md-wrap + verify-md-links + verify-type-equiv), module-graph freshness (the quality-gates RFC). Don't re-review what a gate already enforces — trust the gate and spend attention on what it can't check. Note that the `doc-sync` gate only covers compilable `ts` blocks, the generated cordis events/services catalog, markdown wrapping/links, and verbatim type-equiv blocks; prose drift (checks #1 and #2) is *additional* manual review on top of it, not covered by it. +4. **Quality gates pass.** typecheck, lint, test, test:coverage (100% per-file on `packages/*/src`), knip, build, publint, constraints, `doc-sync` (doc-typecheck + verify-cordis-catalog + verify-md-wrap + verify-md-links + verify-type-equiv + verify-translation-pairing), module-graph freshness (the quality-gates RFC). Don't re-review what a gate already enforces — trust the gate and spend attention on what it can't check. Note that the `doc-sync` gate only covers compilable `ts` blocks, the generated cordis events/services catalog, markdown wrapping/links, verbatim type-equiv blocks, and the bilingual pairing contract ([docs/i18n/README.md](../../../docs/i18n/README.md)); prose drift (checks #1 and #2) and translation *quality* (the [dsh-translate-docs](../dsh-translate-docs/SKILL.md) rules) are *additional* manual review on top of it, not covered by it. ## Reviewer-only checks (gates can't catch these — judgment required) diff --git a/.agents/skills/dsh-translate-docs/SKILL.md b/.agents/skills/dsh-translate-docs/SKILL.md new file mode 100644 index 0000000000..8fb231d2fc --- /dev/null +++ b/.agents/skills/dsh-translate-docs/SKILL.md @@ -0,0 +1,56 @@ +--- +name: dsh-translate-docs +description: Use when creating or updating Chinese (.zh.md) translations of this repo's documentation — orients the translator to the bilingual pairing contract, the terminology source of truth, the translation rules, and the freshness gate that verifies the result +--- + +# Translating DeepSeek-Harness docs + +**This skill is guidance, not a translation memory.** It is the workflow map for producing `.zh.md` files that pass the pairing gate and read as natural technical Chinese. You are the translator: the rules below say what must hold, not how to phrase any particular sentence — phrasing judgment is yours, terminology is not. + +## Sources of truth (read, don't re-summarize) + +These are authoritative; read them at the source so this skill never drifts out of sync. + +- **[docs/i18n/README.md](../../../docs/i18n/README.md)** — the pairing contract: sibling `foo.md ↔ foo.zh.md`, the `i18n-source` fingerprint format, 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/terminology.md](../../../docs/i18n/terminology.md)** — the terminology table. Load it BEFORE translating, not when a term feels uncertain; the terms you don't notice are the ones that drift. + +## Find the work + +- `pnpm run verify-translation-pairing --list` prints every in-scope document as missing / stale / ok — the work list for a translation batch. +- In a PR that edits English docs, the work list is the diff itself: every changed `.md` with an existing `.zh.md` sibling needs its translation updated in the same PR, and the gate goes red if you forget. + +## Triage by change type + +Do not process every file the same way: + +- **New translation** (no `.zh.md` yet): translate the whole file, section by section for long documents — keep each section's structure locked to the source as you go rather than fixing structure at the end. +- **Update** (`.zh.md` exists but stale): do NOT re-translate the file. The fingerprint names the exact source text the translation was based on — recover it and diff: + + ```sh + git cat-file -p > /tmp/old-source.md + git diff --no-index /tmp/old-source.md docs/foo.md + ``` + + Apply the smallest Chinese edits that cover that diff. A minimal update preserves the reviewed phrasing of everything that didn't change; a re-translation throws that review away. +- **Deleted or renamed source**: delete or rename the `.zh.md` alongside it — the gate reports it as an orphan otherwise. + +## Translate + +- Work through the document applying [translation-rules.md](../../../docs/i18n/translation-rules.md). Internally: first render faithfully, then re-read the Chinese alone for awkward or ambiguous phrasing, then polish — but write ONLY the final Chinese to the file, never drafts or notes. +- Every term in [terminology.md](../../../docs/i18n/terminology.md) renders exactly as specified, including first-occurrence annotations. A term the table misses: translate only with a citable precedent from a major Chinese OSS/vendor doc; otherwise keep the English and add it to the PR's 「待定术语」 list with your suggested rendering. Never invent a rendering inline — that decision belongs to a human and then to the table. +- Code blocks are byte-identical to the source, comments included. Relative links keep their English targets; only the switcher line links `.zh.md`. + +## Finish the pair + +1. Fingerprint: compute the source's current blob hash and write the comment as the FIRST line of the `.zh.md` — `git hash-object docs/foo.md` → ``. +2. Switcher: `[English](foo.md) | 中文` immediately after the translation's H1; confirm the English file carries `English | [中文](foo.zh.md)` after its own H1 — add it if this is the pair's first translation. +3. New batch landed? Add the English paths to `required` in [scripts/translation-pairing.manifest.json](../../../scripts/translation-pairing.manifest.json) so the gate ratchets forward. + +## Verify — the gate, not your eyes + +Run `pnpm run verify-translation-pairing`, then the rest of the Markdown gates (`pnpm run verify-md-wrap && pnpm run verify-md-links`, or full `pnpm run doc-sync` before the PR). Fix what they report; do not hand-check what they cover. What they can NOT check — translation quality, terminology judgment calls, tone — is exactly what the PR reviewer will read for, so keep the PR reviewable: state which files are new translations vs minimal updates, and list 「待定术语」 prominently. + +## How to respond to translation review + +Same discipline as any review in this repo (see [dsh-code-review](../dsh-code-review/SKILL.md) § How to respond): evaluate each comment on its merits, and for terminology comments, remember the table is the contract — a reviewer's rendering decision gets applied to [terminology.md](../../../docs/i18n/terminology.md) so it binds every future translation, not just patched into one file. diff --git a/AGENTS.md b/AGENTS.md index 4bdeb727fe..617a16089f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -177,9 +177,13 @@ pnpm run verify-package-paths # assert every packages/ cited in Markdown pnpm run verify-rfc-classification # assert every RFC lives in a valid # {lifecycle}/{class}/ folder and docs/rfc/README.md lists it # under the matching heading (closed class set + index completeness) +pnpm run verify-translation-pairing # assert the bilingual pairing contract + # (docs/i18n/README.md): required docs have a .zh.md sibling; + # every .zh.md is fingerprint-fresh, switcher-linked, and + # structure-matched. `--list` prints the translation work list pnpm run verify-node-next-types # assert built declarations typecheck for a # standard external NodeNext ESM TypeScript consumer -pnpm run doc-sync # doc-typecheck + verify-cordis-catalog + verify-md-wrap + verify-md-links + verify-doc-refs + verify-package-paths + verify-rfc-classification + verify-type-equiv (CI runs this) +pnpm run doc-sync # doc-typecheck + verify-cordis-catalog + verify-tool-catalog + verify-md-wrap + verify-md-links + verify-doc-refs + verify-package-paths + verify-rfc-classification + verify-type-equiv + verify-translation-pairing (CI runs this) pnpm run demo:echo # run examples/echo-agent (no API key; type "echo hi" to # see a tool call) — the mock skeleton pnpm run demo:coding # run examples/coding-agent — the real agent (needs @@ -276,7 +280,7 @@ This codebase aims to be **very type-safe and well documented** for maintainabil In the **core** packages (`packages/llm/llm`, `packages/core/tools`, `packages/core/agent`, `packages/core/agent-loop`, `packages/core/session`, `packages/core/system-prompt`), **type gymnastics are acceptable when they improve the DX of plugin authors** for common plugin types. The `defineTool` typed schema DSL in `dsh-tools` is the canonical example: the `SchemaSpec` to `InferArgs` type-level mapping gives tool authors zero-cast typed `execute` args, and the cost of the conditional types stays inside the core package. -Verbose documentation is fine **as long as docs and code stay strictly in sync**. Out-of-sync docs are worse than no docs. **When you change code, update its docs in the SAME change** — grep the package README and the module/JSDoc comments for the old behavior (config keys, defaults, error codes, wire field names, event names) and fix every hit. CI runs `pnpm run doc-sync` (`doc-typecheck` + `verify-cordis-catalog` + `verify-md-wrap` + `verify-md-links` + `verify-doc-refs` + `verify-package-paths` + `verify-rfc-classification` + `verify-type-equiv`), which typechecks every fenced `ts` block in `README.md`, `docs/**/*.md`, and `packages/*/*.md`, regenerates the cordis events/services catalog from source and fails if the committed copy is stale, asserts no hard-wrapped prose paragraphs, checks that every relative Markdown cross-link resolves, checks that every `docs/*.md` path cited in a source comment resolves, checks that every `packages/` reference naming a real package resolves, checks that every RFC is filed under a valid class folder and listed in its index, and checks that every ` ```ts type-equiv ` doc block still matches its source type — across those files plus `AGENTS.md` / `packages/AGENTS.md` — but that scope does NOT catch prose drift in `AGENTS.md` / `packages/AGENTS.md` / `packages/README.md` (config keys, defaults, error codes), so keeping those in sync remains on the author. Every module has a module-level doc comment explaining its role. Every exported class, interface, type, function, and non-obvious method has a JSDoc that explains semantics (not just the name) — contracts (what events fire when), disposal behavior, error behavior, and extension intent. Internal helpers get docs only where non-obvious. Prefer one-liners when one line suffices. +Verbose documentation is fine **as long as docs and code stay strictly in sync**. Out-of-sync docs are worse than no docs. **When you change code, update its docs in the SAME change** — grep the package README and the module/JSDoc comments for the old behavior (config keys, defaults, error codes, wire field names, event names) and fix every hit. CI runs `pnpm run doc-sync` (`doc-typecheck` + `verify-cordis-catalog` + `verify-tool-catalog` + `verify-md-wrap` + `verify-md-links` + `verify-doc-refs` + `verify-package-paths` + `verify-rfc-classification` + `verify-type-equiv` + `verify-translation-pairing`), which typechecks every fenced `ts` block in `README.md`, `docs/**/*.md`, and `packages/*/*.md`, regenerates the cordis events/services catalog from source and fails if the committed copy is stale, asserts no hard-wrapped prose paragraphs, checks that every relative Markdown cross-link resolves, checks that every `docs/*.md` path cited in a source comment resolves, checks that every `packages/` reference naming a real package resolves, checks that every RFC is filed under a valid class folder and listed in its index, checks that every ` ```ts type-equiv ` doc block still matches its source type, and checks the bilingual pairing contract (required docs have a fresh `.zh.md` sibling — see [docs/i18n/README.md](docs/i18n/README.md)) — across those files plus `AGENTS.md` / `packages/AGENTS.md` — but that scope does NOT catch prose drift in `AGENTS.md` / `packages/AGENTS.md` / `packages/README.md` (config keys, defaults, error codes), so keeping those in sync remains on the author. The same-change rule extends to translations: **editing an English doc that has a `.zh.md` sibling means updating the translation in the SAME change** (run the [dsh-translate-docs](.agents/skills/dsh-translate-docs/SKILL.md) skill); the pairing gate goes red otherwise. Every module has a module-level doc comment explaining its role. Every exported class, interface, type, function, and non-obvious method has a JSDoc that explains semantics (not just the name) — contracts (what events fire when), disposal behavior, error behavior, and extension intent. Internal helpers get docs only where non-obvious. Prefer one-liners when one line suffices. **Tag every new event with `@mode`.** The cordis events/services catalog ([docs/cordis-catalog/events-and-services.md](docs/cordis-catalog/events-and-services.md)) is GENERATED from source by `scripts/gen-cordis-catalog.ts` — never hand-edit it; run `pnpm run gen-cordis-catalog` and commit the result. When you add an event to an `interface Events` block, its JSDoc MUST carry a `@mode emit|waterfall|parallel|serial` tag (the generator hard-errors without it): use `waterfall` when the signature ends with a `next: () => …` parameter (the listener transforms or vetoes via `next()`), `parallel` when the loop awaits a fan-out and must run every listener (e.g. an awaited `Promise | void` checkpoint like `session/flush`), `serial` when the loop awaits listeners in registration order and should isolate side effects (e.g. an ordered surface-mutation checkpoint like `agent/pre-step`; Cordis stops early if a listener returns a bail value, so `void` serial listeners must not return a semantic veto), and `emit` for plain fire-and-forget notifications. The generator also cross-checks the tag against the signature where the shape is conclusive (a trailing `next` ⇒ waterfall) and hard-errors on a contradiction. Write the rest of the event's JSDoc to stand alone — it is the catalog entry's prose. diff --git a/README.md b/README.md index 1ce5aa8960..33c03fad14 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # DeepSeek Harness +English | [中文](README.zh.md) + Monorepo for the DeepSeek Harness group. ## Projects diff --git a/README.zh.md b/README.zh.md new file mode 100644 index 0000000000..4c911a42e0 --- /dev/null +++ b/README.zh.md @@ -0,0 +1,26 @@ + + +# DeepSeek Harness + +[English](README.md) | 中文 + +DeepSeek Harness 小组的 monorepo。 + +## 项目 + +- **DeepSeek Code** — DeepSeek 的编码 agent(智能体)产品。 + +## 开发 + +本 monorepo 基于 [Cordis](https://github.com/cordiverse/cordis) 框架构建(以源码形式收录在 `vendor/` 下),采用微内核风格:一切皆插件。 + +```sh +pnpm install +pnpm run test # vitest +pnpm run demo:echo # runnable echo-agent example (no API key needed) +pnpm run demo:coding # the real DeepSeek coding agent (needs DEEPSEEK_API_KEY) +``` + +面向人类读者:先读[开发指南](docs/development.md)了解本地环境、钩子、环境变量与质量门禁,动手改 package 之前再读[架构设计](docs/architecture.md)。局部上下文见 [packages/](packages/) 与 [vendor/](vendor/)。 + +面向 agent:遵循 [AGENTS.md](AGENTS.md)。 diff --git a/docs/i18n/README.md b/docs/i18n/README.md new file mode 100644 index 0000000000..e70a1fed0d --- /dev/null +++ b/docs/i18n/README.md @@ -0,0 +1,47 @@ +# Bilingual documentation + +English | [中文](README.zh.md) + +This repo's documentation is read by people and agents both inside and outside the company, so the README and the docs tree are maintained in English and Simplified Chinese. This page defines the pairing contract, the enforcement gate, and the rollout policy; [translation-rules.md](translation-rules.md) defines how to translate; [terminology.md](terminology.md) is the terminology source of truth. The committed agent workflow lives in [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md). + +## The pairing contract + +- **English is canonical.** Every document is authored in English at its existing path, and the Chinese file is derived from it — translation flows EN → ZH only. A content change starts in the English file; the Chinese file never carries information its English source lacks. +- **Paired sibling files.** The translation of `foo.md` is `foo.zh.md` in the same directory. No locale directories, no separate translation repo, no interleaved bilingual files. +- **Source fingerprint.** The FIRST line of every `.zh.md` file is an HTML comment recording the repo-relative path and the git blob hash (first 12 hex digits of `git hash-object`) of the English source it was translated from: + + ```markdown + + ``` + + A blob hash, not a commit hash, so the fingerprint is computable for an English file edited in the same PR (`git hash-object docs/foo.md`), and so staleness is a pure content comparison. The fingerprint is also the update tool: `git cat-file -p ` recovers the exact source text a stale translation was based on, and `git diff ` isolates what changed so the translation can be updated minimally instead of re-translated. +- **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 source.** Heading hierarchy, list shape, table columns, and code blocks match the English file one to one — 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 + +`pnpm run verify-translation-pairing` (part of `doc-sync`, so CI and the pre-push hook run it) enforces the contract mechanically: + +1. Every English file listed as `required` in [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) has a `.zh.md` sibling. +2. Every existing `.zh.md` file — required or not — passes all of: its English source exists (no orphans), its fingerprint matches the source's current blob hash (no stale translations), both sides carry the language switcher, and its fenced-code-block and heading counts equal the source's. +3. Files listed as `excluded` have no `.zh.md` sibling at all. + +`pnpm run verify-translation-pairing --list` prints the current translation state of every document in scope — missing, stale, or ok — and is the work list for translation batches. It never fails; it reports. + +The practical rule this gate creates: **when a PR edits an English document that has a `.zh.md` sibling, the same PR updates the translation** (run the [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill), exactly like the repo's existing doc-sync rule for code and READMEs. A PR that leaves a translation stale goes red in CI. + +## Scope, exclusions, and rollout + +**Scope**: the root `README.md` and everything under `docs/**`. Package READMEs (`packages/**`) join the scope in a later batch. + +**Excluded** (never paired, and the gate rejects a `.zh.md` for them): + +- `docs/cordis-catalog/`, `docs/tool-catalog/`, `docs/module-graph.md` — generated files; their generators emit English only, so a translation would go stale on every regeneration. +- `docs/AGENTS.md` — agent instructions, maintained in English only like the root `AGENTS.md`. +- `docs/i18n/terminology.md` — the terminology table is itself bilingual by construction. + +**Rollout**: the `required` list in the manifest is the enforcement frontier, not the goal. The goal is full bilingual coverage of the scope. Translation lands in reviewable batches (core entry docs, cookbook, RFCs, postmortems, …); each merged batch adds its files to `required`, so the gate ratchets forward and never regresses. Documents not yet in `required` are backlog — visible in `--list` — but any translation that already exists is held to the full contract regardless of the list. + +## Division of labor + +Translations 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 exists so that neither the agent nor the reviewer has to remember the contract: pairing, freshness, and structure are checked mechanically, and review attention goes to translation quality and terminology, where human judgment is the whole point. diff --git a/docs/i18n/README.zh.md b/docs/i18n/README.zh.md new file mode 100644 index 0000000000..12b0f736d7 --- /dev/null +++ b/docs/i18n/README.zh.md @@ -0,0 +1,49 @@ + + +# 双语文档 + +[English](README.md) | 中文 + +本仓库的文档会被公司内外的人和 agent(智能体)阅读,因此 README 与 docs 目录树以英文和简体中文双语维护。本页定义配对契约、强制门禁与推进策略;[translation-rules.md](translation-rules.md) 定义如何翻译;[terminology.md](terminology.md) 是术语真源。进仓的 agent 工作流见 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md)。 + +## 配对契约 + +- **英文是唯一真源。**每篇文档都以英文在其现有路径撰写,中文文件由它派生——翻译只沿 EN → ZH 单向流动。内容变更始于英文文件;中文文件永远不携带英文源没有的信息。 +- **配对的同目录文件。**`foo.md` 的译文是同目录下的 `foo.zh.md`。不用语言目录,不用独立翻译仓库,不用中英混排的单文件。 +- **源指纹。**每个 `.zh.md` 文件的第一行是一条 HTML 注释,记录它翻译所依据的英文源的仓库相对路径和 git blob hash(`git hash-object` 的前 12 位十六进制): + + ```markdown + + ``` + + 用 blob hash 而不是 commit hash,这样同一个 PR 里改动的英文文件也能算出指纹(`git hash-object docs/foo.md`),过期检测则是纯内容比较。指纹同时也是更新工具:`git cat-file -p ` 能还原过期译文当初依据的确切源文本,`git diff <当前 blob>` 能隔离出变化的部分,让译文做最小更新而不是整篇重译。 +- **语言切换行。**两个文件在各自 H1 标题之后立即互链:英文文件带 `English | [中文](foo.zh.md)`,中文文件带 `[English](foo.md) | 中文`。 +- **结构与源一一对应。**标题层级、列表形态、表格列与代码块和英文文件一一对应——完整保持规则见 [translation-rules.md](translation-rules.md)。既有 Markdown 门禁对 `.zh.md` 文件原样生效(`verify-md-wrap`、`verify-md-links`)。 + +## 门禁:verify-translation-pairing + +`pnpm run verify-translation-pairing`(`doc-sync` 的一环,因此 CI 和 pre-push 钩子都会运行)机械地强制这份契约: + +1. [scripts/translation-pairing.manifest.json](../../scripts/translation-pairing.manifest.json) 中 `required` 列出的每个英文文件都有 `.zh.md` 配对文件。 +2. 每个已存在的 `.zh.md` 文件——无论是否 required——都通过全部检查:其英文源存在(无孤儿)、指纹等于源的当前 blob hash(无过期译文)、双方都带语言切换行、其代码块与标题数量等于源文件。 +3. 列为 `excluded` 的文件完全没有 `.zh.md` 配对。 + +`pnpm run verify-translation-pairing --list` 打印范围内每篇文档的当前翻译状态——missing、stale 或 ok——是翻译批次的工作清单。它从不失败;它只报告。 + +这个门禁带来的实际规则是:**当一个 PR 修改了已有 `.zh.md` 配对的英文文档时,同一个 PR 更新译文**(运行 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill),与本仓库既有的代码/README doc-sync 规则完全一致。留下过期译文的 PR 会在 CI 变红。 + +## 范围、排除与推进 + +**范围**:根 `README.md` 与 `docs/**` 下的全部内容。package README(`packages/**`)在后续批次加入范围。 + +**排除**(永不配对,门禁拒绝为它们建 `.zh.md`): + +- `docs/cordis-catalog/`、`docs/tool-catalog/`、`docs/module-graph.md` —— 生成文件;生成器只输出英文,译文在每次重新生成时必然过期。 +- `docs/AGENTS.md` —— agent 指令,与根 `AGENTS.md` 一样只以英文维护。 +- `docs/i18n/terminology.md` —— 术语表本身即是双语构造。 + +**推进**:manifest 中的 `required` 列表是强制边界,不是目标。目标是范围内的全量双语覆盖。翻译按可评审的批次落地(核心入口文档、cookbook、RFC、postmortem……);每个批次合入后把其文件加进 `required`,门禁只进不退。尚未进入 `required` 的文档是 backlog——在 `--list` 中可见——但任何已存在的译文无论在不在清单里都按完整契约检查。 + +## 分工 + +这里的译文由运行 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) 的 agent 产出、由人评审——在这里推理(inference)很便宜,评审注意力才是稀缺资源。门禁的存在让 agent 和评审者都不必记住契约:配对、新鲜度和结构由机械检查兜底,评审注意力投向翻译质量与术语——这正是人的判断的用武之地。 diff --git a/docs/i18n/translation-rules.md b/docs/i18n/translation-rules.md new file mode 100644 index 0000000000..323504edea --- /dev/null +++ b/docs/i18n/translation-rules.md @@ -0,0 +1,60 @@ +# Translation rules (EN → ZH) + +English | [中文](translation-rules.zh.md) + +How to translate a document in this repo into Simplified Chinese. These rules bind humans and agents equally; the committed agent workflow that applies them is [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md), and the pairing/freshness mechanics live in [README.md](README.md). Rule levels follow RFC 2119 usage: **MUST** / **MUST NOT** are gate- or review-blocking; **SHOULD** needs a stated reason to deviate; **MAY** is discretionary. + +## Faithfulness + +- The translation MUST say what the source says — no added behavior, prerequisites, warnings, version claims, or examples, and no dropped ones. If the source is wrong, fix the English file first (English is canonical), then re-translate. +- The translation SHOULD read as natural technical Chinese, not word-by-word gloss. Translate meaning, restructure sentences where Chinese grammar wants it, and keep the author's register — terse stays terse. +- Do not translate the untranslatable: if a sentence resists natural rendering because it leans on an English idiom, translate the idea, not the idiom. + +## Structure preservation + +The paired files MUST match one to one in: + +- heading hierarchy (same levels, same order — heading TEXT is translated), +- list shape and numbering, +- tables (same columns, same row order; header cells translated per terminology), +- fenced code blocks — **byte-identical, including comments**; code is part of the verified surface (` ```ts ` blocks compile under `doc-typecheck`), and an edited comment is drift the fence-count gate cannot see, +- inline code spans (commands, flags, config keys, file paths, event names, API names, version numbers) — verbatim, never translated or reformatted, +- links and anchors: every relative link MUST point at the same target as the source — the canonical English file — so links never dangle when a translation batch lands before its neighbors. The ONLY zh-specific link is the language switcher. Link TEXT is translated; the target is not. + +The repo's Markdown conventions apply to `.zh.md` files unchanged: one physical line per paragraph (`verify-md-wrap`), resolving relative links (`verify-md-links`), exactly one trailing newline. + +## Terminology + +- [terminology.md](terminology.md) is the source of truth. Before translating, load it; while translating, every term it lists MUST be rendered exactly as it specifies, including its first-occurrence annotations (e.g. `agent(智能体)` on first mention, plain `agent` after) and its "不要译作" prohibitions. +- A technical term NOT in the table MAY be translated only when a major Chinese-language OSS or vendor doc has an established rendering for it (K8s/Vue/MDN Chinese docs, 微软简中风格指南, big-tech project docs). Cite the precedent in the PR. +- A term with NO established precedent MUST stay in English in the translation and MUST be listed in the PR description under 「待定术语」(pending terms) with a suggested rendering for the reviewer to decide. MUST NOT invent a Chinese rendering inline — an unprecedented translation creates exactly the ambiguity the terminology table exists to prevent. Decided terms then land in [terminology.md](terminology.md) in the same PR or a follow-up. + +## Typography + +The mixed-script rules below follow the cross-project consensus of the [MDN Simplified Chinese translation guide](https://github.com/mdn/translated-content/blob/main/docs/zh-cn/translation-guide.md), the [Kubernetes zh-cn localization guide](https://kubernetes.io/zh-cn/docs/contribute/localization_zh/), the [Vue.js Chinese translation conventions](https://github.com/vuejs-translations/docs-zh-cn/wiki/%E7%BF%BB%E8%AF%91%E9%A1%BB%E7%9F%A5), and [中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines), which in turn ground in [W3C clreq](https://www.w3.org/TR/clreq/) and GB/T 15834—2011: + +- MUST put one half-width space between Chinese text and Latin words, and between Chinese text and numerals: `每个 plugin 注册 3 个 tool`。No space between a full-width punctuation mark and anything. +- MUST use full-width (Chinese) punctuation in Chinese prose: `,。:;?!()「」`. Half-width punctuation stays inside code spans, inside complete English sentences quoted as-is, and in numbers (`3.5`, `1,024`). +- Enumeration commas: a Chinese list of parallel items uses 顿号(、), not commas. +- MUST NOT use full-width digits or full-width Latin letters — `123` never, `123` always. +- Proper nouns keep their canonical casing: GitHub, TypeScript, DeepSeek — never `github`/`Github` unless quoting code. +- Second person is 你, not 您 (matches the Vue and Kubernetes Chinese conventions and this repo's direct voice). +- Emphasis markers (`**bold**`, `*italic*`) stay on the same spans as the source; Chinese has no italics, so the rendered emphasis may look identical — do not substitute quotation marks or other decoration. + +## Quality bar + +- A translation is done when a bilingual engineer reading only the Chinese file gets everything a reader of the English file gets — same facts, same caveats, same tone — and nothing extra. +- Before handing off, self-check the result against this file and re-read the Chinese ALONE, without the English side by side; awkward phrasing is easier to hear without the source anchoring you. +- The mechanical contract (fingerprint, switcher, structure counts, wrap, links) is checked by `pnpm run verify-translation-pairing` and the rest of `doc-sync` — run them; do not hand-verify what a gate covers. + +## References + +Authorities cited by these rules, for humans and agents who want the underlying reasoning: + +- [中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines) — the de-facto community standard for mixed CJK/Latin spacing and punctuation. +- [MDN zh-CN translation guide](https://github.com/mdn/translated-content/blob/main/docs/zh-cn/translation-guide.md) — an in-repo translation-rules file of the same shape as this one; spacing, punctuation, and glossary practice. +- [Kubernetes zh-cn localization guide](https://kubernetes.io/zh-cn/docs/contribute/localization_zh/) — terminology-first-occurrence and punctuation practice from the largest zh localization team. +- [Vue.js docs-zh-cn 翻译须知](https://github.com/vuejs-translations/docs-zh-cn/wiki/%E7%BF%BB%E8%AF%91%E9%A1%BB%E7%9F%A5) — per-term translate/keep decisions and tone. +- [zh-style-guide](https://zh-style-guide.readthedocs.io) — a community Chinese technical-writing style guide whose rule-level taxonomy (and RFC 2119 keyword levels) this file borrows; aggregates GB/T 15834/15835, clreq, and vendor guides. +- [W3C clreq](https://www.w3.org/TR/clreq/) and the [Microsoft Simplified Chinese style guide](https://learn.microsoft.com/en-us/globalization/reference/microsoft-style-guides) — the formal typographic and vendor-localization baselines. +- GB/T 19682-2005《翻译服务译文质量要求》 — the national standard whose three base requirements (忠实原文、术语统一、行文通顺) this file's Faithfulness and Terminology sections operationalize. diff --git a/docs/i18n/translation-rules.zh.md b/docs/i18n/translation-rules.zh.md new file mode 100644 index 0000000000..96576782e6 --- /dev/null +++ b/docs/i18n/translation-rules.zh.md @@ -0,0 +1,62 @@ + + +# 翻译规则(EN → ZH) + +[English](translation-rules.md) | 中文 + +本文规定如何把本仓库的文档翻译成简体中文。这些规则对人和 agent(智能体)同等生效;应用它们的进仓 agent 工作流是 [.agents/skills/dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md),配对与新鲜度机制见 [README.md](README.md)。规则级别沿用 RFC 2119 的用法:**必须(MUST)**/**禁止(MUST NOT)**会卡门禁或评审;**应当(SHOULD)**偏离时要说明理由;**可以(MAY)**由译者自行裁量。 + +## 忠实性 + +- 译文必须说源文所说的话——不添加行为、前置条件、警告、版本声明或示例,也不丢弃任何一项。如果源文有错,先改英文文件(英文是唯一真源),再重新翻译。 +- 译文应当读起来是自然的中文技术文字,而不是逐词对照。翻译语义,在中文语法需要处重组句子,并保持原作者的语域——简练的保持简练。 +- 不要翻译不可译的东西:一句话如果依赖英文习语而无法自然转换,就翻译它的意思,而不是习语本身。 + +## 结构保持 + +配对的两个文件必须在以下方面一一对应: + +- 标题层级(相同级别、相同顺序——标题的**文字**要翻译), +- 列表形态与编号, +- 表格(相同的列、相同的行序;表头单元格按术语表翻译), +- 围栏代码块——**逐字节一致,包括注释**;代码属于被验证的表面(` ```ts ` 块要通过 `doc-typecheck` 编译),而被改动的注释是代码块计数门禁看不见的漂移, +- 行内代码(命令、flag、配置键、文件路径、事件名、API 名、版本号)——原样保留,从不翻译或重排, +- 链接与锚点:每个相对链接必须指向与源文相同的目标——即英文正典文件——这样翻译批次先后落地时链接永不悬空。唯一的 zh 特有链接是语言切换行。链接**文字**翻译;链接目标不翻。 + +本仓库的 Markdown 约定对 `.zh.md` 文件原样生效:一个段落一个物理行(`verify-md-wrap`)、相对链接必须可解析(`verify-md-links`)、文件末尾恰好一个换行。 + +## 术语 + +- [terminology.md](terminology.md) 是术语真源。翻译前先加载它;翻译中,表内的每个术语都必须严格按表规定的译法呈现,包括首次出现的括注(如首现写 `agent(智能体)`,之后写 `agent`)与「不要译作」的禁项。 +- 表中**没有**的技术术语,只有当某个主要中文 OSS 或厂商文档已有成型译法时(K8s/Vue/MDN 中文文档、微软简中风格指南、大厂项目文档)才可以翻译。在 PR 中注明先例出处。 +- **没有**成型先例的术语,译文中必须保留英文,并且必须在 PR 描述的「待定术语」下列出、附上建议译法交评审者定夺。禁止就地发明中文译法——无先例的翻译恰恰制造了术语表要防止的歧义。定下来的术语随后在同一个 PR 或后续 PR 进入 [terminology.md](terminology.md)。 + +## 排版 + +下面的中西文混排规则遵循 [MDN 简体中文翻译指南](https://github.com/mdn/translated-content/blob/main/docs/zh-cn/translation-guide.md)、[Kubernetes 中文本地化指南](https://kubernetes.io/zh-cn/docs/contribute/localization_zh/)、[Vue.js 中文翻译须知](https://github.com/vuejs-translations/docs-zh-cn/wiki/%E7%BF%BB%E8%AF%91%E9%A1%BB%E7%9F%A5)与[中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines)的跨项目共识,其根据是 [W3C clreq](https://www.w3.org/TR/clreq/) 与 GB/T 15834—2011: + +- 必须在中文与拉丁词之间、中文与数字之间各留一个半角空格:`每个 plugin 注册 3 个 tool`。全角标点与任何字符之间不加空格。 +- 中文行文必须使用全角(中文)标点:`,。:;?!()「」`。半角标点保留在代码内、按原样引用的完整英文句子内、以及数字内(`3.5`、`1,024`)。 +- 并列顿开:中文的并列项之间用顿号(、),不用逗号。 +- 禁止使用全角数字或全角拉丁字母——永远不写 `123`,永远写 `123`。 +- 专有名词保持规范大小写:GitHub、TypeScript、DeepSeek——除非引用代码,否则绝不写 `github`/`Github`。 +- 第二人称用「你」,不用「您」(与 Vue、Kubernetes 中文约定及本仓库的直接语气一致)。 +- 强调标记(`**加粗**`、`*斜体*`)落在与源文相同的文字段上;中文没有斜体,渲染效果可能看不出差别——不要用引号或其他装饰替代。 + +## 质量线 + +- 一篇译文的完成标准:一位只读中文文件的双语工程师,得到与英文读者完全相同的信息——相同的事实、相同的告诫、相同的语气——并且没有任何多余的内容。 +- 交付前,对照本文自查一遍,并**只读中文**再通读一遍、不看英文对照;没有源文锚着,别扭的表述更容易被听出来。 +- 机械契约(指纹、切换行、结构计数、折行、链接)由 `pnpm run verify-translation-pairing` 和 `doc-sync` 的其余门禁检查——跑门禁;门禁覆盖的不要手工核对。 + +## 参考资料 + +本文各规则引用的权威出处,供想了解底层依据的人和 agent 查阅: + +- [中文文案排版指北](https://github.com/sparanoid/chinese-copywriting-guidelines) —— 中西文混排空格与标点的社区事实标准。 +- [MDN 简体中文翻译指南](https://github.com/mdn/translated-content/blob/main/docs/zh-cn/translation-guide.md) —— 与本文同形态的进仓翻译规则文件;空格、标点与术语表实践。 +- [Kubernetes 中文本地化指南](https://kubernetes.io/zh-cn/docs/contribute/localization_zh/) —— 最大的中文本地化团队的术语首现与标点实践。 +- [Vue.js docs-zh-cn 翻译须知](https://github.com/vuejs-translations/docs-zh-cn/wiki/%E7%BF%BB%E8%AF%91%E9%A1%BB%E7%9F%A5) —— 逐术语的译/留决策与语气。 +- [zh-style-guide](https://zh-style-guide.readthedocs.io) —— 社区中文技术文档写作规范,本文借用了它的规则分类粒度(与 RFC 2119 关键词分级);它聚合了 GB/T 15834/15835、clreq 与各厂商指南。 +- [W3C clreq](https://www.w3.org/TR/clreq/) 与[微软简体中文风格指南](https://learn.microsoft.com/en-us/globalization/reference/microsoft-style-guides) —— 排版学与厂商本地化的正式基线。 +- GB/T 19682-2005《翻译服务译文质量要求》 —— 国家标准;本文「忠实性」与「术语」两节把它的三项基本要求(忠实原文、术语统一、行文通顺)落成可操作规则。 diff --git a/docs/rfc/README.md b/docs/rfc/README.md index 59516c24c5..43619b395a 100644 --- a/docs/rfc/README.md +++ b/docs/rfc/README.md @@ -141,6 +141,7 @@ Do NOT write one for a mechanical or local choice (a variable name, a one-file r | [Generated cordis events + services catalog](implemented/process/2026-06-20-generated-cordis-catalog.md) | 2026-06-20 | | [Classify RFCs by kind via path-encoded subdirectories](implemented/process/2026-06-20-rfc-classification.md) | 2026-06-20 | | [Generated tool-schema catalog (boot-and-harvest)](implemented/process/2026-07-02-tool-schema-catalog.md) | 2026-07-02 | +| [Bilingual documentation via paired sibling files and a pairing gate](implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md) | 2026-07-02 | ### Testing diff --git a/docs/rfc/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md b/docs/rfc/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md new file mode 100644 index 0000000000..cd9dc413e7 --- /dev/null +++ b/docs/rfc/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md @@ -0,0 +1,31 @@ +# Bilingual documentation via paired sibling files and a pairing gate + +## Context + +This repo's README and docs tree are read by people and agents inside and outside the company, in both English and Chinese. Maintaining a second language by hand, with no mechanism, is how translations rot: the English file moves on, the Chinese file silently lies, and no gate notices. The repo's standing answer to invariants of this kind is to encode them as a mechanical check (see [quality gates](2026-06-11-quality-gates.md) and [doc-sync enforcement](2026-06-11-doc-sync-enforcement.md)), so the bilingual policy ships with one. + +## Decision + +- **Paired sibling files, English canonical.** The translation of `foo.md` is `foo.zh.md` in the same directory; English is the only authoring language and translation flows EN → ZH. Policy: [docs/i18n/README.md](../../../i18n/README.md); translation rules: [docs/i18n/translation-rules.md](../../../i18n/translation-rules.md); terminology source of truth: [docs/i18n/terminology.md](../../../i18n/terminology.md). +- **A blob-hash fingerprint makes freshness checkable.** The first line of every `.zh.md` records the repo-relative path and the first 12 hex digits of the git blob hash of the English source it renders. Staleness is then a pure content comparison — no history lookup — and the hash is computable for a source edited in the same PR, which a commit-hash fingerprint (the MDN `l10n.sourceCommit` model) is not. +- **`verify-translation-pairing` joins `doc-sync`.** The gate ([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts)) enforces: required pairs exist, every existing translation is fresh/switched/structure-matched/non-orphaned, and excluded (generated or bilingual-by-construction) files stay unpaired. The `required` list in [scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) is a ratchet: each merged translation batch adds its files, so coverage only grows. +- **Translation is agent work with human review.** The committed workflow is [.agents/skills/dsh-translate-docs](../../../../.agents/skills/dsh-translate-docs/SKILL.md), following the same pattern as [dsh-code-review](../../../../.agents/skills/dsh-code-review/SKILL.md): the skill carries the workflow and defers to the docs as sources of truth. + +## Alternatives considered + +- **Locale directories (`docs/en/` + `docs/zh/`, the Kubernetes/ECharts model)** — rejected: this repo has no docs-site framework to map locales to routes, moving every English file would churn every existing cross-reference, and `verify-md-links`/`verify-doc-refs` would need path-mapping logic instead of working unchanged. +- **A separate translation repo (the PingCAP `docs`/`docs-cn` model)** — rejected: right for a docs product with independent release trains, overkill for a monorepo's own documentation; it also puts the translation outside the reach of this repo's gates. +- **Interleaved bilingual files (single file, both languages)** — rejected: doubles every diff, breaks the one-line-per-paragraph convention's diff ergonomics, and makes partial staleness invisible. +- **Commit-hash fingerprints (MDN `l10n.sourceCommit`)** — rejected in favor of blob hashes: a same-PR source edit has no commit hash yet, so the MDN model cannot express "translated against the version this PR introduces", and verifying it requires git history instead of file content. +- **Comparing git timestamps of the pair (no fingerprint)** — rejected: formatting-only English edits would false-positive, and a translation committed after an unrelated English edit would false-negative; content identity is the only signal that means what the gate claims. + +## Industry precedent + +Paired sibling files with locale suffixes are the dominant Chinese big-tech convention (ant-design `index.zh-CN.md`/`index.en-US.md`; arco-design `README.zh-CN.md` with a top-of-file switcher; Apache ShardingSphere's 387 `.cn.md`/`.en.md` pairs) — but none of those repos *enforce* pairing or freshness in CI; the convention holds by review alone. Freshness automation exists outside China: MDN's `l10n.sourceCommit` front-matter fingerprint, Vue's Ryu-Cho action (upstream-commit watcher that opens issues/PRs for stale translations), Kubernetes' localization drift scripts, and Microsoft's Azure co-op-translator (source-hash-driven LLM re-translation in CI). This design combines the two: the Chinese-ecosystem file layout with a fingerprint gate, plus a committed agent skill in place of a bot service. + +## Consequences + +- Editing an English doc that has a `.zh.md` sibling obligates the same PR to update the translation — the gate makes the doc-sync rule bilingual, and CI (not reviewer memory) carries the invariant. +- Generated docs (`cordis-catalog/`, `tool-catalog/`, `module-graph.md`) are never paired; their generators emit English only, and the gate rejects a stray translation of them. +- Rollout is incremental by design: documents outside `required` are visible backlog (`--list`), not red CI, so translation lands in reviewable batches without a big-bang PR. +- The fingerprint doubles as the update tool (`git cat-file -p ` recovers the exact translated-from text for a minimal diff-based update), so re-translation of whole files is never forced by the mechanism. diff --git a/package.json b/package.json index 7d82af4aad..a6a62b41a8 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "verify-package-paths": "tsx scripts/verify-package-paths.ts", "verify-rfc-classification": "tsx scripts/verify-rfc-classification.ts", "verify-type-equiv": "tsx scripts/verify-type-equiv.ts", + "verify-translation-pairing": "tsx scripts/verify-translation-pairing.ts", "verify-node-next-types": "tsx scripts/verify-node-next-types.ts", "gen-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts", "verify-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts --check", @@ -39,7 +40,7 @@ "gen-module-graph": "tsx scripts/gen-module-graph.ts", "verify-module-graph": "tsx scripts/gen-module-graph.ts --check", "constraints": "tsx scripts/check-workspace-constraints.ts", - "doc-sync": "pnpm run doc-typecheck && pnpm run verify-cordis-catalog && pnpm run verify-tool-catalog && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-doc-refs && pnpm run verify-package-paths && pnpm run verify-rfc-classification && pnpm run verify-type-equiv", + "doc-sync": "pnpm run doc-typecheck && pnpm run verify-cordis-catalog && pnpm run verify-tool-catalog && pnpm run verify-md-wrap && pnpm run verify-md-links && pnpm run verify-doc-refs && pnpm run verify-package-paths && pnpm run verify-rfc-classification && pnpm run verify-type-equiv && pnpm run verify-translation-pairing", "hygiene": "pnpm run knip && pnpm run publint && pnpm run constraints && pnpm run verify-node-next-types", "demo:echo": "node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/echo-agent/cordis.yml", "demo:coding": "node --expose-internals --import tsx packages/ui/stdio-agent/src/bin.ts examples/coding-agent/cordis.yml", diff --git a/scripts/translation-pairing.manifest.json b/scripts/translation-pairing.manifest.json new file mode 100644 index 0000000000..8b713c1ea0 --- /dev/null +++ b/scripts/translation-pairing.manifest.json @@ -0,0 +1,14 @@ +{ + "required": [ + "README.md", + "docs/i18n/README.md", + "docs/i18n/translation-rules.md" + ], + "excluded": [ + "docs/AGENTS.md", + "docs/module-graph.md", + "docs/cordis-catalog/", + "docs/tool-catalog/", + "docs/i18n/terminology.md" + ] +} diff --git a/scripts/verify-md-links.ts b/scripts/verify-md-links.ts index cbd913d5ca..2a96cfd0af 100644 --- a/scripts/verify-md-links.ts +++ b/scripts/verify-md-links.ts @@ -48,6 +48,7 @@ const root = resolve(import.meta.dirname, '..') */ const PATTERNS = [ 'README.md', + 'README.zh.md', 'docs/**/*.md', 'packages/*/*.md', 'packages/*/*/*.md', diff --git a/scripts/verify-md-wrap.ts b/scripts/verify-md-wrap.ts index f8acb26d78..c899b00f00 100644 --- a/scripts/verify-md-wrap.ts +++ b/scripts/verify-md-wrap.ts @@ -36,7 +36,7 @@ import type { Nodes } from 'mdast' const root = resolve(import.meta.dirname, '..') /** Files to check: doc-typecheck's scope plus the AGENTS.md pair. */ -const PATTERNS = ['README.md', 'docs/**/*.md', 'packages/*/*.md', 'packages/*/*/*.md', 'AGENTS.md', 'packages/AGENTS.md'] +const PATTERNS = ['README.md', 'README.zh.md', 'docs/**/*.md', 'packages/*/*.md', 'packages/*/*/*.md', 'AGENTS.md', 'packages/AGENTS.md'] /** A located hard-wrap: a prose paragraph spanning more than one source line. */ interface Violation { diff --git a/scripts/verify-translation-pairing.ts b/scripts/verify-translation-pairing.ts new file mode 100644 index 0000000000..b89b9b49fe --- /dev/null +++ b/scripts/verify-translation-pairing.ts @@ -0,0 +1,201 @@ +/** + * Doc-sync gate: enforce the bilingual pairing contract (docs/i18n/README.md). + * English is canonical; the translation of `foo.md` is a sibling `foo.zh.md` + * whose FIRST line fingerprints the English source it was translated from: + * + * + * + * The gate checks, mechanically, everything the contract promises: + * + * 1. Every English file in the manifest's `required` list has a `.zh.md` + * sibling (the enforcement frontier — grows batch by batch). + * 2. Every EXISTING `.zh.md`, required or not, is sound: its source exists + * (no orphans), its fingerprint equals the source's current blob hash + * (no stale translations), both sides carry the language-switcher link, + * and its fenced-code-block and heading counts match the source. + * 3. `excluded` files (generated docs, agent instructions, the bilingual + * terminology table) have no `.zh.md` at all. + * + * The fingerprint is a git BLOB hash, not a commit hash, so a translation + * updated in the same PR as its English source verifies without any history + * lookup: staleness is a pure content comparison, computed here directly + * (sha1 of `blob \0`) without spawning git. + * + * Run: `tsx scripts/verify-translation-pairing.ts` — or with `--list` to print + * the translation state (missing/stale/ok) of every in-scope document as a + * work list; `--list` always exits 0. + */ + +import { createHash } from 'node:crypto' +import { existsSync, readFileSync } from 'node:fs' +import { basename, join, resolve } from 'node:path' +import { glob } from 'node:fs/promises' +import { fromMarkdown } from 'mdast-util-from-markdown' +import { gfmFromMarkdown } from 'mdast-util-gfm' +import { gfm } from 'micromark-extension-gfm' +import type { Nodes } from 'mdast' + +const root = resolve(import.meta.dirname, '..') +const listMode = process.argv.includes('--list') + +/** Scope of the bilingual contract: the root README and the docs tree. */ +const SCOPE_PATTERNS = ['README.md', 'README.zh.md', 'docs/**/*.md'] + +/** The enforcement frontier and the never-paired set (docs/i18n/README.md § Scope). */ +interface Manifest { + required: string[] + excluded: string[] +} +const manifest = JSON.parse(readFileSync(join(root, 'scripts/translation-pairing.manifest.json'), 'utf8')) as Manifest + +/** First line of a translation: fingerprint of the English source it renders. */ +const FINGERPRINT = /^$/ + +/** An excluded entry ending in `/` excludes the whole directory. */ +function isExcluded(file: string): boolean { + return manifest.excluded.some(entry => (entry.endsWith('/') ? file.startsWith(entry) : file === entry)) +} + +/** Git blob hash (what `git hash-object` prints), truncated to 12 hex digits. */ +function blobHash(content: Buffer): string { + const hash = createHash('sha1') + hash.update(`blob ${content.byteLength}\0`) + hash.update(content) + return hash.digest('hex').slice(0, 12) +} + +/** Counts that must match between a source and its translation. */ +interface Shape { + codeBlocks: number + headings: number +} + +/** Whether `text` contains a relative markdown link to exactly `target`. */ +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 +} + +function shapeOf(tree: Nodes): Shape { + let codeBlocks = 0 + let headings = 0 + const visit = (node: Nodes): void => { + if (node.type === 'code') codeBlocks++ + if (node.type === 'heading') headings++ + if ('children' in node) for (const child of node.children) visit(child) + } + visit(tree) + return { codeBlocks, headings } +} + +function parse(content: string): Nodes { + return fromMarkdown(content, { extensions: [gfm()], mdastExtensions: [gfmFromMarkdown()] }) +} + +// Enumerate the scope once, split into sources and translations. +const files = new Set() +for (const pattern of SCOPE_PATTERNS) { + for await (const match of glob(pattern, { cwd: root })) files.add(match) +} +const translations = [...files].filter(f => f.endsWith('.zh.md')).sort() +const sources = [...files].filter(f => !f.endsWith('.zh.md')).sort() + +const errors: string[] = [] +const state = new Map() + +// 1. Required pairs exist. +for (const req of manifest.required) { + if (!existsSync(join(root, req))) { + errors.push(`${req}: listed in translation-pairing.manifest.json \`required\` but the file does not exist`) + continue + } + const zh = req.replace(/\.md$/, '.zh.md') + if (!existsSync(join(root, zh))) { + errors.push(`${req}: required to have a translation, but ${zh} does not exist`) + state.set(req, 'missing') + } +} + +// 2. Every existing translation is sound. +for (const zh of translations) { + const source = zh.replace(/\.zh\.md$/, '.md') + const sourceAbs = join(root, source) + if (!existsSync(sourceAbs)) { + errors.push(`${zh}: orphan — its English source ${source} does not exist (delete or rename the translation alongside its source)`) + continue + } + if (isExcluded(source)) { + errors.push(`${zh}: ${source} is excluded from pairing (generated or bilingual-by-construction); this translation must not exist`) + continue + } + + const zhContent = readFileSync(join(root, zh), 'utf8') + const firstLine = zhContent.slice(0, zhContent.indexOf('\n')) + const match = FINGERPRINT.exec(firstLine) + if (!match?.groups) { + errors.push(`${zh}: first line is not an i18n-source fingerprint (expected \`\`, got \`${firstLine.slice(0, 60)}\`)`) + continue + } + if (match.groups['path'] !== source) { + errors.push(`${zh}: fingerprint names ${match.groups['path']} but the sibling source is ${source}`) + continue + } + + const sourceContent = readFileSync(sourceAbs) + const current = blobHash(sourceContent) + if (match.groups['hash'] !== current) { + errors.push(`${zh}: stale — fingerprint ${match.groups['hash']} but ${source} is now ${current} (update the translation, then re-fingerprint)`) + state.set(source, 'stale') + continue + } + + const zhTree = parse(zhContent) + const sourceTree = parse(sourceContent.toString('utf8')) + if (!linksTo(zhTree, basename(source))) { + errors.push(`${zh}: missing language switcher — no link to ${basename(source)}`) + } + if (!linksTo(sourceTree, basename(zh))) { + errors.push(`${source}: missing language switcher — no link back to ${basename(zh)}`) + } + const zhShape = shapeOf(zhTree) + const sourceShape = shapeOf(sourceTree) + if (zhShape.codeBlocks !== sourceShape.codeBlocks) { + errors.push(`${zh}: ${zhShape.codeBlocks} fenced code block(s) vs ${sourceShape.codeBlocks} in ${source} — code blocks must mirror the source`) + } + if (zhShape.headings !== sourceShape.headings) { + errors.push(`${zh}: ${zhShape.headings} heading(s) vs ${sourceShape.headings} in ${source} — heading structure must mirror the source`) + } + if (!state.has(source)) state.set(source, 'ok') +} + +// Complete the state map for --list: any in-scope, non-excluded source with no translation yet is backlog. +for (const source of sources) { + if (!isExcluded(source) && !state.has(source)) state.set(source, 'missing') +} + +if (listMode) { + const order = { stale: 0, missing: 1, ok: 2 } as const + const rows = [...state.entries()].sort((a, b) => order[a[1]] - order[b[1]] || a[0].localeCompare(b[0])) + for (const [file, status] of rows) { + const required = manifest.required.includes(file) + console.log(`${status.padEnd(7)} ${file}${status === 'missing' ? (required ? ' (required)' : ' (backlog)') : ''}`) + } + const counts = { ok: 0, stale: 0, missing: 0 } + for (const status of state.values()) counts[status]++ + console.log(`verify-translation-pairing: ${counts.ok} ok, ${counts.stale} stale, ${counts.missing} missing (of ${state.size} in scope)`) + process.exit(0) +} + +if (errors.length === 0) { + console.log(`verify-translation-pairing: ${translations.length} translation(s) checked against ${manifest.required.length} required pair(s), all sound.`) + process.exit(0) +} + +console.error('verify-translation-pairing: bilingual pairing contract violated (see docs/i18n/README.md):') +for (const message of errors) console.error(` ${message}`) +process.exit(1)