chore(l10n): add CI guard against ARB placeholder corruption
Build & Deploy PluriWave / Análisis de código (push) Successful in 24s
Build & Deploy PluriWave / Build APK + AAB release (push) Successful in 1m45s

Adds tool/check_arb_placeholder_corruption.py, a static check that flags
literal "?" glued to an ICU placeholder brace in any lib/l10n/app_*.arb
value that has a placeholders metadata block. This is the exact corruption
shape fixed in the previous commit; flutter analyze doesn't catch it since
the JSON/ICU stays syntactically valid. Wired as a CI step before
flutter analyze so it fails fast.

Also audited lib/l10n/app_localizations_ext.dart (hand-maintained weekday/
month/date-sentence maps, not covered by ARB tooling): all 22 locale maps
have the full 13/13 keys with no corruption or leftover English — no
changes needed there.
This commit is contained in:
Javier Bautista Fernández
2026-07-21 10:04:41 +02:00
parent 689a386403
commit 90b75c1825
2 changed files with 94 additions and 0 deletions
+3
View File
@@ -27,6 +27,9 @@ jobs:
- name: Obtener dependencias - name: Obtener dependencias
run: flutter pub get run: flutter pub get
- name: Verificar integridad de literales i18n
run: python3 tool/check_arb_placeholder_corruption.py
- name: Analizar código - name: Analizar código
run: flutter analyze --no-fatal-infos --no-fatal-warnings run: flutter analyze --no-fatal-infos --no-fatal-warnings
+91
View File
@@ -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())