Files
inkwell/android/tools/check-strings.py
T
bvandeusenandClaude Opus 5.5 0a631219ee
CI & Build / Python lint (push) Successful in 2s
CI & Build / Build now, or wait for Android? (push) Successful in 3s
Android / Build, or is the channel already serving this? (push) Successful in 3s
Desktop (Tauri) / Build, or is the channel already serving this? (push) Successful in 2s
Desktop (Tauri) / Web tests, clippy, Rust tests and rustfmt (push) Skipped
Desktop (Tauri) / Tauri desktop (Linux) (push) Skipped
Desktop (Tauri) / Windows installer (cross-compiled) (push) Skipped
Desktop (Tauri) / Update manifest (push) Skipped
CI & Build / Web typecheck and unit tests (push) Successful in 9s
CI & Build / Python tests (push) Successful in 15s
Android / Core and FFI clippy and tests (push) Successful in 27s
CI & Build / integration (push) Successful in 1m39s
CI & Build / Build & push image (push) Skipped
Android / Kotlin + Rust (APK) (push) Successful in 7m55s
Android / Build the server image (push) Successful in 6s
Android comments describe the code to a stranger (#6223)
Milestone 546 "Bring the repo to the comment standard (rule 32) and
compose defaults (rule 198)", step #6223 "Comment standard sweep:
Android client".

KDoc, comments, manifest and resource notes, Gradle and TOML comments
and the ffi crate's docs now state what the code does and the
non-obvious why in the present tense. Issue, Scribe-note, milestone
(M13, M304, M315, M12) and family-idea references are gone, as are
comparisons to Minstrel and the narration of what used to be: the
capture sheet the editor replaced, the per-note colour story, the
operator's device-pass feedback, the bindgen-as-[[bin]] failure, the
0.60 edge alpha before it was re-solved, and the checker bugs behind
check-strings/check-symbols. Hazards stay as facts: the signing key that
can't be rotated, the alias that keeps the earlier name, PKCS12's single
password, unwind over abort, the UTC date-picker read, the
DerivedTint/colors.ts mirror.

One stale claim is gone: DerivedTint said the web had no test runner,
but notes/grammar.test.ts runs the same tint cases. detekt.yml said
"these two places" over a list of five.

49 files, comments only: a comment-stripping diff of every changed file
against HEAD is identical.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 20:24:52 -04:00

111 lines
3.6 KiB
Python
Executable File

#!/usr/bin/env python3
"""Check every R.string / R.plurals reference against strings.xml.
Three ways a resource reference compiles and then fails, none of which ktlint,
detekt or the Kotlin compiler will catch:
1. the name does not exist -> resource-not-found at runtime
2. `stringResource` on a plural (or the reverse) -> wrong overload, wrong text
3. the format string takes more arguments than the call passes -> the format
silently renders `%2$s` as literal text, or throws
python3 android/tools/check-strings.py
Exits non-zero on any problem.
"""
import glob
import os
import re
import sys
import xml.etree.ElementTree as ET
BASE = (
sys.argv[1]
if len(sys.argv) > 1
else os.path.join(os.path.dirname(os.path.abspath(__file__)), "..")
)
STRINGS = os.path.join(BASE, "app", "src", "main", "res", "values", "strings.xml")
SOURCES = os.path.join(BASE, "app", "src", "main", "java", "**", "*.kt")
CALL = re.compile(
r"(stringResource|pluralStringResource)\(\s*R\.(string|plurals)\.(\w+)"
r"((?:[^()]|\([^()]*\))*)\)"
)
def arity(text):
"""How many distinct arguments a format string consumes."""
numbered = set(re.findall(r"%(\d)\$", text))
return len(numbered) if numbered else len(re.findall(r"%[sd]", text))
def supplied_args(rest):
"""Count top-level commas in an argument tail.
Kotlin permits a TRAILING comma before the closing paren, which is not an
argument; counting it would inflate every multi-line call by one. Braces count
toward depth as well as parens, or a comma inside a lambda would be read as
another argument.
"""
rest = rest.rstrip()
if rest.endswith(","):
rest = rest[:-1]
depth = 0
count = 0
for ch in rest:
if ch in "([{":
depth += 1
elif ch in ")]}":
depth -= 1
elif ch == "," and depth == 0:
count += 1
return count
def main():
root = ET.parse(STRINGS).getroot()
strings = {e.get("name"): "".join(e.itertext()) for e in root.findall("string")}
plurals = {
e.get("name"): max(
(arity("".join(i.itertext())) for i in e.findall("item")), default=0
)
for e in root.findall("plurals")
}
problems = 0
for path in glob.glob(SOURCES, recursive=True):
with open(path, encoding="utf-8") as fh:
src = fh.read()
for match in CALL.finditer(src):
fn, kind, name, rest = match.groups()
line = src[: match.start()].count("\n") + 1
where = f"{os.path.basename(path)}:{line} {name}"
if kind == "string" and name not in strings:
print(f"MISSING {where}: no such string")
problems += 1
continue
if kind == "plurals" and name not in plurals:
print(f"MISSING {where}: no such plural")
problems += 1
continue
if (fn == "pluralStringResource") != (kind == "plurals"):
print(f"KIND {where}: {fn} used on R.{kind}")
problems += 1
continue
passed = supplied_args(rest)
# A plural call passes the count first, then the format arguments.
wanted = arity(strings[name]) if kind == "string" else plurals[name] + 1
if passed != wanted:
print(f"ARITY {where}: wants {wanted}, call passes {passed}")
problems += 1
print(f"\n{len(strings)} strings, {len(plurals)} plurals, {problems} problems")
return 1 if problems else 0
if __name__ == "__main__":
sys.exit(main())