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

277 lines
28 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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, но это не заменяет требуемого юнит-свидетеля.
---
<!-- material-anchors: сгенерировано конвейером (#414) -->
## Материал раунда
- Ветка: `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
```