diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index f400078..687cd6a 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -27,6 +27,9 @@ jobs: - name: Obtener dependencias run: flutter pub get + - name: Verificar integridad de literales i18n + run: python3 tool/check_arb_placeholder_corruption.py + - name: Analizar código run: flutter analyze --no-fatal-infos --no-fatal-warnings diff --git a/tool/check_arb_placeholder_corruption.py b/tool/check_arb_placeholder_corruption.py new file mode 100644 index 0000000..3e8c9bc --- /dev/null +++ b/tool/check_arb_placeholder_corruption.py @@ -0,0 +1,91 @@ +#!/usr/bin/env python3 +"""Detect corrupted ICU placeholders in ARB localization files. + +Guards against the class of bug where a translation string value gets a +literal '?' glued next to an ICU placeholder brace (e.g. "{hours}?? +{minutes}?"), silently destroying the translated text while staying valid +JSON/ICU. This does not require Flutter tooling, so it can run early and +fast in CI. +""" + +from __future__ import annotations + +import glob +import json +import re +import sys +from pathlib import Path + +# A literal '?' sitting immediately adjacent (0-1 whitespace chars) to a +# placeholder brace. Narrow on purpose: a normal sentence ending in a real +# '?' does not sit glued to a '{' or '}', so it won't match. +CORRUPTION_PATTERN = re.compile(r"\}\s?\?|\?\s?\{") + +ARB_GLOB = "lib/l10n/app_*.arb" + + +def find_repo_root(start: Path) -> Path: + """Walk up from `start` until a directory containing lib/l10n is found.""" + current = start.resolve() + for candidate in [current, *current.parents]: + if (candidate / "lib" / "l10n").is_dir(): + return candidate + return current + + +def check_file(path: Path) -> list[str]: + """Return a list of human-readable findings for a single ARB file.""" + findings: list[str] = [] + + try: + raw = path.read_text(encoding="utf-8") + except UnicodeDecodeError as exc: + return [f"{path}: FAILED TO DECODE AS UTF-8 -> {exc}"] + + try: + data = json.loads(raw) + except json.JSONDecodeError as exc: + return [f"{path}: FAILED TO PARSE JSON -> {exc}"] + + for key, value in data.items(): + if key.startswith("@"): + continue + + meta = data.get(f"@{key}") + if not isinstance(meta, dict) or "placeholders" not in meta: + continue + + if not isinstance(value, str): + continue + + if CORRUPTION_PATTERN.search(value): + findings.append(f'{path}:{key} -> "{value}"') + + return findings + + +def main() -> int: + repo_root = find_repo_root(Path(__file__).parent) + arb_files = sorted(glob.glob(str(repo_root / ARB_GLOB))) + + if not arb_files: + print(f"No ARB files found matching {ARB_GLOB} under {repo_root}") + return 1 + + all_findings: list[str] = [] + for arb_path in arb_files: + all_findings.extend(check_file(Path(arb_path))) + + if all_findings: + print("Placeholder corruption detected in ARB files:") + for finding in all_findings: + print(f" {finding}") + print(f"\n{len(all_findings)} finding(s) across {len(arb_files)} file(s).") + return 1 + + print(f"OK: checked {len(arb_files)} ARB file(s), no placeholder corruption found.") + return 0 + + +if __name__ == "__main__": + sys.exit(main())