Files
houseplan-card/docs/reviews/SPEC-REVIEW-391-r1.md
T
2026-08-30 16:44:22 +00:00

13 KiB
Raw Blame History

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<string, readonly string[]> в 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, снято решением ревьюера с записью выше, без возврата автору.