Files
amethyst/.claude/hooks/compose_escaping_check.py
T
Vitor PamplonaandClaude Opus 5 f9baab0e92 fix(strings): re-apply escape conversion, and add a check so it stops recurring
The Crowdin sync reintroduced Android escaping into the Compose catalog a second
time (PR #4046, commit 2129e16044): the same 2,068 escaped apostrophes and 749
escaped quotes across 40 locale files, always the apostrophe-heavy regional
variants -- uz-rUZ 949, fr-rFR 341, fr-rCA 326, tr-rTR 189. Compose resolves
neither, so those strings render with a literal backslash.

Repairing after each sync is not a fix: Crowdin holds the Android-escaped source,
so every import brings it back. Add .claude/hooks/compose_escaping_check.py, a
sub-second scan of the Compose catalog for \' \" \? \@ and tools: attributes,
mirroring orphan_strings_check.py -- same shape of bug, same fix. \n, \t, \uXXXX
and \\ are left alone because Compose resolves those itself, and Android res trees
are not scanned because there the escaping is correct.

Verified in both directions: clean tree exits 0; restoring the regressed
values-fr-rFR makes it exit 1 and name the file with a per-escape breakdown.

Not yet wired into CI or the pre-push hook -- that is a maintainer call about
where the gate lives. As amethyst/src/main/res/CLAUDE.md notes for the identical
orphan-strings desync, the CI lint job is the layer that matters: these arrive
through bot-authored PRs with no local session in the path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01V1CzYQvWyHfipSW7x3j4Yo
2026-09-03 14:07:48 -04:00

89 lines
3.4 KiB
Python
Executable File

#!/usr/bin/env python3
"""Fail if the Compose resource catalog carries Android-only string escaping.
Android's aapt and Compose Multiplatform do not share escaping rules. Compose
(compose-gradle-plugin `handleSpecialCharacters`) resolves only \\uXXXX, \\n and
\\t, and collapses \\\\. It leaves \\' \\" \\? \\@ alone and renders Android's
quote-wrapping literally, so a value carried verbatim out of `res/values/` ships
a visible backslash: the login screen once read `Don\\'t have a Nostr account?`.
`tools:` attributes are the same class of mistake. They are an Android-lint
construct whose namespace is declared on the Android `<resources>` root; Compose
catalog roots do not declare it, so the prefix is unbound and the XML malformed.
**This recurs on every Crowdin sync.** Crowdin holds the Android-escaped source,
so each import reintroduces it -- twice in two days, 2,068 escaped apostrophes
across 40 locales each time, always the apostrophe-heavy regional variants
(uz-rUZ, fr-rFR, fr-rCA, tr-rTR). Like the orphan-strings desync, it arrives
through a bot-authored PR with no local session in the path, so CI is the layer
that has to catch it.
Repair with:
python3 tools/strings-migrate/fix_escapes.py --no-unwrap-quotes \\
commons/src/commonMain/composeResources
`--no-unwrap-quotes` is mandatory on already-migrated files: escape conversion is
idempotent, quote-unwrapping is not, and a second unwrap strips the real display
quotes from values like `import_follows_tips`.
Only the Compose catalog is scanned. In an Android res tree the same escaping is
correct and must be left alone.
"""
import re
import sys
from collections import defaultdict
from pathlib import Path
CATALOG = "*/src/*/composeResources/values*/strings.xml"
# A backslash escape that is not itself escaped. \n, \t, \uXXXX and \\ are fine --
# Compose resolves those itself.
ANDROID_ESCAPE = re.compile(r"(?<!\\)\\(['\"?@])")
TOOLS_ATTR = re.compile(r'tools:[\w.-]+="')
def find_violations(root: Path):
found = defaultdict(lambda: defaultdict(int))
for path in sorted(root.glob(CATALOG)):
text = path.read_text(encoding="utf-8", errors="replace")
for esc in ANDROID_ESCAPE.findall(text):
found[path][f"\\{esc}"] += 1
n = len(TOOLS_ATTR.findall(text))
if n:
found[path]["tools: attribute"] += n
return found
def main() -> int:
root = Path(__file__).resolve().parents[2]
found = find_violations(root)
if not found:
return 0
out = sys.stderr
total = sum(sum(k.values()) for k in found.values())
print(
f"Android-only escaping in the Compose resource catalog: "
f"{total} occurrence(s) in {len(found)} file(s).",
file=out,
)
print("Compose does not resolve these; they render literally.\n", file=out)
for path, kinds in sorted(found.items(), key=lambda kv: -sum(kv[1].values()))[:12]:
detail = ", ".join(f"{k} x{v}" for k, v in sorted(kinds.items()))
print(f" {path.relative_to(root)}: {detail}", file=out)
if len(found) > 12:
print(f" ... and {len(found) - 12} more file(s)", file=out)
print(
"\nRepair:\n"
" python3 tools/strings-migrate/fix_escapes.py --no-unwrap-quotes \\\n"
" commons/src/commonMain/composeResources\n"
"(--no-unwrap-quotes is mandatory on already-migrated files.)",
file=out,
)
return 1
if __name__ == "__main__":
sys.exit(main())