docs: review document for #252

Issue: #252
User-Visible: no
This commit is contained in:
claude[bot]
2026-08-23 08:06:22 +00:00
parent 4edb2bb2eb
commit e383fc2989
+205
View File
@@ -0,0 +1,205 @@
# CODE-REVIEW-252-r1
- Issue: [#252](https://github.com/Matysh/houseplan-card/issues/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-находок нет.