chore(l10n): add CI guard against ARB placeholder corruption
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:
@@ -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
|
||||
|
||||
|
||||
@@ -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())
|
||||
Reference in New Issue
Block a user