Skip to content

Commit f9dab5d

Browse files
committed
fix(i18n): fail the coverage gate on a floors file it can't read
load_floors() returned {} for a file that existed but didn't parse, so a merge-conflicted translation_floors.json switched the gate off, and --update-floors then rewrote every floor from that day's coverage. A refuting review reproduced both: zh-rCN went from 76.5 to 70.0 with the gate green, and a floor stored as a string was dropped the same way. A missing file still means no floors yet. A present but unreadable one, a missing "floors" object, or a non-numeric floor (booleans included) raises FloorsFileError, which main() counts as an error, and write_floors() leaves the file alone. --update-floors skipped the floor check and printed success at any coverage. It now checks against the floors it found first, so a locale below its floor fails either way. The gate's error text told people to lower a floor while the file said never to. Both now say a lower floor is a hand edit explained in the commit. Tests: test_floors_round_trip_and_a_broken_file_is_not_fatal pinned the fail-open behaviour. It's replaced by ..._fails_closed, which asserts the opposite for five broken shapes. New tests drive main() over a throwaway res tree: below-floor --update-floors fails and keeps the floor, a passed floor is raised (positive control), and an unreadable file fails the check and isn't rewritten. Five mutants of the fix are each killed by name.
1 parent ea02b60 commit f9dab5d

5 files changed

Lines changed: 153 additions & 26 deletions

File tree

‎CHANGELOG.md‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,10 @@ The rest of this section takes effect when the report Worker is next deployed.
255255
lower coverage instead, and the Chinese floor slid from 76.1% to 75.6% that
256256
way. The eight strings behind the slide are translated now, and the floor is
257257
back up at 76.5%.
258+
- A floors file that can't be read, such as one left mid-merge, now fails the
259+
check. It used to load as empty, which switched the gate off and let
260+
`--update-floors` write every floor again from scratch. `--update-floors`
261+
also fails when a locale is below its floor, where it used to report success.
258262

259263
## v1.7.38 (2026-08-29)
260264

