Skip to content

i18n_audit: catch contentDescription inside a semantics {} lambda (fixes #571) - #581

Merged
ryanbr merged 1 commit into
mainfrom
fix/i18n-audit-a11y-lambda
Jul 18, 2026
Merged

ryanbr merged 1 commit into
mainfrom
fix/i18n-audit-a11y-lambda

Conversation

@ryanbr

@ryanbr ryanbr commented Jul 18, 2026 •

Copy link
Copy Markdown
Owner

Addresses the inline case of #571. Tools-only Python, so unlike the recent app-target PRs it's fully tested here — 26/26 and --ci clean, no verification gap.

The gap

#558 taught the audit to see conditional literals (Text(if x "a" else "b")), but a UI-text kwarg assigned inside a lambda stayed invisible. contentDescription is already in ANDROID_KWARG_PATTERN, but that pass is gated on an argument boundary (( or ,), so the standard Compose idiom

Modifier.semantics { contentDescription = if (hr) "Ready" else "No data" }

is skipped — its { isn't an arg boundary — and the audit reports clean while TalkBack reads English regardless of device language.

The fix

A dedicated assignment pass (ANDROID_A11Y_ASSIGN_PATTERN): a bare contentDescription = — not a == comparison, a .member read, or a val/var local declaration — is a UI-text site wherever it's assigned. It reads only its own value span via _argument_span_end (which stops at the enclosing }), so it does not reopen unrelated slot lambdas. The 489-explosion guard from #558 still holds (there's a test for exactly that).

Surfaces 22 pre-existing hardcoded a11y strings across 9 screens (AppRoot, CoachScreen, FusedRecordScreen, HealthScreen, PipBar, SettingsScreen, StepsCalibrationScreen, UpdatesInboxScreen, WorkoutsScreen) — all contentDescription = "literal" / = if … forms. Per the #558 pattern these land in the baseline as tracked backlog (170 → 192 android), not 22 rushed translations; --ci stays green and a genuinely new hardcoded literal still fails the gate.

Known limitation — val-laundered a11y is still NOT caught (correction to my first draft)

On re-review I confirmed this does not catch the specific case #571 cited as motivation. CoupledScreen.kt uses:

val a11y = when { … "Recovery calibrating, …" … else -> "Recovery, no data yet" }   // line 276
…
.semantics { contentDescription = a11y }                                           // line 305

The assignment's value is the variable a11y, so my pass finds no literal there; the literals live in the val a11y = when { … } declaration, which is deliberately not scanned (the #558 local-decl exclusion, there to avoid flagging a val text = when{} that isn't UI). Catching this needs light dataflow — "scan a val whose value flows into a contentDescription" — which is a distinct, more false-positive-prone change. So CoupledScreen's a11y strings remain invisible after this PR, and I'd keep #571 open (or spin a narrower follow-up) for the val-laundered variant rather than have this close it.

A few of the 22 are pure-interpolation ("$label: $valueText") that may need no translation — the baseline is a snapshot to triage, not a mandate.

Verification

  • python3 -m unittest test_i18n_audit → 26/26 (4 new: the semantics idiom, multi-line, decl/member-read exclusion, the no-reopen-unrelated-slot guard)
  • python3 Tools/i18n_audit.py --ci origin/main → exit 0

…#571)

#558 fixed conditional literals but left a different axis invisible: a UI-text kwarg assigned
INSIDE a lambda. `contentDescription` is in ANDROID_KWARG_PATTERN, but that pass is gated on an
arg boundary (`(` or `,`), so the standard Compose a11y idiom

    Modifier.semantics { contentDescription = if (hr) "Ready" else "No data" }

was skipped — its `{` isn't an arg boundary — and the audit reported clean while TalkBack read
English regardless of device language.

Adds a dedicated assignment pass: a bare `contentDescription =` (not a `==` comparison, a
`.member` read, or a `val`/`var` local decl) is a UI-text site wherever it's assigned. It reads
only its own value span via _argument_span_end (which stops at the enclosing `}`), so it does
NOT reopen unrelated slot lambdas — the 489-explosion guard still holds.

Surfaces 22 pre-existing hardcoded a11y strings across 9 screens (AppRoot, CoachScreen,
FusedRecordScreen, HealthScreen, PipBar, SettingsScreen, StepsCalibrationScreen,
UpdatesInboxScreen, WorkoutsScreen). Per the #558 pattern these land in the baseline as tracked
backlog (170 -> 192 android) rather than forcing 22 rushed translations; --ci stays green and
they get localized incrementally. NO new hardcoded literal can still land.

Tested (Tools-only Python, fully runnable — no app-target/CI gap):
  python3 -m unittest test_i18n_audit -> 26/26 (4 new: semantics idiom, multi-line, decl/member
    exclusion, and the no-reopen-unrelated-slot guard)
  python3 Tools/i18n_audit.py --ci origin/main -> exit 0
