diff --git a/README.md b/README.md index 071089c..48c7116 100644 --- a/README.md +++ b/README.md @@ -1,5 +1,7 @@ # partme-codeguard-plugin Plugin +> Parity note: `README.md` and `README.zh-CN.md` must stay structurally aligned (heading levels, local links, version strings) — enforced by `tests/test_readme_parity.py`; mirror any structural edit into both files in the same commit. +

partme-codeguard-plugin — Make AI-written code pass lint on first try. Supports ZCode, Claude Code, Codex CLI, and Kimi Code.

@@ -13,7 +15,7 @@ English · 简体中文 · Architecture · - Technical roadmap + Technical roadmap

--- @@ -40,7 +42,7 @@ It is a **constraint-type plugin** for AI assistants, not a productivity-type pl | AI uses `unwrap()` in Rust business code | `cargo clippy -- -D warnings` runs on each `.rs` file | [Architecture §2.1](docs/partme-codeguard-plugin-Architecture.zh_CN.md) | | AI introduces `any` and unused vars in TypeScript | `eslint --max-warnings 0` blocks the AI | `linters/eslint/recommended.cjs` | | "did it pass lint?" is asked manually after every AI session | Stop hook summarizes lint pass/fail counts | `hooks/stop_summary.py` | -| pre-commit and CI catch issues 30s+5min late, by then the AI has moved on | Three-layer defense: hook (<2s) → pre-commit (30s) → CI (5min) | [Technical roadmap §1](docs/5、partme-codeguard-plugin-技术方案与路线.md) | +| pre-commit and CI catch issues 30s+5min late, by then the AI has moved on | Three-layer defense: hook (<2s) → pre-commit (30s) → CI (5min) | [Technical roadmap §1](docs/technical-roadmap.zh_CN.md) | ## At a glance @@ -65,7 +67,7 @@ AI code that passes lint on first try |---|---| | Plugin ID | `partme-codeguard-plugin` | | Hosts | ZCode, Claude Code, Codex CLI, Kimi Code | -| Current version | `0.5.4` | +| Current version | `0.6.7` | | ZCode manifest | `.zcode-plugin/plugin.json` | | Codex manifest | `.codex-plugin/plugin.json` | | MCP server | Not published until the SDK-backed protocol implementation is ready; use the CLI and hooks | @@ -104,7 +106,7 @@ The commit gate is pre-wired in the pre-commit template (`stages: [commit-msg]`) ### External skill source -The 68 portable skills are authored in [full-stack-skills/codeguard-skills](https://github.com/full-stack-skills/codeguard-skills), not independently inside this plugin. This repository vendors the complete `v0.1.0` snapshot so installed plugins work offline: +The 68 portable skills are authored in [full-stack-skills/codeguard-skills](https://github.com/full-stack-skills/codeguard-skills), not independently inside this plugin. This repository vendors the complete `v0.1.2` snapshot so installed plugins work offline: - `skills.lock.json` pins the upstream repository, immutable tag, resolved commit, managed skill names, and per-skill SHA-256 digests. - `python3 scripts/vendor/skill_vendor.py update` refreshes only the skill names listed in the lock. @@ -151,6 +153,8 @@ PostToolUse is the **highest-ROI** layer because it gives the AI feedback **whil ### CLI (codeguard) +`bin/codeguard` is a bash dispatcher: each subcommand (`check` / `fix` / `cve` / `dockerfile` / `detect`) routes to the matching `scripts/*.py` implementation. + ```bash # Optional one-time setup: put the CLI on PATH ln -s $PWD/bin/codeguard /usr/local/bin/codeguard @@ -233,7 +237,7 @@ partme-codeguard-plugin/ │ └── vendor/skill_vendor.py # lock-driven external skill vendor/check ├── skills.lock.json # upstream tag/commit + managed skills + SHA-256 digests ├── plugin-local-skills.json # explicit plugin-only skill exceptions (currently empty) -├── skills/ # 68 vendored skills from codeguard-skills v0.1.0 +├── skills/ # 68 vendored skills from codeguard-skills v0.1.2 │ ├── codeguard/ # main entry │ ├── codeguard-init/ # one-line bootstrap │ ├── codeguard-{java,rust,typescript,python}/ @@ -255,7 +259,7 @@ partme-codeguard-plugin/ │ └── pre-commit/ # .pre-commit-config.template.yaml ├── docs/ │ ├── partme-codeguard-plugin-Architecture.zh_CN.md -│ └── 5、partme-codeguard-plugin-技术方案与路线.md +│ └── technical-roadmap.zh_CN.md ├── README.md # this file ├── README.zh-CN.md # Chinese ├── LICENSE # Apache-2.0 diff --git a/README.zh-CN.md b/README.zh-CN.md index b9a8157..637cd1b 100644 --- a/README.zh-CN.md +++ b/README.zh-CN.md @@ -1,5 +1,7 @@ # partme-codeguard-plugin 插件 +> 结构对齐:`README.md` 与 `README.zh-CN.md` 必须保持结构一致(标题层级、本地链接、版本串)——由 `tests/test_readme_parity.py` 门禁守护;结构性修改须同 commit 镜像到两份文件。 +

