Skip to content

i18n audit is blind to conditional literals — Text(if x "a" else "b") reports clean #540

Closed

Description

@ryanbr

The problem

Tools/i18n_audit.py reports "OK no hardcoded literals" on main today — while at least seven user-facing English strings ship untranslated to German, Spanish and French.

The Android scan matches a literal only when it sits directly after the composable's opening paren:

ANDROID_PATTERN = re.compile(
    r"\b(?:Text|Snackbar|AlertDialog|TopAppBar)\s*\(\s*\"((?:[^\"\\]|\\.)*)\""
    r"|"
    r"\b(?:title|label|text|contentDescription|placeholder)\s*=\s*\"((?:[^\"\\]|\\.)*)\""
)

So Text("Save") is caught, but Text(if (saved) "Saved" else "Save") is not — Text( is followed by if, not ". The regex slides straight past, and the gate reports clean.

Evidence

grep -rnE "\b(Text|Snackbar|AlertDialog|TopAppBar)\s*\(\s*(if|when)\b" android/app/src/main/java/com/noop/ui/

JournalLog.kt:411          Text(if (item.kind.isNumeric) "Change to Yes/No" else "Change to Number")
SkinTempCardsScreen.kt:280 Text(if (plan == null) "Plan a trip or shift" else "View the full plan")
HrvSnapshotScreen.kt:290   Text(if (saved) "Saved" else "Save")
WorkoutsScreen.kt:1621     Text(if (editing == null) "Add Workout" else "Edit Workout")
WorkoutsScreen.kt:1667     Text(if (editing == null) "Add" else "Save")
BreatheScreen.kt:491       Text(if (running) "Stop session" else "Start session")
JournalLog.kt:300          Text(if (collapsed) "▸" else "▾")            <- glyphs, not translatable

Every one of these is a button or label a German/Spanish/French user reads in English.

The eighth was the "Keep NOOP alive overnight" row — a whole multi-sentence battery warning, hidden the same way. Fixed in #539, which is what surfaced this.

Why it matters more than the count suggests

This is a gate that reports false confidence. #514 (a release shipping an unlocalised changelog title) and #535 (auditing main pushes) both leaned on this audit as the thing that catches drift. It does — but only for literals in the one shape it can see. Anything behind a conditional is invisible, and every future Text(if …) slips the same way, silently.

For these sites, a gate that says "clean" is arguably worse than no gate: it stops anyone looking.

Possible directions

  • Extend the regex to allow if/when between the paren and the literal. Cheapest, but regex-over-Kotlin stays fragile — the next shape (Text(buildString { … }), a ?:, a let) slips again.
  • Scan the argument span: find literals anywhere inside a Text(...)/Button(...) call, using balanced-paren matching rather than a flat regex. Catches conditionals and most other shapes.
  • Invert it: flag every string literal in ui//widget/ that passes the existing is_probably_ui_text heuristic and is not already a resource reference, with an explicit allowlist for the genuine non-UI cases (log lines, pref keys, test ids). Strictest, and it fails closed — but needs an allowlist seeded once.

The third is the only one that fails closed. The second is probably the best effort/robustness trade.

No urgency on the seven sites themselves — they are short labels. The gate is the point.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions