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

29 KiB
Raw Blame History

CODE-REVIEW-265-r1

  • Issue: #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) 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), 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.