# SPEC-REVIEW-371-r1 Issue: [#371 — Add French localization files](https://github.com/Matysh/houseplan-card/issues/371) Трек: `small` (лёгкий), заход r1, лимит циклов лёгкого трека — 2, израсходовано 0/2 (жёлтый вердикт первого захода расходует цикл; см. §4 PROCESS.md). Ревьюер: Claude (роль «ревьюер ТЗ»), автор ТЗ: Codex (issue-комментарий от 2026-08-29). Материал: тело issue #371 на момент разбора (комментарий "S2→S3" от Matysh, 2026-08-29T09:59:53Z), без файла в `docs/specs/` — соответствует метке `small`. ## Скоуп Добавление французского как четвёртого полноценного UI-языка (после en/ru/de), на инфраструктуре реестра локалей #62 и лениво-загружаемого немецкого прецедента #348. Контракт (К1–К4 в теле issue): файлы словарей (`src/i18n/fr.json`, `src/i18n/fr.ts`, `custom_components/houseplan/translations/fr.json`), запись в `registry.ts` + правки `bundle-manifest.mjs` (retry-токен, `_role`, regex), авто-выбор по HA-локали, правки USER-GUIDE и changelog. AC1–AC5 с указанием доказательства (unit/smoke/gate). User-Visible: yes. ## Как проверялось 1. Прочитан `docs/SCOPE.md`, `AGENTS.md`, `PROCESS.md` целиком — критерии лёгкого трека (§5), обязательные разделы ТЗ (§7.1) и упрощённый шаблон для `small` (проблема · контракт · AC · откат). 2. Прочитано тело issue #371 и оба комментария владельца (принятие вклада, S2→S3 с обоснованием трека). 3. Сверены технические утверждения ТЗ с текущим кодом на `dev` (`914bb4ed`): `src/i18n/registry.ts`, `src/i18n/de.ts`, `scripts/bundle-manifest.mjs`, `test/i18n.test.mjs`, `docs/USER-GUIDE.md` / `.ru.md`, `docs/CHANGELOG.ru.md`, история коммитов `12cd8a1a` (German locale), `78c60207`/`4a241cef` (#353, entry-fallback). 4. Арифметически перепроверена «оценка вклада»: `en.json` на `dev` — 1126 ключей; 1026 (в присланном словаре) + 121 (недостающих) − 21 (устаревших) = 1126 — сходится, догадкой не является. 5. Проверено предыдущее полноформатное ТЗ немецкой локализации (#348, issue + `docs/specs/348-german-localization.md`) как прецедент трека — German шёл **полным** треком (первое использование инфраструктуры #62 для реального языка), French заявлен `small`, ссылаясь на то, что German эту инфраструктуру уже обкатал. Обоснование трека признано состоятельным (см. ниже). 6. Прочитан `demo/smoke_german_locale.mjs` — файл существует, ссылка АС3 не висит в воздухе (`ls demo/smoke_*.mjs` → 202 файла). Гейты (typecheck/test/build/scripts) на этом этапе не запускались — этап `spec`, продуктового кода ещё нет, запускать нечего. ## Находки ### M1 (Medium, в скоупе) — К2 не покрывает второй файл в bundle-manifest.mjs, который тоже хардкодит список локалей `scripts/bundle-manifest.mjs` содержит **два** независимых места, где список UI-языков зашит по именам, а не выводится из `LANGUAGE_REGISTRY`: - `bundleManifestPlugin`/`editorRuntimeRetryUrlPlugin` — то, что перечисляет К2 (роль `'locale'` по `/src/i18n/de.ts`, `DE_RETRY_ASSET_TOKEN`, regex `de-`). Это ТЗ покрывает: явно расписано, что для `fr` заводится параллельный токен, парная роль и обобщённый regex `(de|fr)-`. - `entryFallbackPlugin` (строки 192–224, конкретно тернарник строк 208–212): локализованный текст «страница устарела, перезагрузите» выбирается по `navigator.language`, но веткой обёрнуты только `ru` и `de` — любой другой язык, включая ещё не существующий на момент написания этого кода, молча получает английский текст. Функция добавлена **после** German (#348) — коммитом `78c60207`/`4a241cef` (issue #353, "lazy delivery survives flaky networks and stale caches"), и её ветки `ru`/`de` — прямое следствие того, что на тот момент это были все три UI-языка. Иными словами: у проекта уже есть прецедент "третий язык получает свою ветку в этом файле", и French станет четвёртым языком без такой ветки, если К2 останется как есть. ТЗ формулирует К2 как исчерпывающий список правок именно в `bundle-manifest.mjs`: "FR-токен в retry-плагине..., `_role: 'locale'`..., regex localeRoots обобщается...". Это выглядит как полный список изменений файла, но пропускает второй хардкод в том же файле. Ни один AC (1–5) не покрывает entry-fallback текст — при код-ревью эта ветка просто не будет исполнена ни одним тестом, и дыра проедет в релиз незамеченной, ровно как исчезновение записи толщины или несинхронного числа: не сбой теста, а тихое несоответствие тому, что #353 уже установил как контракт «полноценный язык — значит, локализован transaction-fallback тоже». **Что нужно решить, не владельцу.** Это техническое, не продуктовое: добавить `l.startsWith("fr") ? "House Plan a été mis à jour — veuillez recharger la page (Ctrl+F5)."` веткой рядом с `ru`/`de`, обновить троекратную проверку `editorReplacements`/`onboardingReplacements`/`germanReplacements` на четвёртый счётчик — либо явно записать в ТЗ решение «оставляем английский текст для French в этой ветке» с причиной (например: сама ветка emergency-only и не входит в обещание «полноценный язык»). Молчание — не то и не другое. ### M2 (Medium, в скоупе) — AC1 не решает судьбу двух German-специфичных тестов качества, добавленных как часть оригинального AC #348 `test/i18n.test.mjs` содержит **шесть** паритетных тестов, зациклённых по `LANGUAGE_REGISTRY` (строки 26–133) — они действительно "расширяются на fr" простым фактом попадания `fr` в реестр, как обещает AC1. Но там же есть **два** теста, написанных именно под немецкий и не читающих реестр вообще: - `i18n: German catalog keeps the product glossary and has no translation sentinels` (строки 327–338) — точечные assert'ы конкретных немецких строк (`de['btn.save'] === 'Speichern'` и т.п.) плюс скан на маркеры машинного перевода (`ZXQPH`, `QXZ`, `⟦HP`) и на кириллицу; - `i18n: German values equal to English are explicitly reviewed` (строки 340–370) — сверяет множество ключей, где `de[key] === en[key]`, с жёстко прописанным allow-list; появление нового совпадения красит тест — это ловит именно тот класс дефекта, который «0 расхождений плейсхолдеров, 0 подозрительного контента» из оценки вклада не проверяет: пропущенный, скопированный из английского ключ, который синтаксически валиден (не пуст, плейсхолдеры совпадают), но семантически не переведён. `git log -S` подтверждает: оба теста появились **в одном коммите** с самой немецкой локализацией (`12cd8a1a`), то есть были частью изначального AC German-задачи, а не более поздней надстройкой поверх неё. Раз ТЗ AC1 заявляет «существующий тестовый механизм расширяется на fr» как единый факт, а по факту половина механизма (родовые тесты) расширяется бесплатно, а другая половина (эти два теста) не расширяется вообще — это ровно тот случай «утверждение о поведении, которого нет ни в одном документе, выдано за решение» из инструкции ревью, только с обратным знаком: тут не придумано поведение, а не поставлен вопрос о нужном. **Что нужно решить.** Либо АС1 явно включает french-аналоги этих двух тестов (глоссарий ключевых терминов + скан на артефакты машинного перевода/кириллицу + allow-list явных совпадений с английским для `fr`), либо ТЗ прямо проговаривает, что для French эта дополнительная проверка не пишется сейчас и почему (например: контрибьютор уже прогнал скрипт с эквивалентной эвристикой, а нативная проверка обещана постфактум — согласно комментарию владельца в issue). Оба варианта закрывают находку; отсутствие любого из них — нет. ### L1 (Low, снимается ревьюером с записью) — формулировка AC5 избыточна и путает мутанта "мутант «fr-словарь исключён из паритета»... формулировка проще: мутант «запись fr удалена из LANGUAGE_REGISTRY»" — два предложения описывают, по всей видимости, один и тот же мутант разными словами, оставляя неопределённость, сколько мутантов реально проверяется. Смысл читаем (мутация реестра должна покраснить AC2 и AC3), содержательной проблемы нет — это стилистическая шероховатость, не блокирует. Снимаю без правки: при реализации это разрешится тем, что действительно будет один тест на исчезновение записи `fr`. ### L2 (Low, снимается ревьюером с записью) — DoR-пункты touch/perf не проговорены явным предложением DoR (§2.5 PROCESS.md) требует явно назвать влияние на perf и touch (или явное «нет»). ТЗ покрывает perf через AC4 (`bundle:budget`), но нигде не пишет фразой «touch не затронут». Для чисто словарной правки без нового UI это самоочевидно и уже подтверждается критериями лёгкого трека («нет влияния на touch-контракт» — один из пяти одновременных критериев `small`, названных в самом ТЗ). Не блокирует, добавить одну строку не помешает, но не обязательно. ## Что проверено и корректно - **Классификация трека `small`.** Все пять критериев §5 проверены по отдельности: сложность/риск (единственная точка расширения — реестр, спроектированная под это самим автором инфраструктуры, судя по комментарию в `registry.ts`: «Adding a locale means adding its frontend/backend JSON files and one static entry»); одна поверхность (locale pipeline); миграции конфига нет (язык — уже существующее строковое поле, `languageOptions` сохраняет неизвестные сырые значения); нового UX-контракта нет (селектор языка уже существует, просто получает четвёртый пункт); влияния на perf/touch не заявлено и не просматривается в диффе, который описывает ТЗ. Прецедент German (#348) шёл полным треком именно потому, что обкатывал инфраструктуру впервые — довод, что второй проход по уже проверенному пути дешевле, содержателен, а не удобная отговорка. - **К1 (файлы).** Арифметика вклада проверяется: 1026 (снимок контрибьютора) + 121 (новые ключи) − 21 (устаревшие) = 1126 = точное число ключей в `src/i18n/en.json` на `dev` сейчас. Это не бездоказательная цифра. Backend-паритет «6/6» тоже сходится: `custom_components/houseplan/translations/en.json` действительно содержит 6 листовых ключей. - **К2 (registry.ts).** Описанный `loadFrench`/токен/запись в `LANGUAGE_REGISTRY` — точная копия существующего `loadGerman` (`src/i18n/registry.ts:20-25,33-37`) и `src/i18n/de.ts` (клон тривиален: импорт JSON + экспорт fingerprint-токена). Технически выполнимо без дополнительных решений. - **К2 (bundle-manifest.mjs, заявленная часть).** Проверено построчно: `_role: 'locale'` определяется по `endsWith('/src/i18n/de.ts')` (`scripts/bundle-manifest.mjs:32`), `localeRoots` — по этой роли или regex `de-` (`:61-62`), `DE_RETRY_ASSET_TOKEN` и проверка счётчика замен 1/1/1 в `editorRuntimeRetryUrlPlugin` (`:9,141-172`). Всё, что ТЗ обещает здесь исправить, действительно требует исправления и действительно ограничивается перечисленными тремя точками (не считая M1). - **К3 (авто-выбор).** `resolveLanguageCode` (`src/i18n/registry.ts:119-136`) уже обобщён — точное совпадение, затем primary-код (`locale.split('-')[0]`). Существующий юнит-тест `i18n.test.mjs:135-152` доказывает это для `de-DE`/`de-AT`/`de-CH` без отдельных записей на каждый диалект в реестре — то же самое автоматически сработает для `fr-FR`/`fr-CA`/`fr-BE`/`fr-CH` без дополнительного кода. Утверждение ТЗ «существующая нормализация... юнит фиксирует» не голословно. - **К4 (доки).** Абзац auto-языка в `docs/USER-GUIDE.ru.md:153-157` и зеркальный в `docs/USER-GUIDE.md` действительно построен по шаблону «`auto` учитывает... `de`, `de-DE`, `de-AT` и `de-CH`» — French по той же форме добавляется без изобретения новой терминологии. Прецедента благодарности контрибьютору по нику в changelog нет, но и противоречия стилю нет — это первый внешний вклад такого рода, и тон уже задан комментарием владельца в issue. - **АС3 (смок).** `demo/smoke_german_locale.mjs` существует — ссылка ТЗ не на пустое место. - **Продуктовая рамка.** Локализация интерфейса не входит буквальной строкой в J1–J7 `docs/SCOPE.md`, но прецедент #62/#348 уже прошёл этот вопрос: #62 специально спроектирован как обобщённая инфраструктура под будущие языки, а German — первый язык, принятый на нём с P1. Открывать этот вопрос заново на четвёртом языке для того же самого пайплайна избыточно. - **Догадки, выданные за факт.** Не найдено ни одного утверждения о поведении, которого нет в коде/документах и которое не помечено как предположение. Оба технических пробела (M1, M2) — это не придуманное поведение, а нерешённые точки контракта; это ровно та категория, которую инструкция просит решать самому ревьюеру, а не выносить владельцу. ## Чего не проверял - Не открывал приложенный контрибьютором zip-архив (`houseplan-fr-translations-only.zip`) — на этапе ТЗ файлов French в репозитории ещё нет (`ls src/i18n/` подтверждает: только `de.json/de.ts/en.json/ru.json/registry.ts/language-runtime.ts`), проверка реального содержимого словаря — предмет код-ревью, не ТЗ. - Не запускал `npm run typecheck`/`test`/`build` — класс A ещё не тронут, фиксировать зелёный прогон не на чем. Это будет обязательным гейтом код-ревью (#371, следующий этап), не этого. - Не проверял `npm run bundle:budget` вживую (нет собранного бандла с French) — числовой бюджет AC4 по своей природе проверяется только после реализации. - Не оценивал лингвистическое качество французского перевода — вне компетенции ревью ТЗ; process явно оставляет финальную проверку носителем языка на постфактум-ревью после беты (см. комментарий владельца в issue). ## Вердикт Жёлтый: 0 High, 2 Medium в скоупе (M1, M2), оба чинятся правкой текста ТЗ в теле issue — не отдельным issue, без блокировки владельцем. Автор либо дополняет К2/AC1 решением по двум названным пробелам, либо явно фиксирует, почему они не нужны сейчас, и повторно выставляет `S4-spec-review`.