Files
houseplan-card/docs/reviews/CODE-REVIEW-461-r1.md
T
2026-09-05 11:36:18 +00:00

28 KiB
Raw Blame History

CODE-REVIEW-461-r1

  • Issue: https://github.com/Matysh/houseplan-card/issues/461
  • ТЗ: docs/specs/461-wall-draw-click-performance.md (SPEC-REVIEW-461-r1, зелёный)
  • Материал: SHA 34d53bf2d1b9252e37b356c1a295b1bcab7865f9, ветка issue/461-wall-draw-click-performance, диапазон origin/dev..HEAD = 4 коммита (1ef21037 ТЗ, 9bdf531c spec-review doc, 04a1de09 реализация User-Visible: yes, 34d53bf2 рефреш screenshot-фингерпринта User-Visible: no).
  • Заход: r1 · блокирующих циклов израсходовано 0 из 4
  • Вердикт: жёлтый

Скоуп ревью

Этап code (PROCESS.md §2.7). Диапазон git diff origin/dev...HEAD: 50 файлов, +2388/−491, из них продуктовый исходник — src/draft-live-commit.ts (новый, 152 строки), src/draft-live-preflight.ts (новый, 406 строк), src/houseplan-editor-runtime.ts (+2/−1: подмена вызова в _persistActiveDraftSegment). Остальное — тесты (test/draft-live-preflight.test.mjs новый, test/wall-union-isolation.test.mjs +6/−1), гейты (scripts/mutation-gate.mjs +5 мутантов, scripts/smoke-links.mjs, .github/workflows/validate.yml, package.json), демо/perf (demo/wall-draw-click-harness.mjs, demo/benchmark_wall_draw_click.mjs, demo/smoke_wall_draw_click.mjs, demo/fixtures/wall-draw-click.mjs, demo/performance/README.md), документация (ARCHITECTURE/CONFIG-COMPATIBILITY/ оба CHANGELOG/specs README) и сгенерированные бандлы + docs/images/screenshots.json (только sourceFingerprint, все 10 imageSha256 не изменились — сверено построчно).

Как проверялось

