Files
houseplan-card/docs/reviews/CODE-REVIEW-298-r1.md
T
2026-08-24 19:54:40 +00:00

23 KiB
Raw Blame History

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):

// 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 → в задаче