Files
houseplan-card/docs/reviews/CODE-REVIEW-265-r1.md
T
2026-08-25 12:05:54 +00:00

304 lines
29 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-265-r1
- Issue: [#265](https://github.com/Matysh/houseplan-card/issues/265) — единый контракт ссылочного шва импорта
- Этап: код-ревью · заход r1 · блокирующих циклов израсходовано 0/4 (первый заход этапа code)
- Ветка: `issue/265-import-seam-contract`
- Материал: `git diff origin/dev...9f54cbab7591ffba17ad3999bedeb70eba685aa9`, 39 файлов, +2990/-958
- Спецификация: `docs/specs/265-import-reference-seam.md` (принята на SPEC-REVIEW r3, зелёный, SHA `26af611a`)
- Ревьюер: свежая сессия, без контекста реализации
## 1. Скоуп
Задача переносит контроль ссылочного шва импорта из трёх разрозненных починок
(#244/#248/#252) в один контракт: канонический (bounded, ≤16 слоёв) lineage-id
для повторных space-import, безопасный cross-generation repair мёртвых
target-ссылок, единая типизированная матрица из §8 ТЗ, неизменяемый
preview/apply candidate с digest, структурированный отчёт и расширенный
`model-invariants.mjs`. Затронуты классы A (`custom_components/houseplan/
import_export.py`, `websocket_api.py`, `src/houseplan-card.ts`,
`src/space-reference-repair.ts`, `src/styles.ts`, `src/i18n/*.json`) и B/C/D
(тесты, скрипты, demo, документация, бандл).
Соответствует J6 «Keep the plan true as the home evolves» из `docs/SCOPE.md` —
чинит накопление служебного мусора при повторном импорте, не открывает новую
UX-поверхность. Импорт/детали остаются desktop admin-editing поверхностью, как
и зафиксировано в ТЗ §1 (View/kiosk не затронуты) — подтверждено чтением: новый
UI живёт целиком внутри `_backupImportDialog` рендера, доступного только из
редактора.
## 2. Как проверялось
| Гейт | Команда | Результат |
|---|---|---|
| Typecheck | `npx tsc --noEmit` | green (прогнан локально) |
| Unit/юнит | `npm test --silent` | green — 1298 тестов, 1297 pass, 1 skip, 0 fail (прогнан локально, совпадает с CI) |
| Build + сверка бандла | `npm run build && cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js` | green — байт-в-байт совпадает после чистой пересборки |
| `check-docs` (diff трогает `src/**`) | `node scripts/check-docs.mjs` | green — 7 файлов, 10 внешних ссылок |
| `model-invariants` (диф трогает ссылочную матрицу) | `npm test` (юнит-часть `test/model-invariants.test.mjs`, включает новые категории #265); CLI-прогон на конкретном экспорте не переисполнял — требует пересборки `test-build/*`, которую уже покрывает `npm test`/CI `frontend` | green по юнитам; независимая проверка через готовый CI-прогон (`frontend` job, SHA `9f54cbab`) — green |
| Смок-выборка | `node scripts/smoke-select.mjs --base origin/dev --head HEAD` | 1 зарегистрированная связь: `demo/smoke_orphan_space_references.mjs` (прямых совпадений нет) |
| Целевые браузерные смоки | CI `smoke` (3 шарда) на SHA `9f54cbab` | green — оба изменённых файла (`smoke_backup_transfer.mjs`, `smoke_orphan_space_references.mjs`) в наборе, оба шарда green |
| `golden:verify` (diff меняет видимый результат import preview) | CI `golden` job на SHA `9f54cbab` (`Validate` run [32844271523](https://github.com/Matysh/houseplan-card/actions/runs/32844271523)) | **red** — см. находку H1 |
| `pytest tests_backend` (правки в Python) | CI `backend` job на SHA `9f54cbab` (полный HA-harness, Linux) — локально недоступно (нет `homeassistant`/`.venv-backend` в этой среде, `test_ha_*.py` тихо пропускаются без него, см. AGENTS.md) | green на CI — каноническое доказательство есть |
| `performance_smoke` | CI job на SHA `9f54cbab` | green (не в AC как обязательный, но CI уже покрыл) |
Все обязательные дешёвые гейты (`typecheck`, `test`, `build`+сверка бандла,
`check-docs`) прогнаны мной лично на этом дереве, а не только заявлены автором.
Тяжёлые гейты (`backend`, `smoke`, `golden`, `performance_smoke`) взяты из
завершённого прогона CI `Validate` на точном заявленном SHA — это тот же канон,
что требует `PROCESS.md` §8 для бета-гейта, и здесь он же служит независимым
подтверждением/опровержением для код-ревью.
## 3. Находки
### H1 (High) — `golden` красный на заявленном SHA для самого нового экрана; заявление автора о зелёном golden не подтверждается
**Где:** сценарий `backup-space-preview-mobile-ru` в `demo/golden/harness.mjs`
(строки, добавленные этим диффом: `repaired_target_refs`, `preserved_unresolved_refs`,
`reference_report` в моке `space`-превью) → `src/houseplan-card.ts:15925-15943` (новый
блок `backupwarn`/`backupdetails`) — не сопровождается обновлённым/принятым
`demo/golden/baselines/backup-space-preview-mobile-ru.png`.
**Воспроизведение:** CI `Validate` на точном SHA `9f54cbab7591ffba17ad3999bedeb70eba685aa9`
(run [32844271523](https://github.com/Matysh/houseplan-card/actions/runs/32844271523)),
job `golden` → шаг «Capture or verify golden matrix» завершается `exit code 1`,
единственная расходящаяся строка:
```
different backup-space-preview-mobile-ru
```
Скачан артефакт `golden-images` этого прогона и сверены `actual/` и `diff/` для
этого сценария: diff-маска (сплошная magenta) покрывает практически весь
диалог — то есть baseline вообще не содержит новый контент, а не отличается на
пиксель. `actual/backup-space-preview-mobile-ru.png` при этом визуально
корректен: показывает ровно то, что требует §10 ТЗ и добавленные в этом же
диффе `src/i18n/ru.json` строки — «Существующих ссылок восстановлено этим
импортом: 3.», блок «Неоднозначные ссылки сохранены без изменений: 1. House
Plan ничего не угадывал и не удалял. После импорта запустите «Оптимизировать
планы»…» и раскрывающийся `▶ Подробности восстановления ссылок`. Логической
ошибки в самом рендере не нашёл — проблема ровно в том, что
`demo/golden/baselines/**` (class D, генерируется) не тронут этим диффом
(`git diff --stat origin/dev...HEAD` не содержит файлов `demo/golden/baselines/**`),
хотя сценарий, привязанный к изменённому экрану, обязан был получить новый
принятый baseline.
**Почему это прошло мимо автора:** в комментарии готовности к ревью автор
пишет «`npm run golden:capture` — все сценарии выполнены без runtime/semantic
failures». `golden:capture` — режим захвата, а не сравнения: он не отказывает,
если существующий baseline не совпадает с новым рендером, он просто
перезаписывает `actual/`. Раз baseline уже существует в репозитории, канонический
режим — `golden:verify` (тот же, что запускает CI при наличии baseline,
`demo/golden/README.md`/`AGENTS.md` §8), и именно он ловит расхождение. Заявленная
проверка технически не могла обнаружить эту находку — она не про то ложное
чувство уверенности, которое запрещено правилом «Verified без названной команды
и её результата не является доказательством»: команда названа верно, но её
режим не давал права на утверждение «зелёный golden», которое в отчёте
фактически прочитывается как «golden:verify».
**Почему это блокирует, а не Low:** §15.1 ТЗ прямо требует для AC10
«reviewed canonical golden import preview» как обязательное доказательство до
S7; на заявленном SHA это доказательство не просто отсутствует — CI по нему
красный. Смежное прошлое (`check-docs` в #230/#234, AGENTS.md §8) уже показало
цену пропуска именно такого шага: не пойманное здесь, оно оставит `dev` с
красным job `golden` до следующей задачи, которая случайно это заметит.
**Как чинится (в скоупе, без нового issue):** пересобрать golden с этого дерева,
прогнать `npm run golden:verify` локально (или взять уже готовый CI-артефакт
этого прогона), просмотреть `diff/backup-space-preview-mobile-ru.png`, принять
через `npm run golden:accept -- --reviewed` на полном Linux CI-артефакте с
трейлерами `Release:`/`Baseline-Reviewed:` в отдельном коммите. Механическая
правка, но обязательная перед повторным заходом.
### M1 (Medium, в скоупе) — нет backend-теста на успешный cross-generation lineage repair (только exact-map и ambiguous-fail покрыты)
**Где:** `tests_backend/test_ha_import_export.py`, раздел новых тестов (строки
1134–1259 в диффе). Матрица доказательств §15.1 ТЗ требует для AC2 «Backend
positive/live/ambiguous/type-mismatch tests», для AC3 — «Parameterized
unit/backend test каждой строки §8».
**Что есть:** `test_issue_244_space_import_repairs_existing_target_refs_with_exact_map`
(не новый, ранее существовавший — это exact-map: `marker["space"] == "ground"`
буквально совпадает с исходным id импортируемого пространства, не lineage);
`test_issue_265_cross_generation_target_repair_fails_closed_when_ambiguous`
(новый, но это отрицательный случай — сохраняет ссылку, потому что есть второй
живой кандидат того же lineage); `test_issue_265_target_marker_links_require_an_imported_light_target`
(тоже отрицательный, про `marker.controls`/light-семантику).
**Чего нет:** ни одного backend-теста, где мёртвая target-ссылка на
*прошлое поколение* (не exact-map, а именно каноническая lineage — например
`space_ground_<hash1>` при живом импорте, создающем `space_ground_<hash2>`, без
конкурирующего живого кандидата) успешно переносится в новое поколение
(`repaired_target_refs > 0` за счёт reason `"lineage"` в `_lineage_resolver`, а
не `"exact"`). Это ровно тот регресс, который сформулирован в теле issue #265
(«создаёт новый `space_f1_<hash2>`, но не переносит ссылку:
`repaired_target_refs == 0`») — то есть заглавный дефект задачи не имеет
положительного backend-доказательства фикса, только фронтенд-версия того же
алгоритма (`test/space-reference-repair.test.mjs`: «issue 265 Optimize resolves
an import-of-import lineage without guessing» — это TypeScript/Optimize сторона,
не Python/import сторона).
Прочитал код `_lineage_resolver`/`_repair_target_space_refs`
(`custom_components/houseplan/import_export.py:100-362`) построчно: логика
выглядит корректной для этого случая (кандидаты строятся из `id_maps` текущего
импорта, при отсутствии живого конкурента с тем же root возвращается
`"lineage"`) — поэтому не блокирую как High, отмечаю как «проверено чтением, не
исполнением» для положительного backend-пути, но это не заменяет обязательное
по ТЗ автотест-доказательство и не даёт «тесту, который умеет падать» для этой
конкретной ветки.
### M2 (Medium, в скоупе) — нет synthetic maximum-size backend теста, требуемого AC9
**Где:** та же матрица §15.1: «AC9 | Synthetic maximum-size backend test,
owner/TTL/limit security tests и code review отсутствия O(n²) lookup».
Owner/TTL/limit тест есть и не новый
(`test_preview_candidate_digest_and_global_memory_cap_are_enforced`), но он не
про размер одного candidate/report, тронутый этим диффом (`_REPORT_EXAMPLE_LIMIT`,
рост `reference_report` пропорционально числу владельцев/ссылок). Отсутствие
O(n²) я проверил чтением: `_lineage_resolver` строит `imported`/`live` словари
за один проход и дальше отвечает за O(1) на обращение; циклы по `spaces` →
`rooms`/`openings`, по `markers`, по `layout` — каждый одного прохода, без
вложенного полного сравнения. Но синтетического теста на реальный большой
конфиг (число owners/refs у предела `MAX_CONFIG_BYTES`) не добавлено — заявлено
в матрице доказательств как обязательное, доказательства по факту нет.
Оба M1/M2 — в скоупе этой же задачи, чинятся автором тут же (issue #202: свой
issue для Medium заводится только вне скоупа).
## 4. Что проверено и признано корректным (по AC)
- **AC1 (плоский lineage).** `canonical_import_root`/`_fresh` в Python
(`import_export.py:809-848`) и `canonicalImportRoot` в TS
(`space-reference-repair.ts:68-83`) реализуют один и тот же bounded-parser (≤16
слоёв, строгий `^<prefix>_(.+)_([0-9a-f]{8})$`). Общий conformance-fixture
`test/fixtures/import-id-lineage.json` покрывает: нулевой слой, один слой, два
слоя, uppercase-хеш (не снимается), чужой namespace (не снимается), unicode
root, 17-кратную обёртку (bounded=true на границе 16). Backend-тест
`test_issue_265_python_lineage_matches_shared_fixture` и frontend-тест «issue
265 Python/TypeScript lineage fixture stays strict and bounded» гоняют один и
тот же fixture через оба языка — параллельная реализация не может тихо
расползтись. `test_issue_265_import_of_import_flattens_every_owned_namespace`
прогоняет реальный import-of-import через `build_space_merge` два раза и
проверяет отсутствие `space_space_`/`room_room_`/`partition_partition_`/
`opening_opening_` в результате — тест умеет падать: на старом коде (`_fresh`
строил stem из `old` без канонизации) второй импорт детерминированно давал
`space_space_f1_..._...`. Принято.
- **AC2 (безопасный cross-generation repair), кроме positive-ветки (см. M1).**
Условия §6.2 ТЗ (мёртвая ссылка, ровно один совместимый живой кандидат,
отсутствие exact live id, отсутствие второго живого кандидата) отображены в
`_lineage_resolver.resolve()` построчно: `live_ids` проверяется первым (reason
`"live"`), `exact_map` — вторым (`"exact"`), затем canonical root против
`imported` с условием `len(candidates) == 1 and not live.get(root)` —
это ровно «единственный кандидат и нет второго живого с тем же root».
Ambiguous-путь и light-target-gate покрыты новыми backend-тестами и проходят
на CI. Локальность владения (matrix-примечание §8: room/partition-ссылка не
переписывается через границу пространств) реализована консервативно —
`room.open_to`/`opening.host` целевых (не импортируемых) пространств никогда
не перезаписываются, только помечаются в `preservedUnresolved`, что корректно
соответствует правилу («существующего независимого пространства нельзя
направлять в новую импортированную копию») — других target-пространств,
которые сами были бы одновременно remap'нуты, в текущей архитектуре
одного-пространства-на-импорт не бывает, поэтому эта строка матрицы §8
безопасно никогда не переходит в фактический remap. Frontend-путь (Optimize)
имеет свой positive-тест («issue 265 Optimize resolves an import-of-import
lineage without guessing») — умеет падать: на старом однослойном regex
`importedRoom`/`importedSpace` (двухслойные lineage-id) не матчились бы вовсе.
- **AC3 (полная матрица).** Каждая строка §8 представлена: `marker.space`,
`marker.room_id`, `vacuum.segment_map`, `marker.controls`, `value_badge.ref`,
`layout` (`rl_`, marker-key, `position.s`), `room.open_to`, `opening.host` — в
коде `_repair_target_space_refs` и в `model-invariants.mjs`
(`marker_room`/`vacuum_room`/`marker_control`/`marker_badge`/`room_open_to`/
`opening_host`). Geometry carriers (`walls[*].key`, `open_spans`) не тронуты
ни в одном из добавленных путей — подтверждено чтением: `_fresh`/lineage
вызываются только для `_IMPORT_ID_NAMESPACES`, wall keys туда не входят.
- **AC4 (точный preview/apply).** `_candidate_digest` считает SHA-256 от
canonical JSON (`sort_keys=True`) по всему материализованному кандидату
(document, policy, revisions, `target_config`, `target_layout`, `details`).
`prepare_apply` (import_export.py:1819-1840) больше не строит merge повторно —
берёт `candidate["target_config"]/["target_layout"]/["details"]` буквально;
`test_issue_265_apply_uses_the_exact_materialized_preview_candidate`
monkeypatch'ит `_fresh` так, чтобы AssertionError бросался при повторном
вызове — тест умеет падать (это была бы старая архитектура: apply вызывал
`build_space_merge` заново). `revalidate_candidate` пересчитывает
`target_config/target_layout/details/digest` заново при каждом вызове
(`import_export.py:1763-1817`) — старое подтверждение обязано быть сброшено:
проверено чтением фронтенда, `src/houseplan-card.ts:15720` и `:15802`
сбрасывают `confirmMissing: false` в обоих местах, где вызывается revalidate
(явный клик и авто-revalidate после конфликта revision). Существующий тест
`test_preview_candidate_digest_and_global_memory_cap_are_enforced` (не новый,
но актуален для нового `_candidate_digest`) подтверждает детекцию мутации
кандидата — прошёл на CI backend.
- **AC5 (lossless unresolved/tombstone).** `_repair_target_space_refs` не
фильтрует `removed: true` маркеры перед репарингом их полей (в отличие от
`model-invariants.mjs`, который их явно игнорирует для invariant-проверки) —
это корректно: обновление внутренних ссылок tombstone-записи не является ни
переименованием, ни удалением самой записи, и держит её консистентной для
будущего повторного добавления. `preserve_once`/`preservedUnresolved`
никогда не удаляют исходные данные — только помечают.
- **AC6 (invariant gate).** Shared fixture parity — см. AC1. Мутанты новых
категорий: `test/model-invariants.test.mjs` тест «#265: полный внутренний
ссылочный граф...» одним вызовом ломает все 6 новых полей и проверяет, что
`checkReferences` возвращает все 6 kind — до этого диффа ни один из них не
существовал в `checkReferences`, то есть тест гарантированно падал на коде
без фикса.
- **AC9 (в части O(n²) и секретов), кроме synthetic-size (M2).** Отсутствие
квадратичной сложности проверено чтением (см. M2). `examples` ограничены
`_REPORT_EXAMPLE_LIMIT = 24` на backend и дополнительно `.slice(0, 8)` на
фронтенде; поля `owner`/`reference` — это plan-internal id, не секреты и не
полные payload.
- **AC10 (i18n/доки), кроме принятого golden baseline — см. H1.** RU/EN ключи
из §10 ТЗ добавлены синхронно в `src/i18n/en.json`/`ru.json`, названия
совпадают со списком ТЗ буквально. `docs/USER-GUIDE.md`/`.ru.md` и оба
`CHANGELOG` обновлены в том же коммите (`51810164`, `User-Visible: yes`) —
трейлеры корректны, оба changelog присутствуют. Терминология
(«Оптимизировать планы», «Подробности восстановления ссылок») совпадает с
уже принятой в `USER-GUIDE.ru.md` (не изобретена). «Одно число — один
источник»: верхняя строка `backup.repaired_target_refs` и сумма
`remapped.target` внутри `▶ Подробности` увеличиваются в одном и том же месте
кода (`replace()` в `_repair_target_space_refs`, `repaired += 1` и
`_report_remap(..., "target", ...)` — соседние строки одного `if`) — то же
для `preserved_unresolved_refs`: и backend-сумма, и frontend-сумма читают
один и тот же словарь `reference_report.preservedUnresolved`, посчитанный
один раз. Расхождения источников не нашёл.
- **Трейлеры коммитов.** Все восемь коммитов диапазона несут `Issue: #265` и
корректный `User-Visible:`; единственный `yes` (`51810164`) сопровождается
правками обоих changelog в том же коммите — проверено `git show
51810164 --stat`.
## 5. Чего не проверял
- Не переисполнял backend pytest локально — среда ревью не содержит
`homeassistant`/`.venv-backend` (см. AGENTS.md: `test_ha_*.py` тихо
пропускаются без реального HA). Каноническое доказательство взял из CI
`backend` job на точном SHA — green, включая все шесть новых
`test_issue_265_*` тестов и не изменённые тесты #244/#248/#252/#258/#262 (их
прохождение на этом SHA подтверждает отсутствие регрессии от рефакторинга
`_repair_target_space_refs`/`prepare_apply`, хотя ни один явно не назван по
этим номерам в диффе — не стал заново перечитывать каждый построчно, доверяю
зелёному прогону полного файла).
- Не гонял `npm run golden:capture`/`accept` и не пытался сам принять
baseline — это шаг автора, не ревьюера (принятие требует
`Baseline-Reviewed:` трейлера с реальной ссылкой на прогон, которую не
вправе изобретать).
- Не прогонял полный `demo/smoke_*.mjs` набор (188 смоков) локально — доверился
зелёным CI `smoke` (1)(2)(3) на этом SHA, которые включают весь набор, и
выводу `smoke-select.mjs`.
- Не проверял вручную UI в браузере (ручного тестирования в цикле код-ревью
нет по процессу) — визуальную корректность новой ветки диалога подтвердил
через `actual/backup-space-preview-mobile-ru.png` из golden-артефакта CI, а
не собственным запуском demo-стенда.
- Не перечитывал построчно код валидации geometry/wall carrier инвариантов —
диф их не трогает (подтверждено `git diff --stat`), доверился этому факту, а
не повторной проверке недвинутого кода.
- Performance-профили не запускал целенаправленно — в AC не названы явно,
`performance_smoke` CI green попутно.
## 6. Итог
High: 1 (H1, golden красный на заявленном SHA для изменённого экрана — AC10 не
доказан). Medium: 2 (M1, M2 — пробелы в обязательной по §15.1 матрице
доказательств тестами; оба в скоупе, не блокируют логику, но не выполнены).
Ядро реализации (lineage-канонизация, safe cross-generation repair, матрица
ссылок, неизменяемый candidate/digest, invariant gate, i18n/доки) проверено
построчно и тестами CI на заявленном SHA и выглядит корректным — единственная
причина невозможности зелёного вердикта — недоказанный (и на деле красный)
AC10-гейт плюс два пробела тестового покрытия, требуемого тем же документом,
который автор сам утвердил на SPEC-REVIEW r3.