fix(i18n): address the ru review — units, Cyrillic regexes, locale registry

Dose bank
- DRUG_UNIT learns мг / мкг / мл / ЕД / МЕ, so "2,4 мг" anchors a dose again.
- The bank matches verb-first Russian word order too ("принимайте 2 таблетки",
  "пропустите дозу метформина", "начните с 5 мг"), and the Cyrillic drug names
  people actually see on the box; the deferral words ("спросите врача",
  "как назначено") stand those shapes down.

Cyrillic-safe regexes
- \w and \b are ASCII in JavaScript: every stem in the ru bank and in
  population-norms/ru.ts is [а-яё]*, and the word edges are lookarounds
  (?<![а-яёa-z]), so «высокий риск» and «нормальный диапазон» match again.
- coach-prose-grounding: Russian month names in the genitive, a space as the
  thousands separator in parseLocaleNumber and numFrag, and Cyrillic-safe
  edges around the written-date pattern.

Registered everywhere a locale has to be
- targetLanguageName() returns "Russian", so the coach, the period narrative
  and the document descriptions stop saying "write in English".
- i18n-key-tool.mjs, split-description.mjs, LOINC name normalisation (it
  stripped Cyrillic, so no Russian lab name resolved), HOST_TLDS (ru/by/kz/ua),
  no-key-fallbacks (32 tables), country-label, the AUDIT-C standard drink.
- docs/api/openapi.yaml regenerated.

Questionnaires
- The PHQ-9 / GAD-7 source comments no longer claim no validated Russian
  version exists (Zolotareva 2023; Pfizer's official screeners).
- WHO-5 items are gender-neutral: «чувствовал(а)».
- "body fat" and "fat mass" both read «Жировая масса»; body fat is now
  «Процент жировой массы», which is what the two metrics measure.

Tests
- ru in the 13 locale tables, plus mood, onboarding, instruments, clinician
  view, status summary, describe screen, citation coverage and LOINC.
- i18n-no-dot-separator: the ru bundle is exempt from the dash rule (a spaced
  dash is Russian punctuation, not a fragment join); the middle dot still
  fails there.
- Two 800-line baselines raised by the 12 and 5 lines this change adds.

pnpm typecheck, pnpm openapi:check and the full suite (36 316 tests) pass.
This commit is contained in:
l1137l committed 2026-11-02 20:22:03 +00:00
1 parent 6b9d222643
commit 48bfbad4e6
44 files changed
+431 -70

No files matched your search

@@ -1,7 +1,7 @@
{
"$comment": "Production files over 800 lines and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 84,
"total": 94362,
"total": 94379,
"entries": {
"app/medications/page-client.tsx": 839,
"components/charts/mood-chart.tsx": 1166,
@@ -22,14 +22,14 @@
"components/timeline/timeline-geometry.ts": 1288,
"lib/ai/anthropic-client.ts": 804,
"lib/ai/coach/about-me.ts": 1012,
"lib/ai/coach/coach-prose-grounding.ts": 1079,
"lib/ai/coach/coach-prose-grounding.ts": 1091,
"lib/ai/coach/persistence.ts": 1218,
"lib/ai/coach/results/metric-table-tool.ts": 1102,
"lib/ai/coach/snapshot.ts": 1346,
"lib/ai/coach/stream-events.ts": 817,
"lib/ai/coach/types.ts": 1011,
"lib/ai/codex-client.ts": 907,
"lib/ai/prompts/insight-generator.ts": 1110,
"lib/ai/prompts/insight-generator.ts": 1115,
"lib/ai/provider.ts": 1628,
"lib/analytics/score/reader.ts": 998,
"lib/analytics/sleep-night.ts": 1345,
@@ -1,7 +1,7 @@
{
"$comment": "Production functions over 200 lines, keyed \"file :: name\", and their length. Maintained by src/__tests__/source-size-ratchet.test.ts.",
"count": 345,
"total": 111929,
"total": 111928,
"entries": {
"app/achievements/page.tsx :: AchievementsPage": 206,
"app/api/admin/backups/upload/route.ts :: apiHandler(arg)": 275,
@@ -45,7 +45,7 @@
"app/coach/plans/page.tsx :: CoachPlansBody": 216,
"app/insights/catalog/page.tsx :: MetricCatalogPage": 204,
"app/insights/medications/page.tsx :: InsightsMedikamentePage": 265,
"app/insights/page-client.tsx :: InsightsPageClient": 376,
"app/insights/page-client.tsx :: InsightsPageClient": 375,
"app/labs/page.tsx :: LabsPage": 228,
"app/medications/page-client.tsx :: MedicationsPageClient": 590,
"app/notifications/page.tsx :: NotificationsPage": 327,
@@ -166,7 +166,7 @@ describe("dynamic-key exhaustiveness (enum-derived)", () => {
expect(LOCALES.length).toBeGreaterThanOrEqual(6);
});
it("discovers all nine shipped locales", () => {
it("discovers all ten shipped locales", () => {
expect(LOCALES.map((l) => l.locale).sort()).toEqual([
"de",
"en",
@@ -177,6 +177,7 @@ describe("dynamic-key exhaustiveness (enum-derived)", () => {
"nl",
"pl",
"pt-BR",
"ru",
]);
});
+15 -1
View File
@@ -30,6 +30,14 @@ const hasSeparator = (value: string) =>
/** Key path -> why the separator is right there. Empty by intent. */
const ALLOWED: Record<string, string> = {};
/**
* Bundles where a spaced dash is punctuation the language requires rather than
* a fragment join. Russian uses the dash where English puts a comma or a full
* stop ("Сводка за неделю — всё спокойно"), so the ru bundle is exempt from
* the dash rule; the middle dot is still flagged there.
*/
const DASH_EXEMPT_BUNDLES = new Set(["ru.json"]);
function flatten(
obj: unknown,
prefix: string,
@@ -66,7 +74,13 @@ describe("message bundles carry no ' · ', ' — ' or ' – ' separator", () =>
it(`${file} has no dot or dash separator outside the allowlist`, () => {
const offenders = [...values]
.filter(([key, value]) => hasSeparator(value) && !(key in ALLOWED))
.filter(([key, value]) => {
if (!hasSeparator(value) || key in ALLOWED) return false;
if (DASH_EXEMPT_BUNDLES.has(file) && !value.includes(" · ")) {
return false;
}
return true;
})
.map(([key, value]) => `${key} = ${JSON.stringify(value)}`);
expect(offenders).toEqual([]);
});