# CODE-REVIEW-267-r1 - **Issue:** https://github.com/Matysh/houseplan-card/issues/267 - **Этап:** код-ревью (PROCESS.md §2.7) - **Заход:** r1 · блокирующих циклов израсходовано 0 из 4 (первый заход) - **Материал:** ветка `issue/267-device-presentation-table`, `git rev-parse HEAD` = `6efc315ba62e9b62ad5d95bbd0d2bdecb7d967ba` (совпадает с SHA, заявленным в хендоффе), база `origin/dev@2294d46f` - **Вердикт: красный** ## Скоуп ревью Первый заход по этапу код-ревью — разбор полный (ТЗ уже прошло отдельное зелёное ревью r1, `docs/reviews/SPEC-REVIEW-267-r1.md`). Проверялись AC1–AC10 из `docs/specs/267-device-presentation-decision-table.md`, соответствие `docs/DEVICE-PRESENTATION.md` ↔ `test/fixtures/device-presentation-decisions.mjs` ↔ `scripts/mutation-gate.mjs`, отсутствие второго resolver/раскрытия raw HA state в renderer, refactor-only контракт (AC8) и штатные гейты. ## Как проверялось | Что | Команда | Результат | |---|---|---| | Типы | `npx tsc --noEmit` | зелёный | | Юнит-тесты | `npm test` | 1379 тестов, 1378 passed, 0 failed, 1 skipped | | Сборка + сверка бандлов | `npm run build && npm run bundle:sync` + `sha256sum` трёх копий | все три идентичны, `bad2c56e89bc83276476e596e0b85d5fea909874588d0645218cd4afcf714202` — совпадает с хендоффом | | Документация | `node scripts/check-docs.mjs` | `Documentation checks passed (7 files, 10 external links)` | | Провенанс/статус issue | `node scripts/process-gate.mjs --base origin/dev --head HEAD --issues` | `гейт пройден, предупреждений 0` | | Реестр мутантов, структурная сверка | `node scripts/mutation-gate.mjs --check` | все анкоры на месте (это только текстовая проверка наличия строки-якоря, не запуск мутанта — см. находку High-1) | | Выборка смоков | `node scripts/smoke-select.mjs --base origin/dev --head HEAD` | прямое совпадение: `smoke_controls.mjs` (← `DevItem`), `smoke_wireless_controller_parity.mjs` (← `controllerAvailability`) | | Названные смоки | `node demo/smoke_controls.mjs`, `node demo/smoke_wireless_controller_parity.mjs`, `node demo/smoke_device_icon_design.mjs` | все три `OK`, все проверки `true` | | Golden (полный, не выборочно — диф трогает рендер-путь `device-presentation.ts`) | `npm run golden:verify` (Linux, тот же движок, что CI) | **130/130 passed**, ни один сценарий не менялся, включая `device-icon-state-table-{light,dark}`, `device-text-shell-long-*`, `device-value-badge-positions-dark` | | Targeted mutation-gate — реально применены патчи и перепройден guard | `node scripts/mutation-gate.mjs --id=<каждый из 12 новых/изменённых ID>` (индивидуально) | все 12 «поймано 1 из 1» — **но см. находку High-1**: «поймано» не равно «доказывает заявленные ряды» | | Инварианты геометрии/модели | не прогонялись | diff не трогает геометрию/`layout`/толщину стен — неприменимо | | Backend | не прогонялся | diff не трогает `custom_components/**/*.py` — неприменимо | | Performance-профиль | не прогонялся | AC9 не называет числовой бюджет; проверено чтением кода (см. ниже) | Полный набор golden я прогнал не выборочно, а целиком, потому что diff меняет сам путь построения `visual`/`face`/pulse на каждом маркере — это ровно тот случай «может изменить видимый результат», который делает выборку неоправданной экономией. ## Находки ### High-1 — три из сорока четырёх рядов не имеют реальной мутационной защиты, вопреки AC5 **Файлы:** `scripts/mutation-gate.mjs` (мутант `device-presentation-policy-lifecycle`), `test/fixtures/device-presentation-decisions.mjs` (строки L04, L05, L06), `src/device-presentation-policy.ts:83-98`. AC5 требует: «У каждого ряда есть существующий mutation ID; удаление/ перестановка соответствующей production policy красит focused guard» (доказательство: mutation registry contract + targeted mutation-gate runs). §9 ТЗ разрешает нескольким рядам ссылаться на один mutant **только если focused guard проверяет каждый их row ID** — то есть патч должен реально задевать код каждого сославшегося ряда. Ряды L04 (`user-hidden`), L05 (`user-hidden preview`) и L06 (`orphaned/unverified`) в fixture ссылаются на тот же mutant ID `device-presentation-policy-lifecycle`, что и L02/L03 (`ha_disabled`). Но зарегистрированный патч этого мутанта — ```js find: " if (input.bindingLifecycle === 'ha_disabled') {\n effectiveHidden = true;", replace: " if (input.bindingLifecycle === 'ha_disabled') {\n effectiveHidden = false;", ``` — текстово находится **только** внутри ветки `ha_disabled` (`src/device-presentation-policy.ts:84-86`) и физически не может задеть соседние `else if` ветки `userHidden`/`orphaned`/`unverified` (`device-presentation-policy.ts:87-98`). Это не вопрос трактовки: patch — точная подстрока, `find`/`replace` работают по exact match. **Воспроизведение:** ``` grep -n "input.userHidden\|bindingLifecycle === 'orphaned'\|bindingLifecycle === 'unverified'" scripts/mutation-gate.mjs # пусто — ни один зарегистрированный мутант во всём файле не патчит эти ветки ``` Я также убедился практически: временно заменил весь блок `userHidden`/`orphaned`/`unverified` (строки 87–98) на один `else`, прогнал `npx tsc -p tsconfig.test.json && node scripts/fix-test-build.mjs && node --test --test-name-pattern="every documented decision row" test/device-presentation-policy.test.mjs` — тест красный (ожидаемо, это ловит обычный `npm test`, не мутация). Затем восстановил файл и убедился, что дерево чистое (`git status` — чисто). Ключевой факт для находки не в этом эксперименте, а в grep выше: **ни один зарегистрированный `--id=` мутант** физически не способен воспроизвести такое повреждение — при `node scripts/mutation-gate.mjs --id=device-presentation-policy-lifecycle` патчится только строка `ha_disabled`, и это единственное, что когда-либо проверяется под этим ID. Практическое следствие: `npm test` действительно поймает грубую поломку (потому что ассерты по decisionId специфичны), но формальная гарантия «у каждого ряда есть mutant, который его целенаправленно ломает» — то самое, ради чего в ТЗ есть отдельный §9 — для L04/L05/L06 не выполнена. Реестр проходит `--check` только потому, что `--check` — статическая проверка существования строки-якоря в файле (`scripts/mutation-gate.mjs:3385-3401`), а не семантическая проверка соответствия ряду. **Почему это блокирует, а не Low:** AC5 — один из десяти явно пронумерованных, машинно проверяемых критериев приёмки этой задачи, и его единственный смысл — не дать будущей правке одной из этих трёх строк остаться незамеченной специально предназначенным для этого механизмом. Три ряда из сорока четырёх (L04, L05, L06 — то есть ровно вся ветка user-hidden/design preview и весь orphaned/unverified) — это не косметика поблизости от AC, а не выполненная часть самого AC5. **Что нужно для исправления:** отдельный мутант (или несколько), патчащий реально ветки `userHidden`/`!designPreview`, `userHidden` (preview-исключение) и `orphaned`/`unverified`, привязанный к L04/L05/L06 в fixture вместо общего `device-presentation-policy-lifecycle`. ### Medium-1 (в скоупе) — `unverified` в `BindingPresentationLifecycle` недостижим ни в одном реальном сценарии **Файлы:** `src/devices.ts:1193,1225` (не менялись в этом диффе, но определяют достижимость), `src/device-presentation-policy.ts:94-95`, `src/device-presentation.ts:588`. `docs/DEVICE-PRESENTATION.md`, ряд L06, документирует один результат для двух входов — «orphaned/unverified» — и заявляет для обоих «доказательство: `device-presentation-policy-lifecycle`; `presentation-row-contract`». Фактически `src/devices.ts` **фильтрует** `bindingStatus.kind === 'unverified'` через `continue` в обеих ветках построения explicit-маркеров (`kind === 'device'` и `kind === 'entity'`, строки 1193 и 1225) — то есть `DevItem` с таким `bindingStatus` никогда не строится и никогда не попадает в `resolveDevicePresentation()` из реального прогона карты. Это подтверждается и тем, как устроен сам тест: rowRunner `L06` (`test/device-presentation-policy.test.mjs:136-143`) вызывает **только** `resolveDevicePresentationPolicy()` напрямую с искусственным `bindingLifecycle: 'unverified'`, а не `resolveDevicePresentation()` с настоящим `DevItem` — в отличие от A07 (`test/device-presentation-policy.test.mjs:338-359`), которая именно так доказывает `orphaned` через `resolveDevicePresentation()` с реальным `bindingStatus: {kind:'orphaned', ...}`. Для `unverified` такого прогона нет нигде в диффе, и не может быть: `devices.ts` не оставляет для него ни одного конструирующего DevItem пути (проверил все места создания `DevItem` в файле — `grep -n "bindingStatus" src/devices.ts`). Итог: ветка `unverified` в `device-presentation-policy.ts` — код, который реальный HA-снимок никогда не активирует. Он не ломает поведение (мёртвая ветка безопасна), но AC4 обещает «каждый fixture вызывает production resolver», а здесь для половины ряда L06 это структурно невозможно доказать, и задокументированное решение `lifecycle.unverified_diagnostic` описывает поведение, которого этот refactor не может произвести на реальных данных. **Решение по месту:** либо явно пометить `unverified` как forward-looking допущение с отдельной пометкой «недостижимо до правки `devices.ts`» в §20 ТЗ и в самом документе (а не как рабочий, «доказанный» ряд), либо убрать `unverified` из типа и оставить его на попечении будущей задачи, которая действительно даст ему путь до resolver — в текущем виде утверждение документа не соответствует продукту. ### Low-1 — правило «неизвестный decision ID ломает тест» (ТЗ §8, п.5) не реализовано **Файл:** `src/device-presentation.ts:177-178` (`EMPTY_SOURCES.decisionIds`). Сверил все строковые литералы вида `'xxx.yyy'` в `device-presentation.ts`/`device-presentation-policy.ts` построчно против `docs/DEVICE-PRESENTATION.md`. Один — `source.skipped_static_fast_path` (возвращается реальной веткой `staticIcon && options.sourceDetails === false`) — не встречается ни в одной строке документа и ни в одной fixture-записи. Контракт-тест (`test/device-presentation-policy.test.mjs:93-107`) проверяет только направление «документ/fixture → существующий мутант», а не обратное «каждый производимый кодом decisionId документирован» — удаление, переименование или опечатка в этой строке ничего не сломает. Остальные недокументированные ID (`lifecycle.active`, `face.dynamic`, `content.icon`, `diagnostics.base_icon`, `diagnostics.metrics_suppressed`, `diagnostics.vacuum_static`, `availability.source`, `activity.pulse_eligible`, `face.hidden`) — это принятый по духу документа паттерн «default/else без собственного ряда» (те же самые токены проверяются отдельными assert'ами в том же тестовом файле, просто не через 44-рядную матрицу), не считаю их находкой. **Решение ревьюера:** снимается без правки в этом раунде — не порождает неверного пользовательского поведения, а единственный реальный пример (`source.skipped_static_fast_path`) не участвует ни в одной проверке AC. Если исправление High-1 потребует трогать `mutation-gate.mjs`/fixture в этом же раунде, было бы дёшево добавить и эту строку в документ, но отдельно не блокирует. ## Что проверено и корректно - **AC1** (каноническая таблица) — `docs/DEVICE-PRESENTATION.md` содержит все 44 ряда с visible result/interactivity/evidence, терминология совпадает с структурой `docs/USER-GUIDE.ru.md` §12 (device display modes), связи с issue и ARCHITECTURE.md на месте. - **AC2** (один pure policy owner) — прочитан диф `device-presentation.ts` целиком: старые inline-условия (`effectiveHidden`, `visual` override цепочка, `explanationReason`) удалены и заменены вызовом `resolveDevicePresentationPolicy()`/`resolvePresentationReason()`; renderer (`device-face.ts`, `houseplan-card.ts`, `space-card.ts`, `space-render.ts`) не тронут в этом диффе и продолжает читать готовый `ResolvedDevicePresentation`. - **AC3** (source decisions названы) — `resolvePresentationSources()` теперь возвращает `decisionIds` для каждой ветки `cover/controls/light/device_role/ primary/none` плюс `critical_sibling`/`filtered_saved_controls`; проверено тестом `source decision trace names every source winner...` (`test/device-presentation-policy.test.mjs:475-549`) — прогнан, зелёный. - **AC6** (controller/target regressions) — переиспользованы существующие мутанты `controller-availability-follows-target` (обновлён под новый файл, прогнан лично — `поймано 1 из 1`) и `controller-diagnostics-do-not-prove-online`, `wireless-controller-loses-filtered-target-role`, `wireless-controller-preview-drops-sibling-markers`; `smoke_wireless_controller_parity.mjs` прогнан лично, `previewMatchesPlan: true`. - **AC7** (surface parity) — тот же смок подтверждает `planDomAgrees`, `previewHonoursFullMarkerRoster`, `previewTextIsNotUnavailable`. - **AC8** (refactor-only pixel/config contract) — `npm run golden:verify` прогнан целиком на Linux (том же движке, что CI): **130/130 passed**, ни один baseline не менялся; три копии бандла идентичны по SHA-256; diff не трогает i18n/config/CSS. - **AC9** (fast path) — прочитано построчно: `controllerAvailability: controllerFace ? controllerAvailability(hass, d) : 'available'` сохраняет тот же гейт, что был в коде до рефакторинга (`controllerFace ? controllerAvailability(hass, d) : ...` — идентичное условие, только перенесённое). Обычный маркер не получает дополнительного вызова `resolvedLightSources()` — проверено чтением, не отдельным call-counter тестом (ТЗ допускает «code review» как альтернативное доказательство AC9). - **AC10** (штатные гейты) — все прогнаны лично, см. таблицу выше; расхождений с хендоффом нет, кроме одного теста (1378 passed/1 skipped у меня против заявленных автором 1377/2 — расхождение на один тест, не влияет на результат «0 failed», не стал разбирать отдельно). - Трейлеры (`Issue: #267`, `User-Visible: no`) на всех трёх коммитах, `User-Visible: no` корректно — CHANGELOG не тронут, поведения не меняется. `git rev-parse HEAD` = `6efc315b…`, совпадает с заявленным материалом. - `process-gate.mjs --issues` зелёный, статус issue (`S7-code-review`) корректен для проверки. ## Чего не проверял - Инварианты геометрии/модели (`npm run invariants`) и backend-тесты (`pytest tests_backend`) — diff не трогает геометрию, `layout`, толщину стен или `custom_components/**/*.py`; неприменимо по самому содержанию диффа. - Полный набор `demo/smoke_*.mjs` (192 файла) — не запускал; ограничился выборкой `smoke-select.mjs` (`smoke_controls.mjs`, `smoke_wireless_controller_parity.mjs`) плюс заявленным автором `smoke_device_icon_design.mjs`, поскольку diff не задевает механику stroke, touch, wall junctions и прочих не относящихся к device presentation подсистем. Полный набор — предрелизный гейт. - Реальную нагрузочную/perf-метрику AC9 (числового бюджета в AC нет; оценка сделана чтением, как явно разрешает ТЗ). - Ручное открытие демо-стенда в браузере — не делал; полагаюсь на golden (полный прогон, 130/130) и три названных смока как эквивалентное подтверждение, что визуальный/DOM результат не изменился. - L02/L03/L04/L05 lifecycle-ветки как таковые я НЕ считаю недоказанными функционально (обычный `npm test` их бы поймал) — недоказанность именно специфическим мутационным механизмом, который AC5 требует явно; это и есть находка High-1, а не «всё сломано». ## Итог Один блокирующий (High) и два не блокирующих (Medium-в-скоупе, Low) находки. Medium-1 и Low-1 — не «вне скоупа» issue, обе внутри собственного нового модуля/документа этой задачи, чинятся в этом же issue вместе с High-1, отдельный issue не заводится.