Files
houseplan-card/docs/reviews/CODE-REVIEW-371-r1.md
T
2026-08-29 13:42:09 +03:00

177 lines
17 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# CODE-REVIEW-371-r1
**Issue:** [#371 — Add French localization files](https://github.com/Matysh/houseplan-card/issues/371)
**Этап:** code (PROCESS.md §2.7)
**Заход:** r1 · блокирующих циклов израсходовано 0 из 2 (лёгкий трек, бюджет §4 = 2)
**SHA на момент ревью:** `3c19ba1881a3fc3bb7f0a504d9ef332c3ccce192`
**База:** `origin/dev`
**Коммиты в диапазоне:**
- `fd673b32` feat: French localization — community contribution by @OUARZA (#371) — `User-Visible: yes`
- `a29d9bc6` test: pin the fr entry to the fr dictionary via a live-bundle mutant (#371) — `User-Visible: no`
- `3c19ba18` test: keep the fr chunk emitted in the #371 mutant (wrong dictionary via en) — `User-Visible: no`
Это первый заход код-ревью для #371 (спек-ревью уже прошло два захода r1/r2 и
закрылось зелёным на этапе `spec`); правило «объём по дельте» (§2.9) на код-этап
не переносится — здесь начинается собственный счётчик циклов и полный разбор.
## Скоуп
Класс A (`src/i18n/fr.json`, `src/i18n/fr.ts`, `src/i18n/registry.ts`,
`custom_components/houseplan/translations/fr.json`) + класс B (`scripts/bundle-manifest.mjs`,
`scripts/mutation-gate.mjs`, `scripts/smoke-links.mjs`, `demo/smoke_french_locale.mjs`,
`demo/smoke_entry_stale.mjs`, `test/i18n.test.mjs`, `test/bundle-assets.test.mjs`) +
класс C (`docs/CHANGELOG.md`, `docs/CHANGELOG.ru.md`, `docs/USER-GUIDE.md`,
`docs/USER-GUIDE.ru.md`) + класс D (три копии бандла, `docs/images/*`,
`docs/images/screenshots.json` — регенерированы `check-docs`/`capture.mjs`
из-за роста `sourceFingerprint` над `src/**`, ожидаемо).
Задача — French как четвёртая (третья ленивая) локаль по контракту реестра
#62, буквальный клон механики German (#348). Соответствует core user job
J1/J4 (`docs/SCOPE.md`): расширяет доступность интерфейса без нового UX-контракта.
## Как проверялось
Прогнано лично, гейты дешёвые, результаты — команда и вывод:
| Гейт | Команда | Результат |
|---|---|---|
| Типы | `npx tsc --noEmit` | чисто, без ошибок |
| Юниты | `npm test` | `# tests 1544 / pass 1543 / fail 0 / skipped 1` (пропуск не связан с задачей — существовал до неё) |
| Сборка | `npm run build` | успех, `dist` создан за 14.5s |
| Синхронизация бандла | `git status --short` после `npm run build` | пусто — закоммиченный `dist` побайтово совпадает с пересобранным; отдельно сверены `dist/houseplan-assets`, `custom_components/houseplan/frontend/houseplan-assets`, `dist/houseplan-assets.json`, `dist/houseplan-card.js` через `diff -rq`/`diff` — расхождений нет |
| Бюджет | `npm run bundle:budget` | `initial View: 273826 B gzip (budget 300000, headroom 26174)`, `lazy locale: 45215 B gzip` — совпадает с числом автора (273 826) |
| Доки-отпечаток | `node scripts/check-docs.mjs` | `Documentation checks passed (7 files, 10 external links)` |
| Выбор смоков | `node scripts/smoke-select.mjs --base origin/dev --head HEAD` | «НЕОПРЕДЕЛЁННОСТЬ»: `FRENCH_RETRY_ASSET`, `LazyLanguageModule`, `loadFrench` не встречаются ни в одном смоке по имени — см. решение ниже |
| Смок AC3 | `node demo/smoke_french_locale.mjs` (после `npm run bundle:sync`, т.к. стенд был устаревшим) | `OK`, все 5 полей true |
| Смок AC (fallback) | `node demo/smoke_entry_stale.mjs` | `OK` |
| Смок регресс | `node demo/smoke_german_locale.mjs` (немецкая ветка делит код с новой — проверка отсутствия регресса) | `OK`, все 9 полей true |
| Мутант AC5 (дёшево) | `node scripts/mutation-gate.mjs --check --id=french-locale-wrong-dictionary` | `ok french-locale-wrong-dictionary` (патч ложится на текущий код) |
| Паритет словаря (независимый скрипт, не доверяю заявлению автора на слово) | inline Python: сравнение ключей/порядка/плейсхолдеров `en.json` vs `fr.json` | `en keys 1128, fr keys 1128`, `missing/extra: []`, `order matches: True`, `empty fr values: 0`, `placeholder mismatches: 0` |
| Артефакты не-перевода (независимо) | inline Python: кириллица + сентинелы в `fr.json` | `cyrillic hits: []`, `sentinel hits: []` |
| Allow-list "равно en" (независимо) | inline Python: множество ключей с `en[k]==fr[k]` | ровно те же 22 ключа, что в `test/i18n.test.mjs` allow-list — совпадение полное |
| Паритет бэкенда | inline Python: flatten `custom_components/houseplan/translations/{en,fr}.json` | `en keys 6, fr keys 6, missing/extra: []` |
| Манифест | inline Python по `dist/houseplan-assets.json` | `fr-BSq3jD_I.js` в `lazyLocaleFiles`, отсутствует в `initialViewFiles` |
| Трейлеры | `git log` по трём коммитам | `Issue: #371` и `User-Visible` на всех трёх; `User-Visible: yes` (`fd673b32`) правит оба changelog в том же коммите (подтверждено диффом) |
**Не проверялось и почему:**
- `python -m pytest tests_backend -q` — не запускался: diff не трогает
`custom_components/houseplan/**/*.py`, только статический JSON перевода.
- `npm run invariants` (модельные инварианты, #254) — не запускался: diff не
касается геометрии (нет правок рёбер комнат, `layout`, `marker.space`,
`open_spans`, толщины стен) — ни один из грепов по этим токенам не дал
совпадений в diff.
- `npm run golden:verify` — не запускался: видимый рендер плана (геометрия,
стили, слои) не меняется; единственное видимое изменение — новый набор
строк словаря за новым языковым тегом, который golden не покрывает.
- Полный `node scripts/mutation-gate.mjs` (без `--check`) — не запускался:
документация самого скрипта прямо называет полный прогон дорогим
(пересборка бандла в отдельном worktree) и относит его место к
пред-релизному гейту (`.github/workflows/mutation-gate.yml`), не к
ревью каждой беты; проверено дешёвой частью (`--check`, применимость патча)
плюс отчёт автора «поймано 1/1» с описанием, как чинился инфраструктурный
ложный провал мутанта (`a29d9bc6`→`3c19ba18` — тришейкинг `fr.ts` валил
сборку раньше, чем гард успевал сработать; переработка обоснованная).
- Полный набор `demo/smoke_*.mjs` — не запускался: `smoke-select.mjs`
подтверждает, что диапазон изменений локален (3 символа проекта, 2 файла
`src/**`), широкий прогон не оправдан задачей уровня `small`. Выбраны:
прямое совпадение (`smoke_french_locale.mjs`, назван в AC3),
зарегистрированная связь (`smoke_entry_stale.mjs` — фолбэк-плашка теперь
четырёхветочная), плюс `smoke_german_locale.mjs` вручную — код `de`/`fr`
делит одну и ту же функцию сборки роли/локейл-рутов и ретрай-плагин,
регрессия по соседству была бы правдоподобна.
- «НЕОПРЕДЕЛЁННОСТЬ» инструмента (`loadFrench`, `FRENCH_RETRY_ASSET`,
`LazyLanguageModule`) — решение ревьюера: эти символы — прямые аналоги
`loadGerman`/`GERMAN_RETRY_ASSET`, которые тоже нигде не зарегистрированы в
`scripts/smoke-links.mjs` (там только общеконтрактные `LanguageRuntime`,
`dictionaryFor`, `ensureLanguage` — уже расширены на `smoke_french_locale.mjs`
автором). `smoke_french_locale.mjs` прямым исполнением проверяет именно то,
что реализуют эти три символа (один ленивый fr-чанк на профиль fr-CA,
отсутствие в initial-графе) — прогнан выше и зелёный. Дополнительной записи
в `smoke-links.mjs` не требуется по той же причине, по которой её нет для
German-аналогов: связь уже находится прямым совпадением имени смока с темой.
- Лингвистическое качество перевода — вне ревью кода по решению владельца в
этом же issue (носитель, @OUARZA, проверит после публикации беты).
- `demo/smoke_opening_measure.mjs` и прочие несвязанные смоки — задача не
трогает геометрию/измерения, тема не пересекается.
## Находки
Нет. High: 0, Medium: 0.
Отдельно проверены места, где легко было бы найти скрытую деградацию:
- **Порядок ветвей в `entryFallbackPlugin`** (`scripts/bundle-manifest.mjs:216-225`):
тернарник `ru → de → fr → en` — французская ветка вставлена перед финальным
английским умолчанием, а не после (что было бы недостижимым кодом). Смок
`smoke_entry_stale.mjs` подтверждает исполнение этой ветки.
- **Роль чанка (`_role: 'locale'`)** (`scripts/bundle-manifest.mjs:33`):
дизъюнкция `de.ts || fr.ts` не меняет порядок остальных проверок
(`onboarding`/`editor`) — де-факто проверено юнитом
`test/bundle-assets.test.mjs` и совпадением манифеста в бандле.
- **Мутант AC5** пришлось дважды перепроектировать в процессе (см. коммиты
`a29d9bc6`, `3c19ba18`): первая версия валила сборку раньше, чем успевал
сработать смок-гард — это не найдено ревью как отдельная находка, потому что
автор исправил это сам до подачи на ревью, а итоговый мутант
(`--check` подтверждён) действительно проверяет то, что заявлено в
`because`: «профиль читает не тот словарь» ловится только браузерным смоком,
не паритет-юнитами.
- **Единственное число, видимое дважды**: не применимо — задача не добавляет
новую пользовательскую величину (только текстовые строки нового языка);
`test/single-source-numbers.test.mjs` не тронут и остаётся зелёным в общем
прогоне юнитов.
## Что проверено и корректно
- **К1 (файлы).** `src/i18n/fr.json`: 1128/1128 ключей, порядок идентичен
`en.json`, пустых значений и расхождений плейсхолдеров нет (независимая
проверка скриптом, не только заявление автора). `src/i18n/fr.ts` —
побуквенный клон `de.ts` с фингерпринт-хендшейком.
`custom_components/houseplan/translations/fr.json` — 6/6 ключей, полный
паритет с `en.json` бэкенда (независимая проверка).
- **К2 (реестр и сборка).** `loadFrench`/`FRENCH_RETRY_ASSET` — точная копия
German-пути; `localeRoots` обобщён на `(de|fr)-`; `_role` учитывает оба
файла; retry-плагин требует ровно 1/1/1/1 замену (было 1/1/1) и
падает иначе — юнит `test/bundle-assets.test.mjs` это фиксирует.
`entryFallbackPlugin` получил французскую ветку (закрывает M1
спек-ревью r1) — подтверждено и текстом кода, и зелёным
`smoke_entry_stale.mjs`.
- **К3 (авто-выбор).** `fr`, `fr-FR`, `fr-CA`, `fr-BE`, `fr-CH` → `fr`
проверено юнитом `test/i18n.test.mjs` (`resolveLanguageCode`, `langOf`) и
смоком (`fr-CA` в реальном бандле).
- **К4 (доки).** `USER-GUIDE.md`/`.ru.md` называют французский в абзаце
авто-языка с указанием тегов; оба `CHANGELOG` получили запись с
благодарностью автору вклада, в том же user-visible коммите.
- **AC1** — паритет доказан скриптом (см. таблицу), плюс французский
глоссарий продуктовых терминов и скан кириллицы/сентинелов
(`test/i18n.test.mjs:377-421`) — оба зелёные, независимо
перепроверены значения (`btn.save`→`Enregistrer` и т.д.) и allow-list
из 22 «легитимных омографов» — множество совпадает бит-в-бит.
- **AC2** — `languageEntry('fr-CA')` → `fr` покрыт в цикле по всем кодам
реестра (`test/i18n.test.mjs:56` через `languageEntry(entry.code.toUpperCase())`,
выполняется и для `fr`); реестр — ровно 4 записи (`en/ru/de/fr`), явных
дубликатов кодов юнит проверяет через `Set` (`i18n: registry codes and
English fallback are valid`).
- **AC3** — `smoke_french_locale.mjs` прогнан лично: fr-CA-профиль коммитит
французский, один запрос fr-чанка на страницу, initial-граф без fr;
сценарий отказа не дублируется — доказан generic-веткой в
`smoke_entry_stale.mjs` с французским текстом.
- **AC4** — `npm run bundle:budget`: initial 273 826 Б (совпадает с числом
автора и headroom не тронут), lazy-locale выросла на fr-чанк
(45 215 Б суммарно de+fr).
- **AC5** — мутант `french-locale-wrong-dictionary` в
`scripts/mutation-gate.mjs:683-693` применяется к текущему коду
(`--check`); по заявлению автора и описанию гарда ловится только
`smoke_french_locale.mjs`, что согласуется с архитектурой (паритет-юниты
видят только статический импорт, не рантайм-подмену словаря).
Гейты класса A/B зелёные без исключений; регрессии в German-пути (общий код)
не найдено; трейлеры и правило двух changelog'ов соблюдены.
## Вердикт
Все AC (К1–К4, AC1–AC5) доказаны — либо автотестом, для которого проверена
способность падать (мутант AC5, юниты паритета/глоссария), либо независимой
перепроверкой скриптом поверх заявлений автора (парность словарей, манифест,
allow-list). Находок нет. Задача не расширяет и не сужает продуктовый скоуп
за пределы контракта реестра #62, соответствует `docs/SCOPE.md` (J1/J4) и не
деградирует German-путь.