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

349 lines
32 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-316-r1 — миграция v9 сама разрешает конфликт «проём ↔ нулевая стена»
- Issue: [#316](https://github.com/Matysh/houseplan-card/issues/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`](../../specs/316-opening-host-auto-resolution.md),
ревизия 4, ревью ТЗ зелёное — [`SPEC-REVIEW-316-r4.md`](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`,
результат идентичен):**
```js
// 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
# 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 → в задаче