docs: review document for #264

Issue: #264
User-Visible: no
This commit is contained in:
claude[bot]
2026-08-27 16:51:56 +00:00
parent 6e1aea3ae7
commit c46c714c58
+233
View File
@@ -0,0 +1,233 @@
# 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.