# SPEC-REVIEW-391-r1 Issue: #391 · Этап: spec (S4-spec-review) · Трек: `small` (лёгкий) · Заход: r1 · блокирующих циклов ревью ТЗ израсходовано 0 из 2 (лимит лёгкого трека — 2) ## Скоуп Тех.долг: заменить `as any` на 33 вызовах `this.host._t(...)` в `src/houseplan-editor-runtime.ts` на снятие каста (для литеральных ключей) или узкий `as I18nKey` (для вычисляемых ключей), в трёх семействах — `device_inbox.*`, `marker.*`, `gs.preflight_reason_*`. Выделено из код-ревью #390. Изменение невидимо пользователю (`User-Visible: no`), не меняет ключи, переводы, разметку, поведение и не расширяет тип `I18nKey`/`_t`. Кода ещё нет: ветки `issue/391-*` не существует (`git branch -a` пуст), проверяется только текст ТЗ в теле issue против фактического состояния `src/houseplan-editor-runtime.ts` на `origin/dev` = `a47ec97a`. ## Как проверялось Ревью ТЗ на лёгком треке не гоняет билд-гейты (кода нет, гонять нечего) — основной метод: чтение `docs/SCOPE.md`, `AGENTS.md`, `PROCESS.md`, тела issue #391 и его единственного комментария (аналитика владельца), затем построчная сверка каждого технического утверждения ТЗ с реальным кодом: - пересчитал все `as any` рядом с `_t(...)` в файле поимённо (`awk` по токену `as any` на строках, содержащих `device_inbox`/`marker.`/`gs.preflight`, считая по несколько совпадений на строку, а не по строкам) — получил ровно **26 device_inbox + 6 marker + 1 gs = 33**, что совпадает с числом из тела issue и из комментария аналитика. Первый проход давал 23/5/1=29 и 25/5/1=31 — ошибка была в собственном regexp (пропускал вторую строку многострочного `_t(...)`, строку `const emptyKey = ... as any` без вызова `_t` на той же строке, и `${…} as any : ${…} as any` — два каста в одном вызове на строке 12055). После исправления regexp числа сошлись; - для каждого класса вычисляемых ключей проверил, действительно ли источник — закрытый union, как утверждает ТЗ: - `device_inbox.tab_${tab}` / `device_inbox.empty_${dialog.tab}` — `tab`/`dialog.tab` типа `DeviceInboxCategory = 'on_plan'|'available'|'hidden'|'readd'` (`src/device-inbox.ts:12`), словарь содержит все 4×2 производных ключа — сходится, каст снимается на `as I18nKey` без потери типобезопасности; - `device_inbox.status_${row.status.kind}` — ветка после отсечения `'active'` в тернаре, оставшиеся значения `ha_disabled|orphaned|unverified`, все три ключа в словаре есть (`src/i18n/en.json:500-502`); - `gs.preflight_reason_${failure.reason}` — `OptimizeGeometryFailureReason` закрытый union (`src/plan-geometry-preflight.ts:71`), ключи в словаре подтверждены (`src/i18n/en.json:885-891`); - `marker.value_badge_attr_${source.attribute}` (строка 12411) — единственный проверенный случай, где источник **не** закрытый union: `ValueBadgeSource['attribute']` типизирован как обычный `string` (`src/types.ts:103-106`, `VALUE_BADGE_ATTRIBUTES: Record` в `src/device-value-badge.ts:9`). Но это именно тот случай, который ТЗ прямо описывает как «компилятор не выводит ключ сам» — узкий `as I18nKey` здесь необходим и ожидаем самим текстом ТЗ, а не недосмотром; - `marker.toggle_effect_${value.replace('-', '_')}` и аналоги (12257, 12294) — `String.prototype.replace` всегда возвращает `string` независимо от входа, литеральный union теряется — тоже честный случай для `as I18nKey`, ТЗ это покрывает; - проверил, что литеральные ключи (device_inbox.title/button/search/…, device_inbox.saved, marker.value_badge_lqi и т.д.) действительно есть в `src/i18n/en.json` без модификации — 17 выборочно проверенных ключей все найдены; - отдельно нашёл в файле соседний, но не входящий в скоуп паттерн: строки 11927–11968 используют `as never` (не `as any`) на других ключах семейства `device_inbox.filters_*`/`device_inbox.empty_*`-соседях. Это не предмет задачи (ТЗ и правило 3 явно запрещают *добавлять* новые `as never`, но не требуют убирать существующие), не находка; - проверил прецедент: предшественник #390 (та же пара автор/ревьюер, тот же файл, тот же лёгкий трек) был принят и слит без явного раздела «откат» в теле issue — ориентир для оценки, насколько строго требовать этот раздел здесь (см. находку L1 ниже); - проверил самооценку трека: сложность 1/3, одна поверхность (`houseplan-editor-runtime.ts`), нет миграции конфига, нового UX-контракта, влияния на perf/touch — подтверждается тем, что диапазон правки — только снятие/сужение приведения типов на уже существующих строках, без изменения веток исполнения. Гейты (`typecheck`/`test`/`build`) не прогонялись сознательно: на этом этапе нет диффа кода, который они могли бы проверить — они относятся к этапу код-ревью после реализации, не к ревью ТЗ. ## Находки ### L1 (Low) — нет явного раздела «откат» Шаблон лёгкого трека (`PROCESS.md` §5) требует «проблема · контракт · AC1…ACn с доказательством · **откат**». В теле issue #391 такого раздела нет (есть «Контекст/Проблема», «Цель», «Скоуп», «Правила реализации», «Критерии приёмки», «Проверка», «Оценка и трек», «DoD» — отката среди них нет). Снимаю без правки: откат здесь тривиален и не требует отдельного решения — `User-Visible: no`, миграции нет, единственный коммит с трейлерами `Issue: #391`/`User-Visible: no`, откат = `git revert` этого коммита без побочных эффектов. Тот же паттерн (без явного «откат») был принят у предшественника #390 с тем же профилем риска. Дальнейшего требования к автору нет — только запись в этом документе. Находок High или Medium, блокирующих или требующих правки, не найдено. ## Что проверено и корректно - **Числа в ТЗ точны**: 26 device_inbox + 6 marker + 1 gs = 33 `as any` на вызовах `_t`, проверено построчным пересчётом, не поверено на слово автора. - **Технические предположения не являются недоказанной догадкой**: утверждение «динамические ключи собираются из закрытых union» проверено по каждому вычисляемому ключу; там, где это неверно (`source.attribute: string`, `.replace()` результат), ТЗ само относит случай к «узкий `as I18nKey`, если компилятор не выводит ключ сам» — то есть не выдаёт неполную догадку за универсальный факт, оговаривает исключение заранее. - **Скоуп замкнут и не пересекается с продуктовым поведением**: словари, переводы, `_t`/`I18nKey`, Device Inbox/marker/preflight UX явно исключены; соседние `as any` (decor.*, vac.*, run.*, tap.*, fill.*, room.*, markup.*) тоже явно вне скоупа и не тронуты правилами реализации. - **AC проверяемы и у каждого понятен способ доказательства**: AC1–AC4 — чтением кода/diff (описано в разделе «Проверка»: grep на `as any`, сверка ключей/веток/строк), AC5–AC7 — именованные команды (`npm run typecheck`, `npm test`, `npm run build`+`bundle:sync`+`bundle:budget`+`inventory`). - **Классификация `small`/сложность 1/3 обоснована**, а не заявлена без проверки: единственная поверхность, механическая типизация, ни один пункт критериев лёгкого трека не нарушается. - **Соответствие docs/SCOPE.md**: задача не добавляет и не меняет пользовательское поведение, поэтому не обязана закрывать строку Core user jobs — это внутренняя типобезопасность, тот же класс, что принятый предшественник #390. - **Гейт #342 (`no-new-any`) корректно учтён**: правило 5 ТЗ верно описывает механику гейта (судит только добавленные/изменённые строки диапазона) — соответствует `PROCESS.md` §8 дословно. ## Чего не проверял - Не запускал `npx tsc --noEmit`/`npm test`/`npm run build` — на этом этапе нет кода для проверки (ветка `issue/391-*` не создана). Это будет предметом код-ревью после реализации, а не спек-ревью. - Не проверял il8n-ключи `ru.json` на предмет паритета для затронутых семейств — вне скоупа задачи (ключи не меняются) и не влияет на оценку ТЗ. - Не выполнял линию `npm run inventory`/`bundle:budget` — неприменимо без кода. - Не проверял все 33 места по отдельности до последней строки итогового кода (это будет делать код-ревью с реальным diff'ом); проверил представительную выборку и все нетривиальные (не факт «закрытый union») случаи, которых оказалось 2 из 33 (`marker.value_badge_attr_*`, `marker.toggle_*_${value...}` и `marker.toggle_none_${reason...}` через `.replaceAll`) — оба уже покрыты формулировкой ТЗ. ## Вердикт Зелёный. ТЗ выполнимо, проверяемо, не содержит недоказанных догадок, самооценка трека верна. Единственное отклонение от шаблона (нет раздела «откат») — Low, снято решением ревьюера с записью выше, без возврата автору.