# CODE-REVIEW-290-r2 - **Issue:** https://github.com/Matysh/houseplan-card/issues/290 - **Ветка:** `issue/290-near-axis-authoring-repair` - **Диапазон:** `origin/dev` (`f472af88`) → HEAD (`db77470d`) - **ТЗ:** `docs/specs/290-near-axis-authoring-and-repair.md`, ревью ТЗ зелёное на r2 (`docs/reviews/SPEC-REVIEW-290-r2.md`) - **Заход:** r2 (код-ревью), блокирующих циклов 1/4 - **Вердикт:** зелёный · High: 0 · Medium: 0 ## 0. Примечание о трассируемости r1 (методология, не находка против автора) Документ `docs/reviews/CODE-REVIEW-290-r1.md` называет диапазон `origin/dev (523190d8) → HEAD (5aae905a)`. Ни один из этих двух SHA не резолвится в текущей истории: `523190d8` — реальный коммит, но он на 10 коммитов позади текущего `origin/dev` (`f472af88`); `5aae905a` не существует вовсе. Также сам вердикт-комментарий в issue не называет SHA явно (только документ). Причина, установленная сравнением `git diff 523190d8..f472af88`: между r1 и этим заходом issue #289 (независимая задача, тоже трогающая `src/resize.ts`) была домержена в `dev`, и ветка #290 была перебазирована на новый `dev` — отсюда новые SHA всех коммитов (коммиттер-даты всех коммитов диапазона сгруппированы в окне 15:51–16:01, тогда как авторские даты разбросаны по всему дню — прямой след rebase). Это ровно случай «ребейз на ушедший вперёд dev — после ребейза это другой код» (PROCESS.md §7.2). Поэтому раздел `src/resize.ts` разобран заново полностью, а не по дельте между коммитами: я лично воспроизвёл поведение дефекта H1 r1 на **старом** `resize.ts` (восстановленном из `43264eda`) и на **новом** и убедился, что вывод не зависит от того, какой SHA был назван в r1 — код, который дошёл до меня, доказан напрямую (§3.1). #289 не тронул `src/near-axis.ts`, `src/houseplan-card.ts`, `src/plan-optimizer.ts`, `src/wall-thickness.ts` (проверено: `git diff 523190d8..f472af88 --stat`) — для них дельта-разбор корректен, см. «Унаследовано из r1». ## 1. Скоуп диффа этого раунда Между состоянием, которое **содержательно** проверял r1 (коммит `43264eda`, последний коммит с полным фиче-контентом до раунда) и текущим HEAD, вне `docs/reviews/**`, изменилось: ``` custom_components/houseplan/frontend/houseplan-card.js | 299 +++++++++---------- dist/houseplan-card.js | 299 +++++++++---------- docs/images/09-device-info.png | Bin 146160 -> 146170 bytes docs/images/screenshots.json | 24 ++++++++++------ scripts/model-invariants.mjs | 7 +- scripts/mutation-gate.mjs | 36 +++ src/resize.ts | 9 +- test/model-invariants.test.mjs | 4 + test/near-axis.test.mjs | 28 ++ test/resize.test.mjs | 24 ++ ``` Это в точности три находки r1 (High H1, Medium M1, Medium M2) плюс пересборка `dist`/frontend-копии (класс D, следствие правки `resize.ts`) и пересъёмка одного docs-скриншота (`device-info`, class C/D — фингерпринт источника изменился у всех 10 сцен, но `imageSha256` реально сдвинулся только у этой одной; проверил — панель `device-info` не касается resize/near-axis, это шум капчура, `check-docs.mjs` принял). `src/near-axis.ts`, `src/houseplan-card.ts`, `src/plan-optimizer.ts`, `src/wall-thickness.ts`, все demo/i18n/docs-файлы фичи в этом раунде не менялись. ## 2. Как проверялось — гейты | Гейт | Команда | Результат | |---|---|---| | typecheck | `npx tsc --noEmit` | зелёный | | unit | `npm test` | 1232 passed, 1 skipped, 0 failed (было 1226/1/0 на r1 — прирост от новых тестов H1/M1/M2) | | build + bundle parity | `npm run build && cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js` | идентичны, `git status` чист после сборки | | docs fingerprint | `node scripts/check-docs.mjs` (обязателен: диф трогает `src/**`) | «Documentation checks passed (7 files, 10 external links)» | | invariants (обязателен: диф трогает geometry/resize) | `npm run invariants -- --config test/fixtures/real-plan-second-floor.json --near-axis` и `--config test/fixtures/real-plan-first-floor.json --near-axis` | буквальный CLI-вызов теперь работает **без обёртки** (закрывает вторичную придирку M2 из r1): `real-second-floor: 1` / `0` почти-осевых стен; полный прогон без `--near-axis` — «Инварианты выполнены», exit 0 на обоих | | mutation-gate (3 новых мутанта AC9) | `node scripts/mutation-gate.mjs --id=near-axis-shared-owner-repair-partial` / `--id=near-axis-shared-owner-double-counted` / `--id=near-axis-optimize-confirmation-bypassed` | все три: «тест покраснел, как обязан», «поймано 1 из 1» | | mutation-gate реестр | `node scripts/mutation-gate.mjs --check` | без `FAIL`, все якоря патчей валидны (реестр не устарел) | | smoke-выборка | `node scripts/smoke-select.mjs --base origin/dev --head HEAD` | см. §2.1 | | golden | не запускал | диф не добавляет/не меняет golden-сценарии; H1-фикс — это validation-логика (`validateSafeResize`), не рендер; ни один golden baseline на near-axis/resize-геометрии не завязан | | performance | не запускал | AC не называет количественный бюджет для этой правки; фикс сужает существующую проверку (меньше работы на итерацию, не больше) | | backend pytest | не запускал | диф не трогает `custom_components/**/*.py` | ### 2.1 Смок-выборка `node scripts/smoke-select.mjs --base origin/dev --head HEAD` (по всему диффу issue, не только по дельте раунда — дельта слишком мала для собственной осмысленной выборки): - **Прямое совпадение (3):** `smoke_decor_layer_order.mjs`, `smoke_room_resize.mjs`, `smoke_zero_divider_taper.mjs` (`roomPoly`/ `samePoint`). Прогнал все три на текущем HEAD (после `npm run bundle:sync`, до этого демо не поднималось — не хватало `demo/srv/assets/houseplan-card.js`, ожидаемо, класс D не коммитится): зелёные, `OK`. - **Зарегистрированная связь (1):** `smoke_near_axis_optimize.mjs` — прогнан как guard мутанта `near-axis-optimize-confirmation-bypassed`, зелёный. - **Слабая связь (19, общий символ `_path`):** не прогонял в этом раунде. Дельта не трогает `houseplan-card.ts`/authoring-путь (уже проверено r1 на идентичном по содержанию коммите `43264eda`, включая специально `smoke_wall_junctions.mjs` по прецеденту #234 — `wall-thickness.ts` в этом раунде не менялся). Наследую решение r1 не гонять остальные 17. ## 3. Находки Новых находок нет. ### 3.1 Проверка закрытия High H1 (не декларативно — воспроизведением на обоих состояниях кода) **Файл:** `src/resize.ts:1012-1023` Старый код (был на момент r1, восстановлен из `43264eda` для сравнения, временно, без коммита — `git status` чист до и после): ```ts for (let index = 0; index < next.length; index++) { if (classifyNearAxisSegment(next[index], next[(index + 1) % next.length])) return false; } ``` Новый код (`615c0744`): ```ts const n = original.poly.length; const prev = (edge - 1 + n) % n; const involvedEdges = [prev, edge, (edge + 1) % n]; for (const index of involvedEdges) { if (classifyNearAxisSegment(next[index], next[(index + 1) % n])) return false; } ``` Прогнал напрямую (не через demo, через `test-build/resize.js`) на фикстуре `test/fixtures/279-near-orthogonal-junction.json` (та самая геометрия, что в теле issue и в `real-plan-second-floor.json`): | Handle | Старый код | Новый код | |---|---|---| | `north-west`, edge 3 (осевое ребро, не смежное с near-axis edge 1) | `invalid-geometry` | `partial-shared` | | `south-west`, edge 1 (осевое ребро, не смежное с near-axis edge 3) | `invalid-geometry` | `enabled: true` | Оба ребра под старым кодом валились с `invalid-geometry` — это и есть H1: near-axis проверка была не привязана к сдвигаемому ребру и валила весь полигон. Под новым кодом `south-west,1` включается штатно — прямое доказательство, что over-broad проверка снята. `north-west,3` теперь блокируется, но по **другой, не связанной с #290 причине**: `resolveSafeResize` после исходной near-axis-проверки (`d=0`) идёт дальше и пробует реальные шаги `±step`; для этого ребра оба шага ломают `sideOwnershipPreserved` — это ветка `resolveSafeResize` (`src/resize.ts:923-925`), унаследованная от #289 («не превращать shared стену в outer»), и она не зависит от near-axis классификатора вообще. Убедился математически: сдвигаемые вершины — это ровно `edge` и `edge+1`; рёбра, инцидентные им — ровно `{prev, edge, edge+1}`; других рёбер, которые могли бы измениться от драга, не существует — значит `involvedEdges` полон и корректен, недостача исключена. Тест `test/resize.test.mjs:308+` (`#290 a remote near-axis edge does not disable an unrelated exact resize handle`, дважды правленный — `615c0744` → `6ded0f01` reconcile) фиксирует ровно эту таблицу: явно проверяет, что `north-west,3` даёт `partial-shared` (с комментарием «#289 still blocks...», не «near-axis»), и что `south-west,1` — реальный успешный кейс. Формулировка АС3 («Outer и exact-shared safe drags сохраняют moving/side edges exact-axis») совпадает с новым скоупом `involvedEdges` дословно — не шире и не уже. **Вывод:** H1 закрыт по существу, не только тестом. `git diff 615c0744..6ded0f01` («test: reconcile near-axis resize proof») — не заметание находки, а самокоррекция автора: первая версия теста в `615c0744` утверждала `enabled: true` для `north-west,3`, что было бы неверно (реально `partial-shared`); следующий коммит поймал это и добавил правильный негативный кейс вместо удаления проверки. ### 3.2 Проверка закрытия Medium M1 (mutation-gate) `scripts/mutation-gate.mjs` теперь регистрирует все 6 категорий AC9. Три новых (`near-axis-shared-owner-repair-partial`, `near-axis-shared-owner-double-counted`, `near-axis-optimize-confirmation-bypassed`) прогнаны индивидуально (`--id=...`), все убиты целевым тестом/смоком (§2, таблица гейтов). `--check` не находит устаревших якорей ни у одного из 20+ мутантов реестра. ### 3.3 Проверка закрытия Medium M2 (AC10 автотест) `test/near-axis.test.mjs:106-132` — новый тест `#290 Optimize reduces the tracked real-plan near-axis profile and stays idempotent` вызывает `nearAxisProfile`/`optimizePlans` на **обеих** реальных фикстурах (`real-plan-first-floor.json`, `real-plan-second-floor.json`): проверяет строгое `after.total <= before.total`, точное равенство `after.total === before.total - wallsStraightened`, нулевой `wallsStraightened` на втором проходе (идемпотентность) и явно `before.total === 1`, `repaired.report.wallsStraightened === 1`, `after.total === 0` для second-floor — это ровно факт, который r1 проверял вручную. `npm test` прогнал — тест зелёный, участвует в общем прогоне (не отдельный ad-hoc скрипт). Убедился, что тест умеет падать: без правки `readModel` (M2 внутри M1-коммита также чинит парсинг `{note, space}` в `scripts/model-invariants.mjs`) он не понадобился бы для самого near-axis теста (тот использует `nearAxisProfile` напрямую на `{spaces:[space]}`, собранном вручную в тесте, не через CLI) — а вот CLI-путь `npm run invariants -- --config ` теперь тоже работает буквально, что и было второй, менее блокирующей претензией M2. ## 4. Проверено и корректно (в этом раунде) - Все три находки r1 (H1, M1, M2) закрыты по существу, не только декларативно — см. §3.1–3.3. - `git status` чист на всём протяжении проверки: build, bundle:sync, mutation-gate (использует временные worktree, не трогает основное дерево), временная подмена `src/resize.ts` для сравнения — всё восстановлено. - Коммитные трейлеры всего диапазона `origin/dev..HEAD` (9 коммитов): `Issue: #290` на каждом; `User-Visible: yes` только на `43264eda` (единственный коммит с видимым поведением — Optimize報report/UI), и в нём же правки `docs/CHANGELOG.md`/`docs/CHANGELOG.ru.md` (проверено ещё в r1, перепроверил сам факт присутствия обоих файлов в файловом списке того коммита). Три новых коммита раунда (`615c0744`, `6ded0f01`, `db77470d`) — все `User-Visible: no`, changelog не требуется и не тронут. - «Одно число, один источник»: в дельте раунда новых видимых пользователю чисел не появилось (правки — валидационная логика, тесты, CLI-парсинг); `test/single-source-numbers.test.mjs` прошёл в общем прогоне. ## 5. Унаследовано из r1 (без повторной проверки в этом раунде) Документ: `docs/reviews/CODE-REVIEW-290-r1.md`. SHA, названный в том документе (`5aae905a`), не резолвится (см. §0) — как эквивалент указываю `43264eda`, который содержательно идентичен по всем файлам, перечисленным ниже (не тронут ни #289-rebase, ни коммитами этого раунда — проверено `git diff 523190d8..f472af88 --stat` и `git diff 43264eda..HEAD --stat`). - **AC1** (boundary matrix threshold/inclusive) — `src/near-axis.ts`, `test/near-axis.test.mjs` boundary-тест; r1 подтвердил мутантами `near-axis-threshold-weakened`/`near-axis-inclusive-boundary-disabled`. - **AC2** (Walls authoring не сохраняет `316×1`) — `src/houseplan-card.ts`, `demo/smoke_plan_drawing_repairs.mjs`; r1 прогнал смок и мутант `near-axis-authoring-snap-bypassed`. - **AC4/AC5/AC6/AC7** (Optimize чинит `316×1`, сохраняет related geometry, multi-space/cell_cm) — `src/plan-optimizer.ts`, `test/near-axis.test.mjs`, `test/plan-optimizer.test.mjs`; r1 проверил чтением и ручным прогоном для multi-space части. - **AC8** (UI/Undo/revision, atomic write) — `demo/smoke_near_axis_optimize.mjs`; r1 прогнал все 7 сценариев смока. - **Документация** (CANVAS/RESIZE/WALL-THICKNESS/ARCHITECTURE/ CONFIG-COMPATIBILITY/USER-GUIDE(.ru)/TESTING/CHANGELOG(.ru)) — не тронута в этом раунде, терминология уже сверена r1. - **Low L1** (AC1 source guard не механизирован, grep-гейта нет) — снят с записью в r1, дельта этого раунда его не касается, риск тот же. ## 6. Закрытие раунда r1 | Находка r1 | Чем закрыта | Где видно | |---|---|---| | **High H1** — near-axis проверка валит весь полигон комнаты, а не moving/side edges | `src/resize.ts:1018-1023` сужен до `involvedEdges = [prev, edge, edge+1]` | `615c0744` (`fix: scope near-axis resize validation`); воспроизведено мной на старом/новом коде в §3.1; тест `test/resize.test.mjs:308+`, дважды доведённый до правильного утверждения в `6ded0f01` | | **Medium M1** — 3 из 6 обязательных мутантов AC9 отсутствуют | добавлены `near-axis-shared-owner-repair-partial`, `near-axis-shared-owner-double-counted`, `near-axis-optimize-confirmation-bypassed` | `scripts/mutation-gate.mjs:964-999` (`615c0744`); все три прогнаны индивидуально, см. §3.2 | | **Medium M2** — AC10 не покрыт автотестом на реальных планах | новый тест на обеих `real-plan-*.json` фикстурах + читатель `readModel` принимает `{note, space}` без обёртки | `test/near-axis.test.mjs:106-132`, `scripts/model-invariants.mjs:519-524` (`615c0744`); прогнано, см. §3.3 | | Low L1 (source guard не механизирован) | не в скоупе этого раунда, снят с записью r1 | — (унаследовано, см. §5) | ## 7. Чего не проверял - **Полный Linux CI Validate на точном SHA HEAD** — не переоткрывал `gh`-прогон отдельно; полагаюсь на локальный повтор дешёвых гейтов + целевые смоки + мутанты, как и r1. - **Полный набор `demo/smoke_*.mjs`** (182 файла) — прогнал только прямые совпадения и зарегистрированную связь из `smoke-select`; слабую связь (19 файлов на `_path`) не прогонял в этом раунде — дельта не касается `houseplan-card.ts`/authoring-пути, где эти смоки актуальны; полный набор — предрелизный гейт (PROCESS.md §8). - **`npm run golden:verify` и performance-профили** — не запускал; правка сужает существующую проверку в `validateSafeResize` (валидационная логика, не рендер, не добавляет работы), в AC нет количественного бюджета для этой части. - **`python -m pytest tests_backend`** — не запускал; диф не трогает Python. - **Touch/pinch/pointercancel** — дельта раунда не трогает pointer-разбор вообще (`resize.ts` validation logic не завязана на источник события); унаследовано из r1, который проверил это для authoring-пути.