Files
houseplan-card/docs/reviews/SPEC-REVIEW-353-r1.md
T
2026-08-28 11:13:38 +00:00

275 lines
23 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-353-r1
Issue: #353 — «Устойчивость lazy-загрузки: терминальный отказ редактора после
сетевого сбоя и мёртвый entry-лоадер после обновления»
Этап: ревью ТЗ (PROCESS.md §2.4), лёгкий трек (`small`), заход r1,
блокирующих циклов израсходовано 0 из 2 (лимит §4: 2).
ТЗ живёт в теле issue #353 (лёгкий трек, файла в `docs/specs/` нет).
Материал ревью: тело issue #353 на момент разбора (комментарий
Matysh, S2→S3, `2026-08-28T11:01:48Z`), метки `bug · P1 · S4-spec-review
· small`.
## Вердикт
**Жёлтый.** High: 1 · Medium: 1 (в скоупе) · Low: 1 (снят с запиской, см.
ниже). Причина: K3 (устойчивый entry) как буквально описан ломает
инвариант, на который опирается сам демо-стенд, а значит и весь браузерный
конвейер гейтов, построенный поверх него — при этом ни один AC этого не
ловит. Остальной контракт (K1, K2, K4, K5) и критерии приёмки методологически
крепкие и не требуют доработки.
## Скоуп разбора
Первый заход — разбор полный, весь текст ТЗ в теле issue #353. Раздел
«Унаследовано» и «Закрытие предыдущего раунда» не нужны — предыдущих раундов
нет.
## Как проверялось
1. Прочитаны `docs/SCOPE.md`, `AGENTS.md`, `PROCESS.md` (полностью, включая
§2.4, §2.9/2.10, §5, §7.1, §8).
2. Прочитано тело issue #353 и единственный комментарий (переход S2→S3).
3. Прочитан связанный issue #337 (родительский контракт lazy-бандла) для
понимания, что «терминально»/«лениво» значило до этой задачи.
4. Построчно сверены все технические утверждения ТЗ с текущим кодом:
- `src/editor-runtime-loader.ts` (класс `EditorRuntimeLoader`, текущая
семантика retry/terminal);
- `src/houseplan-card.ts:802-930` (оба лоадера, `_requestMode`, тосты
`editor.load_failed`/`editor.refresh_advice`, `customElements.define`
на строке 12889-12890);
- `dist/houseplan-card.js` — реальное содержимое собранного entry-чанка:
`globalThis.__HOUSEPLAN_BUILD_FINGERPRINT__="...";export{d6 as
HouseplanCard}from"./houseplan-assets/houseplan-card-BarpA18i.js";`
(подтверждает описание N3 буквально);
- `rollup.config.mjs`, `scripts/bundle-manifest.mjs`
(`editorRuntimeRetryUrlPlugin`, `bundleManifestPlugin`,
`buildBundleManifest` — как считаются `initial`/`lazy` графы);
- `scripts/bundle-tree.mjs` (`verifyBundleTree` — подтверждён пробел N4:
проверяет только файлы ИЗ манифеста, не сканирует каталог на лишнее);
- `scripts/bundle-budget.mjs` + `test/bundle-assets.test.mjs` (гейт #352,
как считается `initialViewGzipBytes`);
- `custom_components/houseplan/frontend_assets.py`,
`frontend_asset_manifest.py`, `__init__.py:85-132` (текущие
`Cache-Control`, fail-closed резолвер, регистрация `res_type: module`);
- `demo/srv/demo.html:180-209` (загрузка карточки демо-стендом — ключевая
находка, см. ниже);
- `test/editor-runtime-loader.test.mjs` (существующее покрытие: retry
ровно один раз для любой ошибки, терминальность только для mismatch —
подтверждает описание N2 буквально);
- `src/i18n/en.json`, `ru.json`, `de.json` (текущие `editor.load_failed`/
`editor.refresh_advice`, три словаря синхронны, новый ключ вписывается
по образцу).
5. `git status`/`git log` — рабочее дерево чистое на `07b6a1f2` (= `origin/dev`),
ветки `issue/353-*` нет: код по этой задаче ещё не начат, что ожидаемо на
этапе `S4-spec-review`.
### Гейты — что прогнано и почему
Этап — ревью ТЗ, кода по issue #353 в дереве нет (задача ещё не покидала
`S4-spec-review`, ветки `issue/353-*` не существует). `typecheck`/`test`/
`build`/`check-docs`/`smoke-select`/`model-invariants` — гейты код-ревью
(PROCESS.md §8, §2.7) и относятся к диффу кода, которого здесь нет; на этапе
ТЗ (§2.4) прогонять их нечего и не о чем — сверять было бы не с чем.
Ничего не пропущено умалчиванием: раздел «Гейты» в этом документе не
применим к этапу spec, и это явное решение, а не пропуск.
## Находки
### [High] K3: замена статического реэкспорта на «голый» `import().catch()` рвёт гарантию `await import(entry) ⇒ элемент зарегистрирован`, на которую опирается сам демо-стенд
**Файл/раздел:** тело issue #353, раздел «К3 — entry переживает
несвежесть».
**Что не так.** Сейчас `dist/houseplan-card.js` — это ES-реэкспорт:
```js
export{d6 as HouseplanCard}from"./houseplan-assets/houseplan-card-BarpA18i.js";
```
Это **статический** import-edge: спецификация ES-модулей гарантирует, что
`await import('/assets/houseplan-card.js')` не резолвится, пока весь граф,
достижимый через `export...from`, не будет получен, слинкован и
выполнен — то есть пока `customElements.define('houseplan-card',
HouseplanCard)` (строка `houseplan-card.ts:12889-12890`, лежит внутри
чанка `houseplan-card-<hash>.js`) гарантированно не отработает.
К3 предписывает заменить это на `import("./houseplan-assets/…").catch(…)`
— **не** `export`, а голый вызов внутри тела entry-чанка, судя по
формулировке и по аналогии с `editorRuntimeRetryUrlPlugin` (пост-обработка
уже сгенерированного `chunk.code` строковой заменой в `generateBundle`,
после того как Rollup уже посчитал граф). Promise такого `import()` никуда
не экспортируется и не await'ится потребителем entry-модуля. Значит,
top-level выполнение entry-чанка завершается почти сразу — до того, как
динамически запрошенный `houseplan-card-<hash>.js` успеет загрузиться и
выполниться, — и именно в этот момент резолвится `await
import('/assets/houseplan-card.js')` у любого потребителя.
Демо-стенд, на котором держится вся браузерная часть гейтов, устроен именно
так — `demo/srv/demo.html:193-197`:
```js
await import('/assets/houseplan-card.js');
const card=document.createElement('houseplan-card');
card.setConfig({type:'custom:houseplan-card', ...});
```
После К3 `await import(...)` резолвится раньше, чем элемент `houseplan-card`
зарегистрирован. `document.createElement('houseplan-card')` в этот момент
создаёт обычный неапгрейженный `HTMLElement` (в спецификации Custom Elements
апгрейд элемента до пользовательского класса происходит именно в момент
`customElements.define`, а не раньше) — метода `setConfig` на нём ещё нет.
Следующая строка `card.setConfig(...)` бросает `TypeError: card.setConfig
is not a function` **синхронно**, в самом бутстрапе demo.html.
**Почему это не мелочь и не ловится AC.** Это не побочный редкий кейс —
это обычный, счастливый путь загрузки (когда главный чанк грузится
успешно). Он ломает бутстрап demo-стенда **для каждого** существующего
`demo/smoke_*.mjs`, для `golden:capture`/`golden:verify`, для
`performance_smoke` и для `demo/docs/capture.mjs` (снятие скриншотов
документации) — то есть весь браузерный слой CI одномоментно.
AC3 из ТЗ этого не поймает: (а) юнит-тест — это проверка текста
собранного файла на подстроки (`dynamic import`, `catch`, `define-фолбэк`),
не поведения при исполнении; (б) `demo/smoke_entry_stale.mjs` намеренно
воспроизводит **испорченный** главный чанк — сценарий, где `import()`
падает и код уходит в `catch` быстро; обычный работающий путь загрузки
(когда `import()` успешен) в AC не проверяется вовсе, а именно там гонка
и стреляет.
**Как воспроизвести рассуждение (без исполнения — стадия ТЗ, кода ещё
нет):** прочитать текущее содержимое `dist/houseplan-card.js` (статический
реэкспорт), заменить его мысленно на буквальный текст К3
(`import(...).catch(...)` без `export`/`await`), проследить, что
`await import(entry)` у потребителя больше не транзитивно ждёт вложенный
`import()`, и сопоставить это с `demo/srv/demo.html:193-197`, где
`document.createElement` + `setConfig` идут немедленно и синхронно после
`await import(entry)`.
**Что нужно поправить в ТЗ.** Явно решить один из двух путей и записать
его в контракт K3:
- либо entry сохраняет цепочку ожидания — например top-level `await` внутри
самого чанка (`await import(...).catch(...)`, ES2022 top-level await,
Rollup format `es` его поддерживает) с реэкспортом результата, чтобы
`await import(entry)` по-прежнему резолвился только после того, как
главный чанк загружен либо фолбэк-элемент определён; это сохраняет
сегодняшний инвариант без правок demo.html;
- либо явно принять, что гонка есть, и добавить в объём задачи правку
`demo/srv/demo.html` (и любого другого потребителя entry, включая то, как
HA резолвит `res_type: module`) на ожидание
`customElements.whenDefined('houseplan-card')` вместо немедленного
`document.createElement`+`setConfig` — тогда это меняет объём файлов и,
вероятно, поверхность (`demo/**` — класс B, но задачу это не выводит из
лёгкого трека автоматически; решить нужно явно, а не молчанием).
Без этого уточнения ТЗ не выполнимо буквально: реализация по тексту К3
ломает работающий сегодня демо-стенд и, транзитивно, красит `smoke`,
`golden`, `performance_smoke`, `docs` — не после дефекта в проде, а сразу
на первом прогоне гейтов реализации.
### [Medium, в скоупе] K2 не имеет ни одного AC/доказательства
**Файл/раздел:** тело issue #353, «К2 — тост на каждый неудачный цикл» vs
раздел «AC и доказательства».
К2 — не мелочь: это ядро продуктового сценария issue-AC1 («каждая неудача
видима тостом», сетевая — новым ключом `editor.retry_advice`, терминальная
— старым `editor.refresh_advice`, для **обоих** лоадеров, editor и
onboarding). Но ни один из перечисленных AC1…AC4 его не проверяет:
- AC1 — юнит на уровне модуля `editor-runtime-loader.test.mjs`, проверяет
только сам класс `EditorRuntimeLoader` (колбэк `failed(error,
{terminal:false})`, переход в `idle`) — не то, какой текст тоста выберет
`houseplan-card.ts` в своих двух колбэках `failed: (error) => {...}`
(строки 832-835, 860-863), которые как раз и нужно переписать на `(error,
info) => info.terminal ? refresh_advice : retry_advice`.
- AC2 — про терминальность mismatch, тоже не про текст тоста.
- AC3/AC4 — про entry-чанк и orphan-проверку, к тостам отношения не имеют.
Реализация, которая продолжит всегда показывать `refresh_advice`
(игнорируя `info.terminal`) для обоих лоадеров, пройдёт весь названный
набор AC без единого красного теста — при том что это именно та регрессия,
из-за которой issue вообще завели (N2: «тост… показывается один раз… все
последующие нажатия — молчаливый no-op»; K2 — прямое лекарство от этого).
**Фикс, полностью в скоупе задачи:** добавить AC5, например: юнит на
уровне `houseplan-card.ts` (или на уровне колбэков `failed`, вынесенных в
проверяемую точку), который стабом задаёт `failed(error, {terminal:
true|false})` для `_editorRuntimeLoader` и `_onboardingRuntimeLoader` и
проверяет, что показанный тост содержит `editor.refresh_advice` в
терминальном случае и `editor.retry_advice` в сетевом, для обоих лоадеров
(4 комбинации). С High это не связано и не требует отдельного цикла как
самостоятельная находка — фиксируется тем же заходом, что и K3.
### [Low, снято с запиской] docs/USER-GUIDE.ru.md не упомянут в объёме задачи, хотя описывает контракт, который эта задача меняет
`docs/USER-GUIDE.ru.md:108-111` и `:1740` документируют **сегодняшнее**
поведение («после одной повторной попытки… предлагает обновить страницу»;
«После обновления старая версия → перезапустите HA, перезагрузите
ресурсы/браузер»). После K1–K4 часть этого текста устареет: сетевой сбой
станет самостоятельно устраняемым повторным нажатием «Редактировать», а
несвежий entry — понятным баннером вместо тишины. ТЗ (как и полагается
лёгкому треку, §5) не обязано перечислять release-артефакты явно, а
`docs/**` — документация в тему той же задачи по DoD (PROCESS.md §2.6,
правило 11: «документация — в том же коммите, что поведение»), так что
формально это не пробел ТЗ. Снимаю находку записью здесь, а не как
блокирующую: автору стоит обновить эти два места в USER-GUIDE.ru.md в том
же коммите, что и код, но это не требует правки самого ТЗ.
## Что проверено и корректно
- **Скоуп и трек.** Задача чинит регресс поведения уже принятого механизма
#337 (терминальность/несвежесть lazy-доставки), не расширяет продукт —
укладывается в J1/J4/J6 SCOPE.md («редакторы остаются доступны»,
«план остаётся верным»). Критерии лёгкого трека (§5) все выполнены
формально: одна логическая поверхность (конвейер доставки lazy-бандла),
риск заявлен ≤3, миграций конфига нет, новый UX-контракт не вводит новых
элементов интерфейса (то же нажатие «Редактировать», тот же тост).
- **K1 (терминальность loader'а).** Логика «mismatch на ЛЮБОЙ попытке ⇒
терминально, иначе ⇒ `idle` и новый явный `ensure()`» согласована с
существующим кодом `_loadWithRetry` (attempt 1 использует
cache-buster — то есть mismatch на attempt 1 после успешного обхода кэша
действительно означает версионный дрейф, а не транзиентный сетевой сбой)
и с уже существующим тестом (`editor-runtime-loader.test.mjs:61-83`),
который остаётся зелёным без правки ассертов. AC1/AC2 однозначны и
проверяемы юнитом.
- **N4/K5 (осиротевшие чанки).** Утверждение «`verifyBundleTree` не
детектирует лишние файлы вне манифеста» подтверждено чтением
`scripts/bundle-tree.mjs:38-53` буквально — функция итерирует только
`manifest.files`, каталог не сканирует. Фикс и AC4 просты и корректны.
- **Проверенный факт вместо догадки.** Утверждение К3 «именованный экспорт
entry исчезает — потребителей нет… проверено grep'ом» подтверждено
независимо: единственное место, где entry импортируется как модуль —
`demo/srv/demo.html:193`, `await import(...)` без захвата именованного
экспорта; в `custom_components/**`, `test/**` потребителей нет. Это не
голословная догадка, а верно проверенный факт.
- **K4 (immutable Cache-Control).** Согласуется с текущим кодом
(`frontend_assets.py:34` — `no-cache` меняется на `public,
max-age=31536000, immutable`; `__init__.py:100/109` — entry остаётся
`cache_headers=False`, K4 явно это не трогает). Хэшированные имена чанков
делают инвалидацию неактуальной проблемой — решение обратимо, откат
корректен.
- **Откат.** Один revert, конфиг/схема/миграции не затронуты — соответствует
фактическому масштабу изменения (два флага поведения + один rollup-плагин
+ один HTTP-заголовок + одна проверка каталога).
- **i18n.** Новый ключ `editor.retry_advice` вписывается по образцу
существующих `editor.load_failed`/`editor.refresh_advice`, которые уже
синхронны между `en.json`/`ru.json`/`de.json`.
## Чего не проверял
- Не прогонял `typecheck`/`test`/`build`/`check-docs`/`smoke-select`/
`model-invariants` — на этапе ревью ТЗ кода по issue #353 в дереве нет
(ветка `issue/353-*` не создана), сравнивать не с чем; эти гейты
относятся к код-ревью (§2.7, §8) и будут прогнаны в следующем цикле,
когда появится диапазон коммитов.
- Не оценивал реальное поведение HA Lovelace (`res_type: module`) при
гонке из находки High — оценка ограничена наблюдаемым фактом, что
demo-стенд ломается гарантированно; поведение реального HA-фронтенда
(который, вероятно, толерантен к асинхронной регистрации через
`customElements.whenDefined` с таймаутом) не проверялось и не входит в
этот репозиторий — не меняет вывода: даже если прод HA переживёт гонку,
собственный демо-стенд проекта, на котором держится весь браузерный слой
гейтов, не переживёт, и этого достаточно для блокирующей находки.
- Не пересчитывал вручную содержимое `initialViewGzipBytes`/бюджета #352
после гипотетической реализации K3: проверено чтением `bundle-manifest.mjs`,
что `chunk.imports`/`chunk.dynamicImports` — метаданные, которые Rollup
фиксирует до `generateBundle`, и последующая строковая мутация
`chunk.code` (аналогично уже существующему `editorRuntimeRetryUrlPlugin`)
их не меняет; отдельной находки по бюджету #352 в этом ТЗ не завожу,
так как классификация initial/lazy остаётся корректной независимо от K3.