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.
92 lines
2.7 KiB
Python
92 lines
2.7 KiB
Python
#!/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())
|