mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge branch 'master' into markdown-ui
This commit is contained in:
@@ -1,6 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
2026-07-02-bilingual-docs-and-pairing-gate.md: bebff600ca27763c04bdecea78ceb916542f7eca
|
||||
2026-07-02-bilingual-docs-and-pairing-gate.zh.md: 66c9b75b1bab47558bb63b7e97cf6d7c7610b0d5
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-02-bilingual-docs-and-pairing-gate.md
|
||||
2026-07-02-bilingual-docs-and-pairing-gate.md: 8e9d0ab8653528517346b198c97814d8d8979440
|
||||
2026-07-02-bilingual-docs-and-pairing-gate.zh.md: cf37d56be0180cc742b0dabefeb1f8f3b54ba557
|
||||
|
||||
@@ -11,7 +11,7 @@ This repo's documentation corpus is read by people and agents inside and outside
|
||||
## Decision
|
||||
|
||||
- **Paired sibling files with equal authority.** A documentation pair is three sibling files: English `foo.md`, Chinese `foo.zh.md`, and a consistency record `foo.i18n.yaml`. Neither language is canonical — a document may be authored and reviewed Chinese-first and translated to English afterwards, or the reverse; what binds the pair is that both sides must say the same thing, and pairs merge whole (both languages plus the record, never one alone). Policy: [docs/i18n/README.md](../../../../docs/i18n/README.md); translation rules: [docs/i18n/translation-rules.md](../../../../docs/i18n/translation-rules.md); terminology source of truth: [docs/i18n/terminology.md](../../../../docs/i18n/terminology.md).
|
||||
- **A sidecar record of both blob hashes makes consistency checkable.** `foo.i18n.yaml` holds the full git blob hash of each side as of the last confirmed-consistent state. An edit to either side without re-confirming the pair is then mechanically detectable as a pure content comparison — no history lookup — and the hashes are computable for files edited in the same PR, which a commit-hash record is not. Re-recording (`verify-translation-pairing --write`) produces a reviewable yaml diff: confirming consistency is an explicit, visible act in the PR.
|
||||
- **A sidecar record of both blob hashes makes consistency checkable.** `foo.i18n.yaml` holds the full git blob hash of each side as of the last confirmed-consistent state. An edit to either side without re-confirming the pair is then mechanically detectable as a pure content comparison — no history lookup — and the hashes are computable for files edited in the same PR, which a commit-hash record is not. Re-recording (`verify-translation-pairing --write <pair>`, which requires naming the confirmed pairs — bulk re-record is an explicit `--write --all`) produces a reviewable yaml diff: confirming consistency is an explicit, visible act in the PR.
|
||||
- **`verify-translation-pairing` joins `doc-sync`.** The gate ([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts)) enforces: every discovered, non-excluded source has a complete pair; every existing pair is complete (all three files) and consistent (both hashes match, switcher links both ways, structural signatures identical); and excluded generated, instruction, or bilingual-by-construction files stay unpaired. [scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) contains only explicit exclusions, so no requirement can bypass discovery and receive a weaker check. Source-oriented code gates consume a `.zh.md` fence sequence as a derivative only when its unsuffixed sibling has the same tracked fences in the same order with byte-identical bodies; an incomplete, reordered, reclassified, or changed sequence stays independent, so the owning code gate or pairing gate reports the mismatch.
|
||||
- **One corpus-wide requirement.** Every document in scope requires a complete pair from creation; the policy has no per-file rollout state, date cutoff, or README-specific class. README discovery covers every case-insensitive README basename outside vendored, dependency, and ignored build-output trees, including future top-level directories. A site-published pair uses `pairedPages()` so the root locale projects `.zh.md` and `/en/` projects `.md`; creating a counterpart alone does not publish it.
|
||||
- **Pairing records are metadata, not Cordis Loader configuration.** Cordis configuration discovery accepts actual `.cordis.yml` and `.cordis.yaml` files while excluding `*.i18n.yaml`, even when the document name contains `cordis`. This preserves validation of executable Loader entries without parsing translation hashes as configuration.
|
||||
@@ -41,4 +41,4 @@ Paired sibling files with locale suffixes are the dominant Chinese big-tech conv
|
||||
- When the two sides disagree, no mechanical rule picks a winner — the PR review does. That is the price of equal authority, accepted deliberately: the alternative (a canonical language) forbids Chinese-first authoring.
|
||||
- Generated docs (`cordis-catalog/`, `tool-catalog/`, `module-graph.md`) are excluded for now; the planned follow-up is to teach their generators to emit Chinese alongside English, at which point they leave the exclusion list.
|
||||
- The exclusions-only manifest makes every current and future in-scope document mandatory through the same path. There is no explicit requirement, cutoff, or class entry that can fall outside discovery while appearing enforced.
|
||||
- The recorded hashes double as the update tool (`git cat-file -p <hash>` recovers either side's last-confirmed text for a minimal diff-based update), so re-translation of whole files is never forced by the mechanism.
|
||||
- The recorded hashes double as the update tool: [gen-translation-brief](2026-07-26-briefed-minimal-translation-updates.md) recovers either side's last-confirmed text from them and assembles the minimal-update briefing, so re-translation of whole files is never forced by the mechanism.
|
||||
|
||||
@@ -11,7 +11,7 @@ Status: implemented
|
||||
## 决策
|
||||
|
||||
- **配对兄弟文件,两种语言同权。** 一对文档由三个兄弟文件组成:英文 `foo.md`、中文 `foo.zh.md`,以及一份一致性记录 `foo.i18n.yaml`。没有哪种语言是正典:一篇文档可以先用中文撰写和评审、之后再译成英文,反之亦可;约束配对的是:两侧必须表达相同的内容,且配对整体合并(两种语言加记录,绝不单独落一侧)。政策见 [docs/i18n/README.md](../../../../docs/i18n/README.md);翻译规则见 [docs/i18n/translation-rules.md](../../../../docs/i18n/translation-rules.md);术语真源见 [docs/i18n/terminology.md](../../../../docs/i18n/terminology.md)。
|
||||
- **伴随记录保存两侧 blob hash,使一致性可检查。** `foo.i18n.yaml` 保存两侧文件在上一次确认一致时各自的完整 git blob hash。此后修改了任一侧而未重新确认配对,都能被机械检测出来(纯内容比较,无需查询历史),而且同一个 PR(Pull Request)内改动的文件也能计算出 hash,commit hash 式的记录做不到这一点。重新记录(`verify-translation-pairing --write`)会产生一份可评审的 yaml diff:确认一致在 PR 中是一个显式、可见的动作。
|
||||
- **伴随记录保存两侧 blob hash,使一致性可检查。** `foo.i18n.yaml` 保存两侧文件在上一次确认一致时各自的完整 git blob hash。此后修改了任一侧而未重新确认配对,都能被机械检测出来(纯内容比较,无需查询历史),而且同一个 PR(Pull Request)内改动的文件也能计算出 hash,commit hash 式的记录做不到这一点。重新记录(`verify-translation-pairing --write <pair>`,要求点名所确认的配对;批量重新记录是显式的 `--write --all`)会产生一份可评审的 yaml diff:确认一致在 PR 中是一个显式、可见的动作。
|
||||
- **`verify-translation-pairing` 加入 `doc-sync`。** 门禁([scripts/verify-translation-pairing.ts](../../../../scripts/verify-translation-pairing.ts))强制执行以下规则:每个已发现且未排除的源文档都有完整配对;每个现有配对都完整(三个文件齐全)且一致(两个 hash 匹配、切换行双向互链、结构签名一致);被排除的生成文档、指令文档或本身即双语的文档不得配对。[scripts/translation-pairing.manifest.json](../../../../scripts/translation-pairing.manifest.json) 只包含显式排除项,因此任何要求都无法绕过发现流程而接受较弱的检查。只有当 `.zh.md` 围栏序列与其无后缀兄弟文件拥有顺序相同、正文按字节一致的同一组受跟踪围栏时,面向源码的代码门禁才会将其作为派生内容消费;不完整、顺序变更、重分类或已改动的序列仍会独立受检,因此由其所属的代码门禁或配对门禁报告不匹配。
|
||||
- **全语料统一要求。** 范围内的每篇文档从创建起就必须有完整配对;政策没有逐文件推进状态、日期分界或 README 专用类别。README 发现会覆盖 vendor 源码、依赖目录与被忽略的构建产物目录之外所有文件名不区分大小写匹配 README 的文件,包括今后新增的顶层目录。发布到文档站的配对使用 `pairedPages()`,由根 locale 投影 `.zh.md`,由 `/en/` 投影 `.md`;仅创建对侧文件并不会发布它。
|
||||
- **配对记录是元数据,而不是 Cordis Loader 配置。** Cordis 配置发现会接受实际的 `.cordis.yml` 和 `.cordis.yaml` 文件,同时排除 `*.i18n.yaml`,即使文档名中包含 `cordis` 也不例外。这样既能继续校验可执行的 Loader 配置项,又不会把翻译 hash 当作配置来解析。
|
||||
@@ -41,4 +41,4 @@ Status: implemented
|
||||
- 两侧说法冲突时,没有机械规则裁决谁赢,由 PR 评审裁决。这是同权的代价,且是有意接受的:另一个选项(正典语言)会禁止中文先行撰写。
|
||||
- 生成文档(`cordis-catalog/`、`tool-catalog/`、`module-graph.md`)暂被排除;计划中的后续工作是让生成器在输出英文的同时输出中文,届时将这些文件移出排除清单。
|
||||
- 只含排除项的 manifest 通过同一路径,要求当前及今后纳入范围的每篇文档都必须配对。不存在显式要求、分界或类别条目可以落在发现范围之外,却看似已经强制执行。
|
||||
- 记录的 hash 兼作更新工具(`git cat-file -p <hash>` 能还原任一侧上次确认的文本,用于基于 diff 的最小更新),因此这套机制从不强迫整篇重译。
|
||||
- 记录的 hash 兼作更新工具:[gen-translation-brief](2026-07-26-briefed-minimal-translation-updates.md) 会从中还原任一侧上次确认的文本并组装最小更新简报,因此这套机制从不强迫整篇重译。
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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:
|
||||
# pnpm run verify-translation-pairing --write .agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md
|
||||
2026-07-26-briefed-minimal-translation-updates.md: 63f25d5c36caecba435e9192534e0b494e1e0105
|
||||
2026-07-26-briefed-minimal-translation-updates.zh.md: 17cf2f895b187fb794e691c2b14d6e0bff78363d
|
||||
@@ -0,0 +1,50 @@
|
||||
# Agent Note: Briefed minimal translation updates
|
||||
|
||||
Status: implemented
|
||||
|
||||
English | [中文](2026-07-26-briefed-minimal-translation-updates.zh.md)
|
||||
|
||||
## Problem
|
||||
|
||||
The [bilingual pairing contract](2026-07-02-bilingual-docs-and-pairing-gate.md) already prescribed minimal counterpart updates — diff the edited side against its last-confirmed state, patch the counterpart, never re-translate — but the committed workflow made every update pay whole-document overheads. The translating subagent loaded the full guidance corpus (skill, pairing contract, translation rules, the 192-line terminology table, style samples, prose standard) before touching a two-line diff; it re-derived the last-confirmed diff by hand through `git cat-file`; and each iteration re-ran the corpus-wide pairing gate, which parses every pair in the tree to validate one. A small English prose edit routinely cost tens of times its proportional share of tokens and minutes, which taxes exactly the behavior the contract wants — bringing the counterpart along in the same PR.
|
||||
|
||||
## Decision
|
||||
|
||||
Pair updates run on a generated briefing instead of the guidance corpus; only new pairs still run the whole-document workflow, which is unchanged.
|
||||
|
||||
- **`pnpm run gen-translation-brief [--apply] [pair...]`** ([scripts/gen-translation-brief.ts](../../../../scripts/gen-translation-brief.ts), assembly in [scripts/translation-brief.ts](../../../../scripts/translation-brief.ts)) prints, per out-of-sync pair, the authored side's diff from its recorded last-confirmed blob to the working tree plus the change mapped at the narrowest safely aligned granularity, deterministically widening on mapping failure: a change confined to the pair's byte-identical code fences is computed outright (`--apply` splices it into the counterpart and validates the result against the pairing gate's structural signature before writing); otherwise changed Markdown units (headings, paragraphs, table rows, list items, code fences, block quotes, HTML blocks, thematic breaks, link definitions — matched by container-scoped kind sequences) each carry their last-confirmed source, current source, and current counterpart text with line numbers; units that do not align fall back to depth-matched heading sections; and when sections do not align either, or both sides drifted, the briefing says so and withholds the mapping instead of guessing. Terminology rows are matched against the changed spans only (word-boundary English matching with plural inflections), and for Chinese targets the briefing tracks each relevant term's document-wide first occurrence — when an edit moves it, the vacated and receiving spans join the briefing with an explanatory note, since the 首次出现 annotation must move with it. The unit mapping, code splice, and first-occurrence mechanics adopt the planner design from the [incremental prompt-pipeline work](https://github.com/deepseek-harness/deepseek-harness/pull/684), whose provider-backed bake-off independently validated the same scope ladder for the automated pipeline. The briefing is the translator's whole working set; the full sources of truth remain the escalation path for decisions the briefing cannot answer.
|
||||
- **The update path in [dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md)** consumes the briefing: mechanical (code-fence-only) changes are applied with `--apply`, no subagent; prose diffs go to a subagent whose prompt is the briefing, not the corpus; verification is clause-by-clause on the changed spans, not the whole document.
|
||||
- **The pairing gate takes pair arguments.** `verify-translation-pairing [pair...]` checks just the named pairs (any of a pair's three files, or the bare stem, names it); the corpus-wide sweep remains the no-argument form that `doc-sync` and CI run. `--write` now requires naming the confirmed pairs — bare `--write` refuses, and re-recording everything is an explicit `--write --all` — because the old bare form silently blessed every drifted pair in the tree, including ones the caller never looked at, and a prose-only drift would then stay green forever. Each record's comment names its own scoped command.
|
||||
|
||||
## Benchmark
|
||||
|
||||
The decision followed a controlled replay of ten real pair updates from this repo's history (July 2026; 1-64 changed English lines each, READMEs, RFCs, Agent Notes, and user docs). Each example was reconstructed in a scratch repo at its true last-confirmed state with the English edit uncommitted, then run through competing workflows with fresh subagents: the status-quo corpus-loading path, the briefed path, a no-guidance control, whole-document re-translation, the briefed path on a small model, and a three-pairs-per-agent batch. Outputs were gated mechanically and scored blind by judges who also received the real historical update and the untouched stale counterpart as controls.
|
||||
|
||||
- The briefed path matched the status-quo path on judged faithfulness, preservation, and fluency — both at or above the real historical updates — while spending roughly a third of the tokens and wall clock on the stall-free examples (medians across all ten: 276k vs 595k relative token-cost units, 14 vs 32 turns).
|
||||
- Re-translation was confirmed harmful, not merely wasteful: judged preservation collapsed (4.4/10 vs 9.8) because it discards reviewed phrasing, it drifted established terminology the update arms kept (the counterpart's own text carries the renderings), and it was the most expensive arm.
|
||||
- The no-guidance control held quality too — the binding context for an update is the diff plus the counterpart's own reviewed text, not the corpus — but the briefing buys a fixed working set, inline terminology, and the both-sides-drifted warning at negligible cost over it.
|
||||
- On the briefing, a small model performed at parity with the large one, so the update path no longer assumes a frontier translator.
|
||||
- Batching three pairs into one subagent showed no reliable saving over three briefed runs and couples unrelated failures; it was rejected.
|
||||
|
||||
A second head-to-head replay on the same ten examples compared this note's shipped briefing against its earlier section-only form (no unit tier, no computed mechanical path, counterpart-only context, no first-occurrence tracking). Prose quality and cost were at parity — pairwise blind verdicts split with only stylistic margins — and the shipped form won on two objective outcomes: the two code-fence-only examples were completed byte-identical to the human-reviewed historical updates in under a second with no model tokens, and on the example whose edit moved a term's document-wide first occurrence, the shipped briefing's flagged move reproduced the human-reviewed gloss relocation while the section-only form left a 首次出现 violation for review to catch.
|
||||
|
||||
## Alternatives considered
|
||||
|
||||
- **Keep the workflow, just scope the gate** — the gate scan was the smaller cost; the corpus loads and archaeology dominated. Scoping alone would have left the ~3x overhead in place.
|
||||
- **Whole-document re-translation as the update path** (what a naive pipeline does) — rejected on benchmark evidence: preservation collapse, terminology drift, highest cost. The contract's minimal-update rule survives with data behind it.
|
||||
- **Batching several pairs per subagent** — rejected: no measured saving (briefings already deduplicate the fixed content), and one stalled or confused pair holds the others hostage.
|
||||
- **Per-paragraph translation-memory records in the sidecar** (segment hashes instead of whole-file hashes) — rejected: paragraph boundaries may legitimately differ across the pair, either side can be authored first, and the records would bloat and conflict in merges. Span mapping computed on demand from the existing whole-file hashes recovers the same alignment when it is trustworthy and says so when it is not.
|
||||
- **An update mode in the automated prompt pipeline (prompt-v5)** — deferred, not designed here: nothing drives [scripts/translation-prompt.ts](../../../../scripts/translation-prompt.ts) today, and the agent path was the live cost center. The pipeline keeps its whole-document v4 contract until it has a consumer.
|
||||
|
||||
## Consequences
|
||||
|
||||
- A small prose edit's counterpart update now costs a briefing generation plus one small focused task — no corpus reads, no archaeology, no corpus-wide scans inside the loop — and the same PR obligation holds; the cheap path and the correct path point the same way.
|
||||
- The briefing generator is a second consumer of the consistency records: recorded blob hashes now also drive diff recovery and section mapping, strengthening the incentive to keep records honest.
|
||||
- `--write` without arguments no longer works; muscle-memory callers must name pairs or pass `--all`. That is the point — the bulk bless is now a visible, deliberate act.
|
||||
- Scoped checks mean an update loop can be green while an unrelated pair elsewhere is red; the corpus-wide check in `doc-sync`/CI still owns the tree-level invariant.
|
||||
- Span mapping trusts an alignment only when the kind sequences match across the last-confirmed source, current source, and current counterpart; a mapping failure widens deterministically (units → sections → whole document) rather than guessing, so a restructured document gets an explicit "locate the regions yourself" briefing, never a wrong map.
|
||||
- A first-occurrence move can enlarge a briefing beyond the directly changed spans; that cost is an explicit consequence of the 首次出现 contract, not an alignment heuristic.
|
||||
|
||||
## Testing
|
||||
|
||||
[scripts/translation-brief.spec.ts](../../../../scripts/translation-brief.spec.ts) pins unit and section span extraction (container-scoped kinds, depth-only section alignment so translated heading text still maps, preamble), alignment and changed-index detection, the mechanical code splice and each of its refusal conditions, terminology row matching in both directions with word-boundary and plural-inflection discipline, first-occurrence movement tracking, fence escalation, and the rendered briefing's contract (unit bundles with three-way context, mechanical/sections/document scopes, per-direction digests, scoped finish commands). [scripts/translation-pairing.spec.ts](../../../../scripts/translation-pairing.spec.ts) pins argument normalization (any pair file or bare stem to the anchor) and the CLI matrix: scoped check, bare `--write` refusal, `--write <pair>`, `--write --all`, `--list` exclusivity, unknown flags.
|
||||
@@ -0,0 +1,50 @@
|
||||
# Agent Note: 基于简报的最小化翻译更新
|
||||
|
||||
Status: implemented
|
||||
|
||||
[English](2026-07-26-briefed-minimal-translation-updates.md) | 中文
|
||||
|
||||
## 问题
|
||||
|
||||
[双语配对契约](2026-07-02-bilingual-docs-and-pairing-gate.md)早已规定对侧文件按最小幅度更新:把被改的一侧与其上次确认状态做 diff,据此修补对侧文件,绝不整篇重译;但仓库内置的工作流让每次更新都付出整篇文档级别的开销。负责翻译的 subagent 在动手处理一个两行的 diff 之前,要先加载完整的指导语料(guidance corpus),即 skill(技能)、配对契约、翻译规则、192 行的术语表、语体样例与行文标准;要通过 `git cat-file` 手工重新推导上次确认状态以来的 diff;每轮迭代还要重跑全语料配对门禁,而该门禁为校验一个配对要解析整棵树里的每一个配对。一次小的英文行文修改,动辄花掉数十倍于其应得份额的 token 用量与分钟数,被惩罚的恰恰是契约想要的行为:在同一个 PR(Pull Request)里把对侧文件一并带上。
|
||||
|
||||
## 决策
|
||||
|
||||
配对更新基于生成的简报(briefing)运行,而非基于指导语料;只有新建配对仍走整篇文档工作流,后者保持不变。
|
||||
|
||||
- **`pnpm run gen-translation-brief [--apply] [pair...]`**([scripts/gen-translation-brief.ts](../../../../scripts/gen-translation-brief.ts),组装逻辑在 [scripts/translation-brief.ts](../../../../scripts/translation-brief.ts))针对每个失去同步的配对,打印被改一侧从其记录在案的上次确认 blob 到当前工作区的 diff,并附上以能安全对齐的最窄粒度映射的这次改动,映射失败时粒度确定性地逐级放宽:仅落在配对中逐字节一致的围栏代码块内的改动会直接算出(`--apply` 会把它拼接进对侧文件,并在写入前用配对门禁的结构签名校验所得结果);否则,每个有改动的 Markdown 单元(标题、段落、表格行、列表项、围栏代码块、块引用、HTML 块、分隔线、链接定义;匹配依据是以容器为作用域的种类序列)都带上各自的上次确认源文、当前源文与当前对侧文本及行号;无法对齐的单元回退到按深度匹配的标题章节;当章节也无法对齐或两侧同时漂移时,简报会明说这一点并省略映射,而不是靠猜。术语表行只与改动块匹配(英文术语按词边界匹配,含复数变形);当目标侧是中文时,简报还会跟踪每个相关术语在整篇文档中的首次出现:一旦某次编辑使其移位,腾出的与接收的两处区间就会附一条解释性说明加入简报,因为「首次出现」括注必须随之移动。单元映射、代码拼接与首次出现机制采纳了[增量提示词流水线工作](https://github.com/deepseek-harness/deepseek-harness/pull/684)中的规划器设计;该项工作中接入提供方的对比评测,已为自动流水线独立验证了同一套范围阶梯。简报就是译者的全部工作集;简报回答不了的决策,仍以完整的真源文档作为升级求证路径。
|
||||
- **[dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md) 中的更新路径**消费这份简报:机械类改动(只涉及围栏代码块)用 `--apply` 应用,不动用 subagent;行文类 diff 交给 subagent,其提示词就是简报本身,而非指导语料;核验只对改动块逐句进行,不覆盖整篇文档。
|
||||
- **配对门禁接受配对参数。**`verify-translation-pairing [pair...]` 只检查被点名的配对(配对三个文件中的任意一个,或其裸词干,都能指代该配对);全语料扫描仍是 `doc-sync`(文档同步门禁)与 CI 运行的无参数形式。`--write` 现在要求点名已确认的配对:裸 `--write` 会拒绝执行,重新记录全部配对必须显式写 `--write --all`;原因是旧的裸形式会默默为树中每一个漂移的配对背书,包括调用者从未看过的那些,纯行文层面的漂移于是可以永远保持绿灯。每份记录的注释都写明针对该配对自身的按对命令。
|
||||
|
||||
## 基准测试
|
||||
|
||||
该决策来自对本仓库历史上十次真实配对更新的受控回放(2026 年 7 月;每例改动 1 到 64 行英文,涵盖 README、RFC、Agent Note(agent 决策记录)与用户文档)。每个样例都在临时仓库中重建到其真实的上次确认状态,英文改动保持未提交,再用全新的 subagent 分别跑过相互竞争的各条工作流:维持现状的语料加载路径、简报路径、无指导对照组、整篇重译、小模型上的简报路径,以及每个 agent(智能体)一次处理三对文档的批量方案。产出先经机械门禁把关,再由评委盲评打分;评委还同时收到真实的历史更新与原样未动的陈旧对侧文件作为对照。
|
||||
|
||||
- 简报路径在盲评的忠实性、保留度与流畅度上与现状路径打平(两者都达到或超过真实历史更新的水平),而在未发生停滞的样例上只花费约三分之一的 token 用量与墙钟时间(全部十例的中位数:相对 token 成本单位 276k 对 595k,轮次数 14 对 32)。
|
||||
- 整篇重译被证实有害,而不只是浪费:它丢弃经评审的措辞,盲评保留度因此崩塌(4.4/10 对 9.8);它还使各更新组保持住的既定术语发生漂移(既定译法本就写在对侧文件自身的正文里);而且它是成本最高的一组。
|
||||
- 无指导对照组的质量同样立得住(对一次更新有约束力的上下文,是 diff 加上对侧文件自身经评审的正文,而非指导语料),但简报以几乎可忽略的额外成本,换来固定的工作集、内联的术语,以及两侧同时漂移的警告。
|
||||
- 以简报为输入,小模型的表现与大模型持平,因此更新路径不再假定翻译必须由前沿模型完成。
|
||||
- 把三对文档合并给同一个 subagent,相比三次各自带简报的运行没有可靠的节省,还把互不相关的失败耦合在一起;该方案被否决。
|
||||
|
||||
在同样这十个样例上进行的第二次正面对比回放,把本文最终交付的简报与其早前仅按章节的形态(没有单元层级、没有直接算出的机械路径、上下文只含对侧文件、不跟踪首次出现)相对照。行文质量与成本两相持平(两两盲评裁定各有胜负,差距仅在文风),而最终交付的形态在两项客观结果上胜出:两个只涉及围栏代码块的样例在一秒之内完成且不消耗任何模型 token,产出与经人工评审的历史更新逐字节一致;而在那个编辑使某术语在整篇文档中的首次出现发生移位的样例上,最终交付的简报所标记的移位复现了经人工评审的括注迁移,仅按章节的形态则留下一处「首次出现」违例,留待评审去捕捉。
|
||||
|
||||
## 曾考虑的替代方案
|
||||
|
||||
- **保留原工作流,只让门禁支持按对检查**:门禁扫描本是较小的开销,大头在语料加载与翻查历史。只收窄检查范围,约 3 倍的开销仍会原地保留。
|
||||
- **把整篇重译作为更新路径**(朴素流水线的做法):依据基准测试证据否决,理由是保留度崩塌、术语漂移、成本最高。契约的最小更新规则得以延续,且从此有数据支撑。
|
||||
- **每个 subagent 批量处理多对文档**:否决。没有实测出节省(简报本身已对固定内容做了去重),而且一对文档停滞或陷入混乱会把其余配对一并拖住。
|
||||
- **在伴随记录中保存逐段的翻译记忆条目**(用分段 hash 取代整文件 hash):否决。配对两侧的段落边界可以合理地不同,任一侧都可能先撰写,这类条目还会不断膨胀并在合并时产生冲突。基于现有整文件 hash 按需计算的区间映射,在对齐可信时能恢复同样的对齐关系,不可信时会明确说明。
|
||||
- **给自动提示词流水线加一个更新模式(prompt-v5)**:推迟,本文不做设计。今天没有任何调用方在驱动 [scripts/translation-prompt.ts](../../../../scripts/translation-prompt.ts),实际的成本中心是 agent 路径。流水线在拥有消费方之前,维持其整篇文档的 v4 契约。
|
||||
|
||||
## 后果
|
||||
|
||||
- 一次小的行文修改,其对侧更新如今只需生成一份简报,外加一个小而聚焦的任务(不读指导语料、不翻查历史、循环内不做全语料扫描),同一 PR 内完成更新的义务保持不变;低成本的路径与正确的路径指向同一个方向。
|
||||
- 简报生成器成为一致性记录的第二个消费方:记录的 blob hash 如今还驱动 diff 还原与章节映射,这进一步强化了如实维护记录的动机。
|
||||
- 不带参数的 `--write` 不再可用;靠肌肉记忆的调用者必须点名配对或传 `--all`。这正是目的所在:批量背书如今是一个可见的、有意为之的动作。
|
||||
- 按对检查意味着一个更新循环可以在别处某个无关配对处于红灯时自己保持绿灯;`doc-sync`/CI 中的全语料检查仍然承载树级不变式。
|
||||
- 区间映射只在上次确认源文、当前源文与当前对侧文本三方的种类序列一致时才信任一处对齐;映射失败时粒度确定性地逐级放宽(单元 → 章节 → 整篇文档)而不是靠猜,因此被重构过的文档拿到的是一份明确写着「请自行定位相关区域」的简报,绝不会是一张错误的地图。
|
||||
- 「首次出现」的一次移位可能让简报扩大到直接改动块之外;这一成本是「首次出现」契约的明确后果,而非对齐启发式。
|
||||
|
||||
## 测试
|
||||
|
||||
[scripts/translation-brief.spec.ts](../../../../scripts/translation-brief.spec.ts) 固定单元与章节的区间提取(以容器为作用域的种类、只按深度对齐章节从而让已翻译的标题文字仍能映射、首个标题前的序言)、对齐与改动索引检测、机械代码拼接及其每一个拒绝条件、带词边界与复数变形约束的双向术语行匹配、首次出现移位跟踪、围栏升级,以及渲染后简报的契约(带三方上下文的单元条目、机械/章节/整篇文档三种范围、分方向的规则摘要、按对的收尾命令)。[scripts/translation-pairing.spec.ts](../../../../scripts/translation-pairing.spec.ts) 固定参数归一化(配对的任一文件或裸词干都归一到锚点)与 CLI(命令行界面)用例矩阵:按对检查、裸 `--write` 拒绝执行、`--write <pair>`、`--write --all`、`--list` 的互斥性、未知标志。
|
||||
@@ -46,4 +46,4 @@ Apply the ordered relocate-condense-raise policy in [docs/AGENTS.md](../../../do
|
||||
|
||||
## Validation and PR hygiene
|
||||
|
||||
Run at least `pnpm run doc-sync`, `pnpm run lint`, and `git diff --check`; JSDoc changes may regenerate catalogs. If a paired doc changed, follow [dsh-translate-docs](../dsh-translate-docs/SKILL.md) and run `pnpm run verify-translation-pairing --write`. The PR body should give word deltas, explain any deliberately long exception, and list checks.
|
||||
Run at least `pnpm run doc-sync`, `pnpm run lint`, and `git diff --check`; JSDoc changes may regenerate catalogs. If a paired doc changed, follow [dsh-translate-docs](../dsh-translate-docs/SKILL.md) and run `pnpm run verify-translation-pairing --write <pair>`. The PR body should give word deltas, explain any deliberately long exception, and list checks.
|
||||
|
||||
@@ -5,17 +5,33 @@ description: Use when creating or updating the bilingual counterpart of a doc in
|
||||
|
||||
# Translating DeepSeek-Harness docs
|
||||
|
||||
## Delegate to a subagent
|
||||
|
||||
When this skill fires and translations need to be written, do not translate yourself: spawn a subagent to do the translation work. If you are that delegated subagent, skip this section; the sections from here on address the agent actually writing the translation.
|
||||
|
||||
## What this skill is
|
||||
|
||||
**This skill is guidance, not a translation memory.** It is the workflow map for keeping `foo.md ↔ foo.zh.md` pairs consistent and natural in both languages. Both languages carry equal authority — a change is authored in either one, and that side is the source for that update. 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)
|
||||
## Triage by change type — this decides everything else
|
||||
|
||||
These are authoritative; read them at the source so this skill never drifts out of sync.
|
||||
- **Update** (pair exists, one side edited): follow [the update path](#the-update-path-briefing-driven). It is briefing-driven and deliberately cheap: no guidance-corpus reading, no git archaeology, smallest counterpart edit. Never re-translate a whole document to apply an update — a minimal update preserves the reviewed phrasing of everything that didn't change; a re-translation throws that review away.
|
||||
- **New pair** (no counterpart yet): follow [the whole-document path](#the-whole-document-path-new-pairs).
|
||||
- **Deleted or renamed doc**: delete or rename the counterpart and the `.i18n.yaml` alongside it — the gate reports an incomplete pair otherwise.
|
||||
|
||||
Frozen Agent Notes under `.agents/notes/archived/` are not translation work. Their complete triplets are sealed by the archive verifier; never update, re-record, or repair either side after archival.
|
||||
|
||||
## The update path (briefing-driven)
|
||||
|
||||
Benchmarked on real pair updates from this repo's history, the briefing-driven path costs a fraction of a guidance-corpus-loading run at equal measured quality; the [briefed-updates Agent Note](../../notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md) holds the evidence.
|
||||
|
||||
1. **Generate the briefing**: `pnpm run gen-translation-brief <any file of the pair>` (no arguments briefs every out-of-sync pair). The briefing maps the change at the narrowest safely aligned granularity — changed Markdown units (paragraph, table row, list item, heading), then whole heading sections, then whole document — and contains the authored side's diff since the last confirmed-consistent state, each changed unit's last-confirmed source, current source, and current counterpart text (with line numbers), the terminology rows the change touches, first-occurrence movement notes, and a digest of the binding update rules.
|
||||
2. **Mechanical-only diff? `--apply` it.** When every change lies inside code fences that the pair shares byte-identically, the briefing says so; `pnpm run gen-translation-brief --apply <pair>` splices the edited fences into the counterpart and structure-validates the result before writing — no subagent, no hand-editing.
|
||||
3. **Prose diff? Delegate to a subagent, passing the briefing** (or the command to generate it). The briefing is the translator's whole working set — the subagent does not re-read the guidance corpus (the rules digest, terminology rows, and each changed unit's three-way context are inline) and does not re-derive the diff. It escalates to the whole-document path's sources of truth only when the briefing leaves a specific decision genuinely unanswerable — an unlisted term with no precedent in the surrounding text, or a whole-document briefing (`BOTH sides changed`, or neither units nor sections align), which always means reconciling by hand under [translation-rules.md](../../../docs/i18n/translation-rules.md).
|
||||
4. **Smallest edit that covers the diff.** Preserve the reviewed phrasing of everything the diff does not touch, then verify the changed hunks clause by clause against the source: nothing added, nothing dropped, terminology per the inline rows, code spans verbatim.
|
||||
5. **Record and verify, scoped**: `pnpm run verify-translation-pairing --write <pair>` then `pnpm run verify-translation-pairing <pair>`. `--write` names exactly the pairs you confirmed — it refuses to run bare so a bulk re-record is always an explicit `--all`. The corpus-wide check still runs in `doc-sync`/CI; do not run it per-update.
|
||||
|
||||
## The whole-document path (new pairs)
|
||||
|
||||
When translations need to be written from scratch, the orchestrating agent does not translate: spawn a subagent to do the translation work. The translator reads the sources of truth below first, then translates the whole file into the other language — section by section for long documents, keeping each section's structure locked to the source as you go rather than fixing structure at the end.
|
||||
|
||||
### Sources of truth (read, don't re-summarize)
|
||||
|
||||
- **[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, and exclusions.
|
||||
- **[docs/i18n/translation-rules.md](../../../docs/i18n/translation-rules.md)** — how to translate: faithfulness, structure preservation, terminology discipline, typography (MUST/SHOULD levels).
|
||||
@@ -23,29 +39,7 @@ These are authoritative; read them at the source so this skill never drifts out
|
||||
- **[docs/i18n/translation-prompt.md](../../../docs/i18n/translation-prompt.md)** — the automated pipeline's calibrated machine-consumed template. Agents using this skill do not render it; the terminology table is the only repository file the automated renderer injects, while this skill and `translation-rules.md` remain binding for agent-authored translations.
|
||||
- **[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
|
||||
|
||||
- `pnpm run verify-translation-pairing --list` prints every in-scope document as missing / out-of-sync / ok. Missing and out-of-sync rows are contract violations; the normal check rejects them.
|
||||
- In a PR that edits paired docs, the work list is the diff itself: every changed side of a pair needs its counterpart updated and the pair re-recorded 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 pair** (no counterpart yet): whichever language exists — English or Chinese — translate the whole file into the other, section by section for long documents, keeping each section's structure locked to the source as you go rather than fixing structure at the end.
|
||||
- **Update** (pair exists, one side edited): do NOT re-translate. The consistency record names the exact last-confirmed text of both sides — recover the edited side's previous state and diff:
|
||||
|
||||
```sh
|
||||
git cat-file -p <hash-from-i18n-yaml> > /tmp/last-confirmed.md
|
||||
git diff --no-index /tmp/last-confirmed.md docs/foo.md
|
||||
```
|
||||
|
||||
Apply the smallest counterpart 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 doc**: delete or rename the counterpart and the `.i18n.yaml` alongside it — the gate reports an incomplete pair otherwise.
|
||||
|
||||
Frozen Agent Notes under `.agents/notes/archived/` are not translation work. Their complete triplets are sealed by the archive verifier; never update, re-record, or repair either side after archival.
|
||||
|
||||
## Translate
|
||||
### Translate
|
||||
|
||||
- **Pass 1 — write, don't transpose.** Read a semantic unit, then restate it as a native technical author in the nearest [style sample's](../../../docs/i18n/style-samples.md) register. Preserve the required frame without forcing sentence-by-sentence correspondence.
|
||||
- **Pass 2 — verify against the source, clause by clause.** Fidelity is checked here, not written in: confirm nothing was added or dropped, every term follows the table, and each code span survived verbatim. Fix by rewriting the sentence natively, not by patching words into it.
|
||||
@@ -54,15 +48,19 @@ Frozen Agent Notes under `.agents/notes/archived/` are not translation work. The
|
||||
- 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 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.
|
||||
|
||||
## Find the work
|
||||
|
||||
- `pnpm run verify-translation-pairing --list` prints every in-scope document as missing / out-of-sync / ok. Missing and out-of-sync rows are contract violations; the normal check rejects them.
|
||||
- `pnpm run gen-translation-brief` with no arguments prints the briefing for every out-of-sync pair.
|
||||
- In a PR that edits paired docs, the work list is the diff itself: every changed side of a pair needs its counterpart updated and the pair re-recorded in the same PR, and the gate goes red if you forget.
|
||||
|
||||
## Finish the pair
|
||||
|
||||
1. Switcher: `[English](foo.md) | 中文` immediately after the Chinese file's H1, `English | [中文](foo.zh.md)` after the English file's H1 — add both if this is a new pair.
|
||||
2. Record consistency: `pnpm run verify-translation-pairing --write` recomputes and records both sides' full blob hashes in `foo.i18n.yaml`. The yaml diff in your PR is the reviewable statement "I confirmed these two say the same thing" — only run it after you actually have.
|
||||
2. Record consistency: `pnpm run verify-translation-pairing --write <pair>` recomputes and records both sides' full blob hashes in `foo.i18n.yaml`. The yaml diff in your PR is the reviewable statement "I confirmed these two say the same thing" — only run it after you actually have.
|
||||
3. No manifest entry is needed for an ordinary document: every in-scope source requires a pair. Change [scripts/translation-pairing.manifest.json](../../../scripts/translation-pairing.manifest.json) only when the owning policy documents a genuine generated, instructional, or bilingual-by-construction exclusion.
|
||||
|
||||
## Verify the mechanical and human halves
|
||||
|
||||
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 and manually verify the obligations listed in Pass 2 that the gates do not encode. Keep the PR reviewable: state which pairs are new versus minimally updated and list 「待定术语」 prominently.
|
||||
4. Before the PR: the touched pairs are green under the scoped check; `pnpm run doc-sync` (which includes the corpus-wide pairing check plus `verify-md-wrap`/`verify-md-links`) runs once at PR level per [dsh-pre-push-checks](../dsh-pre-push-checks/SKILL.md), not inside each translation task.
|
||||
5. Keep the PR reviewable: state which pairs are new versus minimally updated and list 「待定术语」 prominently.
|
||||
|
||||
## How to respond to translation review
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
development.md: c46d84740e6f0a1f67158f39f9ea421cb57165d4
|
||||
development.zh.md: 9e13ab258e1db5406f84ece61959a995110578ae
|
||||
# pnpm run verify-translation-pairing --write docs/development.md
|
||||
development.md: fd7f39ae7b5aac2d44572979ca8c8f1d2df0de6f
|
||||
development.zh.md: 7dd6209bad75d605e0056d2465a35b08aa091780
|
||||
|
||||
@@ -112,6 +112,7 @@ pnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README
|
||||
pnpm run verify-mermaid # fail if a ```mermaid diagram has invalid Mermaid syntax
|
||||
pnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type
|
||||
pnpm run verify-doc-budgets # fail if a budgeted standing doc exceeds its word ceiling
|
||||
pnpm run gen-translation-brief # print the minimal-update briefing for out-of-sync translation pairs (--apply splices code-only edits)
|
||||
pnpm run doc-sync # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list
|
||||
pnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps
|
||||
pnpm run verify-module-graph # fail if docs/module-graph.md is stale
|
||||
|
||||
@@ -112,6 +112,7 @@ pnpm run verify-md-wrap # fail on hard-wrapped prose paragraphs in docs/README
|
||||
pnpm run verify-mermaid # fail if a ```mermaid diagram has invalid Mermaid syntax
|
||||
pnpm run verify-type-equiv # fail if a ```ts type-equiv doc block drifts from its source type
|
||||
pnpm run verify-doc-budgets # fail if a budgeted standing doc exceeds its word ceiling
|
||||
pnpm run gen-translation-brief # print the minimal-update briefing for out-of-sync translation pairs (--apply splices code-only edits)
|
||||
pnpm run doc-sync # all Markdown/doc gates, scheduled concurrently; the doc-sync leaf list in scripts/run-gates.ts is the full list
|
||||
pnpm run gen-module-graph # regenerate docs/module-graph.md from package peerDeps
|
||||
pnpm run verify-module-graph # fail if docs/module-graph.md is stale
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
|
||||
# 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:
|
||||
# pnpm run verify-translation-pairing --write
|
||||
README.md: daddd35f981879b539ec76f0c21cf232f4688036
|
||||
README.zh.md: c36728240690edb7ae35f33eaa7595a5d320860f
|
||||
# pnpm run verify-translation-pairing --write docs/i18n/README.md
|
||||
README.md: 9fe01174909f0400b94c70b95759526aec783d2e
|
||||
README.zh.md: 2a9a20ae4702e205879f0fbfb6d76d3a761d2446
|
||||
|
||||
@@ -15,7 +15,7 @@ This repo's documentation is read by people and agents both inside and outside t
|
||||
foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b
|
||||
```
|
||||
|
||||
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 hashes also recover the exact last-confirmed text of either side, so an out-of-sync pair is updated by patching the counterpart minimally against the edited side's diff — never by re-translating whole files. `pnpm run gen-translation-brief <pair>` assembles that update's working set mechanically at the narrowest safely aligned granularity — changed Markdown units, then heading sections, then whole document — with the edited side's diff since last confirmation, each changed span's three-way text, the terminology rows the change touches, and the binding update rules; a change confined to the pair's byte-identical code fences is computed outright, and `--apply` splices it into the counterpart after structural validation ([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md)). After bringing the pair back in line, `pnpm run verify-translation-pairing --write <pair>` re-records both hashes; that yaml diff is the reviewable act of confirming consistency, which is why `--write` requires naming the pairs you confirmed (`--write --all` is the explicit corpus-wide form).
|
||||
- **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, 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`).
|
||||
|
||||
@@ -31,7 +31,9 @@ Source-oriented code gates consume an exact `.zh.md` fence sequence as a derivat
|
||||
|
||||
`pnpm run verify-translation-pairing --list` prints the current pairing state of every document in scope — missing, out-of-sync, or ok. It never fails; `missing` and `out-of-sync` rows identify violations that the normal check rejects.
|
||||
|
||||
The practical rule this gate creates: **when a PR edits either side of a paired document, the same PR updates the counterpart and re-records the pair** (run the [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill, then `--write`), exactly like the repo's existing doc-sync rule for code and READMEs. A PR that leaves a pair out of sync goes red in CI.
|
||||
`pnpm run verify-translation-pairing <pair...>` checks just the named pairs — any of a pair's three files (or its bare stem) names it — so an update loop verifies its own pair in seconds instead of re-scanning the corpus. The no-argument corpus-wide form is what `doc-sync` and CI run; a scoped green never substitutes for it at PR level.
|
||||
|
||||
The practical rule this gate creates: **when a PR edits either side of a paired document, the same PR updates the counterpart and re-records the pair** (run the [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill, then `--write <pair>`), exactly like the repo's existing doc-sync rule for code and READMEs. A PR that leaves a pair out of sync goes red in CI.
|
||||
|
||||
The gate's limit, stated plainly: **a green gate means the pair was confirmed consistent at these exact contents, not that the confirmation was sound.** It checks hashes and shape; it cannot judge whether the two sides actually say the same thing, or whether the wording is accurate, well-termed, and natural — that is the reviewer's half of the contract, per [translation-rules.md](translation-rules.md). A re-recorded pair with a sloppy counterpart passes the gate; it must not pass review.
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@
|
||||
foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b
|
||||
```
|
||||
|
||||
用 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 还能还原任一侧上次确认时的确切文本,所以失去同步的配对是「按被改一侧的 diff 最小化地修补另一侧」,从不整篇重译。`pnpm run gen-translation-brief <pair>` 会以能安全对齐的最窄粒度——先是有改动的 Markdown 单元,再是标题小节,最后是整篇文档——机械地汇集这次更新的工作集:被改一侧自上次确认以来的 diff、每个改动块的三方文本、改动触及的术语表行,以及有约束力的更新规则;仅落在配对中逐字节一致的围栏代码块内的改动可以直接算出,`--apply` 则经结构签名校验后把它拼接进对侧文件([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md))。两侧对齐后,`pnpm run verify-translation-pairing --write <pair>` 重新记录两个 hash;那份 yaml diff 就是「确认一致」这个动作本身,可以被评审,也正因如此,`--write` 要求点名你确认过的配对(`--write --all` 是显式的全语料形式)。
|
||||
- **语言切换行。** 两个文件在各自 H1 标题之后立即互链:英文文件带 `English | [中文](foo.zh.md)`,中文文件带 `[English](foo.md) | 中文`。
|
||||
- **结构与另一侧一一对应。** 标题深度与顺序、列表类型、有序列表起始编号、列表项数量、表格行列数、链接目标与逐字节一致的代码块在配对两侧一一对应;完整保持规则见 [translation-rules.md](translation-rules.md)。既有 Markdown 门禁对 `.zh.md` 文件原样生效(`verify-md-wrap`、`verify-md-links`)。
|
||||
|
||||
@@ -31,7 +31,9 @@
|
||||
|
||||
`pnpm run verify-translation-pairing --list` 打印范围内每篇文档的当前配对状态(missing、out-of-sync 或 ok)。它从不失败;其中 missing 与 out-of-sync 行指出普通检查会拒绝的违规。
|
||||
|
||||
这个门禁带来的实际规则是:**当一个 PR 修改了已配对文档的任一侧时,同一个 PR 更新另一侧并重新记录配对**(运行 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill(技能),再 `--write`),与本仓库既有的代码与 README 的 doc-sync 规则完全一致。留下失去同步的配对的 PR 会在 CI 变红。
|
||||
`pnpm run verify-translation-pairing <pair...>` 只检查被点名的配对——配对的三个文件中的任意一个(或其裸词干)都能点名它——因此更新循环几秒内就能验证自己的配对,而不必重新扫描全语料。`doc-sync` 与 CI 运行的是无参数的全语料形式;限定范围的绿灯在 PR 层面永远不能替代它。
|
||||
|
||||
这个门禁带来的实际规则是:**当一个 PR 修改了已配对文档的任一侧时,同一个 PR 更新另一侧并重新记录配对**(运行 [dsh-translate-docs](../../.agents/skills/dsh-translate-docs/SKILL.md) skill(技能),再 `--write <pair>`),与本仓库既有的代码与 README 的 doc-sync 规则完全一致。留下失去同步的配对的 PR 会在 CI 变红。
|
||||
|
||||
把门禁的边界说白:**门禁通过意味着这组文档在当前内容上的一致性得到了确认,不代表确认本身正确可靠。** 它检查记录的 hash 与结构签名;它无法判断两侧是否真的在说同样的话,也无法判断措辞是否准确、术语是否得当、行文是否自然;这部分契约由评审者把关,见 [translation-rules.md](translation-rules.md)。重新记录了 hash 但另一侧翻得潦草的配对能通过门禁;它不得通过评审。
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
"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",
|
||||
"gen-translation-brief": "tsx scripts/gen-translation-brief.ts",
|
||||
"verify-doc-budgets": "tsx scripts/verify-doc-budgets.ts",
|
||||
"docs:dev": "pnpm --filter @deepseek-ai/website run dev",
|
||||
"docs:build": "pnpm --filter @deepseek-ai/website run build",
|
||||
|
||||
@@ -343,7 +343,7 @@
|
||||
.textRef {
|
||||
color: transparent;
|
||||
background-color: transparent;
|
||||
box-decoration-break: clone;
|
||||
box-decoration-break: clone;
|
||||
-webkit-box-decoration-break: clone;
|
||||
position: relative;
|
||||
}
|
||||
@@ -352,7 +352,7 @@
|
||||
position: absolute;
|
||||
left: 0;
|
||||
top: 0;
|
||||
|
||||
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
|
||||
|
||||
302
scripts/gen-translation-brief.ts
Normal file
302
scripts/gen-translation-brief.ts
Normal file
@@ -0,0 +1,302 @@
|
||||
/**
|
||||
* Print the minimal-update briefing for out-of-sync translation pairs:
|
||||
* `pnpm run gen-translation-brief [--apply] [pair paths...]`. With no
|
||||
* arguments it discovers every out-of-sync pair; with arguments (any file
|
||||
* of a pair) it briefs exactly those pairs and fails loud on in-sync,
|
||||
* incomplete, or out-of-scope requests. Each briefing maps the change at
|
||||
* the narrowest safe granularity — code-fence-only splice, changed
|
||||
* Markdown units, heading sections, whole document — and `--apply` writes
|
||||
* the computed counterpart for pairs whose change is code-fence-only.
|
||||
* The briefing contract lives in `scripts/translation-brief.ts`; the
|
||||
* consuming workflow is `.agents/skills/dsh-translate-docs/SKILL.md`.
|
||||
*/
|
||||
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { existsSync, globSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { basename, join, resolve, sep } from 'node:path'
|
||||
import {
|
||||
isTranslationScopeFile,
|
||||
pairAnchorOfArgument,
|
||||
parseTranslationMarkdown,
|
||||
parseTranslationPairingManifest,
|
||||
TRANSLATION_SCOPE_GLOB_EXCLUDES,
|
||||
translationStructureDiff,
|
||||
translationStructureSignature,
|
||||
} from './translation-pairing.ts'
|
||||
import {
|
||||
changedSpanIndices,
|
||||
computeMechanicalUpdate,
|
||||
firstOccurrenceContext,
|
||||
markdownUnits,
|
||||
relevantTerminologyRows,
|
||||
renderTranslationBrief,
|
||||
sectionSpans,
|
||||
spansAligned,
|
||||
type BriefBundle,
|
||||
type BriefDirection,
|
||||
type BriefScope,
|
||||
type MarkdownSpan,
|
||||
} from './translation-brief.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const manifest = parseTranslationPairingManifest(readFileSync(join(root, 'scripts/translation-pairing.manifest.json'), 'utf8'))
|
||||
const terminology = readFileSync(join(root, 'docs/i18n/terminology.md'), 'utf8')
|
||||
|
||||
function isExcluded(file: string): boolean {
|
||||
return manifest.excluded.some(entry => (entry.endsWith('/') ? file.startsWith(entry) : file === entry))
|
||||
}
|
||||
|
||||
/** Recorded hashes of one consistency record: basename → blob hash. */
|
||||
function parseMeta(content: string): Map<string, string> | undefined {
|
||||
const out = new Map<string, string>()
|
||||
for (const line of content.split('\n')) {
|
||||
if (line === '' || line.startsWith('#')) continue
|
||||
const match = /^([^:#]+\.md): ([0-9a-f]{40})$/.exec(line)
|
||||
if (!match?.[1] || !match[2]) return undefined
|
||||
out.set(match[1], match[2])
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
function git(args: string[], allowedExitCodes: number[] = [0]): string {
|
||||
const result = spawnSync('git', ['-C', root, ...args], { encoding: 'utf8', maxBuffer: 1 << 26 })
|
||||
if (result.error) throw result.error
|
||||
if (!allowedExitCodes.includes(result.status ?? -1)) {
|
||||
throw new Error(`git ${args.join(' ')} failed: ${result.stderr}`)
|
||||
}
|
||||
return result.stdout
|
||||
}
|
||||
|
||||
function blobText(hash: string): string {
|
||||
return git(['cat-file', '-p', hash])
|
||||
}
|
||||
|
||||
/** Unified diff between two texts, headers stripped, via `git diff --no-index`. */
|
||||
function diffTexts(before: string, after: string): string {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'translation-brief-'))
|
||||
try {
|
||||
writeFileSync(join(dir, 'last-confirmed.md'), before)
|
||||
writeFileSync(join(dir, 'current.md'), after)
|
||||
const raw = git(['diff', '--no-index', '--unified=2', join(dir, 'last-confirmed.md'), join(dir, 'current.md')], [0, 1])
|
||||
return raw.split('\n')
|
||||
.filter(line => !line.startsWith('diff --git') && !line.startsWith('index ') && !line.startsWith('--- ') && !line.startsWith('+++ '))
|
||||
.join('\n')
|
||||
.trim()
|
||||
} finally {
|
||||
rmSync(dir, { recursive: true, force: true })
|
||||
}
|
||||
}
|
||||
|
||||
interface PairState {
|
||||
anchor: string
|
||||
zh: string
|
||||
meta: string
|
||||
enDrifted: boolean
|
||||
zhDrifted: boolean
|
||||
enLast: string
|
||||
zhLast: string
|
||||
}
|
||||
|
||||
/** Load one pair's recorded and current state, or explain why it cannot be briefed. */
|
||||
function loadPair(anchor: string): PairState | string {
|
||||
const zh = anchor.replace(/\.md$/, '.zh.md')
|
||||
const meta = anchor.replace(/\.md$/, '.i18n.yaml')
|
||||
if (!isTranslationScopeFile(anchor) || isExcluded(anchor)) {
|
||||
return `${anchor}: not an in-scope documentation pair (docs/i18n/README.md)`
|
||||
}
|
||||
const missing = [anchor, zh, meta].filter(file => !existsSync(join(root, file)))
|
||||
if (missing.length > 0) {
|
||||
return `${anchor}: incomplete pair (missing ${missing.join(', ')}) — a new counterpart is whole-document translation work, not a minimal update`
|
||||
}
|
||||
const record = parseMeta(readFileSync(join(root, meta), 'utf8'))
|
||||
const enRecorded = record?.get(basename(anchor))
|
||||
const zhRecorded = record?.get(basename(zh))
|
||||
if (record === undefined || enRecorded === undefined || zhRecorded === undefined) {
|
||||
return `${meta}: malformed consistency record`
|
||||
}
|
||||
const enCurrent = readFileSync(join(root, anchor), 'utf8')
|
||||
const zhCurrent = readFileSync(join(root, zh), 'utf8')
|
||||
const enLast = blobText(enRecorded)
|
||||
const zhLast = blobText(zhRecorded)
|
||||
return {
|
||||
anchor,
|
||||
zh,
|
||||
meta,
|
||||
enDrifted: enCurrent !== enLast,
|
||||
zhDrifted: zhCurrent !== zhLast,
|
||||
enLast,
|
||||
zhLast,
|
||||
}
|
||||
}
|
||||
|
||||
/** Assemble bundles for the given changed + first-occurrence span indices. */
|
||||
function bundlesFor(
|
||||
indices: number[],
|
||||
extraIndices: number[],
|
||||
confirmed: MarkdownSpan[],
|
||||
current: MarkdownSpan[],
|
||||
counterpart: MarkdownSpan[],
|
||||
): BriefBundle[] {
|
||||
const extras = new Set(extraIndices)
|
||||
return [...new Set([...indices, ...extraIndices])].sort((left, right) => left - right).map((index) => {
|
||||
const confirmedSpan = confirmed[index]
|
||||
const currentSpan = current[index]
|
||||
const counterpartSpan = counterpart[index]
|
||||
if (confirmedSpan === undefined || currentSpan === undefined || counterpartSpan === undefined) {
|
||||
throw new Error(`gen-translation-brief: span ${index} is unmapped despite alignment`)
|
||||
}
|
||||
return {
|
||||
index,
|
||||
label: currentSpan.label,
|
||||
reason: extras.has(index) && confirmedSpan.text === currentSpan.text ? 'first-occurrence' as const : undefined,
|
||||
confirmedSourceText: confirmedSpan.text,
|
||||
currentSourceText: currentSpan.text,
|
||||
counterpartText: counterpartSpan.text,
|
||||
counterpartStartLine: counterpartSpan.startLine,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
interface PlannedBrief {
|
||||
scope: BriefScope
|
||||
/** Old + new text of the changed spans, for terminology matching. */
|
||||
changedText: string
|
||||
/** Computed counterpart for a mechanical scope, for `--apply`. */
|
||||
mechanicalResult?: string | undefined
|
||||
}
|
||||
|
||||
/** Choose the narrowest safely mapped granularity for one drifted side. */
|
||||
function planScope(
|
||||
sourceLast: string,
|
||||
sourceCurrent: string,
|
||||
counterpartCurrent: string,
|
||||
direction: BriefDirection,
|
||||
bothDrifted: boolean,
|
||||
): PlannedBrief {
|
||||
const wholeChangedText = `${sourceLast}\n${sourceCurrent}`
|
||||
if (bothDrifted) {
|
||||
return {
|
||||
scope: { kind: 'document', reason: 'BOTH sides changed since the pair was last confirmed consistent, so no side is a trustworthy mapping anchor; decide which side owns each divergence.' },
|
||||
changedText: wholeChangedText,
|
||||
}
|
||||
}
|
||||
const mechanical = computeMechanicalUpdate(sourceLast, sourceCurrent, counterpartCurrent)
|
||||
if (mechanical !== undefined) {
|
||||
return { scope: { kind: 'mechanical' }, changedText: wholeChangedText, mechanicalResult: mechanical }
|
||||
}
|
||||
for (const [kind, spansOf] of [['units', markdownUnits], ['sections', sectionSpans]] as const) {
|
||||
const confirmed = spansOf(sourceLast)
|
||||
const current = spansOf(sourceCurrent)
|
||||
const counterpart = spansOf(counterpartCurrent)
|
||||
if (!spansAligned(confirmed, current) || !spansAligned(confirmed, counterpart)) continue
|
||||
const changed = changedSpanIndices(confirmed, current)
|
||||
if (changed.length === 0) continue
|
||||
const changedText = changed.map(index => `${confirmed[index]?.text ?? ''}\n${current[index]?.text ?? ''}`).join('\n')
|
||||
const rows = relevantTerminologyRows(terminology, direction, changedText)
|
||||
const occurrence = direction === 'en-to-zh'
|
||||
? firstOccurrenceContext(sourceLast, sourceCurrent, confirmed, current, rows, new Set(changed))
|
||||
: { notes: [], extraSpanIndices: [] }
|
||||
return {
|
||||
scope: {
|
||||
kind,
|
||||
bundles: bundlesFor(changed, occurrence.extraSpanIndices, confirmed, current, counterpart),
|
||||
firstOccurrenceNotes: occurrence.notes,
|
||||
},
|
||||
changedText,
|
||||
}
|
||||
}
|
||||
return {
|
||||
scope: { kind: 'document', reason: 'Neither fine-grained units nor heading sections align one to one across the last-confirmed source, current source, and current counterpart.' },
|
||||
changedText: wholeChangedText,
|
||||
}
|
||||
}
|
||||
|
||||
/** Validate a computed mechanical counterpart and write it. */
|
||||
function applyMechanical(counterpartPath: string, sourceCurrent: string, result: string): void {
|
||||
const counterpartBase = basename(counterpartPath)
|
||||
const sourceBase = counterpartBase.endsWith('.zh.md')
|
||||
? counterpartBase.replace(/\.zh\.md$/, '.md')
|
||||
: counterpartBase.replace(/\.md$/, '.zh.md')
|
||||
const errors = translationStructureDiff(
|
||||
translationStructureSignature(parseTranslationMarkdown(sourceCurrent), counterpartBase),
|
||||
translationStructureSignature(parseTranslationMarkdown(result), sourceBase),
|
||||
)
|
||||
if (errors.length > 0) {
|
||||
throw new Error(`gen-translation-brief: computed mechanical update for ${counterpartPath} violates the pair structure: ${errors.join('; ')}`)
|
||||
}
|
||||
writeFileSync(join(root, counterpartPath), result)
|
||||
console.error(`gen-translation-brief: applied code-fence splice to ${counterpartPath}; review the diff, then record the pair.`)
|
||||
}
|
||||
|
||||
/** Render (and under `--apply`, apply) the briefing for one drifted side. */
|
||||
function briefDirection(pair: PairState, direction: BriefDirection, apply: boolean): string {
|
||||
const sourceIsEnglish = direction === 'en-to-zh'
|
||||
const sourcePath = sourceIsEnglish ? pair.anchor : pair.zh
|
||||
const counterpartPath = sourceIsEnglish ? pair.zh : pair.anchor
|
||||
const sourceLast = sourceIsEnglish ? pair.enLast : pair.zhLast
|
||||
const sourceCurrent = readFileSync(join(root, sourcePath), 'utf8')
|
||||
const counterpartCurrent = readFileSync(join(root, counterpartPath), 'utf8')
|
||||
const diff = diffTexts(sourceLast, sourceCurrent)
|
||||
const planned = planScope(sourceLast, sourceCurrent, counterpartCurrent, direction, pair.enDrifted && pair.zhDrifted)
|
||||
if (apply && planned.mechanicalResult !== undefined) {
|
||||
applyMechanical(counterpartPath, sourceCurrent, planned.mechanicalResult)
|
||||
}
|
||||
return renderTranslationBrief({
|
||||
sourcePath,
|
||||
counterpartPath,
|
||||
direction,
|
||||
diff,
|
||||
scope: planned.scope,
|
||||
terminology: relevantTerminologyRows(terminology, direction, planned.changedText),
|
||||
})
|
||||
}
|
||||
|
||||
const argv = process.argv.slice(2)
|
||||
const flags = argv.filter(argument => argument.startsWith('--'))
|
||||
const unknownFlags = flags.filter(flag => flag !== '--apply')
|
||||
if (unknownFlags.length > 0) {
|
||||
console.error(`gen-translation-brief: unknown flag(s): ${unknownFlags.join(', ')} (only --apply is supported)`)
|
||||
process.exit(2)
|
||||
}
|
||||
const applyMode = flags.includes('--apply')
|
||||
const requested = argv.filter(argument => !argument.startsWith('--')).map(pairAnchorOfArgument)
|
||||
|
||||
let anchors: string[]
|
||||
if (requested.length > 0) {
|
||||
anchors = [...new Set(requested)].sort()
|
||||
} else {
|
||||
const discovered = new Set<string>()
|
||||
for (const match of globSync('**/*.i18n.yaml', { cwd: root, exclude: TRANSLATION_SCOPE_GLOB_EXCLUDES })) {
|
||||
const normalized = match.split(sep).join('/')
|
||||
if (isTranslationScopeFile(normalized)) discovered.add(normalized.replace(/\.i18n\.yaml$/, '.md'))
|
||||
}
|
||||
anchors = [...discovered].sort()
|
||||
}
|
||||
|
||||
const briefs: string[] = []
|
||||
const problems: string[] = []
|
||||
const skipped: string[] = []
|
||||
for (const anchor of anchors) {
|
||||
const pair = loadPair(anchor)
|
||||
if (typeof pair === 'string') {
|
||||
if (requested.length > 0) problems.push(pair)
|
||||
continue
|
||||
}
|
||||
if (!pair.enDrifted && !pair.zhDrifted) {
|
||||
if (requested.length > 0) skipped.push(`${anchor}: pair is consistent with its record — nothing to brief`)
|
||||
continue
|
||||
}
|
||||
if (pair.enDrifted) briefs.push(briefDirection(pair, 'en-to-zh', applyMode))
|
||||
if (pair.zhDrifted) briefs.push(briefDirection(pair, 'zh-to-en', applyMode))
|
||||
}
|
||||
|
||||
if (problems.length > 0 || skipped.length > 0) {
|
||||
for (const message of [...problems, ...skipped]) console.error(`gen-translation-brief: ${message}`)
|
||||
process.exit(2)
|
||||
}
|
||||
if (briefs.length === 0) {
|
||||
console.log('gen-translation-brief: every recorded pair matches its consistency record; nothing to brief.')
|
||||
process.exit(0)
|
||||
}
|
||||
console.log(briefs.join('\n\n---\n\n'))
|
||||
File diff suppressed because one or more lines are too long
283
scripts/translation-brief.spec.ts
Normal file
283
scripts/translation-brief.spec.ts
Normal file
@@ -0,0 +1,283 @@
|
||||
/** Regression tests for the minimal-update briefing assembly. */
|
||||
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
changedSpanIndices,
|
||||
computeMechanicalUpdate,
|
||||
firstOccurrenceContext,
|
||||
markdownUnits,
|
||||
parseTerminologyRows,
|
||||
relevantTerminologyRows,
|
||||
renderTranslationBrief,
|
||||
sectionSpans,
|
||||
spansAligned,
|
||||
termOffsets,
|
||||
} from './translation-brief.ts'
|
||||
|
||||
const DOC = [
|
||||
'Preamble line.',
|
||||
'',
|
||||
'# Title',
|
||||
'',
|
||||
'Intro paragraph.',
|
||||
'',
|
||||
'## First',
|
||||
'',
|
||||
'First body.',
|
||||
'',
|
||||
'```ts',
|
||||
'const value = 1',
|
||||
'```',
|
||||
'',
|
||||
'## Second',
|
||||
'',
|
||||
'| A | B |',
|
||||
'|---|---|',
|
||||
'| 1 | 2 |',
|
||||
'',
|
||||
'- item one',
|
||||
'- item two',
|
||||
].join('\n')
|
||||
|
||||
describe('markdown spans', () => {
|
||||
it('lists units with container-scoped kinds in document order', () => {
|
||||
const kinds = markdownUnits(DOC).map(span => span.kind)
|
||||
expect(kinds).toEqual([
|
||||
'root.0:paragraph',
|
||||
'root.1:heading:1',
|
||||
'root.2:paragraph',
|
||||
'root.3:heading:2',
|
||||
'root.4:paragraph',
|
||||
'root.5:code',
|
||||
'root.6:heading:2',
|
||||
'root.7.0:tableRow',
|
||||
'root.7.1:tableRow',
|
||||
'root.8.0:listItem',
|
||||
'root.8.1:listItem',
|
||||
])
|
||||
})
|
||||
|
||||
it('lists heading sections with a preamble span and heading labels', () => {
|
||||
const sections = sectionSpans(DOC)
|
||||
expect(sections.map(span => span.label)).toEqual([
|
||||
'(preamble before the first heading)',
|
||||
'Title',
|
||||
'First',
|
||||
'Second',
|
||||
])
|
||||
expect(sections[0]).toMatchObject({ startLine: 1, endLine: 2 })
|
||||
expect(sections[2]).toMatchObject({ startLine: 7, endLine: 14 })
|
||||
})
|
||||
|
||||
it('labels units by their node type', () => {
|
||||
const units = markdownUnits(DOC)
|
||||
expect(units[0]!.label).toBe('paragraph')
|
||||
expect(units[1]!.label).toBe('heading')
|
||||
expect(units[7]!.label).toBe('tableRow')
|
||||
})
|
||||
|
||||
it('aligns sections by depth only, so translated heading text still maps', () => {
|
||||
const zh = DOC.replace('## First', '## 第一节').replace('## Second', '## 第二节').replace('# Title', '# 标题')
|
||||
expect(spansAligned(sectionSpans(DOC), sectionSpans(zh))).toBe(true)
|
||||
})
|
||||
|
||||
it('aligns span lists only on equal non-empty kind sequences', () => {
|
||||
const zh = DOC.replace('First body.', '第一段。').replace('item one', '第一项').replace('Intro paragraph.', '导语。')
|
||||
expect(spansAligned(markdownUnits(DOC), markdownUnits(zh))).toBe(true)
|
||||
const reshaped = DOC.replace('- item one\n- item two', 'merged paragraph')
|
||||
expect(spansAligned(markdownUnits(DOC), markdownUnits(reshaped))).toBe(false)
|
||||
expect(spansAligned([], [])).toBe(false)
|
||||
})
|
||||
|
||||
it('reports the indices whose text changed', () => {
|
||||
const edited = DOC.replace('First body.', 'First body, revised.').replace('| 1 | 2 |', '| 1 | 3 |')
|
||||
expect(changedSpanIndices(markdownUnits(DOC), markdownUnits(edited))).toEqual([4, 8])
|
||||
})
|
||||
})
|
||||
|
||||
describe('mechanical code updates', () => {
|
||||
const en = '# T\n\nProse.\n\n```sh\nrun one\n```\n'
|
||||
const zh = '# T\n\n中文。\n\n```sh\nrun one\n```\n'
|
||||
|
||||
it('splices a fence-only edit into the counterpart', () => {
|
||||
const edited = en.replace('run one', 'run two')
|
||||
expect(computeMechanicalUpdate(en, edited, zh)).toBe(zh.replace('run one', 'run two'))
|
||||
})
|
||||
|
||||
it('refuses when prose changed too', () => {
|
||||
const edited = en.replace('Prose.', 'Prose!').replace('run one', 'run two')
|
||||
expect(computeMechanicalUpdate(en, edited, zh)).toBeUndefined()
|
||||
})
|
||||
|
||||
it('refuses when the counterpart fences already diverge from last-confirmed', () => {
|
||||
const edited = en.replace('run one', 'run two')
|
||||
expect(computeMechanicalUpdate(en, edited, zh.replace('run one', 'run stale'))).toBeUndefined()
|
||||
})
|
||||
|
||||
it('refuses when fence counts differ or nothing changed', () => {
|
||||
expect(computeMechanicalUpdate(en, `${en}\n\`\`\`sh\nextra\n\`\`\`\n`, zh)).toBeUndefined()
|
||||
expect(computeMechanicalUpdate(en, en, zh)).toBeUndefined()
|
||||
})
|
||||
})
|
||||
|
||||
const TERMINOLOGY = [
|
||||
'| English | 中文 | 首次出现 | 不要译作 | 备注 |',
|
||||
'|---|---|---|---|---|',
|
||||
'| agent | agent | agent(智能体) | 智能体 | |',
|
||||
'| session log | 会话日志 | | 会话记录 | |',
|
||||
'| gate | 门禁 | | | |',
|
||||
'| registry | 注册表 | | | |',
|
||||
].join('\n')
|
||||
|
||||
describe('terminology', () => {
|
||||
it('parses data rows and skips the header and separator', () => {
|
||||
const rows = parseTerminologyRows(TERMINOLOGY)
|
||||
expect(rows.map(row => row.english)).toEqual(['agent', 'session log', 'gate', 'registry'])
|
||||
expect(rows[0]).toMatchObject({ chinese: 'agent', first: 'agent(智能体)' })
|
||||
})
|
||||
|
||||
it('matches English terms on word boundaries with plural inflections', () => {
|
||||
expect(termOffsets('two agents met', 'agent', true)).toEqual([4])
|
||||
expect(termOffsets('two registries', 'registry', true)).toEqual([4])
|
||||
expect(termOffsets('reagents', 'agent', true)).toEqual([])
|
||||
expect(termOffsets('', 'agent', true)).toEqual([])
|
||||
})
|
||||
|
||||
it('selects rows for the changed text per direction', () => {
|
||||
expect(relevantTerminologyRows(TERMINOLOGY, 'en-to-zh', 'All agents write a session log.').map(row => row.english))
|
||||
.toEqual(['agent', 'session log'])
|
||||
expect(relevantTerminologyRows(TERMINOLOGY, 'zh-to-en', '门禁在提交时运行。').map(row => row.english))
|
||||
.toEqual(['gate'])
|
||||
expect(relevantTerminologyRows(TERMINOLOGY, 'en-to-zh', 'delegate the work')).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('first-occurrence tracking', () => {
|
||||
const before = '# T\n\nAlpha paragraph.\n\nThe agent runs.\n'
|
||||
const after = '# T\n\nAlpha paragraph with an agent.\n\nThe agent runs.\n'
|
||||
const rows = parseTerminologyRows(TERMINOLOGY).filter(row => row.english === 'agent')
|
||||
|
||||
it('flags a moved first occurrence and pulls the vacated span in', () => {
|
||||
const context = firstOccurrenceContext(
|
||||
before, after, markdownUnits(before), markdownUnits(after), rows, new Set([1]),
|
||||
)
|
||||
expect(context.notes).toHaveLength(1)
|
||||
expect(context.notes[0]).toContain('moved from #2 to #1')
|
||||
expect(context.extraSpanIndices).toEqual([2])
|
||||
})
|
||||
|
||||
it('stays silent when the first occurrence does not move', () => {
|
||||
const unmoved = before.replace('Alpha paragraph.', 'Alpha paragraph, revised.')
|
||||
const context = firstOccurrenceContext(
|
||||
before, unmoved, markdownUnits(before), markdownUnits(unmoved), rows, new Set([1]),
|
||||
)
|
||||
expect(context.notes).toEqual([])
|
||||
expect(context.extraSpanIndices).toEqual([])
|
||||
})
|
||||
|
||||
it('ignores rows without a first-occurrence rendering', () => {
|
||||
const bare = parseTerminologyRows(TERMINOLOGY).filter(row => row.english === 'gate')
|
||||
const withGate = after.replace('The agent runs.', 'The gate runs.')
|
||||
const context = firstOccurrenceContext(
|
||||
before, withGate, markdownUnits(before), markdownUnits(withGate), bare, new Set([2]),
|
||||
)
|
||||
expect(context.notes).toEqual([])
|
||||
})
|
||||
})
|
||||
|
||||
describe('brief rendering', () => {
|
||||
const base = {
|
||||
sourcePath: 'docs/foo.md',
|
||||
counterpartPath: 'docs/foo.zh.md',
|
||||
direction: 'en-to-zh' as const,
|
||||
diff: '@@ -5 +5 @@\n-old text about the agent\n+new text about the agent',
|
||||
terminology: relevantTerminologyRows(TERMINOLOGY, 'en-to-zh', 'the agent'),
|
||||
}
|
||||
const bundle = {
|
||||
index: 4,
|
||||
label: 'paragraph',
|
||||
confirmedSourceText: 'old text about the agent\n',
|
||||
currentSourceText: 'new text about the agent\n',
|
||||
counterpartText: '关于 agent 的旧文本\n',
|
||||
counterpartStartLine: 9,
|
||||
}
|
||||
|
||||
it('renders unit bundles with three-way context and line anchors', () => {
|
||||
const brief = renderTranslationBrief({
|
||||
...base,
|
||||
scope: { kind: 'units', bundles: [bundle], firstOccurrenceNotes: ['agent: the document-wide first occurrence moved from #2 to #1; the agent(智能体) form moves with it (later occurrences drop the annotation).'] },
|
||||
})
|
||||
expect(brief).toContain('# Translation update briefing: docs/foo.md')
|
||||
expect(brief).toContain('## Changed units')
|
||||
expect(brief).toContain('### #4 paragraph — counterpart at docs/foo.zh.md:9')
|
||||
expect(brief).toContain('Last-confirmed English:')
|
||||
expect(brief).toContain('Current Chinese (bring this along):')
|
||||
expect(brief).toContain('## First-occurrence notes')
|
||||
expect(brief).toContain('agent(智能体)')
|
||||
expect(brief).toContain('首次出现 annotations attach to the document-wide first occurrence only')
|
||||
expect(brief).toContain('verify-translation-pairing --write docs/foo.md')
|
||||
})
|
||||
|
||||
it('marks first-occurrence bundles and omits their unchanged confirmed text', () => {
|
||||
const brief = renderTranslationBrief({
|
||||
...base,
|
||||
scope: {
|
||||
kind: 'units',
|
||||
bundles: [{ ...bundle, reason: 'first-occurrence', confirmedSourceText: bundle.currentSourceText }],
|
||||
firstOccurrenceNotes: [],
|
||||
},
|
||||
})
|
||||
expect(brief).toContain('unchanged; included for a first-occurrence move')
|
||||
expect(brief).not.toContain('Last-confirmed English:')
|
||||
})
|
||||
|
||||
it('renders the mechanical scope with the --apply command', () => {
|
||||
const brief = renderTranslationBrief({ ...base, scope: { kind: 'mechanical' } })
|
||||
expect(brief).toContain('## Mechanical update — no translation judgment involved')
|
||||
expect(brief).toContain('gen-translation-brief --apply docs/foo.md')
|
||||
expect(brief).not.toContain('## Changed units')
|
||||
})
|
||||
|
||||
it('renders the section fallback under its own heading', () => {
|
||||
const brief = renderTranslationBrief({
|
||||
...base,
|
||||
scope: { kind: 'sections', bundles: [bundle], firstOccurrenceNotes: [] },
|
||||
})
|
||||
expect(brief).toContain('## Changed sections')
|
||||
expect(brief).toContain('fine-grained units do not align')
|
||||
})
|
||||
|
||||
it('renders the document fallback with its reason and no bundles', () => {
|
||||
const brief = renderTranslationBrief({
|
||||
...base,
|
||||
scope: { kind: 'document', reason: 'BOTH sides changed since the pair was last confirmed consistent, so no side is a trustworthy mapping anchor; decide which side owns each divergence.' },
|
||||
})
|
||||
expect(brief).toContain('## Whole-document update required')
|
||||
expect(brief).toContain('BOTH sides changed')
|
||||
expect(brief).toContain('locate the affected regions yourself')
|
||||
})
|
||||
|
||||
it('renders the English-target digest for zh-to-en updates', () => {
|
||||
const brief = renderTranslationBrief({
|
||||
...base,
|
||||
direction: 'zh-to-en',
|
||||
sourcePath: 'docs/foo.zh.md',
|
||||
counterpartPath: 'docs/foo.md',
|
||||
scope: { kind: 'units', bundles: [bundle], firstOccurrenceNotes: [] },
|
||||
})
|
||||
expect(brief).toContain('exactly what the new Chinese states')
|
||||
expect(brief).toContain('verify-translation-pairing --write docs/foo.md')
|
||||
})
|
||||
|
||||
it('grows bundle fences past tilde runs in the text', () => {
|
||||
const brief = renderTranslationBrief({
|
||||
...base,
|
||||
scope: {
|
||||
kind: 'units',
|
||||
bundles: [{ ...bundle, counterpartText: '~~~~\ninner\n~~~~\n' }],
|
||||
firstOccurrenceNotes: [],
|
||||
},
|
||||
})
|
||||
expect(brief).toContain('~~~~~markdown')
|
||||
})
|
||||
})
|
||||
513
scripts/translation-brief.ts
Normal file
513
scripts/translation-brief.ts
Normal file
@@ -0,0 +1,513 @@
|
||||
/**
|
||||
* Pure assembly of the minimal-update briefing for one out-of-sync
|
||||
* translation pair: the authored side's changes since the last confirmed
|
||||
* state at the narrowest safely mapped granularity (code-fence-only splice,
|
||||
* changed Markdown units, heading sections, whole document), the terminology
|
||||
* rows those changes touch, first-occurrence movement notes, and a digest of
|
||||
* the binding update rules. The unit mapping, mechanical code splice, and
|
||||
* first-occurrence tracking adopt the planner mechanics validated in the
|
||||
* incremental-pipeline work (PR #684). The CLI wrapper is
|
||||
* `scripts/gen-translation-brief.ts`; the workflow that consumes the
|
||||
* briefing is `.agents/skills/dsh-translate-docs/SKILL.md`.
|
||||
*/
|
||||
|
||||
import type { Nodes } from 'mdast'
|
||||
import { parseTranslationMarkdown } from './translation-pairing.ts'
|
||||
|
||||
/** One block-level span of a Markdown document, in document order. */
|
||||
export interface MarkdownSpan {
|
||||
/** Position in the span list; briefing ids derive from it. */
|
||||
index: number
|
||||
/**
|
||||
* Structural kind compared for alignment, language-neutral: container path
|
||||
* plus node type for units (`root.3:tableRow`), depth for sections (`section:2`).
|
||||
*/
|
||||
kind: string
|
||||
/** Reader-facing label: heading text for sections, node type for units. */
|
||||
label: string
|
||||
/** 1-based first source line. */
|
||||
startLine: number
|
||||
/** 1-based last source line. */
|
||||
endLine: number
|
||||
/** The span's text, trailing newline normalized to exactly one. */
|
||||
text: string
|
||||
}
|
||||
|
||||
function linesOf(markdown: string): string[] {
|
||||
const lines = markdown.replaceAll('\r\n', '\n').split('\n')
|
||||
if (lines.at(-1) === '') lines.pop()
|
||||
return lines
|
||||
}
|
||||
|
||||
function sliceLines(lines: string[], startLine: number, endLine: number): string {
|
||||
return `${lines.slice(startLine - 1, endLine).join('\n')}\n`
|
||||
}
|
||||
|
||||
/**
|
||||
* List a document's translation units: the outermost block nodes a minimal
|
||||
* update can replace independently. Headings, paragraphs, code fences, table
|
||||
* rows, list items, block quotes, HTML blocks, thematic breaks, and link
|
||||
* definitions are units; the container path is part of the kind so kind
|
||||
* sequences only align when container membership also aligns.
|
||||
*
|
||||
* @param markdown - Document text.
|
||||
* @returns Units in document order.
|
||||
*/
|
||||
export function markdownUnits(markdown: string): MarkdownSpan[] {
|
||||
const positions: Array<{ kind: string; label: string; startLine: number; endLine: number }> = []
|
||||
const visit = (node: Nodes, path: string): void => {
|
||||
let kind: string | undefined
|
||||
switch (node.type) {
|
||||
case 'heading':
|
||||
kind = `${path}:heading:${node.depth}`
|
||||
break
|
||||
case 'paragraph':
|
||||
case 'code':
|
||||
case 'tableRow':
|
||||
case 'listItem':
|
||||
case 'blockquote':
|
||||
case 'html':
|
||||
case 'thematicBreak':
|
||||
case 'definition':
|
||||
kind = `${path}:${node.type}`
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
if (kind !== undefined && node.position !== undefined) {
|
||||
positions.push({ kind, label: node.type, startLine: node.position.start.line, endLine: node.position.end.line })
|
||||
return
|
||||
}
|
||||
if ('children' in node) for (const [index, child] of node.children.entries()) visit(child, `${path}.${index}`)
|
||||
}
|
||||
visit(parseTranslationMarkdown(markdown), 'root')
|
||||
positions.sort((left, right) => left.startLine - right.startLine)
|
||||
const lines = linesOf(markdown)
|
||||
return positions.map((position, index) => ({
|
||||
index,
|
||||
...position,
|
||||
text: sliceLines(lines, position.startLine, position.endLine),
|
||||
}))
|
||||
}
|
||||
|
||||
/**
|
||||
* List a document's heading-delimited sections, including a leading
|
||||
* `preamble` span when content precedes the first heading.
|
||||
*
|
||||
* @param markdown - Document text.
|
||||
* @returns Sections in document order.
|
||||
*/
|
||||
export function sectionSpans(markdown: string): MarkdownSpan[] {
|
||||
const headings: Array<{ depth: number; line: number; label: string }> = []
|
||||
const visit = (node: Nodes): void => {
|
||||
if (node.type === 'heading' && node.position !== undefined) {
|
||||
let label = ''
|
||||
const collect = (child: Nodes): void => {
|
||||
if ('value' in child && typeof child.value === 'string') label += child.value
|
||||
if ('children' in child) for (const grandchild of child.children) collect(grandchild)
|
||||
}
|
||||
for (const child of node.children) collect(child)
|
||||
headings.push({ depth: node.depth, line: node.position.start.line, label })
|
||||
}
|
||||
if ('children' in node) for (const child of node.children) visit(child)
|
||||
}
|
||||
visit(parseTranslationMarkdown(markdown))
|
||||
headings.sort((left, right) => left.line - right.line)
|
||||
const lines = linesOf(markdown)
|
||||
const spans: MarkdownSpan[] = []
|
||||
const firstHeadingLine = headings[0]?.line ?? lines.length + 1
|
||||
if (firstHeadingLine > 1) {
|
||||
spans.push({ index: 0, kind: 'preamble', label: '(preamble before the first heading)', startLine: 1, endLine: firstHeadingLine - 1, text: sliceLines(lines, 1, firstHeadingLine - 1) })
|
||||
}
|
||||
for (const [order, heading] of headings.entries()) {
|
||||
const endLine = (headings[order + 1]?.line ?? lines.length + 1) - 1
|
||||
spans.push({
|
||||
index: spans.length,
|
||||
// Depth only: heading TEXT is translated across a pair, so it cannot
|
||||
// participate in cross-language alignment.
|
||||
kind: `section:${heading.depth}`,
|
||||
label: heading.label === '' ? '(untitled section)' : heading.label,
|
||||
startLine: heading.line,
|
||||
endLine,
|
||||
text: sliceLines(lines, heading.line, endLine),
|
||||
})
|
||||
}
|
||||
return spans
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether two span lists map one to one: same non-zero length and the same
|
||||
* kind at every position.
|
||||
*
|
||||
* @param left - One document's spans.
|
||||
* @param right - The other document's spans.
|
||||
* @returns True when index-wise mapping is sound.
|
||||
*/
|
||||
export function spansAligned(left: MarkdownSpan[], right: MarkdownSpan[]): boolean {
|
||||
return left.length > 0
|
||||
&& left.length === right.length
|
||||
&& left.every((span, index) => span.kind === right[index]?.kind)
|
||||
}
|
||||
|
||||
/**
|
||||
* Indices whose text differs between two aligned span lists.
|
||||
*
|
||||
* @param before - Spans of the earlier state.
|
||||
* @param after - Spans of the later state, aligned with `before`.
|
||||
* @returns Ascending changed indices.
|
||||
*/
|
||||
export function changedSpanIndices(before: MarkdownSpan[], after: MarkdownSpan[]): number[] {
|
||||
return before.filter((span, index) => span.text !== after[index]?.text).map(span => span.index)
|
||||
}
|
||||
|
||||
function codeSpansOf(markdown: string): MarkdownSpan[] {
|
||||
return markdownUnits(markdown).filter(span => span.kind.endsWith(':code'))
|
||||
.map((span, index) => ({ ...span, index }))
|
||||
}
|
||||
|
||||
function replaceSpanTexts(markdown: string, spans: MarkdownSpan[], replacements: Map<number, string>): string {
|
||||
const lines = linesOf(markdown)
|
||||
for (const [index, replacement] of [...replacements.entries()].sort((left, right) => right[0] - left[0])) {
|
||||
const span = spans[index]
|
||||
if (span === undefined) throw new Error(`translation brief: unknown replacement span ${index}`)
|
||||
lines.splice(span.startLine - 1, span.endLine - span.startLine + 1, ...linesOf(replacement))
|
||||
}
|
||||
return `${lines.join('\n')}\n`
|
||||
}
|
||||
|
||||
function maskCodeSpans(markdown: string, spans: MarkdownSpan[]): string {
|
||||
return replaceSpanTexts(markdown, spans, new Map(spans.map(span => [span.index, `DSH_TRANSLATION_CODE_${span.index}\n`])))
|
||||
}
|
||||
|
||||
/**
|
||||
* Compute the counterpart update for a change confined to fenced code
|
||||
* blocks. Fences are byte-identical across a pair, so when the source's
|
||||
* prose is untouched and the counterpart's fences match the last-confirmed
|
||||
* source, splicing the edited fences into the counterpart is the complete
|
||||
* update — no translation judgment is involved.
|
||||
*
|
||||
* @param confirmedSource - The changed side's last-confirmed text.
|
||||
* @param currentSource - The changed side's current text.
|
||||
* @param counterpart - The other side's current text.
|
||||
* @returns The updated counterpart, or undefined when the change is not code-only.
|
||||
*/
|
||||
export function computeMechanicalUpdate(confirmedSource: string, currentSource: string, counterpart: string): string | undefined {
|
||||
const confirmed = codeSpansOf(confirmedSource)
|
||||
const current = codeSpansOf(currentSource)
|
||||
const target = codeSpansOf(counterpart)
|
||||
if (confirmed.length === 0 || confirmed.length !== current.length || confirmed.length !== target.length) return undefined
|
||||
if (maskCodeSpans(confirmedSource, confirmed) !== maskCodeSpans(currentSource, current)) return undefined
|
||||
if (confirmed.some((span, index) => span.text !== target[index]?.text)) return undefined
|
||||
const changed = current.filter((span, index) => span.text !== confirmed[index]?.text)
|
||||
if (changed.length === 0) return undefined
|
||||
return replaceSpanTexts(counterpart, target, new Map(changed.map(span => [span.index, span.text])))
|
||||
}
|
||||
|
||||
/** One parsed terminology-table data row. */
|
||||
export interface TerminologyRow {
|
||||
english: string
|
||||
chinese: string
|
||||
/** The 首次出现 cell (first-occurrence rendering), possibly empty. */
|
||||
first: string
|
||||
/** The verbatim table row. */
|
||||
line: string
|
||||
}
|
||||
|
||||
/** Strip Markdown emphasis and code markers from a terminology cell. */
|
||||
function plainTerm(cell: string): string {
|
||||
return cell.replaceAll('`', '').replaceAll('**', '').trim()
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the data rows of the terminology table.
|
||||
*
|
||||
* @param terminology - Full `docs/i18n/terminology.md` contents.
|
||||
* @returns Rows in table order.
|
||||
*/
|
||||
export function parseTerminologyRows(terminology: string): TerminologyRow[] {
|
||||
const rows: TerminologyRow[] = []
|
||||
for (const line of terminology.split('\n')) {
|
||||
if (!line.startsWith('|')) continue
|
||||
if (/^\|[\s:|-]+\|$/.test(line)) continue
|
||||
const cells = line.split('|').map(cell => cell.trim())
|
||||
const english = plainTerm(cells[1] ?? '')
|
||||
if (english === '' || english === 'English') continue
|
||||
rows.push({ english, chinese: plainTerm(cells[2] ?? ''), first: plainTerm(cells[3] ?? ''), line })
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
/**
|
||||
* Character offsets of a term's occurrences. English word-like terms match
|
||||
* on word boundaries and accept plural inflections (`agents`, `registries`);
|
||||
* other terms match as case-insensitive substrings.
|
||||
*
|
||||
* @param text - Text to search.
|
||||
* @param term - The term to find.
|
||||
* @param englishInflections - Whether to accept English plural forms.
|
||||
* @returns Ascending match offsets.
|
||||
*/
|
||||
export function termOffsets(text: string, term: string, englishInflections = false): number[] {
|
||||
if (term === '') return []
|
||||
const escape = (value: string): string => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
||||
const wordLike = /^[A-Za-z0-9][A-Za-z0-9 ._-]*[A-Za-z0-9]$/.test(term)
|
||||
const inflected = englishInflections && wordLike
|
||||
? /[^aeiou]y$/i.test(term)
|
||||
? `${escape(term.slice(0, -1))}(?:y|ies)`
|
||||
: `${escape(term)}(?:s|es)?`
|
||||
: escape(term)
|
||||
const expression = new RegExp(wordLike ? `(?<![A-Za-z0-9_])${inflected}(?![A-Za-z0-9_])` : inflected, 'gi')
|
||||
return [...text.matchAll(expression)].map(match => match.index)
|
||||
}
|
||||
|
||||
/** The two update directions a pair supports. */
|
||||
export type BriefDirection = 'en-to-zh' | 'zh-to-en'
|
||||
|
||||
/** Whether a row's source-language term occurs in the given text. */
|
||||
function rowOccurs(row: TerminologyRow, direction: BriefDirection, text: string): boolean {
|
||||
const terms = direction === 'en-to-zh' ? [row.english] : [row.first, row.chinese].filter(term => /[一-鿿]/.test(term))
|
||||
return terms.some(term => termOffsets(text, term, direction === 'en-to-zh').length > 0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Select the terminology rows whose source-language term occurs in the
|
||||
* changed text (old and new states combined).
|
||||
*
|
||||
* @param terminology - Full `docs/i18n/terminology.md` contents.
|
||||
* @param direction - Update direction; decides which columns to match.
|
||||
* @param changedText - Concatenated old and new text of the changed spans.
|
||||
* @returns Matched rows in table order.
|
||||
*/
|
||||
export function relevantTerminologyRows(terminology: string, direction: BriefDirection, changedText: string): TerminologyRow[] {
|
||||
return parseTerminologyRows(terminology).filter(row => rowOccurs(row, direction, changedText))
|
||||
}
|
||||
|
||||
function lineAtOffset(text: string, offset: number): number {
|
||||
return text.slice(0, offset).split('\n').length
|
||||
}
|
||||
|
||||
function spanIndexAtOffset(text: string, spans: MarkdownSpan[], offset: number | undefined): number | undefined {
|
||||
if (offset === undefined) return undefined
|
||||
const line = lineAtOffset(text, offset)
|
||||
return spans.find(span => line >= span.startLine && line <= span.endLine)?.index
|
||||
}
|
||||
|
||||
/** First-occurrence guidance computed for a Chinese-target update. */
|
||||
export interface FirstOccurrenceContext {
|
||||
/** Human-readable notes for the briefing. */
|
||||
notes: string[]
|
||||
/** Unchanged span indices that must join the briefing because a first occurrence moved into or out of them. */
|
||||
extraSpanIndices: number[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Track document-wide first occurrences of the relevant English terms. The
|
||||
* 首次出现 rendering attaches to a term's first occurrence, so when an edit
|
||||
* moves that occurrence across spans, both the old and new spans need
|
||||
* counterpart edits even when only one of them changed.
|
||||
*
|
||||
* @param confirmedSource - Last-confirmed English text.
|
||||
* @param currentSource - Current English text.
|
||||
* @param confirmedSpans - Spans of the last-confirmed English text.
|
||||
* @param currentSpans - Spans of the current English text, aligned with `confirmedSpans`.
|
||||
* @param rows - The relevant terminology rows.
|
||||
* @param changed - Span indices already in the briefing.
|
||||
* @returns Notes and extra span indices to include.
|
||||
*/
|
||||
export function firstOccurrenceContext(
|
||||
confirmedSource: string,
|
||||
currentSource: string,
|
||||
confirmedSpans: MarkdownSpan[],
|
||||
currentSpans: MarkdownSpan[],
|
||||
rows: TerminologyRow[],
|
||||
changed: Set<number>,
|
||||
): FirstOccurrenceContext {
|
||||
const notes: string[] = []
|
||||
const extra = new Set<number>()
|
||||
for (const row of rows) {
|
||||
if (row.first === '') continue
|
||||
const oldIndex = spanIndexAtOffset(confirmedSource, confirmedSpans, termOffsets(confirmedSource, row.english, true)[0])
|
||||
const newIndex = spanIndexAtOffset(currentSource, currentSpans, termOffsets(currentSource, row.english, true)[0])
|
||||
if (oldIndex === newIndex) continue
|
||||
for (const index of [oldIndex, newIndex]) {
|
||||
if (index !== undefined && !changed.has(index)) extra.add(index)
|
||||
}
|
||||
notes.push(`${row.english}: the document-wide first occurrence moved from ${oldIndex === undefined ? 'absent' : `#${oldIndex}`} to ${newIndex === undefined ? 'absent' : `#${newIndex}`}; the ${row.first} form moves with it (later occurrences drop the annotation).`)
|
||||
}
|
||||
return { notes, extraSpanIndices: [...extra].sort((left, right) => left - right) }
|
||||
}
|
||||
|
||||
/** Smallest fence of `mark` characters that safely wraps `body`. */
|
||||
function fenceFor(body: string, mark: '`' | '~'): string {
|
||||
let longest = 2
|
||||
for (const line of body.split('\n')) {
|
||||
const run = new RegExp(`^\\s*(${mark === '`' ? '`' : '~'}{3,})`).exec(line)
|
||||
if (run?.[1] !== undefined && run[1].length > longest) longest = run[1].length
|
||||
}
|
||||
return mark.repeat(longest + 1)
|
||||
}
|
||||
|
||||
/** One changed (or first-occurrence) span with its three-way context. */
|
||||
export interface BriefBundle {
|
||||
/** Span index shared by the aligned documents. */
|
||||
index: number
|
||||
/** Human label: heading text or node type. */
|
||||
label: string
|
||||
/** Why the bundle is present when its source text did not change. */
|
||||
reason?: 'first-occurrence' | undefined
|
||||
confirmedSourceText: string
|
||||
currentSourceText: string
|
||||
counterpartText: string
|
||||
/** 1-based line the counterpart span starts on. */
|
||||
counterpartStartLine: number
|
||||
}
|
||||
|
||||
/** The granularities a briefing can map the change at, narrowest first. */
|
||||
export type BriefScope =
|
||||
| { kind: 'mechanical' }
|
||||
| { kind: 'units'; bundles: BriefBundle[]; firstOccurrenceNotes: string[] }
|
||||
| { kind: 'sections'; bundles: BriefBundle[]; firstOccurrenceNotes: string[] }
|
||||
| { kind: 'document'; reason: string }
|
||||
|
||||
/** Inputs for rendering one pair's briefing. */
|
||||
export interface TranslationBriefInput {
|
||||
/** Repo-relative path of the side that changed. */
|
||||
sourcePath: string
|
||||
/** Repo-relative path of the counterpart to update. */
|
||||
counterpartPath: string
|
||||
direction: BriefDirection
|
||||
/** Unified diff of the changed side, last-confirmed to current. */
|
||||
diff: string
|
||||
scope: BriefScope
|
||||
terminology: TerminologyRow[]
|
||||
}
|
||||
|
||||
const ZH_TARGET_DIGEST = [
|
||||
'- Edit ONLY what the change requires; preserve the reviewed phrasing of everything unchanged.',
|
||||
'- Nothing added, nothing dropped: the Chinese must state exactly what the new English states.',
|
||||
'- Write natural institutional technical Chinese, not word-by-word gloss; terse stays terse.',
|
||||
'- Code fences byte-identical to the English side, comments included; inline code spans verbatim.',
|
||||
'- Relative links keep the `.md` target; only the switcher line links `.zh.md`.',
|
||||
'- Structure mirrors the counterpart: heading depths and order, list kinds and item counts, table rows and columns.',
|
||||
'- 首次出现 annotations attach to the document-wide first occurrence only; later occurrences use the bare form, and an empty 首次出现 cell means never gloss.',
|
||||
'- Typography: one half-width space between Chinese and Latin or digits; full-width punctuation in Chinese prose; 顿号 for enumerations; second person is 你.',
|
||||
'- One physical line per paragraph; exactly one trailing newline.',
|
||||
]
|
||||
|
||||
const EN_TARGET_DIGEST = [
|
||||
'- Edit ONLY what the change requires; preserve the reviewed phrasing of everything unchanged.',
|
||||
'- Nothing added, nothing dropped: the English must state exactly what the new Chinese states.',
|
||||
'- Write concise professional developer prose, not word-by-word gloss; terse stays terse.',
|
||||
'- Code fences byte-identical to the Chinese side, comments included; inline code spans verbatim.',
|
||||
'- Relative links keep the `.md` target; only the switcher line links `.zh.md`.',
|
||||
'- Structure mirrors the counterpart: heading depths and order, list kinds and item counts, table rows and columns.',
|
||||
'- One physical line per paragraph; exactly one trailing newline.',
|
||||
]
|
||||
|
||||
function renderBundles(out: string[], input: TranslationBriefInput, bundles: BriefBundle[], firstOccurrenceNotes: string[]): void {
|
||||
const sourceLanguage = input.direction === 'en-to-zh' ? 'English' : 'Chinese'
|
||||
const counterpartLanguage = input.direction === 'en-to-zh' ? 'Chinese' : 'English'
|
||||
for (const bundle of bundles) {
|
||||
out.push('')
|
||||
out.push(`### #${bundle.index} ${bundle.label}${bundle.reason === 'first-occurrence' ? ' — unchanged; included for a first-occurrence move' : ''} — counterpart at ${input.counterpartPath}:${bundle.counterpartStartLine}`)
|
||||
const fence = fenceFor([bundle.confirmedSourceText, bundle.currentSourceText, bundle.counterpartText].join('\n'), '~')
|
||||
if (bundle.confirmedSourceText !== bundle.currentSourceText) {
|
||||
out.push('')
|
||||
out.push(`Last-confirmed ${sourceLanguage}:`)
|
||||
out.push('')
|
||||
out.push(`${fence}markdown`)
|
||||
out.push(bundle.confirmedSourceText.trimEnd())
|
||||
out.push(fence)
|
||||
}
|
||||
out.push('')
|
||||
out.push(`Current ${sourceLanguage}:`)
|
||||
out.push('')
|
||||
out.push(`${fence}markdown`)
|
||||
out.push(bundle.currentSourceText.trimEnd())
|
||||
out.push(fence)
|
||||
out.push('')
|
||||
out.push(`Current ${counterpartLanguage} (bring this along):`)
|
||||
out.push('')
|
||||
out.push(`${fence}markdown`)
|
||||
out.push(bundle.counterpartText.trimEnd())
|
||||
out.push(fence)
|
||||
}
|
||||
if (firstOccurrenceNotes.length > 0) {
|
||||
out.push('')
|
||||
out.push('## First-occurrence notes')
|
||||
out.push('')
|
||||
for (const note of firstOccurrenceNotes) out.push(`- ${note}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the complete briefing for one out-of-sync pair.
|
||||
*
|
||||
* @param input - Diff, mapped scope, terminology, and pair identity.
|
||||
* @returns Markdown briefing text.
|
||||
*/
|
||||
export function renderTranslationBrief(input: TranslationBriefInput): string {
|
||||
const sourceLanguage = input.direction === 'en-to-zh' ? 'English' : 'Chinese'
|
||||
const counterpartLanguage = input.direction === 'en-to-zh' ? 'Chinese' : 'English'
|
||||
const out: string[] = []
|
||||
out.push(`# Translation update briefing: ${input.sourcePath}`)
|
||||
out.push('')
|
||||
out.push(`The ${sourceLanguage} side changed; bring \`${input.counterpartPath}\` along with the smallest edit that covers the change.`)
|
||||
if (input.scope.kind === 'mechanical') {
|
||||
out.push('')
|
||||
out.push('## Mechanical update — no translation judgment involved')
|
||||
out.push('')
|
||||
out.push(`Every change since the last confirmed state is inside fenced code blocks, which are byte-identical across the pair. Run \`pnpm run gen-translation-brief --apply ${input.sourcePath}\` to splice the updated fences into the counterpart (the result is structure-validated before writing), then record per the Finish steps.`)
|
||||
}
|
||||
out.push('')
|
||||
out.push(`## ${sourceLanguage} diff (last-confirmed → current)`)
|
||||
out.push('')
|
||||
const diffFence = fenceFor(input.diff, '`')
|
||||
out.push(`${diffFence}diff`)
|
||||
out.push(input.diff.trimEnd())
|
||||
out.push(diffFence)
|
||||
switch (input.scope.kind) {
|
||||
case 'mechanical':
|
||||
break
|
||||
case 'units':
|
||||
out.push('')
|
||||
out.push(`## Changed units (last-confirmed ${sourceLanguage} → current ${sourceLanguage}, with the current ${counterpartLanguage})`)
|
||||
renderBundles(out, input, input.scope.bundles, input.scope.firstOccurrenceNotes)
|
||||
break
|
||||
case 'sections':
|
||||
out.push('')
|
||||
out.push('## Changed sections (fine-grained units do not align across the pair; whole heading sections shown)')
|
||||
renderBundles(out, input, input.scope.bundles, input.scope.firstOccurrenceNotes)
|
||||
break
|
||||
case 'document':
|
||||
out.push('')
|
||||
out.push('## Whole-document update required')
|
||||
out.push('')
|
||||
out.push(`${input.scope.reason} Open \`${input.counterpartPath}\` directly, locate the affected regions yourself, and reconcile under docs/i18n/translation-rules.md.`)
|
||||
break
|
||||
default:
|
||||
input.scope satisfies never
|
||||
}
|
||||
if (input.terminology.length > 0) {
|
||||
out.push('')
|
||||
out.push('## Binding terminology rows matching this change (docs/i18n/terminology.md)')
|
||||
out.push('')
|
||||
out.push('| English | 中文 | 首次出现 | 不要译作 | 备注 |')
|
||||
out.push('|---|---|---|---|---|')
|
||||
for (const row of input.terminology) out.push(row.line)
|
||||
out.push('')
|
||||
out.push('For any term you introduce that is not listed above, consult the full table before inventing a rendering.')
|
||||
}
|
||||
out.push('')
|
||||
out.push('## Rules digest (full rules: docs/i18n/translation-rules.md)')
|
||||
out.push('')
|
||||
out.push(...(input.direction === 'en-to-zh' ? ZH_TARGET_DIGEST : EN_TARGET_DIGEST))
|
||||
out.push('')
|
||||
out.push('## Finish')
|
||||
out.push('')
|
||||
out.push('1. Apply the smallest counterpart edit that covers the change, then verify the changed spans clause by clause against the source.')
|
||||
out.push(`2. \`pnpm run verify-translation-pairing --write ${input.sourcePath.replace(/\.zh\.md$/, '.md')}\``)
|
||||
out.push(`3. \`pnpm run verify-translation-pairing ${input.sourcePath.replace(/\.zh\.md$/, '.md')}\``)
|
||||
out.push('')
|
||||
return out.join('\n')
|
||||
}
|
||||
@@ -3,7 +3,9 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import {
|
||||
isTranslationScopeFile,
|
||||
pairAnchorOfArgument,
|
||||
parseTranslationMarkdown,
|
||||
parseTranslationPairingCliArgs,
|
||||
parseTranslationPairingManifest,
|
||||
translationStructureDiff,
|
||||
translationStructureSignature,
|
||||
@@ -102,3 +104,40 @@ describe('translation structural signature', () => {
|
||||
])
|
||||
})
|
||||
})
|
||||
|
||||
describe('pair CLI arguments', () => {
|
||||
it('normalizes any pair file or bare stem to the English anchor', () => {
|
||||
expect(pairAnchorOfArgument('docs/foo.md')).toBe('docs/foo.md')
|
||||
expect(pairAnchorOfArgument('docs/foo.zh.md')).toBe('docs/foo.md')
|
||||
expect(pairAnchorOfArgument('docs/foo.i18n.yaml')).toBe('docs/foo.md')
|
||||
expect(pairAnchorOfArgument('docs/foo')).toBe('docs/foo.md')
|
||||
expect(pairAnchorOfArgument('.\\docs\\foo.zh.md')).toBe('docs/foo.md')
|
||||
})
|
||||
|
||||
it('scopes a check to named pairs and dedupes the three spellings', () => {
|
||||
expect(parseTranslationPairingCliArgs(['docs/foo.zh.md', 'docs/foo.i18n.yaml', 'docs/bar.md'])).toEqual({
|
||||
mode: 'check',
|
||||
scope: 'pairs',
|
||||
anchors: ['docs/bar.md', 'docs/foo.md'],
|
||||
})
|
||||
expect(parseTranslationPairingCliArgs([])).toEqual({ mode: 'check', scope: 'corpus', anchors: [] })
|
||||
})
|
||||
|
||||
it('requires --write to name confirmed pairs or opt into --all', () => {
|
||||
expect(() => parseTranslationPairingCliArgs(['--write'])).toThrow('requires the pair(s) you confirmed')
|
||||
expect(parseTranslationPairingCliArgs(['--write', 'docs/foo.md'])).toEqual({
|
||||
mode: 'write',
|
||||
scope: 'pairs',
|
||||
anchors: ['docs/foo.md'],
|
||||
})
|
||||
expect(parseTranslationPairingCliArgs(['--write', '--all'])).toEqual({ mode: 'write', scope: 'corpus', anchors: [] })
|
||||
expect(() => parseTranslationPairingCliArgs(['--write', '--all', 'docs/foo.md'])).toThrow('not both')
|
||||
})
|
||||
|
||||
it('keeps --list corpus-only and rejects unknown flags', () => {
|
||||
expect(parseTranslationPairingCliArgs(['--list'])).toEqual({ mode: 'list', scope: 'corpus', anchors: [] })
|
||||
expect(() => parseTranslationPairingCliArgs(['--list', 'docs/foo.md'])).toThrow('takes no other flags or paths')
|
||||
expect(() => parseTranslationPairingCliArgs(['--all'])).toThrow('--all only applies to --write')
|
||||
expect(() => parseTranslationPairingCliArgs(['--frobnicate'])).toThrow('unknown flag(s): --frobnicate')
|
||||
})
|
||||
})
|
||||
|
||||
@@ -102,6 +102,66 @@ export function parseTranslationPairingManifest(content: string): TranslationPai
|
||||
return { excluded: excludedField(record) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize one CLI pair argument to its English anchor path: any of the
|
||||
* pair's three files (`foo.md`, `foo.zh.md`, `foo.i18n.yaml`) or the bare
|
||||
* `foo` stem names the same pair, and platform separators are accepted.
|
||||
*
|
||||
* @param argument - Repo-relative path as passed on a command line.
|
||||
* @returns The pair's `foo.md` anchor path with `/` separators.
|
||||
*/
|
||||
export function pairAnchorOfArgument(argument: string): string {
|
||||
const normalized = argument.split('\\').join('/').replace(/^\.\//, '')
|
||||
if (normalized.endsWith('.zh.md')) return `${normalized.slice(0, -'.zh.md'.length)}.md`
|
||||
if (normalized.endsWith('.i18n.yaml')) return `${normalized.slice(0, -'.i18n.yaml'.length)}.md`
|
||||
if (normalized.endsWith('.md')) return normalized
|
||||
return `${normalized}.md`
|
||||
}
|
||||
|
||||
/** A parsed `verify-translation-pairing` invocation. */
|
||||
export interface TranslationPairingCliRequest {
|
||||
mode: 'check' | 'list' | 'write'
|
||||
/** `corpus` runs discovery over the whole tree; `pairs` touches only the named anchors. */
|
||||
scope: 'corpus' | 'pairs'
|
||||
/** English anchor paths, empty for corpus scope. */
|
||||
anchors: string[]
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse and validate `verify-translation-pairing` CLI arguments.
|
||||
*
|
||||
* Check accepts optional pair paths; `--write` requires either pair paths or
|
||||
* `--all` so a bulk re-record is always an explicit choice — a bare
|
||||
* `--write` would silently bless every drifted pair in the tree, including
|
||||
* ones the caller never confirmed. `--list` is corpus-only.
|
||||
*
|
||||
* @param argv - Arguments after the script name.
|
||||
* @returns The validated request.
|
||||
* @throws Error when flags or their combination are invalid.
|
||||
*/
|
||||
export function parseTranslationPairingCliArgs(argv: string[]): TranslationPairingCliRequest {
|
||||
const flags = argv.filter(argument => argument.startsWith('--'))
|
||||
const anchors = [...new Set(argv.filter(argument => !argument.startsWith('--')).map(pairAnchorOfArgument))].sort()
|
||||
const unknown = flags.filter(flag => !['--list', '--write', '--all'].includes(flag))
|
||||
if (unknown.length > 0) throw new Error(`unknown flag(s): ${unknown.join(', ')}`)
|
||||
const listMode = flags.includes('--list')
|
||||
const writeMode = flags.includes('--write')
|
||||
const allMode = flags.includes('--all')
|
||||
if (listMode && (writeMode || allMode || anchors.length > 0)) {
|
||||
throw new Error('--list reports the whole corpus and takes no other flags or paths')
|
||||
}
|
||||
if (allMode && !writeMode) throw new Error('--all only applies to --write')
|
||||
if (writeMode) {
|
||||
if (anchors.length > 0 && allMode) throw new Error('--write takes either pair paths or --all, not both')
|
||||
if (anchors.length === 0 && !allMode) {
|
||||
throw new Error('--write requires the pair(s) you confirmed (any file of a pair), or --all to re-record every complete pair; recording pairs you did not review blesses unconfirmed content')
|
||||
}
|
||||
return { mode: 'write', scope: allMode ? 'corpus' : 'pairs', anchors }
|
||||
}
|
||||
if (listMode) return { mode: 'list', scope: 'corpus', anchors: [] }
|
||||
return { mode: 'check', scope: anchors.length > 0 ? 'pairs' : 'corpus', anchors }
|
||||
}
|
||||
|
||||
/** The structural surface compared between the two sides of a pair. */
|
||||
export interface TranslationStructureSignature {
|
||||
/** Heading depths in document order (h2 -> 2). */
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
* Enforce complete English/Chinese pairs, matching structure, and recorded git
|
||||
* blob hashes for every in-scope document. The manifest contains only explicit
|
||||
* exclusions, which may have neither a counterpart nor a sidecar.
|
||||
* `--list` reports state and `--write` records both sides after human review.
|
||||
* Translation quality remains a review responsibility.
|
||||
* `--list` reports state; `--write <pairs...>` records the named confirmed
|
||||
* pairs (`--write --all` records every complete pair); a check or write named
|
||||
* with pair paths touches only those pairs, so update iteration does not pay
|
||||
* for a corpus scan. Translation quality remains a review responsibility.
|
||||
* See `docs/i18n/README.md` for the owning contract.
|
||||
*/
|
||||
|
||||
@@ -13,6 +15,7 @@ import { basename, join, resolve, sep } from 'node:path'
|
||||
import {
|
||||
linksTo,
|
||||
parseTranslationMarkdown,
|
||||
parseTranslationPairingCliArgs,
|
||||
parseTranslationPairingManifest,
|
||||
isTranslationScopeFile,
|
||||
TRANSLATION_SCOPE_GLOB_EXCLUDES,
|
||||
@@ -21,8 +24,15 @@ import {
|
||||
} from './translation-pairing.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const listMode = process.argv.includes('--list')
|
||||
const writeMode = process.argv.includes('--write')
|
||||
let request: ReturnType<typeof parseTranslationPairingCliArgs>
|
||||
try {
|
||||
request = parseTranslationPairingCliArgs(process.argv.slice(2))
|
||||
} catch (error) {
|
||||
console.error(`verify-translation-pairing: ${error instanceof Error ? error.message : String(error)}`)
|
||||
process.exit(2)
|
||||
}
|
||||
const listMode = request.mode === 'list'
|
||||
const writeMode = request.mode === 'write'
|
||||
|
||||
/** Discover source Markdown and pairing sidecars before applying the corpus predicate. */
|
||||
const SCOPE_PATTERNS = [
|
||||
@@ -77,32 +87,67 @@ function renderMeta(source: string, sourceHash: string, zh: string, zhHash: stri
|
||||
'# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each',
|
||||
'# 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:',
|
||||
'# pnpm run verify-translation-pairing --write',
|
||||
`# pnpm run verify-translation-pairing --write ${source}`,
|
||||
`${basename(source)}: ${sourceHash}`,
|
||||
`${basename(zh)}: ${zhHash}`,
|
||||
'',
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
// Enumerate the scope once.
|
||||
// Enumerate the scope once: the whole corpus, or exactly the named pairs'
|
||||
// three files (a named pair whose files are absent is caught by the same
|
||||
// completeness rules that cover discovered remnants).
|
||||
const files = new Set<string>()
|
||||
for (const pattern of SCOPE_PATTERNS) {
|
||||
for (const match of globSync(pattern, { cwd: root, exclude: TRANSLATION_SCOPE_GLOB_EXCLUDES })) {
|
||||
const normalized = match.split(sep).join('/')
|
||||
if (isTranslationScopeFile(normalized)) files.add(normalized)
|
||||
if (request.scope === 'pairs') {
|
||||
for (const anchor of request.anchors) {
|
||||
for (const file of [anchor, ...Object.values(pairPaths(anchor))]) {
|
||||
if (existsSync(join(root, file))) files.add(file)
|
||||
}
|
||||
// A named anchor with no files on disk still enters the source list so
|
||||
// the check reports it instead of silently passing an empty scope.
|
||||
if (!existsSync(join(root, anchor))) files.add(anchor)
|
||||
}
|
||||
} else {
|
||||
for (const pattern of SCOPE_PATTERNS) {
|
||||
for (const match of globSync(pattern, { cwd: root, exclude: TRANSLATION_SCOPE_GLOB_EXCLUDES })) {
|
||||
const normalized = match.split(sep).join('/')
|
||||
if (isTranslationScopeFile(normalized)) files.add(normalized)
|
||||
}
|
||||
}
|
||||
}
|
||||
const translations = [...files].filter(f => f.endsWith('.zh.md')).sort()
|
||||
const metas = [...files].filter(f => f.endsWith('.i18n.yaml')).sort()
|
||||
const sources = [...files].filter(f => f.endsWith('.md') && !f.endsWith('.zh.md')).sort()
|
||||
|
||||
// --write: (re)record both hashes for every complete pair, creating missing records.
|
||||
if (request.scope === 'pairs') {
|
||||
const rejected = request.anchors.filter(anchor => !isTranslationScopeFile(anchor) || isExcluded(anchor))
|
||||
const absent = request.anchors.filter(anchor => ![anchor, ...Object.values(pairPaths(anchor))].some(file => existsSync(join(root, file))))
|
||||
if (rejected.length > 0 || absent.length > 0) {
|
||||
for (const anchor of rejected) {
|
||||
console.error(`verify-translation-pairing: ${anchor} is not an in-scope pair (excluded or outside the documentation corpus; see docs/i18n/README.md)`)
|
||||
}
|
||||
for (const anchor of absent) {
|
||||
console.error(`verify-translation-pairing: ${anchor} names no pair on disk (none of its three files exist)`)
|
||||
}
|
||||
process.exit(2)
|
||||
}
|
||||
}
|
||||
|
||||
// --write: (re)record both hashes for the requested complete pairs, creating
|
||||
// missing records. A named pair that cannot be recorded (missing counterpart)
|
||||
// fails loud; corpus scope (--all) skips pairless sources as before.
|
||||
if (writeMode) {
|
||||
let written = 0
|
||||
for (const source of sources) {
|
||||
if (isExcluded(source)) continue
|
||||
const { zh, meta } = pairPaths(source)
|
||||
if (!existsSync(join(root, zh))) continue
|
||||
if (!existsSync(join(root, source)) || !existsSync(join(root, zh))) {
|
||||
if (request.scope === 'pairs') {
|
||||
console.error(`verify-translation-pairing: cannot record ${source}: missing ${existsSync(join(root, source)) ? zh : source}`)
|
||||
process.exit(2)
|
||||
}
|
||||
continue
|
||||
}
|
||||
const record = renderMeta(source, blobHash(readFileSync(join(root, source))), zh, blobHash(readFileSync(join(root, zh))))
|
||||
if (existsSync(join(root, meta)) && readFileSync(join(root, meta), 'utf8') === record) continue
|
||||
writeFileSync(join(root, meta), record)
|
||||
@@ -204,7 +249,9 @@ if (listMode) {
|
||||
}
|
||||
|
||||
if (errors.length === 0) {
|
||||
console.log(`verify-translation-pairing: ${pairAnchors.size} pair(s) checked across all in-scope documentation, all consistent.`)
|
||||
console.log(request.scope === 'pairs'
|
||||
? `verify-translation-pairing: ${pairAnchors.size} named pair(s) consistent; the corpus-wide check still runs in doc-sync.`
|
||||
: `verify-translation-pairing: ${pairAnchors.size} pair(s) checked across all in-scope documentation, all consistent.`)
|
||||
process.exit(0)
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user