32 KiB
CODE-REVIEW-316-r1 — миграция v9 сама разрешает конфликт «проём ↔ нулевая стена»
- Issue: #316
- Этап:
code(PROCESS.md §2.7) - Диапазон:
origin/dev...HEAD,origin/dev=f3fe8637(docs: review document for #319),HEAD=ee672dcd(веткаissue/316-migration-auto-resolve, ребейз наdevвыполнен автором до этого ревью — коммент issue от 2026-08-26T19:12:24Z, конфликт был только вCHANGELOG) - ТЗ:
docs/specs/316-opening-host-auto-resolution.md, ревизия 4, ревью ТЗ зелёное —SPEC-REVIEW-316-r4.md - Заход ревью: r1, блокирующих циклов код-ревью израсходовано 0/4
- Вердикт: красный
Скоуп ревью
Первый заход код-ревью (ревью ТЗ бюджет не тратит, §10.4/§227; первая попытка
запуска код-ревью не состоялась из-за конфликта ребейза с dev и цикл не
израсходовала — это не первый заход по номеру документа, но первый
фактический разбор кода). Полный разбор, дельты по §2.10 нет.
Продуктовый код (класс A): src/wall-segment-model.ts (+111/−7),
custom_components/houseplan/wall_segment_model.py (Python-зеркало миграции,
+96/−9), custom_components/houseplan/validation.py (+4/−1, разрешение
host: null в _config_wall_segment_invariants).
Гейты/инструменты (класс B): test/wall-segment-model.test.mjs (+97/−7,
6 новых тестов), tests_backend/test_wall_segment_model.py (+51/−10, 4 новых
теста), demo/smoke_zero_wall_migration_unblocked.mjs (новый),
demo/golden/{harness,matrix}.mjs, demo/golden/baselines/baselines-index.json
(новый эталон span-over-door-migrated-dark, matrixVersion 46→47),
test/golden-matrix.test.mjs (версия матрицы), test/fixtures/316-span-over-door-migrated.json
(новый), scripts/config-field-registry.mjs (запись host=wall переписана),
scripts/mutation-gate.mjs (2 новых мутанта).
Документация (класс C): docs/CONFIG-COMPATIBILITY.md (раздел «Canonical
zero-thickness walls — model v9» переписан под §3.1–3.4), docs/CHANGELOG.md +
docs/CHANGELOG.ru.md (User-Visible: yes, оба в одном коммите).
Сгенерированное (класс D): dist/houseplan-card.js,
custom_components/houseplan/frontend/houseplan-card.js (сверены байт-в-байт с
локальной пересборкой), demo/golden/baselines/span-over-door-migrated-dark.png
(коммит несёт Release:/Baseline-Reviewed: — прогон CI 33000824647).
i18n не тронут (src/i18n/** вне диффа) — соответствует ТЗ §5.2 («без изменений»).
Прочитано до вердикта: docs/SCOPE.md, AGENTS.md, PROCESS.md, тело issue
#316 и все 12 комментариев (аналитика, все 4 раунда ревью ТЗ, хендофф
реализации, отчёт о несостоявшемся первом запуске код-ревью из-за конфликта
ребейза, отчёт о ребейзе), ТЗ ревизии 4 целиком, docs/CONFIG-COMPATIBILITY.md
(разделы «Canonical zero-thickness walls» и «Independent-wall opening host»),
весь новый/изменённый продуктовый код в TS и Python, все новые тесты
(frontend + backend), новый смок, изменения config-field-registry.mjs и
mutation-gate.mjs.
Как проверялось
| Гейт | Команда | Результат |
|---|---|---|
| Типы | npx tsc --noEmit |
green |
| Unit (frontend) | npm test |
1351/1352 green, 1 skipped (не относится к диффу — существующий known-skip) |
| Сборка | npm run build |
green |
| Синхронность бандла | cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js |
идентичны байт-в-байт |
Документация (обязателен: diff трогает src/**) |
node scripts/check-docs.mjs |
green — «Documentation checks passed (7 files, 10 external links)» |
Инварианты модели (диф трогает host/атомизацию) |
npm run invariants -- --config <фикстура AC1> и --config <мой репро §H1> |
оба «Инварианты выполнены» — эта проверка не видит находку H1 (см. ниже, она проверяет разрешимость ссылок и наличие записи толщины, не геометрическое согласие host↔x/y) |
| Backend pytest | python -m pytest tests_backend -q |
недоступно в этой среде — homeassistant не установлен (нет .venv-backend, чистый контейнер ревью, не cloud-агент). Компенсировано прямым исполнением: модули validation.py/wall_segment_model.py загружены в изоляции от custom_components/houseplan/__init__.py (который требует HA) через importlib с застабленным пакетом — это и вскрыло находку H1 (ниже, точный скрипт воспроизведения приложен) |
Смоки — по выборке smoke-select.mjs (прямое совпадение) |
node demo/smoke_edit_walk.mjs, smoke_glow.mjs, smoke_junction_holes.mjs |
все OK |
| Смоки — зарегистрированная связь | smoke_real_plan_masonry.mjs, smoke_resize_pointer_real_plan.mjs, smoke_resize_wall_thickness.mjs |
все OK (связь подтверждена, регрессий нет) |
| Смок AC1 (репродукция #316) | node demo/smoke_zero_wall_migration_unblocked.mjs |
OK — но см. H1: этот смок мокает callWS и никогда не пропускает мигрированный кандидат через реальную бэкенд-схему, поэтому находку не ловит |
| Golden | npm run bundle:sync && npm run golden:verify (полный матрикс, 130+ сцен, включая span-over-door-migrated-dark и coincident-partition-virtual-dark) |
все passed, exit 0 |
| single-source-numbers | node --test test/single-source-numbers.test.mjs |
green (3/3), не по существу диффа, но диф трогает видимую геометрию — прогнан для очистки |
| Дисциплина «тест умеет падать» | вручную применены оба мутанта mutation-gate.mjs (§3.1: span-cut-erases-the-door-wall, §3.4: migration-throws-over-an-opening-again) на src/wall-segment-model.ts, пересобран test-build, целевой тест перезапущен |
оба красные — тесты AC2/AC1 реально способны упасть (полный вывод в разделе «Проверка AC») |
Одно число — один источник. В диффе нет новой пользовательски видимой
величины (числа, подписи) — только геометрия (позиции атомов/host). Проверка
не применима буквально к «числу», но её геометрический аналог — согласие
opening.host с фактической позицией opening.x/y — и есть предмет находки
H1: после миграции у проёма фактически два источника позиции, которые
могут разойтись (сохранённые x/y и позиция, которую подразумевает
host.id + host.t), и этот разлад не смягчается рендером (он честно рисует по
x/y), а ломается ниже по цепочке — на записи.
Находки
H1 (High, в скоупе) — деградированный host (§3.3) может завести данные, которые бэкенд тут же отклоняет: миграция снова блокирует запись
Смысл. migrateRoomOpeningHost (src/wall-segment-model.ts:679-708, зеркало
_host_openings/degraded в custom_components/houseplan/wall_segment_model.py:580-612)
для деградированного случая (§3.3) выбирает любую стену пространства,
подходящую по углу (wallAngleMatches, допуск 8°) и вместимости (length),
без ограничения по расстоянию — буквально по тексту ТЗ §3.3. Дальше host
материализуется как {kind:'wall', id, t: clamp(projectT(centre, host.a, host.b))}
— t берётся из ортогональной проекции центра проёма на прямую хоста, а не из
факта, что проём на ней лежит.
Но opening.x/y при этом не меняются — миграция их не трогает нигде.
Если после этого документ проходит через CONFIG_SCHEMA (а он проходит:
config/set и plan/optimize в websocket_api.py валидируют кандидат после
commit_wall_segment_model, строки 1653/1661-1662 и 1325 применительно к уже
мигрированному фронтендом кандидату), срабатывает существующая, не тронутая
этим диффом проверка _config_wall_segment_invariants
(custom_components/houseplan/validation.py:1849-1853): она пересчитывает
x, y из host.a/b + t и требует abs(x - opening.x) ≤ 2e-8. Для
деградированного host (по построению не близкого к проёму — вся суть §3.3 в
том, что рядом ничего нет) это расхождение равно перпендикулярному расстоянию
от проёма до выбранной стены — на практике многие сантиметры в нормализованных
координатах, на много порядков больше допуска. Проверка падает с
wall opening geometry must match its host, CONFIG_SCHEMA кидает
vol.Invalid, и websocket_api.py (оба пути, config/set:1339-1341 и
plan/optimize:1687-1688) отвечает клиенту invalid_format — запись
отклонена целиком.
Итог: для этого класса входных данных миграция не выполняет своё собственное
нормативное правило §3.4 («initial migration никогда не кидает opening-host»)
по факту — она действительно не кидает WallSegmentModelError('opening-host')
из hostRoomOpenings, но провоцирует эквивалентный по последствиям отказ
чуть ниже по конвейеру, тем же классом ошибки («пространство не
преобразовано»), который и есть предмет #316. Часть, ради которой заведён
issue, — «рисование нигде не блокируется» (тело ТЗ, §1) — не выполняется для
этого случая.
Заодно замечание по замыслу. Присвоенный деградированный host не даёт
никакой функциональной пользы: рендер контурных host'ов всегда идёт по
собственным x/y проёма (space-render.ts:285,
plan-geometry-preflight.ts:224/276, houseplan-card.ts:9282), а
физика/вырезы (physical-geometry.ts) режут тело по геометрическому
пересечению, не по host. Единственные потребители host.id для
kind:'wall' — это (а) сравнение линии владения при протяжке ID-наследования
(openingHostCounts, wall-segment-model.ts:296-303) и (б) блокировка
инструмента «Толщина» при занулении стены, которая якобы несёт проём
(houseplan-card.ts:11753-11758, не тронут этим диффом). Для (б) деградированный
host активно вреден: он запрещает занулить чужую, физически не связанную с
проёмом стену, и одновременно снимает позиционную защиту (if (opening.host) return false;
на той же строке 11756) с настоящей стены под проёмом, у которой host не установлен.
То есть даже если бы бэкенд-валидацию поправили, деградированный host остаётся
данными без пользы и с прямым вредом для UX инструмента «Толщина» —
это довод в пользу того, что §3.3 в этой части должен вести сразу к
непривязанному состоянию (без host), а не к «дальней» привязке; ТЗ прямо
допускает такую альтернативу («альтернатива «сразу непривязанный без поиска»
тоже согласуется с решением владельца», §6 ТЗ).
Почему это не поймали шлюзы. Ни один прогнанный гейт не проверяет
согласие host↔x/y после миграции:
npm testне вызываетCONFIG_SCHEMAвообще (чистый TS, backend не участвует);tests_backend/test_wall_segment_model.py::test_unhosted_contour_opening_is_a_valid_degraded_v9_state— единственный тест, который пропускает мигрированный кандидат черезCONFIG_SCHEMA(строка добавлена этим диффом), но его фикстура (angle: 90против единственной стеныangle: 0) не даёт ни одного кандидата даже в деградированном пуле — угол не совпадает вовсе, поэтому проём уходит в истинно непривязанное состояние (hostотсутствует) и находка не воспроизводится;demo/smoke_zero_wall_migration_unblocked.mjsмокаетcard.hass.callWSбезусловным{ ok: true }— реальная бэкенд-схема не участвует;npm run invariants(scripts/model-invariants.mjs) проверяет разрешимость ссылок и наличие записи толщины по грид-ключу (#254/#258/#259), но не геометрическое согласиеhost.t↔x/y— проверено запуском на моём воспроизведении (см. ниже), инварианты «выполнены», находку не видят;- golden/визуальные гейты не видят разницы, т.к. рендер идёт по
x/yнезависимо отhost— визуально деградированный и истинно непривязанный случай неотличимы.
Воспроизведение (проверено исполнением, дважды — TS через реальный dist-путь
модуля и Python-зеркало через прямую загрузку модулей в обход custom_components/houseplan/__init__.py,
результат идентичен):
// TS, тот же код, что использует фронтенд (test-build/wall-segment-model.js)
const config = {
spaces: [{
id: 'floor', title: 'Floor', cell_cm: 5, view_box: [0,0,1,1],
rooms: [{ id: 'room', poly: [[0,0],[1,0],[1,1],[0,1]] }],
walls: [{ key: '', a: [0,0], b: [1,0], cm: 15 }],
openings: [{ id: 'orphan', type: 'door', x: 0.5, y: 0.5, angle: 0, length: 0.1 }],
}],
markers: [], settings: {},
};
commitWallSegmentModel(config).config.spaces[0].openings[0];
// → { id:'orphan', type:'door', x:0.5, y:0.5, angle:0, length:0.1,
// host:{ kind:'wall', id:'wall-6jl52lapgi6hgvc2qe6r', t:0.5 } }
// opening.x/y (0.5, 0.5) остаются исходными; host ссылается на стену y=0 —
// перпендикулярное расстояние 0.5, т.е. половина плана.
# Python-зеркало, идентичный вход
migrated, _ = commit_wall_segment_model(src)
# migrated["spaces"][0]["openings"][0]["host"] ==
# {"kind": "wall", "id": "wall-6jl52lapgi6hgvc2qe6r", "t": 0.5}
CONFIG_SCHEMA(copy.deepcopy(migrated))
# → voluptuous.error.MultipleInvalid: wall opening geometry must match its host
Команды и полный стек трассировки воспроизведены локально в рамках этого
ревью (см. «Как проверялось» — раздел backend pytest недоступен, но модули
загружены напрямую тем же интерпретатором и той же версией voluptuous, что
использует tests_backend).
Требуется: до возврата на правку — не мой мандат чинить, но по существу:
деградированный кандидат в §3.3 обязан либо (а) материализовывать host только
когда реконструированные x/y совпадают с сохранёнными в пределах допуска,
который реально примет _config_wall_segment_invariants (т.е. фактически
эквивалентно текущему eligible(), а не отдельному безлимитному пулу), либо
(б) уходить сразу в истинно непривязанное состояние без host, как это уже
происходит, когда угол не совпадает вовсе. Второй вариант дешевле и совпадает
с уже одобренной в ТЗ §6 альтернативой.
M1 (Medium, в скоупе) — CHANGELOG заявляет то, чего код не гарантирует
docs/CHANGELOG.md/.ru.md: «Drawing works again everywhere: converting a
plan to the new wall model no longer stops on a legacy border/opening
conflict» / «Рисование снова работает везде… больше не останавливается». При
находке H1 это утверждение ложно для деградированного случая — превращение
всё ещё останавливается, просто с другим кодом ошибки на другом уровне. Формулировка
чинится в этом же коммите вместе с H1 (её объём и формулировка зависят от
выбранного решения H1), отдельного issue не заводится (Medium в скоупе, #202).
Проверка AC
- AC1 (репродукция #316). Доказательство — смок
demo/smoke_zero_wall_migration_unblocked.mjs, воспроизводящий ровно сценарий issue (span+door в одном пространстве, пустое — в другом). Прогнан: OK. Мутантmigration-throws-over-an-opening-againделает его красным при откате исправления — проверено вручную (раздел «Как проверялось»). AC доказан для этого конкретного сценария (§3.1, не §3.3) — см. H1 про общий контракт «никогда не блокирует». - AC2 (§3.1). Доказательство — юнит-тест
an opening keeps its wall through a legacy span cut. Прогнан: OK; мутантspan-cut-erases-the-door-wallделает его красным при откате — проверено вручную, тест реально падает (AssertionError: the atom under the door keeps its real thickness). AC доказан. - AC3 (§3.2). Доказательство — юнит-тест
an ambiguous carrier is resolved deterministically at a thickness boundary. Фикстура — стык двух контурных атомов одной стены на границе смены толщины, проём центрирован ровно на стыке (перпендикулярное расстояние 0 к обоим кандидатам) — реально достижима внутриresolveRoomOpeningHost, тай-брейк «больший cm» проверяется явно. AC доказан. Тай-брейк «текущий host» и «меньший id» кодом реализован (pick(), TS и Python идентичны построчно), но отдельными тестами не покрыт — не блокирует (не входит в AC3 буквально), отмечаю как Low-полноту тестового покрытия, не требую правки. - AC4 (§3.3). Доказательство — юнит-тест фронта
(
an opening with no usable carrier migrates unhosted...) + бэкенд-тест схемы (test_unhosted_contour_opening_is_a_valid_degraded_v9_state). Оба зелёные, но оба тестируют только истинно пустой пул кандидатов (угол вообще не совпадает ни с одной стеной) — не тестируют деградированный пул с найденным, но геометрически несвязанным кандидатом, который и оказался сломан (H1). Формально буква AC4 («проём вдали от всех стен мигрирует в состояние «без host»») для протестированного случая выполняется; для непротестированного соседнего случая, нормативно предписанного тем же §3.3, — нет. AC не доказан полностью. - AC5 (граница §2). Доказательство — юнит-тест
a post-v9 write that LOST its carrier keeps the fail-closed refusal(frontend) +test_post_v9_write_that_lost_its_carrier_keeps_the_refusal(backend). Оба зелёные, оба явно проверяютWallSegmentModelError/WallSegmentMigrationError('opening-host')приinitialMigration=false. AC доказан. - AC6 (идемпотентность). Доказательство —
commit(commit(x)) === commit(x)байтово, проверено в каждом новом тесте (§3.1/§3.2/§3.3/фикстура) и отдельно тестом на зафиксированной фикстуре316-span-over-door-migrated.json. Проверено запуском (node --test), AC доказан для протестированных путей. Не проверяет: идемпотентность сохраняется и для деградированного host из H1 (сам факт того, чтоcommit(x)неконсистентен сCONFIG_SCHEMA, делает вопрос об идемпотентности вторичным — до записи дело не доходит).
Что проверено и корректно
- §3.1 (легаси-кат щадит атом с проёмом) реализован идентично в TS и Python:
один и тот же критерий (
GRID_STEP_N*0.02по расстоянию, тот же угловой критерий), фильтр «проём реально стоит на легаси-кате» (GRID_STEP_N*0.04) не даёт постороннему проёму перекраивать каталог — проверено чтением и тестом на фикстуре с «дальним» проёмом (из хендоффа автора о фиксе после ребейза). - §3.2 тай-брейк (текущий host → расстояние → cm → id) реализован одинаково в
pick()TS и Python, порядок сравнений совпадает построчно. - §3.4 (initial migration никогда не throw из
hostRoomOpenings) — верно локально для этой функции; связка с H1 показывает, что гарантия не переживает следующий слой (CONFIG_SCHEMA), но именно эта функция сама по себе корректна. scripts/config-field-registry.mjs,docs/CONFIG-COMPATIBILITY.md(раздел «Canonical zero-thickness walls — model v9») правлены в том же коммите, что и код (правило 11), формулировки соответствуют §3.1–3.4 ТЗ. Раздел «Independent-wall opening host (#132)» не тронут — верно, партиционные проёмы вне скоупа (hostRoomOpeningsявно пропускаетkind==='partition', не изменено этим диффом).- Golden: новая сцена
span-over-door-migrated-darkдобавлена с корректнымRelease:/Baseline-Reviewed:в отдельном D-коммите; полный прогонgolden:verify(130+ сцен, включая эту иcoincident-partition-virtual-dark) — всеpassed, подтверждает заявление ТЗ §5.3 «без изменений» для партиционной сцены. - Трейлеры коммитов корректны:
Issue: #316на обоих (реализация и D-коммит),User-Visible: yesна реализации с правками в обоих CHANGELOG в том же коммите,User-Visible: no+Release:/Baseline-Reviewed:на golden-коммите.pre-push/process-gateне могли это поймать сами — подтверждено чтением коммитов, не только заявлением автора. - i18n действительно не тронут (
git diff --statпоsrc/i18n/**пуст), соответствует ТЗ §5.2. - Ребейз на
dev(после несостоявшегося первого запуска ревью) не принёс нового кода — единственный конфликт был вCHANGELOG.md/.ru.mdс уже слитым #319; проверено чтением коммита19:12:24Zи тем, чтоwall-segment-model.ts/.pyне менялись междуdf893c1c(отвергнутый первый HEAD) иee672dcdза вычетом уточнения edge-breaks, которое автор сам описал и которое покрыто существующими тестами на «посторонний проём не перекраивает каталог».
Чего не проверял
python -m pytest tests_backend -qцеликом — недоступен в этой среде (нетhomeassistant, нет.venv-backend). Компенсация: прямой запускvalidation.py/wall_segment_model.pyв обход пакета — тем же способом подтверждены оба заявленных автором числа тестов существованием функций и найдена H1; полный набор (18/19 по заявлению автора) не прогнан этим ревью буквально, доверия к зелёному CI (Baseline-Reviewedссылка на прогон 33000824647/33001442307) достаточно для остальных backend-тестов, кроме специфического сценария H1, которого в наборе нет.- Полный набор
demo/smoke_*.mjs(192 файла) — не прогнан целиком, только выборкаsmoke-select.mjs(прямое совпадение + зарегистрированная связь) и AC1-смок. Диф не задевает подсистемы, для которых нужен полный прогон (партиции, изометрия, устройства); полный прогон — обязанность предрелизного гейта (PROCESS.md §8). performance_smoke— не запускал; ни AC, ни диф не называют влияние на перф (ТЗ §5.4 явно: миграция — единственный проход по проёмам, вне горячего пути рендера), и диф не трогает рендер-цикл.npm run invariantsна реальном экспорте с продакшен-плана — прогнан только на синтетических фикстурах (AC1 golden-фикстура и моё воспроизведение H1); ни то ни другое не эквивалент «реального большого плана», ноsmoke_real_plan_masonry.mjs(косвенно, через связку сatomicPolyForRoom) прогнан и зелёный.- Тай-брейк «текущий host» и «меньший id» в §3.2 — реализация проверена чтением (идентична TS/Python), отдельным юнит-тестом не покрыта; не поднимаю до Medium, т.к. это не AC3 буквально и код прямолинеен (простая сортировка).
Резюме
High-находка H1 показывает, что нормативное правило §3.4 («initial migration
никогда не блокирует запись из-за проёма») не выполняется целиком: для
деградированного случая §3.3 миграция создаёт геометрически несогласованный
host, который существующая (не тронутая этим диффом) бэкенд-валидация
_config_wall_segment_invariants отклоняет — воспроизведено исполнением и в
TS, и в Python. Ни один из прогнанных гейтов (unit, смоки, golden,
model-invariants) этот путь не покрывает; единственный тест, который мог
бы его поймать (test_unhosted_contour_opening_is_a_valid_degraded_v9_state),
случайно обходит деградированный пул стороной из-за несовпадения угла в своей
фикстуре. Возврат автору для исправления материализации host в §3.3 (или
замены на прямой переход к непривязанному состоянию) и правки формулировки
CHANGELOG (M1) следом.
Вердикт: красный · заход r1 · блокирующих циклов код-ревью 1/4 · High: 1 · Medium: 1 → в задаче