28 KiB
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ТЗ,9bdf531cspec-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, guardnode --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 по diffsrc/i18n/*.json— файлы не тронуты). - Golden/screenshots —
docs/images/screenshots.json: все 10imageSha256идентичны версии на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— ребейз его осиротит, и это нормально: ниже якоря, которые ребейз не меняет. - Дерево материала:
35ad6e67e45fd6b74f0f345c1a9d4d6c30d6bd54git log --all --format='%H %T' | grep 35ad6e67e45f - ТЗ
docs/specs/461-wall-draw-click-performance.md, блобccf374b1c0797ace0156b7cfb366007f1271fd35git log --all --find-object=ccf374b1c0797ace0156b7cfb366007f1271fd35 -- docs/specs/461-wall-draw-click-performance.md