21 KiB
SPEC-REVIEW-611-r1
Issue: #611 — «Импорт пространства не перепривязывает
vacuum.map_routes[].space: на чужой установке импорт падает, на своей маршрут робота молча уводит на старый этаж»
Этап: spec (PROCESS.md §2.4)
Трек: small (лёгкий) — лимит циклов ревью ТЗ 2
Заход: r1 · блокирующих циклов израсходовано 0 из 2
Ревьюер: независимая сессия, без контекста автора ТЗ
Вердикт: жёлтый
Скоуп
ТЗ живёт в теле issue #611 (раздел ## ТЗ), не в docs/specs/ — задача заведена после 2026-09-10 (#517).
Предмет ревью — контракт, критерии приёмки AC1–AC3, откат и раздел «Принятые предположения» этого текста,
проверенные против фактического кода custom_components/houseplan/import_export.py,
validation.py, vacuum_routes.py на HEAD 61292c9e.
Заход первый: раздела «Закрытие предыдущего раунда» и «Унаследовано из r0» не требуются (§2.10 применяется со второго цикла).
Как проверялось
Ручного тестирования и исполнения кода на этапе ревью ТЗ не предусмотрено (артефакта для исполнения ещё нет — код не написан). Проверка велась чтением:
- Прочитаны
docs/SCOPE.md(задача закрывает J4/J6 — многоэтажный план, перенос пространства без потери привязки устройств),PROCESS.md§2.3/§2.4/§7.1,AGENTS.md. - Прочитано тело issue #611 целиком и единственный комментарий (аналитика: ценность 8/10, сложность 3/10,
P2, трек
small, обоснование трека — все критерии §5 выполняются). - Построчно сверены три утверждения ТЗ против кода:
custom_components/houseplan/import_export.py:1284-1660(build_space_merge) — цикл по входящим маркерам (1415-1497), где сейчас ремапитсяmarker.space(1428),marker.room_id(1432-1438),radar(1439-1454) иvacuum.segment_map(1455-1466), но неvacuum.map_routes[].space;custom_components/houseplan/validation.py:910-951(validate_marker_vacuum_routes) — вызывается наmerged_configуже после сборки (import_export.py:1627), т.е. до неё контракт п.1 успевает выполниться;custom_components/houseplan/vacuum_routes.py:106-146(effective_routes) — подтверждает п.3 ТЗ: legacy-маршрут получаетspaceиз параметраdock_space, а не из отдельного сохранённого поля, значит ремапmarker.spaceдействительно достаточен для legacy-случая.
- Прочитаны действующие тесты
tests_backend/test_ha_import_export.py:test_issue_162_space_export_drops_map_routes_of_other_spaces,test_issue_443_space_export_preserves_explicit_empty_routes,test_issue_162_full_export_round_trips_every_map_route,test_issue_162_space_export_keeps_legacy_calibration_untouched(строки 2678–2755) — подтверждают, что #162/#443 покрывают экспорт, а не перепривязку при импорте, как и заявляет «Механизм» issue;test_space_remap_covers_marker_id_layout_and_vacuum_segment_map,test_issue_244_space_import_repairs_existing_target_refs_with_exact_map,test_issue_265_space_import_repairs_unique_previous_generation_lineage,test_issue_244_space_import_does_not_repair_target_while_source_exists(1491–1600+) — раскрыли путь, не упомянутый в ТЗ (см. находку M1 ниже).
- Проверена ссылка AC3 на
scripts/mutation-registry.mjs: файл действительно регистрирует backend-мутанты противcustom_components/houseplan/import_export.py(например, окрестности строк 4938, 5596–5686, 6869–6898, 9474) — ссылка не выдумана, а корректна. - Проверено, нет ли в
docs/CONFIG-COMPATIBILITY.md(раздел «Vacuum map routes (#162)», строки 163–195) иdocs/ARCHITECTURE.md:409-420утверждений, которые фикс сделал бы ложными, и не описывают ли они уже ремап при импорте пространства отдельно отmap_routes(нет — оба документа молчат об идентификаторах при копировании пространства одинаково дляmarker.space,room_idиmap_routes; заявление ТЗ «документация не требуется» самосогласовано с этим прецедентом, а не произвольно). - Проверена терминология:
docs/USER-GUIDE.ru.mdиспользует «пространство», «этаж», «робот-пылесос» — ТЗ не вводит новых терминов интерфейса (фикс не user-facing поверхность, только сообщение об ошибке импорта и changelog).
Дешёвые гейты (typecheck/test/build) и Validate на этом этапе неприменимы: продуктовый код ещё не написан, класс A не тронут.
Находки
Medium — M1: контракт не называет второй код-путь с идентичным дефектом (_repair_target_space_refs)
Файл/место: тело issue #611, раздел «Контракт» п.1–2 и «Критерии приёмки» AC1–AC2 (текст ТЗ);
затрагиваемый код — custom_components/houseplan/import_export.py:1052-1270 (_repair_target_space_refs),
вызывается из build_space_merge на import_export.py:1564, то есть до валидации на :1627 — тем же
контрактом п.1, который говорит «в build_space_merge», без уточнения.
Формулировка: контракт п.1 и AC1/AC2 описывают перепривязку vacuum.map_routes[].space только для
входящих маркеров документа (цикл 1415-1497, marker из incoming.get("markers")). Но build_space_merge
вызывает и второй, отдельный проход — _repair_target_space_refs — который чинит уже существующие в целевой
конфигурации маркеры, чей marker.space/room_id/vacuum.segment_map ссылались на мёртвый (более не
live) ID, если этот ID лineage-разрешим к только что импортированному пространству (тесты
test_issue_244_*, test_issue_265_*). В этом проходе (import_export.py:1152-1179) marker.space (1154),
marker.room_id (1157-1165) и vacuum.segment_map (1166-1179) чинятся, а vacuum.map_routes[].space — нет:
для этого прохода в файле вообще нет строки, аналогичной 1455-1466 из первого цикла. Ни контракт, ни AC1-AC3,
ни «Принятые предположения» не упоминают этот путь — не как исключённый явно, а никак.
Почему это тот же дефект, а не гипотетический: тест test_issue_244_space_import_repairs_existing_target_refs_with_exact_map
(строки 1507-1537) прогоняет ровно такой маркер — "vacuum": {"segment_map": {"12": "living"}} в целевой
конфигурации, чей room_id/space чинятся через лineage/exact-map. Если у такого же целевого маркера вместо
(или вместе с) segment_map стоит явный map_routes со space, указывающим на тот же старый ID, после починки
marker.space он укажет на новое пространство, а vacuum.map_routes[0].space — на старый, уже мёртвый ID.
Дальше — тот же исход, что описывает issue: либо invalid_vacuum_map_route (если старый ID действительно
исчез из spaces), либо (если по случайности ID ещё жив в другом контексте — на практике не бывает, used
исключает коллизии) молчаливо неверный этаж. Это тот же класс дефекта, что и заявленный в issue, просто
триггер — не сам факт копирования пространства, а последующий ре-импорт после того, как исходное пространство
уже когда-то было удалено (детач) и лineage-таблица его помнит.
Почему это не придирка к незаявленному сценарию: контракт п.1 буквально говорит «в build_space_merge»
(имя функции, а не «в цикле по входящим маркерам»), и _repair_target_space_refs — код внутри той же функции,
вызванный до той же строки валидации (:1627), к которой апеллирует контракт. Формулировка «до запуска
validate_marker_vacuum_routes» относится к обоим проходам одинаково буквально.
Последствие для процесса: это Medium, поскольку не блокирует реализуемость и проверяемость AC1-AC3 как
написано — они по-прежнему однозначны и доказуемы отдельно от этого пробела. Но раз пробел лежит в скоупе той
же задачи (тот же баг-класс, тот же файл, тот же вызов внутри build_space_merge, соседний код уже показывает
правильный паттерн на segment_map), это Medium в скоупе, а не повод заводить отдельный issue: чинится
здесь же — либо явным расширением контракта/AC (AC4 на путь _repair_target_space_refs), либо явной записью
в «Принятые предположения», почему этот путь сознательно не покрывается в #611 (например: «до отдельного
issue, потому что репродукция требует предварительного детача пространства — двухшаговый сценарий вне
аудита 22.09»). Молчание — не то же самое, что решение.
Не High: ни один из AC1-AC3 не становится невыполнимым или недоказуемым без этого расширения; проблема —
в полноте контракта относительно собственной формулировки «в build_space_merge», а не в его противоречивости.
Проверено и корректно
- Механизм воспроизведён точно.
old_space_id/new_space_id(import_export.py:1338,1342),details["space_id"] = new_space_id(:1648) — ТЗ ссылается на реальные имена и реальный порядок вызовов, не выдуманные. - AC1/AC2 доказуемы уже сейчас, без домыслов о будущем API:
create_export(kind="space"),parse_document,build_space_merge(..., same_source=...)— существующие публичные точки входа модуля, тест-файлtests_backend/test_ha_import_export.pyуже содержит соседние тесты той же формы (тестовый паттерн_document(tmp_path, "space")+build_space_merge(...)используется многократно, включаяtest_issue_244_*/test_issue_265_*). - П.3 контракта (legacy-маршрут вычисляется из
marker.space) подтверждён кодом —vacuum_routes.py:106-146,effective_routes()берётdock_spaceкак параметр, отдельного сохраняемогоroute.spaceдля legacy нет. Это не домысел, а точное описание существующего инварианта. Первое предложение п.3 («явный список не создаётся из legacy при импорте») тоже подтверждено — этот путь вbuild_space_mergeне создаётmap_routesтам, где их не было. - П.4 (виртуализация теряет
vacuumцеликом) подтверждён —import_export.py:1489-1496,"vacuum"в списке полей, которыеpop-аются приduplicate_policy == "virtual". Ремапmap_routes, если он случится раньше проверки на 1467, для виртуализированного маркера станет мёртвым кодом, но не изменит наблюдаемый результат — контракт корректно не создаёт здесь новой обязанности. - Ссылка на
scripts/mutation-registry.mjsв AC3 реальна — файл уже регистрирует backend-мутанты именно противimport_export.py, формат (guard: 'python3 -m pytest tests_backend/...') соответствует остальному реестру. - Раздел «Принятые предположения» — не выдача догадки за факт. Утверждение об отказе перепривязывать «похожие», а не точно совпадающие ID, явно помечено как сохранение существующего fail-closed поведения и обосновано (повреждённые/вручную изменённые документы) — это ровно тот случай, когда решение принято и явно записано, а не спрятано.
- DoR-чеклист §2.5 закрыт по всем пунктам, кроме найденного M1: i18n (нет новых ключей — верно, ошибка
использует существующий код
invalid_vacuum_map_route), touch/performance (нет влияния — верно, чисто backend-импорт), откат (revert одним коммитом — реалистично, схема хранения не меняется,CONFIG_SCHEMAуже допускаетmap_routesчерез существующийvol.Optional), i18n en/ru changelog — план назван. - Трек
smallподтверждён. Один backend-контракт (build_space_merge), никакой миграции конфигурации, никакого нового UX-контракта, i18n/touch/perf не затронуты — критерии §5 действительно выполняются все одновременно, а не выборочно. - Продуктовая рамка отвечает на оба обязательных вопроса §7.1. Сценарий: администратор House Plan
переносит/копирует пространство многоэтажного дома, где настроен пылесос с явными маршрутами (J4/J6 из
docs/SCOPE.md). Что человек увидит: на чужой установке импорт пространства с настроенными маршрутами вместо паденияinvalid_vacuum_map_routeпроходит; на своей — робот на новой копии не «наследует» этаж старого оригинала молча.
Чего не проверял
- Исполнение кода не проводилось — на этапе ревью ТЗ кода ещё нет; все проверки — чтением существующего
кода и тестов на HEAD
61292c9e, не запуском. Это ожидаемо для этапа spec (артефакта для запуска нет), отмечается явно по правилу «проверено чтением, не исполнением». - Не проверялась производительность сборки
build_space_mergeна больших конфигурациях — ТЗ прямо заявляет «без влияния», и это правдоподобно для точечной правки одного цикла по маркерам без изменения сложности; отдельного перф-профиля для backend-импорта в проекте не предусмотрено. - Не проверялось, существуют ли реальные, уже сохранённые у пользователей конфигурации, где сработал бы
путь M1 (
_repair_target_space_refs+ явныйmap_routesна целевом маркере) — это гипотетический, но code-grounded сценарий; фактическая частота не оценивалась и не требуется на этапе ревью ТЗ.
Материал раунда
- Issue: #611, тело проверено на состоянии, зафиксированном при выдаче задачи ревьюеру.
sha256нормализованного (UTF-8, как получено черезgh issue view --json body) тела issue:94e2253da2c31f34919377a64987560e85945a765281dac78e320a29231f1553(4369 байт).- Код-база:
61292c9ea05f0d7d3a98392c81f3be89f2706c7c(рабочая копия соответствует HEAD). - Комментарий аналитики учтён (ценность 8/10 · сложность 3/10 · P2 · тип bug · трек
small).
Итог
Вердикт: жёлтый. AC1-AC3 сформулированы однозначно и доказуемо, механизм и все технические ссылки в ТЗ
точны и проверены построчно против кода — это добротная работа. Единственная находка (M1, Medium, в скоупе)
не рушит написанные критерии, но оставляет незакрытым второй код-путь с тем же классом дефекта внутри той же
функции. Возврат автору: расширить контракт/AC на _repair_target_space_refs, либо явно и обоснованно
исключить этот путь в «Принятых предположениях».
Материал раунда
- Ветка:
dev, коммит61292c9ea05f— ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет. - Дерево материала:
008da06ab0a721f37308e9af3cf9fbfad2c659degit log --all --format='%H %T' | grep 008da06ab0a7 - Тело issue:
e67628cb6a8a9b34c284381c57eb7ef1105361614b8bf1341c41e4af19b87f19 - Вердикт конвейера:
yellow· High 0