partme-codeguard-plugin — 让 AI 写的代码一次过 lint。支持 ZCode、Claude Code、Codex CLI、Kimi Code。

@@ -13,7 +15,7 @@ English · 简体中文 · 架构文档 · - 技术方案 + 技术方案

--- @@ -40,7 +42,7 @@ | AI 在 Rust 业务代码用 `unwrap()` | `cargo clippy -- -D warnings` 跑每个 `.rs` 文件 | [架构文档 §2.1](docs/partme-codeguard-plugin-Architecture.zh_CN.md) | | AI 写 `any` 和未用变量 | `eslint --max-warnings 0` 阻塞 AI | `linters/eslint/recommended.cjs` | | 每次会话都要手工问「过 lint 了吗?」 | Stop 钩子自动总结本会话 lint 通过/失败次数 | `hooks/stop_summary.py` | -| pre-commit / CI 发现时 AI 已切走,修复率 <30% | **三层防御**:钩子(<2s)→ pre-commit(30s)→ CI(5min) | [技术方案 §1](docs/5、partme-codeguard-plugin-技术方案与路线.md) | +| pre-commit / CI 发现时 AI 已切走,修复率 <30% | **三层防御**:钩子(<2s)→ pre-commit(30s)→ CI(5min) | [技术方案 §1](docs/technical-roadmap.zh_CN.md) | ## 一览 @@ -65,7 +67,7 @@ AI 一次写出就过 lint 的代码 |---|---| | 插件 ID | `partme-codeguard-plugin` | | 宿主 | ZCode、Claude Code、Codex CLI、Kimi Code | -| 当前版本 | `0.5.4` | +| 当前版本 | `0.6.7` | | ZCode manifest | `.zcode-plugin/plugin.json` | | Codex manifest | `.codex-plugin/plugin.json` | | MCP 服务 | SDK 协议实现完成前不随清单发布;当前使用 CLI 与 Hooks | @@ -86,9 +88,11 @@ AI 一次写出就过 lint 的代码 > 此前其 lint 命令缺 glob、恒以用法错误退出,现已返回真实结论。`codeguard init` 会拷入宽松配置模板。 | **Planned**(4 种,无独立 CLI linter) | Metal、ArkTS(HarmonyOS)、COBOL、Liquid(theme-check 待接通) | -## 外部技能来源 +## 治理技能(Git 与安全) + +### 外部技能来源 -68 个可复用技能统一在 [full-stack-skills/codeguard-skills](https://github.com/full-stack-skills/codeguard-skills) 编写,插件不再维护一份独立手写副本。为保证插件安装后离线可用,本仓库 vendor 了完整的 `v0.1.0` 快照: +68 个可复用技能统一在 [full-stack-skills/codeguard-skills](https://github.com/full-stack-skills/codeguard-skills) 编写,插件不再维护一份独立手写副本。为保证插件安装后离线可用,本仓库 vendor 了完整的 `v0.1.2` 快照: - `skills.lock.json` 固定上游仓库、不可变 tag、解析后的 commit、受管技能清单与逐技能 SHA-256。 - `python3 scripts/vendor/skill_vendor.py update` 只刷新 lock 中列出的技能。 @@ -96,7 +100,7 @@ AI 一次写出就过 lint 的代码 - 不得直接修改 lock 管理的技能目录。应先在 `codeguard-skills` 修改并发布,再更新 lock ref 并执行 vendor update。 - 只有插件内部定制技能可以直接保留在 `skills/`,且必须明确不列入 `skills.lock.json`、显式登记到 `plugin-local-skills.json`;vendor 会保留已声明目录并拒绝未声明例外。 -Hooks、linters、commands、MCP 接线和可执行脚本仍由插件仓负责。 +Hooks、linters、commands、MCP 接线和可执行脚本仍由插件仓负责。作者编写规范见 [docs/CODEGUARD_SKILLS_SPEC.md](docs/CODEGUARD_SKILLS_SPEC.md)。 ## 能力与边界 @@ -135,6 +139,8 @@ PostToolUse 是 **最高 ROI** 的层,因为 AI 在它「还在乎这个问题 ### CLI(codeguard) +`bin/codeguard` 是 bash 分发器:每个子命令(`check` / `fix` / `cve` / `dockerfile` / `detect`)都路由到对应的 `scripts/*.py` 实现。 + ```bash # 安装 CLI(可选):放到 PATH 后任意目录直接用 ln -s $PWD/bin/codeguard /usr/local/bin/codeguard @@ -216,7 +222,7 @@ partme-codeguard-plugin/ │ └── vendor/skill_vendor.py # lock 驱动的外部技能 vendor/check ├── skills.lock.json # 上游 tag/commit + 受管技能 + SHA-256 ├── plugin-local-skills.json # 插件专属技能显式例外清单(当前为空) -├── skills/ # 从 codeguard-skills v0.1.0 vendor 的 68 个技能 +├── skills/ # 从 codeguard-skills v0.1.2 vendor 的 68 个技能 │ ├── codeguard/ # 主入口 │ ├── codeguard-init/ # 一行接入 │ ├── codeguard-{java,rust,typescript,python}/ @@ -238,7 +244,7 @@ partme-codeguard-plugin/ │ └── pre-commit/ # .pre-commit-config.template.yaml ├── docs/ │ ├── partme-codeguard-plugin-Architecture.zh_CN.md -│ └── 5、partme-codeguard-plugin-技术方案与路线.md +│ └── technical-roadmap.zh_CN.md ├── README.md # 本文件(英文) ├── README.zh-CN.md # 本文件(中文) ├── LICENSE # Apache-2.0 diff --git "a/docs/5\343\200\201partme-codeguard-plugin-\346\212\200\346\234\257\346\226\271\346\241\210\344\270\216\350\267\257\347\272\277.md" b/docs/technical-roadmap.zh_CN.md similarity index 99% rename from "docs/5\343\200\201partme-codeguard-plugin-\346\212\200\346\234\257\346\226\271\346\241\210\344\270\216\350\267\257\347\272\277.md" rename to docs/technical-roadmap.zh_CN.md index 9e13eee..c5b82c7 100644 --- "a/docs/5\343\200\201partme-codeguard-plugin-\346\212\200\346\234\257\346\226\271\346\241\210\344\270\216\350\267\257\347\272\277.md" +++ b/docs/technical-roadmap.zh_CN.md @@ -1,4 +1,4 @@ -# 5、partme-codeguard-plugin 技术方案与路线 +# PartMe CodeGuard 技术方案与路线 > **文档说明**:本文档描述 partme-codeguard-plugin 插件的技术选型、关键决策、ADR(架构决策记录)和版本路线图。 > diff --git a/openspec/changes/archive/2026-09-22-add-readme-parity-gate/.openspec.yaml b/openspec/changes/archive/2026-09-22-add-readme-parity-gate/.openspec.yaml new file mode 100644 index 0000000..a9eca53 --- /dev/null +++ b/openspec/changes/archive/2026-09-22-add-readme-parity-gate/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-09-22 \ No newline at end of file diff --git a/openspec/changes/archive/2026-09-22-add-readme-parity-gate/design.md b/openspec/changes/archive/2026-09-22-add-readme-parity-gate/design.md new file mode 100644 index 0000000..7c89d94 --- /dev/null +++ b/openspec/changes/archive/2026-09-22-add-readme-parity-gate/design.md @@ -0,0 +1,47 @@ +## Context + +实测漂移证据(v0.6.6): + +1. **标题层级序列不等**(围栏外解析): + - 英文:`H2 Governance skills (Git & Security)` → `H3 External skill source` + - 中文:`H2 外部技能来源`(缺包裹 H2) + - 其余 20 个标题层级与顺序完全一致(H1×1 / H2×12 / H3×9 vs 修复前中文 H3×8)。 +2. **版本串停在 0.5.4**:`| Current version | 0.5.4 |`(双语),实际已发 0.6.6+。 +3. **vendor 快照串过期**:双语 4 处写 `v0.1.0`,`skills.lock.json.ref = v0.1.2`。 +4. **docs 文件名含全角逗号** `5、`:两份 README 共 6 处链接 + 文档 H1 引用。 + +漂移根因:无门禁。曾考虑「英文 SSOT + 自动翻译生成中文」——被否:仓内无翻译管线,伪自动生成会把漂移换成假同步。选择**结构门禁 + 单 commit 镜像规则**:机器守结构(标题/链接/版本三类硬一致),人守译文。 + +## Decisions + +### 门禁只守「结构可机检」三类 + +`tests/test_readme_parity.py` 断言(全部跳过 ``` 围栏,避免把 bash 注释 `#` 当标题): + +1. 标题层级序列相等(`[1,2,3,2,...]`)——不要求文字相同(语言不同),只要求结构镜像。 +2. 本地链接目标集合相等(`](path)` 去掉 http/mailto/锚点)——docs 改名两边必须同时改。 +3. `x.y.z` 版本串集合相等——`Current version`、vendor 快照等串任一边漏改即失败。 + +不守的:译文正确性(机器判不了)、图片 alt 文案、表格行数。 + +### mirror-edit 规则写进文件与 spec + +两份 README 顶部各加一行 parity 注(互指 + 指向门禁测试);spec `bilingual-docs-consistency` 加 Requirement:任一 README 修改必须同 commit 镜像另一份,结构差异由门禁拦截、译文差异由 review 拦截。 + +### 不做 SSOT 自动翻译 + +原因如上;若未来引入翻译管线,可在本 spec 下 MODIFIED Requirement 升级为「单源生成」。 + +### docs 改名目标与既有约定对齐 + +`docs/` 既有 `partme-codeguard-plugin-Architecture.zh_CN.md`——新名 `technical-roadmap.zh_CN.md` 同风格(蛇形 + `.zh_CN.md` 后缀)。文档 H1 去掉序号前缀 `5、`。共 7 处引用更新(README en×3 / zh×3 / 文档 H1×1)。 + +### 兼容性「已验证」行不升级 + +`✅ V0.5.4 verified` 是历史验证记录;在未对 0.6.7 重做宿主安装验证前改写它 = 虚假声明。`Current version` 行是事实字段(当前发布版本),跟着 release 走,本次预写 `0.6.7`(与本 change 的发版一致)。 + +## Risks / Trade-offs + +- **门禁只挡结构漂移**:译文漏改仍靠 review——两份 README 顶部的 parity 注 + spec Requirement 是软约束。 +- **`Current version` 行预写**:feat PR 合并到 release PR 之间存在短暂 README=0.6.7 / manifest=0.6.6 窗口;门禁不比对 manifest(只比对两份 README 互等),窗口内 CI 仍绿。release PR 合并后收敛。 +- **docs 改名断外部深链**:该文件是中文技术方案,外部引用概率低;GitHub 对改名文件不自动重定向(wiki/issue 内的旧链接会 404)。接受此风险换取文件名可移植性。 \ No newline at end of file diff --git a/openspec/changes/archive/2026-09-22-add-readme-parity-gate/proposal.md b/openspec/changes/archive/2026-09-22-add-readme-parity-gate/proposal.md new file mode 100644 index 0000000..e9a881d --- /dev/null +++ b/openspec/changes/archive/2026-09-22-add-readme-parity-gate/proposal.md @@ -0,0 +1,26 @@ +## Why + +`README.md`(310 行,英文)与 `README.zh-CN.md`(293 行,简体中文)是双语镜像,但没有任何门禁守护一致性——MEDIUM #4 评审指出任一版本修改都需手工同步另一版本,实际已发生漂移:英文版有 `## Governance skills (Git & Security) → ### External skill source` 的两层嵌套,中文版是平铺的 `## 外部技能来源`;两份文件的 `Current version` 行都停在 `0.5.4`(实际 0.6.6+);vendor 快照串都写 `v0.1.0`(`skills.lock.json` 实为 `v0.1.2`)。 + +## What Changes + +- 新增 `tests/test_readme_parity.py`:三条一致性断言(围栏外标题层级序列相等、本地链接目标集合相等、`x.y.z` 版本串集合相等),任一 README 单边修改即 CI 失败。 +- `README.zh-CN.md`:插入 `## 治理技能(Git 与安全)` 并把 `外部技能来源` 降级为 `###`,恢复与英文版一致的标题层级序列。 +- 双语同步修正(两份文件同 commit):`Current version` 行 `0.5.4 → 0.6.7`;vendor 快照串 `v0.1.0 → v0.1.2`(与 lock 一致);在 `### CLI` 段落补一句 `bin/codeguard` 分发器的说明(bash 子命令 → `scripts/*.py` 映射,回应评审 LOW #13)。 +- 两份 README 顶部各加一行 parity 说明(指向门禁测试)。 +- 文档改名(回应评审 LOW #14):`docs/5、partme-codeguard-plugin-技术方案与路线.md` → `docs/technical-roadmap.zh_CN.md`(与既有 `...Architecture.zh_CN.md` 命名对齐,消除全角逗号文件名的跨平台转义风险);同步更新两份 README 的 3 处链接(导航、表格引用、仓库树目录)与文档自身 H1。 +- `✅ V0.5.4 verified` 兼容性行**保持不动**——它们记录的是「在 v0.5.4 验证过」的历史事实,未在 0.6.7 重测就不改写。 + +## Capabilities + +### New Capabilities + +- `bilingual-docs-consistency`: Defines the parity contract between README.md and README.zh-CN.md and the single-commit mirror-edit rule. + +### Modified Capabilities + +None. + +## Impact + +新增 1 个测试(~60 行);修改两份 README(结构修正 + 4 处同步串 + 2 行说明);`git mv` 1 个 docs 文件 + 7 处链接更新。无运行时行为变化。 \ No newline at end of file diff --git a/openspec/changes/archive/2026-09-22-add-readme-parity-gate/specs/bilingual-docs-consistency/spec.md b/openspec/changes/archive/2026-09-22-add-readme-parity-gate/specs/bilingual-docs-consistency/spec.md new file mode 100644 index 0000000..525217f --- /dev/null +++ b/openspec/changes/archive/2026-09-22-add-readme-parity-gate/specs/bilingual-docs-consistency/spec.md @@ -0,0 +1,24 @@ +## ADDED Requirements + +### Requirement: README structural parity SHALL be enforced by tests/test_readme_parity.py + +`tests/test_readme_parity.py` SHALL assert that `README.md` and `README.zh-CN.md` have: (1) identical heading-level sequences outside fenced code blocks, (2) identical sets of local link targets, and (3) identical sets of `x.y.z` version strings. Any single-sided edit to one README SHALL fail this test. + +#### Scenario: The technical roadmap doc is renamed + +- **WHEN** `docs/5、partme-codeguard-plugin-技术方案与路线.md` is renamed and only `README.md` links are updated +- **THEN** `test_readme_parity` fails on the local-link-target set difference + +#### Scenario: The current version row is bumped in one language only + +- **WHEN** `| Current version | 0.6.7 |` is updated in `README.md` but `README.zh-CN.md` still says `0.5.4` +- **THEN** `test_readme_parity` fails on the version-string set difference + +### Requirement: README mirror edits SHALL land in the same commit + +Any change to one README's structure, links, or version strings SHALL be mirrored into the other README in the same commit. Both files SHALL carry a parity note near the top pointing at `tests/test_readme_parity.py`. + +#### Scenario: A contributor adds a new section to README.md + +- **WHEN** a new `## Section` is added to `README.md` with links and version strings +- **THEN** the same commit adds the translated `## 章节` to `README.zh-CN.md` at the same position, and CI passes \ No newline at end of file diff --git a/openspec/changes/archive/2026-09-22-add-readme-parity-gate/tasks.md b/openspec/changes/archive/2026-09-22-add-readme-parity-gate/tasks.md new file mode 100644 index 0000000..19c1fee --- /dev/null +++ b/openspec/changes/archive/2026-09-22-add-readme-parity-gate/tasks.md @@ -0,0 +1,29 @@ +## 1. Parity gate + +- [x] 1.1 Create `tests/test_readme_parity.py` with three assertions (heading-level sequence / local link targets / version strings), fence-aware. +- [x] 1.2 Run it against current READMEs; record every existing mismatch. + +## 2. Drift repairs (mirror both files in one commit) + +- [x] 2.1 zh: insert `## 治理技能(Git 与安全)` and demote `外部技能来源` to `###` so heading sequences match. +- [x] 2.2 Both: `Current version` row `0.5.4 → 0.6.7`. +- [x] 2.3 Both: vendor snapshot `v0.1.0 → v0.1.2` (4 places; matches `skills.lock.json`). +- [x] 2.4 Both: add `bin/codeguard` dispatcher sentence in `### CLI` section (same position). +- [x] 2.5 Both: add parity note after H1 pointing at the gate test. + +## 3. Docs rename (LOW #14) + +- [x] 3.1 `git mv 'docs/5、partme-codeguard-plugin-技术方案与路线.md' docs/technical-roadmap.zh_CN.md`. +- [x] 3.2 Update 6 README links (en ×3, zh ×3) + document H1 (drop `5、` prefix). + +## 4. Validation + +- [x] 4.1 `python3 -m unittest discover -s tests -p 'test_*.py' -v` — all pass (incl. new parity test). +- [x] 4.2 `python3 tests/run_all.py` — 141 / 0 / 0. +- [x] 4.3 `git diff --check` — clean. +- [x] 4.4 `openspec validate --all --strict` — pass. + +## 5. Release + +- [x] 5.1 bump patch → 0.6.7; fix in-repo marketplace pins; feat PR → merge; release PR → merge; tag v0.6.7; market repo catalog + README row → 0.6.7. +- [x] 5.2 `openspec archive add-readme-parity-gate`. \ No newline at end of file diff --git a/openspec/specs/bilingual-docs-consistency/spec.md b/openspec/specs/bilingual-docs-consistency/spec.md new file mode 100644 index 0000000..e4677cb --- /dev/null +++ b/openspec/specs/bilingual-docs-consistency/spec.md @@ -0,0 +1,28 @@ +# bilingual-docs-consistency Specification + +## Purpose +TBD - created by archiving change add-readme-parity-gate. Update Purpose after archive. +## Requirements +### Requirement: README structural parity SHALL be enforced by tests/test_readme_parity.py + +`tests/test_readme_parity.py` SHALL assert that `README.md` and `README.zh-CN.md` have: (1) identical heading-level sequences outside fenced code blocks, (2) identical sets of local link targets, and (3) identical sets of `x.y.z` version strings. Any single-sided edit to one README SHALL fail this test. + +#### Scenario: The technical roadmap doc is renamed + +- **WHEN** `docs/5、partme-codeguard-plugin-技术方案与路线.md` is renamed and only `README.md` links are updated +- **THEN** `test_readme_parity` fails on the local-link-target set difference + +#### Scenario: The current version row is bumped in one language only + +- **WHEN** `| Current version | 0.6.7 |` is updated in `README.md` but `README.zh-CN.md` still says `0.5.4` +- **THEN** `test_readme_parity` fails on the version-string set difference + +### Requirement: README mirror edits SHALL land in the same commit + +Any change to one README's structure, links, or version strings SHALL be mirrored into the other README in the same commit. Both files SHALL carry a parity note near the top pointing at `tests/test_readme_parity.py`. + +#### Scenario: A contributor adds a new section to README.md + +- **WHEN** a new `## Section` is added to `README.md` with links and version strings +- **THEN** the same commit adds the translated `## 章节` to `README.zh-CN.md` at the same position, and CI passes + diff --git a/tests/test_readme_parity.py b/tests/test_readme_parity.py new file mode 100644 index 0000000..f3360f2 --- /dev/null +++ b/tests/test_readme_parity.py @@ -0,0 +1,83 @@ +#!/usr/bin/env python3 +"""README.md 与 README.zh-CN.md 的结构一致性门禁(bilingual-docs-consistency spec)。 + +守三类「机器可判定」的一致性(全部跳过 ``` 围栏,避免把 bash 注释当标题): +1. 标题层级序列相等(语言不同、文字不同,但结构必须镜像); +2. 本地链接目标集合相等(docs 改名必须两边同时改); +3. `x.y.z` 版本串集合相等(Current version / vendor 快照等任一边漏改即失败)。 + +译文正确性不在门禁范围——由两份文件顶部的 parity 注 + review 把守。 +""" +from __future__ import annotations + +import re +import unittest +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +EN = ROOT / "README.md" +ZH = ROOT / "README.zh-CN.md" + +_HEADING_RE = re.compile(r"^(#{1,6})\s") +_LINK_RE = re.compile(r"\]\(([^)]+)\)") +_VERSION_RE = re.compile(r"\d+\.\d+\.\d+") + + +def _unfenced(text: str) -> str: + """去掉 ``` 围栏内的行(bash 注释形如 # ... 会被误判成标题)。""" + out, fenced = [], False + for line in text.splitlines(): + if line.lstrip().startswith("```"): + fenced = not fenced + continue + if not fenced: + out.append(line) + return "\n".join(out) + + +def _heading_levels(text: str) -> list[int]: + return [len(m.group(1)) for m in + (_HEADING_RE.match(ln) for ln in _unfenced(text).splitlines()) if m] + + +def _local_links(text: str) -> set[str]: + return {t for t in _LINK_RE.findall(_unfenced(text)) + if not t.startswith(("http://", "https://", "mailto:", "#"))} + + +def _versions(text: str) -> set[str]: + return set(_VERSION_RE.findall(text)) + + +class ReadmeParityTest(unittest.TestCase): + @classmethod + def setUpClass(cls) -> None: + cls.en = EN.read_text(encoding="utf-8") + cls.zh = ZH.read_text(encoding="utf-8") + + def test_both_files_exist(self) -> None: + self.assertTrue(EN.is_file(), "README.md missing") + self.assertTrue(ZH.is_file(), "README.zh-CN.md missing") + + def test_parity_notes_present(self) -> None: + self.assertIn("tests/test_readme_parity.py", self.en, "en parity note missing") + self.assertIn("tests/test_readme_parity.py", self.zh, "zh parity note missing") + + def test_heading_level_sequences_match(self) -> None: + en_h, zh_h = _heading_levels(self.en), _heading_levels(self.zh) + self.assertEqual(en_h, zh_h, + msg=f"heading-level sequence drift:\n en={en_h}\n zh={zh_h}") + + def test_local_link_targets_match(self) -> None: + en_l, zh_l = _local_links(self.en), _local_links(self.zh) + self.assertEqual(en_l, zh_l, + msg=f"link drift: only_en={en_l - zh_l} only_zh={zh_l - en_l}") + + def test_version_strings_match(self) -> None: + en_v, zh_v = _versions(self.en), _versions(self.zh) + self.assertEqual(en_v, zh_v, + msg=f"version drift: only_en={en_v - zh_v} only_zh={zh_v - en_v}") + + +if __name__ == "__main__": + unittest.main()