feat(i18n): require translation updates for English copy changes (#15794)

This commit is contained in:
Wenxi authored and GitHub committed 2026-10-09 23:34:20 +00:00
1 parent f7769f27e7
commit d89fd563ca
5 files changed
+259

No files matched your search

+2
View File
@@ -45,6 +45,8 @@ jobs:
- uses: j178/prek-action@4e14d07f9231acabce116ccfca13b13dd9755ece
env:
SKIP: ty,desktop-rust-fmt,desktop-rust-clippy
# All-files checks need the push range for translation comparisons.
PRE_COMMIT_FROM_REF: ${{ github.event_name == 'push' && github.ref_name == 'main' && github.event.before != '0000000000000000000000000000000000000000' && github.event.before || '' }}
# Lets the zizmor hook run its online audits. Read-only: the job
# grants `contents: read` and nothing else.
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+7
View File
@@ -273,6 +273,13 @@ repos:
- repo: local
hooks:
- id: i18n-copy-changes
name: translation updates for English copy changes
language: system
entry: python3 tools/i18n/check_copy_changes.py
pass_filenames: false
stages: [pre-commit]
files: ^web/src/i18n/messages/.*\.json$
# `uv pip compile` cannot read `[build-system] requires`, so the pins are
# duplicated in tools/requirements/build-constraints.in. Catch a mismatch
# here rather than as an unsatisfiable resolution when a release builds wheels.
+102
View File
@@ -0,0 +1,102 @@
"""Reject English copy changes whose translations have not changed."""
from __future__ import annotations
import argparse
import json
import os
import subprocess
from pathlib import Path
CATALOG_PATH: Path = Path("web/src/i18n/messages")
def flatten(value: object, prefix: str = "") -> dict[str, str]:
if isinstance(value, str):
return {prefix: value}
if not isinstance(value, dict):
raise ValueError(f"Invalid message at {prefix}")
messages: dict[str, str] = {}
for key, child in value.items():
if not isinstance(key, str):
raise ValueError("Message keys must be strings")
messages.update(flatten(child, f"{prefix}.{key}" if prefix else key))
return messages
def read_catalog(path: Path, base: str | None = None) -> dict[str, str]:
source: str = (
subprocess.check_output(["git", "show", f"{base}:{path.as_posix()}"], text=True)
if base
else path.read_text()
)
return flatten(json.loads(source))
def default_base() -> str:
"""Check working edits against HEAD, or the last commit in a clean checkout."""
changes: str = subprocess.check_output(
["git", "status", "--porcelain", "--", CATALOG_PATH.as_posix()], text=True
)
if changes:
return "HEAD"
parent: subprocess.CompletedProcess[str] = subprocess.run(
["git", "rev-parse", "--verify", "HEAD^"],
capture_output=True,
text=True,
check=False,
)
return "HEAD^" if parent.returncode == 0 else "HEAD"
def main() -> int:
parser: argparse.ArgumentParser = argparse.ArgumentParser(description=__doc__)
parser.add_argument("--base")
args: argparse.Namespace = parser.parse_args()
base: str = args.base or os.environ.get("PRE_COMMIT_FROM_REF") or default_base()
previous: dict[str, str] = read_catalog(CATALOG_PATH / "en.json", base)
current: dict[str, str] = read_catalog(CATALOG_PATH / "en.json")
changed: list[str] = [
key for key, text in current.items() if previous.get(key) != text
]
if not changed:
return 0
baseline_paths: set[str] = set(
subprocess.check_output(
[
"git",
"ls-tree",
"-r",
"--name-only",
base,
"--",
CATALOG_PATH.as_posix(),
],
text=True,
).splitlines()
)
failures: list[str] = []
for path in sorted(CATALOG_PATH.glob("*.json")):
if path.name == "en.json":
continue
old: dict[str, str] = (
read_catalog(path, base) if path.as_posix() in baseline_paths else {}
)
new: dict[str, str] = read_catalog(path)
failures.extend(
f"{path.stem}: {key}"
for key in changed
if key not in new or new[key] == old.get(key)
)
if failures:
print("English copy changed without corresponding translation updates:")
print("\n".join(f" {failure}" for failure in failures))
print(
"Update each listed translation. This check cannot assess translation quality."
)
return 1
return 0
if __name__ == "__main__":
raise SystemExit(main())
+144
View File
@@ -0,0 +1,144 @@
"""Exercise the copy gate against real Git baselines and working catalogs."""
import json
import os
import subprocess
import sys
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch
CHECKER: Path = Path(__file__).with_name("check_copy_changes.py").resolve()
class CopyChangesTest(unittest.TestCase):
def setUp(self) -> None:
self.temp: tempfile.TemporaryDirectory[str] = tempfile.TemporaryDirectory()
self.addCleanup(self.temp.cleanup)
self.root: Path = Path(self.temp.name)
self.catalogs: Path = self.root / "web/src/i18n/messages"
self.catalogs.mkdir(parents=True)
self.git("init", "-q")
self.write("en", {"title": "Hello", "other": "Keep"})
self.write("fr", {"title": "Bonjour", "other": "Garder"})
self.write("de", {"title": "Hallo", "other": "Behalten"})
self.git("add", ".")
self.git(
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"commit",
"-qm",
"baseline",
)
def git(self, *args: str) -> str:
return subprocess.check_output(["git", *args], cwd=self.root, text=True)
def write(self, locale: str, messages: dict[str, str]) -> None:
(self.catalogs / f"{locale}.json").write_text(json.dumps({"nested": messages}))
def check(self, *args: str) -> subprocess.CompletedProcess[str]:
return subprocess.run(
[sys.executable, str(CHECKER), *args],
cwd=self.root,
text=True,
capture_output=True,
check=False,
)
def test_unchanged_copy_passes(self) -> None:
self.assertEqual(self.check().returncode, 0)
def test_requires_the_same_key_in_every_locale(self) -> None:
self.write("en", {"title": "Welcome", "other": "Keep"})
self.write("fr", {"title": "Bienvenue", "other": "Garder"})
self.write("de", {"title": "Hallo", "other": "Anders"})
result: subprocess.CompletedProcess[str] = self.check()
self.assertEqual(result.returncode, 1)
self.assertIn("de: nested.title", result.stdout)
self.assertNotIn("fr: nested.title", result.stdout)
def test_updated_translations_pass(self) -> None:
self.write("en", {"title": "Welcome"})
self.write("fr", {"title": "Bienvenue"})
self.write("de", {"title": "Willkommen"})
self.assertEqual(self.check().returncode, 0)
def test_missing_new_key_fails(self) -> None:
self.write("en", {"title": "Hello", "new": "New"})
self.assertIn("fr: nested.new", self.check().stdout)
self.assertEqual(self.check().returncode, 1)
def test_branch_base_checks_committed_copy(self) -> None:
base: str = self.git("rev-parse", "HEAD").strip()
self.write("en", {"title": "Welcome"})
self.git("add", ".")
self.git(
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"commit",
"-qm",
"copy",
)
self.assertEqual(self.check().returncode, 1)
self.assertEqual(self.check("--base", base).returncode, 1)
def test_new_locale_has_no_previous_translation(self) -> None:
self.write("en", {"title": "Welcome"})
self.write("fr", {"title": "Bienvenue"})
self.write("de", {"title": "Willkommen"})
self.write("es", {"title": "Bienvenido"})
self.assertEqual(self.check().returncode, 0)
def test_push_range_checks_copy_before_an_unrelated_commit(self) -> None:
base: str = self.git("rev-parse", "HEAD").strip()
self.write("en", {"title": "Welcome"})
self.git("add", ".")
self.git(
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"commit",
"-qm",
"copy",
)
(self.root / "unrelated.txt").write_text("unrelated")
self.git("add", ".")
self.git(
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"commit",
"-qm",
"unrelated",
)
self.assertEqual(self.check().returncode, 0)
with patch.dict(os.environ, {"PRE_COMMIT_FROM_REF": base}):
self.assertEqual(self.check().returncode, 1)
def test_clean_checkout_with_committed_translations_passes(self) -> None:
self.write("en", {"title": "Welcome"})
self.write("fr", {"title": "Bienvenue"})
self.write("de", {"title": "Willkommen"})
self.git("add", ".")
self.git(
"-c",
"user.name=Test",
"-c",
"user.email=test@example.com",
"commit",
"-qm",
"translated copy",
)
self.assertEqual(self.check().returncode, 0)
if __name__ == "__main__":
unittest.main()
+4
View File
@@ -63,6 +63,10 @@ Never import from `web/src/components/`. It is legacy and being deleted. The one
- `web/src/i18n/messages/en.json` is the source of truth. When you add or change a key, add your
best translation to every other locale file in that directory. Missing or extra keys fail
`types:check`. ICU shape must match across locales (`src/i18n/__tests__/catalog.test.ts`).
- The `i18n-copy-changes` pre-commit hook requires each changed English message to have a changed translation in every locale.
It uses the pre-commit range base when supplied. Otherwise, it checks working edits against HEAD or clean checkouts against HEAD^.
Main push checks use the push event’s previous commit. The hook does not assess translation quality.
Run `python3 tools/i18n/check_copy_changes.py --base origin/main` from the repository root to check a branch.
- Keys are stable identifiers: `<namespace>.<section>.<element>.<role>` in camelCase, for example
`settings.appearance.colorMode.title`. Rewording the English never changes the key.
- Use ICU for arguments and plurals. Never concatenate translated fragments.