diff --git a/docs/reviews/CODE-REVIEW-282-r3.md b/docs/reviews/CODE-REVIEW-282-r3.md new file mode 100644 index 00000000..26edaf5c --- /dev/null +++ b/docs/reviews/CODE-REVIEW-282-r3.md @@ -0,0 +1,322 @@ +# 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 ` | зелёный до и после миграции: «ссылки разрешимы, записи толщины находятся» | +| Model invariants — решётка | `node scripts/model-invariants.mjs --config --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` у реального `` внутри `.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` не ловит эквивалентную +логику, выраженную иначе).