@ryanbr
ryanbr merged commit 2bf4ba4 into main Jul 18, 2026
1 check passed
@ryanbr
ryanbr deleted the fix/i18n-audit-a11y-lambda branch July 18, 2026 21:52
DX23876 pushed a commit to DX23876/noop that referenced this pull request Jul 22, 2026
…ryanbr#571) (ryanbr#581)

ryanbr#558 fixed conditional literals but left a different axis invisible: a UI-text kwarg assigned
INSIDE a lambda. `contentDescription` is in ANDROID_KWARG_PATTERN, but that pass is gated on an
arg boundary (`(` or `,`), so the standard Compose a11y idiom

    Modifier.semantics { contentDescription = if (hr) "Ready" else "No data" }

was skipped — its `{` isn't an arg boundary — and the audit reported clean while TalkBack read
English regardless of device language.

Adds a dedicated assignment pass: a bare `contentDescription =` (not a `==` comparison, a
`.member` read, or a `val`/`var` local decl) is a UI-text site wherever it's assigned. It reads
only its own value span via _argument_span_end (which stops at the enclosing `}`), so it does
NOT reopen unrelated slot lambdas — the 489-explosion guard still holds.

Surfaces 22 pre-existing hardcoded a11y strings across 9 screens (AppRoot, CoachScreen,
FusedRecordScreen, HealthScreen, PipBar, SettingsScreen, StepsCalibrationScreen,
UpdatesInboxScreen, WorkoutsScreen). Per the ryanbr#558 pattern these land in the baseline as tracked
backlog (170 -> 192 android) rather than forcing 22 rushed translations; --ci stays green and
they get localized incrementally. NO new hardcoded literal can still land.

Tested (Tools-only Python, fully runnable — no app-target/CI gap):
  python3 -m unittest test_i18n_audit -> 26/26 (4 new: semantics idiom, multi-line, decl/member
    exclusion, and the no-reopen-unrelated-slot guard)
  python3 Tools/i18n_audit.py --ci origin/main -> exit 0
colpa176 added a commit to colpa176/personalnoop that referenced this pull request Aug 2, 2026
Daily screen time arrives via a noop://import-screentime deep link, following
ryanbr#581's discipline: version-gated, strictly validated, staged for explicit
confirmation before any write, and stored under a dedicated source id that is
neither a strap nor an Apple source.

The double-tap trigger needed no code — FrameRouter already decodes DOUBLE_TAP
and MacActionKind.runShortcut already fires shortcuts://run-shortcut?name=<name>
from Automations, so a double-tap can run the Shortcut that reads the number and
opens this link. This is the receiving end.

Adds the screen_time metric under Mind, so it gets the standard detail page,
dot/line trend and correlation scan with no per-metric UI.

App build (macOS+iOS), i18n Coverage and Source Hygiene green on the branch.

# Conflicts:
#	Strand/Resources/Localizable.xcstrings
simoncad7 pushed a commit to simoncad7/noop that referenced this pull request Aug 17, 2026
…ryanbr#571) (ryanbr#581)

ryanbr#558 fixed conditional literals but left a different axis invisible: a UI-text kwarg assigned
INSIDE a lambda. `contentDescription` is in ANDROID_KWARG_PATTERN, but that pass is gated on an
arg boundary (`(` or `,`), so the standard Compose a11y idiom

    Modifier.semantics { contentDescription = if (hr) "Ready" else "No data" }

was skipped — its `{` isn't an arg boundary — and the audit reported clean while TalkBack read
English regardless of device language.

Adds a dedicated assignment pass: a bare `contentDescription =` (not a `==` comparison, a
`.member` read, or a `val`/`var` local decl) is a UI-text site wherever it's assigned. It reads
only its own value span via _argument_span_end (which stops at the enclosing `}`), so it does
NOT reopen unrelated slot lambdas — the 489-explosion guard still holds.

Surfaces 22 pre-existing hardcoded a11y strings across 9 screens (AppRoot, CoachScreen,
FusedRecordScreen, HealthScreen, PipBar, SettingsScreen, StepsCalibrationScreen,
UpdatesInboxScreen, WorkoutsScreen). Per the ryanbr#558 pattern these land in the baseline as tracked
backlog (170 -> 192 android) rather than forcing 22 rushed translations; --ci stays green and
they get localized incrementally. NO new hardcoded literal can still land.

Tested (Tools-only Python, fully runnable — no app-target/CI gap):
  python3 -m unittest test_i18n_audit -> 26/26 (4 new: semantics idiom, multi-line, decl/member
    exclusion, and the no-reopen-unrelated-slot guard)
  python3 Tools/i18n_audit.py --ci origin/main -> exit 0
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant