Files
houseplan-card/docs/reviews/CODE-REVIEW-385-r2.md
T
2026-08-30 08:41:44 +00:00

23 KiB
Raw Blame History

CODE-REVIEW-385-r2

  • Issue: https://github.com/Matysh/houseplan-card/issues/385
  • Заход: r2 · блокирующих циклов израсходовано до этого раунда: 0/4
  • Материал: git log --oneline origin/dev..HEAD / git diff origin/dev...HEAD на SHA 2e77fe09a554692bc23bf1f2497c2ae6c8c02059 (branch tip == материал ревью, origin/dev = fbbea475, ветка полностью включает dev — merge-base совпадает с origin/dev)
  • Спека: docs/specs/385-audit-lows.md (ревизия 2, SPEC-REVIEW-385-r2 зелёное)

Почему разбор полный, а не по дельте

Пайплайн привёл ветку к dev до этого ревью: поверх легло 1 незнакомых коду r1 коммит(ов) dev (fbbea475 — ci: name the commit that actually broke golden, класс B, только workflow, не пересекается с диффом задачи). Прежний r1 code-review (зелёный, 564bf419) не смог слиться (конфликт), задача ушла в S6-in-progress на ребейз, и найденный SHA 564bf419 в текущей истории уже недостижим — ветка была перестроена. Согласно §7.2/§2.10 после ребейза это другой код, поэтому разбор — полный: весь диапазон origin/dev...HEAD перечитан заново, каждый AC1–AC6 перепроверен по коду и/или гейтом лично в этом раунде.

Скоуп диффа

