Merge branch 'worktree/ci-native-windows-20260808' into worktree/ci-native-windows-coverage-20260808

This commit is contained in:
Tianyi Cui
2026-08-09 01:34:01 +08:00
29 changed files with 1712 additions and 102 deletions

View File

@@ -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-08-08-automatic-translation-pairing-merges.md
2026-08-08-automatic-translation-pairing-merges.md: ac4e0c11290abfd48d0f3caf037062aa2fe36c4d
2026-08-08-automatic-translation-pairing-merges.zh.md: 38e8c0a5613a6bd0b3e76143a93c93653430d0c2

View File

@@ -0,0 +1,55 @@
# Agent Note: Automatically compose translation pairing records
Status: implemented
English | [中文](2026-08-08-automatic-translation-pairing-merges.zh.md)
## Problem
A bilingual consistency record contains the two owner files' exact blob hashes. Two branches that independently update different parts of the same confirmed pair therefore conflict on both hash lines even when Git cleanly composes both Markdown owners. Selecting either side leaves stale hashes, while regenerating the record by hand repeats a deterministic operation and prevents an otherwise automatic merge.
## Decision
`*.i18n.yaml` uses the repository-owned `dsh-translation-pairing` merge driver. The worktree-local Git installer registers its command alongside Lefthook setup; Git configuration remains local because a tracked attribute can name a driver but cannot carry its executable command.
The installer loads the exact Node/tsx entrypoint before publishing worktree integration. Git invokes a checked-in shell launcher that does not require Node and repeats this probe before every driver execution. When the runtime or entrypoint is unavailable, the launcher materializes Git's ordinary three-way text result in the sidecar but returns a conflict even when that text merge is clean, so Git retains the unmerged index stages and never accepts unverified metadata.
The driver parses the ancestor, current, and other records and loads the six owner blobs named by their hashes. It independently runs Git's default three-way text merge for the English and Chinese triplets, requires both merges to be clean, verifies language switchers and the pairing structural signature, stores the two merged blobs, and writes their hashes as the canonical record. This composes confirmations already present in both parents; it never records an ordinary one-sided documentation edit.
The driver fails with an ordinary unresolved sidecar when a record is malformed, an object is missing, an owner uses another merge strategy (including a non-text `merge.default` inherited by an otherwise unspecified path), either owner has content conflicts, or the merged pair violates structural checks. Add/delete and rename shapes remain manual because their path ownership is not the same three-record operation.
`pnpm run resolve-translation-pairing-conflicts` applies the same algorithm after a merge has already stopped. Before writing any sidecar, it proves that the sidecar still contains Git's untouched conflict result and that the staged owner blob IDs and working-tree bytes equal its independent merges. It writes and stages every safe record as one batch even when another pair still needs manual work, then reports the remaining pairing conflicts and exits unsuccessfully so callers cannot mistake a partial resolution for a completed merge.
`pre-merge-commit` and `pre-commit` verify staged `.i18n.yaml` files against the exact index bytes of their owners. They validate driver output but do not regenerate records, so bypassing a hook cannot silently bless translation drift; the corpus-wide `doc-sync` check remains authoritative in CI.
## Failure contract
| Failure during a normal `git merge` | Observable state | Recovery |
|---|---|---|
| A fresh install cannot probe the driver or install Lefthook | No new driver or hook-path configuration is published; any newly added integration is rolled back to the previous hook lookup. | Restore the dependencies and rerun `node scripts/install-lefthook.mjs`. |
| Node, tsx, or the driver entrypoint becomes unavailable after installation | The merge stops with the sidecar at `UU`, index stages 1/2/3 remain, the worktree sidecar contains Git's text result, `MERGE_HEAD` exists, and no commit is created. | Restore the dependencies and run `pnpm run resolve-translation-pairing-conflicts`, or run `git merge --abort`. |
| The repository-aware driver rejects the records | The merge stops with the sidecar unresolved and no commit; the driver prints the owner-repair and explicit-resolver path. | Repair the owner conflict or record, then run the printed resolver workflow or abort. |
| The driver process crashes with a status above 128 | Git aborts the merge strategy without publishing `MERGE_HEAD` or unmerged index stages. | Repair the runtime and rerun the merge. |
| `pre-merge-commit` rejects an otherwise clean file merge | No unmerged entries remain, the complete result is staged with `MERGE_HEAD`, and no merge commit is created. | Repair the hook failure and run `git commit`, or run `git merge --abort`. |
An installer rollback failure reports both the original installation error and every rollback error. Because worktree configuration may then be partial, the contributor repairs or inspects it before merging instead of relying on a silent fallback.
## Verification
Script tests exercise clean composition through the installed launcher, missing-runtime and broken-entrypoint text fallback, installer probe rollback, a rejecting `pre-merge-commit` hook, explicit recovery from an unresolved index, mixed safe and owner-conflicted pairs, edited sidecars, non-text default merge configuration, record parsing, and worktree-local installation. The existing corpus verifier continues to prove that a committed record matches its two owners.
## Alternatives considered
**Take ours or use Git's union driver.** Either parent record names pre-merge content, while union produces duplicate or unordered hash keys. Neither represents the merged owners.
**Regenerate in `post-merge` or only in a commit hook.** `post-merge` does not run after a conflicted merge and cannot affect its outcome. Commit hooks are reached only after the index has no unresolved entries, so a hook alone cannot clear the generated conflict.
**Wrap every merge in a repository command.** A wrapper can resolve the conflict from the populated index, and the explicit resolver retains that recovery path, but raw Git, stack tooling, rebases, and cherry-picks would still stop before it. The merge driver is the file-level extension point shared by those operations.
**Resolve on GitHub through Actions or an app.** Hosted automation could update PR branches, but it adds credentials, concurrency control, and branch mutation. Local and agent-driven merge-forward workflows already have a checkout and push authority; the repository keeps remote automation out of this mechanism.
## Consequences
Installed worktrees automatically remove pairing-record-only conflicts while preserving human judgment for owner conflicts and translation quality. GitHub's hosted mergeability calculation does not run the worktree-local executable, so a contributor or agent must still merge the base and push the resulting commit before the remote conflict badge clears.
The installer reserves `merge.dsh-translation-pairing.*` in worktree configuration and refuses a conflicting custom value. Automatic composition depends on the installed Node dependencies, like the repository's contributor hooks; runtime loss produces a visible unresolved text result rather than selecting stale metadata.

View File

@@ -0,0 +1,55 @@
# Agent Note: 自动组合翻译配对记录
Status: implemented
[English](2026-08-08-automatic-translation-pairing-merges.md) | 中文
## 问题
一份双语一致性记录包含两侧 Markdown 文件的精确 blob hash。因此当两个分支分别更新同一已确认配对的不同部分时即使 Git 能干净合并两侧 Markdown 文件,记录中的两行 hash 仍会发生冲突。选择任一侧都会留下陈旧 hash手工重新生成记录则会重复执行一项确定性操作并阻止本可自动完成的合并。
## 决策
`*.i18n.yaml` 使用由仓库维护的 `dsh-translation-pairing` 合并驱动。worktree 本地的 Git 安装脚本在设置 Lefthook 的同时注册该驱动命令Git 配置仍限定于当前 worktree因为受跟踪的属性可以指定驱动却无法携带其可执行命令。
安装脚本会在发布 worktree 集成前加载确切的 Node/tsx 入口点。Git 会调用仓库内已提交且不依赖 Node 的 shell 启动器;该启动器会在每次执行驱动前重复这项探测。运行时或入口点不可用时,即使文本合并干净完成,启动器也会将 Git 的普通三方文本合并结果写入伴随文件并返回冲突状态,使 Git 保留未合并的索引阶段,绝不接受未经验证的元数据。
驱动会解析祖先、当前和另一侧的记录,并加载其中 hash 指向的六个 Markdown blob。它分别对英文文档的三个 blob 与中文文档的三个 blob 运行 Git 默认的三方文本合并,要求两次合并都干净完成,校验语言切换行与配对结构签名,存储两个合并后的 blob再将其 hash 写入规范记录。这一过程组合两个父提交中已经存在的确认;它绝不会为普通的单侧文档编辑写入记录。
记录格式错误、对象缺失、配对文档采用其他合并策略(包括原本未单独指定策略的路径继承了非文本 `merge.default`)、任一配对文档存在内容冲突,或合并后的配对违反结构检查时,驱动会让伴随文件保持为普通的未解决冲突。新增、删除及重命名情形仍需手工处理,因为它们的路径归属关系无法由上述基于三份记录的操作处理。
`pnpm run resolve-translation-pairing-conflicts` 会在合并已经停止后应用同一算法。写入任何伴随文件之前,它会验证伴随文件仍保留 Git 生成且未经改动的冲突结果,并验证暂存区中配对文档的 blob ID 以及工作树中的文件字节都与工具独立完成的合并结果一致。即使其他配对仍需手工处理,它也会将所有可安全生成的记录作为一个批次写入并暂存;随后报告剩余的配对冲突并以非零状态退出,确保调用方不会把仅部分解决误认为合并已经完成。
`pre-merge-commit``pre-commit` 通过限定范围的配对检查,对照两侧文档在索引中的精确字节校验暂存的 `.i18n.yaml` 文件。这些钩子会验证驱动输出但不会重新生成记录因此绕过钩子也无法默默确认发生漂移的译文CI 中全语料的 `doc-sync`(文档同步门禁)仍是权威检查。
## 失败契约
| 普通 `git merge` 期间的失败 | 可观察状态 | 恢复 |
|---|---|---|
| 全新安装无法探测驱动或安装 Lefthook | 不会发布新的驱动或钩子路径配置;任何新增集成都会回滚到原有的钩子查找方式。 | 恢复依赖并重新运行 `node scripts/install-lefthook.mjs`。 |
| 安装后 Node、tsx 或驱动入口点变得不可用 | 合并停止,伴随文件状态为 `UU`;索引阶段 1/2/3 保留worktree 中的伴随文件包含 Git 的文本结果,`MERGE_HEAD` 存在,且不创建提交。 | 恢复依赖并运行 `pnpm run resolve-translation-pairing-conflicts`,或运行 `git merge --abort`。 |
| 了解仓库状态的驱动拒绝记录 | 合并停止,伴随文件保持未解决状态且不创建提交;驱动会打印修复配对文档和运行显式冲突解决命令的路径。 | 修复配对文档冲突或记录,然后运行打印出的冲突解决工作流,或中止合并。 |
| 驱动进程以大于 128 的状态码崩溃 | Git 中止合并策略,既不写入 `MERGE_HEAD`,也不产生未合并的索引阶段。 | 修复运行时并重新执行合并。 |
| `pre-merge-commit` 拒绝原本能干净完成的文件合并 | 不再有未合并条目,完整结果已暂存且 `MERGE_HEAD` 存在,但不会创建合并提交。 | 修复钩子失败并运行 `git commit`,或运行 `git merge --abort`。 |
安装脚本回滚失败时,会同时报告最初的安装错误和每一项回滚错误。此时 worktree 配置可能只完成了一部分,因此贡献者会先修复或检查配置再执行合并,而不是依赖静默回退。
## 验证
脚本测试通过已安装的启动器覆盖无冲突组合,并覆盖运行时缺失与入口点损坏时的文本回退、安装脚本探测回滚、拒绝合并的 `pre-merge-commit` 钩子、从含未解决条目的索引中显式恢复、可安全解决的配对与配对文档冲突的配对并存、被编辑过的伴随文件、非文本默认合并配置、记录解析和 worktree 本地安装。既有的全语料校验器继续证明已提交的记录与两侧配对文档相符。
## 考虑过的替代方案
**选择 ours 或使用 Git 的 union 驱动。** 任一父提交的记录都指向合并前的内容,而 union 会产生重复或乱序的 hash 键。两者都无法表示合并后的配对文档。
**在 `post-merge` 中或仅通过提交钩子重新生成。** 发生冲突的合并不会运行 `post-merge`,而且该钩子无法改变合并结果。只有暂存区不含未解决条目后才会触发提交钩子,因此仅用钩子无法清除生成文件冲突。
**用仓库命令包装每次合并。** 包装命令可以根据已由合并填充的索引解决冲突,显式冲突解决命令也保留这条恢复路径,但直接运行 Git、stack 工具、rebase 和 cherry-pick 时仍会在此前停止。合并驱动才是这些操作共用的文件级扩展点。
**通过 GitHub Actions 或应用解决。** 托管自动化可以更新 PRPull Request分支却会引入凭据、并发控制与分支修改。本地执行或由 agent智能体驱动的 merge-forward 工作流已经具有检出目录与推送权限,因此仓库不把远程自动化纳入该机制。
## 后果
完成安装的 worktree 会自动消除仅由配对记录引起的冲突同时仍由人工处理配对文档冲突并评判翻译质量。GitHub 托管的可合并性计算不会运行 worktree 本地的可执行命令,因此仍须由贡献者或 agent 将 base 分支合入当前分支并推送结果提交,远端冲突标记才会消失。
安装脚本会在 worktree 配置中预留 `merge.dsh-translation-pairing.*`,并拒绝与之冲突的自定义值。与仓库贡献者钩子一样,自动组合依赖已安装的 Node 依赖;运行时不可用时会产生可见的未解决文本结果,而不是选择陈旧元数据。

