Files
houseplan-card/docs/reviews/CODE-REVIEW-267-r1.md
T
2026-08-27 19:31:52 +00:00

257 lines
22 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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 не заводится.