Files
houseplan-card/docs/reviews/CODE-REVIEW-252-r1.md
T
2026-08-23 08:06:22 +00:00

18 KiB
Raw Blame History

CODE-REVIEW-252-r1

  • Issue: #252 — «Отчёт "Оптимизировать" перечисляет внутренние id вместо того, чтобы починить или сказать, что делать»
  • Этап: code (PROCESS.md §2.7)
  • Заход: r1 · блокирующих циклов израсходовано 0 из 4
  • ТЗ: docs/specs/252-optimize-orphan-layout-report.md, зелёное ревью SPEC-REVIEW-252-r2
  • Диапазон: origin/dev..HEAD, коммиты 883a95a…df51542; поведенческий коммит — 8fd8ccb (Issue: #252, User-Visible: yes)
  • Вердикт: зелёный · High: 0 · Medium: 0 · Low: 1 (снято с записью)

Скоуп

Задача — J6 (docs/SCOPE.md): «Keep the plan true as the home evolves». Optimize теперь классифицирует layout-позиции с мёртвым space по владельцу (комната, устройство/маркер, групповая метка, неизвестный namespace), автоматически удаляет только доказанно отсутствующих владельцев, сохраняет живых и непроверенных, и убирает внутренние id из основного текста отчёта в свёрнутые «Подробности». Изменение не расширяет скоуп за рамки ТЗ: новых персистентных полей, миграций или новых экранов нет; config/HA registry не модифицируются, изменяется только layout-запись. Правило SCOPE.md «никогда не удалять файл пользователя по догадке» не нарушено — здесь удаляются не файлы, а координатные записи, и только при доказанном отсутствии владельца, а не по инференции.

Как проверялось

Дешёвые гейты (прогнаны в этом раунде, код изменился существенно):

Гейт Результат
npx tsc --noEmit зелёный, без вывода
npm test зелёный: 1124/1124 (в моём прогоне 0 skipped; автор указывал 1123/1 skipped — расхождение не воспроизвелось, не блокирует)
npm run build + сверка трёх копий бандла зелёный; SHA-256 dist/houseplan-card.js, custom_components/houseplan/frontend/houseplan-card.js, demo/srv/assets/houseplan-card.js идентичны и равны 341ef5e3… из отчёта автора
node scripts/check-docs.mjs зелёный: «7 files, 10 external links» (diff трогает src/**)

Инварианты модели (npm run invariants): в этой версии репозитория такого npm-скрипта не существует (package.json его не содержит, #254 ещё не слит в dev) — гейт неприменим, не пропущен молча.

python -m pytest tests_backend: не запускался — diff не трогает custom_components/**/*.py (только сгенерированный JS-бандл в той же папке).

Smoke-подбор — node scripts/smoke-select.mjs --base origin/dev --head HEAD:

Прямое совпадение (6):
  demo/smoke_orphan_space_references.mjs   ← _alignDialog, _openAlignDialog, _toggleOptimizeLivePositions
  demo/smoke_grid_snap.mjs                 ← _alignDialog, _openAlignDialog
  demo/smoke_optimize_coordinate_canonicalization.mjs ← _alignDialog, _openAlignDialog
  demo/smoke_optimize_geometry_preflight.mjs ← _alignDialog, _openAlignDialog
  demo/smoke_optimize_micro_interval.mjs   ← _alignDialog, _openAlignDialog
  demo/smoke_warm_dialogs.mjs              ← _alignDialog, _openAlignDialog

Совпадает с выбором автора. Все 6 прогнаны локально (headless Chromium) — все OK, включая smoke_orphan_space_references.mjs, который прямо проверяет отсутствие сырых id (opaque_owner, gone) в основном тексте, aria-pressed на кнопке opt-in и текст «выбраны для удаления» без противоречивого «будут сохранены» рядом (регресс, который сам автор поймал и закрыл в df51542). Остальные 165 смоков не запускались — тема (grid/geometry preflight/micro-interval/warm-dialogs) действительно про общий диалог Optimize, других сильных или слабых связей инструмент не назвал.

node scripts/mutation-gate.mjs --check — полный прогон, 136/136 ok, включая оба новых мутанта:

  • orphan-cleanup-partial-registry-deletes — ловит мутацию, при которой rosterAuthoritative захардкожен в true (неполный registry перестаёт быть доказательством отсутствия);
  • orphan-cleanup-proven-owners-kept — ловит мутацию, которая перестаёт реально удалять absent-позицию из layout, оставляя только счётчик.

npm run golden:verify — политика репозитория (demo/golden/policy.mjs) запрещает частичный --scenario=, разрешён только полный прогон. Прогнал полный матрикс (97 сценариев, ~2 минуты, Chromium 151.0.7922.34 — совпадает с зафиксированным в baselines-index.json): 97/97 passed, включая optimize-orphan-references-dark-en и optimize-orphan-references-light-ru. Дополнительно сверил git diff по baselines-index.json — единственные изменённые/добавленные хэши это ровно эти два сценария, что подтверждает запись автора «изменены только две ожидаемые #252-сцены, 95 остальных возвращены к прежним байтам» независимо от CI.

Performance-профили не запускались — не названы в AC8 явно как обязательные для цикла ревью (AC8 требует их только на pre-release гейте), путь maintenance-only и не в render loop.

Разбор по AC

  • AC1 (доказанно отсутствующие room label/device/group удаляются, категории без id, сумма верна, второй Optimize — no-op): подтверждено unit-фикстурой issue 252 removes only proven room, device and group orphans and is idempotent (32/3/2, ровно числа из issue) и мутантом orphan-cleanup-proven-owners-kept; идемпотентность — вторым вызовом repairSpaceReferences в том же тесте (again.report.*Removed === 0).
  • AC2 (живой config-marker/HA device/lg_ entity в мёртвом пространстве не удаляется по умолчанию, называется именем, удаляется только после opt-in + Apply): unit issue 252 preserves live owners by default and removes only them after explicit opt-in покрывает все три категории живых владельцев; smoke проверяет UI-цепочку preview → toggle → Apply → Undo сквозно.
  • AC3 (неавторитетный/ограниченный registry, неизвестный namespace, объект, пропавший только из _devices, — сохраняются, разрушительной кнопки нет): unit issue 252 fails closed for limited registry and unknown future layout owners + мутант orphan-cleanup-partial-registry-deletes. Отдельно прочитан код _optimizeReferenceContext: liveDeviceIds/liveEntityIds строятся из полного registry.devices/registry.entities (+ hass.states для entity), а не из отфильтрованного _devices, значит временный уход объекта из render-снапшота (area-фильтр и т.п.) не превращается в удаление — проверено чтением, не исполнением, отдельного unit на этот конкретный канал фильтрации нет, но принцип (_devices только для имён, не как доказательство) структурно закреплён и покрыт мутационным гейтом на противоположный канал (полный registry).
  • AC4 (id не в основном тексте; нет старых терминов «неразрешённых позиций»/«вложенных сопоставлений»): test/i18n.test.mjs явно проверяет отсутствие this._t('gs.optimize_reference_warning' в исходнике и отсутствие технических слов в новых RU/EN строках; smoke проверяет реальный DOM (сырые id есть только в закрытом <details>).
  • AC5 (clean config/layout deep-equal; #244 remap/detach не регрессирует; unknown fields сохраняются; #248 round-trip): существующие #244-тесты адаптированы, а не удалены (например, «detaches a live marker but preserves its stale coordinates until explicit cleanup» — намеренная смена поведения ровно в сторону AC2, не регресс); «large valid reference graph stays unchanged» не тронут и зелёный.
  • AC6 (Cancel не пишет; Apply — exact preview; Undo восстанавливает всё; no-op не трогает backup): подтверждено smoke (previewOffersOneApplyWithoutWriting, cancelWritesNothing, applyUsesExactAtomicEndpoint, undoRestoresDeadRefs — включая восстановление rl_removed_room и removed-marker, не только старого orphan-кейса).
  • AC7 (keyboard/screen reader, <details> свёрнут по умолчанию, фокус не теряется, touch target): <details> без open — свёрнут; .optimize-cleanup — настоящая <button type="button"> с min-height: 44px; aria-pressed и role="status"/role="alert" расставлены по смыслу. Сохранение фокуса на кнопке после пересчёта preview не покрыто отдельным assert на document.activeElement — проверено чтением: кнопка рендерится в одной и той же ветке шаблона независимо от состояния removeLiveMissingPositions (меняются только атрибуты/текст внутри неё), поэтому lit-html переиспользует тот же DOM-узел и не может увести фокус структурно. Не блокирует, но стоит отметить как не доказанное исполнением.
  • AC8 (гейты implementation-цикла зелёные): typecheck/unit/build зелёные лично; targeted smoke и полный golden выполнены выше и тоже зелёные.

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

  • Классификация владельца (src/space-reference-repair.ts:302-360) построена на индексах, собранных один раз (existingRoomIds, activeMarkers, removedMarkers, liveDeviceIds, liveEntityIds, knownDeviceIds) — линейна по layout, соответствует заявленной сложности §12 ТЗ. removed:true маркер — единственное «мгновенное» доказательство отсутствия устройства (соответствует принятому предположению §14.4 ТЗ); unknown namespace всегда unverified независимо от authoritative (соответствует §6.3 «неизвестный ключ: всегда unverified»).
  • Классификация не мутирует config — удаляется только layout[key]; сумма orphan*Removed корректно входит и в toast (gs.align_done), и в maintenance count диалога.
  • plan-optimizer.ts: новые счётчики зануляются в «no persisted change» ветке наравне со старыми (spaceRefsRemapped и т.п.), а не персонально, что сохраняет прежний инвариант «нулевые счётчики на чистом конфиге» (AC5) без дублирования логики.
  • Документация: docs/CANVAS.md:494-497 — старая формулировка заменена (не дополнена) новым доказательным контрактом, как и требовало ТЗ и зелёное SPEC-REVIEW-252-r2; docs/CHANGELOG.md/.ru.md в том же коммите 8fd8ccb явно называют сужение обещания v1.59.0-rc.1; docs/CONFIG-COMPATIBILITY.md, docs/ARCHITECTURE.md, docs/TESTING.md, docs/USER-GUIDE(.ru).md обновлены по списку §13 ТЗ.
  • Baseline-Reviewed golden-коммит (668ed49) соответствует правилу §12 «не принимать golden по частичному артефакту»: явно указано, что просмотрен полный 97-сценарийный Linux-артефакт, изменения ограничены двумя #252-сценами, остальные 95 возвращены к прежним байтам — независимо перепроверено (см. выше).

Находки

Low (снято с записью, не блокирует). src/i18n/en.json/ru.json сохраняют ключ gs.optimize_reference_warning со старой формулировкой («неразрешённых позиций», «вложенных сопоставлений»), хотя код его больше не вызывает (test/i18n.test.mjs прямо проверяет отсутствие вызова). Мёртвый литерал, не влияющий на поведение и не нарушающий AC4 (AC4 — про то, что видит пользователь, а не про наличие неиспользуемого ключа в словаре). Не возвращаю автору: цена держания записи ниже цены повторного цикла ради одной строки в JSON.

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

  • npm run invariants — скрипта нет в этой версии репозитория (#254 не слит); гейт неприменим, а не пропущен.
  • python -m pytest tests_backend — diff не трогает custom_components/**/*.py.
  • Полная браузерная матрица smoke (171 файл за вычетом выбранных 6) — тема не пересекается с изменённым символьным следом (_alignDialog, _openAlignDialog, _toggleOptimizeLivePositions); инструмент подбора не назвал других прямых или слабых связей.
  • Performance-профили (benchmark:*) — не названы в AC8 как обязательные для цикла ревью; путь maintenance-only, не render loop, полный performance-гейт явно отнесён ТЗ (§11) к пред-релизному циклу.
  • Ручное тестирование в браузере (не входит в конвейер ревью) — вместо него использованы браузерные smoke на headless Chromium и полный golden:verify.
  • Фокус-контракт AC7 (кнопка opt-in) — разобран чтением кода/шаблона lit-html, не отдельным исполняемым assert-ом на document.activeElement (см. AC7 выше).

Итог

Реализация точно следует одобренному ТЗ: доказательный, а не эвристический, контракт удаления; живые и непроверенные владельцы по умолчанию сохраняются; внутренние id ушли из основного отчёта в «Подробности»; Preview/Cancel/Apply/ Undo/идемпотентность #248 не регрессируют. Все обязательные для этого этапа гейты (typecheck, unit, build+bundle parity, check-docs, targeted smoke, mutation-gate, полный golden) прогнаны лично и зелёные. Единственная находка — Low, снята с записью. Blocking-находок нет.