# SPEC-REVIEW-353-r2 Issue: #353 — «Устойчивость lazy-загрузки: терминальный отказ редактора после сетевого сбоя и мёртвый entry-лоадер после обновления» Этап: ревью ТЗ (PROCESS.md §2.4), лёгкий трек (`small`), заход r2, блокирующих циклов израсходовано 1 из 2 (лимит §4: 2 на лёгком треке). ТЗ живёт в теле issue #353 (лёгкий трек, файла в `docs/specs/` нет). Предыдущий раунд: `docs/reviews/SPEC-REVIEW-353-r1.md` (в дереве на `07b6a1f2`, опубликован коммитом `5044c281`), вердикт жёлтый, High: 1 · Medium: 1 (в скоупе) · Low: 1 (снят с запиской). Материал предыдущего раунда — тело issue на момент комментария Matysh `2026-08-28T11:01:48Z` (S2→S3). Материал этого раунда — текущее тело issue #353, помеченное автором как «Ревизия 2 (по SPEC-REVIEW-353-r1)» (комментарий Matysh, `2026-08-28T11:14:34Z`, вернувший `S4-spec-review`). Для ТЗ нет SHA — предмет ревью текст, а не код; дельта раунда — правки тела issue между ревизией 1 (разобрана в r1) и ревизией 2 (этот раунд), названные автором в комментарии и в приписке в конце тела issue. ## Вердикт **Зелёный.** High: 0 · Medium: 0. Оба блокирующих замечания r1 закрыты текстом ревизии 2 предметно, без появления новых High/Medium. Один Low (остаточная неточность формулировки AC5) отмечаю и снимаю запиской — не блокирует. ## Скоуп разбора — по дельте (PROCESS.md §2.10) Дельта локальна: правки ограничены разделом «К3» (механизм постобработки entry-чанка) и разделом «AC и доказательства» (добавлен AC5). Остальной текст ТЗ — «Проблема», критерии приёмки issue, К1/К2/К4/К5 (кроме способа доставки тоста в К2, который делит AC5), «Откат», `User-Visible` — не редактировался между ревизиями (совпадает дословно с текстом, разобранным в r1). Изменение не является ребейзом (кода ещё нет), не меняет контракт поведения сверх того, что r1 уже потребовал исправить, не задевает новую подсистему и по объёму меньше исходной задачи — полный повторный разбор всего ТЗ не требуется; разбираю дельту и всё, до чего она дотягивается (AC3 целиком, поскольку К3 переписан; AC5 целиком, поскольку это новый раздел). ## Закрытие раунда r1 | Находка r1 | Чем закрыта | Где это видно | |---|---|---| | **[High] K3** — замена статического реэкспорта на «голый» `import().catch()` резолвит `await import(entry)` у потребителя (демо-стенда) раньше, чем главный чанк успевает выполниться и зарегистрировать `customElements.define('houseplan-card', …)`; ломает `document.createElement('houseplan-card').setConfig(...)` в `demo/srv/demo.html:193-197`, то есть счастливый путь всего браузерного слоя гейтов. | К3 переписан на top-level await внутри самого entry-чанка: `try { await import("./houseplan-assets/houseplan-card-.js"); } catch (e) { /* define fallback */ }`. Это ровно первый из двух путей, которые r1 предложил как исправление («entry сохраняет цепочку ожидания — top-level await внутри самого чанка… чтобы `await import(entry)` по-прежнему резолвился только после того, как главный чанк загружен либо фолбэк-элемент определён»). Семантика TLA в ES-модулях (`format: 'es'`, `rollup.config.mjs:22`) гарантирует, что entry не завершает собственное выполнение, пока вложенный `import()` не разрешится — то есть `await import(entry)` у `demo/srv/demo.html:193` по-прежнему ждёт `customElements.define`, как и до К3. `demo.html` не правится — инвариант сохранён без изменения потребителя, как и требовал r1. | Тело issue #353, раздел «К3 — entry переживает несвежесть, не трогая счастливый путь» (блок кода `try { await import(...) } catch (e) {...}`) + предложение «Семантика TLA: модуль-потребитель… не резолвится, пока entry не довыполнится… гарантия… сохраняется без правки `demo.html`». | | **[Medium, в скоупе] K2** (тост на каждый неудачный цикл, ключ по `terminal`) не имел ни одного AC — реализация, всегда показывающая `refresh_advice`, проходила весь набор AC1–AC4. | Добавлен **AC5**: юнит на чистую функцию `lazyLoadFailureMessage(t, {terminal})` для обеих комбинаций (`true`→`refresh_advice`, `false`→`retry_advice`) + структурная проверка «в духе `test/houseplan-source.mjs`», что `failed`-колбэки ОБОИХ лоадеров (`_editorRuntimeLoader`, `_onboardingRuntimeLoader` в `src/houseplan-card.ts:830-835,861-865` на момент r1) вызывают именно этот общий помощник, а не строят текст тоста сами. | Тело issue #353, раздел «AC и доказательства», пункт «**AC5** (закрывает К2)»; раздел «Контракт», К2: «Оба лоадера… выбирают текст через ОБЩИЙ экспортируемый помощник `lazyLoadFailureMessage(t, { terminal })`». | | **[Low, снято с запиской в r1] USER-GUIDE.ru.md** не в объёме задачи. | Без изменений — уже снято в r1 с запиской «чинится в том же коммите реализации», ревизия 2 её подтверждает явной строкой. | Тело issue #353: «Попутно (Low r1): затронутые места docs/USER-GUIDE.ru.md обновляются в том же коммите реализации.» | ## Как проверялось (дельта) 1. Перечитан весь текст тела issue #353 на текущий момент и сверен построчно с цитатами r1 (что изменилось — К3 и «AC и доказательства»; что нет — «Проблема», критерии issue, К1/К4/К5, «Откат», `User-Visible`). 2. К3 разобран построчно на согласованность заявленного механизма (TLA) с форматом сборки: `rollup.config.mjs` — `input: 'src/houseplan-card.ts'`, `output.format: 'es'`, `intro` инжектирует фингерпринт, плагины `editorRuntimeRetryUrlPlugin()` → `bundleManifestPlugin(...)` в этом порядке в `generateBundle`. Новый плагин описан «рядом с `editorRuntimeRetryUrlPlugin`… ДО `bundleManifestPlugin`» — та же семья постобработки, тот же порядок, что уже проверен и принят в r1 для аналогичной строковой мутации кода чанка. 3. Проверено рассуждение (без исполнения — кода ещё нет): TLA в ES-модулях означает, что evaluation entry-модуля не завершается, пока вложенный `await import(...)` не разрешится; следовательно `await import(entry)` любого потребителя (в т.ч. `demo/srv/demo.html:193`) транзитивно ждёт того же события, что и раньше (выполнение главного чанка, `customElements.define`). Ровно то свойство, разрыв которого был предметом High в r1. 4. Сверено, что «именованный экспорт entry исчезает — потребителей нет» осталось тем же утверждением, которое r1 уже независимо перепроверил grep'ом (`demo/srv/demo.html:193` — `await import(...)` без захвата именованного экспорта; в `custom_components/**`, `test/**` потребителей нет) — дельта его не меняет, факт наследуется. 5. AC5 сверен с текущим кодом `src/houseplan-card.ts` (класс `HouseplanCard`, оба `EditorRuntimeLoader`, поля `_editorRuntimeLoader`/ `_onboardingRuntimeLoader`, оба колбэка `failed: (error) => {...}`, `console.error(...)` + `this._showToast(...editor.refresh_advice)`) — подтверждено, что сегодня оба колбэка игнорируют причину отказа и всегда показывают один и тот же текст; ровно это АС5 обязана ловить. 6. Перечитан `test/houseplan-source.mjs` — существующий образец «структурной проверки» в этом проекте: AST-разбор TS-исходников через `typescript` (`ts.createSourceFile`), сверка членов класса, а не простой grep по подстроке. Это делает правдоподобным заявление АС5, что структурная проверка способна отличить «вызывает помощник с прокинутым `info.terminal`» от «вызывает помощник с захардкоженным аргументом» — но ТЗ не проговаривает это явно (см. Low ниже). 7. Git: рабочее дерево чистое на `5044c281` (= `origin/dev`), ветки `issue/353-*` нет — код по задаче не начат, соответствует этапу `S4-spec-review`. ### Гейты — что прогнано и почему Не применимо к этому раунду: этап — ревью ТЗ, кода по issue #353 в дереве по-прежнему нет (ветка `issue/353-*` не создана, дельта — правка текста issue, не коммит). `typecheck`/`test`/`build`/`check-docs`/`smoke-select`/ `model-invariants` относятся к код-ревью (§2.7, §8) и будут прогнаны, когда появится диапазон коммитов. Это то же решение, что и в r1, не молчаливый пропуск. ## Унаследовано из r1 Принято без повторной проверки в этом раунде — не задето дельтой: - **Скоуп и трек.** Задача остаётся в J1/J4/J6 SCOPE.md, критерии лёгкого трека (§5) выполнены. — `docs/reviews/SPEC-REVIEW-353-r1.md`, раздел «Что проверено и корректно», на `07b6a1f2`. - **К1 (терминальность loader'а)** и AC1/AC2 — логика «mismatch на любой попытке ⇒ терминально, иначе ⇒ `idle`» согласована с `test/editor-runtime-loader.test.mjs:61-83` и текущим `_loadWithRetry`. — там же, раздел «К1», на `07b6a1f2`. - **К5/N4 (осиротевшие чанки), AC4** — `scripts/bundle-tree.mjs:38-53` подтверждённо не сканирует каталог вне манифеста; фикс и AC4 просты. — там же, раздел «N4/K5», на `07b6a1f2`. - **К4 (immutable Cache-Control)** — согласуется с `frontend_assets.py:34`, `__init__.py:100/109`; откат корректен. — там же, раздел «K4», на `07b6a1f2`. - **i18n** — новый ключ `editor.retry_advice` по образцу существующих, словари синхронны. — там же, на `07b6a1f2`. - **Бюджет #352 / классификация initial-lazy графа.** Постобработка `generateBundle` (строковая мутация уже посчитанного Rollup чанка) не меняет `chunk.imports`/`chunk.dynamicImports`, зафиксированные до генерации бандла — рассуждение в r1 строилось на существующем `editorRuntimeRetryUrlPlugin`, и новый TLA-плагин относится к той же семье постобработки (см. «Как проверялось», п.2). Отдельно не пересчитывал. — `docs/reviews/SPEC-REVIEW-353-r1.md`, раздел «Чего не проверял», на `07b6a1f2`. - **Поведение реального HA-фронтенда** при аналогичной гонке — не оценивалось ни в r1, ни здесь; вывод не меняется — демо-стенд остаётся критерием, и он теперь гонки не имеет. ## Находки ### [Low, снято с запиской] AC5 не проговаривает, что структурная проверка обязана отличать «параметр `info.terminal` прокинут в вызов» от «вызов есть, но аргумент захардкожен» **Файл/раздел:** тело issue #353, «AC и доказательства», пункт AC5. АС5 утверждает: «структурная проверка… `failed`-колбэки ОБОИХ лоадеров… вызывают `lazyLoadFailureMessage` (реализация «всегда refresh_advice» падает)». Формулировка «вызывают `lazyLoadFailureMessage`» буквально описывает только факт вызова. Реализация, которая зовёт помощник, но захардкоживает `{ terminal: true }` вместо прокидывания параметра `info` колбэка, тоже «вызывает `lazyLoadFailureMessage`» — и без уточнения, что проверяется именно происхождение аргумента (идентификатор `info`/ `info.terminal`, а не литерал), такая проверка может пропустить именно ту регрессию, которую AC5 призван ловить (тот же дефект N2, просто воспроизведённый на уровне выбора текста, а не состояния лоадера). Это не голословное сомнение: раздел «Мутанты в реестр» ТЗ перечисляет 5 мутантов для AC1–AC4, но ни один — для AC5, хотя ровно AC5 существует для отлова конкретного класса регрессии (игнорирование `terminal`). Не блокирую: `test/houseplan-source.mjs` — существующий в проекте образец именно такой AST-проверки (разбирает TS через `ts.createSourceFile`, сверяет структуру, а не текстовые подстроки), так что технически реализуемо и укладывается в стиль проекта; способ доказательства и его точность — по правилу «в теле issue сказано `в духе test/houseplan-source.mjs`» уже названы, а точная форма AST-проверки — техническая деталь реализации, которую решает автор (PROCESS.md: «где хранится состояние… стратегия теста… решай сам»). Записываю как пункт для код-ревью: убедиться, что реализованный тест действительно падает на мутанте «захардкоженный `terminal`» — то есть на дисциплину «тест умеет падать», применённую к этому конкретному AC. ## Что проверено и корректно - К3 (ревизия 2) закрывает High r1 предметно: TLA-обёртка сохраняет гарантию «`await import(entry)` резолвится не раньше, чем главный чанк выполнен либо фолбэк определён», без правки `demo.html` — именно то решение, которое r1 предложил как один из двух допустимых путей. - AC5 закрывает Medium r1: К2 (выбор текста тоста по `terminal`, для обоих лоадеров) теперь имеет явный AC и явно названный проверяемый дефект («всегда refresh_advice»). - Плагин К3 задан в той же точке пайплайна (`generateBundle`, до `bundleManifestPlugin`), что и уже принятый `editorRuntimeRetryUrlPlugin` — не открывает новых вопросов к бюджету #352 сверх уже принятого в r1. - Остальной контракт (К1, К4, К5, «Откат», `User-Visible`) не редактировался между ревизиями — выводы r1 по ним стоят без изменений. ## Чего не проверял - Не прогонял `typecheck`/`test`/`build`/`check-docs`/`smoke-select`/ `model-invariants` — кода по issue #353 в дереве нет (см. «Гейты» выше). - Не проверял заново К1, К4, К5, критерии лёгкого трека, i18n-синхронность и бюджет-рассуждение — не задеты дельтой r1→r2, унаследованы из `SPEC-REVIEW-353-r1.md` (раздел «Унаследовано из r1» выше). - Не оценивал реальную поддержку top-level await целевыми окружениями (HA frontend resource, `res_type: module`) — техническая деталь реализации; TLA в ES-модулях — часть спецификации ECMAScript, поддержана всеми актуальными эвергрин-браузерами, отдельного продуктового риска не усматриваю и не отношу к продуктовой неоднозначности, которую решает владелец.