From 53283003e968f46c355a3fc14b9cda0d212a541b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 27 Jul 2026 02:31:07 +0800 Subject: [PATCH] feat(i18n): unit-mapped briefings with mechanical --apply, adopting the #684 planner mechanics MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The briefing now maps each update at the narrowest safely aligned granularity, widening deterministically 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, 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; units that do not align fall back to depth-matched heading sections (depth only, so translated heading text still maps); and when sections do not align either, or both sides drifted, the briefing says so and withholds the mapping. Terminology rows now match the changed spans only, English terms on word boundaries with plural inflections, and Chinese-target briefings track each relevant term's document-wide first occurrence — a moved occurrence pulls the vacated and receiving spans into the briefing with an explanatory note. The unit mapping, mechanical code splice, and first-occurrence tracking adopt the planner design from the incremental prompt-pipeline PR (#684), whose provider-backed bake-off independently validated the same scope ladder; this PR carries those mechanics into the agent-facing briefing path so both consumers of the consistency records behave alike. The prior line-hunk section mapping and its heading-text alignment (which could not map cross-language sections) are replaced wholesale. Docs: SKILL.md update path, i18n README pair, development.md pair, and the briefed-updates Agent Note pair brought along; the development.md fence edit was applied with --apply itself, and the prose updates were made through the new unit/section briefings. --- ...efed-minimal-translation-updates.i18n.yaml | 4 +- ...-26-briefed-minimal-translation-updates.md | 11 +- ...-briefed-minimal-translation-updates.zh.md | 13 +- .agents/skills/dsh-translate-docs/SKILL.md | 6 +- docs/development.i18n.yaml | 4 +- docs/development.md | 2 +- docs/development.zh.md | 2 +- docs/i18n/README.i18n.yaml | 4 +- docs/i18n/README.md | 2 +- docs/i18n/README.zh.md | 2 +- scripts/gen-translation-brief.ts | 169 +++++- scripts/translation-brief.spec.ts | 290 ++++++--- scripts/translation-brief.ts | 550 ++++++++++++------ 13 files changed, 748 insertions(+), 311 deletions(-) diff --git a/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.i18n.yaml index 17011b6edc..446eee7619 100644 --- a/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.i18n.yaml @@ -2,5 +2,5 @@ # 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: b155ee7e51a819cdea34051c4d734fbc06a1dca8 -2026-07-26-briefed-minimal-translation-updates.zh.md: 8da0b3c2b62113af47ea58334034feb6c5ee2959 +2026-07-26-briefed-minimal-translation-updates.md: 42baedc8d68557bc0d273c5a476806ac480d4afd +2026-07-26-briefed-minimal-translation-updates.zh.md: 18653fe1097f4028a0671b6d15d1982ad137f47a diff --git a/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md b/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md index b155ee7e51..42baedc8d6 100644 --- a/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md +++ b/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md @@ -12,8 +12,8 @@ The [bilingual pairing contract](2026-07-02-bilingual-docs-and-pairing-gate.md) 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 [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, the counterpart sections that diff lands in with current line numbers (mapped through the heading structure, which the gate proves aligned at the last confirmed state; when both sides drifted or headings do not align, the briefing says so and withholds the mapping instead of guessing), the terminology rows whose terms appear in the changed lines, and a fixed digest of the binding update rules. 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 diffs (changed lines confined to the byte-identical code fences) are applied by the orchestrator directly; prose diffs go to a subagent whose prompt is the briefing, not the corpus; verification is clause-by-clause on the changed hunks, not the whole document. +- **`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 @@ -31,7 +31,7 @@ The decision followed a controlled replay of ten real pair updates from this rep - **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. Heading-level mapping from the existing whole-file hashes recovers the same alignment when it is trustworthy and says so when it is not. +- **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 @@ -40,8 +40,9 @@ The decision followed a controlled replay of ten real pair updates from this rep - 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. -- The section mapping trusts heading alignment only where the gate proved it at the last confirmed state; documents restructured on one side fall back to an explicit "locate the regions yourself" briefing rather than a wrong map. +- 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 diff parsing, section mapping (including preamble and multi-section hunks), terminology row matching in both directions with word-boundary discipline, fence escalation, and the rendered briefing's contract (aligned sections, both-drifted warning, 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 `, `--write --all`, `--list` exclusivity, unknown flags. +[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 `, `--write --all`, `--list` exclusivity, unknown flags. diff --git a/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.zh.md b/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.zh.md index 8da0b3c2b6..18653fe109 100644 --- a/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.zh.md @@ -12,13 +12,13 @@ Status: implemented 配对更新基于生成的简报(briefing)运行,而非基于指导语料;只有新建配对仍走整篇文档工作流,后者保持不变。 -- **`pnpm run gen-translation-brief [pair...]`**([scripts/gen-translation-brief.ts](../../../../scripts/gen-translation-brief.ts),组装逻辑在 [scripts/translation-brief.ts](../../../../scripts/translation-brief.ts))针对每个失去同步的配对打印:被改一侧从其记录在案的上次确认 blob 到当前工作区的 diff;该 diff 落入的对侧章节及其当前行号(经标题结构映射得到;该结构在上次确认状态的对齐已由门禁证明;当两侧同时漂移或标题无法对齐时,简报会明说这一点并省略映射,而不是靠猜);改动行所涉术语对应的术语表行;以及一份固定的约束性更新规则摘要。简报就是译者的全部工作集;简报回答不了的决策,仍以完整的真源文档作为升级求证路径。 -- **[dsh-translate-docs](../../../skills/dsh-translate-docs/SKILL.md) 中的更新路径**消费这份简报:机械类 diff(改动行全部落在逐字节一致的围栏代码块内)由编排 agent(智能体)直接应用;行文类 diff 交给 subagent,其提示词就是简报本身,而非指导语料;核验只对改动块逐句进行,不覆盖整篇文档。 +- **`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 一次处理三对文档的批量方案。产出先经机械门禁把关,再由评委盲评打分;评委还同时收到真实的历史更新与原样未动的陈旧对侧文件作为对照。 +该决策来自对本仓库历史上十次真实配对更新的受控回放(2026 年 7 月;每例改动 1 到 64 行英文,涵盖 README、RFC、Agent Note(agent 决策记录)与用户文档)。每个样例都在临时仓库中重建到其真实的上次确认状态,英文改动保持未提交,再用全新的 subagent 分别跑过相互竞争的各条工作流:维持现状的语料加载路径、简报路径、无指导对照组、整篇重译、小模型上的简报路径,以及每个 agent(智能体)一次处理三对文档的批量方案。产出先经机械门禁把关,再由评委盲评打分;评委还同时收到真实的历史更新与原样未动的陈旧对侧文件作为对照。 - 简报路径在盲评的忠实性、保留度与流畅度上与现状路径打平(两者都达到或超过真实历史更新的水平),而在未发生停滞的样例上只花费约三分之一的 token 用量与墙钟时间(全部十例的中位数:相对 token 成本单位 276k 对 595k,轮次数 14 对 32)。 - 整篇重译被证实有害,而不只是浪费:它丢弃经评审的措辞,盲评保留度因此崩塌(4.4/10 对 9.8);它还使各更新组保持住的既定术语发生漂移(既定译法本就写在对侧文件自身的正文里);而且它是成本最高的一组。 @@ -31,7 +31,7 @@ Status: implemented - **保留原工作流,只让门禁支持按对检查**:门禁扫描本是较小的开销,大头在语料加载与翻查历史。只收窄检查范围,约 3 倍的开销仍会原地保留。 - **把整篇重译作为更新路径**(朴素流水线的做法):依据基准测试证据否决,理由是保留度崩塌、术语漂移、成本最高。契约的最小更新规则得以延续,且从此有数据支撑。 - **每个 subagent 批量处理多对文档**:否决。没有实测出节省(简报本身已对固定内容做了去重),而且一对文档停滞或陷入混乱会把其余配对一并拖住。 -- **在伴随记录中保存逐段的翻译记忆条目**(用分段 hash 取代整文件 hash):否决。配对两侧的段落边界可以合理地不同,任一侧都可能先撰写,这类条目还会不断膨胀并在合并时产生冲突。基于现有整文件 hash 的标题级映射,在对齐可信时能恢复同样的对齐关系,不可信时会明确说明。 +- **在伴随记录中保存逐段的翻译记忆条目**(用分段 hash 取代整文件 hash):否决。配对两侧的段落边界可以合理地不同,任一侧都可能先撰写,这类条目还会不断膨胀并在合并时产生冲突。基于现有整文件 hash 按需计算的区间映射,在对齐可信时能恢复同样的对齐关系,不可信时会明确说明。 - **给自动提示词流水线加一个更新模式(prompt-v5)**:推迟,本文不做设计。今天没有任何调用方在驱动 [scripts/translation-prompt.ts](../../../../scripts/translation-prompt.ts),实际的成本中心是 agent 路径。流水线在拥有消费方之前,维持其整篇文档的 v4 契约。 ## 后果 @@ -40,8 +40,9 @@ Status: implemented - 简报生成器成为一致性记录的第二个消费方:记录的 blob hash 如今还驱动 diff 还原与章节映射,这进一步强化了如实维护记录的动机。 - 不带参数的 `--write` 不再可用;靠肌肉记忆的调用者必须点名配对或传 `--all`。这正是目的所在:批量背书如今是一个可见的、有意为之的动作。 - 按对检查意味着一个更新循环可以在别处某个无关配对处于红灯时自己保持绿灯;`doc-sync`/CI 中的全语料检查仍然承载树级不变式。 -- 章节映射只在门禁已于上次确认状态证明标题对齐的范围内信任这种对齐;在单侧被重构过的文档会回退到一份明确写着「请自行定位相关区域」的简报,而不是拿到一张错误的地图。 +- 区间映射只在上次确认源文、当前源文与当前对侧文本三方的种类序列一致时才信任一处对齐;映射失败时粒度确定性地逐级放宽(单元 → 章节 → 整篇文档)而不是靠猜,因此被重构过的文档拿到的是一份明确写着「请自行定位相关区域」的简报,绝不会是一张错误的地图。 +- 「首次出现」的一次移位可能让简报扩大到直接改动块之外;这一成本是「首次出现」契约的明确后果,而非对齐启发式。 ## 测试 -[scripts/translation-brief.spec.ts](../../../../scripts/translation-brief.spec.ts) 固定 diff 解析、章节映射(含首个标题前的序言与跨多个章节的改动块)、带词边界约束的双向术语行匹配、围栏升级,以及渲染后简报的契约(对齐的章节、两侧同时漂移的警告、分方向的规则摘要、按对的收尾命令)。[scripts/translation-pairing.spec.ts](../../../../scripts/translation-pairing.spec.ts) 固定参数归一化(配对的任一文件或裸词干都归一到锚点)与 CLI(命令行界面)用例矩阵:按对检查、裸 `--write` 拒绝执行、`--write `、`--write --all`、`--list` 的互斥性、未知标志。 +[scripts/translation-brief.spec.ts](../../../../scripts/translation-brief.spec.ts) 固定单元与章节的区间提取(以容器为作用域的种类、只按深度对齐章节从而让已翻译的标题文字仍能映射、首个标题前的序言)、对齐与改动索引检测、机械代码拼接及其每一个拒绝条件、带词边界与复数变形约束的双向术语行匹配、首次出现移位跟踪、围栏升级,以及渲染后简报的契约(带三方上下文的单元条目、机械/章节/整篇文档三种范围、分方向的规则摘要、按对的收尾命令)。[scripts/translation-pairing.spec.ts](../../../../scripts/translation-pairing.spec.ts) 固定参数归一化(配对的任一文件或裸词干都归一到锚点)与 CLI(命令行界面)用例矩阵:按对检查、裸 `--write` 拒绝执行、`--write `、`--write --all`、`--list` 的互斥性、未知标志。 diff --git a/.agents/skills/dsh-translate-docs/SKILL.md b/.agents/skills/dsh-translate-docs/SKILL.md index 7e0d2f41ec..f7ca758514 100644 --- a/.agents/skills/dsh-translate-docs/SKILL.md +++ b/.agents/skills/dsh-translate-docs/SKILL.md @@ -19,9 +19,9 @@ description: Use when creating or updating the bilingual counterpart of a doc in 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 ` (no arguments briefs every out-of-sync pair). The briefing contains the authored side's diff since the last confirmed-consistent state, the counterpart sections that diff lands in (with current line numbers), the terminology rows the diff touches, and a digest of the binding update rules. -2. **Mechanical-only diff? Apply it directly.** If every changed line lies inside code fences that the pair shares byte-identically, the counterpart edit is byte-copying with no translation judgment; the orchestrator applies it without spawning a subagent. -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 and terminology rows are inline, and the counterpart's own surrounding text carries the established renderings) 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 `BOTH sides changed` warning, which always means reconciling by hand under [translation-rules.md](../../../docs/i18n/translation-rules.md). +1. **Generate the briefing**: `pnpm run gen-translation-brief ` (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 ` 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 ` then `pnpm run verify-translation-pairing `. `--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. diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index 31d656cd97..8d19bd9880 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -2,5 +2,5 @@ # 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 docs/development.md -development.md: 4aab6772a514c5c461535f6e37906a503c10215a -development.zh.md: c420c0132d5d945457bad73ae71691d345488150 +development.md: fd7f39ae7b5aac2d44572979ca8c8f1d2df0de6f +development.zh.md: 7dd6209bad75d605e0056d2465a35b08aa091780 diff --git a/docs/development.md b/docs/development.md index 4aab6772a5..fd7f39ae7b 100644 --- a/docs/development.md +++ b/docs/development.md @@ -112,7 +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 +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 diff --git a/docs/development.zh.md b/docs/development.zh.md index c420c0132d..7dd6209bad 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -112,7 +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 +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 diff --git a/docs/i18n/README.i18n.yaml b/docs/i18n/README.i18n.yaml index faee4f30cf..ae5e6b1418 100644 --- a/docs/i18n/README.i18n.yaml +++ b/docs/i18n/README.i18n.yaml @@ -2,5 +2,5 @@ # 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 docs/i18n/README.md -README.md: ea090373f25f49ab20d6eb5d5ff866fba8006847 -README.zh.md: 4fbd282948554a089b50445c89053b570ae56b28 +README.md: c23994a7de21a46519a28e8cdd5c206428a03a4a +README.zh.md: ee751382556e6cd4b51c0416fa9eb63d0fccfd09 diff --git a/docs/i18n/README.md b/docs/i18n/README.md index ea090373f2..c23994a7de 100644 --- a/docs/i18n/README.md +++ b/docs/i18n/README.md @@ -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 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 ` assembles that update's working set mechanically: the edited side's diff since last confirmation, the counterpart sections it lands in, the terminology rows it touches, and the binding update rules ([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 ` 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). + 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 ` 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 ` 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`). diff --git a/docs/i18n/README.zh.md b/docs/i18n/README.zh.md index 4fbd282948..ee75138255 100644 --- a/docs/i18n/README.zh.md +++ b/docs/i18n/README.zh.md @@ -15,7 +15,7 @@ foo.zh.md: 89e6c98d92887913cadf06b2adb97f26cde4849b ``` - 用 blob hash 而不是 commit hash,这样同一个 PR 里改动的文件也能算出记录(`git hash-object foo.md`),一致性是纯内容比较。记录的 hash 还能还原任一侧上次确认时的确切文本,所以失去同步的配对是「按被改一侧的 diff 最小化地修补另一侧」,从不整篇重译。`pnpm run gen-translation-brief ` 会机械地汇集这次更新的工作集:被改一侧自上次确认以来的 diff、该 diff 落入的对侧文件小节、触及的术语表行,以及有约束力的更新规则([briefed-updates Agent Note](../../.agents/notes/implemented/process/2026-07-26-briefed-minimal-translation-updates.md))。两侧对齐后,`pnpm run verify-translation-pairing --write ` 重新记录两个 hash;那份 yaml diff 就是「确认一致」这个动作本身,可以被评审,也正因如此,`--write` 要求点名你确认过的配对(`--write --all` 是显式的全语料形式)。 + 用 blob hash 而不是 commit hash,这样同一个 PR 里改动的文件也能算出记录(`git hash-object foo.md`),一致性是纯内容比较。记录的 hash 还能还原任一侧上次确认时的确切文本,所以失去同步的配对是「按被改一侧的 diff 最小化地修补另一侧」,从不整篇重译。`pnpm run gen-translation-brief ` 会以能安全对齐的最窄粒度——先是有改动的 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 ` 重新记录两个 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`)。 diff --git a/scripts/gen-translation-brief.ts b/scripts/gen-translation-brief.ts index 5dd175f669..a979d2652a 100644 --- a/scripts/gen-translation-brief.ts +++ b/scripts/gen-translation-brief.ts @@ -1,11 +1,14 @@ /** * Print the minimal-update briefing for out-of-sync translation pairs: - * `pnpm run gen-translation-brief [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. The briefing contract lives in - * `scripts/translation-brief.ts`; the consuming workflow is - * `.agents/skills/dsh-translate-docs/SKILL.md`. + * `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' @@ -15,19 +18,25 @@ import { basename, join, resolve, sep } from 'node:path' import { isTranslationScopeFile, pairAnchorOfArgument, + parseTranslationMarkdown, parseTranslationPairingManifest, TRANSLATION_SCOPE_GLOB_EXCLUDES, + translationStructureDiff, + translationStructureSignature, } from './translation-pairing.ts' import { - changedLinesOfDiff, - extractCounterpartSections, - headingSections, - mapHunksToSections, - matchTerminologyRows, - parseUnifiedDiffHunks, + changedSpanIndices, + computeMechanicalUpdate, + firstOccurrenceContext, + markdownUnits, + relevantTerminologyRows, renderTranslationBrief, + sectionSpans, + spansAligned, + type BriefBundle, type BriefDirection, - type CounterpartSection, + type BriefScope, + type MarkdownSpan, } from './translation-brief.ts' const root = resolve(import.meta.dirname, '..') @@ -121,15 +130,107 @@ function loadPair(anchor: string): PairState | string { } } -/** Whether two documents' heading sequences align one to one. */ -function headingsAligned(a: string, b: string): boolean { - const aHeads = headingSections(a) - const bHeads = headingSections(b) - return aHeads.length === bHeads.length && aHeads.every((heading, index) => heading.depth === bHeads[index]?.depth) +/** 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, + } + }) } -/** Render the briefing for one drifted side of a pair. */ -function briefDirection(pair: PairState, direction: BriefDirection): string { +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 @@ -137,25 +238,29 @@ function briefDirection(pair: PairState, direction: BriefDirection): string { const sourceCurrent = readFileSync(join(root, sourcePath), 'utf8') const counterpartCurrent = readFileSync(join(root, counterpartPath), 'utf8') const diff = diffTexts(sourceLast, sourceCurrent) - const bothDrifted = pair.enDrifted && pair.zhDrifted - - let counterpartSections: CounterpartSection[] | undefined - if (!bothDrifted && headingsAligned(sourceLast, counterpartCurrent)) { - const sections = mapHunksToSections(parseUnifiedDiffHunks(diff), headingSections(sourceLast)) - counterpartSections = extractCounterpartSections(counterpartCurrent, sections) + 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, - counterpartSections, - bothDrifted, - terminology: matchTerminologyRows(terminology, changedLinesOfDiff(diff)), + scope: planned.scope, + terminology: relevantTerminologyRows(terminology, direction, planned.changedText), }) } -const requested = process.argv.slice(2).map(pairAnchorOfArgument) +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) { @@ -182,8 +287,8 @@ for (const anchor of anchors) { 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')) - if (pair.zhDrifted) briefs.push(briefDirection(pair, 'zh-to-en')) + 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) { diff --git a/scripts/translation-brief.spec.ts b/scripts/translation-brief.spec.ts index e103de319f..0e0bc8b24c 100644 --- a/scripts/translation-brief.spec.ts +++ b/scripts/translation-brief.spec.ts @@ -2,45 +2,18 @@ import { describe, expect, it } from 'vitest' import { - changedLinesOfDiff, - extractCounterpartSections, - headingSections, - mapHunksToSections, - matchTerminologyRows, - parseUnifiedDiffHunks, + changedSpanIndices, + computeMechanicalUpdate, + firstOccurrenceContext, + markdownUnits, + parseTerminologyRows, + relevantTerminologyRows, renderTranslationBrief, + sectionSpans, + spansAligned, + termOffsets, } from './translation-brief.ts' -const DIFF = [ - '@@ -3,3 +3,3 @@', - ' unchanged context', - '-The agent loop retries once.', - '+The agent loop retries twice.', - '@@ -12 +12,2 @@', - '+A new sentence about the session log.', -].join('\n') - -describe('unified diff parsing', () => { - it('reads hunk starts and counts, defaulting count to 1', () => { - expect(parseUnifiedDiffHunks(DIFF)).toEqual([ - { start: 3, count: 3 }, - { start: 12, count: 1 }, - ]) - }) - - it('collects only changed lines, markers stripped', () => { - expect(changedLinesOfDiff(DIFF)).toBe([ - 'The agent loop retries once.', - 'The agent loop retries twice.', - 'A new sentence about the session log.', - ].join('\n')) - }) - - it('ignores file header lines that also start with +/-', () => { - expect(changedLinesOfDiff('--- a/foo.md\n+++ b/foo.md\n+added')).toBe('added') - }) -}) - const DOC = [ 'Preamble line.', '', @@ -52,57 +25,163 @@ const DOC = [ '', 'First body.', '', + '```ts', + 'const value = 1', + '```', + '', '## Second', '', - 'Second body.', + '| A | B |', + '|---|---|', + '| 1 | 2 |', + '', + '- item one', + '- item two', ].join('\n') -describe('section mapping', () => { - it('lists headings with lines, depths, and labels', () => { - expect(headingSections(DOC)).toEqual([ - { line: 3, depth: 1, label: 'Title' }, - { line: 7, depth: 2, label: 'First' }, - { line: 11, depth: 2, label: 'Second' }, +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('maps hunks to the sections they span, including the preamble', () => { - const headings = headingSections(DOC) - expect(mapHunksToSections([{ start: 1, count: 1 }], headings)).toEqual([0]) - expect(mapHunksToSections([{ start: 9, count: 1 }], headings)).toEqual([2]) - expect(mapHunksToSections([{ start: 9, count: 4 }], headings)).toEqual([2, 3]) - expect(mapHunksToSections([{ start: 0, count: 0 }], headings)).toEqual([0]) + 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('extracts counterpart section text with start lines and labels', () => { - expect(extractCounterpartSections(DOC, [0, 2])).toEqual([ - { label: '(preamble before the first heading)', startLine: 1, text: 'Preamble line.' }, - { label: '## First', startLine: 7, text: '## First\n\nFirst body.' }, - ]) + 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 loop | agent loop | agent loop(智能体循环) | | |', + '| agent | agent | agent(智能体) | 智能体 | |', '| session log | 会话日志 | | 会话记录 | |', '| gate | 门禁 | | | |', + '| registry | 注册表 | | | |', ].join('\n') -describe('terminology matching', () => { - it('selects rows whose English term appears on a word boundary', () => { - const matches = matchTerminologyRows(TERMINOLOGY, 'The agent loop retries twice.') - expect(matches.rows).toEqual(['| agent loop | agent loop | agent loop(智能体循环) | | |']) - expect(matches.header).toContain('English') +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('selects rows whose Chinese term appears when the source is Chinese', () => { - expect(matchTerminologyRows(TERMINOLOGY, '门禁在提交时运行。').rows).toEqual(['| gate | 门禁 | | | |']) + 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('does not match substrings inside larger words', () => { - expect(matchTerminologyRows(TERMINOLOGY, 'delegate the work').rows).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([]) }) }) @@ -111,29 +190,71 @@ describe('brief rendering', () => { sourcePath: 'docs/foo.md', counterpartPath: 'docs/foo.zh.md', direction: 'en-to-zh' as const, - diff: DIFF, - counterpartSections: [{ label: '## First', startLine: 7, text: '## First\n\n正文。' }], - bothDrifted: false, - terminology: matchTerminologyRows(TERMINOLOGY, changedLinesOfDiff(DIFF)), + 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 diff, aligned sections, terminology, digest, and finish steps', () => { - const brief = renderTranslationBrief(base) + 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('```diff') - expect(brief).toContain('docs/foo.zh.md:7') - expect(brief).toContain('agent loop(智能体循环)') - expect(brief).toContain('| 会话日志 |') - expect(brief).toContain('Rules digest') + 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') - expect(brief).toContain('smallest edit that covers the diff') }) - it('warns instead of showing sections when both sides drifted', () => { - const brief = renderTranslationBrief({ ...base, bothDrifted: true, counterpartSections: undefined }) + 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 regions yourself') - expect(brief).not.toContain('docs/foo.zh.md:7') + expect(brief).toContain('locate the affected regions yourself') }) it('renders the English-target digest for zh-to-en updates', () => { @@ -142,15 +263,20 @@ describe('brief rendering', () => { 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 the section fence past tilde runs in the body', () => { + it('grows bundle fences past tilde runs in the text', () => { const brief = renderTranslationBrief({ ...base, - counterpartSections: [{ label: '## First', startLine: 7, text: '~~~~\ninner\n~~~~' }], + scope: { + kind: 'units', + bundles: [{ ...bundle, counterpartText: '~~~~\ninner\n~~~~\n' }], + firstOccurrenceNotes: [], + }, }) expect(brief).toContain('~~~~~markdown') }) diff --git a/scripts/translation-brief.ts b/scripts/translation-brief.ts index 6ac4c20fc3..18e1e91e14 100644 --- a/scripts/translation-brief.ts +++ b/scripts/translation-brief.ts @@ -1,160 +1,216 @@ /** * Pure assembly of the minimal-update briefing for one out-of-sync - * translation pair: the authored side's diff since the last confirmed - * state, the counterpart sections that diff lands in, the terminology rows - * the diff touches, and a digest of the binding update rules. The CLI - * wrapper is `scripts/gen-translation-brief.ts`; the workflow that consumes - * the briefing is `.agents/skills/dsh-translate-docs/SKILL.md`. + * 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 hunk of a unified diff, in old-side line coordinates. */ -export interface DiffHunk { - /** First old-side line the hunk touches (0 for an insertion at the top). */ - start: number - /** Old-side line count (0 for a pure insertion). */ - count: number -} - -/** - * Parse the `@@ -start,count +… @@` hunk headers of a unified diff. - * - * @param diff - Unified diff text. - * @returns Hunks in old-side coordinates, in order of appearance. - */ -export function parseUnifiedDiffHunks(diff: string): DiffHunk[] { - const hunks: DiffHunk[] = [] - for (const line of diff.split('\n')) { - const match = /^@@ -(\d+)(?:,(\d+))? \+\d+(?:,\d+)? @@/.exec(line) - if (match?.[1] === undefined) continue - hunks.push({ start: Number(match[1]), count: match[2] === undefined ? 1 : Number(match[2]) }) - } - return hunks -} - -/** - * Extract the added and removed content lines of a unified diff. - * - * @param diff - Unified diff text. - * @returns The changed lines joined by newlines, diff markers stripped. - */ -export function changedLinesOfDiff(diff: string): string { - const out: string[] = [] - for (const line of diff.split('\n')) { - if (line.startsWith('+++') || line.startsWith('---')) continue - if (line.startsWith('+') || line.startsWith('-')) out.push(line.slice(1)) - } - return out.join('\n') -} - -/** One heading of a Markdown document, in document order. */ -export interface HeadingSection { - /** 1-based source line the heading starts on. */ - line: number - /** Heading depth (`##` is 2). */ - depth: number - /** Concatenated plain text of the heading. */ +/** 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 headings with their start lines via the pairing-gate parser. + * 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 Headings in document order. + * @returns Units in document order. */ -export function headingSections(markdown: string): HeadingSection[] { - const out: HeadingSection[] = [] +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') { + 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) - out.push({ line: node.position?.start.line ?? 1, depth: node.depth, label }) + 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)) - return out -} - -/** Section index containing a 1-based line: 0 is the preamble before the first heading, i is the i-th heading's section. */ -function sectionOf(line: number, headings: HeadingSection[]): number { - let section = 0 - for (let index = 0; index < headings.length; index++) { - const heading = headings[index] - if (heading !== undefined && heading.line <= line) section = index + 1 + 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) }) } - return section + 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 } /** - * Map diff hunks to the section indices they touch in the diffed document. + * Whether two span lists map one to one: same non-zero length and the same + * kind at every position. * - * @param hunks - Hunks in the diffed document's old-side coordinates. - * @param headings - The diffed document's headings at that same old state. - * @returns Ascending section indices (0 = preamble). + * @param left - One document's spans. + * @param right - The other document's spans. + * @returns True when index-wise mapping is sound. */ -export function mapHunksToSections(hunks: DiffHunk[], headings: HeadingSection[]): number[] { - const sections = new Set() - for (const hunk of hunks) { - const first = sectionOf(Math.max(hunk.start, 1), headings) - const last = sectionOf(Math.max(hunk.start + Math.max(hunk.count - 1, 0), 1), headings) - for (let section = first; section <= last; section++) sections.add(section) - } - return [...sections].sort((a, b) => a - b) -} - -/** One counterpart section to update, with its current location. */ -export interface CounterpartSection { - /** Heading label, or the preamble marker for section 0. */ - label: string - /** 1-based line the section starts on in the counterpart file. */ - startLine: number - /** Current section text, trailing blank lines trimmed. */ - text: string +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) } /** - * Extract the counterpart's text for the given section indices. + * Indices whose text differs between two aligned span lists. * - * Callers must only pass indices produced against a structurally aligned - * pair (same heading count and order), which the pairing gate guarantees - * for a recorded-consistent state. - * - * @param counterpart - Current counterpart document text. - * @param sections - Ascending section indices (0 = preamble). - * @returns One entry per requested section. + * @param before - Spans of the earlier state. + * @param after - Spans of the later state, aligned with `before`. + * @returns Ascending changed indices. */ -export function extractCounterpartSections(counterpart: string, sections: number[]): CounterpartSection[] { - const headings = headingSections(counterpart) - const lines = counterpart.split('\n') - return sections.map((section) => { - const heading = section === 0 ? undefined : headings[section - 1] - const startLine = heading?.line ?? 1 - const nextHeading = headings[section] - const endLine = nextHeading === undefined ? lines.length : nextHeading.line - 1 - const body = lines.slice(startLine - 1, endLine) - while (body.length > 0 && body.at(-1) === '') body.pop() - return { - label: heading === undefined ? '(preamble before the first heading)' : `${'#'.repeat(heading.depth)} ${heading.label}`, - startLine, - text: body.join('\n'), - } - }) +export function changedSpanIndices(before: MarkdownSpan[], after: MarkdownSpan[]): number[] { + return before.filter((span, index) => span.text !== after[index]?.text).map(span => span.index) } -/** Terminology rows relevant to one diff, grouped under their table header. */ -export interface TerminologyMatches { - /** The matched rows' shared header row, or undefined when no row matched. */ - header?: string | undefined - /** Matched data rows, verbatim, in table order. */ - rows: string[] +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): 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. */ @@ -163,37 +219,122 @@ function plainTerm(cell: string): string { } /** - * Select the terminology rows whose English or Chinese term occurs in the diff. - * - * English terms match case-insensitively on non-alphanumeric boundaries; - * Chinese terms match by substring. + * Parse the data rows of the terminology table. * * @param terminology - Full `docs/i18n/terminology.md` contents. - * @param changedText - Changed diff lines (see {@link changedLinesOfDiff}). - * @returns Matched rows under their header. + * @returns Rows in table order. */ -export function matchTerminologyRows(terminology: string, changedText: string): TerminologyMatches { - const matches: TerminologyMatches = { rows: [] } - let header: string | undefined +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()) - if (line.includes('English') && line.includes('中文')) { - header = line - continue - } const english = plainTerm(cells[1] ?? '') - const chinese = plainTerm(cells[2] ?? '') - const escaped = english.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') - const englishHit = english.length > 1 && new RegExp(`(? 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 ? `(? 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, +): FirstOccurrenceContext { + const notes: string[] = [] + const extra = new Set() + 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`. */ @@ -206,8 +347,27 @@ function fenceFor(body: string, mark: '`' | '~'): string { return mark.repeat(longest + 1) } -/** The two update directions a pair supports. */ -export type BriefDirection = 'en-to-zh' | 'zh-to-en' +/** 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 { @@ -218,26 +378,24 @@ export interface TranslationBriefInput { direction: BriefDirection /** Unified diff of the changed side, last-confirmed to current. */ diff: string - /** Counterpart sections the diff maps to, or undefined when alignment is untrusted. */ - counterpartSections?: CounterpartSection[] | undefined - /** Whether both sides drifted since the last confirmed state. */ - bothDrifted: boolean - terminology: TerminologyMatches + scope: BriefScope + terminology: TerminologyRow[] } const ZH_TARGET_DIGEST = [ - '- Edit ONLY what the diff requires; preserve the reviewed phrasing of everything unchanged.', + '- 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 diff requires; preserve the reviewed phrasing of everything unchanged.', + '- 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.', @@ -246,10 +404,46 @@ const EN_TARGET_DIGEST = [ '- 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 sections, terminology, and pair identity. + * @param input - Diff, mapped scope, terminology, and pair identity. * @returns Markdown briefing text. */ export function renderTranslationBrief(input: TranslationBriefInput): string { @@ -258,9 +452,13 @@ export function renderTranslationBrief(input: TranslationBriefInput): string { const out: string[] = [] out.push(`# Translation update briefing: ${input.sourcePath}`) out.push('') - out.push(input.bothDrifted - ? `WARNING: BOTH sides changed since the pair was last confirmed consistent. Reconcile the two sides by hand — decide which side owns each divergence per docs/i18n/translation-rules.md — before recording. The diff below covers the ${sourceLanguage} side only.` - : `The ${sourceLanguage} side changed; bring \`${input.counterpartPath}\` along with the smallest edit that covers the diff. The ${counterpartLanguage} side is untouched since the pair was last confirmed consistent.`) + 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('') @@ -268,29 +466,35 @@ export function renderTranslationBrief(input: TranslationBriefInput): string { out.push(`${diffFence}diff`) out.push(input.diff.trimEnd()) out.push(diffFence) - if (input.counterpartSections !== undefined) { - out.push('') - out.push(`## ${counterpartLanguage} text to update (aligned sections, current line numbers)`) - for (const section of input.counterpartSections) { + switch (input.scope.kind) { + case 'mechanical': + break + case 'units': out.push('') - out.push(`### ${section.label} — ${input.counterpartPath}:${section.startLine}`) + 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('') - const fence = fenceFor(section.text, '~') - out.push(`${fence}markdown`) - out.push(section.text) - out.push(fence) - } - } else { - out.push('') - out.push(`Counterpart sections are not shown: the pair's heading structures do not align at the compared states, so open \`${input.counterpartPath}\` directly and locate the regions yourself.`) + 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.rows.length > 0 && input.terminology.header !== undefined) { + if (input.terminology.length > 0) { out.push('') - out.push('## Binding terminology rows matching this diff (docs/i18n/terminology.md)') + out.push('## Binding terminology rows matching this change (docs/i18n/terminology.md)') out.push('') - out.push(input.terminology.header) - out.push(`|${' --- |'.repeat(Math.max(input.terminology.header.split('|').length - 2, 1))}`) - for (const row of input.terminology.rows) out.push(row) + 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.') } @@ -301,7 +505,7 @@ export function renderTranslationBrief(input: TranslationBriefInput): string { out.push('') out.push('## Finish') out.push('') - out.push('1. Apply the smallest counterpart edit that covers the diff, then verify the changed hunks clause by clause against the source.') + 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('')