# CODE-REVIEW-298-r1 - **Issue:** https://github.com/Matysh/houseplan-card/issues/298 - **Этап:** код-ревью (PROCESS.md §2.7), заход **r1**, блокирующих циклов израсходовано **0/4** - **Ветка:** `issue/298-resize-wall-thickness`, реализация — коммит `3fb2edc9` - **Ревьюер:** Claude (внешняя сессия, без контекста реализации) - **ТЗ:** `docs/specs/298-resize-wall-thickness-carrier.md`, ревью ТЗ зелёное r2 (`docs/reviews/SPEC-REVIEW-298-r2.md`) ## Скоуп проверки `git diff origin/dev...HEAD` = один продуктовый коммит `3fb2edc9`, класс A/B/C/D: - `src/wall-thickness.ts` — `fixed-topology` режим `rekeyWallsAfterMove()`, новые `wallRecordCarrierViolations()` / `wallRecordsHaveCarrierCoverage()`; - `src/houseplan-card.ts` — вызов rekey с `mode='fixed-topology'`, новый carrier/lattice preflight в `_rszApplyPreview()` перед принятием preview; - `test/wall-thickness.test.mjs` — 4 новых теста; - `demo/smoke_edit_walk.mjs` — обновление таблицы `KNOWN`; - `scripts/mutation-gate.mjs` — новый мутант `safe-resize-wall-endpoints-affine-scaled`; - `docs/{RESIZE,WALL-THICKNESS,TESTING,ARCHITECTURE,CHANGELOG,CHANGELOG.ru,USER-GUIDE,USER-GUIDE.ru}.md`, `docs/images/*` (скриншоты пересняты), `dist/`+`custom_components/.../houseplan-card.js` (класс D, синхронизированы). Прочитано перед разбором: `docs/SCOPE.md` (J6), `AGENTS.md`, `PROCESS.md`, тело issue #298 и все 8 комментариев (аналитика → ТЗ r1/r2 → реализация), `docs/specs/298-resize-wall-thickness-carrier.md` целиком, `docs/RESIZE.md`, `docs/WALL-THICKNESS.md` (диффы и итоговый текст). Это первый заход код-ревью (r1) — разбор полный, разделов «Унаследовано из r0» и «Закрытие раунда r0» не требуется (PROCESS.md §2.10 относится к повторным заходам). ## Как проверялось (гейты) | Гейт | Команда | Результат | |---|---|---| | typecheck | `npx tsc --noEmit` | чисто, без вывода | | unit | `npm test` | `tests 1263, pass 1262, fail 0, skipped 1` | | build + bundle parity | `npm run build && node scripts/bundle-sync.mjs && cmp dist/houseplan-card.js custom_components/houseplan/frontend/houseplan-card.js && cmp dist/houseplan-card.js demo/srv/assets/houseplan-card.js` | обе копии побайтово совпадают | | docs fingerprint | `node scripts/check-docs.mjs` | `Documentation checks passed (7 files, 10 external links)` | | model invariants (сырой diff) | `node scripts/smoke-select.mjs --base origin/dev --head HEAD` | прямое совпадение (5): `smoke_decor_layer_order`, `smoke_drag_bounds`, `smoke_grid_snap`, `smoke_infinite_canvas`, `smoke_room_resize`; зарегистрированная связь (2): `smoke_resize_pointer_real_plan`, `smoke_resize_wall_thickness` | | смоки из списка выше | `node demo/smoke_{decor_layer_order,drag_bounds,grid_snap,infinite_canvas,room_resize,resize_pointer_real_plan,resize_wall_thickness}.mjs` | все `OK`, без находок | | AC6, шесть прогонов обхода правок | `node demo/smoke_edit_walk.mjs --seed {1,2,3}` (примечание ниже про `--plan`) | все 6 комбинаций (`walk_{second,first}_floor_seed{1,2,3}`) → `true`, `OK` | | AC8, мутационный страж | `node scripts/mutation-gate.mjs --id=safe-resize-wall-endpoints-affine-scaled` | `тест покраснел, как обязан`, `поймано 1 из 1` | | реестр мутантов | `node scripts/mutation-gate.mjs --check` | без ошибок регистрации | | AC9, целевой перф | `node demo/benchmark_safe_resize.mjs` | `candidate p95 0.0123ms` vs `relativeLimit 4.97ms`; `commitPreflight p95 0.002ms` vs бюджет `75ms`; `"pass": true` | | модельные инварианты на самих фикстурах | `node scripts/model-invariants.mjs --config test/fixtures/real-plan-second-floor.json --json` | одно нарушение — ранее известный `partition_over_room_wall` (#296), не новое | **Примечание к AC6:** флаг `--plan` в `demo/smoke_edit_walk.mjs` декоративный — скрипт всегда проходит обе фикстуры из `PLANS`, `--plan` нигде не читается после парсинга. Шесть команд из AC6/issue физически выполняются как три (`--seed 1/2/3`), каждая уже покрывает оба этажа; результат тот же. Это существующее поведение скрипта, не тронутое этим диффом (диф правил только таблицу `KNOWN`) — не блокирует, но AC6 в issue стоило бы переформулировать при случае. **Не прогонялось (и почему):** - `npm run golden:verify` — визуальный результат не меняется (спецификация §11 явно фиксирует «новый golden baseline не ожидается»); скриншоты документации это подтверждают (пересняты штатной джобой, `check-docs` зелёный) и это единственный проверяемый здесь визуальный артефакт; - `python -m pytest tests_backend` — диф не касается `custom_components/**/*.py`; - `demo/benchmark_safe_resize_render.mjs` — новый код живёт в rekey/preflight над данными стен, не в рендер-слое; рендер-путь не тронут, а `benchmark_safe_resize.mjs` уже показывает, что commit-preflight (тот же новый код) дешёвый; - полный `ls demo/smoke_*.mjs` набор (185 файлов) — не оправдан объёмом диффа (2 продуктовых файла, оба геометрических); выбор ограничен прямыми совпадениями/связями инструмента плюс явно названными в AC. ## Находки ### High-1 — AC4 (legacy/key-only записи) не реализован: fail-closed контракт §3.3 отсутствует, дефект воспроизводится **Скоуп:** явно внутри задачи — раздел «Входит» ТЗ прямо называет «fixed-topology rekey exact **и legacy** wall records» (docs/specs/298-…md §6), а AC4 посвящён целиком этому классу записей. **Что требует ТЗ (§3.3):** для legacy (только `key`, без `a/b`) записи разрешён один-единственный перенос — точное совпадение старого ключа с целым изменившимся edge. «Projected-midpoint перенос по относительной доле запрещён… Если legacy key затронут, но whole-edge соответствие неоднозначно, кандидат fail closed и не сохраняется.» AC4 требует отдельного теста на этот отказ («pure unit и production-preview reject с нулём config/history writes»). **Что в коде:** блок обработки legacy-записей в `rekeyWallsAfterMove()` (`src/wall-thickness.ts:732-761`) **не тронут этим диффом ни одной строкой** — собственный комментарий кода (`// Move an unambiguous whole-edge key or projected midpoint`) дословно описывает именно тот projected-midpoint fallback, который §3.3 запрещает. Единственное, что меняется через `mode`, — это то, ЧТО именно проецируется (`mapPoint`), но не факт, что вместо отказа происходит проекция. При неоднозначном/неполном совпадении (`nk` не вычислен) код молча оставляет **старый ключ без изменений** (`out.push({ ...w, key: nk || w.key, … })`) — ни отказа кандидата, ни ошибки, ни пометки для последующего carrier-preflight. Сам carrier-preflight (`wallRecordCarrierViolations`) намеренно пропускает такие записи (`entrySpan` возвращает `null` без `a/b`), поэтому никакого другого защитного слоя для этого пути нет — ни в `_rszApplyPreview`, ни где-либо ещё в live-пути. **Воспроизведение (числа те же, что в задаче, только формат записи — legacy):** ```js // probe: боковая стена удлиняется с 80 до 92 шагов (тот же класс жеста, // что в issue), legacy-запись хранит ключ, чуть отличающийся от точного // текущего ключа ребра (тот самый класс дрейфа ключей #258/#279/#291) — // поэтому быстрый путь `keyMoves.get(w.key)` промахивается и код уходит в // запрещённый §3.3 fallback. rekeyWallsAfterMove( [{ key: wallKey([1/480,0],[80-1/480,0], 1/240), cm: 22 }], [[[0,0],[80,0]]], [[[0,0],[92,0]]], 1/240, 1000, 'fixed-topology', ); // → key остаётся "40.000000,0.000000@0.0000" (середина СТАРОЙ стены), // хотя новая стена [0,92] должна иметь середину в x=46. // Запись теперь описывает точку внутри удлинившейся стены, не привязанную // ни к одной вершине и не совпадающую с новым carrier-серединой — то есть // именно тот класс дефекта, ради которого заведён #298, просто для // legacy-хранения вместо exact. ``` Второй пробник показывает тот же провал для настоящей неоднозначности (две коллинеарные затронутые edges с разными результатами для одной legacy-точки): вместо отказа кандидата ключ остаётся байт-в-байт старым. **Почему это не гипотетика:** legacy-записи — не мёртвый код: `WallEntry.a/b` опциональны специально ради старых, ни разу не тронутых новым write-путём конфигураций (см. собственные комментарии `wall-thickness.ts` про compatibility), а обе фикстуры для смоков (`real-plan-{first,second}-floor.json`) проверены мной напрямую — **в них 0 legacy-записей**, поэтому AC6 (шесть прогонов обхода правок) физически не может обнаружить этот путь. Отсюда и отсутствие сигнала «всё зелено» ничего не доказывает про этот класс данных. **Требуется для исправления в этой же задаче:** legacy-путь должен либо явно возвращать признак «кандидат невозможен» (пробрасываемый до `_rszApplyPreview` как отказ, а не молчаливое сохранение старого ключа), либо carrier-preflight должен перестать пропускать legacy-записи. Плюс тесты AC4 (unit на «unambiguous whole-edge», «untouched», «ambiguous midpoint → fail closed», «production-preview reject с нулём writes»), которых сейчас в диффе нет вовсе. ### High-2 — AC2 не доказан: нет отдельного теста на репродукцию первого этажа **Скоуп:** явно внутри задачи. AC2 ТЗ: «На `real-plan-first-floor.json` последовательность `room-b`, edge 0, `x=49 → 52` не создаёт endpoint `59.538`… Доказательство: **отдельный** fixture-backed regression с exact endpoint, carrier/lattice и unchanged-record assertions.» То же самое Evidence 2 из тела issue («Свидетельство 2 — первый этаж») требует теста, который «обязан падать на текущем коде» (issue, AC5/issue-нумерация). **Что в диффе:** `test/wall-thickness.test.mjs` получил ровно 4 новых теста (`grep` по всему диффу подтверждает). Три из них про механику (`never scales`, `translates every breakpoint`, `preserves an unrelated record`) и один — про carrier coverage. Тест `never scales an interior side-wall endpoint` использует числа `-85`/`-100..100`/`-96..100` — это **второй этаж** (Evidence 1 из issue, тот же y=304, тот же -85). Ни один тест, ни в этом файле, ни в остальном диффе (`git diff` по `test/` и `demo/` не содержит строк `59.538`, `room-b`, `first-floor` рядом с числами `17/52/57/101`), не воспроизводит **первый этаж** — конкретные числа `49→52`, `17/52/57/101`, `59.538` нигде не встречаются. Общий обход `smoke_edit_walk.mjs` (AC6) реально гоняет обе фикстуры и зелёный, но это недостаточное доказательство именно этого AC: обход детерминированно генерирует свою последовательность шагов по семени, а не воспроизводит **именно** «room-b, edge 0, x 49→52», и не делает покомпонентного assert «конкретная identity записи до/после», которого явно требует формулировка AC. **Почему это блокирует, а не Low:** AC2 — один из двух явно перечисленных в issue «обязаны падать на текущем коде» репродукций. Раз ревью кода отвечает на вопрос «оно вообще работает» по каждому AC (PROCESS.md §2.7), а для второго из двух заявленных дефектных жестов пруфа нет вообще — AC2 не может быть засчитан выполненным по чтению кода: степень уверенности в конкретно этой фикстуре (`real-plan-first-floor.json`, `room-b`) ниже, чем во второй, и не подтверждена ни одним новым тестом. ## Что проверено и корректно - **AC1 (эквивалент AC2, но для второго этажа):** воспроизведено — `test/wall-thickness.test.mjs:611-627` использует ровно числа issue (`-85`/`-82.457`, `y=304`), affine-режим действительно проецирует пропорционально (тест это явно проверяет через `assert.notDeepEqual`), `fixed-topology` режим сохраняет запись byte-equivalent. - **AC3 (untouched exact records):** `preserves an unrelated exact record byte-semantically` — `assert.deepEqual(next, [wall])`, дословное совпадение объекта, ключ включён. Конфликт-резолюция нескольких перекрывающихся `moves` на один атом (`candidates.slice(1).some(...)`, `src/wall-thickness.ts:685-691`) — код не тронут этим диффом, откат к исходному атому вместо выбора по порядку — поведение унаследовано из до-#298 кода и покрыто существующим тестом «issue 253 key collisions never erase…»; проверено чтением, не исполнением отдельно для этого диффа. - **AC5 (carrier preflight покрывает весь span, не только концы):** новый тест `carrier proof covers collinear chains and rejects gaps or off-grid endpoints` — три сценария (цепочка из двух коллинеарных carriers, разрыв между ними, истинно off-grid координата) подтверждены прогоном, разрыв и off-grid реально даю `false`. Табличное покрытие уже (нет отдельного кейса «endpoint на independent partition» и «conflicting shared destinations»), но базовый механизм (полное покрытие интервала, а не только концов/ midpoint) доказан — держу как Low, не блокирует. - **AC6:** лично прогнал все шесть комбинаций (`--seed 1/2/3` × обе фикстуры внутри каждого прогона) — `off_lattice_coordinate` и `wall_carrier` из `KNOWN` действительно исчезли, остался только независимый `mixed_role_record` (#299), что соответствует ТЗ и не маскируется под результат этой задачи. - **AC7 (атомарность preview/commit):** не появилось нового отдельного теста именно на «forced carrier failure → 0 writes», но: (а) новый carrier-guard использует тот же существующий, уже протестированный контракт `{ ok: false, reason: 'wall-metadata' }`, что и ранее существовавшая проверка сохранности `cm` (`src/houseplan-card.ts:8556`, не новая в этом диффе) — тот же путь отказа, та же атомарность; (б) `smoke_resize_pointer_real_plan.mjs` (не изменён этим диффом, но прогнан мной заново на новом коде) зелёный целиком, включая preview/commit/Undo/Redo на реальной фикстуре. Прямого forced-reject сценария именно для нового carrier-guard нет — это Medium-по-полноте наблюдение, снимаю с записью здесь, а не как блокер: атомарность инфраструктуры отказа не новая, она унаследована и уже нагружена другими причинами отказа. - **AC8 (мутационный страж):** новый мутант `safe-resize-wall-endpoints-affine-scaled` действительно ловится только тестами `issue 298` — прогнал `--id=` явно, гард покраснел при мутации, «поймано 1 из 1». - **AC9 (перф):** прогнал `benchmark_safe_resize.mjs` — новый carrier-код добавляет исчезающе малую стоимость (`commitPreflight` p95 0.002мс против бюджета 75мс), запас на три порядка. - **Гейты процесса:** typecheck/test/build/bundle-parity/check-docs зелёные; единственный коммит несёт `Issue: #298` и `User-Visible: yes`, оба changelog правлены тем же коммитом; `docs/RESIZE.md`, `docs/WALL-THICKNESS.md`, `docs/TESTING.md`, `docs/ARCHITECTURE.md`, `docs/USER-GUIDE{,.ru}.md` обновлены содержательно и без выдуманных утверждений о поведении, которого нет в коде (сверено построчно с реализацией). - **Одно число — один источник:** этот дифф не добавляет новую пользовательски видимую величину (толщина `cm` как была, так и осталась единственным хранимым числом; координаты `a/b` — внутреннее хранение, не показываются пользователю напрямую) — `test/single-source-numbers.test.mjs` прошёл в общем прогоне `npm test`, отдельного нового риска не вижу. - **Touch/UX/i18n:** новых контролов, ключей или состояний нет (сверено с §5.3/UX-раздела ТЗ), в диффе действительно не добавлено ни одного i18n ключа и ни одного нового UI-текста, что подтверждает содержание диффа. ## Чего не проверял - Полный `golden:verify` и полный browser-smoke матрица (185 файлов) — не оправданы объёмом/природой диффа, см. таблицу гейтов выше; - backend/Python — диф их не касается; - реальный HA harness (Linux CI/WSL) — вне ревью кода, это пред-релизный гейт; - `demo/benchmark_safe_resize_render.mjs` — рендер-путь не тронут этим диффом; - поведение на конфигурациях **с** legacy-записями в проде за пределами сконструированных мной проб — таких фикстур в репозитории нет; это и есть суть находки High-1. ## Вердикт Оба High-финдинга — прямое следствие того, что заявленный в ТЗ и AC скоуп («legacy wall records» в AC4, отдельная first-floor репродукция в AC2) не доведён до конца: код для legacy-записей не переписан под fixed-topology контракт (хотя раздел «Входит» ТЗ его туда явно включает и демонстрируемо воспроизводит тот же класс дефекта, ради которого заведена задача), а вторая из двух явно поимённых в issue репродукций не имеет теста вовсе. Оба находятся в скоупе этой же задачи и чинятся в ней же — не отдельным issue. **Вердикт: красный · заход r1 · блокирующих циклов 0/4 · High: 2 · Medium: 0 → в задаче**