ci(i18n): cover every shipped locale, not the four in a constant - #850
Conversation
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.
|
Re-reviewed my own change; three fixes pushed, no behaviour change.
All four gate cases re-verified after the refactor — green on main, fires on a removed |
…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.
|
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 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; 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.
|
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.
It now reports every locale the catalog ships: 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, |
#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.
Implements #844.
The gate covered half the shipped languages
Tools/i18n_audit.py:34isLANGS = ["de", "es", "fr", "pt-PT"]. The Apple catalogs ship eight non-English localizations.Strand(3255 keys)StrandDesign(95)NOOPWatch(52)NOOPWatchComplications(30)The four gated languages are at 100 % in every catalog. The four ungated ones are missing up to 85 % —
StrandDesignships 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_DIRShardcodes the same four,values-zhexists 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 fromvalues-*on disk. A locale is covered the day it appears, not the day someone remembers to update a constant.LANGSkeeps 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, printingIMPROVEDwhen a count drops.Verification
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.