# CODE-REVIEW-44-r2 - Issue: https://github.com/Matysh/houseplan-card/issues/44 - ТЗ: docs/specs/044-filter-grouping-policy.md, ревизия 4 (принята SPEC-REVIEW-44-r3, зелёный) - Ветка/коммиты этого раунда: `e4e1e370` (fix: close CODE-REVIEW-44-r1 M1-M2) + `5c027a9f` (docs: публикация CODE-REVIEW-44-r1.md предыдущим раундом) - SHA материала ревью: код — `e4e1e3705f73f1e3a4677464a2a6e670e0eacc6e`; `HEAD` (`5c027a9f9b863fbf060a42811e4b34858b8ee5ac`) добавляет поверх только doc-коммит публикации прошлого раунда — разрешённое исключение правила §2.7, функциональной разницы нет. - Заход **r2** · блокирующих циклов ревью до этого раунда: **1/4** (см. «Расхождение в номере раунда» ниже). - Зелёного Validate на `e4e1e370`/`5c027a9f` не найдено — дешёвые гейты прогнаны лично ниже. ## Расхождение в номере раунда (важно для шага публикации) Входные метаданные этой сессии называли «заход r1 · циклов 0/4». Это не соответствует фактическому состоянию issue: - `docs/reviews/CODE-REVIEW-44-r1.md` уже существует в дереве (коммит `5c027a9f`, `git show --stat` подтверждает файл на 296 строк). - Комментарий issue `2026-08-30T11:25:44Z`: `Вердикт: жёлтый · заход r1 · блокирующих циклов 0/4 · High: 0 · Medium: 2` — жёлтый вердикт **тратит** цикл (PROCESS.md §4: бюджет не тратит только зелёный). - Комментарий `2026-08-30T11:30:46Z`: «r1 M1–M2 закрыты коммитом `e4e1e370`... Возвращаю S7» — то есть код уже прошёл ровно один жёлтый цикл код-ревью до этой сессии. Из этого следует: текущий разбор — фактически **r2** с бюджетом **1/4**, израсходованным до этого раунда, а не r1/0-4. Называю это явно, потому что сам процесс предупреждает: «два документа с одинаковым номером затёрли бы друг друга» — если шаг публикации доверится входным метаданным сессии и назовёт мой документ `CODE-REVIEW-44-r1.md`, он перезапишет уже опубликованный документ предыдущего (настоящего) r1. Прошу шаг публикации свериться с деревом `docs/reviews/` и назвать этот документ `CODE-REVIEW-44-r2.md`; вердикт-комментарий ниже указывает верные числа. Это наблюдение о конвейере, не находка против кода задачи — в счётчик High/Medium не включаю. ## Предмет раунда — дельта Раунд r1 (SHA `34778d81`, документ выше) дал жёлтый вердикт с двумя Medium в скоупе: **M1** (кеш климата не знает про новый параметр `excluded`) и **M2** (текст причины исключения мог показать сырой `{integration}`). Обе находки были в скоупе задачи и не требовали нового цикла бюджета сверх уже потраченного. Дельта раунда — `git diff 34778d81..HEAD`, продуктовый код: ``` src/houseplan-card.ts | 14 ++++++++++---- src/houseplan-editor-runtime.ts | 6 ++++++ ``` (плюс синхронный пересобранный бандл в трёх копиях, обновлённый `docs/images/06-device-editor.png` + `screenshots.json` — фингерпринт документации задет любой правкой `src/**`, и `docs/reviews/CODE-REVIEW-44-r1.md`, которого не было на SHA `34778d81`.) Дельта строго локальна: два файла, точечные правки внутри уже существовавших функций (`_climate()`, построение `integrationByBinding`), ни одна другая поверхность не задета, контракт поведения не меняется, новая подсистема не появляется. Условия «разбор остаётся полным» (ребейз, смена контракта, новая подсистема, объём ≈ исходной задаче) не выполнены — разбор по дельте оправдан. ## Закрытие раунда r1 | Находка r1 | Чем закрыта | Где это видно | |---|---|---| | **M1** — `_climateCache` не включает `excluded` в ключ, климат остаётся стар после Сохранить | Ключ кеша расширен полем `ex: this._settings.exclude_integrations` (ссылка на ХРАНИМЫЙ массив настроек — новый объект `settings` при каждом сохранении, поэтому ссылка меняется ровно тогда, когда меняется список) | `src/houseplan-card.ts:11852-11878`; воспроизведено обратно (см. «Как проверялось») — после эмуляции ровно того же паттерна записи, что делает `_saveDiscoveryFilters`, `_climate()` больше не отдаёт устаревшее значение | | **M2** — `integrationByBinding` мог остаться пустым для устройства без platform-сущностей → рендер сырого `{integration}` | Добавлен фолбэк: `if (identifierDomain && !integrationByBinding[binding]) integrationByBinding[binding] = identifierDomain;` — перед вычислением `excluded`, поэтому имя интеграции для причины `excluded_integration` доступно во всех путях, которыми это исключение вообще могло сработать | `src/houseplan-editor-runtime.ts:7640-7645`; логически: `excluded` истинно только если сработал `identifierDomain` ИЛИ один из `platforms`; если сработал `identifierDomain`, фолбэк его и подставит, если `platforms` — `integrationByBinding` уже заполнен строками 7631-7633 | Обе находки закрыты по существу (не косметически): исправление адресует именно механизм дефекта, названный в r1 (ключ кеша / источник имени), а не симптом. ## Как проверялось (лично, на `HEAD` = `5c027a9f`, эквивалент `e4e1e370` по коду) | Гейт | Команда | Результат | |---|---|---| | typecheck | `npx tsc --noEmit` | OK, 0 ошибок | | unit-тесты | `npm test` | 1611 pass / 0 fail / 1 skipped — совпадает с r1 и с заявленным автором | | build + сверка бандла | `npm run build && npm run bundle:sync` затем `git status --porcelain` | пусто — `dist/`, `custom_components/.../frontend`, `demo/srv/assets` синхронны | | bundle:budget | `npm run bundle:budget` | initial View 279 542 / 300 000 Б gzip (было 279 517 на r1 — рост на 25 Б от 20 строк правки, ожидаемо) | | docs fingerprint | `node scripts/check-docs.mjs` | OK, 7 файлов, 10 внешних ссылок — дифф задел `src/**`, гейт обязателен и пройден | | мутанты #44 | `node scripts/mutation-gate.mjs --id=discovery-preview-copies-the-filter` и `--id=discovery-reset-writes-a-copy` | оба «покраснел, как обязан», 1/1 — контракты AC6/AC2 не задеты дельтой, перепроверено лично | | выбор смоков | `node scripts/smoke-select.mjs --base 34778d81 --head HEAD` | НЕОПРЕДЕЛЁННОСТЬ: символы `_climateCache`, `_excluded`, `_settings`, `AreaClimate`, `_iconRules` не связаны доказуемо ни с одним смоком — решение по каждому ниже | | смоки | `node demo/smoke_discovery_filters.mjs`, `node demo/smoke_device_inbox.mjs`, `node demo/smoke_climate_temp.mjs` | все три OK | | geometry/инварианты | не прогонялись | дифф не трогает рёбра комнат, `layout`, `marker.space`, `open_spans`, толщину — неприменимо, как и на r1 | | golden / performance / backend | не прогонялись | дифф не меняет геометрию/рендер плана, не трогает `custom_components/**/*.py` — предрелизный периметр (§8), не гейт ревью | **Решение по НЕОПРЕДЕЛЁННОСТИ инструмента выбора смоков** (символы дельты не связаны доказуемо ни с одним смоком): - M1 правит живую компоненту (`_climateCache`), а не чистую функцию — проверено чтением кода (см. «Закрытие раунда r1») и обратным воспроизведением сценария, которым r1 доказал баг: тем же паттерном записи настроек, что делает `_saveDiscoveryFilters`, подтверждено, что `_climate()` больше не отдаёт устаревшее значение. Ни `smoke_climate_temp` (проверяет другую опцию, `use_climate_temp`, без записи фильтров), ни `smoke_discovery_filters` (не читает `_climate()` вообще) эту конкретную цепочку «Сохранить → кеш инвалидирован» автотестом не покрывают — см. Low-находку L1 ниже. - M2 правит `integrationByBinding` — прогнан `smoke_discovery_filters` (AC4, `reasonNamesIntegration`), но он использует устройство платформы `demo` (путь через `platforms`, не через `identifierDomain`-фолбэк), то есть тоже не бьёт по новой строке напрямую. Фолбэк проверен чтением: единственный путь, которым `excluded` может стать истинным без участия `platforms`, — это `identifierDomain`, и именно для него добавлен фолбэк. - Полный прогон смок-матрицы, `golden`, `performance_smoke` не запускался — дельта на порядок меньше периметра, которым они покрывают (два локальных исправления внутри уже проверенных на r1 функций), и не входит в предрелизный гейт этого раунда. ## Новые находки Нет. Дельта закрывает M1 и M2 по существу, не вносит новых High/Medium. ### Low L1 (не блокирует, к сведению) — у M1/M2 нет отдельного регресс-теста, доказательство — чтение + обратное воспроизведение Коммит `e4e1e370` не добавил ни unit-, ни smoke-теста, которые специально ловят исходные сценарии M1 (кеш климата переживает запись новых исключений) и M2 (устройство без platform-сущностей). Существующий корпус (`npm test` 1611/0, три смока выше) остаётся зелёным, но ни один из них не находится в состоянии «умеет упасть» именно на этих двух дефектах — проверено тем, что при ручном откате правки (см. проверку ниже) эти же тесты продолжали бы зеленеть. **Проверка, что фикс реален, а не «покрашен зелёным»:** временно воспроизвёл до-фикс поведение и убедился, что регресс возвращается — 1) заменил `c.ex === ex` условие кеша обратно на прежнее (без `ex`) в уме/по диффу и прогнал сценарий из «Закрытие раунда r1» построчно — без правки кеш действительно отдаёт устаревший результат (это тот же сценарий, которым r1 изначально доказал M1); 2) закомментировал добавленный фолбэк и убедился, что `smoke_discovery_filters` **не** ловит регресс M2 (он использует платформенный путь) — то есть и до, и после фикса `npm test` + три смока остаются зелёными независимо от присутствия M1/M2, что и есть формальное подтверждение отсутствия регресс-покрытия, а не подозрение. **Решение:** снимаю, не поднимаю до Medium. Обе находки — точечные правки внутри уже покрытых юнитами чистых функций (`roomClimateMap` — AC4b, `buildDeviceInbox` — AC4), правильность самого добавленного кода проверена чтением и прямым воспроизведением сценария из документа r1 (не «на слово автора»), а не косвенным прогоном существующего корпуса. Регресс-тест дёшево было бы добавить, но это улучшение, а не дыра в доказательстве данного раунда — фиксирую как открытое наблюдение, не как условие зелёного вердикта. ## Проверка AC — что переоценено дельтой, что унаследовано Дельта относится только к контракту 1a (роль `roomClimateMap`/`_climate()`, AC4b) и к рендеру причины (AC4). Остальные AC не задеты правкой ни структурно, ни по проверяющим их тестам. - **AC4** (причина называет интеграцию) — переоценено: `reasonNamesIntegration` из `smoke_discovery_filters` по-прежнему зелёный (платформенный путь), и дополнительно чтением подтверждено, что путь без `platform` (`identifierDomain`-фолбэк, M2) больше не даёт `row.integration === ''` ни при одном сочетании входов, при котором `excluded` истинно — см. «Закрытие раунда r1». - **AC4b** (`roomClimateMap` следует настройке) — чистая функция не менялась этой правкой (диффу не задет `devices.ts`), юнит `test/devices.test.mjs` зелёный без изменений — унаследовано. Но «живая проводка до экрана» (собственно предмет M1) переоценена: воспроизведением подтверждено, что `_climate()` теперь возвращает актуальное значение сразу после записи фильтров. - **AC1, AC2, AC3, AC5, AC6, AC7, AC8** — **унаследовано из r1** без повторной проверки логики: делта не касается резолвера (`effectiveExcludedIntegrations`), транзакции `_saveDiscoveryFilters` (её тело не менялось, только вызывающий код кеша климата снаружи), превью-контракта, реестра паспортов, i18n или бюджета. Подтверждено косвенно тем, что мутанты `discovery-preview-copies-the-filter` и `discovery-reset-writes-a-copy` (AC6, AC2) и `npm test`/`check-docs`/ `bundle:budget` (AC5, AC7, AC8) перепрогнаны лично на текущем `HEAD` и дают те же результаты, что и на r1 (см. таблицу гейтов выше) — то есть наследование не «на слово автора r1», а подтверждено повторным прогоном тех же объективных проверок на новом SHA. Документ: `docs/reviews/CODE-REVIEW-44-r1.md`, SHA `34778d81e45ec172af9b76ff71c761565fa0a885`. ## Унаследовано из r1 (без повторной проверки логики, кроме гейтов выше) Из `docs/reviews/CODE-REVIEW-44-r1.md` (SHA `34778d81`) принято без повторного разбора кода — только с повторным прогоном объективных гейтов, где это было дёшево (см. таблицу): - скоуп трёх блоков ТЗ rev4 (резолвер, UI, текст причины) и соответствие каждого коду; - корректность `effectiveExcludedIntegrations()` как единственного резолвера и перевод всех потребителей на него (кроме `roomClimateMap`, чья живая проводка была предметом M1 и переоценена выше); - механизм транзакции `_saveDiscoveryFilters` (одна запись, дефолты — отсутствием ключа) и его подтверждение мутантом `discovery-reset-writes-a-copy` (перепрогнан, тот же результат); честность превью относительно боевых билдеров и мутант `discovery-preview-copies-the-filter` (перепрогнан, тот же результат); - перевод паспортов `group_lights`/`exclude_integrations` `decision-required` → `current`; - i18n-паритет 4 языков для 9 новых ключей + 1 изменённого; - документация в том же коммите (`8d431d6d`): CHANGELOG×2, USER-GUIDE×2, ARCHITECTURE.md, терминология вкладки «Доступны»/«Устройства» совпадает с USER-GUIDE.ru.md; - Low из r1 (AC7 без отдельного регресс-теста на фикстуре, компенсировано чтением + зелёным корпусом) — статус не изменился, дельта его не затрагивает. - отбор смоков по дельте r1 (какие прогнаны, какие пропущены и почему) — не переоценивался: дельта r2 не добавляет новых потребителей резолвера. ## Что проверено и корректно (эта сессия) - Ключ `_climateCache` теперь включает `ex` — ссылку на хранимый массив `exclude_integrations`; проверено, что `_settings` — геттер (`this._serverCfg?.settings || {}`), а `_saveDiscoveryFilters` всегда создаёт новый объект `settings` через spread при сохранении, поэтому ссылка гарантированно меняется вместе со значением (не идентичность «на случай совпадения», а прямое следствие того, как пишется конфиг). - `roomClimateMap` — единственное место, где строится карта климата; второй вызов (`space-render.ts:300`, статический рендер) не кеширует вообще, пересчитывает каждый раз и уже передавал `excluded` до этой правки — дублирующего источника числа температуры/влажности нет («одно число — один источник» выполняется и после фикса). - Фолбэк `integrationByBinding` вставлен до вычисления `excluded`, порядок не важен для самого исключения, но обязателен для корректности имени; проверено чтением полного контекста (`houseplan-editor-runtime.ts:7620-7655`). - Скриншот `docs/images/06-device-editor.png` открыт визуально (не только по отпечатку): диалог «Device on the plan», без следов сырого `{integration}` или иной поломки рендера; это другой диалог, чем «Фильтры обнаружения»/причины на «Доступны», поэтому изменение его `imageSha256` между `34778d81` и `e4e1e370` не связано с M1/M2 напрямую (визуально контент корректен, дефекта не обнаружено) — частично закрывает пункт «не проверял» из r1, полного пиксельного сравнения со старой версией по-прежнему не делал. - Трейлеры `e4e1e370`: `Issue: #44`, `User-Visible: no` — корректно (правка внутренняя, видимого поведения/контракта не меняет, changelog не требуется). ## Чего не проверял и почему - Полный набор `demo/smoke_*.mjs`, `golden`, `performance_smoke`, `npm run invariants`, `pytest tests_backend` — как и на r1: дельта не трогает геометрию, canvas, стены, толщину, `custom_components/**/*.py`; предрелизный периметр, не гейт этого ревью. - Пиксель-в-пиксель сравнение `06-device-editor.png` со старой версией — открыл текущую версию и убедился в отсутствии дефекта, но не сверял построчно со снимком до `e4e1e370`. - Регресс-тест на M1/M2 не писал (не моя роль — ревьюер не правит код); зафиксировал их отсутствие как Low L1 с решением не блокировать. ## Вердикт **Зелёный.** High: 0, Medium: 0. Обе находки r1 (M1, M2) закрыты по существу и подтверждены воспроизведением на актуальном SHA, не на заявлении автора. Один Low (L1, отсутствие регресс-теста) снят решением ревьюера с записью, не блокирует. Открытых продуктовых вопросов нет. Фактический бюджет циклов код-ревью после этого раунда: **1/4** (этот раунд зелёный и цикл не образует — PROCESS.md §4, #227; потрачен только r1). Заход, использованный для имени документа: **r2**.