Two merges, one declined check, and a bug in my own checker: good_job's Russian and Ukrainian plurals
good_job's dashboard said \"1 минута\" for 21 minutes. Two small PRs fixed 30 strings, the maintainer declined a CI check for a good reason, and that reason exposed a false negative in the checker that found the bug.
Key takeaway
good_job's dashboard said \"1 минута\" for 21 minutes. Two small PRs fixed 30 strings, the maintainer declined a CI check for a good reason, and that reason exposed a false negative in the checker that found the bug.
good_job is a Postgres-based Active Job backend for Ruby on Rails,
with about 3,000 GitHub stars. It ships a web dashboard translated into 13 languages besides English, in hand-maintained Rails
YAML under config/locales/. It already lints those files with
i18n-tasks, which its maintainer, Ben Sheldon, added in
#559.
In October I sent it two small PRs. Both merged within a day. I also offered a CI check, and Ben declined it. His reason was correct, and it pointed at a bug in my own checker.
The first PR: one Ukrainian string
Rails plurals pick a form by count. Ukrainian has four forms, and few covers 2–4, 22–24, 32–34 and so on. In
uk.yml, x_minutes.few was the literal text 1 хвилини, so the dashboard's relative times said "1 хвилини"
("1 minute") for 3 minutes and for 22 minutes.
I found it while testing a release of my checker on 74 open-source repos. The fix,
#1842, was one line: %{count} хвилини, matching the other
few entries in the same file. I opened it on October 5. Ben labelled it localization and merged it
the next day.
The second PR: the one form is not "one" in Russian
That first bug sent me back to the same files. In English, the one form is only ever used for 1, so writing
"1 minute" without %{count} is fine:
# en.yml
x_minutes:
one: 1 minute
other: "%{count} minutes"
In Russian and Ukrainian, one also covers 21, 31, 41… ("21 минута", "21 хвилина"). Both files had copied the
English shape. Twelve distance_in_words keys plus apply_to_all hard-coded "1" in their one form, 26
strings across the two files. The result: 21 minutes rendered as "1 минута" / "1 хвилина", and 21 hours as
"около 1 часа" ("about 1 hour").
Reading the file turned up a second bug that no tool would have caught. The Russian
jobs.table.actions.apply_to_all, the label on the jobs page's bulk action, held a copy of about_x_hours.
Instead of "Apply to all 1234 jobs" it said "около 1234 часов" ("about 1234 hours"). The placeholders matched,
so it was invisible to any placeholder check.
#1848 fixed both: %{count} in the 26 one forms, and a
real Russian label ("Применить ко всем %{count} заданиям."). I asked for a native speaker's look in the PR. I
opened it on October 6, and Ben merged it at 14:32 UTC the same day.
The declined check
good_job's linter hadn't caught any of this, and the PR body explained why. i18n-tasks'
inconsistent_interpolations only walks keys that exist in English, so few and many are never compared. The
repo's i18n-tasks.yml also ignores *.one. I offered a small spec that would check every plural form except
an English-style one.
Ben replied seventeen seconds before merging:
I vaguely remember this being there because different languages have different rules about whether they require an interpolation or not.
I'm ok with this and limping forward on language-by-language fixes.
That is a reasonable call for a project with 13 translations, and I didn't push it. The
*.one ignore is still in i18n-tasks.yml today.
What his reason said about my checker
He was right that languages differ on whether one needs the count. In English, German or French, one means
exactly 1, and "1 minute" with no %{count} is correct. That's why the ignore exists.
My checker made the same assumption, the other way round. It compared each one form with the English one,
which has no %{count}, so a Ukrainian one of "1 хвилина" passed. It flagged a hard-coded count in few
(that's how it found #1842), but it couldn't see the 26 strings in #1848. I had found those by reading the file.
The fix shipped the next day as @shipi18n/core 2.15.1.
Where a language's one covers more than 1 (Russian, Ukrainian, Belarusian, Croatian, Bosnian, Serbian,
Lithuanian, Latvian, Icelandic…), the one form is now held to the source's other, and a missing count is a
warning that names the numbers ("uk "one" also covers 21, 31…"). Where one means exactly 1, nothing changes.
Its changelog records the before and after on good_job itself: 0 → 26 warnings on the files before #1848, exactly
the 26 strings fixed upstream, and 0 after.
So the distinction Ben gave for not adding a check is the one the check needed to learn.
What held
- Held: on 2026-10-13,
main(a7f4452) still has%{count}in every ru/ukoneform, the Ukrainianfewfix, and the Russianapply_to_alllabel. The checker (2.16.1) finds 0 placeholder or plural problems in any of the 13 translations. - No CI check, by choice. Further fixes will come language by language, as Ben said.
Timeline
| date (UTC) | event |
|---|---|
| Oct 5 10:37 | #1842 opened: uk x_minutes.few hard-coded "1" |
| Oct 6 01:58 | #1842 merged |
| Oct 6 10:03 | #1848 opened: 26 ru/uk one forms + ru apply_to_all, with a CI-check offer |
| Oct 6 14:31 | Ben declines the check |
| Oct 6 14:32 | #1848 merged |
| Oct 7 08:51 | Shipi18n 2.15.1 released: one checked against other where one ⊃ 21, 31… |
| Oct 13 | re-checked on main: still clean |
I maintain Shipi18n, the open-source checker that found the first
of these bugs and learned to find the second. Every finding was verified by hand against main before filing.
The full list of repos and strings is at shipi18n.com/oss.