‎docs/TRANSLATING.md‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -81,9 +81,12 @@ If you improve a locale, raise its floor in the same change:
8181
python scripts/check_translations.py --update-floors
8282
```
8383

84-
The floor only ever goes up. Lowering one is a deliberate decision that
85-
belongs in the commit message. A new locale starts with no floor and only
86-
warns until someone records one.
84+
`--update-floors` only ever raises a floor, and it still fails when a locale
85+
is below the floor it had. Lowering a floor means editing
86+
`scripts/translation_floors.json` by hand, on purpose, with the reason in the
87+
commit message. A floors file that can't be read fails the check rather than
88+
switching it off. A new locale starts with no floor and only warns until
89+
someone records one.
8790

8891
## Style notes
8992

‎scripts/check_translations.py‎

Lines changed: 41 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -290,19 +290,34 @@ def check_locale(locale_dir: Path, base_strings, base_untranslatable, base_plura
290290
return errors, warnings, translated, len(translatable_base)
291291

292292

293+
class FloorsFileError(ValueError):
294+
"""The floors file exists but can't be trusted."""
295+
296+
293297
def load_floors() -> dict[str, float]:
294-
"""Return the recorded per-locale coverage floors."""
298+
"""Return the recorded per-locale coverage floors.
299+
300+
A missing file means no floors have been recorded yet. A file that exists
301+
but can't be parsed, or holds a floor that isn't a number, raises
302+
FloorsFileError. Reading it as empty would switch the gate off (a
303+
merge-conflicted file did exactly that) and let --update-floors rewrite
304+
every floor from whatever coverage happens to be today.
305+
"""
306+
if not FLOORS_FILE.exists():
307+
return {}
295308
try:
296309
with FLOORS_FILE.open(encoding="utf-8") as handle:
297310
recorded = json.load(handle)
298-
except (OSError, ValueError):
299-
return {}
300-
if not isinstance(recorded, dict):
301-
return {}
311+
except (OSError, ValueError) as error:
312+
raise FloorsFileError(f"{FLOORS_FILE.name} can't be read: {error}") from error
313+
recorded_floors = recorded.get("floors") if isinstance(recorded, dict) else None
314+
if not isinstance(recorded_floors, dict):
315+
raise FloorsFileError(f'{FLOORS_FILE.name} has no "floors" object')
302316
floors = {}
303-
for locale, value in recorded.get("floors", {}).items():
304-
if isinstance(value, (int, float)):
305-
floors[str(locale)] = float(value)
317+
for locale, value in recorded_floors.items():
318+
if isinstance(value, bool) or not isinstance(value, (int, float)):
319+
raise FloorsFileError(f"{FLOORS_FILE.name}: the floor for {locale} is {value!r}, not a number")
320+
floors[str(locale)] = float(value)
306321
return floors
307322

308323

@@ -331,8 +346,9 @@ def floor_report(
331346
report.append(
332347
(
333348
f" ERROR: {locale} coverage fell to {percent:.1f}%, below its "
334-
f"{floor:.1f}% floor. Translate the new strings, or lower the floor "
335-
f"deliberately in {FLOORS_FILE.name} and say why.",
349+
f"{floor:.1f}% floor. Translate the new strings. Lowering a floor is "
350+
f"a hand edit to {FLOORS_FILE.name}, made on purpose and explained "
351+
f"in the commit message.",
336352
True,
337353
)
338354
)
@@ -345,16 +361,17 @@ def write_floors(coverage: dict[str, float]) -> None:
345361
A floor only goes up here. Running --update-floors after adding English
346362
strings nobody has translated yet used to record the lower coverage, which
347363
quietly loosened the gate it exists to enforce. Floors for locales this
348-
run didn't measure are kept.
364+
run didn't measure are kept. An unreadable file raises FloorsFileError
365+
and is left alone.
349366
"""
350367
floors = load_floors()
351368
for locale, percent in coverage.items():
352369
floors[locale] = max(floors.get(locale, 0.0), round(percent, 1))
353370
payload = {
354371
"description": (
355372
"Minimum translated-string coverage per locale, in percent. check_translations.py "
356-
"fails when a shipped locale drops below its floor. Raise a floor with --update-floors; "
357-
"never lower one by hand."
373+
"fails when a shipped locale drops below its floor. --update-floors only raises a floor. "
374+
"Lowering one is a hand edit, made on purpose and explained in the commit message."
358375
),
359376
"floors": dict(sorted(floors.items())),
360377
}
@@ -417,15 +434,22 @@ def main() -> int:
417434

418435
# A floor is only meaningful for a locale that ships from this repo;
419436
# --dir points at an arbitrary tree (a fork's, pre-merge) that has none.
437+
# --update-floors still checks against the floors it found: it can't lower
438+
# one, so a locale below its floor stays an error either way.
420439
if not args.dir:
421-
if args.update_floors:
422-
write_floors(coverage)
423-
print(f"\nRecorded coverage floors in {FLOORS_FILE.name}.")
440+
try:
441+
floors = load_floors()
442+
except FloorsFileError as error:
443+
print(f" ERROR: {error}. Fix or restore it before checking coverage.")
444+
total_errors += 1
424445
else:
425-
for message, is_error in floor_report(coverage, load_floors()):
446+
for message, is_error in floor_report(coverage, floors):
426447
print(message)
427448
if is_error:
428449
total_errors += 1
450+
if args.update_floors:
451+
write_floors(coverage)
452+
print(f"\nRecorded coverage floors in {FLOORS_FILE.name}.")
429453

430454
# Only meaningful for locales that actually live in the repo.
431455
for error in check_locales_config([d for d in locale_dirs if d.parent == RES_DIR]):

‎scripts/test_check_translations.py‎

Lines changed: 101 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,15 @@
11
#!/usr/bin/env python3
22
"""Security regression tests for the community translation validator."""
33

4+
import contextlib
5+
import io
6+
import json
7+
import sys
48
import tempfile
59
import unittest
610
import xml.etree.ElementTree as ET
711
from pathlib import Path
12+
from unittest import mock
813

914
import check_translations
1015

@@ -86,7 +91,10 @@ def test_locale_without_a_floor_warns_but_does_not_fail(self):
8691
self.assertFalse(is_error)
8792
self.assertIn("--update-floors", message)
8893

89-
def test_floors_round_trip_and_a_broken_file_is_not_fatal(self):
94+
def test_floors_round_trip_and_a_broken_file_fails_closed(self):
95+
# A missing file is "no floors yet". A file that is there but unreadable
96+
# used to load as empty too, which switched the gate off for a
97+
# merge-conflicted file. It is an error now.
9098
with tempfile.TemporaryDirectory() as directory:
9199
path = Path(directory) / "translation_floors.json"
92100
original = check_translations.FLOORS_FILE
@@ -98,10 +106,36 @@ def test_floors_round_trip_and_a_broken_file_is_not_fatal(self):
98106
{"values-de": 40.0, "values-zh-rCN": 76.1},
99107
check_translations.load_floors(),
100108
)
101-
path.write_text("not json", encoding="utf-8")
102-
self.assertEqual({}, check_translations.load_floors())
103-
path.write_text('{"floors": {"values-de": "sixty"}}', encoding="utf-8")
104-
self.assertEqual({}, check_translations.load_floors())
109+
for broken in (
110+
"not json",
111+
"[]",
112+
'{"description": "no floors object"}',
113+
'{"floors": {"values-de": "60.0"}}',
114+
'{"floors": {"values-de": true}}',
115+
):
116+
path.write_text(broken, encoding="utf-8")
117+
with self.assertRaises(check_translations.FloorsFileError, msg=broken):
118+
check_translations.load_floors()
119+
finally:
120+
check_translations.FLOORS_FILE = original
121+
122+
def test_update_floors_leaves_an_unreadable_file_alone(self):
123+
conflicted = (
124+
"{\n<<<<<<< HEAD\n"
125+
' "floors": {"values-zh-rCN": 76.5}\n'
126+
"=======\n"
127+
' "floors": {"values-zh-rCN": 76.1}\n'
128+
">>>>>>> branch\n}\n"
129+
)
130+
with tempfile.TemporaryDirectory() as directory:
131+
path = Path(directory) / "translation_floors.json"
132+
path.write_text(conflicted, encoding="utf-8")
133+
original = check_translations.FLOORS_FILE
134+
check_translations.FLOORS_FILE = path
135+
try:
136+
with self.assertRaises(check_translations.FloorsFileError):
137+
check_translations.write_floors({"values-zh-rCN": 70.0})
138+
self.assertEqual(conflicted, path.read_text(encoding="utf-8"))
105139
finally:
106140
check_translations.FLOORS_FILE = original
107141

@@ -127,5 +161,67 @@ def test_recorded_floor_matches_the_shipped_locale(self):
127161
self.assertIn("values-zh-rCN", floors, floors)
128162

129163

164+
class TranslationCheckerMainTest(unittest.TestCase):
165+
"""Drives main() over a throwaway resource tree with one German locale."""
166+
167+
NAMES = ("a", "b", "c", "d")
168+
169+
def make_tree(self, directory: str, translated: int) -> Path:
170+
res = Path(directory) / "res"
171+
(res / "values").mkdir(parents=True)
172+
(res / "values-de").mkdir()
173+
english = "".join(f'<string name="{name}">{name}</string>' for name in self.NAMES)
174+
german = "".join(f'<string name="{name}">{name}-de</string>' for name in self.NAMES[:translated])
175+
(res / "values" / "strings.xml").write_text(f"<resources>{english}</resources>", encoding="utf-8")
176+
(res / "values-de" / "strings.xml").write_text(f"<resources>{german}</resources>", encoding="utf-8")
177+
return res
178+
179+
def run_checker(self, res: Path, floors_file: Path, *argv: str) -> int:
180+
with (
181+
mock.patch.object(check_translations, "RES_DIR", res),
182+
mock.patch.object(check_translations, "BASE_DIR", res / "values"),
183+
mock.patch.object(check_translations, "FLOORS_FILE", floors_file),
184+
mock.patch.object(sys, "argv", ["check_translations.py", *argv]),
185+
contextlib.redirect_stdout(io.StringIO()),
186+
contextlib.redirect_stderr(io.StringIO()),
187+
):
188+
return check_translations.main()
189+
190+
@staticmethod
191+
def recorded(floors_file: Path) -> dict:
192+
return json.loads(floors_file.read_text(encoding="utf-8"))["floors"]
193+
194+
def test_update_floors_still_fails_when_a_locale_is_below_its_floor(self):
195+
# It used to skip the gate and report success at 25% against a 50% floor.
196+
with tempfile.TemporaryDirectory() as directory:
197+
res = self.make_tree(directory, translated=1)
198+
floors = Path(directory) / "translation_floors.json"
199+
floors.write_text('{"floors": {"values-de": 50.0}}', encoding="utf-8")
200+
201+
self.assertEqual(1, self.run_checker(res, floors, "--update-floors"))
202+
self.assertEqual({"values-de": 50.0}, self.recorded(floors))
203+
self.assertEqual(1, self.run_checker(res, floors))
204+
205+
def test_update_floors_raises_a_floor_the_locale_has_passed(self):
206+
with tempfile.TemporaryDirectory() as directory:
207+
res = self.make_tree(directory, translated=3)
208+
floors = Path(directory) / "translation_floors.json"
209+
floors.write_text('{"floors": {"values-de": 50.0}}', encoding="utf-8")
210+
211+
self.assertEqual(0, self.run_checker(res, floors, "--update-floors"))
212+
self.assertEqual({"values-de": 75.0}, self.recorded(floors))
213+
self.assertEqual(0, self.run_checker(res, floors))
214+
215+
def test_an_unreadable_floors_file_fails_the_check_and_is_not_rewritten(self):
216+
with tempfile.TemporaryDirectory() as directory:
217+
res = self.make_tree(directory, translated=3)
218+
floors = Path(directory) / "translation_floors.json"
219+
floors.write_text("not json", encoding="utf-8")
220+
221+
self.assertEqual(1, self.run_checker(res, floors))
222+
self.assertEqual(1, self.run_checker(res, floors, "--update-floors"))
223+
self.assertEqual("not json", floors.read_text(encoding="utf-8"))
224+
225+
130226
if __name__ == "__main__":
131227
unittest.main()

‎scripts/translation_floors.json‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
{
2-
"description": "Minimum translated-string coverage per locale, in percent. check_translations.py fails when a shipped locale drops below its floor. Raise a floor with --update-floors; never lower one by hand.",
2+
"description": "Minimum translated-string coverage per locale, in percent. check_translations.py fails when a shipped locale drops below its floor. --update-floors only raises a floor. Lowering one is a hand edit, made on purpose and explained in the commit message.",
33
"floors": {
44
"values-zh-rCN": 76.5
55
}

0 commit comments

Comments
 (0)