Files
houseplan-card/docs/reviews/SPEC-REVIEW-295-r1.md
T
2026-08-26 10:28:29 +03:00

247 lines
22 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-295-r1
Issue: https://github.com/Matysh/houseplan-card/issues/295
ТЗ: `docs/specs/295-preflight-diagnostics.md`
Ветка: `issue/295-preflight-diagnostics`, SHA материала ревью: `c1b5ceb6b42b5ef506c5c625037334fca6ddf877`
Заход: r1 · трек: обычный (нет `small`/`trivial`) · лимит циклов 4 · блокирующих циклов израсходовано до этого ревью: 0/4
## Скоуп
Задача делает диагностируемым отказ geometric preflight перед «Оптимизировать»:
причина (7 значений `OptimizeGeometryFailureReason`) должна дойти до пользователя
и до отчёта об ошибке, а не теряться, как сейчас. Issue — тип bug/диагностируемость,
трек обычный, ТЗ файлом. Проверялся только этап ТЗ: файл спеки целиком, тело issue
и оба комментария (аналитика, «ТЗ готово»), плюс код подсистемы, которую контракт
трогает (`src/plan-geometry-preflight.ts`, `src/houseplan-card.ts` — диалог
Optimize/align, `custom_components/houseplan/websocket_api.py`,
`custom_components/houseplan/__init__.py`), канонические `docs/CANVAS.md` и
`docs/ARCHITECTURE.md` (раздел про барьер #199) и существующие юниты
`test/plan-geometry-preflight.test.mjs`. Код не менялся, гейты не гонялись — это
этап ревью ТЗ, не код-ревью.
## Как проверялось
Ревью ТЗ — состязательное чтение контракта против фактического кода, а не
пересказ автора:
1. `docs/SCOPE.md`, `AGENTS.md`, `PROCESS.md` — прочитаны целиком (процесс, классы
изменений, §7.1 обязательные разделы, лимит циклов).
2. Issue #295 (тело + оба комментария) через `gh issue view --comments`.
3. `docs/specs/295-preflight-diagnostics.md` — прочитан целиком.
4. Каждое техническое утверждение контракта (§1) сверено с реальным кодом:
- `src/plan-geometry-preflight.ts:60-140, 310-397` — типы, `catch`-блоки,
`checkOptimizeGeometry`/`checkSpacePhysicalGeometry`, комментарий
`:312-315` («Geometry values never escape this call…»);
- `src/houseplan-card.ts:15769-15920` (`_checkOptimizeGeometry`,
`_previewAlignDialog`, `_runAlignToGrid` — обе точки вычисления preflight)
и `:16995-17040` (рендер ветки `failed`);
- все вызовы `_checkSpacePhysicalGeometry` (`grep`: `:7155, 7326, 7385, 7498,
7523, 8865`) — что они читают из результата;
- `src/i18n/ru.json`/`en.json` — существующие ключи `gs.align_preflight_*`,
`gs.optimize_detail_*`, `test/i18n.test.mjs` (правила паритета);
- `custom_components/houseplan/websocket_api.py:1103-1161`
(`houseplan/config/get`), `custom_components/houseplan/__init__.py:95-129`
(регистрация Lovelace-ресурса), `custom_components/houseplan/const.py`,
`import_export.py:526` — источники версии интеграции, доступные фронту;
- `git show 482afb73` — коммит, вводивший барьер #199, и его правки
`docs/CANVAS.md`/`docs/ARCHITECTURE.md`.
5. Существующие смоки/юниты по теме: `ls demo/smoke_*.mjs` (нашёл
`smoke_optimize_geometry_preflight.mjs`), `test/plan-geometry-preflight.test.mjs`
целиком — включая три assert'а, которые прямо противоречат контракту §1.1
(см. находку H1).
Тяжёлые гейты (typecheck/test/build/смоки) не гонялись: на этапе ревью ТЗ кода
нет, гонять их не над чем.
## Находки
### H1 (High, блокирует) — контракт реверсит документированный и протестированный барьер #199 без единого слова об этом
`§1.1` предписывает добавить `detail?: string`, заполняемый из
`String((error as Error)?.message ?? error).slice(0, 200)` в трёх catch-блоках
`checkOptimizeGeometry` (`prepare-exception`, `wall-exception`,
`floor-exception`), а `§1.4` кладёт этот `detail` в диагностический блок, который
(а) копируется в буфер пользователем и (б) уходит в `console.warn` при КАЖДОМ
неуспешном preflight — то есть автоматически, не только по решению пользователя.
Это прямо противоречит существующей, явно задокументированной архитектурной
границе, введённой тем же барьером #199 и не тронутой с тех пор:
- `src/plan-geometry-preflight.ts:312-315`, doc-комментарий к
`checkOptimizeGeometry`: *«Geometry values never escape this call; the dialog
retains only bounded statuses, names and the candidate fingerprint.»*
- `docs/CANVAS.md` (введено в 482afb73, issue #199): *«The dialog retains only
bounded statuses plus `contentFingerprint(candidate.config)`… not polygon
output **or exception text**.»*
- `docs/ARCHITECTURE.md` (тот же коммит): *«The dialog retains statuses and a
config fingerprint, not polygon output **or exception text**.»*
- `test/plan-geometry-preflight.test.mjs:123-145` — три прямых assert'а,
написанных именно для проверки этого свойства:
```js
const wallThrows = checkOptimizeGeometry(wallConfig, { wallPass: () => { throw new Error('secret'); } });
assert.doesNotMatch(JSON.stringify(wallThrows), /secret/);
...
assert.doesNotMatch(JSON.stringify(floorThrows), /private floor detail/);
...
assert.doesNotMatch(JSON.stringify(prepareThrows), /private preparation detail/);
```
Спека не упоминает эту границу вообще — ни в контракте, ни в рисках, ни в
не-скоупе. Технически это означает: реализация по тексту §1.1 гарантированно
делает эти три `assert.doesNotMatch` красными, а автор должен будет либо удалить
их, либо решить это на месте, не имея от ТЗ ни слова о том, что граница
изменяется осознанно. Это ровно тот случай, о котором предупреждает PROCESS.md:
утверждение о поведении, реверсирующее задокументированное решение, подано как
решённый контракт, а не как предположение, которое надо было заметить и оспорить.
Вопрос не продуктовый (владельцу такой вопрос не задаётся), а технический —
разрешаю его сам: граница #199 «exception text никогда не покидает барьер» была
осознанным решением (см. цитаты выше), и одна фраза в ТЗ не может её тихо
отменить. Автору нужно явно выбрать один из двух путей и вписать выбор в ТЗ:
1. **Не выносить raw `error.message` вообще.** `detail` заменяется на что-то
ограниченное самим типом (например `error.name`/фиксированный код), причина
(`reason`) и отпечатки уже дают инструменту достаточно, чтобы диагностировать
класс отказа без риска утечки содержимого geometry-исключения. Тогда граница
#199 не трогается, обновлять `docs/CANVAS.md`/`ARCHITECTURE.md` и три
существующих `assert.doesNotMatch` не требуется — AC1/AC2/AC3/AC4 всё равно
выполняются (причина видна, блок копируется, dev-лог пишется).
2. **Осознанно расширить границу** — явно вписать в контракт, что «exception
text» теперь ограниченно (≤200 символов) покидает барьер именно в
диагностическом блоке/dev-логе (не в основном тексте диалога), обновить
`docs/CANVAS.md` и `docs/ARCHITECTURE.md` тем же изменением и переписать три
`assert.doesNotMatch` на положительные проверки truncation/redaction, а не
просто удалить их.
Любой из путей закрывает находку; молчание — не закрывает. Без этого правки
пункт DoR «модель данных… решены» не выполнен: контракт меняет уже принятое
архитектурное решение без ссылки на него.
### M1 (Medium, в скоупе) — предикат «версия карточки ≠ версия интеграции» (§1.5) ссылается на несуществующий канал данных
`§1.5`: *«`gs.preflight_update_hint` показывается только если версия карточки
!== версия интеграции (доступно из hass-конфига интеграции/manifest…)»*. Это
утверждение проверено и не подтверждается кодом:
- `houseplan/config/get` (`custom_components/houseplan/websocket_api.py:1146-1161`,
ответ фронту при каждой загрузке конфигурации) не несёт `integration_version`
вообще — только `config`, `rev`, `virtual_lights`, `can_write`,
`can_optimize_undo`, `undo_kind`.
- `hass.config` (стандартный core-конфиг HA, доступный карточке) не содержит
версию произвольной кастомной интеграции — это не «hass-конфиг интеграции».
- Единственный реальный сигнал во всей системе — query-параметр `?v=<VERSION>`
на зарегистрированном Lovelace-ресурсе (`custom_components/houseplan/__init__.py:116`,
`module_url = f"{FRONTEND_URL}?v={VERSION}"`), который карточка теоретически
могла бы прочитать из `import.meta.url`/`document.currentScript.src` в момент
загрузки модуля — но это (а) не «hass-конфиг/manifest», как написано в ТЗ,
(б) отдельный, неочевидный механизм, ни разу не упомянутый в контракте,
(в) отражает версию интеграции на момент последней регистрации ресурса, а не
гарантированно текущую — то есть именно то расхождение, которое AC6 пытается
поймать, может дать неверный результат этим же путём.
- Поле `integration_version` существует только в **другом**, не относящемся к
делу контексте — снимке резервной копии (`import_export.py:526`,
`document.get("integration_version")`, используется диалогом импорта бэкапа,
`houseplan-card.ts:16263`) — это версия, записанная В МОМЕНТ ЭКСПОРТА файла,
а не текущая версия работающей интеграции.
AC6 («показывается при расхождении… юнит на предикат + смок одной из веток»)
специфицирован против источника данных, которого не существует, и ни аналитика,
ни ТЗ не называют backend-файл затронутой поверхностью — то есть пункт DoR
«перечислены затронутые файлы и модули» неполон, если для AC6 в действительности
нужна правка `websocket_api.py`.
Дешёвое и точное решение — то же, что уже сделано для бэкапов: добавить
`"integration_version": VERSION` в ответ `houseplan/config/get`
(зеркально `import_export.py:526`), которое карточка и так получает на каждую
загрузку конфигурации. Контракт должен явно это назвать и добавить
`custom_components/houseplan/websocket_api.py` (плюс backend-юнит) в затронутые
файлы. В скоупе задачи — чинится здесь же, отдельный issue не заводится.
### L1 (Low) — несогласованность `detail: null` в примере блока
`§1.4` показывает пример `"detail": "<message или null>"` для всех записей, а
`§1.1` говорит, что для причин без исключения (`wall-null`,
`wall-degraded-extra`, `wall-failed-core`, `floor-null`) `detail` **не
заполняется** — то есть поле у объекта отсутствует (`undefined`), а не равно
`null` (`JSON.stringify` вообще выкинет отсутствующее поле, а не запишет
`null`). Реализующему придётся угадывать, какая форма верна. Правится одной
фразой в §1.4 («поле отсутствует для причин без исключения») — не блокирует.
### L2 (Low) — влияние на производительность не названо явно
DoR (§2.5 PROCESS.md) требует явного пункта «влияние на производительность… или
явно "нет"». В спеке §3/§4 это не сказано ни разу (только UX/данные/i18n/touch и
риски). Влияние очевидно нулевое (один `console.warn` на неуспешный preflight,
с дедупликацией по fingerprint — §4.2), но пункт должен быть явным, а не
подразумеваемым. Правится одной строкой.
## Что проверено и корректно
- **§0 (сценарий/до-после) и §7.1**: все обязательные разделы PROCESS.md §7.1
присутствуют (сценарий, что видит человек до/после — явно размечено «До:»/
«После:», проблема, скоуп/не-скоуп, контракт, UX/данные/i18n/touch, риски, AC
с доказательствами, план тестов, откат, release-артефакты).
- **Технические утверждения о текущем коде** — все совпадают с реальным кодом:
7 значений `OptimizeGeometryFailureReason` (`plan-geometry-preflight.ts:60-67`)
— совпадает; отсутствие i18n-ключей для причин — подтверждено (только
`gs.align_preflight_failed/hint/space/more` существуют, ни одного
`preflight_reason_*`); диалог печатает только `{spaces}` — подтверждено
(`houseplan-card.ts:17033`); две точки вычисления preflight
(`_previewAlignDialog`/`_runAlignToGrid`, строки близки к заявленным
`:15860`/`:15891`, разошлись на несколько строк из-за более свежего HEAD, что
не меняет смысл находки аналитика) — подтверждено.
- **Безопасность типа для остальных потребителей `checkSpacePhysicalGeometry`**:
проверил все шесть точек вызова (`houseplan-card.ts:7155, 7326, 7385, 7498,
7523, 8865`) — все читают только `.ok`, не `.reason`/`.detail`. Расширение
общего интерфейса полем `detail` не протекает в другие потребители кроме
диалога Optimize — само по себе расширение типа не создаёт нового скоупа.
- **Мутанты (§5.7)** используют существующий паттерн `test/mutation-gate.test.mjs`
(«гварды с типовым прологом») — не новая инфраструктура, реализуемо.
- **Клипборд-фолбэк** (`<details><pre>`) — паттерн уже есть в этом же диалоге
(`gs.optimize_details`/`<details class="optimize-details">`), решение
однородно с остальным UI.
- **i18n**: паритет RU/EN и заполненность проверяются существующим
`test/i18n.test.mjs`; жёсткого формата ключей (запрет дефиса в хвосте ключа)
тест не требует — ключи вида `gs.preflight_reason_wall-degraded-extra`
технически валидны, хоть и не совпадают со стилем соседних ключей (не
блокирует, не стал заводить как Low — чисто стилистически).
- **Скоуп/не-скоуп**: рантайм-отказ владельца прямо исключён из скоупа с
внятной причиной (диагностика для СЛЕДУЮЩЕГО отказа, не для текущего);
барьер #199, решётка #291, экспортный формат — явно не трогаются. Это
корректно резонирует с docs/SCOPE.md: задача не создаёт нового
пользовательского job, а делает диагностируемым уже существующий
административный инструмент (ближе к J6) — вне списка Core user jobs это не
выходит.
- **Откат** (§8) — один revert, ни модели, ни миграции, соответствует
фактическому объёму изменений (UI+i18n+dev-log, без схемы/бэкенд-хранилища).
## Чего не проверял
- Не проверял рантайм-отказ самого владельца (out of scope самой задачи —
корректно вынесен в отдельный будущий issue).
- Не гонял ни один гейт (`typecheck`/`test`/`build`/смоки) — на этапе ревью ТЗ
кода нет, для находок H1/M1 достаточно было прочитать существующий код и
существующие тесты; сам факт, что `test/plan-geometry-preflight.test.mjs:125,
139, 145` красны при реализации §1.1 буквально, установлен чтением тестов и
логики (`assert.doesNotMatch` против буквального текста ошибки), не запуском —
это чтение кода, а не исполнение, но вывод из него однозначен и не требует
прогона, чтобы быть верным.
- Не проверял `docs/USER-GUIDE.ru.md` на предмет текущей формулировки раздела
«Оптимизировать» построчно — спека обещает туда абзац о диагностике (§7),
конкретного текста не предлагает, что на этапе ТЗ нормально (финальная
формулировка — предмет код-ревью/DoR, не блокирует спеку).
- Не искал полностью весь диапазон использования `contentFingerprint`/других
fingerprint-функций за пределами прямо процитированных мест — ограничился
тем, что относится к контракту §1.4.
## Вывод
Блокирует одна находка (H1) — контракт тихо отменяет задокументированную и
протестированную границу барьера #199, без единого упоминания этого в ТЗ.
Вторая (M1) в скоупе — предикат AC6 ссылается на источник данных, которого не
существует; чинится маленькой явной правкой backend-ответа. Обе технические,
не продуктовые — владельцу не выносятся, решение остаётся за автором в
следующей редакции ТЗ. L1/L2 — редакционные, можно закрыть тем же проходом.
**Вердикт: жёлтый · заход r1 · блокирующих циклов 1/4 · High: 1 · Medium: 1 → в задаче**