Files
houseplan-card/legacy/reviews/v1.68.0/CODE-REVIEW-316-r1.md
T
Claudeandclaude[bot] 0991c45374 fix(tools): архив переписывает относительные ссылки перенесённых документов (#682)
Ревью #682 r1, Medium: перенос добавляет документу уровень вложенности
(`docs/reviews/X.md` → `legacy/reviews/<тег>/X.md`, `docs/specs/` →
`legacy/specs/`), а относительные ссылки внутри перенесённых документов и в
соседях, ссылавшихся на них, никто не пересчитывал — на `97d19268` 53 битые
ссылки в 46 файлах (заявление «все 26 резолвятся» в `7feb6177` было верно
только до переноса документов ревью). Гейты архив не смотрят.

`reviews-archive.mjs`: `repairLinks` пересчитывает ссылку, если она не
резолвится от нового места, а цель находится от нового или старого места
через карту переносов; битая и до переноса ссылка не трогается. `--apply`
делает это само, `--repair-links=<rev>` — для всех переименований
`<rev>..HEAD`, `--check-links` печатает битые. Этим коммитом
`--repair-links=origin/dev` переписал ровно 53 ссылки в 46 файлах; остались
две прежние «...»-заглушки в CODE-REVIEW-448-r2 (битые и на dev). Тесты:
перенесённый документ, сосед со ссылкой в архив, ТЗ со ссылкой на позже
перенесённое ревью, битая-до-переноса не трогается, в `legacy/` битых нет;
мутант `reviews-archive-links-from-new-place-only`. PROCESS §2.10 и
DEVELOPMENT › Release называют переписывание и `--check-links`.

Issue: #682
User-Visible: no
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018qZfe7YS4rqEMKoVeS3GKd
2026-09-27 22:10:47 +00:00

32 KiB
Raw Blame History

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 → в задаче