diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 18bebc5..f55d339 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -24,6 +24,7 @@ jobs: - run: python tests/test_generated_views.py - run: python tests/test_markdown_links.py - run: python tests/test_locale_invariants.py + - run: python tests/test_locale_freshness_regressions.py - name: Check localized source freshness env: BEFORE: ${{ github.event.before }} diff --git a/docs/localization/TRANSLATION_SURFACE.md b/docs/localization/TRANSLATION_SURFACE.md index 41e4d22..83e41f7 100644 --- a/docs/localization/TRANSLATION_SURFACE.md +++ b/docs/localization/TRANSLATION_SURFACE.md @@ -1,6 +1,6 @@ # Translation Surface Inventory -Status: **implemented first-release inventory; Wave 4 source-alignment update in progress** +Status: **current supported-locale inventory** Canonical source language: English (`en`) First supported locale: Simplified Chinese (`zh-CN`) @@ -11,7 +11,7 @@ First supported locale: Simplified Chinese (`zh-CN`) - **C - keep canonical, localize around it:** machine/protocol structure should remain unchanged; translated explanation may be added elsewhere. - **D - defer:** historical, empty runtime, binary, or otherwise outside the first localization release. -For `zh-CN`, the first-release Class A route and selected Class B support surfaces were implemented and reviewed against their then-current English sources. Canonical changes merged after that review are explicitly tracked as Wave 4 source-alignment work; a class is a treatment decision, not a claim that its current English revision is already localized. Current locale support status is recorded in `locales/README.md`. +For `zh-CN`, the Class A operational route and selected Class B support surfaces are implemented. A class is a treatment decision, not a claim that every current English document in that class has a localized counterpart. Current locale support status is recorded in `locales/README.md`; source/counterpart freshness is enforced for existing pairs. ## Top-level files @@ -30,6 +30,7 @@ For `zh-CN`, the first-release Class A route and selected Class B support surfac | Path | Class | Treatment | | --- | --- | --- | +| `docs/README.md` | A | Localized documentation index; route readers to localized operational guidance when a counterpart exists | | `docs/ACQUISITION.md` | A | Full localized onboarding; commands/URLs unchanged | | `docs/quickstart.md` | A | Full localized walkthrough; paths/identifiers unchanged | | `docs/visitor_lobby_model.md` | A | Translate carefully; `visitor` has project-specific meaning | @@ -44,10 +45,16 @@ For `zh-CN`, the first-release Class A route and selected Class B support surfac | `docs/CONNECTOR_SAFE_WORDING.md` | B | Translate after core route, preserving compatibility intent | | `docs/branding.md` | A | Translate explanation; product names remain unchanged | | `docs/UPGRADING.md` | B | Translate as an upgrade companion after the core operational route is current | -| `docs/registry/*.md` | B | Translate as Registry Contract v1 reference companions; preserve fields, enums, paths, and examples structurally | +| `docs/WHY_LABNOTE.md` | B | Localized conceptual companion explaining how LabNote fits beside context files and model memory | +| `docs/PROJECT_PROVENANCE.md` | B | Localized project-provenance record | +| active Registry Contract v1 references under `docs/registry/` | B | Canonical English reference set; localized companions are deferred until a dedicated reference-translation need is established | +| `docs/registry/REGISTRY_SURFACE_AUDIT.md` | D | Keep as a canonical historical pre-v1 audit; it is not current operational guidance | | `docs/localization/GLOSSARY.md` | B | Maintain the canonical terminology source together with its explicitly mapped locale glossary | | `docs/localization/TAG_DISPLAY_CATALOG.md` | B | Maintain canonical display text together with its explicitly mapped localized catalogue | -| other `docs/localization/` process files | D | Keep canonical unless a separate localization-maintenance need is established | +| `docs/localization/COMMUNICATION_REGISTRY_SOURCE_DRIFT.md` | D | Keep canonical as localization-maintenance history | +| `docs/localization/LOCALIZATION_CONTRACT.md` | D | Keep canonical as the maintainer-facing localization contract | +| `docs/localization/TRANSLATION_SURFACE.md` | D | Keep canonical as the maintainer-facing inventory | +| `docs/localization/ZH_CN_VALIDATION.md` | D | Keep canonical as the zh-CN validation record | ## `lobby/` diff --git a/locales/zh-CN/docs/README.md b/locales/zh-CN/docs/README.md index 96ff2d6..4e2ff84 100644 --- a/locales/zh-CN/docs/README.md +++ b/locales/zh-CN/docs/README.md @@ -12,7 +12,7 @@ ## 工作 -- [进行常规投递](../../../lobby/ROUTINE_DEPOSIT_QUICKSTART.md) —— 以最小但有用的记录,把一项普通工作放到正确路径中。 +- [进行常规投递](../lobby/ROUTINE_DEPOSIT_QUICKSTART.md) —— 以最小但有用的记录,把一项普通工作放到正确路径中。 - [理解审阅与决定](review_workflow.md) —— 查看回复如何成为经过审阅的结果。 - [理解访客会话与会话身份](visitor_lobby_model.md) —— 了解实际工作区中的会话记录代表什么。 - [理解消息路由](message_routing_model.md) —— 遵循会话或角色之间的消息模型。 @@ -22,7 +22,7 @@ - [阅读存储策略](storage_policy.md) —— 让台账保持精简、可检查,并符合工作区可见性的要求。 - [阅读文档投递策略](DOCUMENT_DEPOSIT_POLICY.md) —— 处理文档和二进制文件,避免把 LabNote 变成普通文件堆。 -- [阅读安全说明](../../../SECURITY.md) —— 不要把凭证、token、密钥或无意暴露的私密材料放入错误的工作区。 +- [阅读安全说明](../SECURITY.md) —— 不要把凭证、token、密钥或无意暴露的私密材料放入错误的工作区。 ## 了解项目 diff --git a/tests/test_locale_freshness.py b/tests/test_locale_freshness.py index 19aa632..46a22ed 100644 --- a/tests/test_locale_freshness.py +++ b/tests/test_locale_freshness.py @@ -10,6 +10,7 @@ import hashlib import json +import re import subprocess import sys from pathlib import Path @@ -26,17 +27,51 @@ } -def paired_surfaces() -> dict[str, str]: - pairs: dict[str, str] = {} +def source_for_locale(relative: str) -> str: + return SOURCE_OVERRIDES.get(relative, relative) + + +def locale_surfaces() -> dict[str, str]: + surfaces: dict[str, str] = {} for locale_path in sorted(LOCALE_ROOT.rglob("*.md")): locale_rel = locale_path.relative_to(ROOT).as_posix() relative = locale_path.relative_to(LOCALE_ROOT).as_posix() - source_rel = SOURCE_OVERRIDES.get(relative, relative) + surfaces[locale_rel] = source_for_locale(relative) + return surfaces + + +def paired_surfaces() -> dict[str, str]: + pairs: dict[str, str] = {} + for locale_rel, source_rel in locale_surfaces().items(): if (ROOT / source_rel).is_file(): pairs[source_rel] = locale_rel return pairs +def files_at(revision: str) -> set[str]: + result = subprocess.run( + ["git", "ls-tree", "-r", "--name-only", revision], + cwd=ROOT, + check=True, + text=True, + capture_output=True, + ) + return {line for line in result.stdout.splitlines() if line} + + +def paired_surfaces_at(revision: str) -> dict[str, str]: + files = files_at(revision) + pairs: dict[str, str] = {} + prefix = "locales/zh-CN/" + for locale_rel in sorted( + path for path in files if path.startswith(prefix) and path.endswith(".md") + ): + source_rel = source_for_locale(locale_rel.removeprefix(prefix)) + if source_rel in files: + pairs[source_rel] = locale_rel + return pairs + + def digest(path: Path) -> str: return hashlib.sha256(path.read_bytes()).hexdigest() @@ -64,7 +99,9 @@ def acknowledgements(pairs: dict[str, str]) -> dict[str, str]: reason = entry.get("reason") if not isinstance(source, str) or source not in pairs: raise AssertionError(f"invalid acknowledgement source: {source!r}") - if not isinstance(source_sha256, str) or len(source_sha256) != 64: + if not isinstance(source_sha256, str) or not re.fullmatch( + r"[0-9a-f]{64}", source_sha256 + ): raise AssertionError(f"invalid source_sha256 for {source!r}") if not isinstance(reason, str) or not reason.strip(): raise AssertionError(f"missing review reason for {source!r}") @@ -79,10 +116,24 @@ def main() -> int: raise SystemExit("usage: test_locale_freshness.py ") pairs = paired_surfaces() + base_pairs = paired_surfaces_at(sys.argv[1]) changed = changed_paths(sys.argv[1], sys.argv[2]) reviewed = acknowledgements(pairs) failures: list[str] = [] + for locale_rel, source_rel in locale_surfaces().items(): + if not (ROOT / source_rel).is_file(): + failures.append( + f"localized surface has no canonical source: {locale_rel} ({source_rel})" + ) + + for source_rel, locale_rel in base_pairs.items(): + if (ROOT / source_rel).is_file() and not (ROOT / locale_rel).is_file(): + failures.append( + f"paired localized surface was removed while its canonical source remains: " + f"{locale_rel} ({source_rel})" + ) + for source_rel, locale_rel in pairs.items(): if source_rel not in changed or locale_rel in changed: continue diff --git a/tests/test_locale_freshness_regressions.py b/tests/test_locale_freshness_regressions.py new file mode 100644 index 0000000..b5c4a42 --- /dev/null +++ b/tests/test_locale_freshness_regressions.py @@ -0,0 +1,103 @@ +#!/usr/bin/env python3 +"""Regression tests for locale-pair freshness and survival.""" +from __future__ import annotations + +import json +import shutil +import subprocess +import sys +import tempfile +from pathlib import Path + + +ROOT = Path(__file__).resolve().parents[1] +CHECKER = ROOT / "tests" / "test_locale_freshness.py" + + +def git(repository: Path, *args: str) -> str: + result = subprocess.run( + ["git", *args], + cwd=repository, + text=True, + capture_output=True, + check=False, + ) + if result.returncode != 0: + raise AssertionError(f"git {' '.join(args)} failed:\n{result.stdout}\n{result.stderr}") + return result.stdout.strip() + + +def init_repository(repository: Path) -> str: + (repository / "tests").mkdir() + (repository / "locales" / "zh-CN").mkdir(parents=True) + shutil.copy2(CHECKER, repository / "tests" / CHECKER.name) + (repository / "tests" / "locale_review_acknowledgements.json").write_text( + json.dumps({"schema_version": 1, "acknowledgements": []}) + "\n", + encoding="utf-8", + ) + (repository / "README.md").write_text("English source\n", encoding="utf-8") + (repository / "locales" / "zh-CN" / "README.md").write_text( + "Chinese counterpart\n", encoding="utf-8" + ) + git(repository, "init", "--quiet") + git(repository, "config", "user.name", "Fixture") + git(repository, "config", "user.email", "fixture@example.invalid") + git(repository, "add", ".") + git(repository, "commit", "--quiet", "-m", "initial pair") + return git(repository, "rev-parse", "HEAD") + + +def commit(repository: Path, message: str) -> str: + git(repository, "add", "-A") + git(repository, "commit", "--quiet", "-m", message) + return git(repository, "rev-parse", "HEAD") + + +def run_checker(repository: Path, base: str, head: str) -> subprocess.CompletedProcess[str]: + return subprocess.run( + [sys.executable, "tests/test_locale_freshness.py", base, head], + cwd=repository, + text=True, + capture_output=True, + check=False, + ) + + +def expect_failure(result: subprocess.CompletedProcess[str], label: str) -> None: + if result.returncode == 0: + raise AssertionError(f"{label} was not rejected") + + +def main() -> int: + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) + base = init_repository(repository) + (repository / "locales" / "zh-CN" / "README.md").unlink() + head = commit(repository, "delete localized counterpart") + expect_failure(run_checker(repository, base, head), "localized deletion") + + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) + base = init_repository(repository) + git(repository, "mv", "README.md", "GUIDE.md") + head = commit(repository, "rename canonical source only") + expect_failure(run_checker(repository, base, head), "unpaired source rename") + + with tempfile.TemporaryDirectory() as temporary: + repository = Path(temporary) + base = init_repository(repository) + git(repository, "mv", "README.md", "GUIDE.md") + git(repository, "mv", "locales/zh-CN/README.md", "locales/zh-CN/GUIDE.md") + head = commit(repository, "rename both sides of pair") + result = run_checker(repository, base, head) + if result.returncode != 0: + raise AssertionError( + f"paired rename was rejected:\n{result.stdout}\n{result.stderr}" + ) + + print("locale freshness regression tests passed") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tests/test_locale_invariants.py b/tests/test_locale_invariants.py index fb32350..5ae0fc2 100644 --- a/tests/test_locale_invariants.py +++ b/tests/test_locale_invariants.py @@ -13,6 +13,19 @@ ROOT = Path(__file__).resolve().parents[1] SURFACES: dict[str, dict[str, object]] = { + "README.md": { + "locale": "locales/zh-CN/README.md", + "literals": ( + "AI_ENTRYPOINT.md", + "lobby/", + "datadrops/", + "responses/", + "registry/", + "docs/ACQUISITION.md", + "docs/quickstart.md", + "https://github.com/Wonderforge-Lab/CapstanAI-LabNote/generate", + ), + }, "AI_ENTRYPOINT.md": { "locale": "locales/zh-CN/AI_ENTRYPOINT.md", "literals": ( @@ -37,6 +50,45 @@ "bridge_config.json", ), }, + "docs/README.md": { + "locale": "locales/zh-CN/docs/README.md", + "literals": ( + "ACQUISITION.md", + "quickstart.md", + "../lobby/ROUTINE_DEPOSIT_QUICKSTART.md", + "review_workflow.md", + "visitor_lobby_model.md", + "message_routing_model.md", + "REGISTRY_RECORDS.md", + "storage_policy.md", + "DOCUMENT_DEPOSIT_POLICY.md", + "../SECURITY.md", + "WHY_LABNOTE.md", + "PROJECT_PROVENANCE.md", + ), + }, + "docs/ACQUISITION.md": { + "locale": "locales/zh-CN/docs/ACQUISITION.md", + "literals": ( + "AI_ENTRYPOINT.md", + "https://github.com/Wonderforge-Lab/CapstanAI-LabNote.git", + "git clone", + "git remote rename origin upstream", + "git remote add origin ", + "git push -u origin main", + "Use this template", + ), + }, + "docs/quickstart.md": { + "locale": "locales/zh-CN/docs/quickstart.md", + "literals": ( + "AI_ENTRYPOINT.md", + "ACQUISITION.md", + "registry/packets//", + "registry/responses//", + "accepted", + ), + }, "docs/localization/GLOSSARY.md": { "locale": "locales/zh-CN/GLOSSARY.md", "literals": (