diff --git a/.github/workflows/pr-quality-checks.yml b/.github/workflows/pr-quality-checks.yml index b2dbe62da4..c6ba786650 100644 --- a/.github/workflows/pr-quality-checks.yml +++ b/.github/workflows/pr-quality-checks.yml @@ -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 }} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b9d72efa51..2fa2237ea8 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -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. diff --git a/tools/i18n/check_copy_changes.py b/tools/i18n/check_copy_changes.py new file mode 100644 index 0000000000..ddeb22a16c --- /dev/null +++ b/tools/i18n/check_copy_changes.py @@ -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()) diff --git a/tools/i18n/test_check_copy_changes.py b/tools/i18n/test_check_copy_changes.py new file mode 100644 index 0000000000..56efa64c3d --- /dev/null +++ b/tools/i18n/test_check_copy_changes.py @@ -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() diff --git a/web/AGENTS.md b/web/AGENTS.md index 101b8fb389..abd350ec01 100644 --- a/web/AGENTS.md +++ b/web/AGENTS.md @@ -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: `.
..` 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.