# SPEC-REVIEW-44-r1 - Issue: https://github.com/Matysh/houseplan-card/issues/44 - Этап: spec (ревью ТЗ, PROCESS.md §2.4) - Заход: r1 · блокирующих циклов израсходовано 0/4 - ТЗ: `docs/specs/044-filter-grouping-policy.md`, ревизия 2, зафиксирована коммитом `3d4c5090` (только этот файл, `docs/specs/044-filter-grouping-policy.md`, 180 добавлено / 56 удалено — см. `git show --stat 3d4c5090`) - Вердикт: **красный** ## Скоуп ревью Полный разбор ТЗ #44 ревизии 2 (первый заход, дельты нет). Сверялись: 1. `docs/SCOPE.md` — задача закрывает пункт J6 issue-текста («третий вариант» — скрытая настройка с видимым эффектом — запрещён), не конфликтует со SCOPE. 2. `AGENTS.md`, `PROCESS.md` §7.1, §5 — трек полный (не `small`), файл ТЗ обязателен и существует; проверены обязательные разделы. 3. Тело issue #44 и три комментария (актуализация 2026-08-14, вход в работу и ревизия 2 от 2026-08-30) — сверка фактов кода, на которые ссылается автор. 4. `docs/USER-GUIDE.ru.md` §10 «Устройства» — терминология вкладок инбокса. 5. Код, который ТЗ описывает как «факты»: `src/devices.ts`, `src/rules.ts`, `src/houseplan-card.ts`, `src/space-render.ts`, `src/device-inbox.ts`, `src/houseplan-editor-runtime.ts`, `src/i18n/*.json`, `scripts/config-field-registry.mjs`. ## Как проверялось Построчная сверка каждого числового/файлового факта из раздела «Проблема», «Скоуп» и «Контракт поведения» ТЗ с текущим деревом (`git log` подтверждает, что рабочая копия — это коммит `3d4c5090`, единственный докс-коммит #44 в этой ветке). Инструменты: `Grep`/`Read` по указанным файлам и строкам, `git log -S` для датировки происхождения кода, на который ссылается ТЗ как на «уже существующее». ## Находки ### H1 — Сценарий и AC4 построены на неверном факте: причина «исключена интеграция» и появление кандидата в инбоксе уже существуют сегодня, но не там, куда их помещает ТЗ **Файл:** `docs/specs/044-filter-grouping-policy.md`, раздел «Что человек увидит до и после» (строка 23) и «Скоуп → 2. Причина „Исключена интеграция X“» (строки 69–75), AC4 (строка 128). **Утверждение ТЗ:** «До: … понять „почему этого устройства нет в списке“ нельзя. […] Сегодня такие устройства не попадают в инбокс вовсе — они появляются на «Скрытых» именно с этой причиной» и AC4: «скрытый фильтром кандидат виден на «Скрытых» с причиной «Исключена интеграция X»». **Что показывает код:** - Значение `excluded_integration` типа `DeviceInboxReason` и одноимённый i18n- ключ `device_inbox.reason_excluded_integration` уже существуют и не новые: добавлены коммитом `cab8d128 feat: add device lifecycle catalog` (issue #29), задолго до ревизии 2 этого ТЗ (`git log -S excluded_integration` → `cab8d128`). Тексты уже переведены на все 4 языка (`src/i18n/en.json:484`, `de.json:484`, `fr.json:484`, `ru.json:484`), сегодняшний текст — не с плейсхолдером: «Integration excluded by device filters» / «Интеграция исключена фильтрами устройств» и т.д. — обобщённая фраза без имени интеграции. - Уже сегодня `src/houseplan-editor-runtime.ts:7635-7637` считает `excluded = […].some((domain) => this.host._excluded.has(domain))` (тот же резолвер `_excluded`, что ТЗ описывает как «единственный источник» в Контракте №3) и присваивает `reasonByBinding[binding] = 'excluded_integration'` ещё до всякой доработки — то есть кандидат УЖЕ размечен этой причиной. - Эта причина уже рендерится пользователю безусловно для любой категории строки (`src/houseplan-editor-runtime.ts:11848-11851`, `${this.host._t(\`device_inbox.reason_${row.reason}\` as any)}` — вызов не зависит от `row.category`). - Категория строки для такого кандидата — не «Скрытые», а «Доступны»: `buildDeviceInbox` (`src/device-inbox.ts:218-223`) присваивает `category = 'hidden'` **только** когда есть маркер `live?.hidden === true`; кандидат без runtime/live и без маркера получает `category = 'available'` (строка 222), а `reasonByBinding[binding]` подставляется именно в ветке «иначе» для *этой* категории (`src/device-inbox.ts:234-239`). Категория `'hidden'` в принципе не рассматривает `reasonByBinding` для кандидатов без маркера — код это структурно не допускает. **Воспроизведение (проверено чтением, не исполнением):** возьмите конфигурацию без маркера на устройство с платформой из `EXCLUDED_DOMAINS` (или из `settings.exclude_integrations`). Откройте диалог «Устройства» уже на сегодняшнем `dev`. Кандидат уже виден на вкладке «Доступны» (не «Скрытые») со строкой причины «Integration excluded by device filters» — это происходит без единой строки нового кода, только из-за существующей связки `houseplan-editor-runtime.ts:7637` → `device-inbox.ts:238` → `houseplan-editor-runtime.ts:11849`. **Почему это блокирует:** ТЗ выдаёт неверное описание сегодняшнего поведения за факт (не за предположение) и строит на нём и сценарий, и AC4. Раздел «Принятые предположения» (строка 183: «причина показывается на «Скрытых», НЕ в отдельной новой вкладке») маскирует ровно эту развилку решением, но не называет техническое противоречие: сегодняшняя категоризация структурно не кладёт такого кандидата на «Скрытые» без отдельного изменения контракта `buildDeviceInbox`, которого раздел «Контракт поведения» не содержит (там только 5 пунктов, ни один не про категорию строки). AC4 в текущей формулировке невыполним без незаявленного изменения категоризации, а смок, написанный по AC4 «как есть», либо провалится на реальной категории («available», не «hidden»), либо будет молча ослаблен под фактическое поведение — оба исхода авторами не решены, а решение здесь ровно продуктовое («на какой вкладке пользователь видит причину») и должно быть либо переписано по факту, либо вынесено владельцу одним вопросом с вариантом по умолчанию (PROCESS.md §7.1). **Что нужно поправить:** переписать «Проблема»/сценарий по фактическому сегодняшнему поведению (причина и её текст уже есть, только обобщённые и на вкладке «Доступны»), решить и явно записать: 1) остаётся ли причина на «Доступны» (тогда AC4 меняет текст на плейсхолдерный, но не трогает `buildDeviceInbox`) или 2) вводится изменение категоризации, переносящее такие строки на «Скрытые» (тогда это новый пункт Контракта поведения и отдельный риск — категория `hidden` сегодня зарезервирована за маркерами `hidden: true`, смешение с «нет маркера вовсе» меняет смысл вкладки для остальных находок). ### H2 — Контракт №3 («оба значения читаются из одного источника всеми потребителями») не выполняется уже сегодня для `exclude_integrations`, и ТЗ не замечает единственного потребителя-исключения **Файл:** `docs/specs/044-filter-grouping-policy.md`, «Проблема» (строка 34: «потребители `houseplan-card.ts:3924`, `space-render.ts:245`, discovery `devices.ts:1491`») и «Контракт поведения» п.3 (строка 98). **Что показывает код:** `src/devices.ts:1491` — это НЕ потребитель настраиваемого `ctx.excluded`/`_excluded`. Строка лежит внутри `roomClimateMap` (объявление `src/devices.ts:1435`, сигнатура `roomClimateMap(hass, rules?, markers?)` — параметра `settings`/`excluded` в ней нет вовсе) и хардкодит продуктовый `EXCLUDED_DOMAINS`, импортированный напрямую из `rules.ts` (`src/devices.ts:5`): ``` if (EXCLUDED_DOMAINS.has(reg.platform)) continue; // filtered-out integrations ``` Это единственное место в `src/**`, где `EXCLUDED_DOMAINS.has(` вызывается напрямую, минуя настраиваемый резолвер (`grep -rn "EXCLUDED_DOMAINS\.has\(" src` → одно вхождение). `lightGroups()` (второй ключ, `group_lights`) такой проблемы не имеет — оба его вызова (`devices.ts:1035`, `:1100`) читают `settings.group_lights` честно. **Почему это в скоупе, а не соседний баг:** issue #44 прямо требует «для каждого ключа выбрать: 1) поддерживаемая настройка с понятным эффектом […] 2) фиксированное поведение. Нельзя оставлять третий вариант — скрытую настройку, которая влияет на результат, но не видна пользователю». `roomClimateMap` (агрегирование температуры/влажности комнаты, J5) — это ровно третий вариант для `exclude_integrations`: после того как пользователь через новый UI уберёт интеграцию из исключений, устройство появится в списке комнаты, но его температурные/влажностные показания по-прежнему будут молча исключаться (или не исключаться) по продуктовому дефолту — независимо от выбора пользователя. И наоборот: добавление интеграции в исключения через UI не уберёт её показания из climate-агрегата. Это именно то расхождение, ради ликвидации которого заведён #44, только оно остаётся необнаруженным самим ТЗ. **Воспроизведение (проверено чтением, не исполнением):** установить `settings.exclude_integrations: []` (пользователь явно снял все исключения через «Вернуть рекомендуемые» → нет, это удаляет ключ; для эффекта нужно явно задать `[]` — «ничего не исключать», валидное значение по «Принятым предположениям» ТЗ). После этого `_excluded` в `houseplan-card.ts:3925` вернёт пустой `Set`, устройства всех интеграций попадут в `buildDevices`. Но `roomClimateMap` по-прежнему исключит их показания температуры/влажности по жёстко зашитому `EXCLUDED_DOMAINS`, потому что в её сигнатуре нет входа для пользовательского значения. Пользователь увидит устройство на плане, но не увидит его вклад в комнатный climate-показатель, и UI, который ТЗ обещает («честные представления», «понятный эффект»), не объяснит почему. **Почему это блокирует:** Контракт поведения — раздел, на который ссылаются AC (AC7 — «отсутствие ключей → выдача байт-в-байт», проверяет только `buildDevices`, не climate-агрегат) и release-текст «нет скрытых discovery-ключей» (Release-артефакты, ARCHITECTURE.md). Ложное утверждение «читается из одного источника всеми потребителями» — это ровно тот случай, когда «утверждение о поведении, которого нет ни в одном документе, выдано за факт»: инвентаризация (тот самый пункт «инвентаризация exclude_integrations… на реальных конфигах», который issue требует первым делом) пропустила единственного потребителя-нарушителя. ТЗ должно либо явно перевести `roomClimateMap` на настраиваемый резолвер (тогда это новый пункт контракта/AC и новая поверхность — `roomClimateMap`, J5), либо осознанно задокументировать расхождение как принятое ограничение с явным «не-скоуп» и предупреждением в UI/USER-GUIDE («фильтр не влияет на климат-агрегацию») — сейчас не сделано ни то, ни другое. ### L1 — Название вкладки инбокса не совпадает с интерфейсным словарём **Файл:** `docs/specs/044-filter-grouping-policy.md`, «Сценарий» (строка 13), «Скоуп → 1» (строка 48) и везде далее — «вкладка «Доступные»». Канонический термин из `docs/USER-GUIDE.ru.md:785` и живого `src/i18n/ru.json:457` — **«Доступны»** (`device_inbox.tab_available`), не «Доступные». AGENTS.md требует брать терминологию интерфейса из USER-GUIDE, а не изобретать. Не блокирует (Low, по решению ревьюера правится автором при следующей правке текста ТЗ, отдельного цикла не требует). ## Что проверено и корректно - Резолвер `group_lights` (`devices.ts:1033`, `:1098` — `!== false`, default TRUE) и `exclude_integrations` (`houseplan-card.ts:3924-3925`, `space-render.ts:245-246` — replace-семантика, не additive) описаны точно, номера строк совпадают день в день. - `EXCLUDED_DOMAINS` (`rules.ts:12`) — 13 доменов, совпадает с описанием. - `scripts/config-field-registry.mjs:42-70` — оба ключа сегодня `status: 'decision-required'`, `ui: 'hidden'`, паспорта `allow-extra` — ТЗ переносит их в `current` с `ui`-путём в инбоксе; статус `current` используется в реестре многократно (6 других полей), формат совпадает. - `expected_rev` (оптимистическая блокировка, #340) реально существует в `houseplan-card.ts`/`houseplan-editor-runtime.ts`; шаблон удаления ключа при возврате к дефолту уже применён для `settings.weather_entity` (`enforcedBy` в реестре) — техническое решение ТЗ опирается на существующий прецедент, а не выдумано. - `buildDevices(ctx: BuildCtx)` (`devices.ts:1092`) — чистая функция, принимает `settings`/`excluded` через `ctx`, не делает I/O; план AC6 («превью — тот же вход, что у боевого buildDevices, без копии логики фильтра») технически реализуем как заявлено. - `[]` как валидное «ничего не исключать», отличное от отсутствия ключа — подтверждено кодом (`list ? new Set(list) : EXCLUDED_DOMAINS`, строка разбора верна). - Обязательные разделы §7.1 присутствуют все (сценарий, что увидит человек, проблема, скоуп/не-скоуп, контракт, UX/i18n, модель данных и миграция, AC, план автотестов, риски, откат, release-артефакты); «Принятые предположения» оформлены отдельным блоком, как требует PROCESS.md, хотя (H1) не покрывают реальное техническое противоречие. - Трек (полный, не `small`) выбран правильно и соответствует решению аналитики от 2026-08-15, зафиксированному в issue. ## Чего не проверял - Гейты `typecheck`/`test`/`build` не запускались: на этом этапе (S4, ревизия ТЗ) изменён только `docs/specs/044-filter-grouping-policy.md` (коммит `3d4c5090`, `git show --stat` подтверждает единственный файл); продуктовый код не тронут, гейты неприменимы к чистому докс-коммиту. `node scripts/check-docs.mjs` не запускался по той же причине (diff не касается `src/**`). - Смоки/golden/backend/perf — неприменимо, кода нет. - Не проверялась истинность утверждения «инфраструктура для UI появилась #29 … паспорта уже выданы в #33» сверх того, что напрямую процитировано выше (паспорта `allow-extra` подтверждены реестром; сам факт наличия вкладок/`DeviceInboxReason` подтверждён и лёг в основу H1). - Не оценивалась производительность превью-диффа (буст `buildDevices` на больших registry) — вопрос реализации, не ТЗ; риск назван самим автором и выглядит разумно (пересчёт по явному действию, не на тик). ## Итог Вердикт: красный. Два High: ложное описание сегодняшнего поведения инбокса (H1, делает AC4 невыполнимым как написано и маскирует нерешённый продуктовый вопрос про вкладку) и незамеченный потребитель `EXCLUDED_DOMAINS` в `roomClimateMap`, нарушающий заявленный контракт «единственный источник» (H2, оставляет ровно тот «третий вариант», ради ликвидации которого заведён issue). Оба — фактические ошибки инвентаризации кода, а не вкусовщина; обе воспроизведены чтением конкретных строк, обе in-scope issue #44. Low (L1) — терминология вкладки, правится попутно.