Files
houseplan-card/docs/reviews/SPEC-REVIEW-264-r1.md
T
2026-08-27 16:51:56 +00:00

234 lines
18 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.
# SPEC-REVIEW-264-r1 — контроллер Resize и commit-инвариант кладки
- **Issue:** https://github.com/Matysh/houseplan-card/issues/264
- **Этап:** ТЗ (S4-spec-review), заход r1, лимит циклов 4 (полный трек)
- **Ревьюер:** независимая сессия, без контекста автора ТЗ
- **Материал:** `docs/specs/264-resize-controller.md` на SHA `6e1aea3a`
(коммиты `a3e32995` «specify resize controller extraction»,
`6e1aea3a` «add resize controller user scenario»; `git diff origin/dev...HEAD`
затрагивает только этот один файл, продуктовый код не менялся)
- **Вердикт: жёлтый · заход r1 · блокирующих циклов 1/4 · High: 0 · Medium: 2 → в задаче**
## Скоуп проверки
Полный разбор (первый заход, дельты нет). Прочитаны:
`docs/SCOPE.md`, `AGENTS.md`, `PROCESS.md`, тело issue #264 и все 4 комментария
(включая обе аналитики владельца от 2026-08-24 и 2026-08-27), `docs/RESIZE.md`
(канон Resize), `docs/specs/034-frontend-decomposition.md` (зонтичный слайс),
тело issue #277 (безопасный fixed-topology контракт, prerequisite).
## Как проверялось
Поскольку это этап ТЗ, а не код-ревью, тяжёлые гейты (`typecheck`/`test`/`build`)
не запускались — `git diff origin/dev...HEAD` показывает изменение только
`docs/specs/264-resize-controller.md`, продуктовый код не тронут. Вместо
прогона гейтов сверял каждое фактическое утверждение ТЗ о **текущем** состоянии
кода с реальным `src/houseplan-card.ts`, `src/resize.ts`, `src/wall-thickness.ts`,
`src/open-spans.ts`, `scripts/model-invariants.mjs`, `package.json` и `demo/`,
командами `grep`/`sed` — то, что задача прямо требует называть предположением
или доказывать, а не декларировать.
## Находки
### M1 (Medium, в скоупе) — утверждение «Escape в idle Resize уже сейчас сразу возвращает Draw» не соответствует коду
**Файл:** `docs/specs/264-resize-controller.md`, §2 п.7 и §19 (строка риска
про `_rszSel`).
**Утверждение ТЗ:**
> Мёртвое session-only поле `_rszSel` удаляется: после #277 оно не имеет
> renderer-потребителя и не влияет на пиксели, config или Undo. **Escape в
> idle Resize, как и фактически сейчас, сразу возвращает neutral Draw.**
и в таблице рисков (§19):
> Removal of `_rszSel` changes warm/Escape behavior | Source/runtime parity
> test; **it has no render consumer and idle Escape ends at Draw.**
**Воспроизведение по коду.** `src/houseplan-card.ts:2701-2710`
(обработчик Escape, тул `resize`, вне драга):
```ts
if (this._tool === 'resize') {
e.preventDefault();
if (this._rszDrag) {
this._rszCancelDrag();
return;
}
if (this._rszSel) this._rszSel = null;
else this._tool = 'draw';
return;
}
```
Это ветвится **не всегда одинаково**: если пользователь щёлкнул по площади
комнаты в инструменте Resize (обработчик клика на `src/houseplan-card.ts:7802-7804`
ставит `this._rszSel = room?.id || null`), первый Escape только сбрасывает
`_rszSel` и остаётся в Resize; тул на Draw переключает только **второй**
Escape. То есть сегодня «idle Escape» **не** всегда сразу возвращает Draw —
это зависит от того, была ли до этого выбрана комната кликом.
`_rszSel` действительно не имеет визуального потребителя (проверено:
`demo/smoke_hide_layers.mjs:159` и `demo/smoke_pan_any_zoom.mjs:134`
намеренно выставляют `_rszSel`, доказывая лишь отсутствие старой corner-frame
разметки #277 — это подтверждает часть про «не влияет на пиксели», но не
часть про Escape). Значит убрать это поле «как есть» без изменения самого
`if (this._rszDrag) {...} if (this._rszSel) ... else this._tool = 'draw';`
означает буквально удалить условную ветку — и тогда Escape при выбранной
(невидимо) комнате в Resize станет **сразу** переключать на Draw, чего
сегодня не происходит.
**Почему это находка, а не мелочь.** §1 ТЗ прямо обещает персоне, что слайс
«обязан быть полностью незаметен», и в перечне неизменного в самом начале
документа: «DOM, пиксели, тексты, **жесты** и persisted config не меняются».
Escape-последовательность — это жест. Документ одновременно (а) заявляет
парность с текущим поведением как факт, (б) сам же перечисляет удаление
`_rszSel` как часть скоупа (§10) и своей же мерой риска называет то самое
неверное утверждение о «текущем» поведении. Это ровно тот класс дефекта,
о котором предупреждает процесс: догадка о поведении, записанная как факт,
проходит ревью, потому что выглядит решением.
**Требуемое исправление одно из двух** (решает автор, не владелец — это
техническая деталь конечного автомата контроллера, не то, что видит
пользователь по существу задачи):
1. явно зафиксировать, что рефакторинг **меняет** это конкретное поведение
(Escape после выбора комнаты в Resize отныне сразу выходит в Draw), и
убрать формулировку «как фактически сейчас» — задокументировать как
осознанное упрощение; либо
2. сохранить двухшаговый Escape, перенеся понятие «комната выбрана»
в состояние контроллера (например, отдельный `hasSelection`/`picked`
в typed outcome конечного автомата §6), а не как «мёртвое» поле для
простого удаления.
Ни один из вариантов не требует нового вопроса владельцу — это находка
внутри уже принятой владельцем границы «что делает автор» (§7.1 PROCESS.md).
### M2 (Medium, в скоупе) — несуществующий файл смока в планируемом diff
**Файл:** `docs/specs/264-resize-controller.md`, §15 «Файлы и модули».
ТЗ перечисляет как существующий поведенческий тест:
> существующие `demo/smoke_safe_resize.mjs`, `demo/smoke_resize_wall_thickness.mjs`,
> `demo/smoke_resize_inner_dimensions.mjs` — behavioral parity;
`demo/smoke_safe_resize.mjs` **не существует**:
```
$ ls demo/smoke_safe_resize.mjs
ls: cannot access 'demo/smoke_safe_resize.mjs': No such file or directory
```
Канонический список Resize-смоков — раздел «Verification» в `docs/RESIZE.md` —
называет `demo/smoke_room_resize.mjs` (production bundle pointer handlers,
preview/commit/Undo, disabled a11y, production-preflight failure и
cancellation — именно то, что §15 хочет накрыть словом «behavioral parity»)
и `demo/smoke_resize_pointer_real_plan.mjs`. Оба реально существуют в `demo/`.
Тело исходного issue #264 в разделе «Как проверять» тоже не называет
`smoke_safe_resize`, только `smoke_resize_wall_thickness` и
`smoke_resize_inner_dimensions` — значит ошибка внесена автором ТЗ, не
унаследована из issue.
**Почему это находка.** Список файлов в DoR — то, по чему исполнитель и
ревьюер кода сверяют полноту diff'а (§2.5, §7.1 PROCESS.md). Несуществующее
имя тут — либо повод по ошибке создать новый файл-дубликат вместо запуска
существующего `smoke_room_resize.mjs`, либо просто нерабочая команда при
локальном прогоне. Дешёво чинится заменой имени на `demo/smoke_room_resize.mjs`
(и, по желанию автора, добавлением `demo/smoke_resize_pointer_real_plan.mjs`
рядом, раз он тоже покрывает production pointer path).
## Что проверено и корректно
- **Структура ТЗ** покрывает все обязательные разделы §7.1 PROCESS.md:
сценарий и персона (§1, добавлен вторым коммитом — реагирует именно на то,
что требует шаблон), что человек увидит до/после (§1), проблема (§1),
скоуп/не-скоуп (§10/§11), контракт поведения (§4-§9 плюс явная ссылка на
канон #277), UX/touch (§14), данные и миграция (§18 — нет), i18n (§18 —
нет), AC1…AC12 с методом доказательства (§16), план автотестов (§17),
риски (§19), откат (§20), release-артефакты (§18).
- **Продуктовый вопрос из тела issue закрыт корректно.** Исходный открытый
вопрос «что делать при нарушении инварианта» (отменить жест / записать
диагностику / показать пользователю) снят ссылкой на уже выпущенный #277
(fail-closed, ноль записи) — это подтверждено и в комментарии владельца от
2026-08-24/27, и напрямую в §2 ТЗ. Новых открытых продуктовых вопросов
документ не содержит и не должен: остальные решения (имена типов, файлы,
разбиение controller/root) — техническая территория автора по §7.1.
- **Все ссылки на существующий код в архитектурном разделе (кроме M2)
фактически верны**, что важно для refactor-only задачи с высоким риском
«догадки, выданной за решение»:
- все перечисленные `_rsz*` mutable-поля (`_rszDrag`, `_rszPreview`,
`_rszLive`, `_rszEligibilityCache`, `_rszSel`) реально существуют в
`src/houseplan-card.ts` (119 упоминаний `_rsz` в файле);
- «inline сравнение внутри Resize» из §0 реально существует —
`src/houseplan-card.ts` (`_rszApplyPreview`) сравнивает
`JSON.stringify(beforeWallCms) !== JSON.stringify(afterWallCms)`
отсортированных мультимножеств `cm`, что уже сегодня фактически является
exact-multiplicity проверкой (включая `cm:0`), которую §8 предлагает
формально вынести в общий модуль — согласуется;
- `checkWallRecordsPreserved(before, after, { allowClear = false })` в
`scripts/model-invariants.mjs:301` действительно фильтрует `cm > 0`
(presence-семантика #254), т.е. новый режим `exactMultiplicity` из §8 —
это расширение, а не переписывание текущего поведения;
- паттерн «CLI импортирует production-модуль из `test-build/*`, собранного
`tsc -p tsconfig.test.json`» уже используется для
`plan-geometry-preflight`/`near-axis`/`coordinate-canonicalization`
(`scripts/model-invariants.mjs:21-26`), и `npm run invariants` в
`package.json` действительно гоняет `tsc -p tsconfig.test.json` перед
CLI — план §8 по добавлению туда же `wall-record-preservation` технически
исполним без нового механизма;
- все геометрические функции, на которые ссылается §4.3/§6
(`resolveSafeResize`, `clampSafeResize`, `applySafeResize`,
`validateSafeResize`, `safeResizePointerDisplacement` в `src/resize.ts`;
`rekeyWallsAfterMoveChecked` в `src/wall-thickness.ts`;
`rekeyOpenSpansAfterMove` в `src/open-spans.ts`) существуют под теми же
именами;
- `docs/TESTING.md`, `docs/ARCHITECTURE.md`, `docs/STATUS.md`,
`tsconfig.test.json` — все существуют, ссылки на них в §15/§18 корректны;
- `WarmViewport.rszSel` (`src/houseplan-card.ts:430`) — это поле
неперсистентного модульного кэша `_warmVp` («the memo is module state,
never serialised»), не часть сохранённого конфига — утверждение §9 о
том, что удаление `rszSel` из `WarmViewport` не меняет сериализуемый
config, подтверждено чтением кода.
- **Performance-бюджеты §13** (pointermove p95 ≤16 мс/≤20% над baseline,
pointerup p95 ≤75 мс) дословно совпадают с уже канонизированными в
`docs/RESIZE.md`, а не изобретены заново.
- **Класс изменения и трейлеры.** Class A+B+C, полный флоу — верно для
задачи, трогающей `src/**` (Class A) даже как рефакторинг; `User-Visible: no`
корректен для содержания ТЗ *после* исправления M1 (сейчас документ сам
себе противоречит по этому пункту, см. находку).
- **Соответствие SCOPE.md.** Задача не создаёт нового пользовательского
поведения, служит устойчивости J6 («Keep the plan true as the home
evolves» — drag/resize) снижением риска регрессий в самом багоносном
инструменте; входит в уже согласованный владельцем зонтичный слайс #34.
Продуктовая ценность честно указана низкой (2/10), инженерная — обоснованно
высокой (9/10); это не попытка протащить фичу под видом рефакторинга.
- **Порядок slice vs #34.** Расхождение «сначала диалог или сначала Resize»,
которое исходный текст issue называл открытым, закрыто прямым решением
владельца («Взято в работу по решению владельца 2026-08-27») — ТЗ корректно
это не пересматривает.
## Чего не проверял
- **Гейты `typecheck`/`test`/`build`/`check-docs`/`invariants` не
запускались** — на этом этапе нет продуктового кода, диапазон
`git diff origin/dev...HEAD` содержит только markdown-файл спецификации.
Это решение по объёму, а не пропуск: гейты станут обязательны на код-ревью
(§7 PROCESS.md, S7-code-review).
- **Смоки `demo/smoke_*resize*.mjs` не запускал** — на этом этапе нечего
прогонять, поведение ещё не реализовано; факт существования файлов
(кроме M2) подтверждён только `ls`, не исполнением.
- **Не проверял детально типизацию generics `ResizeControllerOutcome<TSpace,
TSnapshot, TWallUnion>`** — §5 сама оговаривает «конкретные имена типов
могут уточняться при реализации», это явно техническая, не продуктовая
граница, оставляю автору.
- **Не сверял построчно все 18 пунктов матрицы edge cases (§12) с кодом** —
выборочно (пп. 5, 6, 12, 14) сверил с текущей реализацией `_rszMove`/`_rszUp`
и они совпадают; полную построчную сверку всех 18 не делал, так как это
либо прямое наследование уже выпущенного #277 (не предмет этого ТЗ), либо
будет доказано тестами на код-ревью.
## Итог
Two Medium findings, both in scope, ни один не является продуктовым вопросом
для владельца — оба технические и решаются автором в этом же issue. High-находок
нет, вердикт жёлтый, документ засчитывает заход r1 и тратит первый из четырёх
циклов бюджета §4.