# CODE-REVIEW-289-r1 - **Issue:** #289 «Ресайз комнаты с общими стенами портит их толщину» - **Этап:** код-ревью (PROCESS.md §2.7) - **Заход:** r1 · блокирующих циклов израсходовано 0 из 4 (это первый заход этапа code; ревью ТЗ уже прошло два захода отдельно и бюджет не делит) - **Диапазон:** `git log --oneline origin/dev..HEAD` / `git diff origin/dev...HEAD` - **Ветка:** `issue/289-no-mixed-role-resize` - **Коммиты в диапазоне:** - `5e169f48` — ТЗ (спека) - `1dae3b0c`, `e02c282d`, `96232cc0` — ревью ТЗ / правки ТЗ - `5142fc8b fix: prevent mixed-role walls during resize` — реализация, `Issue: #289`, `User-Visible: yes` - `4d1285a9 docs: accept screenshots after resize guard` — приёмка скриншотов, `Issue: #289`, `User-Visible: no` ## Скоуп J6 из `docs/SCOPE.md` — «Keep the plan true as the home evolves». ТЗ `docs/specs/289-no-mixed-role-resize.md` прошло ревью ТЗ дважды (r1: жёлтый, 3×Medium — все закрыты в `e02c282d`; r2: зелёный). Технический контракт §2–§4 и AC1–AC9 в этом заходе не пересматриваю заново по существу — они уже приняты ревью спеки на зелёном вердикте; здесь проверяю, что реализация действительно делает то, что написано, и что диапазон коммитов не тянет за собой ничего лишнего. ## Как проверялось (гейты) | Гейт | Команда | Результат | |---|---|---| | typecheck | `npx tsc --noEmit` | зелёный, без вывода | | unit | `npm test` | 1223 passed, 1 skipped, 0 failed (совпадает с заявлением автора) | | build + сверка бандлов | `npm run build`; `git status --short` после — пусто; `diff dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js` — идентичны | зелёный, три копии синхронны | | docs fingerprint | `node scripts/check-docs.mjs` | «Documentation checks passed (7 files, 10 external links)» | | целевой мутант | `node scripts/mutation-gate.mjs --id=safe-resize-side-ownership-bypassed` | «поймано 1 из 1» — тест умеет падать | | целевой смок | `node demo/smoke_room_resize.mjs` (после `npm run bundle:sync`) | `OK`, без записей в FAILED | | выборка смоков | `node scripts/smoke-select.mjs --base origin/dev --head HEAD` | прямое совпадение: `demo/smoke_sun_soft.mjs` (символ `axisOf`, не изменён по существу — только новые точки вызова существующей функции); зарегистрированная связь: `demo/smoke_room_resize.mjs` (уже прогнан выше) | | smoke_sun_soft.mjs (прямое совпадение, прогнан по решению ревьюера) | `node demo/smoke_sun_soft.mjs` | `OK`, все проверки true | | performance | `node demo/benchmark_safe_resize.mjs` | `pass: true`; pointer p95 ≈0.0099 мс, commit-preflight p95 ≈0.0017 мс — оба далеко внутри бюджетов 16 мс / 75 мс | | model invariants на реальных планах | часть `npm test` (`test/model-invariants.test.mjs`, «реальные планы проекта эту проверку») | зелёный, включает `checkMixedRoleRecords`/`checkWallRecordsPreserved`/`checkWallKeys` на обеих моделях проекта | | golden (полная матрица, обязательна т.к. diff меняет видимую доступность/состояние ручки resize) | `npm run golden:verify` на HEAD, затем повторно на `origin/dev` в отдельном worktree том же окружением | **на HEAD: 3 сцены `different` из ~110; на `origin/dev` тем же прогоном все зелёные** — см. находку H1 | Не прогонял: полный `demo/smoke_*.mjs` (81 файл) — не оправдано объёмом дельты; `python -m pytest tests_backend` — diff не трогает `custom_components/**/*.py` (только синхронизированный бандл); `npm run benchmark:safe-resize-render` — не назван в AC, диф не меняет рендер-путь предпросмотра сверх уже измеренного `benchmark_safe_resize`. ## Находки ### H1 — Незапланированный код #260 в ветке #289 ломает два golden-эталона, третий не принят (High, блокирует) **Что обнаружено.** Диапазон коммитов содержит файлы `demo/fixtures/wall-key.mjs`, `test/fixture-wall-key.test.mjs`, а также изменения `demo/fixtures/large-house.mjs`, `demo/fixtures/visual-matrix.mjs`, `test/model-invariants.test.mjs` — это **побайтово тот же диф**, что лежит в отдельной, уже готовой ветке `origin/issue/260-fixture-wall-keys` (один коммит `9031ba0d`, `dev + 1`). Тело issue #289 в разделе «При слиянии» и ТЗ §11 («Инфраструктурная #260 должна попасть в `dev` до финальной пересъёмки, **но не входит в product branch #289**») явно требуют слить `#260` отдельной веткой. Автор вместо этого продублировал содержимое прямо в коммит `5142fc8b`, чей трейлер — только `Issue: #289` (без `Issue: #260`), и в комментарии issue признал это прямо: «Дополнительно в эту геометрическую задачу включена ранее отложенная инфраструктурная чистка wall-key fixtures из #260». Это не только расхождение с одобренным (зелёным) ТЗ и попутная правка «раз уж я здесь» (запрещено PROCESS.md §12), но и измеримый дефект: новая формула ключа стены в `demo/fixtures/wall-key.mjs` используется золотым харнесом (`demo/golden/harness.mjs` импортирует `fixtureWallKey` из `demo/fixtures/visual-matrix.mjs`, которая теперь **ре-экспортирует** новую формулу) для построения `golden-geometry` — фикстуры, по которой строятся, среди прочего, сцены `isometric-geometry-view-dark/light`. **Воспроизведение.** Прогнал `npm run golden:verify` дважды в одном и том же окружении: на HEAD и на `origin/dev` (отдельный `git worktree`, тот же Chromium/кэш). На `origin/dev` — все ~110 сцен `passed`. На HEAD — три сцены `different`: ``` different isometric-geometry-view-dark diffRatio 0.00473 (порог 0.0005, х9.5) different isometric-geometry-view-light diffRatio 0.00438 (порог 0.0005, х8.8) different safe-resize-handles-clamp-dark diffRatio 0.00081 (порог 0.0008, впритык) ``` `isometric-geometry-view-*` не имеют отношения к Resize — это `golden-geometry` в изометрической проекции, `view`-режим, ручки resize не рисуются вовсе. Единственная связь с этим диффом — общая формула ключа стены. Дифф не проходит ни по одному AC #289 и не упомянут автором в отчёте о прогнанных гейтах (комментарий в issue перечисляет unit/typecheck/build/smoke/mutation/ benchmark/инварианты/docs-скриншоты, но не `golden:verify`/`golden:accept`). **Почему это блокирует.** Смердженная в `dev` ветка оставит два golden- эталона объективно устаревшими: следующий `npm run golden:verify` (обязателен перед бетой, PROCESS.md §8, RESIZE.md «Verification») упадёт на коммите, не имеющем отношения к причине падения — расследовать это придётся заново, без контекста настоящего обзора. `docs/reviews`/ТЗ #289 не содержат согласия владельца на такое расширение скоупа, а separate-branch путь для #260 уже существует и готов к собственному, отдельному ревью. **Рекомендация.** Убрать из этой ветки файлы, дублирующие `issue/260-fixture-wall-keys` (`demo/fixtures/wall-key.mjs`, `test/fixture-wall-key.test.mjs`, соответствующие правки `large-house.mjs`/`visual-matrix.mjs`/`model-invariants.test.mjs`), слить `issue/260-fixture-wall-keys` в `dev` отдельно, как и планировало ТЗ, затем перебазировать #289 на обновлённый `dev`. Если владелец вместо этого решит оставить #260 внутри #289 — это меняет одобренный контракт §11 и требует нового решения владельца, а коммит должен нести оба трейлера `Issue: #289` и `Issue: #260`. ### M1 — Golden-эталон `safe-resize-handles-clamp-{dark,light}` не обновлён под настоящее поведение #289 (Medium, в скоупе, чинится в этом же issue) Из того же прогона: **обе** темы сцены `safe-resize-handles-clamp` показывают одинаковый по характеру дифф — 855 (dark) и 811 (light) пикселей, оба почти на пороге (0.00081 vs порог 0.0008 и 0.00076 vs тот же порог — dark чуть выше, light чуть ниже, эталон «прошёл» только по везению). Пиксели сосредоточены в четырёх угловых зонах фикстуры `golden-safe-resize` — визуально это ручка resize, которая на baseline (`origin/dev`) отрисована светлой/включённой (насыщенная синяя двойная стрелка), а на HEAD — приглушённой/выключенной (см. сравнение обрезков `demo/golden/baselines/safe-resize-handles-clamp-dark.png` и `artifacts/golden/actual/safe-resize-handles-clamp-dark.png`, область ≈x:340–420,y:190–270). Это выглядит как ожидаемое следствие нового ownership-контракта: фикстура `golden-safe-resize` (`demo/golden/harness.mjs`, `scenario.safeResizeFixture`) содержит `resize-left`/`resize-right` с точно общей средней стеной `lm0–lm1`; перетаскивание верхней (не общей) стены одной из комнат до сих пор удлиняло/укорачивало эту среднюю стену только с одной стороны — именно класс дефекта из #289. Новый side-ownership-чек по AC1/AC3 обязан здесь сработать и запретить/клэмпнуть жест — то есть код, скорее всего, ведёт себя правильно. Но правило §11 ТЗ («если меняется вид disabled handle, targeted golden/docs screenshots принимаются только из штатного Linux workflow после bundle sync») не выполнено: baseline не пересобран и не принят через `npm run golden:accept -- --reviewed` на артефакте канонического Linux CI. Пока это не сделано, `safe-resize-handles-clamp-dark` будет падать на предрелизном гейте по причине, не имеющей отношения к новому дефекту, а `safe-resize-handles-clamp-light` — «зелёный» только на грани порога и упадёт от любого будущего микроскопического шума рендера. **Рекомендация.** Прогнать `Docs`/`Golden`-эталоны на каноническом Linux CI для этой сцены (обе темы), убедиться, что новое состояние ручки — то самое корректное disabled по `partial-shared`/направленному клэмпу, и принять через `golden:accept -- --reviewed` в том же PR. ## Проверено и корректно - **AC1** (exact repro запрещён до жеста): `resolveSafeResize()` на `test/fixtures/289-mixed-role-resize.json` возвращает `{enabled:false, reason:'partial-shared'}`; оба направления (`+43`, `-43`) отдельно проверены через `validateSafeResize` → `false`. Мутант, снимающий проверку (`safe-resize-side-ownership-bypassed`), красит именно этот тест — падение подтверждено прогоном. - **AC2** (причина доступна человеку): `resize.disabled.partial-shared` переведён на RU «Нельзя сдвинуть только часть общей стены» (совпадает с зафиксированным в ТЗ и issue текстом дословно) и EN-эквивалент; ключ проверяется в `test/resize-production-path.test.mjs` («every stable disabled reason… is localized RU/EN»), сохранена структура disabled handle (`aria-disabled`, `tabindex`, `_rszDisabledKey`, `cursor: not-allowed`) — это существующая инфраструктура, `src/houseplan-card.ts` не тронут (правки не потребовались, ТЗ этого не требовало). Проверено чтением, не исполнением: продакшн-смок для click/tap/toast по этому пути не расширялся (в отличие от disabled/no-drag/zero-write, которые проверены смоком) — считаю это допустимым, т.к. общий toast/hover путь для `partial-shared` уже покрыт существующим смоком `demo/ smoke_room_resize.mjs` до этой задачи, а изменился только текст строки. - **AC3** (directed clamp не перепрыгивает роль): unit `test/resize.test.mjs` («#289 side ownership…») — `clampSafeResize` на диапазонной фикстуре останавливается ровно на границе владения (40→20), а обратное безопасное направление остаётся рабочим (-40→-40); production- смок `safe_resize.owner_boundary_clamped/topology/no_mixed_role/ cm_preserved` подтверждает то же на реальном bundle. - **AC4** (сценарии #277 сохраняются): тот же unit добавляет проверку, что обычный наружный resize не выключен целиком (`outer.enabled === true`); существующая позитивная матрица `test/resize.test.mjs` и `demo/smoke_room_resize.mjs` не ослаблены (диф теста — только добавления). - **AC5** (persisted model чиста): `checkMixedRoleRecords`, `checkWallRecordsPreserved`, `checkWallKeys` на обеих реальных моделях проекта — часть `npm test`, зелёные. Production-смок дополнительно прогоняет `checkMixedRoleRecords` на живом `_serverCfg` после жеста (`safe_resize.owner_boundary_no_mixed_role`). - **AC6** (preview/commit — один proof): `plan.sideOwnership` строится один раз в `resolveSafeResize` (immutable), `sideOwnershipPreserved()` вызывается внутри `validateSafeResize()`, который переиспользуется и в `clampSafeResize` (через preview), и в `_rszUp()` при pointerup (`src/houseplan-card.ts:8619` `topologyValid = validateSafeResize(...)`) — один и тот же код пути, а не два разных. Расхождение владельцев между preview/commit ловится тем же `resize.commit_failed`, инфраструктура не менялась. - **AC7** (production-bundle smoke): добавленный сценарий в `demo/smoke_room_resize.mjs` выполняет запрещённый 43-шаговый drag (проверяет disabled/no-drag/geometry-exact/zero-write) и разрешённый outer/diapазонный drag на том же bundle — прогнан, зелёный. - **AC8** (мутант): `safe-resize-side-ownership-bypassed` убивается только целевым `#289`-тестом (`--test-name-pattern="#289 side ownership"`), позитивные AC3/AC4 не задеты (мутация не отключает все ручки огулом — проверено чтением патча: `if (false && !sideOwnershipPreserved(...))`, т.е. отключается именно и только новая проверка). - **AC9** (локальные гейты): typecheck/test/build/check-docs — все зелёные (таблица выше); targeted mutation/smoke — зелёные. - **§7 Performance:** `sideOwnership` строится один раз на `resolveSafeResize` (не в `pointermove`) — подтверждено чтением (`buildSideOwnership` вызывается только в `resolveSafeResize`, не в `clampSafeResize`/`applySafeResize`); `benchmark_safe_resize` подтверждает бюджеты с большим запасом. - **Терминология:** RU-документация (`USER-GUIDE.ru.md`, `RESIZE.md`) использует каноническое «ручка», а не «рукоятка» — L1 из ревью ТЗ в код не просочился. - **Changelog:** `docs/CHANGELOG.md`/`.ru.md` оба правлены в коммите `5142fc8b` с `User-Visible: yes` — соответствует трейлеру. - **Один источник числа:** фича не вводит новое отображаемое значение (только новое состояние disabled + текст причины) — раздел «одно число — один источник» неприменим; `test/single-source-numbers.test.mjs` зелёный отдельно. - **smoke_sun_soft.mjs** (прямое совпадение по `axisOf` от `smoke-select`): прогнан, зелёный. `axisOf` не менялась — только новые точки вызова существующей функции внутри `src/resize.ts`, риск для солнца/теней отсутствует и по чтению кода. ## Чего не проверял - Полный `demo/smoke_*.mjs` (81 файл) и `npm run golden:capture`/полную ревизию всех golden-сцен глазами — прогнал только полную **матрицу сравнения** (`golden:verify`, обязателен так как diff трогает видимую доступность ручки), а не каждую сцену вручную; за пределами трёх найденных расхождений остальные ~107 сцен просто «passed» их числовым порогом, тексты остальных сцен не пересматривал построчно. - `python -m pytest tests_backend` — diff не трогает `custom_components/**/*.py` кроме синхронизированного бандла. - `npm run benchmark:safe-resize-render` — не выполнял; AC9/§7 не называют его явно для этой задачи, а изменение не трогает рендер-путь предпросмотра сверх уже измеренного eligibility/clamp. - Human/touch pass — вне цикла ревью по процессу (§2.7): фаза ручного тестирования отсутствует, полагаюсь на автотесты и production-смок. - Доверился, но не перепроверял заново по существу: сам технический контракт §2–§4 ТЗ и формулировки AC1–AC9 — они уже прошли отдельное ревью спеки (`docs/reviews/SPEC-REVIEW-289-r1.md`, `-r2.md`, зелёный вердикт на `e02c282d`); в этом заходе я проверял соответствие реализации этому контракту и реальную работоспособность, а не заново продуктовую формулировку. ## Вывод Сама логика side-ownership в `src/resize.ts` реализована по контракту, тесты и мутант умеют находить регресс, производительность в бюджете, документация и changelog согласованы. Блокирует не корректность решения AC, а то, что ветка физически содержит код другого issue (#260) вопреки явному пункту ТЗ §11, и это уже сломало два несвязанных golden-эталона и оставило третий (относящийся к самому #289, вероятно корректный по существу) непринятым.