Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
111 changes: 110 additions & 1 deletion Tools/i18n_audit.py
Original file line number Diff line number Diff line change
Expand Up @@ -267,6 +267,8 @@ def _extract_literals(text: str, start: int, end: int) -> list[tuple[int, str]]:
# `_argument_span_end`, which stops at the enclosing `}`), so unrelated lambda content is never swept in.
ANDROID_A11Y_ASSIGN_PATTERN = re.compile(r"(?<![.\w])contentDescription\s*=(?!=)\s*")
_LOCAL_DECL_BEFORE = re.compile(r"\b(?:val|var)\s+\Z")
_SIMPLE_IDENTIFIER = re.compile(r"[A-Za-z_]\w*\Z")
_LOCAL_VAL_PATTERN = re.compile(r"\bval\s+([A-Za-z_]\w*)\s*=\s*")


def _mask_comments(text: str) -> str:
Expand Down Expand Up @@ -306,6 +308,101 @@ def _mask_comments(text: str) -> str:
return "".join(out)


def _brace_stack_at(text: str, end: int) -> tuple[int, ...]:
"""Opening `{` offsets whose scopes contain `end`, ignoring string
contents. Used for the deliberately small bit of Kotlin name resolution
below: a local `val` is visible only while its declaring brace is still
open at the use site."""
stack: list[int] = []
i = 0
while i < end:
ch = text[i]
if ch == '"':
i = _skip_string_literal(text, i)
continue
if ch == "{":
stack.append(i)
elif ch == "}" and stack:
stack.pop()
i += 1
return tuple(stack)


def _statement_span_end(text: str, start: int) -> int:
"""End of a Kotlin `val` initializer.

Newlines before the expression are allowed; once the expression starts, a
newline or semicolon at top level ends it unless Kotlin syntax clearly
continues on the next line (for example `"prefix" +` followed by an
`if`). Balanced calls and `when { }` / `if { }` expressions can span lines
without exposing the following statements to literal extraction.
"""
depth = 0
saw_expression = False
i = start
while i < len(text):
ch = text[i]
if ch == '"':
saw_expression = True
i = _skip_string_literal(text, i)
continue
if ch in "({[":
depth += 1
saw_expression = True
elif ch in ")}]":
if depth == 0:
return i
depth -= 1
elif depth == 0 and ch == ";":
return i
elif depth == 0 and ch == "\n" and saw_expression:
previous = i - 1
while previous >= start and text[previous].isspace():
previous -= 1
following = i + 1
while following < len(text) and text[following].isspace():
following += 1
trails_operator = (
previous >= start and text[previous] in "+-*/%&|?:,.="
)
starts_continuation = (
text.startswith(".", following)
or text.startswith("?:", following)
or re.match(r"else\b", text[following:]) is not None
)
if not trails_operator and not starts_continuation:
return i
elif not ch.isspace():
saw_expression = True
i += 1
return i


def _visible_val_initializer(
text: str, name: str, use_offset: int
) -> tuple[int, int] | None:
"""Initializer span for the nearest preceding `val name = ...` visible at
`use_offset`.

This is intentionally lexical rather than general Kotlin dataflow. It
covers the common Compose shape `val a11y = when { ... }; semantics {
contentDescription = a11y }`, while declining parameters, properties
outside a braced scope, computed references, and declarations in sibling
blocks. The nearest visible declaration wins, matching local shadowing.
"""
use_scopes = set(_brace_stack_at(text, use_offset))
declarations = list(_LOCAL_VAL_PATTERN.finditer(text, 0, use_offset))
for declaration in reversed(declarations):
if declaration.group(1) != name:
continue
declaration_scopes = _brace_stack_at(text, declaration.start())
if not declaration_scopes or declaration_scopes[-1] not in use_scopes:
continue
start = declaration.end()
return start, _statement_span_end(text, start)
return None