4
.gitattributes vendored
View File

@@ -7,3 +7,7 @@
* text=auto eol=lf
*.pdf binary
# Pairing records are generated from the three confirmed owner-blob pairs.
# The worktree-local installer registers the fail-closed driver command.
*.i18n.yaml merge=dsh-translation-pairing

58
.github/workflows/e2b-e2e.yml vendored Normal file
View File

@@ -0,0 +1,58 @@
name: E2E (E2B sandbox)
# This suite provisions external E2B sandboxes and is intentionally opt-in.
# It has no push, pull_request, schedule, or workflow_call trigger.
on:
workflow_dispatch:
permissions:
contents: read
env:
# CI runs must never report to the production telemetry endpoint baked
# into apps/cli/cordis.yml (AppCLIEntry disables the row when set).
DSH_TELEMETRY_DISABLED: '1'
jobs:
e2b:
runs-on: ubuntu-latest
name: E2B live Loader composition
timeout-minutes: 30
steps:
- uses: actions/checkout@v6
- uses: pnpm/action-setup@v4
- uses: actions/setup-node@v6
with:
node-version: 24
cache: pnpm
- name: Install (immutable)
run: pnpm install --frozen-lockfile
# The tests self-skip locally when the credential is absent. A manually
# dispatched run must fail instead of reporting an all-skipped green.
- name: Preflight (require E2B API key)
env:
E2B_API_KEY: ${{ secrets.E2B_API_KEY_EXTERNAL }}
run: |
set -euo pipefail
if [ -z "${E2B_API_KEY:-}" ]; then
echo "::error::E2B_API_KEY is empty. Configure the E2B_API_KEY_EXTERNAL repository secret."
exit 1
fi
echo "E2B_API_KEY present."
# The Loader smoke runs package exports under plain Node in lib mode.
- name: Build (lib for the E2B Loader smoke)
run: pnpm run build
- name: E2B tests (live sandbox)
env:
E2B_API_KEY: ${{ secrets.E2B_API_KEY_EXTERNAL }}
DSH_E2E_MAX_WORKERS: '1'
DSH_EXAMPLE_MODE: lib
run: >-
pnpm exec vitest run --config vitest.e2e.config.ts
packages/e2b/e2b/tests/composition.e2e.ts

View File

@@ -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: a9edd80abfbd53c9fc0495b4b495fa495207ca95
development.zh.md: 405942c26ef3600209bc14a300c94a98d4f2b9f3
development.md: 0032000e298ef49516d618e932258e79cf90e802
development.zh.md: d681306e31991794519b62c3edbb6ca35ebc228b

View File

@@ -21,9 +21,9 @@ Install dependencies from the repo root:
pnpm install
```
The install also configures worktree-local lefthook hooks through `scripts/install-lefthook.mjs`. The [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) owns the safety and migration contract.
The install also configures worktree-local Lefthook hooks and the `dsh-translation-pairing` Git merge driver through `scripts/install-lefthook.mjs`. The [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) owns the hook-path safety contract; the [automatic pairing merges Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) owns the merge driver.
If hooks are missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:
If either integration is missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:
```sh
node scripts/install-lefthook.mjs
@@ -98,18 +98,23 @@ DEEPSEEK_BASE_URL=https://... # optional
`DEEPSEEK_BASE_URL` is optional and defaults to the public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set.
### Git hooks
### Git integrations
The pairing merge driver derives a conflicted `.i18n.yaml` record from the confirmed ancestor, current, and other owner blobs when both language files use Git's default text strategy and merge cleanly. It fails closed on owner conflicts, non-text merge configuration, or invalid records; after an already-stopped merge, run `pnpm run resolve-translation-pairing-conflicts`, which stages every safe pairing record and exits unsuccessfully if other pairing conflicts still need manual work. See the [bilingual documentation contract](i18n/README.md#the-pairing-contract) for the exact boundary.
The installer probes the exact Node/tsx driver entrypoint before publishing its worktree configuration. If that runtime later becomes unavailable, the Node-independent launcher writes Git's ordinary text result, leaves the sidecar unresolved, and prints the recovery path; restore dependencies and run `pnpm run resolve-translation-pairing-conflicts`, or run `git merge --abort`. If `pre-merge-commit` rejects an otherwise clean merge, Git leaves the complete result staged without a commit; repair the failure and run `git commit`, or abort. The [automatic pairing merges Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md#failure-contract) owns the exact index and `MERGE_HEAD` states.
lefthook is configured in `lefthook.yml` as a fast local checkpoint:
- `pre-commit` applies formatting-only ESLint fixes, validates the staged files with the project-free `.oxlintrc.staged.json` profile and applies Oxlint's native fixes, regenerates `THIRD_PARTY_NOTICES.md` when a staged file is one of its inputs, checks the staged diff for whitespace errors, and runs the vendor manifest guard.
- `pre-commit` verifies staged pairing records against the staged owner blobs, applies formatting-only ESLint fixes, validates the staged files with the project-free `.oxlintrc.staged.json` profile and applies Oxlint's native fixes, regenerates `THIRD_PARTY_NOTICES.md` when a staged file is one of its inputs, checks the staged diff for whitespace errors, and runs the vendor manifest guard.
- `pre-merge-commit` performs the same index-backed pairing check before Git creates an automatic merge commit.
- `pre-push` runs `pnpm run typecheck`, which completes the Host lib phase, including generated TypeRT contracts, before the Client TypeScript check.
The vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.
The hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.
Apart from the scoped staged-record verification, the hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.
Contributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of both Git hooks and is not an agent instruction.
Contributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of the Git hooks and is not an agent instruction.
### CI gates

View File

@@ -21,9 +21,9 @@
pnpm install
```
安装过程还会通过 `scripts/install-lefthook.mjs` 配置 worktree 本地的 lefthook 钩子。其安全与迁移契约由 [worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) 负责。
安装过程还会通过 `scripts/install-lefthook.mjs` 配置 worktree 本地的 Lefthook 钩子`dsh-translation-pairing` Git 合并驱动。[worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) 负责钩子路径的安全契约;[自动配对合并 Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) 负责合并驱动
如果依赖是从缓存恢复或 `postinstall` 被跳过而导致缺少钩子,请手动安装:
如果依赖是从缓存恢复或 `postinstall` 被跳过而导致任一集成缺失,请手动安装:
```sh
node scripts/install-lefthook.mjs
@@ -98,18 +98,23 @@ DEEPSEEK_BASE_URL=https://... # optional
`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。
### Git 钩子
### Git 集成
当两种语言的文件都使用 Git 默认文本策略且能干净合并时,配对合并驱动会根据已确认的祖先、当前和另一侧的配对文档 blob推导出发生冲突的 `.i18n.yaml` 记录。配对文档发生冲突、存在非文本合并配置或记录无效时,它会拒绝处理并保留冲突;如果合并已经因冲突而停止,请运行 `pnpm run resolve-translation-pairing-conflicts`,该命令会暂存每份可安全生成的配对记录;如果其他配对冲突仍需手工处理,则以非零状态退出。确切边界见[双语文档契约](i18n/README.md#the-pairing-contract)。
安装脚本在发布 worktree 配置前,会探测确切的 Node/tsx 驱动入口点。如果该运行时之后变得不可用,不依赖 Node 的启动器会写入 Git 的普通文本合并结果、让伴随文件保持未解决状态,并打印恢复路径;请恢复依赖后运行 `pnpm run resolve-translation-pairing-conflicts`,或运行 `git merge --abort`。如果 `pre-merge-commit` 拒绝原本能干净完成的合并Git 会把完整结果留在暂存区但不创建提交;请修复失败后运行 `git commit`,或中止合并。确切的索引与 `MERGE_HEAD` 状态由[自动配对合并 Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md#failure-contract)负责记录。
lefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:
- `pre-commit` 应用仅用于格式化的 ESLint 修复,使用不加载项目的 `.oxlintrc.staged.json` 配置验证暂存文件并应用 Oxlint 的原生修复,在暂存文件属于 `THIRD_PARTY_NOTICES.md` 的输入时重新生成该文件,然后检查暂存 diff 中的空白错误,并运行 vendor manifest元数据清单守卫
- `pre-commit` 对照暂存的配对文档 blob 校验暂存的配对记录,应用仅用于格式化的 ESLint 修复,使用不加载项目的 `.oxlintrc.staged.json` 配置验证暂存文件并应用 Oxlint 的原生修复,在暂存文件属于 `THIRD_PARTY_NOTICES.md` 的输入时重新生成该文件,然后检查暂存 diff 中的空白错误,并运行 vendor manifest元数据清单守卫
- `pre-merge-commit` 在 Git 创建自动合并提交前执行同样以索引为准的配对检查;
- `pre-push` 运行 `pnpm run typecheck`;该命令会先完成包含 TypeRT 契约生成的完整 Host lib 阶段,再运行 Client TypeScript 检查。
vendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`
这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally)CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。
除限定范围的暂存记录校验外,这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally)CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。
贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于两个 Git 钩子,也不是对 agent 的指令。
贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于 Git 钩子,也不是对 agent 的指令。
### CI 门禁

View File

@@ -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: 2bac578034441bbe786ce54c051cc622bf9275c0
README.zh.md: b7566bf6f88e03eb06227b63011f78252b4dcd07
README.md: 30f56eeebf15a3edec559e3080247dab9ab14178
README.zh.md: 91d5b35a00994beea70f2bbd674cfb557d6f834d

View File

@@ -16,6 +16,8 @@ This repo's documentation is read by people and agents both inside and outside t
```
Blob hashes, not commit hashes, so the record is computable for files edited in the same PR (`git hash-object foo.md`) and consistency is a pure content comparison. `--write` stores those snapshots in the local Git object database before recording them, including uncommitted working-tree contents, and pins every distinct stored blob under a content-addressed `refs/dsh/translation-pairing/snapshots/` ref so garbage collection cannot invalidate a recorded recovery pointer. The recorded hashes therefore 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).
When two branches contain valid confirmations of the same pair, the installed `dsh-translation-pairing` Git merge driver composes a new record only if Git's default text merge succeeds for both recorded owner-blob triplets and the merged pair retains its switchers and structural signature. Any uncertain shape remains an ordinary conflict; `pnpm run resolve-translation-pairing-conflicts` applies the same fail-closed operation to a merge that has already stopped, stages every safe pairing record, and exits unsuccessfully when other pairing conflicts remain. The [automatic pairing merges Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) owns the mechanism and alternatives.
- **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`).

View File

@@ -16,6 +16,8 @@
```
用 blob hash 而不是 commit hash这样同一个 PR 里改动的文件也能算出记录(`git hash-object foo.md`),一致性是纯内容比较。`--write` 会先把这些快照存入本地 Git 对象库再写下记录,未提交的工作树内容也不例外;它还会在内容寻址的 `refs/dsh/translation-pairing/snapshots/` ref 下固定每个不同的已存 blob使垃圾回收无法让已记录的恢复指针失效。因此记录的 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` 是显式的全语料形式)。
当两个分支都包含同一配对的有效确认时,已安装的 `dsh-translation-pairing` Git 合并驱动只会在 Git 默认文本合并能分别干净合并记录所指向的英文三方 blob 与中文三方 blob且合并后的配对仍保留两侧的语言切换行和结构签名时组合出一份新记录。任何无法确定的情形都保留为普通冲突`pnpm run resolve-translation-pairing-conflicts` 会对已经停止的合并执行同一套遇错即保留冲突的操作,暂存每份可安全生成的配对记录,并在还有其他配对冲突时以非零状态退出。[自动配对合并 Agent Note](../../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) 负责记录该机制与备选方案。
- **语言切换行。** 两个文件在各自 H1 标题之后立即互链:英文文件带 `English | [中文](foo.zh.md)`,中文文件带 `[English](foo.md) | 中文`。
- **结构与另一侧一一对应。** 标题深度与顺序、列表类型、有序列表起始编号、列表项数量、表格行列数、链接目标与逐字节一致的代码块在配对两侧一一对应;完整保持规则见 [translation-rules.md](translation-rules.md)。既有 Markdown 门禁对 `.zh.md` 文件原样生效(`verify-md-wrap`、`verify-md-links`)。

View File

@@ -4,6 +4,10 @@
pre-commit:
jobs:
- name: translation pairing (staged records)
glob: '*.i18n.yaml'
run: node_modules/.bin/tsx scripts/verify-translation-pairing.ts --cached {staged_files}
- name: format (staged)
glob: '*.{ts,tsx,mts,cts,mjs}'
exclude:
@@ -34,6 +38,12 @@ pre-commit:
- name: vendor manifest guard
run: scripts/check-vendor-manifest.sh
pre-merge-commit:
jobs:
- name: translation pairing (staged records)
glob: '*.i18n.yaml'
run: node_modules/.bin/tsx scripts/verify-translation-pairing.ts --cached {staged_files}
pre-push:
jobs:
- name: typecheck

View File

@@ -78,6 +78,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",
"resolve-translation-pairing-conflicts": "tsx scripts/merge-translation-pairing.ts --resolve",
"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",

View File

@@ -35,6 +35,7 @@
"devDependencies": {
"@deepseek-ai/dsh-invariants": "workspace:^",
"@deepseek-ai/dsh-loader-smoke": "workspace:^",
"@deepseek-ai/dsh-sandbox-policy": "workspace:^",
"cordis": "^4.0.0-rc.7"
}
}

View File

@@ -13,6 +13,7 @@ import {
} from '@deepseek-ai/dsh-e2b'
import PtyService, { PtySessionId } from '@deepseek-ai/dsh-pty'
import { LocalPtyBackend } from '@deepseek-ai/dsh-pty-local'
import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy'
import { Session, SessionId } from '@deepseek-ai/dsh-session'
import E2BSubprocessService from '@deepseek-ai/dsh-subprocess-e2b'
@@ -51,10 +52,10 @@ describe.skipIf(!process.env.E2B_API_KEY)('E2B live Loader composition', () => {
runtimeRoot: '/home/user/.dsh-e2b',
getSandbox: async () => sandbox,
} as never)
ctx.provide('sandboxPolicy', {
defaultMode: 'danger-full-access',
const sandboxPolicyFiber = await ctx.plugin(SandboxPolicyService, {
mode: 'danger-full-access',
workspaceRoot: '/home/user',
} as never)
})
const ptyFiber = await ctx.plugin(PtyService)
const subprocessFiber = await ctx.plugin(E2BSubprocessService)
const node = await ctx.subprocess.resolveExecutable('node')
@@ -114,6 +115,7 @@ describe.skipIf(!process.env.E2B_API_KEY)('E2B live Loader composition', () => {
await session.close('environment test complete')
await subprocessFiber.dispose()
await ptyFiber.dispose()
await sandboxPolicyFiber.dispose()
} finally {
await sandbox.kill().catch(() => false)

3
pnpm-lock.yaml generated
View File

@@ -3156,6 +3156,9 @@ importers:
'@deepseek-ai/dsh-loader-smoke':
specifier: workspace:^
version: link:../../support/loader-smoke
'@deepseek-ai/dsh-sandbox-policy':
specifier: workspace:^
version: link:../../sandbox/sandbox-policy
cordis:
specifier: ^4.0.0-rc.7
version: link:../../../vendor/cordis

View File

@@ -59,6 +59,33 @@ describe('CI workflow', () => {
})
})
describe('E2B e2e workflow', () => {
it('is manual-only and fails loud before running the focused live suite', () => {
const workflow = loadWorkflow('.github/workflows/e2b-e2e.yml')
expect(workflow.on).toEqual({ workflow_dispatch: null })
if (!isRecord(workflow.jobs) || !isRecord(workflow.jobs.e2b) || !Array.isArray(workflow.jobs.e2b.steps)) {
throw new TypeError('E2B e2e workflow must define the e2b job steps')
}
const steps = workflow.jobs.e2b.steps.filter(isRecord)
const preflight = steps.find(step => step.name === 'Preflight (require E2B API key)')
const e2b = steps.find(step => step.name === 'E2B tests (live sandbox)')
expect(preflight).toMatchObject({
env: { E2B_API_KEY: '${{ secrets.E2B_API_KEY_EXTERNAL }}' },
})
expect(preflight?.run).toContain('E2B_API_KEY_EXTERNAL repository secret')
expect(e2b).toMatchObject({
env: {
E2B_API_KEY: '${{ secrets.E2B_API_KEY_EXTERNAL }}',
DSH_E2E_MAX_WORKERS: '1',
DSH_EXAMPLE_MODE: 'lib',
},
})
expect(e2b?.run).toContain('packages/e2b/e2b/tests/composition.e2e.ts')
})
})
describe('Issue lifecycle workflow', () => {
it('uses review signals instead of rerunning when a draft becomes ready', () => {
const lifecycle = loadWorkflow('.github/workflows/issue-lifecycle.yml')

View File

@@ -27,6 +27,19 @@ const INSTALL_LOCK_INITIALIZATION_TIMEOUT_MS = 1_000
const INSTALL_LOCK_POLL_MS = 50
const ALLOW_HOOKS_PATH_OVERRIDE = 'DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE'
const REPOSITORY_EXTENSION_PATTERN = '^extensions\\.'
const PAIRING_MERGE_DRIVER_CONFIG = [
['merge.dsh-translation-pairing.name', 'DeepSeek Harness bilingual pairing records'],
[
'merge.dsh-translation-pairing.driver',
'scripts/merge-translation-pairing-driver.sh %O %A %B %P',
],
]
const PAIRING_MERGE_DRIVER_PROBE = [
'--import',
'tsx/esm',
'scripts/merge-translation-pairing.ts',
'--probe',
]
function errorCode(error) {
return typeof error === 'object' && error !== null && 'code' in error
@@ -595,6 +608,86 @@ function refuseScopedHooksPath(entry) {
)
}
function installPairingMergeDriver(root, worktreeConfigPath) {
const added = []
try {
for (const [key, expected] of PAIRING_MERGE_DRIVER_CONFIG) {
const entries = includedFileConfigEntries(root, worktreeConfigPath, key)
const includedEntry = entries.find(entry => !originIsFile(entry.origin, root, worktreeConfigPath))
if (includedEntry !== undefined) {
throw new Error(
`refusing pairing merge-driver config from an included worktree file (${configSource(includedEntry)})`,
)
}
const existing = assertSingle(entries.map(entry => entry.value), `worktree ${key}`)
const effectiveBefore = effectiveConfigEntry(root, key)
if (effectiveBefore?.scope === 'command') {
throw new Error(
`refusing command-scoped ${key} (${configSource(effectiveBefore)}); `
+ 'transient configuration cannot be replaced by the worktree installer',
)
}
if (existing === undefined && effectiveBefore !== undefined && effectiveBefore.value !== expected) {
throw new Error(
`refusing to mask inherited ${key} (${configSource(effectiveBefore)}); `
+ 'remove or integrate the custom pairing merge driver explicitly',
)
}
if (existing !== undefined && existing !== expected) {
throw new Error(
`refusing to replace worktree ${key} value ${JSON.stringify(existing)}; `
+ 'remove or integrate the custom pairing merge driver explicitly',
)
}
if (existing === undefined) {
git(['config', '--worktree', key, expected], root)
added.push(key)
}
const installed = includedFileConfigEntries(root, worktreeConfigPath, key)
if (
installed.length !== 1
|| installed[0]?.value !== expected
|| !originIsFile(installed[0].origin, root, worktreeConfigPath)
) {
throw new Error(`new worktree-local ${key} did not become the direct worktree value`)
}
const effectiveAfter = effectiveConfigEntry(root, key)
if (
effectiveAfter === undefined
|| effectiveAfter.scope !== 'worktree'
|| effectiveAfter.value !== expected
|| !originIsFile(effectiveAfter.origin, root, worktreeConfigPath)
) {
throw new Error(`new worktree-local ${key} did not become the effective direct worktree value`)
}
}
} catch (error) {
const rollbackErrors = []
for (const key of added.reverse()) {
try {
git(['config', '--worktree', '--unset-all', key], root)
} catch (rollbackError) {
rollbackErrors.push(rollbackError)
}
}
if (rollbackErrors.length > 0) {
throw new AggregateError(
[error, ...rollbackErrors],
`Pairing merge-driver configuration failed: ${String(error)}; `
+ `rollback also failed: ${rollbackErrors.map(String).join('; ')}`,
)
}
throw error
}
return () => {
for (const key of added.reverse()) git(['config', '--worktree', '--unset-all', key], root)
}
}
function probePairingMergeDriver(root) {
capture(process.execPath, PAIRING_MERGE_DRIVER_PROBE, { cwd: root })
}
async function main() {
if (process.env.CI === 'true' || process.env.GITHUB_ACTIONS === 'true') return
if (typeof lefthookPackage.bin?.lefthook !== 'string') return
@@ -682,7 +775,10 @@ async function main() {
applyWorktreeConfigMigration(root, commonConfigPath, migration)
let pathChanged = false
let rollbackPairingMergeDriver = () => {}
try {
probePairingMergeDriver(root)
rollbackPairingMergeDriver = installPairingMergeDriver(root, worktreeConfigPath)
git(['config', '--worktree', 'core.hooksPath', hooksPath], root)
pathChanged = worktreePath !== hooksPath
const installedEntry = effectiveConfigEntry(root, 'core.hooksPath')
@@ -697,6 +793,7 @@ async function main() {
runLefthook(root, lefthook)
updateOwnershipMarker(ownedHooksDirectory.markerPath, hooksPath)
} catch (error) {
const rollbackErrors = []
if (pathChanged) {
try {
if (worktreePath === undefined) {
@@ -705,13 +802,21 @@ async function main() {
git(['config', '--worktree', 'core.hooksPath', worktreePath], root)
}
} catch (rollbackError) {
throw new AggregateError(
[error, rollbackError],
`Lefthook installation failed: ${String(error)}; `
+ `worktree hook rollback also failed: ${String(rollbackError)}`,
)
rollbackErrors.push(rollbackError)
}
}
try {
rollbackPairingMergeDriver()
} catch (rollbackError) {
rollbackErrors.push(rollbackError)
}
if (rollbackErrors.length > 0) {
throw new AggregateError(
[error, ...rollbackErrors],
`Lefthook installation failed: ${String(error)}; `
+ `worktree integration rollback also failed: ${rollbackErrors.map(String).join('; ')}`,
)
}
throw error
}
} catch (error) {

View File

@@ -18,6 +18,9 @@ import { fileURLToPath } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
const installer = fileURLToPath(new URL('./install-lefthook.mjs', import.meta.url))
const pairingMergeDriver = 'scripts/merge-translation-pairing-driver.sh %O %A %B %P'
const scriptsDirectory = fileURLToPath(new URL('.', import.meta.url))
const tsxPackageDirectory = dirname(fileURLToPath(import.meta.resolve('tsx/package.json')))
const fixtures: string[] = []
// Multi-worktree cases spawn several Git and Node subprocesses; coverage concurrency can
// legitimately exceed Vitest's default deadline without changing the installer behavior.
@@ -95,7 +98,7 @@ if (!shouldFail) {
const binary = join(root, 'node_modules', '.bin', process.platform === 'win32' ? 'lefthook.cmd' : 'lefthook')
const config = readFileSync(join(root, 'lefthook.yml'), 'utf8').trim()
const hook = \`#!/bin/sh\\n# root=\${root}\\n# binary=\${binary}\\n# config=\${config}\\nexit 0\\n\`
for (const name of ['pre-commit', 'pre-push']) writeFileSync(join(hooksPath, name), hook, { mode: 0o755 })
for (const name of ['pre-commit', 'pre-merge-commit', 'pre-push']) writeFileSync(join(hooksPath, name), hook, { mode: 0o755 })
}
if (existsSync(running)) unlinkSync(running)
if (process.env.DSH_TEST_LEFTHOOK_BREAK_WORKTREE_CONFIG === '1') {
@@ -122,6 +125,12 @@ function installFakeLefthook(root: string): void {
chmodSync(shim, 0o755)
}
function installPairingProbeFixture(root: string): void {
const linkType = process.platform === 'win32' ? 'junction' : 'dir'
symlinkSync(scriptsDirectory, join(root, 'scripts'), linkType)
symlinkSync(tsxPackageDirectory, join(root, 'node_modules/tsx'), linkType)
}
function createFixture(names: { main?: string; linked?: string } = {}): Fixture {
const container = mkdtempSync(join(tmpdir(), 'dsh-lefthook-'))
fixtures.push(container)
@@ -151,6 +160,8 @@ function createFixture(names: { main?: string; linked?: string } = {}): Fixture
write(join(linked, 'lefthook.yml'), 'linked-worktree-config\n')
installFakeLefthook(main)
installFakeLefthook(linked)
installPairingProbeFixture(main)
installPairingProbeFixture(linked)
return fixture
}
@@ -222,6 +233,9 @@ describe('worktree-local Lefthook installer', { timeout: 15_000 }, () => {
expect(git(fixture, fixture.main, ['config', '--get', 'core.repositoryFormatVersion'])).toBe('0')
expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false)
expect(existsSync(join(common, 'config.worktree'))).toBe(false)
expect(gitResult(fixture, fixture.main, [
'config', '--get', 'merge.dsh-translation-pairing.driver',
]).status).toBe(1)
})
}
@@ -241,6 +255,12 @@ describe('worktree-local Lefthook installer', { timeout: 15_000 }, () => {
expect(mainHooks).not.toBe(linkedHooks)
expect(git(fixture, fixture.main, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(mainHooks)
expect(git(fixture, fixture.linked, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(linkedHooks)
expect(git(fixture, fixture.main, [
'config', '--worktree', '--get', 'merge.dsh-translation-pairing.driver',
])).toBe(pairingMergeDriver)
expect(git(fixture, fixture.linked, [
'config', '--worktree', '--get', 'merge.dsh-translation-pairing.driver',
])).toBe(pairingMergeDriver)
const mainHook = readFileSync(join(mainHooks, 'pre-commit'), 'utf8')
const linkedHook = readFileSync(join(linkedHooks, 'pre-commit'), 'utf8')
@@ -252,6 +272,8 @@ describe('worktree-local Lefthook installer', { timeout: 15_000 }, () => {
expect(linkedHook).toContain(`# root=${canonicalLinked}`)
expect(linkedHook).toContain('# config=linked-worktree-config')
expect(linkedHook).not.toContain(canonicalMain)
expect(existsSync(join(mainHooks, 'pre-merge-commit'))).toBe(true)
expect(existsSync(join(linkedHooks, 'pre-merge-commit'))).toBe(true)
expect(readFileSync(legacyHook, 'utf8')).toBe('#!/bin/sh\n# legacy hook\n')
const commonConfig = join(common, 'config')
@@ -275,6 +297,7 @@ describe('worktree-local Lefthook installer', { timeout: 15_000 }, () => {
git(fixture, fixture.main, ['worktree', 'add', '-b', 'late-linked', lateLinked])
write(join(lateLinked, 'lefthook.yml'), 'late-linked-worktree-config\n')
installFakeLefthook(lateLinked)
installPairingProbeFixture(lateLinked)
expect(git(fixture, lateLinked, ['config', '--worktree', '--get', 'core.hooksPath'])).toBe(mainHooks)
const linkedInstall = await runInstaller(fixture, lateLinked)
@@ -677,9 +700,50 @@ describe('worktree-local Lefthook installer', { timeout: 15_000 }, () => {
expect(result.stderr).toContain('command-scoped core.hooksPath')
expect(readFileSync(sentinel, 'utf8')).toBe('#!/bin/sh\n# command-scope sentinel\n')
expect(gitResult(fixture, fixture.main, ['config', '--get', 'core.hooksPath']).status).toBe(1)
expect(gitResult(fixture, fixture.main, [
'config', '--get', 'merge.dsh-translation-pairing.driver',
]).status).toBe(1)
expect(existsSync(hooksPath(fixture, fixture.main))).toBe(false)
})
it('never replaces a custom worktree pairing merge driver', async () => {
const fixture = createFixture()
const commonConfig = join(commonDirectory(fixture), 'config')
git(fixture, fixture.main, ['config', '--file', commonConfig, 'core.repositoryFormatVersion', '1'])
git(fixture, fixture.main, ['config', '--file', commonConfig, 'extensions.worktreeConfig', 'true'])
git(fixture, fixture.main, [
'config', '--worktree', 'merge.dsh-translation-pairing.driver', 'custom-driver %A',
])
const result = await runInstaller(fixture, fixture.main)
expect(result.status).toBe(1)
expect(result.stderr).toContain('refusing to replace worktree merge.dsh-translation-pairing.driver')
expect(git(fixture, fixture.main, [
'config', '--worktree', '--get', 'merge.dsh-translation-pairing.driver',
])).toBe('custom-driver %A')
expect(gitResult(fixture, fixture.main, ['config', '--get', 'core.hooksPath']).status).toBe(1)
})
it('never masks an inherited custom pairing merge driver', async () => {
const fixture = createFixture()
git(fixture, fixture.main, [
'config', '--local', 'merge.dsh-translation-pairing.driver', 'inherited-driver %A',
])
const result = await runInstaller(fixture, fixture.main)
expect(result.status).toBe(1)
expect(result.stderr).toContain('refusing to mask inherited merge.dsh-translation-pairing.driver')
expect(git(fixture, fixture.main, [
'config', '--local', '--get', 'merge.dsh-translation-pairing.driver',
])).toBe('inherited-driver %A')
expect(gitResult(fixture, fixture.main, [
'config', '--worktree', '--get', 'merge.dsh-translation-pairing.driver',
]).status).toBe(1)
expect(gitResult(fixture, fixture.main, ['config', '--get', 'core.hooksPath']).status).toBe(1)
})
it('does not pass unrelated command-scoped Git config to Lefthook', async () => {
const fixture = createFixture()
@@ -729,9 +793,29 @@ describe('worktree-local Lefthook installer', { timeout: 15_000 }, () => {
expect(result.stderr).toContain('exit status 77')
expect(gitResult(fixture, fixture.main, ['config', '--worktree', '--get', 'core.hooksPath']).status).toBe(1)
expect(gitResult(fixture, fixture.main, ['config', '--get', 'core.hooksPath']).status).toBe(1)
expect(gitResult(fixture, fixture.main, [
'config', '--worktree', '--get', 'merge.dsh-translation-pairing.name',
]).status).toBe(1)
expect(gitResult(fixture, fixture.main, [
'config', '--worktree', '--get', 'merge.dsh-translation-pairing.driver',
]).status).toBe(1)
expect(readFileSync(legacyHook, 'utf8')).toBe('#!/bin/sh\n# legacy pre-push\n')
})
it('does not publish worktree integration when the pairing driver probe fails', async () => {
const fixture = createFixture()
rmSync(join(fixture.main, 'node_modules/tsx'), { recursive: true, force: true })
const result = await runInstaller(fixture, fixture.main)
expect(result.status).toBe(1)
expect(result.stderr).toContain('merge-translation-pairing.ts --probe failed')
expect(gitResult(fixture, fixture.main, ['config', '--get', 'core.hooksPath']).status).toBe(1)
expect(gitResult(fixture, fixture.main, [
'config', '--get', 'merge.dsh-translation-pairing.driver',
]).status).toBe(1)
})
it('reports installation and hook-path rollback failures together', async () => {
const fixture = createFixture()
@@ -743,8 +827,9 @@ describe('worktree-local Lefthook installer', { timeout: 15_000 }, () => {
expect(result.status).toBe(1)
expect(result.stderr).toContain('Lefthook installation failed')
expect(result.stderr).toContain('exit status 77')
expect(result.stderr).toContain('worktree hook rollback also failed')
expect(result.stderr).toContain('worktree integration rollback also failed')
expect(result.stderr).toContain('git config --worktree --unset-all core.hooksPath failed')
expect(result.stderr).toContain('git config --worktree --unset-all merge.dsh-translation-pairing.driver failed')
})
it('refuses an unowned directory at the reserved worktree hook path', async () => {

View File

@@ -0,0 +1,35 @@
#!/bin/sh
if [ "$#" -ne 4 ]; then
echo 'merge-translation-pairing: expected <ancestor> <current> <other> <repository-path>' >&2
exit 129
fi
ancestor_path=$1
current_path=$2
other_path=$3
meta_path=$4
driver_directory=$(CDPATH= cd -P "$(dirname "$0")" && pwd) || exit 129
driver_path=$driver_directory/merge-translation-pairing.ts
if command -v node >/dev/null 2>&1 \
&& node --import tsx/esm "$driver_path" --probe >/dev/null 2>&1; then
exec node --import tsx/esm "$driver_path" \
"$ancestor_path" "$current_path" "$other_path" "$meta_path"
fi
echo "merge-translation-pairing: runtime is unavailable; leaving an ordinary text conflict in $meta_path" >&2
git merge-file \
-L "$meta_path:current" \
-L "$meta_path:ancestor" \
-L "$meta_path:other" \
-- "$current_path" "$ancestor_path" "$other_path"
fallback_status=$?
echo 'merge-translation-pairing: restore Node dependencies, then rerun the merge or `pnpm run resolve-translation-pairing-conflicts`; use `git merge --abort` to cancel' >&2
# A clean text merge is still unverified pairing metadata, so the driver must
# leave Git's index stages unresolved until the repository-aware resolver runs.
if [ "$fallback_status" -gt 127 ]; then
exit "$fallback_status"
fi
exit 1

View File

@@ -0,0 +1,51 @@
/** Git merge-driver and explicit conflict-resolver entrypoint for pairing records. */
import { execFileSync } from 'node:child_process'
import { readFileSync, writeFileSync } from 'node:fs'
import {
mergeTranslationPairingRecords,
resolveTranslationPairingConflicts,
} from './translation-pairing-merge.ts'
const args = process.argv.slice(2)
try {
if (args[0] === '--probe') {
if (args.length !== 1) throw new Error('--probe takes no other arguments')
} else {
const root = execFileSync('git', ['rev-parse', '--show-toplevel'], { encoding: 'utf8' }).trim()
if (args[0] === '--resolve') {
if (args.length !== 1) throw new Error('--resolve takes no paths; it inspects the unmerged index')
const resolved = resolveTranslationPairingConflicts(root)
if (resolved.length === 0) {
console.log('merge-translation-pairing: no unresolved pairing records')
} else {
for (const path of resolved) console.log(`merge-translation-pairing: resolved ${path}`)
}
} else {
if (args.length !== 4) {
throw new Error('merge-driver mode requires <ancestor> <current> <other> <repository-path>')
}
const [ancestorPath, currentPath, otherPath, metaPath] = args
if (ancestorPath === undefined || currentPath === undefined || otherPath === undefined || metaPath === undefined) {
throw new Error('merge-driver arguments are incomplete')
}
const result = mergeTranslationPairingRecords(
root,
metaPath,
readFileSync(ancestorPath, 'utf8'),
readFileSync(currentPath, 'utf8'),
readFileSync(otherPath, 'utf8'),
)
writeFileSync(currentPath, result.record)
}
}
} catch (error) {
console.error(`merge-translation-pairing: ${error instanceof Error ? error.message : String(error)}`)
console.error(
'merge-translation-pairing: resolve owner conflicts, then confirm the pair with '
+ '`pnpm run verify-translation-pairing --write <pair>`; rerun '
+ '`pnpm run resolve-translation-pairing-conflicts` for other safe records',
)
process.exitCode = 1
}

File diff suppressed because one or more lines are too long

View File

@@ -5,6 +5,9 @@ import { createHash } from 'node:crypto'
const SNAPSHOT_REF_PREFIX = 'refs/dsh/translation-pairing/snapshots'
/** Maximum buffered stdout or stderr for repository-owned Git subprocesses. */
export const GIT_COMMAND_MAX_BUFFER = 1 << 26
/** Full SHA-1 Git blob hash (the 40-hex format used by pairing records). */
export function gitBlobHash(content: Buffer): string {
const hash = createHash('sha1')
@@ -13,10 +16,20 @@ export function gitBlobHash(content: Buffer): string {
return hash.digest('hex')
}
function runGit(root: string, args: string[], operation: string, input?: Buffer): Buffer {
/**
* Run one Git subprocess and return its exact stdout bytes.
*
* @param root - Repository root used as Git's working directory.
* @param args - Arguments following the `git` executable.
* @param operation - Human-readable operation for failure diagnostics.
* @param input - Optional stdin bytes.
* @returns Exact stdout bytes.
* @throws Error when Git cannot start or exits unsuccessfully.
*/
export function runGit(root: string, args: string[], operation: string, input?: Buffer): Buffer {
const result = spawnSync('git', ['-C', root, ...args], {
input,
maxBuffer: 1 << 26,
maxBuffer: GIT_COMMAND_MAX_BUFFER,
})
if (result.error) {
throw new Error(`${operation} failed: ${result.error.message}`, { cause: result.error })
@@ -27,6 +40,39 @@ function runGit(root: string, args: string[], operation: string, input?: Buffer)
return result.stdout
}
/** One regular stage-zero Git index entry and its exact blob bytes. */
export interface GitIndexBlob {
/** Object ID recorded in the index. */
objectId: string
/** Blob bytes stored under that object ID. */
content: Buffer
}
/**
* Read one path from the Git index without consulting working-tree bytes.
*
* @param root - Repository root.
* @param path - Repository-relative path.
* @returns The stage-zero blob, or `undefined` when the path is absent.
* @throws Error when the path is unmerged or has an invalid index shape.
*/
export function readGitIndexBlob(root: string, path: string): GitIndexBlob | undefined {
const output = runGit(
root,
['ls-files', '--stage', '-z', '--', path],
`git ls-files --stage for ${path}`,
).toString('utf8')
const entries = output.split('\0').filter(Boolean)
if (entries.length === 0) return undefined
if (entries.length !== 1) throw new Error(`${path} does not have exactly one resolved index entry`)
const match = /^(?:\d+) ([0-9a-f]+) 0\t[\s\S]+$/.exec(entries[0] ?? '')
if (!match?.[1]) throw new Error(`${path} remains unmerged or has an invalid index entry`)
return {
objectId: match[1],
content: runGit(root, ['cat-file', 'blob', match[1]], `reading staged ${path}`),
}
}
/**
* Persist exact working-tree bytes so a pairing record can later recover them
* with `git cat-file`, even when they have never appeared in the index or a

View File

@@ -0,0 +1,508 @@
/** Integration coverage for automatic and explicit pairing-record conflict resolution. */
import { execFileSync, spawnSync } from 'node:child_process'
import { chmodSync, mkdtempSync, mkdirSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { delimiter, dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterEach, describe, expect, it } from 'vitest'
import { gitBlobHash, storeGitBlob } from './translation-pairing-git.ts'
import {
mergeTranslationPairingRecords,
resolveTranslationPairingConflicts,
} from './translation-pairing-merge.ts'
import {
renderTranslationPairingRecord,
translationPairPaths,
} from './translation-pairing-record.ts'
const driver = fileURLToPath(new URL('./merge-translation-pairing.ts', import.meta.url))
const driverLauncher = fileURLToPath(new URL('./merge-translation-pairing-driver.sh', import.meta.url))
const workspaceRoot = fileURLToPath(new URL('../', import.meta.url))
const tsxLoader = fileURLToPath(import.meta.resolve('tsx/esm'))
const fixtures: string[] = []
interface Fixture {
env: NodeJS.ProcessEnv
root: string
}
afterEach(() => {
for (const fixture of fixtures.splice(0)) rmSync(fixture, { recursive: true, force: true })
})
function git(fixture: Fixture, args: string[]): string {
return execFileSync('git', ['-C', fixture.root, ...args], {
encoding: 'utf8',
env: fixture.env,
}).trim()
}
function write(root: string, path: string, content: string): void {
const absolute = join(root, path)
mkdirSync(dirname(absolute), { recursive: true })
writeFileSync(absolute, content)
}
function shellQuote(value: string): string {
return `"${value.replace(/["\\$`]/g, '\\$&')}"`
}
function installFixtureRuntime(root: string): void {
const linkType = process.platform === 'win32' ? 'junction' : 'dir'
symlinkSync(
join(workspaceRoot, 'node_modules'),
join(root, 'node_modules'),
linkType,
)
symlinkSync(join(workspaceRoot, 'scripts'), join(root, 'scripts'), linkType)
}
function startMergeWithFakeNode(
fixture: Fixture,
nodeScript = '#!/bin/sh\nexit 72\n',
) {
const fakeBin = join(fixture.root, 'fake-bin')
const fakeNode = join(fakeBin, 'node')
write(fixture.root, 'fake-bin/node', nodeScript)
chmodSync(fakeNode, 0o755)
git(fixture, [
'config',
'merge.dsh-translation-pairing.driver',
`${shellQuote(driverLauncher)} %O %A %B %P`,
])
return spawnSync('git', ['-C', fixture.root, 'merge', '--no-commit', 'master'], {
encoding: 'utf8',
env: {
...fixture.env,
PATH: `${fakeBin}${delimiter}${fixture.env.PATH ?? ''}`,
},
})
}
function createFixture(attributes = true): Fixture {
const root = mkdtempSync(join(tmpdir(), 'dsh-translation-pairing-merge-'))
fixtures.push(root)
const env: NodeJS.ProcessEnv = {
...process.env,
GIT_AUTHOR_EMAIL: 'pairing@example.test',
GIT_AUTHOR_NAME: 'Pairing Test',
GIT_COMMITTER_EMAIL: 'pairing@example.test',
GIT_COMMITTER_NAME: 'Pairing Test',
GIT_CONFIG_GLOBAL: join(root, 'global.gitconfig'),
GIT_CONFIG_NOSYSTEM: '1',
GIT_DEFAULT_HASH: 'sha1',
}
const fixture = { env, root }
execFileSync('git', ['init', '--quiet', '--initial-branch=master', root], { env })
if (attributes) write(root, '.gitattributes', '*.i18n.yaml merge=dsh-translation-pairing\n')
return fixture
}
function record(root: string, path: string, source: string, zh: string): string {
const paths = translationPairPaths(path)
write(root, paths.source, source)
write(root, paths.zh, zh)
const content = renderTranslationPairingRecord(paths, {
sourceHash: storeGitBlob(root, Buffer.from(source)),
zhHash: storeGitBlob(root, Buffer.from(zh)),
})
write(root, paths.meta, content)
return content
}
const baseSource = '# Guide\n\nEnglish | [中文](guide.zh.md)\n\nAlpha base.\n\nBeta base.\n'
const baseZh = '# 指南\n\n[English](guide.md) | 中文\n\n甲基础。\n\n乙基础。\n'
const currentSource = baseSource.replace('Alpha base.', 'Alpha current.')
const currentZh = baseZh.replace('甲基础。', '甲当前。')
const otherSource = baseSource.replace('Beta base.', 'Beta other.')
const otherZh = baseZh.replace('乙基础。', '乙对侧。')
const mergedSource = currentSource.replace('Beta base.', 'Beta other.')
const mergedZh = currentZh.replace('乙基础。', '乙对侧。')
const manualBaseSource = baseSource.replace('guide.zh.md', 'manual.zh.md')
const manualBaseZh = baseZh.replace('guide.md', 'manual.md')
const manualCurrentSource = manualBaseSource.replace('Alpha base.', 'Alpha current.')
const manualCurrentZh = manualBaseZh.replace('甲基础。', '甲当前。')
const manualOtherSource = manualBaseSource.replace('Alpha base.', 'Alpha other.')
const manualOtherZh = manualBaseZh.replace('甲基础。', '甲对侧。')
function commitPair(fixture: Fixture, source: string, zh: string, message: string): string {
const sidecar = record(fixture.root, 'docs/guide.md', source, zh)
git(fixture, ['add', '.'])
git(fixture, ['commit', '-m', message])
return sidecar
}
function commitTextCleanPair(fixture: Fixture, source: string, zh: string, message: string): void {
const sidecar = record(fixture.root, 'docs/guide.md', source, zh)
write(
fixture.root,
'docs/guide.i18n.yaml',
sidecar.replace('\nguide.zh.md:', '\n# Stable separator for independent line merges.\nguide.zh.md:'),
)
git(fixture, ['add', '.'])
git(fixture, ['commit', '-m', message])
}
function createDivergedPair(fixture: Fixture): { ancestor: string; current: string; other: string } {
const ancestor = commitPair(fixture, baseSource, baseZh, 'base')
git(fixture, ['switch', '-c', 'current'])
const current = commitPair(fixture, currentSource, currentZh, 'current')
git(fixture, ['switch', 'master'])
const other = commitPair(fixture, otherSource, otherZh, 'other')
git(fixture, ['switch', 'current'])
return { ancestor, current, other }
}
function createTextCleanDivergedPair(fixture: Fixture): void {
commitTextCleanPair(fixture, baseSource, baseZh, 'base')
git(fixture, ['switch', '-c', 'current'])
commitTextCleanPair(fixture, currentSource, baseZh, 'current source')
git(fixture, ['switch', 'master'])
commitTextCleanPair(fixture, baseSource, otherZh, 'other translation')
git(fixture, ['switch', 'current'])
}
function startStoppedPairingMerge(fixture: Fixture): void {
createDivergedPair(fixture)
const merge = spawnSync('git', ['-C', fixture.root, 'merge', '--no-commit', 'master'], {
encoding: 'utf8',
env: fixture.env,
})
expect(merge.status).toBe(1)
expect(git(fixture, ['diff', '--name-only', '--diff-filter=U'])).toBe('docs/guide.i18n.yaml')
}
function commitMixedPairs(
fixture: Fixture,
guide: { source: string; zh: string },
manual: { source: string; zh: string },
message: string,
): void {
record(fixture.root, 'docs/guide.md', guide.source, guide.zh)
record(fixture.root, 'docs/manual.md', manual.source, manual.zh)
git(fixture, ['add', '.'])
git(fixture, ['commit', '-m', message])
}
function startMixedPairingMerge(fixture: Fixture): void {
commitMixedPairs(
fixture,
{ source: baseSource, zh: baseZh },
{ source: manualBaseSource, zh: manualBaseZh },
'base',
)
git(fixture, ['switch', '-c', 'current'])
commitMixedPairs(
fixture,
{ source: currentSource, zh: currentZh },
{ source: manualCurrentSource, zh: manualCurrentZh },
'current',
)
git(fixture, ['switch', 'master'])
commitMixedPairs(
fixture,
{ source: otherSource, zh: otherZh },
{ source: manualOtherSource, zh: manualOtherZh },
'other',
)
git(fixture, ['switch', 'current'])
const merge = spawnSync('git', ['-C', fixture.root, 'merge', '--no-commit', 'master'], {
encoding: 'utf8',
env: fixture.env,
})
expect(merge.status).toBe(1)
}
function expectMergedPair(fixture: Fixture): void {
expect(readFileSync(join(fixture.root, 'docs/guide.md'), 'utf8')).toBe(mergedSource)
expect(readFileSync(join(fixture.root, 'docs/guide.zh.md'), 'utf8')).toBe(mergedZh)
expect(readFileSync(join(fixture.root, 'docs/guide.i18n.yaml'), 'utf8')).toBe(
renderTranslationPairingRecord(translationPairPaths('docs/guide.md'), {
sourceHash: gitBlobHash(Buffer.from(mergedSource)),
zhHash: gitBlobHash(Buffer.from(mergedZh)),
}),
)
}
describe('translation pairing merge composition', () => {
it('rejects a pairing-record path outside the repository', () => {
const fixture = createFixture(false)
expect(() => mergeTranslationPairingRecords(
fixture.root,
'../guide.i18n.yaml',
'',
'',
'',
)).toThrow('pairing record escapes the repository')
})
it('merges the owner blobs named by three valid records', () => {
const fixture = createFixture(false)
git(fixture, ['config', 'merge.default', 'text'])
const records = createDivergedPair(fixture)
const result = mergeTranslationPairingRecords(
fixture.root,
'docs/guide.i18n.yaml',
records.ancestor,
records.current,
records.other,
)
expect(result.sourceContent.toString('utf8')).toBe(mergedSource)
expect(result.zhContent.toString('utf8')).toBe(mergedZh)
expect(result.sourceHash).toBe(gitBlobHash(Buffer.from(mergedSource)))
expect(result.zhHash).toBe(gitBlobHash(Buffer.from(mergedZh)))
})
it('leaves owner-content conflicts for a human', () => {
const fixture = createFixture(false)
const ancestor = record(fixture.root, 'docs/guide.md', baseSource, baseZh)
const current = record(
fixture.root,
'docs/guide.md',
baseSource.replace('Alpha base.', 'Alpha current.'),
baseZh.replace('甲基础。', '甲当前。'),
)
const other = record(
fixture.root,
'docs/guide.md',
baseSource.replace('Alpha base.', 'Alpha other.'),
baseZh.replace('甲基础。', '甲对侧。'),
)
expect(() => mergeTranslationPairingRecords(
fixture.root,
'docs/guide.i18n.yaml',
ancestor,
current,
other,
)).toThrow('docs/guide.md has content conflicts')
})
it('rejects structurally divergent clean owner merges', () => {
const fixture = createFixture(false)
const ancestor = record(fixture.root, 'docs/guide.md', baseSource, baseZh)
const current = record(fixture.root, 'docs/guide.md', currentSource, currentZh)
const other = record(
fixture.root,
'docs/guide.md',
`${otherSource}\n## Extra\n`,
otherZh,
)
expect(() => mergeTranslationPairingRecords(
fixture.root,
'docs/guide.i18n.yaml',
ancestor,
current,
other,
)).toThrow('clean merges diverge structurally')
})
it('refuses owners assigned to another merge strategy', () => {
const fixture = createFixture(false)
write(fixture.root, '.gitattributes', 'docs/*.md merge=custom-owner\n')
const records = createDivergedPair(fixture)
expect(() => mergeTranslationPairingRecords(
fixture.root,
'docs/guide.i18n.yaml',
records.ancestor,
records.current,
records.other,
)).toThrow('docs/guide.md uses merge=custom-owner')
})
it('refuses unspecified owners affected by merge.default', () => {
const fixture = createFixture(false)
git(fixture, ['config', 'merge.default', 'custom-owner'])
const records = createDivergedPair(fixture)
expect(() => mergeTranslationPairingRecords(
fixture.root,
'docs/guide.i18n.yaml',
records.ancestor,
records.current,
records.other,
)).toThrow('merge.default=custom-owner')
})
it('runs as Git\'s custom driver and commits a clean composed record', () => {
const fixture = createFixture()
createDivergedPair(fixture)
installFixtureRuntime(fixture.root)
git(fixture, [
'config',
'merge.dsh-translation-pairing.driver',
'scripts/merge-translation-pairing-driver.sh %O %A %B %P',
])
git(fixture, ['merge', '--no-edit', 'master'])
expect(git(fixture, ['diff', '--name-only', '--diff-filter=U'])).toBe('')
expectMergedPair(fixture)
})
it('leaves an ordinary recoverable conflict when the configured runtime is unavailable', () => {
const fixture = createFixture()
const records = createDivergedPair(fixture)
const headBefore = git(fixture, ['rev-parse', 'HEAD'])
const result = startMergeWithFakeNode(fixture)
expect(result.status).toBe(1)
expect(result.stderr).toContain('runtime is unavailable; leaving an ordinary text conflict')
expect(git(fixture, ['rev-parse', 'HEAD'])).toBe(headBefore)
expect(git(fixture, ['rev-parse', '--verify', 'MERGE_HEAD'])).not.toBe('')
expect(git(fixture, ['diff', '--name-only', '--diff-filter=U'])).toBe('docs/guide.i18n.yaml')
expect(git(fixture, ['ls-files', '--unmerged', '--', 'docs/guide.i18n.yaml']).split('\n')).toHaveLength(3)
const conflicted = readFileSync(join(fixture.root, 'docs/guide.i18n.yaml'), 'utf8')
expect(conflicted).toContain('<<<<<<< docs/guide.i18n.yaml:current')
for (const record of [records.current, records.other]) {
const dataLines = record.split('\n').filter(line => line !== '' && !line.startsWith('#')).join('\n')
expect(conflicted).toContain(dataLines)
}
expect(resolveTranslationPairingConflicts(fixture.root)).toEqual(['docs/guide.i18n.yaml'])
expectMergedPair(fixture)
})
it('falls back before a broken driver entrypoint can replace the launcher', () => {
const fixture = createFixture()
createDivergedPair(fixture)
const result = startMergeWithFakeNode(
fixture,
'#!/bin/sh\nif [ "$3" = "--eval" ]; then exit 0; fi\nexit 72\n',
)
expect(result.status).toBe(1)
expect(result.stderr).toContain('runtime is unavailable; leaving an ordinary text conflict')
expect(readFileSync(join(fixture.root, 'docs/guide.i18n.yaml'), 'utf8')).toContain(
'<<<<<<< docs/guide.i18n.yaml:current',
)
})
it('keeps a clean text fallback unresolved until the explicit resolver confirms it', () => {
const fixture = createFixture()
createTextCleanDivergedPair(fixture)
const result = startMergeWithFakeNode(fixture)
expect(result.status).toBe(1)
expect(git(fixture, ['diff', '--name-only', '--diff-filter=U'])).toBe('docs/guide.i18n.yaml')
const canonicalRecord = renderTranslationPairingRecord(translationPairPaths('docs/guide.md'), {
sourceHash: gitBlobHash(Buffer.from(currentSource)),
zhHash: gitBlobHash(Buffer.from(otherZh)),
})
expect(readFileSync(join(fixture.root, 'docs/guide.i18n.yaml'), 'utf8')).toBe(
canonicalRecord.replace(
'\nguide.zh.md:',
'\n# Stable separator for independent line merges.\nguide.zh.md:',
),
)
expect(resolveTranslationPairingConflicts(fixture.root)).toEqual(['docs/guide.i18n.yaml'])
expect(readFileSync(join(fixture.root, 'docs/guide.md'), 'utf8')).toBe(currentSource)
expect(readFileSync(join(fixture.root, 'docs/guide.zh.md'), 'utf8')).toBe(otherZh)
expect(readFileSync(join(fixture.root, 'docs/guide.i18n.yaml'), 'utf8')).toBe(canonicalRecord)
})
it('leaves a staged merge when the pre-merge-commit hook rejects it', () => {
const fixture = createFixture()
createDivergedPair(fixture)
installFixtureRuntime(fixture.root)
git(fixture, [
'config',
'merge.dsh-translation-pairing.driver',
'scripts/merge-translation-pairing-driver.sh %O %A %B %P',
])
const hooks = join(fixture.root, 'hooks')
write(
fixture.root,
'hooks/pre-merge-commit',
'#!/bin/sh\necho "fixture pre-merge-commit rejection" >&2\nexit 77\n',
)
chmodSync(join(hooks, 'pre-merge-commit'), 0o755)
git(fixture, ['config', 'core.hooksPath', hooks])
const headBefore = git(fixture, ['rev-parse', 'HEAD'])
const result = spawnSync('git', ['-C', fixture.root, 'merge', '--no-edit', 'master'], {
encoding: 'utf8',
env: fixture.env,
})
expect(result.status).toBe(1)
expect(result.stderr).toContain('fixture pre-merge-commit rejection')
expect(git(fixture, ['rev-parse', 'HEAD'])).toBe(headBefore)
expect(git(fixture, ['rev-parse', '--verify', 'MERGE_HEAD'])).not.toBe('')
expect(git(fixture, ['diff', '--name-only', '--diff-filter=U'])).toBe('')
expect(git(fixture, ['diff', '--cached', '--name-only']).split('\n')).toContain(
'docs/guide.i18n.yaml',
)
expectMergedPair(fixture)
})
it('prints the recovery path when driver input is not composable', () => {
const fixture = createFixture(false)
const result = spawnSync(process.execPath, ['--import', tsxLoader, driver], {
cwd: fixture.root,
encoding: 'utf8',
env: fixture.env,
})
expect(result.status).toBe(1)
expect(result.stderr).toContain('pnpm run verify-translation-pairing --write <pair>')
expect(result.stderr).toContain('pnpm run resolve-translation-pairing-conflicts')
})
it('resolves an already-stopped generated-only conflict from index stages', () => {
const fixture = createFixture(false)
startStoppedPairingMerge(fixture)
expect(resolveTranslationPairingConflicts(fixture.root)).toEqual(['docs/guide.i18n.yaml'])
expect(git(fixture, ['diff', '--name-only', '--diff-filter=U'])).toBe('')
expectMergedPair(fixture)
})
it('refuses to confirm unstaged owner bytes after a stopped merge', () => {
const fixture = createFixture(false)
startStoppedPairingMerge(fixture)
write(fixture.root, 'docs/guide.md', `${mergedSource}\nunstaged\n`)
expect(() => resolveTranslationPairingConflicts(fixture.root)).toThrow(
'docs/guide.md has unstaged content',
)
expect(git(fixture, ['diff', '--name-only', '--diff-filter=U'])).toBe('docs/guide.i18n.yaml')
})
it('refuses to overwrite an edited sidecar after a stopped merge', () => {
const fixture = createFixture(false)
startStoppedPairingMerge(fixture)
write(fixture.root, 'docs/guide.i18n.yaml', 'manually resolved\n')
expect(() => resolveTranslationPairingConflicts(fixture.root)).toThrow(
'docs/guide.i18n.yaml has edited conflict content',
)
expect(readFileSync(join(fixture.root, 'docs/guide.i18n.yaml'), 'utf8')).toBe('manually resolved\n')
expect(git(fixture, ['diff', '--name-only', '--diff-filter=U'])).toBe('docs/guide.i18n.yaml')
})
it('resolves safe records while leaving an owner-conflicted pair untouched', () => {
const fixture = createFixture(false)
startMixedPairingMerge(fixture)
expect(() => resolveTranslationPairingConflicts(fixture.root)).toThrow(
'docs/manual.i18n.yaml: docs/manual.md has content conflicts',
)
expect(git(fixture, ['diff', '--name-only', '--diff-filter=U']).split('\n')).toEqual([
'docs/manual.i18n.yaml',
'docs/manual.md',
'docs/manual.zh.md',
])
expectMergedPair(fixture)
})
})

View File

@@ -0,0 +1,338 @@
/** Fail-closed composition of bilingual pairing records during Git merges. */
import { spawnSync } from 'node:child_process'
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { basename, isAbsolute, join, relative, resolve, sep } from 'node:path'
import {
GIT_COMMAND_MAX_BUFFER,
gitBlobHash,
readGitIndexBlob,
runGit,
storeGitBlob,
} from './translation-pairing-git.ts'
import {
linksTo,
isTranslationScopeFile,
parseTranslationMarkdown,
translationStructureDiff,
translationStructureSignature,
} from './translation-pairing.ts'
import {
parseTranslationPairingRecord,
renderTranslationPairingRecord,
translationPairPathsFromMeta,
type TranslationPairPaths,
type TranslationPairingRecord,
} from './translation-pairing-record.ts'
const UNMERGED_ENTRY = /^(\d+) ([0-9a-f]+) ([123])\t([\s\S]+)$/
/** A mechanically composed record and the exact merged owner contents it names. */
export interface TranslationPairingMergeResult extends TranslationPairingRecord {
/** Canonical generated sidecar text. */
record: string
/** Clean three-way merge of the English owner. */
sourceContent: Buffer
/** Clean three-way merge of the Simplified Chinese owner. */
zhContent: Buffer
}
interface UnmergedStages {
ancestor?: string
current?: string
other?: string
}
function readGitBlob(root: string, objectId: string, owner: string): Buffer {
const content = runGit(root, ['cat-file', 'blob', objectId], `reading ${owner} blob ${objectId}`)
if (gitBlobHash(content) !== objectId) {
throw new Error(`${owner} record names ${objectId}, which is not its SHA-1 git blob hash`)
}
return content
}
function readMergeDefault(root: string): string | undefined {
const result = spawnSync('git', ['-C', root, 'config', '--get', 'merge.default'], {
maxBuffer: GIT_COMMAND_MAX_BUFFER,
})
if (result.error) {
throw new Error(`reading merge.default failed: ${result.error.message}`, { cause: result.error })
}
if (result.status === 1) return undefined
if (result.status !== 0) {
throw new Error(
`reading merge.default failed with status ${String(result.status)}: ${result.stderr.toString('utf8').trim()}`,
)
}
return result.stdout.toString('utf8').trim()
}
function assertDefaultTextMerge(root: string, paths: TranslationPairPaths): void {
const output = runGit(
root,
['check-attr', '-z', 'merge', '--', paths.source, paths.zh],
'checking bilingual owner merge attributes',
).toString('utf8')
const fields = output.split('\0')
fields.pop()
let mergeDefault: string | undefined
for (let index = 0; index < fields.length; index += 3) {
const path = fields[index]
const value = fields[index + 2]
if (path === undefined || value === undefined) {
throw new Error('git check-attr returned a malformed result')
}
if (!['unspecified', 'set', 'text'].includes(value)) {
throw new Error(`${path} uses merge=${value}; the pairing driver only composes Git's default text merge`)
}
if (value === 'unspecified') {
mergeDefault ??= readMergeDefault(root)
if (mergeDefault !== undefined && mergeDefault !== 'text') {
throw new Error(
`${path} inherits merge.default=${mergeDefault}; the pairing driver only composes Git's default text merge`,
)
}
}
}
}
function runTextMerge(
root: string,
label: string,
ancestor: Buffer | string,
current: Buffer | string,
other: Buffer | string,
): { output: Buffer; status: number | null } {
const temporary = mkdtempSync(join(tmpdir(), 'dsh-translation-pairing-merge-'))
try {
const ancestorPath = join(temporary, 'ancestor')
const currentPath = join(temporary, 'current')
const otherPath = join(temporary, 'other')
writeFileSync(ancestorPath, ancestor)
writeFileSync(currentPath, current)
writeFileSync(otherPath, other)
const result = spawnSync('git', [
'-C', root,
'merge-file', '-p',
'-L', `${label}:current`,
'-L', `${label}:ancestor`,
'-L', `${label}:other`,
currentPath, ancestorPath, otherPath,
], { maxBuffer: GIT_COMMAND_MAX_BUFFER })
if (result.error) {
throw new Error(`merging ${label} failed: ${result.error.message}`, { cause: result.error })
}
return { output: result.stdout, status: result.status }
} finally {
rmSync(temporary, { recursive: true, force: true })
}
}
function mergeBlobTriplet(
root: string,
owner: string,
ancestor: Buffer,
current: Buffer,
other: Buffer,
): Buffer {
const result = runTextMerge(root, owner, ancestor, current, other)
if (result.status !== 0) {
const kind = result.status !== null && result.status > 0 && result.status <= 127
? 'has content conflicts'
: `failed with status ${String(result.status)}`
throw new Error(`${owner} ${kind}`)
}
return result.output
}
function loadRecordOwners(
root: string,
label: string,
content: string,
paths: TranslationPairPaths,
): { source: Buffer; zh: Buffer } {
const record = parseTranslationPairingRecord(content, paths)
if (record === undefined) throw new Error(`${label} ${paths.meta} is not a valid two-hash pairing record`)
return {
source: readGitBlob(root, record.sourceHash, `${label} ${paths.source}`),
zh: readGitBlob(root, record.zhHash, `${label} ${paths.zh}`),
}
}
function assertMergedPairStructure(paths: TranslationPairPaths, source: Buffer, zh: Buffer): void {
const sourceTree = parseTranslationMarkdown(source.toString('utf8'))
const zhTree = parseTranslationMarkdown(zh.toString('utf8'))
if (!linksTo(sourceTree, basename(paths.zh))) {
throw new Error(`${paths.source} clean merge lost its language-switcher link to ${basename(paths.zh)}`)
}
if (!linksTo(zhTree, basename(paths.source))) {
throw new Error(`${paths.zh} clean merge lost its language-switcher link to ${basename(paths.source)}`)
}
const divergences = translationStructureDiff(
translationStructureSignature(sourceTree, basename(paths.zh)),
translationStructureSignature(zhTree, basename(paths.source)),
)
if (divergences.length > 0) {
throw new Error(`${paths.source} and ${paths.zh} clean merges diverge structurally: ${divergences.join('; ')}`)
}
}
function normalizeMetaPath(root: string, meta: string): string {
if (isAbsolute(meta)) throw new Error(`pairing record must be repository-relative: ${JSON.stringify(meta)}`)
const repositoryRelative = relative(resolve(root), resolve(root, meta))
if (repositoryRelative === '' || repositoryRelative === '..' || repositoryRelative.startsWith(`..${sep}`)) {
throw new Error(`pairing record escapes the repository: ${JSON.stringify(meta)}`)
}
return repositoryRelative.split(sep).join('/')
}
/**
* Compose one generated sidecar from the ancestor, current, and other records.
*
* Each input record is already a confirmation of its two owner blobs. The
* result exists only when Git's default text merge succeeds independently for
* both languages and the composed documents retain the pairing structure.
*
* @param root - Repository root containing the referenced Git objects.
* @param metaPath - Repository-relative sidecar path.
* @param ancestorRecord - Common-ancestor sidecar text.
* @param currentRecord - Current-side sidecar text.
* @param otherRecord - Other-side sidecar text.
* @returns The canonical record and exact merged owner contents.
* @throws Error when the input is not mechanically composable.
*/
export function mergeTranslationPairingRecords(
root: string,
metaPath: string,
ancestorRecord: string,
currentRecord: string,
otherRecord: string,
): TranslationPairingMergeResult {
const normalizedMeta = normalizeMetaPath(root, metaPath)
if (!isTranslationScopeFile(normalizedMeta)) {
throw new Error(`${normalizedMeta} is outside the active bilingual documentation corpus`)
}
const paths = translationPairPathsFromMeta(normalizedMeta)
assertDefaultTextMerge(root, paths)
const ancestor = loadRecordOwners(root, 'ancestor', ancestorRecord, paths)
const current = loadRecordOwners(root, 'current', currentRecord, paths)
const other = loadRecordOwners(root, 'other', otherRecord, paths)
const sourceContent = mergeBlobTriplet(root, paths.source, ancestor.source, current.source, other.source)
const zhContent = mergeBlobTriplet(root, paths.zh, ancestor.zh, current.zh, other.zh)
assertMergedPairStructure(paths, sourceContent, zhContent)
const sourceHash = storeGitBlob(root, sourceContent)
const zhHash = storeGitBlob(root, zhContent)
return {
record: renderTranslationPairingRecord(paths, { sourceHash, zhHash }),
sourceContent,
sourceHash,
zhContent,
zhHash,
}
}
function unmergedSidecars(root: string): Map<string, UnmergedStages> {
const output = runGit(root, ['ls-files', '--unmerged', '-z'], 'listing unresolved merge entries').toString('utf8')
const records = new Map<string, UnmergedStages>()
for (const entry of output.split('\0')) {
if (entry === '') continue
const match = UNMERGED_ENTRY.exec(entry)
if (!match?.[2] || !match[3] || match[4] === undefined) {
throw new Error(`git ls-files returned a malformed unmerged entry: ${JSON.stringify(entry)}`)
}
const path = match[4]
if (!path.endsWith('.i18n.yaml')) continue
const stages = records.get(path) ?? {}
const field = match[3] === '1' ? 'ancestor' : match[3] === '2' ? 'current' : 'other'
stages[field] = match[2]
records.set(path, stages)
}
return records
}
function assertUneditedSidecar(
root: string,
metaPath: string,
ancestorRecord: string,
currentRecord: string,
otherRecord: string,
): void {
const worktreeRecord = readFileSync(join(root, metaPath), 'utf8')
if (worktreeRecord === currentRecord || worktreeRecord === otherRecord) return
const textMerge = runTextMerge(root, metaPath, ancestorRecord, currentRecord, otherRecord)
if (textMerge.status === 0 && textMerge.output.toString('utf8') === worktreeRecord) return
const stageDataLines = [currentRecord, otherRecord]
.flatMap(record => record.split(/\r?\n/))
.filter(line => line !== '' && !line.startsWith('#'))
const hasUneditedConflict = worktreeRecord.includes('<<<<<<<')
&& worktreeRecord.includes('=======')
&& worktreeRecord.includes('>>>>>>>')
&& stageDataLines.every(line => worktreeRecord.includes(line))
if (!hasUneditedConflict) {
throw new Error(`${metaPath} has edited conflict content; refusing to overwrite manual work`)
}
}
/**
* Resolve every mechanically composable `.i18n.yaml` conflict in the index.
*
* The command first proves that Git's already-staged owner merges match the
* independently composed contents, then writes and stages all sidecars as one
* batch. Other conflicts remain untouched; after staging the safe records, an
* aggregate error reports any pairing conflicts that still need manual work.
*
* @param root - Repository root with an in-progress merge-like operation.
* @returns Repository-relative sidecar paths resolved and staged.
*/
export function resolveTranslationPairingConflicts(root: string): string[] {
const resolutions: { path: string; record: string }[] = []
const failures: { path: string; reason: string }[] = []
for (const [metaPath, stages] of [...unmergedSidecars(root)].sort(([left], [right]) => left.localeCompare(right))) {
try {
if (stages.ancestor === undefined || stages.current === undefined || stages.other === undefined) {
throw new Error('is an add/delete or incomplete-stage conflict and requires manual resolution')
}
const ancestorRecord = readGitBlob(root, stages.ancestor, `ancestor ${metaPath}`).toString('utf8')
const currentRecord = readGitBlob(root, stages.current, `current ${metaPath}`).toString('utf8')
const otherRecord = readGitBlob(root, stages.other, `other ${metaPath}`).toString('utf8')
assertUneditedSidecar(root, metaPath, ancestorRecord, currentRecord, otherRecord)
const result = mergeTranslationPairingRecords(
root,
metaPath,
ancestorRecord,
currentRecord,
otherRecord,
)
const paths = translationPairPathsFromMeta(metaPath)
if (readGitIndexBlob(root, paths.source)?.objectId !== result.sourceHash) {
throw new Error(`${paths.source} staged merge does not match the pairing driver's clean merge`)
}
if (readGitIndexBlob(root, paths.zh)?.objectId !== result.zhHash) {
throw new Error(`${paths.zh} staged merge does not match the pairing driver's clean merge`)
}
for (const [path, expected] of [[paths.source, result.sourceHash], [paths.zh, result.zhHash]] as const) {
if (gitBlobHash(readFileSync(join(root, path))) !== expected) {
throw new Error(`${path} has unstaged content; refusing to confirm bytes outside the merge result`)
}
}
resolutions.push({ path: metaPath, record: result.record })
} catch (error) {
failures.push({ path: metaPath, reason: error instanceof Error ? error.message : String(error) })
}
}
for (const resolution of resolutions) writeFileSync(join(root, resolution.path), resolution.record)
if (resolutions.length > 0) {
runGit(root, ['add', '--', ...resolutions.map(resolution => resolution.path)], 'staging resolved pairing records')
}
if (failures.length > 0) {
const resolved = resolutions.length === 0
? ''
: `resolved and staged ${resolutions.map(resolution => resolution.path).join(', ')}; `
throw new Error(
`${resolved}left ${String(failures.length)} pairing conflict(s) unresolved:\n`
+ failures.map(failure => `- ${failure.path}: ${failure.reason}`).join('\n'),
)
}
return resolutions.map(resolution => resolution.path)
}

View File

@@ -0,0 +1,99 @@
/** Canonical paths, parsing, and rendering for bilingual pairing records. */
import { basename } from 'node:path'
/** The three repository-relative paths that form one bilingual pair. */
export interface TranslationPairPaths {
/** English document path. */
source: string
/** Simplified Chinese document path. */
zh: string
/** Generated consistency-record path. */
meta: string
}
/** The two content hashes recorded for a bilingual pair. */
export interface TranslationPairingRecord {
/** Git blob hash of the English document. */
sourceHash: string
/** Git blob hash of the Simplified Chinese document. */
zhHash: string
}
const META_LINE = /^([^:#]+\.md): ([0-9a-f]{40})$/
/**
* Derive the counterpart and consistency-record paths from an English document.
*
* @param source - Repository-relative English Markdown path.
* @returns The complete three-path pair.
*/
export function translationPairPaths(source: string): TranslationPairPaths {
if (!source.endsWith('.md') || source.endsWith('.zh.md')) {
throw new Error(`expected an English Markdown path, received ${JSON.stringify(source)}`)
}
return {
source,
zh: source.replace(/\.md$/, '.zh.md'),
meta: source.replace(/\.md$/, '.i18n.yaml'),
}
}
/**
* Derive one pair from its consistency-record path.
*
* @param meta - Repository-relative `foo.i18n.yaml` path.
* @returns The complete three-path pair.
*/
export function translationPairPathsFromMeta(meta: string): TranslationPairPaths {
if (!meta.endsWith('.i18n.yaml')) {
throw new Error(`expected a bilingual consistency-record path, received ${JSON.stringify(meta)}`)
}
return translationPairPaths(meta.replace(/\.i18n\.yaml$/, '.md'))
}
/**
* Parse a consistency record for its expected sibling names.
*
* @param content - Complete sidecar text.
* @param paths - Expected sibling paths.
* @returns The two hashes, or `undefined` for malformed, duplicate, or unexpected keys.
*/
export function parseTranslationPairingRecord(
content: string,
paths: TranslationPairPaths,
): TranslationPairingRecord | undefined {
const hashes = new Map<string, string>()
for (const line of content.split('\n')) {
if (line === '' || line.startsWith('#')) continue
const match = META_LINE.exec(line)
if (!match?.[1] || !match[2] || hashes.has(match[1])) return undefined
hashes.set(match[1], match[2])
}
const sourceHash = hashes.get(basename(paths.source))
const zhHash = hashes.get(basename(paths.zh))
if (hashes.size !== 2 || sourceHash === undefined || zhHash === undefined) return undefined
return { sourceHash, zhHash }
}
/**
* Render the canonical consistency record for a pair.
*
* @param paths - Pair paths written into the record and its recovery command.
* @param record - Confirmed content hashes.
* @returns Canonical YAML text with exactly one trailing newline.
*/
export function renderTranslationPairingRecord(
paths: TranslationPairPaths,
record: TranslationPairingRecord,
): string {
return [
'# 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 ${paths.source}`,
`${basename(paths.source)}: ${record.sourceHash}`,
`${basename(paths.zh)}: ${record.zhHash}`,
'',
].join('\n')
}

View File

@@ -1,11 +1,16 @@
/** Regression tests for bilingual snapshots, corpus scope, and structure. */
import { execFileSync, spawnSync } from 'node:child_process'
import { mkdtempSync, rmSync } from 'node:fs'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { describe, expect, it } from 'vitest'
import { gitBlobHash, storeGitBlob } from './translation-pairing-git.ts'
import { gitBlobHash, readGitIndexBlob, storeGitBlob } from './translation-pairing-git.ts'
import {
parseTranslationPairingRecord,
renderTranslationPairingRecord,
translationPairPaths,
} from './translation-pairing-record.ts'
import {
isTranslationScopeFile,
pairAnchorOfArgument,
@@ -74,6 +79,28 @@ describe('translation pairing snapshots', () => {
}
})
it('reads staged bytes independently of the working tree', () => {
const root = mkdtempSync(join(tmpdir(), 'dsh-translation-pairing-index-'))
try {
execFileSync('git', ['init', '--quiet', root], {
env: { ...process.env, GIT_DEFAULT_HASH: 'sha1' },
})
execFileSync('git', ['-C', root, 'config', 'user.email', 'pairing@example.test'])
execFileSync('git', ['-C', root, 'config', 'user.name', 'Pairing Test'])
writeFileSync(join(root, 'owner.md'), 'staged')
execFileSync('git', ['-C', root, 'add', 'owner.md'])
writeFileSync(join(root, 'owner.md'), 'unstaged')
const indexed = readGitIndexBlob(root, 'owner.md')
expect(indexed?.content.toString('utf8')).toBe('staged')
expect(indexed?.objectId).toBe(gitBlobHash(Buffer.from('staged')))
expect(readGitIndexBlob(root, 'absent.md')).toBeUndefined()
} finally {
rmSync(root, { recursive: true, force: true })
}
})
it.skipIf(!supportsSha256ObjectFormat)('rejects an object format that pairing records cannot represent', () => {
const root = mkdtempSync(join(tmpdir(), 'dsh-translation-pairing-'))
try {
@@ -113,6 +140,32 @@ describe('translation pairing manifest', () => {
})
})
describe('translation pairing records', () => {
const paths = translationPairPaths('docs/foo.md')
const record = {
sourceHash: '1'.repeat(40),
zhHash: '2'.repeat(40),
}
it('round-trips the canonical two-hash record', () => {
expect(parseTranslationPairingRecord(renderTranslationPairingRecord(paths, record), paths)).toEqual(record)
})
it('rejects duplicate or unexpected keys', () => {
expect(parseTranslationPairingRecord([
`foo.md: ${'1'.repeat(40)}`,
`foo.md: ${'3'.repeat(40)}`,
`foo.zh.md: ${'2'.repeat(40)}`,
'',
].join('\n'), paths)).toBeUndefined()
expect(parseTranslationPairingRecord([
`foo.md: ${'1'.repeat(40)}`,
`bar.zh.md: ${'2'.repeat(40)}`,
'',
].join('\n'), paths)).toBeUndefined()
})
})
describe('translation scope discovery', () => {
it.each([
'README.md',
@@ -186,28 +239,56 @@ describe('pair CLI arguments', () => {
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({
input: 'worktree',
mode: 'check',
scope: 'pairs',
anchors: ['docs/bar.md', 'docs/foo.md'],
})
expect(parseTranslationPairingCliArgs([])).toEqual({ mode: 'check', scope: 'corpus', anchors: [] })
expect(parseTranslationPairingCliArgs([])).toEqual({
input: 'worktree',
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({
input: 'worktree',
mode: 'write',
scope: 'pairs',
anchors: ['docs/foo.md'],
})
expect(parseTranslationPairingCliArgs(['--write', '--all'])).toEqual({ mode: 'write', scope: 'corpus', anchors: [] })
expect(parseTranslationPairingCliArgs(['--write', '--all'])).toEqual({
input: 'worktree',
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'])).toEqual({
input: 'worktree',
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')
})
it('makes cached verification a named, read-only index check', () => {
expect(parseTranslationPairingCliArgs(['--cached', 'docs/foo.i18n.yaml'])).toEqual({
input: 'index',
mode: 'check',
scope: 'pairs',
anchors: ['docs/foo.md'],
})
expect(() => parseTranslationPairingCliArgs(['--cached'])).toThrow('requires the staged pair paths')
expect(() => parseTranslationPairingCliArgs(['--cached', '--write', 'docs/foo.md'])).toThrow('read-only')
})
})

View File

@@ -120,6 +120,8 @@ export function pairAnchorOfArgument(argument: string): string {
/** A parsed `verify-translation-pairing` invocation. */
export interface TranslationPairingCliRequest {
/** Content plane read by the check. Writes and corpus checks use the working tree. */
input: 'worktree' | 'index'
mode: 'check' | 'list' | 'write'
/** `corpus` runs discovery over the whole tree; `pairs` touches only the named anchors. */
scope: 'corpus' | 'pairs'
@@ -142,24 +144,32 @@ export interface TranslationPairingCliRequest {
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))
const unknown = flags.filter(flag => !['--list', '--write', '--all', '--cached'].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)) {
const cachedMode = flags.includes('--cached')
if (listMode && (writeMode || allMode || cachedMode || 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 (cachedMode && writeMode) throw new Error('--cached is a read-only index check and cannot be combined with --write')
if (cachedMode && anchors.length === 0) throw new Error('--cached requires the staged pair paths to check')
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 }
return { input: 'worktree', mode: 'write', scope: allMode ? 'corpus' : 'pairs', anchors }
}
if (listMode) return { input: 'worktree', mode: 'list', scope: 'corpus', anchors: [] }
return {
input: cachedMode ? 'index' : 'worktree',
mode: 'check',
scope: anchors.length > 0 ? 'pairs' : 'corpus',
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. */

View File

@@ -3,15 +3,21 @@
* 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; `--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.
* pairs (`--write --all` records every complete pair); `--cached <pairs...>`
* checks exact index bytes for hooks. 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.
*/
import { existsSync, globSync, readFileSync, writeFileSync } from 'node:fs'
import { basename, join, resolve, sep } from 'node:path'
import { gitBlobHash, storeGitBlob } from './translation-pairing-git.ts'
import { gitBlobHash, readGitIndexBlob, storeGitBlob } from './translation-pairing-git.ts'
import {
parseTranslationPairingRecord,
renderTranslationPairingRecord,
translationPairPaths,
} from './translation-pairing-record.ts'
import {
linksTo,
parseTranslationMarkdown,
@@ -33,6 +39,24 @@ try {
}
const listMode = request.mode === 'list'
const writeMode = request.mode === 'write'
const indexMode = request.input === 'index'
const contentCache = new Map<string, Buffer | undefined>()
/** Read one repository path from the selected worktree or index plane. */
function readRepositoryFile(file: string): Buffer | undefined {
if (contentCache.has(file)) return contentCache.get(file)
const content = indexMode
? readGitIndexBlob(root, file)?.content
: existsSync(join(root, file)) ? readFileSync(join(root, file)) : undefined
contentCache.set(file, content)
return content
}
/** Whether one path exists in the selected content plane. */
function repositoryFileExists(file: string): boolean {
return readRepositoryFile(file) !== undefined
}
/** Discover source Markdown and pairing sidecars before applying the corpus predicate. */
const SCOPE_PATTERNS = [
@@ -42,7 +66,11 @@ const SCOPE_PATTERNS = [
'.agents/notes/**/*.i18n.yaml',
]
const manifest = parseTranslationPairingManifest(readFileSync(join(root, 'scripts/translation-pairing.manifest.json'), 'utf8'))
const manifestContent = readRepositoryFile('scripts/translation-pairing.manifest.json')
if (manifestContent === undefined) {
throw new Error('scripts/translation-pairing.manifest.json is missing from the selected content plane')
}
const manifest = parseTranslationPairingManifest(manifestContent.toString('utf8'))
/**
* An excluded entry ending in `/` excludes the whole directory. The trailing
@@ -54,50 +82,20 @@ function isExcluded(file: string): boolean {
return manifest.excluded.some(entry => (entry.endsWith('/') ? file.startsWith(entry) : file === entry))
}
/** The three paths of a pair, derived from the English-file path. */
function pairPaths(source: string): { zh: string; meta: string } {
return { zh: source.replace(/\.md$/, '.zh.md'), meta: source.replace(/\.md$/, '.i18n.yaml') }
}
const META_LINE = /^([^:#]+\.md): ([0-9a-f]{40})$/
/** Parse a `foo.i18n.yaml` consistency record: basename → recorded 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 = META_LINE.exec(line)
if (!match?.[1] || !match[2]) return undefined
out.set(match[1], match[2])
}
return out
}
/** Render a `foo.i18n.yaml` consistency record. */
function renderMeta(source: string, sourceHash: string, zh: string, zhHash: string): string {
return [
'# 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 ${source}`,
`${basename(source)}: ${sourceHash}`,
`${basename(zh)}: ${zhHash}`,
'',
].join('\n')
}
// 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>()
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)
const { source, zh, meta } = translationPairPaths(anchor)
for (const file of [source, zh, meta]) {
if (repositoryFileExists(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)
// A named worktree anchor with no files still enters the source list so
// an interactive check reports it. An index check accepts a complete
// three-file deletion and still rejects every partial deletion below.
if (!indexMode && !repositoryFileExists(anchor)) files.add(anchor)
}
} else {
for (const pattern of SCOPE_PATTERNS) {
@@ -113,8 +111,11 @@ const sources = [...files].filter(f => f.endsWith('.md') && !f.endsWith('.zh.md'
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) {
const absent = request.anchors.filter((anchor) => {
const { source, zh, meta } = translationPairPaths(anchor)
return ![source, zh, meta].some(repositoryFileExists)
})
if (rejected.length > 0 || (!indexMode && 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)`)
}
@@ -132,20 +133,25 @@ if (writeMode) {
let written = 0
for (const source of sources) {
if (isExcluded(source)) continue
const { zh, meta } = pairPaths(source)
if (!existsSync(join(root, source)) || !existsSync(join(root, zh))) {
const paths = translationPairPaths(source)
const { zh, meta } = paths
if (!repositoryFileExists(source) || !repositoryFileExists(zh)) {
if (request.scope === 'pairs') {
console.error(`verify-translation-pairing: cannot record ${source}: missing ${existsSync(join(root, source)) ? zh : source}`)
console.error(`verify-translation-pairing: cannot record ${source}: missing ${repositoryFileExists(source) ? zh : source}`)
process.exit(2)
}
continue
}
const sourceContent = readFileSync(join(root, source))
const zhContent = readFileSync(join(root, zh))
const sourceContent = readRepositoryFile(source)
const zhContent = readRepositoryFile(zh)
if (sourceContent === undefined || zhContent === undefined) throw new Error(`${source}: complete pair became unreadable`)
// A consistency record is also a recovery pointer for the briefing
// generator. Persist both snapshots even when the sidecar text is already
// current, because the bytes may exist only in this working tree.
const record = renderMeta(source, storeGitBlob(root, sourceContent), zh, storeGitBlob(root, zhContent))
const record = renderTranslationPairingRecord(paths, {
sourceHash: storeGitBlob(root, sourceContent),
zhHash: storeGitBlob(root, zhContent),
})
if (existsSync(join(root, meta)) && readFileSync(join(root, meta), 'utf8') === record) continue
writeFileSync(join(root, meta), record)
console.log(`verify-translation-pairing: recorded ${meta}`)
@@ -161,8 +167,8 @@ const state = new Map<string, 'ok' | 'out-of-sync' | 'missing'>()
// 1. Every discovered, non-excluded source merges bilingual.
for (const source of sources) {
if (isExcluded(source)) continue
const { zh } = pairPaths(source)
if (!existsSync(join(root, zh))) {
const { zh } = translationPairPaths(source)
if (!repositoryFileExists(zh)) {
errors.push(`${source}: in-scope documentation must merge bilingual (docs/i18n/README.md); add the counterpart and record the pair`)
state.set(source, 'missing')
}
@@ -176,8 +182,13 @@ for (const zh of translations) pairAnchors.add(zh.replace(/\.zh\.md$/, '.md'))
for (const meta of metas) pairAnchors.add(meta.replace(/\.i18n\.yaml$/, '.md'))
for (const source of [...pairAnchors].sort()) {
const { zh, meta } = pairPaths(source)
const have = { source: existsSync(join(root, source)), zh: existsSync(join(root, zh)), meta: existsSync(join(root, meta)) }
const paths = translationPairPaths(source)
const { zh, meta } = paths
const have = {
source: repositoryFileExists(source),
zh: repositoryFileExists(zh),
meta: repositoryFileExists(meta),
}
if (isExcluded(source)) {
if (have.zh) errors.push(`${zh}: ${source} is excluded from pairing (generated or bilingual-by-construction); this translation must not exist`)
@@ -190,10 +201,14 @@ for (const source of [...pairAnchors].sort()) {
continue
}
const sourceContent = readFileSync(join(root, source))
const zhContent = readFileSync(join(root, zh))
const record = parseMeta(readFileSync(join(root, meta), 'utf8'))
if (!record || record.size !== 2 || !record.has(basename(source)) || !record.has(basename(zh))) {
const sourceContent = readRepositoryFile(source)
const zhContent = readRepositoryFile(zh)
const metaContent = readRepositoryFile(meta)
if (sourceContent === undefined || zhContent === undefined || metaContent === undefined) {
throw new Error(`${source}: complete pair became unreadable`)
}
const record = parseTranslationPairingRecord(metaContent.toString('utf8'), paths)
if (record === undefined) {
errors.push(`${meta}: malformed consistency record (expected exactly \`${basename(source)}: <40-hex>\` and \`${basename(zh)}: <40-hex>\`)`)
continue
}
@@ -201,7 +216,8 @@ for (const source of [...pairAnchors].sort()) {
let consistent = true
for (const [file, content] of [[source, sourceContent], [zh, zhContent]] as const) {
const current = gitBlobHash(content)
if (record.get(basename(file)) !== current) {
const recorded = file === source ? record.sourceHash : record.zhHash
if (recorded !== current) {
errors.push(`${file}: out of sync — content no longer matches the pair's last confirmed-consistent state in ${meta} (bring the other side along, then re-record with --write)`)
consistent = false
}
@@ -247,7 +263,7 @@ if (listMode) {
if (errors.length === 0) {
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} named ${indexMode ? 'staged ' : ''}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)
}