Skip to content

ci(i18n): cover every shipped locale, not the four in a constant - #850

Merged
ryanbr merged 4 commits into
mainfrom
ci/i18n-cover-all-shipped-locales
Jul 27, 2026
Merged

ci(i18n): cover every shipped locale, not the four in a constant#850
ryanbr merged 4 commits into
mainfrom
ci/i18n-cover-all-shipped-locales

Conversation

@ryanbr

@ryanbr ryanbr commented Jul 27, 2026

Copy link
Copy Markdown
Owner

Implements #844.

The gate covered half the shipped languages

Tools/i18n_audit.py:34 is LANGS = ["de", "es", "fr", "pt-PT"]. The Apple catalogs ship eight non-English localizations.

catalog de/es/fr/pt-PT it ru zh-Hans zh-Hant
Strand (3255 keys) 100 % 3086 3110 3136 3086
StrandDesign (95) 100 % 14 41 20 14
NOOPWatch (52) 100 % 49 50 49 49
NOOPWatchComplications (30) 100 % 5 30 5 5

The four gated languages are at 100 % in every catalog. The four ungated ones are missing up to 85 %StrandDesign ships 14 of 95 Italian, the watch complications 5 of 30. That is the gate doing its job where it is pointed, and nothing happening where it is not.

Android is the same shape: ANDROID_LOCALE_DIRS hardcodes the same four, values-zh exists on disk, is never compared, and sits 43 keys behind. That is how #834 passed CI while adding a key to four of five locale files.

The fix

Both lists are discovered rather than declared — Apple languages from the union of each catalog's own localizations, Android dirs from values-* on disk. A locale is covered the day it appears, not the day someone remembers to update a constant.

LANGS keeps zero tolerance and is untouched. Those four are perfect precisely because they are hard-gated, and this must not regress that.

The newly-covered locales carry 1019 real pre-existing gaps, so they gate against a ratcheting allowance. Switching them on hard would red-check every open PR on lines nobody touched — the exact #514 failure this workflow's own comments warn about. Counts rather than key lists, same reasoning as doc_comment_lint_baseline.txt (#849): a key list goes stale on every edit and trains people to regenerate it unread. It ratchets down, printing IMPROVED when a count drops.

Verification

green on main                                            -> exit 0  (1019 tracked)
remove one values-zh key            (43 -> 44)           -> exit 1
remove one StrandDesign Italian string (81 -> 82)        -> exit 1
remove one values-de key                                 -> exit 1  (zero tolerance intact)

The last one matters most: this touches the only job currently gating PRs, so the existing hard behaviour had to be shown unchanged.

Worth noting my first probe didn't fire — it had removed app_name, which is explicitly exempt. The gate was right and the test was wrong; re-run with a real key it fails as intended.

What this deliberately doesn't do

It translates nothing. It stops the hole growing and makes the debt countable. Whether it/ru/zh-* should ship at 14/95 or be withheld until filled is a product call — I'd rather you make it than have it implied by a linter.

ryanbr added 2 commits July 26, 2026 18:16
Tools/i18n_audit.py:34 is LANGS = ["de","es","fr","pt-PT"]. The Apple catalogs ship
EIGHT non-English localizations, so the gate covered four of them — and the four it
did not cover drifted exactly as you would expect:

  catalog                  de/es/fr/pt-PT   it     ru    zh-Hans  zh-Hant
  Strand (3255 keys)             100%      3086   3110    3136     3086
  StrandDesign (95)              100%        14     41      20       14
  NOOPWatch (52)                 100%        49     50      49       49
  NOOPWatchComplications (30)    100%         5     30       5        5