# Only an argument in actual call-argument position (right after `(` or `,`,
# modulo whitespace) — excludes `val text = when { "Awake" -> ...; ... }`,
# a plain local declaration this keyword list would otherwise also match.
Expand Down Expand Up @@ -351,7 +448,19 @@ def record(span_start: int, span_end: int) -> None:
for m in ANDROID_A11Y_ASSIGN_PATTERN.finditer(text):
if _LOCAL_DECL_BEFORE.search(text, 0, m.start()):
continue
record(m.end(), _argument_span_end(text, m.end()))
span_end = _argument_span_end(text, m.end())
record(m.end(), span_end)

# The remaining #571 case: the assignment contains no literal
# because a local `val` launders it. Follow only a bare
# identifier to the nearest lexically-visible declaration;
# pass-through parameters and arbitrary expressions remain
# outside this targeted audit rule.
reference = text[m.end():span_end].strip()
if _SIMPLE_IDENTIFIER.fullmatch(reference):
initializer = _visible_val_initializer(text, reference, m.start())
if initializer is not None:
record(*initializer)

return findings

Expand Down
100 changes: 92 additions & 8 deletions Tools/i18n_audit_baseline.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,26 @@
"android/app/src/main/java/com/noop/ui/CaffeineLog.kt",
"No caffeine logged. Log an intake to see an estimate."
],
[
"android/app/src/main/java/com/noop/ui/Charts.kt",
"Bars"
],
[
"android/app/src/main/java/com/noop/ui/Charts.kt",
"Breakdown, ${segments.size} segments"
],
[
"android/app/src/main/java/com/noop/ui/Charts.kt",
"Breakdown, no data"
],
[
"android/app/src/main/java/com/noop/ui/Charts.kt",
"Timeline"
],
[
"android/app/src/main/java/com/noop/ui/Charts.kt",
"Trend"
],
[
"android/app/src/main/java/com/noop/ui/CoachScreen.kt",
"Bring your own API key. It is stored encrypted on this device and only used to "
Expand Down Expand Up @@ -152,6 +172,18 @@
"android/app/src/main/java/com/noop/ui/CoupledScreen.kt",
"A classic one-glance read of NOOP's own scores. Same data, different lens."
],
[
"android/app/src/main/java/com/noop/ui/CoupledScreen.kt",
"Recovery ${recovery.roundToInt()} percent. See what shaped your Charge"
],
[
"android/app/src/main/java/com/noop/ui/CoupledScreen.kt",
"Recovery calibrating, $calibrationNights of ${Baselines.minNightsSeed} nights"
],
[
"android/app/src/main/java/com/noop/ui/CoupledScreen.kt",
"Recovery, no data yet"
],
[
"android/app/src/main/java/com/noop/ui/DataSourcesScreen.kt",
"Imported"
Expand Down Expand Up @@ -184,6 +216,14 @@
"android/app/src/main/java/com/noop/ui/FusedRecordScreen.kt",
", in use"
],
[
"android/app/src/main/java/com/noop/ui/FusedRecordScreen.kt",
"Scores still calibrating, no single day-owner yet"
],
[
"android/app/src/main/java/com/noop/ui/FusedRecordScreen.kt",
"Today's scores owned by ${owner.displayName}"
],
[
"android/app/src/main/java/com/noop/ui/HealthScreen.kt",
"$displayHr bpm"
Expand Down Expand Up @@ -260,6 +300,14 @@
"android/app/src/main/java/com/noop/ui/HealthScreen.kt",
"about your age"
],
[
"android/app/src/main/java/com/noop/ui/HrvSnapshotScreen.kt",
"$value $unit"
],
[
"android/app/src/main/java/com/noop/ui/HrvSnapshotScreen.kt",
"Capturing. $value milliseconds RMSSD so far. $sub."
],
[
"android/app/src/main/java/com/noop/ui/HrvSnapshotScreen.kt",
"Save"
Expand Down Expand Up @@ -416,6 +464,10 @@
"android/app/src/main/java/com/noop/ui/LiveWorkoutScreen.kt",
"Zone $zone: ${band.lower.toInt()} - ${band.upper.toInt()} bpm (${(band.lowerPct * 100).toInt()} - ${(band.upperPct * 100).toInt()}% max HR)"
],
[
"android/app/src/main/java/com/noop/ui/MindSection.kt",
"${face.word}, mood ${face.value.toInt()} of 5"
],
[
"android/app/src/main/java/com/noop/ui/MindSection.kt",
"r = %+.2f"
Expand Down Expand Up @@ -552,6 +604,14 @@
"android/app/src/main/java/com/noop/ui/SleepScreen.kt",
"Removes this sleep and recomputes the day without it. You can undo for a few seconds after."
],
[
"android/app/src/main/java/com/noop/ui/SleepScreen.kt",
"Sleep deleted."
],
[
"android/app/src/main/java/com/noop/ui/SleepScreen.kt",
"Sleep deleted. NOOP won't detect sleep between $startText and $endText again."
],
[
"android/app/src/main/java/com/noop/ui/SmartAlarmScreen.kt",
"Armed on the strap itself with the experimental 5/MG command. A strap-driven wake is still unconfirmed on 5/MG on our side (confirmed only on WHOOP 4.0), so keep a backup alarm for anything you truly can't miss."
Expand All @@ -572,14 +632,6 @@
"android/app/src/main/java/com/noop/ui/StepsCalibrationScreen.kt",
"Auto"
],
[
"android/app/src/main/java/com/noop/ui/StepsCalibrationScreen.kt",
"Manual steps coefficient, %.1f steps per motion unit"
],
[
"android/app/src/main/java/com/noop/ui/StepsCalibrationScreen.kt",
"Manual steps coefficient, automatic"
],
[
"android/app/src/main/java/com/noop/ui/StepsCalibrationScreen.kt",
"fit from your phone"
Expand All @@ -604,6 +656,14 @@
"android/app/src/main/java/com/noop/ui/TodayScreen.kt",
"Calibrating , no heart rate banked yet today. Your curve fills in as the strap offloads."
],
[
"android/app/src/main/java/com/noop/ui/TodayScreen.kt",
"How ${it.label} is calculated"
],
[
"android/app/src/main/java/com/noop/ui/TodayScreen.kt",
"How this score is calculated"
],
[
"android/app/src/main/java/com/noop/ui/TodayScreen.kt",
"No heart rate for this day. Step back to a day the strap was worn."
Expand Down Expand Up @@ -632,6 +692,14 @@
"android/app/src/main/java/com/noop/ui/TodayScreen.kt",
"Show fewer"
],
[
"android/app/src/main/java/com/noop/ui/TodayScreen.kt",
"Strap battery"
],
[
"android/app/src/main/java/com/noop/ui/TodayScreen.kt",
"Strap battery ${it.roundToInt()} percent"
],
[
"android/app/src/main/java/com/noop/ui/TodayScreen.kt",
"Zoomed in · drag to pan"
Expand All @@ -652,6 +720,22 @@
"android/app/src/main/java/com/noop/ui/WorkoutStart.kt",
"%d:%02d"
],
[
"android/app/src/main/java/com/noop/ui/WorkoutsScreen.kt",
"${WorkoutEditing.displaySport(row.sport)}, ${dateLabel(row.startTs)}"
],
[
"android/app/src/main/java/com/noop/ui/WorkoutsScreen.kt",
". Imported, can't be merged."
],
[
"android/app/src/main/java/com/noop/ui/WorkoutsScreen.kt",
". Not selected."
],
[
"android/app/src/main/java/com/noop/ui/WorkoutsScreen.kt",
". Selected."
],
[
"android/app/src/main/java/com/noop/ui/WorkoutsScreen.kt",
"0 to 100 Effort"
Expand Down
Loading
Loading