Класс A: src/devices.ts, src/houseplan-editor-runtime.ts, custom_components/houseplan/import_export.py. Класс B: scripts/process-gate.mjs, scripts/mutation-gate.mjs (+2 мутанта), test/devices.test.mjs, test/process-gate.test.mjs, tests_backend/test_ha_import_export.py, demo/smoke_value_face_source.mjs. Класс C: docs/CHANGELOG.md, docs/CHANGELOG.ru.md, docs/images/screenshots.json (fingerprint bump), docs/reviews/CODE-REVIEW-385-r1.md (артефакт r1, cherry-pick). Класс D: custom_components/houseplan/frontend/**, dist/** — байт-в-байт совпадают с локальной пересборкой, не рассматриваю отдельно.

Один коммит 223f499e несёт все четыре пункта (а)–(г) с трейлерами Issue: #385 / User-Visible: yes; оба changelog в нём же. Коммит 2e77fe09 (документ r1, User-Visible: no) — публикационный, не продуктовый.

Как проверялось (гейты, лично на этом SHA)

Гейт Команда Результат
typecheck npx tsc --noEmit чисто
unit npm test 1592 pass / 0 fail / 1 skipped
build + 3 копии бандла npm run build && cmp dist/houseplan-card.js custom_components/.../houseplan-card.js; npm run bundle:sync без diff, git status --short пуст после сборки
check-docs node scripts/check-docs.mjs зелёный (7 файлов, 10 внешних ссылок); sourceFingerprint в screenshots.json уже обновлён под текущий src/**
bundle:budget npm run bundle:budget initial View 277 995 / 300 000 Б gzip (запас 22 005 Б) — совпадает по порядку с заявленным автором
no-new-any node scripts/no-new-any.mjs --base origin/dev --head HEAD 23 новых строки в 2 файлах, новых any нет
smoke-select node scripts/smoke-select.mjs --base origin/dev --head HEAD 33 слабые связи по общему имени _markerDialog (НЕОПРЕДЕЛЁННОСТЬ — ни одного прямого совпадения). Прогнал только именованный в AC demo/smoke_value_face_source.mjs — прямое совпадение по факту (это тот самый файл, который дифф меняет). Остальные 32 — общее имя _markerDialog, слабая связь; задача не задевает ничего вне диалога маркера и binding-выбора, прогон остальных не требовался
smoke_value_face_source node demo/smoke_value_face_source.mjs (после bundle:sync) все 16 полей true, включая новые sameBindingKeepsSource, sameVirtualKeepsSource
мутанты (новые, AC1/AC4) node scripts/mutation-gate.mjs --id=same-binding-click-resets-source; --id=release-proof-computed-for-every-commit оба 1/1 пойманы
golden:verify — не прогонял: дифф не меняет рендер/геометрию/стили (только обработчики кликов и данные), подтверждено — imageSha256 во всех сценариях screenshots.json не изменились, поменялся только sourceFingerprint
инварианты модели — не прогонял: дифф не трогает рёбра комнат, layout, marker.space, open_spans, толщину стен
backend pytest python3 -m pytest tests_backend/test_ha_import_export.py -q не завершился штатно: в этом окружении нет .venv-backend/homeassistant/voluptuous — сборка теста падает на import voluptuous as vol ещё на этапе коллекции (не skip, а ImportError). AC5 закрыт чтением кода, не исполнением: см. раздел ниже
py_compile (запасной для backend) python3 -m py_compile custom_components/houseplan/import_export.py tests_backend/test_ha_import_export.py чисто

Разбор по AC (полный)

AC1 (клик по тому же кандидату в списке — no-op для value_source/badge, другой кандидат — сброс как раньше). houseplan-editor-runtime.ts:12305-12325: ранний if (c.value === d.binding) { ...; return; } перед сбросом. Мутант same-binding-click-resets-source (возвращает безусловный сброс) — красный, smoke_value_face_source — зелёный до и после мутации в верном направлении (ловится). Регрессия (клик по другому кандидату всё ещё сбрасывает) — поле bindingResetToAuto/draftSourceExact, оба true. Доказано автотестом, тест умеет падать.

AC2 (то же для «радио virtual» — повторный клик по уже активному virtual-binding не трогает value_source/badge). Реализовано симметрично AC1 в houseplan-editor-runtime.ts:12252-12260: if (d.binding === 'virtual') { ...; return; }. Смок добавил поле sameVirtualKeepsSource (demo/smoke_value_face_source.mjs:176-183) и оно зелёное.

Но это доказательство ложное — проверено исполнением, а не только чтением. Я вручную заменил условие if (d.binding === 'virtual') на if (false) (эквивалент отсутствия фикса), пересобрал бандл (bundle:sync) и перезапустил smoke_value_face_source.mjs: все 16 полей, включая sameVirtualKeepsSource, остались true — мутация не поймана (проверено также отдельным вызовом page.evaluate в реальном Chromium этого проекта: клик по уже отмеченному <input type="radio"> не порождает событие change вовсе, count === 0 после двух .click() подряд). Причина структурная, не флак: в _markerDialog инвариант bindingMode === 'virtual' ⟺ binding === 'virtual' держится во всех точках записи состояния (:7657, :7730, :12262, :12278 — при уходе в bindingMode: 'ha' binding синхронно обнуляется, если был 'virtual'). Значит checked радиокнопки всегда synced с d.binding === 'virtual', а клик по уже отмеченной радиокнопке в браузере не эмитит change — ветка if (d.binding === 'virtual') в обработчике не может исполниться ни при каком реальном пользовательском клике: это мёртвый код, а sameVirtualKeepsSource истинно независимо от того, есть guard или нет.

Само поведение при этом корректно (сброса на «virtual» из «virtual» никогда не было и не может быть — по той же причине, по которой guard недостижим), поэтому пользовательского дефекта нет. Дефект — в доказательстве: спека («Контракт (а)», раздел «Риски») прямо предписала чинить и радио-ветку с условием d.binding === 'virtual', а хендофф автора заявил AC2 доказанным смоком — это не так, смок ничего не проверяет для этой ветки.

Medium (в скоупе задачи, не заводится отдельным issue — #202). Ревьюер обязан либо получить тест, который умеет падать, либо честную запись «проверено чтением» (§2.7, правило 18) — здесь заявлено первое, а по факту ни то, ни другое: тест не падает, а чтением автор не подтвердил. Правка на выбор автора: (а) убрать недостижимый guard и строки sameVirtualKeepsSource из смока, заменив комментарий honest-запиской «эта ветка не нуждается в guard — see AC2 review», либо (б) оставить guard как документирующую защиту-на-будущее, но заменить claim AC2 в спеке/хендоффе на «проверено чтением: ветка недостижима через настоящий клик» вместо ложной ссылки на смок. Блокирующих последствий для пользователя нет — находка про честность доказательства AC, а не про баг.

AC3 (rewriteMarkerControlReferences не сажает value_badge/ value_source как undefined). src/devices.ts:889-898: условные спреды. Юнит test/devices.test.mjs (новый тест) проверяет 'value_badge' in rewritten === false и 'value_source' in rewritten === false на голом маркере, и что существующий value_source по-прежнему переписывается. npm test зелёный. Доказано автотестом, падение проверено логически: без условного спреда старый код писал бы ключ безусловно ({ ...marker, controls, value_badge: valueBadge, value_source: valueSource }), что провалило бы 'value_badge' in rewritten === false — тест умеет падать по построению (свойство in детектирует именно ключ с undefined).

AC4 (дорогая проверка гейта считается только для релизных коммитов, предикат — тот же, что в makeCommit). scripts/process-gate.mjs:109-121 выносит isReleaseCommit(subject, one) целиком (оба дизъюнкта, включая Release:-трейлер) — используется и в makeCommit (:147), и в parseRecords (:169-172). Юнит со шпионом (test/process-gate.test.mjs, новый тест) гоняет 3 коммита (обычный / бета-приёмка с Release:-трейлером / стабильный релиз по subject) и проверяет: isRelease = [false, true, true], вычислитель вызван только для двух релизных (calls содержит ровно их SHA), у нерелизного releaseSourceViolations === null. Мутант release-proof-computed-for-every-commit (возвращает безусловный вызов) — красный, node --test test/process-gate.test.mjs — зелёный. Доказано автотестом, тест умеет падать. Ровно закрывает M1 из SPEC-REVIEW-385-r1 (узкий предикат считал бы бета-приёмку нерелизной и ронял бы pre-push/CI ложно).

AC5 (парная нейтрализация value_badge/value_source при экспорте, dropped_marker_links == 2). custom_components/houseplan/import_export.py:504-527 — логика не менялась относительно dev (это тот же код, что уже был принят в #378/#379); диф добавляет только объясняющий комментарий (:504-511) и новый парный тест tests_backend/test_ha_import_export.py: test_issue_385_space_export_drops_badge_and_value_face_links_together. Прочитан построчно: маркер с внешним value_badge.source.ref и внешним value_source.ref — оба ветвления (:512-520 бейдж, :521-527 value_source) исполняются последовательно на одном и том же marker, каждое инкрементит dropped_marker_links; итог == 2 соответствует тесту. badge["enabled"] = False; badge["source"] = None и marker.pop("value_source", None) дают именно те поля/отсутствие ключа, что проверяет тест.

Не смог исполнить: в этом окружении нет .venv-backend/homeassistant — python3 -m pytest tests_backend/test_ha_import_export.py падает на ImportError: No module named 'voluptuous' уже на коллекции, HA-харнесс недоступен (только py_compile, который чист). AC5 закрыт чтением кода, не исполнением — заявляю это честно, а не как «verified». Риск невысокий: логика не менялась с версии, которую r1-код-ревью на 564bf419 уже гонял исполнением (pytest tests_backend/ 446 passed) в среде с харнессом, а единственная новая часть — сам тест, чья корректность проверена построчным сравнением с кодом выше.

AC6 (полный гейт зелёный, бюджет ≈ 0). См. таблицу гейтов — все зелёные, бюджет 277 995 Б (запас 22 005 Б, дельта от 277 979 Б у автора — 16 Б, в пределах шума версии Rollup/окружения).

Закрытие раунда r1

Документ r1: docs/reviews/CODE-REVIEW-385-r1.md (в дереве ветки, cherry-pick коммитом 2e77fe09). Вердикт r1: зелёный, 564bf419, High 0 / Medium 0, одна находка L1.

Находка r1 Чем закрыта Где видно
L1 (Low): вспомогательный one(name) в process-gate.mjs продублирован двумя реализациями (makeCommit через matchAll, parseRecords через match) — риск будущего дрейфа Не закрыта, оставлена как есть — ревьюер r1 явно передал это на усмотрение автора вне скоупа AC4, не блокирует scripts/process-gate.mjs:129-130 (all/one через matchAll) и :166 (локальный one через match) — оба выражения по-прежнему разные текстово, поведенчески идентичны, подтверждено новым юнитом AC4 (сверял лично)

r1 не содержал High/Medium — только это одно Low, поэтому таблица короткая: r1 не отправлял задачу на правки (зелёный вердикт цикла не расходует, #227), и переделывать было нечего — работу вернул только неудавшийся merge.

Унаследовано из r1 — не переоценивается, потому что переоценено с нуля

Формально это полный, а не дельта-разбор (см. раздел выше), поэтому строгого «наследования без проверки» здесь нет: AC1, AC3, AC4, AC5, AC6 перечитаны и перепроверены гейтами заново в этом раунде на текущем SHA, а не приняты на слово из r1. Единственное, что действительно наследуется без повторного прогона, — сам факт, что r1 однажды провёл pytest tests_backend/ целиком в среде с HA-харнессом и получил 446 passed / 1 skipped / 1 error (ошибка — известный флак test_ha_upload.py::test_upload_ok, не связан с диффом): это использовано выше как historical подтверждение для AC5 в дополнение к чтению кода, а не как замена сегодняшней проверки. Документ: r1, SHA 564bf419 (недостижим в текущей истории после ребейза, но зафиксирован в docs/reviews/CODE-REVIEW-385-r1.md).

Что проверено и корректно

  • Единственный коммит несёт оба трейлера, User-Visible: yes и правки в оба changelog в нём же.
  • Изменение видимого поведения ограничено пунктом (а); (б)–(г) действительно не меняют вывод для пользователя — подтверждено чтением и тестами.
  • Третьего места сброса value_source/badge при выборе binding нет: grep bindingMode:/binding: по всему файлу даёт ровно 4 точки записи (:7556 инбокс, :7657/:7730 открытие диалога, :12262/:12278/:12314 переключатели) — все учтены, инвариант bindingMode==='virtual' ⟺ binding==='virtual' держится всюду.
  • Одно число — один источник: value_source/badge не создают дублирующего отображения величины, эта задача не вводит новых видимых чисел.
  • Никакой новой геометрии, i18n, миграции конфига, touch-контракта не затронуто — согласуется со спекой.

Чего не проверял и почему

  • Полный pytest tests_backend (HA-харнесс) — недоступен в этом окружении (.venv-backend отсутствует, homeassistant/voluptuous не установлены). Заменено чтением кода + py_compile + опорой на прежний прогон r1 (см. «Унаследовано»). Это честно называемый пробел, не «verified».
  • golden:verify — дифф не меняет рендер (подтверждено неизменными imageSha256 в screenshots.json), пропуск обоснован §8.
  • Инварианты модели (scripts/model-invariants.mjs) — дифф не трогает геометрию/layout/marker.space/toлщину, не запускал.
  • 31 из 33 «слабых» смоков от smoke-select.mjs (общее имя _markerDialog) — не прогонял: диф не касается ничего вне логики binding-выбора и парных полей value_source/badge, которые целиком покрыты прогнанным smoke_value_face_source.mjs.
  • Полный mutation-gate.mjs — прогонял только два новых мутанта (--id=), не весь набор: он для этого диффа не требуется целиком (это предрелизный/тяжёлый гейт), плюс полный прогон в этой песочнице падает на несвязанном шаге backend-test-guard.mjs (No module named pytest) до установки pytest — окружение, не регрессия диффа.
  • performance_smoke — в AC не назван, дифф не касается чувствительных к перфу путей рендера (только обработчики кликов и статическая проверка release-коммитов), не запускал.

Вердикт

Жёлтый. Единственная находка — Medium (AC2, см. выше), в скоупе задачи, не блокирует пользователя, но нарушает дисциплину «тест умеет падать» для одного из шести AC и не позволяет мне подтвердить его честным «проверено чтением» вместо ложной ссылки на смок, которую несёт хендофф. High нет. Правка — на выбор автора (убрать недостижимый guard либо честно перезаявить способ доказательства), повторный цикл код-ревью обязателен (правило: правка по замечанию может задеть AC, который уже сочли выполненным, — перепроверяю прицельно AC1/AC2 в r3).