The gated four are at 100% everywhere. The ungated four are missing up to 85% —
StrandDesign ships 14 of 95 Italian, the watch complications 5 of 30. That is the gate
working where it is pointed and nothing happening where it is not. Android is the same
shape: ANDROID_LOCALE_DIRS hardcodes the same four, values-zh exists on disk, is never
compared, and sits 43 keys behind (#844, found via #834).

Both lists are now DISCOVERED: Apple languages from the union of each catalog's own
localizations, Android dirs from `values-*` on disk. A locale is covered the day it
appears rather than the day someone remembers a constant.

LANGS keeps ZERO tolerance and is untouched — that is why those four are perfect, and
the existing behaviour must not regress. The newly-covered locales carry 1019 real
pre-existing gaps, so they gate against a ratcheting allowance instead: switching them
on hard would red-check every open PR on lines nobody touched, the exact #514 failure
this workflow's own comments warn about. Counts rather than key lists, same reasoning
as Tools/doc_comment_lint_baseline.txt: a key list goes stale on every edit and trains
people to regenerate it unread. Ratchets DOWN, printing IMPROVED when a count drops.

Verified: green on main; fails when a values-zh key is removed (43 -> 44); fails when
one StrandDesign Italian string is removed (81 -> 82); and de still hard-fails on a
single missing key, so zero tolerance is intact.

Translates nothing. It stops the hole growing and makes the debt countable. Whether
it/ru/zh-* should ship at 14/95 or be withheld until filled is a product call, not a
linter's.
Three things found re-reviewing my own change.

The app_name exemption was defined twice — once inside android_strings_xml_gaps and
again inline in the new discovered-locale check. That is the same two-definitions-of-
one-rule pattern #829 removed this morning, and it would have let the hard-gated path
and the discovered path disagree the first time anyone added a second exempt key.
Hoisted to ANDROID_EXEMPT_KEYS, used by both.

The new check re-loaded every catalog the loop above had just parsed — a second full
parse of a 3255-string file for no reason. The extra-locale counts are now collected
inside that existing loop and gated afterwards.

--update-baseline rewrites the JSON baseline only, so its help now says it does not
touch the locale allowance. That file is lowered by hand on purpose: shrinking it
should be a deliberate act, not a side effect of regenerating something else.

No behaviour change — all four gate cases re-verified after the refactor.
@ryanbr

ryanbr commented Jul 27, 2026

Copy link
Copy Markdown
Owner Author

Re-reviewed my own change; three fixes pushed, no behaviour change.

  1. The app_name exemption was defined twice — once inside android_strings_xml_gaps, and again inline in the new discovered-locale check. That is exactly the two-definitions-of-one-rule pattern refactor(sleep): one stage-key canonicalisation, not two kept in parity by comment #829 removed this morning, and I reintroduced it hours later. It would have let the hard-gated path and the discovered path disagree the first time anyone added a second exempt key. Hoisted to ANDROID_EXEMPT_KEYS, used by both.

  2. The new check re-parsed every catalog the loop above had just loaded — a second full parse of a 3255-string file for nothing. The extra-locale counts are now collected inside that existing loop.

  3. --update-baseline does not touch the new allowance file, which was silently misleading given the tool now has two baselines. Its help says so now. Hand-lowering is deliberate: shrinking the allowance should be an explicit act, not a side effect of regenerating something else.

All four gate cases re-verified after the refactor — green on main, fires on a removed values-zh key (43→44), fires on a removed StrandDesign Italian string, and de still hard-fails on one missing key.

…k failed

Read the merged code rather than trusting the patch, and found the summary keyed on
the GLOBAL `failed` flag. So a PR breaking a German string — or any earlier check —
saw the new section print no verdict at all: no OK, no counts, nothing. That reads as
'this section did not run', which is the worst thing a gate can say to someone trying
to understand a red build. Scoped to a local flag; the section now always states its
own result.

Also surfaces STALE allowance entries. An allowance for a target that no longer exists
(locale removed, catalog dropped) can never be satisfied and silently inflates the
tracked total, so it is now reported for removal rather than left to rot.

Re-verified all four gate cases plus the two new behaviours.
@ryanbr

ryanbr commented Jul 27, 2026

Copy link
Copy Markdown
Owner Author

Third pass, and this one found a real bug — by reading the merged file instead of trusting the patch I had applied.

The new section keyed its summary on the GLOBAL failed flag. So a PR that broke a German string — or tripped any earlier check — saw the locale section print no verdict at all. No OK, no counts, nothing. That reads as "this section did not run", which is the worst thing a gate can tell someone trying to understand a red build. Scoped to a local flag; it now always states its own result:

de broken -> exit=1, and the section still reports:
  OK no new gaps in the non-focus locales (1019 tracked, ratcheting down)

Also added STALE reporting. An allowance for a target that no longer exists — locale removed, catalog dropped — can never be satisfied and silently inflates the tracked total. It is now surfaced for removal rather than left to rot.

Six behaviours verified after the change: green on main; fires when the zh gap grows 43→44; fires when a StrandDesign Italian string is removed; de still hard-fails on one missing key; a fabricated values-xx allowance is reported STALE; and clean after restore.

Both jobs green.

catalog_summary() iterated the same hardcoded LANGS, so the human-readable report read
0 missing across the board while it/ru/zh-Hans/zh-Hant sat at up to 85% untranslated.
That is very likely WHY this went unnoticed for so long: anyone who ran the audit to
check coverage was shown a clean sheet.

Now reports every locale the catalog ships. The Strand catalog alone surfaces it=168,
ru=149, zh-Hans=118, zh-Hant=168, and StrandDesign 81/54/75/81 of 95 keys.

The gate and the human-readable view must see the same set of languages, or the view
quietly reassures you about the ones nobody is checking.

Gate behaviour unchanged; re-verified.
@ryanbr

ryanbr commented Jul 27, 2026

Copy link
Copy Markdown
Owner Author

Fourth pass — looked at what sits next to the change rather than only at the change, and found the same blind spot in the reporting path.

catalog_summary() iterated the same hardcoded LANGS. So the human-readable coverage report read missing=0 across the board while four locales sat at up to 85 % untranslated. That is very likely why this went unnoticed for so long: anyone who ran the audit to check coverage was shown a clean sheet.

It now reports every locale the catalog ships:

StrandDesign (95 keys):   de 0  es 0  fr 0  it 81  pt-PT 0  ru 54  zh-Hans 75  zh-Hant 81
Strand (3255 keys):       de 0  es 0  fr 0  it 168 pt-PT 0  ru 149 zh-Hans 118 zh-Hant 168
NOOPWatchComplications:   de 0  es 0  fr 0  it 25  pt-PT 0  ru 0   zh-Hans 25  zh-Hant 25

The gate and the human-readable view have to see the same set of languages, or the view quietly reassures you about the ones nobody is checking. It also turns the summary into the burn-down dashboard for the 1019 tracked gaps, which is what you would actually want to look at while closing them.

Gate behaviour unchanged and re-verified: green on main, fires when the zh gap grows, de still hard-fails on one key. Both jobs green.

@ryanbr
ryanbr merged commit 0dffd7b into main Jul 27, 2026
2 checks passed
@ryanbr
ryanbr deleted the ci/i18n-cover-all-shipped-locales branch July 27, 2026 01:25
ryanbr pushed a commit that referenced this pull request Jul 27, 2026
#546 gave the 2140-byte layout-v20 record a NEUTRAL structure — five 422-byte blocks, each with a
21-byte header, two 200-byte readout slots and a reserved byte — deliberately without names, because
nothing in the bytes says red, IR, green or ambient. This adds the harness that could earn those
names.

Phase marking in Settings (Advanced > Experimental, shown only when frame recording is already on)
appends one line to the existing puffin-deepbuffers.jsonl. An offline CLI
(whoop-optical-experiment) plus OpticalExperimentAnalysis reads it back, attributes each v20 frame
to a phase, and reports per-phase block activation, per-header-byte behaviour, per-channel ADC
statistics and adjacent-phase deltas.

It sends NOTHING to the strap, and that is structural rather than a policy: markWhoop5OpticalPhase
reaches PuffinDeepBufferLog.appendOpticalPhase, which is a file append. No peripheral, no
characteristic, no command path on that route — verified, not asserted.

Attribution uses the strap timestamp inside each frame, never log-arrival time, because deep buffers
land during the connect-time offload burst minutes after the seconds they describe. experiment_end
is excluded from the phase list but retained in markers, so it bounds the final phase without
becoming one — endUnixTs indexes the full marker list while phaseIndex searches only real phases.
The frame.baseTs == strap_ts check gates a malformed envelope without substituting for frame
content. The CLI drops the first 10 s after each marker by default; the library defaults to 0, which
is the right split — a library should not silently discard data.

The only modification to existing code is a clean extraction: the soft-cap rotation was lifted
verbatim into a private append(_:) so the marker path shares it. Phase markers therefore inherit the
existing rotation rather than growing the file unbounded. Everything else is additive.

Claims no wavelength and computes no SpO2 or blood pressure. The questions it asks are the narrow
answerable ones — which blocks respond to contact, darkness, room light, pressure, paced breathing;
whether blocks 1 and 2 ever acquire; which header bytes move with which condition; whether the two
readout slots behave as paired detectors. Those have to be settled before anyone can honestly claim
a wavelength, let alone a saturation.

Swift only, on purpose: the deep-buffer lane (#423) has no Android counterpart, so there is nothing
there for a marker to mark. The JSONL and report format are the contract, not the Swift.

i18n done properly. #850 landed hours earlier and put it/ru/zh-Hans/zh-Hant behind a ratcheting
allowance; all four new strings are translated into all EIGHT locales with the allowance file
untouched. Raising it would have passed CI just as green.

Verified: 10/10 CI green. Not validated on hardware and cannot be — the harness exists precisely
because nobody has a labelled capture yet. The app-target half is compiled and tested locally only,
as app-build.yml is disabled.
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