Files
houseplan-card/docs/reviews/CODE-REVIEW-282-r3.md
T
2026-08-25 23:49:05 +00:00

323 lines
31 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-r3
- **Issue:** #282 — стабильная идентичность сегментов стен (ADR Stage 1)
- **Ветка:** `issue/282-wall-geometry-model`, HEAD `95fa23ca79db830b201e82d9a34a05dcc3cecb29` (после ребейза на `origin/dev` `5f0a7f5` — по словам автора конфликтов не было; проверено: `git merge-base --is-ancestor origin/dev HEAD` — true, ветка целиком впереди `dev`)
- **ТЗ:** `docs/specs/282-stable-wall-segment-identity.md` (spec-review зелёный на r2, `2f30c481`; в этом раунде правка только строки статуса — см. §1)
- **Этап:** код-ревью (PROCESS.md §2.7)
- **Заход:** r3 · блокирующих циклов израсходовано 2 из 4 до этого вердикта
- **Предыдущий раунд:** `docs/reviews/CODE-REVIEW-282-r2.md`, красный, HEAD на тот момент `3588284f` (после итогового ребейза переименован без конфликта в `0f54daf3` — коммит `git show 0f54daf3` содержит буквально текст «preserve passage validation during wall migration», совпадающий с описанием фикса в исходном r2-документе; сам документ r2 закоммичен в `c75c77d5`)
- **Вердикт:** **красный**
## 1. Скоуп проверки
Раунд не расширяется до полного повторного разбора всей подсистемы: финальный
ребейз на `dev` `5f0a7f5` прошёл **без конфликта** (в отличие от двух
предыдущих раундов, где конфликт вносил новый код прямо в identity-барьер и
поэтому требовал полного разбора). Дельта r2→r3 (`0f54daf3..HEAD`, что то же
самое, что `c75c77d5..HEAD` за вычетом самого коммита с документом r2) — 5
коммитов, 17 файлов без учёта review-документа:
```
demo/smoke_near_axis_optimize.mjs, demo/smoke_openwall.mjs,
demo/smoke_optimize_coincident_partition.mjs,
demo/smoke_resize_outer_reconciliation.mjs,
demo/smoke_resize_virtual_thick.mjs, demo/smoke_sun.mjs,
demo/smoke_wall_thickness.mjs (новый),
custom_components/houseplan/frontend/houseplan-card.js, dist/houseplan-card.js,
docs/CHANGELOG.md, docs/CHANGELOG.ru.md, docs/images/screenshots.json,
docs/specs/282-stable-wall-segment-identity.md,
src/houseplan-card.ts, src/space-render.ts,
test/open-passage-contract.test.mjs, test/wall-segment-model.test.mjs
```
Разобрана полностью именно эта дельта: обе находки r2 (H1, H2) закрыты в ней
же (коммит `20f5c396` = переименованный `bcf8089`), и следом идёт
самостоятельный коммит `cfc1bb06` («fix: preserve migrated wall interactions»)
с продуктовым кодом в `src/houseplan-card.ts`, не связанным ни с одной находкой
r1/r2 — по методологии §2.10 такой код в дельте разбирается так же строго, как
если бы он пришёл первым разом.
Остальной ~60-файловый диапазон диффа #282 (миграция, backend-зеркало,
import/export, i18n, документация подсистемы, lineage-логика
`wall-segment-model.ts`, mutation-gate) дельтой не затронут — наследуется из
r2 (§6 ниже), она сама наследовала это из r1.
## 2. Как проверялось — таблица гейтов
| Гейт | Команда | Результат |
|---|---|---|
| Typecheck | `npx tsc --noEmit` | зелёный |
| Build + sync бандла | `npm run build && npm run bundle:sync` | зелёный; дерево осталось чистым (`git status` пусто) — предъявленный бандл побайтово совпадает с пересобранным из исходников на HEAD |
| Unit/frontend | `npm test` | 1335 тестов, **1334 passed, 0 failed, 1 skipped** — H1(r2) закрыт, регрессий нет |
| Docs fingerprint | `node scripts/check-docs.mjs` | зелёный (7 файлов, 10 внешних ссылок) — обязателен, дельта трогает `src/**` |
| Model invariants (синтетика) | часть `npm test` | зелёный |
| Model invariants (реальная нагрузка) | `node scripts/model-invariants.mjs --config <large-house dump, v7 и после commitWallSegmentModel>` | зелёный до и после миграции: «ссылки разрешимы, записи толщины находятся» |
| Model invariants — решётка | `node scripts/model-invariants.mjs --config <large-house v8> --lattice` | 0.00% шума у узла на всех классах объектов (room/partition/wall/column/opening/decor) |
| Smoke selection (delta-scoped) | `node scripts/smoke-select.mjs --base c75c77d5 --head HEAD` | 7 прямых совпадений, 0 зарегистрированных, 0 слабых — см. §4 разбор по каждой строке |
| Целевые смоки дельты | `node demo/smoke_{junction_holes,active_chain_ink,drag_bounds,grid_snap,infinite_canvas,sun,wall_union_isolation}.mjs` | все 7 — **OK** |
| Смоки, изменённые самой дельтой | `node demo/smoke_{openwall,resize_virtual_thick,near_axis_optimize,optimize_coincident_partition,resize_outer_reconciliation,wall_thickness}.mjs` | все 6 — **OK** |
| AC7-евиденс смок (не в дельте, но прямо касается найденного) | `node demo/smoke_open_passage.mjs` | формально **OK** (все 13 проверок), но см. H1 — сама проверка `staticCutsAndFillsPassage` не способна обнаружить найденный дефект (проверяет наличие элементов, не форму пути) |
| Целевой диагностический прогон (не входит в проектный набор) | см. H1: точечный запуск `wallBodiesUnionPath` с продукционными константами + модифицированная **локальная копия** `smoke_open_passage.mjs` (не коммитилась, лежит вне репозитория) | воспроизводит дефект детерминированно, дважды |
| CI на точном SHA | `gh run view 32910611706` + `gh run view 32911155216` (`Matysh/houseplan-card`) | первый прогон: `frontend/hacs/hassfest/backend/performance_smoke/golden/smoke(1-3)` зелёные, `process-gate` красный (инфраструктурная причина — force-push обнулил `before`); второй прогон: `process-gate` зелёный, тяжёлые джобы легитимно reused (побайтово те же входы, механизм #208) |
| Mutation gate | не перепрогонял отдельно — дельта не трогает ни один из трёх поименованных `wall-identity-*`-мутантов (файлы `plan-optimizer.ts`/history-restore/editor-commit не в дельте); автор заявляет 226/226 на этом же HEAD | принято из хендоффа автора, не переисполнено мной |
| `pytest tests_backend` | не прогонялся — среда без `homeassistant` (как в r2 §8) | backend в дельте не тронут вовсе (все 17 файлов — frontend/demo/docs) — не требуется в этом раунде |
| Полный `performance_smoke`, `golden:verify` локально | не прогонялся отдельно | пре-релизный гейт; CI-прогон (см. выше) покрывает golden/performance на точном SHA |
Дешёвые гейты и все целевые смоки дельты — зелёные. Но целевой AC7-evidence
смок, при точечной проверке глубже штатной ассерции, обнаруживает находку H1.
## 3. High — блокирующие находки
### H1. AC7 всё ещё нарушен: Static-карточка рисует сплошную стену прямо по месту прохода после миграции в v8
**Файл:** `src/space-render.ts:437` (`renderSpaceStatic`, канонический геометрический
union стен для Static-карточки) — строка **не входит в дельту r2→r3** (не
менялась ни в `20f5c396`, ни в любом более раннем коммите #282), но её
поведение изменилось из-за самой Stage 1: до миграции проход без хозяина не
имел `opening.host` вовсе; после миграции в v8 **любой** не-partition opening
получает `host: {kind:'wall', id, t}` (`hostRoomOpenings`,
`src/wall-segment-model.ts:531-557`, не тронут дельтой, но это и есть
источник новых входных данных для строки 437).
```ts
// src/space-render.ts:434-443 (без изменений в этой дельте)
const canonicalWallGeometry = needsCanonicalWallGeometry
? cachedStaticWallGeometry(o.cfg, space.id, wallGeometryFingerprint, () => wallBodiesUnionPath(
space.rooms, walls, [], [
...staticPassages.filter((opening) => !opening.host).map((opening) => ({
x: opening.rx, y: opening.ry, angle: opening.angle, length: opening.rlen,
})),
...hostedCompositeOpenings,
], GRID_STEP_N, cellCm, GRID_PITCH, NORM_W, extras,
))
: null;
```
Ровно двумя строками выше, в этой же дельте, H2(r2) закрыт правильным
паттерном: `opening.host?.kind !== 'partition'` / `!opening.host ||
opening.host.kind === 'wall'` (строки 221, 279). Строка 437 использует старый
одноусловный `!opening.host` — единственное оставшееся место в файле с этим
паттерном (проверено `grep -n "\.host\b" src/*.ts`: везде, где встречается
`opening.host`/`o.host` вне `space-render.ts:437`, различение
`kind === 'wall'` уже сделано — `plan-geometry-preflight.ts:207,259`,
`houseplan-card.ts:9204`, и сам `space-render.ts:221,279`).
**Последствие:** после того как пространство мигрирует в v8 (любое структурное
редактирование, Optimize, либо просто открытие Plan-редактора), обычный
(не-partition) `passage` получает `host.kind === 'wall'` и **выпадает** из
списка вырезов, который `wallBodiesUnionPath` вычитает из сплошной кладки —
масонри рисуется **сплошным** прямо по месту прохода. Декоративная заливка
тоннеля (`passageDataTunnels`, не затронута багом) продолжает рисоваться, но
раньше в DOM-порядке (`space-render.ts:540/544` против `545` для
`.wallbodies`) — сплошная стена рисуется поверх неё. Проход, который ТЗ
называет «negative architecture» (комментарий в самом
`demo/smoke_open_passage.mjs:1-3`), визуально становится обычной глухой
стеной на Static-карточке.
`showBorders` (стены вообще рисуются на Static) по умолчанию **включён**
именно для пространств без залитого plan-изображения (`src/logic.ts:1265`,
`showBorders: s.show_borders ?? noPlan` — «spaces without a plan default to
visible markup») — то есть для основного онбординг-сценария J4
(`docs/SCOPE.md`: «от нуля до плана за вечер... draw»), не для редкого угла.
**Доказательство (исполнено, не только прочитано):**
1. Точечный прогон чистой функции с продукционными константами проекта
(`GRID_STEP_N=1/240`, `GRID_PITCH=1000/240`, `NORM_W=1000`), скомпилированной
тем же `tsc -p tsconfig.test.json`, что используют штатные unit-тесты:
```
wallBodiesUnionPath(rooms, walls, [], [passage], ...).d
→ "M 491.67 100 L 508.33 100 L 508.33 455 L 491.67 455 Z
M 491.67 545 L 508.33 545 L 508.33 900 L 491.67 900 Z" (разрыв есть)
wallBodiesUnionPath(rooms, walls, [], [], ...).d
→ "M 491.67 100 L 508.33 100 L 508.33 900 L 491.67 900 Z" (сплошная стена)
```
Присутствие/отсутствие объекта прохода в 4-м аргументе детерминированно
решает, будет ли в стене разрыв — то есть исключение `host.kind==='wall'`
этим фильтром гарантированно убирает разрыв.
2. Полный браузерный прогон: **локальная копия** (вне репозитория, не
коммитилась и не будет) `demo/smoke_open_passage.mjs` с одной добавленной
диагностикой — чтением `d` у реального `<path>` внутри `.wallbodies` на
том же самом fixture, что и штатный смок (проход `passage-main`,
`x=0.5,y=0.45,length=0.1`, стена `cm:20`, после реального
`_saveOpening()` → `_commitPhysicalGeometry` → v8), собранная тем же
`npm run build && npm run bundle:sync`, что и остальные гейты этого
раунда:
```
"wallbodyPathCount": 2,
"wallbodyD": "M 491.67 200 L 508.33 200 L 508.33 700 L 491.67 700 Z
|| M 491.67 200 L 508.33 200 L 508.33 700 L 491.67 700 Z",
"wallVerticesNearPassage": []
```
Оба пути — сплошной прямоугольник от y=200 до y=700, разрыва у y=450
(центр прохода) нет. Воспроизведено дважды, детерминированно.
3. Почему штатный `smoke_open_passage.mjs` (тот, что числится evidence AC7 в
самом ТЗ, §-«Доказательство: source guard + projection snapshots +
existing geometry suite») это пропускает: его единственная проверка формы
— `staticCutsAndFillsPassage = !!staticRoot.querySelector('.wallbodies') &&
!!staticRoot.querySelector('.static-opening-tunnels [data-kind="passage"]')`
— оба селектора существуют независимо от того, вырезана ли стена: группа
`.wallbodies` рендерится, если есть стены вообще, а тоннель считается по
отдельному, не затронутому багом пути (`passageTunnelGeometry`, строки
444-452, использует нефильтрованный `staticPassages`). Проверка смотрит на
присутствие DOM-узлов, а не на геометрию пути — она не может упасть на
этом классе дефекта ни сейчас, ни раньше.
**Дополнительно:** `docs/CHANGELOG.md`/`.ru.md`, добавленные тем же коммитом
`20f5c396`, который закрывал H2(r2), формулируют это как решённое: «The
compact space card keeps wall-hosted doors, windows, gates **and passages**
visible after the migration as well» / «...двери, окна, ворота **и проходы**,
привязанные к стенам». Для дверей/окон/ворот это верно (они не проходят через
`wallBodiesUnionPath` вовсе — Static рисует их старым символьным путём).
Для проходов утверждение неточное: символ и тоннель-заливка видимы, но
физический вырез в кладке — нет.
**Итог:** прямое нарушение AC7 («projection даёт текущим full, static...
consumers прежние inputs»; сам ТЗ называет доказательством именно
«existing geometry suite», и это ровно тот тест, который проверка выше не
способна исполнить содержательно) на документированной поверхности продукта
(`houseplan-space-card`, `ARCHITECTURE.md` «Second card», с v1.16.0),
конфликт с J1/J2 `docs/SCOPE.md` (план должен показывать, что происходит и
где — «negative architecture» превращается в глухую стену), тот же класс
дефекта, что и H2(r2), только один код-путь глубже в той же функции. Блокирует.
## 4. Medium — по существу задачи
Новых находок Medium нет. Мелкое наблюдение без действия: `smoke_near_axis_optimize.mjs`
ослабил проверку точного соответствия индексов вершин (`north.poly[1] ===
south.poly[0]`) до `.some(point => samePoint(...))` — без сопроводительного
комментария, чем вызвано изменение порядка точек. Не поднимаю до находки:
сама геометрическая позиция всё ещё проверяется байт-точно, теряется только
проверка порядка индексов, который не является частью ни одного AC.
## 5. Закрытие раунда r2
| Находка r2 | Чем закрыта | Где это видно |
|---|---|---|
| **H1** — `npm test` красный: `validate_opening_passages(` встречается 3 раза в `websocket_api.py`, тест ожидал 2 | `test/open-passage-contract.test.mjs:76-82` теперь ожидает 3 и явно матчит оба вызова (до и после барьера v7→v8) по тексту | `npm test` → 1334 passed / 1 skipped / **0 failed** (было 1329 passed / 1 failed) — перепрогнано мной |
| **H2** — AC7: `houseplan-space-card` теряет wall-hosted opening (символ/материализация) после v8 | `resolvedHosted` теперь `if (opening.host?.kind !== 'partition') return []`; `resolvedRawOpenings` теперь `if (!opening.host \|\| opening.host.kind === 'wall') return [opening]` | `src/space-render.ts:217-282`; `node demo/smoke_open_passage.mjs` → `staticDoesNotInventPassageSymbol`/`staticCutsAndFillsPassage` OK — перепрогнано мной. **Но** соседняя строка 437 этой же функции, отвечающая за физический вырез кладки под проходом, не была приведена к тому же паттерну — см. H1 этого раунда: класс дефекта H2(r2) закрыт не полностью, только для символа/материализации, не для геометрии кладки |
Находка закрыта частично: правка правильная и достаточная для того, что она
адресовала (материализация/символ), но не покрывает соседний код-путь той же
функции, потребляющий тот же новый `host.kind === 'wall'` — ровно та
проверка, которую сам документ r2 в итоговом абзаце (§9) явно рекомендовал:
«стоит проверить на этом же файле и другие пути, потребляющие opening.host…».
## 6. Унаследовано из r2
Дельта `0f54daf3..HEAD` не касается — принято на основании выводов r2
(`docs/reviews/CODE-REVIEW-282-r2.md`, HEAD на котором получен вывод —
`3588284f`/переименован в `0f54daf3`), которая сама наследовала это из r1
(`5adfdb5d`/`466a6765`):
- **Migration determinism/idempotence**, **backend-зеркало валидации**
(unique-id, owner-count, dangling refs, edge-geometry parity,
`_wall_catalog_projection`), **import/export remap**, **i18n-ключи**
(`toast.wall_model_migration_blocked`, `toast.wall_model_client_outdated`,
`gs.wall_segments_migrated`, `wall_model.reason.*`), **документация**
(`WALL-THICKNESS.md`, `CANVAS.md`, `USER-GUIDE.{ru,}.md`, `TESTING.md`),
**`config-field-registry.mjs`**, **три `wall-identity-*` мутанта
mutation-gate**, **lineage hints** (`fixedTopologyWallLineageHints`),
**off-grid guard база** (`wallModelOffGridValueCount`, до добавления
`additionalAuthoredPoints` в этой дельте) — не в дельте r2→r3 (проверено
`git diff --stat c75c77d5..HEAD`, приведён в §1), наследуется без
переразбора.
- **Guard-порядок #278**, **8 именованных structural-smoke из H2(r1)** — файлы
не в дельте r2→r3, наследуются из r2 §5/§6 (которая сама перепроверила их
исполнением на своём HEAD).
Обоснование: находка этого раунда (H1) обнаружена не потому, что унаследованный
код изменился, а потому, что дельта r2→r3 (миграция `host.kind==='wall'`,
действующая с самого Stage 1, то есть с r1) впервые была прослежена до всех
потребителей `opening.host` в файле, который сама дельта модифицировала —
это ревизия по следу дельты (§2.10), а не расширение на несвязанный код.
## 7. Что проверено и корректно
- **Sun rays после структурного редактирования** (`cfc1bb06`,
`src/houseplan-card.ts:15465-15471`) — фильтр окон-источников солнца
заменён с `!o.host` на `o.host?.kind !== 'partition'` по той же причине,
что и H2(r2); прочитано и подтверждено прогоном: `node demo/smoke_sun.mjs`
→ все проверки OK, включая переписанные `aboveThresholdDrawn`/
`backAboveThreshold` (теперь защищены от `null` через `layer()?.`).
- **`additionalAuthoredPoints` в `_commitPhysicalGeometry`**
(`src/houseplan-card.ts:7296-7367`) — открытая пользователем граница
добавляет свои же конечные точки в список «авторских» точек до сравнения
off-grid-счётчика, так что открытие границы на уже мигрированном плане не
выглядит как рост шума и не блокируется собственным guard-ом; прочитано,
подтверждено `node demo/smoke_openwall.mjs` → OK (все проверки), включая
переработанный сценарий второго открытого пролёта через инструмент
`boundary`, а не через прямую инъекцию `open_to`.
- **Явный ноль толщины не воскресает** (`src/houseplan-card.ts:12002-12010`,
зеркалирование `next`→`wall_segments[].cm` до `_normalizeWalls`) — новый
unit-тест `test/wall-segment-model.test.mjs` («an explicit canonical zero is
not resurrected…») зелёный в составе `npm test`; логика прочитана и
соответствует комментарию в коде.
- **Обновлённые смоки редактирования** (`smoke_resize_virtual_thick`,
`smoke_near_axis_optimize`, `smoke_optimize_coincident_partition`,
`smoke_resize_outer_reconciliation`, `smoke_wall_thickness`) — изменения
адаптируют ассерции к тому, что после Optimize/Resize пространство законно
становится v8 и все openings несут `host.kind==='wall'`; новый предикат
`wallHostResolves` проверяет не только присутствие `host`, но и то, что он
реально резолвится к существующему `wall_segments[]` — это усиление
проверки, не ослабление; все 6 файлов перепрогнаны мной, зелёные.
- **Model invariants на реальной нагрузке** (`large-house`, 3486 координат) —
0% шума у узла до и после `commitWallSegmentModel`, ссылки разрешимы —
перепрогнано мной с нуля (собственный дамп фикстуры), не только принято из
хендоффа автора.
- **Трейлеры** — все 5 коммитов дельты (`20f5c396`, `cfc1bb06`, `ad54716d`,
`063026bc`, `95fa23ca`) несут `Issue: #282`; два `User-Visible: yes`
(`20f5c396`, `cfc1bb06`) правят оба changelog в том же коммите (проверено
`git show --stat` на каждом).
- **CI на точном SHA** — смоки (3 шарда), golden, performance, backend,
frontend, hacs, hassfest зелёные (первый прогон, за вычетом инфраструктурного
сбоя `process-gate` от force-push); повторный прогон закрыл `process-gate`
и легитимно переиспользовал тяжёлые джобы (побайтово идентичные входы).
## 8. Чего не проверял и почему
- **`pytest tests_backend`** — не запускался: backend не входит в дельту
r2→r3 вовсе (все 17 изменённых файлов — frontend/demo/docs), не требуется
в этом раунде; полный HA-харнесс остаётся Linux CI-гейтом.
- **Mutation gate, полный `performance_smoke`, `golden:verify` локально** —
не перепрогонялись мной отдельно: ни один из трёх поимённых
`wall-identity-*` мутантов не задет дельтой (файлы вне диффа), а
golden/performance покрыты зелёным CI-прогоном на точном SHA (§2) — считаю
это достаточным для этих трёх пунктов в данном раунде.
- **7 «слабая связь» смоков** (`smoke_align_guides` и другие, связь только по
`_path`) из полного `origin/dev..HEAD` смок-подбора — не прогонялись:
методология не изменилась с r2, найденная блокирующая находка (H1) уже
достаточна для возврата, а сама находка обнаружена не через этот список, а
через целевой разбор дельты.
- **Глубокий аудит остальных потребителей `.host` за пределами
`space-render.ts`** (hidden-isometric, `iso-openings.ts`) — точечно
проверено (`grep` не находит там обращений к `.host` вовсе — изометрия
потребляет уже резолвленные `_openingsR` из `houseplan-card.ts`, где паттерн
подтверждённо корректен ещё с r2), но не воспроизведено исполнением
отдельным браузерным прогоном — риск ниже, чем у Static, так как код там
не содержит собственного `!opening.host`-фильтра.
## 9. Резюме
Красный вердикт, третий подряд, но предметно иной находке, чем r1/r2: обе
находки r2 (H1, H2) закрыты — H1 полностью и подтверждено исполнением
(`npm test` зелёный), H2 закрыт для символа/материализации, но не для
физического выреза кладки под проходом в той же функции того же файла.
Именно этот непокрытый код-путь — `src/space-render.ts:437` — воспроизведён
исполнением (не только прочитан) как реальный дефект: Static-карточка рисует
сплошную стену прямо по месту прохода на любом пространстве, мигрировавшем в
v8, если проход не привязан к перегородке. Это прямое нарушение AC7 задачи на
документированной, реально используемой поверхности продукта, в сценарии, где
стены по умолчанию видимы (J4, безкартиночный план).
Задача возвращается в «В разработке». Фикс структурно локален (тот же паттерн
`opening.host?.kind !== 'partition'` уже применён двумя строками выше в этом
же файле) и не должен требовать полного повторного разбора подсистемы в r4,
если дельта останется ограничена этой строкой и её прямыми следствиями —
но стоит одновременно перепроверить, не остался ли где-то в проекте ещё один
потребитель `opening.host` с тем же устаревшим одноусловным `!opening.host`
(в этом раунде такой список исчерпан `grep`, но `grep` не ловит эквивалентную
логику, выраженную иначе).