Дешёвые гейты подтверждены на этом же SHA прогоном Validate (https://github.com/Matysh/houseplan-card/actions/runs/33962804004, success) — не перегонял npx tsc --noEmit, npm test, npm run build+сверку бандлов, no-new-any, check-docs, канонические screenshots и полный browser smoke: все они уже зелёные на точном 34d53bf2, диапазон между этим SHA и текущим HEAD пуст (я разбираю тот же коммит).

Гейт Прогнал сам Результат / источник
npx tsc --noEmit нет Validate на 34d53bf2 — success
npm test нет Validate на 34d53bf2 — success (1995 pass, заявлено автором, подтверждено тем же прогоном CI)
npm run build + сверка 3 копий бандла нет Validate на 34d53bf2 — success; вручную свёл, что единственная содержательная правка бандла — переименованный чанк houseplan-editor-runtime-* (см. git diff --stat), схоже с добавлением нового модуля
node scripts/no-new-any.mjs нет Validate на 34d53bf2 — success; заявлено автором «560 добавленных строк, новых any нет»
node scripts/check-docs.mjs нет Validate на 34d53bf2 — success; diff трогает src/**, отпечаток обновлён коммитом 34d53bf2
Канонические screenshots нет отдельный прогон job docs, ссылка в хендоффе: 10/10 imageSha256 без изменений, принят только sourceFingerprint; сверил сам docs/images/screenshots.json — все imageSha256 идентичны dev
node scripts/smoke-select.mjs --base origin/dev --head HEAD да 38 прямых совпадений, 20 зарегистрированных/слабых связей, 1 «зарегистрированная связь» — вывод приложен ниже
Targeted browser smoke (smoke_wall_draw_click.mjs + связанные _markupClick-смоки) нет Validate на 34d53bf2 прогнал полный smoke тремя шардами — success; целевой смок новый, заявлен автором зелёным
npm run benchmark:wall-draw-click нет (не воспроизводил числа) зарегистрирован в CI (validate.yml шаг «Быстрый commit точки стены (#461)»), заявленные числа (median 10.9 мс, max 13.8 мс, remote 16.7 мс) укладываются в бюджеты 150/250/~36.3 мс с большим запасом; структуру ассертов проверил чтением demo/benchmark_wall_draw_click.mjs — соответствует §9.2
python -m pytest tests_backend нет diff не трогает custom_components/**/*.py
node scripts/model-invariants.mjs нет diff не меняет способ ЗАПИСИ геометрии (commitWallSegmentModel/adoptWallSegmentModelCandidateInPlace не изменены — git diff по src/wall-segment-model.ts пуст), новый код только читает существующие структуры для построения runtime-only local proof; ссылки/толщины остаются под тем же самым write-барьером, что и раньше
Мутанты scripts/mutation-gate.mjs да (чтением + прослеживание сигналов) все 5 новых мутантов трассированы до guard-команды и до конкретного счётчика/поля, которое красит патч (таблица ниже)

Дальше — построчное чтение продуктового кода: src/draft-live-preflight.ts целиком (406 строк), src/draft-live-commit.ts целиком (152 строки), diff src/houseplan-editor-runtime.ts (call site), test/draft-live-preflight.test.mjs целиком, demo/wall-draw-click-harness.mjs, demo/benchmark_wall_draw_click.mjs, demo/fixtures/wall-draw-click.mjs, demo/smoke_wall_draw_click.mjs, diff scripts/mutation-gate.mjs, все правки документации.

Находки

Medium (в скоупе задачи — чинится в этой же issue)

M1. AC4 заявляет метод доказательства «unit + mutation witness» для «verdict fast/full совпадает на матрице §8», но матрица не проверена ни одним тестом через новый путь.

§8 перечисляет десять конкретных сценариев: свободное продолжение цепочки, 14°/15° junction, valence 6/7, стена 19/20 см и короткий атом в более длинном run, node distance 4/5 см, пересечение комнаты/partition/чужого draft/column, физический wall-degraded-extra (фикстура #278), hosted opening, нулевая стена, malformed local carrier. AC4 в самом ТЗ отдельно называет ещё три обязательные мутации: «выбросить соседнюю комнату», «не включать чужой draft/column», «не замыкать junction rays».

Что реально доказано:

  • «выбросить соседнюю комнату» — есть, wall-draw-local-neighbour-dropped в scripts/mutation-gate.mjs, guard node --test --test-name-pattern="local draft projection" test/draft-live-preflight.test.mjs.

  • «не включать чужой draft/column» и «не замыкать junction rays» — не зарегистрированы как мутанты (что легально для чистых юнитов, PROCESS.md §2.7), но подтверждены прогоном самих unit-тестов: deepEqual на remote-draft/remote-column в тесте «retains interacting geometry and drops remote work» и на beyond-one-layer/remote в тесте «closes a collinear run and keeps every ray at its junctions» — оба списка исчерпывающие (deepEqual, не some), значит лишний или недостающий элемент красит их.

  • Сама матрица §8 (10 пунктов) — не проверена ни разу через новый путь. git diff --stat -- 'test/*.mjs' за весь диапазон называет только test/draft-live-preflight.test.mjs (новый) и test/wall-union-isolation.test.mjs (+6/−1, только источник строки вызова). Ни один из этих файлов не строит ни 14°, ни valence 6/7, ни 19/20 см, ни 4/5 см, ни wall-degraded-extra. Единственный близкий случай — demo/smoke_wall_draw_click.mjs:27, один клик на 10 см, отклонённый «существующим правилом 20 см» — это одно представление правила минимальной длины, и оно не на границе (19 vs 20), а далеко от неё.

    Я проверил все восемь существующих смоков с «junction»/«wall_chain» в имени (smoke_junction_limits.mjs, smoke_wall_junctions.mjs, smoke_multiwall_junction.mjs, smoke_near_orthogonal_junction.mjs, smoke_wall_chain_merge.mjs, smoke_wall_chain_thickness.mjs, smoke_junction_holes.mjs, smoke_junction_patch_resilience.mjs) на предмет случайного покрытия: ни один не вызывает _markupClick (grep -c "_markupClick" — везде 0). smoke_junction_limits.mjs строит свои 14°/19 см кейсы через card._commitRoom() — путь создания комнаты, явно вне скоупа задачи (§3.2) и не задевающий commitDraftSegmentGeometry вовсе. Это подтверждено и инструментом smoke-select.mjs: он относит smoke_junction_limits.mjs к «прямому совпадению» по общим символам (_activeDraftId, _path, _showToast), но общий символ — не то же самое, что общий код-путь, и здесь это ложное срабатывание эвристики.

    Я проверил и обратное — что механизм спроектирован разумно (общие production-функции checkSpacePhysicalGeometry/_junctionLimitsIntroduced вызываются без изменений, локальный проектор собирает контекст дистанционно + итеративным закрытием), и структурно это должно давать parity. Но это рассуждение, а не свидетель, а именно свидетель заявлен как метод доказательства AC4.

    Почему это Medium, а не Low. Правило #435 (PROCESS.md §2.7): «Пустой третий столбец — находка Medium, а не примечание». Именно это здесь и происходит — заявленный метод доказательства («unit + mutation witness» на конкретно перечисленной матрице) не подкреплён ни одним тестом, который реально строит эту матрицу через новый код-путь.

    Фикс в скоупе задачи (Medium в скоупе чинится в текущем issue, PROCESS.md §2.7/#202): добавить unit-тест(ы), которые прогоняют реальные checkSpacePhysicalGeometry/_junctionLimitsIntroduced на локальном candidate/previous из draftLiveCandidateSpace для хотя бы репрезентативного подмножества строк §8 (в первую очередь — границы 14°/15°, 19/20 см, 4/5 см и valence 6/7, поскольку это единственные пункты, зависящие именно от completeness локального проектора у существующей границы, а не только от наличия/отсутствия объекта), сравнивая verdict с той же проверкой на полном space. Не обязательно заводить новый мутант в scripts/mutation-gate.mjs для каждого пункта — для чистых юнитов достаточно демонстрации в документе, но сами тесты обязаны существовать.

Low (не блокирует, оставляю с записью)

L1. §6.4 против фактической обработки !safe в commitDraftSegmentGeometry.

ТЗ: «Исключение, null, degraded-extra, failed-core, floor failure либо невозможность построить local candidate отклоняют fast path и приводят к generic full barrier; они не дают fail-open принятия» (docs/specs/461-*.md:205-207).

Код (src/draft-live-commit.ts:108-121): если checkSpacePhysicalGeometry бросает исключение, возвращает {ok:false} (в т.ч. degraded-extra/ failed-core) или проваливает off-grid проверку — вызывается rejectUnsafe (тост geometry_unsafe, откат before, return false), а НЕ runtime._commitPhysicalGeometry(...) (fallback на generic barrier). Буквально это расходится с «приводят к generic full barrier».

Снимаю без правки. Тот же паттерн (try { ok } catch { false } → restore + toast('geometry_unsafe') без повторной попытки на полном барьере) уже существует в этом же файле для аналогичного случая — houseplan-editor-runtime.ts:1846-1854 (реконсиляция pending physical write). Значит выбор реализации соответствует установившейся в этом же рантайме конвенции для «локальный физический чек нашёл проблему», а не является недосмотром. Разница с fallback-веткой (return fallback()) содержательна: fallback() используется, когда невозможно доказать форму diff'а (legacy/mixed/неизвестная модель, !candidateProjection — не удалось ПОСТРОИТЬ candidate), тогда как rejectUnsafe используется, когда candidate построен, но производственная проверка нашла в нём нарушение — в этом случае повторный прогон того же самого plan-geometry-preflight на полном пространстве почти никогда не отменит локально найденное нарушение (локальный набор — строгое подмножество полного), а на редкой доказуемой развилке (когда degraded-extra мог бы разрешиться более широким контекстом) поведение безопаснее: fail-closed вместо потенциальной второй попытки. Ни один AC не формулирует эту развилку как отдельный проверяемый критерий, так что это не нарушение AC — расхождение только с прозой §6.4.

Таблица «AC · чем доказан · чем краснеет» (PROCESS.md §2.7 #435)

AC Чем доказан Чем краснеет
AC1 demo/benchmark_wall_draw_click.mjs: base.result.first.* (первый клик 0 checks/write/history), clickShape на каждый из 7 кликов мутант wall-draw-full-preflight-again — ломает clickShape (localPhysicalChecks/junctionArtifactPasses уходят от 1), report.pass=false, exit 1
AC2 test/draft-live-preflight.test.mjs («route guard accepts exactly one active-draft append») — мутированный/mixed/legacy diff → false; код-ревью commitDraftSegmentGeometry:77-80 (model_version, draftId, isSingleDraftAppend) сам unit-тест: isSingleDraftAppend на mixed/чужой draftId ожидает false, снятие любой из проверок красит соответствующий assert.equal
AC3 test/draft-live-preflight.test.mjs («retains interacting geometry and drops remote work», «closes a collinear run…», «malformed local geometry fails closed») deepEqual/assert.equal на списках near/remote id — лишний/недостающий элемент красит тест напрямую; malformed-тест краснеет, если malformed запись выпадает из proof
AC4 частично: см. M1 — «drop room»/«foreign draft-column»/«junction rays» покрыты, матрица §8 (10 пунктов) не покрыта wall-draw-local-neighbour-dropped (комната); остальное — пусто, находка M1
AC5 demo/benchmark_wall_draw_click.mjs: clickShape.junctionArtifactPasses === 1 мутант wall-draw-wall-artifact-discarded (передаёт null вместо geometry) — junctionArtifactPasses не инкрементируется, clickShape→false, exit 1
AC6 demo/smoke_wall_draw_click.mjs: rejected.restored/noGesture/noHistory/noWrite/existingDraftsIntact/namesRule мутант wall-draw-rejection-rollback-skipped (убирает _restoreGeometryStateLocal в ветке introduced.length) — _serverCfg не совпадёт с beforeReject (черновик уже мутирован in-place до вызова commitDraftSegmentGeometry, см. _persistActiveDraftSegment:2793-2798), rejected.restored=false
AC7 demo/benchmark_wall_draw_click.mjs: structural требует terminal.fullSpacePhysicalChecks >= 1 мутант wall-draw-terminal-full-check-skipped (заменяет _commitPhysicalGeometry на _recordGeometry при wall_chain_finish) — счётчик остаётся 0, structural=false
AC8 регрессия существующих _markupClick-смоков (v8_draft_write, free_walls, wall_chain_merge, room_autoclose, editor_gestures, card_tool_conflict, grid_snap, plan_snap_overlay, island_rooms, plan_drawing_repairs, merge_split) — все автоматически идут через новый fast path, т.к. это единственный call site _persistActiveDraftSegment Validate на 34d53bf2 — success по всем трём шардам smoke; не проверял по отдельности через снятую защиту (полагаюсь на факт «эти смоки — прямые совпадения diff» из smoke-select.mjs + зелёный CI)
AC9 demo/benchmark_wall_draw_click.mjs целиком: fixture shape, structural, timing (median/max/remote vs budgets) wall-draw-full-preflight-again и wall-draw-terminal-full-check-skipped оба ломают structural; сам timing проверяется реальными числами (10.9/13.8/16.7 мс против 150/250/~36.3), заявлено автором, не перепроверял вычисления
AC10 Validate на 34d53bf2 (tsc/test/build/no-new-any) не воспроизводил
AC11 docs/images/screenshots.json — все 10 imageSha256 идентичны dev, изменился только sourceFingerprint; регрессионные smoke/golden — Validate green не воспроизводил golden:verify локально
AC12 docs/ARCHITECTURE.md (+14, новый абзац о границе), docs/CONFIG-COMPATIBILITY.md (+9), оба docs/CHANGELOG*.md в одном User-Visible: yes коммите (04a1de09), docs/specs/README.md +1 строка, demo/performance/README.md +21 чтением: все обязательные файлы присутствуют, ссылка на #461 в обоих changelog проверена текстом

Что проверено и корректно

  • Единственная точка изменения — _persistActiveDraftSegment в houseplan-editor-runtime.ts:2795, подмена _commitPhysicalGeometry на commitDraftSegmentGeometry; diff файла — ровно 2 добавленные и 1 удалённая строка. Все остальные ~15 вызовов _commitPhysicalGeometry не тронуты (сверено test/wall-union-isolation.test.mjs — список history-ключей сохранён без draft_segment, добавлена отдельная проверка нового call site).
  • Route guard isSingleDraftAppend корректно требует byte-identical прочие массивы (rooms/openings/walls/wall_segments/open_spans/partitions/ wall_columns/decor) и plan_transform, ровно +1 точку/+1 сегмент к ровно одному активному draft; первый персистентный клик (2 точки из «ниоткуда») обработан отдельной веткой. Легаси/будущая модель отсекается сравнением model_version до вызова route guard.
  • Локальный проектор draftLiveCandidateSpace: не мутирует исходный space (проверено тестом на JSON.stringify равенство до/после), сохраняет неизвестные поля (future_field: 'kept' в тесте), корректно учитывает реальную толщину при отборе комнат (thick-envelope-кейс: партиция вне 5 см по оси, но внутри реального 40-сантиметрового тела — включена), закрывает коллинеарный run итеративно до неподвижной точки (while(grew)), закрывает все rays на достигнутых junction одним проходом (сознательно не рекурсирует дальше — подтверждено тестом «keeps every ray at its junctions», явно исключающим beyond-one-layer), fail-closed на malformed/unlocatable записях (не удаляет их из proof, а сохраняет как есть).
  • Переиспользование wall-артефакта: checkSpacePhysicalGeometry вызывается один раз на local candidate, его roomGeom/multiWallNodes передаются в _junctionLimitsIntroduced вместо повторного union — подтверждено чтением draft-live-commit.ts:110-125 и мутантом wall-draw-wall-artifact-discarded.
  • Rollback: sp.room_drafts[i] = saved в _persistActiveDraftSegment мутирует _serverCfg до вызова commitDraftSegmentGeometry — значит _restoreGeometryStateLocal(before) в rejectUnsafe/junction-reject ветке не косметика, а обязательное условие отсутствия «зависшей» точки; смок и мутант это подтверждают.
  • Identity re-sync после принятия (draft-live-commit.ts:133-143): _path/_draftSegmentCms перезаписываются точными координатами из принятого canonical config, а не оставляют локальные IEEE-хвосты — комментарий в коде верно называет причину (следующий клик иначе не докажет draft-only diff).
  • Perf-бюджет: структура ассертов demo/benchmark_wall_draw_click.mjs соответствует §9.2/§9.3 буквально (structural отдельно от timing, localProofMaxObjects < positiveSegments доказывает нелокальность роста, remote-вариант удваивает удалённые сегменты и проверяет, что localProofMaxObjects не меняется).
  • Changelog/архитектура/compatibility — все три документа обновлены в одном коммите с поведением (04a1de09, User-Visible: yes), содержат пользовательский эффект и ссылку на #461; новых i18n-ключей нет (grep по diff src/i18n/*.json — файлы не тронуты).
  • Golden/screenshots — docs/images/screenshots.json: все 10 imageSha256 идентичны версии на dev, изменился только sourceFingerprint (ожидаемо для любой правки src/** по механике check-docs, не является визуальной регрессией).

Чего не проверял

  • Не перегонял npx tsc --noEmit / npm test / npm run build+сверку бандлов / no-new-any / check-docs / канонические screenshots / полный browser smoke — все зелёные на точном материале ревью (34d53bf2), прогон https://github.com/Matysh/houseplan-card/actions/runs/33962804004.
  • Не воспроизводил числа npm run benchmark:wall-draw-click локально (нет Chromium-профиля под рукой в контексте ревью) — доверился структуре ассертов (прочитаны построчно) и заявленным автором числам с большим запасом от бюджета (10.9 мс против 150 мс).
  • Не гонял node scripts/model-invariants.mjs — diff не меняет функции записи геометрии (commitWallSegmentModel/ adoptWallSegmentModelCandidateInPlace не изменены), только читает существующие структуры для построения runtime-only proof; риск рассинхронизации ключей толщины/решётки не создаётся этой задачей.
  • Не гонял pytest tests_backend — diff не касается custom_components/**/*.py.
  • Не строил вручную ни один из десяти пунктов матрицы §8, чтобы лично убедиться в parity fast/full — это и есть предмет находки M1: без теста такую проверку пришлось бы делать вручную в браузере, а «проверено вручную» не является допустимым доказательством AC (PROCESS.md §2.7/№18). Прочитал код и рассуждением (не исполнением) убедился, что механизм (общие production-функции + дистанционный отбор + итеративное закрытие) СТРУКТУРНО должен давать parity, но это не заменяет требуемого юнит-свидетеля.

Материал раунда

  • Ветка: issue/461-wall-draw-click-performance, коммит 34d53bf2d1b9 — ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет.
  • Дерево материала: 35ad6e67e45fd6b74f0f345c1a9d4d6c30d6bd54
    git log --all --format='%H %T' | grep 35ad6e67e45f
    
  • ТЗ docs/specs/461-wall-draw-click-performance.md, блоб ccf374b1c0797ace0156b7cfb366007f1271fd35
    git log --all --find-object=ccf374b1c0797ace0156b7cfb366007f1271fd35 -- docs/specs/461-wall-draw-click-performance.md