Files
houseplan-card/docs/reviews/CODE-REVIEW-282-r2.md
T
2026-08-26 02:21:15 +03:00

313 lines
30 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-282-r2
- **Issue:** #282 — стабильная идентичность сегментов стен (ADR Stage 1)
- **Ветка:** `issue/282-wall-geometry-model`, HEAD `3588284f` (после ребейза на `origin/dev` `30698d6e` — merge-base подтверждён, ветка полностью впереди `dev`)
- **ТЗ:** `docs/specs/282-stable-wall-segment-identity.md` (spec-review зелёный на r2, `2f30c481`)
- **Этап:** код-ревью (PROCESS.md §2.7)
- **Заход:** r2 · блокирующих циклов израсходовано 1 из 4 до этого вердикта
- **Предыдущий раунд:** `docs/reviews/CODE-REVIEW-282-r1.md`, красный, HEAD на тот момент `5adfdb5d` (SHA назван в документе — не находка). Этот SHA более не существует в истории ветки: после r1 ветка дважды уходила на ребейз (issue-комментарии `#282`, 19:33 и 21:12/21:21), причём второй ребейз (на `dev` `30698d6e`) потребовал реального разрешения конфликта — по словам автора, «post-rebase fixes also make direct backend Optimize cross the v7→v8 barrier, retain authored active-chain coordinates during conversion, and keep a rejected wall transaction atomic» — то есть новую продуктовую логику, а не только смену родителя.
- **Вердикт:** **красный**
## 1. Скоуп проверки
Раунд не локален по критериям PROCESS.md §2.10: ребейз на ушедший вперёд `dev`
с конфликтом, разрешённым новым кодом в самом identity-барьере — ровно та
подсистема, где r1 нашёл H1/H2. Поэтому разбор полный, но выполнен по факту
через дельту `9fc213c6..HEAD` (27 файлов, +1793/−1020: коммит `9fc213c6` — это
сам документ `CODE-REVIEW-282-r1.md`, его родитель `466a6765` — переименованный
рикбейзом эквивалент `5adfdb5d`, на котором получен вердикт r1). Дельта
покрывает: `src/houseplan-card.ts` (`_commitPhysicalGeometry`,
`_applyGeometryState`), `src/wall-segment-model.ts` (lineage hints, off-grid
guard), `src/plan-geometry-preflight.ts`, backend
`validation.py`/`wall_segment_model.py`/`websocket_api.py`, тесты и
mutation-gate. Остальные ~45 файлов исходного 62-файлового диффа r1 (import/export
remap, i18n, документация, backend-зеркало валидации помимо перечисленного)
дельтой не задеты — раздел «Унаследовано из r1» ниже.
## 2. Как проверялось — таблица гейтов
| Гейт | Команда | Результат |
|---|---|---|
| Typecheck | `npx tsc --noEmit` | зелёный |
| Unit/frontend | `npm test` | 1331 тестов, **1329 passed, 1 failed, 1 skipped** — см. H1 |
| Build + sync бандла | `npm run build && npm run bundle:sync` + `cmp` трёх копий | зелёный, `dist/`, `custom_components/houseplan/frontend/`, `demo/srv/assets/` побайтово идентичны |
| Docs fingerprint | `node scripts/check-docs.mjs` | зелёный (7 файлов, 10 внешних ссылок) — обязателен, дельта трогает `src/**` |
| Model invariants (синтетика) | часть `npm test` (`test/model-invariants.test.mjs`, `test/wall-segment-model.test.mjs`) | зелёный |
| Model invariants (реальная нагрузка) | `node scripts/model-invariants.mjs --config <large-house.mjs, до и после `commitWallSegmentModel`>` | зелёный до миграции («ссылки разрешимы, записи толщины находятся»), зелёный после; `--lattice`: 0.00% шума у узла |
| Smoke selection | `node scripts/smoke-select.mjs --base origin/dev --head HEAD` | 31 прямое совпадение, 5 зарегистрированных связей, 7 слабых — вывод см. §4 |
| Golden | `npm run golden:verify` | **зелёный**, 129/129 сценариев, в т.ч. оба сценария из M1(r1) |
| Mutation gate | `node scripts/mutation-gate.mjs --check` | **зелёный**, 226/226 мутантов пойманы, включая 3 мутанта #282 (editor-commit/history-restore/optimize barrier) |
| Named/registered smokes | 31 прямых + 5 зарегистрированных = 36 запущено | **1 красный** (`smoke_open_passage.mjs`) — см. H2, остальные 35 зелёные |
| Backend pure (`tests_backend`) | не прогонялся мной — среда без `homeassistant`/`voluptuous`/`.venv-backend` (см. §6) | проверено чтением диффа `validation.py`/`wall_segment_model.py` + нового backend-теста, не исполнением |
| `pytest tests_backend` (полный, Linux CI) | не прогонялся | пре-релизный гейт, вне объёма код-ревью (§8) |
| Performance-профили (полный `performance_smoke`) | не прогонялся | пре-релизный гейт; целевой `npm run benchmark:wall-model` дельтой не задет (файл вне диффа) |
Дешёвые гейты (`tsc`, `build`, `check-docs`) зелёные. Но `npm test` сам по себе
**красный** — это не пре-релизный, а обязательный на каждом раунде гейт (§8,
§2.10), и он не проходит на предъявленном HEAD. Расширенные гейты (invariants,
smoke-select, golden, mutation-gate, целевые smoke) прогнаны, потому что дельта
меняет геометрию/ссылки на неё и барьер идентичности — именно то сочетание,
для которого §8 требует их «по необходимости». Golden и mutation-gate теперь
полностью зелёные (закрывают M1(r1) и M2(r1) — см. §5 «Закрытие раунда r1»); но
один из 36 запущенных смоков нашёл новую, не связанную с r1 регрессию (H2).
## 3. High — блокирующие находки
### H1. `npm test` красный: диагностика больше не соответствует контракту, который сама же проверяет
**Файл:** `test/open-passage-contract.test.mjs:70-74`, тест «all write/import
paths invoke the semantic passage validator».
**Воспроизведение:**
```
npm test
# ...
not ok 688 - all write/import paths invoke the semantic passage validator
Expected values to be strictly equal:
3 !== 2
location: test/open-passage-contract.test.mjs:71
```
Тест жёстко ожидает ровно 2 вхождения `validate_opening_passages(` в
`custom_components/houseplan/websocket_api.py`. Последний коммит дельты,
`3588284f` («fix: preserve passage validation during wall migration»),
осознанно добавил третий вызов — `validate_opening_passages(candidate_config,
config_data.get("config"))` в `ws_plan_optimize` **до** барьера
`commit_wall_segment_model`, рядом с уже существующим вызовом **после** барьера
(строки 1655 и 1672; третий, старый вызов на 1327 — в `ws_config_set`). Это
осмысленное и корректное по существу исправление (см. §5, closure M-out
comment #16 в issue): без него Optimize возвращал общий
`wall_model_migration_blocked` вместо конкретного
`invalid_passage_fields`-кода на конфиге с некорректным passage ещё до миграции.
Но тест, фиксирующий количество мест вызова, не обновлён вместе с фиксом — он
писался под контракт «ровно 2 вызова» и не отражает намеренно добавленный
третий.
Это прямое нарушение дисциплины: `npm test` — гейт, обязательный **в каждом
раунде** (§2.10, §8), и он сейчас красный на предъявленном коде. Хендофф-
комментарий автора к `3588284f` (issue, 22:04:14) называет только узкий прогон
14 pure-passage-кейсов и process-gate, не полный `npm test` — расхождение
между «verified» и фактическим состоянием gate.
**Доказательство:** тест уже красный на HEAD, механически (`node --test
test/open-passage-contract.test.mjs`); падение детерминировано, не флуктуация.
### H2. AC7 нарушен: opening на обычной стене исчезает со Static-карточки после миграции в v8
**Файл:** `demo/smoke_open_passage.mjs` — не тронут этой дельтой (0 строк в
`git diff --stat origin/dev...HEAD`). Корень — `src/space-render.ts:217-278`
(`renderSpaceStatic`, единственный потребитель — `space-card.ts:770`, т. е.
`houseplan-space-card`, документированная в `ARCHITECTURE.md` «Second card»);
файл не тронут диффом #282 вовсе (`git diff --stat origin/dev...HEAD --
src/space-render.ts` → пусто).
**Воспроизведение:** на `origin/dev` (`30698d6e`, отдельный git-worktree, тот
же `npm run bundle:sync`):
```
node demo/smoke_open_passage.mjs → OK (все 13 проверок true)
```
На HEAD (`3588284f`), дважды подряд, детерминированно:
```
"staticCutsAndFillsPassage": false
FAILED (1)
```
Целевая диагностика (см. приложенный debug-прогон) показывает точный механизм:
после того как `_saveOpening()` проводит объект через
`_commitPhysicalGeometry` (v7→v8 baseline-replay из `c694f6ea`), опция
`passage-main` корректно получает `host: {kind:'wall', id:'wall-…', t:0.5}` —
барьер `hostRoomOpenings` (`src/wall-segment-model.ts:530-556`) тегирует
**любой** незакреплённый за partition opening (door/window/gate/passage
одинаково, без разбора по типу) таким `host` при миграции. Но
`renderSpaceStatic` (`space-render.ts:217-222`, `resolvedHosted`) резолвит
`opening.host` только через `resolvePartitionOpeningCompat` — функцию,
понимающую исключительно `host.kind === 'partition'`. Для нашего
`host.kind === 'wall'` резолюция не находится, `resolvedHosted` пуст для этой
записи, и `resolvedRawOpenings` (строки 274-278) на строке `return resolved ?
[materializePartitionOpening(...)] : []` **молча роняет** opening из всего
дальнейшего рендера Static-карточки — не только из tunnel-заливки прохода, а
из любой отрисовки (символ, вырез в стене, hit-box).
Сам код `src/houseplan-card.ts` (полный/Plan/View card) везде корректно
различает `host?.kind === 'partition'` от прочих случаев (8295, 8325, 8480,
12766, 12804, 12958) — регрессия локализована именно в `space-render.ts`,
единственном месте, которое ADR Stage 1 должен был обновить и не обновил, хотя
сам ТЗ прямо обещает это в AC7: «v8 → legacy… projection даёт текущим full,
**static**… consumers прежние inputs» с доказательством «projection snapshots
+ **existing geometry suite**» — а именно этот existing-geometry смок
(`smoke_open_passage.mjs`, часть suite с #157) и ловит нарушение AC7.
Blast radius не ограничен синтетическим passage из смока: `hostRoomOpenings`
единообразно тегирует **все** типы openings без partition-host при миграции,
значит после Optimize/любой структурной правки, переводящей пространство в
v8, **любой** door/window/gate/passage на обычной (не-partition) стене
перестаёт отображаться на `houseplan-space-card` — символ, вырез, hit-box.
Это прямой конфликт с J1/J2 из `docs/SCOPE.md` (план должен показывать, что
происходит и где) на реальной, документированной с v1.16.0 поверхности
продукта.
**Доказательство:** тест умеет падать — зафиксирована пара «зелёный на
`origin/dev` `30698d6e` / красный на HEAD `3588284f`» на байт-идентичном файле
теста, дважды воспроизведено. Причина локализована до конкретных строк (в
отличие от недиагностированных H1/H2 из r1) через целевой debug-прогон,
приложенный к разбору находки, а не только «зелёный/красный».
**Итог по H1+H2:** оба блокируют. H1 — процессный, лечится синхронизацией
числа в тесте с фактическим количеством вызовов (тривиально, но не сделано).
H2 — продуктовый: явное нарушение собственного AC7 задачи, широкий blast
radius на документированной поверхности продукта, которую эта дельта не
касалась и не тестировала.
## 4. Medium — по существу задачи
Новых находок Medium в этом раунде нет. Все четыре Medium из r1 закрыты — см.
§5 «Закрытие раунда r1». Отдельных находок вне скоупа нет — новый issue не
заводился.
## 5. Закрытие раунда r1
| Находка r1 | Чем закрыта | Где это видно |
|---|---|---|
| **H1** — guard #278 не отклоняет правку деградированного пространства | `_commitPhysicalGeometry` (`c694f6ea`, `src/houseplan-card.ts:7296-7313`) теперь вызывает `_checkSpacePhysicalGeometry` на `liveCandidate` **до** прохождения identity-барьера (`legacySafe`), а не после — восстановлен исходный порядок guard-а #278 | `node demo/smoke_wall_union_isolation.mjs` → OK, все 11 проверок `true` (перепроверено мной на HEAD, включая три ранее красные: `degradedPhysicalEditRejected`, `rejectedEditHasNoHistoryOrWrite`, `rejectedEditHasLocalizedToast`) |
| **H2** — 8 именованных structural-smoke красные | Переработан `_commitPhysicalGeometry`: для v7-пространства identity выводится из pre-edit baseline (`baselineSource`/`historyBefore`), а не из уже отредактированной геометрии, плюс lineage hints (`fixedTopologyWallLineageHints`) для сохранения ID при чистом split | Все 8 смоков (`smoke_resize_pointer_real_plan`, `smoke_wall_chain_thickness`, `smoke_unified_wall_tool`, `smoke_wall_face_overlap`, `smoke_edit_walk`, `smoke_editor_tabs`, `smoke_plan_snap_overlay`, `smoke_partition_openings`) перепрогнаны мной на HEAD — все зелёные |
| **M1** — golden baseline не обновлён под AC15-строку | Baseline не потребовался: фикстура `optimize-orphan-references-*` (`demo/golden/matrix.mjs:695-720`) уже v8/canonical, `Optimize` на ней не мигрирует ни одного сегмента, `r.wallSegmentsMigrated` = 0 → строка `gs.wall_segments_migrated` не рендерится, визуального различия нет | `npm run golden:verify` → 129/129 passed, включая `optimize-orphan-references-dark-en`/`-light-ru`; `demo/golden/baselines/**` не тронут диффом (подтверждено `git log` — пусто), что согласуется, а не противоречит |
| **M2** — AST/choke-point guard уже, чем заявлено в AC5/`TESTING.md` | Добавлены два новых именованных мутанта: `wall-identity-history-restore-barrier-bypassed` (Undo/Redo restore) и `wall-identity-optimize-barrier-bypassed` (`plan-optimizer.ts`), плюс переименован исходный в `wall-identity-editor-commit-barrier-bypassed` — все три известных writer-пути теперь поимённо покрыты | `node scripts/mutation-gate.mjs --check` → 226/226 `ok`, включая все три `wall-identity-*` мутанта (перепрогнано мной) |
| **M3** — `wall_model_client_outdated` не срабатывает на реалистичном echo-сценарии | `validate_wall_model_transition` (`custom_components/houseplan/validation.py:185-217`) получил новую ветку `old_model >= 8 and new_model >= 8 and geometry_changed`, сверяющую `_wall_catalog_projection` (только `wall_segments`) — расхождение поднимает `WallModelClientOutdatedError` с текстом «unchanged wall catalogue» | Новый тест `tests_backend/test_wall_segment_model.py::test_stale_client_echoing_v8_catalog_gets_the_named_error` — прочитан, логика соответствует описанному сценарию; **не исполнен** (см. §6, нет `homeassistant`/`voluptuous` в этой среде) — проверено чтением, не исполнением |
| **M4** — регрессионный тест #248 ослаблен без обновления фикстуры | `test/fixtures/optimize-storage-roundtrip.json` обновлена под полный v8-выход, `test/plan-optimizer.test.mjs:366` вернул строгий `assert.deepEqual(first.config, storageRoundtripFixture.expected.config)` вместо частичного сравнения только `poly` | Тест прогнан мной в составе `npm test` — проходит (часть 1329 passed); дифф `test/plan-optimizer.test.mjs` (`9fc213c6..HEAD`) показывает возврат полного `deepEqual` |
Все шесть находок r1 (H1, H2, M1-M4) закрыты предметно, не декларативно. Ни
одна из них не осталась открытой или не переоткрылась при моей перепроверке.
## 6. Унаследовано из r1
Из полного 62-файлового диффа r1 (`git diff origin/dev...5adfdb5d` — уже
недоступен напрямую как SHA, но содержательно эквивалентен диапазону, который
описывает `docs/reviews/CODE-REVIEW-282-r1.md`), эта дельта не касается и
поэтому **не переразобрано**, а принято на основании выводов r1 (документ
`CODE-REVIEW-282-r1.md`, SHA на котором получен вывод — `5adfdb5d`/содержательно
`466a6765` после ребейза):
- **Migration determinism/idempotence** (`deterministicWallSegmentId`,
суффиксы при коллизии) — r1 §5, не задето дельтой (`test/wall-segment-model.test.mjs`
дельта затрагивает только новые тест-кейсы, не эту логику).
- **Backend-зеркало валидации** — unique-id, owner-count, dangling refs,
edge-geometry parity кроме нового `_wall_catalog_projection`-блока (см. §5
M3) — r1 §5, файл `wall_segment_model.py` в дельте затронут (+48/-х строк),
но это тот же lineage-код, что и H1/H2-фикс на фронтенде; backend-инварианты
(owner-count/dangling refs) не изменены текстуально в дельте.
- **Import/export remap** (`import_export.py`, `secrets.token_hex(4)`) — не в
дельте (`git diff --stat 9fc213c6..HEAD` не содержит `import_export.py`),
наследуется из r1 §5 без переразбора.
- **i18n-ключи** (`toast.wall_model_migration_blocked`,
`toast.wall_model_client_outdated`, `gs.wall_segments_migrated`,
`wall_model.reason.*`) — не в дельте, наследуется из r1 §5.
- **Документация** (`WALL-THICKNESS.md`, `CANVAS.md`, `USER-GUIDE.{ru,}.md` —
кроме `TESTING.md`, который в дельте, см. §5 M2) — наследуется из r1 §5.
- **`config-field-registry.mjs`** — не в дельте, наследуется.
- **Трейлеры и changelog структуры коммитов r1** — наследуется; трейлеры
каждого нового коммита дельты проверены заново мной отдельно (см. §7).
Обоснование, почему это не требует переразбора несмотря на общий полный охват
раунда (§1): дельта физически не касается перечисленных файлов
(`git diff --stat 9fc213c6..HEAD`, приведён в §1), а находки этого раунда (H1,
H2) обнаружены и локализованы именно в затронутых дельтой файлах
(`test/open-passage-contract.test.mjs` тестирует код `websocket_api.py`,
который в дельте; `space-render.ts` хоть и не в дельте, но находка H2 —
следствие данных, которые генерирует именно delta-код `wall-segment-model.ts`,
т.е. вызвана дельтой, а не унаследованным кодом).
## 7. Что проверено и корректно
- **Трейлеры** — все 6 коммитов дельты (`c694f6ea`, `41d7a63f`, `c24792b6`,
`35fce64a`, `38b03549`, `3588284f`) несут `Issue: #282`; четыре
`User-Visible: yes` (`c694f6ea`, `35fce64a`, `38b03549`, `3588284f`) —
каждый правит оба changelog в том же коммите (проверено `git show --stat`
на каждом).
- **Backend-барьер Optimize** (`35fce64a`) — `ws_plan_optimize` теперь ведёт
тот же v7→v8-барьер, что и frontend `_commitPhysicalGeometry`
(`commit_wall_segment_model` + `too_large`-проверка после миграции +
`WallSegmentMigrationError` в списке перехватываемых) — прочитано полностью,
логика симметрична фронтенду.
- **Guard-переупорядочивание #278** (H1(r1)) — `legacySafe`-проверка на
`liveCandidate` до барьера, restore на отказ — прочитано и подтверждено
прогоном (см. §5).
- **Lineage hints** (`fixedTopologyWallLineageHints`,
`adoptWallSegmentModelCandidateInPlace`) — прочитаны; логика различения
`preferredIds`/`positionalIds` через `collinearOverlap` (защита от
«застарелого ординала» при сплите) выглядит осмысленной и подтверждена всем
доступным smoke/mutation набором.
- **Off-grid guard с authored path** (`38b03549`) — `wallModelOffGridValueCount`
теперь учитывает `_path` (активная рисуемая цепочка) как базовую линию
роста, не как новое отклонение — юнит-тест на это добавлен и проходит.
- **Три мутанта #282** (editor-commit/history-restore/optimize barrier) —
реально пойманы (§2, §5 M2).
- **`npm run invariants`-эквивалент на реальной нагрузке** — прогнан на
`demo/fixtures/large-house.mjs` (3 пространства, 1788 записей по прошлым
измерениям Stage 0) до и после `commitWallSegmentModel`: ссылки разрешимы,
записи толщины находятся, 0% шума у узла.
- **35 из 36 запущенных смоков** зелёные (см. §2, §4).
## 8. Чего не проверял и почему
- **`pytest tests_backend`, даже pure-subset** — среда исполнения не содержит
`homeassistant`, а `custom_components/houseplan/__init__.py` безусловно
импортирует `homeassistant.components.frontend`, поэтому даже
не-`test_ha_*.py` модули (`test_validation.py`, `test_wall_segment_model.py`)
не собираются без полного HA-пакета — это не селективный пропуск
`conftest.py`, а полный отказ коллекции. `.venv-backend` в этой среде не
существует (в отличие от cloud-agent окружения, описанного в `AGENTS.md`).
M3 (§5) и общая корректность `validation.py`/`wall_segment_model.py`-диффа
проверены **чтением**, не исполнением — честно назвать это ограничением, не
«verified».
- **Полный `performance_smoke`** — пре-релизный гейт (§8); целевой
`benchmark:wall-model` дельтой не затронут (файл вне диффа), поэтому не
перепрогонялся отдельно в этом раунде — цифры из r1/хендоффа автора (167-284
мс на p95 при бюджете 500 мс) остаются в силе, так как измеряемый код не в
дельте.
- **7 «слабая связь» смоков** (`smoke_align_guides`, `smoke_card_tool_conflict`,
`smoke_editor_gestures`, `smoke_esc_dialogs`, `smoke_island_rooms`,
`smoke_optional_space_model`, `smoke_pan_any_zoom` — связь только по
`_path`) — не прогонялись: методология идентична r1 (§6 её документа) — уже
найденных блокирующих находок достаточно для возврата, и один из 36 более
сильно связанных смоков (`open_passage`) уже красный. Автору стоит
прогнать все 7 вместе с полным набором перед следующей сдачей.
- **Глубокий line-by-line аудит `buildAtoms`/lineage-логики
(`src/wall-segment-model.ts`, весь диапазон 218 изменённых строк)** — не
выполнен formal-proof, только чтение ключевых веток плюс полное
black-box-покрытие (36 смоков, 226 мутантов, 129 golden, полный `npm test`
кроме H1). Смок `smoke_open_passage.mjs` — прямое доказательство, что
black-box-покрытие не исчерпывающее; глубже эту логику не разбирал, так как
найденных H1/H2 достаточно, чтобы вернуть задачу автору (тот же принцип, что
в r1 §3 «проверять глубже уже не требуется по методологии»).
## 9. Резюме
Красный вердикт, второй подряд, но по существу другим находкам, чем r1: все
шесть находок r1 (2 High + 4 Medium) закрыты предметно и перепроверены
исполнением (кроме M3 — чтением, backend-харнесс недоступен). Однако именно
дельта, закрывшая r1, внесла две новые блокирующие находки:
- **H1** — `npm test` красный прямо сейчас: тест на количество вызовов
`validate_opening_passages(` не обновлён под намеренно добавленный третий
вызов из последнего коммита дельты. Механически тривиально, но нарушает
обязательный на каждом раунде гейт.
- **H2** — нарушение собственного AC7 задачи: `houseplan-space-card` теряет
**любой** wall-hosted opening (не только passage) после миграции
пространства в v8, потому что `src/space-render.ts` — единственный
нетронутый диффом #282 потребитель `opening.host` — не различает
`host.kind === 'wall'` от `host.kind === 'partition'` и молча роняет
нерезолвленные openings из рендера. Реальный blast radius на
документированной поверхности продукта, конфликтующий с J1/J2
`docs/SCOPE.md`.
Задача возвращается в «В разработке». Оба фикса локальны и не должны требовать
повторного полного разбора всей подсистемы в r3, если дельта останется
локальной к `websocket_api.py`-тесту и `space-render.ts`; но `space-render.ts`
до сих пор не в диффе #282 вовсе — стоит проверить на этом же файле и другие
пути, потребляющие `opening.host` (hidden-isometric, если он существует
отдельно), а не только `houseplan-space-card`.